-- v1.2 Workflows (blueprint §9.1/§9.2): a durable DAG orchestration engine. -- -- A `workflow` is a declarative graph of steps; each step invokes a function -- (a script, by name, resolved in the app's scope) or a nested sub-workflow, -- with `depends_on` edges, a per-step `when` condition, input mapping, and a -- per-step retry + `on_error` policy. A `workflow::start(name, input)` (or the -- admin run API) creates a RUN; a dedicated background orchestrator advances it -- step-by-step, durably, surviving restarts. -- -- Three tables: -- * workflows — the definition (owner-polymorphic like scripts/0050: -- app XOR group; M1 authors app-owned only, the group_id -- column ships unused so group-owned templates stay a -- door-open follow-up). -- * workflow_runs — one row per start(); the durable run record. -- * workflow_run_steps — one row per step per run; carries the competing- -- consumer lease (claim_token/claimed_at) exactly like -- queue_messages (0034), so parallel steps complete -- lock-free and a crashed worker's step is reclaimable. -- --------------------------------------------------------------------------- -- workflows: the definition (owner-polymorphic, mirrors scripts/0050). -- --------------------------------------------------------------------------- CREATE TABLE workflows ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- exactly one of app_id / group_id (the exactly-one CHECK below). Code is -- not data, so RESTRICT (a group can't be deleted out from under a workflow -- it owns), mirroring scripts. app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE, group_id UUID NULL REFERENCES groups(id) ON DELETE RESTRICT, name TEXT NOT NULL, -- the parsed DAG: { "steps": [ { name, function|workflow, input, depends_on, -- when, retry, on_error } ] }. Validated server-side (acyclic, unique step -- names, deps exist) before it is ever written. definition JSONB NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT workflows_owner_exactly_one CHECK ((group_id IS NULL) <> (app_id IS NULL)) ); -- Per-owner, case-insensitive name uniqueness (partial, one per owner column). CREATE UNIQUE INDEX workflows_app_name_uidx ON workflows (app_id, LOWER(name)) WHERE app_id IS NOT NULL; CREATE UNIQUE INDEX workflows_group_name_uidx ON workflows (group_id, LOWER(name)) WHERE group_id IS NOT NULL; CREATE INDEX workflows_group_id_idx ON workflows (group_id) WHERE group_id IS NOT NULL; -- --------------------------------------------------------------------------- -- workflow_runs: one row per start(). Carries app_id NOT NULL (the data-plane -- invariant — every run-listing query filters by app without a join) even -- though it is derivable from the workflow. -- --------------------------------------------------------------------------- CREATE TABLE workflow_runs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE RESTRICT, app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'canceled')), input JSONB NOT NULL DEFAULT '{}'::jsonb, output JSONB NULL, error TEXT NULL, -- correlates every step execution in execution_logs under one id. root_execution_id UUID NOT NULL, -- nesting: a sub-workflow run links back to the parent step that spawned it. workflow_depth INT NOT NULL DEFAULT 0, parent_run_id UUID NULL REFERENCES workflow_runs(id) ON DELETE SET NULL, parent_step_id UUID NULL, started_at TIMESTAMPTZ NULL, finished_at TIMESTAMPTZ NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX workflow_runs_app_status_idx ON workflow_runs (app_id, status); CREATE INDEX workflow_runs_workflow_idx ON workflow_runs (workflow_id, created_at DESC); CREATE INDEX workflow_runs_parent_idx ON workflow_runs (parent_run_id) WHERE parent_run_id IS NOT NULL; -- --------------------------------------------------------------------------- -- workflow_run_steps: one row per step per run. The claim_token/claimed_at -- lease is the queue_messages (0034) competing-consumer pattern — the -- orchestrator claims a `ready` step FOR UPDATE SKIP LOCKED, and the reclaim -- task frees a stale lease so a crashed worker's step is retried. -- --------------------------------------------------------------------------- CREATE TABLE workflow_run_steps ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE, step_name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ready', 'running', 'succeeded', 'failed', 'skipped')), attempt INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 1, output JSONB NULL, error TEXT NULL, -- competing-consumer lease (mirrors queue_messages). claim_token UUID NULL, claimed_at TIMESTAMPTZ NULL, -- retry backoff / initial dispatch gate (NULL = due immediately once ready). next_attempt_at TIMESTAMPTZ NULL, -- set when this step is a nested sub-workflow: the child run it is waiting on. child_run_id UUID NULL REFERENCES workflow_runs(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (run_id, step_name) ); -- The orchestrator's claim scan: ready steps whose backoff gate has elapsed. -- (deliver-gate NOW() comparison is applied as a filter on the small partial -- set, as in queue_messages.) CREATE INDEX workflow_run_steps_claimable_idx ON workflow_run_steps (next_attempt_at) WHERE status = 'ready'; -- Advance re-eval + reclaim scans by run. CREATE INDEX workflow_run_steps_run_idx ON workflow_run_steps (run_id); -- Reclaim-task scan: leased steps whose claim is older than the visibility -- timeout (bounded by in-flight step count). CREATE INDEX workflow_run_steps_claimed_idx ON workflow_run_steps (claimed_at) WHERE claim_token IS NOT NULL;