Skip to content

Routines: ops-grade list and full detail page at /routines/<id> - #173

Merged
TheGreatAxios merged 4 commits into
mainfrom
cl-6418-routines-detail
Aug 21, 2026
Merged

TheGreatAxios merged 4 commits into
mainfrom
cl-6418-routines-detail

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes https://linear.app/abklabs/issue/CL-6418

Routines become operable: the roster is an ops table and every routine gets its own page, replacing the placeholder CL-6412 routed there. This is surfacing work — the schedule and health telemetry (nextFireAt, consecutiveFailures, deadLetteredAt, and the per-fire routine_run history) was already recorded and simply never shown. No new storage, no new migration.

Addressing: /routines/<id> is canonical, a name resolves onto it

Corrected from the first revision of this PR, which had it backwards. DESIGN.md permits a slug in a route only where it is "immutable and tenant-unique, enforced as a hard database constraint — never a soft convention a migration can violate," and prescribes the opaque-ID route as the fallback where that can't be guaranteed. A routine has no slug column, so a name-derived slug is exactly the soft convention that rule forbids — it breaks on rename and collides across benches.

So the id is the address and renders the page directly, with no redirect hop. A name-shaped segment still resolves, as a convenience for links people typed or shared, by redirecting to the id path — so what lands in the address bar, a bookmark, and a shared link is always the durable address. A name two routines answer to resolves to neither and offers both by id link. An unknown segment says the routine is gone, rather than silently showing the roster under a URL that no longer means anything. resolveRoutineSegment is the whole rule in one pure function, and every branch is tested.

A real slug column for routines is ticketed separately and deliberately not built here.

Cron → English: cronstrue, and the old renderer is gone

cronstrue 3.24.0 — MIT, zero runtime dependencies, browser-safe — as a dependency of @corbits/routines. It had to be a real parser rather than a switch over the shapes cronExpressionForTrigger emits: the cron escape hatch, and now the detail page's editable expression field, accept any 5-field expression cron.ts validates.

Also corrected from the first revision, which claimed the Copy rule now held everywhere while routineCadenceLabel/routineCadenceSummary still shipped alongside — still emitting Cron: 0 9 * * 1-5 into the schedule editor's live summary (routine-schedule.tsx), the canvas panel's trigger rows (shell/routine-panel.tsx), and the in-workbench "routine created" notice (routes.ts). Both functions are deleted, every call site is cut over to routineScheduleSentence, and apps/web/src/routine-trigger.ts — whose remaining exports had no callers left — is deleted with them. One renderer, no parallel path.

Two rules keep the sentences honest: an interval keeps the schedule editor's own words ("Every 15 minutes", not cron's "On the hour, every 6 hours"), and the timezone is named only when the schedule has a wall clock to read in it — "Every 15 minutes (UTC)" says nothing.

Deploy skew: nextFireAt ships optional for one release

Explicitly choosing the optional-field path over documenting an atomic deploy. A browser on the new bundle can be talking to an un-upgraded hub, and a required field would make arktype reject the whole routines payload — blanking every routines surface over a display-only value. The wire schema carries a comment naming the tightening as a follow-up; absent and null read the same ("not scheduled"), which is the honest reading of "the server didn't say."

"Last run" has one definition

lastFireAt is written only inside claimRoutineFire, so a run-now-only routine reported "never run" beside a history table full of runs. It is removed from the wire rather than left as a second, disagreeing source; "last run" is health.lastRunAt — the newest row of the fire history — and the list column and the detail rail both read it.

Mutations report their own failures

useRoutineActions is the one place the three writes live, shared by both surfaces. Run-now, Pause/Resume, and Save schedule each catch and say what happened in words (routineActionFailedToast + describeApiError); Save schedule additionally keeps the draft and repeats it next to the field. No optimistic lies — a row changes when the hub says it changed — and no bare .then left to drop a 403 on the floor.

The rest

  • health.ts decides what "healthy" means once: six states, each with a pill label and a caption saying the same thing in words (DESIGN.md, State Pills), plus clean streak, median fire duration, last failure.
  • run-language.ts puts run statuses and fire causes into the reader's words — no more running / completed / schedule-failed enum values badged onto the screen.
  • A past-due nextFireAt reads "Overdue", not "2h ago" under a "Next run" heading.
  • The schedule editor trims once: the same expression is compared, previewed, and sent, and Save is enabled exactly when the expression is both runnable and describable.
  • Tests assert the contract of the cron sentence (starts with At/Every, names the zone only with a wall clock, never contains the raw expression) rather than pinning cronstrue's exact phrasing, so a dependency upgrade can't turn into a red build with no behaviour change.
  • detail-placeholders.tsx loses only its routine entry; the agent, skill, and plugin placeholders stay for their own tickets.

Deferred, ticketed separately

  • ROUTINE_HEALTH_TONE belongs beside health.ts in the package (blocked only on BadgeTone being a react-ui type); noted in the file.
  • A server-side health summary on GET /routines to collapse the per-bench client fan-out; noted in global-routines.ts.
  • Routines list selection / bulk bar / context-menu parity with DESIGN.md's table grammar.
  • Tightening nextFireAt to required once the hub is known-upgraded.
  • A real slug column for routines.

Checks

bun run check is green for everything this PR touches (@workbench/web 660 pass / 0 fail; @corbits/routines 268 pass / 0 fail).

Two failures in @corbits/assistant-workflow are pre-existing on main, untouched by this branch: 0a2398d9 bumped @corbits/connections-tools to 0.0.5 without repinning workflows/assistant/src/index.ts, which still pins 0.0.4. Left alone rather than folded into an unrelated PR.

https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti

@TheGreatAxios TheGreatAxios changed the title Routines: ops-grade list and full detail page at /routines/<slug> Routines: ops-grade list and full detail page at /routines/<id> Aug 20, 2026
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/<id>` deep link landing on that page.
- `/routines/<slug>`: 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
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/<id>`
  redirects to it so old links still land somewhere real.
- `/routines/<slug>` 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
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/<name>` gets its own real assertion rather than sharing the
placeholder table.

Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti
… failure

Review fixes, two of them canon-level.

**Addressing inverted.** `/routines/<id>` 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
@TheGreatAxios
TheGreatAxios force-pushed the cl-6418-routines-detail branch from 1b22591 to 4157b23 Compare August 21, 2026 00:39
@TheGreatAxios
TheGreatAxios merged commit 71a801e into main Aug 21, 2026
0 of 2 checks passed
@TheGreatAxios
TheGreatAxios deleted the cl-6418-routines-detail branch August 21, 2026 00:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant