import type { Effect } from "effect"; import { Data } from "effect"; // --------------------------------------------------------------------------- // WorkflowRunner — durable workflow execution with an event-sourced journal. // // Cloudflare Workflows semantics verbatim: `step.do(name, fn)` memoized by the // journal, `step.sleep`, `step.waitForEvent`, plus executor's `step.tool` // (journal + audit in one). Kill mid-step -> restart -> completed steps do NOT // re-execute (replay reads the journal). The self-hosted backing is a SQLite // journal replay runner + in-process scheduler; the cloud backing (future) is // CF Workflows + dynamic-workflows. The journal shape is modeled on // vercel/workflow's World Storage contract (append-only events, materialized // run/step views). // // SUBSTRATE-NEUTRALITY (Fix 3): the runner is driven by DATA, not closures. The // author's workflow body runs INSIDE the ToolSandbox (QuickJS), the same // isolation the tool handlers use, and every `step.*` / `db.sql` call crosses a // serializable `WorkflowBridge` the host services against the journal. So // `start`/`resume`/`signal` take (scope, workflow, snapshotId, entryPath, // input) — never an `execute(steps)` closure — and the whole seam can back onto // an RPC (CF Workflows) unchanged. A `WorkflowDriver` (see below) is the one // injected collaborator: it loads the pinned bundle and runs one replay in the // sandbox behind the bridge. // --------------------------------------------------------------------------- export class WorkflowError extends Data.TaggedError("WorkflowError")<{ readonly message: string; readonly cause?: unknown; }> {} export type RunStatus = "running" | "sleeping" | "waiting" | "completed" | "failed" | "cancelled"; export interface RunView { readonly runId: string; readonly scope: string; readonly workflow: string; readonly snapshotId: string; readonly status: RunStatus; readonly input: unknown; readonly output?: unknown; readonly error?: string; readonly createdAt: number; readonly updatedAt: number; } export type StepStatus = "completed" | "failed"; export interface StepView { readonly runId: string; readonly name: string; readonly status: StepStatus; readonly output?: unknown; readonly error?: string; readonly attempt: number; readonly completedAt: number; } // --------------------------------------------------------------------------- // The serializable step bridge. This is the ONE channel between the sandboxed // workflow body and the host journal. Every op and its result is JSON (the // cloud backing is an RPC), so the seam forbids passing closures or live // objects. The host (the SQLite backing) services each op against the journal: // a completed step returns its recorded value WITHOUT re-executing; a new step // records and returns; sleep/waitForEvent return a structured suspend. // --------------------------------------------------------------------------- /** One op the sandboxed body asks the host to service. `kind` discriminates. */ export type WorkflowBridgeOp = // `step.do`: the host reports whether the step is journaled. When NOT // journaled the sandbox runs the author `fn` locally and reports its value via // `step.record`. (The fn body cannot cross the boundary, so it stays in the // sandbox; only the value it produced is recorded host-side.) | { readonly kind: "step.check"; readonly step: string } | { readonly kind: "step.record"; readonly step: string; readonly value: unknown } // `step.tool`: journaled? return value : run the bound tool (journal + audit), // record, return. | { readonly kind: "step.tool"; readonly step: string; readonly address: string; readonly args: unknown; } // `step.sleep`: schedule + suspend the first time; complete when due. | { readonly kind: "step.sleep"; readonly step: string; readonly ms: number } // `step.waitForEvent`: return the delivered payload or suspend awaiting it. | { readonly kind: "step.waitForEvent"; readonly step: string } // `step.notify`: a memoized best-effort side channel. | { readonly kind: "step.notify"; readonly msg: { title: string; body?: string; link?: string } } // `db.sql`: a scope-db statement between steps (parameterized). | { readonly kind: "db.sql"; readonly sql: string; readonly params: readonly unknown[] }; /** A structured suspension marker (never a string-matched error). The runner * reads `kind` to set the run's sleeping/waiting state. */ export type SuspendMarker = | { readonly suspend: "sleep" } | { readonly suspend: "event"; readonly event: string }; /** A step-level error the host reports back to the sandbox body (e.g. a * `step.tool` whose bound tool threw). `retryable` is a typed discriminator so * the body / runner never string-match: a retryable step error leaves the run * re-drivable, a fatal one fails it. */ export interface StepErrorMarker { readonly error: { readonly message: string; readonly retryable: boolean; readonly retryAfter?: number; }; } /** The result the host bridge returns for one op: a plain value, a structured * suspend, or a structured step error (all JSON, no thrown control flow). */ export type WorkflowBridgeResult = { readonly value: unknown } | SuspendMarker | StepErrorMarker; /** The serializable bridge the sandboxed body calls out through. */ export interface WorkflowBridge { readonly call: (op: WorkflowBridgeOp) => Effect.Effect; } /** The typed outcome of ONE sandboxed replay of a workflow body. Structured, so * the runner never string-matches: a `suspended` outcome carries the marker; a * `failed` outcome carries a typed retryable-vs-fatal discriminator. */ export type DriveOutcome = | { readonly status: "completed"; readonly output: unknown } | { readonly status: "suspended"; readonly marker: SuspendMarker } | { readonly status: "failed"; readonly retryable: boolean; readonly message: string; readonly retryAfter?: number; }; /** How `step.tool` and `step.notify` reach the outside world for a run: bound to * the run's snapshot + scope + the real tool-invoke/audit path. Everything they * take/return is JSON. The host calls them only for a step NOT yet journaled, * so they run at most once per step across replays. */ export interface WorkflowBindings { readonly runTool: (address: string, args: unknown) => Promise; readonly notify: (msg: { readonly title: string; readonly body?: string; readonly link?: string; }) => Promise; /** Run a parameterized scope-db statement for a `db.sql` op in the body. * Returns the result rows as JSON. Optional: a runner may omit db access. */ readonly runDb?: (scope: string, sql: string, params: readonly unknown[]) => Promise; } /** * The DATA-driven driver the runner uses to execute one replay. It loads the * pinned workflow bundle (from the snapshot) and runs it inside the ToolSandbox * behind the `WorkflowBridge`, returning a structured `DriveOutcome`. This is * the seam that keeps the orchestrator substrate-neutral: it takes the run's * identity + input + a JSON bridge, never a closure over host objects. */ export interface WorkflowDriver { readonly drive: ( input: { readonly scope: string; readonly workflow: string; readonly snapshotId: string; readonly entryPath: string; readonly input: unknown; }, bridge: WorkflowBridge, ) => Effect.Effect; } export interface StartRunInput { readonly scope: string; readonly workflow: string; readonly snapshotId: string; /** The workflow entry path within the snapshot, e.g. `workflows/morning-sync.ts`. */ readonly entryPath: string; readonly input: unknown; /** Optional caller-supplied run id (idempotent starts / scheduler dedupe). */ readonly runId?: string; /** * The caller's connection bindings, persisted with the run so a later * `resume`/`signal` re-drives with the SAME bindings the run started with (not * empty defaults). Opaque JSON to the runner; the caller (AppsRuntime) reads it * back via `getPersisted` to rebuild the real `WorkflowBindings`. Pinning it * here, alongside the pinned `snapshotId`, is what makes a suspended run resume * against the exact code + credentials it began with. */ readonly persistedBindings?: unknown; } /** The persisted, snapshot-pinned facts about a run needed to faithfully resume * it: its pinned snapshot, entry path, and the bindings captured at start. */ export interface PersistedRun { readonly runId: string; readonly scope: string; readonly workflow: string; readonly snapshotId: string; readonly entryPath: string; readonly persistedBindings?: unknown; } /** * The durable runner. `start` creates a run and drives it (in the sandbox) to * its first suspension or completion. `resume` re-drives a suspended/interrupted * run from the journal (this is what makes the kill test pass: completed steps * replay from the journal and never re-execute). `signal` delivers a * waitForEvent payload. `get`/`listSteps` are the queryable status/history. * * All of these take DATA — the workflow identity is enough, the runner loads the * bundle and runs it via the injected `WorkflowDriver`. `bindings` is how * `step.tool` / `step.notify` reach the real invoke path for THIS run. */ export interface WorkflowRunner { readonly start: ( input: StartRunInput, bindings: WorkflowBindings, ) => Effect.Effect; readonly resume: ( runId: string, bindings: WorkflowBindings, ) => Effect.Effect; readonly signal: ( runId: string, eventName: string, payload: unknown, bindings: WorkflowBindings, ) => Effect.Effect; readonly cancel: (runId: string) => Effect.Effect; readonly get: (runId: string) => Effect.Effect; /** The snapshot-pinned facts (snapshot, entry path, start-time bindings) for a * run, so a resume rebuilds bindings + descriptor from EXACTLY what the run * started with rather than the latest publish. */ readonly getPersisted: (runId: string) => Effect.Effect; readonly list: (filter?: { readonly scope?: string; readonly workflow?: string; }) => Effect.Effect; readonly listSteps: (runId: string) => Effect.Effect; readonly close: () => Effect.Effect; } /** Control errors that steer retry/replay, matching CF's vocabulary. Surfaced * from the sandboxed body via the typed `DriveOutcome.retryable` discriminator, * never by string-matching an error message. */ export class FatalError extends Data.TaggedError("FatalError")<{ readonly message: string; }> {} export class RetryableError extends Data.TaggedError("RetryableError")<{ readonly message: string; readonly retryAfter?: number; }> {}