From 30ff3d7c5874e8c71586bb016984c1d37f96ed28 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:47:24 -0500 Subject: [PATCH] feat(routines): expose activity gate API (#9438) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Scheduled routines provide recurring control-plane work without manual intervention > - The new activity gate can suppress scheduled runs when no external work occurred > - The core scheduler and database support landed without a public create/update contract > - Agents, operators, and managed plugins need validated fields plus discoverable semantics to opt in safely > - This pull request exposes the activity gate through routine APIs, revisions, plugin contracts, tests, and skill documentation > - The benefit is backward-compatible control over idle scheduled work without losing activity-triggered follow-up ## Linked Issues or Issue Description - Refs #8534 ## What Changed - Added shared activity-gate policy and scope enums with create/PATCH validation. - Persisted activity-gate fields through routine creation, updates, revision snapshots, pipeline snapshots, and revision restores. - Defaulted legacy revision snapshots during restore and added regression coverage for pre-field snapshots. - Extended managed-plugin routine declarations, production reconciliation, and the SDK test harness to preserve non-default gate settings. - Added end-to-end API coverage for create/PATCH/list/detail round-trips, defaults, and invalid enum rejection. - Documented schedule-only semantics, activity windows, own-run/read-action exclusions, scopes, and an hourly quiet-night watcher example. ## Verification - `pnpm exec vitest run packages/shared/src/validators/routine.test.ts server/src/__tests__/routines-service.test.ts server/src/__tests__/routines-e2e.test.ts` - `pnpm exec vitest run packages/shared/src/validators/plugin.test.ts packages/plugins/sdk/tests/testing-actions.test.ts server/src/__tests__/plugin-managed-routines.test.ts server/src/__tests__/routines-service.test.ts -t 'activity gate|preserves declared activity gate settings|resolves routine agent and project refs'` - `pnpm exec vitest run ui/src/lib/workspace-routines.test.ts ui/src/pages/Routines.test.tsx` - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/plugin-sdk typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm check:token-gates` - GitHub CI: all final-head checks green; Storybook visual regression skipped by path rules. - Greptile: 5/5 with no unresolved review threads. ## Risks - Low risk: defaults remain `always` and `company`, preserving existing routine behavior and old revision snapshots. - Managed plugin manifests can now declare the same validated gate settings as the public routine API; omitted values retain core defaults. - Revision snapshots now include the new fields so policy changes are not lost or treated as no-ops during restore. > For core feature work, checked `ROADMAP.md`: this extends the existing Scheduled Routines roadmap item and does not duplicate a separate planned capability. ## Model Used - OpenAI GPT-5.5 via Codex CLI, with repository tool use and code execution; context-window size was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/plugins/sdk/src/testing.ts | 2 + .../plugins/sdk/tests/testing-actions.test.ts | 24 ++++++++ packages/shared/src/constants.ts | 6 ++ packages/shared/src/types/plugin.ts | 6 ++ packages/shared/src/types/routine.ts | 6 ++ packages/shared/src/validators/plugin.test.ts | 4 ++ packages/shared/src/validators/plugin.ts | 4 ++ .../shared/src/validators/routine.test.ts | 15 +++++ packages/shared/src/validators/routine.ts | 6 ++ .../__tests__/plugin-managed-routines.test.ts | 4 ++ server/src/__tests__/routines-e2e.test.ts | 59 +++++++++++++++++++ server/src/__tests__/routines-service.test.ts | 38 +++++++++++- server/src/services/pipelines.ts | 2 + .../src/services/plugin-managed-routines.ts | 6 ++ server/src/services/routines.ts | 13 +++- skills/paperclip/references/routines.md | 46 ++++++++++++++- ui/src/components/RoutineHistoryTab.test.tsx | 4 ++ ui/src/lib/workspace-routines.test.ts | 2 + ui/src/pages/Routines.test.tsx | 2 + .../stories/routine-detail-c.stories.tsx | 2 + .../stories/routine-secrets.stories.tsx | 4 ++ 21 files changed, 252 insertions(+), 3 deletions(-) diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 8894bda890..e3255c8058 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1245,6 +1245,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { status: declaration.status ?? (assigneeAgentId ? "active" : "paused"), concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active", catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed", + activityGatePolicy: declaration.activityGatePolicy ?? "always", + activityGateScope: declaration.activityGateScope ?? "company", variables: declaration.variables ?? [], latestRevisionId: null, latestRevisionNumber: 1, diff --git a/packages/plugins/sdk/tests/testing-actions.test.ts b/packages/plugins/sdk/tests/testing-actions.test.ts index e661c001b0..5579890090 100644 --- a/packages/plugins/sdk/tests/testing-actions.test.ts +++ b/packages/plugins/sdk/tests/testing-actions.test.ts @@ -73,6 +73,30 @@ describe("createTestHarness action context", () => { }); }); +describe("createTestHarness managed routines", () => { + it("preserves declared activity gate settings", async () => { + const harness = createTestHarness({ + manifest: { + ...manifest, + capabilities: ["routines.managed"], + routines: [{ + routineKey: "quiet-watcher", + title: "Quiet watcher", + activityGatePolicy: "require_external_activity", + activityGateScope: "project", + }], + }, + }); + + const resolved = await harness.ctx.routines.managed.reconcile("quiet-watcher", "company-1"); + + expect(resolved.routine).toMatchObject({ + activityGatePolicy: "require_external_activity", + activityGateScope: "project", + }); + }); +}); + describe("createTestHarness issue interactions", () => { it("creates request_checkbox_confirmation interactions through the typed host helper", async () => { const harness = createTestHarness({ diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index e6aa21d173..81ede0f4f8 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -563,6 +563,12 @@ export type RoutineConcurrencyPolicy = (typeof ROUTINE_CONCURRENCY_POLICIES)[num export const ROUTINE_CATCH_UP_POLICIES = ["skip_missed", "enqueue_missed_with_cap"] as const; export type RoutineCatchUpPolicy = (typeof ROUTINE_CATCH_UP_POLICIES)[number]; +export const ROUTINE_ACTIVITY_GATE_POLICIES = ["always", "require_external_activity"] as const; +export type RoutineActivityGatePolicy = (typeof ROUTINE_ACTIVITY_GATE_POLICIES)[number]; + +export const ROUTINE_ACTIVITY_GATE_SCOPES = ["company", "project"] as const; +export type RoutineActivityGateScope = (typeof ROUTINE_ACTIVITY_GATE_SCOPES)[number]; + export const ROUTINE_TRIGGER_KINDS = ["schedule", "webhook", "api"] as const; export type RoutineTriggerKind = (typeof ROUTINE_TRIGGER_KINDS)[number]; diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 0c090d7acf..2ff9334382 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -22,6 +22,8 @@ import type { IssuePriority, ProjectStatus, RoutineCatchUpPolicy, + RoutineActivityGatePolicy, + RoutineActivityGateScope, RoutineConcurrencyPolicy, RoutineStatus, IssueSurfaceVisibility, @@ -316,6 +318,10 @@ export interface PluginManagedRoutineDeclaration { concurrencyPolicy?: RoutineConcurrencyPolicy; /** Suggested missed-trigger behavior. Defaults to core routine default. */ catchUpPolicy?: RoutineCatchUpPolicy; + /** Suggested external-activity gate behavior. Defaults to `always`. */ + activityGatePolicy?: RoutineActivityGatePolicy; + /** Suggested external-activity gate scope. Defaults to `company`. */ + activityGateScope?: RoutineActivityGateScope; /** Suggested routine variables. */ variables?: RoutineVariable[]; /** Suggested triggers created when the routine is first reconciled. */ diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index bab97b6d4e..2f17fa3d88 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -1,6 +1,8 @@ import type { IssueOriginKind, IssuePriority, + RoutineActivityGatePolicy, + RoutineActivityGateScope, RoutineCatchUpPolicy, RoutineConcurrencyPolicy, RoutineStatus, @@ -81,6 +83,8 @@ export interface Routine { status: string; concurrencyPolicy: string; catchUpPolicy: string; + activityGatePolicy: string; + activityGateScope: string; originKind?: string; originId?: string | null; variables: RoutineVariable[]; @@ -124,6 +128,8 @@ export interface RoutineRevisionSnapshotRoutineV1 { status: RoutineStatus; concurrencyPolicy: RoutineConcurrencyPolicy; catchUpPolicy: RoutineCatchUpPolicy; + activityGatePolicy: RoutineActivityGatePolicy; + activityGateScope: RoutineActivityGateScope; originKind?: string; originId?: string | null; variables: RoutineVariable[]; diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 8b210885b0..a560ab25e5 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -104,10 +104,14 @@ describe("plugin managed routine validators", () => { const parsed = pluginManagedRoutineDeclarationSchema.parse({ routineKey: "wiki.refresh", title: "Refresh Wiki", + activityGatePolicy: "require_external_activity", + activityGateScope: "project", issueTemplate: { surfaceVisibility: "default" }, }); expect(parsed.issueTemplate?.surfaceVisibility).toBe("default"); + expect(parsed.activityGatePolicy).toBe("require_external_activity"); + expect(parsed.activityGateScope).toBe("project"); }); it("rejects non-core issue surface visibility values in routine templates", () => { diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 9093c0042f..c2671cc6f0 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -18,6 +18,8 @@ import { PLUGIN_API_ROUTE_METHODS, ISSUE_PRIORITIES, ROUTINE_CATCH_UP_POLICIES, + ROUTINE_ACTIVITY_GATE_POLICIES, + ROUTINE_ACTIVITY_GATE_SCOPES, ROUTINE_CONCURRENCY_POLICIES, ROUTINE_STATUSES, ROUTINE_TRIGGER_KINDS, @@ -238,6 +240,8 @@ export const pluginManagedRoutineDeclarationSchema = z.object({ priority: z.enum(ISSUE_PRIORITIES).optional(), concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional(), catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional(), + activityGatePolicy: z.enum(ROUTINE_ACTIVITY_GATE_POLICIES).optional(), + activityGateScope: z.enum(ROUTINE_ACTIVITY_GATE_SCOPES).optional(), variables: z.array(routineVariableSchema).optional(), triggers: z.array(z.object({ kind: z.enum(ROUTINE_TRIGGER_KINDS), diff --git a/packages/shared/src/validators/routine.test.ts b/packages/shared/src/validators/routine.test.ts index 82c921b9cc..84d3421d0c 100644 --- a/packages/shared/src/validators/routine.test.ts +++ b/packages/shared/src/validators/routine.test.ts @@ -43,6 +43,8 @@ describe("routine validators", () => { }); expect(parsed.triggers[0]?.publicId).toBe("routine_webhook_123"); + expect(parsed.routine.activityGatePolicy).toBe("always"); + expect(parsed.routine.activityGateScope).toBe("company"); }); it("rejects secret-bearing trigger fields in routine revision snapshots", () => { @@ -85,6 +87,19 @@ describe("routine validators", () => { }).baseRevisionId).toBe(baseRevisionId); }); + it("validates routine activity gate values", () => { + expect(updateRoutineSchema.parse({ + activityGatePolicy: "require_external_activity", + activityGateScope: "project", + })).toMatchObject({ + activityGatePolicy: "require_external_activity", + activityGateScope: "project", + }); + + expect(() => updateRoutineSchema.parse({ activityGatePolicy: "when_busy" })).toThrow(); + expect(() => updateRoutineSchema.parse({ activityGateScope: "agent" })).toThrow(); + }); + it("accepts date variables with valid YYYY-MM-DD defaults", () => { expect(routineVariableSchema.parse({ name: "startDate", diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index f0e4a92851..1f27068cbf 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { ISSUE_PRIORITIES, + ROUTINE_ACTIVITY_GATE_POLICIES, + ROUTINE_ACTIVITY_GATE_SCOPES, ROUTINE_CATCH_UP_POLICIES, ROUTINE_CONCURRENCY_POLICIES, ROUTINE_STATUSES, @@ -71,6 +73,8 @@ export const createRoutineSchema = z.object({ status: z.enum(ROUTINE_STATUSES).optional().default("active"), concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"), catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional().default("skip_missed"), + activityGatePolicy: z.enum(ROUTINE_ACTIVITY_GATE_POLICIES).optional(), + activityGateScope: z.enum(ROUTINE_ACTIVITY_GATE_SCOPES).optional(), variables: z.array(routineVariableSchema).optional().default([]), env: envConfigSchema.optional().nullable(), }); @@ -96,6 +100,8 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({ status: z.enum(ROUTINE_STATUSES), concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES), catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES), + activityGatePolicy: z.enum(ROUTINE_ACTIVITY_GATE_POLICIES).default("always"), + activityGateScope: z.enum(ROUTINE_ACTIVITY_GATE_SCOPES).default("company"), variables: z.array(routineVariableSchema), env: envConfigSchema.nullable().default(null), responsibleUserId: z.string().nullable().default(null), diff --git a/server/src/__tests__/plugin-managed-routines.test.ts b/server/src/__tests__/plugin-managed-routines.test.ts index e478cf68b3..25a02531c2 100644 --- a/server/src/__tests__/plugin-managed-routines.test.ts +++ b/server/src/__tests__/plugin-managed-routines.test.ts @@ -78,6 +78,8 @@ function manifest(): PaperclipPluginManifestV1 { priority: "medium", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", + activityGatePolicy: "require_external_activity", + activityGateScope: "project", triggers: [{ kind: "schedule", label: "Nightly", @@ -167,6 +169,8 @@ describeEmbeddedPostgres("plugin-managed routines", () => { title: "Nightly lint", assigneeAgentId: agent.agentId, projectId: project.projectId, + activityGatePolicy: "require_external_activity", + activityGateScope: "project", managedByPlugin: expect.objectContaining({ pluginKey: "paperclip.managed-routines-test", resourceKind: "routine", diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index b93c148a03..e0ee29b001 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -249,14 +249,29 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { priority: "high", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", + activityGatePolicy: "require_external_activity", + activityGateScope: "project", }); expect([200, 201]).toContain(createRes.status); expect(createRes.body.title).toBe("Daily standup prep"); expect(createRes.body.assigneeAgentId).toBe(agentId); + expect(createRes.body.activityGatePolicy).toBe("require_external_activity"); + expect(createRes.body.activityGateScope).toBe("project"); const routineId = createRes.body.id as string; + const updateRes = await request(app) + .patch(`/api/routines/${routineId}`) + .send({ + activityGatePolicy: "always", + activityGateScope: "company", + }); + + expect(updateRes.status).toBe(200); + expect(updateRes.body.activityGatePolicy).toBe("always"); + expect(updateRes.body.activityGateScope).toBe("company"); + const triggerRes = await request(app) .post(`/api/routines/${routineId}/triggers`) .send({ @@ -286,12 +301,16 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { expect(listRes.status).toBe(200); const listed = listRes.body.find((r: { id: string }) => r.id === routineId); expect(listed).toBeDefined(); + expect(listed.activityGatePolicy).toBe("always"); + expect(listed.activityGateScope).toBe("company"); expect(listed.triggers).toHaveLength(1); expect(listed.triggers[0].cronExpression).toBe("0 10 * * 1-5"); expect(listed.triggers[0].timezone).toBe("UTC"); const detailRes = await request(app).get(`/api/routines/${routineId}`); expect(detailRes.status).toBe(200); + expect(detailRes.body.activityGatePolicy).toBe("always"); + expect(detailRes.body.activityGateScope).toBe("company"); expect(detailRes.body.triggers).toHaveLength(1); expect(detailRes.body.triggers[0]?.id).toBe(createdTrigger.id); expect(detailRes.body.recentRuns).toHaveLength(1); @@ -385,6 +404,46 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { expect(issue?.description).toBe("Review paperclip for high bugs"); }); + it("defaults activity gates and rejects invalid activity gate values", 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: "Default activity gate", + assigneeAgentId: agentId, + }); + + expect(createRes.status).toBe(201); + expect(createRes.body.activityGatePolicy).toBe("always"); + expect(createRes.body.activityGateScope).toBe("company"); + + const invalidCreateRes = await request(app) + .post(`/api/companies/${companyId}/routines`) + .send({ + projectId, + title: "Invalid activity gate", + assigneeAgentId: agentId, + activityGatePolicy: "when_busy", + }); + + expect(invalidCreateRes.status).toBe(400); + + const invalidPatchRes = await request(app) + .patch(`/api/routines/${createRes.body.id}`) + .send({ activityGateScope: "agent" }); + + expect(invalidPatchRes.status).toBe(400); + }); + it("allows drafting a routine without defaults and running it with one-off overrides", async () => { const { companyId, agentId, projectId, userId } = await seedFixture(); const app = await createApp({ diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 0cd39660f7..9c98324956 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -21,6 +21,7 @@ import { projectWorkspaces, projects, routineDocuments, + routineRevisions, routineRuns, routines, routineTriggers, @@ -607,11 +608,15 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { changeSummary: "Created routine", }); expect(initialRevisions[0]?.snapshot.routine.description).toBe("Run the frog routine"); + expect(initialRevisions[0]?.snapshot.routine.activityGatePolicy).toBe("always"); + expect(initialRevisions[0]?.snapshot.routine.activityGateScope).toBe("company"); const updated = await svc.update( routine.id, { description: "Run the frog routine with logs", + activityGatePolicy: "require_external_activity", + activityGateScope: "project", baseRevisionId: routine.latestRevisionId, }, {}, @@ -623,6 +628,8 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { routine.id, { description: "Run the frog routine with logs", + activityGatePolicy: "require_external_activity", + activityGateScope: "project", baseRevisionId: updated?.latestRevisionId, }, {}, @@ -633,6 +640,8 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { const revisions = await svc.listRevisions(routine.id); expect(revisions.map((revision) => revision.revisionNumber)).toEqual([2, 1]); expect(revisions[0]?.snapshot.routine.description).toBe("Run the frog routine with logs"); + expect(revisions[0]?.snapshot.routine.activityGatePolicy).toBe("require_external_activity"); + expect(revisions[0]?.snapshot.routine.activityGateScope).toBe("project"); expect(revisions[1]?.snapshot.routine.description).toBe("Run the frog routine"); }); @@ -739,7 +748,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { const { routine, svc } = await seedFixture(); const revision1Id = routine.latestRevisionId!; const run = await svc.runRoutine(routine.id, { source: "manual" }); - const revision2Routine = await svc.update(routine.id, { description: "revision 2" }, {}); + const revision2Routine = await svc.update(routine.id, { + description: "revision 2", + activityGatePolicy: "require_external_activity", + activityGateScope: "project", + }, {}); const restored = await svc.restoreRevision(routine.id, revision1Id, {}); @@ -748,6 +761,8 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(restored.routine.latestRevisionNumber).toBe(3); expect(restored.routine.latestRevisionId).not.toBe(revision2Routine?.latestRevisionId); expect(restored.routine.description).toBe("Run the frog routine"); + expect(restored.routine.activityGatePolicy).toBe("always"); + expect(restored.routine.activityGateScope).toBe("company"); expect(restored.revision.restoredFromRevisionId).toBe(revision1Id); expect(restored.revision.snapshot.routine.description).toBe("Run the frog routine"); @@ -756,6 +771,27 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { await expect(db.select().from(routineRuns).where(eq(routineRuns.id, run.id))).resolves.toHaveLength(1); }); + it("defaults activity gates when restoring a legacy routine revision snapshot", async () => { + const { routine, svc } = await seedFixture(); + const revision1Id = routine.latestRevisionId!; + const [revision1] = await db.select().from(routineRevisions).where(eq(routineRevisions.id, revision1Id)); + const legacySnapshot = structuredClone(revision1!.snapshot) as { routine: Record }; + delete legacySnapshot.routine.activityGatePolicy; + delete legacySnapshot.routine.activityGateScope; + await db.update(routineRevisions).set({ snapshot: legacySnapshot }).where(eq(routineRevisions.id, revision1Id)); + await svc.update(routine.id, { + activityGatePolicy: "require_external_activity", + activityGateScope: "project", + }, {}); + + const restored = await svc.restoreRevision(routine.id, revision1Id, {}); + + expect(restored.routine.activityGatePolicy).toBe("always"); + expect(restored.routine.activityGateScope).toBe("company"); + expect(restored.revision.snapshot.routine.activityGatePolicy).toBe("always"); + expect(restored.revision.snapshot.routine.activityGateScope).toBe("company"); + }); + it("rejects restoring the current latest routine revision", async () => { const { routine, svc } = await seedFixture(); diff --git a/server/src/services/pipelines.ts b/server/src/services/pipelines.ts index a6e611c513..dfe2bb43f4 100644 --- a/server/src/services/pipelines.ts +++ b/server/src/services/pipelines.ts @@ -1175,6 +1175,8 @@ function routineRevisionSnapshotRoutine(routine: typeof routines.$inferSelect): status: routine.status as RoutineRevisionSnapshotV1["routine"]["status"], concurrencyPolicy: routine.concurrencyPolicy as RoutineRevisionSnapshotV1["routine"]["concurrencyPolicy"], catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"], + activityGatePolicy: routine.activityGatePolicy as RoutineRevisionSnapshotV1["routine"]["activityGatePolicy"], + activityGateScope: routine.activityGateScope as RoutineRevisionSnapshotV1["routine"]["activityGateScope"], originKind: routine.originKind, originId: routine.originId, variables: routine.variables ?? [], diff --git a/server/src/services/plugin-managed-routines.ts b/server/src/services/plugin-managed-routines.ts index 94027dd323..f91983c621 100644 --- a/server/src/services/plugin-managed-routines.ts +++ b/server/src/services/plugin-managed-routines.ts @@ -49,6 +49,8 @@ function buildRoutineDefaults(declaration: PluginManagedRoutineDeclaration) { priority: declaration.priority ?? "medium", concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active", catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed", + activityGatePolicy: declaration.activityGatePolicy ?? "always", + activityGateScope: declaration.activityGateScope ?? "company", variables: declaration.variables ?? [], triggers: declaration.triggers ?? [], issueTemplate: declaration.issueTemplate ?? null, @@ -370,6 +372,8 @@ export function pluginManagedRoutineService( status: declaration.status ?? (refs.assigneeAgentId ? "active" : "paused"), concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active", catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed", + activityGatePolicy: declaration.activityGatePolicy ?? "always", + activityGateScope: declaration.activityGateScope ?? "company", variables: declaration.variables ?? [], }, { agentId: null, userId: null }); await upsertBinding(companyId, declaration, created.id); @@ -430,6 +434,8 @@ export function pluginManagedRoutineService( status: declaration.status ?? (refs.assigneeAgentId ? "active" : "paused"), concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active", catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed", + activityGatePolicy: declaration.activityGatePolicy ?? "always", + activityGateScope: declaration.activityGateScope ?? "company", variables: declaration.variables ?? [], }, { agentId: null, userId: null }); if (!updated) throw notFound("Managed routine not found"); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 6152ba10cc..330d96053e 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -52,6 +52,7 @@ import { interpolateRoutineTemplate, isValidRoutineDateString, pluginOperationIssueOriginKind, + routineRevisionSnapshotSchema, stringifyRoutineVariableValue, syncRoutineVariablesWithTemplate, } from "@paperclipai/shared"; @@ -522,6 +523,8 @@ function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSna status: routine.status as RoutineRevisionSnapshotV1["routine"]["status"], concurrencyPolicy: routine.concurrencyPolicy as RoutineRevisionSnapshotV1["routine"]["concurrencyPolicy"], catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"], + activityGatePolicy: routine.activityGatePolicy as RoutineRevisionSnapshotV1["routine"]["activityGatePolicy"], + activityGateScope: routine.activityGateScope as RoutineRevisionSnapshotV1["routine"]["activityGateScope"], variables: routine.variables ?? [], env: routine.env ?? null, responsibleUserId: routine.responsibleUserId ?? null, @@ -2115,6 +2118,8 @@ export function routineService( status, concurrencyPolicy: input.concurrencyPolicy, catchUpPolicy: input.catchUpPolicy, + activityGatePolicy: input.activityGatePolicy ?? "always", + activityGateScope: input.activityGateScope ?? "company", variables, env, responsibleUserId, @@ -2228,6 +2233,8 @@ export function routineService( status: nextStatus, concurrencyPolicy: patch.concurrencyPolicy ?? locked.concurrencyPolicy, catchUpPolicy: patch.catchUpPolicy ?? locked.catchUpPolicy, + activityGatePolicy: patch.activityGatePolicy ?? locked.activityGatePolicy, + activityGateScope: patch.activityGateScope ?? locked.activityGateScope, variables: nextVariables, env: nextEnv, responsibleUserId: locked.responsibleUserId ?? responsibleUserId, @@ -2291,6 +2298,8 @@ export function routineService( status: candidate.status, concurrencyPolicy: candidate.concurrencyPolicy, catchUpPolicy: candidate.catchUpPolicy, + activityGatePolicy: candidate.activityGatePolicy, + activityGateScope: candidate.activityGateScope, variables: candidate.variables, env: candidate.env, responsibleUserId: candidate.responsibleUserId, @@ -2572,7 +2581,7 @@ export function routineService( .then((rows) => rows[0] ?? null); if (!targetRevision) throw notFound("Routine revision not found"); - const snapshot = targetRevision.snapshot as RoutineRevisionSnapshotV1; + const snapshot = routineRevisionSnapshotSchema.parse(targetRevision.snapshot) as RoutineRevisionSnapshotV1; const routineSnapshot = snapshot.routine; await assertRestorableAssignee(existingRoutine.companyId, routineSnapshot.assigneeAgentId, actor); @@ -2627,6 +2636,8 @@ export function routineService( status: routineSnapshot.status, concurrencyPolicy: routineSnapshot.concurrencyPolicy, catchUpPolicy: routineSnapshot.catchUpPolicy, + activityGatePolicy: routineSnapshot.activityGatePolicy, + activityGateScope: routineSnapshot.activityGateScope, variables: routineSnapshot.variables, env: routineSnapshot.env, updatedByAgentId: actor.agentId ?? null, diff --git a/skills/paperclip/references/routines.md b/skills/paperclip/references/routines.md index 1d1987fbee..253999aa74 100644 --- a/skills/paperclip/references/routines.md +++ b/skills/paperclip/references/routines.md @@ -7,6 +7,7 @@ A routine has: - One or more triggers (`schedule`, `webhook`, or `api`) - A concurrency policy (what to do when a previous run is still active) - A catch-up policy (what to do with missed scheduled runs) +- An activity gate policy (whether quiet scheduled ticks should be skipped) **Authorization:** Agents can read all routines in their company but can only create or manage routines assigned to themselves. Board operators have full access, including reassignment. @@ -37,7 +38,9 @@ POST /api/companies/{companyId}/routines "priority": "medium", "status": "active", "concurrencyPolicy": "coalesce_if_active", - "catchUpPolicy": "skip_missed" + "catchUpPolicy": "skip_missed", + "activityGatePolicy": "always", + "activityGateScope": "company" } ``` @@ -53,6 +56,8 @@ POST /api/companies/{companyId}/routines | `status` | no | `active` (default) `paused` `archived` | | `concurrencyPolicy` | no | See below | | `catchUpPolicy` | no | See below | +| `activityGatePolicy` | no | `always` (default) or `require_external_activity`; see below | +| `activityGateScope` | no | `company` (default) or `project`; see below | --- @@ -79,6 +84,45 @@ Controls what happens with scheduled runs that were missed, for example during s --- +## Activity-Gated Scheduled Runs + +`activityGatePolicy` controls whether a **schedule trigger** runs when the system has been quiet. It does not gate manual, API, or webhook runs. + +| Policy | Behaviour | +|--------|-----------| +| `always` **(default)** | Run on every scheduled tick | +| `require_external_activity` | Run only when qualifying activity occurred after this routine's last dispatched, non-skipped run | + +`activityGateScope` selects where qualifying activity is checked: + +| Scope | Behaviour | +|-------|-----------| +| `company` **(default)** | Activity anywhere in the routine's company can wake it | +| `project` | Only activity attributed to the routine's project can wake it | + +The activity window starts at the `triggeredAt` time of the last dispatched run. A routine that has never dispatched always runs once. Runs skipped for quiet activity do not advance the window, so one later qualifying event still wakes the next scheduled tick. + +The gate excludes activity generated by the routine's own dispatched run issues, scheduler bookkeeping for that routine, and pure-read actions such as issue read/unread changes and inbox archive/unarchive actions. Work performed by other agents on tasks the routine delegated is external activity and wakes the routine on its next tick. + +### Example: skip quiet nights + +This hourly watcher runs after company activity, follows up while delegated work continues, and stops consuming runs once the company settles overnight: + +```json +{ + "title": "Hourly work watcher", + "description": "Review recent work and follow up on delegated tasks", + "assigneeAgentId": "{agentId}", + "projectId": "{projectId}", + "activityGatePolicy": "require_external_activity", + "activityGateScope": "company" +} +``` + +Add a schedule trigger with `cronExpression: "0 * * * *"`. The first tick runs. Later ticks run only after qualifying company activity since the last dispatched run; quiet skipped ticks keep the original activity window open. + +--- + ## Adding Triggers A routine can have multiple triggers of different kinds. diff --git a/ui/src/components/RoutineHistoryTab.test.tsx b/ui/src/components/RoutineHistoryTab.test.tsx index f5f83c8806..cde960d13d 100644 --- a/ui/src/components/RoutineHistoryTab.test.tsx +++ b/ui/src/components/RoutineHistoryTab.test.tsx @@ -97,6 +97,8 @@ function snapshotV1(overrides?: Partial): status: "active", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", + activityGatePolicy: "always", + activityGateScope: "company", variables: [], env: null, ...overrides, @@ -139,6 +141,8 @@ function createRoutine(overrides: Partial = {}): Routine { status: "active", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", + activityGatePolicy: "always", + activityGateScope: "company", variables: [], latestRevisionId: "revision-2", latestRevisionNumber: 2, diff --git a/ui/src/lib/workspace-routines.test.ts b/ui/src/lib/workspace-routines.test.ts index 40f647d3f9..e5ef480fd9 100644 --- a/ui/src/lib/workspace-routines.test.ts +++ b/ui/src/lib/workspace-routines.test.ts @@ -21,6 +21,8 @@ function createRoutine(overrides: Partial = {}): RoutineListIte status: "active", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", + activityGatePolicy: "always", + activityGateScope: "company", variables: [], latestRevisionId: null, latestRevisionNumber: 1, diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index ee70969230..03ad7dfd1c 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -299,6 +299,8 @@ function createRoutine(overrides: Partial): RoutineListItem { status: "active", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", + activityGatePolicy: "always", + activityGateScope: "company", variables: [], latestRevisionId: null, latestRevisionNumber: 1, diff --git a/ui/storybook/stories/routine-detail-c.stories.tsx b/ui/storybook/stories/routine-detail-c.stories.tsx index 959dd42dc3..c246bdd45e 100644 --- a/ui/storybook/stories/routine-detail-c.stories.tsx +++ b/ui/storybook/stories/routine-detail-c.stories.tsx @@ -87,6 +87,8 @@ const routine: RoutineDetailType = { status: "active", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", + activityGatePolicy: "always", + activityGateScope: "company", variables, env: { DATABASE_URL: { type: "secret_ref", secretId: "secret-prod-db", version: "latest" } } as never, latestRevisionId: "rev-17", diff --git a/ui/storybook/stories/routine-secrets.stories.tsx b/ui/storybook/stories/routine-secrets.stories.tsx index 6aabca5358..42bf771b2b 100644 --- a/ui/storybook/stories/routine-secrets.stories.tsx +++ b/ui/storybook/stories/routine-secrets.stories.tsx @@ -157,6 +157,8 @@ function makeSnapshot(env: RoutineEnvConfig | null): RoutineRevisionSnapshotV1 { status: "active", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", + activityGatePolicy: "always", + activityGateScope: "company", variables: [], env, }, @@ -179,6 +181,8 @@ function makeRoutine(latestRevisionId: string, latestRevisionNumber: number): Ro status: "active", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", + activityGatePolicy: "always", + activityGateScope: "company", variables: [], env: makeSnapshot({ OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" },