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:
Dotta 2026-07-24 16:47:24 -05:00 committed by GitHub
parent 8f08ec5ce6
commit 30ff3d7c58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 252 additions and 3 deletions

View File

@ -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,

View File

@ -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({

View File

@ -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];

View File

@ -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. */

View File

@ -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[];

View File

@ -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", () => {

View File

@ -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),

View File

@ -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",

View File

@ -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),

View File

@ -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",

View File

@ -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({

View File

@ -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();

View File

@ -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 ?? [],

View File

@ -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");

View File

@ -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,

View File

@ -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.

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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",

View File

@ -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" },