From 34c4031ec7129f621f4b55fea71a3eca0f79b6a3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 15:48:38 -0700 Subject: [PATCH 1/4] Add tests for the routines ops list and detail page Covers the surfacing this ticket is about, all of it against telemetry the scheduler already records: - `routineScheduleSentence` / `cronSentence`: every trigger shape reads as a sentence, including a raw cron expression, and an expression that cannot be described says so instead of printing itself. - `routineHealth`: off / paused / running / failing / idle / healthy, the clean streak, the median fire duration, and the last failure. - The wire view surfaces `nextFireAt` / `lastFireAt`, so a UI reads the scheduler's own clock instead of re-deriving one. - The Routines list's ops columns, the name linking to the routine's own page, and an old `/routines/` deep link landing on that page. - `/routines/`: schedule sentence with the raw cron editable behind it, target workflow with a steps link (absent, not dead, until there is a run), run history deep-linking each trace, the health rail, and Run now / Pause / Resume in the top bar's action slot. Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti --- apps/web/src/insights-stats.test.ts | 2 + apps/web/test/routine-detail-page.test.tsx | 339 +++++++++++ apps/web/test/routine-panel.test.tsx | 2 + apps/web/test/routines-page.test.tsx | 547 ++++++++---------- packages/routines/src/client.test.ts | 16 + packages/routines/src/health.test.ts | 176 ++++++ .../routines/src/schedule-language.test.ts | 78 +++ packages/routines/test/routes.test.ts | 28 + 8 files changed, 898 insertions(+), 290 deletions(-) create mode 100644 apps/web/test/routine-detail-page.test.tsx create mode 100644 packages/routines/src/health.test.ts create mode 100644 packages/routines/src/schedule-language.test.ts diff --git a/apps/web/src/insights-stats.test.ts b/apps/web/src/insights-stats.test.ts index 1aabe3d23..9e8d6bcac 100644 --- a/apps/web/src/insights-stats.test.ts +++ b/apps/web/src/insights-stats.test.ts @@ -59,6 +59,8 @@ function routine( deliveryWorkbenchId: null, consecutiveFailures: 0, deadLetteredAt: null, + nextFireAt: null, + lastFireAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...partial, diff --git a/apps/web/test/routine-detail-page.test.tsx b/apps/web/test/routine-detail-page.test.tsx new file mode 100644 index 000000000..ccdeb063b --- /dev/null +++ b/apps/web/test/routine-detail-page.test.tsx @@ -0,0 +1,339 @@ +// `/routines/` (CL-6418): the routine's own page — schedule as a +// sentence with the raw cron editable behind it, the target workflow with +// a way through to its steps, the whole fire history deep-linking into the +// run surface, and a health rail off telemetry the scheduler already +// records. Lifecycle actions (Run now, Pause/Resume) sit in the top bar's +// action slot and are wired to the routines package's existing mutations — +// never a button that does nothing. + +import { describe, expect, test } from "bun:test"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { NavigationProvider } from "../src/navigation"; +import { + RoutineDetailPage, + RoutineScheduleSection, +} from "../src/pages/routine-detail-page"; +import type { GlobalRoutineRow } from "../src/global-routines"; +import type { Routine, RoutineRun } from "../src/routines-api"; + +const noop = () => undefined; +const NOW = Date.parse("2026-01-02T00:00:00.000Z"); + +const routine: Routine = { + id: "rtn_1", + name: "Morning brief", + definitionId: "wfd_1", + trigger: { kind: "daily", hour: 9, minute: 0 }, + scope: "bench", + input: {}, + enabled: true, + deliveryWorkbenchId: "ch_1", + consecutiveFailures: 0, + deadLetteredAt: null, + nextFireAt: "2026-01-02T09:00:00.000Z", + lastFireAt: "2026-01-01T09:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +const completedRun: RoutineRun = { + runId: "run_1", + triggeredBy: "schedule", + createdAt: "2026-01-01T09:00:00.000Z", + run: { + status: "completed", + createdAt: "2026-01-01T09:00:00.000Z", + endedAt: "2026-01-01T09:00:30.000Z", + }, +}; + +const failedFire: RoutineRun = { + runId: "run_0", + triggeredBy: "schedule-failed", + createdAt: "2025-12-31T09:00:00.000Z", + error: "sidecar unreachable", +}; + +function row(overrides: Partial = {}): GlobalRoutineRow { + return { + routine, + tenantId: "tnt_1", + tenantName: "Acme Team", + deliveryWorkbenchName: "Ops", + runs: [completedRun], + ...overrides, + }; +} + +const pageProps = { + now: NOW, + workflowName: "Daily digest", + onRunNow: () => Promise.resolve(), + onToggleEnabled: (_enabled: boolean) => {}, + onSaveSchedule: (_expression: string) => Promise.resolve(), +}; + +function renderPage(overrides: Partial = {}): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("RoutineDetailPage", () => { + test("titles itself with a trail back to the roster", () => { + const markup = renderPage(); + expect(markup).toContain('href="/routines"'); + expect(markup).toContain("Morning brief"); + expect(markup).toContain("Acme Team"); + }); + + test("the schedule reads as a sentence with the raw cron editable behind it", () => { + const markup = renderPage(); + expect(markup).toContain("At 09:00 (UTC)"); + expect(markup).toContain("Cron expression"); + expect(markup).toContain('value="0 9 * * *"'); + }); + + test("shows the target workflow, never an agent it runs as", () => { + const markup = renderPage(); + expect(markup).toContain("Runs this workflow"); + expect(markup).toContain("Daily digest"); + expect(markup).not.toContain("Runs as"); + }); + + test("View steps links into the run surface for the latest real run", () => { + expect(renderPage()).toContain('href="/insights/runs/run_1"'); + }); + + test("with no runs there is no steps link at all, rather than a dead one", () => { + const markup = renderPage({ runs: [] }); + expect(markup).not.toContain("View steps"); + expect(markup).toContain("after the first run"); + }); + + test("run history lists each fire and deep-links its trace", () => { + const markup = renderPage({ runs: [completedRun, failedFire] }); + expect(markup).toContain("Run history"); + expect(markup).toContain('href="/insights/runs/run_1"'); + expect(markup).toContain("Failed to start"); + expect(markup).toContain("sidecar unreachable"); + // A fire that never produced a run has no trace to link to. + expect(markup).not.toContain('href="/insights/runs/run_0"'); + expect(markup).toContain("Never started"); + }); + + test("the health rail reports streak, typical duration, and the last failure", () => { + const markup = renderPage({ runs: [completedRun, failedFire] }); + expect(markup).toContain("Clean streak"); + expect(markup).toContain("1 run without a failure"); + expect(markup).toContain("Typical run"); + expect(markup).toContain("30s"); + expect(markup).toContain("Last failure"); + expect(markup).toContain("sidecar unreachable"); + }); + + test("an enabled routine offers Pause; a disabled one offers Resume", () => { + expect(renderPage()).toContain("Pause"); + expect(renderPage({ routine: { ...routine, enabled: false } })).toContain( + "Resume", + ); + }); + + test("Run now and Pause both sit in the top bar's action slot", () => { + const markup = renderPage(); + const actions = markup.slice( + markup.indexOf('data-testid="stage-top-bar-actions"'), + ); + expect(actions).toContain("Run now"); + expect(actions).toContain("Pause"); + }); +}); + +describe("RoutineDetailPage lifecycle actions", () => { + function mount( + props: Partial = {}, + overrides: Partial = {}, + ): { container: HTMLDivElement; root: Root } { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render( + createElement(NavigationProvider, { + navigate: noop, + children: createElement(RoutineDetailPage, { + row: row(overrides), + ...pageProps, + ...props, + }), + }), + ); + }); + return { container, root }; + } + + function clickButton(container: HTMLElement, label: string): void { + const button = [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ); + expect(button).not.toBeUndefined(); + act(() => { + button?.click(); + }); + } + + test("Pause asks for the routine to be disabled", () => { + const calls: boolean[] = []; + const { container, root } = mount({ + onToggleEnabled: (enabled: boolean) => calls.push(enabled), + }); + try { + clickButton(container, "Pause"); + expect(calls).toEqual([false]); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + + test("Resume asks for a paused routine to be enabled again", () => { + const calls: boolean[] = []; + const { container, root } = mount( + { onToggleEnabled: (enabled: boolean) => calls.push(enabled) }, + { routine: { ...routine, enabled: false } }, + ); + try { + clickButton(container, "Resume"); + expect(calls).toEqual([true]); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + + test("Run now triggers the run-now mutation", () => { + let runs = 0; + const { container, root } = mount({ + onRunNow: () => { + runs += 1; + return Promise.resolve(); + }, + }); + try { + clickButton(container, "Run now"); + expect(runs).toBe(1); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); +}); + +describe("RoutineScheduleSection", () => { + function mount(onSave: (expression: string) => Promise): { + container: HTMLDivElement; + root: Root; + } { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render( + createElement(RoutineScheduleSection, { row: row(), onSave }), + ); + }); + return { container, root }; + } + + function type(container: HTMLElement, value: string): void { + const input = container.querySelector("input") as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + if (setter === undefined) { + throw new Error("native value setter unavailable"); + } + act(() => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + + function saveButton(container: HTMLElement): HTMLButtonElement { + const button = [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === "Save schedule", + ); + expect(button).not.toBeUndefined(); + return button as HTMLButtonElement; + } + + test("an unchanged schedule cannot be saved", () => { + const { container, root } = mount(() => Promise.resolve()); + try { + expect(saveButton(container).disabled).toBe(true); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + + test("editing the expression previews what it means in words", () => { + const { container, root } = mount(() => Promise.resolve()); + try { + type(container, "0 9 * * 1-5"); + expect(container.textContent).toContain("Monday through Friday"); + expect(saveButton(container).disabled).toBe(false); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + + test("an expression the scheduler cannot run says so and cannot be saved", () => { + const { container, root } = mount(() => Promise.resolve()); + try { + type(container, "99 9 * * *"); + expect(container.textContent).toContain("isn't a schedule this can run"); + expect(saveButton(container).disabled).toBe(true); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + + test("saving hands the new expression up", () => { + const saved: string[] = []; + const { container, root } = mount((expression) => { + saved.push(expression); + return Promise.resolve(); + }); + try { + type(container, "30 6 * * *"); + act(() => { + saveButton(container).click(); + }); + expect(saved).toEqual(["30 6 * * *"]); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + + test("a manual routine gets no cron field rather than an inert one", () => { + const markup = renderToStaticMarkup( + Promise.resolve()} + />, + ); + expect(markup).toContain("On demand only"); + expect(markup).not.toContain("Cron expression"); + }); +}); diff --git a/apps/web/test/routine-panel.test.tsx b/apps/web/test/routine-panel.test.tsx index 59fdbc33d..4957608b4 100644 --- a/apps/web/test/routine-panel.test.tsx +++ b/apps/web/test/routine-panel.test.tsx @@ -80,6 +80,8 @@ function routineRecord( deliveryWorkbenchId: null, consecutiveFailures: 0, deadLetteredAt: null, + nextFireAt: null, + lastFireAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...overrides, diff --git a/apps/web/test/routines-page.test.tsx b/apps/web/test/routines-page.test.tsx index 407599095..84bc15c9e 100644 --- a/apps/web/test/routines-page.test.tsx +++ b/apps/web/test/routines-page.test.tsx @@ -1,10 +1,15 @@ -// Screen-level proof for the global Routines page (CL-6362): every -// routine across every workbench the account belongs to, as rows — -// workbench attribution, running-or-not state, schedule, inline -// enable/disable, Run now, and an inline-expandable detail with recent -// runs. `GlobalRoutinesList` is pure (real props in, honest markup out); -// `RoutinesRoute` (aggregation across bench memberships, never -// creator-scoped) gets its own fetch-mocked integration coverage below. +// Screen-level proof for the global Routines page: every routine across +// every workbench the account belongs to, as ops rows (CL-6418) — +// human-language schedule, the scheduler's own next-run clock, health as +// a state pill with a caption, the last run and its status, workbench +// attribution, Pause/Resume, and Run now. `GlobalRoutinesList` is pure +// (real props in, honest markup out); `RoutinesRoute` (aggregation across +// bench memberships, never creator-scoped) gets its own fetch-mocked +// integration coverage below. +// +// Row detail is a page now (`/routines/`), not an inline expansion: +// the name is a link, and an old `/routines/` deep link redirects to +// that page rather than expanding a row that no longer expands. import { describe, expect, test } from "bun:test"; import { act, createElement } from "react"; @@ -14,10 +19,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { GlobalRoutinesList, - routineStateChip, - scheduleSummary, + nextRunLabel, + routineRowHealth, + scheduleSentence, } from "../src/pages/routines-page"; import type { GlobalRoutineRow } from "../src/pages/routines-page"; +import { NavigationProvider } from "../src/navigation"; import type { Routine, RoutineRun } from "../src/routines-api"; const noop = () => undefined; @@ -33,6 +40,8 @@ const routine: Routine = { deliveryWorkbenchId: "ch_1", consecutiveFailures: 0, deadLetteredAt: null, + nextFireAt: "2026-01-02T09:00:00.000Z", + lastFireAt: "2026-01-01T09:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }; @@ -50,115 +59,145 @@ function row(overrides: Partial = {}): GlobalRoutineRow { const listProps = { now: Date.parse("2026-01-01T12:00:00.000Z"), - expandedId: null as string | null, - onToggleExpanded: noop, onToggleEnabled: (_row: GlobalRoutineRow, _enabled: boolean) => {}, onRunNow: (_row: GlobalRoutineRow) => Promise.resolve(), - onEdit: (_row: GlobalRoutineRow) => {}, onOpenWorkbench: (_workbenchId: string) => {}, }; -describe("routineStateChip", () => { +function renderList(rows: readonly GlobalRoutineRow[]): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("routineRowHealth", () => { test("Off for a disabled routine, regardless of run history", () => { - expect( - routineStateChip(row({ routine: { ...routine, enabled: false } })), - ).toEqual({ label: "Off", tone: "neutral" }); + const health = routineRowHealth( + row({ routine: { ...routine, enabled: false } }), + ); + expect(health.state).toBe("off"); + expect(health.label).toBe("Off"); }); test("Paused for a dead-lettered routine", () => { - expect( - routineStateChip( - row({ - routine: { - ...routine, - deadLetteredAt: "2026-01-02T00:00:00.000Z", - }, - }), - ), - ).toEqual({ label: "Paused", tone: "danger" }); - }); - - test("Idle for an enabled routine with no run history", () => { - expect(routineStateChip(row())).toEqual({ label: "Idle", tone: "neutral" }); + const health = routineRowHealth( + row({ + routine: { ...routine, deadLetteredAt: "2026-01-02T00:00:00.000Z" }, + }), + ); + expect(health.state).toBe("paused"); }); - test("Running now while the latest run is in flight", () => { - const run: RoutineRun = { + test("a clean streak is counted, not just asserted", () => { + const finished: RoutineRun = { runId: "run_1", triggeredBy: "schedule", createdAt: "2026-01-01T00:00:00.000Z", - run: { status: "running" }, + run: { status: "completed" }, }; - expect(routineStateChip(row({ runs: [run] }))).toEqual({ - label: "Running now", - tone: "success", - }); + const health = routineRowHealth(row({ runs: [finished, finished] })); + expect(health.state).toBe("ok"); + expect(health.cleanStreak).toBe(2); }); +}); - test("Last run failed when the latest run errored", () => { - const run: RoutineRun = { - runId: "run_1", - triggeredBy: "schedule-failed", - createdAt: "2026-01-01T00:00:00.000Z", - error: "sidecar unreachable", - }; - expect(routineStateChip(row({ runs: [run] }))).toEqual({ - label: "Last run failed", - tone: "danger", - }); +describe("scheduleSentence", () => { + test("humanizes the cadence and never prints the expression", () => { + const sentence = scheduleSentence(row()); + expect(sentence).toBe("At 09:00 (UTC)"); + expect(sentence).not.toMatch(/\d+ \d+ \* \* \*/); }); -}); -describe("scheduleSummary", () => { - test("humanizes the cadence and appends a relative next-run", () => { - const summary = scheduleSummary( - row(), - Date.parse("2026-01-01T00:00:00.000Z"), + test("a raw cron routine still reads as a sentence", () => { + const sentence = scheduleSentence( + row({ + routine: { + ...routine, + trigger: { kind: "cron", expression: "0 9 * * 1-5" }, + }, + }), ); - expect(summary).toContain("Daily at 09:00 UTC"); - expect(summary).toContain("next"); - expect(summary).not.toMatch(/\d+ \d+ \* \* \*/); + expect(sentence).toContain("Monday through Friday"); + expect(sentence).not.toContain("1-5"); }); - test("no next-run suffix for a manual routine", () => { - const summary = scheduleSummary( - row({ routine: { ...routine, trigger: null } }), - Date.now(), + test("a manual routine says it runs on demand", () => { + expect( + scheduleSentence(row({ routine: { ...routine, trigger: null } })), + ).toBe("On demand only"); + }); +}); + +describe("nextRunLabel", () => { + test("reads the scheduler's own clock, not a re-derived estimate", () => { + expect(nextRunLabel(row(), listProps.now)).toBe( + // 2026-01-02T09:00Z from 2026-01-01T12:00Z + "in 21h", ); - expect(summary).toBe("Manual"); + }); + + test("a routine with nothing scheduled says so rather than guessing", () => { + expect( + nextRunLabel( + row({ routine: { ...routine, trigger: null, nextFireAt: null } }), + listProps.now, + ), + ).toBe("Not scheduled"); }); }); describe("GlobalRoutinesList", () => { test("says there are no routines yet when the list is empty", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("No routines yet"); + expect(renderList([])).toContain("No routines yet"); }); - test("renders a row with its name and workbench attribution", () => { - const markup = renderToStaticMarkup( - , - ); + test("a row carries the ops columns: schedule, next run, health, last run", () => { + const markup = renderList([ + row({ + runs: [ + { + runId: "run_1", + triggeredBy: "schedule", + createdAt: "2026-01-01T09:00:00.000Z", + run: { status: "completed" }, + }, + ], + }), + ]); expect(markup).toContain("Morning brief"); expect(markup).toContain("Acme Team"); + expect(markup).toContain("At 09:00 (UTC)"); + expect(markup).toContain("in 21h"); + expect(markup).toContain("Healthy"); + expect(markup).toContain("completed"); expect(markup).toContain("Ops"); - expect(markup).toContain("Daily at 09:00 UTC"); + }); + + test("the routine's name links to its own page", () => { + expect(renderList([row()])).toContain('href="/routines/morning-brief"'); + }); + + test("a failing routine states its failure count in words, not only in colour", () => { + const markup = renderList([ + row({ routine: { ...routine, consecutiveFailures: 2 } }), + ]); + expect(markup).toContain("Failing"); + expect(markup).toContain("2 runs failed in a row"); + }); + + test("a routine that has never run says Never rather than showing an empty cell", () => { + expect(renderList([row()])).toContain("Never"); }); test("a routine with no delivery workbench shows a dash, not a broken link", () => { - const markup = renderToStaticMarkup( - , - ); + const markup = renderList([ + row({ + routine: { ...routine, deliveryWorkbenchId: null }, + deliveryWorkbenchName: null, + }), + ]); expect(markup).toContain("—"); }); @@ -169,13 +208,16 @@ describe("GlobalRoutinesList", () => { const root: Root = createRoot(container); act(() => { root.render( - createElement(GlobalRoutinesList, { - rows: [row()], - ...listProps, - onRunNow: (r: GlobalRoutineRow) => { - calls.push(r); - return Promise.resolve(); - }, + createElement(NavigationProvider, { + navigate: noop, + children: createElement(GlobalRoutinesList, { + rows: [row()], + ...listProps, + onRunNow: (r: GlobalRoutineRow) => { + calls.push(r); + return Promise.resolve(); + }, + }), }), ); }); @@ -195,19 +237,22 @@ describe("GlobalRoutinesList", () => { } }); - test("the Enabled switch calls onToggleEnabled with the flipped value", () => { + test("the On switch calls onToggleEnabled with the flipped value", () => { const calls: [GlobalRoutineRow, boolean][] = []; const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); act(() => { root.render( - createElement(GlobalRoutinesList, { - rows: [row()], - ...listProps, - onToggleEnabled: (r: GlobalRoutineRow, enabled: boolean) => { - calls.push([r, enabled]); - }, + createElement(NavigationProvider, { + navigate: noop, + children: createElement(GlobalRoutinesList, { + rows: [row()], + ...listProps, + onToggleEnabled: (r: GlobalRoutineRow, enabled: boolean) => { + calls.push([r, enabled]); + }, + }), }), ); }); @@ -232,10 +277,13 @@ describe("GlobalRoutinesList", () => { const root: Root = createRoot(container); act(() => { root.render( - createElement(GlobalRoutinesList, { - rows: [row()], - ...listProps, - onOpenWorkbench: (workbenchId: string) => opened.push(workbenchId), + createElement(NavigationProvider, { + navigate: noop, + children: createElement(GlobalRoutinesList, { + rows: [row()], + ...listProps, + onOpenWorkbench: (workbenchId: string) => opened.push(workbenchId), + }), }), ); }); @@ -253,94 +301,6 @@ describe("GlobalRoutinesList", () => { container.remove(); } }); - - test("Edit calls onEdit with the row", () => { - const edited: GlobalRoutineRow[] = []; - const container = document.createElement("div"); - document.body.appendChild(container); - const root: Root = createRoot(container); - act(() => { - root.render( - createElement(GlobalRoutinesList, { - rows: [row()], - ...listProps, - onEdit: (r: GlobalRoutineRow) => edited.push(r), - }), - ); - }); - try { - const editButton = [...container.querySelectorAll("button")].find( - (button) => button.textContent?.trim() === "Edit", - ); - expect(editButton).not.toBeUndefined(); - act(() => { - editButton?.click(); - }); - expect(edited).toHaveLength(1); - expect(edited[0]?.routine.id).toBe("rtn_1"); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("expanding a row shows recent runs and the delivery note inline, without navigating", () => { - const run: RoutineRun = { - runId: "run_1", - triggeredBy: "schedule", - createdAt: "2026-01-01T00:00:00.000Z", - run: { status: "completed" }, - }; - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Run updates post into Ops"); - expect(markup).toContain("completed"); - }); - - test("a collapsed row shows no run detail", () => { - const run: RoutineRun = { - runId: "run_1", - triggeredBy: "schedule", - createdAt: "2026-01-01T00:00:00.000Z", - run: { status: "completed" }, - }; - const markup = renderToStaticMarkup( - , - ); - expect(markup).not.toContain("Run updates post into"); - }); - - test("expand toggling calls onToggleExpanded with the routine id", () => { - const toggled: string[] = []; - const container = document.createElement("div"); - document.body.appendChild(container); - const root: Root = createRoot(container); - act(() => { - root.render( - createElement(GlobalRoutinesList, { - rows: [row()], - ...listProps, - onToggleExpanded: (id: string) => toggled.push(id), - }), - ); - }); - try { - const expandButton = container.querySelector("button[aria-expanded]"); - expect(expandButton).not.toBeNull(); - act(() => { - (expandButton as HTMLButtonElement).click(); - }); - expect(toggled).toEqual(["rtn_1"]); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); }); describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { @@ -351,78 +311,54 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { }); } - test("lists routines from every bench the account is a member of, not just the currently selected one, and never creator-scoped", async () => { - const { BenchProvider } = await import("../src/bench-context"); - const { NavigationProvider } = await import("../src/navigation"); - const { CanvasAvailabilityProvider } = - await import("../src/shell/canvas-availability"); - const { RoutinesRoute } = await import("../src/pages/routines-page"); - const { TestQueryProvider } = await import("./test-query-provider"); - - const realFetch = globalThis.fetch; - // Two benches this account belongs to — GET /routines is already - // tenant-scoped, never filtered by who created a row, so a second - // member's routine (created by a different principal) shows up here - // exactly like the viewer's own. - const memberships = [ - { - principalId: "prn_me", - tenantId: "tnt_1", - tenantName: "Acme Team", - tenantSlug: "acme", - kind: "user", - status: "active", - roles: [], - }, - { - principalId: "prn_me_2", - tenantId: "tnt_2", - tenantName: "Beta Team", - tenantSlug: "beta", - kind: "user", - status: "active", - roles: [], - }, - ]; - const routinesByTenant: Record[]> = { - tnt_1: [ - { - id: "rtn_mine", - name: "My digest", - definitionId: "wfd_1", - trigger: null, - scope: "bench", - input: {}, - enabled: true, - deliveryWorkbenchId: null, - consecutiveFailures: 0, - deadLetteredAt: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - tnt_2: [ - { - id: "rtn_theirs", - name: "Their digest", - definitionId: "wfd_2", - trigger: null, - scope: "bench", - input: {}, - enabled: true, - deliveryWorkbenchId: null, - consecutiveFailures: 0, - deadLetteredAt: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], + function routineRecord( + overrides: Record, + ): Record { + return { + definitionId: "wfd_1", + trigger: null, + scope: "bench", + input: {}, + enabled: true, + deliveryWorkbenchId: null, + consecutiveFailures: 0, + deadLetteredAt: null, + nextFireAt: null, + lastFireAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, }; + } - globalThis.fetch = (async ( - input: RequestInfo | URL, - _init?: RequestInit, - ): Promise => { + const memberships = [ + { + principalId: "prn_me", + tenantId: "tnt_1", + tenantName: "Acme Team", + tenantSlug: "acme", + kind: "user", + status: "active", + roles: [], + }, + { + principalId: "prn_me_2", + tenantId: "tnt_2", + tenantName: "Beta Team", + tenantSlug: "beta", + kind: "user", + status: "active", + roles: [], + }, + ]; + + const routinesByTenant: Record[]> = { + tnt_1: [routineRecord({ id: "rtn_mine", name: "My digest" })], + tnt_2: [routineRecord({ id: "rtn_theirs", name: "Their digest" })], + }; + + function mockFetch(): typeof fetch { + return (async (input: RequestInfo | URL): Promise => { const url = String(input); if (url.includes("/api/me/principals")) { return jsonResponse({ data: memberships, nextCursor: null }); @@ -444,45 +380,59 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { } return Promise.reject(new Error(`unrouted fetch: ${url}`)); }) as typeof fetch; + } + + async function renderRoute( + path: string, + navigate: (to: string) => void, + ): Promise<{ container: HTMLDivElement; root: Root }> { + const { BenchProvider } = await import("../src/bench-context"); + const { CanvasAvailabilityProvider } = + await import("../src/shell/canvas-availability"); + const { RoutinesRoute } = await import("../src/pages/routines-page"); + const { TestQueryProvider } = await import("./test-query-provider"); const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); - try { + await act(async () => { + root.render( + + + + {}} + openArtifact={() => {}} + openRoutine={() => {}} + toggleFocus={() => {}} + close={() => {}} + > + {createElement(RoutinesRoute, { path, navigate })} + + + + , + ); + }); + for (let i = 0; i < 8; i++) { await act(async () => { - root.render( - - {}}> - - {}} - openArtifact={() => {}} - openRoutine={() => {}} - toggleFocus={() => {}} - close={() => {}} - > - {createElement(RoutinesRoute, { - path: "/routines", - navigate: () => {}, - })} - - - - , - ); + await new Promise((resolve) => setTimeout(resolve, 10)); }); - for (let i = 0; i < 8; i++) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - } + } + return { container, root }; + } + test("lists routines from every bench the account is a member of, not just the currently selected one, and never creator-scoped", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = mockFetch(); + const { container, root } = await renderRoute("/routines", () => {}); + try { // Bench switcher defaults to the first bench (tnt_1) — proving the // second bench's routine still renders proves this page never // narrows to just the selected tenant. @@ -497,4 +447,21 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { window.localStorage.clear(); } }); + + test("an old /routines/ deep link lands on the routine's own page", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = mockFetch(); + const navigated: string[] = []; + const { container, root } = await renderRoute("/routines/rtn_mine", (to) => + navigated.push(to), + ); + try { + expect(navigated).toContain("/routines/my-digest"); + } finally { + act(() => root.unmount()); + container.remove(); + globalThis.fetch = realFetch; + window.localStorage.clear(); + } + }); }); diff --git a/packages/routines/src/client.test.ts b/packages/routines/src/client.test.ts index 7daa72d19..a887b4ee0 100644 --- a/packages/routines/src/client.test.ts +++ b/packages/routines/src/client.test.ts @@ -13,9 +13,21 @@ import { routineRunNowPath, routineRunStartedToast, routineRunsPath, + routineSlug, routinesPath, } from "./client"; +describe("routineSlug", () => { + test("derives the URL-facing name from the display name", () => { + expect(routineSlug("Morning brief")).toBe("morning-brief"); + expect(routineSlug("Weekly Digest — Q3!")).toBe("weekly-digest-q3"); + }); + + test("empty for a name that cannot name a URL, so a caller can skip the link", () => { + expect(routineSlug("🌅")).toBe(""); + }); +}); + describe("routine toast copy", () => { test("create carries the routine's name", () => { expect(routineCreatedToast("Morning brief")).toBe( @@ -69,6 +81,8 @@ describe("wire schemas", () => { deliveryWorkbenchId: null, consecutiveFailures: 0, deadLetteredAt: null, + nextFireAt: null, + lastFireAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); @@ -95,6 +109,8 @@ describe("wire schemas", () => { deliveryWorkbenchId: null, consecutiveFailures: 0, deadLetteredAt: null, + nextFireAt: null, + lastFireAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); diff --git a/packages/routines/src/health.test.ts b/packages/routines/src/health.test.ts new file mode 100644 index 000000000..370e65742 --- /dev/null +++ b/packages/routines/src/health.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; + +import { + cleanFireStreak, + lastFailedFire, + medianFireDurationMs, + routineHealth, +} from "./health"; +import type { RoutineFire, RoutineHealthSubject } from "./health"; + +const healthy: RoutineHealthSubject = { + enabled: true, + consecutiveFailures: 0, + deadLetteredAt: null, +}; + +function fire( + runId: string, + overrides: Partial = {}, + run: Record = { status: "completed" }, +): RoutineFire { + return { + runId, + triggeredBy: "schedule", + createdAt: "2026-01-01T00:00:00.000Z", + run, + ...overrides, + }; +} + +describe("cleanFireStreak", () => { + test("counts successes from the newest fire and stops at the first failure", () => { + expect( + cleanFireStreak([ + fire("r5"), + fire("r4"), + fire("r3", {}, { status: "failed" }), + fire("r2"), + ]), + ).toBe(2); + }); + + test("an in-flight run neither breaks nor extends the streak", () => { + expect( + cleanFireStreak([fire("r2", {}, { status: "running" }), fire("r1")]), + ).toBe(1); + }); + + test("no history is a streak of zero, not a failure", () => { + expect(cleanFireStreak([])).toBe(0); + }); +}); + +describe("lastFailedFire", () => { + test("a synthetic launch failure counts as a failure and carries its message", () => { + expect( + lastFailedFire([ + fire("r2"), + fire( + "r1", + { + triggeredBy: "schedule-failed", + error: "sidecar unreachable", + createdAt: "2026-01-02T00:00:00.000Z", + }, + {}, + ), + ]), + ).toEqual({ at: "2026-01-02T00:00:00.000Z", error: "sidecar unreachable" }); + }); + + test("null when nothing in the history failed", () => { + expect(lastFailedFire([fire("r1")])).toBeNull(); + }); +}); + +describe("medianFireDurationMs", () => { + function timed(runId: string, seconds: number): RoutineFire { + return fire( + runId, + {}, + { + status: "completed", + createdAt: "2026-01-01T00:00:00.000Z", + endedAt: new Date( + Date.parse("2026-01-01T00:00:00.000Z") + seconds * 1000, + ).toISOString(), + }, + ); + } + + test("the middle duration of an odd number of finished fires", () => { + expect( + medianFireDurationMs([timed("a", 10), timed("b", 2), timed("c", 6)]), + ).toBe(6_000); + }); + + test("averages the middle pair for an even count", () => { + expect(medianFireDurationMs([timed("a", 2), timed("b", 6)])).toBe(4_000); + }); + + test("one outlier cannot move the median", () => { + expect( + medianFireDurationMs([timed("a", 12), timed("b", 12), timed("c", 3600)]), + ).toBe(12_000); + }); + + test("null when no fire recorded both ends", () => { + expect(medianFireDurationMs([fire("a")])).toBeNull(); + expect(medianFireDurationMs([])).toBeNull(); + }); +}); + +describe("routineHealth", () => { + test("a disabled routine is Off whatever its history says", () => { + const health = routineHealth({ ...healthy, enabled: false }, [ + fire("r1", {}, { status: "failed" }), + ]); + expect(health.state).toBe("off"); + expect(health.label).toBe("Off"); + }); + + test("a dead-lettered routine is Paused, not merely failing", () => { + const health = routineHealth( + { + enabled: true, + consecutiveFailures: 3, + deadLetteredAt: "2026-01-03T00:00:00.000Z", + }, + [], + ); + expect(health.state).toBe("paused"); + expect(health.caption).toContain("resumes"); + }); + + test("an in-flight latest run reports Running now", () => { + expect( + routineHealth(healthy, [fire("r1", {}, { status: "running" })]).state, + ).toBe("running"); + }); + + test("consecutive failures are stated in the caption, not just the pill", () => { + const health = routineHealth({ ...healthy, consecutiveFailures: 2 }, [ + fire("r1", { error: "boom" }, {}), + ]); + expect(health.state).toBe("failing"); + expect(health.caption).toContain("2 runs failed in a row"); + }); + + test("never run yet is idle, not healthy and not failing", () => { + expect(routineHealth(healthy, []).state).toBe("idle"); + }); + + test("a clean streak reads as healthy and reports the streak", () => { + const health = routineHealth(healthy, [fire("r2"), fire("r1")]); + expect(health.state).toBe("ok"); + expect(health.cleanStreak).toBe(2); + expect(health.caption).toContain("2 runs in a row"); + }); + + test("carries the last failure through even while healthy again", () => { + const health = routineHealth(healthy, [ + fire("r2"), + fire( + "r1", + { error: "timed out", createdAt: "2026-01-01T05:00:00.000Z" }, + {}, + ), + ]); + expect(health.state).toBe("ok"); + expect(health.lastFailure).toEqual({ + at: "2026-01-01T05:00:00.000Z", + error: "timed out", + }); + }); +}); diff --git a/packages/routines/src/schedule-language.test.ts b/packages/routines/src/schedule-language.test.ts new file mode 100644 index 000000000..916555fc7 --- /dev/null +++ b/packages/routines/src/schedule-language.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; + +import { cronSentence, routineScheduleSentence } from "./schedule-language"; + +const RAW_CRON = /\*|\d+ \d+ \* \* /; + +describe("cronSentence", () => { + test("reads a weekday morning schedule as a sentence, never the expression", () => { + const sentence = cronSentence("0 9 * * 1-5"); + expect(sentence).toBe("At 09:00, Monday through Friday (UTC)"); + expect(sentence).not.toMatch(RAW_CRON); + }); + + test("names the timezone the wall clock is read in", () => { + expect(cronSentence("30 14 * * *", "America/Los_Angeles")).toBe( + "At 14:30 (America/Los_Angeles)", + ); + }); + + test("reads a step expression as a frequency", () => { + expect(cronSentence("*/15 * * * *")).toBe("Every 15 minutes (UTC)"); + }); + + test("reads a day-of-month expression", () => { + expect(cronSentence("0 0 1 * *")).toContain("day 1 of the month"); + }); + + test("null for an expression that cannot be described", () => { + expect(cronSentence("not a cron")).toBeNull(); + expect(cronSentence("")).toBeNull(); + }); +}); + +describe("routineScheduleSentence", () => { + test("every clock-driven preset reads as a sentence", () => { + expect(routineScheduleSentence({ kind: "daily", hour: 9, minute: 0 })).toBe( + "At 09:00 (UTC)", + ); + expect( + routineScheduleSentence({ + kind: "weekly", + dayOfWeek: 1, + hour: 7, + minute: 30, + }), + ).toBe("At 07:30, only on Monday (UTC)"); + expect( + routineScheduleSentence({ kind: "interval", unit: "hours", every: 6 }), + ).toBe("On the hour, every 6 hours (UTC)"); + }); + + test("a raw cron trigger never leaks its expression to the reader", () => { + const sentence = routineScheduleSentence({ + kind: "cron", + expression: "0 9 * * 1,3,5", + timezone: "Europe/Berlin", + }); + expect(sentence).toContain("Monday, Wednesday, and Friday"); + expect(sentence).toContain("Europe/Berlin"); + expect(sentence).not.toContain("1,3,5"); + }); + + test("the non-clock triggers each get their own words", () => { + expect(routineScheduleSentence(null)).toBe("On demand only"); + expect( + routineScheduleSentence({ kind: "webhook", webhookTriggerId: "wht_1" }), + ).toBe("When its webhook receives a delivery"); + expect(routineScheduleSentence({ kind: "once" })).toBe( + "Once, when it was created", + ); + }); + + test("an expression saved before a stricter check says so instead of printing itself", () => { + expect( + routineScheduleSentence({ kind: "cron", expression: "garbage" }), + ).toBe("Schedule not readable"); + }); +}); diff --git a/packages/routines/test/routes.test.ts b/packages/routines/test/routes.test.ts index a1178e4f6..7fc58c7eb 100644 --- a/packages/routines/test/routes.test.ts +++ b/packages/routines/test/routes.test.ts @@ -302,6 +302,34 @@ describe("createRoutineRoutes", () => { expect(due.find((r) => r.id === body["id"])).toBeUndefined(); }); + test("the wire view surfaces the scheduler's own next-fire clock, so a UI never has to re-derive it", async () => { + const deps = buildDeps(); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { body } = await createRoutine(app, { + ...VALID_BODY, + trigger: { kind: "daily", hour: 9, minute: 0 }, + }); + + expect(typeof body["nextFireAt"]).toBe("string"); + expect(body["lastFireAt"]).toBeNull(); + + // The same instant the scheduler's own claim test compares against — + // not an independently rendered estimate. + const nextFireAt = new Date(body["nextFireAt"] as string); + expect(nextFireAt.getUTCHours()).toBe(9); + expect(nextFireAt.getUTCMinutes()).toBe(0); + expect(nextFireAt.getTime()).toBeGreaterThan(Date.now()); + }); + + test("a manual routine reports no next fire rather than omitting the field", async () => { + const deps = buildDeps(); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { body } = await createRoutine(app, { ...VALID_BODY, trigger: null }); + + expect(body["nextFireAt"]).toBeNull(); + expect(body["lastFireAt"]).toBeNull(); + }); + test("accepts a webhook trigger when no checker is wired (always-allow)", async () => { const deps = buildDeps(); const app = mountAs(createRoutineRoutes(deps), "user_1"); From 66809bb755d03f80864ed1cfaca0850364e456ee Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 15:48:53 -0700 Subject: [PATCH 2/4] Routines: ops-grade list and full detail page at /routines/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Routines roster becomes an ops table and every routine gets its own page, replacing the CL-6412 placeholder. No new storage: schedule and health telemetry (`nextFireAt`, `lastFireAt`, `consecutiveFailures`, `deadLetteredAt`, and the per-fire history table) were already recorded and simply never surfaced. - `@corbits/routines` gains `schedule-language.ts` — cron rendered as an English sentence via `cronstrue` (MIT, no runtime deps, browser-safe) rather than a hand-rolled renderer, since the product's cron escape hatch accepts any expression `cron.ts` validates. DESIGN.md's Copy rule now holds everywhere: no surface prints a cron expression at a reader. - `health.ts` decides what "healthy" means once — six states, each with a pill label and a caption that says the same thing in words — plus the clean streak, the median fire duration, and the last failure. The list pill and the detail rail read the same function, never two opinions. - `routineView` and the client `Routine` schema carry `nextFireAt` / `lastFireAt`, so "next run" is the instant the scheduler will test against rather than a browser's estimate of it. - The list's columns are the questions an operator arrives with: schedule, next run, health, last run and its status. Inline row expansion is gone — detail is a page now, and `/routines/` redirects to it so old links still land somewhere real. - `/routines/` shows the schedule (sentence first, raw cron editable behind it, previewing what an edit means before it saves), the target workflow with a link into the existing `/insights/runs` trace, the full fire history, and the health rail. It shows a workflow, never an agent it "runs as". Run now and Pause/Resume are the routines package's existing mutations (`POST /routines/:id/run`, `PATCH {enabled}` — which also clears a dead-letter); no new write path was needed for either. Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti --- apps/web/src/global-routines.ts | 184 +++++++ apps/web/src/pages/detail-placeholders.tsx | 15 +- apps/web/src/pages/routine-detail-page.tsx | 510 ++++++++++++++++++++ apps/web/src/pages/routines-page.tsx | 530 ++++++++------------- apps/web/src/routes.tsx | 9 +- apps/web/src/routine-health-tone.ts | 22 + bun.lock | 4 + packages/routines/package.json | 2 + packages/routines/src/client.ts | 38 ++ packages/routines/src/health.ts | 215 +++++++++ packages/routines/src/routes.ts | 2 + packages/routines/src/schedule-language.ts | 62 +++ 12 files changed, 1238 insertions(+), 355 deletions(-) create mode 100644 apps/web/src/global-routines.ts create mode 100644 apps/web/src/pages/routine-detail-page.tsx create mode 100644 apps/web/src/routine-health-tone.ts create mode 100644 packages/routines/src/health.ts create mode 100644 packages/routines/src/schedule-language.ts diff --git a/apps/web/src/global-routines.ts b/apps/web/src/global-routines.ts new file mode 100644 index 000000000..a2a5899d1 --- /dev/null +++ b/apps/web/src/global-routines.ts @@ -0,0 +1,184 @@ +// Every routine across every bench the signed-in account belongs to, as +// one flat list — the aggregation both routines surfaces read from: the +// roster at `/routines` and the detail page at `/routines/`. +// +// It lives here rather than inside `pages/routines-page.tsx` because the +// detail page needs the identical resolution (a slug names a routine in +// *some* bench, not in the currently selected one) and two copies of a +// membership fan-out would drift the moment either page changed which +// benches it looks in. +// +// Visibility resolves through the same membership the sidebar's bench +// switcher uses (`useBench().memberships`, the `/api/me/principals` / +// CL-6332 principal model), filtered to actual benches with +// `classifyBenchMembership` — never just the currently selected one, and +// never creator-scoped: `GET /routines` already lists every routine a +// bench's own grant covers, regardless of who created it. +import { listWorkbenches } from "@corbits/chat-ui"; +import { + classifyBenchMembership, + listWorkbenchTenantIds, +} from "@corbits/bench-ui"; +import { routineSlug } from "@corbits/routines/client"; +import { useMemo } from "react"; +import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { APIQuery } from "@corbits/api-query"; + +import type { Principal } from "./api"; +import { useBench } from "./bench-context"; +import { ROUTINES_PATH_PREFIX } from "./path-ids"; +import { meKeys, tenantKeys } from "./query-client"; +import { listRoutineRuns, listRoutines } from "./routines-api"; +import type { Routine, RoutineRun } from "./routines-api"; + +/** The query-key suffix both routines surfaces share, so a mutation on + * either invalidates the other's copy of the same fetch. */ +const ROUTINES_QUERY_SCOPE = "global-page"; + +export type GlobalRoutineRow = { + readonly routine: Routine; + readonly tenantId: string; + readonly tenantName: string; + readonly deliveryWorkbenchName: string | null; + readonly runs: readonly RoutineRun[]; +}; + +/** `/routines/` — the routine's own page. `null` when the name has + * nothing sluggable in it, so a caller renders the routine without a link + * rather than linking somewhere that cannot resolve. */ +export function routineDetailPath(name: string): string | null { + const slug = routineSlug(name); + return slug === "" ? null : `${ROUTINES_PATH_PREFIX}/${slug}`; +} + +/** The rows whose routine answers to `slug`. More than one means two + * routines share a name — the caller says so rather than silently picking + * one (DESIGN.md: a route never invents a slug that might collide). */ +export function rowsForSlug( + rows: readonly GlobalRoutineRow[], + slug: string, +): readonly GlobalRoutineRow[] { + return rows.filter((row) => routineSlug(row.routine.name) === slug); +} + +/** Every bench the signed-in account belongs to — not just the currently + * selected one — the same classification the bench switcher uses so a + * workbench child tenancy or a raw-id row never masquerades as a bench a + * person can browse routines in. */ +function useMemberBenches(): { + readonly kind: "loading" | "ready"; + readonly benches: readonly { tenantId: string; tenantName: string }[]; +} { + const { memberships } = useBench(); + const allMemberships: readonly Principal[] = + memberships.kind === "ready" ? memberships.data.data : []; + const tenantIds = useMemo( + () => allMemberships.map((m) => m.tenantId), + [allMemberships], + ); + const workbenchTenancyKinds = useQuery({ + queryKey: meKeys.workbenchTenancyKinds(tenantIds), + queryFn: () => listWorkbenchTenantIds(tenantIds), + enabled: tenantIds.length > 0, + }); + const benches = useMemo( + () => + allMemberships + .filter( + (m) => + classifyBenchMembership( + m, + workbenchTenancyKinds.data ?? new Set(), + ) === "bench", + ) + .map((m) => ({ tenantId: m.tenantId, tenantName: m.tenantName })), + [allMemberships, workbenchTenancyKinds.data], + ); + if (memberships.kind !== "ready") return { kind: "loading", benches: [] }; + return { kind: "ready", benches }; +} + +type BenchRoutinesData = { + readonly routines: readonly Routine[]; + readonly workbenchNames: ReadonlyMap; + readonly runHistories: ReadonlyMap; +}; + +async function fetchBenchRoutinesData( + tenantId: string, +): Promise { + const [routines, workbenches] = await Promise.all([ + listRoutines(tenantId), + listWorkbenches(tenantId, "workbench"), + ]); + const runHistoryEntries = await Promise.all( + routines.map( + async (r) => [r.id, await listRoutineRuns(tenantId, r.id)] as const, + ), + ); + return { + routines, + workbenchNames: new Map(workbenches.map((w) => [w.id, w.title])), + runHistories: new Map(runHistoryEntries), + }; +} + +/** Every routine across every bench the account belongs to, flattened + * into one list with its own workbench attribution — the aggregation + * `GET /routines` doesn't do server-side (it's tenant-scoped, per bench), + * done the cheapest correct client-side way: one fetch per bench, run in + * parallel. */ +export function useGlobalRoutines(): APIQuery { + const { kind: benchesKind, benches } = useMemberBenches(); + const results = useQueries({ + queries: benches.map((bench) => ({ + queryKey: [...tenantKeys.routines(bench.tenantId), ROUTINES_QUERY_SCOPE], + queryFn: () => fetchBenchRoutinesData(bench.tenantId), + })), + }); + + if (benchesKind === "loading") return { kind: "loading" }; + if (results.some((r) => r.isLoading)) return { kind: "loading" }; + const failed = results.find((r) => r.isError); + if (failed !== undefined) { + return { + kind: "error", + message: + failed.error instanceof Error + ? failed.error.message + : "Couldn't load routines.", + retry: () => { + for (const result of results) void result.refetch(); + }, + }; + } + + const rows: GlobalRoutineRow[] = []; + benches.forEach((bench, index) => { + const data = results[index]?.data; + if (data === undefined) return; + for (const routine of data.routines) { + rows.push({ + routine, + tenantId: bench.tenantId, + tenantName: bench.tenantName, + deliveryWorkbenchName: + routine.deliveryWorkbenchId !== null + ? (data.workbenchNames.get(routine.deliveryWorkbenchId) ?? null) + : null, + runs: data.runHistories.get(routine.id) ?? [], + }); + } + }); + return { kind: "ready", data: rows }; +} + +/** Refetch one bench's routines after a mutation on either surface. */ +export function useInvalidateRoutines(): (tenantId: string) => void { + const queryClient = useQueryClient(); + return (tenantId: string) => { + void queryClient.invalidateQueries({ + queryKey: [...tenantKeys.routines(tenantId), ROUTINES_QUERY_SCOPE], + }); + }; +} diff --git a/apps/web/src/pages/detail-placeholders.tsx b/apps/web/src/pages/detail-placeholders.tsx index b57930d50..da4a9af32 100644 --- a/apps/web/src/pages/detail-placeholders.tsx +++ b/apps/web/src/pages/detail-placeholders.tsx @@ -4,14 +4,13 @@ // testable before the page behind it exists. import { Button, EmptyState, PageShell } from "@corbits/react-ui"; -import { FlowArrow, Lightning, SquaresFour } from "@corbits/icons"; +import { Lightning, SquaresFour } from "@corbits/icons"; import type { Slug } from "@corbits/slug"; import type { ReactNode } from "react"; import { Link } from "../navigation"; import { PLUGINS_PATH_PREFIX, - ROUTINES_PATH_PREFIX, SKILLS_PATH_PREFIX, } from "../path-ids"; import { StageTopBar } from "../shell/stage-top-bar"; @@ -73,15 +72,3 @@ export function PluginDetailPlaceholder({ slug }: { readonly slug: Slug }) { /> ); } - -export function RoutineDetailPlaceholder({ slug }: { readonly slug: Slug }) { - return ( - } - /> - ); -} diff --git a/apps/web/src/pages/routine-detail-page.tsx b/apps/web/src/pages/routine-detail-page.tsx new file mode 100644 index 000000000..a6dff692b --- /dev/null +++ b/apps/web/src/pages/routine-detail-page.tsx @@ -0,0 +1,510 @@ +// `/routines/` — a routine's own page (CL-6418), replacing the +// placeholder CL-6412 routed here. +// +// A routine is a workflow on a schedule: schedule + target workflow + +// health + history, and nothing else. It never shows an agent it "runs +// as" — there isn't one; the thing that runs is a workflow definition, +// and its steps are read on the platform's own run surface under +// `/insights/runs`, which this page links into rather than re-rendering. +// +// The schedule reads as a sentence first and stays editable as raw cron +// underneath (DESIGN.md, Copy): the sentence is what a person checks, the +// expression is what they change. Editing writes a `cron` trigger — the +// canonical form every preset already renders to (`cronExpressionForTrigger`) +// — so saving a preset routine's expression is a schedule change, not a +// change of trigger kind. +// +// Run-now and Pause/Resume are the routine's two lifecycle actions and +// they live in the top bar's action slot, never in the page body +// (DESIGN.md, Pages & Routing). Both reuse the routines package's +// existing mutations — `POST /routines/:id/run` and `PATCH {enabled}`, +// which also clears a dead-letter — so neither is a new write path. +import { + Badge, + Button, + Input, + PageShell, + EmptyState, + RunNowButton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + formatRelativeTime, + toast, +} from "@corbits/react-ui"; +import { Clock, FlowArrow } from "@corbits/icons"; +import type { Slug } from "@corbits/slug"; +import { useState, type ReactNode } from "react"; +import { + isValidCronExpression, + cronExpressionForTrigger, + cronSentence, + routineHealth, + routineScheduleSentence, + timezoneForTrigger, +} from "@corbits/routines/client"; +import type { RoutineHealth } from "@corbits/routines/client"; + +import { + rowsForSlug, + useGlobalRoutines, + useInvalidateRoutines, +} from "../global-routines"; +import type { GlobalRoutineRow } from "../global-routines"; +import { runDetailPath } from "../insights-deeplinks"; +import { Link } from "../navigation"; +import { ROUTINES_PATH_PREFIX } from "../path-ids"; +import { ROUTINE_HEALTH_TONE } from "../routine-health-tone"; +import { StageTopBar } from "../shell/stage-top-bar"; +import { RunStatusCell, TriggeredByCell } from "./routines-page"; +import { + listWorkflowDefinitions, + routineRunStartedToast, + runRoutineNow, + updateRoutine, + useTenantQuery, +} from "../routines-api"; +import { tenantKeys } from "../query-client"; + +/** The cron expression behind a routine's schedule — `null` for the + * trigger shapes that have no clock at all (manual, webhook, run-once), + * which get no expression field rather than an inert one. */ +function editableCronFor(row: GlobalRoutineRow): string | null { + const { trigger } = row.routine; + if (trigger === null) return null; + if (trigger.kind === "webhook" || trigger.kind === "once") return null; + return cronExpressionForTrigger(trigger); +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${String(ms)} ms`; + const seconds = Math.round(ms / 1000); + if (seconds < 90) return `${String(seconds)}s`; + const minutes = Math.round(seconds / 60); + if (minutes < 90) return `${String(minutes)} min`; + return `${String(Math.round(minutes / 60))} h`; +} + +function RailFact({ + label, + children, +}: { + readonly label: string; + readonly children: ReactNode; +}) { + return ( +
+ + {label} + + {children} +
+ ); +} + +/** Streak, typical duration, and the last failure — the three questions + * "is this routine well?" actually decomposes into, all read off history + * the scheduler already writes. */ +export function RoutineHealthRail({ + health, + row, + now, +}: { + readonly health: RoutineHealth; + readonly row: GlobalRoutineRow; + readonly now: number; +}) { + const { nextFireAt, lastFireAt } = row.routine; + return ( + + ); +} + +/** + * The schedule, sentence first: the raw expression is editable behind it + * and the sentence re-renders from whatever is typed, so a person sees + * what their change means before they save it. An expression the + * scheduler's own parser rejects (`isValidCronExpression`, the same check + * the server saves against) cannot be saved at all. + */ +export function RoutineScheduleSection({ + row, + onSave, +}: { + readonly row: GlobalRoutineRow; + readonly onSave: (expression: string) => Promise; +}) { + const stored = editableCronFor(row); + const timezone = timezoneForTrigger(row.routine.trigger); + const [draft, setDraft] = useState(stored ?? ""); + const [saving, setSaving] = useState(false); + const valid = isValidCronExpression(draft); + const preview = valid ? cronSentence(draft, timezone) : null; + const changed = stored !== null && draft.trim() !== stored; + + return ( +
+

+ Schedule +

+

+ {routineScheduleSentence(row.routine.trigger)} +

+ {stored === null ? null : ( +
+ +
+ setDraft(event.target.value)} + /> + +
+

+ {preview ?? "That isn't a schedule this can run."} +

+
+ )} +
+ ); +} + +/** The workflow this routine runs, with a way through to its steps on the + * platform's own run surface. The link points at the most recent run — + * that is where steps are actually readable — and is simply absent until + * there is a run to read, never a button that goes nowhere. */ +export function RoutineTargetSection({ + workflowName, + latestRunId, +}: { + readonly workflowName: string; + readonly latestRunId: string | null; +}) { + return ( +
+

+ Runs this workflow +

+
+ {workflowName} + {latestRunId === null ? ( + + Its steps show up here after the first run. + + ) : ( + + )} +
+
+ ); +} + +/** Every fire on record, each row a door into that run's own trace. */ +export function RoutineRunHistory({ + row, + now, +}: { + readonly row: GlobalRoutineRow; + readonly now: number; +}) { + return ( +
+

+ Run history +

+ {row.runs.length === 0 ? ( +

+ This routine has not run yet — on its schedule or by hand. +

+ ) : ( + + + + Triggered by + Status + When + Trace + + + + {row.runs.map((run) => ( + + + + + + + + {formatRelativeTime(run.createdAt, now)} + + {run.triggeredBy === "schedule-failed" ? ( + + Never started + + ) : ( + + View run + + )} + + + ))} + +
+ )} +
+ ); +} + +/** The whole page body, given a resolved routine — pure, so the layout is + * testable without a fetch or a router. */ +export function RoutineDetailPage({ + row, + now, + workflowName, + onRunNow, + onToggleEnabled, + onSaveSchedule, +}: { + readonly row: GlobalRoutineRow; + readonly now: number; + readonly workflowName: string; + readonly onRunNow: () => Promise; + readonly onToggleEnabled: (enabled: boolean) => void; + readonly onSaveSchedule: (expression: string) => Promise; +}) { + const health = routineHealth(row.routine, row.runs); + const latestRunId = + row.runs.find((run) => run.triggeredBy !== "schedule-failed")?.runId ?? + null; + return ( +
+ + + + + } + /> + +
+
+ {/* Keyed on the saved expression: once a save lands, the + editor's draft is stale by definition, so it remounts + against the schedule that is now real. */} + + + +
+ +
+
+
+ ); +} + +function NotFound({ + title, + description, +}: { + readonly title: string; + readonly description: string; +}) { + return ( +
+ + + } + title={title} + description={description} + action={ + + } + /> + +
+ ); +} + +/** The workflow's own display name for `definitionId`. Falls back to the + * id only while the catalog is still loading or when the definition is no + * longer listed — a routine pointing at a retired workflow still has to + * render. */ +function useWorkflowName(row: GlobalRoutineRow | undefined): string { + const tenantId = row?.tenantId ?? ""; + const definitions = useTenantQuery( + [...tenantKeys.routines(tenantId), "definitions"], + tenantId !== "", + () => listWorkflowDefinitions(tenantId), + ); + if (definitions.kind !== "ready" || row === undefined) { + return row?.routine.definitionId ?? ""; + } + const match = definitions.data.find( + (definition) => definition.id === row.routine.definitionId, + ); + return match?.name ?? row.routine.definitionId; +} + +export function RoutineDetailRoute({ slug }: { readonly slug: Slug }) { + const routinesQuery = useGlobalRoutines(); + const invalidate = useInvalidateRoutines(); + const rows = routinesQuery.kind === "ready" ? routinesQuery.data : []; + const matches = rowsForSlug(rows, slug); + const row = matches.length === 1 ? matches[0] : undefined; + const workflowName = useWorkflowName(row); + const now = Date.now(); + + if (routinesQuery.kind === "loading") { + return ( +
+ + + } title="Loading routine…" /> + +
+ ); + } + if (routinesQuery.kind === "error") { + return ; + } + if (matches.length > 1) { + return ( + + ); + } + if (row === undefined) { + return ( + + ); + } + + const resolved = row; + return ( + { + await runRoutineNow(resolved.tenantId, resolved.routine.id); + invalidate(resolved.tenantId); + toast(routineRunStartedToast(resolved.routine.name)); + }} + onToggleEnabled={(enabled) => { + void updateRoutine(resolved.tenantId, resolved.routine.id, { + enabled, + }).then(() => invalidate(resolved.tenantId)); + }} + onSaveSchedule={async (expression) => { + const timezone = timezoneForTrigger(resolved.routine.trigger); + await updateRoutine(resolved.tenantId, resolved.routine.id, { + trigger: + timezone === "UTC" + ? { kind: "cron", expression } + : { kind: "cron", expression, timezone }, + }); + invalidate(resolved.tenantId); + }} + /> + ); +} diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index 1d6bd034b..51cd20152 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -2,16 +2,20 @@ // signed-in account is a member of (CL-6362). Per-workbench routines // chrome (the header's Routines button, the `/run` composer command, and // the canvas pane's list/runs views) is gone — this page is the only -// place to browse and run routines now; a routine's own workbench still -// shows it "where it was made" via in-room notices and run-now approval -// cards, which this page never touches. +// place to browse routines now; a routine's own workbench still shows it +// "where it was made" via in-room notices and run-now approval cards, +// which this page never touches. // -// Visibility resolves through the same membership the sidebar's bench -// switcher uses (`useBench().memberships`, the `/api/me/principals` / -// CL-6332 principal model), filtered to actual benches with -// `classifyBenchMembership` — never just the currently selected one, and -// never creator-scoped: `GET /routines` already lists every routine a -// bench's own grant covers, regardless of who created it. +// The list is an ops table, not a browse list (CL-6418): a routine is a +// workflow on a schedule, so the columns are the questions an operator +// actually arrives with — when does it run next, is it healthy, when did +// it last run and how did that go. Row detail is a real page +// (`/routines/`, `routine-detail-page.tsx`), never an inline +// expansion: run history, the target workflow, and the schedule editor +// all outgrew a row long ago. +// +// Every schedule reads as a sentence (`routineScheduleSentence`), never a +// cron expression — DESIGN.md, Copy. import { Badge, Button, @@ -29,37 +33,40 @@ import { toast, } from "@corbits/react-ui"; import type { BadgeTone } from "@corbits/react-ui"; -import { listWorkbenches } from "@corbits/chat-ui"; -import { - classifyBenchMembership, - listWorkbenchTenantIds, -} from "@corbits/bench-ui"; -import { CaretDown, CaretRight, Clock } from "@corbits/icons"; -import { Fragment, useMemo, useState } from "react"; +import { Clock } from "@corbits/icons"; +import { useEffect } from "react"; import type { KeyboardEvent } from "react"; -import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { APIQuery } from "@corbits/api-query"; +import { + routineHealth, + routineScheduleSentence, +} from "@corbits/routines/client"; +import type { RoutineHealth } from "@corbits/routines/client"; -import type { Principal } from "../api"; import { useBench } from "../bench-context"; -import { routineIdFromPath } from "../path-ids"; +import { + routineDetailPath, + useGlobalRoutines, + useInvalidateRoutines, +} from "../global-routines"; +import type { GlobalRoutineRow } from "../global-routines"; +import { Link } from "../navigation"; import { workbenchPath } from "../workbench-path"; -import { meKeys, tenantKeys } from "../query-client"; -import { cadenceLabel, approximateNextRun } from "../routine-trigger"; +import { routineIdFromPath } from "../path-ids"; +import { ROUTINE_HEALTH_TONE } from "../routine-health-tone"; import { useOpenRoutineInCanvas } from "../shell/canvas-availability"; import { StageTopBar } from "../shell/stage-top-bar"; import { - listRoutineRuns, - listRoutines, routineRunStartedToast, runRoutineNow, updateRoutine, } from "../routines-api"; -import type { Routine, RoutineRun } from "../routines-api"; +import type { RoutineRun } from "../routines-api"; + +export type { GlobalRoutineRow } from "../global-routines"; const RUN_STATUS_TONE: Record = { - running: "success", - completed: "info", + running: "info", + completed: "success", failed: "danger", cancelled: "neutral", }; @@ -112,7 +119,6 @@ export function RunsTable({ {runs.map((run) => { - const status = run.run?.status; const rowProps = workbenchId !== null ? { @@ -127,29 +133,13 @@ export function RunsTable({ }, } : {}; - const hasError = run.error !== undefined && run.error !== null; return ( - - {run.triggeredBy === "schedule-failed" - ? "Failed to start" - : run.triggeredBy} - - {hasError ? ( -

- {run.error} -

- ) : null} +
- {typeof status === "string" ? ( - - {status} - - ) : ( - "—" - )} + {formatRelativeTime(run.createdAt, now)}
@@ -160,183 +150,74 @@ export function RunsTable({ ); } -/** Every bench the signed-in account belongs to — not just the currently - * selected one — the same classification the bench switcher uses so a - * workbench child tenancy or a raw-id row never masquerades as a bench a - * person can browse routines in. */ -function useMemberBenches(): { - readonly kind: "loading" | "ready"; - readonly benches: readonly { tenantId: string; tenantName: string }[]; -} { - const { memberships } = useBench(); - const allMemberships: readonly Principal[] = - memberships.kind === "ready" ? memberships.data.data : []; - const tenantIds = useMemo( - () => allMemberships.map((m) => m.tenantId), - [allMemberships], - ); - const workbenchTenancyKinds = useQuery({ - queryKey: meKeys.workbenchTenancyKinds(tenantIds), - queryFn: () => listWorkbenchTenantIds(tenantIds), - enabled: tenantIds.length > 0, - }); - const benches = useMemo( - () => - allMemberships - .filter( - (m) => - classifyBenchMembership( - m, - workbenchTenancyKinds.data ?? new Set(), - ) === "bench", - ) - .map((m) => ({ tenantId: m.tenantId, tenantName: m.tenantName })), - [allMemberships, workbenchTenancyKinds.data], +/** What started a fire, plus the launch failure's own message when the + * fire never produced a run at all. Shared by the roster's run table and + * the detail page's history table. */ +export function TriggeredByCell({ run }: { readonly run: RoutineRun }) { + const hasError = run.error !== undefined && run.error !== null; + return ( + <> + + {run.triggeredBy === "schedule-failed" + ? "Failed to start" + : run.triggeredBy} + + {hasError ? ( +

+ {run.error} +

+ ) : null} + ); - if (memberships.kind !== "ready") return { kind: "loading", benches: [] }; - return { kind: "ready", benches }; } -export type GlobalRoutineRow = { - readonly routine: Routine; - readonly tenantId: string; - readonly tenantName: string; - readonly deliveryWorkbenchName: string | null; - readonly runs: readonly RoutineRun[]; -}; - -type BenchRoutinesData = { - readonly routines: readonly Routine[]; - readonly workbenchNames: ReadonlyMap; - readonly runHistories: ReadonlyMap; -}; - -async function fetchBenchRoutinesData( - tenantId: string, -): Promise { - const [routines, workbenches] = await Promise.all([ - listRoutines(tenantId), - listWorkbenches(tenantId, "workbench"), - ]); - const runHistoryEntries = await Promise.all( - routines.map( - async (r) => [r.id, await listRoutineRuns(tenantId, r.id)] as const, - ), - ); - return { - routines, - workbenchNames: new Map(workbenches.map((w) => [w.id, w.title])), - runHistories: new Map(runHistoryEntries), - }; +/** A fire's settled run status, or a dash when the platform has no run to + * report (a launch that never got that far). */ +export function RunStatusCell({ run }: { readonly run: RoutineRun }) { + const status = run.run?.status; + if (typeof status !== "string") { + return —; + } + return {status}; } -/** Every routine across every bench the account belongs to, flattened - * into one list with its own workbench attribution — the aggregation - * `GET /routines` doesn't do server-side (it's tenant-scoped, per bench), - * done the cheapest correct client-side way: one fetch per bench, run in - * parallel. */ -function useGlobalRoutines(): APIQuery { - const { kind: benchesKind, benches } = useMemberBenches(); - const results = useQueries({ - queries: benches.map((bench) => ({ - queryKey: [...tenantKeys.routines(bench.tenantId), "global-page"], - queryFn: () => fetchBenchRoutinesData(bench.tenantId), - })), - }); - - if (benchesKind === "loading") return { kind: "loading" }; - if (results.some((r) => r.isLoading)) return { kind: "loading" }; - const failed = results.find((r) => r.isError); - if (failed !== undefined) { - return { - kind: "error", - message: - failed.error instanceof Error - ? failed.error.message - : "Couldn't load routines.", - retry: () => { - for (const result of results) void result.refetch(); - }, - }; - } +/** A routine's health, from the telemetry the scheduler already records — + * the same reading the detail page's health rail shows, never a second + * opinion. */ +export function routineRowHealth(row: GlobalRoutineRow): RoutineHealth { + return routineHealth(row.routine, row.runs); +} - const rows: GlobalRoutineRow[] = []; - benches.forEach((bench, index) => { - const data = results[index]?.data; - if (data === undefined) return; - for (const routine of data.routines) { - rows.push({ - routine, - tenantId: bench.tenantId, - tenantName: bench.tenantName, - deliveryWorkbenchName: - routine.deliveryWorkbenchId !== null - ? (data.workbenchNames.get(routine.deliveryWorkbenchId) ?? null) - : null, - runs: data.runHistories.get(routine.id) ?? [], - }); - } - }); - return { kind: "ready", data: rows }; +/** "At 09:00, Monday through Friday (UTC)" — the schedule as a sentence, + * for every routine shape including a raw cron expression. */ +export function scheduleSentence(row: GlobalRoutineRow): string { + return routineScheduleSentence(row.routine.trigger); } -/** Idle/On/Off/Paused/Running/Failed — every row's own running-or-not - * state at a glance, never a separate detail hop to find out. */ -export function routineStateChip(row: GlobalRoutineRow): { - readonly label: string; - readonly tone: BadgeTone; -} { - if (!row.routine.enabled) return { label: "Off", tone: "neutral" }; - if (row.routine.deadLetteredAt !== null) { - return { label: "Paused", tone: "danger" }; - } - const latest = row.runs[0]; - if (latest === undefined) return { label: "Idle", tone: "neutral" }; - const status = latest.run?.status; - if (status === "running") return { label: "Running now", tone: "success" }; - if ( - (latest.error !== undefined && latest.error !== null) || - status === "failed" - ) { - return { label: "Last run failed", tone: "danger" }; - } - return { label: "On", tone: "success" }; +/** + * When this routine fires next, read off the scheduler's own `nextFireAt` + * clock rather than re-derived in the browser — a routine that is off, + * dead-lettered, manual, or webhook-driven honestly has no next run, and + * says so. + */ +export function nextRunLabel(row: GlobalRoutineRow, now: number): string { + const { nextFireAt } = row.routine; + if (nextFireAt === null) return "Not scheduled"; + return formatRelativeTime(nextFireAt, now); } -/** "Daily at 09:00 UTC, next in 3 hours" — consumer language throughout, - * never a raw cron string. `approximateNextRun` and `cadenceLabel` are - * this codebase's one source for either half. */ -export function scheduleSummary(row: GlobalRoutineRow, now: number): string { - const label = cadenceLabel(row.routine.trigger); - const next = approximateNextRun(row.routine.trigger, new Date(now)); - if (next === null) return label; - return `${label} · next ${formatRelativeTime(next.toISOString(), now)}`; +/** The newest fire on record — what "last run" means in the list. */ +export function latestFire(row: GlobalRoutineRow): RoutineRun | undefined { + return row.runs[0]; } -function RoutineRowDetail({ - row, - now, - onOpenWorkbench, -}: { - readonly row: GlobalRoutineRow; - readonly now: number; - readonly onOpenWorkbench: (workbenchId: string) => void; -}) { +function HealthCell({ health }: { readonly health: RoutineHealth }) { return ( -
- {row.deliveryWorkbenchName !== null ? ( -

- Run updates post into {row.deliveryWorkbenchName}. -

- ) : null} - +
+ {health.label} + + {health.caption} +
); } @@ -344,20 +225,14 @@ function RoutineRowDetail({ export function GlobalRoutinesList({ rows, now, - expandedId, - onToggleExpanded, onToggleEnabled, onRunNow, - onEdit, onOpenWorkbench, }: { readonly rows: readonly GlobalRoutineRow[]; readonly now: number; - readonly expandedId: string | null; - readonly onToggleExpanded: (routineId: string) => void; readonly onToggleEnabled: (row: GlobalRoutineRow, enabled: boolean) => void; readonly onRunNow: (row: GlobalRoutineRow) => Promise; - readonly onEdit: (row: GlobalRoutineRow) => void; readonly onOpenWorkbench: (workbenchId: string) => void; }) { if (rows.length === 0) { @@ -374,105 +249,94 @@ export function GlobalRoutinesList({ Routine - Delivers to Schedule - Status - Enabled + Next run + Health + Last run + Delivers to + On Actions {rows.map((row) => { - const chip = routineStateChip(row); - const expanded = expandedId === row.routine.id; + const health = routineRowHealth(row); + const detailPath = routineDetailPath(row.routine.name); + const lastRun = latestFire(row); return ( - - - - - - - {row.routine.deliveryWorkbenchId !== null && - row.deliveryWorkbenchName !== null ? ( - ) : ( - — + + {row.routine.name} + )} - - - {scheduleSummary(row, now)} - - - {chip.label} - - - onToggleEnabled(row, enabled)} - /> - - -
- onRunNow(row)} - /> - -
-
-
- {expanded ? ( - - - - - - ) : null} -
+ + {row.tenantName} + + + + + {scheduleSentence(row)} + + + {nextRunLabel(row, now)} + + + + + + {lastRun === undefined ? ( + + Never + + ) : ( + + + {formatRelativeTime(lastRun.createdAt, now)} + + + + )} + + + {row.routine.deliveryWorkbenchId !== null && + row.deliveryWorkbenchName !== null ? ( + + ) : ( + — + )} + + + onToggleEnabled(row, enabled)} + /> + + + onRunNow(row)} + /> + + ); })}
@@ -480,6 +344,27 @@ export function GlobalRoutinesList({ ); } +/** + * `/routines/` used to expand a row on this page. Detail lives at + * `/routines/` now, so an id deep link (a context menu's "Open + * routine", an old bookmark) resolves the routine and hops to its page — + * an old link always lands somewhere real (DESIGN.md, Pages & Routing). + */ +function useRoutineIdDeepLink( + path: string, + rows: readonly GlobalRoutineRow[], + navigate: (to: string) => void, +): void { + const routineId = routineIdFromPath(path); + const match = rows.find((row) => row.routine.id === routineId); + const target = + match === undefined ? null : routineDetailPath(match.routine.name); + useEffect(() => { + if (target === null) return; + navigate(target); + }, [target, navigate]); +} + export function RoutinesRoute({ path, navigate, @@ -488,25 +373,13 @@ export function RoutinesRoute({ readonly navigate: (to: string) => void; }) { const routinesQuery = useGlobalRoutines(); - const queryClient = useQueryClient(); + const invalidate = useInvalidateRoutines(); const openRoutine = useOpenRoutineInCanvas(); const { selectTenant } = useBench(); - const deepLinkedId = routineIdFromPath(path); - const [expandedId, setExpandedId] = useState(deepLinkedId); const now = Date.now(); const rows = routinesQuery.kind === "ready" ? routinesQuery.data : []; - - function invalidate(tenantId: string) { - void queryClient.invalidateQueries({ - queryKey: [...tenantKeys.routines(tenantId), "global-page"], - }); - } - - function openWorkbench(tenantId: string, workbenchId: string) { - selectTenant(tenantId); - navigate(workbenchPath(workbenchId)); - } + useRoutineIdDeepLink(path, rows, navigate); return (
@@ -514,7 +387,7 @@ export function RoutinesRoute({ crumbs={[{ label: "Routines" }]} subtitle={ routinesQuery.kind === "ready" - ? `${rows.length} automation${rows.length === 1 ? "" : "s"} across your workbenches` + ? `${String(rows.length)} automation${rows.length === 1 ? "" : "s"} across your workbenches` : null } actions={ @@ -540,12 +413,6 @@ export function RoutinesRoute({ - setExpandedId((current) => - current === routineId ? null : routineId, - ) - } onToggleEnabled={(row, enabled) => { void updateRoutine(row.tenantId, row.routine.id, { enabled, @@ -556,20 +423,13 @@ export function RoutinesRoute({ invalidate(row.tenantId); toast(routineRunStartedToast(row.routine.name)); }} - onEdit={(row) => - openRoutine({ - routineId: row.routine.id, - ...(row.routine.deliveryWorkbenchId !== null - ? { workbenchId: row.routine.deliveryWorkbenchId } - : {}), - }) - } onOpenWorkbench={(workbenchId) => { const row = rows.find( (r) => r.routine.deliveryWorkbenchId === workbenchId, ); if (row === undefined) return; - openWorkbench(row.tenantId, workbenchId); + selectTenant(row.tenantId); + navigate(workbenchPath(workbenchId)); }} /> )} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 99ee7c83c..708f072b5 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -90,9 +90,8 @@ const PluginDetailPlaceholder = lazy(async () => ({ default: (await import("./pages/detail-placeholders")) .PluginDetailPlaceholder, })); -const RoutineDetailPlaceholder = lazy(async () => ({ - default: (await import("./pages/detail-placeholders")) - .RoutineDetailPlaceholder, +const RoutineDetailRoute = lazy(async () => ({ + default: (await import("./pages/routine-detail-page")).RoutineDetailRoute, })); /** The signed-out screen (CL-6369) — a real route, not a conditional swap: @@ -247,9 +246,7 @@ export const APP_ROUTES: readonly AppRoute[] = [ label: "Routine", icon: , render: (path: string) => ( - + ), }, { diff --git a/apps/web/src/routine-health-tone.ts b/apps/web/src/routine-health-tone.ts new file mode 100644 index 000000000..654179bd5 --- /dev/null +++ b/apps/web/src/routine-health-tone.ts @@ -0,0 +1,22 @@ +// One state → tone table for a routine's health, shared by the Routines +// list's pill and the detail page's health rail. DESIGN.md's State Pills +// rule is what makes this a table and not two inline ternaries: the four +// live states never share a colour, so the mapping has to exist in exactly +// one place or two screens will disagree about what "failing" looks like. +// +// `failing` (still scheduled, still retrying) reads warning; `paused` +// (dead-lettered, the scheduler gave up) reads danger — a routine that has +// stopped for good is not the same signal as one having a bad morning. +import type { BadgeTone } from "@corbits/react-ui"; +import type { RoutineHealthState } from "@corbits/routines/client"; + +export const ROUTINE_HEALTH_TONE: Readonly< + Record +> = { + off: "neutral", + idle: "neutral", + ok: "success", + running: "info", + failing: "warning", + paused: "danger", +}; diff --git a/bun.lock b/bun.lock index 1f25be0b0..dc910c30f 100644 --- a/bun.lock +++ b/bun.lock @@ -1028,12 +1028,14 @@ "version": "0.0.1", "dependencies": { "@corbits/folded-runs": "workspace:*", + "@corbits/slug": "workspace:*", "@corbits/workflow-catalog": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/log": "0.3.0", "arktype": "catalog:", + "cronstrue": "^3.24.0", "drizzle-orm": "catalog:", "hono": "catalog:", "postgres": "catalog:", @@ -2557,6 +2559,8 @@ "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + "cronstrue": ["cronstrue@3.24.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], diff --git a/packages/routines/package.json b/packages/routines/package.json index bb0b40e2b..72bc52fe1 100644 --- a/packages/routines/package.json +++ b/packages/routines/package.json @@ -17,6 +17,8 @@ }, "dependencies": { "@corbits/folded-runs": "workspace:*", + "@corbits/slug": "workspace:*", + "cronstrue": "^3.24.0", "@corbits/workflow-catalog": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", diff --git a/packages/routines/src/client.ts b/packages/routines/src/client.ts index 42deafe25..3468ed964 100644 --- a/packages/routines/src/client.ts +++ b/packages/routines/src/client.ts @@ -9,10 +9,25 @@ // so a browser caller has one import for the whole client surface. import { type } from "arktype"; +import { slugify } from "@corbits/slug"; import { RoutineTriggerWire, type RoutineTriggerT } from "./trigger"; export { suggestRoutineNameFromPrompt } from "./suggest-name"; +export { cronSentence, routineScheduleSentence } from "./schedule-language"; +export { + cleanFireStreak, + fireFailed, + lastFailedFire, + medianFireDurationMs, + routineHealth, +} from "./health"; +export type { + RoutineFire, + RoutineHealth, + RoutineHealthState, + RoutineHealthSubject, +} from "./health"; export { RoutineTrigger, RoutineTriggerWire, @@ -58,11 +73,34 @@ export const Routine = type({ // — the scheduler stops claiming this routine until a person // re-enables or edits it. `null` means still scheduling normally. deadLetteredAt: "string | null", + // The scheduler's own due-fire clock, surfaced rather than recomputed: + // a UI that re-derives "next run" from the trigger is guessing, while + // this is the instant the scheduler will actually test against. `null` + // for a routine that never auto-fires (manual, webhook, run-once) or + // one that is disabled or dead-lettered. + nextFireAt: "string | null", + // The last time it actually fired on its schedule. `null` until it has. + lastFireAt: "string | null", createdAt: "string", updatedAt: "string", }); export type Routine = typeof Routine.infer; +/** + * A routine's URL-facing name, for `/routines/`. Derived from the + * display name rather than stored: routines predate slug-addressed detail + * routes and carry no slug column, and DESIGN.md's rule for exactly this + * case is that a route without a guaranteed-unique slug falls back to an + * opaque id — which `/routines/` already is, since an id (`rtn_1`) is + * not slug-shaped and so resolves to the roster instead. Empty string for + * a name with nothing sluggable in it (emoji, a non-Latin script); a + * caller renders that routine without a detail link rather than linking + * to a path that cannot resolve. + */ +export function routineSlug(name: string): string { + return slugify(name); +} + export const RoutinesResponse = type({ items: Routine.array() }); export const RoutineRun = type({ diff --git a/packages/routines/src/health.ts b/packages/routines/src/health.ts new file mode 100644 index 000000000..e1c548c06 --- /dev/null +++ b/packages/routines/src/health.ts @@ -0,0 +1,215 @@ +// A routine's health, read off telemetry the scheduler already stores: +// `enabled` / `consecutiveFailures` / `deadLetteredAt` on the routine row +// and the per-fire history rows (`routine_run`). Nothing here needs a new +// column — this module is the reading, not the recording. +// +// It lives in the package, not in the Routines page, because "what counts +// as healthy" is a product rule about routines: the list's state pill, the +// detail page's health rail, and anything else that ever renders a +// routine's condition have to agree, and they can only agree if there is +// one function that decides. + +/** The per-fire history row this module reads — structurally the subset of + * `./client`'s `RoutineRun` that says whether a fire worked and how long + * it took. Declared here rather than imported so `./client` can re-export + * this module without a cycle. */ +export type RoutineFire = { + readonly runId: string; + readonly triggeredBy: string; + readonly createdAt: string; + readonly error?: string | null; + readonly run?: Record; +}; + +/** The routine fields health depends on — every one of them already + * persisted (see ./schema.ts). */ +export type RoutineHealthSubject = { + readonly enabled: boolean; + readonly consecutiveFailures: number; + readonly deadLetteredAt: string | null; +}; + +/** + * Six states, each its own pill colour and its own words. `paused` is + * dead-lettered — the scheduler gave up after `MAX_ROUTINE_FIRE_FAILURES` + * and will not claim the routine again until a person re-enables or edits + * it — which is a different fact from `off` (someone turned it off) and + * from `failing` (still scheduled, still retrying). + */ +export type RoutineHealthState = + "off" | "paused" | "running" | "failing" | "idle" | "ok"; + +export type RoutineHealth = { + readonly state: RoutineHealthState; + /** The pill's words. A pill is never the only signal (DESIGN.md, State + * Pills) — `caption` says the same thing in a full phrase. */ + readonly label: string; + readonly caption: string; + /** Successful fires since the most recent failed one. */ + readonly cleanStreak: number; + readonly consecutiveFailures: number; + readonly lastFailure: { + readonly at: string; + readonly error: string | null; + } | null; + /** Median wall-clock duration of the finished fires in `fires` — `null` + * when none of them recorded both ends. */ + readonly medianDurationMs: number | null; +}; + +function statusOf(fire: RoutineFire): string | null { + const status = fire.run?.status; + return typeof status === "string" ? status : null; +} + +/** A fire failed when it recorded a launch error (the synthetic + * `schedule-failed` row) or its run settled as failed. */ +export function fireFailed(fire: RoutineFire): boolean { + if (fire.error !== undefined && fire.error !== null) return true; + return statusOf(fire) === "failed"; +} + +function fireSucceeded(fire: RoutineFire): boolean { + return !fireFailed(fire) && statusOf(fire) === "completed"; +} + +/** Successful fires from the newest backwards, stopping at the first + * failure — the "N clean runs" streak, not a lifetime total. */ +export function cleanFireStreak(fires: readonly RoutineFire[]): number { + let streak = 0; + for (const fire of fires) { + if (fireFailed(fire)) break; + if (fireSucceeded(fire)) streak += 1; + } + return streak; +} + +/** The most recent failed fire, or `null` when none of the history failed. */ +export function lastFailedFire( + fires: readonly RoutineFire[], +): { readonly at: string; readonly error: string | null } | null { + const failed = fires.find(fireFailed); + if (failed === undefined) return null; + return { at: failed.createdAt, error: failed.error ?? null }; +} + +function durationOf(fire: RoutineFire): number | null { + const endedAt = fire.run?.endedAt; + if (typeof endedAt !== "string") return null; + const startedRaw = fire.run?.createdAt; + const started = Date.parse( + typeof startedRaw === "string" ? startedRaw : fire.createdAt, + ); + const ended = Date.parse(endedAt); + if (Number.isNaN(started) || Number.isNaN(ended)) return null; + return ended < started ? null : ended - started; +} + +/** + * Median duration over the fires that recorded both a start and an end. + * Median, not mean: one run that hung for an hour should not make a + * routine that normally takes twelve seconds look slow. + */ +export function medianFireDurationMs( + fires: readonly RoutineFire[], +): number | null { + const durations = fires + .map(durationOf) + .filter((value): value is number => value !== null) + .sort((a, b) => a - b); + if (durations.length === 0) return null; + const middle = Math.floor(durations.length / 2); + if (durations.length % 2 === 1) return durations[middle] ?? null; + const lower = durations[middle - 1]; + const upper = durations[middle]; + if (lower === undefined || upper === undefined) return null; + return Math.round((lower + upper) / 2); +} + +function pluralRuns(count: number): string { + return count === 1 ? "1 run" : `${String(count)} runs`; +} + +function stateAndWords( + routine: RoutineHealthSubject, + fires: readonly RoutineFire[], + streak: number, +): { + readonly state: RoutineHealthState; + readonly label: string; + readonly caption: string; +} { + if (!routine.enabled) { + return { + state: "off", + label: "Off", + caption: "Turned off — it will not run on its schedule.", + }; + } + if (routine.deadLetteredAt !== null) { + return { + state: "paused", + label: "Paused after failures", + caption: + "Too many failures in a row — paused until someone resumes or edits it.", + }; + } + const latest = fires[0]; + if (latest !== undefined && statusOf(latest) === "running") { + return { + state: "running", + label: "Running now", + caption: "A run is in progress.", + }; + } + if (routine.consecutiveFailures > 0) { + return { + state: "failing", + label: "Failing", + caption: `${pluralRuns(routine.consecutiveFailures)} failed in a row — still retrying.`, + }; + } + if (latest !== undefined && fireFailed(latest)) { + return { + state: "failing", + label: "Last run failed", + caption: "The most recent run failed.", + }; + } + if (fires.length === 0) { + return { + state: "idle", + label: "Not run yet", + caption: "This routine has never run.", + }; + } + return { + state: "ok", + label: "Healthy", + caption: + streak === 0 + ? "No failures on record." + : `${pluralRuns(streak)} in a row without a failure.`, + }; +} + +/** + * A routine's health from its own row plus its fire history (newest + * first, as `GET /routines/:id/runs` returns it). + */ +export function routineHealth( + routine: RoutineHealthSubject, + fires: readonly RoutineFire[], +): RoutineHealth { + const cleanStreak = cleanFireStreak(fires); + const words = stateAndWords(routine, fires, cleanStreak); + return { + state: words.state, + label: words.label, + caption: words.caption, + cleanStreak, + consecutiveFailures: routine.consecutiveFailures, + lastFailure: lastFailedFire(fires), + medianDurationMs: medianFireDurationMs(fires), + }; +} diff --git a/packages/routines/src/routes.ts b/packages/routines/src/routes.ts index 4efef23de..9e6ce86ce 100644 --- a/packages/routines/src/routes.ts +++ b/packages/routines/src/routes.ts @@ -298,6 +298,8 @@ export function routineView(row: RoutineRow) { deliveryWorkbenchId: row.deliveryWorkbenchId, consecutiveFailures: row.consecutiveFailures, deadLetteredAt: row.deadLetteredAt?.toISOString() ?? null, + nextFireAt: row.nextFireAt?.toISOString() ?? null, + lastFireAt: row.lastFireAt?.toISOString() ?? null, presetKey: row.presetKey, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), diff --git a/packages/routines/src/schedule-language.ts b/packages/routines/src/schedule-language.ts new file mode 100644 index 000000000..9f8242866 --- /dev/null +++ b/packages/routines/src/schedule-language.ts @@ -0,0 +1,62 @@ +// Every schedule a person reads, as a sentence. DESIGN.md's Copy rule is +// absolute: "cron expressions render as human sentences ('every weekday +// at 9am'), never as the raw expression, in any surface a person reads +// them" — and `routineCadenceLabel`'s `cron` branch broke it, printing +// `Cron: 0 9 * * 1-5` verbatim because a preset-shaped switch has no way +// to read an arbitrary expression. +// +// `cronstrue` (MIT, zero runtime dependencies, browser-safe) is the +// normalizer, not a hand-rolled one: the product's own cron escape hatch +// accepts any 5-field expression `./cron.ts` validates, so the renderer +// has to cover the same grammar rather than the handful of shapes +// `cronExpressionForTrigger` happens to emit. +// +// Times render 24-hour to match `routineCadenceLabel`'s existing clock +// format, and the timezone is appended parenthetically — the wall clock a +// sentence describes is meaningless without the zone it is read in. +import { toString as describeCronExpression } from "cronstrue"; + +import { cronExpressionForTrigger, timezoneForTrigger } from "./trigger"; +import type { RoutineTriggerT } from "./trigger"; + +/** + * A raw 5-field cron expression as an English sentence, with its + * timezone named — `null` when the expression is not describable, so a + * caller can show the person their own invalid input instead of a + * confident sentence about a schedule that will never fire. + */ +export function cronSentence( + expression: string, + timezone: string = "UTC", +): string | null { + let described: string; + try { + described = describeCronExpression(expression, { + verbose: false, + use24HourTimeFormat: true, + throwExceptionOnParseError: true, + }); + } catch { + return null; + } + if (described === "") return null; + return `${described} (${timezone})`; +} + +/** + * Any trigger shape as one human sentence — the single rendering every + * routines surface uses for "when does this run". Clock-driven triggers + * (interval, daily, weekly, raw cron) route through the canonical cron + * expression the scheduler itself fires on, so the sentence can never + * describe a cadence the scheduler wouldn't also compute. + */ +export function routineScheduleSentence(trigger: RoutineTriggerT): string { + if (trigger === null) return "On demand only"; + if (trigger.kind === "webhook") return "When its webhook receives a delivery"; + if (trigger.kind === "once") return "Once, when it was created"; + const sentence = cronSentence( + cronExpressionForTrigger(trigger), + timezoneForTrigger(trigger), + ); + return sentence ?? "Schedule not readable"; +} From 0f834c6bbf73ec7bc1e0906670b4f3bf9a4e7c13 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 16:34:52 -0700 Subject: [PATCH 3/4] Add tests for id-addressed routines and the review round's fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two canon violations and a set of honesty gaps. The tests come first, and each one names the behaviour it pins rather than the implementation: - `resolveRoutineSegment`: an id renders directly, a name redirects to the id, a shared name resolves to neither and offers both by id, an unknown segment is gone. Plus the mounted route through each branch — including that an id wins over a routine whose *name* slugs to another routine's id. - "Last run" is the newest fire history row, so a run-now-only routine (which never gets a `lastFireAt`) does not report "never run" beside its own runs. - A payload from a hub that doesn't send `nextFireAt` yet still parses: deploy skew must not blank the routines surface over a display value. - A refused save says so beside the field and keeps the draft; trailing whitespace is not a change; a valid-but-undescribable expression cannot be saved under error copy. - A past-due next run reads "Overdue", not elapsed time. - Statuses and fire causes read as words, not as column values. - Cron sentences are asserted by contract — opens with At/Every, names the zone only when there is a wall clock, never contains the raw expression — not by `cronstrue`'s exact phrasing, so upgrading the dependency cannot redden a green suite without a behaviour change. The deleted `routineCadenceLabel`/`routineCadenceSummary` tests go with their subject, and the routine detail path is no longer a placeholder, so `/routines/` gets its own real assertion rather than sharing the placeholder table. Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti --- apps/web/src/insights-stats.test.ts | 1 - apps/web/src/routine-trigger.ts | 61 ---- apps/web/test/routes.test.tsx | 19 +- apps/web/test/routine-detail-page.test.tsx | 321 +++++++++++++++++- apps/web/test/routine-panel.test.tsx | 3 +- apps/web/test/routine-trigger.test.ts | 106 ------ apps/web/test/routines-page.test.tsx | 62 +++- packages/routines/src/client.test.ts | 50 ++- packages/routines/src/health.test.ts | 17 + packages/routines/src/run-language.test.ts | 45 +++ .../routines/src/schedule-language.test.ts | 116 +++++-- packages/routines/src/trigger.test.ts | 134 +------- .../src/workflow-routine-routes.test.ts | 4 +- packages/routines/test/routes.test.ts | 9 +- 14 files changed, 590 insertions(+), 358 deletions(-) delete mode 100644 apps/web/src/routine-trigger.ts delete mode 100644 apps/web/test/routine-trigger.test.ts create mode 100644 packages/routines/src/run-language.test.ts diff --git a/apps/web/src/insights-stats.test.ts b/apps/web/src/insights-stats.test.ts index 9e8d6bcac..76a07194b 100644 --- a/apps/web/src/insights-stats.test.ts +++ b/apps/web/src/insights-stats.test.ts @@ -60,7 +60,6 @@ function routine( consecutiveFailures: 0, deadLetteredAt: null, nextFireAt: null, - lastFireAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...partial, diff --git a/apps/web/src/routine-trigger.ts b/apps/web/src/routine-trigger.ts deleted file mode 100644 index 5f50ec410..000000000 --- a/apps/web/src/routine-trigger.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Renders a `RoutineTrigger` (the wire shape `@corbits/routines` defines -// in packages/routines/src/trigger.ts) into what the Routines page shows: -// a plain-language cadence and a best-effort next-run estimate. -// -// The cadence line itself is `routineCadenceLabel` from -// `@corbits/routines/trigger` — the same subpath `routines-page.tsx` -// pulls `routineMatchesModeFilter` from, and for the same reason: a -// product rule about how a cadence reads belongs with the routines -// domain, not this app. `nextCronFireAfter` from `@corbits/routines/cron` -// is the exact same minute-by-minute search the hub's scheduler runs -// against the exact same rendered cron expression — never a second, -// hand-rolled matcher that could drift from what actually fires. -// `cronExpressionForTrigger`/`timezoneForTrigger` are the same package's -// own preset-to-cron renderer — this module used to hand-roll a second -// copy of that switch; it now reuses the one the scheduler and the -// schedule editor both already depend on. Neither subpath (not the -// package's default export) pulls in `drizzle-orm` and `postgres` through -// `store.ts`, which have no business in a browser bundle. -import { nextCronFireAfter } from "@corbits/routines/cron"; -import { - cronExpressionForTrigger, - routineCadenceLabel, - routineCadenceSummary, - timezoneForTrigger, -} from "@corbits/routines/trigger"; -import type { RoutineTrigger } from "./routines-api"; - -export const cadenceLabel = routineCadenceLabel; -export const cadenceSummary = routineCadenceSummary; - -/** - * A best-effort next-fire estimate for display only — never fed back - * into a launch decision, which is the scheduler's job against the real - * clock. Returns `null` for a manual or webhook routine (neither fires - * on a clock), or when the expression has no fire inside the lookahead - * window. - * - * Raw cron is estimated the same way presets are: same package, same - * timezone semantics as the hub. - */ -export function approximateNextRun( - trigger: RoutineTrigger, - now: Date, -): Date | null { - if ( - trigger === null || - trigger.kind === "webhook" || - trigger.kind === "once" - ) { - return null; - } - try { - return nextCronFireAfter( - cronExpressionForTrigger(trigger), - now, - timezoneForTrigger(trigger), - ); - } catch { - return null; - } -} diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index f58563cdb..86bfa5472 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -147,7 +147,7 @@ describe("route table", () => { "/new", "/w", "/inbox", - "/routines/:slug", + "/routines/:routine", "/routines", "/files", "/library", @@ -242,7 +242,9 @@ describe("route table", () => { expect(routeFor("/routines/weekly-digest")).toBe(ROUTINE_DETAIL_PATH); expect(routeFor("/agents/wfd_1")).toBe("/agents"); expect(routeFor("/skills/skill_1")).toBe("/skills"); - expect(routeFor("/routines/wfr_1")).toBe("/routines"); + // Routines are the one roster addressed by id: the detail route + // claims any single segment (see ROUTINE_DETAIL_PATH). + expect(routeFor("/routines/rtn_1")).toBe(ROUTINE_DETAIL_PATH); }); test("the Plugins roster owns its bare path and slug details, nothing else", () => { @@ -258,6 +260,8 @@ describe("route table", () => { const routeFor = (path: string) => APP_ROUTES.find((candidate) => matchesRoute(candidate.path, path))?.path; expect(routeFor("/plugins/%")).toBeUndefined(); + // A segment that cannot be decoded names no routine, so the detail + // route declines it and the roster answers instead. expect(routeFor("/routines/%E0%A4%A")).toBe("/routines"); expect(matchesRoute(PLUGIN_DETAIL_PATH, "/plugins/%")).toBe(false); expect(matchesRoute(ROUTINE_DETAIL_PATH, "/routines/%E0%A4%A")).toBe(false); @@ -356,7 +360,6 @@ describe("routes render", () => { test.each([ ["/skills/pr-review", "pr-review", "Skills"], ["/plugins/linear", "linear", "Plugins"], - ["/routines/weekly-digest", "weekly-digest", "Routines"], ])( "%s titles the detail placeholder %s with its roster row lit", async (path, slug, footerLabel) => { @@ -367,6 +370,16 @@ describe("routes render", () => { }, ); + test("a routine segment that resolves to nothing still titles itself and lights Routines", async () => { + // Routines is a real page now, not a placeholder: with no routine + // behind the segment it says so and offers the way back, rather than + // rendering the roster under a URL that names nothing. + const markup = await renderApp("/routines/weekly-digest"); + expect(stagePageTitle(markup)).toBe("weekly-digest"); + expect(markup).toContain("Back to Routines"); + expect(activeFooterLabel(markup)).toBe("Routines"); + }); + test("a slug-shaped path under no known entity renders the not-found screen", async () => { const markup = await renderApp("/agent/triage-bot"); expect(markup).toContain("Page not found"); diff --git a/apps/web/test/routine-detail-page.test.tsx b/apps/web/test/routine-detail-page.test.tsx index ccdeb063b..8763b3e85 100644 --- a/apps/web/test/routine-detail-page.test.tsx +++ b/apps/web/test/routine-detail-page.test.tsx @@ -1,10 +1,15 @@ -// `/routines/` (CL-6418): the routine's own page — schedule as a +// `/routines/` (CL-6418): the routine's own page — schedule as a // sentence with the raw cron editable behind it, the target workflow with // a way through to its steps, the whole fire history deep-linking into the // run surface, and a health rail off telemetry the scheduler already // records. Lifecycle actions (Run now, Pause/Resume) sit in the top bar's // action slot and are wired to the routines package's existing mutations — // never a button that does nothing. +// +// The address is the id; a name resolves onto it. `resolveRoutineSegment` +// is where that lives, and every one of its branches — found, redirect, +// two routines with one name, and nothing at all — is covered below, +// because those are the branches a person actually arrives through. import { describe, expect, test } from "bun:test"; import { act, createElement } from "react"; @@ -14,6 +19,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { NavigationProvider } from "../src/navigation"; import { + resolveRoutineSegment, RoutineDetailPage, RoutineScheduleSection, } from "../src/pages/routine-detail-page"; @@ -35,7 +41,6 @@ const routine: Routine = { consecutiveFailures: 0, deadLetteredAt: null, nextFireAt: "2026-01-02T09:00:00.000Z", - lastFireAt: "2026-01-01T09:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }; @@ -138,6 +143,19 @@ describe("RoutineDetailPage", () => { expect(markup).toContain("sidecar unreachable"); }); + test("last run reads off the fire history, so a run-now-only routine never says Never beside its own runs", () => { + // `lastFireAt` is written only on a scheduled claim; a manual run + // still shows up here, because both surfaces read the newest history + // row instead. + const markup = renderPage({ + routine: { ...routine, trigger: null, nextFireAt: null }, + runs: [{ ...completedRun, triggeredBy: "manual" }], + }); + expect(markup).toContain("Last run"); + expect(markup).not.toContain("Never"); + expect(markup).toContain("By hand"); + }); + test("an enabled routine offers Pause; a disabled one offers Resume", () => { expect(renderPage()).toContain("Pause"); expect(renderPage({ routine: { ...routine, enabled: false } })).toContain( @@ -336,4 +354,303 @@ describe("RoutineScheduleSection", () => { expect(markup).toContain("On demand only"); expect(markup).not.toContain("Cron expression"); }); + + test("trailing whitespace is not a change, and does not block Save either way", () => { + const { container, root } = mount(() => Promise.resolve()); + try { + type(container, "0 9 * * * "); + expect(saveButton(container).disabled).toBe(true); + type(container, " 30 9 * * * "); + expect(saveButton(container).disabled).toBe(false); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + + test("a valid-but-undescribable expression cannot be saved either", () => { + // Validity and describability are the same question — "will this do + // what it reads like?" — so an enabled Save under error copy would be + // a lie about what is about to happen. + const { container, root } = mount(() => Promise.resolve()); + try { + type(container, "0 9 30 2 *"); + const enabled = !saveButton(container).disabled; + const readable = !container.textContent?.includes( + "isn't a schedule this can run", + ); + expect(enabled).toBe(readable); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + + test("a refused save says so next to the field and keeps the draft", async () => { + const { container, root } = mount(() => Promise.reject(new Error("nope"))); + try { + type(container, "30 6 * * *"); + await act(async () => { + saveButton(container).click(); + await Promise.resolve(); + }); + expect(container.textContent).toContain("Not saved"); + const input = container.querySelector("input") as HTMLInputElement; + expect(input.value).toBe("30 6 * * *"); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); +}); + +describe("resolveRoutineSegment", () => { + const mine = row(); + const theirs = row({ + routine: { ...routine, id: "rtn_2", name: "Morning brief" }, + tenantName: "Beta Team", + }); + + test("an id renders the page directly — no redirect hop", () => { + expect(resolveRoutineSegment([mine], "rtn_1")).toEqual({ + kind: "found", + row: mine, + }); + }); + + test("a name redirects to the id, so the durable address is what sticks", () => { + expect(resolveRoutineSegment([mine], "morning-brief")).toEqual({ + kind: "redirect", + to: "/routines/rtn_1", + }); + }); + + test("a name two routines answer to resolves to neither", () => { + const resolution = resolveRoutineSegment([mine, theirs], "morning-brief"); + expect(resolution.kind).toBe("ambiguous"); + expect( + resolution.kind === "ambiguous" + ? resolution.rows.map((r) => r.routine.id) + : [], + ).toEqual(["rtn_1", "rtn_2"]); + }); + + test("an unknown id or name is gone, not a silent roster", () => { + expect(resolveRoutineSegment([mine], "rtn_nope").kind).toBe("gone"); + expect(resolveRoutineSegment([mine], "no-such-routine").kind).toBe("gone"); + }); + + test("an id is preferred over a name that happens to match another routine", () => { + // A routine literally named "rtn_2" must not shadow the routine whose + // id is `rtn_2`: the canonical address wins. + const named = row({ + routine: { ...routine, id: "rtn_9", name: "rtn 2" }, + }); + expect(resolveRoutineSegment([named, theirs], "rtn_2")).toEqual({ + kind: "found", + row: theirs, + }); + }); +}); + +describe("RoutineDetailRoute", () => { + function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + function routineRecord( + overrides: Record, + ): Record { + return { + definitionId: "wfd_1", + trigger: null, + scope: "bench", + input: {}, + enabled: true, + deliveryWorkbenchId: null, + consecutiveFailures: 0, + deadLetteredAt: null, + nextFireAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; + } + + const memberships = [ + { + principalId: "prn_me", + tenantId: "tnt_1", + tenantName: "Acme Team", + tenantSlug: "acme", + kind: "user", + status: "active", + roles: [], + }, + { + principalId: "prn_me_2", + tenantId: "tnt_2", + tenantName: "Beta Team", + tenantSlug: "beta", + kind: "user", + status: "active", + roles: [], + }, + ]; + + function mockFetch( + routinesByTenant: Record[]>, + ): typeof fetch { + return (async (input: RequestInfo | URL): Promise => { + const url = String(input); + if (url.includes("/api/me/principals")) { + return jsonResponse({ data: memberships, nextCursor: null }); + } + if (url.includes("/api/workbench-tenancies/kinds")) { + return jsonResponse({ workbenchTenantIds: [] }); + } + const routinesMatch = url.match(/\/api\/tenants\/([^/]+)\/routines$/); + if (routinesMatch) { + return jsonResponse({ + items: routinesByTenant[routinesMatch[1] as string] ?? [], + }); + } + if (url.includes("/routines/") && url.endsWith("/runs")) { + return jsonResponse({ items: [], nextCursor: null }); + } + if (url.includes("/chat/workbenches") && url.includes("kind=workbench")) { + return jsonResponse({ items: [] }); + } + if (url.includes("/workflows/definitions")) { + return jsonResponse({ data: [], nextCursor: null }); + } + return Promise.reject(new Error(`unrouted fetch: ${url}`)); + }) as typeof fetch; + } + + async function renderRoute( + segment: string, + routinesByTenant: Record[]>, + navigate: (to: string) => void, + ): Promise<{ container: HTMLDivElement; root: Root }> { + const { BenchProvider } = await import("../src/bench-context"); + const { RoutineDetailRoute } = + await import("../src/pages/routine-detail-page"); + const { TestQueryProvider } = await import("./test-query-provider"); + + globalThis.fetch = mockFetch(routinesByTenant); + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + await act(async () => { + root.render( + + + + {createElement(RoutineDetailRoute, { segment, navigate })} + + + , + ); + }); + for (let i = 0; i < 8; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + return { container, root }; + } + + const realFetch = globalThis.fetch; + + function cleanup(container: HTMLDivElement, root: Root): void { + act(() => root.unmount()); + container.remove(); + globalThis.fetch = realFetch; + window.localStorage.clear(); + } + + test("an id renders the routine itself, with no redirect", async () => { + const navigated: string[] = []; + const { container, root } = await renderRoute( + "rtn_mine", + { tnt_1: [routineRecord({ id: "rtn_mine", name: "My digest" })] }, + (to) => navigated.push(to), + ); + try { + expect(container.textContent).toContain("My digest"); + expect(navigated).toEqual([]); + } finally { + cleanup(container, root); + } + }); + + test("a name hops to the id address", async () => { + const navigated: string[] = []; + const { container, root } = await renderRoute( + "my-digest", + { tnt_1: [routineRecord({ id: "rtn_mine", name: "My digest" })] }, + (to) => navigated.push(to), + ); + try { + expect(navigated).toEqual(["/routines/rtn_mine"]); + } finally { + cleanup(container, root); + } + }); + + test("a name two routines share offers both by id, and redirects to neither", async () => { + const navigated: string[] = []; + const { container, root } = await renderRoute( + "my-digest", + { + tnt_1: [routineRecord({ id: "rtn_mine", name: "My digest" })], + tnt_2: [routineRecord({ id: "rtn_theirs", name: "My digest" })], + }, + (to) => navigated.push(to), + ); + try { + expect(container.textContent).toContain("More than one routine"); + const hrefs = [...container.querySelectorAll("a")].map((a) => + a.getAttribute("href"), + ); + expect(hrefs).toContain("/routines/rtn_mine"); + expect(hrefs).toContain("/routines/rtn_theirs"); + expect(navigated).toEqual([]); + } finally { + cleanup(container, root); + } + }); + + test("an unknown id says the routine is gone, never a silent roster", async () => { + const navigated: string[] = []; + const { container, root } = await renderRoute( + "rtn_deleted", + { tnt_1: [routineRecord({ id: "rtn_mine", name: "My digest" })] }, + (to) => navigated.push(to), + ); + try { + expect(container.textContent).toContain("That routine is gone"); + expect(container.textContent).toContain("Back to Routines"); + expect(navigated).toEqual([]); + } finally { + cleanup(container, root); + } + }); + + test("an unknown name is gone too, with the same words", async () => { + const { container, root } = await renderRoute( + "no-such-routine", + { tnt_1: [routineRecord({ id: "rtn_mine", name: "My digest" })] }, + () => {}, + ); + try { + expect(container.textContent).toContain("That routine is gone"); + } finally { + cleanup(container, root); + } + }); }); diff --git a/apps/web/test/routine-panel.test.tsx b/apps/web/test/routine-panel.test.tsx index 4957608b4..67bc0fc23 100644 --- a/apps/web/test/routine-panel.test.tsx +++ b/apps/web/test/routine-panel.test.tsx @@ -81,7 +81,6 @@ function routineRecord( consecutiveFailures: 0, deadLetteredAt: null, nextFireAt: null, - lastFireAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...overrides, @@ -594,7 +593,7 @@ describe("RoutinePanel", () => { (c["trigger"] as { kind: string } | undefined)?.kind === "daily", ), ).toBe(true); - expect(container.textContent).toContain("Daily at 09:00 UTC"); + expect(container.textContent).toContain("At 09:00 (UTC)"); }); }); }); diff --git a/apps/web/test/routine-trigger.test.ts b/apps/web/test/routine-trigger.test.ts deleted file mode 100644 index 881320b56..000000000 --- a/apps/web/test/routine-trigger.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Pure-function proof for the Routines page's cadence rendering and -// best-effort next-run estimate — no fetch, no DOM. - -import { describe, expect, test } from "bun:test"; -import { approximateNextRun, cadenceLabel } from "../src/routine-trigger"; - -describe("cadenceLabel", () => { - // The wording itself is `@corbits/routines/trigger`'s - // `routineCadenceLabel`, covered there — this only proves the app - // re-exports it under its established name. - test("re-exports routineCadenceLabel", () => { - expect(cadenceLabel(null)).toBe("Manual"); - expect(cadenceLabel({ kind: "daily", hour: 9, minute: 5 })).toBe( - "Daily at 09:05 UTC", - ); - }); -}); - -describe("approximateNextRun", () => { - test("manual triggers have no estimate", () => { - expect(approximateNextRun(null, new Date())).toBeNull(); - }); - - test("webhook triggers have no estimate — they fire on delivery, never a clock", () => { - expect( - approximateNextRun( - { kind: "webhook", webhookTriggerId: "wht_1" }, - new Date(), - ), - ).toBeNull(); - }); - - test("one-shot triggers have no future estimate", () => { - expect(approximateNextRun({ kind: "once" }, new Date())).toBeNull(); - }); - - test("raw cron is estimated through the same package the hub uses", () => { - const next = approximateNextRun( - { kind: "cron", expression: "0 9 * * *" }, - new Date("2026-01-01T08:00:00Z"), - ); - expect(next?.toISOString()).toBe("2026-01-01T09:00:00.000Z"); - }); - - test("interval adds its step to now when now sits on a boundary", () => { - const now = new Date("2026-01-01T00:00:00Z"); - const next = approximateNextRun( - { kind: "interval", unit: "minutes", every: 15 }, - now, - ); - expect(next?.toISOString()).toBe("2026-01-01T00:15:00.000Z"); - }); - - test("interval is wall-clock aligned, not an offset from the viewing moment", () => { - const now = new Date("2026-01-01T00:07:00Z"); - const next = approximateNextRun( - { kind: "interval", unit: "minutes", every: 10 }, - now, - ); - expect(next?.toISOString()).toBe("2026-01-01T00:10:00.000Z"); - }); - - test("hourly interval is wall-clock aligned to the hour", () => { - const now = new Date("2026-01-01T01:00:00Z"); - const next = approximateNextRun( - { kind: "interval", unit: "hours", every: 2 }, - now, - ); - expect(next?.toISOString()).toBe("2026-01-01T02:00:00.000Z"); - }); - - test("daily rolls to tomorrow once today's time has passed", () => { - const now = new Date("2026-01-01T10:00:00Z"); - const next = approximateNextRun({ kind: "daily", hour: 9, minute: 0 }, now); - expect(next?.toISOString()).toBe("2026-01-02T09:00:00.000Z"); - }); - - test("daily stays today when the time has not passed yet", () => { - const now = new Date("2026-01-01T08:00:00Z"); - const next = approximateNextRun({ kind: "daily", hour: 9, minute: 0 }, now); - expect(next?.toISOString()).toBe("2026-01-01T09:00:00.000Z"); - }); - - test("daily with timezone uses local wall-clock (UTC storage)", () => { - const now = new Date("2026-01-15T12:00:00Z"); - const next = approximateNextRun( - { - kind: "daily", - hour: 9, - minute: 0, - timezone: "America/Los_Angeles", - }, - now, - ); - expect(next?.toISOString()).toBe("2026-01-15T17:00:00.000Z"); - }); - - test("weekly finds the next matching weekday", () => { - const now = new Date("2026-01-01T00:00:00Z"); - const next = approximateNextRun( - { kind: "weekly", dayOfWeek: 1, hour: 9, minute: 0 }, - now, - ); - expect(next?.toISOString()).toBe("2026-01-05T09:00:00.000Z"); - }); -}); diff --git a/apps/web/test/routines-page.test.tsx b/apps/web/test/routines-page.test.tsx index 84bc15c9e..9233c0fc1 100644 --- a/apps/web/test/routines-page.test.tsx +++ b/apps/web/test/routines-page.test.tsx @@ -7,9 +7,9 @@ // bench memberships, never creator-scoped) gets its own fetch-mocked // integration coverage below. // -// Row detail is a page now (`/routines/`), not an inline expansion: -// the name is a link, and an old `/routines/` deep link redirects to -// that page rather than expanding a row that no longer expands. +// Row detail is a page now (`/routines/`), not an inline expansion, +// and the row links there by id — the only address a routine has that a +// rename cannot break. import { describe, expect, test } from "bun:test"; import { act, createElement } from "react"; @@ -41,7 +41,6 @@ const routine: Routine = { consecutiveFailures: 0, deadLetteredAt: null, nextFireAt: "2026-01-02T09:00:00.000Z", - lastFireAt: "2026-01-01T09:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }; @@ -171,12 +170,41 @@ describe("GlobalRoutinesList", () => { expect(markup).toContain("At 09:00 (UTC)"); expect(markup).toContain("in 21h"); expect(markup).toContain("Healthy"); - expect(markup).toContain("completed"); + expect(markup).toContain("Finished"); expect(markup).toContain("Ops"); }); - test("the routine's name links to its own page", () => { - expect(renderList([row()])).toContain('href="/routines/morning-brief"'); + test("the routine's name links to its own page by id, not by name", () => { + const markup = renderList([row()]); + expect(markup).toContain('href="/routines/rtn_1"'); + expect(markup).not.toContain('href="/routines/morning-brief"'); + }); + + test("a past-due next run reads as overdue, never as time already elapsed", () => { + const markup = renderList([ + row({ + routine: { ...routine, nextFireAt: "2025-12-31T00:00:00.000Z" }, + }), + ]); + expect(markup).toContain("Overdue"); + expect(markup).not.toContain("ago"); + }); + + test("statuses and causes read as words, not as column values", () => { + const markup = renderList([ + row({ + runs: [ + { + runId: "run_1", + triggeredBy: "schedule", + createdAt: "2026-01-01T09:00:00.000Z", + run: { status: "running" }, + }, + ], + }), + ]); + expect(markup).toContain("Running now"); + expect(markup).not.toContain(">running<"); }); test("a failing routine states its failure count in words, not only in colour", () => { @@ -324,7 +352,6 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { consecutiveFailures: 0, deadLetteredAt: null, nextFireAt: null, - lastFireAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...overrides, @@ -383,7 +410,6 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { } async function renderRoute( - path: string, navigate: (to: string) => void, ): Promise<{ container: HTMLDivElement; root: Root }> { const { BenchProvider } = await import("../src/bench-context"); @@ -413,7 +439,7 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { toggleFocus={() => {}} close={() => {}} > - {createElement(RoutinesRoute, { path, navigate })} + {createElement(RoutinesRoute, { navigate })} @@ -431,7 +457,7 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { test("lists routines from every bench the account is a member of, not just the currently selected one, and never creator-scoped", async () => { const realFetch = globalThis.fetch; globalThis.fetch = mockFetch(); - const { container, root } = await renderRoute("/routines", () => {}); + const { container, root } = await renderRoute(() => {}); try { // Bench switcher defaults to the first bench (tnt_1) — proving the // second bench's routine still renders proves this page never @@ -448,15 +474,17 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { } }); - test("an old /routines/ deep link lands on the routine's own page", async () => { + test("each row links to its routine by id — the address a rename cannot break", async () => { const realFetch = globalThis.fetch; globalThis.fetch = mockFetch(); - const navigated: string[] = []; - const { container, root } = await renderRoute("/routines/rtn_mine", (to) => - navigated.push(to), - ); + const { container, root } = await renderRoute(() => {}); try { - expect(navigated).toContain("/routines/my-digest"); + const hrefs = [...container.querySelectorAll("a")].map((a) => + a.getAttribute("href"), + ); + expect(hrefs).toContain("/routines/rtn_mine"); + expect(hrefs).toContain("/routines/rtn_theirs"); + expect(hrefs).not.toContain("/routines/my-digest"); } finally { act(() => root.unmount()); container.remove(); diff --git a/packages/routines/src/client.test.ts b/packages/routines/src/client.test.ts index a887b4ee0..840b9b96e 100644 --- a/packages/routines/src/client.test.ts +++ b/packages/routines/src/client.test.ts @@ -13,10 +13,36 @@ import { routineRunNowPath, routineRunStartedToast, routineRunsPath, + routineActionFailedToast, routineSlug, routinesPath, } from "./client"; +describe("routineActionFailedToast", () => { + test("names the action that didn't happen, the routine, and why", () => { + expect( + routineActionFailedToast( + "pause", + "Morning brief", + "You don't have access to this.", + ), + ).toBe("Couldn't pause Morning brief. You don't have access to this."); + }); + + test("every lifecycle action has its own verb", () => { + const reason = "Try again."; + expect(routineActionFailedToast("run", "X", reason)).toContain( + "Couldn't start X", + ); + expect(routineActionFailedToast("resume", "X", reason)).toContain( + "Couldn't resume X", + ); + expect(routineActionFailedToast("schedule", "X", reason)).toContain( + "Couldn't reschedule X", + ); + }); +}); + describe("routineSlug", () => { test("derives the URL-facing name from the display name", () => { expect(routineSlug("Morning brief")).toBe("morning-brief"); @@ -82,7 +108,28 @@ describe("wire schemas", () => { consecutiveFailures: 0, deadLetteredAt: null, nextFireAt: null, - lastFireAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + expect(out instanceof type.errors).toBe(false); + }); + + test("Routine parses a payload from a hub that doesn't send nextFireAt yet", () => { + // Deploy skew: a browser on the new bundle can be talking to an + // un-upgraded hub for a release. A required field would make arktype + // reject the whole payload and blank every routines surface over a + // display-only value. + const out = Routine({ + id: "r1", + name: "Morning brief", + definitionId: "wfd_1", + trigger: null, + scope: "personal", + input: {}, + enabled: true, + deliveryWorkbenchId: null, + consecutiveFailures: 0, + deadLetteredAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); @@ -110,7 +157,6 @@ describe("wire schemas", () => { consecutiveFailures: 0, deadLetteredAt: null, nextFireAt: null, - lastFireAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); diff --git a/packages/routines/src/health.test.ts b/packages/routines/src/health.test.ts index 370e65742..2fa67cc10 100644 --- a/packages/routines/src/health.test.ts +++ b/packages/routines/src/health.test.ts @@ -158,6 +158,23 @@ describe("routineHealth", () => { expect(health.caption).toContain("2 runs in a row"); }); + test("last run is the newest history row, whatever started it — never the scheduler's own stamp", () => { + // A run-now-only routine never gets a `lastFireAt` (the store writes + // that inside the scheduled-claim path), so reading anything else + // here would report "never run" beside a full history table. + const manual = fire("r1", { + triggeredBy: "manual", + createdAt: "2026-02-01T10:00:00.000Z", + }); + expect(routineHealth(healthy, [manual]).lastRunAt).toBe( + "2026-02-01T10:00:00.000Z", + ); + }); + + test("no history means no last run", () => { + expect(routineHealth(healthy, []).lastRunAt).toBeNull(); + }); + test("carries the last failure through even while healthy again", () => { const health = routineHealth(healthy, [ fire("r2"), diff --git a/packages/routines/src/run-language.test.ts b/packages/routines/src/run-language.test.ts new file mode 100644 index 000000000..1c70c0dea --- /dev/null +++ b/packages/routines/src/run-language.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; + +import { + fireNeverStarted, + runStatusLabel, + triggeredByLabel, +} from "./run-language"; + +describe("runStatusLabel", () => { + test("every status a routine surface can show reads as words", () => { + expect(runStatusLabel("running")).toBe("Running now"); + expect(runStatusLabel("completed")).toBe("Finished"); + expect(runStatusLabel("failed")).toBe("Failed"); + expect(runStatusLabel("cancelled")).toBe("Cancelled"); + expect(runStatusLabel("queued")).toBe("Waiting to start"); + }); + + test("an unrecognised status is shown, not swallowed", () => { + expect(runStatusLabel("reticulating")).toBe("reticulating"); + }); +}); + +describe("triggeredByLabel", () => { + test("the cause of a fire reads as words, not a column value", () => { + expect(triggeredByLabel("schedule")).toBe("On schedule"); + expect(triggeredByLabel("manual")).toBe("By hand"); + expect(triggeredByLabel("run-now")).toBe("By hand"); + expect(triggeredByLabel("once")).toBe("On creation"); + expect(triggeredByLabel("webhook")).toBe("By webhook"); + }); + + test("both synthetic launch-failure rows read the same way", () => { + expect(triggeredByLabel("schedule-failed")).toBe("Failed to start"); + expect(triggeredByLabel("once-failed")).toBe("Failed to start"); + }); +}); + +describe("fireNeverStarted", () => { + test("true only for the fires that produced no platform run", () => { + expect(fireNeverStarted("schedule-failed")).toBe(true); + expect(fireNeverStarted("once-failed")).toBe(true); + expect(fireNeverStarted("schedule")).toBe(false); + expect(fireNeverStarted("manual")).toBe(false); + }); +}); diff --git a/packages/routines/src/schedule-language.test.ts b/packages/routines/src/schedule-language.test.ts index 916555fc7..cdbf35650 100644 --- a/packages/routines/src/schedule-language.test.ts +++ b/packages/routines/src/schedule-language.test.ts @@ -1,28 +1,61 @@ +// These tests assert the *contract* — a sentence, in the reader's words, +// naming the zone only when there is a clock to read in it, and never the +// raw expression — not `cronstrue`'s exact phrasing. Pinning the library's +// wording would turn a harmless dependency upgrade into a red build with +// no behaviour change; the interval cases below are ours, so those are +// pinned exactly. + import { describe, expect, test } from "bun:test"; -import { cronSentence, routineScheduleSentence } from "./schedule-language"; +import { + cronHasWallClock, + cronSentence, + routineScheduleSentence, +} from "./schedule-language"; -const RAW_CRON = /\*|\d+ \d+ \* \* /; +/** Any run of digits or a `*` where a cron field would sit — the thing no + * reader-facing sentence may ever contain. */ +const LOOKS_LIKE_CRON = /\*|\d+\s+\d+\s/; -describe("cronSentence", () => { - test("reads a weekday morning schedule as a sentence, never the expression", () => { - const sentence = cronSentence("0 9 * * 1-5"); - expect(sentence).toBe("At 09:00, Monday through Friday (UTC)"); - expect(sentence).not.toMatch(RAW_CRON); +function expectSentence(sentence: string | null): string { + expect(sentence).not.toBeNull(); + const text = sentence as string; + expect(text).not.toMatch(LOOKS_LIKE_CRON); + expect(text.length).toBeGreaterThan(3); + return text; +} + +describe("cronHasWallClock", () => { + test("a pinned hour or minute is a clock reading", () => { + expect(cronHasWallClock("0 9 * * *")).toBe(true); + expect(cronHasWallClock("30 * * * *")).toBe(true); }); - test("names the timezone the wall clock is read in", () => { - expect(cronSentence("30 14 * * *", "America/Los_Angeles")).toBe( - "At 14:30 (America/Los_Angeles)", - ); + test("a pure cadence has no clock, in any zone", () => { + expect(cronHasWallClock("* * * * *")).toBe(false); + expect(cronHasWallClock("*/15 * * * *")).toBe(false); + expect(cronHasWallClock("* */2 * * *")).toBe(false); + }); +}); + +describe("cronSentence", () => { + test("a weekday morning schedule reads as words naming its zone", () => { + const sentence = expectSentence(cronSentence("0 9 * * 1-5")); + expect(sentence).toStartWith("At "); + expect(sentence).toContain("Friday"); + expect(sentence).toEndWith("(UTC)"); }); - test("reads a step expression as a frequency", () => { - expect(cronSentence("*/15 * * * *")).toBe("Every 15 minutes (UTC)"); + test("the named zone is the one the wall clock is read in", () => { + expect(cronSentence("30 14 * * *", "America/Los_Angeles")).toEndWith( + "(America/Los_Angeles)", + ); }); - test("reads a day-of-month expression", () => { - expect(cronSentence("0 0 1 * *")).toContain("day 1 of the month"); + test("a pure cadence names no zone — it is the same in every zone", () => { + const sentence = expectSentence(cronSentence("*/15 * * * *")); + expect(sentence).toStartWith("Every "); + expect(sentence).not.toContain("UTC"); }); test("null for an expression that cannot be described", () => { @@ -32,30 +65,49 @@ describe("cronSentence", () => { }); describe("routineScheduleSentence", () => { - test("every clock-driven preset reads as a sentence", () => { - expect(routineScheduleSentence({ kind: "daily", hour: 9, minute: 0 })).toBe( - "At 09:00 (UTC)", + test("a daily preset reads as a clock time in its zone", () => { + const sentence = expectSentence( + routineScheduleSentence({ kind: "daily", hour: 9, minute: 0 }), ); + expect(sentence).toStartWith("At "); + expect(sentence).toEndWith("(UTC)"); + }); + + test("a weekly preset names its day", () => { expect( - routineScheduleSentence({ - kind: "weekly", - dayOfWeek: 1, - hour: 7, - minute: 30, - }), - ).toBe("At 07:30, only on Monday (UTC)"); + expectSentence( + routineScheduleSentence({ + kind: "weekly", + dayOfWeek: 1, + hour: 7, + minute: 30, + }), + ), + ).toContain("Monday"); + }); + + test("an interval keeps the schedule editor's own words, with no zone", () => { expect( routineScheduleSentence({ kind: "interval", unit: "hours", every: 6 }), - ).toBe("On the hour, every 6 hours (UTC)"); + ).toBe("Every 6 hours"); + expect( + routineScheduleSentence({ kind: "interval", unit: "minutes", every: 15 }), + ).toBe("Every 15 minutes"); + expect( + routineScheduleSentence({ kind: "interval", unit: "hours", every: 1 }), + ).toBe("Every hour"); }); test("a raw cron trigger never leaks its expression to the reader", () => { - const sentence = routineScheduleSentence({ - kind: "cron", - expression: "0 9 * * 1,3,5", - timezone: "Europe/Berlin", - }); - expect(sentence).toContain("Monday, Wednesday, and Friday"); + const sentence = expectSentence( + routineScheduleSentence({ + kind: "cron", + expression: "0 9 * * 1,3,5", + timezone: "Europe/Berlin", + }), + ); + expect(sentence).toContain("Monday"); + expect(sentence).toContain("Friday"); expect(sentence).toContain("Europe/Berlin"); expect(sentence).not.toContain("1,3,5"); }); diff --git a/packages/routines/src/trigger.test.ts b/packages/routines/src/trigger.test.ts index 2d6b18ebc..483aa67c2 100644 --- a/packages/routines/src/trigger.test.ts +++ b/packages/routines/src/trigger.test.ts @@ -8,10 +8,9 @@ import { computeNextFireAt, cronExpressionForTrigger, cronTriggerForWeekdays, - routineCadenceLabel, - routineCadenceSummary, routineTriggerCategory, } from "./trigger"; +import { routineScheduleSentence } from "./schedule-language"; describe("RoutineTrigger vs RoutineTriggerWire (Postel's law)", () => { test("an unrecognized timezone is rejected on write by RoutineTrigger", () => { @@ -65,118 +64,6 @@ describe("ROUTINE_WEEKDAY_NAMES", () => { }); }); -describe("routineCadenceLabel", () => { - test("a null trigger reads as Manual", () => { - expect(routineCadenceLabel(null)).toBe("Manual"); - }); - - test("a webhook trigger reads as On webhook", () => { - expect( - routineCadenceLabel({ kind: "webhook", webhookTriggerId: "wht_1" }), - ).toBe("On webhook"); - }); - - test("a singular interval drops the count", () => { - expect( - routineCadenceLabel({ kind: "interval", unit: "minutes", every: 1 }), - ).toBe("Every minute"); - expect( - routineCadenceLabel({ kind: "interval", unit: "hours", every: 1 }), - ).toBe("Every hour"); - }); - - test("a plural interval keeps the count and unit", () => { - expect( - routineCadenceLabel({ kind: "interval", unit: "minutes", every: 15 }), - ).toBe("Every 15 minutes"); - }); - - test("daily spells out the time and defaults to UTC", () => { - expect(routineCadenceLabel({ kind: "daily", hour: 9, minute: 5 })).toBe( - "Daily at 09:05 UTC", - ); - }); - - test("daily with a timezone names it instead of UTC", () => { - expect( - routineCadenceLabel({ - kind: "daily", - hour: 9, - minute: 0, - timezone: "America/Los_Angeles", - }), - ).toBe("Daily at 09:00 America/Los_Angeles"); - }); - - test("weekly names the day and time", () => { - expect( - routineCadenceLabel({ - kind: "weekly", - dayOfWeek: 1, - hour: 9, - minute: 30, - }), - ).toBe("Weekly on Monday at 09:30 UTC"); - }); - - test("cron shows the raw expression, with a timezone suffix when not UTC", () => { - expect( - routineCadenceLabel({ kind: "cron", expression: "0 9 * * 1-5" }), - ).toBe("Cron: 0 9 * * 1-5"); - expect( - routineCadenceLabel({ - kind: "cron", - expression: "0 9 * * 1-5", - timezone: "America/Los_Angeles", - }), - ).toBe("Cron: 0 9 * * 1-5 (America/Los_Angeles)"); - }); -}); - -describe("routineCadenceSummary", () => { - test("a null trigger reads as On demand", () => { - expect(routineCadenceSummary(null)).toBe("On demand"); - }); - - test("a webhook trigger reads as On webhook", () => { - expect( - routineCadenceSummary({ kind: "webhook", webhookTriggerId: "wht_1" }), - ).toBe("On webhook"); - }); - - test("interval triggers read as a cadence", () => { - expect( - routineCadenceSummary({ kind: "interval", unit: "minutes", every: 15 }), - ).toBe("Every 15 minutes"); - expect( - routineCadenceSummary({ kind: "interval", unit: "hours", every: 1 }), - ).toBe("Every 1 hour"); - }); - - test("daily triggers read as a zero-padded time with no zone suffix", () => { - expect(routineCadenceSummary({ kind: "daily", hour: 9, minute: 0 })).toBe( - "Daily 09:00", - ); - }); - - test("weekly triggers name the day with no zone suffix", () => { - expect( - routineCadenceSummary({ - kind: "weekly", - dayOfWeek: 1, - hour: 9, - minute: 30, - }), - ).toBe("Every Monday 09:30"); - }); - - test("cron triggers show the expression with no zone suffix", () => { - expect( - routineCadenceSummary({ kind: "cron", expression: "0 9 * * 1-5" }), - ).toBe("Cron 0 9 * * 1-5"); - }); -}); - describe("interval trigger 'days' unit", () => { test("is accepted by the strict RoutineTrigger schema", () => { const out = RoutineTrigger({ kind: "interval", unit: "days", every: 3 }); @@ -189,20 +76,14 @@ describe("interval trigger 'days' unit", () => { ).toBe("0 0 */3 * *"); }); - test("cadence label singularizes 'Every 1 days' to 'Every day'", () => { + test("reads as a sentence that singularizes 'Every 1 days'", () => { expect( - routineCadenceLabel({ kind: "interval", unit: "days", every: 1 }), + routineScheduleSentence({ kind: "interval", unit: "days", every: 1 }), ).toBe("Every day"); expect( - routineCadenceLabel({ kind: "interval", unit: "days", every: 3 }), + routineScheduleSentence({ kind: "interval", unit: "days", every: 3 }), ).toBe("Every 3 days"); }); - - test("cadence summary singularizes the unit for every === 1", () => { - expect( - routineCadenceSummary({ kind: "interval", unit: "days", every: 1 }), - ).toBe("Every 1 day"); - }); }); describe("{kind: 'once'} trigger", () => { @@ -224,9 +105,10 @@ describe("{kind: 'once'} trigger", () => { expect(routineTriggerCategory({ kind: "once" })).toBe("demand"); }); - test("reads as 'Runs once' in both the label and summary", () => { - expect(routineCadenceLabel({ kind: "once" })).toBe("Runs once"); - expect(routineCadenceSummary({ kind: "once" })).toBe("Runs once"); + test("reads as a one-time schedule, not a cadence", () => { + expect(routineScheduleSentence({ kind: "once" })).toBe( + "Once, when it was created", + ); }); }); diff --git a/packages/routines/src/workflow-routine-routes.test.ts b/packages/routines/src/workflow-routine-routes.test.ts index 925a004b9..8aaebb595 100644 --- a/packages/routines/src/workflow-routine-routes.test.ts +++ b/packages/routines/src/workflow-routine-routes.test.ts @@ -399,7 +399,7 @@ test("POST /routines posts an honest notice when created enabled", async () => { VALID_BODY.deliveryWorkbenchId, ); expect(workbenchNotice.calls[0]?.text).toBe( - 'Created routine "Morning digest" — runs Daily at 09:00 UTC. ' + + 'Created routine "Morning digest" — At 09:00 (UTC). ' + "Manage it from Routines.", ); }); @@ -430,7 +430,7 @@ test("PATCH /routines/:id posts an honest notice when flipped to enabled", async expect(response.status).toBe(200); expect(workbenchNotice.calls.length).toBe(1); expect(workbenchNotice.calls[0]?.text).toBe( - 'Enabled routine "Morning digest" — runs Daily at 09:00 UTC. ' + + 'Enabled routine "Morning digest" — At 09:00 (UTC). ' + "Manage it from Routines.", ); }); diff --git a/packages/routines/test/routes.test.ts b/packages/routines/test/routes.test.ts index 7fc58c7eb..54e10f71c 100644 --- a/packages/routines/test/routes.test.ts +++ b/packages/routines/test/routes.test.ts @@ -311,7 +311,6 @@ describe("createRoutineRoutes", () => { }); expect(typeof body["nextFireAt"]).toBe("string"); - expect(body["lastFireAt"]).toBeNull(); // The same instant the scheduler's own claim test compares against — // not an independently rendered estimate. @@ -327,7 +326,9 @@ describe("createRoutineRoutes", () => { const { body } = await createRoutine(app, { ...VALID_BODY, trigger: null }); expect(body["nextFireAt"]).toBeNull(); - expect(body["lastFireAt"]).toBeNull(); + // No `lastFireAt`: the store writes it only on a scheduled claim, so + // "last run" is read off the fire history instead (see health.ts). + expect(body["lastFireAt"]).toBeUndefined(); }); test("accepts a webhook trigger when no checker is wired (always-allow)", async () => { @@ -447,7 +448,7 @@ describe("createRoutineRoutes", () => { VALID_BODY.deliveryWorkbenchId, ); expect(workbenchNotice.calls[0]?.text).toBe( - 'Created routine "Morning digest" — runs Daily at 09:00 UTC. ' + + 'Created routine "Morning digest" — At 09:00 (UTC). ' + "Manage it from Routines.", ); }); @@ -477,7 +478,7 @@ describe("createRoutineRoutes", () => { expect(response.status).toBe(200); expect(workbenchNotice.calls.length).toBe(1); expect(workbenchNotice.calls[0]?.text).toBe( - 'Enabled routine "Morning digest" — runs Daily at 09:00 UTC. ' + + 'Enabled routine "Morning digest" — At 09:00 (UTC). ' + "Manage it from Routines.", ); }); From 4157b2363ef147f5f1d44cbb3405cd3c1ea0e32b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 16:35:09 -0700 Subject: [PATCH 4/4] Routines: address by id, one schedule renderer, mutations that report failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes, two of them canon-level. **Addressing inverted.** `/routines/` is canonical and renders the page directly; a name-shaped segment resolves and *redirects* to the id. The previous direction made the fragile address canonical, which DESIGN.md forbids outright: a slug belongs in a route only when it is immutable and tenant-unique by hard database constraint, and the prescribed fallback where it isn't is the opaque-id route. A routine has no slug column. Two routines sharing a name now resolve to neither and offer both by id link; an unknown segment says the routine is gone instead of showing the roster under a URL that names nothing. A real slug column stays a separate ticket. **One schedule renderer.** `routineCadenceLabel` and `routineCadenceSummary` are deleted, not left beside the new one — they were still printing `Cron: 0 9 * * 1-5` into the schedule editor's live summary, the canvas panel's trigger rows, and the in-workbench routine notice. Every call site reads `routineScheduleSentence` now, and `apps/web/src/routine-trigger.ts` goes too: with the list reading the scheduler's own `nextFireAt`, nothing called its remaining exports. An interval keeps the schedule editor's own words rather than cron's reading of it, and the timezone is named only when there is a wall clock to read in that zone. Also: - "Last run" has one definition. `lastFireAt` is written only on a scheduled claim, so it is off the wire entirely rather than left as a second source that disagrees; both surfaces read `health.lastRunAt`, the newest fire history row. - `nextFireAt` ships optional for one release (comment names the tightening follow-up): a required field would let an un-upgraded hub blank every routines surface through an arktype rejection. - `useRoutineActions` owns the three writes for both surfaces, and each reports its own refusal in words. No bare `.then` dropping a 403, no silent snap-back on run-now, and a refused schedule save keeps the draft and says so beside the field. - Run statuses and fire causes read as words (`run-language.ts`). - A past-due next run reads "Overdue" rather than elapsed time. - The schedule editor trims once, so the same expression is compared, previewed, and sent; Save is enabled exactly when the expression is both runnable and describable. Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti --- apps/web/src/global-routines.ts | 125 ++++++++++++- apps/web/src/pages/routine-detail-page.tsx | 207 ++++++++++++++------- apps/web/src/pages/routines-page.tsx | 96 ++++------ apps/web/src/path-ids.ts | 9 +- apps/web/src/routes.tsx | 46 ++++- apps/web/src/routine-health-tone.ts | 5 + apps/web/src/routine-schedule.tsx | 4 +- apps/web/src/shell/routine-panel.tsx | 4 +- packages/routines/src/client.ts | 58 +++++- packages/routines/src/health.ts | 9 + packages/routines/src/routes.ts | 15 +- packages/routines/src/run-language.ts | 47 +++++ packages/routines/src/schedule-language.ts | 84 +++++++-- packages/routines/src/trigger.ts | 70 ------- 14 files changed, 528 insertions(+), 251 deletions(-) create mode 100644 packages/routines/src/run-language.ts diff --git a/apps/web/src/global-routines.ts b/apps/web/src/global-routines.ts index a2a5899d1..4ca29e602 100644 --- a/apps/web/src/global-routines.ts +++ b/apps/web/src/global-routines.ts @@ -19,16 +19,28 @@ import { classifyBenchMembership, listWorkbenchTenantIds, } from "@corbits/bench-ui"; -import { routineSlug } from "@corbits/routines/client"; +import { + routineActionFailedToast, + routineRunStartedToast, + routineSlug, + timezoneForTrigger, +} from "@corbits/routines/client"; +import { toast } from "@corbits/react-ui"; import { useMemo } from "react"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { describeApiError } from "@corbits/api-query"; import type { APIQuery } from "@corbits/api-query"; import type { Principal } from "./api"; import { useBench } from "./bench-context"; import { ROUTINES_PATH_PREFIX } from "./path-ids"; import { meKeys, tenantKeys } from "./query-client"; -import { listRoutineRuns, listRoutines } from "./routines-api"; +import { + listRoutineRuns, + listRoutines, + runRoutineNow, + updateRoutine, +} from "./routines-api"; import type { Routine, RoutineRun } from "./routines-api"; /** The query-key suffix both routines surfaces share, so a mutation on @@ -43,10 +55,24 @@ export type GlobalRoutineRow = { readonly runs: readonly RoutineRun[]; }; -/** `/routines/` — the routine's own page. `null` when the name has - * nothing sluggable in it, so a caller renders the routine without a link - * rather than linking somewhere that cannot resolve. */ -export function routineDetailPath(name: string): string | null { +/** + * A routine's own page. Addressed by id, which is the only address a + * routine actually has: DESIGN.md permits a slug in a route only where it + * is immutable and tenant-unique by hard database constraint, and a + * routine has no slug column — so a name-derived slug is the soft + * convention that rule forbids, and the opaque id is the documented + * fallback. `routineSlugPath` below still builds the name address, for + * resolving links people typed or shared. + */ +export function routineDetailPath(routineId: string): string { + return `${ROUTINES_PATH_PREFIX}/${encodeURIComponent(routineId)}`; +} + +/** The name-derived address a person may have typed or shared — resolved + * and redirected to `routineDetailPath` by the detail route, never + * rendered as canonical. `null` when the name has nothing sluggable in + * it. */ +export function routineSlugPath(name: string): string | null { const slug = routineSlug(name); return slug === "" ? null : `${ROUTINES_PATH_PREFIX}/${slug}`; } @@ -127,7 +153,9 @@ async function fetchBenchRoutinesData( * into one list with its own workbench attribution — the aggregation * `GET /routines` doesn't do server-side (it's tenant-scoped, per bench), * done the cheapest correct client-side way: one fetch per bench, run in - * parallel. */ + * parallel. A server-side health summary that collapses this fan-out into + * a single request is ticketed separately; it changes where the numbers + * are computed, not what they mean. */ export function useGlobalRoutines(): APIQuery { const { kind: benchesKind, benches } = useMemberBenches(); const results = useQueries({ @@ -182,3 +210,86 @@ export function useInvalidateRoutines(): (tenantId: string) => void { }); }; } + +export type RoutineActions = { + /** Toasts and resolves on failure — the caller has no second thing to + * say, and an unhandled rejection is not a user-facing error message. */ + readonly runNow: (row: GlobalRoutineRow) => Promise; + readonly setEnabled: ( + row: GlobalRoutineRow, + enabled: boolean, + ) => Promise; + /** Toasts and *rethrows*, so a schedule editor can also keep the draft + * on screen and say what happened next to the field. */ + readonly saveCronSchedule: ( + row: GlobalRoutineRow, + expression: string, + ) => Promise; +}; + +/** + * The three routine mutations, each of which reports its own failure. + * Shared by the roster and the detail page so a refused write reads the + * same on both, and so neither surface can quietly drop one: every path + * here either invalidates on success or says what went wrong in words. + * Nothing is applied optimistically — the row changes when the hub says + * it changed. + */ +export function useRoutineActions(): RoutineActions { + const invalidate = useInvalidateRoutines(); + return { + runNow: async (row) => { + try { + await runRoutineNow(row.tenantId, row.routine.id); + invalidate(row.tenantId); + toast(routineRunStartedToast(row.routine.name)); + } catch (cause) { + toast( + routineActionFailedToast( + "run", + row.routine.name, + describeApiError(cause, "starting this routine"), + ), + ); + } + }, + setEnabled: async (row, enabled) => { + try { + await updateRoutine(row.tenantId, row.routine.id, { enabled }); + invalidate(row.tenantId); + } catch (cause) { + toast( + routineActionFailedToast( + enabled ? "resume" : "pause", + row.routine.name, + describeApiError( + cause, + enabled ? "resuming this routine" : "pausing this routine", + ), + ), + ); + } + }, + saveCronSchedule: async (row, expression) => { + const timezone = timezoneForTrigger(row.routine.trigger); + try { + await updateRoutine(row.tenantId, row.routine.id, { + trigger: + timezone === "UTC" + ? { kind: "cron", expression } + : { kind: "cron", expression, timezone }, + }); + } catch (cause) { + toast( + routineActionFailedToast( + "schedule", + row.routine.name, + describeApiError(cause, "saving this schedule"), + ), + ); + throw cause; + } + invalidate(row.tenantId); + }, + }; +} diff --git a/apps/web/src/pages/routine-detail-page.tsx b/apps/web/src/pages/routine-detail-page.tsx index a6dff692b..54096740d 100644 --- a/apps/web/src/pages/routine-detail-page.tsx +++ b/apps/web/src/pages/routine-detail-page.tsx @@ -1,5 +1,7 @@ -// `/routines/` — a routine's own page (CL-6418), replacing the -// placeholder CL-6412 routed here. +// `/routines/` — a routine's own page (CL-6418), replacing the +// placeholder CL-6412 routed here. The id is the address (see +// `resolveRoutineSegment` at the bottom for why, and for how a name still +// resolves onto it). // // A routine is a workflow on a schedule: schedule + target workflow + // health + history, and nothing else. It never shows an agent it "runs @@ -33,15 +35,14 @@ import { TableHeader, TableRow, formatRelativeTime, - toast, } from "@corbits/react-ui"; import { Clock, FlowArrow } from "@corbits/icons"; -import type { Slug } from "@corbits/slug"; -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { isValidCronExpression, cronExpressionForTrigger, cronSentence, + fireNeverStarted, routineHealth, routineScheduleSentence, timezoneForTrigger, @@ -49,9 +50,10 @@ import { import type { RoutineHealth } from "@corbits/routines/client"; import { + routineDetailPath, rowsForSlug, useGlobalRoutines, - useInvalidateRoutines, + useRoutineActions, } from "../global-routines"; import type { GlobalRoutineRow } from "../global-routines"; import { runDetailPath } from "../insights-deeplinks"; @@ -59,14 +61,8 @@ import { Link } from "../navigation"; import { ROUTINES_PATH_PREFIX } from "../path-ids"; import { ROUTINE_HEALTH_TONE } from "../routine-health-tone"; import { StageTopBar } from "../shell/stage-top-bar"; -import { RunStatusCell, TriggeredByCell } from "./routines-page"; -import { - listWorkflowDefinitions, - routineRunStartedToast, - runRoutineNow, - updateRoutine, - useTenantQuery, -} from "../routines-api"; +import { nextRunLabel, RunStatusCell, TriggeredByCell } from "./routines-page"; +import { listWorkflowDefinitions, useTenantQuery } from "../routines-api"; import { tenantKeys } from "../query-client"; /** The cron expression behind a routine's schedule — `null` for the @@ -117,7 +113,6 @@ export function RoutineHealthRail({ readonly row: GlobalRoutineRow; readonly now: number; }) { - const { nextFireAt, lastFireAt } = row.routine; return (
)} @@ -299,7 +313,7 @@ export function RoutineRunHistory({ {formatRelativeTime(run.createdAt, now)} - {run.triggeredBy === "schedule-failed" ? ( + {fireNeverStarted(run.triggeredBy) ? ( Never started @@ -337,8 +351,7 @@ export function RoutineDetailPage({ }) { const health = routineHealth(row.routine, row.runs); const latestRunId = - row.runs.find((run) => run.triggeredBy !== "schedule-failed")?.runId ?? - null; + row.runs.find((run) => !fireNeverStarted(run.triggeredBy))?.runId ?? null; return (
@@ -410,6 +427,7 @@ function NotFound({ } /> + {children}
); @@ -435,14 +453,66 @@ function useWorkflowName(row: GlobalRoutineRow | undefined): string { return match?.name ?? row.routine.definitionId; } -export function RoutineDetailRoute({ slug }: { readonly slug: Slug }) { +/** + * What `/routines/` resolves to. + * + * The id is the canonical address and renders the page directly — a + * routine has no slug column, so a name-derived slug is exactly the "soft + * convention a migration can violate" DESIGN.md forbids in a route, and + * the opaque id is the fallback that section prescribes. A name still + * resolves, as a convenience: it redirects to the id path, so what ends + * up in the address bar, in a bookmark, and in a shared link is the + * address that cannot break when someone renames the routine. + * + * A name two routines answer to resolves to neither — it offers both by + * id instead. A segment nothing answers to says the routine is gone, + * rather than quietly showing the roster under a URL that no longer means + * anything. + */ +export type RoutineResolution = + | { readonly kind: "found"; readonly row: GlobalRoutineRow } + | { readonly kind: "redirect"; readonly to: string } + | { readonly kind: "ambiguous"; readonly rows: readonly GlobalRoutineRow[] } + | { readonly kind: "gone" }; + +export function resolveRoutineSegment( + rows: readonly GlobalRoutineRow[], + segment: string, +): RoutineResolution { + const byId = rows.find((row) => row.routine.id === segment); + if (byId !== undefined) return { kind: "found", row: byId }; + const byName = rowsForSlug(rows, segment); + const only = byName.length === 1 ? byName[0] : undefined; + if (only !== undefined) { + return { kind: "redirect", to: routineDetailPath(only.routine.id) }; + } + if (byName.length > 1) return { kind: "ambiguous", rows: byName }; + return { kind: "gone" }; +} + +export function RoutineDetailRoute({ + segment, + navigate, +}: { + readonly segment: string; + readonly navigate: (to: string) => void; +}) { const routinesQuery = useGlobalRoutines(); - const invalidate = useInvalidateRoutines(); + const actions = useRoutineActions(); const rows = routinesQuery.kind === "ready" ? routinesQuery.data : []; - const matches = rowsForSlug(rows, slug); - const row = matches.length === 1 ? matches[0] : undefined; + const resolution = resolveRoutineSegment(rows, segment); + const row = resolution.kind === "found" ? resolution.row : undefined; const workflowName = useWorkflowName(row); const now = Date.now(); + const redirectTo = + routinesQuery.kind === "ready" && resolution.kind === "redirect" + ? resolution.to + : null; + + useEffect(() => { + if (redirectTo === null) return; + navigate(redirectTo); + }, [redirectTo, navigate]); if (routinesQuery.kind === "loading") { return ( @@ -450,7 +520,7 @@ export function RoutineDetailRoute({ slug }: { readonly slug: Slug }) { @@ -460,21 +530,41 @@ export function RoutineDetailRoute({ slug }: { readonly slug: Slug }) { ); } if (routinesQuery.kind === "error") { - return ; + return ( + + ); } - if (matches.length > 1) { + if (resolution.kind === "redirect") { return ( - ); } + if (resolution.kind === "ambiguous") { + return ( + +
    + {resolution.rows.map((candidate) => ( +
  • + + {candidate.routine.name} · {candidate.tenantName} + +
  • + ))} +
+
+ ); + } if (row === undefined) { return ( - ); } @@ -485,26 +575,13 @@ export function RoutineDetailRoute({ slug }: { readonly slug: Slug }) { row={resolved} now={now} workflowName={workflowName} - onRunNow={async () => { - await runRoutineNow(resolved.tenantId, resolved.routine.id); - invalidate(resolved.tenantId); - toast(routineRunStartedToast(resolved.routine.name)); - }} + onRunNow={() => actions.runNow(resolved)} onToggleEnabled={(enabled) => { - void updateRoutine(resolved.tenantId, resolved.routine.id, { - enabled, - }).then(() => invalidate(resolved.tenantId)); - }} - onSaveSchedule={async (expression) => { - const timezone = timezoneForTrigger(resolved.routine.trigger); - await updateRoutine(resolved.tenantId, resolved.routine.id, { - trigger: - timezone === "UTC" - ? { kind: "cron", expression } - : { kind: "cron", expression, timezone }, - }); - invalidate(resolved.tenantId); + void actions.setEnabled(resolved, enabled); }} + onSaveSchedule={(expression) => + actions.saveCronSchedule(resolved, expression) + } /> ); } diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index 51cd20152..07a34abb9 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -30,15 +30,15 @@ import { TableHead, TableHeader, TableRow, - toast, } from "@corbits/react-ui"; import type { BadgeTone } from "@corbits/react-ui"; import { Clock } from "@corbits/icons"; -import { useEffect } from "react"; import type { KeyboardEvent } from "react"; import { routineHealth, routineScheduleSentence, + runStatusLabel, + triggeredByLabel, } from "@corbits/routines/client"; import type { RoutineHealth } from "@corbits/routines/client"; @@ -46,20 +46,14 @@ import { useBench } from "../bench-context"; import { routineDetailPath, useGlobalRoutines, - useInvalidateRoutines, + useRoutineActions, } from "../global-routines"; import type { GlobalRoutineRow } from "../global-routines"; import { Link } from "../navigation"; import { workbenchPath } from "../workbench-path"; -import { routineIdFromPath } from "../path-ids"; import { ROUTINE_HEALTH_TONE } from "../routine-health-tone"; import { useOpenRoutineInCanvas } from "../shell/canvas-availability"; import { StageTopBar } from "../shell/stage-top-bar"; -import { - routineRunStartedToast, - runRoutineNow, - updateRoutine, -} from "../routines-api"; import type { RoutineRun } from "../routines-api"; export type { GlobalRoutineRow } from "../global-routines"; @@ -152,15 +146,14 @@ export function RunsTable({ /** What started a fire, plus the launch failure's own message when the * fire never produced a run at all. Shared by the roster's run table and - * the detail page's history table. */ + * the detail page's history table. Words, not the `triggered_by` column's + * enum (DESIGN.md, Copy). */ export function TriggeredByCell({ run }: { readonly run: RoutineRun }) { const hasError = run.error !== undefined && run.error !== null; return ( <> - {run.triggeredBy === "schedule-failed" - ? "Failed to start" - : run.triggeredBy} + {triggeredByLabel(run.triggeredBy)} {hasError ? (

@@ -171,14 +164,18 @@ export function TriggeredByCell({ run }: { readonly run: RoutineRun }) { ); } -/** A fire's settled run status, or a dash when the platform has no run to - * report (a launch that never got that far). */ +/** A fire's settled run status in words, or a dash when the platform has + * no run to report (a launch that never got that far). */ export function RunStatusCell({ run }: { readonly run: RoutineRun }) { const status = run.run?.status; if (typeof status !== "string") { return —; } - return {status}; + return ( + + {runStatusLabel(status)} + + ); } /** A routine's health, from the telemetry the scheduler already records — @@ -198,15 +195,24 @@ export function scheduleSentence(row: GlobalRoutineRow): string { * When this routine fires next, read off the scheduler's own `nextFireAt` * clock rather than re-derived in the browser — a routine that is off, * dead-lettered, manual, or webhook-driven honestly has no next run, and - * says so. + * says so. An absent field reads the same as `null`: an un-upgraded hub + * has told us nothing, which is not a licence to guess (see the wire + * schema's own note). + * + * A due time in the past is not "2h ago" — the fire has not happened, the + * scheduler is behind, and the word for that is overdue. */ export function nextRunLabel(row: GlobalRoutineRow, now: number): string { - const { nextFireAt } = row.routine; + const nextFireAt = row.routine.nextFireAt ?? null; if (nextFireAt === null) return "Not scheduled"; + const due = Date.parse(nextFireAt); + if (Number.isNaN(due)) return "Not scheduled"; + if (due <= now) return "Overdue"; return formatRelativeTime(nextFireAt, now); } -/** The newest fire on record — what "last run" means in the list. */ +/** The newest fire on record — the one definition of "last run", shared + * with the detail page's health rail through `routineHealth`. */ export function latestFire(row: GlobalRoutineRow): RoutineRun | undefined { return row.runs[0]; } @@ -261,21 +267,17 @@ export function GlobalRoutinesList({ {rows.map((row) => { const health = routineRowHealth(row); - const detailPath = routineDetailPath(row.routine.name); const lastRun = latestFire(row); return ( - {detailPath === null ? ( - - {row.routine.name} - - ) : ( - - {row.routine.name} - - )} + + {row.routine.name} + {row.tenantName} @@ -344,42 +346,18 @@ export function GlobalRoutinesList({ ); } -/** - * `/routines/` used to expand a row on this page. Detail lives at - * `/routines/` now, so an id deep link (a context menu's "Open - * routine", an old bookmark) resolves the routine and hops to its page — - * an old link always lands somewhere real (DESIGN.md, Pages & Routing). - */ -function useRoutineIdDeepLink( - path: string, - rows: readonly GlobalRoutineRow[], - navigate: (to: string) => void, -): void { - const routineId = routineIdFromPath(path); - const match = rows.find((row) => row.routine.id === routineId); - const target = - match === undefined ? null : routineDetailPath(match.routine.name); - useEffect(() => { - if (target === null) return; - navigate(target); - }, [target, navigate]); -} - export function RoutinesRoute({ - path, navigate, }: { - readonly path: string; readonly navigate: (to: string) => void; }) { const routinesQuery = useGlobalRoutines(); - const invalidate = useInvalidateRoutines(); + const actions = useRoutineActions(); const openRoutine = useOpenRoutineInCanvas(); const { selectTenant } = useBench(); const now = Date.now(); const rows = routinesQuery.kind === "ready" ? routinesQuery.data : []; - useRoutineIdDeepLink(path, rows, navigate); return (

@@ -414,15 +392,9 @@ export function RoutinesRoute({ rows={rows} now={now} onToggleEnabled={(row, enabled) => { - void updateRoutine(row.tenantId, row.routine.id, { - enabled, - }).then(() => invalidate(row.tenantId)); - }} - onRunNow={async (row) => { - await runRoutineNow(row.tenantId, row.routine.id); - invalidate(row.tenantId); - toast(routineRunStartedToast(row.routine.name)); + void actions.setEnabled(row, enabled); }} + onRunNow={(row) => actions.runNow(row)} onOpenWorkbench={(workbenchId) => { const row = rows.find( (r) => r.routine.deliveryWorkbenchId === workbenchId, diff --git a/apps/web/src/path-ids.ts b/apps/web/src/path-ids.ts index 9c25c448a..63937a612 100644 --- a/apps/web/src/path-ids.ts +++ b/apps/web/src/path-ids.ts @@ -71,10 +71,11 @@ export function skillIdFromPath(path: string): string | null { return entityIdFromTopLevelPath(path, SKILLS_PATH_PREFIX); } -/** A deep link into one routine (the context menu's "Open routine", - * `/routines/:id` bookmarks) expands that row on the Routines page — the - * page itself is one flat list, never a route per routine. */ -export function routineIdFromPath(path: string): string | null { +/** The segment `/routines/` addresses: a routine id (the + * canonical address) or a name-derived slug the detail route resolves and + * redirects to the id. `null` for the bare roster path, a path outside + * Routines, or a segment whose percent-escapes cannot be decoded. */ +export function routineSegmentFromPath(path: string): string | null { return entityIdFromTopLevelPath(path, ROUTINES_PATH_PREFIX); } diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 708f072b5..907d3c563 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -34,9 +34,10 @@ import { lazy, useEffect, type ReactElement, type ReactNode } from "react"; import { AGENTS_PATH_PREFIX, PLUGINS_PATH_PREFIX, - ROUTINES_PATH_PREFIX, SKILLS_PATH_PREFIX, + ROUTINES_PATH_PREFIX, detailSlugFromPath, + routineSegmentFromPath, } from "./path-ids"; import { WORKBENCH_PATH_PREFIX, isWorkbenchPath } from "./workbench-path"; import { @@ -126,12 +127,38 @@ const SLUG_SEGMENT = "/:slug"; export const AGENT_DETAIL_PATH = `${AGENTS_PATH_PREFIX}${SLUG_SEGMENT}`; export const SKILL_DETAIL_PATH = `${SKILLS_PATH_PREFIX}${SLUG_SEGMENT}`; export const PLUGIN_DETAIL_PATH = `${PLUGINS_PATH_PREFIX}${SLUG_SEGMENT}`; -export const ROUTINE_DETAIL_PATH = `${ROUTINES_PATH_PREFIX}${SLUG_SEGMENT}`; + +/** + * Routines are addressed by id, not by slug. DESIGN.md allows a slug in a + * route only where it is "immutable and tenant-unique, enforced as a hard + * database constraint — never a soft convention"; a routine has no slug + * column, so a name-derived one is exactly the soft convention that rule + * forbids, and the documented fallback is the opaque id. So this route + * claims any single segment under `/routines`: an id renders the page, + * and a name still resolves — `routine-detail-page.tsx` redirects it to + * the id path — which keeps human-typed and shared-by-name links working + * without making the fragile address canonical. A real slug column is + * ticketed separately. + */ +const ROUTINE_SEGMENT = "/:routine"; +export const ROUTINE_DETAIL_PATH = `${ROUTINES_PATH_PREFIX}${ROUTINE_SEGMENT}`; function slugForDetailRoute(routePath: string, path: string): Slug | null { return detailSlugFromPath(path, routePath.slice(0, -SLUG_SEGMENT.length)); } +/** The routine detail route only ever renders for a path `matchesRoute` + * already accepted, which is what makes the segment non-null here. */ +function routineDetailSegment(path: string): string { + const segment = routineSegmentFromPath(path); + if (segment === null) { + throw new Error( + `${ROUTINE_DETAIL_PATH} rendered for a path with no routine: ${path}`, + ); + } + return segment; +} + /** A detail route only ever renders for a path `matchesRoute` already * accepted, which is what makes the slug non-null here. */ function detailRouteSlug(routePath: string, path: string): Slug { @@ -173,6 +200,10 @@ export function matchesRoute(routePath: string, path: string): boolean { if (routePath === WORKBENCH_PATH_PREFIX) { return isWorkbenchPath(path) || path === "/"; } + if (routePath === ROUTINE_DETAIL_PATH) { + const segment = routineSegmentFromPath(path); + return segment !== null && !segment.includes("/"); + } if (routePath.endsWith(SLUG_SEGMENT)) { return slugForDetailRoute(routePath, path) !== null; } @@ -245,16 +276,19 @@ export const APP_ROUTES: readonly AppRoute[] = [ path: ROUTINE_DETAIL_PATH, label: "Routine", icon: , - render: (path: string) => ( - + render: (path: string, navigate: (to: string) => void) => ( + ), }, { path: "/routines", label: "Routines", icon: , - render: (path: string, navigate: (to: string) => void) => ( - + render: (_path: string, navigate: (to: string) => void) => ( + ), }, { diff --git a/apps/web/src/routine-health-tone.ts b/apps/web/src/routine-health-tone.ts index 654179bd5..c961eb318 100644 --- a/apps/web/src/routine-health-tone.ts +++ b/apps/web/src/routine-health-tone.ts @@ -7,6 +7,11 @@ // `failing` (still scheduled, still retrying) reads warning; `paused` // (dead-lettered, the scheduler gave up) reads danger — a routine that has // stopped for good is not the same signal as one having a bad morning. +// +// This table belongs next to `health.ts` in the routines package, since +// the state→tone pairing is as much a product rule as the states +// themselves; it sits here only because `BadgeTone` is a react-ui type and +// the package has no react-ui dependency yet. Moving both is ticketed. import type { BadgeTone } from "@corbits/react-ui"; import type { RoutineHealthState } from "@corbits/routines/client"; diff --git a/apps/web/src/routine-schedule.tsx b/apps/web/src/routine-schedule.tsx index b2be7168a..8a9e2b756 100644 --- a/apps/web/src/routine-schedule.tsx +++ b/apps/web/src/routine-schedule.tsx @@ -19,10 +19,10 @@ import { } from "@corbits/react-ui"; import { cronTriggerForWeekdays, + routineScheduleSentence, ROUTINE_WEEKDAY_NAMES, type RoutineTriggerT, } from "@corbits/routines/client"; -import { cadenceLabel } from "./routine-trigger"; type ScheduleTrigger = Exclude; @@ -326,7 +326,7 @@ export function ScheduleEditor({ {value !== null ? (

- {cadenceLabel(value)} + {routineScheduleSentence(value)}

) : null}
diff --git a/apps/web/src/shell/routine-panel.tsx b/apps/web/src/shell/routine-panel.tsx index e173bcc30..e7ec0cd64 100644 --- a/apps/web/src/shell/routine-panel.tsx +++ b/apps/web/src/shell/routine-panel.tsx @@ -49,7 +49,7 @@ import { Clock, X } from "@corbits/icons"; import { useBench } from "../bench-context"; import { useNavigate } from "../navigation"; import { ensureMyraWorkbench } from "../myra-workbench"; -import { cadenceLabel } from "../routine-trigger"; +import { routineScheduleSentence } from "@corbits/routines/client"; import { ScheduleEditor } from "../routine-schedule"; import { createRoutine, @@ -87,7 +87,7 @@ function triggerRowSummary( sourceLabel: string | null, ): string { if (trigger.kind === "webhook") return sourceLabel ?? "On webhook"; - return cadenceLabel(trigger); + return routineScheduleSentence(trigger); } /** diff --git a/packages/routines/src/client.ts b/packages/routines/src/client.ts index 3468ed964..e17d243f3 100644 --- a/packages/routines/src/client.ts +++ b/packages/routines/src/client.ts @@ -14,7 +14,16 @@ import { slugify } from "@corbits/slug"; import { RoutineTriggerWire, type RoutineTriggerT } from "./trigger"; export { suggestRoutineNameFromPrompt } from "./suggest-name"; -export { cronSentence, routineScheduleSentence } from "./schedule-language"; +export { + cronHasWallClock, + cronSentence, + routineScheduleSentence, +} from "./schedule-language"; +export { + fireNeverStarted, + runStatusLabel, + triggeredByLabel, +} from "./run-language"; export { cleanFireStreak, fireFailed, @@ -36,8 +45,6 @@ export { cronTriggerForWeekdays, isValidCronExpression, isValidTimeZone, - routineCadenceLabel, - routineCadenceSummary, routineMatchesModeFilter, routineTriggerCategory, ROUTINE_WEEKDAY_NAMES, @@ -78,9 +85,22 @@ export const Routine = type({ // this is the instant the scheduler will actually test against. `null` // for a routine that never auto-fires (manual, webhook, run-once) or // one that is disabled or dead-lettered. - nextFireAt: "string | null", - // The last time it actually fired on its schedule. `null` until it has. - lastFireAt: "string | null", + // + // Optional for exactly one release, on purpose: a browser that has + // already loaded the new bundle can be talking to a hub that has not + // shipped this field yet, and a required field would make arktype + // reject the whole routines payload — blanking every routines surface + // over a display-only value. Tightening to required once the hub is + // known-upgraded is its own follow-up ticket; until then the reader + // treats absent and null alike ("not scheduled" is the honest reading + // of "the server didn't say"). + // + // There is deliberately no `lastFireAt` here. The store writes it only + // on a scheduled claim, so a run-now-only routine would report "never + // run" beside a history table full of runs. "Last run" has one + // definition — the newest row of the fire history — and it lives in + // ./health.ts. + "nextFireAt?": "string | null", createdAt: "string", updatedAt: "string", }); @@ -215,3 +235,29 @@ export function routineCreatedToast(name: string): string { export function routineRunStartedToast(name: string): string { return `Run started · ${name}`; } + +/** + * A routine action that didn't happen has to say so. Every lifecycle + * control on a routines surface is a write against a live hub that can + * refuse it — a revoked grant, a routine deleted in another tab, an + * expired session — and a control that swallows the refusal leaves a + * person believing a schedule changed when it did not. `reason` is a + * caller-supplied sentence (`describeApiError` in this repo's web app), + * never a raw status line or a request path. + */ +const ROUTINE_ACTION_VERBS = { + run: "start", + pause: "pause", + resume: "resume", + schedule: "reschedule", +} as const; + +export type RoutineAction = keyof typeof ROUTINE_ACTION_VERBS; + +export function routineActionFailedToast( + action: RoutineAction, + name: string, + reason: string, +): string { + return `Couldn't ${ROUTINE_ACTION_VERBS[action]} ${name}. ${reason}`; +} diff --git a/packages/routines/src/health.ts b/packages/routines/src/health.ts index e1c548c06..dd813ee99 100644 --- a/packages/routines/src/health.ts +++ b/packages/routines/src/health.ts @@ -47,6 +47,14 @@ export type RoutineHealth = { readonly caption: string; /** Successful fires since the most recent failed one. */ readonly cleanStreak: number; + /** + * When this routine last ran, by the only definition every surface can + * agree on: the newest row of its own fire history. Deliberately not + * the routine row's `lastFireAt`, which the store stamps only inside + * `claimRoutineFire` — a run-now-only routine would report "never run" + * beside a history table full of runs. + */ + readonly lastRunAt: string | null; readonly consecutiveFailures: number; readonly lastFailure: { readonly at: string; @@ -208,6 +216,7 @@ export function routineHealth( label: words.label, caption: words.caption, cleanStreak, + lastRunAt: fires[0]?.createdAt ?? null, consecutiveFailures: routine.consecutiveFailures, lastFailure: lastFailedFire(fires), medianDurationMs: medianFireDurationMs(fires), diff --git a/packages/routines/src/routes.ts b/packages/routines/src/routes.ts index 9e6ce86ce..d246421f6 100644 --- a/packages/routines/src/routes.ts +++ b/packages/routines/src/routes.ts @@ -21,11 +21,8 @@ import { OneShotDefinitionNotFoundError, } from "@corbits/folded-runs"; -import { - RoutineTrigger, - routineCadenceLabel, - type RoutineTriggerT, -} from "./trigger"; +import { RoutineTrigger, type RoutineTriggerT } from "./trigger"; +import { routineScheduleSentence } from "./schedule-language"; import type { RoutineRow, RoutineRunRow, @@ -299,7 +296,6 @@ export function routineView(row: RoutineRow) { consecutiveFailures: row.consecutiveFailures, deadLetteredAt: row.deadLetteredAt?.toISOString() ?? null, nextFireAt: row.nextFireAt?.toISOString() ?? null, - lastFireAt: row.lastFireAt?.toISOString() ?? null, presetKey: row.presetKey, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), @@ -493,9 +489,12 @@ export async function postRoutineEnabledNotice( ): Promise { if (deps.workbenchNotice === undefined) return; if (input.workbenchId === null || input.workbenchId === "") return; + // The schedule is its own sentence rather than a clause: it is written + // for a reader ("At 09:00 (UTC)"), and splicing it mid-phrase would + // either capitalise oddly or lowercase the timezone into nonsense. const text = - `${input.verb} routine "${input.name}" — runs ` + - `${routineCadenceLabel(input.trigger)}. Manage it from Routines.`; + `${input.verb} routine "${input.name}" — ` + + `${routineScheduleSentence(input.trigger)}. Manage it from Routines.`; try { await deps.workbenchNotice.postWorkbenchNotice({ tenantId: input.tenantId, diff --git a/packages/routines/src/run-language.ts b/packages/routines/src/run-language.ts new file mode 100644 index 000000000..9157b773f --- /dev/null +++ b/packages/routines/src/run-language.ts @@ -0,0 +1,47 @@ +// A fire's status and its cause, in the reader's words rather than the +// system's. DESIGN.md's Copy rule again: "Copy speaks the user's +// vocabulary, not the system's internals. 'Running now,' never 'in +// flight.'" — which the routines surfaces broke by badging a +// `workflow_run.status` enum and a `routine_run.triggered_by` column +// straight onto the screen. +// +// Both fall back to the raw value rather than hiding an unrecognised one: +// a status this build has never heard of is still information, and +// silently rendering nothing would be worse than rendering it plainly. + +const RUN_STATUS_WORDS: Readonly> = { + running: "Running now", + completed: "Finished", + failed: "Failed", + cancelled: "Cancelled", + queued: "Waiting to start", + pending: "Waiting to start", +}; + +/** A platform run status as words. */ +export function runStatusLabel(status: string): string { + return RUN_STATUS_WORDS[status] ?? status; +} + +const TRIGGERED_BY_WORDS: Readonly> = { + schedule: "On schedule", + // The synthetic rows `markFailedFire` and the run-once create path + // record for a launch that never produced a platform run at all. + "schedule-failed": "Failed to start", + "once-failed": "Failed to start", + manual: "By hand", + "run-now": "By hand", + once: "On creation", + webhook: "By webhook", +}; + +/** What started a fire, as words. */ +export function triggeredByLabel(triggeredBy: string): string { + return TRIGGERED_BY_WORDS[triggeredBy] ?? triggeredBy; +} + +/** True when this fire never reached the platform — there is no run to + * open, so a caller offers no trace link rather than a broken one. */ +export function fireNeverStarted(triggeredBy: string): boolean { + return triggeredBy === "schedule-failed" || triggeredBy === "once-failed"; +} diff --git a/packages/routines/src/schedule-language.ts b/packages/routines/src/schedule-language.ts index 9f8242866..654becab9 100644 --- a/packages/routines/src/schedule-language.ts +++ b/packages/routines/src/schedule-language.ts @@ -1,29 +1,58 @@ -// Every schedule a person reads, as a sentence. DESIGN.md's Copy rule is -// absolute: "cron expressions render as human sentences ('every weekday -// at 9am'), never as the raw expression, in any surface a person reads -// them" — and `routineCadenceLabel`'s `cron` branch broke it, printing -// `Cron: 0 9 * * 1-5` verbatim because a preset-shaped switch has no way -// to read an arbitrary expression. +// Every schedule a person reads, as a sentence — the only renderer in the +// package. DESIGN.md's Copy rule is absolute: "cron expressions render as +// human sentences ('every weekday at 9am'), never as the raw expression, +// in any surface a person reads them", and the preset-shaped switch this +// module replaced could not honour it — an arbitrary expression has no +// preset to read, so its `cron` branch printed `Cron: 0 9 * * 1-5` +// verbatim into the routines list, the schedule editor's live summary, the +// canvas panel's trigger rows, and the in-workbench "routine created" +// notice alike. // // `cronstrue` (MIT, zero runtime dependencies, browser-safe) is the -// normalizer, not a hand-rolled one: the product's own cron escape hatch +// normalizer, not a hand-rolled one: the product's cron escape hatch // accepts any 5-field expression `./cron.ts` validates, so the renderer // has to cover the same grammar rather than the handful of shapes // `cronExpressionForTrigger` happens to emit. // -// Times render 24-hour to match `routineCadenceLabel`'s existing clock -// format, and the timezone is appended parenthetically — the wall clock a -// sentence describes is meaningless without the zone it is read in. +// Two rules keep the sentences honest rather than merely mechanical: +// +// - An interval trigger keeps the schedule editor's own words ("Every 15 +// minutes"), not cron's reading of the equivalent expression ("On the +// hour, every 6 hours"). One cadence, one phrasing, wherever a person +// meets it. +// - The timezone is named only when the schedule actually has a wall +// clock to read in that zone. "Every 15 minutes (UTC)" says nothing — +// the cadence is identical in every zone on earth. import { toString as describeCronExpression } from "cronstrue"; import { cronExpressionForTrigger, timezoneForTrigger } from "./trigger"; import type { RoutineTriggerT } from "./trigger"; +/** A cron field is unpinned when it matches every value in its range — + * a bare star, or a star with a step (which pins a cadence, not a clock + * reading). */ +function fieldIsUnpinned(field: string): boolean { + return /^\*(\/\d+)?$/.test(field); +} + +/** + * True when the expression names a time of day — an hour or a minute + * someone could point at on a clock. A step-every-15-minutes expression + * does not; `0 9 * * *` does, and only then does the zone it is read in + * mean anything. + */ +export function cronHasWallClock(expression: string): boolean { + const [minute, hour] = expression.trim().split(/\s+/); + if (minute === undefined || hour === undefined) return false; + return !fieldIsUnpinned(minute) || !fieldIsUnpinned(hour); +} + /** - * A raw 5-field cron expression as an English sentence, with its - * timezone named — `null` when the expression is not describable, so a - * caller can show the person their own invalid input instead of a - * confident sentence about a schedule that will never fire. + * A raw 5-field cron expression as an English sentence, naming its + * timezone when the schedule has a wall clock to read in it — `null` when + * the expression is not describable, so a caller can show the person + * their own invalid input instead of a confident sentence about a + * schedule that will never fire. */ export function cronSentence( expression: string, @@ -40,20 +69,37 @@ export function cronSentence( return null; } if (described === "") return null; - return `${described} (${timezone})`; + return cronHasWallClock(expression) + ? `${described} (${timezone})` + : described; +} + +/** "Every 15 minutes" / "Every hour" — the schedule editor's own words for + * an interval cadence, which has no wall clock and so no timezone. */ +function intervalSentence( + trigger: Extract, +): string { + if (trigger.every === 1) { + const singular = { minutes: "minute", hours: "hour", days: "day" }[ + trigger.unit + ]; + return `Every ${singular}`; + } + return `Every ${String(trigger.every)} ${trigger.unit}`; } /** * Any trigger shape as one human sentence — the single rendering every - * routines surface uses for "when does this run". Clock-driven triggers - * (interval, daily, weekly, raw cron) route through the canonical cron - * expression the scheduler itself fires on, so the sentence can never - * describe a cadence the scheduler wouldn't also compute. + * routines surface uses for "when does this run". Wall-clock triggers + * (daily, weekly, raw cron) route through the canonical cron expression + * the scheduler itself fires on, so the sentence can never describe a + * cadence the scheduler wouldn't also compute. */ export function routineScheduleSentence(trigger: RoutineTriggerT): string { if (trigger === null) return "On demand only"; if (trigger.kind === "webhook") return "When its webhook receives a delivery"; if (trigger.kind === "once") return "Once, when it was created"; + if (trigger.kind === "interval") return intervalSentence(trigger); const sentence = cronSentence( cronExpressionForTrigger(trigger), timezoneForTrigger(trigger), diff --git a/packages/routines/src/trigger.ts b/packages/routines/src/trigger.ts index 0731e2ed4..74dce1701 100644 --- a/packages/routines/src/trigger.ts +++ b/packages/routines/src/trigger.ts @@ -318,73 +318,3 @@ export const ROUTINE_WEEKDAY_NAMES = [ "Friday", "Saturday", ] as const; - -function zeroPadClock(hour: number, minute: number): string { - return `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`; -} - -function zoneSuffix(timezone: string | undefined): string { - return timezone === undefined || timezone === "UTC" ? "UTC" : timezone; -} - -/** - * The verbose, one-line cadence a routine's detail page shows: a full - * sentence, timezone spelled out for daily/weekly, in parentheses for - * cron. `nextCronFireAfter`'s own timezone semantics are what this reads - * against — the wording never encodes a schedule the scheduler wouldn't - * also compute. - */ -export function routineCadenceLabel(trigger: RoutineTriggerT): string { - if (trigger === null) return "Manual"; - switch (trigger.kind) { - case "webhook": - return "On webhook"; - case "once": - return "Runs once"; - case "interval": { - const singular = { minutes: "minute", hours: "hour", days: "day" }[ - trigger.unit - ]; - return trigger.every === 1 - ? `Every ${singular}` - : `Every ${String(trigger.every)} ${trigger.unit}`; - } - case "daily": - return `Daily at ${zeroPadClock(trigger.hour, trigger.minute)} ${zoneSuffix(trigger.timezone)}`; - case "weekly": - return `Weekly on ${ROUTINE_WEEKDAY_NAMES[trigger.dayOfWeek]} at ${zeroPadClock(trigger.hour, trigger.minute)} ${zoneSuffix(trigger.timezone)}`; - case "cron": { - const zone = - trigger.timezone !== undefined && trigger.timezone !== "UTC" - ? ` (${trigger.timezone})` - : ""; - return `Cron: ${trigger.expression}${zone}`; - } - } -} - -/** - * The terse cadence a routine row's detail slot shows: no timezone - * suffix, a manual trigger reads as "On demand" rather than "Manual" — - * the feed's language for a routine with nothing scheduled. - */ -export function routineCadenceSummary(trigger: RoutineTriggerT): string { - if (trigger === null) return "On demand"; - switch (trigger.kind) { - case "interval": { - const unit = - trigger.every === 1 ? trigger.unit.replace(/s$/, "") : trigger.unit; - return `Every ${String(trigger.every)} ${unit}`; - } - case "daily": - return `Daily ${zeroPadClock(trigger.hour, trigger.minute)}`; - case "weekly": - return `Every ${ROUTINE_WEEKDAY_NAMES[trigger.dayOfWeek] ?? "week"} ${zeroPadClock(trigger.hour, trigger.minute)}`; - case "cron": - return `Cron ${trigger.expression}`; - case "webhook": - return "On webhook"; - case "once": - return "Runs once"; - } -}