merge master into pap-1078-qol-fixes

Resolve the keyboard shortcut conflicts after [#2539](https://github.com/paperclipai/paperclip/pull/2539) and [#2540](https://github.com/paperclipai/paperclip/pull/2540), keep the release package rewrite working with cliVersion, and stabilize the provisioning timeout in the full suite.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-02 13:14:20 -05:00
commit fb3b57ab1f
59 changed files with 16794 additions and 375 deletions

View file

@ -231,11 +231,31 @@ describe("agent skill routes", () => {
);
});
it("keeps runtime materialization for persistent skill adapters", async () => {
it("skips runtime materialization when listing Codex skills", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("codex_local"));
mockAdapter.listSkills.mockResolvedValue({
adapterType: "codex_local",
supported: true,
mode: "ephemeral",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],
warnings: [],
});
const res = await request(createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
materializeMissing: false,
});
});
it("keeps runtime materialization for persistent skill adapters", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("cursor"));
mockAdapter.listSkills.mockResolvedValue({
adapterType: "cursor",
supported: true,
mode: "persistent",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],

View file

@ -35,6 +35,7 @@ describe("instance settings routes", () => {
vi.clearAllMocks();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({
@ -45,6 +46,7 @@ describe("instance settings routes", () => {
id: "instance-settings-1",
general: {
censorUsernameInLogs: true,
keyboardShortcuts: true,
feedbackDataSharingPreference: "allowed",
},
});
@ -114,6 +116,7 @@ describe("instance settings routes", () => {
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
});
@ -121,18 +124,20 @@ describe("instance settings routes", () => {
.patch("/api/instance/settings/general")
.send({
censorUsernameInLogs: true,
keyboardShortcuts: true,
feedbackDataSharingPreference: "allowed",
});
expect(patchRes.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
censorUsernameInLogs: true,
keyboardShortcuts: true,
feedbackDataSharingPreference: "allowed",
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("rejects non-admin board users", async () => {
it("allows non-admin board users to read general settings", async () => {
const app = createApp({
type: "board",
userId: "user-1",
@ -143,8 +148,25 @@ describe("instance settings routes", () => {
const res = await request(app).get("/api/instance/settings/general");
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.getGeneral).toHaveBeenCalled();
});
it("rejects non-admin board users from updating general settings", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true, keyboardShortcuts: true });
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
});
it("rejects agent callers", async () => {

View file

@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
@ -428,6 +429,160 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
resurfacedIssueId,
]));
});
it("resurfaces archived issue when status/updatedAt changes after archiving", async () => {
const companyId = randomUUID();
const userId = "user-1";
const otherUserId = "user-2";
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Issue with old comment then status change",
status: "todo",
priority: "medium",
createdByUserId: userId,
createdAt: new Date("2026-03-26T10:00:00.000Z"),
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
});
// Old external comment before archiving
await db.insert(issueComments).values({
companyId,
issueId,
authorUserId: otherUserId,
body: "Old comment before archive",
createdAt: new Date("2026-03-26T11:00:00.000Z"),
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
});
// Archive after seeing the comment
await svc.archiveInbox(
companyId,
issueId,
userId,
new Date("2026-03-26T12:00:00.000Z"),
);
// Verify it's archived
const afterArchive = await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
});
expect(afterArchive.map((i) => i.id)).not.toContain(issueId);
// Status/work update changes updatedAt (no new comment)
await db
.update(issues)
.set({
status: "in_progress",
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
})
.where(eq(issues.id, issueId));
// Should resurface because updatedAt > archivedAt
const afterUpdate = await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
});
expect(afterUpdate.map((i) => i.id)).toContain(issueId);
});
it("sorts and exposes last activity from comments and non-local issue activity logs", async () => {
const companyId = randomUUID();
const olderIssueId = randomUUID();
const commentIssueId = randomUUID();
const activityIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: olderIssueId,
companyId,
title: "Older issue",
status: "todo",
priority: "medium",
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
{
id: commentIssueId,
companyId,
title: "Comment activity issue",
status: "todo",
priority: "medium",
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
{
id: activityIssueId,
companyId,
title: "Logged activity issue",
status: "todo",
priority: "medium",
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
]);
await db.insert(issueComments).values({
companyId,
issueId: commentIssueId,
body: "New comment without touching issue.updatedAt",
createdAt: new Date("2026-03-26T11:00:00.000Z"),
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
});
await db.insert(activityLog).values([
{
companyId,
actorType: "system",
actorId: "system",
action: "issue.document_updated",
entityType: "issue",
entityId: activityIssueId,
createdAt: new Date("2026-03-26T12:00:00.000Z"),
},
{
companyId,
actorType: "user",
actorId: "user-1",
action: "issue.read_marked",
entityType: "issue",
entityId: olderIssueId,
createdAt: new Date("2026-03-26T13:00:00.000Z"),
},
]);
const result = await svc.list(companyId, {});
expect(result.map((issue) => issue.id)).toEqual([
activityIssueId,
commentIssueId,
olderIssueId,
]);
expect(result.find((issue) => issue.id === activityIssueId)?.lastActivityAt?.toISOString()).toBe(
"2026-03-26T12:00:00.000Z",
);
expect(result.find((issue) => issue.id === commentIssueId)?.lastActivityAt?.toISOString()).toBe(
"2026-03-26T11:00:00.000Z",
);
expect(result.find((issue) => issue.id === olderIssueId)?.lastActivityAt?.toISOString()).toBe(
"2026-03-26T10:00:00.000Z",
);
});
});
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {

View file

@ -10,11 +10,13 @@ import {
companies,
companyMemberships,
createDb,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
instanceSettings,
issues,
principalPermissionGrants,
projectWorkspaces,
projects,
routineRuns,
routines,
@ -102,6 +104,8 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(routines);
@ -272,4 +276,136 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
]),
);
});
it("runs routines with variable inputs and interpolates the execution issue description", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture();
const app = await createApp({
type: "board",
userId,
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const createRes = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Repository triage",
description: "Review {{repo}} for {{priority}} bugs",
assigneeAgentId: agentId,
variables: [
{ name: "repo", type: "text", required: true },
{ name: "priority", type: "select", required: true, defaultValue: "high", options: ["high", "low"] },
],
});
expect(createRes.status).toBe(201);
const runRes = await request(app)
.post(`/api/routines/${createRes.body.id}/run`)
.send({
source: "manual",
variables: { repo: "paperclip" },
});
expect(runRes.status).toBe(202);
expect(runRes.body.triggerPayload).toEqual({
variables: {
repo: "paperclip",
priority: "high",
},
});
const [issue] = await db
.select({ description: issues.description })
.from(issues)
.where(eq(issues.id, runRes.body.linkedIssueId));
expect(issue?.description).toBe("Review paperclip for high bugs");
});
it("persists execution workspace selections from manual routine runs", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const app = await createApp({
type: "board",
userId,
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
sharedWorkspaceKey: "routine-primary",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Routine worktree",
status: "active",
providerType: "git_worktree",
});
await db
.update(projects)
.set({
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: projectWorkspaceId,
},
})
.where(eq(projects.id, projectId));
await db.insert(instanceSettings).values({
experimental: { enableIsolatedWorkspaces: true },
});
const createRes = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Workspace-aware routine",
assigneeAgentId: agentId,
});
expect(createRes.status).toBe(201);
const runRes = await request(app)
.post(`/api/routines/${createRes.body.id}/run`)
.send({
source: "manual",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
expect(runRes.status).toBe(202);
const [issue] = await db
.select({
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(eq(issues.id, runRes.body.linkedIssueId));
expect(issue).toEqual({
projectWorkspaceId,
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
});
});

View file

@ -8,8 +8,11 @@ import {
companySecrets,
companySecretVersions,
createDb,
executionWorkspaces,
heartbeatRuns,
instanceSettings,
issues,
projectWorkspaces,
projects,
routineRuns,
routines,
@ -20,6 +23,7 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import { routineService } from "../services/routines.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
@ -49,9 +53,12 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
await db.delete(companySecrets);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
await db.delete(instanceSettings);
});
afterAll(async () => {
@ -317,6 +324,196 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(routineIssues[0]?.id).toBe(previousIssue.id);
});
it("interpolates routine variables into the execution issue and stores resolved values", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture();
const variableRoutine = await svc.create(
companyId,
{
projectId,
goalId: null,
parentIssueId: null,
title: "repo triage",
description: "Review {{repo}} for {{priority}} bugs",
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
{ name: "priority", label: null, type: "select", defaultValue: "high", required: true, options: ["high", "low"] },
],
},
{},
);
const run = await svc.runRoutine(variableRoutine.id, {
source: "manual",
variables: { repo: "paperclip" },
});
const storedIssue = await db
.select({ description: issues.description })
.from(issues)
.where(eq(issues.id, run.linkedIssueId!))
.then((rows) => rows[0] ?? null);
const storedRun = await db
.select({ triggerPayload: routineRuns.triggerPayload })
.from(routineRuns)
.where(eq(routineRuns.id, run.id))
.then((rows) => rows[0] ?? null);
expect(storedIssue?.description).toBe("Review paperclip for high bugs");
expect(storedRun?.triggerPayload).toEqual({
variables: {
repo: "paperclip",
priority: "high",
},
});
});
it("attaches the selected execution workspace to manually triggered routine issues", async () => {
const { companyId, projectId, routine, svc } = await seedFixture();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db
.update(projects)
.set({
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: projectWorkspaceId,
},
})
.where(eq(projects.id, projectId));
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
sharedWorkspaceKey: "routine-primary",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Routine worktree",
status: "active",
providerType: "git_worktree",
});
const run = await svc.runRoutine(routine.id, {
source: "manual",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
const storedIssue = await db
.select({
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(eq(issues.id, run.linkedIssueId!))
.then((rows) => rows[0] ?? null);
expect(storedIssue).toEqual({
projectWorkspaceId,
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
});
it("blocks schedule triggers when required variables do not have defaults", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture();
const variableRoutine = await svc.create(
companyId,
{
projectId,
goalId: null,
parentIssueId: null,
title: "repo triage",
description: "Review {{repo}}",
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
],
},
{},
);
await expect(
svc.createTrigger(variableRoutine.id, {
kind: "schedule",
label: "daily",
cronExpression: "0 10 * * *",
timezone: "UTC",
}, {}),
).rejects.toThrow(/require defaults for required variables/i);
});
it("treats malformed stored defaults as missing when validating schedule triggers", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture();
const variableRoutine = await svc.create(
companyId,
{
projectId,
goalId: null,
parentIssueId: null,
title: "ship check",
description: "Review {{approved}}",
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [
{ name: "approved", label: null, type: "boolean", defaultValue: true, required: true, options: [] },
],
},
{},
);
await db
.update(routines)
.set({
variables: [
{
name: "approved",
label: null,
type: "boolean",
defaultValue: "definitely",
required: true,
options: [],
},
],
})
.where(eq(routines.id, variableRoutine.id));
await expect(
svc.createTrigger(variableRoutine.id, {
kind: "schedule",
label: "daily",
cronExpression: "0 10 * * *",
timezone: "UTC",
}, {}),
).rejects.toThrow(/require defaults for required variables/i);
});
it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => {
const { routine, svc } = await seedFixture({
wakeup: async (wakeupAgentId, wakeupOpts) => {

View file

@ -552,7 +552,7 @@ describe("realizeExecutionWorkspace", () => {
} finally {
process.chdir(previousCwd);
}
});
}, 15_000);
it("records worktree setup and provision operations when a recorder is provided", async () => {
const repoRoot = await createTempRepo();

View file

@ -2,7 +2,7 @@ import { Router, type Request } from "express";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable } from "@paperclipai/db";
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import {
agentSkillSyncSchema,
@ -220,6 +220,73 @@ export function agentRoutes(db: Db) {
return allowedByGrant || canCreateAgents(actorAgent);
}
async function buildSkippedWakeupResponse(
agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>,
payload: Record<string, unknown> | null | undefined,
) {
const issueId = typeof payload?.issueId === "string" && payload.issueId.trim() ? payload.issueId : null;
if (!issueId) {
return {
status: "skipped" as const,
reason: "wakeup_skipped",
message: "Wakeup was skipped.",
issueId: null,
executionRunId: null,
executionAgentId: null,
executionAgentName: null,
};
}
const issue = await db
.select({
id: issuesTable.id,
executionRunId: issuesTable.executionRunId,
})
.from(issuesTable)
.where(and(eq(issuesTable.id, issueId), eq(issuesTable.companyId, agent.companyId)))
.then((rows) => rows[0] ?? null);
if (!issue?.executionRunId) {
return {
status: "skipped" as const,
reason: "wakeup_skipped",
message: "Wakeup was skipped.",
issueId,
executionRunId: null,
executionAgentId: null,
executionAgentName: null,
};
}
const executionRun = await heartbeat.getRun(issue.executionRunId);
if (!executionRun || (executionRun.status !== "queued" && executionRun.status !== "running")) {
return {
status: "skipped" as const,
reason: "wakeup_skipped",
message: "Wakeup was skipped.",
issueId,
executionRunId: issue.executionRunId,
executionAgentId: null,
executionAgentName: null,
};
}
const executionAgent = await svc.getById(executionRun.agentId);
const executionAgentName = executionAgent?.name ?? null;
return {
status: "skipped" as const,
reason: "issue_execution_deferred",
message: executionAgentName
? `Wakeup was deferred because this issue is already being executed by ${executionAgentName}.`
: "Wakeup was deferred because this issue already has an active execution run.",
issueId,
executionRunId: executionRun.id,
executionAgentId: executionRun.agentId,
executionAgentName,
};
}
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
@ -532,8 +599,15 @@ export function agentRoutes(db: Db) {
};
}
const ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS = new Set([
"cursor",
"gemini_local",
"opencode_local",
"pi_local",
]);
function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) {
return adapterType !== "claude_local";
return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType);
}
async function buildRuntimeSkillConfig(
@ -1994,7 +2068,7 @@ export function agentRoutes(db: Db) {
});
if (!run) {
res.status(202).json({ status: "skipped" });
res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
return;
}

View file

@ -21,7 +21,11 @@ export function instanceSettingsRoutes(db: Db) {
const svc = instanceSettingsService(db);
router.get("/instance/settings/general", async (req, res) => {
assertCanManageInstanceSettings(req);
// General settings (e.g. keyboardShortcuts) are readable by any
// authenticated board user. Only PATCH requires instance-admin.
if (req.actor.type !== "board") {
throw forbidden("Board access required");
}
res.json(await svc.getGeneral());
});
@ -56,7 +60,11 @@ export function instanceSettingsRoutes(db: Db) {
);
router.get("/instance/settings/experimental", async (req, res) => {
assertCanManageInstanceSettings(req);
// Experimental settings are readable by any authenticated board user.
// Only PATCH requires instance-admin.
if (req.actor.type !== "board") {
throw forbidden("Board access required");
}
res.json(await svc.getExperimental());
});

View file

@ -27,6 +27,7 @@ import type {
CompanyPortabilitySidebarOrder,
CompanyPortabilitySkillManifestEntry,
CompanySkill,
RoutineVariable,
} from "@paperclipai/shared";
import {
ISSUE_PRIORITIES,
@ -523,7 +524,7 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
claude_local: [
{ path: ["timeoutSec"], value: 0 },
{ path: ["graceSec"], value: 15 },
{ path: ["maxTurnsPerRun"], value: 300 },
{ path: ["maxTurnsPerRun"], value: 1000 },
],
openclaw_gateway: [
{ path: ["timeoutSec"], value: 120 },
@ -568,6 +569,29 @@ function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIss
};
}
function normalizeRoutineVariableExtension(value: unknown): RoutineVariable | null {
if (!isPlainRecord(value)) return null;
const name = asString(value.name);
if (!name) return null;
const type = asString(value.type) ?? "text";
if (!["text", "textarea", "number", "boolean", "select"].includes(type)) return null;
const options = Array.isArray(value.options)
? value.options.map((entry) => asString(entry)).filter((entry): entry is string => Boolean(entry))
: [];
const defaultValue =
typeof value.defaultValue === "string" || typeof value.defaultValue === "number" || typeof value.defaultValue === "boolean"
? value.defaultValue
: null;
return {
name,
label: asString(value.label),
type: type as RoutineVariable["type"],
defaultValue,
required: asBoolean(value.required) ?? true,
options,
};
}
function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRoutineManifestEntry | null {
if (!isPlainRecord(value)) return null;
const triggers = Array.isArray(value.triggers)
@ -575,9 +599,15 @@ function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRouti
.map((entry) => normalizeRoutineTriggerExtension(entry))
.filter((entry): entry is CompanyPortabilityIssueRoutineTriggerManifestEntry => entry !== null)
: [];
const variables = Array.isArray(value.variables)
? value.variables
.map((entry) => normalizeRoutineVariableExtension(entry))
.filter((entry): entry is RoutineVariable => entry !== null)
: null;
const routine = {
concurrencyPolicy: asString(value.concurrencyPolicy),
catchUpPolicy: asString(value.catchUpPolicy),
variables,
triggers,
};
return stripEmptyValues(routine) ? routine : null;
@ -587,6 +617,7 @@ function buildRoutineManifestFromLiveRoutine(routine: RoutineLike): CompanyPorta
return {
concurrencyPolicy: routine.concurrencyPolicy,
catchUpPolicy: routine.catchUpPolicy,
variables: routine.variables,
triggers: routine.triggers.map((trigger) => ({
kind: trigger.kind,
label: trigger.label ?? null,
@ -1086,11 +1117,13 @@ function resolvePortableRoutineDefinition(
? {
concurrencyPolicy: issue.routine.concurrencyPolicy,
catchUpPolicy: issue.routine.catchUpPolicy,
variables: issue.routine.variables ?? null,
triggers: [...issue.routine.triggers],
}
: {
concurrencyPolicy: null,
catchUpPolicy: null,
variables: null,
triggers: [] as CompanyPortabilityIssueRoutineTriggerManifestEntry[],
};
@ -3204,6 +3237,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
priority: routine.priority !== "medium" ? routine.priority : undefined,
concurrencyPolicy: routine.concurrencyPolicy !== "coalesce_if_active" ? routine.concurrencyPolicy : undefined,
catchUpPolicy: routine.catchUpPolicy !== "skip_missed" ? routine.catchUpPolicy : undefined,
variables: (routine.variables ?? []).length > 0 ? routine.variables : undefined,
triggers: routine.triggers.map((trigger) => stripEmptyValues({
kind: trigger.kind,
label: trigger.label ?? null,
@ -4173,6 +4207,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const routineDefinition = resolvedRoutine.routine ?? {
concurrencyPolicy: null,
catchUpPolicy: null,
variables: null,
triggers: [],
};
const createdRoutine = await routines.create(targetCompany.id, {
@ -4196,6 +4231,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
routineDefinition.catchUpPolicy && ROUTINE_CATCH_UP_POLICIES.includes(routineDefinition.catchUpPolicy as any)
? routineDefinition.catchUpPolicy as typeof ROUTINE_CATCH_UP_POLICIES[number]
: "skip_missed",
variables: routineDefinition.variables ?? [],
}, {
agentId: null,
userId: actorUserId ?? null,

View file

@ -99,6 +99,11 @@ type IssueUserCommentStats = {
myLastCommentAt: Date | null;
lastExternalCommentAt: Date | null;
};
type IssueLastActivityStat = {
issueId: string;
latestCommentAt: Date | null;
latestLogAt: Date | null;
};
type IssueUserContextInput = {
createdByUserId: string | null;
assigneeUserId: string | null;
@ -262,8 +267,8 @@ function issueLastActivityAtExpr(companyId: string, userId: string) {
const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId);
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
return sql<Date>`
COALESCE(
${lastExternalCommentAt},
GREATEST(
COALESCE(${lastExternalCommentAt}, to_timestamp(0)),
CASE
WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0))
THEN ${issues.updatedAt}
@ -273,6 +278,52 @@ function issueLastActivityAtExpr(companyId: string, userId: string) {
`;
}
const ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS = [
"issue.read_marked",
"issue.read_unmarked",
"issue.inbox_archived",
"issue.inbox_unarchived",
] as const;
function issueLatestCommentAtExpr(companyId: string) {
return sql<Date | null>`
(
SELECT MAX(${issueComments.createdAt})
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
)
`;
}
function issueLatestLogAtExpr(companyId: string) {
return sql<Date | null>`
(
SELECT MAX(${activityLog.createdAt})
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND ${activityLog.action} NOT IN (${sql.join(
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
sql`, `,
)})
)
`;
}
function issueCanonicalLastActivityAtExpr(companyId: string) {
const latestCommentAt = issueLatestCommentAtExpr(companyId);
const latestLogAt = issueLatestLogAtExpr(companyId);
return sql<Date>`
GREATEST(
${issues.updatedAt},
COALESCE(${latestCommentAt}, to_timestamp(0)),
COALESCE(${latestLogAt}, to_timestamp(0))
)
`;
}
function unreadForUserCondition(companyId: string, userId: string) {
const touchedCondition = touchedByUserCondition(companyId, userId);
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
@ -383,6 +434,19 @@ export function deriveIssueUserContext(
};
}
function latestIssueActivityAt(...values: Array<Date | string | null | undefined>): Date | null {
const normalized = values
.map((value) => {
if (!value) return null;
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
})
.filter((value): value is Date => value instanceof Date)
.sort((a, b) => b.getTime() - a.getTime());
return normalized[0] ?? null;
}
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
const map = new Map<string, IssueLabelRow[]>();
if (issueIds.length === 0) return map;
@ -749,66 +813,158 @@ export function issueService(db: Db) {
ELSE 6
END
`;
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
const rows = await db
.select()
.from(issues)
.where(and(...conditions))
.orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
.orderBy(
hasSearch ? asc(searchOrder) : asc(priorityOrder),
asc(priorityOrder),
desc(canonicalLastActivityAt),
desc(issues.updatedAt),
);
const withLabels = await withIssueLabels(db, rows);
const runMap = await activeRunMapForIssues(db, withLabels);
const withRuns = withActiveRuns(withLabels, runMap);
if (!contextUserId || withRuns.length === 0) {
if (withRuns.length === 0) {
return withRuns;
}
const issueIds = withRuns.map((row) => row.id);
const statsRows = await db
.select({
issueId: issueComments.issueId,
myLastCommentAt: sql<Date | null>`
MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
`,
lastExternalCommentAt: sql<Date | null>`
MAX(
CASE
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
THEN ${issueComments.createdAt}
END
const [statsRows, readRows, lastActivityRows] = await Promise.all([
contextUserId
? db
.select({
issueId: issueComments.issueId,
myLastCommentAt: sql<Date | null>`
MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
`,
lastExternalCommentAt: sql<Date | null>`
MAX(
CASE
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
THEN ${issueComments.createdAt}
END
)
`,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
inArray(issueComments.issueId, issueIds),
),
)
`,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
inArray(issueComments.issueId, issueIds),
),
)
.groupBy(issueComments.issueId);
const readRows = await db
.select({
issueId: issueReadStates.issueId,
myLastReadAt: issueReadStates.lastReadAt,
})
.from(issueReadStates)
.where(
and(
eq(issueReadStates.companyId, companyId),
eq(issueReadStates.userId, contextUserId),
inArray(issueReadStates.issueId, issueIds),
),
);
.groupBy(issueComments.issueId)
: Promise.resolve([]),
contextUserId
? db
.select({
issueId: issueReadStates.issueId,
myLastReadAt: issueReadStates.lastReadAt,
})
.from(issueReadStates)
.where(
and(
eq(issueReadStates.companyId, companyId),
eq(issueReadStates.userId, contextUserId),
inArray(issueReadStates.issueId, issueIds),
),
)
: Promise.resolve([]),
Promise.all([
db
.select({
issueId: issueComments.issueId,
latestCommentAt: sql<Date | null>`MAX(${issueComments.createdAt})`,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
inArray(issueComments.issueId, issueIds),
),
)
.groupBy(issueComments.issueId),
db
.select({
issueId: activityLog.entityId,
latestLogAt: sql<Date | null>`MAX(${activityLog.createdAt})`,
})
.from(activityLog)
.where(
and(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
inArray(activityLog.entityId, issueIds),
sql`${activityLog.action} NOT IN (${sql.join(
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
sql`, `,
)})`,
),
)
.groupBy(activityLog.entityId),
]).then(([commentRows, logRows]) => {
const byIssueId = new Map<string, IssueLastActivityStat>();
for (const row of commentRows) {
byIssueId.set(row.issueId, {
issueId: row.issueId,
latestCommentAt: row.latestCommentAt,
latestLogAt: null,
});
}
for (const row of logRows) {
const existing = byIssueId.get(row.issueId);
if (existing) existing.latestLogAt = row.latestLogAt;
else {
byIssueId.set(row.issueId, {
issueId: row.issueId,
latestCommentAt: null,
latestLogAt: row.latestLogAt,
});
}
}
return [...byIssueId.values()];
}),
]);
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
if (!contextUserId) {
return withRuns.map((row) => {
const activity = lastActivityByIssueId.get(row.id);
const lastActivityAt = latestIssueActivityAt(
row.updatedAt,
activity?.latestCommentAt ?? null,
activity?.latestLogAt ?? null,
) ?? row.updatedAt;
return {
...row,
lastActivityAt,
};
});
}
const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
return withRuns.map((row) => ({
...row,
...deriveIssueUserContext(row, contextUserId, {
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
myLastReadAt: readByIssueId.get(row.id) ?? null,
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
}),
}));
return withRuns.map((row) => {
const activity = lastActivityByIssueId.get(row.id);
const lastActivityAt = latestIssueActivityAt(
row.updatedAt,
activity?.latestCommentAt ?? null,
activity?.latestLogAt ?? null,
) ?? row.updatedAt;
return {
...row,
lastActivityAt,
...deriveIssueUserContext(row, contextUserId, {
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
myLastReadAt: readByIssueId.get(row.id) ?? null,
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
}),
};
});
},
countUnreadTouchedByUser: async (companyId: string, userId: string, status?: string) => {

View file

@ -21,10 +21,16 @@ import type {
RoutineRunSummary,
RoutineTrigger,
RoutineTriggerSecretMaterial,
RoutineVariable,
RunRoutine,
UpdateRoutine,
UpdateRoutineTrigger,
} from "@paperclipai/shared";
import {
interpolateRoutineTemplate,
stringifyRoutineVariableValue,
syncRoutineVariablesWithTemplate,
} from "@paperclipai/shared";
import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { issueService } from "./issues.js";
@ -138,6 +144,151 @@ function normalizeWebhookTimestampMs(rawTimestamp: string) {
return parsed > 1e12 ? parsed : parsed * 1000;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseBooleanVariableValue(name: string, raw: unknown) {
if (typeof raw === "boolean") return raw;
if (typeof raw === "number" && (raw === 0 || raw === 1)) return raw === 1;
if (typeof raw === "string") {
const normalized = raw.trim().toLowerCase();
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
}
throw unprocessable(`Variable "${name}" must be a boolean`);
}
function parseNumberVariableValue(name: string, raw: unknown) {
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
if (typeof raw === "string" && raw.trim().length > 0) {
const parsed = Number(raw);
if (Number.isFinite(parsed)) return parsed;
}
throw unprocessable(`Variable "${name}" must be a number`);
}
function normalizeRoutineVariableValue(variable: RoutineVariable, raw: unknown): string | number | boolean | null {
if (raw == null) return null;
if (variable.type === "boolean") return parseBooleanVariableValue(variable.name, raw);
if (variable.type === "number") return parseNumberVariableValue(variable.name, raw);
const normalized = stringifyRoutineVariableValue(raw);
if (variable.type === "select") {
if (!variable.options.includes(normalized)) {
throw unprocessable(`Variable "${variable.name}" must match one of: ${variable.options.join(", ")}`);
}
}
return normalized;
}
function isMissingRoutineVariableValue(value: string | number | boolean | null) {
return value == null || (typeof value === "string" && value.trim().length === 0);
}
function assertRoutineVariableDefinitions(variables: RoutineVariable[]) {
for (const variable of variables) {
if (variable.defaultValue != null) {
normalizeRoutineVariableValue(variable, variable.defaultValue);
}
if (variable.type === "select" && variable.options.length === 0) {
throw unprocessable(`Variable "${variable.name}" must define at least one option`);
}
}
}
function sanitizeRoutineVariableInputs(
variables: Array<Partial<RoutineVariable> & Pick<RoutineVariable, "name">> | null | undefined,
): RoutineVariable[] {
return (variables ?? []).map((variable) => ({
name: variable.name,
label: variable.label ?? null,
type: variable.type ?? "text",
defaultValue: variable.defaultValue ?? null,
required: variable.required ?? true,
options: variable.options ?? [],
}));
}
function assertScheduleCompatibleVariables(variables: RoutineVariable[]) {
const missingDefaults = variables
.filter((variable) => variable.required)
.filter((variable) => {
try {
return isMissingRoutineVariableValue(normalizeRoutineVariableValue(variable, variable.defaultValue));
} catch {
return true;
}
})
.map((variable) => variable.name);
if (missingDefaults.length > 0) {
throw unprocessable(
`Scheduled routines require defaults for required variables: ${missingDefaults.join(", ")}`,
);
}
}
function collectProvidedRoutineVariables(
source: "schedule" | "manual" | "api" | "webhook",
payload: Record<string, unknown> | null | undefined,
variables: Record<string, unknown> | null | undefined,
) {
const nestedVariables = isPlainRecord(payload) && isPlainRecord(payload.variables) ? payload.variables : {};
const provided = {
...(source === "webhook" && payload ? payload : {}),
...nestedVariables,
...(variables ?? {}),
};
delete provided.variables;
return provided;
}
function resolveRoutineVariableValues(
variables: RoutineVariable[],
input: {
source: "schedule" | "manual" | "api" | "webhook";
payload?: Record<string, unknown> | null;
variables?: Record<string, unknown> | null;
},
) {
if (variables.length === 0) return {} as Record<string, string | number | boolean>;
const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables);
const resolved: Record<string, string | number | boolean> = {};
const missing: string[] = [];
for (const variable of variables) {
const candidate = provided[variable.name] !== undefined ? provided[variable.name] : variable.defaultValue;
const normalized = normalizeRoutineVariableValue(variable, candidate);
if (normalized == null || (typeof normalized === "string" && normalized.trim().length === 0)) {
if (variable.required) missing.push(variable.name);
continue;
}
resolved[variable.name] = normalized;
}
if (missing.length > 0) {
throw unprocessable(`Missing routine variables: ${missing.join(", ")}`);
}
return resolved;
}
function mergeRoutineRunPayload(
payload: Record<string, unknown> | null | undefined,
variables: Record<string, string | number | boolean>,
) {
if (Object.keys(variables).length === 0) return payload ?? null;
if (!payload) return { variables };
const existingVariables = isPlainRecord(payload.variables) ? payload.variables : {};
return {
...payload,
variables: {
...existingVariables,
...variables,
},
};
}
export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeupDeps } = {}) {
const issueSvc = issueService(db);
const secretsSvc = secretService(db);
@ -515,8 +666,15 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
trigger: typeof routineTriggers.$inferSelect | null;
source: "schedule" | "manual" | "api" | "webhook";
payload?: Record<string, unknown> | null;
variables?: Record<string, unknown> | null;
idempotencyKey?: string | null;
executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null;
executionWorkspaceSettings?: Record<string, unknown> | null;
}) {
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
const run = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
await tx.execute(
@ -553,7 +711,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
status: "received",
triggeredAt,
idempotencyKey: input.idempotencyKey ?? null,
triggerPayload: input.payload ?? null,
triggerPayload,
})
.returning();
@ -589,13 +747,16 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
goalId: input.routine.goalId,
parentId: input.routine.parentIssueId,
title: input.routine.title,
description: input.routine.description,
description,
status: "todo",
priority: input.routine.priority,
assigneeAgentId: input.routine.assigneeAgentId,
originKind: "routine_execution",
originId: input.routine.id,
originRunId: createdRun.id,
executionWorkspaceId: input.executionWorkspaceId ?? null,
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
executionWorkspaceSettings: input.executionWorkspaceSettings ?? null,
});
} catch (error) {
const isOpenExecutionConflict =
@ -824,6 +985,11 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
await assertAssignableAgent(companyId, input.assigneeAgentId);
if (input.goalId) await assertGoal(companyId, input.goalId);
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
const variables = syncRoutineVariablesWithTemplate(
input.description,
sanitizeRoutineVariableInputs(input.variables),
);
assertRoutineVariableDefinitions(variables);
const [created] = await db
.insert(routines)
.values({
@ -838,6 +1004,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
status: input.status,
concurrencyPolicy: input.concurrencyPolicy,
catchUpPolicy: input.catchUpPolicy,
variables,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
updatedByAgentId: actor.agentId ?? null,
@ -852,10 +1019,31 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (!existing) return null;
const nextProjectId = patch.projectId ?? existing.projectId;
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
const nextDescription = patch.description === undefined ? existing.description : patch.description;
const nextVariables = syncRoutineVariablesWithTemplate(
nextDescription,
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
);
if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
if (patch.assigneeAgentId) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId);
if (patch.goalId) await assertGoal(existing.companyId, patch.goalId);
if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId);
assertRoutineVariableDefinitions(nextVariables);
const enabledScheduleTriggers = await db
.select({ id: routineTriggers.id })
.from(routineTriggers)
.where(
and(
eq(routineTriggers.routineId, existing.id),
eq(routineTriggers.kind, "schedule"),
eq(routineTriggers.enabled, true),
),
)
.limit(1)
.then((rows) => rows.length > 0);
if (enabledScheduleTriggers) {
assertScheduleCompatibleVariables(nextVariables);
}
const [updated] = await db
.update(routines)
.set({
@ -863,12 +1051,13 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
goalId: patch.goalId === undefined ? existing.goalId : patch.goalId,
parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
title: patch.title ?? existing.title,
description: patch.description === undefined ? existing.description : patch.description,
description: nextDescription,
assigneeAgentId: nextAssigneeAgentId,
priority: patch.priority ?? existing.priority,
status: patch.status ?? existing.status,
concurrencyPolicy: patch.concurrencyPolicy ?? existing.concurrencyPolicy,
catchUpPolicy: patch.catchUpPolicy ?? existing.catchUpPolicy,
variables: nextVariables,
updatedByAgentId: actor.agentId ?? null,
updatedByUserId: actor.userId ?? null,
updatedAt: new Date(),
@ -892,6 +1081,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
let nextRunAt: Date | null = null;
if (input.kind === "schedule") {
assertScheduleCompatibleVariables(routine.variables ?? []);
const timeZone = input.timezone || "UTC";
assertTimeZone(timeZone);
const error = validateCron(input.cronExpression);
@ -947,6 +1137,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
let timezone = existing.timezone;
if (existing.kind === "schedule") {
const routine = await getRoutineById(existing.routineId);
if (!routine) throw notFound("Routine not found");
if (patch.cronExpression !== undefined) {
if (patch.cronExpression == null) throw unprocessable("Scheduled triggers require cronExpression");
const error = validateCron(patch.cronExpression);
@ -961,6 +1153,9 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (cronExpression && timezone) {
nextRunAt = nextCronTickInTimeZone(cronExpression, timezone, new Date());
}
if ((patch.enabled ?? existing.enabled) === true) {
assertScheduleCompatibleVariables(routine.variables ?? []);
}
}
const [updated] = await db
@ -1034,7 +1229,12 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
trigger,
source: input.source,
payload: input.payload as Record<string, unknown> | null | undefined,
variables: input.variables as Record<string, unknown> | null | undefined,
idempotencyKey: input.idempotencyKey,
executionWorkspaceId: input.executionWorkspaceId ?? null,
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
executionWorkspaceSettings:
(input.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null,
});
},
@ -1097,6 +1297,9 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
trigger,
source: "webhook",
payload: input.payload,
variables: isPlainRecord(input.payload) && isPlainRecord(input.payload.variables)
? input.payload.variables
: null,
idempotencyKey: input.idempotencyKey,
});
},