First milestone of the v1.2 Workflows track (blueprint §9.1/§9.2): the durable
DAG engine's foundation — the definition model, its validation, and the
declarative `apply` reconcile path. No execution yet (the orchestrator is M2).
- Migration 0071_workflows: `workflows` (owner-polymorphic like scripts, app-
owned in M1), `workflow_runs`, and `workflow_run_steps` (the per-step
competing-consumer lease table the M2 orchestrator will claim). Schema golden
reblessed.
- `shared::workflow`: the `WorkflowDefinition` / `WorkflowStepDef` DTOs (shared
by the CLI, apply, and the future orchestrator) + run/step status enums.
- Pure, DB-free `workflow_template` (input `{{ input.x }}` / `{{ steps.a.output.y }}`
resolution, type-preserving) and `workflow_expr` (a small safe JSON-predicate
evaluator for `when` — keeps manager-core free of a scripting engine).
- `workflow_repo`: read trait + reconcile tx free-fns (insert/update/delete).
- apply_service: `BundleWorkflow` + `Plan.workflows` + `CurrentState.workflows`
+ `ApplyReport.workflows_*`; server-side `validate_workflow_definition`
(unique/acyclic steps via topological sort, deps exist, function XOR workflow,
`when`/template parse) rejecting on a group node; `diff_workflows` by
lower(name) (Update on a definition change) + in-tx reconcile.
- CLI: `[[workflows]]` + `[[workflows.steps]]` manifest structs → wire bundle;
plan/apply render + report counts.
- Tests: 16 manager-core lib tests (template, expr, definition validation) +
the `workflows` CLI journey (apply → NoOp → prune; cyclic DAG rejected).
Deferred to later milestones: the orchestrator worker (M2), conditional/template
runtime wiring (M3), nested sub-workflows (M4), the SDK/API/CLI run surface
(M5), the dashboard (M6). The `group_id` column + run/step tables ship now so
those slot in without a migration churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
6.5 KiB
SQL
121 lines
6.5 KiB
SQL
-- 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;
|