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 <noreply@paperclip.ing>
This commit is contained in:
parent
8f08ec5ce6
commit
30ff3d7c58
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> };
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ function snapshotV1(overrides?: Partial<RoutineRevisionSnapshotV1["routine"]>):
|
|||
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> = {}): Routine {
|
|||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
activityGatePolicy: "always",
|
||||
activityGateScope: "company",
|
||||
variables: [],
|
||||
latestRevisionId: "revision-2",
|
||||
latestRevisionNumber: 2,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ function createRoutine(overrides: Partial<RoutineListItem> = {}): RoutineListIte
|
|||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
activityGatePolicy: "always",
|
||||
activityGateScope: "company",
|
||||
variables: [],
|
||||
latestRevisionId: null,
|
||||
latestRevisionNumber: 1,
|
||||
|
|
|
|||
|
|
@ -299,6 +299,8 @@ function createRoutine(overrides: Partial<RoutineListItem>): RoutineListItem {
|
|||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
activityGatePolicy: "always",
|
||||
activityGateScope: "company",
|
||||
variables: [],
|
||||
latestRevisionId: null,
|
||||
latestRevisionNumber: 1,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue