diff --git a/ui/src/components/routine-sections/context.tsx b/ui/src/components/routine-sections/context.tsx index 332b8437b0..6920ea45a6 100644 --- a/ui/src/components/routine-sections/context.tsx +++ b/ui/src/components/routine-sections/context.tsx @@ -45,7 +45,7 @@ export const SECTION_FIELD_KEYS: Record = { 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; }; diff --git a/ui/src/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx index d00d6742a3..49e79c2522 100644 --- a/ui/src/components/routine-sections/editable-sections.tsx +++ b/ui/src/components/routine-sections/editable-sections.tsx @@ -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 = { @@ -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 (
@@ -692,6 +726,38 @@ export function DeliverySection() { options={catchUpPolicyOptions} />
+
+

+ Advanced run policy +

+ + setEditDraft((current) => ({ ...current, activityGatePolicy })) + } + options={activityGatePolicyOptions} + disabled={!hasScheduleTrigger} + /> + {!hasScheduleTrigger ? ( +

+ Add a schedule trigger to gate runs on activity. Webhook, manual, and API fires always + run. +

+ ) : gateEnabled ? ( +
+ + + setEditDraft((current) => ({ ...current, activityGateScope })) + } + options={activityGateScopeOptions} + /> +
+ ) : null} +
{ 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", () => { diff --git a/ui/src/lib/routine-run-display.ts b/ui/src/lib/routine-run-display.ts index 188d1ceffb..e05b0da007 100644 --- a/ui/src/lib/routine-run-display.ts +++ b/ui/src/lib/routine-run-display.ts @@ -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 = { + 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[] = []; diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 6460fb4fde..e5216f0dbc 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -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, }); diff --git a/ui/storybook/stories/routine-detail-c.stories.tsx b/ui/storybook/stories/routine-detail-c.stories.tsx index c246bdd45e..d85a6924b6 100644 --- a/ui/storybook/stories/routine-detail-c.stories.tsx +++ b/ui/storybook/stories/routine-detail-c.stories.tsx @@ -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, };