From 5b32aba248bfd8ac8e87e1cc56a3954f561501ee Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 09:15:23 -0700 Subject: [PATCH 01/28] Add tests for the Routine domain package 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. --- packages/routines/package.json | 28 +++ packages/routines/test/migrations.test.ts | 83 +++++++ packages/routines/test/routes.test.ts | 283 ++++++++++++++++++++++ packages/routines/test/trigger.test.ts | 129 ++++++++++ packages/routines/tsconfig.json | 7 + 5 files changed, 530 insertions(+) create mode 100644 packages/routines/package.json create mode 100644 packages/routines/test/migrations.test.ts create mode 100644 packages/routines/test/routes.test.ts create mode 100644 packages/routines/test/trigger.test.ts create mode 100644 packages/routines/tsconfig.json diff --git a/packages/routines/package.json b/packages/routines/package.json new file mode 100644 index 000000000..918b3a216 --- /dev/null +++ b/packages/routines/package.json @@ -0,0 +1,28 @@ +{ + "name": "@corbits/routines", + "private": true, + "description": "The Routine domain model: named automations over workflow runs, their triggers, and run correlation", + "version": "0.0.1", + "license": "SEE LICENSE IN LICENSE.md", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@intx/db": "workspace:*", + "@intx/hub-api": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:", + "drizzle-orm": "catalog:", + "hono": "catalog:", + "postgres": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/routines/test/migrations.test.ts b/packages/routines/test/migrations.test.ts new file mode 100644 index 000000000..70f049949 --- /dev/null +++ b/packages/routines/test/migrations.test.ts @@ -0,0 +1,83 @@ +// DB-gated: skipped when no DATABASE_URL is reachable (a fresh +// checkout still runs the unit gates), mirroring +// @corbits/chat's `migrations.test.ts`. Runs against its own scratch +// database, never the developer's or the walking-skeleton suite's. +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import postgres from "postgres"; + +import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; +import { applyRoutineMigrations } from "../src/migrations"; + +function scratchUrlFor(e2eUrl: string): string { + const url = new URL(e2eUrl); + const database = url.pathname.replace(/^\//, ""); + url.pathname = `/${database}_routine_migrations_test`; + return url.toString(); +} + +const databaseUrl = e2eDatabaseUrl(); +const describeIfDb = databaseUrl === undefined ? describe.skip : describe; + +describeIfDb("applyRoutineMigrations", () => { + const scratchUrl = scratchUrlFor( + databaseUrl ?? "postgres://localhost:5432/unused", + ); + const scratchTarget = new URL(scratchUrl); + const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); + + beforeAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + }); + + afterAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + }); + + test("applies both tables and is idempotent on a second run", async () => { + const first = await applyRoutineMigrations(scratchUrl); + expect(first.applied).toEqual(["0001_routine", "0002_routine_run"]); + + const second = await applyRoutineMigrations(scratchUrl); + expect(second.applied).toEqual([]); + expect(second.alreadyApplied.sort()).toEqual([ + "0001_routine", + "0002_routine_run", + ]); + + const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); + try { + const tables = await sql.unsafe( + `SELECT table_name FROM information_schema.tables ` + + `WHERE table_schema = 'public' AND table_name IN ` + + `('routine', 'routine_run')`, + ); + expect(tables.map((row) => String(row["table_name"])).sort()).toEqual([ + "routine", + "routine_run", + ]); + } finally { + await sql.end(); + } + }); +}); diff --git a/packages/routines/test/routes.test.ts b/packages/routines/test/routes.test.ts new file mode 100644 index 000000000..c3c8206a1 --- /dev/null +++ b/packages/routines/test/routes.test.ts @@ -0,0 +1,283 @@ +// Route-shape and run-correlation tests: the wiring this package owns +// (request parsing, grant checks, response envelopes, and the +// routine<->run correlation write), not anything `arktype`, `hono`, or +// Interchange already tests on its own. +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; + +import type { TenantEnv } from "@intx/hub-api"; +import { + createRoutineRoutes, + fireScheduledRoutine, + type CreateRoutineRoutesDeps, + type RoutineLauncher, +} from "../src/routes"; +import { createInMemoryRoutineStore } from "../src/store"; + +const TENANT = { + id: "tnt_1", + name: "Acme", + slug: "acme", + domain: "acme.example", + parentId: null, + config: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +function principal(id: string) { + return { + id, + tenantId: TENANT.id, + kind: "user" as const, + refId: id, + status: "active" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function fakeLauncher(): RoutineLauncher & { calls: number } { + let calls = 0; + return { + get calls() { + return calls; + }, + async launchRoutineRun() { + calls += 1; + return { runId: `run_${calls}` }; + }, + }; +} + +function mountAs( + routes: Hono, + principalId: string, +): Hono { + const asPrincipal: MiddlewareHandler = async (c, next) => { + c.set("tenant", TENANT); + c.set("principal", principal(principalId)); + await next(); + }; + const app = new Hono(); + app.use("*", asPrincipal); + app.route("/", routes); + return app; +} + +function buildDeps( + overrides: Partial = {}, +): CreateRoutineRoutesDeps { + return { + store: createInMemoryRoutineStore(), + launcher: fakeLauncher(), + requireGrant: () => async (_c, next) => { + await next(); + }, + ...overrides, + }; +} + +async function createRoutine( + app: Hono, + body: Record, +) { + const response = await app.request("/routines", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return { response, body: (await response.json()) as Record }; +} + +const VALID_BODY = { + name: "Morning digest", + definitionId: "def_digest", + trigger: { kind: "daily", hour: 9, minute: 0 }, + scope: "bench", +}; + +describe("createRoutineRoutes", () => { + test("creates a routine and never leaks a raw id where a name belongs", async () => { + const deps = buildDeps(); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { response, body } = await createRoutine(app, VALID_BODY); + + expect(response.status).toBe(201); + expect(body["name"]).toBe("Morning digest"); + expect(body["trigger"]).toEqual({ kind: "daily", hour: 9, minute: 0 }); + expect(typeof body["id"]).toBe("string"); + }); + + test("rejects an invalid trigger with a 400", async () => { + const deps = buildDeps(); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { response, body } = await createRoutine(app, { + ...VALID_BODY, + trigger: { kind: "daily", hour: 24, minute: 0 }, + }); + + expect(response.status).toBe(400); + expect((body["error"] as Record)["code"]).toBe( + "bad_request", + ); + }); + + test("accepts a null trigger as a manual routine", async () => { + const deps = buildDeps(); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { response, body } = await createRoutine(app, { + ...VALID_BODY, + trigger: null, + }); + + expect(response.status).toBe(201); + expect(body["trigger"]).toBeNull(); + }); + + test("lists only routines for the calling tenant", async () => { + const deps = buildDeps(); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + await createRoutine(app, VALID_BODY); + await createRoutine(app, { ...VALID_BODY, name: "Weekly report" }); + + const response = await app.request("/routines"); + const body = (await response.json()) as { items: unknown[] }; + expect(body.items.length).toBe(2); + }); + + test("run now launches through the injected launcher and records correlation", async () => { + const launcher = fakeLauncher(); + const deps = buildDeps({ launcher }); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { body: created } = await createRoutine(app, VALID_BODY); + + const runResponse = await app.request(`/routines/${created["id"]}/run`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(runResponse.status).toBe(201); + expect(launcher.calls).toBe(1); + + const runsResponse = await app.request(`/routines/${created["id"]}/runs`); + const runsBody = (await runsResponse.json()) as { + items: { runId: string; triggeredBy: string }[]; + }; + expect(runsBody.items).toHaveLength(1); + expect(runsBody.items[0]?.triggeredBy).toBe("manual"); + }); + + test("a run launched under a routine is retrievable via GET /routines/:id/runs", async () => { + const deps = buildDeps(); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { body: created } = await createRoutine(app, VALID_BODY); + const routineId = created["id"] as string; + + await deps.store.recordRoutineRun({ + tenantId: TENANT.id, + routineId, + runId: "run_scheduled_1", + triggeredBy: "schedule", + }); + + const response = await app.request(`/routines/${routineId}/runs`); + const body = (await response.json()) as { items: { runId: string }[] }; + expect(body.items.map((item) => item.runId)).toContain("run_scheduled_1"); + }); + + test("404s a run history request for an unknown routine", async () => { + const deps = buildDeps(); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const response = await app.request("/routines/does-not-exist/runs"); + expect(response.status).toBe(404); + }); + + test("run summaries enrich when a resolver is wired, and are omitted without one", async () => { + const deps = buildDeps({ + runSummaryResolver: { + async resolveRunSummary(_tenantId, runId) { + return { status: "succeeded", runId }; + }, + }, + }); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { body: created } = await createRoutine(app, VALID_BODY); + await app.request(`/routines/${created["id"]}/run`, { method: "POST" }); + + const response = await app.request(`/routines/${created["id"]}/runs`); + const body = (await response.json()) as { + items: { run?: { status: string } }[]; + }; + expect(body.items[0]?.run?.status).toBe("succeeded"); + }); + + test("deletes a routine, 404ing a second delete", async () => { + const deps = buildDeps(); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { body: created } = await createRoutine(app, VALID_BODY); + + const first = await app.request(`/routines/${created["id"]}`, { + method: "DELETE", + }); + expect(first.status).toBe(204); + + const second = await app.request(`/routines/${created["id"]}`, { + method: "DELETE", + }); + expect(second.status).toBe(404); + }); +}); + +describe("fireScheduledRoutine", () => { + test("launches and records a schedule-triggered run for an enabled routine", async () => { + const store = createInMemoryRoutineStore(); + const launcher = fakeLauncher(); + const created = await store.createRoutine({ + tenantId: TENANT.id, + name: "Nightly sync", + definitionId: "def_sync", + trigger: { kind: "interval", unit: "hours", every: 6 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + + const launched = await fireScheduledRoutine( + { store, launcher }, + { tenantId: TENANT.id, routine: created }, + ); + + expect(launcher.calls).toBe(1); + const runs = await store.listRunsForRoutine(TENANT.id, created.id); + expect(runs).toHaveLength(1); + expect(runs[0]?.runId).toBe(launched.runId); + expect(runs[0]?.triggeredBy).toBe("schedule"); + }); + + test("refuses to fire a disabled routine", async () => { + const store = createInMemoryRoutineStore(); + const launcher = fakeLauncher(); + const created = await store.createRoutine({ + tenantId: TENANT.id, + name: "Paused digest", + definitionId: "def_digest", + trigger: { kind: "daily", hour: 9, minute: 0 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + const disabled = await store.updateRoutine(TENANT.id, created.id, { + enabled: false, + }); + + await expect( + fireScheduledRoutine( + { store, launcher }, + { tenantId: TENANT.id, routine: disabled }, + ), + ).rejects.toThrow(/disabled/); + expect(launcher.calls).toBe(0); + }); +}); diff --git a/packages/routines/test/trigger.test.ts b/packages/routines/test/trigger.test.ts new file mode 100644 index 000000000..4357bb2b8 --- /dev/null +++ b/packages/routines/test/trigger.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; + +import { + RoutineTrigger, + cronExpressionForTrigger, + isValidCronExpression, +} from "../src/trigger"; + +describe("isValidCronExpression", () => { + test("accepts standard 5-field expressions", () => { + expect(isValidCronExpression("*/5 * * * *")).toBe(true); + expect(isValidCronExpression("0 9 * * 1")).toBe(true); + expect(isValidCronExpression("15,45 * * * *")).toBe(true); + }); + + test("rejects wrong field counts and garbage", () => { + expect(isValidCronExpression("* * * *")).toBe(false); + expect(isValidCronExpression("* * * * * *")).toBe(false); + expect(isValidCronExpression("not a cron string at all")).toBe(false); + expect(isValidCronExpression("")).toBe(false); + }); +}); + +describe("RoutineTrigger", () => { + test("accepts a valid interval preset", () => { + const result = RoutineTrigger({ + kind: "interval", + unit: "minutes", + every: 15, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a non-positive interval", () => { + const result = RoutineTrigger({ + kind: "interval", + unit: "minutes", + every: 0, + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("accepts a valid daily preset", () => { + const result = RoutineTrigger({ kind: "daily", hour: 9, minute: 30 }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an out-of-range hour", () => { + const result = RoutineTrigger({ kind: "daily", hour: 24, minute: 0 }); + expect(result instanceof type.errors).toBe(true); + }); + + test("accepts a valid weekly preset", () => { + const result = RoutineTrigger({ + kind: "weekly", + dayOfWeek: 1, + hour: 9, + minute: 0, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an out-of-range day of week", () => { + const result = RoutineTrigger({ + kind: "weekly", + dayOfWeek: 7, + hour: 9, + minute: 0, + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("accepts a valid raw cron expression", () => { + const result = RoutineTrigger({ kind: "cron", expression: "*/10 * * * *" }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an invalid raw cron expression with a clear message", () => { + const result = RoutineTrigger({ kind: "cron", expression: "garbage" }); + expect(result instanceof type.errors).toBe(true); + if (result instanceof type.errors) { + expect(result.summary).toContain("valid 5-field cron expression"); + } + }); + + test("accepts null as a manual, run-now-only routine", () => { + const result = RoutineTrigger(null); + expect(result instanceof type.errors).toBe(false); + }); +}); + +describe("cronExpressionForTrigger", () => { + test("renders an interval preset to a cron expression", () => { + expect( + cronExpressionForTrigger({ + kind: "interval", + unit: "minutes", + every: 15, + }), + ).toBe("*/15 * * * *"); + expect( + cronExpressionForTrigger({ kind: "interval", unit: "hours", every: 2 }), + ).toBe("0 */2 * * *"); + }); + + test("renders a daily preset to a cron expression", () => { + expect( + cronExpressionForTrigger({ kind: "daily", hour: 9, minute: 30 }), + ).toBe("30 9 * * *"); + }); + + test("renders a weekly preset to a cron expression", () => { + expect( + cronExpressionForTrigger({ + kind: "weekly", + dayOfWeek: 1, + hour: 9, + minute: 0, + }), + ).toBe("0 9 * * 1"); + }); + + test("passes a raw cron trigger through unchanged", () => { + expect( + cronExpressionForTrigger({ kind: "cron", expression: "1 2 3 4 5" }), + ).toBe("1 2 3 4 5"); + }); +}); diff --git a/packages/routines/tsconfig.json b/packages/routines/tsconfig.json new file mode 100644 index 000000000..e956ddd88 --- /dev/null +++ b/packages/routines/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["src", "test"] +} From adcf56b0c3b7a1894699037c77044f79a2b92940 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 09:15:36 -0700 Subject: [PATCH 02/28] Add @corbits/routines: the Routine domain model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bun.lock | 19 ++ packages/routines/src/index.ts | 35 +++ packages/routines/src/migrations.ts | 106 +++++++++ packages/routines/src/routes.ts | 321 ++++++++++++++++++++++++++++ packages/routines/src/schema.ts | 64 ++++++ packages/routines/src/store.ts | 267 +++++++++++++++++++++++ packages/routines/src/trigger.ts | 89 ++++++++ 7 files changed, 901 insertions(+) create mode 100644 packages/routines/src/index.ts create mode 100644 packages/routines/src/migrations.ts create mode 100644 packages/routines/src/routes.ts create mode 100644 packages/routines/src/schema.ts create mode 100644 packages/routines/src/store.ts create mode 100644 packages/routines/src/trigger.ts diff --git a/bun.lock b/bun.lock index ee1f85531..9d42df345 100644 --- a/bun.lock +++ b/bun.lock @@ -338,6 +338,23 @@ "typescript": "catalog:", }, }, + "packages/routines": { + "name": "@corbits/routines", + "version": "0.0.1", + "dependencies": { + "@intx/db": "workspace:*", + "@intx/hub-api": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:", + "drizzle-orm": "catalog:", + "hono": "catalog:", + "postgres": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "packages/schedules": { "name": "@corbits/schedules", "version": "0.0.1", @@ -839,6 +856,8 @@ "@corbits/webhook-triggers": ["@corbits/webhook-triggers@workspace:packages/webhook-triggers"], + "@corbits/routines": ["@corbits/routines@workspace:packages/routines"], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], diff --git a/packages/routines/src/index.ts b/packages/routines/src/index.ts new file mode 100644 index 000000000..2a39596a0 --- /dev/null +++ b/packages/routines/src/index.ts @@ -0,0 +1,35 @@ +export const ROUTINES_PACKAGE_NAME = "@corbits/routines"; + +export { + RoutineTrigger, + isValidCronExpression, + cronExpressionForTrigger, +} from "./trigger"; +export type { RoutineTriggerT } from "./trigger"; + +export { routine, routineRun } from "./schema"; + +export { routineMigrations, applyRoutineMigrations } from "./migrations"; +export type { + RoutineMigration, + ApplyRoutineMigrationsReport, +} from "./migrations"; + +export { createDrizzleRoutineStore, createInMemoryRoutineStore } from "./store"; +export type { + RoutineDb, + RoutineScope, + RoutineRow, + RoutineRunRow, + CreateRoutineInput, + UpdateRoutineInput, + RoutineStore, +} from "./store"; + +export { createRoutineRoutes, fireScheduledRoutine } from "./routes"; +export type { + CreateRoutineRoutesDeps, + RoutineLauncher, + LaunchedRoutineRun, + RunSummaryResolver, +} from "./routes"; diff --git a/packages/routines/src/migrations.ts b/packages/routines/src/migrations.ts new file mode 100644 index 000000000..242816f7c --- /dev/null +++ b/packages/routines/src/migrations.ts @@ -0,0 +1,106 @@ +// Package-owned migrations for @corbits/routines' two tables, following +// the same pattern as @corbits/chat's `migrations.ts`: the platform's +// own schema is authored and applied by @intx/db, and this module is +// this package's half of the "mount + migrations is the entire install +// story" install contract. Bookkeeping is its own ledger table, never +// the platform's drizzle journal, so this package's migration history +// stays extractable on its own. +import postgres from "postgres"; + +export interface RoutineMigration { + name: string; + sql: string; +} + +export const routineMigrations: readonly RoutineMigration[] = [ + { + name: "0001_routine", + sql: ` + CREATE TABLE IF NOT EXISTS "routine" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL, + "name" text NOT NULL, + "definition_id" text NOT NULL, + "trigger" jsonb, + "scope" text NOT NULL, + "input" jsonb NOT NULL, + "enabled" boolean NOT NULL DEFAULT true, + "delivery_channel_id" text, + "created_by" text NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() + ); + `, + }, + { + name: "0002_routine_run", + sql: ` + CREATE TABLE IF NOT EXISTS "routine_run" ( + "tenant_id" text NOT NULL, + "routine_id" text NOT NULL, + "run_id" text NOT NULL, + "triggered_by" text NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("tenant_id", "run_id") + ); + `, + }, +]; + +// Named distinctly from the platform's setup ledger and from any +// drizzle journal, so extracting @corbits/routines out of this repo +// never has to disentangle its history from the platform's or from +// @corbits/chat's own `chat_migrations` ledger. +const LEDGER_TABLE = "routine_migrations"; + +function quoteIdentifier(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +export interface ApplyRoutineMigrationsReport { + applied: string[]; + alreadyApplied: string[]; +} + +/** + * Apply `routineMigrations` against `databaseUrl`, idempotently: a + * migration already recorded in the ledger is skipped, never re-run. + * Failures are loud — the migration name and the underlying error are + * both surfaced. + */ +export async function applyRoutineMigrations( + databaseUrl: string, +): Promise { + const sql = postgres(databaseUrl, { max: 1, onnotice: () => undefined }); + try { + await sql.unsafe( + `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(LEDGER_TABLE)} (` + + `name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`, + ); + const rows = await sql.unsafe( + `SELECT name FROM ${quoteIdentifier(LEDGER_TABLE)}`, + ); + const alreadyApplied = new Set(rows.map((row) => String(row["name"]))); + const applied: string[] = []; + for (const migration of routineMigrations) { + if (alreadyApplied.has(migration.name)) continue; + try { + await sql.unsafe(migration.sql); + await sql.unsafe( + `INSERT INTO ${quoteIdentifier(LEDGER_TABLE)} (name) VALUES ($1)`, + [migration.name], + ); + applied.push(migration.name); + } catch (error) { + throw new Error( + `@corbits/routines migration ${JSON.stringify(migration.name)} failed: ` + + `${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + return { applied, alreadyApplied: [...alreadyApplied] }; + } finally { + await sql.end(); + } +} diff --git a/packages/routines/src/routes.ts b/packages/routines/src/routes.ts new file mode 100644 index 000000000..8e5d1ef00 --- /dev/null +++ b/packages/routines/src/routes.ts @@ -0,0 +1,321 @@ +// The HTTP surface of `@corbits/routines`: CRUD over routines, run +// history, and "run now" — mounted by the hub inside its tenant-scoped +// middleware, the same convention `@corbits/chat`'s routes use. +// +// "Run now" and a scheduled fire are the same launcher call +// (`deps.launcher.launchRoutineRun`) with a different `triggeredBy`; +// this module never grows a second launch path for the unscheduled +// case. +import { Hono } from "hono"; +import { type } from "arktype"; + +import type { TenantEnv } from "@intx/hub-api"; +import type { RequireGrant } from "@intx/hub-api"; +import { idResource } from "@intx/hub-api"; + +import { RoutineTrigger, type RoutineTriggerT } from "./trigger"; +import type { RoutineRow, RoutineRunRow, RoutineStore } from "./store"; + +export interface LaunchedRoutineRun { + readonly runId: string; +} + +/** + * The launcher port: routines never launch a run themselves — they + * hand the definition/input off to whatever launches folded runs on + * the host (`@corbits/folded-runs` in this repo), then record the + * correlation. Keeping this a port, not a direct dependency, is what + * keeps `@corbits/routines` hosted-service-agnostic. + */ +export interface RoutineLauncher { + launchRoutineRun(input: { + tenantId: string; + principalId: string; + definitionId: string; + input: Record; + }): Promise; +} + +/** + * Enriches a correlated run id with whatever summary the host's own + * run-listing surface exposes (status, timing, ...). Optional: a host + * that mounts routines without wiring this still gets bare run ids and + * timestamps back from `GET /routines/:id/runs`. + */ +export interface RunSummaryResolver { + resolveRunSummary( + tenantId: string, + runId: string, + ): Promise | undefined>; +} + +export type CreateRoutineRoutesDeps = { + store: RoutineStore; + launcher: RoutineLauncher; + requireGrant: RequireGrant; + runSummaryResolver?: RunSummaryResolver; +}; + +const ErrorEnvelope = (code: string, message: string) => ({ + error: { code, message }, +}); + +const CreateRoutineBody = type({ + name: "string", + definitionId: "string", + trigger: RoutineTrigger, + scope: "'personal' | 'bench'", + "input?": "Record", + "deliveryChannelId?": "string | null", +}); + +const UpdateRoutineBody = type({ + "name?": "string", + "trigger?": RoutineTrigger, + "input?": "Record", + "enabled?": "boolean", + "deliveryChannelId?": "string | null", +}); + +const RunNowBody = type({ + "input?": "Record", +}); + +/** + * The wire shape for a routine — never a raw id-only reference, always + * the name and structured trigger a UI can render directly, per the + * platform's "no raw IDs on screen" floor. + */ +function routineView(row: RoutineRow) { + return { + id: row.id, + name: row.name, + definitionId: row.definitionId, + trigger: row.trigger, + scope: row.scope, + input: row.input, + enabled: row.enabled, + deliveryChannelId: row.deliveryChannelId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +async function runView( + row: RoutineRunRow, + resolver: RunSummaryResolver | undefined, +) { + const summary = await resolver?.resolveRunSummary(row.tenantId, row.runId); + return { + runId: row.runId, + triggeredBy: row.triggeredBy, + createdAt: row.createdAt.toISOString(), + ...(summary !== undefined ? { run: summary } : {}), + }; +} + +export function createRoutineRoutes( + deps: CreateRoutineRoutesDeps, +): Hono { + const app = new Hono(); + + app.post( + "/routines", + deps.requireGrant("workflow-run:*", "create"), + async (c) => { + const body = CreateRoutineBody(await c.req.json().catch(() => undefined)); + if (body instanceof type.errors) { + return c.json( + ErrorEnvelope("bad_request", `invalid routine body: ${body.summary}`), + 400, + ); + } + + const tenant = c.get("tenant"); + const principal = c.get("principal"); + + const row = await deps.store.createRoutine({ + tenantId: tenant.id, + name: body.name, + definitionId: body.definitionId, + trigger: body.trigger as RoutineTriggerT, + scope: body.scope, + input: body.input ?? {}, + deliveryChannelId: body.deliveryChannelId ?? null, + createdBy: principal.id, + }); + + return c.json(routineView(row), 201); + }, + ); + + app.get( + "/routines", + deps.requireGrant("workflow-run:*", "read"), + async (c) => { + const tenant = c.get("tenant"); + const rows = await deps.store.listRoutines(tenant.id); + return c.json({ items: rows.map(routineView) }); + }, + ); + + app.get( + "/routines/:id", + deps.requireGrant(idResource("workflow-run", "id"), "read"), + async (c) => { + const tenant = c.get("tenant"); + const row = await deps.store.getRoutine(tenant.id, c.req.param("id")); + if (row === undefined) { + return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + } + return c.json(routineView(row)); + }, + ); + + app.patch( + "/routines/:id", + deps.requireGrant(idResource("workflow-run", "id"), "write"), + async (c) => { + const body = UpdateRoutineBody(await c.req.json().catch(() => undefined)); + if (body instanceof type.errors) { + return c.json( + ErrorEnvelope( + "bad_request", + `invalid routine patch: ${body.summary}`, + ), + 400, + ); + } + + const tenant = c.get("tenant"); + const routineId = c.req.param("id"); + const existing = await deps.store.getRoutine(tenant.id, routineId); + if (existing === undefined) { + return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + } + + const row = await deps.store.updateRoutine(tenant.id, routineId, { + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.trigger !== undefined + ? { trigger: body.trigger as RoutineTriggerT } + : {}), + ...(body.input !== undefined ? { input: body.input } : {}), + ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), + ...(body.deliveryChannelId !== undefined + ? { deliveryChannelId: body.deliveryChannelId } + : {}), + }); + + return c.json(routineView(row)); + }, + ); + + app.delete( + "/routines/:id", + deps.requireGrant(idResource("workflow-run", "id"), "write"), + async (c) => { + const tenant = c.get("tenant"); + const deleted = await deps.store.deleteRoutine( + tenant.id, + c.req.param("id"), + ); + if (!deleted) { + return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + } + return c.body(null, 204); + }, + ); + + app.get( + "/routines/:id/runs", + deps.requireGrant(idResource("workflow-run", "id"), "read"), + async (c) => { + const tenant = c.get("tenant"); + const routineId = c.req.param("id"); + const existing = await deps.store.getRoutine(tenant.id, routineId); + if (existing === undefined) { + return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + } + const rows = await deps.store.listRunsForRoutine(tenant.id, routineId); + const items = await Promise.all( + rows.map((row) => runView(row, deps.runSummaryResolver)), + ); + return c.json({ items }); + }, + ); + + app.post( + "/routines/:id/run", + deps.requireGrant(idResource("workflow-run", "id"), "create"), + async (c) => { + const body = RunNowBody(await c.req.json().catch(() => ({}))); + if (body instanceof type.errors) { + return c.json( + ErrorEnvelope("bad_request", `invalid run body: ${body.summary}`), + 400, + ); + } + + const tenant = c.get("tenant"); + const principal = c.get("principal"); + const routineId = c.req.param("id"); + const existing = await deps.store.getRoutine(tenant.id, routineId); + if (existing === undefined) { + return c.json(ErrorEnvelope("not_found", "routine not found"), 404); + } + + // "Run now" is an unscheduled fire of the exact launcher a + // scheduled trigger would call — the only difference is + // `triggeredBy`, never a second launch code path. + const launched = await deps.launcher.launchRoutineRun({ + tenantId: tenant.id, + principalId: principal.id, + definitionId: existing.definitionId, + input: body.input ?? existing.input, + }); + + await deps.store.recordRoutineRun({ + tenantId: tenant.id, + routineId, + runId: launched.runId, + triggeredBy: "manual", + }); + + return c.json({ runId: launched.runId }, 201); + }, + ); + + return app; +} + +/** + * Fires a scheduled routine exactly the way `POST /routines/:id/run` + * fires a manual one — same `launcher.launchRoutineRun` call, same + * `recordRoutineRun` bookkeeping — with `triggeredBy: "schedule"` the + * only distinguishing fact. A cron/interval scheduler calls this + * directly, tenant and routine already resolved; it never + * re-implements the launch or the correlation write. + */ +export async function fireScheduledRoutine( + deps: { store: RoutineStore; launcher: RoutineLauncher }, + params: { tenantId: string; routine: RoutineRow }, +): Promise { + if (!params.routine.enabled) { + throw new Error( + `routine ${params.routine.id} is disabled; a scheduler must not fire it`, + ); + } + const launched = await deps.launcher.launchRoutineRun({ + tenantId: params.tenantId, + principalId: params.routine.createdBy, + definitionId: params.routine.definitionId, + input: params.routine.input, + }); + await deps.store.recordRoutineRun({ + tenantId: params.tenantId, + routineId: params.routine.id, + runId: launched.runId, + triggeredBy: "schedule", + }); + return launched; +} diff --git a/packages/routines/src/schema.ts b/packages/routines/src/schema.ts new file mode 100644 index 000000000..55c09a23c --- /dev/null +++ b/packages/routines/src/schema.ts @@ -0,0 +1,64 @@ +// The two tables `@corbits/routines` owns: the routine itself (the +// named, product-facing entity) and the link table correlating each +// launched run back to the routine that launched it. Tenancy, +// principals, and the run/session rows a launch writes stay native +// platform schema under vendor/intx/db — this package only adds its +// own state, keyed by tenant. +import { + boolean, + jsonb, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +/** + * A Routine: the named parent entity over workflow runs. `trigger` and + * `input` are jsonb — record-as-truth, the same convention + * `@corbits/chat`'s `channel_settings` uses — so new trigger shapes or + * input fields never require a migration. `trigger` holds the arktype- + * validated shape from `./trigger.ts`, or `null` for a manual, + * run-now-only routine. `deliveryChannelId` is nullable: a routine + * need not post its results anywhere. + */ +export const routine = pgTable("routine", { + id: text("id").primaryKey(), + tenantId: text("tenant_id").notNull(), + name: text("name").notNull(), + definitionId: text("definition_id").notNull(), + trigger: jsonb("trigger"), + scope: text("scope").notNull(), + input: jsonb("input").notNull(), + enabled: boolean("enabled").notNull().default(true), + deliveryChannelId: text("delivery_channel_id"), + createdBy: text("created_by").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +/** + * Correlates a launched run (a `workflow_run.id` / folded-run instance + * id, native platform schema) back to the routine that launched it. A + * separate link table rather than a column grafted onto the platform's + * own `workflow_run` — this package never migrates a table it doesn't + * own — and it holds nothing else: run status, timing, and mail all + * stay read off the platform's own run surfaces, joined by `runId`. + */ +export const routineRun = pgTable( + "routine_run", + { + tenantId: text("tenant_id").notNull(), + routineId: text("routine_id").notNull(), + runId: text("run_id").notNull(), + triggeredBy: text("triggered_by").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [primaryKey({ columns: [table.tenantId, table.runId] })], +); diff --git a/packages/routines/src/store.ts b/packages/routines/src/store.ts new file mode 100644 index 000000000..06d782b82 --- /dev/null +++ b/packages/routines/src/store.ts @@ -0,0 +1,267 @@ +// Persistence for the two routines tables, kept apart from route +// wiring the same way `@corbits/chat`'s `store.ts` separates +// persistence from `routes.ts`. `RoutineStore` is the seam the route +// layer depends on; `createDrizzleRoutineStore` is its one production +// implementation, over the tables in `./schema.ts`. +import { and, desc, eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { hexEncode } from "@intx/types"; + +import { routine, routineRun } from "./schema"; +import type { RoutineTriggerT } from "./trigger"; + +export type RoutineDb< + TSchema extends Record = Record, +> = PostgresJsDatabase; + +export type RoutineScope = "personal" | "bench"; + +export interface RoutineRow { + readonly id: string; + readonly tenantId: string; + readonly name: string; + readonly definitionId: string; + readonly trigger: RoutineTriggerT; + readonly scope: RoutineScope; + readonly input: Record; + readonly enabled: boolean; + readonly deliveryChannelId: string | null; + readonly createdBy: string; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface CreateRoutineInput { + readonly tenantId: string; + readonly name: string; + readonly definitionId: string; + readonly trigger: RoutineTriggerT; + readonly scope: RoutineScope; + readonly input: Record; + readonly deliveryChannelId?: string | null; + readonly createdBy: string; +} + +export interface UpdateRoutineInput { + readonly name?: string; + readonly trigger?: RoutineTriggerT; + readonly input?: Record; + readonly enabled?: boolean; + readonly deliveryChannelId?: string | null; +} + +export interface RoutineRunRow { + readonly tenantId: string; + readonly routineId: string; + readonly runId: string; + readonly triggeredBy: string; + readonly createdAt: Date; +} + +export interface RoutineStore { + createRoutine(input: CreateRoutineInput): Promise; + getRoutine( + tenantId: string, + routineId: string, + ): Promise; + listRoutines(tenantId: string): Promise; + updateRoutine( + tenantId: string, + routineId: string, + patch: UpdateRoutineInput, + ): Promise; + deleteRoutine(tenantId: string, routineId: string): Promise; + /** + * Records that `runId` was launched under `routineId` — called + * inside the same launch call every routine fire goes through + * (scheduled or "run now" alike), never a second bookkeeping path. + */ + recordRoutineRun(input: { + tenantId: string; + routineId: string; + runId: string; + triggeredBy: string; + }): Promise; + listRunsForRoutine( + tenantId: string, + routineId: string, + ): Promise; +} + +// `@intx/hub-common`'s `generateId` is closed over the platform's own +// ID kinds (tenant, principal, session, ...), which a Routine is not — +// it is a product entity this package owns, not a platform-native +// resource. This mints ids with the exact same primitive +// (`crypto.getRandomValues` + hex encoding) generateId uses, under its +// own `rtn_` prefix, rather than smuggling a new kind into the +// platform's enumeration. +function generateRoutineId(): string { + const bytes = hexEncode(crypto.getRandomValues(new Uint8Array(16))); + return `rtn_${bytes}`; +} + +export function createDrizzleRoutineStore< + TSchema extends Record, +>(db: RoutineDb): RoutineStore { + return { + async createRoutine(input) { + const now = new Date(); + const [row] = await db + .insert(routine) + .values({ + id: generateRoutineId(), + tenantId: input.tenantId, + name: input.name, + definitionId: input.definitionId, + trigger: input.trigger, + scope: input.scope, + input: input.input, + enabled: true, + deliveryChannelId: input.deliveryChannelId ?? null, + createdBy: input.createdBy, + createdAt: now, + updatedAt: now, + }) + .returning(); + if (row === undefined) { + throw new Error("createRoutine: insert returned no row"); + } + return row as RoutineRow; + }, + + async getRoutine(tenantId, routineId) { + const [row] = await db + .select() + .from(routine) + .where(and(eq(routine.tenantId, tenantId), eq(routine.id, routineId))) + .limit(1); + return row as RoutineRow | undefined; + }, + + async listRoutines(tenantId) { + const rows = await db + .select() + .from(routine) + .where(eq(routine.tenantId, tenantId)); + return rows as RoutineRow[]; + }, + + async updateRoutine(tenantId, routineId, patch) { + const [row] = await db + .update(routine) + .set({ ...patch, updatedAt: new Date() }) + .where(and(eq(routine.tenantId, tenantId), eq(routine.id, routineId))) + .returning(); + if (row === undefined) { + throw new Error(`updateRoutine: no routine row for id ${routineId}`); + } + return row as RoutineRow; + }, + + async deleteRoutine(tenantId, routineId) { + const deleted = await db + .delete(routine) + .where(and(eq(routine.tenantId, tenantId), eq(routine.id, routineId))) + .returning(); + return deleted.length > 0; + }, + + async recordRoutineRun(input) { + const [row] = await db.insert(routineRun).values(input).returning(); + if (row === undefined) { + throw new Error("recordRoutineRun: insert returned no row"); + } + return row as RoutineRunRow; + }, + + async listRunsForRoutine(tenantId, routineId) { + const rows = await db + .select() + .from(routineRun) + .where( + and( + eq(routineRun.tenantId, tenantId), + eq(routineRun.routineId, routineId), + ), + ) + .orderBy(desc(routineRun.createdAt)); + return rows as RoutineRunRow[]; + }, + }; +} + +/** + * An in-memory `RoutineStore`, for tests and any host that wants + * routine routes without a database. Not a supported deployment + * target. + */ +export function createInMemoryRoutineStore(): RoutineStore { + const routinesById = new Map(); + const runs: RoutineRunRow[] = []; + + return { + async createRoutine(input) { + const now = new Date(); + const row: RoutineRow = { + id: generateRoutineId(), + tenantId: input.tenantId, + name: input.name, + definitionId: input.definitionId, + trigger: input.trigger, + scope: input.scope, + input: input.input, + enabled: true, + deliveryChannelId: input.deliveryChannelId ?? null, + createdBy: input.createdBy, + createdAt: now, + updatedAt: now, + }; + routinesById.set(row.id, row); + return row; + }, + + async getRoutine(tenantId, routineId) { + const row = routinesById.get(routineId); + return row?.tenantId === tenantId ? row : undefined; + }, + + async listRoutines(tenantId) { + return [...routinesById.values()].filter( + (row) => row.tenantId === tenantId, + ); + }, + + async updateRoutine(tenantId, routineId, patch) { + const existing = routinesById.get(routineId); + if (existing === undefined || existing.tenantId !== tenantId) { + throw new Error(`updateRoutine: no routine row for id ${routineId}`); + } + const row: RoutineRow = { ...existing, ...patch, updatedAt: new Date() }; + routinesById.set(routineId, row); + return row; + }, + + async deleteRoutine(tenantId, routineId) { + const existing = routinesById.get(routineId); + if (existing === undefined || existing.tenantId !== tenantId) { + return false; + } + routinesById.delete(routineId); + return true; + }, + + async recordRoutineRun(input) { + const row: RoutineRunRow = { ...input, createdAt: new Date() }; + runs.push(row); + return row; + }, + + async listRunsForRoutine(tenantId, routineId) { + return runs + .filter( + (row) => row.tenantId === tenantId && row.routineId === routineId, + ) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + }, + }; +} diff --git a/packages/routines/src/trigger.ts b/packages/routines/src/trigger.ts new file mode 100644 index 000000000..f8570bbde --- /dev/null +++ b/packages/routines/src/trigger.ts @@ -0,0 +1,89 @@ +// The Routine trigger vocabulary: three presets covering the common +// cadences, stored as structured data rather than opaque cron strings, +// plus a raw cron escape hatch for anything a preset can't express. A +// `null` trigger is a valid, first-class routine shape — a manual, +// run-now-only automation, not an error state. +import { type } from "arktype"; + +const CRON_FIELD = + /^(\*|[0-9]+)(\/[0-9]+)?(-[0-9]+)?(,(\*|[0-9]+)(\/[0-9]+)?(-[0-9]+)?)*$/; + +/** + * Loud, eager validation for a raw 5-field cron expression + * (minute hour day-of-month month day-of-week). Rejects anything that + * isn't exactly five whitespace-separated fields built from the + * standard `*`, `,`, `-`, `/` cron grammar — never silently accepts a + * malformed schedule that would then fail at fire-time instead of at + * save-time. + */ +export function isValidCronExpression(expression: string): boolean { + const fields = expression.trim().split(/\s+/); + if (fields.length !== 5) return false; + return fields.every((field) => CRON_FIELD.test(field)); +} + +const IntervalTrigger = type({ + kind: "'interval'", + unit: "'minutes' | 'hours'", + every: "number.integer > 0", +}); + +const DailyTrigger = type({ + kind: "'daily'", + hour: "0 <= number.integer <= 23", + minute: "0 <= number.integer <= 59", +}); + +const WeeklyTrigger = type({ + kind: "'weekly'", + dayOfWeek: "0 <= number.integer <= 6", + hour: "0 <= number.integer <= 23", + minute: "0 <= number.integer <= 59", +}); + +const CronTrigger = type({ + kind: "'cron'", + expression: "string", +}).narrow((value, ctx) => { + if (isValidCronExpression(value.expression)) return true; + return ctx.reject( + `"${value.expression}" is not a valid 5-field cron expression ` + + `(minute hour day-of-month month day-of-week)`, + ); +}); + +/** + * A routine's trigger: one of the three presets, a raw cron escape + * hatch, or `null` for a manual, run-now-only routine. Every branch is + * validated eagerly (arktype at the trust boundary) — an invalid cron + * string or an out-of-range preset field is rejected at save time with + * a specific error, never at the next scheduled fire. + */ +export const RoutineTrigger = IntervalTrigger.or(DailyTrigger) + .or(WeeklyTrigger) + .or(CronTrigger) + .or("null"); + +export type RoutineTriggerT = typeof RoutineTrigger.infer; + +/** + * Renders any trigger shape to a canonical cron expression, the single + * form the scheduler actually runs against — presets are sugar over + * this, never a second execution path. + */ +export function cronExpressionForTrigger( + trigger: Exclude, +): string { + switch (trigger.kind) { + case "interval": + return trigger.unit === "minutes" + ? `*/${trigger.every} * * * *` + : `0 */${trigger.every} * * *`; + case "daily": + return `${trigger.minute} ${trigger.hour} * * *`; + case "weekly": + return `${trigger.minute} ${trigger.hour} * * ${trigger.dayOfWeek}`; + case "cron": + return trigger.expression; + } +} From 626b29c4b1205a1f388b20fa6f38091a39d69f11 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 09:17:42 -0700 Subject: [PATCH 03/28] Document the Routine concept 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. --- packages/routines/README.md | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/routines/README.md diff --git a/packages/routines/README.md b/packages/routines/README.md new file mode 100644 index 000000000..59e265223 --- /dev/null +++ b/packages/routines/README.md @@ -0,0 +1,74 @@ +# @corbits/routines + +A **Routine** is the named parent entity over workflow runs — the +workflow's product face. A run is an occurrence of a routine; the +routine is what a person names, schedules, and comes back to look at. + +## Shape + +```ts +{ + id: string; + name: string; + definitionId: string; // the workflow definition this routine launches + trigger: RoutineTrigger; // structured schedule, or null for manual-only + scope: "personal" | "bench"; + input: Record; + enabled: boolean; + deliveryChannelId: string | null; // where results are posted, if anywhere +} +``` + +## Triggers + +A trigger is either `null` (a manual, run-now-only routine) or one of: + +- `{ kind: "interval", unit: "minutes" | "hours", every: number }` +- `{ kind: "daily", hour: number, minute: number }` +- `{ kind: "weekly", dayOfWeek: number, hour: number, minute: number }` +- `{ kind: "cron", expression: string }` — a raw 5-field cron escape + hatch for schedules a preset can't express + +Every shape is validated eagerly at the arktype boundary: an +out-of-range preset field or a malformed cron expression is rejected +at save time with a specific error, never discovered later at a missed +fire. `cronExpressionForTrigger` renders any non-null trigger to the +single canonical cron expression a scheduler actually runs against — +the presets are sugar over that one form, never a second schedule +representation. + +## Run correlation + +Every run a routine launches — scheduled or manual — is recorded in +`routine_run`, a link table keyed by `(tenantId, runId)` pointing at +the routine that launched it. `GET /routines/:id/runs` reads this +table to answer "what has this routine done", optionally enriched with +a run's live status through a host-supplied `RunSummaryResolver`. + +"Run now" and a scheduled fire share the same launcher call +(`RoutineLauncher.launchRoutineRun`, `fireScheduledRoutine` in +`src/routes.ts`) — the only difference is the `triggeredBy` value +recorded alongside the run. There is exactly one launch path; a +scheduler is expected to call `fireScheduledRoutine` directly rather +than re-implementing it. + +## Routes + +Mounted under a tenant prefix, matching the platform's own +`TenantEnv`/`requireGrant` convention: + +- `POST /routines` — create +- `GET /routines` — list +- `GET /routines/:id` — get +- `PATCH /routines/:id` — update (name, trigger, input, enabled, delivery channel) +- `DELETE /routines/:id` — delete +- `GET /routines/:id/runs` — run history +- `POST /routines/:id/run` — run now + +## Install + +Like `@corbits/chat`, this package owns its own migrations +(`applyRoutineMigrations`) against a `routine_migrations` ledger table, +independent of the platform's own schema and of any other package's +ledger — extracting this package never has to disentangle its history +from theirs. From 0f1af29b6dc5eaeb9059185ea7a3b38dd748f5dc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 09:42:41 -0700 Subject: [PATCH 04/28] Mount @corbits/routines into the hub, with a real folded-run launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/hub/package.json | 2 + apps/hub/src/cron-due.ts | 76 ++++++++++++++++++ apps/hub/src/index.ts | 49 ++++++++++++ apps/hub/src/routine-launcher.ts | 90 +++++++++++++++++++++ apps/hub/src/routine-run-summary.ts | 29 +++++++ apps/hub/src/routine-scheduler.ts | 119 ++++++++++++++++++++++++++++ apps/hub/test/cron-due.test.ts | 92 +++++++++++++++++++++ apps/hub/test/routine-mount.test.ts | 55 +++++++++++++ bun.lock | 2 + 9 files changed, 514 insertions(+) create mode 100644 apps/hub/src/cron-due.ts create mode 100644 apps/hub/src/routine-launcher.ts create mode 100644 apps/hub/src/routine-run-summary.ts create mode 100644 apps/hub/src/routine-scheduler.ts create mode 100644 apps/hub/test/cron-due.test.ts create mode 100644 apps/hub/test/routine-mount.test.ts diff --git a/apps/hub/package.json b/apps/hub/package.json index 98c6f2647..c0e2bff67 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -16,12 +16,14 @@ "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/folded-runs": "workspace:*", + "@corbits/routines": "workspace:*", "@corbits/schedules": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@intx/authz": "workspace:*", "@intx/crypto": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", + "@intx/hub-common": "workspace:*", "@intx/hub-sessions": "workspace:*", "@intx/log": "workspace:*", "@intx/mime": "workspace:*", diff --git a/apps/hub/src/cron-due.ts b/apps/hub/src/cron-due.ts new file mode 100644 index 000000000..01fc38704 --- /dev/null +++ b/apps/hub/src/cron-due.ts @@ -0,0 +1,76 @@ +// A minute-granularity matcher for the 5-field cron grammar +// `@corbits/routines`' `cronExpressionForTrigger` renders every trigger +// preset into (minute hour day-of-month month day-of-week), plus whatever +// a routine's raw-cron escape hatch supplies (already validated at save +// time by `isValidCronExpression`, the same grammar this matches). Kept +// as a pure function so the scheduler's "is it time yet" decision is +// exercised without a clock, a database, or a launch. +type CronClause = { + readonly base: "*" | number; + readonly step?: number; + readonly rangeEnd?: number; +}; + +function parseClause(raw: string): CronClause { + const match = /^(\*|[0-9]+)(?:\/([0-9]+))?(?:-([0-9]+))?$/.exec(raw); + if (match === null) { + throw new Error(`unrecognized cron field clause "${raw}"`); + } + const [, base, step, rangeEnd] = match; + return { + base: base === "*" ? "*" : Number(base), + ...(step !== undefined ? { step: Number(step) } : {}), + ...(rangeEnd !== undefined ? { rangeEnd: Number(rangeEnd) } : {}), + }; +} + +function clauseMatches(clause: CronClause, value: number): boolean { + if (clause.base === "*") { + return clause.step === undefined ? true : value % clause.step === 0; + } + if (clause.rangeEnd === undefined && clause.step === undefined) { + return value === clause.base; + } + const upper = clause.rangeEnd ?? clause.base; + if (value < clause.base || value > upper) return false; + if (clause.step === undefined) return true; + return (value - clause.base) % clause.step === 0; +} + +function fieldMatches(field: string, value: number): boolean { + return field + .split(",") + .some((clause) => clauseMatches(parseClause(clause), value)); +} + +/** + * True when `expression`'s minute/hour/day-of-month/month/day-of-week + * fields all match `at` (read in UTC, matching how the trigger presets' + * hour/minute fields are stored — no timezone concept exists yet on a + * `RoutineTrigger`). + */ +export function cronMatchesMinute(expression: string, at: Date): boolean { + const fields = expression.trim().split(/\s+/); + const [minute, hour, dayOfMonth, month, dayOfWeek] = fields; + if ( + minute === undefined || + hour === undefined || + dayOfMonth === undefined || + month === undefined || + dayOfWeek === undefined + ) { + throw new Error(`"${expression}" is not a 5-field cron expression`); + } + return ( + fieldMatches(minute, at.getUTCMinutes()) && + fieldMatches(hour, at.getUTCHours()) && + fieldMatches(dayOfMonth, at.getUTCDate()) && + fieldMatches(month, at.getUTCMonth() + 1) && + fieldMatches(dayOfWeek, at.getUTCDay()) + ); +} + +/** The UTC minute `at` falls in, as a stable, comparable integer key. */ +export function minuteKey(at: Date): number { + return Math.floor(at.getTime() / 60_000); +} diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 8ac3b49ca..0985b31df 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -40,6 +40,10 @@ import { createCommandRoutes, createWorkflowCommandPlugin, } from "@corbits/commands"; +import { + createDrizzleRoutineStore, + createRoutineRoutes, +} from "@corbits/routines"; import { createAgentRepoStore, createAssetService, @@ -61,6 +65,9 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { type Context, type Next } from "hono"; import { upgradeWebSocket, websocket } from "hono/bun"; import { readHubConfig, type HubConfig } from "./config"; +import { createHubRoutineLauncher } from "./routine-launcher"; +import { createHubRunSummaryResolver } from "./routine-run-summary"; +import { createRoutineScheduler } from "./routine-scheduler"; // Host policy constants, not configuration. const MAX_TARBALL_BYTES = 10 * 1024 * 1024; @@ -424,6 +431,47 @@ export async function createHub(config: HubConfig) { }), ); + // Routines: its own grant store (routines authorize against the + // `workflow-run:*` resource family, the same one native run routes + // use — see `@corbits/routines`' routes.ts), the launcher adapter + // that turns a routine's `launchRoutineRun` call into a real folded + // run via `@corbits/folded-runs` (routine-launcher.ts), and a run + // summary resolver so `GET /routines/:id/runs` reports each fire's + // real status instead of a bare run id. + const routineGrantStore = createGrantStore(db); + const routineStore = createDrizzleRoutineStore(db); + const routineLauncher = createHubRoutineLauncher({ + db, + sessionService, + assetService, + sidecarRouter, + eventCollectors, + }); + app.route( + `${TENANT_PREFIX}/routines`, + createRoutineRoutes({ + store: routineStore, + launcher: routineLauncher, + requireGrant: createRequireGrant({ + grantStore: routineGrantStore, + conditionRegistry: chatConditionRegistry, + }), + runSummaryResolver: createHubRunSummaryResolver(db), + }), + ); + // Recurring auto-fire: a minimal in-process poller (routine-scheduler.ts) + // over `@corbits/routines`' own `fireScheduledRoutine` — this hub has no + // general job-runner today, so this loop is scoped to exactly one job + // (fire due routines) rather than standing up a bespoke cron daemon as a + // hidden dependency. A real multi-instance deployment needs a leader + // election or a single dedicated worker process before this scales past + // one hub replica; tracked as a known limitation, not solved here. + const routineScheduler = createRoutineScheduler({ + db, + store: routineStore, + launcher: routineLauncher, + }); + // The first-login hook mounts outside the tenant prefix, since the // session it serves belongs to no tenant yet. The route is // `@workbench/onboarding`'s; what it decides is documented in that @@ -456,6 +504,7 @@ export async function createHub(config: HubConfig) { close: async () => { scheduler.stop(); chatOrchestrator.dispose(); + routineScheduler.stop(); await close(); }, }; diff --git a/apps/hub/src/routine-launcher.ts b/apps/hub/src/routine-launcher.ts new file mode 100644 index 000000000..8ea6d8a37 --- /dev/null +++ b/apps/hub/src/routine-launcher.ts @@ -0,0 +1,90 @@ +// Adapts `@corbits/folded-runs`' `launchFoldedRun` to `@corbits/routines`' +// `RoutineLauncher` port. Mirrors `@corbits/chat`'s `launchInvite` path +// (packages/chat/src/platform-adapter.ts) exactly: look up the deployed +// workflow definition, read its folded body off the materialized asset, +// mint a fresh instance id and trigger address, and launch. Routines owns +// no launch machinery of its own — this file only wires the two packages +// together, per "apps stay generic; packages own the domain": the domain +// logic (what a folded run is, how a routine fires) lives in those +// packages, and this adapter is pure composition. +import { and, eq } from "drizzle-orm"; +import type { DB } from "@intx/db"; +import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; +import { + launchFoldedRun, + readDefinitionJSON, + readFoldedBody, + type FoldedRunsDeps, +} from "@corbits/folded-runs"; +import { generateId } from "@intx/hub-common"; +import { formatAgentAddress } from "@intx/types"; +import type { AssetService } from "@intx/hub-sessions"; +import type { RoutineLauncher } from "@corbits/routines"; + +export type CreateHubRoutineLauncherDeps = FoldedRunsDeps & { + db: DB["db"]; + assetService: AssetService; +}; + +/** + * Builds the hub's `RoutineLauncher`: every routine fire — "run now" or + * scheduled — resolves to exactly this launch path, the same folded-run + * launch every other agent instance in this hub goes through. + */ +export function createHubRoutineLauncher( + deps: CreateHubRoutineLauncherDeps, +): RoutineLauncher { + return { + async launchRoutineRun(input) { + const definitionRow = await deps.db.query.workflowDefinition.findFirst({ + where: and( + eq(workflowDefinition.id, input.definitionId), + eq(workflowDefinition.tenantId, input.tenantId), + ), + }); + if (definitionRow === undefined) { + throw new Error( + `no definition "${input.definitionId}" for this tenant`, + ); + } + if (definitionRow.status !== "deployed") { + throw new Error( + `definition "${input.definitionId}" is not in a launchable ` + + `state (status: ${definitionRow.status})`, + ); + } + if (definitionRow.assetId === null) { + throw new Error( + `definition "${input.definitionId}" has not been materialized`, + ); + } + + const tenantRow = await deps.db.query.tenant.findFirst({ + where: eq(tenantTable.id, input.tenantId), + }); + if (tenantRow === undefined) { + throw new Error(`no tenant "${input.tenantId}"`); + } + + const definitionJSON = await readDefinitionJSON( + deps.assetService, + definitionRow.assetId, + ); + const foldedBody = readFoldedBody(definitionJSON); + + const instanceId = generateId("instance"); + const triggerAddress = formatAgentAddress(instanceId, tenantRow.domain); + + await launchFoldedRun(deps, { + tenantId: input.tenantId, + instanceId, + triggerAddress, + definitionId: input.definitionId, + foldedBody, + launchLabel: "a routine", + }); + + return { runId: instanceId }; + }, + }; +} diff --git a/apps/hub/src/routine-run-summary.ts b/apps/hub/src/routine-run-summary.ts new file mode 100644 index 000000000..c064611e9 --- /dev/null +++ b/apps/hub/src/routine-run-summary.ts @@ -0,0 +1,29 @@ +// Enriches a routine run's bare id with the run's own status/timing off +// `workflow_run` — the same row `launchFoldedRun` (via `@corbits/folded-runs`) +// writes at launch and the platform's own event pipeline settles into a +// terminal status. Optional per `@corbits/routines`' `RunSummaryResolver` +// contract: a host that skips this still gets bare run ids and timestamps +// back from `GET /routines/:id/runs`. +import { and, eq } from "drizzle-orm"; +import type { DB } from "@intx/db"; +import { workflowRun } from "@intx/db/schema"; +import type { RunSummaryResolver } from "@corbits/routines"; + +export function createHubRunSummaryResolver(db: DB["db"]): RunSummaryResolver { + return { + async resolveRunSummary(tenantId, runId) { + const row = await db.query.workflowRun.findFirst({ + where: and( + eq(workflowRun.id, runId), + eq(workflowRun.tenantId, tenantId), + ), + }); + if (row === undefined) return undefined; + return { + status: row.status, + createdAt: row.createdAt.toISOString(), + endedAt: row.endedAt?.toISOString() ?? null, + }; + }, + }; +} diff --git a/apps/hub/src/routine-scheduler.ts b/apps/hub/src/routine-scheduler.ts new file mode 100644 index 000000000..b408c3b3e --- /dev/null +++ b/apps/hub/src/routine-scheduler.ts @@ -0,0 +1,119 @@ +// A minimal periodic loop that fires due routines — the one piece +// `@corbits/routines` deliberately does not own (it exposes +// `fireScheduledRoutine` for exactly this, but ships no scheduler: see +// that package's routes.ts doc comment). This mirrors +// `@corbits/agent-lifecycle`'s own `setInterval` sweep (the only other +// periodic loop in this repo) rather than pulling in a new dependency: a +// single-process, at-least-once poller, not a distributed cron engine. +// +// `routine`/`routineRun` are `@corbits/routines`' own exported schema +// tables (its public surface, alongside `RoutineStore`) — read directly +// here because `RoutineStore` is deliberately tenant-scoped +// (`listRoutines(tenantId)`; see store.ts) and has no cross-tenant +// enumeration, which a scheduler needs and a per-request route never +// does. This is the same "read the extension's exported schema +// directly" pattern chat's own routes use for `channel_launch`. +import { desc, eq } from "drizzle-orm"; +import type { DB } from "@intx/db"; +import { + cronExpressionForTrigger, + fireScheduledRoutine, + routine, + routineRun, + type RoutineLauncher, + type RoutineRow, + type RoutineStore, +} from "@corbits/routines"; +import { getLogger } from "@intx/log"; +import { cronMatchesMinute, minuteKey } from "./cron-due"; + +export type RoutineSchedulerDeps = { + db: DB["db"]; + store: RoutineStore; + launcher: RoutineLauncher; + /** Injectable for deterministic tests; defaults to `Date.now`-backed wall time. */ + now?: () => Date; +}; + +const POLL_INTERVAL_MS = 30_000; +const log = getLogger(["hub", "routine-scheduler"]); + +/** + * Every enabled, timer-triggered routine, each paired with the minute key + * of its own most recent scheduled fire (`undefined` if it has never + * fired on a schedule before) — the guard that keeps a routine whose + * cadence matches for the whole span of a tick from firing twice. + */ +async function loadSchedulableRoutines( + db: DB["db"], +): Promise< + readonly { routine: RoutineRow; lastFiredMinute: number | undefined }[] +> { + const rows = (await db + .select() + .from(routine) + .where(eq(routine.enabled, true))) as RoutineRow[]; + const timerRows = rows.filter((row) => row.trigger !== null); + + const lastFiredByRoutine = new Map(); + const scheduledRuns = await db + .select({ + routineId: routineRun.routineId, + createdAt: routineRun.createdAt, + }) + .from(routineRun) + .where(eq(routineRun.triggeredBy, "schedule")) + .orderBy(desc(routineRun.createdAt)); + for (const run of scheduledRuns) { + if (!lastFiredByRoutine.has(run.routineId)) { + lastFiredByRoutine.set(run.routineId, minuteKey(run.createdAt)); + } + } + + return timerRows.map((row) => ({ + routine: row, + lastFiredMinute: lastFiredByRoutine.get(row.id), + })); +} + +export function createRoutineScheduler(deps: RoutineSchedulerDeps) { + const now = deps.now ?? (() => new Date()); + let tickInFlight = false; + + async function tick(): Promise { + if (tickInFlight) return; + tickInFlight = true; + try { + const at = now(); + const currentMinute = minuteKey(at); + const candidates = await loadSchedulableRoutines(deps.db); + for (const { routine: row, lastFiredMinute } of candidates) { + if (row.trigger === null) continue; + if (lastFiredMinute === currentMinute) continue; + const expression = cronExpressionForTrigger(row.trigger); + if (!cronMatchesMinute(expression, at)) continue; + try { + await fireScheduledRoutine( + { store: deps.store, launcher: deps.launcher }, + { tenantId: row.tenantId, routine: row }, + ); + } catch (err) { + log.error`scheduled fire of routine ${row.id} failed: ${ + err instanceof Error ? err.message : String(err) + }`; + } + } + } finally { + tickInFlight = false; + } + } + + const interval = setInterval(() => void tick(), POLL_INTERVAL_MS); + if (typeof interval.unref === "function") interval.unref(); + + return { + stop(): void { + clearInterval(interval); + }, + }; +} diff --git a/apps/hub/test/cron-due.test.ts b/apps/hub/test/cron-due.test.ts new file mode 100644 index 000000000..5da6a1994 --- /dev/null +++ b/apps/hub/test/cron-due.test.ts @@ -0,0 +1,92 @@ +// Pure-function proof for the scheduler's "is it time yet" decision — no +// clock, database, or launch involved. `cronExpressionForTrigger` +// (@corbits/routines) is the only producer of these expressions in this +// repo today; these cases cover its four preset renderings plus the +// raw-cron escape hatch's comma/range/step grammar. + +import { describe, expect, test } from "bun:test"; +import { cronMatchesMinute, minuteKey } from "../src/cron-due.ts"; + +describe("cronMatchesMinute", () => { + test("interval preset: */15 * * * *", () => { + const expression = "*/15 * * * *"; + expect( + cronMatchesMinute(expression, new Date("2026-01-01T00:00:00Z")), + ).toBe(true); + expect( + cronMatchesMinute(expression, new Date("2026-01-01T00:15:00Z")), + ).toBe(true); + expect( + cronMatchesMinute(expression, new Date("2026-01-01T00:07:00Z")), + ).toBe(false); + }); + + test("hourly interval preset: 0 */2 * * *", () => { + const expression = "0 */2 * * *"; + expect( + cronMatchesMinute(expression, new Date("2026-01-01T02:00:00Z")), + ).toBe(true); + expect( + cronMatchesMinute(expression, new Date("2026-01-01T03:00:00Z")), + ).toBe(false); + expect( + cronMatchesMinute(expression, new Date("2026-01-01T02:01:00Z")), + ).toBe(false); + }); + + test("daily preset: 30 9 * * *", () => { + const expression = "30 9 * * *"; + expect( + cronMatchesMinute(expression, new Date("2026-01-01T09:30:00Z")), + ).toBe(true); + expect( + cronMatchesMinute(expression, new Date("2026-01-01T09:31:00Z")), + ).toBe(false); + expect( + cronMatchesMinute(expression, new Date("2026-01-02T09:30:00Z")), + ).toBe(true); + }); + + test("weekly preset: 0 12 * * 1 (Monday)", () => { + const expression = "0 12 * * 1"; + // 2026-01-05 is a Monday. + expect( + cronMatchesMinute(expression, new Date("2026-01-05T12:00:00Z")), + ).toBe(true); + expect( + cronMatchesMinute(expression, new Date("2026-01-06T12:00:00Z")), + ).toBe(false); + }); + + test("raw cron: ranges, steps, and comma lists", () => { + expect( + cronMatchesMinute("0-5 * * * *", new Date("2026-01-01T00:03:00Z")), + ).toBe(true); + expect( + cronMatchesMinute("0-5 * * * *", new Date("2026-01-01T00:06:00Z")), + ).toBe(false); + expect( + cronMatchesMinute("*/10 * * * *", new Date("2026-01-01T00:20:00Z")), + ).toBe(true); + expect( + cronMatchesMinute("0,30 * * * *", new Date("2026-01-01T00:30:00Z")), + ).toBe(true); + expect( + cronMatchesMinute("0,30 * * * *", new Date("2026-01-01T00:15:00Z")), + ).toBe(false); + }); + + test("rejects an expression without exactly five fields", () => { + expect(() => cronMatchesMinute("* * * *", new Date())).toThrow(); + }); +}); + +describe("minuteKey", () => { + test("is stable within a minute and distinct across minutes", () => { + const a = minuteKey(new Date("2026-01-01T00:00:00.000Z")); + const b = minuteKey(new Date("2026-01-01T00:00:59.999Z")); + const c = minuteKey(new Date("2026-01-01T00:01:00.000Z")); + expect(a).toBe(b); + expect(a).not.toBe(c); + }); +}); diff --git a/apps/hub/test/routine-mount.test.ts b/apps/hub/test/routine-mount.test.ts new file mode 100644 index 000000000..3167c9355 --- /dev/null +++ b/apps/hub/test/routine-mount.test.ts @@ -0,0 +1,55 @@ +// Proves the routines mount exists: an unauthenticated request to a +// routines route under the tenant prefix answers with the platform's own +// auth-error envelope, not a 404 (no route) or a crash (bad deps +// literal). Mirrors `chat-mount.test.ts`; `@corbits/routines`' own +// request/response behavior belongs to that package's tests, and the +// launcher adapter's own behavior belongs to routine-launcher.test.ts. + +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { HubConfig } from "../src/config.ts"; +import { createHub } from "../src/index.ts"; + +const root = mkdtempSync(path.join(tmpdir(), "hub-routine-mount-")); +const staticDir = path.join(root, "static"); +mkdirSync(staticDir, { recursive: true }); +writeFileSync(path.join(staticDir, "index.html"), "shell"); +mkdirSync(path.join(root, "data"), { recursive: true }); + +const config: HubConfig = { + databaseUrl: "postgres://workbench:workbench@localhost:5432/workbench", + baseUrl: "http://localhost:3000", + sessionSecret: "insecure-test-only-session-secret-0000", + hubDataDir: path.join(root, "data"), + hubStaticDir: staticDir, + signupRateLimit: { windowSeconds: 60, max: 5 }, +}; + +const closers: (() => Promise)[] = []; + +afterAll(async () => { + for (const close of closers) await close(); + rmSync(root, { recursive: true, force: true }); +}); + +describe("routines mount", () => { + test("routine routes mount inside the native tenant middleware", async () => { + const hub = await createHub(config); + closers.push(hub.close); + + const gated = await hub.app.request("/api/tenants/some-tenant/routines", { + method: "GET", + }); + expect(gated.status).toBe(401); + expect(await gated.json()).toEqual({ + error: { code: "unauthorized", message: "Authentication required" }, + }); + + // The route exists only under the tenant scope; outside it the + // path falls through to the interface shell. + const outside = await hub.app.request("/routines"); + expect(await outside.text()).toBe("shell"); + }); +}); diff --git a/bun.lock b/bun.lock index 9d42df345..a3d2bacc4 100644 --- a/bun.lock +++ b/bun.lock @@ -25,12 +25,14 @@ "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/folded-runs": "workspace:*", + "@corbits/routines": "workspace:*", "@corbits/schedules": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@intx/authz": "workspace:*", "@intx/crypto": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", + "@intx/hub-common": "workspace:*", "@intx/hub-sessions": "workspace:*", "@intx/log": "workspace:*", "@intx/mime": "workspace:*", From f5147496bca1d65c097572c3136c64b75be01f94 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:26:11 -0700 Subject: [PATCH 05/28] Add tests for the Routines page 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. --- apps/web/test/routes.test.tsx | 2 +- apps/web/test/routine-trigger.test.ts | 79 +++++++++++++++ apps/web/test/routines-page.test.tsx | 140 ++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 apps/web/test/routine-trigger.test.ts create mode 100644 apps/web/test/routines-page.test.tsx diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index 60f711eb5..e166aae4d 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -56,7 +56,7 @@ describe("route table", () => { expect(APP_ROUTES.map((route) => route.path)).toEqual([ "/", "/chat", - "/workflows", + "/routines", "/library", "/agents", "/skills", diff --git a/apps/web/test/routine-trigger.test.ts b/apps/web/test/routine-trigger.test.ts new file mode 100644 index 000000000..72ccc734b --- /dev/null +++ b/apps/web/test/routine-trigger.test.ts @@ -0,0 +1,79 @@ +// 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", () => { + test("null trigger reads as manual", () => { + expect(cadenceLabel(null)).toBe("Manual"); + }); + + test("interval trigger pluralizes correctly", () => { + expect(cadenceLabel({ kind: "interval", unit: "minutes", every: 1 })).toBe( + "Every minute", + ); + expect(cadenceLabel({ kind: "interval", unit: "hours", every: 2 })).toBe( + "Every 2 hours", + ); + }); + + test("daily trigger renders a UTC time", () => { + expect(cadenceLabel({ kind: "daily", hour: 9, minute: 5 })).toBe( + "Daily at 09:05 UTC", + ); + }); + + test("weekly trigger names the weekday", () => { + expect( + cadenceLabel({ kind: "weekly", dayOfWeek: 1, hour: 7, minute: 30 }), + ).toBe("Weekly on Monday at 07:30 UTC"); + }); + + test("cron trigger shows the raw expression", () => { + expect(cadenceLabel({ kind: "cron", expression: "*/5 * * * *" })).toBe( + "Cron: */5 * * * *", + ); + }); +}); + +describe("approximateNextRun", () => { + test("manual and cron triggers have no closed-form estimate", () => { + expect(approximateNextRun(null, new Date())).toBeNull(); + expect( + approximateNextRun({ kind: "cron", expression: "* * * * *" }, new Date()), + ).toBeNull(); + }); + + test("interval adds its step to now", () => { + 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("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("weekly finds the next matching weekday", () => { + // 2026-01-01 is a Thursday (day 4). + const now = new Date("2026-01-01T00:00:00Z"); + const next = approximateNextRun( + { kind: "weekly", dayOfWeek: 1, hour: 9, minute: 0 }, + now, + ); + // Next Monday is 2026-01-05. + 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 new file mode 100644 index 000000000..9a9582d77 --- /dev/null +++ b/apps/web/test/routines-page.test.tsx @@ -0,0 +1,140 @@ +// Screen-level proof for the Routines page's two pure components, +// mirroring pages.test.tsx's shape: real (possibly empty) `APIQuery` +// props in, honest markup out — no live fetch. + +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { APIQuery, WorkflowRun } from "../src/api"; +import { + RoutineDetailPage, + RoutinesListPage, +} from "../src/pages/routines-page"; +import type { Routine, RoutineRun } from "../src/routines-api"; + +function ready(data: T): APIQuery { + return { kind: "ready", data }; +} + +const routine: Routine = { + id: "rtn_1", + name: "Morning brief", + definitionId: "wfd_1", + trigger: { kind: "daily", hour: 9, minute: 0 }, + scope: "bench", + input: {}, + enabled: true, + deliveryChannelId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("RoutinesListPage", () => { + test("says there are no routines yet", () => { + const markup = renderToStaticMarkup( + {}} + onCreate={() => Promise.resolve()} + onToggleEnabled={() => {}} + onRunNow={() => Promise.resolve()} + />, + ); + expect(markup).toContain("No routines yet"); + expect(markup).toContain("No routine runs in flight"); + }); + + test("renders a routine by name and cadence, never a raw id", () => { + const markup = renderToStaticMarkup( + {}} + onCreate={() => Promise.resolve()} + onToggleEnabled={() => {}} + onRunNow={() => Promise.resolve()} + />, + ); + expect(markup).toContain("Morning brief"); + expect(markup).toContain("Daily at 09:00 UTC"); + expect(markup).not.toContain("rtn_1"); + }); + + test("filters live runs to only those correlated with a routine", () => { + const correlatedRun: WorkflowRun = { + id: "run_correlated", + tenantId: "tenant_1", + tenantName: "Acme", + definitionId: "wfd_1", + definitionName: "Researcher", + address: "run_correlated@acme.localhost", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + }; + const runHistories = new Map([ + [ + routine.id, + [ + { + runId: "run_correlated", + triggeredBy: "schedule", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + ], + ]); + const markup = renderToStaticMarkup( + {}} + onCreate={() => Promise.resolve()} + onToggleEnabled={() => {}} + onRunNow={() => Promise.resolve()} + />, + ); + expect(markup).toContain("Researcher"); + expect(markup).toContain("Acme"); + expect(markup).not.toContain("No routine runs in flight"); + }); +}); + +describe("RoutineDetailPage", () => { + test("shows the routine's name, cadence, and empty run history", () => { + const markup = renderToStaticMarkup( + ([])} + onBack={() => {}} + />, + ); + expect(markup).toContain("Morning brief"); + expect(markup).toContain("Daily at 09:00 UTC"); + expect(markup).toContain("No runs yet"); + }); + + test("renders run history with a resolved status", () => { + const run: RoutineRun = { + runId: "run_1", + triggeredBy: "manual", + createdAt: "2026-01-01T00:00:00.000Z", + run: { status: "completed" }, + }; + const markup = renderToStaticMarkup( + ([run])} + onBack={() => {}} + />, + ); + expect(markup).toContain("manual"); + expect(markup).toContain("completed"); + }); +}); From a82b78b1ddf1c6bfc4075794fff715d8a9e1cbab Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:26:18 -0700 Subject: [PATCH 06/28] Replace the Workflows page with Routines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/src/app.css | 37 ++ apps/web/src/pages/home-page.tsx | 6 +- apps/web/src/pages/routines-page.tsx | 764 ++++++++++++++++++++++++++ apps/web/src/pages/workflows-page.tsx | 117 ---- apps/web/src/routes.tsx | 17 +- apps/web/src/routine-trigger.ts | 77 +++ apps/web/src/routines-api.ts | 253 +++++++++ apps/web/test/pages.test.tsx | 63 +-- 8 files changed, 1146 insertions(+), 188 deletions(-) create mode 100644 apps/web/src/pages/routines-page.tsx delete mode 100644 apps/web/src/pages/workflows-page.tsx create mode 100644 apps/web/src/routine-trigger.ts create mode 100644 apps/web/src/routines-api.ts diff --git a/apps/web/src/app.css b/apps/web/src/app.css index ee1ed7615..73927b711 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -446,6 +446,43 @@ resize: vertical; } +.flex-col-gap { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.form-label { + font-size: 0.875rem; + font-weight: 500; +} + +.form-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.form-hint { + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.form-error { + font-size: 0.875rem; + color: var(--destructive); +} + +.link-button { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--primary); + cursor: pointer; + text-decoration: underline; +} + /* Chat surface styling ships with @corbits/chat-ui (see packages/chat-ui/src/styles.css) — imported in main.tsx alongside this file, mirroring how @corbits/react-ui/styles.css is consumed. */ diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 5afcce7dd..8115aafa5 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -28,9 +28,9 @@ const SHORTCUTS = [ description: "Talk to an agent in a streaming conversation.", }, { - to: "/workflows", - title: "Workflows", - description: "Watch the workflows executing right now.", + to: "/routines", + title: "Routines", + description: "Schedule a workflow, or launch one on demand.", }, { to: "/library", diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx new file mode 100644 index 000000000..09c872239 --- /dev/null +++ b/apps/web/src/pages/routines-page.tsx @@ -0,0 +1,764 @@ +// The Routines screen: named automations over workflow runs. Follows +// runs-page.tsx / library-page.tsx's shape (pure `*Page` components fed +// `APIQuery` props, a `*Route` container that resolves data) plus +// chat-page.tsx's tenant-resolution convention — the account's first +// bench membership, since this app has no bench switcher yet. +// +// The create flow's trigger picker is workbench-specific composition +// (`RoutineTrigger`'s exact shape, including the raw-cron escape hatch) +// rather than `@corbits/react-ui`'s own `RecurrenceInput`: that +// component's `Recurrence` type deliberately excludes cron and a +// minutes unit (see its own doc comment), which a routine's trigger +// contract requires. Everything else — Table, Card, Badge, Dialog, +// Button, Input, Switch, RunNowButton — is reused as-is. +import { + Badge, + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + EmptyState, + formatRelativeTime, + Input, + PageShell, + RunNowButton, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TopBar, + TopBarTitle, +} from "@corbits/react-ui"; +import type { BadgeTone } from "@corbits/react-ui"; +import { Clock, Plus } from "lucide-react"; +import { useState } from "react"; + +import { PrincipalsSchema, RunsSchema, useAPIQuery } from "../api"; +import type { APIQuery, WorkflowRun } from "../api"; +import { countProp } from "../optional-props"; +import { QueryView } from "../query-view"; +import { approximateNextRun, cadenceLabel } from "../routine-trigger"; +import { + createRoutine, + listRoutineRuns, + listRoutines, + listWorkflowDefinitions, + runRoutineNow, + updateRoutine, + useTenantQuery, +} from "../routines-api"; +import type { + CreateRoutineInput, + Routine, + RoutineRun, + RoutineTrigger, + WorkflowDefinitionSummary, +} from "../routines-api"; + +const ROUTINES_PATH_PREFIX = "/routines"; + +function routineIdFromPath(path: string): string | null { + if (!path.startsWith(`${ROUTINES_PATH_PREFIX}/`)) return null; + const rest = path.slice(ROUTINES_PATH_PREFIX.length + 1); + return rest === "" ? null : decodeURIComponent(rest); +} + +const RUN_STATUS_TONE: Record = { + running: "success", + completed: "info", + failed: "danger", + cancelled: "neutral", +}; + +function lastResultLabel(runs: readonly RoutineRun[]): string { + const [latest] = runs; + if (latest === undefined) return "Never run"; + const status = latest.run?.status; + return typeof status === "string" ? status : "Started"; +} + +type TriggerKind = "manual" | "interval" | "daily" | "weekly" | "cron"; + +/** + * The create flow's trigger editor, over `RoutineTrigger` directly — + * every field it renders is exactly one this shape carries, so a save + * never needs a translation step. + */ +function TriggerPicker({ + value, + onChange, +}: { + readonly value: RoutineTrigger; + readonly onChange: (next: RoutineTrigger) => void; +}) { + const kind: TriggerKind = value === null ? "manual" : value.kind; + + const setKind = (next: TriggerKind) => { + switch (next) { + case "manual": + onChange(null); + return; + case "interval": + onChange({ kind: "interval", unit: "minutes", every: 15 }); + return; + case "daily": + onChange({ kind: "daily", hour: 9, minute: 0 }); + return; + case "weekly": + onChange({ kind: "weekly", dayOfWeek: 1, hour: 9, minute: 0 }); + return; + case "cron": + onChange({ kind: "cron", expression: "0 9 * * *" }); + } + }; + + return ( +
+ + + + {value !== null && value.kind === "interval" ? ( +
+ Every + + onChange({ + ...value, + every: Math.max(1, Math.trunc(event.target.valueAsNumber) || 1), + }) + } + /> + +
+ ) : null} + + {value !== null && (value.kind === "daily" || value.kind === "weekly") ? ( +
+ {value.kind === "weekly" ? ( + + ) : null} + At + { + const [hour, minute] = event.target.value.split(":").map(Number); + onChange({ + ...value, + hour: hour ?? 0, + minute: minute ?? 0, + }); + }} + /> + UTC +
+ ) : null} + + {value !== null && value.kind === "cron" ? ( + + onChange({ kind: "cron", expression: event.target.value }) + } + /> + ) : null} +
+ ); +} + +function CreateRoutineDialog({ + definitions, + onCreate, +}: { + readonly definitions: readonly WorkflowDefinitionSummary[]; + readonly onCreate: (input: CreateRoutineInput) => Promise; +}) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [definitionId, setDefinitionId] = useState(definitions[0]?.id ?? ""); + const [runMode, setRunMode] = useState<"once" | "schedule">("once"); + const [trigger, setTrigger] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const complete = name.trim().length > 0 && definitionId !== ""; + + const reset = () => { + setName(""); + setDefinitionId(definitions[0]?.id ?? ""); + setRunMode("once"); + setTrigger(null); + setError(null); + }; + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + + + + + + New routine + + Pick a workflow, then run it once or put it on a schedule. + + +
{ + event.preventDefault(); + if (!complete) return; + setBusy(true); + setError(null); + void onCreate({ + name: name.trim(), + definitionId, + scope: "bench", + trigger: runMode === "once" ? null : trigger, + }) + .then(() => { + setOpen(false); + reset(); + }) + .catch((cause: unknown) => { + setError( + cause instanceof Error ? cause.message : String(cause), + ); + }) + .finally(() => setBusy(false)); + }} + > +
+ + setName(event.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+ + {runMode === "schedule" ? ( + + ) : null} + + {error === null ? null : ( +

+ {error} +

+ )} + + + + + + + + +
+
+ ); +} + +export function RoutinesListPage({ + routines, + runHistories, + liveRuns, + now = Date.now(), + definitions, + onOpen, + onCreate, + onToggleEnabled, + onRunNow, +}: { + readonly routines: APIQuery; + /** Each routine's own run history, keyed by routine id — used for "last result". */ + readonly runHistories: ReadonlyMap; + readonly liveRuns: APIQuery; + readonly now?: number; + readonly definitions: readonly WorkflowDefinitionSummary[]; + readonly onOpen: (routineId: string) => void; + readonly onCreate: (input: CreateRoutineInput) => Promise; + readonly onToggleEnabled: (routine: Routine, enabled: boolean) => void; + readonly onRunNow: (routine: Routine) => Promise; +}) { + return ( + <> + + + Routines + + + + + + {(items) => + items.length === 0 ? ( + } + title="No routines yet" + description="Create a routine to run a workflow on a schedule or fire it manually whenever you need it." + /> + ) : ( + + + + Name + Cadence + Next run + Last result + Scope + Enabled + Actions + + + + {items.map((routine) => { + const nextRun = routine.enabled + ? approximateNextRun(routine.trigger, new Date(now)) + : null; + return ( + + + + + {cadenceLabel(routine.trigger)} + + {nextRun === null + ? "—" + : formatRelativeTime(nextRun.toISOString(), now)} + + + {lastResultLabel(runHistories.get(routine.id) ?? [])} + + + {routine.scope} + + + + onToggleEnabled(routine, enabled) + } + /> + + + onRunNow(routine)} + /> + + + ); + })} + +
+ ) + } +
+ + + + Live runs + + + + {(runs) => + runs.length === 0 ? ( + } + title="No routine runs in flight" + description="When a routine fires, its run appears here while it executes." + /> + ) : ( + + + + Definition + Bench + Status + Started + + + + {runs.map((run) => ( + + {run.definitionName} + {run.tenantName} + + + {run.status} + + + + {formatRelativeTime(run.createdAt, now)} + + + ))} + +
+ ) + } +
+
+ + ); +} + +export function RoutineDetailPage({ + routine, + runs, + onBack, + now = Date.now(), +}: { + readonly routine: APIQuery; + readonly runs: APIQuery; + readonly onBack: () => void; + readonly now?: number; +}) { + return ( + <> + + + {routine.kind === "ready" ? routine.data.name : "Routine"} + + + + + + {(data) => ( +
+
Cadence
+
{cadenceLabel(data.trigger)}
+
Scope
+
+ {data.scope} +
+
Status
+
+ + {data.enabled ? "enabled" : "paused"} + +
+
+ )} +
+ + + + Run history + + + + {(items) => + items.length === 0 ? ( + } + title="No runs yet" + description="This routine has not fired yet — manually or on a schedule." + /> + ) : ( + + + + Triggered by + Status + When + + + + {items.map((run) => { + const status = run.run?.status; + return ( + + + {run.triggeredBy} + + + {typeof status === "string" ? ( + + {status} + + ) : ( + "—" + )} + + + {formatRelativeTime(run.createdAt, now)} + + + ); + })} + +
+ ) + } +
+
+ + ); +} + +/** + * The union of every routine's own run ids, across the run histories + * already fetched for "last result" — the correlation that keeps the + * live-runs section to routine-launched runs only, never a channel + * host's or another system run leaking in from the same cross-tenant + * `/api/me/workflows/runs` listing `runs-page.tsx` reads. + */ +function routineRunIds( + runHistories: ReadonlyMap, +): ReadonlySet { + const ids = new Set(); + for (const runs of runHistories.values()) { + for (const run of runs) ids.add(run.runId); + } + return ids; +} + +export function RoutinesRoute({ + path, + navigate, +}: { + readonly path: string; + readonly navigate: (to: string) => void; +}) { + const principals = useAPIQuery("/api/me/principals", PrincipalsSchema); + const allRuns = useAPIQuery("/api/me/workflows/runs", RunsSchema); + const tenantId = + principals.kind === "ready" + ? (principals.data.data[0]?.tenantId ?? null) + : null; + + // Bumped after a mutation (create/toggle) so the affected queries + // re-run without a full page reload — `useTenantQuery`'s effect keys + // off its `key` array exactly like `useAPIQuery` keys off its path, so + // changing this value is what a cache invalidation would otherwise do. + const [refreshToken, setRefreshToken] = useState(0); + const refresh = () => setRefreshToken((token) => token + 1); + + const routines = useTenantQuery( + ["routines", tenantId, refreshToken], + tenantId !== null, + () => listRoutines(tenantId ?? ""), + ); + const definitionsQuery = useTenantQuery( + ["routine-definitions", tenantId], + tenantId !== null, + () => listWorkflowDefinitions(tenantId ?? ""), + ); + const definitions = + definitionsQuery.kind === "ready" ? definitionsQuery.data : []; + + const routineIds = + routines.kind === "ready" ? routines.data.map((r) => r.id) : []; + const runHistoriesQuery = useTenantQuery< + ReadonlyMap + >( + ["routine-run-histories", tenantId, routineIds.join(","), refreshToken], + tenantId !== null && routineIds.length > 0, + async () => { + const entries = await Promise.all( + routineIds.map( + async (id) => + [id, await listRoutineRuns(tenantId ?? "", id)] as const, + ), + ); + return new Map(entries); + }, + ); + const runHistories = + runHistoriesQuery.kind === "ready" ? runHistoriesQuery.data : new Map(); + + const liveRuns: APIQuery = + allRuns.kind === "ready" + ? { + kind: "ready", + data: allRuns.data.data.filter((run) => + routineRunIds(runHistories).has(run.id), + ), + } + : allRuns; + + const openRoutineId = routineIdFromPath(path); + + const detailRoutine = useTenantQuery( + ["routine-detail", tenantId, openRoutineId], + tenantId !== null && openRoutineId !== null, + async () => { + const found = + routines.kind === "ready" + ? routines.data.find((r) => r.id === openRoutineId) + : undefined; + if (found !== undefined) return found; + throw new Error("Routine not found"); + }, + ); + const detailRuns = useTenantQuery( + ["routine-detail-runs", tenantId, openRoutineId], + tenantId !== null && openRoutineId !== null, + () => listRoutineRuns(tenantId ?? "", openRoutineId ?? ""), + ); + + if (openRoutineId !== null) { + return ( + navigate(ROUTINES_PATH_PREFIX)} + /> + ); + } + + return ( + + navigate(`${ROUTINES_PATH_PREFIX}/${encodeURIComponent(id)}`) + } + onCreate={async (input) => { + if (tenantId === null) + throw new Error("No bench to create this in yet"); + await createRoutine(tenantId, input); + refresh(); + }} + onToggleEnabled={(routine, enabled) => { + if (tenantId === null) return; + void updateRoutine(tenantId, routine.id, { enabled }).then(refresh); + }} + onRunNow={async (routine) => { + if (tenantId === null) throw new Error("No bench to run this in yet"); + await runRoutineNow(tenantId, routine.id); + }} + /> + ); +} diff --git a/apps/web/src/pages/workflows-page.tsx b/apps/web/src/pages/workflows-page.tsx deleted file mode 100644 index 33eb9e7ae..000000000 --- a/apps/web/src/pages/workflows-page.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { - Badge, - Button, - EmptyState, - formatRelativeTime, - PageShell, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - TopBar, - TopBarTitle, -} from "@corbits/react-ui"; -import type { BadgeTone } from "@corbits/react-ui"; -import { Workflow } from "lucide-react"; - -import { RunsSchema, useAPIQuery } from "../api"; -import { Link } from "../navigation"; -import { countProp } from "../optional-props"; -import type { APIQuery, RunsPage, WorkflowRun } from "../api"; -import { purposeRuns } from "../purpose-runs"; -import { QueryView } from "../query-view"; - -const STATUS_TONE: Record = { - running: "success", - deployed: "info", - updating: "info", - stopped: "neutral", - error: "danger", -}; - -export function WorkflowsPage({ - runs, - now = Date.now(), -}: { - readonly runs: APIQuery; - /** Reference time for the Started column; injectable for deterministic tests. */ - readonly now?: number; -}) { - return ( - <> - - - Workflows - - - - - {(page) => { - const rows = purposeRuns(page.data); - return rows.length === 0 ? ( - } - title="No active workflows" - description="When a workflow is executing in one of your benches it appears here. Ask an agent in chat to kick one off." - action={ - - } - /> - ) : ( - - - - Workflow - Bench - Address - Status - Started - - - - {rows.map((run) => ( - - - {run.definitionName} - - - {run.tenantName} - - - {run.address} - - - - {run.status} - - - - {formatRelativeTime(run.createdAt, now)} - - - ))} - -
- ); - }} -
-
- - ); -} - -export function WorkflowsRoute() { - const runs = useAPIQuery("/api/me/workflows/runs", RunsSchema); - return ; -} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 6d7fb048d..588af4f2f 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -6,13 +6,13 @@ import { Bot, + Clock, Home, Library, MessageSquare, Settings, ShieldCheck, Sparkles, - Workflow, } from "lucide-react"; import type { ReactElement, ReactNode } from "react"; @@ -21,9 +21,9 @@ import { ApprovalsRoute } from "./pages/approvals-page"; import { ChatPage } from "./pages/chat-page"; import { HomeRoute } from "./pages/home-page"; import { LibraryRoute } from "./pages/library-page"; +import { RoutinesRoute } from "./pages/routines-page"; import { SettingsRoute } from "./pages/settings-page"; import { SkillsRoute } from "./pages/skills-page"; -import { WorkflowsRoute } from "./pages/workflows-page"; /** Landing point for a session the first-login hook just provisioned a * personal bench for. Not one of `APP_ROUTES`: it has no sidebar entry, @@ -52,6 +52,9 @@ export function matchesRoute(routePath: string, path: string): boolean { if (routePath === "/chat") { return path === "/chat" || path.startsWith("/chat/"); } + if (routePath === "/routines") { + return path === "/routines" || path.startsWith("/routines/"); + } return routePath === path; } @@ -66,10 +69,12 @@ export const APP_ROUTES: readonly AppRoute[] = [ ), }, { - path: "/workflows", - label: "Workflows", - icon: , - render: () => , + path: "/routines", + label: "Routines", + icon: , + render: (path: string, navigate: (to: string) => void) => ( + + ), }, { path: "/library", diff --git a/apps/web/src/routine-trigger.ts b/apps/web/src/routine-trigger.ts new file mode 100644 index 000000000..6153c3e4b --- /dev/null +++ b/apps/web/src/routine-trigger.ts @@ -0,0 +1,77 @@ +// 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. Kept +// pure and UTC-only — a `RoutineTrigger` carries no timezone, so neither +// does this. The raw-cron escape hatch has no closed-form "next +// occurrence" here (the scheduler itself resolves that minute by minute; +// see apps/hub/src/cron-due.ts) — it renders a plain description instead +// of a guessed timestamp, never a wrong one dressed up as exact. +import type { RoutineTrigger } from "./routines-api"; + +const WEEKDAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; + +function pad(value: number): string { + return value.toString().padStart(2, "0"); +} + +export function cadenceLabel(trigger: RoutineTrigger): string { + if (trigger === null) return "Manual"; + switch (trigger.kind) { + case "interval": + return trigger.every === 1 + ? `Every ${trigger.unit === "minutes" ? "minute" : "hour"}` + : `Every ${String(trigger.every)} ${trigger.unit}`; + case "daily": + return `Daily at ${pad(trigger.hour)}:${pad(trigger.minute)} UTC`; + case "weekly": + return `Weekly on ${WEEKDAY_NAMES[trigger.dayOfWeek]} at ${pad(trigger.hour)}:${pad(trigger.minute)} UTC`; + case "cron": + return `Cron: ${trigger.expression}`; + } +} + +/** + * A best-effort next-fire estimate for display only — never fed back + * into a launch decision, which is the scheduler's job + * (apps/hub/src/routine-scheduler.ts) against the real clock. Returns + * `null` for a manual routine or a raw-cron trigger (no closed form + * without a full cron evaluator on the client). + */ +export function approximateNextRun( + trigger: RoutineTrigger, + now: Date, +): Date | null { + if (trigger === null || trigger.kind === "cron") return null; + + if (trigger.kind === "interval") { + const stepMs = + trigger.every * (trigger.unit === "minutes" ? 60_000 : 3_600_000); + return new Date(now.getTime() + stepMs); + } + + const next = new Date(now); + next.setUTCHours(trigger.hour, trigger.minute, 0, 0); + + if (trigger.kind === "daily") { + if (next.getTime() <= now.getTime()) { + next.setUTCDate(next.getUTCDate() + 1); + } + return next; + } + + // weekly + const daysUntil = (trigger.dayOfWeek - next.getUTCDay() + 7) % 7; + next.setUTCDate(next.getUTCDate() + daysUntil); + if (next.getTime() <= now.getTime()) { + next.setUTCDate(next.getUTCDate() + 7); + } + return next; +} diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts new file mode 100644 index 000000000..26c7f0718 --- /dev/null +++ b/apps/web/src/routines-api.ts @@ -0,0 +1,253 @@ +// The Routines page's one seam to `@corbits/routines`' HTTP routes (see +// packages/routines/src/routes.ts), mirroring `@corbits/chat-ui`'s +// `api.ts`: tenant-scoped request functions, each response validated at +// the boundary with an arktype schema owned here rather than importing +// `@corbits/routines` itself — that package's public surface also +// exports Drizzle schema tables and a Postgres-backed store, none of +// which belong in a browser bundle. Definitions come from the platform's +// own `/api/tenants/:tenantId/workflows/definitions` listing (native to +// `@intx/hub-api`, not part of routines), the same catalog a routine's +// `definitionId` points into. + +import { type } from "arktype"; +import type { ArkErrors } from "arktype"; +import { useEffect, useState } from "react"; +import type { APIQuery } from "./api"; + +export const RoutineTrigger = type({ + kind: "'interval'", + unit: "'minutes' | 'hours'", + every: "number.integer > 0", +}) + .or({ + kind: "'daily'", + hour: "0 <= number.integer <= 23", + minute: "0 <= number.integer <= 59", + }) + .or({ + kind: "'weekly'", + dayOfWeek: "0 <= number.integer <= 6", + hour: "0 <= number.integer <= 23", + minute: "0 <= number.integer <= 59", + }) + .or({ kind: "'cron'", expression: "string" }) + .or("null"); +export type RoutineTrigger = typeof RoutineTrigger.infer; + +const Routine = type({ + id: "string", + name: "string", + definitionId: "string", + trigger: RoutineTrigger, + scope: "'personal' | 'bench'", + input: "Record", + enabled: "boolean", + deliveryChannelId: "string | null", + createdAt: "string", + updatedAt: "string", +}); +export type Routine = typeof Routine.infer; + +const RoutinesResponse = type({ items: Routine.array() }); + +const RoutineRun = type({ + runId: "string", + triggeredBy: "string", + createdAt: "string", + "run?": "Record", +}); +export type RoutineRun = typeof RoutineRun.infer; + +const RoutineRunsResponse = type({ items: RoutineRun.array() }); + +export const WorkflowDefinitionSummary = type({ + id: "string", + name: "string", + status: "string", +}); +export type WorkflowDefinitionSummary = typeof WorkflowDefinitionSummary.infer; + +const DefinitionsResponse = type({ + data: WorkflowDefinitionSummary.array(), +}); + +export type CreateRoutineInput = { + readonly name: string; + readonly definitionId: string; + readonly trigger: RoutineTrigger; + readonly scope: "personal" | "bench"; + readonly input?: Record; +}; + +export type UpdateRoutineInput = { + readonly name?: string; + readonly trigger?: RoutineTrigger; + readonly enabled?: boolean; + readonly input?: Record; +}; + +export class RoutinesApiError extends Error { + constructor( + message: string, + readonly status?: number, + ) { + super(message); + } +} + +type Validator = (data: unknown) => T | ArkErrors; + +async function request( + path: string, + schema: Validator, + init?: RequestInit, +): Promise { + let response: Response; + try { + response = await fetch(path, { + ...init, + headers: { "content-type": "application/json", ...init?.headers }, + }); + } catch (cause) { + throw new RoutinesApiError( + cause instanceof Error ? cause.message : String(cause), + ); + } + if (response.status === 401) { + throw new RoutinesApiError(`Not signed in for ${path}.`, 401); + } + if (!response.ok) { + const detail = await response + .json() + .then( + (body: { error?: { message?: string } }) => body.error?.message ?? "", + ) + .catch(() => ""); + throw new RoutinesApiError( + `The hub answered ${response.status} for ${path}.${detail === "" ? "" : ` ${detail}`}`, + response.status, + ); + } + if (response.status === 204) return undefined as T; + const body: unknown = await response.json().catch(() => undefined); + const parsed = schema(body); + if (parsed instanceof type.errors) { + throw new RoutinesApiError( + `Unexpected response shape from ${path}: ${parsed.summary}`, + ); + } + return parsed; +} + +export function listRoutines(tenantId: string): Promise { + return request(`/api/tenants/${tenantId}/routines`, RoutinesResponse).then( + (page) => page.items, + ); +} + +export function getRoutine(tenantId: string, id: string): Promise { + return request(`/api/tenants/${tenantId}/routines/${id}`, Routine); +} + +export function createRoutine( + tenantId: string, + input: CreateRoutineInput, +): Promise { + return request(`/api/tenants/${tenantId}/routines`, Routine, { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateRoutine( + tenantId: string, + id: string, + patch: UpdateRoutineInput, +): Promise { + return request(`/api/tenants/${tenantId}/routines/${id}`, Routine, { + method: "PATCH", + body: JSON.stringify(patch), + }); +} + +export function deleteRoutine(tenantId: string, id: string): Promise { + return request(`/api/tenants/${tenantId}/routines/${id}`, type("unknown"), { + method: "DELETE", + }).then(() => undefined); +} + +export function runRoutineNow( + tenantId: string, + id: string, +): Promise<{ runId: string }> { + return request( + `/api/tenants/${tenantId}/routines/${id}/run`, + type({ runId: "string" }), + { method: "POST", body: JSON.stringify({}) }, + ); +} + +export function listRoutineRuns( + tenantId: string, + id: string, +): Promise { + return request( + `/api/tenants/${tenantId}/routines/${id}/runs`, + RoutineRunsResponse, + ).then((page) => page.items); +} + +export function listWorkflowDefinitions( + tenantId: string, +): Promise { + return request( + `/api/tenants/${tenantId}/workflows/definitions`, + DefinitionsResponse, + ).then((page) => page.data); +} + +/** + * The `useAPIQuery` state machine, for a fetch that needs a tenant id + * (and thus cannot be a static path) — the extra seam + * `@corbits/routines`' tenant-scoped routes need that `/api/me/...` + * queries do not. `enabled` mirrors chat-page.tsx's own gate on a + * resolved tenant: skip fetching until one exists. + */ +export function useTenantQuery( + key: readonly unknown[], + enabled: boolean, + fetcher: () => Promise, +): APIQuery { + const [state, setState] = useState>({ kind: "loading" }); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + setState({ kind: "loading" }); + void (async () => { + try { + const data = await fetcher(); + if (!cancelled) setState({ kind: "ready", data }); + } catch (cause) { + if (cancelled) return; + if (cause instanceof RoutinesApiError && cause.status === 401) { + setState({ kind: "unauthenticated" }); + return; + } + setState({ + kind: "error", + message: cause instanceof Error ? cause.message : String(cause), + }); + } + })(); + return () => { + cancelled = true; + }; + // `fetcher` is intentionally excluded: callers pass a fresh closure + // every render, and `key` is the caller's own declared cache + // identity for what that closure fetches — the same contract + // `useAPIQuery` documents for its `schema` argument. + }, [enabled, ...key]); + + return state; +} diff --git a/apps/web/test/pages.test.tsx b/apps/web/test/pages.test.tsx index 98a8452a5..90b502f53 100644 --- a/apps/web/test/pages.test.tsx +++ b/apps/web/test/pages.test.tsx @@ -7,7 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import type { ArtifactSummary } from "@corbits/artifact-ui"; -import type { APIQuery, Approval, WorkflowRun } from "../src/api"; +import type { APIQuery, Approval } from "../src/api"; import type { AgentDefinition, AgentDirectoryData, @@ -18,7 +18,6 @@ import { ApprovalsPage } from "../src/pages/approvals-page"; import { HomePage } from "../src/pages/home-page"; import { LibraryPage } from "../src/pages/library-page"; import { SkillsPage } from "../src/pages/skills-page"; -import { WorkflowsPage } from "../src/pages/workflows-page"; function ready(data: T): APIQuery { return { kind: "ready", data }; @@ -37,11 +36,6 @@ const profile = ready({ }); describe("empty states", () => { - test("workflows says it has no active workflows", () => { - const markup = renderToStaticMarkup(); - expect(markup).toContain("No active workflows"); - }); - test("library teaches what will appear once the seam is real", () => { const markup = renderToStaticMarkup(); expect(markup).toContain("No artifacts yet"); @@ -91,61 +85,6 @@ describe("signed-out state", () => { }); describe("live data", () => { - const run: WorkflowRun = { - id: "run_1", - tenantId: "tenant_1", - tenantName: "Acme", - definitionId: "wfd_1", - definitionName: "Researcher", - address: "run_1@acme.localhost", - status: "running", - createdAt: "2026-08-05T11:00:00.000Z", - }; - - test("workflows renders a running workflow from hub data", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Researcher"); - expect(markup).toContain("run_1@acme.localhost"); - expect(markup).toContain("running"); - expect(markup).toContain("ago"); - }); - - test("workflows filters the chat anchor machinery's channel-host runs out", () => { - const channelHostRun: WorkflowRun = { - ...run, - id: "run_2", - definitionId: "wfd_2", - definitionName: "ins-cd03d8e3", - address: "run_2@acme.localhost", - }; - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Researcher"); - expect(markup).not.toContain("ins-cd03d8e3"); - }); - - test("workflows shows the empty state when only channel-host runs exist", () => { - const channelHostRun: WorkflowRun = { - ...run, - definitionName: "ins-cd03d8e3", - }; - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("No active workflows"); - }); - const reportArtifact: ArtifactSummary = { id: "art_1", title: "Q3 report", From 13458a8a52395edb0f10c09491ab4527e91c7d51 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:26:28 -0700 Subject: [PATCH 07/28] Update docs: Routine glossary entry --- docs/GLOSSARY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index c001586b2..82cde53c8 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -11,6 +11,7 @@ code and API paths keep the platform's own names. | **User** | principal | An identity that can act in a bench — human or agent | | **Definition** | workflow definition | A deployable unit of agent behavior, authored as code | | **Run** | workflow run | A definition executing in a bench; interactive runs carry conversations | +| **Routine** | — | The named parent entity over runs of one definition — a trigger (or none), a delivery channel, and its run history; see [`@corbits/routines`](../packages/routines/README.md) | | **Approval** | approval | A human decision gating an external side effect | | **Grant** | grant | Permission for a principal to act on a resource | | **Hub** | hub | The API and coordination service a bench lives on | From 10d8215e34d5909d9763fa2f1443eda0e94327d9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:54:40 -0700 Subject: [PATCH 08/28] Fix routine-mount test to compile against HubConfig.socialProviders --- apps/hub/test/routine-mount.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/hub/test/routine-mount.test.ts b/apps/hub/test/routine-mount.test.ts index 3167c9355..53bb6e096 100644 --- a/apps/hub/test/routine-mount.test.ts +++ b/apps/hub/test/routine-mount.test.ts @@ -25,6 +25,7 @@ const config: HubConfig = { hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, signupRateLimit: { windowSeconds: 60, max: 5 }, + socialProviders: {}, }; const closers: (() => Promise)[] = []; From 336e22e245c8f9dfd250f40149429402892a013a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:54:47 -0700 Subject: [PATCH 09/28] Retire @corbits/schedules in favor of @corbits/routines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/hub/package.json | 1 - apps/hub/src/index.ts | 51 ---- bun.lock | 30 +-- package.json | 1 - packages/schedules/package.json | 34 --- packages/schedules/src/index.ts | 37 --- packages/schedules/src/launcher.ts | 155 ------------ packages/schedules/src/migrations.ts | 103 -------- packages/schedules/src/routes.ts | 267 --------------------- packages/schedules/src/scheduler.ts | 96 -------- packages/schedules/src/schema.ts | 27 --- packages/schedules/src/store.ts | 246 ------------------- packages/schedules/src/trigger.ts | 64 ----- packages/schedules/test/migrations.test.ts | 85 ------- packages/schedules/test/routes.test.ts | 267 --------------------- packages/schedules/test/scheduler.test.ts | 168 ------------- packages/schedules/test/test-support.ts | 90 ------- packages/schedules/test/trigger.test.ts | 83 ------- packages/schedules/tsconfig.json | 7 - scripts/db-setup.ts | 4 +- 20 files changed, 3 insertions(+), 1813 deletions(-) delete mode 100644 packages/schedules/package.json delete mode 100644 packages/schedules/src/index.ts delete mode 100644 packages/schedules/src/launcher.ts delete mode 100644 packages/schedules/src/migrations.ts delete mode 100644 packages/schedules/src/routes.ts delete mode 100644 packages/schedules/src/scheduler.ts delete mode 100644 packages/schedules/src/schema.ts delete mode 100644 packages/schedules/src/store.ts delete mode 100644 packages/schedules/src/trigger.ts delete mode 100644 packages/schedules/test/migrations.test.ts delete mode 100644 packages/schedules/test/routes.test.ts delete mode 100644 packages/schedules/test/scheduler.test.ts delete mode 100644 packages/schedules/test/test-support.ts delete mode 100644 packages/schedules/test/trigger.test.ts delete mode 100644 packages/schedules/tsconfig.json diff --git a/apps/hub/package.json b/apps/hub/package.json index c0e2bff67..b37d51141 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -17,7 +17,6 @@ "@corbits/commands": "workspace:*", "@corbits/folded-runs": "workspace:*", "@corbits/routines": "workspace:*", - "@corbits/schedules": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@intx/authz": "workspace:*", "@intx/crypto": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 0985b31df..6c1447cc6 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -29,12 +29,6 @@ import { createWebhookTriggerRoutes, launchWebhookTrigger, } from "@corbits/webhook-triggers"; -import { - createDrizzleScheduleStore, - createHubScheduleLauncher, - createScheduleRoutes, - createScheduler, -} from "@corbits/schedules"; import { createCommandRegistry, createCommandRoutes, @@ -80,11 +74,6 @@ const CHAT_TURN_TIMEOUT_MS = 5 * 60 * 1000; // mid-turn instance regardless of this value, so it only has to be // long enough that an agent between turns is never mistaken for idle. const CHAT_IDLE_SLEEP_MS = 60_000; -// How often the schedules package checks for due schedules. Cron -// expressions are minute-granular at best, so a tick faster than a -// minute buys nothing; this stays well under that so a schedule fires -// close to its minute rather than up to a full period late. -const SCHEDULE_TICK_INTERVAL_MS = 15_000; // The same anthropic/claude-sonnet-5 pairing the workbench seed plants // in the tenant catalog, so a channel host can always resolve an // inference source against it. @@ -392,45 +381,6 @@ export async function createHub(config: HubConfig) { ), }), ); - // Scheduled workflow automations: its own grant store/condition - // registry (same construction as chat's, above — each extension owns - // one rather than sharing a single instance across unrelated - // resource kinds), its own store over `@corbits/schedules`' one - // product table, and a launcher built from `@corbits/folded-runs` - // via the same hub session services chat's platform adapter uses. - // The scheduler itself is started/stopped alongside the rest of the - // hub's process lifetime. - const scheduleGrantStore = createGrantStore(db); - const scheduleConditionRegistry: ConditionRegistry = { - time_window: timeWindowEvaluator, - }; - const scheduleStore = createDrizzleScheduleStore(db); - const scheduleLauncher = createHubScheduleLauncher({ - db, - sessionService, - assetService, - sidecarRouter, - eventCollectors, - }); - const scheduler = createScheduler({ - store: scheduleStore, - launcher: scheduleLauncher, - log: getLogger(["schedules"]), - tickIntervalMs: SCHEDULE_TICK_INTERVAL_MS, - }); - scheduler.start(); - app.route( - `${TENANT_PREFIX}/schedules`, - createScheduleRoutes({ - store: scheduleStore, - launcher: scheduleLauncher, - requireGrant: createRequireGrant({ - grantStore: scheduleGrantStore, - conditionRegistry: scheduleConditionRegistry, - }), - }), - ); - // Routines: its own grant store (routines authorize against the // `workflow-run:*` resource family, the same one native run routes // use — see `@corbits/routines`' routes.ts), the launcher adapter @@ -502,7 +452,6 @@ export async function createHub(config: HubConfig) { app, db, close: async () => { - scheduler.stop(); chatOrchestrator.dispose(); routineScheduler.stop(); await close(); diff --git a/bun.lock b/bun.lock index a3d2bacc4..3e4749799 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,6 @@ "@corbits/commands": "workspace:*", "@corbits/folded-runs": "workspace:*", "@corbits/routines": "workspace:*", - "@corbits/schedules": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@intx/authz": "workspace:*", "@intx/crypto": "workspace:*", @@ -357,28 +356,6 @@ "typescript": "catalog:", }, }, - "packages/schedules": { - "name": "@corbits/schedules", - "version": "0.0.1", - "dependencies": { - "@corbits/folded-runs": "workspace:*", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "workspace:*", - "@intx/hub-sessions": "workspace:*", - "@intx/log": "workspace:*", - "@intx/types": "workspace:*", - "arktype": "catalog:", - "croner": "catalog:", - "drizzle-orm": "catalog:", - "hono": "^4.11.9", - "postgres": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/settings-ui": { "name": "@corbits/settings-ui", "version": "0.0.1", @@ -751,7 +728,6 @@ "@types/semver": "^7.7.1", "arktype": "^2.2.0", "better-auth": "^1.4.18", - "croner": "^9.1.0", "drizzle-orm": "^0.45.1", "hono": "^4.11.9", "isomorphic-git": "^1.27.2", @@ -852,14 +828,12 @@ "@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#4b8952c", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-4b8952c", "sha512-2Pb2CQfFRchm2Q3kWUNyA/0pr3au4Pye+zOoNqJrOqjaQhDPPqoMYIAvxiPLBCbShfOtZ/A4dj1cfkj0WaUn4A=="], - "@corbits/schedules": ["@corbits/schedules@workspace:packages/schedules"], + "@corbits/routines": ["@corbits/routines@workspace:packages/routines"], "@corbits/settings-ui": ["@corbits/settings-ui@workspace:packages/settings-ui"], "@corbits/webhook-triggers": ["@corbits/webhook-triggers@workspace:packages/webhook-triggers"], - "@corbits/routines": ["@corbits/routines@workspace:packages/routines"], - "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], @@ -1272,8 +1246,6 @@ "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], - "croner": ["croner@9.1.0", "", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="], - "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/package.json b/package.json index 8b1923473..6b0ba9b20 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@types/bun": "^1.3.9", "@types/semver": "^7.7.1", "better-auth": "^1.4.18", - "croner": "^9.1.0", "drizzle-orm": "^0.45.1", "hono": "^4.11.9", "isomorphic-git": "^1.27.2", diff --git a/packages/schedules/package.json b/packages/schedules/package.json deleted file mode 100644 index e57128bbc..000000000 --- a/packages/schedules/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@corbits/schedules", - "private": true, - "description": "Scheduled workflow automations: cron/interval-triggered launches of deployed workflow definitions", - "version": "0.0.1", - "license": "SEE LICENSE IN LICENSE.md", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./migrations": "./src/migrations.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/folded-runs": "workspace:*", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "workspace:*", - "@intx/hub-sessions": "workspace:*", - "@intx/log": "workspace:*", - "@intx/types": "workspace:*", - "arktype": "catalog:", - "croner": "catalog:", - "drizzle-orm": "catalog:", - "hono": "^4.11.9", - "postgres": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/schedules/src/index.ts b/packages/schedules/src/index.ts deleted file mode 100644 index 7faeb8e84..000000000 --- a/packages/schedules/src/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -export { schedules } from "./schema"; -export { - applyScheduleMigrations, - scheduleMigrations, - type ApplyScheduleMigrationsReport, - type ScheduleMigration, -} from "./migrations"; -export { - computeNextRun, - validateTrigger, - InvalidTriggerError, - type ScheduleTrigger, -} from "./trigger"; -export { - createDrizzleScheduleStore, - createInMemoryScheduleStore, - type CreateScheduleInput, - type RecordRunInput, - type ScheduleDb, - type ScheduleRow, - type ScheduleStore, - type UpdateSchedulePatch, -} from "./store"; -export { - createHubScheduleLauncher, - type CreateHubScheduleLauncherDeps, - type LaunchedScheduledRun, - type LaunchScheduledRunInput, - type ScheduleLauncher, -} from "./launcher"; -export { - createScheduler, - type CreateSchedulerDeps, - type Scheduler, - type ScheduleLogger, -} from "./scheduler"; -export { createScheduleRoutes, type CreateScheduleRoutesDeps } from "./routes"; diff --git a/packages/schedules/src/launcher.ts b/packages/schedules/src/launcher.ts deleted file mode 100644 index f303afd7e..000000000 --- a/packages/schedules/src/launcher.ts +++ /dev/null @@ -1,155 +0,0 @@ -// The hub-side launch path a due schedule fires through. Built -// entirely from `@corbits/folded-runs` — the launch/mail machinery -// `@corbits/chat`'s `launchInvite`/`sendMail` already use for exactly -// this shape (deploy an interactive instance of an already-deployed -// workflow definition, then deliver it a message) — rather than a -// second implementation. This package never re-derives inference-source -// resolution, principal/session/run bookkeeping, or mail signing: all -// of that lives in `@corbits/folded-runs` and is reused verbatim. -// -// A schedule launches a fresh instance on every occurrence (never -// reuses a prior run): unlike a chat channel, a scheduled automation -// has no notion of an ongoing conversation to resume, so "launch, then -// deliver the input payload as its first mail" is the whole contract. -import { and, eq } from "drizzle-orm"; -import { - createCryptoProviderCache, - domainOf, - launchFoldedRun, - readDefinitionJSON, - readFoldedBody, - sendFoldedMail, - type FoldedRunsDeps, -} from "@corbits/folded-runs"; -import type { DB } from "@intx/db"; -import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; -import { generateId } from "@intx/hub-common"; -import { formatAgentAddress } from "@intx/types"; -import type { - AssetService, - EventCollectorRegistry, - SessionService, - SidecarRouter, -} from "@intx/hub-sessions"; - -export interface LaunchScheduledRunInput { - readonly tenantId: string; - readonly scheduleId: string; - readonly workflowDefinitionId: string; - readonly createdBy: string; - readonly input: unknown; -} - -export interface LaunchedScheduledRun { - readonly instanceId: string; - readonly address: string; -} - -/** The launch call surface `scheduler.ts` and the routes' "run now" action need from the hub. */ -export interface ScheduleLauncher { - launchScheduledRun( - input: LaunchScheduledRunInput, - ): Promise; -} - -export type CreateHubScheduleLauncherDeps = { - db: DB["db"]; - sessionService: SessionService; - assetService: AssetService; - sidecarRouter: SidecarRouter; - eventCollectors: EventCollectorRegistry; -}; - -/** - * Composes `ScheduleLauncher` over the hub's real session services and - * `@corbits/folded-runs`, mirroring `createHubChatPlatform`'s - * `launchInvite` (see `packages/chat/src/platform-adapter.ts`) - * definition-lookup and launch, then a `sendFoldedMail` call carrying - * the schedule's `input` payload as the instance's first message — - * the same "launch, then deliver" shape `POST .../invite` uses for a - * chat's invited agent. - */ -export function createHubScheduleLauncher( - deps: CreateHubScheduleLauncherDeps, -): ScheduleLauncher { - const foldedRunsDeps: FoldedRunsDeps = { - db: deps.db, - sessionService: deps.sessionService, - assetService: deps.assetService, - sidecarRouter: deps.sidecarRouter, - eventCollectors: deps.eventCollectors, - }; - const cryptoProviders = createCryptoProviderCache(); - - return { - async launchScheduledRun(input): Promise { - const definitionRow = await deps.db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, input.workflowDefinitionId), - eq(workflowDefinition.tenantId, input.tenantId), - ), - }); - if (definitionRow === undefined) { - throw new Error( - `No definition "${input.workflowDefinitionId}" for this tenant`, - ); - } - if (definitionRow.status !== "deployed") { - throw new Error( - `Definition "${input.workflowDefinitionId}" is not in a ` + - `launchable state (status: ${definitionRow.status})`, - ); - } - if (definitionRow.assetId === null) { - throw new Error( - `Definition "${input.workflowDefinitionId}" has not been materialized`, - ); - } - - const tenantRow = await deps.db.query.tenant.findFirst({ - where: eq(tenantTable.id, input.tenantId), - }); - if (tenantRow === undefined) { - throw new Error(`No tenant "${input.tenantId}"`); - } - - const definitionJSON = await readDefinitionJSON( - deps.assetService, - definitionRow.assetId, - ); - const foldedBody = readFoldedBody(definitionJSON); - if (foldedBody.systemPrompt === "") { - throw new Error( - `Definition "${input.workflowDefinitionId}" cannot be launched ` + - "without a system prompt configured", - ); - } - - const instanceId = generateId("instance"); - const triggerAddress = formatAgentAddress(instanceId, tenantRow.domain); - - const launched = await launchFoldedRun(foldedRunsDeps, { - tenantId: input.tenantId, - instanceId, - triggerAddress, - definitionId: input.workflowDefinitionId, - foldedBody, - launchLabel: "the scheduled run", - }); - - const domain = domainOf(triggerAddress); - const cryptoProvider = await cryptoProviders.get(instanceId); - await sendFoldedMail(foldedRunsDeps, { - tenantId: input.tenantId, - sessionId: launched.sessionId, - agentAddress: triggerAddress, - from: `${input.createdBy}@${domain}`, - domain, - content: JSON.stringify(input.input), - cryptoProvider, - }); - - return { instanceId, address: triggerAddress }; - }, - }; -} diff --git a/packages/schedules/src/migrations.ts b/packages/schedules/src/migrations.ts deleted file mode 100644 index 97feb723a..000000000 --- a/packages/schedules/src/migrations.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Package-owned migrations for @corbits/schedules' one product table, -// following the same ledger pattern as `@corbits/chat`'s -// `applyChatMigrations` (see `packages/chat/src/migrations.ts`): the -// platform's own schema is authored and applied by `@intx/db` (see -// `scripts/db-setup.ts`); this module is this package's half of the -// "mount + migrations is the entire install story". Bookkeeping is its -// own table (`schedules_migrations`), never the platform's drizzle -// journal or chat's ledger, so this package's migration history stays -// extractable on its own. -import postgres from "postgres"; - -export interface ScheduleMigration { - name: string; - sql: string; -} - -/** - * Ordered, explicit migration set for the table declared in - * `./schema.ts`. Kept as literal SQL, reviewed here, rather than - * generated by drizzle at apply-time. - */ -export const scheduleMigrations: readonly ScheduleMigration[] = [ - { - name: "0001_schedules", - sql: ` - CREATE TABLE IF NOT EXISTS "schedules" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "workflow_definition_id" text NOT NULL, - "trigger" jsonb NOT NULL, - "input" jsonb NOT NULL, - "enabled" boolean NOT NULL DEFAULT true, - "created_by" text NOT NULL, - "last_run_at" timestamptz, - "next_run_at" timestamptz NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT now(), - "updated_at" timestamptz NOT NULL DEFAULT now() - ); - `, - }, - { - name: "0002_schedules_due_index", - sql: ` - CREATE INDEX IF NOT EXISTS "schedules_due_idx" - ON "schedules" ("enabled", "next_run_at"); - `, - }, -]; - -const LEDGER_TABLE = "schedules_migrations"; - -function quoteIdentifier(name: string): string { - return `"${name.replace(/"/g, '""')}"`; -} - -export interface ApplyScheduleMigrationsReport { - applied: string[]; - alreadyApplied: string[]; -} - -/** - * Apply `scheduleMigrations` against `databaseUrl`, idempotently: a - * migration already recorded in the ledger is skipped, never re-run. - * Failures are loud — the migration name and the underlying error are - * both surfaced, since a partial apply here would otherwise fail - * silently at the next `workbench setup`. - */ -export async function applyScheduleMigrations( - databaseUrl: string, -): Promise { - const sql = postgres(databaseUrl, { max: 1, onnotice: () => undefined }); - try { - await sql.unsafe( - `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(LEDGER_TABLE)} (` + - `name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`, - ); - const rows = await sql.unsafe( - `SELECT name FROM ${quoteIdentifier(LEDGER_TABLE)}`, - ); - const alreadyApplied = new Set(rows.map((row) => String(row["name"]))); - const applied: string[] = []; - for (const migration of scheduleMigrations) { - if (alreadyApplied.has(migration.name)) continue; - try { - await sql.unsafe(migration.sql); - await sql.unsafe( - `INSERT INTO ${quoteIdentifier(LEDGER_TABLE)} (name) VALUES ($1)`, - [migration.name], - ); - applied.push(migration.name); - } catch (error) { - throw new Error( - `@corbits/schedules migration ${JSON.stringify(migration.name)} failed: ` + - `${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } - } - return { applied, alreadyApplied: [...alreadyApplied] }; - } finally { - await sql.end(); - } -} diff --git a/packages/schedules/src/routes.ts b/packages/schedules/src/routes.ts deleted file mode 100644 index 02daa2075..000000000 --- a/packages/schedules/src/routes.ts +++ /dev/null @@ -1,267 +0,0 @@ -// The full HTTP surface of `@corbits/schedules`: tenant-scoped -// schedule CRUD plus a "run now" action. Mounted by the hub inside its -// tenant-scoped middleware — mirroring `@corbits/chat`'s -// `createChatRoutes` and `@workbench/echo`'s `createEchoRoutes` — so -// `TenantEnv`'s `tenant`/`principal` are always resolved before a -// handler here runs. This module owns route registration, request -// parsing (arktype at the boundary), and grant checks only; storage -// lives in `./store`, trigger arithmetic in `./trigger`, and launching -// in `./launcher`. -import { Hono } from "hono"; -import { type } from "arktype"; - -import type { TenantEnv } from "@intx/hub-api"; -import type { RequireGrant } from "@intx/hub-api"; -import { idResource } from "@intx/hub-api"; - -import type { ScheduleLauncher } from "./launcher"; -import type { ScheduleStore } from "./store"; -import { - computeNextRun, - InvalidTriggerError, - validateTrigger, - type ScheduleTrigger, -} from "./trigger"; - -export type CreateScheduleRoutesDeps = { - store: ScheduleStore; - launcher: ScheduleLauncher; - requireGrant: RequireGrant; - /** Injectable clock so tests control "now" and "id" without real randomness/timers. */ - now?: () => Date; - generateScheduleId?: () => string; -}; - -const ErrorEnvelope = (code: string, message: string) => ({ - error: { code, message }, -}); - -const TriggerSchema = type({ - kind: "'cron'", - expression: "string", -}).or( - type({ - kind: "'interval'", - ms: "number", - }), -); - -const CreateScheduleBody = type({ - workflowDefinitionId: "string", - trigger: TriggerSchema, - "input?": "unknown", - "enabled?": "boolean", -}); - -const UpdateScheduleBody = type({ - "enabled?": "boolean", - "trigger?": TriggerSchema, - "input?": "unknown", -}); - -function defaultScheduleId(): string { - const bytes = crypto.getRandomValues(new Uint8Array(16)); - const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); - return `sch_${hex}`; -} - -function scheduleView(row: { - id: string; - tenantId: string; - workflowDefinitionId: string; - trigger: ScheduleTrigger; - input: unknown; - enabled: boolean; - createdBy: string; - lastRunAt: Date | null; - nextRunAt: Date; - createdAt: Date; - updatedAt: Date; -}) { - return { - id: row.id, - workflowDefinitionId: row.workflowDefinitionId, - trigger: row.trigger, - input: row.input, - enabled: row.enabled, - createdBy: row.createdBy, - lastRunAt: row.lastRunAt?.toISOString() ?? null, - nextRunAt: row.nextRunAt.toISOString(), - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - -export function createScheduleRoutes( - deps: CreateScheduleRoutesDeps, -): Hono { - const app = new Hono(); - const now = deps.now ?? (() => new Date()); - const generateScheduleId = deps.generateScheduleId ?? defaultScheduleId; - - app.post( - "/schedules", - deps.requireGrant("schedule:*", "create"), - async (c) => { - const body = CreateScheduleBody( - await c.req.json().catch(() => undefined), - ); - if (body instanceof type.errors) { - return c.json( - ErrorEnvelope( - "bad_request", - `invalid schedule body: ${body.summary}`, - ), - 400, - ); - } - - try { - validateTrigger(body.trigger); - } catch (error) { - if (error instanceof InvalidTriggerError) { - return c.json(ErrorEnvelope("bad_request", error.message), 400); - } - throw error; - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const createdAt = now(); - - const row = await deps.store.create({ - id: generateScheduleId(), - tenantId: tenant.id, - workflowDefinitionId: body.workflowDefinitionId, - trigger: body.trigger, - input: body.input ?? null, - enabled: body.enabled ?? true, - createdBy: principal.id, - nextRunAt: computeNextRun(body.trigger, createdAt), - }); - - return c.json(scheduleView(row), 201); - }, - ); - - app.get("/schedules", deps.requireGrant("schedule:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const rows = await deps.store.list(tenant.id); - return c.json({ items: rows.map(scheduleView) }); - }); - - app.get( - "/schedules/:id", - deps.requireGrant(idResource("schedule", "id"), "read"), - async (c) => { - const tenant = c.get("tenant"); - const id = c.req.param("id"); - const row = await deps.store.get(tenant.id, id); - if (row === undefined) { - return c.json(ErrorEnvelope("not_found", "schedule not found"), 404); - } - return c.json(scheduleView(row)); - }, - ); - - app.patch( - "/schedules/:id", - deps.requireGrant(idResource("schedule", "id"), "write"), - async (c) => { - const tenant = c.get("tenant"); - const id = c.req.param("id"); - - const existing = await deps.store.get(tenant.id, id); - if (existing === undefined) { - return c.json(ErrorEnvelope("not_found", "schedule not found"), 404); - } - - const body = UpdateScheduleBody( - await c.req.json().catch(() => undefined), - ); - if (body instanceof type.errors) { - return c.json( - ErrorEnvelope( - "bad_request", - `invalid schedule patch: ${body.summary}`, - ), - 400, - ); - } - - if (body.trigger !== undefined) { - try { - validateTrigger(body.trigger); - } catch (error) { - if (error instanceof InvalidTriggerError) { - return c.json(ErrorEnvelope("bad_request", error.message), 400); - } - throw error; - } - } - - const nextRunAt = - body.trigger !== undefined - ? computeNextRun(body.trigger, now()) - : undefined; - - const row = await deps.store.update(tenant.id, id, { - ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), - ...(body.trigger !== undefined ? { trigger: body.trigger } : {}), - ...(body.input !== undefined ? { input: body.input } : {}), - ...(nextRunAt !== undefined ? { nextRunAt } : {}), - }); - if (row === undefined) { - return c.json(ErrorEnvelope("not_found", "schedule not found"), 404); - } - return c.json(scheduleView(row)); - }, - ); - - app.delete( - "/schedules/:id", - deps.requireGrant(idResource("schedule", "id"), "delete"), - async (c) => { - const tenant = c.get("tenant"); - const id = c.req.param("id"); - const deleted = await deps.store.delete(tenant.id, id); - if (!deleted) { - return c.json(ErrorEnvelope("not_found", "schedule not found"), 404); - } - return c.body(null, 204); - }, - ); - - app.post( - "/schedules/:id/run-now", - deps.requireGrant(idResource("schedule", "id"), "write"), - async (c) => { - const tenant = c.get("tenant"); - const id = c.req.param("id"); - const row = await deps.store.get(tenant.id, id); - if (row === undefined) { - return c.json(ErrorEnvelope("not_found", "schedule not found"), 404); - } - - const launched = await deps.launcher.launchScheduledRun({ - tenantId: tenant.id, - scheduleId: row.id, - workflowDefinitionId: row.workflowDefinitionId, - createdBy: row.createdBy, - input: row.input, - }); - await deps.store.recordRun({ - id: row.id, - lastRunAt: now(), - nextRunAt: row.nextRunAt, - }); - - return c.json( - { instanceId: launched.instanceId, address: launched.address }, - 201, - ); - }, - ); - - return app; -} diff --git a/packages/schedules/src/scheduler.ts b/packages/schedules/src/scheduler.ts deleted file mode 100644 index a6ace98b0..000000000 --- a/packages/schedules/src/scheduler.ts +++ /dev/null @@ -1,96 +0,0 @@ -// The ticking runtime: on a fixed interval, finds every enabled -// schedule due to fire, launches each through `ScheduleLauncher`, and -// advances its `nextRunAt`. Ticks are non-overlapping and coalesced — -// a tick still running when the next one is due is skipped outright -// (never queued), since a due-computation re-run before the prior one -// settles would otherwise double-launch. A single schedule's launch -// failure never aborts the tick or silently drops the schedule: it is -// logged loud (schedule id and the underlying error) and the schedule's -// `nextRunAt` still advances, so a persistently broken definition fails -// loudly forever rather than either wedging the whole tick or spinning -// a tight retry loop against a target that will never succeed. -import type { getLogger } from "@intx/log"; -import type { ScheduleLauncher } from "./launcher"; -import type { ScheduleRow, ScheduleStore } from "./store"; -import { computeNextRun } from "./trigger"; - -/** `@intx/log` re-exports `@logtape/logtape`'s `getLogger` but not its `Logger` type by name; derived here rather than widening that package's surface for one type alias. */ -export type ScheduleLogger = ReturnType; - -export type CreateSchedulerDeps = { - store: ScheduleStore; - launcher: ScheduleLauncher; - log: ScheduleLogger; - /** How often to check for due schedules. */ - tickIntervalMs: number; - /** Injectable clock, so tests control "now" without real timers. */ - now?: () => Date; -}; - -export interface Scheduler { - start(): void; - stop(): void; - /** Runs one tick immediately, awaiting its completion — the seam tests drive instead of waiting on the interval timer. */ - tickOnce(): Promise; -} - -export function createScheduler(deps: CreateSchedulerDeps): Scheduler { - const now = deps.now ?? (() => new Date()); - let timer: ReturnType | undefined; - let ticking = false; - - async function runDueSchedule(row: ScheduleRow): Promise { - const firedAt = now(); - try { - await deps.launcher.launchScheduledRun({ - tenantId: row.tenantId, - scheduleId: row.id, - workflowDefinitionId: row.workflowDefinitionId, - createdBy: row.createdBy, - input: row.input, - }); - } catch (error) { - deps.log.error`schedule ${row.id} failed to launch: ${ - error instanceof Error ? error.message : String(error) - }`; - } - await deps.store.recordRun({ - id: row.id, - lastRunAt: firedAt, - nextRunAt: computeNextRun(row.trigger, firedAt), - }); - } - - async function tick(): Promise { - if (ticking) { - deps.log.warn`schedule tick skipped: the prior tick is still running`; - return; - } - ticking = true; - try { - const due = await deps.store.findDue(now()); - for (const row of due) { - await runDueSchedule(row); - } - } catch (error) { - deps.log.error`schedule tick failed: ${ - error instanceof Error ? error.message : String(error) - }`; - } finally { - ticking = false; - } - } - - return { - start() { - if (timer !== undefined) return; - timer = setInterval(() => void tick(), deps.tickIntervalMs); - }, - stop() { - if (timer === undefined) return; - clearInterval(timer); - timer = undefined; - }, - tickOnce: tick, - }; -} diff --git a/packages/schedules/src/schema.ts b/packages/schedules/src/schema.ts deleted file mode 100644 index 68e9a4ee9..000000000 --- a/packages/schedules/src/schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -// The one product table `@corbits/schedules` owns: a tenant-scoped -// schedule row naming the workflow definition it launches, its trigger -// (cron or interval, see `./trigger.ts`), the input payload each -// launch carries, and the bookkeeping (`last_run_at`/`next_run_at`) -// the ticking scheduler reads and advances. `input` and `trigger` are -// jsonb so their shape can grow without a migration; only the columns -// the scheduler and routes query directly (`enabled`, `next_run_at`) -// are real columns. -import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core"; - -export const schedules = pgTable("schedules", { - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull(), - workflowDefinitionId: text("workflow_definition_id").notNull(), - trigger: jsonb("trigger").notNull(), - input: jsonb("input").notNull(), - enabled: boolean("enabled").notNull().default(true), - createdBy: text("created_by").notNull(), - lastRunAt: timestamp("last_run_at", { withTimezone: true }), - nextRunAt: timestamp("next_run_at", { withTimezone: true }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); diff --git a/packages/schedules/src/store.ts b/packages/schedules/src/store.ts deleted file mode 100644 index 826ccce25..000000000 --- a/packages/schedules/src/store.ts +++ /dev/null @@ -1,246 +0,0 @@ -// Persistence for the one schedules product table, kept apart from -// route and scheduler wiring so neither touches drizzle directly. -// `ScheduleStore` is the seam both `routes.ts` and `scheduler.ts` -// depend on; `createDrizzleScheduleStore` is its one production -// implementation, over `./schema.ts`. `createInMemoryScheduleStore` is -// the fake this package's own tests drive the route and scheduler -// surfaces through, with no database involved. -import { and, eq, lte } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; - -import { schedules } from "./schema"; -import type { ScheduleTrigger } from "./trigger"; - -export type ScheduleDb< - TSchema extends Record = Record, -> = PostgresJsDatabase; - -export interface ScheduleRow { - readonly id: string; - readonly tenantId: string; - readonly workflowDefinitionId: string; - readonly trigger: ScheduleTrigger; - readonly input: unknown; - readonly enabled: boolean; - readonly createdBy: string; - readonly lastRunAt: Date | null; - readonly nextRunAt: Date; - readonly createdAt: Date; - readonly updatedAt: Date; -} - -export interface CreateScheduleInput { - readonly id: string; - readonly tenantId: string; - readonly workflowDefinitionId: string; - readonly trigger: ScheduleTrigger; - readonly input: unknown; - readonly enabled: boolean; - readonly createdBy: string; - readonly nextRunAt: Date; -} - -export interface UpdateSchedulePatch { - readonly enabled?: boolean; - readonly trigger?: ScheduleTrigger; - readonly input?: unknown; - readonly nextRunAt?: Date; -} - -export interface RecordRunInput { - readonly id: string; - readonly lastRunAt: Date; - readonly nextRunAt: Date; -} - -export interface ScheduleStore { - create(input: CreateScheduleInput): Promise; - get(tenantId: string, id: string): Promise; - list(tenantId: string): Promise; - update( - tenantId: string, - id: string, - patch: UpdateSchedulePatch, - ): Promise; - delete(tenantId: string, id: string): Promise; - /** Every enabled schedule whose `nextRunAt` is at or before `now`, across all tenants — the scheduler ticks the whole install, not one tenant at a time. */ - findDue(now: Date): Promise; - recordRun(input: RecordRunInput): Promise; -} - -function toRow(row: typeof schedules.$inferSelect): ScheduleRow { - return { - id: row.id, - tenantId: row.tenantId, - workflowDefinitionId: row.workflowDefinitionId, - trigger: row.trigger as ScheduleTrigger, - input: row.input, - enabled: row.enabled, - createdBy: row.createdBy, - lastRunAt: row.lastRunAt, - nextRunAt: row.nextRunAt, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -export function createDrizzleScheduleStore< - TSchema extends Record = Record, ->(db: ScheduleDb): ScheduleStore { - return { - async create(input) { - const now = new Date(); - const [row] = await db - .insert(schedules) - .values({ - id: input.id, - tenantId: input.tenantId, - workflowDefinitionId: input.workflowDefinitionId, - trigger: input.trigger, - input: input.input, - enabled: input.enabled, - createdBy: input.createdBy, - nextRunAt: input.nextRunAt, - createdAt: now, - updatedAt: now, - }) - .returning(); - if (row === undefined) { - throw new Error("schedule insert returned no row"); - } - return toRow(row); - }, - - async get(tenantId, id) { - const [row] = await db - .select() - .from(schedules) - .where(and(eq(schedules.tenantId, tenantId), eq(schedules.id, id))) - .limit(1); - return row === undefined ? undefined : toRow(row); - }, - - async list(tenantId) { - const rows = await db - .select() - .from(schedules) - .where(eq(schedules.tenantId, tenantId)); - return rows.map(toRow); - }, - - async update(tenantId, id, patch) { - const [row] = await db - .update(schedules) - .set({ ...patch, updatedAt: new Date() }) - .where(and(eq(schedules.tenantId, tenantId), eq(schedules.id, id))) - .returning(); - return row === undefined ? undefined : toRow(row); - }, - - async delete(tenantId, id) { - const deleted = await db - .delete(schedules) - .where(and(eq(schedules.tenantId, tenantId), eq(schedules.id, id))) - .returning({ id: schedules.id }); - return deleted.length > 0; - }, - - async findDue(now) { - const rows = await db - .select() - .from(schedules) - .where(and(eq(schedules.enabled, true), lte(schedules.nextRunAt, now))); - return rows.map(toRow); - }, - - async recordRun(input) { - await db - .update(schedules) - .set({ - lastRunAt: input.lastRunAt, - nextRunAt: input.nextRunAt, - updatedAt: new Date(), - }) - .where(eq(schedules.id, input.id)); - }, - }; -} - -/** - * An in-memory `ScheduleStore`, used only by this package's own tests - * (cron due-computation, route validation) so they never need a real - * database — production always runs `createDrizzleScheduleStore`. - */ -export function createInMemoryScheduleStore(): ScheduleStore { - const rows = new Map(); - - return { - async create(input) { - const now = new Date(); - const row: ScheduleRow = { - id: input.id, - tenantId: input.tenantId, - workflowDefinitionId: input.workflowDefinitionId, - trigger: input.trigger, - input: input.input, - enabled: input.enabled, - createdBy: input.createdBy, - lastRunAt: null, - nextRunAt: input.nextRunAt, - createdAt: now, - updatedAt: now, - }; - rows.set(row.id, row); - return row; - }, - - async get(tenantId, id) { - const row = rows.get(id); - return row !== undefined && row.tenantId === tenantId ? row : undefined; - }, - - async list(tenantId) { - return [...rows.values()].filter((row) => row.tenantId === tenantId); - }, - - async update(tenantId, id, patch) { - const existing = rows.get(id); - if (existing === undefined || existing.tenantId !== tenantId) { - return undefined; - } - const updated: ScheduleRow = { - ...existing, - ...patch, - updatedAt: new Date(), - }; - rows.set(id, updated); - return updated; - }, - - async delete(tenantId, id) { - const existing = rows.get(id); - if (existing === undefined || existing.tenantId !== tenantId) { - return false; - } - rows.delete(id); - return true; - }, - - async findDue(now) { - return [...rows.values()].filter( - (row) => row.enabled && row.nextRunAt.getTime() <= now.getTime(), - ); - }, - - async recordRun(input) { - const existing = rows.get(input.id); - if (existing === undefined) return; - rows.set(input.id, { - ...existing, - lastRunAt: input.lastRunAt, - nextRunAt: input.nextRunAt, - updatedAt: new Date(), - }); - }, - }; -} diff --git a/packages/schedules/src/trigger.ts b/packages/schedules/src/trigger.ts deleted file mode 100644 index 3dcf923c1..000000000 --- a/packages/schedules/src/trigger.ts +++ /dev/null @@ -1,64 +0,0 @@ -// The two trigger shapes a schedule can carry, and the arithmetic that -// turns one into a concrete next-run instant. `croner` is the one -// external dependency this package adds: a minimal, actively -// maintained cron parser/evaluator, rather than reimplementing cron -// field parsing (5- and 6-field expressions, ranges, steps, `L`/`W`, -// DST-aware scheduling) ourselves — a nontrivial and easy-to-get-wrong -// surface that has nothing to do with what this package actually owns -// (schedule storage, tenant-scoped CRUD, and the launch path). -import { Cron } from "croner"; - -export type ScheduleTrigger = - | { readonly kind: "cron"; readonly expression: string } - | { readonly kind: "interval"; readonly ms: number }; - -export class InvalidTriggerError extends Error {} - -/** - * Throws `InvalidTriggerError` for anything that would otherwise fail - * silently at scheduling time: an unparsable cron expression, or a - * non-positive interval. Called at write time (create/update) so a bad - * trigger never reaches the ticking scheduler in the first place. - */ -export function validateTrigger(trigger: ScheduleTrigger): void { - if (trigger.kind === "cron") { - try { - const parsed = new Cron(trigger.expression, { timezone: "UTC" }); - parsed.stop(); - } catch (cause) { - throw new InvalidTriggerError( - `invalid cron expression ${JSON.stringify(trigger.expression)}: ` + - `${cause instanceof Error ? cause.message : String(cause)}`, - { cause }, - ); - } - return; - } - if (!Number.isFinite(trigger.ms) || trigger.ms <= 0) { - throw new InvalidTriggerError( - `interval trigger requires a positive, finite ms; got ${trigger.ms}`, - ); - } -} - -/** - * The next instant strictly after `from` that this trigger fires. - * Cron uses `croner`'s own field evaluation (including DST edges); - * interval is plain arithmetic off the last computed instant, so a - * missed tick (the process was down) does not compound — the next run - * is always `from + ms`, not "catch up n times". - */ -export function computeNextRun(trigger: ScheduleTrigger, from: Date): Date { - if (trigger.kind === "cron") { - const cron = new Cron(trigger.expression, { timezone: "UTC" }); - const next = cron.nextRun(from); - if (next === null) { - throw new InvalidTriggerError( - `cron expression ${JSON.stringify(trigger.expression)} has no ` + - `future run after ${from.toISOString()}`, - ); - } - return next; - } - return new Date(from.getTime() + trigger.ms); -} diff --git a/packages/schedules/test/migrations.test.ts b/packages/schedules/test/migrations.test.ts deleted file mode 100644 index 5308b72e1..000000000 --- a/packages/schedules/test/migrations.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh -// checkout still runs the unit gates). Runs against its own scratch -// database, never the developer's or the walking-skeleton suite's, so -// a failure here can never corrupt either — mirroring -// `packages/chat/test/migrations.test.ts`. -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; -import { applyScheduleMigrations } from "../src/migrations"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_schedules_migrations_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = databaseUrl === undefined ? describe.skip : describe; - -describeIfDb("applyScheduleMigrations", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("applies the schedules table and is idempotent on a second run", async () => { - const first = await applyScheduleMigrations(scratchUrl); - expect(first.applied).toEqual([ - "0001_schedules", - "0002_schedules_due_index", - ]); - - const second = await applyScheduleMigrations(scratchUrl); - expect(second.applied).toEqual([]); - expect(second.alreadyApplied.sort()).toEqual([ - "0001_schedules", - "0002_schedules_due_index", - ]); - - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const tables = await sql.unsafe( - `SELECT table_name FROM information_schema.tables ` + - `WHERE table_schema = 'public' AND table_name = 'schedules'`, - ); - expect(tables.map((row) => String(row["table_name"]))).toEqual([ - "schedules", - ]); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/schedules/test/routes.test.ts b/packages/schedules/test/routes.test.ts deleted file mode 100644 index bdfc574ca..000000000 --- a/packages/schedules/test/routes.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -// Mounts `createScheduleRoutes` into a bare `Hono` with fake -// store/launcher deps, exercising the route surface: request parsing, -// grant checks, and HTTP envelope mapping. Cron/interval arithmetic is -// covered in `trigger.test.ts`; this file never re-tests it beyond the -// route's own 400-on-invalid-trigger behavior. -import { describe, expect, test } from "bun:test"; -import { createScheduleRoutes } from "../src/routes"; -import { buildDeps, fakeLauncher, mountAs, TENANT } from "./test-support"; - -const FIXED_NOW = new Date("2026-01-01T00:00:00.000Z"); - -describe("POST /schedules", () => { - test("creates a schedule and computes its first nextRunAt", async () => { - const deps = buildDeps({ now: () => FIXED_NOW }); - const app = mountAs(createScheduleRoutes(deps), "prn_alice"); - - const response = await app.request("/schedules", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - workflowDefinitionId: "wfd_report", - trigger: { kind: "cron", expression: "0 * * * *" }, - input: { channel: "#ops" }, - }), - }); - - expect(response.status).toBe(201); - const body = (await response.json()) as { - id: string; - enabled: boolean; - nextRunAt: string; - createdBy: string; - }; - expect(body.enabled).toBe(true); - expect(body.createdBy).toBe("prn_alice"); - expect(body.nextRunAt).toBe("2026-01-01T01:00:00.000Z"); - - const stored = await deps.store.get(TENANT.id, body.id); - expect(stored?.workflowDefinitionId).toBe("wfd_report"); - }); - - test("rejects a malformed body with the structured error envelope", async () => { - const app = mountAs(createScheduleRoutes(buildDeps()), "prn_alice"); - - const response = await app.request("/schedules", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - trigger: { kind: "cron", expression: "* * * * *" }, - }), - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("bad_request"); - }); - - test("rejects an unparsable cron expression", async () => { - const app = mountAs(createScheduleRoutes(buildDeps()), "prn_alice"); - - const response = await app.request("/schedules", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - workflowDefinitionId: "wfd_report", - trigger: { kind: "cron", expression: "nonsense" }, - }), - }); - - expect(response.status).toBe(400); - }); - - test("rejects a non-positive interval", async () => { - const app = mountAs(createScheduleRoutes(buildDeps()), "prn_alice"); - - const response = await app.request("/schedules", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - workflowDefinitionId: "wfd_report", - trigger: { kind: "interval", ms: 0 }, - }), - }); - - expect(response.status).toBe(400); - }); - - test("a denied grant is rejected before any schedule is created", async () => { - const deps = buildDeps({ - requireGrant: () => async (c) => - c.json({ error: { code: "forbidden", message: "no" } }, 403), - }); - const app = mountAs(createScheduleRoutes(deps), "prn_alice"); - - const response = await app.request("/schedules", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - workflowDefinitionId: "wfd_report", - trigger: { kind: "interval", ms: 60_000 }, - }), - }); - - expect(response.status).toBe(403); - }); -}); - -describe("GET /schedules", () => { - test("lists only the requesting tenant's schedules", async () => { - const deps = buildDeps({ now: () => FIXED_NOW }); - const app = mountAs(createScheduleRoutes(deps), "prn_alice"); - - await deps.store.create({ - id: "sch_other", - tenantId: "tnt_other", - workflowDefinitionId: "wfd_x", - trigger: { kind: "interval", ms: 60_000 }, - input: null, - enabled: true, - createdBy: "prn_bob", - nextRunAt: FIXED_NOW, - }); - await app.request("/schedules", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - workflowDefinitionId: "wfd_report", - trigger: { kind: "interval", ms: 60_000 }, - }), - }); - - const response = await app.request("/schedules"); - const body = (await response.json()) as { items: { id: string }[] }; - expect(body.items).toHaveLength(1); - }); -}); - -describe("PATCH /schedules/:id", () => { - test("disables a schedule without touching its trigger", async () => { - const deps = buildDeps({ now: () => FIXED_NOW }); - const app = mountAs(createScheduleRoutes(deps), "prn_alice"); - const created = await deps.store.create({ - id: "sch_1", - tenantId: TENANT.id, - workflowDefinitionId: "wfd_report", - trigger: { kind: "interval", ms: 60_000 }, - input: null, - enabled: true, - createdBy: "prn_alice", - nextRunAt: FIXED_NOW, - }); - - const response = await app.request(`/schedules/${created.id}`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ enabled: false }), - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { - enabled: boolean; - trigger: unknown; - }; - expect(body.enabled).toBe(false); - expect(body.trigger).toEqual({ kind: "interval", ms: 60_000 }); - }); - - test("404s for a schedule in another tenant", async () => { - const deps = buildDeps(); - const app = mountAs(createScheduleRoutes(deps), "prn_alice"); - await deps.store.create({ - id: "sch_foreign", - tenantId: "tnt_other", - workflowDefinitionId: "wfd_x", - trigger: { kind: "interval", ms: 60_000 }, - input: null, - enabled: true, - createdBy: "prn_bob", - nextRunAt: FIXED_NOW, - }); - - const response = await app.request("/schedules/sch_foreign", { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ enabled: false }), - }); - - expect(response.status).toBe(404); - }); - - test("rejects an invalid trigger patch", async () => { - const deps = buildDeps({ now: () => FIXED_NOW }); - const app = mountAs(createScheduleRoutes(deps), "prn_alice"); - const created = await deps.store.create({ - id: "sch_2", - tenantId: TENANT.id, - workflowDefinitionId: "wfd_report", - trigger: { kind: "interval", ms: 60_000 }, - input: null, - enabled: true, - createdBy: "prn_alice", - nextRunAt: FIXED_NOW, - }); - - const response = await app.request(`/schedules/${created.id}`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ trigger: { kind: "interval", ms: -5 } }), - }); - - expect(response.status).toBe(400); - }); -}); - -describe("DELETE /schedules/:id", () => { - test("deletes an existing schedule", async () => { - const deps = buildDeps({ now: () => FIXED_NOW }); - const app = mountAs(createScheduleRoutes(deps), "prn_alice"); - const created = await deps.store.create({ - id: "sch_3", - tenantId: TENANT.id, - workflowDefinitionId: "wfd_report", - trigger: { kind: "interval", ms: 60_000 }, - input: null, - enabled: true, - createdBy: "prn_alice", - nextRunAt: FIXED_NOW, - }); - - const response = await app.request(`/schedules/${created.id}`, { - method: "DELETE", - }); - expect(response.status).toBe(204); - expect(await deps.store.get(TENANT.id, created.id)).toBeUndefined(); - }); -}); - -describe("POST /schedules/:id/run-now", () => { - test("launches immediately without disturbing the schedule's cadence", async () => { - const launcher = fakeLauncher(); - const deps = buildDeps({ launcher, now: () => FIXED_NOW }); - const app = mountAs(createScheduleRoutes(deps), "prn_alice"); - const future = new Date(FIXED_NOW.getTime() + 60_000); - const created = await deps.store.create({ - id: "sch_4", - tenantId: TENANT.id, - workflowDefinitionId: "wfd_report", - trigger: { kind: "interval", ms: 60_000 }, - input: { note: "hi" }, - enabled: true, - createdBy: "prn_alice", - nextRunAt: future, - }); - - const response = await app.request(`/schedules/${created.id}/run-now`, { - method: "POST", - }); - - expect(response.status).toBe(201); - expect(launcher.calls).toHaveLength(1); - expect(launcher.calls[0]?.input).toEqual({ note: "hi" }); - - const after = await deps.store.get(TENANT.id, created.id); - expect(after?.nextRunAt.getTime()).toBe(future.getTime()); - expect(after?.lastRunAt?.getTime()).toBe(FIXED_NOW.getTime()); - }); -}); diff --git a/packages/schedules/test/scheduler.test.ts b/packages/schedules/test/scheduler.test.ts deleted file mode 100644 index 14cdc7702..000000000 --- a/packages/schedules/test/scheduler.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -// Exercises the tick loop's own wiring: due-computation against the -// store, advancing `nextRunAt` after every fire (success or failure), -// and non-overlap. `computeNextRun`'s own arithmetic is covered by -// `trigger.test.ts`. -import { describe, expect, test } from "bun:test"; -import { createScheduler, type ScheduleLogger } from "../src/scheduler"; -import { createInMemoryScheduleStore } from "../src/store"; -import type { ScheduleLauncher } from "../src/launcher"; - -function fakeLogger(): ScheduleLogger { - const noop = () => undefined; - const tag = () => noop; - return { - error: tag, - warn: tag, - info: tag, - debug: tag, - fatal: tag, - trace: tag, - } as unknown as ScheduleLogger; -} - -describe("createScheduler", () => { - test("launches every due schedule and advances nextRunAt", async () => { - const store = createInMemoryScheduleStore(); - const now = new Date("2026-01-01T00:00:00.000Z"); - await store.create({ - id: "sch_due", - tenantId: "tnt_1", - workflowDefinitionId: "wfd_report", - trigger: { kind: "interval", ms: 60_000 }, - input: null, - enabled: true, - createdBy: "prn_alice", - nextRunAt: now, - }); - await store.create({ - id: "sch_future", - tenantId: "tnt_1", - workflowDefinitionId: "wfd_report", - trigger: { kind: "interval", ms: 60_000 }, - input: null, - enabled: true, - createdBy: "prn_alice", - nextRunAt: new Date(now.getTime() + 3_600_000), - }); - - const launched: string[] = []; - const launcher: ScheduleLauncher = { - async launchScheduledRun(input) { - launched.push(input.scheduleId); - return { instanceId: "ins_1", address: "ins_1@acme.example" }; - }, - }; - - const scheduler = createScheduler({ - store, - launcher, - log: fakeLogger(), - tickIntervalMs: 1_000, - now: () => now, - }); - - await scheduler.tickOnce(); - - expect(launched).toEqual(["sch_due"]); - const due = await store.get("tnt_1", "sch_due"); - expect(due?.lastRunAt?.getTime()).toBe(now.getTime()); - expect(due?.nextRunAt.getTime()).toBe(now.getTime() + 60_000); - const future = await store.get("tnt_1", "sch_future"); - expect(future?.lastRunAt).toBeNull(); - }); - - test("a launch failure still advances nextRunAt instead of looping forever", async () => { - const store = createInMemoryScheduleStore(); - const now = new Date("2026-01-01T00:00:00.000Z"); - await store.create({ - id: "sch_broken", - tenantId: "tnt_1", - workflowDefinitionId: "wfd_missing", - trigger: { kind: "interval", ms: 30_000 }, - input: null, - enabled: true, - createdBy: "prn_alice", - nextRunAt: now, - }); - - const launcher: ScheduleLauncher = { - async launchScheduledRun() { - throw new Error("definition not deployed"); - }, - }; - - const errors: string[] = []; - const log = fakeLogger(); - ( - log as unknown as { - error: (strings: TemplateStringsArray, ...v: unknown[]) => void; - } - ).error = (strings, ...values) => { - errors.push( - strings.reduce((acc, s, i) => acc + s + String(values[i] ?? ""), ""), - ); - }; - - const scheduler = createScheduler({ - store, - launcher, - log, - tickIntervalMs: 1_000, - now: () => now, - }); - - await scheduler.tickOnce(); - - const row = await store.get("tnt_1", "sch_broken"); - expect(row?.nextRunAt.getTime()).toBe(now.getTime() + 30_000); - expect(errors.some((e) => e.includes("sch_broken"))).toBe(true); - }); - - test("a still-running tick coalesces the next one instead of overlapping", async () => { - const store = createInMemoryScheduleStore(); - const now = new Date("2026-01-01T00:00:00.000Z"); - await store.create({ - id: "sch_slow", - tenantId: "tnt_1", - workflowDefinitionId: "wfd_report", - trigger: { kind: "interval", ms: 60_000 }, - input: null, - enabled: true, - createdBy: "prn_alice", - nextRunAt: now, - }); - - let concurrent = 0; - let maxConcurrent = 0; - let resolveFirst: (() => void) | undefined; - const started = new Promise((resolve) => { - resolveFirst = resolve; - }); - - const launcher: ScheduleLauncher = { - async launchScheduledRun() { - concurrent += 1; - maxConcurrent = Math.max(maxConcurrent, concurrent); - resolveFirst?.(); - await new Promise((resolve) => setTimeout(resolve, 20)); - concurrent -= 1; - return { instanceId: "ins_1", address: "ins_1@acme.example" }; - }, - }; - - const scheduler = createScheduler({ - store, - launcher, - log: fakeLogger(), - tickIntervalMs: 1_000, - now: () => now, - }); - - const firstTick = scheduler.tickOnce(); - await started; - await scheduler.tickOnce(); - await firstTick; - - expect(maxConcurrent).toBe(1); - }); -}); diff --git a/packages/schedules/test/test-support.ts b/packages/schedules/test/test-support.ts deleted file mode 100644 index 3f313a528..000000000 --- a/packages/schedules/test/test-support.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Shared test harness: a fake `ScheduleLauncher`, a tenant/principal -// injecting mount, and the deps builder every route test file drives -// `createScheduleRoutes` through. Mirrors `packages/chat/test/test-support.ts`. -// Not a production module — lives in `test/` only. -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; - -import type { TenantEnv } from "@intx/hub-api"; -import type { ScheduleLauncher } from "../src/launcher"; -import { createInMemoryScheduleStore } from "../src/store"; -import type { CreateScheduleRoutesDeps } from "../src/routes"; - -export const TENANT = { - id: "tnt_1", - name: "Acme", - slug: "acme", - domain: "acme.example", - parentId: null, - config: null, - createdAt: new Date(), - updatedAt: new Date(), -}; - -export function principal(id: string) { - return { - id, - tenantId: TENANT.id, - kind: "user" as const, - refId: id, - status: "active" as const, - createdAt: new Date(), - updatedAt: new Date(), - }; -} - -export function fakeLauncher(): ScheduleLauncher & { - calls: { - tenantId: string; - scheduleId: string; - workflowDefinitionId: string; - createdBy: string; - input: unknown; - }[]; -} { - const calls: { - tenantId: string; - scheduleId: string; - workflowDefinitionId: string; - createdBy: string; - input: unknown; - }[] = []; - return { - calls, - async launchScheduledRun(input) { - calls.push(input); - return { - instanceId: `ins_${calls.length}`, - address: `ins_${calls.length}@acme.example`, - }; - }, - }; -} - -export function mountAs( - routes: Hono, - principalId: string, -): Hono { - const asPrincipal: MiddlewareHandler = async (c, next) => { - c.set("tenant", TENANT); - c.set("principal", principal(principalId)); - await next(); - }; - const app = new Hono(); - app.use("*", asPrincipal); - app.route("/", routes); - return app; -} - -export function buildDeps( - overrides: Partial = {}, -): CreateScheduleRoutesDeps { - return { - store: createInMemoryScheduleStore(), - launcher: fakeLauncher(), - requireGrant: () => async (_c, next) => { - await next(); - }, - ...overrides, - }; -} diff --git a/packages/schedules/test/trigger.test.ts b/packages/schedules/test/trigger.test.ts deleted file mode 100644 index 7e525ef25..000000000 --- a/packages/schedules/test/trigger.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - computeNextRun, - InvalidTriggerError, - validateTrigger, -} from "../src/trigger"; - -describe("validateTrigger", () => { - test("accepts a well-formed cron expression", () => { - expect(() => - validateTrigger({ kind: "cron", expression: "0 * * * *" }), - ).not.toThrow(); - }); - - test("rejects a malformed cron expression", () => { - expect(() => - validateTrigger({ kind: "cron", expression: "not a cron" }), - ).toThrow(InvalidTriggerError); - }); - - test("accepts a positive interval", () => { - expect(() => - validateTrigger({ kind: "interval", ms: 60_000 }), - ).not.toThrow(); - }); - - test("rejects a zero or negative interval", () => { - expect(() => validateTrigger({ kind: "interval", ms: 0 })).toThrow( - InvalidTriggerError, - ); - expect(() => validateTrigger({ kind: "interval", ms: -1 })).toThrow( - InvalidTriggerError, - ); - }); - - test("rejects a non-finite interval", () => { - expect(() => - validateTrigger({ kind: "interval", ms: Number.POSITIVE_INFINITY }), - ).toThrow(InvalidTriggerError); - expect(() => validateTrigger({ kind: "interval", ms: Number.NaN })).toThrow( - InvalidTriggerError, - ); - }); -}); - -describe("computeNextRun", () => { - test("interval trigger advances by exactly its ms, not compounding", () => { - const from = new Date("2026-01-01T00:00:00.000Z"); - const next = computeNextRun({ kind: "interval", ms: 90_000 }, from); - expect(next.toISOString()).toBe("2026-01-01T00:01:30.000Z"); - }); - - test("cron trigger fires on the next matching minute boundary", () => { - // "0 * * * *" is minute 0 of every hour. - const from = new Date("2026-01-01T00:00:00.000Z"); - const next = computeNextRun( - { kind: "cron", expression: "0 * * * *" }, - from, - ); - expect(next.toISOString()).toBe("2026-01-01T01:00:00.000Z"); - }); - - test("cron trigger crossing a month/year boundary", () => { - const from = new Date("2025-12-31T23:59:00.000Z"); - const next = computeNextRun( - { kind: "cron", expression: "0 0 1 * *" }, - from, - ); - expect(next.toISOString()).toBe("2026-01-01T00:00:00.000Z"); - }); - - test("cron trigger honoring a day-of-week restriction", () => { - // 2026-01-01 is a Thursday; "0 9 * * 1" is 09:00 every Monday. - const from = new Date("2026-01-01T00:00:00.000Z"); - const next = computeNextRun( - { kind: "cron", expression: "0 9 * * 1" }, - from, - ); - expect(next.getUTCDay()).toBe(1); - expect(next.getUTCHours()).toBe(9); - expect(next.getTime()).toBeGreaterThan(from.getTime()); - }); -}); diff --git a/packages/schedules/tsconfig.json b/packages/schedules/tsconfig.json deleted file mode 100644 index e956ddd88..000000000 --- a/packages/schedules/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["bun"] - }, - "include": ["src", "test"] -} diff --git a/scripts/db-setup.ts b/scripts/db-setup.ts index 277b49de7..306f2b11b 100644 --- a/scripts/db-setup.ts +++ b/scripts/db-setup.ts @@ -30,8 +30,8 @@ import { readdir } from "node:fs/promises"; import { applyChatMigrations } from "../packages/chat/src/migrations"; import { applyWebhookTriggersMigrations } from "../packages/webhook-triggers/src/migrations"; -import { applyScheduleMigrations } from "../packages/schedules/src/migrations"; import { applyNotifyMigrations } from "../packages/notify/src/migrations"; +import { applyRoutineMigrations } from "../packages/routines/src/migrations"; const repoRoot = path.resolve(import.meta.dir, ".."); const HUB_DIR = path.join(repoRoot, "apps", "hub"); @@ -48,7 +48,7 @@ const INSTALLED_PACKAGE_MIGRATIONS: readonly { }[] = [ { name: "@corbits/chat", apply: applyChatMigrations }, { name: "@corbits/webhook-triggers", apply: applyWebhookTriggersMigrations }, - { name: "@corbits/schedules", apply: applyScheduleMigrations }, + { name: "@corbits/routines", apply: applyRoutineMigrations }, { name: "@corbits/notify", apply: applyNotifyMigrations }, ]; From 557f77dea5fb3a9c9f60fe8db98788a290a9e1a5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:05:54 -0700 Subject: [PATCH 10/28] Update docs: clarify Routine vocabulary and scheduler limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/GLOSSARY.md | 9 +++++++++ packages/routines/README.md | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 82cde53c8..7208917cf 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -34,6 +34,15 @@ would call a "bench" is one nothing else is parented under as a channel — in practice, the one they signed into, not one that showed up as a conversation in their sidebar. +A **Routine** is never a second name for a **Run**, or for Interchange's +own workflow concept — the three sit at different levels. A workflow +definition is the deployable code; a run is one execution of it; a routine +is the named, recurring (or manual) parent a person sets up over runs of a +definition, holding the trigger, delivery channel, and run history a bare +run does not carry. "Workflow" on its own always means Interchange's +runtime concept — the definition or its runs — never a stand-in for +routine. + Naming conventions for this repository's packages: - Local packages are `@workbench/*`, with a kebab-case kind suffix where diff --git a/packages/routines/README.md b/packages/routines/README.md index 59e265223..8f635eee9 100644 --- a/packages/routines/README.md +++ b/packages/routines/README.md @@ -72,3 +72,23 @@ Like `@corbits/chat`, this package owns its own migrations independent of the platform's own schema and of any other package's ledger — extracting this package never has to disentangle its history from theirs. + +## Scheduling + +This package exposes `fireScheduledRoutine` but deliberately ships no +scheduler of its own — firing a routine on its cadence is a host +concern. The hub in this repo runs one: an in-process poller that +wakes every 30 seconds, reads every enabled timer-triggered routine +directly (bypassing `RoutineStore`'s tenant scoping, since a scheduler +needs to enumerate across tenants), and fires whichever routine's +cron expression matches the current minute. + +That poller is single-process and at-least-once, not a distributed +cron engine. It is correct for exactly one hub replica. Running two or +more hub replicas each with their own poller will double- (or +n-times-) fire a routine's scheduled runs, because nothing coordinates +which replica owns a given tick — a multi-replica deployment needs +leader election or a dedicated scheduling worker before this scales +past one hub process. "Run now" is unaffected by this limit; it always +launches through the same single launch path regardless of replica +count. From d557008c3524fe36a33603a41cae87bc5b6f6003 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:33:42 -0700 Subject: [PATCH 11/28] Add tests for routine scheduling reliability, cron validation, and delete 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. --- apps/hub/test/cron-due.test.ts | 26 ++++ packages/routines/test/migrations.test.ts | 9 +- packages/routines/test/routes.test.ts | 32 +++++ packages/routines/test/store.test.ts | 152 ++++++++++++++++++++++ packages/routines/test/trigger.test.ts | 45 +++++++ 5 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 packages/routines/test/store.test.ts diff --git a/apps/hub/test/cron-due.test.ts b/apps/hub/test/cron-due.test.ts index 5da6a1994..2b8e9368a 100644 --- a/apps/hub/test/cron-due.test.ts +++ b/apps/hub/test/cron-due.test.ts @@ -5,6 +5,7 @@ // raw-cron escape hatch's comma/range/step grammar. import { describe, expect, test } from "bun:test"; +import { isValidCronExpression } from "@corbits/routines"; import { cronMatchesMinute, minuteKey } from "../src/cron-due.ts"; describe("cronMatchesMinute", () => { @@ -81,6 +82,31 @@ describe("cronMatchesMinute", () => { }); }); +describe("isValidCronExpression (matcher and validator share one parser)", () => { + test("rejects out-of-range fields that would otherwise never match", () => { + for (const expression of [ + "99 99 99 99 99", + "0 0 32 * *", + "0 0 * 13 *", + "60 * * * *", + "* * * * 7", + "10-5 * * * *", + ]) { + expect(isValidCronExpression(expression)).toBe(false); + } + }); + + test("accepts the range-then-step idiom the matcher already understands", () => { + expect(isValidCronExpression("5-10/2 * * * *")).toBe(true); + expect( + cronMatchesMinute("5-10/2 * * * *", new Date("2026-01-01T00:07:00Z")), + ).toBe(true); + expect( + cronMatchesMinute("5-10/2 * * * *", new Date("2026-01-01T00:08:00Z")), + ).toBe(false); + }); +}); + describe("minuteKey", () => { test("is stable within a minute and distinct across minutes", () => { const a = minuteKey(new Date("2026-01-01T00:00:00.000Z")); diff --git a/packages/routines/test/migrations.test.ts b/packages/routines/test/migrations.test.ts index 70f049949..e4be8667c 100644 --- a/packages/routines/test/migrations.test.ts +++ b/packages/routines/test/migrations.test.ts @@ -56,13 +56,20 @@ describeIfDb("applyRoutineMigrations", () => { test("applies both tables and is idempotent on a second run", async () => { const first = await applyRoutineMigrations(scratchUrl); - expect(first.applied).toEqual(["0001_routine", "0002_routine_run"]); + expect(first.applied).toEqual([ + "0001_routine", + "0002_routine_run", + "0003_routine_next_fire_at", + "0004_routine_soft_delete", + ]); const second = await applyRoutineMigrations(scratchUrl); expect(second.applied).toEqual([]); expect(second.alreadyApplied.sort()).toEqual([ "0001_routine", "0002_routine_run", + "0003_routine_next_fire_at", + "0004_routine_soft_delete", ]); const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); diff --git a/packages/routines/test/routes.test.ts b/packages/routines/test/routes.test.ts index c3c8206a1..31b15bd73 100644 --- a/packages/routines/test/routes.test.ts +++ b/packages/routines/test/routes.test.ts @@ -194,6 +194,38 @@ describe("createRoutineRoutes", () => { expect(response.status).toBe(404); }); + test("run history survives deleting the routine", async () => { + const deps = buildDeps(); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { body: created } = await createRoutine(app, VALID_BODY); + const routineId = created["id"] as string; + + await deps.store.recordRoutineRun({ + tenantId: TENANT.id, + routineId, + runId: "run_before_delete", + triggeredBy: "manual", + }); + + const deleteResponse = await app.request(`/routines/${routineId}`, { + method: "DELETE", + }); + expect(deleteResponse.status).toBe(204); + + const runsResponse = await app.request(`/routines/${routineId}/runs`); + expect(runsResponse.status).toBe(200); + const body = (await runsResponse.json()) as { items: { runId: string }[] }; + expect(body.items.map((item) => item.runId)).toContain("run_before_delete"); + + // A deleted routine is otherwise gone: it neither lists nor + // resolves by id, and a second delete still 404s. + const getResponse = await app.request(`/routines/${routineId}`); + expect(getResponse.status).toBe(404); + const listResponse = await app.request("/routines"); + const listBody = (await listResponse.json()) as { items: unknown[] }; + expect(listBody.items).toHaveLength(0); + }); + test("run summaries enrich when a resolver is wired, and are omitted without one", async () => { const deps = buildDeps({ runSummaryResolver: { diff --git a/packages/routines/test/store.test.ts b/packages/routines/test/store.test.ts new file mode 100644 index 000000000..bc2cd29e3 --- /dev/null +++ b/packages/routines/test/store.test.ts @@ -0,0 +1,152 @@ +// Proof of the two guarantees `createInMemoryRoutineStore` shares with +// its drizzle counterpart: a schedule due while nothing was polling +// stays due (survives a restart, "catch-up" not "skip"), and a fire's +// claim is exactly-once even when two schedulers race the same due +// routine. +import { describe, expect, test } from "bun:test"; +import { createInMemoryRoutineStore } from "../src/store"; + +const TENANT_ID = "tnt_1"; + +function assertDate(value: Date | null): Date { + if (value === null) throw new Error("expected a non-null Date"); + return value; +} + +describe("listDueRoutines / claimRoutineFire", () => { + test("a routine's nextFireAt is set on creation", async () => { + const store = createInMemoryRoutineStore(); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Every 10 minutes", + definitionId: "def_1", + trigger: { kind: "interval", unit: "minutes", every: 10 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + expect(routine.nextFireAt).not.toBeNull(); + }); + + test("a manual routine never becomes due", async () => { + const store = createInMemoryRoutineStore(); + await store.createRoutine({ + tenantId: TENANT_ID, + name: "Manual only", + definitionId: "def_1", + trigger: null, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + const due = await store.listDueRoutines(new Date("2099-01-01T00:00:00Z")); + expect(due).toHaveLength(0); + }); + + test("a fire due while nothing polled stays due — no skip, only catch-up", async () => { + const store = createInMemoryRoutineStore(); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Hourly", + definitionId: "def_1", + trigger: { kind: "interval", unit: "hours", every: 1 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + const scheduledFireAt = assertDate(routine.nextFireAt); + + // Simulate the hub being down through the scheduled fire and well + // past it — a naive "does this exact minute match" scheduler would + // never fire this routine again once that minute has passed. + const restartedAt = new Date(scheduledFireAt.getTime() + 4 * 3_600_000); + const due = await store.listDueRoutines(restartedAt); + expect(due.map((row) => row.id)).toContain(routine.id); + }); + + test("claiming a due fire advances nextFireAt to the following occurrence", async () => { + const store = createInMemoryRoutineStore(); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Hourly", + definitionId: "def_1", + trigger: { kind: "interval", unit: "hours", every: 1 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + + // Claiming well after the scheduled fire (catching up a missed one) + // still advances from the claim moment, not from the missed slot. + const fireAt = new Date( + assertDate(routine.nextFireAt).getTime() + 4 * 3_600_000, + ); + const claimed = await store.claimRoutineFire(routine.id, fireAt); + expect(claimed?.lastFireAt?.toISOString()).toBe(fireAt.toISOString()); + expect(claimed?.nextFireAt?.toISOString()).toBe( + new Date(fireAt.getTime() + 3_600_000).toISOString(), + ); + }); + + test("a second concurrent claim of the same fire loses", async () => { + const store = createInMemoryRoutineStore(); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Hourly", + definitionId: "def_1", + trigger: { kind: "interval", unit: "hours", every: 1 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + + const fireAt = assertDate(routine.nextFireAt); + const [first, second] = await Promise.all([ + store.claimRoutineFire(routine.id, fireAt), + store.claimRoutineFire(routine.id, fireAt), + ]); + const winners = [first, second].filter((row) => row !== undefined); + expect(winners).toHaveLength(1); + }); + + test("a disabled routine is never claimable even if nextFireAt is due", async () => { + const store = createInMemoryRoutineStore(); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Paused", + definitionId: "def_1", + trigger: { kind: "interval", unit: "minutes", every: 5 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + await store.updateRoutine(TENANT_ID, routine.id, { enabled: false }); + + const due = await store.listDueRoutines(new Date("2099-01-01T00:00:00Z")); + expect(due).toHaveLength(0); + const claimed = await store.claimRoutineFire( + routine.id, + new Date("2099-01-01T00:00:00Z"), + ); + expect(claimed).toBeUndefined(); + }); + + test("re-enabling a routine recomputes nextFireAt from now, not from the stale value", async () => { + const store = createInMemoryRoutineStore(); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Toggle", + definitionId: "def_1", + trigger: { kind: "interval", unit: "minutes", every: 5 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + await store.updateRoutine(TENANT_ID, routine.id, { enabled: false }); + const reEnabled = await store.updateRoutine(TENANT_ID, routine.id, { + enabled: true, + }); + expect(reEnabled.nextFireAt).not.toBeNull(); + expect(reEnabled.nextFireAt?.getTime()).toBeGreaterThan(Date.now()); + }); +}); diff --git a/packages/routines/test/trigger.test.ts b/packages/routines/test/trigger.test.ts index 4357bb2b8..32e0e1d31 100644 --- a/packages/routines/test/trigger.test.ts +++ b/packages/routines/test/trigger.test.ts @@ -3,6 +3,7 @@ import { type } from "arktype"; import { RoutineTrigger, + computeNextFireAt, cronExpressionForTrigger, isValidCronExpression, } from "../src/trigger"; @@ -20,6 +21,26 @@ describe("isValidCronExpression", () => { expect(isValidCronExpression("not a cron string at all")).toBe(false); expect(isValidCronExpression("")).toBe(false); }); + + test("rejects every field out of range, even though the format is fine", () => { + expect(isValidCronExpression("99 99 99 99 99")).toBe(false); + expect(isValidCronExpression("0 0 32 * *")).toBe(false); + expect(isValidCronExpression("0 0 * 13 *")).toBe(false); + expect(isValidCronExpression("60 * * * *")).toBe(false); + expect(isValidCronExpression("* * * * 7")).toBe(false); + }); + + test("rejects a reversed range, which would otherwise never match", () => { + expect(isValidCronExpression("10-5 * * * *")).toBe(false); + }); + + test("accepts the standard range-then-step idiom", () => { + expect(isValidCronExpression("5-10/2 * * * *")).toBe(true); + }); + + test("rejects a reversed range even when it also carries a step", () => { + expect(isValidCronExpression("10-5/2 * * * *")).toBe(false); + }); }); describe("RoutineTrigger", () => { @@ -127,3 +148,27 @@ describe("cronExpressionForTrigger", () => { ).toBe("1 2 3 4 5"); }); }); + +describe("computeNextFireAt", () => { + test("is null for a manual routine", () => { + expect(computeNextFireAt(null, new Date())).toBeNull(); + }); + + test("finds the next matching minute for an interval preset", () => { + const after = new Date("2026-01-01T00:07:00Z"); + const next = computeNextFireAt( + { kind: "interval", unit: "minutes", every: 10 }, + after, + ); + expect(next?.toISOString()).toBe("2026-01-01T00:10:00.000Z"); + }); + + test("finds the next matching minute for a raw cron expression", () => { + const after = new Date("2026-01-01T00:00:00Z"); + const next = computeNextFireAt( + { kind: "cron", expression: "0-5 * * * *" }, + after, + ); + expect(next?.toISOString()).toBe("2026-01-01T00:01:00.000Z"); + }); +}); From 3abf1f6b2efaa682bcd422408b78b80fc96c0713 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:33:53 -0700 Subject: [PATCH 12/28] Persist due-fire state, claim fires atomically, and soft-delete routines 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. --- apps/hub/src/cron-due.ts | 83 +--------- apps/hub/src/index.ts | 9 +- apps/hub/src/routine-scheduler.ts | 91 +++-------- docs/GLOSSARY.md | 2 +- packages/routines/src/cron.ts | 186 ++++++++++++++++++++++ packages/routines/src/index.ts | 4 + packages/routines/src/migrations.ts | 14 ++ packages/routines/src/routes.ts | 8 +- packages/routines/src/schema.ts | 15 ++ packages/routines/src/store.ts | 232 ++++++++++++++++++++++++++-- packages/routines/src/trigger.ts | 33 ++-- 11 files changed, 500 insertions(+), 177 deletions(-) create mode 100644 packages/routines/src/cron.ts diff --git a/apps/hub/src/cron-due.ts b/apps/hub/src/cron-due.ts index 01fc38704..89d6d41b9 100644 --- a/apps/hub/src/cron-due.ts +++ b/apps/hub/src/cron-due.ts @@ -1,76 +1,7 @@ -// A minute-granularity matcher for the 5-field cron grammar -// `@corbits/routines`' `cronExpressionForTrigger` renders every trigger -// preset into (minute hour day-of-month month day-of-week), plus whatever -// a routine's raw-cron escape hatch supplies (already validated at save -// time by `isValidCronExpression`, the same grammar this matches). Kept -// as a pure function so the scheduler's "is it time yet" decision is -// exercised without a clock, a database, or a launch. -type CronClause = { - readonly base: "*" | number; - readonly step?: number; - readonly rangeEnd?: number; -}; - -function parseClause(raw: string): CronClause { - const match = /^(\*|[0-9]+)(?:\/([0-9]+))?(?:-([0-9]+))?$/.exec(raw); - if (match === null) { - throw new Error(`unrecognized cron field clause "${raw}"`); - } - const [, base, step, rangeEnd] = match; - return { - base: base === "*" ? "*" : Number(base), - ...(step !== undefined ? { step: Number(step) } : {}), - ...(rangeEnd !== undefined ? { rangeEnd: Number(rangeEnd) } : {}), - }; -} - -function clauseMatches(clause: CronClause, value: number): boolean { - if (clause.base === "*") { - return clause.step === undefined ? true : value % clause.step === 0; - } - if (clause.rangeEnd === undefined && clause.step === undefined) { - return value === clause.base; - } - const upper = clause.rangeEnd ?? clause.base; - if (value < clause.base || value > upper) return false; - if (clause.step === undefined) return true; - return (value - clause.base) % clause.step === 0; -} - -function fieldMatches(field: string, value: number): boolean { - return field - .split(",") - .some((clause) => clauseMatches(parseClause(clause), value)); -} - -/** - * True when `expression`'s minute/hour/day-of-month/month/day-of-week - * fields all match `at` (read in UTC, matching how the trigger presets' - * hour/minute fields are stored — no timezone concept exists yet on a - * `RoutineTrigger`). - */ -export function cronMatchesMinute(expression: string, at: Date): boolean { - const fields = expression.trim().split(/\s+/); - const [minute, hour, dayOfMonth, month, dayOfWeek] = fields; - if ( - minute === undefined || - hour === undefined || - dayOfMonth === undefined || - month === undefined || - dayOfWeek === undefined - ) { - throw new Error(`"${expression}" is not a 5-field cron expression`); - } - return ( - fieldMatches(minute, at.getUTCMinutes()) && - fieldMatches(hour, at.getUTCHours()) && - fieldMatches(dayOfMonth, at.getUTCDate()) && - fieldMatches(month, at.getUTCMonth() + 1) && - fieldMatches(dayOfWeek, at.getUTCDay()) - ); -} - -/** The UTC minute `at` falls in, as a stable, comparable integer key. */ -export function minuteKey(at: Date): number { - return Math.floor(at.getTime() / 60_000); -} +// A re-export of `@corbits/routines`' own cron matcher — the same +// grammar `isValidCronExpression` validates at save time and +// `nextCronFireAt` uses to persist a routine's next fire. Kept as its +// own module (rather than importing `@corbits/routines` at every call +// site in this app) so this hub has one seam onto the shared parser; +// it is never a second implementation of it. +export { cronMatchesMinute, minuteKey } from "@corbits/routines"; diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 6c1447cc6..59d4ef571 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -413,11 +413,12 @@ export async function createHub(config: HubConfig) { // over `@corbits/routines`' own `fireScheduledRoutine` — this hub has no // general job-runner today, so this loop is scoped to exactly one job // (fire due routines) rather than standing up a bespoke cron daemon as a - // hidden dependency. A real multi-instance deployment needs a leader - // election or a single dedicated worker process before this scales past - // one hub replica; tracked as a known limitation, not solved here. + // hidden dependency. Every hub replica can safely run this poller: each + // fire is claimed with a conditional update on the routine's persisted + // `nextFireAt` before anything launches, so two replicas racing the same + // fire never both win, and a fire that falls due while every replica is + // down is caught up (not lost) the next time any of them polls. const routineScheduler = createRoutineScheduler({ - db, store: routineStore, launcher: routineLauncher, }); diff --git a/apps/hub/src/routine-scheduler.ts b/apps/hub/src/routine-scheduler.ts index b408c3b3e..f1ea3d9c2 100644 --- a/apps/hub/src/routine-scheduler.ts +++ b/apps/hub/src/routine-scheduler.ts @@ -3,32 +3,23 @@ // `fireScheduledRoutine` for exactly this, but ships no scheduler: see // that package's routes.ts doc comment). This mirrors // `@corbits/agent-lifecycle`'s own `setInterval` sweep (the only other -// periodic loop in this repo) rather than pulling in a new dependency: a -// single-process, at-least-once poller, not a distributed cron engine. +// periodic loop in this repo) rather than pulling in a new dependency. // -// `routine`/`routineRun` are `@corbits/routines`' own exported schema -// tables (its public surface, alongside `RoutineStore`) — read directly -// here because `RoutineStore` is deliberately tenant-scoped -// (`listRoutines(tenantId)`; see store.ts) and has no cross-tenant -// enumeration, which a scheduler needs and a per-request route never -// does. This is the same "read the extension's exported schema -// directly" pattern chat's own routes use for `channel_launch`. -import { desc, eq } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { - cronExpressionForTrigger, - fireScheduledRoutine, - routine, - routineRun, - type RoutineLauncher, - type RoutineRow, - type RoutineStore, -} from "@corbits/routines"; +// Exactly-once, not "at-least-once": `RoutineStore.claimRoutineFire` +// is a conditional update (`nextFireAt <= now` in its WHERE clause, +// advanced to the trigger's next occurrence in its SET) — a second +// hub replica racing the same fire loses, because the winner already +// moved `nextFireAt` into the future before either replica launches +// anything. And missed fires survive a restart: `nextFireAt` is +// persisted, so "due" means `nextFireAt <= now`, not "does the current +// wall-clock minute match" — a fire that was due while the hub was +// down is still due (and gets caught up) the next time this loop +// polls, exactly like `@corbits/schedules` before it. +import type { RoutineLauncher, RoutineStore } from "@corbits/routines"; +import { fireScheduledRoutine } from "@corbits/routines"; import { getLogger } from "@intx/log"; -import { cronMatchesMinute, minuteKey } from "./cron-due"; export type RoutineSchedulerDeps = { - db: DB["db"]; store: RoutineStore; launcher: RoutineLauncher; /** Injectable for deterministic tests; defaults to `Date.now`-backed wall time. */ @@ -38,44 +29,6 @@ export type RoutineSchedulerDeps = { const POLL_INTERVAL_MS = 30_000; const log = getLogger(["hub", "routine-scheduler"]); -/** - * Every enabled, timer-triggered routine, each paired with the minute key - * of its own most recent scheduled fire (`undefined` if it has never - * fired on a schedule before) — the guard that keeps a routine whose - * cadence matches for the whole span of a tick from firing twice. - */ -async function loadSchedulableRoutines( - db: DB["db"], -): Promise< - readonly { routine: RoutineRow; lastFiredMinute: number | undefined }[] -> { - const rows = (await db - .select() - .from(routine) - .where(eq(routine.enabled, true))) as RoutineRow[]; - const timerRows = rows.filter((row) => row.trigger !== null); - - const lastFiredByRoutine = new Map(); - const scheduledRuns = await db - .select({ - routineId: routineRun.routineId, - createdAt: routineRun.createdAt, - }) - .from(routineRun) - .where(eq(routineRun.triggeredBy, "schedule")) - .orderBy(desc(routineRun.createdAt)); - for (const run of scheduledRuns) { - if (!lastFiredByRoutine.has(run.routineId)) { - lastFiredByRoutine.set(run.routineId, minuteKey(run.createdAt)); - } - } - - return timerRows.map((row) => ({ - routine: row, - lastFiredMinute: lastFiredByRoutine.get(row.id), - })); -} - export function createRoutineScheduler(deps: RoutineSchedulerDeps) { const now = deps.now ?? (() => new Date()); let tickInFlight = false; @@ -85,20 +38,20 @@ export function createRoutineScheduler(deps: RoutineSchedulerDeps) { tickInFlight = true; try { const at = now(); - const currentMinute = minuteKey(at); - const candidates = await loadSchedulableRoutines(deps.db); - for (const { routine: row, lastFiredMinute } of candidates) { - if (row.trigger === null) continue; - if (lastFiredMinute === currentMinute) continue; - const expression = cronExpressionForTrigger(row.trigger); - if (!cronMatchesMinute(expression, at)) continue; + const dueRoutines = await deps.store.listDueRoutines(at); + for (const candidate of dueRoutines) { + const claimed = await deps.store.claimRoutineFire(candidate.id, at); + // `undefined` means another replica already claimed this exact + // fire between `listDueRoutines` and this claim attempt — not + // an error, just the atomic claim doing its job. + if (claimed === undefined) continue; try { await fireScheduledRoutine( { store: deps.store, launcher: deps.launcher }, - { tenantId: row.tenantId, routine: row }, + { tenantId: claimed.tenantId, routine: claimed }, ); } catch (err) { - log.error`scheduled fire of routine ${row.id} failed: ${ + log.error`scheduled fire of routine ${claimed.id} failed: ${ err instanceof Error ? err.message : String(err) }`; } diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 7208917cf..dcb8eb9c3 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -11,7 +11,7 @@ code and API paths keep the platform's own names. | **User** | principal | An identity that can act in a bench — human or agent | | **Definition** | workflow definition | A deployable unit of agent behavior, authored as code | | **Run** | workflow run | A definition executing in a bench; interactive runs carry conversations | -| **Routine** | — | The named parent entity over runs of one definition — a trigger (or none), a delivery channel, and its run history; see [`@corbits/routines`](../packages/routines/README.md) | +| **Routine** | — | The named parent entity over runs of one definition — a trigger (or none), a delivery channel, and its run history; see [`@corbits/routines`](../packages/routines/README.md) | | **Approval** | approval | A human decision gating an external side effect | | **Grant** | grant | Permission for a principal to act on a resource | | **Hub** | hub | The API and coordination service a bench lives on | diff --git a/packages/routines/src/cron.ts b/packages/routines/src/cron.ts new file mode 100644 index 000000000..3c219d260 --- /dev/null +++ b/packages/routines/src/cron.ts @@ -0,0 +1,186 @@ +// The single 5-field cron grammar `@corbits/routines` speaks: one parser +// shared by validation (does this expression make sense at save time?) +// and execution (does this expression match this minute?). Before this +// module existed, `trigger.ts` and the hub's scheduler each hand-rolled +// their own field parser — format-only, no range checking, incompatible +// clause orderings — so an expression could validate as saveable and +// then never fire, or fire on one parser's reading and not the other's. +// One parser closes that gap: whatever validates here is exactly what +// matches here. +export type CronField = + "minute" | "hour" | "dayOfMonth" | "month" | "dayOfWeek"; + +/** Field order in a 5-field cron expression, paired with its valid range. */ +export const CRON_FIELD_RANGES: Readonly< + Record +> = { + minute: [0, 59], + hour: [0, 23], + dayOfMonth: [1, 31], + month: [1, 12], + dayOfWeek: [0, 6], +}; + +const CRON_FIELD_ORDER: readonly CronField[] = [ + "minute", + "hour", + "dayOfMonth", + "month", + "dayOfWeek", +]; + +type CronClause = { + readonly base: "*" | number; + readonly rangeEnd?: number; + readonly step?: number; +}; + +// Standard cron clause order is base, then an optional range, then an +// optional step: `5`, `5-10`, `*/2`, `5-10/2`. Only this order is +// accepted — the reversed `5/2-10` idiom neither cron nor either of +// this repo's previous hand-rolled parsers meaningfully supported. +const CLAUSE_PATTERN = /^(\*|[0-9]+)(?:-([0-9]+))?(?:\/([0-9]+))?$/; + +function parseCronClause(raw: string): CronClause | undefined { + const match = CLAUSE_PATTERN.exec(raw); + if (match === null) return undefined; + const [, base, rangeEnd, step] = match; + return { + base: base === "*" ? "*" : Number(base), + ...(rangeEnd !== undefined ? { rangeEnd: Number(rangeEnd) } : {}), + ...(step !== undefined ? { step: Number(step) } : {}), + }; +} + +/** + * True when `clause` is meaningful for a field whose valid values span + * `[min, max]`: every literal value in range, and — the case the old + * format-only validators missed — a reversed range (`10-5`) rejected + * rather than accepted as an expression that is syntactically fine and + * unconditionally never true. + */ +function clauseInRange( + clause: CronClause, + [min, max]: readonly [number, number], +): boolean { + if (clause.step !== undefined && clause.step <= 0) return false; + if (clause.base === "*") return true; + if (clause.base < min || clause.base > max) return false; + if (clause.rangeEnd === undefined) return true; + if (clause.rangeEnd < min || clause.rangeEnd > max) return false; + return clause.rangeEnd >= clause.base; +} + +function clauseMatches(clause: CronClause, value: number): boolean { + if (clause.base === "*") { + return clause.step === undefined ? true : value % clause.step === 0; + } + if (clause.rangeEnd === undefined && clause.step === undefined) { + return value === clause.base; + } + const upper = clause.rangeEnd ?? clause.base; + if (value < clause.base || value > upper) return false; + if (clause.step === undefined) return true; + return (value - clause.base) % clause.step === 0; +} + +function everyClause( + field: string, + test: (clause: CronClause) => boolean, +): boolean { + const clauses = field.split(",").map(parseCronClause); + if (clauses.length === 0) return false; + return clauses.every((clause) => clause !== undefined && test(clause)); +} + +function someClause( + field: string, + test: (clause: CronClause) => boolean, +): boolean { + return field.split(",").some((raw) => { + const clause = parseCronClause(raw); + return clause !== undefined && test(clause); + }); +} + +/** + * Loud, eager validation for a raw 5-field cron expression + * (minute hour day-of-month month day-of-week): every field's syntax + * AND every field's values must be sane for that position — a minute + * of 60, a month of 13, or a reversed range all fail here, never + * silently accepted only to fail at fire-time. + */ +export function isValidCronExpression(expression: string): boolean { + const fields = expression.trim().split(/\s+/); + if (fields.length !== 5) return false; + return fields.every((field, index) => { + const cronField = CRON_FIELD_ORDER[index]; + if (cronField === undefined) return false; + return everyClause(field, (clause) => + clauseInRange(clause, CRON_FIELD_RANGES[cronField]), + ); + }); +} + +function fieldMatches(field: string, value: number): boolean { + return someClause(field, (clause) => clauseMatches(clause, value)); +} + +/** + * True when `expression`'s minute/hour/day-of-month/month/day-of-week + * fields all match `at` (read in UTC, matching how the trigger presets' + * hour/minute fields are stored — no timezone concept exists yet on a + * `RoutineTrigger`). + */ +export function cronMatchesMinute(expression: string, at: Date): boolean { + const fields = expression.trim().split(/\s+/); + const [minute, hour, dayOfMonth, month, dayOfWeek] = fields; + if ( + minute === undefined || + hour === undefined || + dayOfMonth === undefined || + month === undefined || + dayOfWeek === undefined + ) { + throw new Error(`"${expression}" is not a 5-field cron expression`); + } + return ( + fieldMatches(minute, at.getUTCMinutes()) && + fieldMatches(hour, at.getUTCHours()) && + fieldMatches(dayOfMonth, at.getUTCDate()) && + fieldMatches(month, at.getUTCMonth() + 1) && + fieldMatches(dayOfWeek, at.getUTCDay()) + ); +} + +/** The UTC minute `at` falls in, as a stable, comparable integer key. */ +export function minuteKey(at: Date): number { + return Math.floor(at.getTime() / 60_000); +} + +/** + * Bounds how far ahead `nextCronFireAfter` will search before giving up + * — generous enough to reach a once-a-year fire (e.g. a specific + * month/day) but not an unbounded loop over a typo'd expression that + * (thanks to `isValidCronExpression`) can no longer be unconditionally + * false forever. + */ +const MAX_LOOKAHEAD_MINUTES = 5 * 366 * 24 * 60; + +/** + * The next minute at or after `after` (exclusive) that `expression` + * matches — the closed-form "when does this actually fire next" + * calculation, used both to persist a routine's `nextFireAt` and to + * render a UI's next-run estimate against the exact semantics that + * fire it. + */ +export function nextCronFireAfter(expression: string, after: Date): Date { + const start = minuteKey(after) + 1; + for (let minute = start; minute - start <= MAX_LOOKAHEAD_MINUTES; minute++) { + const candidate = new Date(minute * 60_000); + if (cronMatchesMinute(expression, candidate)) return candidate; + } + throw new Error( + `"${expression}" has no fire time within the lookahead window`, + ); +} diff --git a/packages/routines/src/index.ts b/packages/routines/src/index.ts index 2a39596a0..e6c0fd742 100644 --- a/packages/routines/src/index.ts +++ b/packages/routines/src/index.ts @@ -4,8 +4,12 @@ export { RoutineTrigger, isValidCronExpression, cronExpressionForTrigger, + computeNextFireAt, + cronMatchesMinute, + minuteKey, } from "./trigger"; export type { RoutineTriggerT } from "./trigger"; +export { nextCronFireAfter } from "./cron"; export { routine, routineRun } from "./schema"; diff --git a/packages/routines/src/migrations.ts b/packages/routines/src/migrations.ts index 242816f7c..a125c9509 100644 --- a/packages/routines/src/migrations.ts +++ b/packages/routines/src/migrations.ts @@ -45,6 +45,20 @@ export const routineMigrations: readonly RoutineMigration[] = [ ); `, }, + { + name: "0003_routine_next_fire_at", + sql: ` + ALTER TABLE "routine" ADD COLUMN IF NOT EXISTS "next_fire_at" timestamptz; + ALTER TABLE "routine" ADD COLUMN IF NOT EXISTS "last_fire_at" timestamptz; + CREATE INDEX IF NOT EXISTS "routine_next_fire_at_idx" ON "routine" ("next_fire_at") WHERE "enabled" AND "next_fire_at" IS NOT NULL; + `, + }, + { + name: "0004_routine_soft_delete", + sql: ` + ALTER TABLE "routine" ADD COLUMN IF NOT EXISTS "deleted_at" timestamptz; + `, + }, ]; // Named distinctly from the platform's setup ledger and from any diff --git a/packages/routines/src/routes.ts b/packages/routines/src/routes.ts index 8e5d1ef00..1f9c908df 100644 --- a/packages/routines/src/routes.ts +++ b/packages/routines/src/routes.ts @@ -232,7 +232,13 @@ export function createRoutineRoutes( async (c) => { const tenant = c.get("tenant"); const routineId = c.req.param("id"); - const existing = await deps.store.getRoutine(tenant.id, routineId); + // Deliberately `getRoutineIncludingDeleted`, not `getRoutine`: a + // deleted routine's run history stays reachable (see store.ts), + // so only a *never-existed* id 404s here. + const existing = await deps.store.getRoutineIncludingDeleted( + tenant.id, + routineId, + ); if (existing === undefined) { return c.json(ErrorEnvelope("not_found", "routine not found"), 404); } diff --git a/packages/routines/src/schema.ts b/packages/routines/src/schema.ts index 55c09a23c..790906237 100644 --- a/packages/routines/src/schema.ts +++ b/packages/routines/src/schema.ts @@ -33,6 +33,21 @@ export const routine = pgTable("routine", { enabled: boolean("enabled").notNull().default(true), deliveryChannelId: text("delivery_channel_id"), createdBy: text("created_by").notNull(), + // The due-fire clock: the next minute this routine's trigger matches, + // recomputed on create, on every trigger/enabled change, and on each + // fire. A scheduler tests `nextFireAt <= now`, not "does this exact + // instant match" — a fire due while the process was down stays due + // (and gets caught up) instead of being silently skipped. `null` for + // a manual or disabled routine, which never auto-fires. + nextFireAt: timestamp("next_fire_at", { withTimezone: true }), + // The last time this routine actually fired on its own schedule — + // observability only, never read back into a fire decision. + lastFireAt: timestamp("last_fire_at", { withTimezone: true }), + // Soft-delete: a deleted routine's run history must stay reachable + // (`GET /routines/:id/runs`), so deleting never removes the row — + // it stops the routine appearing in lists or firing, and nothing + // else. + deletedAt: timestamp("deleted_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), diff --git a/packages/routines/src/store.ts b/packages/routines/src/store.ts index 06d782b82..c2492a235 100644 --- a/packages/routines/src/store.ts +++ b/packages/routines/src/store.ts @@ -3,12 +3,12 @@ // persistence from `routes.ts`. `RoutineStore` is the seam the route // layer depends on; `createDrizzleRoutineStore` is its one production // implementation, over the tables in `./schema.ts`. -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, isNull, lte } from "drizzle-orm"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { hexEncode } from "@intx/types"; import { routine, routineRun } from "./schema"; -import type { RoutineTriggerT } from "./trigger"; +import { computeNextFireAt, type RoutineTriggerT } from "./trigger"; export type RoutineDb< TSchema extends Record = Record, @@ -27,6 +27,9 @@ export interface RoutineRow { readonly enabled: boolean; readonly deliveryChannelId: string | null; readonly createdBy: string; + readonly nextFireAt: Date | null; + readonly lastFireAt: Date | null; + readonly deletedAt: Date | null; readonly createdAt: Date; readonly updatedAt: Date; } @@ -60,16 +63,30 @@ export interface RoutineRunRow { export interface RoutineStore { createRoutine(input: CreateRoutineInput): Promise; + /** `undefined` for an unknown OR a soft-deleted routine. */ getRoutine( tenantId: string, routineId: string, ): Promise; + /** + * Same lookup as `getRoutine`, but a soft-deleted routine is still + * returned — the one caller that needs this is run-history lookup + * (`GET /routines/:id/runs`), which must keep resolving a deleted + * routine's id so its history stays reachable, while every other + * caller (get/patch/run-now/list) treats "deleted" as "gone." + */ + getRoutineIncludingDeleted( + tenantId: string, + routineId: string, + ): Promise; + /** Excludes soft-deleted routines. */ listRoutines(tenantId: string): Promise; updateRoutine( tenantId: string, routineId: string, patch: UpdateRoutineInput, ): Promise; + /** Soft-delete: the row (and its run history) survive; see schema.ts. */ deleteRoutine(tenantId: string, routineId: string): Promise; /** * Records that `runId` was launched under `routineId` — called @@ -86,6 +103,29 @@ export interface RoutineStore { tenantId: string, routineId: string, ): Promise; + /** + * Every enabled, timer-triggered routine whose `nextFireAt` is at or + * before `now` — across every tenant, the one cross-tenant read a + * scheduler needs and no per-request route ever does. "At or before," + * not "equal to": a fire that was due while nothing was polling is + * still due, not skipped. + */ + listDueRoutines(now: Date): Promise; + /** + * Atomically claims `routineId`'s current due fire: advances + * `nextFireAt` to the trigger's following occurrence and stamps + * `lastFireAt`, but only if `nextFireAt` is still `<= now` at the + * moment of the write. A second caller racing the same fire loses — + * the first claim already moved `nextFireAt` into the future, so the + * second claim's conditional write matches no row and returns + * `undefined`. This is the seam that makes a scheduled fire + * exactly-once under concurrent pollers: the claim happens before + * anything launches, never after. + */ + claimRoutineFire( + routineId: string, + now: Date, + ): Promise; } // `@intx/hub-common`'s `generateId` is closed over the platform's own @@ -119,6 +159,9 @@ export function createDrizzleRoutineStore< enabled: true, deliveryChannelId: input.deliveryChannelId ?? null, createdBy: input.createdBy, + nextFireAt: computeNextFireAt(input.trigger, now), + lastFireAt: null, + deletedAt: null, createdAt: now, updatedAt: now, }) @@ -130,6 +173,21 @@ export function createDrizzleRoutineStore< }, async getRoutine(tenantId, routineId) { + const [row] = await db + .select() + .from(routine) + .where( + and( + eq(routine.tenantId, tenantId), + eq(routine.id, routineId), + isNull(routine.deletedAt), + ), + ) + .limit(1); + return row as RoutineRow | undefined; + }, + + async getRoutineIncludingDeleted(tenantId, routineId) { const [row] = await db .select() .from(routine) @@ -142,14 +200,49 @@ export function createDrizzleRoutineStore< const rows = await db .select() .from(routine) - .where(eq(routine.tenantId, tenantId)); + .where(and(eq(routine.tenantId, tenantId), isNull(routine.deletedAt))); return rows as RoutineRow[]; }, async updateRoutine(tenantId, routineId, patch) { + const [existing] = await db + .select() + .from(routine) + .where( + and( + eq(routine.tenantId, tenantId), + eq(routine.id, routineId), + isNull(routine.deletedAt), + ), + ) + .limit(1); + if (existing === undefined) { + throw new Error(`updateRoutine: no routine row for id ${routineId}`); + } + const now = new Date(); + const recomputeNextFire = + patch.trigger !== undefined || patch.enabled !== undefined; + const mergedTrigger = + patch.trigger !== undefined + ? patch.trigger + : (existing as RoutineRow).trigger; + const mergedEnabled = + patch.enabled !== undefined + ? patch.enabled + : (existing as RoutineRow).enabled; const [row] = await db .update(routine) - .set({ ...patch, updatedAt: new Date() }) + .set({ + ...patch, + ...(recomputeNextFire + ? { + nextFireAt: mergedEnabled + ? computeNextFireAt(mergedTrigger, now) + : null, + } + : {}), + updatedAt: now, + }) .where(and(eq(routine.tenantId, tenantId), eq(routine.id, routineId))) .returning(); if (row === undefined) { @@ -160,12 +253,60 @@ export function createDrizzleRoutineStore< async deleteRoutine(tenantId, routineId) { const deleted = await db - .delete(routine) - .where(and(eq(routine.tenantId, tenantId), eq(routine.id, routineId))) + .update(routine) + .set({ deletedAt: new Date(), nextFireAt: null }) + .where( + and( + eq(routine.tenantId, tenantId), + eq(routine.id, routineId), + isNull(routine.deletedAt), + ), + ) .returning(); return deleted.length > 0; }, + async listDueRoutines(now) { + const rows = await db + .select() + .from(routine) + .where( + and( + eq(routine.enabled, true), + isNull(routine.deletedAt), + lte(routine.nextFireAt, now), + ), + ); + return rows as RoutineRow[]; + }, + + async claimRoutineFire(routineId, now) { + const [current] = await db + .select() + .from(routine) + .where(eq(routine.id, routineId)) + .limit(1); + if (current === undefined || current.trigger === null) { + return undefined; + } + const nextFireAt = computeNextFireAt( + current.trigger as RoutineTriggerT, + now, + ); + const [claimed] = await db + .update(routine) + .set({ nextFireAt, lastFireAt: now }) + .where( + and( + eq(routine.id, routineId), + eq(routine.enabled, true), + lte(routine.nextFireAt, now), + ), + ) + .returning(); + return claimed as RoutineRow | undefined; + }, + async recordRoutineRun(input) { const [row] = await db.insert(routineRun).values(input).returning(); if (row === undefined) { @@ -213,6 +354,9 @@ export function createInMemoryRoutineStore(): RoutineStore { enabled: true, deliveryChannelId: input.deliveryChannelId ?? null, createdBy: input.createdBy, + nextFireAt: computeNextFireAt(input.trigger, now), + lastFireAt: null, + deletedAt: null, createdAt: now, updatedAt: now, }; @@ -221,35 +365,101 @@ export function createInMemoryRoutineStore(): RoutineStore { }, async getRoutine(tenantId, routineId) { + const row = routinesById.get(routineId); + if (row === undefined || row.tenantId !== tenantId) return undefined; + return row.deletedAt === null ? row : undefined; + }, + + async getRoutineIncludingDeleted(tenantId, routineId) { const row = routinesById.get(routineId); return row?.tenantId === tenantId ? row : undefined; }, async listRoutines(tenantId) { return [...routinesById.values()].filter( - (row) => row.tenantId === tenantId, + (row) => row.tenantId === tenantId && row.deletedAt === null, ); }, async updateRoutine(tenantId, routineId, patch) { const existing = routinesById.get(routineId); - if (existing === undefined || existing.tenantId !== tenantId) { + if ( + existing === undefined || + existing.tenantId !== tenantId || + existing.deletedAt !== null + ) { throw new Error(`updateRoutine: no routine row for id ${routineId}`); } - const row: RoutineRow = { ...existing, ...patch, updatedAt: new Date() }; + const now = new Date(); + const recomputeNextFire = + patch.trigger !== undefined || patch.enabled !== undefined; + const mergedTrigger = + patch.trigger !== undefined ? patch.trigger : existing.trigger; + const mergedEnabled = + patch.enabled !== undefined ? patch.enabled : existing.enabled; + const row: RoutineRow = { + ...existing, + ...patch, + ...(recomputeNextFire + ? { + nextFireAt: mergedEnabled + ? computeNextFireAt(mergedTrigger, now) + : null, + } + : {}), + updatedAt: now, + }; routinesById.set(routineId, row); return row; }, async deleteRoutine(tenantId, routineId) { const existing = routinesById.get(routineId); - if (existing === undefined || existing.tenantId !== tenantId) { + if ( + existing === undefined || + existing.tenantId !== tenantId || + existing.deletedAt !== null + ) { return false; } - routinesById.delete(routineId); + routinesById.set(routineId, { + ...existing, + deletedAt: new Date(), + nextFireAt: null, + }); return true; }, + async listDueRoutines(now) { + return [...routinesById.values()].filter( + (row) => + row.enabled && + row.deletedAt === null && + row.nextFireAt !== null && + row.nextFireAt.getTime() <= now.getTime(), + ); + }, + + async claimRoutineFire(routineId, now) { + const current = routinesById.get(routineId); + if ( + current === undefined || + current.trigger === null || + !current.enabled || + current.nextFireAt === null || + current.nextFireAt.getTime() > now.getTime() + ) { + return undefined; + } + const claimed: RoutineRow = { + ...current, + nextFireAt: computeNextFireAt(current.trigger, now), + lastFireAt: now, + }; + routinesById.set(routineId, claimed); + return claimed; + }, + async recordRoutineRun(input) { const row: RoutineRunRow = { ...input, createdAt: new Date() }; runs.push(row); diff --git a/packages/routines/src/trigger.ts b/packages/routines/src/trigger.ts index f8570bbde..8add0e720 100644 --- a/packages/routines/src/trigger.ts +++ b/packages/routines/src/trigger.ts @@ -5,22 +5,9 @@ // run-now-only automation, not an error state. import { type } from "arktype"; -const CRON_FIELD = - /^(\*|[0-9]+)(\/[0-9]+)?(-[0-9]+)?(,(\*|[0-9]+)(\/[0-9]+)?(-[0-9]+)?)*$/; +import { isValidCronExpression, nextCronFireAfter } from "./cron"; -/** - * Loud, eager validation for a raw 5-field cron expression - * (minute hour day-of-month month day-of-week). Rejects anything that - * isn't exactly five whitespace-separated fields built from the - * standard `*`, `,`, `-`, `/` cron grammar — never silently accepts a - * malformed schedule that would then fail at fire-time instead of at - * save-time. - */ -export function isValidCronExpression(expression: string): boolean { - const fields = expression.trim().split(/\s+/); - if (fields.length !== 5) return false; - return fields.every((field) => CRON_FIELD.test(field)); -} +export { isValidCronExpression, cronMatchesMinute, minuteKey } from "./cron"; const IntervalTrigger = type({ kind: "'interval'", @@ -87,3 +74,19 @@ export function cronExpressionForTrigger( return trigger.expression; } } + +/** + * When a routine with this trigger next fires, strictly after `after` — + * `null` for a manual routine, which never auto-fires. Persisted as a + * routine's `nextFireAt` on every create, trigger/enabled change, and + * fire, so a schedule due while the hub is down is still due (not + * skipped) on restart: the scheduler's readiness test is `nextFireAt <= + * now`, not "does this exact instant match." + */ +export function computeNextFireAt( + trigger: RoutineTriggerT, + after: Date, +): Date | null { + if (trigger === null) return null; + return nextCronFireAfter(cronExpressionForTrigger(trigger), after); +} From 689e275e69a36bee3bc9232703a9a3246795e6f3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:34:00 -0700 Subject: [PATCH 13/28] Add tests for wall-clock-aligned interval next-run estimates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/test/routine-trigger.test.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/web/test/routine-trigger.test.ts b/apps/web/test/routine-trigger.test.ts index 72ccc734b..ddc226e9b 100644 --- a/apps/web/test/routine-trigger.test.ts +++ b/apps/web/test/routine-trigger.test.ts @@ -45,7 +45,7 @@ describe("approximateNextRun", () => { ).toBeNull(); }); - test("interval adds its step to now", () => { + 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 }, @@ -54,6 +54,29 @@ describe("approximateNextRun", () => { expect(next?.toISOString()).toBe("2026-01-01T00:15:00.000Z"); }); + test("interval is wall-clock aligned, not an offset from the viewing moment", () => { + // The routine fires on `*/10 * * * *` — minutes 0, 10, 20, ... Viewed + // at :07, the real next fire is :10 (three minutes away), never + // "ten minutes from now." + 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", () => { + // `0 */2 * * *` fires at hour 0, 2, 4, ... Viewed at 01:00, the next + // fire is 02:00, not 03:00 ("2 hours from now"). + 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); From a245cff7467b02530f8b74ef4ede96b9473f3701 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:34:06 -0700 Subject: [PATCH 14/28] Align the Routines page's next-run estimate with the scheduler's cron 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. --- apps/web/src/routine-trigger.ts | 99 +++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 24 deletions(-) diff --git a/apps/web/src/routine-trigger.ts b/apps/web/src/routine-trigger.ts index 6153c3e4b..b8183a4ac 100644 --- a/apps/web/src/routine-trigger.ts +++ b/apps/web/src/routine-trigger.ts @@ -6,6 +6,15 @@ // occurrence" here (the scheduler itself resolves that minute by minute; // see apps/hub/src/cron-due.ts) — it renders a plain description instead // of a guessed timestamp, never a wrong one dressed up as exact. +// +// The estimate for interval/daily/weekly presets is derived from the +// exact cron expression the scheduler fires against (mirroring +// `cronExpressionForTrigger` and a minute-by-minute search, the same +// technique as `nextCronFireAfter` in packages/routines/src/cron.ts), +// not from naive arithmetic on `now`. An interval preset in particular +// fires on a wall-clock-aligned cadence (`*/N * * * *`), not N minutes +// after whatever moment a viewer happens to load the page — "every 10 +// minutes" viewed at :07 fires at :10, four minutes away, not ten. import type { RoutineTrigger } from "./routines-api"; const WEEKDAY_NAMES = [ @@ -38,6 +47,71 @@ export function cadenceLabel(trigger: RoutineTrigger): string { } } +/** Renders the closed-form presets to the same cron shape the scheduler fires against. */ +function cronExpressionForPreset( + trigger: Exclude, +): string { + switch (trigger.kind) { + case "interval": + return trigger.unit === "minutes" + ? `*/${trigger.every} * * * *` + : `0 */${trigger.every} * * *`; + case "daily": + return `${trigger.minute} ${trigger.hour} * * *`; + case "weekly": + return `${trigger.minute} ${trigger.hour} * * ${trigger.dayOfWeek}`; + } +} + +function cronFieldMatches(field: string, value: number): boolean { + if (field === "*") return true; + const stepMatch = /^\*\/([0-9]+)$/.exec(field); + if (stepMatch?.[1] !== undefined) return value % Number(stepMatch[1]) === 0; + return value === Number(field); +} + +/** + * Matches the subset of the 5-field cron grammar `cronExpressionForPreset` + * ever renders: `*`, a bare number, or a step of `*` (e.g. `star-slash-N`) + * — day-of-month and month are always `*` for these presets, so only + * minute/hour/day-of-week vary. + */ +function presetCronMatchesMinute(expression: string, at: Date): boolean { + const [minute, hour, dayOfMonth, month, dayOfWeek] = expression.split(" "); + if ( + minute === undefined || + hour === undefined || + dayOfMonth === undefined || + month === undefined || + dayOfWeek === undefined + ) { + return false; + } + return ( + cronFieldMatches(minute, at.getUTCMinutes()) && + cronFieldMatches(hour, at.getUTCHours()) && + dayOfMonth === "*" && + month === "*" && + cronFieldMatches(dayOfWeek, at.getUTCDay()) + ); +} + +const NEXT_RUN_LOOKAHEAD_MS = 8 * 24 * 60 * 60 * 1000; + +/** The next minute at or after `after` (exclusive) that `expression` matches. */ +function nextPresetFireAfter(expression: string, after: Date): Date | null { + const start = Math.floor(after.getTime() / 60_000) * 60_000 + 60_000; + for ( + let candidateMs = start; + candidateMs - start <= NEXT_RUN_LOOKAHEAD_MS; + candidateMs += 60_000 + ) { + const candidate = new Date(candidateMs); + if (presetCronMatchesMinute(expression, candidate)) return candidate; + } + return null; +} + /** * A best-effort next-fire estimate for display only — never fed back * into a launch decision, which is the scheduler's job @@ -50,28 +124,5 @@ export function approximateNextRun( now: Date, ): Date | null { if (trigger === null || trigger.kind === "cron") return null; - - if (trigger.kind === "interval") { - const stepMs = - trigger.every * (trigger.unit === "minutes" ? 60_000 : 3_600_000); - return new Date(now.getTime() + stepMs); - } - - const next = new Date(now); - next.setUTCHours(trigger.hour, trigger.minute, 0, 0); - - if (trigger.kind === "daily") { - if (next.getTime() <= now.getTime()) { - next.setUTCDate(next.getUTCDate() + 1); - } - return next; - } - - // weekly - const daysUntil = (trigger.dayOfWeek - next.getUTCDay() + 7) % 7; - next.setUTCDate(next.getUTCDate() + daysUntil); - if (next.getTime() <= now.getTime()) { - next.setUTCDate(next.getUTCDate() + 7); - } - return next; + return nextPresetFireAfter(cronExpressionForPreset(trigger), now); } From b046ee8002be4549089bd306c3de654d8281fe11 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:52:14 -0700 Subject: [PATCH 15/28] Add tests for backfilling next_fire_at on pre-existing routines 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. --- packages/routines/test/migrations.test.ts | 98 ++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/routines/test/migrations.test.ts b/packages/routines/test/migrations.test.ts index e4be8667c..8f1c0a14b 100644 --- a/packages/routines/test/migrations.test.ts +++ b/packages/routines/test/migrations.test.ts @@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import postgres from "postgres"; import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; -import { applyRoutineMigrations } from "../src/migrations"; +import { applyRoutineMigrations, routineMigrations } from "../src/migrations"; function scratchUrlFor(e2eUrl: string): string { const url = new URL(e2eUrl); @@ -88,3 +88,99 @@ describeIfDb("applyRoutineMigrations", () => { } }); }); + +describeIfDb("0003_routine_next_fire_at backfill", () => { + const scratchUrl = scratchUrlFor( + databaseUrl ?? "postgres://localhost:5432/unused", + ); + const scratchTarget = new URL(scratchUrl); + const scratchDatabase = `${scratchTarget.pathname.replace(/^\//, "")}_backfill`; + const backfillScratchUrl = (() => { + const url = new URL(scratchUrl); + url.pathname = `/${scratchDatabase}`; + return url.toString(); + })(); + + beforeAll(async () => { + const maintenanceUrl = new URL(backfillScratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + }); + + afterAll(async () => { + const maintenanceUrl = new URL(backfillScratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + }); + + test("a routine created before the migration is still due and fireable afterwards", async () => { + // Simulate a pre-existing deployment: only 0001/0002 have run, and + // a routine was created under that schema (no next_fire_at column + // existed yet). + const sql = postgres(backfillScratchUrl, { + max: 1, + onnotice: () => undefined, + }); + try { + const [routineMigration, routineRunMigration] = routineMigrations; + if (routineMigration === undefined || routineRunMigration === undefined) { + throw new Error("expected the routine and routine_run migrations"); + } + await sql.unsafe(routineMigration.sql); + await sql.unsafe(routineRunMigration.sql); + await sql` + INSERT INTO "routine" ( + "id", "tenant_id", "name", "definition_id", "trigger", + "scope", "input", "enabled", "created_by" + ) VALUES ( + 'rtn_legacy', 'tnt_1', 'Legacy hourly', 'def_1', + ${sql.json({ kind: "interval", unit: "hours", every: 1 })}, + 'bench', ${sql.json({})}, true, 'user_1' + ) + `; + } finally { + await sql.end(); + } + + // Now bring the database up to date, exactly as a real deploy + // would — the pre-existing row never goes through `createRoutine`. + await applyRoutineMigrations(backfillScratchUrl); + + const sql2 = postgres(backfillScratchUrl, { + max: 1, + onnotice: () => undefined, + }); + try { + const [row] = await sql2` + SELECT "next_fire_at" FROM "routine" WHERE "id" = 'rtn_legacy' + `; + expect(row).toBeDefined(); + const nextFireAt = row?.["next_fire_at"] as Date | null; + expect(nextFireAt).not.toBeNull(); + // Fireable: due within the routine's own hourly cadence, not + // stranded at NULL forever. + expect((nextFireAt as Date).getTime()).toBeLessThanOrEqual( + Date.now() + 3_600_000, + ); + } finally { + await sql2.end(); + } + }); +}); From 176803b0ff9537cee8b57db5c800f85aa69c9203 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:52:23 -0700 Subject: [PATCH 16/28] Backfill next_fire_at for routines that predate the column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/routines/src/migrations.ts | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/routines/src/migrations.ts b/packages/routines/src/migrations.ts index a125c9509..6a44cac77 100644 --- a/packages/routines/src/migrations.ts +++ b/packages/routines/src/migrations.ts @@ -7,9 +7,23 @@ // stays extractable on its own. import postgres from "postgres"; +import { computeNextFireAt, type RoutineTriggerT } from "./trigger"; + +type PostgresSql = ReturnType; + export interface RoutineMigration { name: string; sql: string; + /** + * An optional JS-driven follow-up, run immediately after `sql` inside + * the same migration step — for work `sql` alone can't do, like + * backfilling a computed column from data already in the table. Kept + * separate from `sql` (rather than folding everything into one big + * function) so every migration's schema change stays a plain, + * reviewable SQL string; only the ones that need computed backfill + * carry one. + */ + backfill?: (sql: PostgresSql) => Promise; } export const routineMigrations: readonly RoutineMigration[] = [ @@ -52,6 +66,29 @@ export const routineMigrations: readonly RoutineMigration[] = [ ALTER TABLE "routine" ADD COLUMN IF NOT EXISTS "last_fire_at" timestamptz; CREATE INDEX IF NOT EXISTS "routine_next_fire_at_idx" ON "routine" ("next_fire_at") WHERE "enabled" AND "next_fire_at" IS NOT NULL; `, + // The new column starts NULL for every pre-existing row, and + // `listDueRoutines`'s `nextFireAt <= now` treats NULL as "never + // due" — without this backfill, every routine that existed before + // this migration would stop firing forever, silently, the moment + // it deploys. Every enabled, non-deleted, timer-triggered routine + // gets a fresh `nextFireAt` computed from its own trigger, exactly + // the same way `createRoutine` computes one for a brand new row. + async backfill(sql) { + const rows = await sql<{ id: string; trigger: RoutineTriggerT }[]>` + SELECT "id", "trigger" FROM "routine" + WHERE "enabled" = true + AND "deleted_at" IS NULL + AND "trigger" IS NOT NULL + AND "next_fire_at" IS NULL + `; + const now = new Date(); + for (const row of rows) { + const nextFireAt = computeNextFireAt(row.trigger, now); + await sql` + UPDATE "routine" SET "next_fire_at" = ${nextFireAt} WHERE "id" = ${row.id} + `; + } + }, }, { name: "0004_routine_soft_delete", @@ -100,6 +137,9 @@ export async function applyRoutineMigrations( if (alreadyApplied.has(migration.name)) continue; try { await sql.unsafe(migration.sql); + if (migration.backfill !== undefined) { + await migration.backfill(sql); + } await sql.unsafe( `INSERT INTO ${quoteIdentifier(LEDGER_TABLE)} (name) VALUES ($1)`, [migration.name], From dd7d6637226cf288d99f081eaca19bc7f628b3d5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:52:30 -0700 Subject: [PATCH 17/28] Add tests for the scheduler's launch-failure recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/hub/test/routine-scheduler.test.ts | 117 ++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 apps/hub/test/routine-scheduler.test.ts diff --git a/apps/hub/test/routine-scheduler.test.ts b/apps/hub/test/routine-scheduler.test.ts new file mode 100644 index 000000000..9b70eb2d5 --- /dev/null +++ b/apps/hub/test/routine-scheduler.test.ts @@ -0,0 +1,117 @@ +// The scheduler loop's two failure modes, proven against a single +// deterministic poll (`tickRoutineScheduler`) rather than the real +// `setInterval` wrapper: a launch that throws must not strand the +// routine past its next natural cadence, and a successful launch must +// still record the correlation exactly once. +import { describe, expect, test } from "bun:test"; +import { + createInMemoryRoutineStore, + type RoutineLauncher, +} from "@corbits/routines"; +import { tickRoutineScheduler } from "../src/routine-scheduler"; + +const TENANT_ID = "tnt_1"; + +function throwingLauncher(): RoutineLauncher { + return { + async launchRoutineRun() { + throw new Error("launcher unavailable"); + }, + }; +} + +function succeedingLauncher(): RoutineLauncher & { calls: number } { + let calls = 0; + return { + get calls() { + return calls; + }, + async launchRoutineRun() { + calls += 1; + return { runId: `run_${calls}` }; + }, + }; +} + +describe("tickRoutineScheduler", () => { + test("a due routine fires and its run is recorded", async () => { + const store = createInMemoryRoutineStore(); + const launcher = succeedingLauncher(); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Hourly", + definitionId: "def_1", + trigger: { kind: "interval", unit: "hours", every: 1 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + const fireAt = routine.nextFireAt; + if (fireAt === null) throw new Error("expected a scheduled fire time"); + + await tickRoutineScheduler({ store, launcher }, fireAt); + + expect(launcher.calls).toBe(1); + const runs = await store.listRunsForRoutine(TENANT_ID, routine.id); + expect(runs).toHaveLength(1); + expect(runs[0]?.triggeredBy).toBe("schedule"); + }); + + test("a launch failure restores nextFireAt instead of stranding the routine", async () => { + const store = createInMemoryRoutineStore(); + const launcher = throwingLauncher(); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Hourly", + definitionId: "def_1", + trigger: { kind: "interval", unit: "hours", every: 1 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + const fireAt = routine.nextFireAt; + if (fireAt === null) throw new Error("expected a scheduled fire time"); + + await tickRoutineScheduler({ store, launcher }, fireAt); + + // No run was recorded — the launch never succeeded. + const runs = await store.listRunsForRoutine(TENANT_ID, routine.id); + expect(runs).toHaveLength(0); + + // And the routine is due again at the exact moment it failed, not + // stranded until its next natural hourly cadence. + const dueAgain = await store.listDueRoutines(fireAt); + expect(dueAgain.map((row) => row.id)).toContain(routine.id); + }); + + test("a retried fire after a failure can still succeed", async () => { + const store = createInMemoryRoutineStore(); + let attempts = 0; + const flakyLauncher: RoutineLauncher = { + async launchRoutineRun() { + attempts += 1; + if (attempts === 1) throw new Error("transient failure"); + return { runId: "run_retry" }; + }, + }; + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Hourly", + definitionId: "def_1", + trigger: { kind: "interval", unit: "hours", every: 1 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + const fireAt = routine.nextFireAt; + if (fireAt === null) throw new Error("expected a scheduled fire time"); + + await tickRoutineScheduler({ store, launcher: flakyLauncher }, fireAt); + await tickRoutineScheduler({ store, launcher: flakyLauncher }, fireAt); + + expect(attempts).toBe(2); + const runs = await store.listRunsForRoutine(TENANT_ID, routine.id); + expect(runs).toHaveLength(1); + expect(runs[0]?.runId).toBe("run_retry"); + }); +}); From f9dc871a7bd1224d19c002a7f34418cc107bc211 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:52:40 -0700 Subject: [PATCH 18/28] Retry a routine's fire after a launch failure instead of dropping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/hub/src/routine-scheduler.ts | 92 +++++++++++++++++++++---------- packages/routines/src/store.ts | 84 ++++++++++++++++++++-------- 2 files changed, 124 insertions(+), 52 deletions(-) diff --git a/apps/hub/src/routine-scheduler.ts b/apps/hub/src/routine-scheduler.ts index f1ea3d9c2..5dee544a4 100644 --- a/apps/hub/src/routine-scheduler.ts +++ b/apps/hub/src/routine-scheduler.ts @@ -5,16 +5,25 @@ // `@corbits/agent-lifecycle`'s own `setInterval` sweep (the only other // periodic loop in this repo) rather than pulling in a new dependency. // -// Exactly-once, not "at-least-once": `RoutineStore.claimRoutineFire` -// is a conditional update (`nextFireAt <= now` in its WHERE clause, -// advanced to the trigger's next occurrence in its SET) — a second -// hub replica racing the same fire loses, because the winner already -// moved `nextFireAt` into the future before either replica launches -// anything. And missed fires survive a restart: `nextFireAt` is -// persisted, so "due" means `nextFireAt <= now`, not "does the current -// wall-clock minute match" — a fire that was due while the hub was -// down is still due (and gets caught up) the next time this loop -// polls, exactly like `@corbits/schedules` before it. +// Two guarantees, precisely stated: +// +// - Exactly-once against a *concurrent claim*: `RoutineStore.claimRoutineFire` +// is a conditional update (`nextFireAt <= now` in its WHERE clause, +// advanced to the trigger's next occurrence in its SET) — a second +// hub replica racing the same fire loses, because the winner already +// moved `nextFireAt` into the future before either replica launches +// anything. +// - At-least-once against a *launch failure*: a claim that wins but +// whose `fireScheduledRoutine` call then throws is compensated — +// `nextFireAt` is restored to the moment it was claimed for, so the +// next poll sees the fire as due again instead of silently skipping +// it until the trigger's following occurrence. +// +// And missed fires survive a restart: `nextFireAt` is persisted, so +// "due" means `nextFireAt <= now`, not "does the current wall-clock +// minute match" — a fire that was due while the hub was down is still +// due (and gets caught up) the next time this loop polls, exactly like +// `@corbits/schedules` before it. import type { RoutineLauncher, RoutineStore } from "@corbits/routines"; import { fireScheduledRoutine } from "@corbits/routines"; import { getLogger } from "@intx/log"; @@ -29,6 +38,49 @@ export type RoutineSchedulerDeps = { const POLL_INTERVAL_MS = 30_000; const log = getLogger(["hub", "routine-scheduler"]); +/** + * One poll: claim and fire every routine due at `at`. Exported (rather + * than kept as a closure inside `createRoutineScheduler`) so a test can + * drive a single, deterministic poll against an injected clock without + * waiting on `setInterval`. + */ +export async function tickRoutineScheduler( + deps: Pick, + at: Date, +): Promise { + const dueRoutines = await deps.store.listDueRoutines(at); + for (const candidate of dueRoutines) { + const claimed = await deps.store.claimRoutineFire(candidate.id, at); + // `undefined` means another replica already claimed this exact + // fire between `listDueRoutines` and this claim attempt — not an + // error, just the atomic claim doing its job. + if (claimed === undefined) continue; + try { + await fireScheduledRoutine( + { store: deps.store, launcher: deps.launcher }, + { tenantId: claimed.tenantId, routine: claimed }, + ); + } catch (err) { + log.error`scheduled fire of routine ${claimed.id} failed: ${ + err instanceof Error ? err.message : String(err) + }`; + // The claim already advanced `nextFireAt` past `at`; since the + // launch never happened, restore it to `at` so the next poll + // retries this fire instead of silently dropping it until the + // trigger's following occurrence. + try { + await deps.store.compensateFailedFire(claimed.id, at); + } catch (compensateErr) { + log.error`compensating routine ${claimed.id}'s failed fire also failed: ${ + compensateErr instanceof Error + ? compensateErr.message + : String(compensateErr) + }`; + } + } + } +} + export function createRoutineScheduler(deps: RoutineSchedulerDeps) { const now = deps.now ?? (() => new Date()); let tickInFlight = false; @@ -37,25 +89,7 @@ export function createRoutineScheduler(deps: RoutineSchedulerDeps) { if (tickInFlight) return; tickInFlight = true; try { - const at = now(); - const dueRoutines = await deps.store.listDueRoutines(at); - for (const candidate of dueRoutines) { - const claimed = await deps.store.claimRoutineFire(candidate.id, at); - // `undefined` means another replica already claimed this exact - // fire between `listDueRoutines` and this claim attempt — not - // an error, just the atomic claim doing its job. - if (claimed === undefined) continue; - try { - await fireScheduledRoutine( - { store: deps.store, launcher: deps.launcher }, - { tenantId: claimed.tenantId, routine: claimed }, - ); - } catch (err) { - log.error`scheduled fire of routine ${claimed.id} failed: ${ - err instanceof Error ? err.message : String(err) - }`; - } - } + await tickRoutineScheduler(deps, now()); } finally { tickInFlight = false; } diff --git a/packages/routines/src/store.ts b/packages/routines/src/store.ts index c2492a235..e76ccf43a 100644 --- a/packages/routines/src/store.ts +++ b/packages/routines/src/store.ts @@ -126,6 +126,18 @@ export interface RoutineStore { routineId: string, now: Date, ): Promise; + /** + * Undoes a claim whose launch failed: restores `nextFireAt` to + * `revertNextFireAt` (the moment the claim was made for) so the next + * scheduler poll sees the fire as due again instead of silently + * skipping it until the trigger's following occurrence. Called only + * after `claimRoutineFire` returned a row and the subsequent launch + * threw — a claim that was never granted needs no compensation. + */ + compensateFailedFire( + routineId: string, + revertNextFireAt: Date, + ): Promise; } // `@intx/hub-common`'s `generateId` is closed over the platform's own @@ -281,30 +293,47 @@ export function createDrizzleRoutineStore< }, async claimRoutineFire(routineId, now) { - const [current] = await db - .select() - .from(routine) - .where(eq(routine.id, routineId)) - .limit(1); - if (current === undefined || current.trigger === null) { - return undefined; - } - const nextFireAt = computeNextFireAt( - current.trigger as RoutineTriggerT, - now, - ); - const [claimed] = await db + // A transaction with `FOR UPDATE` locks the row for the read + // that decides `nextFireAt`'s new value, so a concurrent `PATCH` + // of this routine's trigger can't sneak in between "read the + // trigger" and "write the value computed from it" — it blocks + // until this transaction commits, then (having already recomputed + // its own `nextFireAt` off the new trigger in `updateRoutine`) + // is never clobbered by a value computed from the stale one. + return await db.transaction(async (tx) => { + const [current] = await tx + .select() + .from(routine) + .where(eq(routine.id, routineId)) + .for("update") + .limit(1); + if (current === undefined || current.trigger === null) { + return undefined; + } + const nextFireAt = computeNextFireAt( + current.trigger as RoutineTriggerT, + now, + ); + const [claimed] = await tx + .update(routine) + .set({ nextFireAt, lastFireAt: now }) + .where( + and( + eq(routine.id, routineId), + eq(routine.enabled, true), + lte(routine.nextFireAt, now), + ), + ) + .returning(); + return claimed as RoutineRow | undefined; + }); + }, + + async compensateFailedFire(routineId, revertNextFireAt) { + await db .update(routine) - .set({ nextFireAt, lastFireAt: now }) - .where( - and( - eq(routine.id, routineId), - eq(routine.enabled, true), - lte(routine.nextFireAt, now), - ), - ) - .returning(); - return claimed as RoutineRow | undefined; + .set({ nextFireAt: revertNextFireAt }) + .where(eq(routine.id, routineId)); }, async recordRoutineRun(input) { @@ -460,6 +489,15 @@ export function createInMemoryRoutineStore(): RoutineStore { return claimed; }, + async compensateFailedFire(routineId, revertNextFireAt) { + const current = routinesById.get(routineId); + if (current === undefined) return; + routinesById.set(routineId, { + ...current, + nextFireAt: revertNextFireAt, + }); + }, + async recordRoutineRun(input) { const row: RoutineRunRow = { ...input, createdAt: new Date() }; runs.push(row); From 96ddddfc33621f1b3aa1b7a6c0f4d41010d25b57 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:52:47 -0700 Subject: [PATCH 19/28] Share one cron parser between the hub and the Routines page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/package.json | 4 ++ apps/web/src/routine-trigger.ts | 80 +++++++++------------------------ packages/routines/package.json | 3 +- 3 files changed, 27 insertions(+), 60 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 2583294a0..290363bee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,7 +17,11 @@ "@corbits/bench-ui": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/chat-ui": "workspace:*", +<<<<<<< HEAD "@corbits/command-palette": "workspace:*", +======= + "@corbits/routines": "workspace:*", +>>>>>>> ec85256 (Share one cron parser between the hub and the Routines page) "@corbits/settings-ui": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@intx/types": "workspace:*", diff --git a/apps/web/src/routine-trigger.ts b/apps/web/src/routine-trigger.ts index b8183a4ac..3c1857086 100644 --- a/apps/web/src/routine-trigger.ts +++ b/apps/web/src/routine-trigger.ts @@ -7,14 +7,19 @@ // see apps/hub/src/cron-due.ts) — it renders a plain description instead // of a guessed timestamp, never a wrong one dressed up as exact. // -// The estimate for interval/daily/weekly presets is derived from the -// exact cron expression the scheduler fires against (mirroring -// `cronExpressionForTrigger` and a minute-by-minute search, the same -// technique as `nextCronFireAfter` in packages/routines/src/cron.ts), -// not from naive arithmetic on `now`. An interval preset in particular -// fires on a wall-clock-aligned cadence (`*/N * * * *`), not N minutes -// after whatever moment a viewer happens to load the page — "every 10 +// The estimate for interval/daily/weekly presets is computed by +// `nextCronFireAfter` from `@corbits/routines/cron` — the exact same +// minute-by-minute search the hub's own scheduler runs against the +// exact same rendered cron expression — never a second, hand-rolled +// matcher that could drift from what actually fires. That subpath (not +// the package's default export) is deliberate: the default export +// pulls in `drizzle-orm` and `postgres` through `store.ts`, which have +// no business in a browser bundle; `cron.ts` has zero imports and +// bundles cleanly on its own. An interval preset in particular fires +// on a wall-clock-aligned cadence (`*/N * * * *`), not N minutes after +// whatever moment a viewer happens to load the page — "every 10 // minutes" viewed at :07 fires at :10, four minutes away, not ten. +import { nextCronFireAfter } from "@corbits/routines/cron"; import type { RoutineTrigger } from "./routines-api"; const WEEKDAY_NAMES = [ @@ -63,66 +68,23 @@ function cronExpressionForPreset( } } -function cronFieldMatches(field: string, value: number): boolean { - if (field === "*") return true; - const stepMatch = /^\*\/([0-9]+)$/.exec(field); - if (stepMatch?.[1] !== undefined) return value % Number(stepMatch[1]) === 0; - return value === Number(field); -} - -/** - * Matches the subset of the 5-field cron grammar `cronExpressionForPreset` - * ever renders: `*`, a bare number, or a step of `*` (e.g. `star-slash-N`) - * — day-of-month and month are always `*` for these presets, so only - * minute/hour/day-of-week vary. - */ -function presetCronMatchesMinute(expression: string, at: Date): boolean { - const [minute, hour, dayOfMonth, month, dayOfWeek] = expression.split(" "); - if ( - minute === undefined || - hour === undefined || - dayOfMonth === undefined || - month === undefined || - dayOfWeek === undefined - ) { - return false; - } - return ( - cronFieldMatches(minute, at.getUTCMinutes()) && - cronFieldMatches(hour, at.getUTCHours()) && - dayOfMonth === "*" && - month === "*" && - cronFieldMatches(dayOfWeek, at.getUTCDay()) - ); -} - -const NEXT_RUN_LOOKAHEAD_MS = 8 * 24 * 60 * 60 * 1000; - -/** The next minute at or after `after` (exclusive) that `expression` matches. */ -function nextPresetFireAfter(expression: string, after: Date): Date | null { - const start = Math.floor(after.getTime() / 60_000) * 60_000 + 60_000; - for ( - let candidateMs = start; - candidateMs - start <= NEXT_RUN_LOOKAHEAD_MS; - candidateMs += 60_000 - ) { - const candidate = new Date(candidateMs); - if (presetCronMatchesMinute(expression, candidate)) return candidate; - } - return null; -} - /** * A best-effort next-fire estimate for display only — never fed back * into a launch decision, which is the scheduler's job * (apps/hub/src/routine-scheduler.ts) against the real clock. Returns - * `null` for a manual routine or a raw-cron trigger (no closed form - * without a full cron evaluator on the client). + * `null` for a manual routine, a raw-cron trigger (no closed form + * without rendering it through the same package the hub already does), + * or the vanishingly unlikely case of no match inside + * `nextCronFireAfter`'s multi-year lookahead. */ export function approximateNextRun( trigger: RoutineTrigger, now: Date, ): Date | null { if (trigger === null || trigger.kind === "cron") return null; - return nextPresetFireAfter(cronExpressionForPreset(trigger), now); + try { + return nextCronFireAfter(cronExpressionForPreset(trigger), now); + } catch { + return null; + } } diff --git a/packages/routines/package.json b/packages/routines/package.json index 918b3a216..e31fb3726 100644 --- a/packages/routines/package.json +++ b/packages/routines/package.json @@ -6,7 +6,8 @@ "license": "SEE LICENSE IN LICENSE.md", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./cron": "./src/cron.ts" }, "scripts": { "typecheck": "tsc --noEmit", From 7165c8ea039cf7e6689151e929baee5c2bcdf90a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 16:04:59 -0700 Subject: [PATCH 20/28] Add tests proving a compensated fire cannot clobber a concurrent trigger edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/routines/test/store.test.ts | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/routines/test/store.test.ts b/packages/routines/test/store.test.ts index bc2cd29e3..a7988b2a3 100644 --- a/packages/routines/test/store.test.ts +++ b/packages/routines/test/store.test.ts @@ -149,4 +149,60 @@ describe("listDueRoutines / claimRoutineFire", () => { expect(reEnabled.nextFireAt).not.toBeNull(); expect(reEnabled.nextFireAt?.getTime()).toBeGreaterThan(Date.now()); }); + + test("compensateFailedFire restores nextFireAt when nothing has changed since the claim", async () => { + const store = createInMemoryRoutineStore(); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Hourly", + definitionId: "def_1", + trigger: { kind: "interval", unit: "hours", every: 1 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + const fireAt = assertDate(routine.nextFireAt); + const claimed = assertDate( + (await store.claimRoutineFire(routine.id, fireAt))?.nextFireAt ?? null, + ); + + await store.compensateFailedFire(routine.id, fireAt, claimed); + + const restored = await store.getRoutine(TENANT_ID, routine.id); + expect(restored?.nextFireAt?.toISOString()).toBe(fireAt.toISOString()); + }); + + test("compensateFailedFire is a no-op when a trigger edit already moved nextFireAt", async () => { + const store = createInMemoryRoutineStore(); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Hourly", + definitionId: "def_1", + trigger: { kind: "interval", unit: "hours", every: 1 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + const fireAt = assertDate(routine.nextFireAt); + const claimedResult = await store.claimRoutineFire(routine.id, fireAt); + const claimedNextFireAt = assertDate(claimedResult?.nextFireAt ?? null); + + // A trigger edit lands during the failure window, after the claim + // but before the launch's failure is handled — this already gave + // the routine a fresh, unrelated nextFireAt. + const edited = await store.updateRoutine(TENANT_ID, routine.id, { + trigger: { kind: "interval", unit: "minutes", every: 30 }, + }); + const editedNextFireAt = assertDate(edited.nextFireAt); + expect(editedNextFireAt.getTime()).not.toBe(claimedNextFireAt.getTime()); + + // Compensation must not clobber that newer value with the stale + // one computed from the pre-edit trigger. + await store.compensateFailedFire(routine.id, fireAt, claimedNextFireAt); + + const afterCompensation = await store.getRoutine(TENANT_ID, routine.id); + expect(afterCompensation?.nextFireAt?.toISOString()).toBe( + editedNextFireAt.toISOString(), + ); + }); }); From 826614660c9bc4fd3dc2bf490d8181fad9388880 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 16:05:08 -0700 Subject: [PATCH 21/28] Make a failed-fire restore conditional on the claim it is undoing 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. --- apps/hub/src/routine-scheduler.ts | 13 +++++++++++-- packages/routines/src/store.ts | 21 ++++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/hub/src/routine-scheduler.ts b/apps/hub/src/routine-scheduler.ts index 5dee544a4..fe5a58bc1 100644 --- a/apps/hub/src/routine-scheduler.ts +++ b/apps/hub/src/routine-scheduler.ts @@ -67,9 +67,18 @@ export async function tickRoutineScheduler( // The claim already advanced `nextFireAt` past `at`; since the // launch never happened, restore it to `at` so the next poll // retries this fire instead of silently dropping it until the - // trigger's following occurrence. + // trigger's following occurrence. `claimed.nextFireAt` is the + // value the claim itself just wrote (never null — a claim only + // succeeds for a triggered routine), passed through so the + // restore is conditional and can't clobber a newer trigger edit. try { - await deps.store.compensateFailedFire(claimed.id, at); + if (claimed.nextFireAt !== null) { + await deps.store.compensateFailedFire( + claimed.id, + at, + claimed.nextFireAt, + ); + } } catch (compensateErr) { log.error`compensating routine ${claimed.id}'s failed fire also failed: ${ compensateErr instanceof Error diff --git a/packages/routines/src/store.ts b/packages/routines/src/store.ts index e76ccf43a..b61351d4f 100644 --- a/packages/routines/src/store.ts +++ b/packages/routines/src/store.ts @@ -133,10 +133,17 @@ export interface RoutineStore { * skipping it until the trigger's following occurrence. Called only * after `claimRoutineFire` returned a row and the subsequent launch * threw — a claim that was never granted needs no compensation. + * + * Conditional on `nextFireAt` still being `claimedNextFireAt` — the + * value the claim itself wrote. If a trigger edit landed during the + * failure window, `updateRoutine` already recomputed `nextFireAt` + * off the new trigger, and that newer value must win: this restore + * is a no-op rather than clobbering it with the stale one. */ compensateFailedFire( routineId: string, revertNextFireAt: Date, + claimedNextFireAt: Date, ): Promise; } @@ -329,11 +336,16 @@ export function createDrizzleRoutineStore< }); }, - async compensateFailedFire(routineId, revertNextFireAt) { + async compensateFailedFire(routineId, revertNextFireAt, claimedNextFireAt) { await db .update(routine) .set({ nextFireAt: revertNextFireAt }) - .where(eq(routine.id, routineId)); + .where( + and( + eq(routine.id, routineId), + eq(routine.nextFireAt, claimedNextFireAt), + ), + ); }, async recordRoutineRun(input) { @@ -489,9 +501,12 @@ export function createInMemoryRoutineStore(): RoutineStore { return claimed; }, - async compensateFailedFire(routineId, revertNextFireAt) { + async compensateFailedFire(routineId, revertNextFireAt, claimedNextFireAt) { const current = routinesById.get(routineId); if (current === undefined) return; + if (current.nextFireAt?.getTime() !== claimedNextFireAt.getTime()) { + return; + } routinesById.set(routineId, { ...current, nextFireAt: revertNextFireAt, From 05911e127537fc0cf4ed8063b548639581bd8f13 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 16:23:00 -0700 Subject: [PATCH 22/28] Fix migration 0003 referencing a column 0004 hasn't added yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/routines/src/migrations.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/routines/src/migrations.ts b/packages/routines/src/migrations.ts index 6a44cac77..5735353cf 100644 --- a/packages/routines/src/migrations.ts +++ b/packages/routines/src/migrations.ts @@ -70,14 +70,20 @@ export const routineMigrations: readonly RoutineMigration[] = [ // `listDueRoutines`'s `nextFireAt <= now` treats NULL as "never // due" — without this backfill, every routine that existed before // this migration would stop firing forever, silently, the moment - // it deploys. Every enabled, non-deleted, timer-triggered routine - // gets a fresh `nextFireAt` computed from its own trigger, exactly - // the same way `createRoutine` computes one for a brand new row. + // it deploys. Every enabled, timer-triggered routine gets a fresh + // `nextFireAt` computed from its own trigger, exactly the same way + // `createRoutine` computes one for a brand new row. + // + // No `deleted_at IS NULL` filter here: this migration runs before + // 0004_routine_soft_delete adds that column, so at this point in + // the sequence every row is, by definition, not soft-deleted — the + // predicate would be vacuously true if it existed, and referencing + // a column that doesn't exist yet aborts a from-scratch migration + // run outright. async backfill(sql) { const rows = await sql<{ id: string; trigger: RoutineTriggerT }[]>` SELECT "id", "trigger" FROM "routine" WHERE "enabled" = true - AND "deleted_at" IS NULL AND "trigger" IS NOT NULL AND "next_fire_at" IS NULL `; From e92548e83fade3c09be9e6e86a72eb10f153af06 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 16:23:31 -0700 Subject: [PATCH 23/28] Add tests for the routine migration's schema, idempotency, and fireability 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. --- packages/routines/test/migrations.test.ts | 140 ++++++---------------- 1 file changed, 37 insertions(+), 103 deletions(-) diff --git a/packages/routines/test/migrations.test.ts b/packages/routines/test/migrations.test.ts index 8f1c0a14b..1ab4c5f79 100644 --- a/packages/routines/test/migrations.test.ts +++ b/packages/routines/test/migrations.test.ts @@ -3,10 +3,12 @@ // @corbits/chat's `migrations.test.ts`. Runs against its own scratch // database, never the developer's or the walking-skeleton suite's. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; -import { applyRoutineMigrations, routineMigrations } from "../src/migrations"; +import { applyRoutineMigrations } from "../src/migrations"; +import { createDrizzleRoutineStore } from "../src/store"; function scratchUrlFor(e2eUrl: string): string { const url = new URL(e2eUrl); @@ -54,23 +56,17 @@ describeIfDb("applyRoutineMigrations", () => { } }); - test("applies both tables and is idempotent on a second run", async () => { + // Deliberately agnostic to how many migration steps exist or what + // they're named — this proves the schema and the idempotency + // contract, not a specific migration sequence, so it stays true + // whether the package ships one migration or many. + test("applies both tables in their final shape and is idempotent on a second run", async () => { const first = await applyRoutineMigrations(scratchUrl); - expect(first.applied).toEqual([ - "0001_routine", - "0002_routine_run", - "0003_routine_next_fire_at", - "0004_routine_soft_delete", - ]); + expect(first.applied.length).toBeGreaterThan(0); const second = await applyRoutineMigrations(scratchUrl); expect(second.applied).toEqual([]); - expect(second.alreadyApplied.sort()).toEqual([ - "0001_routine", - "0002_routine_run", - "0003_routine_next_fire_at", - "0004_routine_soft_delete", - ]); + expect(second.alreadyApplied.sort()).toEqual(first.applied.sort()); const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); try { @@ -83,104 +79,42 @@ describeIfDb("applyRoutineMigrations", () => { "routine", "routine_run", ]); - } finally { - await sql.end(); - } - }); -}); - -describeIfDb("0003_routine_next_fire_at backfill", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = `${scratchTarget.pathname.replace(/^\//, "")}_backfill`; - const backfillScratchUrl = (() => { - const url = new URL(scratchUrl); - url.pathname = `/${scratchDatabase}`; - return url.toString(); - })(); - - beforeAll(async () => { - const maintenanceUrl = new URL(backfillScratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - afterAll(async () => { - const maintenanceUrl = new URL(backfillScratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + const columns = await sql.unsafe( + `SELECT column_name FROM information_schema.columns ` + + `WHERE table_schema = 'public' AND table_name = 'routine'`, + ); + const columnNames = columns.map((row) => String(row["column_name"])); + expect(columnNames).toContain("next_fire_at"); + expect(columnNames).toContain("last_fire_at"); + expect(columnNames).toContain("deleted_at"); } finally { - await maintenance.end(); + await sql.end(); } }); - test("a routine created before the migration is still due and fireable afterwards", async () => { - // Simulate a pre-existing deployment: only 0001/0002 have run, and - // a routine was created under that schema (no next_fire_at column - // existed yet). - const sql = postgres(backfillScratchUrl, { - max: 1, - onnotice: () => undefined, - }); + test("a routine created on a freshly migrated database ends up fireable", async () => { + const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); try { - const [routineMigration, routineRunMigration] = routineMigrations; - if (routineMigration === undefined || routineRunMigration === undefined) { - throw new Error("expected the routine and routine_run migrations"); - } - await sql.unsafe(routineMigration.sql); - await sql.unsafe(routineRunMigration.sql); - await sql` - INSERT INTO "routine" ( - "id", "tenant_id", "name", "definition_id", "trigger", - "scope", "input", "enabled", "created_by" - ) VALUES ( - 'rtn_legacy', 'tnt_1', 'Legacy hourly', 'def_1', - ${sql.json({ kind: "interval", unit: "hours", every: 1 })}, - 'bench', ${sql.json({})}, true, 'user_1' - ) - `; - } finally { - await sql.end(); - } + const db = drizzle(sql); + const store = createDrizzleRoutineStore(db); + const routine = await store.createRoutine({ + tenantId: "tnt_1", + name: "Hourly", + definitionId: "def_1", + trigger: { kind: "interval", unit: "hours", every: 1 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); - // Now bring the database up to date, exactly as a real deploy - // would — the pre-existing row never goes through `createRoutine`. - await applyRoutineMigrations(backfillScratchUrl); - - const sql2 = postgres(backfillScratchUrl, { - max: 1, - onnotice: () => undefined, - }); - try { - const [row] = await sql2` - SELECT "next_fire_at" FROM "routine" WHERE "id" = 'rtn_legacy' - `; - expect(row).toBeDefined(); - const nextFireAt = row?.["next_fire_at"] as Date | null; - expect(nextFireAt).not.toBeNull(); - // Fireable: due within the routine's own hourly cadence, not - // stranded at NULL forever. - expect((nextFireAt as Date).getTime()).toBeLessThanOrEqual( - Date.now() + 3_600_000, + expect(routine.nextFireAt).not.toBeNull(); + const due = await store.listDueRoutines( + new Date((routine.nextFireAt as Date).getTime() + 1), ); + expect(due.map((row) => row.id)).toContain(routine.id); } finally { - await sql2.end(); + await sql.end(); } }); }); From f1404fcf3edbb3a22a83a87886191fbab6084b2e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 16:23:52 -0700 Subject: [PATCH 24/28] Collapse routine migrations into a single, final-shape file 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. --- packages/routines/src/migrations.ts | 76 +++++------------------------ 1 file changed, 11 insertions(+), 65 deletions(-) diff --git a/packages/routines/src/migrations.ts b/packages/routines/src/migrations.ts index 5735353cf..ec152ef6c 100644 --- a/packages/routines/src/migrations.ts +++ b/packages/routines/src/migrations.ts @@ -5,25 +5,17 @@ // story" install contract. Bookkeeping is its own ledger table, never // the platform's drizzle journal, so this package's migration history // stays extractable on its own. +// +// A single migration, in its final shape, not a sequence of ALTERs +// bolted on as the feature grew: this package predates any real +// traffic, so there is no pre-existing data to carry forward and no +// earlier shape to migrate away from. Hard cutover — one file, no +// backfill, nothing to reconcile. import postgres from "postgres"; -import { computeNextFireAt, type RoutineTriggerT } from "./trigger"; - -type PostgresSql = ReturnType; - export interface RoutineMigration { name: string; sql: string; - /** - * An optional JS-driven follow-up, run immediately after `sql` inside - * the same migration step — for work `sql` alone can't do, like - * backfilling a computed column from data already in the table. Kept - * separate from `sql` (rather than folding everything into one big - * function) so every migration's schema change stays a plain, - * reviewable SQL string; only the ones that need computed backfill - * carry one. - */ - backfill?: (sql: PostgresSql) => Promise; } export const routineMigrations: readonly RoutineMigration[] = [ @@ -41,14 +33,14 @@ export const routineMigrations: readonly RoutineMigration[] = [ "enabled" boolean NOT NULL DEFAULT true, "delivery_channel_id" text, "created_by" text NOT NULL, + "next_fire_at" timestamptz, + "last_fire_at" timestamptz, + "deleted_at" timestamptz, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now() ); - `, - }, - { - name: "0002_routine_run", - sql: ` + CREATE INDEX IF NOT EXISTS "routine_next_fire_at_idx" ON "routine" ("next_fire_at") WHERE "enabled" AND "next_fire_at" IS NOT NULL; + CREATE TABLE IF NOT EXISTS "routine_run" ( "tenant_id" text NOT NULL, "routine_id" text NOT NULL, @@ -59,49 +51,6 @@ export const routineMigrations: readonly RoutineMigration[] = [ ); `, }, - { - name: "0003_routine_next_fire_at", - sql: ` - ALTER TABLE "routine" ADD COLUMN IF NOT EXISTS "next_fire_at" timestamptz; - ALTER TABLE "routine" ADD COLUMN IF NOT EXISTS "last_fire_at" timestamptz; - CREATE INDEX IF NOT EXISTS "routine_next_fire_at_idx" ON "routine" ("next_fire_at") WHERE "enabled" AND "next_fire_at" IS NOT NULL; - `, - // The new column starts NULL for every pre-existing row, and - // `listDueRoutines`'s `nextFireAt <= now` treats NULL as "never - // due" — without this backfill, every routine that existed before - // this migration would stop firing forever, silently, the moment - // it deploys. Every enabled, timer-triggered routine gets a fresh - // `nextFireAt` computed from its own trigger, exactly the same way - // `createRoutine` computes one for a brand new row. - // - // No `deleted_at IS NULL` filter here: this migration runs before - // 0004_routine_soft_delete adds that column, so at this point in - // the sequence every row is, by definition, not soft-deleted — the - // predicate would be vacuously true if it existed, and referencing - // a column that doesn't exist yet aborts a from-scratch migration - // run outright. - async backfill(sql) { - const rows = await sql<{ id: string; trigger: RoutineTriggerT }[]>` - SELECT "id", "trigger" FROM "routine" - WHERE "enabled" = true - AND "trigger" IS NOT NULL - AND "next_fire_at" IS NULL - `; - const now = new Date(); - for (const row of rows) { - const nextFireAt = computeNextFireAt(row.trigger, now); - await sql` - UPDATE "routine" SET "next_fire_at" = ${nextFireAt} WHERE "id" = ${row.id} - `; - } - }, - }, - { - name: "0004_routine_soft_delete", - sql: ` - ALTER TABLE "routine" ADD COLUMN IF NOT EXISTS "deleted_at" timestamptz; - `, - }, ]; // Named distinctly from the platform's setup ledger and from any @@ -143,9 +92,6 @@ export async function applyRoutineMigrations( if (alreadyApplied.has(migration.name)) continue; try { await sql.unsafe(migration.sql); - if (migration.backfill !== undefined) { - await migration.backfill(sql); - } await sql.unsafe( `INSERT INTO ${quoteIdentifier(LEDGER_TABLE)} (name) VALUES ($1)`, [migration.name], From 49ec6ae3d2d909c22e4267786d7abe399dee2685 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 16:24:32 -0700 Subject: [PATCH 25/28] Add DB-gated tests for the drizzle claim-and-compensate path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/routines/test/store.drizzle.test.ts | 147 +++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 packages/routines/test/store.drizzle.test.ts diff --git a/packages/routines/test/store.drizzle.test.ts b/packages/routines/test/store.drizzle.test.ts new file mode 100644 index 000000000..c66e2fe9a --- /dev/null +++ b/packages/routines/test/store.drizzle.test.ts @@ -0,0 +1,147 @@ +// DB-gated: skipped when no DATABASE_URL is reachable (a fresh +// checkout still runs the unit gates), mirroring this package's own +// `migrations.test.ts`. Runs against its own scratch database, never +// the developer's or the walking-skeleton suite's. +// +// `store.test.ts` proves `compensateFailedFire`'s compare-and-restore +// against the in-memory store, which is atomic only because JS is +// single-threaded — it says nothing about whether Postgres's own +// timestamp comparison, round-tripped through drizzle, actually +// behaves the same way. This exercises the real `createDrizzleRoutineStore` +// path: an ordinary restore, and the edit-wins case where a concurrent +// trigger change must survive a stale compensation untouched. +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; +import { applyRoutineMigrations } from "../src/migrations"; +import { createDrizzleRoutineStore } from "../src/store"; + +function scratchUrlFor(e2eUrl: string): string { + const url = new URL(e2eUrl); + const database = url.pathname.replace(/^\//, ""); + url.pathname = `/${database}_routine_store_drizzle_test`; + return url.toString(); +} + +const databaseUrl = e2eDatabaseUrl(); +const describeIfDb = databaseUrl === undefined ? describe.skip : describe; + +const TENANT_ID = "tnt_1"; + +function assertDate(value: Date | null): Date { + if (value === null) throw new Error("expected a non-null Date"); + return value; +} + +describeIfDb( + "createDrizzleRoutineStore: claimRoutineFire / compensateFailedFire", + () => { + const scratchUrl = scratchUrlFor( + databaseUrl ?? "postgres://localhost:5432/unused", + ); + const scratchTarget = new URL(scratchUrl); + const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); + + beforeAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe( + `DROP DATABASE IF EXISTS "${scratchDatabase}"`, + ); + await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + await applyRoutineMigrations(scratchUrl); + }); + + afterAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe( + `DROP DATABASE IF EXISTS "${scratchDatabase}"`, + ); + } finally { + await maintenance.end(); + } + }); + + test("compensateFailedFire restores nextFireAt when nothing has changed since the claim", async () => { + const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); + try { + const store = createDrizzleRoutineStore(drizzle(sql)); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Hourly", + definitionId: "def_1", + trigger: { kind: "interval", unit: "hours", every: 1 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + const fireAt = assertDate(routine.nextFireAt); + const claimed = await store.claimRoutineFire(routine.id, fireAt); + const claimedNextFireAt = assertDate(claimed?.nextFireAt ?? null); + + await store.compensateFailedFire(routine.id, fireAt, claimedNextFireAt); + + const restored = await store.getRoutine(TENANT_ID, routine.id); + expect(restored?.nextFireAt?.toISOString()).toBe(fireAt.toISOString()); + } finally { + await sql.end(); + } + }); + + test("compensateFailedFire is a no-op when a trigger edit already moved nextFireAt", async () => { + const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); + try { + const store = createDrizzleRoutineStore(drizzle(sql)); + const routine = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Hourly again", + definitionId: "def_1", + trigger: { kind: "interval", unit: "hours", every: 1 }, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + const fireAt = assertDate(routine.nextFireAt); + const claimed = await store.claimRoutineFire(routine.id, fireAt); + const claimedNextFireAt = assertDate(claimed?.nextFireAt ?? null); + + // A trigger edit lands during the failure window, after the + // claim but before the launch's failure is handled. + const edited = await store.updateRoutine(TENANT_ID, routine.id, { + trigger: { kind: "interval", unit: "minutes", every: 30 }, + }); + const editedNextFireAt = assertDate(edited.nextFireAt); + expect(editedNextFireAt.getTime()).not.toBe( + claimedNextFireAt.getTime(), + ); + + // The conditional UPDATE's WHERE no longer matches (nextFireAt + // moved), so this must not clobber the edit's newer value. + await store.compensateFailedFire(routine.id, fireAt, claimedNextFireAt); + + const afterCompensation = await store.getRoutine(TENANT_ID, routine.id); + expect(afterCompensation?.nextFireAt?.toISOString()).toBe( + editedNextFireAt.toISOString(), + ); + } finally { + await sql.end(); + } + }); + }, +); From 26effe28d3098fb8096b29eb2dc39ef2dd18e341 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 16:25:23 -0700 Subject: [PATCH 26/28] Mint routine ids through Interchange instead of a local generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bun.lock | 2 +- packages/routines/package.json | 2 +- packages/routines/src/store.ts | 18 +++--------------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/bun.lock b/bun.lock index 3e4749799..125b6d875 100644 --- a/bun.lock +++ b/bun.lock @@ -345,7 +345,7 @@ "dependencies": { "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "workspace:*", + "@intx/hub-common": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "catalog:", diff --git a/packages/routines/package.json b/packages/routines/package.json index e31fb3726..c6b588305 100644 --- a/packages/routines/package.json +++ b/packages/routines/package.json @@ -16,7 +16,7 @@ "dependencies": { "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "workspace:*", + "@intx/hub-common": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "catalog:", diff --git a/packages/routines/src/store.ts b/packages/routines/src/store.ts index b61351d4f..59bda04cc 100644 --- a/packages/routines/src/store.ts +++ b/packages/routines/src/store.ts @@ -5,7 +5,7 @@ // implementation, over the tables in `./schema.ts`. import { and, desc, eq, isNull, lte } from "drizzle-orm"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { hexEncode } from "@intx/types"; +import { generateId } from "@intx/hub-common"; import { routine, routineRun } from "./schema"; import { computeNextFireAt, type RoutineTriggerT } from "./trigger"; @@ -147,18 +147,6 @@ export interface RoutineStore { ): Promise; } -// `@intx/hub-common`'s `generateId` is closed over the platform's own -// ID kinds (tenant, principal, session, ...), which a Routine is not — -// it is a product entity this package owns, not a platform-native -// resource. This mints ids with the exact same primitive -// (`crypto.getRandomValues` + hex encoding) generateId uses, under its -// own `rtn_` prefix, rather than smuggling a new kind into the -// platform's enumeration. -function generateRoutineId(): string { - const bytes = hexEncode(crypto.getRandomValues(new Uint8Array(16))); - return `rtn_${bytes}`; -} - export function createDrizzleRoutineStore< TSchema extends Record, >(db: RoutineDb): RoutineStore { @@ -168,7 +156,7 @@ export function createDrizzleRoutineStore< const [row] = await db .insert(routine) .values({ - id: generateRoutineId(), + id: generateId("instance"), tenantId: input.tenantId, name: input.name, definitionId: input.definitionId, @@ -385,7 +373,7 @@ export function createInMemoryRoutineStore(): RoutineStore { async createRoutine(input) { const now = new Date(); const row: RoutineRow = { - id: generateRoutineId(), + id: generateId("instance"), tenantId: input.tenantId, name: input.name, definitionId: input.definitionId, From fec1f5beeea4938df3274959282d914a3550ad0a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 19:30:11 -0700 Subject: [PATCH 27/28] Allow routine tables in the no-product-tenancy check Routines replace schedules: allow routine + routine_run, drop schedules. --- scripts/checks/no-product-tenancy.ts | 6 +++--- scripts/checks/test/no-product-tenancy.test.ts | 18 +++++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index ea8a4813c..f89f4eb83 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -46,9 +46,9 @@ const ALLOWLIST: readonly { ], }, { - relPath: "packages/schedules/src/schema.ts", - maxOccurrences: 1, - tables: ["schedules"], + relPath: "packages/routines/src/schema.ts", + maxOccurrences: 2, + tables: ["routine", "routine_run"], }, { relPath: "packages/webhook-triggers/src/schema.ts", diff --git a/scripts/checks/test/no-product-tenancy.test.ts b/scripts/checks/test/no-product-tenancy.test.ts index 9b5087e78..ebfcddf90 100644 --- a/scripts/checks/test/no-product-tenancy.test.ts +++ b/scripts/checks/test/no-product-tenancy.test.ts @@ -58,8 +58,11 @@ test("allowlisted product schema files pass at their max count", () => { ].join("\n"), }, { - relPath: "packages/schedules/src/schema.ts", - contents: `export const schedules = pgTable("schedules", {});`, + relPath: "packages/routines/src/schema.ts", + contents: [ + `export const routine = pgTable("routine", {});`, + `export const routineRun = pgTable("routine_run", {});`, + ].join("\n"), }, { relPath: "packages/webhook-triggers/src/schema.ts", @@ -79,14 +82,15 @@ test("allowlisted product schema files pass at their max count", () => { test("allowlisted files fail when they grow past their max", () => { const report = auditProductTenancy([ { - relPath: "packages/schedules/src/schema.ts", + relPath: "packages/routines/src/schema.ts", contents: [ - `export const schedules = pgTable("schedules", {});`, - `export const extra = pgTable("schedules_extra", {});`, + `export const routine = pgTable("routine", {});`, + `export const routineRun = pgTable("routine_run", {});`, + `export const extra = pgTable("routine_extra", {});`, ].join("\n"), }, ]); expect(report.violations).toHaveLength(1); - expect(report.violations[0]).toContain("packages/schedules/src/schema.ts"); - expect(report.violations[0]).toContain("2 pgTable"); + expect(report.violations[0]).toContain("packages/routines/src/schema.ts"); + expect(report.violations[0]).toContain("3 pgTable"); }); From 5961241b77a04bc837ffc7c2931ef161bc301b18 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 20:30:26 -0700 Subject: [PATCH 28/28] Keep command-palette and routines both on the web package after rebase Main already depends on command-palette; routines replaces schedules. Both must stay listed after the rebase merge of those dependency lines. --- apps/web/package.json | 3 --- bun.lock | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 290363bee..00bc7ce72 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,11 +17,8 @@ "@corbits/bench-ui": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/chat-ui": "workspace:*", -<<<<<<< HEAD "@corbits/command-palette": "workspace:*", -======= "@corbits/routines": "workspace:*", ->>>>>>> ec85256 (Share one cron parser between the hub and the Routines page) "@corbits/settings-ui": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@intx/types": "workspace:*", diff --git a/bun.lock b/bun.lock index 125b6d875..c46bd0074 100644 --- a/bun.lock +++ b/bun.lock @@ -90,6 +90,7 @@ "@corbits/chat-ui": "workspace:*", "@corbits/command-palette": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", + "@corbits/routines": "workspace:*", "@corbits/settings-ui": "workspace:*", "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15",