mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## 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>
184 lines
7.5 KiB
TypeScript
184 lines
7.5 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
ISSUE_PRIORITIES,
|
|
ROUTINE_ACTIVITY_GATE_POLICIES,
|
|
ROUTINE_ACTIVITY_GATE_SCOPES,
|
|
ROUTINE_CATCH_UP_POLICIES,
|
|
ROUTINE_CONCURRENCY_POLICIES,
|
|
ROUTINE_STATUSES,
|
|
ROUTINE_TRIGGER_KINDS,
|
|
ROUTINE_TRIGGER_SIGNING_MODES,
|
|
ROUTINE_VARIABLE_TYPES,
|
|
} from "../constants.js";
|
|
import {
|
|
ISSUE_EXECUTION_WORKSPACE_PREFERENCES,
|
|
issueExecutionWorkspaceSettingsSchema,
|
|
} from "./issue.js";
|
|
import { envConfigSchema } from "./secret.js";
|
|
import { isValidRoutineDateString } from "../routine-variables.js";
|
|
|
|
const routineVariableValueSchema = z.union([z.string(), z.number().finite(), z.boolean()]);
|
|
|
|
export const routineVariableSchema = z.object({
|
|
name: z.string().trim().regex(/^[A-Za-z][A-Za-z0-9_]*$/),
|
|
label: z.string().trim().max(120).optional().nullable(),
|
|
type: z.enum(ROUTINE_VARIABLE_TYPES).optional().default("text"),
|
|
defaultValue: routineVariableValueSchema.optional().nullable(),
|
|
required: z.boolean().optional().default(true),
|
|
options: z.array(z.string().trim().min(1).max(120)).max(50).optional().default([]),
|
|
}).superRefine((value, ctx) => {
|
|
if (value.type === "select" && value.options.length === 0) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["options"],
|
|
message: "Select variables require at least one option",
|
|
});
|
|
}
|
|
if (value.type !== "select" && value.options.length > 0) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["options"],
|
|
message: "Only select variables can define options",
|
|
});
|
|
}
|
|
if (value.type === "select" && value.defaultValue != null) {
|
|
if (typeof value.defaultValue !== "string" || !value.options.includes(value.defaultValue)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["defaultValue"],
|
|
message: "Select variable defaults must match one of the allowed options",
|
|
});
|
|
}
|
|
}
|
|
if (value.type === "date" && value.defaultValue != null) {
|
|
if (typeof value.defaultValue !== "string" || !isValidRoutineDateString(value.defaultValue)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["defaultValue"],
|
|
message: "Date variable defaults must be valid YYYY-MM-DD calendar dates",
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
export const createRoutineSchema = z.object({
|
|
projectId: z.string().uuid().optional().nullable(),
|
|
folderId: z.string().uuid().optional().nullable(),
|
|
goalId: z.string().uuid().optional().nullable(),
|
|
parentIssueId: z.string().uuid().optional().nullable(),
|
|
title: z.string().trim().min(1).max(200),
|
|
description: z.string().optional().nullable(),
|
|
assigneeAgentId: z.string().uuid().optional().nullable(),
|
|
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
|
|
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(),
|
|
});
|
|
|
|
export type CreateRoutine = z.infer<typeof createRoutineSchema>;
|
|
|
|
export const updateRoutineSchema = createRoutineSchema.partial().extend({
|
|
baseRevisionId: z.string().uuid().optional().nullable(),
|
|
});
|
|
export type UpdateRoutine = z.infer<typeof updateRoutineSchema>;
|
|
|
|
export const routineRevisionSnapshotRoutineV1Schema = z.object({
|
|
id: z.string().uuid(),
|
|
companyId: z.string().uuid(),
|
|
projectId: z.string().uuid().nullable(),
|
|
folderId: z.string().uuid().nullable().optional(),
|
|
goalId: z.string().uuid().nullable(),
|
|
parentIssueId: z.string().uuid().nullable(),
|
|
title: z.string().trim().min(1).max(200),
|
|
description: z.string().nullable(),
|
|
assigneeAgentId: z.string().uuid().nullable(),
|
|
priority: z.enum(ISSUE_PRIORITIES),
|
|
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),
|
|
}).strict();
|
|
|
|
export const routineRevisionSnapshotTriggerV1Schema = z.object({
|
|
id: z.string().uuid(),
|
|
kind: z.enum(ROUTINE_TRIGGER_KINDS),
|
|
label: z.string().nullable(),
|
|
enabled: z.boolean(),
|
|
cronExpression: z.string().nullable(),
|
|
timezone: z.string().nullable(),
|
|
publicId: z.string().nullable(),
|
|
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).nullable(),
|
|
replayWindowSec: z.number().int().min(30).max(86_400).nullable(),
|
|
}).strict();
|
|
|
|
export const routineRevisionSnapshotV1Schema = z.object({
|
|
version: z.literal(1),
|
|
routine: routineRevisionSnapshotRoutineV1Schema,
|
|
triggers: z.array(routineRevisionSnapshotTriggerV1Schema),
|
|
}).strict();
|
|
|
|
export const routineRevisionSnapshotSchema = routineRevisionSnapshotV1Schema;
|
|
export type RoutineRevisionSnapshotV1 = z.infer<typeof routineRevisionSnapshotV1Schema>;
|
|
export type RoutineRevisionSnapshot = z.infer<typeof routineRevisionSnapshotSchema>;
|
|
|
|
const baseTriggerSchema = z.object({
|
|
label: z.string().trim().max(120).optional().nullable(),
|
|
enabled: z.boolean().optional().default(true),
|
|
});
|
|
|
|
export const createRoutineTriggerSchema = z.discriminatedUnion("kind", [
|
|
baseTriggerSchema.extend({
|
|
kind: z.literal("schedule"),
|
|
cronExpression: z.string().trim().min(1),
|
|
timezone: z.string().trim().min(1).default("UTC"),
|
|
}),
|
|
baseTriggerSchema.extend({
|
|
kind: z.literal("webhook"),
|
|
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().default("bearer"),
|
|
replayWindowSec: z.number().int().min(30).max(86_400).optional().default(300),
|
|
}),
|
|
baseTriggerSchema.extend({
|
|
kind: z.literal("api"),
|
|
}),
|
|
]);
|
|
|
|
export type CreateRoutineTrigger = z.infer<typeof createRoutineTriggerSchema>;
|
|
|
|
export const updateRoutineTriggerSchema = z.object({
|
|
label: z.string().trim().max(120).optional().nullable(),
|
|
enabled: z.boolean().optional(),
|
|
cronExpression: z.string().trim().min(1).optional().nullable(),
|
|
timezone: z.string().trim().min(1).optional().nullable(),
|
|
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().nullable(),
|
|
replayWindowSec: z.number().int().min(30).max(86_400).optional().nullable(),
|
|
});
|
|
|
|
export type UpdateRoutineTrigger = z.infer<typeof updateRoutineTriggerSchema>;
|
|
|
|
export const runRoutineSchema = z.object({
|
|
triggerId: z.string().uuid().optional().nullable(),
|
|
payload: z.record(z.string(), z.unknown()).optional().nullable(),
|
|
variables: z.record(z.string(), routineVariableValueSchema).optional().nullable(),
|
|
projectId: z.string().uuid().optional().nullable(),
|
|
projectWorkspaceId: z.string().uuid().optional().nullable(),
|
|
assigneeAgentId: z.string().uuid().optional().nullable(),
|
|
idempotencyKey: z.string().trim().max(255).optional().nullable(),
|
|
source: z.enum(["manual", "api"]).optional().default("manual"),
|
|
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
|
executionWorkspacePreference: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional().nullable(),
|
|
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
|
|
});
|
|
|
|
export type RunRoutine = z.infer<typeof runRoutineSchema>;
|
|
|
|
export const rotateRoutineTriggerSecretSchema = z.object({});
|
|
export type RotateRoutineTriggerSecret = z.infer<typeof rotateRoutineTriggerSecretSchema>;
|