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(¶ms);
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(¶ms);
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}