diff --git a/apps/hub/package.json b/apps/hub/package.json index 98c6f2647..b37d51141 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -16,12 +16,13 @@ "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/folded-runs": "workspace:*", - "@corbits/schedules": "workspace:*", + "@corbits/routines": "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..89d6d41b9 --- /dev/null +++ b/apps/hub/src/cron-due.ts @@ -0,0 +1,7 @@ +// 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 8ac3b49ca..59d4ef571 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -29,17 +29,15 @@ import { createWebhookTriggerRoutes, launchWebhookTrigger, } from "@corbits/webhook-triggers"; -import { - createDrizzleScheduleStore, - createHubScheduleLauncher, - createScheduleRoutes, - createScheduler, -} from "@corbits/schedules"; import { createCommandRegistry, createCommandRoutes, createWorkflowCommandPlugin, } from "@corbits/commands"; +import { + createDrizzleRoutineStore, + createRoutineRoutes, +} from "@corbits/routines"; import { createAgentRepoStore, createAssetService, @@ -61,6 +59,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; @@ -73,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. @@ -385,44 +381,47 @@ 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({ + // 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, }); - 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, + `${TENANT_PREFIX}/routines`, + createRoutineRoutes({ + store: routineStore, + launcher: routineLauncher, requireGrant: createRequireGrant({ - grantStore: scheduleGrantStore, - conditionRegistry: scheduleConditionRegistry, + 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. 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({ + 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 @@ -454,8 +453,8 @@ export async function createHub(config: HubConfig) { app, db, 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..fe5a58bc1 --- /dev/null +++ b/apps/hub/src/routine-scheduler.ts @@ -0,0 +1,115 @@ +// 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. +// +// 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"; + +export type RoutineSchedulerDeps = { + 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"]); + +/** + * 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. `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 { + 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 + ? compensateErr.message + : String(compensateErr) + }`; + } + } + } +} + +export function createRoutineScheduler(deps: RoutineSchedulerDeps) { + const now = deps.now ?? (() => new Date()); + let tickInFlight = false; + + async function tick(): Promise { + if (tickInFlight) return; + tickInFlight = true; + try { + await tickRoutineScheduler(deps, now()); + } 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..2b8e9368a --- /dev/null +++ b/apps/hub/test/cron-due.test.ts @@ -0,0 +1,118 @@ +// 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 { isValidCronExpression } from "@corbits/routines"; +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("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")); + 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..53bb6e096 --- /dev/null +++ b/apps/hub/test/routine-mount.test.ts @@ -0,0 +1,56 @@ +// 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 }, + socialProviders: {}, +}; + +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/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"); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 2583294a0..00bc7ce72 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "@corbits/chat": "workspace:*", "@corbits/chat-ui": "workspace:*", "@corbits/command-palette": "workspace:*", + "@corbits/routines": "workspace:*", "@corbits/settings-ui": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#4b8952c820b44dbf83b423b97a9ad0f4513df1e0", "@intx/types": "workspace:*", 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..3c1857086 --- /dev/null +++ b/apps/web/src/routine-trigger.ts @@ -0,0 +1,90 @@ +// 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. +// +// 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 = [ + "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}`; + } +} + +/** 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}`; + } +} + +/** + * 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, 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; + try { + return nextCronFireAfter(cronExpressionForPreset(trigger), now); + } catch { + return null; + } +} 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", 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..ddc226e9b --- /dev/null +++ b/apps/web/test/routine-trigger.test.ts @@ -0,0 +1,102 @@ +// 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 when now sits on a boundary", () => { + 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("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); + 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"); + }); +}); diff --git a/bun.lock b/bun.lock index ee1f85531..c46bd0074 100644 --- a/bun.lock +++ b/bun.lock @@ -25,12 +25,13 @@ "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/folded-runs": "workspace:*", - "@corbits/schedules": "workspace:*", + "@corbits/routines": "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:*", @@ -89,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", @@ -338,21 +340,16 @@ "typescript": "catalog:", }, }, - "packages/schedules": { - "name": "@corbits/schedules", + "packages/routines": { + "name": "@corbits/routines", "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", + "hono": "catalog:", "postgres": "catalog:", }, "devDependencies": { @@ -732,7 +729,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", @@ -833,7 +829,7 @@ "@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"], @@ -1251,8 +1247,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/docs/GLOSSARY.md b/docs/GLOSSARY.md index c001586b2..dcb8eb9c3 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 | @@ -33,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/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/routines/README.md b/packages/routines/README.md new file mode 100644 index 000000000..8f635eee9 --- /dev/null +++ b/packages/routines/README.md @@ -0,0 +1,94 @@ +# @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. + +## 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. diff --git a/packages/schedules/package.json b/packages/routines/package.json similarity index 57% rename from packages/schedules/package.json rename to packages/routines/package.json index e57128bbc..c6b588305 100644 --- a/packages/schedules/package.json +++ b/packages/routines/package.json @@ -1,30 +1,25 @@ { - "name": "@corbits/schedules", + "name": "@corbits/routines", "private": true, - "description": "Scheduled workflow automations: cron/interval-triggered launches of deployed workflow definitions", + "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", - "./migrations": "./src/migrations.ts" + "./cron": "./src/cron.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", + "hono": "catalog:", "postgres": "catalog:" }, "devDependencies": { 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 new file mode 100644 index 000000000..e6c0fd742 --- /dev/null +++ b/packages/routines/src/index.ts @@ -0,0 +1,39 @@ +export const ROUTINES_PACKAGE_NAME = "@corbits/routines"; + +export { + RoutineTrigger, + isValidCronExpression, + cronExpressionForTrigger, + computeNextFireAt, + cronMatchesMinute, + minuteKey, +} from "./trigger"; +export type { RoutineTriggerT } from "./trigger"; +export { nextCronFireAfter } from "./cron"; + +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..ec152ef6c --- /dev/null +++ b/packages/routines/src/migrations.ts @@ -0,0 +1,112 @@ +// 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. +// +// 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"; + +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, + "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() + ); + 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, + "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..1f9c908df --- /dev/null +++ b/packages/routines/src/routes.ts @@ -0,0 +1,327 @@ +// 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"); + // 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); + } + 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..790906237 --- /dev/null +++ b/packages/routines/src/schema.ts @@ -0,0 +1,79 @@ +// 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(), + // 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(), + 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..59bda04cc --- /dev/null +++ b/packages/routines/src/store.ts @@ -0,0 +1,518 @@ +// 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, isNull, lte } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { generateId } from "@intx/hub-common"; + +import { routine, routineRun } from "./schema"; +import { computeNextFireAt, 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 nextFireAt: Date | null; + readonly lastFireAt: Date | null; + readonly deletedAt: Date | null; + 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; + /** `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 + * 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; + /** + * 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; + /** + * 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. + * + * 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; +} + +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: generateId("instance"), + 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, + nextFireAt: computeNextFireAt(input.trigger, now), + lastFireAt: null, + deletedAt: null, + 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), + isNull(routine.deletedAt), + ), + ) + .limit(1); + return row as RoutineRow | undefined; + }, + + async getRoutineIncludingDeleted(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(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, + ...(recomputeNextFire + ? { + nextFireAt: mergedEnabled + ? computeNextFireAt(mergedTrigger, now) + : null, + } + : {}), + updatedAt: now, + }) + .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 + .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) { + // 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, claimedNextFireAt) { + await db + .update(routine) + .set({ nextFireAt: revertNextFireAt }) + .where( + and( + eq(routine.id, routineId), + eq(routine.nextFireAt, claimedNextFireAt), + ), + ); + }, + + 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: generateId("instance"), + 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, + nextFireAt: computeNextFireAt(input.trigger, now), + lastFireAt: null, + deletedAt: null, + createdAt: now, + updatedAt: now, + }; + routinesById.set(row.id, row); + return row; + }, + + 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.deletedAt === null, + ); + }, + + async updateRoutine(tenantId, routineId, patch) { + const existing = routinesById.get(routineId); + if ( + existing === undefined || + existing.tenantId !== tenantId || + existing.deletedAt !== null + ) { + 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.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 || + existing.deletedAt !== null + ) { + return false; + } + 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 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, + }); + }, + + 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..8add0e720 --- /dev/null +++ b/packages/routines/src/trigger.ts @@ -0,0 +1,92 @@ +// 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"; + +import { isValidCronExpression, nextCronFireAfter } from "./cron"; + +export { isValidCronExpression, cronMatchesMinute, minuteKey } from "./cron"; + +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; + } +} + +/** + * 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); +} diff --git a/packages/routines/test/migrations.test.ts b/packages/routines/test/migrations.test.ts new file mode 100644 index 000000000..1ab4c5f79 --- /dev/null +++ b/packages/routines/test/migrations.test.ts @@ -0,0 +1,120 @@ +// 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 { 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_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(); + } + }); + + // 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.length).toBeGreaterThan(0); + + const second = await applyRoutineMigrations(scratchUrl); + expect(second.applied).toEqual([]); + expect(second.alreadyApplied.sort()).toEqual(first.applied.sort()); + + 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", + ]); + + 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 sql.end(); + } + }); + + test("a routine created on a freshly migrated database ends up fireable", async () => { + const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); + try { + 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", + }); + + 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 sql.end(); + } + }); +}); diff --git a/packages/routines/test/routes.test.ts b/packages/routines/test/routes.test.ts new file mode 100644 index 000000000..31b15bd73 --- /dev/null +++ b/packages/routines/test/routes.test.ts @@ -0,0 +1,315 @@ +// 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 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: { + 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/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(); + } + }); + }, +); diff --git a/packages/routines/test/store.test.ts b/packages/routines/test/store.test.ts new file mode 100644 index 000000000..a7988b2a3 --- /dev/null +++ b/packages/routines/test/store.test.ts @@ -0,0 +1,208 @@ +// 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()); + }); + + 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(), + ); + }); +}); diff --git a/packages/routines/test/trigger.test.ts b/packages/routines/test/trigger.test.ts new file mode 100644 index 000000000..32e0e1d31 --- /dev/null +++ b/packages/routines/test/trigger.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; + +import { + RoutineTrigger, + computeNextFireAt, + 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); + }); + + 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", () => { + 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"); + }); +}); + +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"); + }); +}); diff --git a/packages/schedules/tsconfig.json b/packages/routines/tsconfig.json similarity index 100% rename from packages/schedules/tsconfig.json rename to packages/routines/tsconfig.json 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/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"); }); 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 }, ];