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>
This commit is contained in:
MechaCat02
2026-07-12 16:46:32 +02:00
parent c8c4f012ff
commit 44f992cbb0
18 changed files with 2059 additions and 3 deletions

View File

@@ -468,6 +468,48 @@ table: vars
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: workflow_run_steps
id: uuid NOT NULL default=gen_random_uuid()
run_id: uuid NOT NULL
step_name: text NOT NULL
status: text NOT NULL default='pending'::text
attempt: integer NOT NULL default=0
max_attempts: integer NOT NULL default=1
output: jsonb NULL
error: text NULL
claim_token: uuid NULL
claimed_at: timestamp with time zone NULL
next_attempt_at: timestamp with time zone NULL
child_run_id: uuid NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: workflow_runs
id: uuid NOT NULL default=gen_random_uuid()
workflow_id: uuid NOT NULL
app_id: uuid NOT NULL
status: text NOT NULL default='pending'::text
input: jsonb NOT NULL default='{}'::jsonb
output: jsonb NULL
error: text NULL
root_execution_id: uuid NOT NULL
workflow_depth: integer NOT NULL default=0
parent_run_id: uuid NULL
parent_step_id: uuid NULL
started_at: timestamp with time zone NULL
finished_at: timestamp with time zone NULL
created_at: timestamp with time zone NOT NULL default=now()
table: workflows
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NULL
group_id: uuid NULL
name: text NOT NULL
definition: jsonb NOT NULL
enabled: boolean NOT NULL default=true
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
## indexes
indexes on abandoned_executions:
@@ -709,6 +751,25 @@ indexes on vars:
vars_group_uidx: public.vars USING btree (group_id, environment_scope, key) WHERE (group_id IS NOT NULL)
vars_pkey: public.vars USING btree (id)
indexes on workflow_run_steps:
workflow_run_steps_claimable_idx: public.workflow_run_steps USING btree (next_attempt_at) WHERE (status = 'ready'::text)
workflow_run_steps_claimed_idx: public.workflow_run_steps USING btree (claimed_at) WHERE (claim_token IS NOT NULL)
workflow_run_steps_pkey: public.workflow_run_steps USING btree (id)
workflow_run_steps_run_id_step_name_key: public.workflow_run_steps USING btree (run_id, step_name)
workflow_run_steps_run_idx: public.workflow_run_steps USING btree (run_id)
indexes on workflow_runs:
workflow_runs_app_status_idx: public.workflow_runs USING btree (app_id, status)
workflow_runs_parent_idx: public.workflow_runs USING btree (parent_run_id) WHERE (parent_run_id IS NOT NULL)
workflow_runs_pkey: public.workflow_runs USING btree (id)
workflow_runs_workflow_idx: public.workflow_runs USING btree (workflow_id, created_at DESC)
indexes on workflows:
workflows_app_name_uidx: public.workflows USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
workflows_group_id_idx: public.workflows USING btree (group_id) WHERE (group_id IS NOT NULL)
workflows_group_name_uidx: public.workflows USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
workflows_pkey: public.workflows USING btree (id)
## constraints
constraints on abandoned_executions:
@@ -967,6 +1028,26 @@ constraints on vars:
[FOREIGN KEY] vars_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] vars_pkey: PRIMARY KEY (id)
constraints on workflow_run_steps:
[CHECK] workflow_run_steps_status_check: CHECK ((status = ANY (ARRAY['pending'::text, 'ready'::text, 'running'::text, 'succeeded'::text, 'failed'::text, 'skipped'::text])))
[FOREIGN KEY] workflow_run_steps_child_run_id_fkey: FOREIGN KEY (child_run_id) REFERENCES workflow_runs(id) ON DELETE SET NULL
[FOREIGN KEY] workflow_run_steps_run_id_fkey: FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
[PRIMARY KEY] workflow_run_steps_pkey: PRIMARY KEY (id)
[UNIQUE] workflow_run_steps_run_id_step_name_key: UNIQUE (run_id, step_name)
constraints on workflow_runs:
[CHECK] workflow_runs_status_check: CHECK ((status = ANY (ARRAY['pending'::text, 'running'::text, 'succeeded'::text, 'failed'::text, 'canceled'::text])))
[FOREIGN KEY] workflow_runs_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] workflow_runs_parent_run_id_fkey: FOREIGN KEY (parent_run_id) REFERENCES workflow_runs(id) ON DELETE SET NULL
[FOREIGN KEY] workflow_runs_workflow_id_fkey: FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE RESTRICT
[PRIMARY KEY] workflow_runs_pkey: PRIMARY KEY (id)
constraints on workflows:
[CHECK] workflows_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[FOREIGN KEY] workflows_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] workflows_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
[PRIMARY KEY] workflows_pkey: PRIMARY KEY (id)
## applied migrations
0001: init
0002: sandbox
@@ -1038,3 +1119,4 @@ constraints on vars:
0068: group dead letters
0069: email secret version
0070: admin session absolute expiry
0071: workflows