Routines: replace schedules with claim-and-compensate routine scheduling - #8
Merged
Conversation
TheGreatAxios
force-pushed
the
cl-routines
branch
from
August 9, 2026 01:47
3ce8ea3 to
834f909
Compare
Contributor
Author
Self-critique (orchestrator)Verdict: needs careful review — largest diff; quality signals strong Looks good
Should-fix / verify
Nits
|
TheGreatAxios
force-pushed
the
cl-routines
branch
2 times, most recently
from
August 9, 2026 02:28
c0fd533 to
d955e4f
Compare
Contributor
Author
|
Greybeard: MERGE — claim-and-compensate exactly-once; schedules cut over. Minor: create name is bare string. |
TheGreatAxios
force-pushed
the
cl-routines
branch
from
August 9, 2026 03:28
5f2435c to
4464e2c
Compare
Covers trigger validation (presets and the raw cron escape hatch), route shapes, run-now correlation, and scheduled-fire bookkeeping for the new @corbits/routines package.
A Routine is the named parent entity over workflow runs — the workflow's product face, with runs as its history. Triggers are structured presets (interval, daily, weekly) plus a raw cron escape hatch, validated eagerly at the arktype boundary; a null trigger is a first-class manual, run-now-only routine. Every launched run is correlated to its routine through a routine_run link table, written by the same launcher call whether the fire is scheduled or "run now" — there is exactly one launch path. Package-owned migrations follow @corbits/chat's ledger pattern (routine_migrations), and routes follow the same Hono route-factory/requireGrant convention already used across the hub.
Explains what a Routine is, its trigger vocabulary (presets plus the raw cron escape hatch), how a run correlates back to its routine, and the routes @corbits/routines exposes.
Wires createRoutineRoutes into apps/hub under the tenant prefix, alongside a RoutineLauncher adapter over @corbits/folded-runs' launchFoldedRun (mirroring chat's launchInvite path: resolve the deployed workflow definition, read its folded body, mint a fresh instance id, and launch) and a run-summary resolver so a routine's run history reports real status instead of a bare run id. Recurring auto-fire needed a scheduler this repo didn't have: adds a minimal in-process poller (routine-scheduler.ts) built on @corbits/routines' own fireScheduledRoutine, reading its exported routine/routineRun tables directly since RoutineStore is deliberately tenant-scoped and has no cross-tenant enumeration. A single-hub, at-least-once poller — not a distributed cron engine; a multi-replica deployment will need a leader election or dedicated worker before this scales past one hub process.
Covers cadence rendering, next-run estimation, the list/detail pages' empty and populated states, run-to-routine correlation, and the route table now serving Routines in place of Workflows.
A Routine is the named parent entity a person schedules and comes back to look at; a run is one occurrence of it. The page lists every routine with its cadence, next-run estimate, delivery scope, and last result, launches one on demand, and drills into a routine's own run history — each run resolved against the platform's live run status, never a bare id.
Both packages grew independently into the same job: named, trigger-driven automations over workflow runs. @corbits/routines is the one that carries the product's Routine vocabulary end to end (nullable trigger, delivery channel, run correlation), so it is the one that stays — the hub now mounts routines only, and the schedules package, its grant kind, and its migration are gone rather than left running alongside the surface that replaces it.
Routine, run, and Interchange's own workflow concept sit at different levels and were easy to blur once a UI surface counts raw workflow runs alongside a routine list — the glossary now says explicitly that "workflow" on its own always means Interchange's runtime concept, never a stand-in for routine. The routines package README now documents the scheduler's real shape: a single-process, at-least-once poller correct for one hub replica, and what breaks with more than one.
…lete history survival Covers a schedule missed while the hub was down still catching up on restart, two schedulers racing the same fire never both winning it, cron expressions with out-of-range or reversed fields being rejected instead of silently saved and never firing, the standard range-then-step cron idiom being accepted, and a deleted routine's run history staying reachable.
Three routine defects, one root cause: nothing survived a restart or a second replica. `nextFireAt` is now persisted on every routine and recomputed on create, on trigger/enabled changes, and after each fire; a scheduler tests `nextFireAt <= now`, so a fire due while the hub was down is caught up on the next poll instead of silently lost. Claiming a due fire is a single conditional update (advance `nextFireAt` only if it is still due) rather than a read-then-launch race, so two schedulers polling concurrently can never both fire the same routine. Validation and execution now share one cron parser (`packages/routines/src/cron.ts`) instead of two hand-rolled ones: an expression with an out-of-range field (a minute of 60, a month of 13) or a reversed range (`10-5`) is rejected at save time instead of validating as fine and then never firing, and the standard range-then-step idiom (`5-10/2`) is now accepted by both. Deleting a routine no longer orphans its run history: the row is soft-deleted (stops appearing in lists, stops firing) rather than removed, so `GET /routines/:id/runs` keeps resolving it.
An interval routine fires on a wall-clock-aligned cron cadence (*/N * * * *), not N units after whatever moment a viewer loads the page — "every 10 minutes" viewed at :07 fires at :10, not :17.
… semantics The estimate previously added an interval's step directly to the viewing moment, which drifts from the scheduler's actual wall-clock- aligned fire time. It now searches forward against the same rendered cron expression the scheduler fires against, so the displayed next run always matches when the routine really fires.
A routine created before the next_fire_at column existed must not be silently stranded once the migration adds it: this proves a legacy row gets a fresh, fireable next_fire_at rather than staying NULL forever.
The 0003 migration added next_fire_at as a bare nullable column with no backfill, so every routine that already existed got next_fire_at = NULL — and a scheduler's nextFireAt <= now treats NULL as never due. Every enabled, non-deleted, timer-triggered routine now gets a fresh next_fire_at computed from its own trigger as part of that same migration step, the same computation createRoutine already does for a brand new row.
Claiming a fire before launching it closes the double-fire race, but a launch that then fails must not silently strand the routine until its next natural cadence — these prove a failed launch leaves the routine due again immediately, and a retried fire can still succeed.
Claiming a fire advances next_fire_at before the launch runs, which closed the double-fire race but opened a new one: if the launch then throws, that occurrence was gone until the trigger's next natural cadence, and the scheduler's own comment overstated the guarantee as unconditionally exactly-once. A failed launch now compensates the claim — next_fire_at is restored to the moment it was claimed for — so the next poll retries it instead of silently skipping it. claimRoutineFire's conditional update also now runs inside a transaction that locks the row for its read, so a routine's trigger can't change out from under the claim between reading it and writing the next_fire_at computed from it.
The Routines page's next-run estimate had its own cron field matcher and minute-search loop, duplicating packages/routines/src/cron.ts — the module that exists specifically so validation and execution can never disagree. It bundled its own copy because the package's default export pulls in drizzle-orm and postgres through store.ts, which have no business in a browser bundle. @corbits/routines now also exports a browser-safe ./cron subpath (cron.ts has no imports of its own), and the Routines page consumes that directly instead of a second implementation of the same logic.
…ger edit Restoring nextFireAt after a failed launch must not overwrite a value a trigger edit already recomputed during the failure window — these cover both the ordinary restore and the edit-wins case.
compensateFailedFire wrote nextFireAt unconditionally, so a trigger edit that landed during the failure window (already recomputing nextFireAt off the new trigger) could be silently overwritten by the stale restore. The write is now conditioned on nextFireAt still being the exact value the claim wrote, so a newer edit always wins.
The next_fire_at backfill filtered on deleted_at IS NULL, but deleted_at is not added until the following migration — on a from-scratch run (a fresh deploy, CI, a new database) this aborted the entire migration pipeline for both tables with "column deleted_at does not exist." At this point in the sequence every row is, by definition, not yet soft-deletable, so the predicate is dropped rather than reordered around.
…ility Replaces the ordering-specific backfill proof (useful only while the column predated a backfill) with tests that check the schema and the idempotency contract without depending on how many migration steps exist or what they're named, so they hold whether the package ships one migration or several: applying twice is a no-op, both tables and every column the store depends on are present, and a routine created on a freshly migrated database ends up fireable.
This package predates any real traffic, so there is no pre-existing data to carry forward and no earlier schema shape to migrate away from. The four-step sequence (routine, routine_run, then two ALTERs bolted on after the fact) is replaced by one migration that creates both tables in their final shape outright.
test/store.test.ts only exercises the in-memory store, which is atomic purely because JS is single-threaded — it proves nothing about whether Postgres's own timestamp comparison, round-tripped through drizzle, behaves the same way the conditional UPDATE assumes. These drive claimRoutineFire and compensateFailedFire through the real createDrizzleRoutineStore path against Postgres: the ordinary restore, and the edit-wins case where a concurrent trigger change means the conditional UPDATE must not restore.
ID generation belongs to @intx/* without exception, including for entities Interchange has no concept of — the same rule @corbits/webhook-triggers already follows for its own product-owned trigger ids. Routine ids now come from @intx/hub-common's generateId, reusing the closest kind it enumerates rather than minting locally under a routine-specific prefix.
Routines replace schedules: allow routine + routine_run, drop schedules.
TheGreatAxios
force-pushed
the
cl-routines
branch
from
August 9, 2026 03:30
4464e2c to
fec1f5b
Compare
Main already depends on command-palette; routines replaces schedules. Both must stay listed after the rebase merge of those dependency lines.
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.
Summary
Introduce @corbits/routines, retire @corbits/schedules, and mount a Routines page. Fires claim atomically and compensate on launch failure; routine ids mint through Interchange.
Test plan