Files
PiCloud/crates/manager-core/migrations/0071_workflows.sql
MechaCat02 44f992cbb0 feat(workflows): M1 — schema + definition validation + declarative reconcile
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>
2026-07-12 16:46:32 +02:00

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;