Skip to main content

yah_qed/
types.rs

1//! @yah:relay(R623, "Migrate QED run history from .yah/jit/qed/*.json to turso")
2//! @yah:assignee(bundle-anthropic-miravel)
3//! @yah:kind(task)
4//! @yah:at(2026-07-23T03:17:22Z)
5//! @yah:gotcha("QED run history is the ODD ONE OUT: task-runs, task-sessions and gnome_queue are all turso-backed (.yah/db/*.turso), but qed runs persist as flat per-run files in .yah/jit/qed/ — <run_id>.json + <run_id>.events.jsonl, 342 of them as of 2026-07-21.")
6//! @yah:next("Migrate persist_qed_run + load_qed_history (app/yah/cli/src/camp.rs) onto a turso store, following the task-runs store shape (oss/qed/crates/task-runs/src/store.rs).")
7//! @yah:next("Needs a migration story for the 342 existing run files — import-on-boot or a one-shot, not a silent drop.")
8//! @yah:next("NOT a blocker for R622 (manual steps). R622's parked-run durability is satisfied by a non-terminal JSON persist reusing the R603 pattern, which a turso migration would carry over anyway. Deliberately decoupled — see W282 'The turso question is real, but separate'.")
9//!
10//! @yah:relay(R622, "QED manual steps: pipelines with a human in the middle (release wizard)")
11//! @yah:status(open)
12//! @yah:at(2026-07-21T18:30:00Z)
13//! @arch:see(.yah/docs/working/W282-qed-manual-steps.md)
14//! @yah:gotcha("runner.rs:20 says 'all qed runs are still in-memory (run-history persistence is R325-F3)'. That is STALE — R325-F3 landed and sits at status(review) on this file. Real state: terminal runs persist as .yah/jit/qed/<run_id>.json + .events.jsonl (342 files today, NOT turso); in-flight runs are not durable in general (camp.rs:851), except R603's non-terminal persist on StepRemoteDispatched. Fix that comment as part of this work.")
15//! @yah:gotcha("A manual step is a COORDINATION point, not an authorization gate. It does not know who clicked Continue. Do not let it grow into a permissions system — that is an explicit non-goal in W282.")
16//! @yah:next("T1: StepKind::Manual + [manual] config block (prompt/terminal/advance/checklist) + validate() rejecting argv, mirroring StepKind::WaitFor which is the existing pure-gate precedent.")
17//! @yah:next("T2: RunStatus::AwaitingHuman + extend RunStatus::aggregate. Add cases to the existing run_status_aggregate_* decision-table tests rather than reasoning about precedence in prose. AwaitingHuman is non-terminal, so like Queued/Running it contributes nothing to an aggregate.")
18//! @yah:next("T3: runner park/resume with LOCK RELEASE. Decided: a parked step releases its concurrency_key and reacquires on resume (Running -> AwaitingHuman -> Queued -> Running). Holding cargo-target through an overnight park would stall every cargo pipeline in the camp, including other agents on the shared tree. Consequence to handle: the tree can move while parked, so resume MUST re-evaluate `advance` rather than blindly continue.")
19//! @yah:next("T4: the human surface is the ANSWER QUEUE — do not build a second one. A manual step mints a Form (crates/yah/forms, W111) and parks on it. Durability then falls out free: .yah/forms/ already holds 7,383 durable camp-scoped forms, so the run only records form_id. Form's by/job/session_id/character are ALL Option and documented as None for scope-unaware callers, so a QED run (not a session, not a subclass) can legally mint one.")
20//! @yah:next("T5: wire — QedEvent::StepAwaitingHuman{index,name,form_id,advance} + QedEventWire kebab 'step-awaiting-human'. THE ONLY GENUINELY NEW MECHANISM: a new OnSubmit variant. Today OnSubmit::Continue routes the answer back to the form's `by` SUBCLASS, which a QED run does not have — needs OnSubmit::ResumeQedRun{run_id,step_index}. Keep qed.resume RPC as the headless path.")
21//! @yah:next("T6: UI is nearly free, and the highest-leverage item is ONE small fix. Markdown.tsx:439 already renders a RunInTerminalButton on any shell code-fence, which mounts an InlineTerminalPanel (ghostty-web) inline. But AnswerModal.tsx:2234 renders form.framing as PLAIN TEXT (<p className='mono ... whitespace-pre-wrap'>), so the chain breaks at the last link. Route framing through Markdown and a form carrying ```sh fences gets working run-buttons + inline terminals with no new UI code. It improves every existing form too — a hint it is the right change, not a carve-out.")
22//! @yah:next("T7: author .yah/qed/release-wizard.toml composing version-bump + oss-publish (both exist, R620) via sub-pipeline steps, with manual steps between. Do not duplicate their steps.")
23//! @yah:next("OPEN QUESTIONS (W282): park timeout (leaning none, but pair park with a party.notify or runs get forgotten); desktop notification on park; whether validate() should reject manual steps on --where=remote or force host-native like SignNativeTarball.")
24//!
25//! @yah:ticket(R325-F3, "Backend: run-history persistence — QedRunId + step results queryable")
26//! @yah:at(2026-05-26T04:09:53Z)
27//! @yah:status(review)
28//! @yah:phase(P3)
29//! @yah:parent(R325)
30//! @yah:depends_on(R325-F1)
31//! @yah:handoff("Landed run-history persistence (R325-F3). Terminal QedRunMeta (success/failed/cancelled) written to <camp_root>/.yah/jit/qed/<run_id>.json on each run's completion. Both qed_run_handler (background task, success+error paths) and qed_cancel_handler call persist_qed_run() — errors logged but never fatal. On daemon startup (both run_with_shutdown and make_daemon_state) load_qed_history() scans .yah/jit/qed/*.json and rehydrates qed_runs with empty event buffers and no abort handles (all historical runs are terminal). Events (stdout/stderr lines) are NOT persisted — history stores meta+step statuses only, matching the ticket's 'step results queryable' scope. 2 new tests: run_history_persists_and_reloads (happy path + reload) and cancelled_run_persists; all 11 r325 tests pass + 21 qed tests pass + cargo check clean.")
32//! @yah:next("R325-T4: Tauri commands exposing qed list/run/status/stream/history to desktop. The persistence store is now stable — qed.list and qed.status serve historical runs. qed.tail returns empty events for loaded-from-disk runs (events were in-memory only); that is expected.")
33//! @yah:verify("cargo test -p qed --lib")
34//! @yah:verify("cargo test -p yah --lib r325")
35//! @yah:verify("cargo check -p yah -p desktop")
36//!
37//! @yah:relay(R435, "QED recipe discipline rollout (W170)")
38//! @yah:at(2026-06-04T19:15:34Z)
39//! @yah:status(open)
40//! @arch:see(.yah/docs/working/W170-qed-recipe-discipline.md)
41//!
42//! @yah:ticket(R435-F1, "Add `placement` field to QED Pipeline schema (local-only / ci-only / anywhere)")
43//! @yah:assignee(agent:claude)
44//! @yah:at(2026-06-04T19:15:56Z)
45//! @yah:status(review)
46//! @yah:phase(P1)
47//! @yah:parent(R435)
48//! @yah:next("Add `placement: Placement` to the [pipeline] struct in types.rs with serde rename_all=\"kebab-case\"")
49//! @yah:next("Default to `anywhere` so existing recipes keep working")
50//! @yah:next("Surface in `yah qed list`/`tail` headers so operators see placement at a glance")
51//! @yah:next("Update the JSON schema (if any) so recipe authors get autocomplete")
52//! @yah:verify("cargo test -p qed --lib parses each enum variant via round-trip")
53//! @yah:verify("Existing recipes still load (default = anywhere) without edits")
54//! @arch:see(.yah/docs/working/W170-qed-recipe-discipline.md)
55//! @yah:handoff("F1 complete. Added `Placement` enum (`local-only` / `ci-only` / `anywhere`, default Anywhere) and `Pipeline.placement: Placement` (#[serde(default)]) in types.rs. PipelineConfig in config.rs mirrors the field and threads it through load_from_str/load_from_file. All 11 Pipeline struct literals (builtins.rs ×3, runner.rs ×7, types.rs test ×1) updated. New tests in types.rs::tests: placement_round_trip_each_variant, placement_defaults_to_anywhere_when_omitted, placement_parses_each_kebab_value_from_toml — 3/3 green. `cargo check --workspace` clean. Full qed lib suite: 156 pass; the single failure (test_builtin_release_build_pipeline, 4-vs-6 step assertion) is pre-existing and explicitly flagged in R380-T3's handoff — unrelated to this ticket. Existing recipes still load (placement omitted → defaults to Anywhere). No JSON schema exists for QED recipes (.yah/schema/ has no qed.toml.schema.json), so the 'update JSON schema' next-step was a no-op.")
56//! @yah:next("R435-F2 can start: runner gates kicks on placement (CLI refuses ci-only without --force; GHA warns/refuses local-only). Placement is now readable via `pipeline.placement` after `PipelineLoader::load(name)`.")
57//! @yah:cleanup("Surface `placement` in `yah qed list`/`tail` headers (deferred from F1's next-steps — purely cosmetic, easier to ship alongside F2 when the field becomes operationally relevant).")
58//!
59//! @yah:ticket(R476-T1, "Add outcomes + step names to qed.pipelines wire shape; drop static BUILTIN_DEFS reliance for outcome rendering")
60//! @yah:assignee(agent:claude)
61//! @yah:at(2026-06-07T08:24:17Z)
62//! @yah:status(review)
63//! @yah:parent(R476)
64//! @yah:next("Wire shape: extend WireQedPipeline (env/types.ts) with outcomes + step names; emit from QedRpc::pipelines (crates/yah/qed/src/lib.rs); drop the BUILTIN_DEFS wire-merge fallback path in QedPanel.tsx so user pipelines source outcomes from the wire instead of a static encoding")
65//! @yah:verify("Run a user pipeline (e.g. desktop-local) with outcomes declared in its TOML; switch to Graph tab during the run; mermaid renders terminal Outcome nodes for that user pipeline (not just built-ins)")
66//! @arch:see(.yah/docs/working/W191-qed-pipeline-ux-tweaks.md)
67//! @yah:handoff("Shipped across 4 files. (1) crates/yah/rpc/src/lib.rs: added QedOutcomeWire enum (yubaba-deploy/publish/almanac-run), QedArtifactStepWire struct, and three new fields on QedPipelineWire — step_names: Vec<String>, outcomes: Vec<QedOutcomeWire>, artifact_steps: Vec<QedArtifactStepWire> — all #[serde(default)]. (2) app/yah/cli/src/camp.rs: qed_pipelines_handler now populates step_names from pipeline.steps[].name, outcomes by matching qed::Outcome variants to QedOutcomeWire, and artifact_steps from steps[].produces with triple-aware display labels. (3) packages/yah/ui/src/env/types.ts: WireQedOutcome discriminated union + extended WireQedPipeline with step_names?, outcomes?, artifact_steps?. (4) packages/yah/ui/src/components/run/QedPanel.tsx: defs useMemo now builds wireSteps/wireOutcomes/wireArtifactSteps from the wire; user pipelines get full outcomes+steps in their PipelineDef; built-ins refresh all wire-authoritative fields with BUILTIN_DEFS as offline fallback. cargo check -p rpc -p yah -p desktop clean; bun run typecheck clean for touched files; bun test qedMermaid.test.ts 5/5.")
68//! @yah:verify("Run a user pipeline (e.g. desktop-local with on_success declared) — Graph tab renders terminal Outcome nodes matching the TOML declaration (not just built-ins)")
69//! @yah:verify("Built-in release-build Graph tab still renders 6 steps + WardenDeploy + Publish terminals (daemon wire takes precedence over BUILTIN_DEFS; BUILTIN_DEFS serves as fallback when daemon is down)")
70//!
71//! @yah:relay(R488, "QED pipeline composition: StepKind::SubPipeline primitive (W201)")
72//! @yah:at(2026-06-08T02:52:03Z)
73//! @yah:status(open)
74//! @yah:parent(Q486)
75//! @yah:next("F1-F5 ship value independently of W200; F6 is the join point that wires GhaWorkflow children into compositions")
76//! @yah:next("Marketing-site unblock path: ship F1+F2+F3 (composition + recursion + aggregation) so a full-release parent can wrap desktop-release (R330-F9) once R330-T6 ships the receiver")
77//! @yah:gotcha("v1 caps nesting depth at 4 with explicit cycle detection — accidental recursion in user TOML is the failure mode")
78//! @arch:see(.yah/docs/working/W201-qed-pipeline-composition.md)
79//!
80//! @yah:ticket(R487-F9, "StepKind::GhaWorkflow + QED runner dispatch (yah qed run release wraps release.yml end-to-end)")
81//! @yah:assignee(agent:claude)
82//! @yah:at(2026-06-08T02:53:47Z)
83//! @yah:status(review)
84//! @yah:phase(P9)
85//! @yah:parent(R487)
86//! @yah:next("Add StepKind::GhaWorkflow { path, event, inputs } to crates/yah/qed/src/types.rs")
87//! @yah:next("runner.rs: dispatch GhaWorkflow steps to yah_qed_gha::execute, collect GhaRunResult { status, produced, job_outputs }")
88//! @yah:next("ProducedArtifact aggregation flows into the outer pipeline's Outcome::Publish exactly like any other producing step")
89//! @yah:next("config.rs: TOML parse for the new step kind")
90//! @yah:verify("yah qed run release (single-step pipeline wrapping release.yml) executes locally and stages to cdn.yah.dev")
91//! @arch:see(.yah/docs/working/W200-qed-gha-action-overrides.md)
92//! @yah:depends_on(R487-F8)
93//! @yah:tier(Warrior)
94//! @yah:handoff("F9 landed: StepKind::GhaWorkflow first-class step kind + qed-runner dispatch + ProducedArtifact bridge. qed --lib: 200 pass (4 new) + 1 pre-existing failure (test_builtin_release_build_pipeline 4-vs-6, documented across R407-T1/R380-T3/R438-T14/R488-F1 handoffs — not introduced by F9). qed-gha: 88/88. cargo check -p yah clean. — types.rs: added StepKind::GhaWorkflow + GhaWorkflowConfig { path, event, inputs } + QedStep.gha_workflow: Option<GhaWorkflowConfig> (#[serde(default)] so existing TOML + literal sites unaffected; sed-inserted None on every QedStep init across builtins/runner/types/cli camp). Two new StepValidationError variants: GhaWorkflowHasArgv + GhaWorkflowMissingConfig (mirrors SubPipeline’s argv/config invariants). — runner.rs: new arm StepKind::GhaWorkflow → execute_step_gha_workflow(); reads workflow YAML at cfg.path (resolved against camp root), parses via yah_qed_gha::parse_workflow, builds yah_qed_gha::Executor with F5–F8 builtins pre-registered, lays inputs + a minimal github context (event_name only, ref/sha/actor empty) onto the executor, calls yah_qed_gha::execute_workflow on a tokio spawn_blocking so docker buildx / git clone / etc. don’t stall the reactor. Each yah_qed_gha::ProducedArtifact { binary, path, triple } lifts to qed::types::ProducedArtifact 1:1 (structurally compatible by F7 design); aggregation goes into the per-pipeline `produced` Vec exactly like a Subprocess `produces` declaration so Outcome::Publish stages them. First-failing-job is surfaced as a clean StepFailed with `gha-workflow <path> failed at job <id>`. — config.rs: LoaderSubPipelineResolver::resolve(SubPipelineRef::GhaWorkflow{path,event,inputs}) now synthesizes a one-step Pipeline carrying a single GhaWorkflow step instead of returning None. Going through SubPipeline preserves propagate.produces / suppress_publish_outcomes plumbing so a child workflow's R2 staging fires from the parent’s terminal publish, not the child’s. — lib.rs: re-exported GhaWorkflowConfig. — qed/Cargo.toml: qed-gha + indexmap path deps. — Tests: validate happy + 2 reject paths in types::tests, resolver synthesis test in config::tests; runner-level end-to-end is left to the integration verify (yah qed run release against a real .github/workflows/release.yml on a host with docker/git/rustup) since hermetic exec would require a stub workflow + an executor injection seam neither crate currently has.")
95//! @yah:next("User: verify F9 — (a) confirm the SubPipeline-synthesis route is the right shape vs a parallel resolver type (preserves propagate.produces + suppress_publish_outcomes for free; alternative was a bypass route that wouldn’t), (b) accept the minimal github-context synthesis (event_name + empty ref/sha/actor — release.yml reads github.ref_name + github.event.inputs.* and the latter comes from the inputs map, but a workflow that touches github.sha will see an empty string), and (c) run the integration verify when next on a host with docker/git/rustup/bun: `yah qed run release` against a release-build pipeline that wraps .github/workflows/release.yml via SubPipelineRef::GhaWorkflow and stages to cdn.yah.dev. After sign-off: archive R487 + R487-S10 (still in review) + R487-F4/F5/F6/F7/F8/F9, then archive R487 itself; R487-T11 (retire .yah/qed/build-yah-yubaba.toml) is the post-F9 cleanup ticket that closes the relay.")
96//!
97//! @yah:ticket(R488-F1, "SubPipeline types + TOML parser + cycle detection (depth-4 cap)")
98//! @yah:assignee(agent:claude)
99//! @yah:at(2026-06-08T02:53:55Z)
100//! @yah:status(review)
101//! @yah:phase(P1)
102//! @yah:parent(R488)
103//! @yah:next("Add StepKind::SubPipeline { target, params, propagate }, SubPipelineRef (Builtin | Path | GhaWorkflow), SubPipelineCollect { produces, outputs }")
104//! @yah:next("config.rs: parse target.builtin / target.path / target.gha-workflow shapes")
105//! @yah:next("Cycle detection by walking the resolution chain (open file path/builtin name set); reject at parse time")
106//! @yah:next("Depth cap at 4; clear error with the chain on overflow")
107//! @yah:verify("Round-trip TOML for all three SubPipelineRef shapes; cycle/depth rejections covered by tests")
108//! @arch:see(.yah/docs/working/W201-qed-pipeline-composition.md)
109//! @yah:tier(Cleric)
110//! @yah:handoff("F1 shipped. Added StepKind::SubPipeline (unit variant; existing Copy preserved) + SubPipelineConfig/Ref/Collect/Error types + validate_sub_pipeline_graph walker + SubPipelineResolver trait on crates/yah/qed/src/types.rs. QedStep grew sub_pipeline: Option<SubPipelineConfig> field (#[serde(default)] so existing TOML + 26 literal sites unaffected; sed-inserted None on every literal across builtins/runner/tests). Three new StepValidationError variants: SubPipelineHasArgv, SubPipelineMissingConfig, SubPipelineHasProduces. Runner gained a SubPipeline arm that returns StepFailed pointing at R488-F2 (execution lives there). MAX_SUB_PIPELINE_DEPTH = 4. Walker is parser-agnostic: takes a SubPipelineResolver, returns SubPipelineError::{Cycle,MaxDepthExceeded} with chain. Tests: 10 new in types::tests covering happy path validate, all three rejection arms, TOML round-trip for all three SubPipelineRef shapes (builtin/path/gha-workflow), acyclic walk, direct + indirect cycles, depth-limit, unresolved-ref tolerance. cargo test -p qed --lib: 181 pass + 1 pre-existing unrelated failure (test_builtin_release_build_pipeline 4-vs-6 step count flagged across R407-T1/R380-T3/R438-T14 handoffs).")
111//! @yah:next("F2 wires the runner side: replace the SubPipeline arm's StepFailed stub in runner.rs:489 with real recursion. Resolver wants .yah/qed/PipelineLoader (builtin + path) + GhaWorkflow returns None until W200-F9. Track nested QedRun with parent_run_id; forward params via Pipeline::apply_params; suppress child's on_success outcomes when propagate.produces=true (the suppression lives in run() before Outcome dispatch — child gets a runner constructed via with_publish_suppressed or equivalent setter). Cycle/depth check should fire ONCE at the outermost run() entry against the loader-backed resolver, not per-step.")
112//! @yah:next("F2 should also call validate_sub_pipeline_graph at the loader entry (PipelineLoader::validate_steps) once the loader-backed SubPipelineResolver impl exists — graceful parse-time cycle detection rather than runtime-only.")
113//! @yah:verify("cargo test -p qed --lib types::tests::sub_pipeline (4 tests)")
114//! @yah:verify("cargo test -p qed --lib types::tests::graph_walk (5 tests)")
115//! @yah:verify("cargo test -p qed --lib types::tests::sub_pipeline_round_trips_through_toml_with_all_three_ref_shapes")
116//!
117//! @yah:ticket(R488-F4, "Named output exposure: QED native steps grow output declarations, propagate.outputs surfaces them")
118//! @yah:assignee(agent:claude)
119//! @yah:at(2026-06-08T02:54:25Z)
120//! @yah:status(review)
121//! @yah:phase(P4)
122//! @yah:parent(R488)
123//! @yah:next("Add outputs: Vec<OutputDecl> to QedStep so native steps can name outputs the way GHA steps do")
124//! @yah:next("Child run's named outputs surface on the parent step as steps.<id>.outputs.<name>")
125//! @yah:next("Reuse W200's expression engine for parent-side substitution if W200-F2 has shipped; else stash for later wiring")
126//! @yah:verify("2-child composite where child 1 emits output X and child 2 step references ${{ steps.child1.outputs.X }}")
127//! @arch:see(.yah/docs/working/W201-qed-pipeline-composition.md)
128//! @yah:depends_on(R488-F3)
129//! @yah:tier(Cleric)
130//! @yah:handoff("F4 shipped. (1) types.rs: Added OutputDecl{name, description} struct; added outputs: Vec<OutputDecl> to QedStep (#[serde(default)] so all 28 existing literal sites + TOML unaffected); added outputs: HashMap<String,String> to StepStatus (#[serde(default)]). (2) runner.rs: Added substitute_step_context() fn (replaces ${{ steps.X.outputs.Y }} patterns, minimal — W200 expression engine subsumes later); added parse_yah_outputs() fn (reads KEY=VALUE file lines); modified execute_step_local to accept extra_env: Option<&HashMap> for $YAH_OUTPUTS injection without mutating the step; modified execute_step_sub_pipeline to return (Vec<ProducedArtifact>, HashMap<String,String>) — propagated_outputs scanned from child StepStatus::outputs filtered by propagate.outputs (last-writer-wins); modified run_inner to track step_context, apply substitution before each step, inject $YAH_OUTPUTS for Native subprocess steps + read back after exit, collect SubPipeline propagated outputs, store outputs in StepStatus. (3) lib.rs: re-exported OutputDecl. (4) 28 QedStep literal sites + 2 StepStatus sites updated across builtins.rs/runner.rs/types.rs/camp.rs. (5) 3 new tests: step_outputs_captured_in_step_status, step_outputs_substituted_into_sibling_argv (verify test: step2 receives ${{ steps.step1.outputs.X }} substituted), sub_pipeline_propagates_named_outputs_to_parent_context. cargo test -p qed --lib --test-threads=1: 194 pass + 1 pre-existing failure (test_builtin_release_build_pipeline 4-vs-6 steps). cargo check -p qed -p yah -p desktop: clean. Container/remote steps do not collect outputs (YAH_OUTPUTS not injected there — documented limitation).")
131//! @yah:verify("cargo test -p qed --lib -- --test-threads=1")
132//! @yah:verify("cargo check -p qed -p yah -p desktop")
133//!
134//! @yah:relay(R494, "QED cross-camp peer composition (W201 append)")
135//! @yah:at(2026-06-08T23:47:59Z)
136//! @yah:status(open)
137//! @yah:parent(Q486)
138//! @arch:see(.yah/docs/working/W201-qed-pipeline-composition.md)
139//!
140//! @yah:ticket(R494-F1, "SubPipelineRef::Peer variant + .yah/qed/peers.toml registry parser")
141//! @yah:assignee(agent:claude)
142//! @yah:at(2026-06-08T23:48:05Z)
143//! @yah:status(review)
144//! @yah:phase(P1)
145//! @yah:parent(R494)
146//! @arch:see(.yah/docs/working/W201-qed-pipeline-composition.md)
147//! @yah:tier(Cleric)
148//! @yah:handoff("F1 shipped. (1) types.rs: added SubPipelineRef::Peer { camp: String, pipeline: String } as a struct variant; serde rename_all=kebab-case gives TOML form `target = { peer = { camp = \"mesofact\", pipeline = \"release-build\" } }`. Extended sub_pipeline_ref_token + the test MapResolver match with the new arm — chain token is `peer:<camp>:<pipeline>`. (2) peers.rs (new module): PeerConfig { peer: HashMap<String, PeerEntry> } + PeerEntry { path: PathBuf, rig: Option<String> } + PeerConfigError. Modeled exactly on registries.rs — load `<qed_dir>/peers.toml` opportunistically, missing file → empty config, malformed → Parse error with path context. v1 rig field is parsed but ignored at resolution time (R494-T5 wires the unsupported-error stub; R494-F2 wires local resolution). (3) lib.rs: pub mod peers + re-exports PeerConfig/PeerConfigError/PeerEntry. (4) config.rs LoaderSubPipelineResolver: SubPipelineRef::Peer arm returns None — keeps cycle/depth detection working (walker stops descending) without compiling in any filesystem assumption about peer-camp layout. F2 replaces this with a peers.toml-backed lookup that loads the peer camp's PipelineLoader. (5) runner.rs sub_pipeline_target_label + the runner's test MapResolver: Peer arm added. (6) Tests: 4 new in peers::tests (missing file, local+remote parse, malformed, missing-path); types::tests::sub_pipeline_round_trips_through_toml_with_all_three_ref_shapes extended with the Peer shape (the name is now stale — 4 shapes — leaving the symbol untouched to avoid breaking the R488-F1 @yah:verify referencing it); new types::tests::graph_walk_detects_peer_cycle covering self-cycle via Peer ref. cargo test -p qed --lib: 206 pass + 1 pre-existing failure (test_builtin_release_build_pipeline 4-vs-6, documented across R407-T1/R380-T3/R438-T14/R488-F1 handoffs). cargo check -p qed -p yah -p desktop clean.")
149//! @yah:next("F2 (R494-F2) wires the runner: PeerSubPipelineResolver wraps a PeerConfig + parent loader, loads the peer camp's `.yah/qed/` PipelineLoader on demand, returns its loaded Pipeline. validate_sub_pipeline_graph at the outermost run() entry needs the peer-aware resolver so a peer cycle (cheers -> mesofact -> cheers) is caught at parse-time. Per-peer-camp run serialization: use a per-camp lock keyed by peers.toml entry id so two concurrent yah runs invoking `peer:cheers` don't race on cheers' target/.")
150//! @yah:next("F2 should call peer's PipelineLoader::load_and_validate_graph rather than load() so the child's own SubPipeline graph is walked too (catch a peer pipeline that itself references back into our camp via path).")
151//! @yah:next("T5 (R494-T5) reserves the rig field stub: LoaderSubPipelineResolver/PeerSubPipelineResolver's Peer arm checks `entry.rig.is_some()` and returns a typed error like `RemotePeerNotYetSupported { camp, rig }` rather than the current None. Surface clearly in CLI so operators don't get a silent skip.")
152//! @yah:verify("cargo test -p qed --lib peers::")
153//! @yah:verify("cargo test -p qed --lib types::tests::sub_pipeline_round_trips_through_toml_with_all_three_ref_shapes")
154//! @yah:verify("cargo test -p qed --lib types::tests::graph_walk_detects_peer_cycle")
155//! @yah:verify("cargo check -p qed -p yah -p desktop")
156//!
157//! @yah:ticket(R494-T5, "Reserve peers.toml rig= field; stub remote-peer hop with explicit unsupported error")
158//! @yah:assignee(agent:claude)
159//! @yah:at(2026-06-08T23:48:30Z)
160//! @yah:status(review)
161//! @yah:phase(P3)
162//! @yah:parent(R494)
163//! @arch:see(.yah/docs/working/W201-qed-pipeline-composition.md)
164//! @yah:depends_on(R494-F1)
165//! @yah:handoff("T5 shipped. Surfaces a typed reason on the R494-F1 remote-peer + unknown-peer paths so operators see an actionable message in StepFailed.msg instead of the generic 'target unresolvable' tail. (1) types.rs: SubPipelineResolver trait gained an optional `unresolved_reason(&SubPipelineRef) -> Option<String>` companion to `resolve` with a `None` default — preserves backward compat for the 3 existing impls (NoopSubPipelineResolver in runner.rs, MapResolver in types::tests + runner::tests). (2) config.rs LoaderSubPipelineResolver: impls unresolved_reason for Peer targets only; three branches — unknown camp routes to peers.toml with a copy-pasteable `[peer.<camp>]` skeleton; remote peer (entry.rig.is_some()) cites the camp + rig + R494-T5 and tells the operator to drop the `rig = ...` field or wait for R494-F10; known camp + missing pipeline names the resolved peer-camp path. Builtin/Path/GhaWorkflow return None (those misses already have their own surfaces). (3) runner.rs execute_step_sub_pipeline: when resolve returns None, query unresolved_reason and put it in StepFailed.msg verbatim; falls back to the previous debug-formatted message when the resolver doesn't diagnose. (4) Tests: 4 new in config::tests (typed remote-peer reason + camp/rig/ticket-id assertions; unknown-camp routes to peers.toml; missing-pipeline names the pipeline + camp; non-peer targets keep None). 1 new in runner::tests (DiagnosticResolver fixture + assertion that StepFailed.msg matches the resolver's typed message verbatim). The pre-existing `peer_resolver_swallows_remote_peers_until_t5_wires_constable` test was renamed to `peer_resolver_remote_peer_surfaces_typed_unsupported_reason` and extended. cargo test -p qed --lib: 218 pass + 1 pre-existing failure (test_builtin_release_build_pipeline 4-vs-6, documented across R488/R494 handoffs). cargo check -p qed -p yah -p desktop clean.")
166//! @yah:verify("cargo test -p qed --lib -- peer_resolver_remote_peer_surfaces peer_resolver_unknown_camp peer_resolver_unknown_pipeline peer_resolver_unresolved_reason_is_none sub_pipeline_unresolved_surfaces (5/5 pass)")
167//! @yah:verify("cargo check -p qed -p yah -p desktop")
168
169use chrono::{DateTime, Utc};
170use serde::{Deserialize, Serialize};
171use std::collections::HashMap;
172use velveteen::TaskRuntime;
173
174pub type QedRunId = String;
175pub type ForgeId = String;
176
177/// Schema for a pipeline-manifest field whose type is dynamic or lives in a
178/// crate we deliberately don't pull `schemars` through (`matrix::MatrixSpec`'s
179/// `toml::Value` blobs, `task::TaskRuntime`, the `manifest-bind` bind/value
180/// types). Accepts any JSON so the generated `qed-pipeline.toml.schema.json`
181/// stays permissive there rather than forcing a derive across those edges.
182/// (R533-T10; tightening these to precise sub-schemas is a tracked follow-up.)
183#[cfg(feature = "json-schema")]
184pub(crate) fn permissive_schema(
185    _gen: &mut schemars::gen::SchemaGenerator,
186) -> schemars::schema::Schema {
187    schemars::schema::Schema::Bool(true)
188}
189
190/// Mint a fresh [`QedRunId`]. Same shape (`Uuid::new_v4`) the [`PipelineRunner`]
191/// uses internally, exposed so an orchestrator (e.g. the matrix fan-out parent
192/// in R506-F1, which has no runner of its own) can allocate a run id.
193pub fn new_run_id() -> QedRunId {
194    uuid::Uuid::new_v4().to_string()
195}
196
197/// What can cause a pipeline to start.
198///
199/// Triggers are *declared* in the pipeline TOML but *dispatched* by the appropriate
200/// scheduler — qed has no polling daemon. Tag triggers are fired by the GHA shim (or a
201/// yubaba git-mirror hook); schedule triggers are fired by almanac; manual is the default.
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
203#[serde(tag = "kind", rename_all = "kebab-case")]
204#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
205pub enum Trigger {
206    /// `yah qed run <pipeline>` from CLI or desktop — always available.
207    Manual,
208    /// Git tag push matching a glob (e.g. `v*.*.*`), fired by the GHA shim or yubaba hook.
209    Tag { pattern: String },
210    /// Cron expression, dispatched by almanac via `["yah", "qed", "run", pipeline]` TaskSpec.
211    Schedule { cron: String },
212    /// Another pipeline completed with the given status, chained by qed outcomes.
213    Pipeline { id: String, status: RunStatus },
214}
215
216/// Where a recipe is allowed to run (W155 principle 2). The runner consults
217/// this at kick time to refuse out-of-place runs before any step executes —
218/// e.g. CLI refuses `CiOnly` from a developer laptop unless `--force`. The
219/// recipe itself never branches on the runner; placement is the contract that
220/// keeps recipes environment-agnostic.
221///
222/// Default is [`Placement::Anywhere`] so existing recipes keep working when
223/// the field is omitted.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
225#[serde(rename_all = "kebab-case")]
226#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
227pub enum Placement {
228    /// Runs on a dev machine; meaningless on CI. The output is "yah.app
229    /// installed in /Applications", "files written to the camp tree", etc.
230    LocalOnly,
231    /// Needs secrets, signing identity, or a clean runner that don't exist
232    /// locally. Publishing, codesigning, notarization.
233    CiOnly,
234    /// Pure verification — lint, typecheck, smoke. The gold standard.
235    #[default]
236    Anywhere,
237}
238
239/// How the runner positions the on-disk tree a pipeline's steps build against,
240/// relative to the run's target ref (the `ref` run-param — a branch, tag, or
241/// SHA; default `HEAD`, i.e. whatever is already checked out).
242///
243/// A QED run's workspace is normally the live camp root — fine for verifying
244/// whatever is on disk, wrong for cutting a release (which must never ship a
245/// dev's uncommitted edits). This is the per-pipeline knob that picks the right
246/// trade-off; the run carries the *which ref*, the pipeline carries the *how
247/// strict*.
248///
249/// Default is [`WorkspaceMode::Checkout`] — switch to the requested ref but
250/// refuse to run over uncommitted changes, so a stray run never silently builds
251/// the wrong bytes and never clobbers local work.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
253#[serde(rename_all = "kebab-case")]
254#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
255pub enum WorkspaceMode {
256    /// Build against the camp root's live working tree exactly as it is on disk
257    /// — no ref switch, no dirty check. For local/dev pipelines that want
258    /// "build what I'm looking at right now".
259    Live,
260    /// Switch the camp root to the run's target ref, but **bail if the tree
261    /// is dirty** (any uncommitted change). The safe default: never builds
262    /// surprise bytes, never discards local work.
263    #[default]
264    Checkout,
265    /// Build in a dedicated git worktree checked out at the target ref; the
266    /// camp root (and any uncommitted work in it) is untouched. The correct
267    /// mode for releases — a tag is always cut from clean committed state.
268    Isolated,
269}
270
271#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272pub struct Pipeline {
273    pub name: String,
274    pub label: String,
275    pub steps: Vec<QedStep>,
276    #[serde(default)]
277    pub params: HashMap<String, ParamDef>,
278    #[serde(default)]
279    pub on_success: Vec<Outcome>,
280    #[serde(default)]
281    pub on_fail: Vec<Outcome>,
282    /// Triggers that can start this pipeline. Defaults to `[Manual]` when omitted.
283    #[serde(default)]
284    pub triggers: Vec<Trigger>,
285    /// Lock key that serializes concurrent runs. When two runs share a key,
286    /// the second one is `Queued` until the first finishes. `None` defaults
287    /// to the pipeline's own name (= one-at-a-time per pipeline). Two
288    /// pipelines that fight for the same resource (e.g. `cargo`'s shared
289    /// `target/`) can pin to the same key to serialize across pipelines.
290    /// The sentinel `"@parallel"` opts out — runs of that pipeline never
291    /// block each other (use for read-only fan-outs).
292    #[serde(default)]
293    pub concurrency_key: Option<String>,
294    /// Where this recipe is allowed to run (W170). Defaults to
295    /// [`Placement::Anywhere`]. The runner enforces this at kick time
296    /// (R435-F2) — the recipe body itself remains environment-agnostic.
297    #[serde(default)]
298    pub placement: Placement,
299    /// How the runner positions the on-disk tree this pipeline builds against
300    /// (W224). Defaults to [`WorkspaceMode::Checkout`] (switch to the run's
301    /// ref, bail if dirty). Releases set `workspace = "isolated"` so a tag is
302    /// always cut from a clean worktree, never a dev's live edits; local-only
303    /// pipelines may set `workspace = "live"` to build the tree as-is.
304    #[serde(default)]
305    pub workspace: WorkspaceMode,
306    /// Optional GHA-workflow this pipeline wraps. Set to `"gha:<rel-path>"`
307    /// in TOML (e.g. `wraps = "gha:.github/workflows/release.yml"`) when the
308    /// pipeline exists *because* it composes a workflow; the daemon then
309    /// suppresses that workflow's auto-ingest so the catalog doesn't show
310    /// both entries. Purely advisory — not interpreted by the runner.
311    #[serde(default)]
312    pub wraps: Option<String>,
313    /// Native matrix expansion (R505). When present, [`crate::matrix::plan`]
314    /// expands the pipeline into one concrete job per matrix row, with
315    /// `${{ matrix.<key> }}` substituted across each step's `argv` / `env` /
316    /// `cwd`. Absent or empty → single-job plan (no expansion). Mirrors GHA's
317    /// `strategy.matrix` semantics (cartesian product + include/exclude).
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub matrix: Option<crate::matrix::MatrixSpec>,
320    /// Declarative toolchain pinning (R507, W208 pillar 3). `[pipeline.toolchain]`
321    /// pins tool versions (rust/xcode/ndk/msvc/…) checked against the host at
322    /// plan time, so a release fails fast with an actionable error instead of
323    /// dying mid-build on a missing SDK. Per-step `toolchain.<tool>` overrides
324    /// (see [`QedStep::toolchain`]) layer on top. Absent (the default) ⇒ no
325    /// pins, no check. See [`crate::toolchain`].
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub toolchain: Option<crate::toolchain::ToolchainSpec>,
328    /// W209: `[[bind]]` tables — pipeline-output → in-tree-manifest write-backs.
329    /// Each bind names a target file/path, a producer step output (or URI
330    /// escape hatch), and an intent predicate. The runner evaluates them
331    /// mid-pipeline as each producing step completes; failed steps simply
332    /// skip the binds that reference them. Defaults to empty for pipelines
333    /// that don't bind anything.
334    #[serde(default)]
335    pub binds: Vec<manifest_bind::BindSpec>,
336    /// W209/R510-F6: `[[on_change]]` hash-change hooks. Each names a bind
337    /// selector (matched against a changed [`manifest_bind::AppliedBind`]'s
338    /// `path`) and an action (fire a pipeline, emit an event, or append to a
339    /// journal). The runner evaluates them after each step's binds commit,
340    /// firing only for binds that actually changed bytes on disk. Empty for
341    /// pipelines without hooks.
342    #[serde(default)]
343    pub on_change: Vec<manifest_bind::OnChangeHook>,
344    /// W207 Gap #6 (R513-F4): always-run teardown steps. Every step here runs
345    /// unconditionally after the main step loop and the background-sidecar reap
346    /// — whether the pipeline passed or failed — making it the home for
347    /// diagnostics/artifact teardown that must happen either way (upload
348    /// Playwright traces, `docker compose down`, collect logs). Sidecar teardown
349    /// itself is already structural (the F2 background reap), so `finally` is for
350    /// the *once-after-loop* work the reap doesn't cover.
351    ///
352    /// Semantics (see [`crate::runner`]): all `finally` steps are attempted
353    /// best-effort — a failing one never aborts the rest (teardown should always
354    /// run to completion). A `finally` step that fails marks the *run* Failed
355    /// (visible in the run tile + `RunFinished`) unless it sets
356    /// `on_fail = "continue"`, but it does **not** change which terminal
357    /// outcomes fire — `on_success` vs `on_fail` is selected from the
358    /// pipeline's *work* result (steps + sidecars), not from teardown. v1
359    /// restricts `finally` steps to [`StepKind::Subprocess`] (the teardown
360    /// shape); composite/background kinds in `finally` are rejected at load
361    /// time.
362    #[serde(default)]
363    pub finally: Vec<QedStep>,
364}
365
366impl Pipeline {
367    /// The effective concurrency key for this pipeline — `concurrency_key`
368    /// if set, otherwise the pipeline name. The daemon's per-key mutex map
369    /// is keyed off this value.
370    pub fn effective_concurrency_key(&self) -> &str {
371        self.concurrency_key.as_deref().unwrap_or(&self.name)
372    }
373
374    /// `true` when the pipeline opts out of serialization via the sentinel
375    /// key `"@parallel"`.
376    pub fn is_parallel(&self) -> bool {
377        self.effective_concurrency_key() == "@parallel"
378    }
379}
380
381impl Pipeline {
382    /// Substitute `{{key}}` placeholders in every step's `argv` and `env`
383    /// values with the supplied params (e.g. `provider=groq` turns
384    /// `"{{provider}}"` into `"groq"`). Unknown placeholders are left
385    /// untouched. Required-param *validation* is the caller's job — this
386    /// only performs the textual substitution.
387    pub fn apply_params(&mut self, params: &HashMap<String, String>) {
388        if params.is_empty() {
389            return;
390        }
391        for step in &mut self.steps {
392            for arg in &mut step.argv {
393                *arg = substitute(arg, params);
394            }
395            for value in step.env.values_mut() {
396                *value = substitute(value, params);
397            }
398        }
399    }
400}
401
402/// Replace each `{{key}}` occurrence in `input` with its param value.
403fn substitute(input: &str, params: &HashMap<String, String>) -> String {
404    let mut out = input.to_string();
405    for (key, value) in params {
406        out = out.replace(&format!("{{{{{key}}}}}"), value);
407    }
408    out
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
412#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
413pub struct QedStep {
414    pub name: String,
415    #[serde(default)]
416    pub argv: Vec<String>,
417    #[serde(default)]
418    pub cwd: Option<String>,
419    #[serde(default)]
420    pub env: HashMap<String, String>,
421    /// Per-step budget **in seconds** (R603-B6). Every pipeline TOML has always
422    /// written seconds (`timeout = 1800` for a 30-minute `cargo check`,
423    /// `timeout = 9000` for the 2.5h rusty-v8 build), but the runner used to
424    /// lower this with `Millis::from_ms`, reading 9000 as 9 *milliseconds*-worth
425    /// of seconds — i.e. 9s. That stayed invisible for local steps (the local
426    /// driver never enforces `spec.timeout`) and silently killed every long
427    /// REMOTE step at 1/1000th of its budget. Lower it with
428    /// [`Millis::from_secs`], never `from_ms`.
429    #[serde(default)]
430    pub timeout: Option<u64>,
431    #[serde(default)]
432    pub on_fail: OnFail,
433    /// Release artifacts this step builds, declared so an [`Outcome::Publish`]
434    /// can collect + upload them into the R2 release channel (R330-F3). Only
435    /// the artifacts of *successful* steps are collected. Defaults to empty —
436    /// most steps (check, typecheck) produce nothing publishable.
437    #[serde(default)]
438    pub produces: Vec<ProducedArtifact>,
439    /// How this step is sandboxed.  `None` defers to the pipeline default
440    /// (resolved from `--where`: local ⇒ Native, remote ⇒ Container).  Setting
441    /// it explicitly in TOML pins the runtime regardless of where the
442    /// pipeline runs — used by `build-image` steps that must always be
443    /// containerised.
444    #[serde(default)]
445    #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
446    pub runtime: Option<TaskRuntime>,
447    /// Which step variant this is.  Defaults to [`StepKind::Subprocess`] —
448    /// existing TOML and Rust literals omit the field.  Set to
449    /// [`StepKind::BuildImage`] to build an image instead of running argv.
450    #[serde(default)]
451    pub kind: StepKind,
452    /// Catalog entry name resolved by the image catalog loader (R381-T1).
453    /// Required when `kind = build-image`; used by `Subprocess` only as a
454    /// nominal hint until the per-step image-override path is wired through
455    /// (R381 follow-up — see runner notes).
456    #[serde(default)]
457    pub image: Option<String>,
458    /// Output tag when `kind = build-image`.  Defaults to the step's `name`.
459    #[serde(default)]
460    pub tag: Option<String>,
461    /// When `kind = build-image`, push the resulting image to its registry
462    /// after a successful build.  Ignored for other kinds.
463    #[serde(default)]
464    pub push: bool,
465    /// For `kind = build-image`: docker `--platform` values the image is built
466    /// for (e.g. `["linux/amd64"]`). Empty (the default) means host-native —
467    /// buildx picks the daemon's own platform, which is what every pre-existing
468    /// build-image step got.
469    ///
470    /// This is the *image* platform, distinct from `platform.target` (the Rust
471    /// triple a build produces). A foreign-arch entry here does NOT authorize
472    /// emulation: the runner refuses to build a foreign platform on a local
473    /// docker daemon (that is QEMU by another name) unless the step also
474    /// declares `platform = { native = true, … }`, which routes it to an
475    /// arch-matched build-worker instead.
476    #[serde(default)]
477    pub platforms: Vec<String>,
478    /// For `kind = package-native-tarball` (R407-T2): filesystem path to the
479    /// static musl Rust binary produced by an earlier build step. Resolved
480    /// relative to the camp root.
481    #[serde(default)]
482    pub binary_path: Option<String>,
483    /// For `kind = package-native-tarball` (R407-T2): target-triple shorthand
484    /// (e.g. `x86_64-unknown-linux-musl`) baked into the tarball stem and the
485    /// emitted manifest. `None` resolves to the build host's triple at
486    /// packaging time.
487    #[serde(default)]
488    pub triple: Option<String>,
489    /// For `kind = musl-static-preflight` (R407-T3): workspace member name
490    /// to gate (e.g. `yubaba`, `yah`). The runner walks its transitive dep
491    /// closure and fails if any crate in
492    /// [`crate::preflight::KNOWN_GLIBC_ONLY_CRATES`] appears.
493    #[serde(default)]
494    pub package: Option<String>,
495    /// For `kind = build-image`: docker build context directory, resolved
496    /// relative to the camp root. Defaults to `.` (camp root itself) when
497    /// absent — the same behaviour as before this field existed. Use this
498    /// to point at a staging directory assembled by an earlier subprocess
499    /// step (e.g. `context = "target/yah-yubaba-ctx"`).
500    #[serde(default)]
501    pub context: Option<std::path::PathBuf>,
502    /// For `kind = build-image`: load the finished image into the local
503    /// docker daemon with `--load` instead of writing an OCI archive.
504    /// Use in dev pipelines where the image must be immediately runnable.
505    /// Mutually exclusive with multi-platform builds; ignored when
506    /// `push = true`.
507    #[serde(default)]
508    pub load: bool,
509    /// For `kind = sub-pipeline` (W201-F1): the target to resolve as a child
510    /// pipeline, params to forward, and what to roll up into the parent. The
511    /// runner recurses into the resolved child as a nested `QedRun` parented
512    /// to the caller; ProducedArtifacts and named outputs flow back per
513    /// [`SubPipelineCollect`].
514    #[serde(default)]
515    pub sub_pipeline: Option<SubPipelineConfig>,
516    /// Named outputs this step may emit (W201-F4). Subprocess steps write
517    /// `KEY=VALUE\n` lines to `$YAH_OUTPUTS`; the runner captures them in
518    /// [`StepStatus::outputs`] for downstream sibling substitution via
519    /// `${{ steps.<name>.outputs.<key> }}`. Declaring outputs here is
520    /// advisory — undeclared keys are captured too.
521    #[serde(default)]
522    pub outputs: Vec<OutputDecl>,
523    /// For `kind = gha-workflow` (W200-F9): path to a
524    /// `.github/workflows/*.yml`, with optional event + dispatch inputs.
525    /// Resolved relative to the camp root. Required when `kind = gha-workflow`;
526    /// `validate()` rejects misconfiguration at parse time the same way
527    /// `sub_pipeline` does.
528    #[serde(default)]
529    pub gha_workflow: Option<GhaWorkflowConfig>,
530    /// For `kind = import` (W224, R533-F1): the imported `workflow.yml` source,
531    /// its pinned blake3 content hash, and the virtual/materialize toggle.
532    /// Required when `kind = import`; `validate()` rejects misconfiguration at
533    /// parse time the same way `gha_workflow` / `sub_pipeline` do. The runner
534    /// re-reads the source, recomputes its hash, and expands it into the native
535    /// subgraph at plan time (`crate::import`).
536    #[serde(default)]
537    pub import: Option<ImportConfig>,
538    /// Step-level matrix (R505). When present, [`crate::matrix::plan`] fans
539    /// this single step out into N step instances within the parent job, each
540    /// carrying its row's coord substituted into `argv` / `env` / `cwd` and
541    /// its name suffixed with the coord pairs.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
544    pub matrix: Option<crate::matrix::MatrixSpec>,
545    /// Declarative on/off switch (R506). When `false`, the runner skips the
546    /// step at plan-time: a `StepStatus` with [`RunStatus::Skipped`] is still
547    /// emitted so the dashboard renders the row, but no subprocess / container
548    /// / sub-pipeline is launched. Defaults to `true`. Orthogonal to
549    /// [`Self::activation`] — `enabled = false` means "I explicitly want this
550    /// off for this run"; `status = "stubbed"` means "this is a planned but
551    /// not-yet-implemented surface". The runner treats both as skip; the
552    /// dashboard renders them distinctly.
553    #[serde(default = "default_enabled")]
554    pub enabled: bool,
555    /// Declarative lifecycle state (R506). `active` (the default) runs the
556    /// step normally; `stubbed` marks the step as a visible-but-skipped row
557    /// — typically a planned target (e.g. `ios-device`, `rpi0`) that hasn't
558    /// been wired up yet but should still appear in the dashboard so bit-rot
559    /// is observable. The runner skips `stubbed` steps the same way it skips
560    /// `enabled = false` steps; the on-demand `--include-stubbed` override
561    /// runs them.
562    #[serde(default, rename = "status")]
563    pub activation: StepActivation,
564    /// Runtime conditional (R506) — a `${{ <expr> }}`-style expression
565    /// evaluated against the W201-F4 context (matrix coords, env, prior
566    /// `steps.<X>.outputs.<Y>`, plus `success()` / `failure()`). When the
567    /// expression evaluates to a falsy value the step is skipped at
568    /// dispatch-time with [`RunStatus::Skipped`]. Bare expressions without
569    /// `${{ }}` delimiters are evaluated as implicit-expression bodies (GHA
570    /// semantics). Layered above [`Self::enabled`] / [`Self::activation`]:
571    /// a step that is `enabled = false` is skipped before `if` is consulted.
572    /// Layered above [`Self::on_fail`]: this gate is *pre-execution*, while
573    /// `on_fail` is post-failure propagation.
574    #[serde(default, rename = "if", skip_serializing_if = "Option::is_none")]
575    pub if_cond: Option<String>,
576    /// Run this step as a long-lived sidecar (R513-F2, W207 Gap #4). A
577    /// background step is *spawned* — `run()` emits its `StepStarted`, kicks
578    /// the subprocess onto its own task, and immediately advances to the next
579    /// step instead of awaiting completion. The classic case is a server a
580    /// later step talks to: `yah-camp`, `vite preview`, a mock auth broker.
581    /// Without this every such step would block the pipeline forever.
582    ///
583    /// Lifecycle: the sidecar lives until it is *reaped*. With
584    /// [`Self::background_until`] unset it is reaped at the end of the step
585    /// loop (after the last foreground step, before terminal outcomes); with
586    /// `background_until = "<step>"` it is reaped the moment that named step
587    /// finishes. Reaping a still-running sidecar kills it (`kill_on_drop`) and
588    /// records [`RunStatus::Success`] — a healthy server torn down on schedule
589    /// is the expected path, not a failure. A sidecar that *exits on its own*
590    /// before reap surfaces its real exit status: clean → `Success`, non-zero
591    /// → `Failed` (a sidecar that crashes mid-pipeline is a genuine problem and
592    /// flips the run to `Failed` so `on_fail` fires).
593    ///
594    /// Log story: a background step's stdout/stderr keep streaming as
595    /// `StepOutput` events tagged with the step index, identical to a
596    /// foreground step — a misbehaving sidecar's logs are exactly what you want
597    /// when triaging, so v1 never silences them; collapsing a chatty sidecar's
598    /// pane is a consumer concern.
599    ///
600    /// v1 scope: background is only valid on [`StepKind::Subprocess`] steps run
601    /// locally (the [`crate::ForgeExecutor`] spawn path). `validate()` rejects
602    /// other kinds; the runner rejects `--where=remote` background steps
603    /// (yubaba-supervised remote sidecars are a separate lifecycle). Defaults
604    /// to `false` — omitted from every existing pipeline.
605    #[serde(default)]
606    pub background: bool,
607    /// Reap this background step right after the named step finishes, rather
608    /// than at the end of the pipeline (R513-F2). Implies [`Self::background`].
609    /// The named step must appear *after* this one in the pipeline — the runner
610    /// rejects a forward-reference to a missing or earlier step at run start, so
611    /// a typo fails loudly instead of silently deferring the reap to pipeline
612    /// end. `None` (the default) ⇒ reap at end of the step loop.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub background_until: Option<String>,
615    /// For `kind = wait-for` (R513-F3, W207 Gap #5): the network endpoint to
616    /// poll and the timeout/interval budget. Required when `kind = wait-for`;
617    /// `validate()` rejects misconfiguration (missing block, no target, both
618    /// targets) at parse time the same way `sub_pipeline` / `gha_workflow` do.
619    /// `None` for every other step kind.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub wait_for: Option<WaitForConfig>,
622    /// For `kind = manifest-stitch` (R590-F2): the arch-agnostic target tag and
623    /// the per-arch source tags to fold into a multi-arch manifest list.
624    /// Required when `kind = manifest-stitch`; `validate()` rejects a missing
625    /// block / empty target / no sources at parse time the same way `wait_for`
626    /// does. `None` for every other step kind.
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub manifest_stitch: Option<ManifestStitchConfig>,
629    /// Structured platform intent (R531-F2, W222): what target this step
630    /// produces and the arch of the base image it pulls. `host` is *not*
631    /// declared here — it's self-detected per runner (R531-T1) and composed
632    /// in at plan time via [`crate::platform::Platform::compose`]. `None` (the
633    /// default, omitted from every existing pipeline file) means host-native /
634    /// no foreign-arch container — the common case. An explicit
635    /// `platform.target` overrides the legacy per-kind `triple` field as the
636    /// composed target.
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub platform: Option<crate::platform::PlatformSpec>,
639    /// Per-step toolchain pin overrides (R507, W208 pillar 3). An inline table
640    /// `toolchain.<tool> = "..."` whose entries [`crate::toolchain::effective_pins`]
641    /// overlays on the pipeline-level `[pipeline.toolchain]` — so a single
642    /// `build-android` step can pin `ndk = "r26d"` while the pipeline pins
643    /// `r27`. `None` (the default) ⇒ the step inherits the pipeline pins
644    /// unchanged. See [`crate::toolchain`].
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
647    pub toolchain: Option<crate::toolchain::ToolchainSpec>,
648}
649
650fn default_enabled() -> bool {
651    true
652}
653
654/// Declarative lifecycle state for a [`QedStep`] (R506). See
655/// [`QedStep::activation`].
656#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
657#[serde(rename_all = "lowercase")]
658#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
659pub enum StepActivation {
660    /// Step runs normally.
661    #[default]
662    Active,
663    /// Step is a visible-but-skipped placeholder — appears in the dashboard
664    /// so the full release surface is observable, but the runner doesn't
665    /// dispatch it. Use for planned targets that aren't wired up yet.
666    /// Overridden by the on-demand `--include-stubbed` runner flag.
667    Stubbed,
668}
669
670/// What a pipeline step does.
671///
672/// On the TOML side this is `kind = "subprocess" | "build-image"`. The default
673/// — and the value omitted from every existing pipeline file — is
674/// [`StepKind::Subprocess`].
675#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
676#[serde(rename_all = "kebab-case")]
677#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
678pub enum StepKind {
679    /// Run `argv` (the existing semantics).
680    #[default]
681    Subprocess,
682    /// Build a container image from the catalog (R381).  The image is looked
683    /// up by `image` (catalog name); the runner materialises a
684    /// `task::ForgeCommand::BuildImage` from the catalog entry.
685    BuildImage,
686    /// Package a static musl Rust binary + workload-spec manifest into a
687    /// `.tar.gz` for the native runtime under Kamaji (R407-T2, W154).
688    /// Catalog entry referenced by `image` must declare
689    /// [`ProduceTarget::NativeTarball`](crate::images::ProduceTarget::NativeTarball);
690    /// `binary_path` points at the cross-compiled binary an earlier step
691    /// produced. No systemd unit is emitted — Kamaji directly
692    /// fork+exec+cgroup+pidfd-supervises the binary at deploy time.
693    PackageNativeTarball,
694    /// Gate a workspace member against
695    /// [`crate::preflight::KNOWN_GLIBC_ONLY_CRATES`] (R407-T3, W154). Walks
696    /// the package's transitive dep closure via `cargo metadata`; fails if
697    /// any glibc-only crate appears. Routes the pipeline author to the
698    /// container fallback (`runtime = "container"`) with a clear,
699    /// actionable error rather than dying mid-cross-build with a linker
700    /// error. Pure host file I/O — no remote variant.
701    MuslStaticPreflight,
702    /// Sign a native tarball produced by an earlier
703    /// [`StepKind::PackageNativeTarball`] step (R407-T5, W154). Extends the
704    /// Sigstore keyless-OIDC trust model already used for OCI images to the
705    /// native-tarball artifact shape via `cosign sign-blob`. The step
706    /// resolves the on-disk tarball path the same way packaging writes it
707    /// (`<camp_root>/.yah/cache/native/<image>-<triple>.tar.gz`) and emits
708    /// `<tarball>.sig`, `<tarball>.crt`, and `<tarball>.bundle` next to it.
709    /// Catalog entry referenced by `image` must declare
710    /// [`ProduceTarget::NativeTarball`](crate::images::ProduceTarget::NativeTarball).
711    /// Pure host file I/O — runs Native even on Remote runners.
712    SignNativeTarball,
713    /// Invoke another pipeline as a child of this step (W201). Resolution
714    /// target + propagation rules live on [`QedStep::sub_pipeline`]. The
715    /// runner runs the resolved child as a nested [`QedRun`] parented to the
716    /// caller, then aggregates ProducedArtifacts and named outputs per
717    /// [`SubPipelineCollect`]. Has no `argv` / `runtime` of its own — runtime
718    /// is whichever the child resolves to.
719    SubPipeline,
720    /// Run a `.github/workflows/*.yml` through the native W200 GHA runtime
721    /// (W200-F9). Step config lives on [`QedStep::gha_workflow`]; the runner
722    /// dispatches to `yah_qed_gha::execute_workflow`, then lifts each
723    /// `yah_qed_gha::ProducedArtifact` into [`ProducedArtifact`] and aggregates
724    /// into the parent's `Outcome::Publish` — same surface as a producing
725    /// `Subprocess` step or a `SubPipeline` child with `propagate.produces`.
726    GhaWorkflow,
727    /// Import a `.github/workflows/*.yml` as a QED source and expand it into
728    /// the native subgraph at plan time (W224 "import, don't emulate";
729    /// R533-F1). Step config lives on [`QedStep::import`]: the source path, a
730    /// blake3 content hash pinning that source, and a `materialize` toggle.
731    ///
732    /// Unlike [`StepKind::GhaWorkflow`] — which treats the YAML as a foreign
733    /// runtime to execute as one black-box step — `Import` treats it as an
734    /// *interchange format*. The expansion is **virtual by default**
735    /// (recomputed at plan time, never persisted ⇒ zero drift by
736    /// construction); the pinned hash is the guardrail that detects a drifted
737    /// source. The expansion logic lives in [`crate::import`]; F1's expansion
738    /// delegates to the recast W200 GHA front-end, and R533-F4 swaps in the
739    /// mechanical tier-1/2 → native map.
740    Import,
741    /// Block until a network endpoint becomes reachable, then advance (R513-F3,
742    /// W207 Gap #5). The classic case is a health-gate between a `background`
743    /// sidecar (`yah-camp`, `vite preview`) and the step that talks to it: poll
744    /// the server's `/health` until it answers, so the consumer step never races
745    /// a not-yet-listening port. Config (the target + timeout/interval) lives on
746    /// [`QedStep::wait_for`]; the step runs no `argv` of its own and produces
747    /// nothing — it is a pure gate. `validate()` rejects `argv` and a missing
748    /// `[wait_for]` block the same way [`StepKind::SubPipeline`] does.
749    WaitFor,
750    /// Stitch N per-arch images (already pushed by earlier `build-image` steps
751    /// routed to arch-matched build-workers) into one multi-arch manifest list
752    /// (R590-F2). Config lives on [`QedStep::manifest_stitch`]: the arch-agnostic
753    /// `target` tag consumers pull, and the arch-specific `sources` to fold in.
754    /// The step shells `docker buildx imagetools create` — a registry-only
755    /// operation, so it runs host-native even under `--where=remote` (the fleet
756    /// does the builds; the stitch runs where qed runs). No `argv` of its own;
757    /// `validate()` rejects `argv` and requires a `[manifest_stitch]` block with
758    /// a target + at least one source.
759    ManifestStitch,
760}
761
762/// Maximum allowed sub-pipeline nesting depth, counted as the number of
763/// SubPipeline edges traversed from the root. Beyond this, [`validate_sub_pipeline_graph`]
764/// rejects with [`SubPipelineError::MaxDepthExceeded`] regardless of cycles.
765/// Defends against accidental recursion in user-authored TOML; 4 is plenty
766/// for full-release → (gha-runtime + desktop-release + ...) layouts.
767pub const MAX_SUB_PIPELINE_DEPTH: usize = 4;
768
769/// Configuration for a [`StepKind::SubPipeline`] step — what to invoke and
770/// what to roll up. Lives on [`QedStep::sub_pipeline`].
771#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
772#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
773pub struct SubPipelineConfig {
774    /// What to resolve and run as the child.
775    pub target: SubPipelineRef,
776    /// Pipeline-level params forwarded to the child (becomes the child's
777    /// [`Pipeline::apply_params`] input).
778    #[serde(default)]
779    pub params: HashMap<String, String>,
780    /// What to collect back up from the child run.
781    #[serde(default)]
782    pub propagate: SubPipelineCollect,
783    /// Opaque opt-out of transparent inlining (W223 R532-F3). A wrapped
784    /// pipeline is a *disregarded entity* by default — its children (GHA jobs,
785    /// or a child pipeline's steps) are attributed to this step's report +
786    /// graph as inlined rows. Set `opaque = true` to keep the wrapper a single
787    /// black-box node instead: the child still runs and its status still rolls
788    /// up, but the per-child rows are suppressed (the `#[inline(never)]`
789    /// equivalent). Useful for a stable, rarely-failing sub-stage or a vendored
790    /// workflow whose internals are noise. Default `false` (transparent).
791    #[serde(default)]
792    pub opaque: bool,
793}
794
795/// How a [`StepKind::SubPipeline`] step resolves to a runnable child. The
796/// TOML serializer renders this as one of four single-key tables:
797///
798/// ```toml
799/// target = { builtin = "desktop-release" }
800/// # or
801/// target = { path = ".yah/qed/full-release.toml" }
802/// # or
803/// target = { gha-workflow = { path = ".github/workflows/release.yml", event = "tag" } }
804/// # or
805/// target = { peer = { camp = "mesofact", pipeline = "release-build" } }
806/// ```
807#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
808#[serde(rename_all = "kebab-case")]
809#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
810pub enum SubPipelineRef {
811    /// Resolve to a builtin pipeline by name (e.g. `"desktop-release"`).
812    Builtin(String),
813    /// Resolve to a TOML pipeline file, relative to the camp root
814    /// (e.g. `.yah/qed/full-release.toml`).
815    Path(std::path::PathBuf),
816    /// Resolve to a `.github/workflows/*.yml` executed by the W200 native
817    /// GHA runtime. The runner-side glue lands in W201-F6; until then a
818    /// resolver returning `None` here is the expected behaviour.
819    GhaWorkflow {
820        path: std::path::PathBuf,
821        #[serde(default)]
822        event: Option<String>,
823        #[serde(default)]
824        inputs: HashMap<String, String>,
825    },
826    /// Resolve to a pipeline declared in another camp on the same rig (or
827    /// brokered to a remote rig via kamaji when the peer registry entry
828    /// has a `rig` field). `camp` is the registry key in
829    /// `<qed_dir>/peers.toml`; `pipeline` is the named pipeline within that
830    /// camp's own `.yah/qed/`. Runner-side resolution lives in R494-F2.
831    Peer { camp: String, pipeline: String },
832}
833
834/// What the parent rolls up from a SubPipeline child run.
835#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
836#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
837pub struct SubPipelineCollect {
838    /// When `true`, [`ProducedArtifact`]s from the child are aggregated into
839    /// the parent's [`Outcome::Publish`] and the child's own publish is
840    /// suppressed — one stage/sync/revalidate at the parent's terminal
841    /// outcome instead of N at the children.
842    #[serde(default)]
843    pub produces: bool,
844    /// Named child outputs to expose on the parent step as
845    /// `steps.<step-name>.outputs.<name>` for sibling references (W201-F4).
846    /// The runner scans all child steps' collected outputs for each listed
847    /// name and surfaces the value under the SubPipeline step's own name so
848    /// later steps can reference `${{ steps.<this>.outputs.<name> }}`.
849    #[serde(default)]
850    pub outputs: Vec<String>,
851}
852
853/// Step-level config for [`StepKind::GhaWorkflow`] (W200-F9). Mirrors
854/// [`SubPipelineRef::GhaWorkflow`] field-for-field — the SubPipeline-rooted
855/// variant goes through a resolver that synthesizes a single GhaWorkflow
856/// step under the hood, so both surfaces resolve to the same runner arm.
857#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
858#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
859pub struct GhaWorkflowConfig {
860    /// Workflow YAML path, resolved relative to the camp root (e.g.
861    /// `.github/workflows/release.yml`).
862    pub path: std::path::PathBuf,
863    /// GHA event the workflow run impersonates (`push`, `workflow_dispatch`).
864    /// `None` defaults to `push` at runtime — matches `release.yml`'s tag-push
865    /// primary trigger.
866    #[serde(default)]
867    pub event: Option<String>,
868    /// `workflow_dispatch` inputs, forwarded as `inputs.<name>` in the
869    /// expression context. Ignored when `event != "workflow_dispatch"`.
870    #[serde(default)]
871    pub inputs: HashMap<String, String>,
872}
873
874/// Step-level config for [`StepKind::Import`] (W224, R533-F1). The W224 import
875/// primitive: a QED step whose source is a `workflow.yml`, carrying the content
876/// hash of that yml plus a toggle for whether the expansion is persisted.
877///
878/// ```toml
879/// [[steps]]
880/// name = "release"
881/// kind = "import"
882/// [steps.import]
883/// source = ".github/workflows/release.yml"
884/// hash = "af1349b9f5f9a1a6a0404dea36dcc949..."  # blake3 of the source, pinned
885/// # materialize = false                          # default — virtual expansion
886/// ```
887///
888/// Whether the expansion is persisted is the migration ramp (W224): virtual
889/// (default) recomputes the subgraph at plan time and stores nothing — zero
890/// drift by construction; `materialize` ejects it to generated TOML (R533-F6).
891/// While the yml is canonical the TOML is virtual; once ejected the yml is
892/// gone — never two editable canonical copies at once. The freshness check and
893/// plan-time expansion live in [`crate::import`].
894#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
895#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
896pub struct ImportConfig {
897    /// Path to the imported `.github/workflows/*.yml`, resolved relative to the
898    /// camp root.
899    pub source: std::path::PathBuf,
900    /// blake3 content hash of the `source` bytes, pinned at import time
901    /// ([`crate::import::content_hash`]). `None` while unpinned (a first import
902    /// or a hand-authored block). On every run the runner recomputes the source
903    /// hash and compares via [`Self::freshness`]: a mismatch means the source
904    /// drifted since pinning. Under the default virtual expansion a mismatch is
905    /// benign (re-expand + re-pin); for a materialized eject it marks the
906    /// on-disk generated TOML stale (R533-F6).
907    #[serde(default)]
908    pub hash: Option<String>,
909    /// Persist the plan-time expansion as generated, hash-stamped TOML (the
910    /// R533-F6 `eject`), vs. the default virtual expansion computed fresh at
911    /// plan time and never stored. Virtual-by-default is zero-drift by
912    /// construction (W224). F1 only carries the toggle; the eject/materialize
913    /// machinery and its stale-source guard land in R533-F6.
914    #[serde(default)]
915    pub materialize: bool,
916    /// GHA event the expansion impersonates while F1's expansion still routes
917    /// through the recast W200 front-end (`push` | `workflow_dispatch`). `None`
918    /// defaults to `push` at runtime — matches `release.yml`'s tag-push primary
919    /// trigger. Forwarded into the synthesized [`GhaWorkflowConfig`] by
920    /// [`crate::import::expand_import`].
921    #[serde(default)]
922    pub event: Option<String>,
923    /// `workflow_dispatch` inputs forwarded into the expansion context. Ignored
924    /// when `event != "workflow_dispatch"`.
925    #[serde(default)]
926    pub inputs: HashMap<String, String>,
927}
928
929/// Step-level config for [`StepKind::WaitFor`] (R513-F3, W207 Gap #5). Names a
930/// single network endpoint to poll and the time budget for it to come up.
931///
932/// ```toml
933/// [[steps]]
934/// name = "wait:ready"
935/// kind = "wait-for"
936/// [steps.wait_for]
937/// http = "http://localhost:3000/health"   # plaintext HTTP GET, healthy on 2xx/3xx
938/// timeout_secs = 30                        # give up (and fail the step) after this
939/// # interval_ms = 500                      # poll cadence (default 500ms)
940/// # expect_status = 200                    # require an exact status instead of any 2xx/3xx
941/// ```
942///
943/// Exactly one of [`Self::http`] / [`Self::tcp`] must be set. The `http` probe
944/// is a dependency-free plaintext HTTP/1.1 GET (no TLS in v1 — an `https://`
945/// URL is rejected at runtime; use a `tcp` gate or terminate TLS in front);
946/// the `tcp` probe is a bare connect to `host:port`, healthy the moment the
947/// port accepts. [`Self::expect_status`] is HTTP-only.
948#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
949#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
950pub struct WaitForConfig {
951    /// Plaintext-HTTP URL to GET each poll (e.g. `http://localhost:3000/health`).
952    /// Healthy on a 2xx/3xx response, or on an exact match to
953    /// [`Self::expect_status`] when set. Mutually exclusive with [`Self::tcp`].
954    #[serde(default)]
955    pub http: Option<String>,
956    /// `host:port` to connect to each poll (e.g. `127.0.0.1:5432`). Healthy the
957    /// moment the connect succeeds — no bytes are exchanged. Mutually exclusive
958    /// with [`Self::http`].
959    #[serde(default)]
960    pub tcp: Option<String>,
961    /// Require this exact HTTP status to consider the endpoint healthy, instead
962    /// of the default "any 2xx/3xx". HTTP-only — `validate()` rejects it
963    /// alongside a `tcp` target. `None` ⇒ any 2xx/3xx.
964    #[serde(default)]
965    pub expect_status: Option<u16>,
966    /// Total budget, in seconds, for the endpoint to become healthy. The step
967    /// fails with a clear "never became healthy" message once this elapses.
968    /// Defaults to 30s.
969    #[serde(default = "default_wait_timeout_secs")]
970    pub timeout_secs: u64,
971    /// Delay between poll attempts, in milliseconds. Defaults to 500ms — snappy
972    /// enough for a fast-booting dev server without hammering the socket.
973    #[serde(default = "default_wait_interval_ms")]
974    pub interval_ms: u64,
975}
976
977fn default_wait_timeout_secs() -> u64 {
978    30
979}
980
981fn default_wait_interval_ms() -> u64 {
982    500
983}
984
985impl WaitForConfig {
986    /// `true` when an `https://` URL was given — TLS health-gates are out of
987    /// scope for v1 (no HTTP client / TLS stack pulled into qed). The runner
988    /// surfaces this as a clean `StepFailed` rather than silently trying a
989    /// plaintext GET against a TLS port.
990    pub fn http_is_tls(&self) -> bool {
991        self.http
992            .as_deref()
993            .is_some_and(|u| u.trim_start().starts_with("https://"))
994    }
995}
996
997/// Step-level config for [`StepKind::ManifestStitch`] (R590-F2). Names the
998/// arch-agnostic manifest-list tag to publish and the per-arch source tags to
999/// fold into it.
1000///
1001/// ```toml
1002/// [[steps]]
1003/// name = "stitch:multi-arch"
1004/// kind = "manifest-stitch"
1005/// [steps.manifest_stitch]
1006/// target = "ghcr.io/yah-ai/yah-rust:v1"
1007/// sources = [
1008///   "ghcr.io/yah-ai/yah-rust:v1-amd64",   # pushed by the amd64 build-worker
1009///   "ghcr.io/yah-ai/yah-rust:v1-arm64",   # pushed by the arm64 build-worker
1010/// ]
1011/// ```
1012///
1013/// `sources` are the arch-specific tags earlier `build-image` steps pushed to
1014/// the registry; `target` is the tag consumers pull (docker resolves the arch
1015/// at pull time from the manifest list). `validate()` requires a non-empty
1016/// `target` and at least one `source`.
1017#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1018#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1019pub struct ManifestStitchConfig {
1020    /// Arch-agnostic manifest-list tag to create/overwrite.
1021    pub target: String,
1022    /// Per-arch source image tags to fold into the manifest list. Must be
1023    /// already pushed to their registry before this step runs.
1024    #[serde(default)]
1025    pub sources: Vec<String>,
1026}
1027
1028/// Declares a named output that a native [`StepKind::Subprocess`] step may
1029/// emit at runtime (W201-F4).
1030///
1031/// At runtime the runner injects a `$YAH_OUTPUTS` environment variable
1032/// pointing at a temporary file. Steps write `KEY=VALUE\n` lines to that
1033/// file; the runner reads them back after the step exits and stores the
1034/// collected values in [`StepStatus::outputs`]. Sibling steps can then
1035/// reference values as `${{ steps.<step-name>.outputs.<key> }}` in their
1036/// `argv` or `env` fields.
1037///
1038/// The `name` field is advisory — undeclared keys written to `$YAH_OUTPUTS`
1039/// are captured too. Declaring outputs explicitly helps with documentation
1040/// and, once the W200 expression engine (R487-F2) lands, with type-checked
1041/// expression validation.
1042#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1043#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1044pub struct OutputDecl {
1045    pub name: String,
1046    #[serde(default)]
1047    pub description: Option<String>,
1048    /// W209: declared value type. The runner type-checks the captured value
1049    /// against this shape before letting it reach any `[[bind]]` whose
1050    /// `from` references this output. Defaults to `string` (i.e. accept
1051    /// anything non-empty) for backwards compatibility with R488-F4 outputs
1052    /// declared without a `type` key.
1053    #[serde(rename = "type", default = "default_value_type")]
1054    #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
1055    pub kind: manifest_bind::ValueType,
1056    /// Optional override regex for the type's built-in validator (W209).
1057    /// Authors rarely need this — the built-in shape regex is right by
1058    /// construction for blake3-hex, semver, oci-digest, etc.
1059    #[serde(default)]
1060    pub validate: Option<String>,
1061}
1062
1063fn default_value_type() -> manifest_bind::ValueType {
1064    manifest_bind::ValueType::String
1065}
1066
1067/// Errors surfaced when walking a sub-pipeline graph at parse time
1068/// ([`validate_sub_pipeline_graph`]).
1069#[derive(Debug, thiserror::Error, PartialEq, Eq)]
1070pub enum SubPipelineError {
1071    #[error("sub-pipeline cycle detected: {chain}")]
1072    Cycle { chain: String },
1073    #[error("sub-pipeline nesting exceeded max depth of {max}: {chain}")]
1074    MaxDepthExceeded { max: usize, chain: String },
1075}
1076
1077/// Resolver hook for [`validate_sub_pipeline_graph`]. The validator calls
1078/// `resolve` for each [`SubPipelineRef`] it encounters; returning `Some`
1079/// continues the walk into the child, `None` stops walking that subtree
1080/// (the runtime will report the resolution failure later). This indirection
1081/// keeps `types.rs` free of any dependency on the builtin registry or
1082/// filesystem — callers wire their own resolver.
1083pub trait SubPipelineResolver {
1084    fn resolve(&self, target: &SubPipelineRef) -> Option<Pipeline>;
1085
1086    /// Optional companion to [`resolve`]: when `resolve` returns `None`,
1087    /// the runner consults this to surface a typed reason in the
1088    /// [`StepKind::SubPipeline`] step's `StepFailed.msg` rather than the
1089    /// generic "target unresolvable" fallback. Implementors return
1090    /// `Some(message)` when they can explain the miss (unknown registry
1091    /// entry, unsupported transport, missing on-disk file) and `None`
1092    /// when the miss has no actionable reason beyond "target not found".
1093    ///
1094    /// Used today by [`crate::config::LoaderSubPipelineResolver`] to
1095    /// surface the R494-T5 "remote peer not yet supported" path with the
1096    /// offending `camp` + `rig` names — operators previously got a silent
1097    /// skip + generic unresolvable error.
1098    fn unresolved_reason(&self, _target: &SubPipelineRef) -> Option<String> {
1099        None
1100    }
1101
1102    /// The camp root that the resolved child pipeline's steps should
1103    /// execute against. The runner uses this as the child runner's
1104    /// `camp_root`, which becomes the working directory for subprocess
1105    /// steps (and the base for resolving produced-artifact paths).
1106    ///
1107    /// For [`SubPipelineRef::Peer`] this is the *peer* camp's root, so a
1108    /// peer's `cargo` steps run in the peer's workspace rather than the
1109    /// parent camp's — without this, `peer-release` runs yubaba's
1110    /// `cargo publish -p workload-spec` from yah's root and fails with a
1111    /// "package ID did not match any packages" error.
1112    ///
1113    /// Returns `None` to inherit the parent runner's `camp_root` — the
1114    /// correct default for `Builtin`/`Path`/`GhaWorkflow` children, which
1115    /// share the parent's camp.
1116    fn resolved_camp_root(&self, _target: &SubPipelineRef) -> Option<std::path::PathBuf> {
1117        None
1118    }
1119}
1120
1121/// Walk a pipeline's SubPipeline graph, rejecting cycles and nesting deeper
1122/// than [`MAX_SUB_PIPELINE_DEPTH`]. The walker tracks the chain of visited
1123/// targets by their canonical string form (`builtin:<name>` / `path:<path>` /
1124/// `gha:<path>`); seeing the same token twice on the active chain is a cycle.
1125/// Unresolved targets are *not* errors here — that's a runtime resolution
1126/// concern; the validator only enforces structural properties.
1127pub fn validate_sub_pipeline_graph(
1128    pipeline: &Pipeline,
1129    resolver: &dyn SubPipelineResolver,
1130) -> Result<(), SubPipelineError> {
1131    let root = format!("pipeline:{}", pipeline.name);
1132    let mut chain: Vec<String> = vec![root];
1133    visit_sub_pipeline(pipeline, resolver, &mut chain)
1134}
1135
1136fn visit_sub_pipeline(
1137    pipeline: &Pipeline,
1138    resolver: &dyn SubPipelineResolver,
1139    chain: &mut Vec<String>,
1140) -> Result<(), SubPipelineError> {
1141    for step in &pipeline.steps {
1142        if step.kind != StepKind::SubPipeline {
1143            continue;
1144        }
1145        let Some(cfg) = step.sub_pipeline.as_ref() else {
1146            // Caught by `QedStep::validate` (SubPipelineMissingConfig); ignore here.
1147            continue;
1148        };
1149        let token = sub_pipeline_ref_token(&cfg.target);
1150        if chain.contains(&token) {
1151            let mut full = chain.clone();
1152            full.push(token);
1153            return Err(SubPipelineError::Cycle {
1154                chain: full.join(" -> "),
1155            });
1156        }
1157        // Depth counts SubPipeline edges traversed (chain.len() - 1 = root + edges).
1158        if chain.len() > MAX_SUB_PIPELINE_DEPTH {
1159            let mut full = chain.clone();
1160            full.push(token);
1161            return Err(SubPipelineError::MaxDepthExceeded {
1162                max: MAX_SUB_PIPELINE_DEPTH,
1163                chain: full.join(" -> "),
1164            });
1165        }
1166        chain.push(token);
1167        if let Some(child) = resolver.resolve(&cfg.target) {
1168            visit_sub_pipeline(&child, resolver, chain)?;
1169        }
1170        chain.pop();
1171    }
1172    Ok(())
1173}
1174
1175/// Stable string representation of a [`SubPipelineRef`] used for chip
1176/// rendering on the wire (`QedEvent::SubPipelineStarted.target`,
1177/// `QedStepWire.sub_pipeline_target`). One of:
1178/// `builtin:<name>` | `path:<rel>` | `gha:<rel>` | `peer:<camp>:<pipeline>`.
1179pub fn sub_pipeline_ref_token(target: &SubPipelineRef) -> String {
1180    match target {
1181        SubPipelineRef::Builtin(name) => format!("builtin:{name}"),
1182        SubPipelineRef::Path(path) => format!("path:{}", path.display()),
1183        SubPipelineRef::GhaWorkflow { path, .. } => format!("gha:{}", path.display()),
1184        SubPipelineRef::Peer { camp, pipeline } => format!("peer:{camp}:{pipeline}"),
1185    }
1186}
1187
1188/// Validation errors surfaced before a pipeline runs.  Returned by
1189/// [`QedStep::validate`] and threaded through the TOML loader.
1190#[derive(Debug, thiserror::Error, PartialEq, Eq)]
1191pub enum StepValidationError {
1192    #[error("step `{0}`: subprocess steps require non-empty `argv`")]
1193    SubprocessMissingArgv(String),
1194    #[error("step `{0}`: build-image steps must omit `argv`")]
1195    BuildImageHasArgv(String),
1196    #[error("step `{0}`: build-image steps require `image = \"<catalog-name>\"`")]
1197    BuildImageMissingImage(String),
1198    #[error(
1199        "step `{0}`: build-image steps must run in a container — \
1200         set `runtime = \"container\"` or omit `runtime` (drop `runtime = \"native\"`)"
1201    )]
1202    BuildImageNativeRuntime(String),
1203    /// `push = true` was set on a step whose tag's registry hostname isn't
1204    /// declared writable in `.yah/qed/registries.toml`. The fix is either
1205    /// drop `push = true` (default: OCI archive output, no registry needed)
1206    /// or add the registry to the camp's `registries.toml` with
1207    /// `writable = true`. Carries the step name and the host the tag pointed
1208    /// at so the operator can see exactly which entry to add.
1209    #[error(
1210        "step `{step}`: `push = true` targets registry `{host}` which is \
1211         not declared writable in `.yah/qed/registries.toml` — \
1212         add `[[registries]]` with `host = \"{host}\"` + `writable = true`, \
1213         or drop `push = true` to fall back to the OCI archive output"
1214    )]
1215    PushRequiresWritableRegistry { step: String, host: String },
1216    #[error("step `{0}`: package-native-tarball steps must omit `argv`")]
1217    PackageNativeTarballHasArgv(String),
1218    #[error("step `{0}`: package-native-tarball steps require `image = \"<catalog-name>\"`")]
1219    PackageNativeTarballMissingImage(String),
1220    #[error(
1221        "step `{0}`: package-native-tarball steps require `binary_path = \"<path>\"` \
1222         (the static musl binary produced by an earlier build step)"
1223    )]
1224    PackageNativeTarballMissingBinaryPath(String),
1225    #[error(
1226        "step `{0}`: package-native-tarball steps run native on the host (pure file I/O) — \
1227         drop `runtime = \"container\"` or set `runtime = \"native\"`"
1228    )]
1229    PackageNativeTarballContainerRuntime(String),
1230    #[error("step `{0}`: musl-static-preflight steps must omit `argv`")]
1231    MuslStaticPreflightHasArgv(String),
1232    #[error(
1233        "step `{0}`: musl-static-preflight steps require `package = \"<workspace-member>\"` \
1234         (e.g. `package = \"yubaba\"`)"
1235    )]
1236    MuslStaticPreflightMissingPackage(String),
1237    #[error(
1238        "step `{0}`: musl-static-preflight runs `cargo metadata` on the host — \
1239         drop `runtime = \"container\"` or set `runtime = \"native\"`"
1240    )]
1241    MuslStaticPreflightContainerRuntime(String),
1242    #[error("step `{0}`: sign-native-tarball steps must omit `argv`")]
1243    SignNativeTarballHasArgv(String),
1244    #[error("step `{0}`: sign-native-tarball steps require `image = \"<catalog-name>\"`")]
1245    SignNativeTarballMissingImage(String),
1246    #[error(
1247        "step `{0}`: sign-native-tarball runs `cosign sign-blob` on the host — \
1248         drop `runtime = \"container\"` or set `runtime = \"native\"`"
1249    )]
1250    SignNativeTarballContainerRuntime(String),
1251    #[error("step `{0}`: sub-pipeline steps must omit `argv`")]
1252    SubPipelineHasArgv(String),
1253    #[error("step `{0}`: sub-pipeline steps require a `[sub_pipeline]` block with `target = ...`")]
1254    SubPipelineMissingConfig(String),
1255    #[error(
1256        "step `{0}`: sub-pipeline steps must not declare `produces` directly — \
1257         ProducedArtifacts come from the child run; set \
1258         `sub_pipeline.propagate.produces = true` to aggregate them"
1259    )]
1260    SubPipelineHasProduces(String),
1261    #[error("step `{0}`: gha-workflow steps must omit `argv`")]
1262    GhaWorkflowHasArgv(String),
1263    #[error("step `{0}`: gha-workflow steps require a `[gha_workflow]` block with `path = ...`")]
1264    GhaWorkflowMissingConfig(String),
1265    #[error("step `{0}`: import steps must omit `argv`")]
1266    ImportHasArgv(String),
1267    #[error("step `{0}`: import steps require an `[import]` block with `source = \"...\"`")]
1268    ImportMissingConfig(String),
1269    #[error(
1270        "step `{0}`: `background` / `background_until` is only valid on subprocess \
1271         steps — a background sub-pipeline / gha-workflow / build-image sidecar \
1272         has no lifecycle yet (R513-F2)"
1273    )]
1274    BackgroundRequiresSubprocess(String),
1275    #[error("step `{0}`: wait-for steps must omit `argv` (a wait-for is a pure gate)")]
1276    WaitForHasArgv(String),
1277    #[error(
1278        "step `{0}`: wait-for steps require a `[wait_for]` block with `http = ...` or `tcp = ...`"
1279    )]
1280    WaitForMissingConfig(String),
1281    #[error(
1282        "step `{0}`: wait-for needs exactly one target — set `http = \"http://…\"` \
1283         OR `tcp = \"host:port\"`, not neither"
1284    )]
1285    WaitForNeedsTarget(String),
1286    #[error(
1287        "step `{0}`: wait-for accepts only one target — set `http` OR `tcp`, not both"
1288    )]
1289    WaitForAmbiguousTarget(String),
1290    #[error(
1291        "step `{0}`: `expect_status` only applies to an `http` wait-for — \
1292         a `tcp` gate is healthy on connect, with no status to match"
1293    )]
1294    WaitForStatusNeedsHttp(String),
1295    #[error("step `{0}`: wait-for `timeout_secs` must be greater than zero")]
1296    WaitForZeroTimeout(String),
1297    #[error("step `{0}`: manifest-stitch steps must omit `argv` (the stitch is a pure registry op)")]
1298    ManifestStitchHasArgv(String),
1299    #[error(
1300        "step `{0}`: manifest-stitch steps require a `[manifest_stitch]` block with \
1301         `target = \"...\"` and `sources = [...]`"
1302    )]
1303    ManifestStitchMissingConfig(String),
1304    #[error("step `{0}`: manifest-stitch requires a non-empty `target` manifest-list tag")]
1305    ManifestStitchMissingTarget(String),
1306    #[error(
1307        "step `{0}`: manifest-stitch requires at least one `sources` entry \
1308         (the per-arch tags to fold into the manifest list)"
1309    )]
1310    ManifestStitchNeedsSources(String),
1311    #[error(
1312        "finally step `{0}`: v1 `[[finally]]` teardown supports only `kind = subprocess` \
1313         (and never `background`) — composite / image / sidecar teardown is a follow-up"
1314    )]
1315    FinallyRequiresSubprocess(String),
1316}
1317
1318/// Deliberately **not** `#[derive(Default)]`.
1319///
1320/// `enabled` carries `#[serde(default = "default_enabled")]` = `true`, and a
1321/// derived `Default` would give it `false` — so `QedStep { name, argv,
1322/// ..Default::default() }` would build a step that the runner silently *skips*,
1323/// and a pipeline made only of such steps reports `Success` having run nothing.
1324/// (R633 hit exactly that: a synthesized image build "succeeded" in 40 ms.)
1325///
1326/// Round-tripping serde's own defaults makes the two definitions the same
1327/// definition, so a future `#[serde(default = …)]` on some other field cannot
1328/// reintroduce the divergence. `name` is the only field without a serde default.
1329impl Default for QedStep {
1330    fn default() -> Self {
1331        serde_json::from_str(r#"{"name":""}"#)
1332            .expect("every QedStep field but `name` has a serde default")
1333    }
1334}
1335
1336impl QedStep {
1337    /// `true` when this step runs as a long-lived sidecar (R513-F2) — either
1338    /// `background = true` or a `background_until` target is set. See
1339    /// [`Self::background`] for the lifecycle.
1340    pub fn is_background(&self) -> bool {
1341        self.background || self.background_until.is_some()
1342    }
1343
1344    /// Validate kind-specific invariants. Called by the TOML loader
1345    /// (`PipelineLoader::load_from_file`) before the pipeline reaches the
1346    /// runner — fail loudly at parse time, not at execution time.
1347    pub fn validate(&self) -> Result<(), StepValidationError> {
1348        // R513-F2: background is a Subprocess-only knob in v1. A background
1349        // sub-pipeline / gha-workflow / build-image has no spawn-and-detach
1350        // lifecycle yet — reject before the runner so the error names the
1351        // offending step at parse time rather than mid-run.
1352        if self.is_background() && self.kind != StepKind::Subprocess {
1353            return Err(StepValidationError::BackgroundRequiresSubprocess(
1354                self.name.clone(),
1355            ));
1356        }
1357        match self.kind {
1358            StepKind::Subprocess => {
1359                if self.argv.is_empty() {
1360                    return Err(StepValidationError::SubprocessMissingArgv(
1361                        self.name.clone(),
1362                    ));
1363                }
1364                Ok(())
1365            }
1366            StepKind::BuildImage => {
1367                if !self.argv.is_empty() {
1368                    return Err(StepValidationError::BuildImageHasArgv(self.name.clone()));
1369                }
1370                if self.image.is_none() {
1371                    return Err(StepValidationError::BuildImageMissingImage(
1372                        self.name.clone(),
1373                    ));
1374                }
1375                if matches!(self.runtime, Some(TaskRuntime::Native)) {
1376                    return Err(StepValidationError::BuildImageNativeRuntime(
1377                        self.name.clone(),
1378                    ));
1379                }
1380                Ok(())
1381            }
1382            StepKind::PackageNativeTarball => {
1383                if !self.argv.is_empty() {
1384                    return Err(StepValidationError::PackageNativeTarballHasArgv(
1385                        self.name.clone(),
1386                    ));
1387                }
1388                if self.image.is_none() {
1389                    return Err(StepValidationError::PackageNativeTarballMissingImage(
1390                        self.name.clone(),
1391                    ));
1392                }
1393                if self.binary_path.is_none() {
1394                    return Err(StepValidationError::PackageNativeTarballMissingBinaryPath(
1395                        self.name.clone(),
1396                    ));
1397                }
1398                if matches!(self.runtime, Some(TaskRuntime::Container)) {
1399                    return Err(StepValidationError::PackageNativeTarballContainerRuntime(
1400                        self.name.clone(),
1401                    ));
1402                }
1403                Ok(())
1404            }
1405            StepKind::MuslStaticPreflight => {
1406                if !self.argv.is_empty() {
1407                    return Err(StepValidationError::MuslStaticPreflightHasArgv(
1408                        self.name.clone(),
1409                    ));
1410                }
1411                if self.package.is_none() {
1412                    return Err(StepValidationError::MuslStaticPreflightMissingPackage(
1413                        self.name.clone(),
1414                    ));
1415                }
1416                if matches!(self.runtime, Some(TaskRuntime::Container)) {
1417                    return Err(StepValidationError::MuslStaticPreflightContainerRuntime(
1418                        self.name.clone(),
1419                    ));
1420                }
1421                Ok(())
1422            }
1423            StepKind::SignNativeTarball => {
1424                if !self.argv.is_empty() {
1425                    return Err(StepValidationError::SignNativeTarballHasArgv(
1426                        self.name.clone(),
1427                    ));
1428                }
1429                if self.image.is_none() {
1430                    return Err(StepValidationError::SignNativeTarballMissingImage(
1431                        self.name.clone(),
1432                    ));
1433                }
1434                if matches!(self.runtime, Some(TaskRuntime::Container)) {
1435                    return Err(StepValidationError::SignNativeTarballContainerRuntime(
1436                        self.name.clone(),
1437                    ));
1438                }
1439                Ok(())
1440            }
1441            StepKind::SubPipeline => {
1442                if !self.argv.is_empty() {
1443                    return Err(StepValidationError::SubPipelineHasArgv(self.name.clone()));
1444                }
1445                if self.sub_pipeline.is_none() {
1446                    return Err(StepValidationError::SubPipelineMissingConfig(
1447                        self.name.clone(),
1448                    ));
1449                }
1450                if !self.produces.is_empty() {
1451                    return Err(StepValidationError::SubPipelineHasProduces(
1452                        self.name.clone(),
1453                    ));
1454                }
1455                Ok(())
1456            }
1457            StepKind::GhaWorkflow => {
1458                if !self.argv.is_empty() {
1459                    return Err(StepValidationError::GhaWorkflowHasArgv(self.name.clone()));
1460                }
1461                if self.gha_workflow.is_none() {
1462                    return Err(StepValidationError::GhaWorkflowMissingConfig(
1463                        self.name.clone(),
1464                    ));
1465                }
1466                Ok(())
1467            }
1468            StepKind::Import => {
1469                if !self.argv.is_empty() {
1470                    return Err(StepValidationError::ImportHasArgv(self.name.clone()));
1471                }
1472                if self.import.is_none() {
1473                    return Err(StepValidationError::ImportMissingConfig(self.name.clone()));
1474                }
1475                Ok(())
1476            }
1477            StepKind::WaitFor => {
1478                if !self.argv.is_empty() {
1479                    return Err(StepValidationError::WaitForHasArgv(self.name.clone()));
1480                }
1481                let Some(cfg) = self.wait_for.as_ref() else {
1482                    return Err(StepValidationError::WaitForMissingConfig(self.name.clone()));
1483                };
1484                match (cfg.http.is_some(), cfg.tcp.is_some()) {
1485                    (false, false) => {
1486                        return Err(StepValidationError::WaitForNeedsTarget(self.name.clone()));
1487                    }
1488                    (true, true) => {
1489                        return Err(StepValidationError::WaitForAmbiguousTarget(
1490                            self.name.clone(),
1491                        ));
1492                    }
1493                    _ => {}
1494                }
1495                if cfg.expect_status.is_some() && cfg.tcp.is_some() {
1496                    return Err(StepValidationError::WaitForStatusNeedsHttp(self.name.clone()));
1497                }
1498                if cfg.timeout_secs == 0 {
1499                    return Err(StepValidationError::WaitForZeroTimeout(self.name.clone()));
1500                }
1501                Ok(())
1502            }
1503            StepKind::ManifestStitch => {
1504                if !self.argv.is_empty() {
1505                    return Err(StepValidationError::ManifestStitchHasArgv(self.name.clone()));
1506                }
1507                let Some(cfg) = self.manifest_stitch.as_ref() else {
1508                    return Err(StepValidationError::ManifestStitchMissingConfig(
1509                        self.name.clone(),
1510                    ));
1511                };
1512                if cfg.target.trim().is_empty() {
1513                    return Err(StepValidationError::ManifestStitchMissingTarget(
1514                        self.name.clone(),
1515                    ));
1516                }
1517                if cfg.sources.is_empty() {
1518                    return Err(StepValidationError::ManifestStitchNeedsSources(
1519                        self.name.clone(),
1520                    ));
1521                }
1522                Ok(())
1523            }
1524        }
1525    }
1526
1527    /// Validate a step that lives in a pipeline's `[[finally]]` teardown block
1528    /// (R513-F4). Runs the normal kind-specific [`Self::validate`] first, then
1529    /// enforces the v1 `finally`-only constraint: teardown is a plain
1530    /// [`StepKind::Subprocess`] and never a `background` sidecar (a detached
1531    /// teardown step has no one to reap it). Composite / image / sub-pipeline
1532    /// teardown is a documented follow-up.
1533    pub fn validate_finally(&self) -> Result<(), StepValidationError> {
1534        self.validate()?;
1535        if self.kind != StepKind::Subprocess || self.is_background() {
1536            return Err(StepValidationError::FinallyRequiresSubprocess(
1537                self.name.clone(),
1538            ));
1539        }
1540        Ok(())
1541    }
1542}
1543
1544/// One built artifact a step emits, addressed into the release channel as
1545/// `[<prefix>/]<binary>/<version>/<triple>/<filename>`.
1546///
1547/// The producer leg of the almanac releases feed (R330): the QED
1548/// `release-build` pipeline declares these on its build steps, and
1549/// [`Outcome::Publish`] copies them into the public-read channel bucket where
1550/// they double as the self-update pointer source AND almanac's `R2Channel`
1551/// input (see self-updating-binaries.md, `crates/yah/almanac/src/r2.rs`).
1552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1553#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1554pub struct ProducedArtifact {
1555    /// Logical binary name — becomes the channel sub-path (`yah`, `desktop`,
1556    /// `camp`). The per-binary `release-manifest.json` lives at this root.
1557    pub binary: String,
1558    /// Path to the built file, resolved relative to the step's `cwd`
1559    /// (defaults to the workspace root). The basename becomes the channel
1560    /// filename.
1561    pub path: String,
1562    /// Target-triple shorthand (e.g. `darwin-aarch64`). `None` resolves to the
1563    /// build host's triple at publish time — GHA fans out one `yah qed run
1564    /// release-build` per platform, each publishing its own triple into the
1565    /// shared bucket.
1566    #[serde(default)]
1567    pub triple: Option<String>,
1568}
1569
1570#[derive(Debug, Clone, Serialize, Deserialize)]
1571#[serde(tag = "kind", rename_all = "lowercase")]
1572#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1573pub enum OnFail {
1574    Abort,
1575    Continue,
1576    Retry { max: u32 },
1577}
1578
1579impl Default for OnFail {
1580    fn default() -> Self {
1581        OnFail::Abort
1582    }
1583}
1584
1585#[derive(Debug, Clone, Serialize, Deserialize)]
1586#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1587pub struct ParamDef {
1588    #[serde(default)]
1589    pub required: bool,
1590    #[serde(default)]
1591    pub description: Option<String>,
1592}
1593
1594#[derive(Debug, Clone, Serialize, Deserialize)]
1595#[serde(tag = "kind", rename_all = "kebab-case")]
1596#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1597pub enum Outcome {
1598    WardenDeploy {
1599        service: String,
1600        env: String,
1601    },
1602    AlmanacRun {
1603        pipeline: String,
1604    },
1605    /// Publish the artifacts declared by the run's successful steps
1606    /// (`QedStep::produces`) into a release channel bucket, then fire the
1607    /// almanac revalidate hook (R330-F3). This is the producer leg of the
1608    /// data-driven releases feed.
1609    Publish {
1610        /// Storage provider — `"r2"` today (Cloudflare R2 via the S3 API,
1611        /// reusing the cloud crate's `publish_to_r2`).
1612        provider: String,
1613        /// Destination bucket (public-read channel), e.g. `"yah-releases"`.
1614        bucket: String,
1615        /// Optional key prefix within the bucket. Channel keys are laid out
1616        /// as `[<prefix>/]<binary>/<version>/<triple>/<filename>`.
1617        #[serde(default)]
1618        prefix: Option<String>,
1619        /// Public-facing root used to write absolute download URLs into the
1620        /// emitted `release-manifest.json` (e.g. `"https://releases.yah.dev"`).
1621        /// When `None`, manifest URLs are written as bucket-relative keys.
1622        #[serde(default)]
1623        base_url: Option<String>,
1624    },
1625    /// Dispatch a named vendor release adapter (R509) — Apple notarize/staple,
1626    /// Authenticode sign, Sparkle appcast, TestFlight/Play/GitHub upload.
1627    /// Resolved by `provider` name through the runner's
1628    /// [`crate::provider::ProviderRegistry`]; credentials resolve through the
1629    /// secrets bridge. Unlike [`Outcome::Publish`] (which syncs a staged tree
1630    /// to a bucket), these adapters transform an artifact in place or block on a
1631    /// remote vendor ticket — see [`crate::provider`]. A pipeline may chain
1632    /// several (`notarize` then `sparkle`): each adapter's transformed
1633    /// artifacts feed the next outcome's input set.
1634    Provider {
1635        /// Adapter name in the [`crate::provider::ProviderRegistry`]
1636        /// (`"notarize"`, `"authenticode"`, `"sparkle"`, …).
1637        provider: String,
1638        /// Vendor-specific config blob (the outcome's `with = { … }` table),
1639        /// opaque here — each adapter deserializes its own typed config.
1640        #[serde(default)]
1641        with: serde_json::Value,
1642        /// Public-facing root for absolute URLs an adapter emits (appcast feed
1643        /// base, release page). `None` leaves URL construction to the adapter.
1644        #[serde(default)]
1645        base_url: Option<String>,
1646    },
1647}
1648
1649#[derive(Debug, Clone, Serialize, Deserialize)]
1650pub struct QedRunMeta {
1651    pub id: QedRunId,
1652    pub pipeline: String,
1653    pub status: RunStatus,
1654    pub created_at: DateTime<Utc>,
1655    pub completed_at: Option<DateTime<Utc>>,
1656    pub steps: Vec<StepStatus>,
1657    /// Run-level failure reason for a failure that happened *outside* any
1658    /// step — a [`RunnerError`] returned before the first `StepStarted`
1659    /// (workspace positioning, toolchain preflight, background-sidecar
1660    /// validation) or after the last step. Unlike [`StepStatus::error`],
1661    /// which explains why a *step* failed, this carries the reason a run
1662    /// died with an empty (or partial) `steps` list, so `qed.status` /
1663    /// the desktop can surface *why* instead of a bare "failed" with
1664    /// nothing to anchor a card on. `None` on success and on step-level
1665    /// failures (the reason lives on the failing [`StepStatus`] there).
1666    #[serde(default, skip_serializing_if = "Option::is_none")]
1667    pub failure_reason: Option<String>,
1668    /// Set on child runs spawned by a [`StepKind::SubPipeline`] step (W201-F5).
1669    /// Carries the immediate parent's [`QedRunId`] so a consumer can walk
1670    /// from a child up to its parent (and recursively to the root). `None`
1671    /// on a top-level run.
1672    #[serde(default, skip_serializing_if = "Option::is_none")]
1673    pub parent_run_id: Option<QedRunId>,
1674}
1675
1676#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1677#[serde(rename_all = "lowercase")]
1678#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1679pub enum RunStatus {
1680    /// Registered but waiting on its `concurrency_key` lock. Emitted as
1681    /// the very first status after `qed_run_handler` registers the run;
1682    /// transitions to `Running` when the key's mutex is acquired.
1683    Queued,
1684    Running,
1685    Success,
1686    Failed,
1687    Cancelled,
1688    /// Step (or run) was skipped without executing (R506). Set when
1689    /// [`QedStep::enabled`] is `false`, [`QedStep::activation`] is
1690    /// [`StepActivation::Stubbed`] (and `--include-stubbed` wasn't passed),
1691    /// or [`QedStep::if_cond`] evaluated to a falsy value. A skipped step
1692    /// does not flip the run's overall status to `Failed`.
1693    Skipped,
1694}
1695
1696impl RunStatus {
1697    /// Aggregate child run statuses into a single parent status (R506-F1
1698    /// matrix fan-out). Mirrors [`yah_qed_gha::JobResult::aggregate`] exactly so a
1699    /// matrixed parent run reports the same overall verdict the GHA graph would
1700    /// for the same set of rows: any `Failed` wins, then `Cancelled`, then
1701    /// `Success`, and only an all-`Skipped` (or empty) set reports `Skipped`.
1702    ///
1703    /// `Queued` / `Running` contribute nothing — `aggregate` is meant to be
1704    /// called once every child has reached a terminal state.
1705    pub fn aggregate<I: IntoIterator<Item = RunStatus>>(children: I) -> RunStatus {
1706        let mut seen_success = false;
1707        let mut seen_failure = false;
1708        let mut seen_cancelled = false;
1709        let mut seen_any = false;
1710        for r in children {
1711            seen_any = true;
1712            match r {
1713                RunStatus::Failed => seen_failure = true,
1714                RunStatus::Cancelled => seen_cancelled = true,
1715                RunStatus::Success => seen_success = true,
1716                RunStatus::Skipped | RunStatus::Queued | RunStatus::Running => {}
1717            }
1718        }
1719        if !seen_any {
1720            RunStatus::Skipped
1721        } else if seen_failure {
1722            RunStatus::Failed
1723        } else if seen_cancelled {
1724            RunStatus::Cancelled
1725        } else if seen_success {
1726            RunStatus::Success
1727        } else {
1728            RunStatus::Skipped
1729        }
1730    }
1731}
1732
1733#[derive(Debug, Clone, Serialize, Deserialize)]
1734pub struct StepStatus {
1735    pub name: String,
1736    pub task_run_id: Option<ForgeId>,
1737    pub status: RunStatus,
1738    pub started_at: Option<DateTime<Utc>>,
1739    pub completed_at: Option<DateTime<Utc>>,
1740    /// Failure reason for a `Failed` step — the `StepFailed.msg` tail (stderr
1741    /// tail for subprocess steps, a typed reason for resolver/config errors).
1742    /// Persisted on the terminal run meta so `qed.status` / `qed report` can
1743    /// surface *why* a step failed long after the live event stream is gone
1744    /// (the reason was previously only emitted into the `StepFinished` event,
1745    /// which doesn't survive in the meta json). `None` for non-failed steps,
1746    /// or when the failure carried no message.
1747    #[serde(default, skip_serializing_if = "Option::is_none")]
1748    pub error: Option<String>,
1749    /// Key-value outputs collected from `$YAH_OUTPUTS` after the step ran
1750    /// (W201-F4). Empty when the step did not write any outputs, when the
1751    /// step kind doesn't support output collection (container, remote,
1752    /// sub-pipeline), or when the step failed before writing anything.
1753    #[serde(default)]
1754    pub outputs: HashMap<String, String>,
1755    /// W209: bind results applied immediately after this step succeeded.
1756    /// Each entry records `file`, `path`, `from`, `old`, `new`, and a
1757    /// `changed` bool the qed-run tile uses to surface "Bound N values in
1758    /// <file> — review diff" (F7) and to drive hash-change hooks (F6).
1759    /// Empty when this step had no binds referencing it, when the predicate
1760    /// rejected every candidate value (e.g. all binds are pinned), or when
1761    /// the step failed before any bind could fire.
1762    #[serde(default)]
1763    pub applied_binds: Vec<manifest_bind::AppliedBind>,
1764    /// Per-job rows for a step that wraps a foreign pipeline (W223 R532-T1).
1765    /// Non-empty only when this step wraps a GitHub Actions workflow — whether
1766    /// reached as a [`StepKind::GhaWorkflow`] step or a [`StepKind::SubPipeline`]
1767    /// whose target is a GHA workflow — which fans out to many jobs. Each row
1768    /// carries one job's terminal status and (on failure) its stderr-tail
1769    /// detail, so the report renders the wrapped workflow *transparently* (the
1770    /// same per-job shape the graph viewer draws) instead of collapsing it into
1771    /// one flattened failure string. The R516 skip-count folds into per-row
1772    /// [`RunStatus::Skipped`] state rather than a trailing sentence. Empty for
1773    /// native steps and for non-GHA sub-pipelines (transparency generalizes to
1774    /// the other `SubPipelineRef` kinds in a later phase).
1775    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1776    pub jobs: Vec<JobRow>,
1777}
1778
1779/// One job within a wrapped foreign pipeline's [`StepStatus`] (W223 R532-T1).
1780///
1781/// A wrapped GHA workflow is a *disregarded entity*: structurally it is one
1782/// QED step, but its internal jobs are attributed to that step's report row as
1783/// if the wrapper weren't there. This is the persisted, structured equivalent
1784/// of one of those jobs.
1785#[derive(Debug, Clone, Serialize, Deserialize)]
1786pub struct JobRow {
1787    /// GHA job id (the `jobs.<id>` key). Combined with the wrapping step's name
1788    /// this yields the stable node address `<step>.<job_id>` that the report,
1789    /// the graph viewer, and `needs.*` cross-references all name (W223
1790    /// §identity — mirrors the existing `<job_id>.<output_key>` output-lifting
1791    /// convention).
1792    pub id: String,
1793    /// This job's terminal status: `Success` / `Failed` / `Skipped`.
1794    /// `Cancelled` maps to `Failed`.
1795    pub status: RunStatus,
1796    /// Failure detail for a `Failed` job — the failing step's name plus its
1797    /// stderr tail (the same text the flattened summary used to concatenate).
1798    /// `None` for success / skipped rows.
1799    #[serde(default, skip_serializing_if = "Option::is_none")]
1800    pub error: Option<String>,
1801    /// Logical job ids this job `needs:` — the intra-workflow dependency edges
1802    /// already computed by `yah_qed_gha::plan` (W223 R532-F2). The graph viewer
1803    /// renders these as real dependency edges between the inlined job nodes, so
1804    /// the wave ordering inside the wrapped workflow is visible rather than a
1805    /// flat list. Empty for a job with no declared `needs`.
1806    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1807    pub needs: Vec<String>,
1808}
1809
1810#[cfg(test)]
1811mod tests {
1812    use super::*;
1813
1814    fn one_step(argv: Vec<&str>, env: &[(&str, &str)]) -> Pipeline {
1815        Pipeline {
1816            name: "p".into(),
1817            label: "p".into(),
1818            steps: vec![QedStep {
1819                background: false,
1820                background_until: None,
1821                wait_for: None,
1822                manifest_stitch: None,
1823                name: "s".into(),
1824                argv: argv.into_iter().map(String::from).collect(),
1825                cwd: None,
1826                env: env
1827                    .iter()
1828                    .map(|(k, v)| (k.to_string(), v.to_string()))
1829                    .collect(),
1830                timeout: None,
1831                on_fail: OnFail::Abort,
1832                produces: Vec::new(),
1833                runtime: None,
1834                kind: StepKind::Subprocess,
1835                image: None,
1836                tag: None,
1837                push: false,
1838                platforms: Vec::new(),
1839                binary_path: None,
1840                triple: None,
1841                package: None,
1842                context: None,
1843                load: false,
1844                sub_pipeline: None,
1845                gha_workflow: None,
1846                import: None,
1847                matrix: None,
1848                enabled: true,
1849                activation: StepActivation::Active,
1850                if_cond: None,
1851                platform: None,
1852                toolchain: None,
1853                outputs: Vec::new(),
1854            }],
1855            params: HashMap::new(),
1856            on_success: vec![],
1857            on_fail: vec![],
1858            triggers: vec![],
1859            concurrency_key: None,
1860            placement: Placement::default(),
1861            workspace: crate::types::WorkspaceMode::default(),
1862            wraps: None,
1863            matrix: None,
1864            toolchain: None,
1865            binds: Vec::new(),
1866            on_change: Vec::new(),
1867            finally: Vec::new(),
1868        }
1869    }
1870
1871    #[test]
1872    fn apply_params_substitutes_argv_and_env() {
1873        let mut p = one_step(
1874            vec!["run", "--", "{{provider}}"],
1875            &[("KEY", "{{provider}}-x")],
1876        );
1877        let mut params = HashMap::new();
1878        params.insert("provider".to_string(), "groq".to_string());
1879        p.apply_params(&params);
1880        assert_eq!(p.steps[0].argv, vec!["run", "--", "groq"]);
1881        assert_eq!(p.steps[0].env.get("KEY").unwrap(), "groq-x");
1882    }
1883
1884    #[test]
1885    fn run_status_aggregate_failure_dominates() {
1886        use RunStatus::*;
1887        assert_eq!(
1888            RunStatus::aggregate([Success, Failed, Skipped, Success]),
1889            Failed
1890        );
1891        assert_eq!(RunStatus::aggregate([Cancelled, Failed]), Failed);
1892    }
1893
1894    #[test]
1895    fn run_status_aggregate_cancelled_beats_success_and_skipped() {
1896        use RunStatus::*;
1897        assert_eq!(
1898            RunStatus::aggregate([Success, Cancelled, Skipped]),
1899            Cancelled
1900        );
1901    }
1902
1903    #[test]
1904    fn run_status_aggregate_success_beats_skipped() {
1905        use RunStatus::*;
1906        // A matrix where one row ran and others were if=-gated out is green.
1907        assert_eq!(RunStatus::aggregate([Skipped, Success, Skipped]), Success);
1908    }
1909
1910    #[test]
1911    fn run_status_aggregate_all_skipped_is_skipped() {
1912        use RunStatus::*;
1913        assert_eq!(RunStatus::aggregate([Skipped, Skipped]), Skipped);
1914        // Empty (vacuous) also reports Skipped, mirroring JobResult::aggregate.
1915        assert_eq!(RunStatus::aggregate(std::iter::empty()), Skipped);
1916    }
1917
1918    #[test]
1919    fn run_status_aggregate_ignores_non_terminal() {
1920        use RunStatus::*;
1921        // Queued/Running contribute nothing; a lone Success still wins.
1922        assert_eq!(RunStatus::aggregate([Queued, Running, Success]), Success);
1923    }
1924
1925    fn build_image_step(name: &str) -> QedStep {
1926        QedStep {
1927            background: false,
1928            background_until: None,
1929            wait_for: None,
1930            manifest_stitch: None,
1931            name: name.into(),
1932            argv: Vec::new(),
1933            cwd: None,
1934            env: HashMap::new(),
1935            timeout: None,
1936            on_fail: OnFail::Abort,
1937            produces: Vec::new(),
1938            runtime: Some(TaskRuntime::Container),
1939            kind: StepKind::BuildImage,
1940            image: Some("yah-rust".into()),
1941            tag: None,
1942            push: false,
1943            platforms: Vec::new(),
1944            binary_path: None,
1945            triple: None,
1946            package: None,
1947            context: None,
1948            load: false,
1949            sub_pipeline: None,
1950            gha_workflow: None,
1951            import: None,
1952            matrix: None,
1953            enabled: true,
1954            activation: StepActivation::Active,
1955            if_cond: None,
1956            platform: None,
1957            toolchain: None,
1958            outputs: Vec::new(),
1959        }
1960    }
1961
1962    fn package_native_tarball_step(name: &str) -> QedStep {
1963        QedStep {
1964            background: false,
1965            background_until: None,
1966            wait_for: None,
1967            manifest_stitch: None,
1968            name: name.into(),
1969            argv: Vec::new(),
1970            cwd: None,
1971            env: HashMap::new(),
1972            timeout: None,
1973            on_fail: OnFail::Abort,
1974            produces: Vec::new(),
1975            runtime: None,
1976            kind: StepKind::PackageNativeTarball,
1977            image: Some("yah-yubaba".into()),
1978            tag: None,
1979            push: false,
1980            platforms: Vec::new(),
1981            binary_path: Some("target/x86_64-unknown-linux-musl/release/yubaba".into()),
1982            triple: Some("x86_64-unknown-linux-musl".into()),
1983            package: None,
1984            context: None,
1985            load: false,
1986            sub_pipeline: None,
1987            gha_workflow: None,
1988            import: None,
1989            matrix: None,
1990            enabled: true,
1991            activation: StepActivation::Active,
1992            if_cond: None,
1993            platform: None,
1994            toolchain: None,
1995            outputs: Vec::new(),
1996        }
1997    }
1998
1999    fn musl_static_preflight_step(name: &str) -> QedStep {
2000        QedStep {
2001            background: false,
2002            background_until: None,
2003            wait_for: None,
2004            manifest_stitch: None,
2005            name: name.into(),
2006            argv: Vec::new(),
2007            cwd: None,
2008            env: HashMap::new(),
2009            timeout: None,
2010            on_fail: OnFail::Abort,
2011            produces: Vec::new(),
2012            runtime: None,
2013            kind: StepKind::MuslStaticPreflight,
2014            image: None,
2015            tag: None,
2016            push: false,
2017            platforms: Vec::new(),
2018            binary_path: None,
2019            triple: None,
2020            package: Some("yubaba".into()),
2021            context: None,
2022            load: false,
2023            sub_pipeline: None,
2024            gha_workflow: None,
2025            import: None,
2026            matrix: None,
2027            enabled: true,
2028            activation: StepActivation::Active,
2029            if_cond: None,
2030            platform: None,
2031            toolchain: None,
2032            outputs: Vec::new(),
2033        }
2034    }
2035
2036    #[test]
2037    fn subprocess_with_argv_validates() {
2038        let step = one_step(vec!["echo", "hi"], &[]).steps.remove(0);
2039        step.validate().unwrap();
2040    }
2041
2042    #[test]
2043    fn subprocess_without_argv_rejected() {
2044        let mut step = one_step(vec!["echo"], &[]).steps.remove(0);
2045        step.argv.clear();
2046        assert_eq!(
2047            step.validate().unwrap_err(),
2048            StepValidationError::SubprocessMissingArgv("s".into())
2049        );
2050    }
2051
2052    #[test]
2053    fn build_image_happy_path_validates() {
2054        build_image_step("bake").validate().unwrap();
2055    }
2056
2057    #[test]
2058    fn build_image_with_argv_rejected() {
2059        let mut step = build_image_step("bake");
2060        step.argv = vec!["docker".into()];
2061        assert_eq!(
2062            step.validate().unwrap_err(),
2063            StepValidationError::BuildImageHasArgv("bake".into())
2064        );
2065    }
2066
2067    #[test]
2068    fn build_image_without_image_rejected() {
2069        let mut step = build_image_step("bake");
2070        step.image = None;
2071        assert_eq!(
2072            step.validate().unwrap_err(),
2073            StepValidationError::BuildImageMissingImage("bake".into())
2074        );
2075    }
2076
2077    #[test]
2078    fn build_image_with_native_runtime_rejected() {
2079        let mut step = build_image_step("bake");
2080        step.runtime = Some(TaskRuntime::Native);
2081        assert_eq!(
2082            step.validate().unwrap_err(),
2083            StepValidationError::BuildImageNativeRuntime("bake".into())
2084        );
2085    }
2086
2087    #[test]
2088    fn build_image_with_default_runtime_accepted() {
2089        // runtime = None means the pipeline default applies; resolve_runtime
2090        // forces Container for build-image steps at runner time. Parse-time
2091        // validation lets this through.
2092        let mut step = build_image_step("bake");
2093        step.runtime = None;
2094        step.validate().unwrap();
2095    }
2096
2097    // ── R407-T2 package-native-tarball validation ──────────────────────────
2098
2099    #[test]
2100    fn package_native_tarball_happy_path_validates() {
2101        package_native_tarball_step("pack").validate().unwrap();
2102    }
2103
2104    #[test]
2105    fn package_native_tarball_with_argv_rejected() {
2106        let mut step = package_native_tarball_step("pack");
2107        step.argv = vec!["tar".into()];
2108        assert_eq!(
2109            step.validate().unwrap_err(),
2110            StepValidationError::PackageNativeTarballHasArgv("pack".into()),
2111        );
2112    }
2113
2114    #[test]
2115    fn package_native_tarball_without_image_rejected() {
2116        let mut step = package_native_tarball_step("pack");
2117        step.image = None;
2118        assert_eq!(
2119            step.validate().unwrap_err(),
2120            StepValidationError::PackageNativeTarballMissingImage("pack".into()),
2121        );
2122    }
2123
2124    #[test]
2125    fn package_native_tarball_without_binary_path_rejected() {
2126        let mut step = package_native_tarball_step("pack");
2127        step.binary_path = None;
2128        assert_eq!(
2129            step.validate().unwrap_err(),
2130            StepValidationError::PackageNativeTarballMissingBinaryPath("pack".into()),
2131        );
2132    }
2133
2134    #[test]
2135    fn package_native_tarball_with_container_runtime_rejected() {
2136        let mut step = package_native_tarball_step("pack");
2137        step.runtime = Some(TaskRuntime::Container);
2138        assert_eq!(
2139            step.validate().unwrap_err(),
2140            StepValidationError::PackageNativeTarballContainerRuntime("pack".into()),
2141        );
2142    }
2143
2144    #[test]
2145    fn package_native_tarball_with_explicit_native_runtime_accepted() {
2146        let mut step = package_native_tarball_step("pack");
2147        step.runtime = Some(TaskRuntime::Native);
2148        step.validate().unwrap();
2149    }
2150
2151    // ── R407-T3 musl-static-preflight validation ───────────────────────────
2152
2153    #[test]
2154    fn musl_static_preflight_happy_path_validates() {
2155        musl_static_preflight_step("preflight").validate().unwrap();
2156    }
2157
2158    #[test]
2159    fn musl_static_preflight_with_argv_rejected() {
2160        let mut step = musl_static_preflight_step("preflight");
2161        step.argv = vec!["cargo".into()];
2162        assert_eq!(
2163            step.validate().unwrap_err(),
2164            StepValidationError::MuslStaticPreflightHasArgv("preflight".into()),
2165        );
2166    }
2167
2168    #[test]
2169    fn musl_static_preflight_without_package_rejected() {
2170        let mut step = musl_static_preflight_step("preflight");
2171        step.package = None;
2172        assert_eq!(
2173            step.validate().unwrap_err(),
2174            StepValidationError::MuslStaticPreflightMissingPackage("preflight".into()),
2175        );
2176    }
2177
2178    #[test]
2179    fn musl_static_preflight_with_container_runtime_rejected() {
2180        let mut step = musl_static_preflight_step("preflight");
2181        step.runtime = Some(TaskRuntime::Container);
2182        assert_eq!(
2183            step.validate().unwrap_err(),
2184            StepValidationError::MuslStaticPreflightContainerRuntime("preflight".into()),
2185        );
2186    }
2187
2188    // ── R407-T5 sign-native-tarball validation ─────────────────────────────
2189
2190    fn sign_native_tarball_step(name: &str) -> QedStep {
2191        QedStep {
2192            background: false,
2193            background_until: None,
2194            wait_for: None,
2195            manifest_stitch: None,
2196            name: name.into(),
2197            argv: Vec::new(),
2198            cwd: None,
2199            env: HashMap::new(),
2200            timeout: None,
2201            on_fail: OnFail::Abort,
2202            produces: Vec::new(),
2203            runtime: None,
2204            kind: StepKind::SignNativeTarball,
2205            image: Some("yah-yubaba".into()),
2206            tag: None,
2207            push: false,
2208            platforms: Vec::new(),
2209            binary_path: None,
2210            triple: Some("x86_64-unknown-linux-musl".into()),
2211            package: None,
2212            context: None,
2213            load: false,
2214            sub_pipeline: None,
2215            gha_workflow: None,
2216            import: None,
2217            matrix: None,
2218            enabled: true,
2219            activation: StepActivation::Active,
2220            if_cond: None,
2221            platform: None,
2222            toolchain: None,
2223            outputs: Vec::new(),
2224        }
2225    }
2226
2227    #[test]
2228    fn sign_native_tarball_happy_path_validates() {
2229        sign_native_tarball_step("sign").validate().unwrap();
2230    }
2231
2232    #[test]
2233    fn sign_native_tarball_with_argv_rejected() {
2234        let mut step = sign_native_tarball_step("sign");
2235        step.argv = vec!["cosign".into()];
2236        assert_eq!(
2237            step.validate().unwrap_err(),
2238            StepValidationError::SignNativeTarballHasArgv("sign".into()),
2239        );
2240    }
2241
2242    #[test]
2243    fn sign_native_tarball_without_image_rejected() {
2244        let mut step = sign_native_tarball_step("sign");
2245        step.image = None;
2246        assert_eq!(
2247            step.validate().unwrap_err(),
2248            StepValidationError::SignNativeTarballMissingImage("sign".into()),
2249        );
2250    }
2251
2252    #[test]
2253    fn sign_native_tarball_with_container_runtime_rejected() {
2254        let mut step = sign_native_tarball_step("sign");
2255        step.runtime = Some(TaskRuntime::Container);
2256        assert_eq!(
2257            step.validate().unwrap_err(),
2258            StepValidationError::SignNativeTarballContainerRuntime("sign".into()),
2259        );
2260    }
2261
2262    #[test]
2263    fn sign_native_tarball_with_explicit_native_runtime_accepted() {
2264        let mut step = sign_native_tarball_step("sign");
2265        step.runtime = Some(TaskRuntime::Native);
2266        step.validate().unwrap();
2267    }
2268
2269    // ── R435-F1 placement enum serde round-trip ────────────────────────────
2270
2271    #[test]
2272    fn placement_round_trip_each_variant() {
2273        for (variant, kebab) in [
2274            (Placement::LocalOnly, "local-only"),
2275            (Placement::CiOnly, "ci-only"),
2276            (Placement::Anywhere, "anywhere"),
2277        ] {
2278            let json = serde_json::to_string(&variant).unwrap();
2279            assert_eq!(json, format!("\"{kebab}\""), "serialize {variant:?}");
2280            let parsed: Placement = serde_json::from_str(&json).unwrap();
2281            assert_eq!(parsed, variant, "deserialize {kebab}");
2282        }
2283    }
2284
2285    #[test]
2286    fn step_platform_block_parses_from_toml() {
2287        // R531-F2: a step's `[platform]` inline table deserializes into the
2288        // structured PlatformSpec; omitting it leaves the field None.
2289        let toml_src = r#"
2290            name = "p"
2291            label = "p"
2292            [[steps]]
2293            name = "build-musl"
2294            argv = ["cargo", "build"]
2295            platform = { target = "x86_64-unknown-linux-musl", container_platform = "linux/amd64" }
2296            [[steps]]
2297            name = "check"
2298            argv = ["cargo", "check"]
2299        "#;
2300        let pipeline: Pipeline = toml::from_str(toml_src).unwrap();
2301        let spec = pipeline.steps[0]
2302            .platform
2303            .as_ref()
2304            .expect("platform parsed");
2305        assert_eq!(spec.target.as_deref(), Some("x86_64-unknown-linux-musl"));
2306        assert_eq!(spec.container_platform.as_deref(), Some("linux/amd64"));
2307        assert!(
2308            pipeline.steps[1].platform.is_none(),
2309            "a step without a [platform] block leaves the field None",
2310        );
2311    }
2312
2313    #[test]
2314    fn placement_defaults_to_anywhere_when_omitted() {
2315        let toml_src = r#"
2316            name = "p"
2317            label = "p"
2318            steps = []
2319        "#;
2320        let pipeline: Pipeline = toml::from_str(toml_src).unwrap();
2321        assert_eq!(pipeline.placement, Placement::Anywhere);
2322    }
2323
2324    #[test]
2325    fn placement_parses_each_kebab_value_from_toml() {
2326        for (kebab, expected) in [
2327            ("local-only", Placement::LocalOnly),
2328            ("ci-only", Placement::CiOnly),
2329            ("anywhere", Placement::Anywhere),
2330        ] {
2331            let toml_src = format!(
2332                r#"
2333                name = "p"
2334                label = "p"
2335                placement = "{kebab}"
2336                steps = []
2337                "#
2338            );
2339            let pipeline: Pipeline = toml::from_str(&toml_src).unwrap();
2340            assert_eq!(pipeline.placement, expected, "TOML placement = \"{kebab}\"");
2341        }
2342    }
2343
2344    #[test]
2345    fn apply_params_leaves_unknown_placeholders_untouched() {
2346        let mut p = one_step(vec!["{{missing}}"], &[]);
2347        p.apply_params(&HashMap::new());
2348        assert_eq!(
2349            p.steps[0].argv,
2350            vec!["{{missing}}"],
2351            "empty params is a no-op"
2352        );
2353
2354        let mut params = HashMap::new();
2355        params.insert("other".to_string(), "v".to_string());
2356        p.apply_params(&params);
2357        assert_eq!(
2358            p.steps[0].argv,
2359            vec!["{{missing}}"],
2360            "unknown key left as-is"
2361        );
2362    }
2363
2364    // ----- background sidecar validation (R513-F2) ----------------------------
2365
2366    #[test]
2367    fn background_on_subprocess_validates_and_reports_is_background() {
2368        let mut s = sub_pipeline_step("srv", SubPipelineRef::Builtin("x".into()));
2369        s.kind = StepKind::Subprocess;
2370        s.argv = vec!["yah-camp".into()];
2371        s.background = true;
2372        assert!(s.is_background());
2373        assert!(s.validate().is_ok(), "background subprocess step is valid");
2374
2375        s.background = false;
2376        s.background_until = Some("test".into());
2377        assert!(s.is_background(), "background_until implies background");
2378        assert!(s.validate().is_ok());
2379    }
2380
2381    #[test]
2382    fn background_on_non_subprocess_is_rejected() {
2383        let mut s = sub_pipeline_step("srv", SubPipelineRef::Builtin("x".into()));
2384        s.background = true;
2385        assert!(matches!(
2386            s.validate(),
2387            Err(StepValidationError::BackgroundRequiresSubprocess(_))
2388        ));
2389    }
2390
2391    // ----- SubPipeline (W201-F1) ----------------------------------------------
2392
2393    fn sub_pipeline_step(name: &str, target: SubPipelineRef) -> QedStep {
2394        QedStep {
2395            background: false,
2396            background_until: None,
2397            wait_for: None,
2398            manifest_stitch: None,
2399            name: name.into(),
2400            argv: Vec::new(),
2401            cwd: None,
2402            env: HashMap::new(),
2403            timeout: None,
2404            on_fail: OnFail::Abort,
2405            produces: Vec::new(),
2406            runtime: None,
2407            kind: StepKind::SubPipeline,
2408            image: None,
2409            tag: None,
2410            push: false,
2411            platforms: Vec::new(),
2412            binary_path: None,
2413            triple: None,
2414            package: None,
2415            context: None,
2416            load: false,
2417            sub_pipeline: Some(SubPipelineConfig {
2418                target,
2419                params: HashMap::new(),
2420                propagate: SubPipelineCollect::default(),
2421                opaque: false,
2422            }),
2423            outputs: Vec::new(),
2424            gha_workflow: None,
2425            import: None,
2426            matrix: None,
2427            enabled: true,
2428            activation: crate::types::StepActivation::Active,
2429            if_cond: None,
2430            platform: None,
2431            toolchain: None,
2432        }
2433    }
2434
2435    fn pipeline_with(name: &str, steps: Vec<QedStep>) -> Pipeline {
2436        Pipeline {
2437            name: name.into(),
2438            label: name.into(),
2439            steps,
2440            params: HashMap::new(),
2441            on_success: vec![],
2442            on_fail: vec![],
2443            triggers: vec![],
2444            concurrency_key: None,
2445            placement: Placement::default(),
2446            workspace: crate::types::WorkspaceMode::default(),
2447            wraps: None,
2448            matrix: None,
2449            toolchain: None,
2450            binds: Vec::new(),
2451            on_change: Vec::new(),
2452            finally: Vec::new(),
2453        }
2454    }
2455
2456    #[test]
2457    fn sub_pipeline_step_validates_when_well_formed() {
2458        let step = sub_pipeline_step("compose", SubPipelineRef::Builtin("desktop-release".into()));
2459        assert!(step.validate().is_ok());
2460    }
2461
2462    #[test]
2463    fn sub_pipeline_step_rejects_argv() {
2464        let mut step = sub_pipeline_step("compose", SubPipelineRef::Builtin("x".into()));
2465        step.argv = vec!["echo".into()];
2466        assert_eq!(
2467            step.validate(),
2468            Err(StepValidationError::SubPipelineHasArgv("compose".into()))
2469        );
2470    }
2471
2472    #[test]
2473    fn sub_pipeline_step_rejects_missing_config() {
2474        let mut step = sub_pipeline_step("compose", SubPipelineRef::Builtin("x".into()));
2475        step.sub_pipeline = None;
2476        assert_eq!(
2477            step.validate(),
2478            Err(StepValidationError::SubPipelineMissingConfig(
2479                "compose".into()
2480            ))
2481        );
2482    }
2483
2484    fn gha_workflow_step(name: &str) -> QedStep {
2485        let mut step = sub_pipeline_step(name, SubPipelineRef::Builtin("x".into()));
2486        step.kind = StepKind::GhaWorkflow;
2487        step.sub_pipeline = None;
2488        step.gha_workflow = Some(GhaWorkflowConfig {
2489            path: std::path::PathBuf::from(".github/workflows/release.yml"),
2490            event: Some("push".into()),
2491            inputs: HashMap::new(),
2492        });
2493        step
2494    }
2495
2496    #[test]
2497    fn gha_workflow_step_validates_when_well_formed() {
2498        let step = gha_workflow_step("run-release-yml");
2499        assert!(step.validate().is_ok());
2500    }
2501
2502    #[test]
2503    fn gha_workflow_step_rejects_argv() {
2504        let mut step = gha_workflow_step("run");
2505        step.argv = vec!["echo".into()];
2506        assert_eq!(
2507            step.validate(),
2508            Err(StepValidationError::GhaWorkflowHasArgv("run".into()))
2509        );
2510    }
2511
2512    #[test]
2513    fn gha_workflow_step_rejects_missing_config() {
2514        let mut step = gha_workflow_step("run");
2515        step.gha_workflow = None;
2516        assert_eq!(
2517            step.validate(),
2518            Err(StepValidationError::GhaWorkflowMissingConfig("run".into()))
2519        );
2520    }
2521
2522    // ── R533-F1 (W224): import step ────────────────────────────────────────
2523
2524    fn import_step(name: &str) -> QedStep {
2525        let mut step = gha_workflow_step(name);
2526        step.kind = StepKind::Import;
2527        step.gha_workflow = None;
2528        step.import = Some(ImportConfig {
2529            source: std::path::PathBuf::from(".github/workflows/release.yml"),
2530            hash: Some("af1349b9f5f9a1a6a0404dea36dcc949".into()),
2531            materialize: false,
2532            event: Some("push".into()),
2533            inputs: HashMap::new(),
2534        });
2535        step
2536    }
2537
2538    #[test]
2539    fn import_step_validates_when_well_formed() {
2540        assert!(import_step("release").validate().is_ok());
2541    }
2542
2543    #[test]
2544    fn import_step_rejects_argv() {
2545        let mut step = import_step("release");
2546        step.argv = vec!["echo".into()];
2547        assert_eq!(
2548            step.validate(),
2549            Err(StepValidationError::ImportHasArgv("release".into()))
2550        );
2551    }
2552
2553    #[test]
2554    fn import_step_rejects_missing_config() {
2555        let mut step = import_step("release");
2556        step.import = None;
2557        assert_eq!(
2558            step.validate(),
2559            Err(StepValidationError::ImportMissingConfig("release".into()))
2560        );
2561    }
2562
2563    #[test]
2564    fn import_step_round_trips_through_toml() {
2565        // The `[import]` block survives a TOML serialize → deserialize cycle,
2566        // including the pinned hash and the default-false materialize toggle.
2567        let step = import_step("release");
2568        let toml_str = toml::to_string(&step).expect("serialize import step");
2569        assert!(toml_str.contains("kind = \"import\""), "{toml_str}");
2570        assert!(
2571            toml_str.contains("source = \".github/workflows/release.yml\""),
2572            "{toml_str}"
2573        );
2574        let parsed: QedStep = toml::from_str(&toml_str).expect("deserialize import step");
2575        assert_eq!(parsed.kind, StepKind::Import);
2576        let cfg = parsed.import.expect("import block present");
2577        assert_eq!(
2578            cfg.source,
2579            std::path::PathBuf::from(".github/workflows/release.yml")
2580        );
2581        assert_eq!(
2582            cfg.hash.as_deref(),
2583            Some("af1349b9f5f9a1a6a0404dea36dcc949")
2584        );
2585        assert!(!cfg.materialize, "materialize defaults false (virtual)");
2586        assert_eq!(cfg.event.as_deref(), Some("push"));
2587    }
2588
2589    #[test]
2590    fn import_block_defaults_materialize_false_and_unpinned() {
2591        // A minimal `[import]` with only `source` parses — hash unpinned,
2592        // materialize off (virtual-by-default).
2593        let toml_str = r#"
2594            name = "release"
2595            kind = "import"
2596            [import]
2597            source = ".github/workflows/release.yml"
2598        "#;
2599        let step: QedStep = toml::from_str(toml_str).expect("parse minimal import");
2600        step.validate().expect("minimal import validates");
2601        let cfg = step.import.expect("import block");
2602        assert_eq!(cfg.hash, None, "unpinned by default");
2603        assert!(!cfg.materialize);
2604        assert_eq!(cfg.event, None);
2605    }
2606
2607    // ── R590-F2: manifest-stitch step ──────────────────────────────────────
2608
2609    fn manifest_stitch_step(name: &str) -> QedStep {
2610        let mut step = gha_workflow_step(name);
2611        step.kind = StepKind::ManifestStitch;
2612        step.gha_workflow = None;
2613        step.argv = vec![];
2614        step.manifest_stitch = Some(ManifestStitchConfig {
2615            target: "ghcr.io/yah-ai/yah-rust:v1".into(),
2616            sources: vec![
2617                "ghcr.io/yah-ai/yah-rust:v1-amd64".into(),
2618                "ghcr.io/yah-ai/yah-rust:v1-arm64".into(),
2619            ],
2620        });
2621        step
2622    }
2623
2624    #[test]
2625    fn manifest_stitch_step_validates_when_well_formed() {
2626        assert!(manifest_stitch_step("stitch").validate().is_ok());
2627    }
2628
2629    #[test]
2630    fn manifest_stitch_step_rejects_argv() {
2631        let mut step = manifest_stitch_step("stitch");
2632        step.argv = vec!["docker".into()];
2633        assert_eq!(
2634            step.validate(),
2635            Err(StepValidationError::ManifestStitchHasArgv("stitch".into()))
2636        );
2637    }
2638
2639    #[test]
2640    fn manifest_stitch_step_rejects_missing_config() {
2641        let mut step = manifest_stitch_step("stitch");
2642        step.manifest_stitch = None;
2643        assert_eq!(
2644            step.validate(),
2645            Err(StepValidationError::ManifestStitchMissingConfig("stitch".into()))
2646        );
2647    }
2648
2649    #[test]
2650    fn manifest_stitch_step_rejects_empty_target() {
2651        let mut step = manifest_stitch_step("stitch");
2652        step.manifest_stitch.as_mut().unwrap().target = "  ".into();
2653        assert_eq!(
2654            step.validate(),
2655            Err(StepValidationError::ManifestStitchMissingTarget("stitch".into()))
2656        );
2657    }
2658
2659    #[test]
2660    fn manifest_stitch_step_rejects_no_sources() {
2661        let mut step = manifest_stitch_step("stitch");
2662        step.manifest_stitch.as_mut().unwrap().sources = vec![];
2663        assert_eq!(
2664            step.validate(),
2665            Err(StepValidationError::ManifestStitchNeedsSources("stitch".into()))
2666        );
2667    }
2668
2669    #[test]
2670    fn manifest_stitch_step_round_trips_through_toml() {
2671        let step = manifest_stitch_step("stitch");
2672        let toml_str = toml::to_string(&step).expect("serialize manifest-stitch step");
2673        assert!(toml_str.contains("kind = \"manifest-stitch\""), "{toml_str}");
2674        let parsed: QedStep = toml::from_str(&toml_str).expect("deserialize manifest-stitch step");
2675        assert_eq!(parsed.kind, StepKind::ManifestStitch);
2676        let cfg = parsed.manifest_stitch.expect("manifest_stitch block present");
2677        assert_eq!(cfg.target, "ghcr.io/yah-ai/yah-rust:v1");
2678        assert_eq!(cfg.sources.len(), 2);
2679    }
2680
2681    // ── R513-F3 (W207 Gap #5): wait-for step ───────────────────────────────
2682
2683    fn wait_for_step(name: &str, cfg: WaitForConfig) -> QedStep {
2684        let mut step = gha_workflow_step(name);
2685        step.kind = StepKind::WaitFor;
2686        step.gha_workflow = None;
2687        step.argv = vec![];
2688        step.wait_for = Some(cfg);
2689        step
2690    }
2691
2692    fn http_wait(url: &str) -> WaitForConfig {
2693        WaitForConfig {
2694            http: Some(url.into()),
2695            tcp: None,
2696            expect_status: None,
2697            timeout_secs: 30,
2698            interval_ms: 500,
2699        }
2700    }
2701
2702    #[test]
2703    fn wait_for_step_validates_http_and_tcp() {
2704        assert!(wait_for_step("gate", http_wait("http://localhost:3000/health"))
2705            .validate()
2706            .is_ok());
2707        let tcp = WaitForConfig {
2708            http: None,
2709            tcp: Some("127.0.0.1:5432".into()),
2710            ..http_wait("ignored")
2711        };
2712        // Clear the http set by the spread.
2713        let mut step = wait_for_step("gate", tcp);
2714        step.wait_for.as_mut().unwrap().http = None;
2715        assert!(step.validate().is_ok());
2716    }
2717
2718    #[test]
2719    fn wait_for_step_rejects_argv() {
2720        let mut step = wait_for_step("gate", http_wait("http://localhost/health"));
2721        step.argv = vec!["curl".into()];
2722        assert_eq!(
2723            step.validate(),
2724            Err(StepValidationError::WaitForHasArgv("gate".into()))
2725        );
2726    }
2727
2728    #[test]
2729    fn wait_for_step_rejects_missing_config() {
2730        let mut step = wait_for_step("gate", http_wait("http://localhost/health"));
2731        step.wait_for = None;
2732        assert_eq!(
2733            step.validate(),
2734            Err(StepValidationError::WaitForMissingConfig("gate".into()))
2735        );
2736    }
2737
2738    #[test]
2739    fn wait_for_step_rejects_no_target_and_both_targets() {
2740        let neither = wait_for_step(
2741            "gate",
2742            WaitForConfig {
2743                http: None,
2744                tcp: None,
2745                expect_status: None,
2746                timeout_secs: 30,
2747                interval_ms: 500,
2748            },
2749        );
2750        assert_eq!(
2751            neither.validate(),
2752            Err(StepValidationError::WaitForNeedsTarget("gate".into()))
2753        );
2754
2755        let both = wait_for_step(
2756            "gate",
2757            WaitForConfig {
2758                http: Some("http://localhost/health".into()),
2759                tcp: Some("localhost:80".into()),
2760                expect_status: None,
2761                timeout_secs: 30,
2762                interval_ms: 500,
2763            },
2764        );
2765        assert_eq!(
2766            both.validate(),
2767            Err(StepValidationError::WaitForAmbiguousTarget("gate".into()))
2768        );
2769    }
2770
2771    #[test]
2772    fn wait_for_step_rejects_expect_status_on_tcp() {
2773        let step = wait_for_step(
2774            "gate",
2775            WaitForConfig {
2776                http: None,
2777                tcp: Some("localhost:5432".into()),
2778                expect_status: Some(200),
2779                timeout_secs: 30,
2780                interval_ms: 500,
2781            },
2782        );
2783        assert_eq!(
2784            step.validate(),
2785            Err(StepValidationError::WaitForStatusNeedsHttp("gate".into()))
2786        );
2787    }
2788
2789    #[test]
2790    fn wait_for_step_rejects_zero_timeout() {
2791        let mut step = wait_for_step("gate", http_wait("http://localhost/health"));
2792        step.wait_for.as_mut().unwrap().timeout_secs = 0;
2793        assert_eq!(
2794            step.validate(),
2795            Err(StepValidationError::WaitForZeroTimeout("gate".into()))
2796        );
2797    }
2798
2799    #[test]
2800    fn wait_for_block_defaults_timeout_and_interval() {
2801        // A minimal `[wait_for]` with only `http` parses — timeout/interval
2802        // fall back to their defaults (30s / 500ms).
2803        let toml_str = r#"
2804            name = "wait:ready"
2805            kind = "wait-for"
2806            [wait_for]
2807            http = "http://localhost:3000/health"
2808        "#;
2809        let step: QedStep = toml::from_str(toml_str).expect("parse minimal wait-for");
2810        step.validate().expect("minimal wait-for validates");
2811        let cfg = step.wait_for.expect("wait_for block");
2812        assert_eq!(cfg.timeout_secs, 30);
2813        assert_eq!(cfg.interval_ms, 500);
2814        assert_eq!(cfg.expect_status, None);
2815    }
2816
2817    #[test]
2818    fn wait_for_step_round_trips_through_toml() {
2819        let step = wait_for_step(
2820            "wait:ready",
2821            WaitForConfig {
2822                http: Some("http://localhost:3000/health".into()),
2823                tcp: None,
2824                expect_status: Some(204),
2825                timeout_secs: 45,
2826                interval_ms: 250,
2827            },
2828        );
2829        let toml_str = toml::to_string(&step).expect("serialize wait-for step");
2830        assert!(toml_str.contains("kind = \"wait-for\""), "{toml_str}");
2831        let parsed: QedStep = toml::from_str(&toml_str).expect("deserialize wait-for step");
2832        assert_eq!(parsed.kind, StepKind::WaitFor);
2833        let cfg = parsed.wait_for.expect("wait_for block present");
2834        assert_eq!(cfg.http.as_deref(), Some("http://localhost:3000/health"));
2835        assert_eq!(cfg.expect_status, Some(204));
2836        assert_eq!(cfg.timeout_secs, 45);
2837        assert_eq!(cfg.interval_ms, 250);
2838    }
2839
2840    // ── R513-F4 (W207 Gap #6): finally teardown step validation ────────────
2841
2842    /// Build a plain subprocess step from the populated `gha_workflow_step`
2843    /// literal so new QedStep fields don't need threading here.
2844    fn subprocess_step(name: &str) -> QedStep {
2845        let mut step = gha_workflow_step(name);
2846        step.kind = StepKind::Subprocess;
2847        step.gha_workflow = None;
2848        step.argv = vec!["echo".into(), "bye".into()];
2849        step
2850    }
2851
2852    #[test]
2853    fn finally_accepts_subprocess() {
2854        assert!(subprocess_step("teardown").validate_finally().is_ok());
2855    }
2856
2857    #[test]
2858    fn finally_rejects_non_subprocess_kind() {
2859        let wf = wait_for_step("gate", http_wait("http://localhost/health"));
2860        assert_eq!(
2861            wf.validate_finally(),
2862            Err(StepValidationError::FinallyRequiresSubprocess("gate".into()))
2863        );
2864    }
2865
2866    #[test]
2867    fn finally_rejects_background_subprocess() {
2868        let mut bg = subprocess_step("bg");
2869        bg.background = true;
2870        assert_eq!(
2871            bg.validate_finally(),
2872            Err(StepValidationError::FinallyRequiresSubprocess("bg".into()))
2873        );
2874    }
2875
2876    #[test]
2877    fn sub_pipeline_step_rejects_direct_produces() {
2878        let mut step = sub_pipeline_step("compose", SubPipelineRef::Builtin("x".into()));
2879        step.produces = vec![ProducedArtifact {
2880            binary: "yah".into(),
2881            path: "target/release/yah".into(),
2882            triple: None,
2883        }];
2884        assert_eq!(
2885            step.validate(),
2886            Err(StepValidationError::SubPipelineHasProduces(
2887                "compose".into()
2888            ))
2889        );
2890    }
2891
2892    /// Test resolver backed by a HashMap so unit tests can stub the
2893    /// pipeline graph without touching disk or builtins.
2894    struct MapResolver(HashMap<String, Pipeline>);
2895
2896    impl SubPipelineResolver for MapResolver {
2897        fn resolve(&self, target: &SubPipelineRef) -> Option<Pipeline> {
2898            let key = match target {
2899                SubPipelineRef::Builtin(n) => format!("builtin:{n}"),
2900                SubPipelineRef::Path(p) => format!("path:{}", p.display()),
2901                SubPipelineRef::GhaWorkflow { path, .. } => format!("gha:{}", path.display()),
2902                SubPipelineRef::Peer { camp, pipeline } => format!("peer:{camp}:{pipeline}"),
2903            };
2904            self.0.get(&key).cloned()
2905        }
2906    }
2907
2908    #[test]
2909    fn graph_walk_accepts_acyclic_chain() {
2910        // root -> child-a -> child-b (no cycles)
2911        let leaf = pipeline_with("child-b", vec![]);
2912        let mid = pipeline_with(
2913            "child-a",
2914            vec![sub_pipeline_step(
2915                "descend",
2916                SubPipelineRef::Builtin("child-b".into()),
2917            )],
2918        );
2919        let root = pipeline_with(
2920            "root",
2921            vec![sub_pipeline_step(
2922                "descend",
2923                SubPipelineRef::Builtin("child-a".into()),
2924            )],
2925        );
2926        let mut map = HashMap::new();
2927        map.insert("builtin:child-a".to_string(), mid);
2928        map.insert("builtin:child-b".to_string(), leaf);
2929        let resolver = MapResolver(map);
2930        assert!(validate_sub_pipeline_graph(&root, &resolver).is_ok());
2931    }
2932
2933    #[test]
2934    fn graph_walk_detects_direct_self_cycle() {
2935        // root -> root (builtin name matches itself's name — irrelevant to the
2936        // walker, but a likely real-world mistake)
2937        let mut root = pipeline_with(
2938            "self",
2939            vec![sub_pipeline_step(
2940                "loop",
2941                SubPipelineRef::Builtin("self".into()),
2942            )],
2943        );
2944        // child resolves back to root with same ref token => cycle.
2945        let mut map = HashMap::new();
2946        map.insert("builtin:self".to_string(), root.clone());
2947        let resolver = MapResolver(map);
2948        // Add the SubPipeline step to root so root's body contains the
2949        // self-reference (above already does — this is just a clarity assertion).
2950        assert_eq!(root.steps.len(), 1);
2951        let err = validate_sub_pipeline_graph(&root, &resolver).unwrap_err();
2952        match err {
2953            SubPipelineError::Cycle { chain } => {
2954                assert!(
2955                    chain.contains("builtin:self"),
2956                    "cycle chain reports the ref: {chain}"
2957                );
2958            }
2959            other => panic!("expected Cycle, got {other:?}"),
2960        }
2961    }
2962
2963    #[test]
2964    fn graph_walk_detects_indirect_cycle() {
2965        // root -> a -> b -> a
2966        let a_loops_back = pipeline_with(
2967            "a",
2968            vec![sub_pipeline_step(
2969                "descend",
2970                SubPipelineRef::Builtin("b".into()),
2971            )],
2972        );
2973        let b_back_to_a = pipeline_with(
2974            "b",
2975            vec![sub_pipeline_step(
2976                "loop",
2977                SubPipelineRef::Builtin("a".into()),
2978            )],
2979        );
2980        let root = pipeline_with(
2981            "root",
2982            vec![sub_pipeline_step(
2983                "enter",
2984                SubPipelineRef::Builtin("a".into()),
2985            )],
2986        );
2987        let mut map = HashMap::new();
2988        map.insert("builtin:a".to_string(), a_loops_back);
2989        map.insert("builtin:b".to_string(), b_back_to_a);
2990        let resolver = MapResolver(map);
2991        let err = validate_sub_pipeline_graph(&root, &resolver).unwrap_err();
2992        match err {
2993            SubPipelineError::Cycle { chain } => {
2994                assert!(chain.contains("builtin:a"));
2995                assert!(chain.contains("builtin:b"));
2996            }
2997            other => panic!("expected Cycle, got {other:?}"),
2998        }
2999    }
3000
3001    #[test]
3002    fn graph_walk_rejects_beyond_max_depth() {
3003        // Build a linear chain root -> d1 -> d2 -> d3 -> d4 -> d5 with no cycles.
3004        // MAX_SUB_PIPELINE_DEPTH = 4 so the 5th edge must fail.
3005        let mut map: HashMap<String, Pipeline> = HashMap::new();
3006        for n in (1..=5).rev() {
3007            let next_step = if n < 5 {
3008                vec![sub_pipeline_step(
3009                    "descend",
3010                    SubPipelineRef::Builtin(format!("d{}", n + 1)),
3011                )]
3012            } else {
3013                vec![]
3014            };
3015            let p = pipeline_with(&format!("d{n}"), next_step);
3016            map.insert(format!("builtin:d{n}"), p);
3017        }
3018        let root = pipeline_with(
3019            "root",
3020            vec![sub_pipeline_step(
3021                "enter",
3022                SubPipelineRef::Builtin("d1".into()),
3023            )],
3024        );
3025        let resolver = MapResolver(map);
3026        let err = validate_sub_pipeline_graph(&root, &resolver).unwrap_err();
3027        assert!(
3028            matches!(
3029                err,
3030                SubPipelineError::MaxDepthExceeded {
3031                    max: MAX_SUB_PIPELINE_DEPTH,
3032                    ..
3033                }
3034            ),
3035            "expected MaxDepthExceeded, got {err:?}"
3036        );
3037    }
3038
3039    #[test]
3040    fn graph_walk_tolerates_unresolved_refs() {
3041        // Resolver returns None — the walker should not error; runtime
3042        // surfaces the resolution failure later.
3043        let root = pipeline_with(
3044            "root",
3045            vec![sub_pipeline_step(
3046                "enter",
3047                SubPipelineRef::Builtin("nonexistent".into()),
3048            )],
3049        );
3050        let resolver = MapResolver(HashMap::new());
3051        assert!(validate_sub_pipeline_graph(&root, &resolver).is_ok());
3052    }
3053
3054    #[test]
3055    fn sub_pipeline_round_trips_through_toml_with_all_three_ref_shapes() {
3056        for target_toml in [
3057            r#"target = { builtin = "desktop-release" }"#,
3058            r#"target = { path = ".yah/qed/full-release.toml" }"#,
3059            r#"target = { gha-workflow = { path = ".github/workflows/release.yml", event = "tag" } }"#,
3060            r#"target = { peer = { camp = "mesofact", pipeline = "release-build" } }"#,
3061        ] {
3062            let toml_src = format!(
3063                r#"
3064                name = "p"
3065                label = "p"
3066
3067                [[steps]]
3068                name = "compose"
3069                kind = "sub-pipeline"
3070
3071                [steps.sub_pipeline]
3072                {target_toml}
3073                propagate = {{ produces = true }}
3074                "#
3075            );
3076            let pipeline: Pipeline = toml::from_str(&toml_src)
3077                .unwrap_or_else(|e| panic!("parse failed for `{target_toml}`: {e}"));
3078            assert_eq!(pipeline.steps.len(), 1);
3079            let cfg = pipeline.steps[0].sub_pipeline.as_ref().unwrap();
3080            assert!(cfg.propagate.produces);
3081        }
3082    }
3083
3084    #[test]
3085    fn graph_walk_detects_peer_cycle() {
3086        // root -> peer:cheers:publish -> peer:cheers:publish (self-loop via peer ref)
3087        let cheers = pipeline_with(
3088            "publish",
3089            vec![sub_pipeline_step(
3090                "republish",
3091                SubPipelineRef::Peer {
3092                    camp: "cheers".into(),
3093                    pipeline: "publish".into(),
3094                },
3095            )],
3096        );
3097        let root = pipeline_with(
3098            "root",
3099            vec![sub_pipeline_step(
3100                "kick",
3101                SubPipelineRef::Peer {
3102                    camp: "cheers".into(),
3103                    pipeline: "publish".into(),
3104                },
3105            )],
3106        );
3107        let mut map = HashMap::new();
3108        map.insert("peer:cheers:publish".to_string(), cheers);
3109        let resolver = MapResolver(map);
3110        let err = validate_sub_pipeline_graph(&root, &resolver).unwrap_err();
3111        match err {
3112            SubPipelineError::Cycle { chain } => {
3113                assert!(chain.contains("peer:cheers:publish"), "chain: {chain}");
3114            }
3115            other => panic!("expected Cycle, got {other:?}"),
3116        }
3117    }
3118}