Skip to content

Routines: replace schedules with claim-and-compensate routine scheduling - #8

Merged
TheGreatAxios merged 28 commits into
mainfrom
cl-routines
Aug 9, 2026
Merged

Routines: replace schedules with claim-and-compensate routine scheduling#8
TheGreatAxios merged 28 commits into
mainfrom
cl-routines

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

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

  • Routine domain + migration + claim-and-compensate tests
  • Concurrent fire compensation tests
  • Routines page tests
  • Schedules package fully retired from hub mount

@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Self-critique (orchestrator)

Verdict: needs careful review — largest diff; quality signals strong

Looks good

  • Clean cutover: @corbits/schedules@corbits/routines (no dual-path).
  • Claim-and-compensate fire path with concurrent-edit tests.
  • Shared cron parser; next_fire_at backfill; soft-delete; launch-failure retry.
  • Hub mount + Routines page; migrations collapsed to final shape.
  • Extensive tests (store drizzle, trigger, routes, migrations).
  • Routine ids mint through Interchange.

Should-fix / verify

  1. Confirm no leftover schedules imports in hub/apps after retirement.
  2. Migration path for existing schedule rows (or clean cutover documented).
  3. CI green on full suite (DB-gated tests).
  4. Greybeard + bruckheimer required — this is the core product rename.

Nits

  • 26 commits is a long story; acceptable if each is atomic (appears so).

@TheGreatAxios
TheGreatAxios force-pushed the cl-routines branch 2 times, most recently from c0fd533 to d955e4f Compare August 9, 2026 02:28
@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Greybeard: MERGE — claim-and-compensate exactly-once; schedules cut over. Minor: create name is bare string.

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.
Main already depends on command-palette; routines replaces schedules.
Both must stay listed after the rebase merge of those dependency lines.
@TheGreatAxios
TheGreatAxios merged commit cea7e14 into main Aug 9, 2026
2 checks passed
@TheGreatAxios
TheGreatAxios deleted the cl-routines branch August 9, 2026 03:36
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