feat(routines): activity-gated advanced run policy (editor + run rows) (#10225)
## 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 activity gate (`activity_gate_policy` / `activity_gate_scope`) lets a scheduled routine skip a tick when nothing has happened since its last run, so watcher-style routines stay asleep while the system is settled instead of burning tokens every tick. > - The scheduler, database columns, and the create/update API for those fields already landed (see #9438), but there was no way for an operator to actually set the policy from the routine editor, and the runs list rendered gated skips as bare "skipped" rows with no "why". > - This pull request adds the editor control and the run-row labels: the Delivery section gets an "Advanced run policy" picker plus a scope selector, and skipped runs explain why they were skipped. > - The benefit is that the activity gate becomes discoverable and usable end-to-end from the UI, closing the loop on the feature the API already supports. ## Linked Issues or Issue Description - Refs #8534 — activity gate for scheduled routines. - Builds on #9438 (merged) which exposed the activity-gate create/update API and the `Routine` response fields this UI reads and writes. This PR is the editor/UI companion to that API work. ## What Changed - **Routine editor — Advanced run policy control** (`ui/src/components/routine-sections/editable-sections.tsx`): the Delivery section gains a `RadioCardGroup` to choose between *Run on every scheduled tick* (default) and *Skip when there's been no activity since the last run*. When gating is enabled, a second scope picker (*Company-wide* / *This project*) appears. The control is **disabled with an explanatory hint** — rather than hidden — when the routine has no schedule trigger, since the gate only affects scheduled ticks (webhook/manual/API fires are themselves activity and always run). This keeps the capability discoverable. - **Edit-draft plumbing** (`ui/src/components/routine-sections/context.tsx`, `ui/src/pages/RoutineDetail.tsx`): `activityGatePolicy` / `activityGateScope` flow through the edit draft, the delivery section's dirty-field detection, the save payload, and revision restore, matching how `concurrencyPolicy` / `catchUpPolicy` are handled. - **Run-history skip reasons** (`ui/src/lib/routine-run-display.ts`): skipped run rows now render a human-readable "why" — `no_external_activity` → "Skipped — no activity since last run", plus labels for `paused` and `worktree_execution_cutoff` — instead of a bare status. - Storybook fixture updates for the new routine fields, plus focused run-display coverage for the skipped-run labels. ## Verification - `pnpm exec vitest run ui/src/lib/routine-run-display.test.ts` — 11 tests passing. - `pnpm --filter @paperclipai/ui typecheck` — clean. - `pnpm check:token-gates` — all token gates clean. - `pnpm --filter @paperclipai/ui build` — production build succeeds. - Current-head GitHub CI for `9f9af8ecccfadcdc4a3afab313aafad81cbd1112` — all checks pass (Storybook visual check skipped by workflow policy). ## Risks - **Low risk.** UI-only change; no schema/migration and no server changes (the API and columns already shipped in #9438). Fields are optional and default to the pre-feature behavior (`always` / `company`), so existing routines are unaffected. The scope picker only renders when gating is turned on, and the whole control is inert without a schedule trigger. ## Model Used - Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended thinking, with tool use (repo edit + shell verification). ## 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
f6ab82d490
commit
ab2bdfeebb
|
|
@ -45,7 +45,7 @@ export const SECTION_FIELD_KEYS: Record<string, string[]> = {
|
|||
overview: ["title", "description", "projectId", "assigneeAgentId", "priority"],
|
||||
variables: ["variables"],
|
||||
secrets: ["env"],
|
||||
delivery: ["concurrencyPolicy", "catchUpPolicy"],
|
||||
delivery: ["concurrencyPolicy", "catchUpPolicy", "activityGatePolicy", "activityGateScope"],
|
||||
};
|
||||
|
||||
export type RoutineEditDraft = {
|
||||
|
|
@ -56,6 +56,8 @@ export type RoutineEditDraft = {
|
|||
priority: string;
|
||||
concurrencyPolicy: string;
|
||||
catchUpPolicy: string;
|
||||
activityGatePolicy: string;
|
||||
activityGateScope: string;
|
||||
variables: RoutineVariable[];
|
||||
env: RoutineEnvConfig | null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -68,6 +68,33 @@ const catchUpPolicyOptions = [
|
|||
},
|
||||
];
|
||||
|
||||
const activityGatePolicyOptions = [
|
||||
{
|
||||
value: "always",
|
||||
title: "Run on every scheduled tick",
|
||||
description: "Fire on the schedule no matter what — the default behavior.",
|
||||
},
|
||||
{
|
||||
value: "require_external_activity",
|
||||
title: "Skip when there's been no activity since the last run",
|
||||
description:
|
||||
"On a scheduled tick, only run if something happened since the last run that finished. Lets a watcher-style routine stay asleep while the system is settled instead of burning tokens.",
|
||||
},
|
||||
];
|
||||
|
||||
const activityGateScopeOptions = [
|
||||
{
|
||||
value: "company",
|
||||
title: "Company-wide",
|
||||
description: "Any activity across the company counts as a reason to run.",
|
||||
},
|
||||
{
|
||||
value: "project",
|
||||
title: "This project",
|
||||
description: "Only activity in the routine's project counts as a reason to run.",
|
||||
},
|
||||
];
|
||||
|
||||
const triggerKinds = ["schedule", "webhook"];
|
||||
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
|
||||
const signingModeDescriptions: Record<string, string> = {
|
||||
|
|
@ -664,6 +691,13 @@ export function DeliverySection() {
|
|||
const ctx = useRoutineDetail();
|
||||
const { editDraft, setEditDraft, routine } = ctx;
|
||||
|
||||
// The activity gate only affects schedule ticks (webhook/manual/API fires are
|
||||
// themselves activity and always run), so the control is only meaningful for
|
||||
// routines that have a schedule trigger. Disable — rather than hide — it
|
||||
// elsewhere so the capability stays discoverable.
|
||||
const hasScheduleTrigger = routine.triggers.some((trigger) => trigger.kind === "schedule");
|
||||
const gateEnabled = editDraft.activityGatePolicy === "require_external_activity";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
|
|
@ -692,6 +726,38 @@ export function DeliverySection() {
|
|||
options={catchUpPolicyOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
|
||||
Advanced run policy
|
||||
</p>
|
||||
<RadioCardGroup
|
||||
ariaLabel="Advanced run policy"
|
||||
value={editDraft.activityGatePolicy}
|
||||
onValueChange={(activityGatePolicy) =>
|
||||
setEditDraft((current) => ({ ...current, activityGatePolicy }))
|
||||
}
|
||||
options={activityGatePolicyOptions}
|
||||
disabled={!hasScheduleTrigger}
|
||||
/>
|
||||
{!hasScheduleTrigger ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add a schedule trigger to gate runs on activity. Webhook, manual, and API fires always
|
||||
run.
|
||||
</p>
|
||||
) : gateEnabled ? (
|
||||
<div className="space-y-2 rounded-lg border border-border p-3">
|
||||
<Label className="text-xs font-medium">Activity scope</Label>
|
||||
<RadioCardGroup
|
||||
ariaLabel="Activity gate scope"
|
||||
value={editDraft.activityGateScope}
|
||||
onValueChange={(activityGateScope) =>
|
||||
setEditDraft((current) => ({ ...current, activityGateScope }))
|
||||
}
|
||||
options={activityGateScopeOptions}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<NextFiresPreview
|
||||
triggers={routine.triggers}
|
||||
concurrencyPolicy={editDraft.concurrencyPolicy}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,28 @@ describe("runRowSubtitle", () => {
|
|||
runRowSubtitle({ status: "succeeded", failureReason: null, triggerPayload: null }, variables),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("labels an activity-gated skip", () => {
|
||||
const subtitle = runRowSubtitle(
|
||||
{ status: "skipped", failureReason: "no_external_activity", triggerPayload: null },
|
||||
variables,
|
||||
);
|
||||
expect(subtitle).toBe("Skipped — no activity since last run");
|
||||
});
|
||||
|
||||
it("labels other known skip reasons", () => {
|
||||
expect(
|
||||
runRowSubtitle({ status: "skipped", failureReason: "paused", triggerPayload: null }, variables),
|
||||
).toBe("Skipped — routine paused");
|
||||
});
|
||||
|
||||
it("falls back to variable values for a skip with no known reason", () => {
|
||||
const subtitle = runRowSubtitle(
|
||||
{ status: "skipped", failureReason: null, triggerPayload: { customer: "Acme" } },
|
||||
variables,
|
||||
);
|
||||
expect(subtitle).toBe('customer="Acme"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("dedupedTriggerLabel", () => {
|
||||
|
|
|
|||
|
|
@ -30,9 +30,22 @@ export function dedupedTriggerLabel(
|
|||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable labels for the reasons a scheduled run was skipped rather than
|
||||
* dispatched. `failureReason` on a skipped run carries the machine reason; these
|
||||
* turn it into a one-line "why" for the runs list.
|
||||
*/
|
||||
const SKIP_REASON_LABELS: Record<string, string> = {
|
||||
no_external_activity: "Skipped — no activity since last run",
|
||||
paused: "Skipped — routine paused",
|
||||
worktree_execution_cutoff: "Skipped — worktree execution cutoff",
|
||||
};
|
||||
|
||||
/**
|
||||
* Subtitle line for a run row (§3.6):
|
||||
* - failed runs show the failure reason ("why" without clicking through);
|
||||
* - skipped runs show why the scheduled tick didn't dispatch (e.g. the activity
|
||||
* gate found the system settled);
|
||||
* - other runs show the inline resolved variable values (e.g. `customer="Acme"`).
|
||||
* Returns an empty string when there is nothing meaningful to show.
|
||||
*/
|
||||
|
|
@ -43,6 +56,10 @@ export function runRowSubtitle(
|
|||
if (run.status === "failed") {
|
||||
return run.failureReason?.trim() || "Run failed";
|
||||
}
|
||||
if (run.status === "skipped") {
|
||||
const reason = run.failureReason?.trim();
|
||||
if (reason && SKIP_REASON_LABELS[reason]) return SKIP_REASON_LABELS[reason];
|
||||
}
|
||||
const payload = run.triggerPayload;
|
||||
if (!payload || typeof payload !== "object") return "";
|
||||
const parts: string[] = [];
|
||||
|
|
|
|||
|
|
@ -176,6 +176,8 @@ export function RoutineDetail() {
|
|||
priority: "medium",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
activityGatePolicy: "always",
|
||||
activityGateScope: "company",
|
||||
variables: [],
|
||||
env: null,
|
||||
});
|
||||
|
|
@ -268,6 +270,8 @@ export function RoutineDetail() {
|
|||
priority: routine.priority,
|
||||
concurrencyPolicy: routine.concurrencyPolicy,
|
||||
catchUpPolicy: routine.catchUpPolicy,
|
||||
activityGatePolicy: routine.activityGatePolicy,
|
||||
activityGateScope: routine.activityGateScope,
|
||||
variables: routine.variables,
|
||||
env: routine.env ?? null,
|
||||
}
|
||||
|
|
@ -296,6 +300,12 @@ export function RoutineDetail() {
|
|||
if (editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy) {
|
||||
result.push({ key: "catchUpPolicy", label: "the catch-up policy" });
|
||||
}
|
||||
if (editDraft.activityGatePolicy !== routineDefaults.activityGatePolicy) {
|
||||
result.push({ key: "activityGatePolicy", label: "the advanced run policy" });
|
||||
}
|
||||
if (editDraft.activityGateScope !== routineDefaults.activityGateScope) {
|
||||
result.push({ key: "activityGateScope", label: "the activity gate scope" });
|
||||
}
|
||||
if (JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)) {
|
||||
result.push({ key: "variables", label: "the variables" });
|
||||
}
|
||||
|
|
@ -652,6 +662,8 @@ export function RoutineDetail() {
|
|||
priority: response.routine.priority,
|
||||
concurrencyPolicy: response.routine.concurrencyPolicy,
|
||||
catchUpPolicy: response.routine.catchUpPolicy,
|
||||
activityGatePolicy: response.routine.activityGatePolicy,
|
||||
activityGateScope: response.routine.activityGateScope,
|
||||
variables: response.routine.variables as RoutineVariable[],
|
||||
env: (response.routine.env ?? null) as RoutineEnvConfig | null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -252,6 +252,8 @@ function makeContext(
|
|||
priority: routineDetail.priority,
|
||||
concurrencyPolicy: routineDetail.concurrencyPolicy,
|
||||
catchUpPolicy: routineDetail.catchUpPolicy,
|
||||
activityGatePolicy: "always",
|
||||
activityGateScope: "company",
|
||||
variables: routineDetail.variables,
|
||||
env: routineDetail.env ?? null,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue