Routines: ops-grade list and full detail page at /routines/<id> - #173
Merged
Merged
Conversation
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
force-pushed
the
cl-6418-routines-detail
branch
from
August 21, 2026 00:39
1b22591 to
4157b23
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-fireroutine_runhistory) was already recorded and simply never shown. No new storage, no new migration.Addressing:
/routines/<id>is canonical, a name resolves onto itCorrected 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.
resolveRoutineSegmentis 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 gonecronstrue3.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 shapescronExpressionForTriggeremits: the cron escape hatch, and now the detail page's editable expression field, accept any 5-field expressioncron.tsvalidates.Also corrected from the first revision, which claimed the Copy rule now held everywhere while
routineCadenceLabel/routineCadenceSummarystill shipped alongside — still emittingCron: 0 9 * * 1-5into 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 toroutineScheduleSentence, andapps/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:
nextFireAtships optional for one releaseExplicitly 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
lastFireAtis written only insideclaimRoutineFire, 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" ishealth.lastRunAt— the newest row of the fire history — and the list column and the detail rail both read it.Mutations report their own failures
useRoutineActionsis 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.thenleft to drop a 403 on the floor.The rest
health.tsdecides 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.tsputs run statuses and fire causes into the reader's words — no morerunning/completed/schedule-failedenum values badged onto the screen.nextFireAtreads "Overdue", not "2h ago" under a "Next run" heading.cronstrue's exact phrasing, so a dependency upgrade can't turn into a red build with no behaviour change.detail-placeholders.tsxloses only its routine entry; the agent, skill, and plugin placeholders stay for their own tickets.Deferred, ticketed separately
ROUTINE_HEALTH_TONEbelongs besidehealth.tsin the package (blocked only onBadgeTonebeing a react-ui type); noted in the file.GET /routinesto collapse the per-bench client fan-out; noted inglobal-routines.ts.nextFireAtto required once the hub is known-upgraded.Checks
bun run checkis green for everything this PR touches (@workbench/web660 pass / 0 fail;@corbits/routines268 pass / 0 fail).Two failures in
@corbits/assistant-workfloware pre-existing on main, untouched by this branch:0a2398d9bumped@corbits/connections-toolsto0.0.5without repinningworkflows/assistant/src/index.ts, which still pins0.0.4. Left alone rather than folded into an unrelated PR.https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti