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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user