Skip to main content

yah_qed/
runner.rs

1//! @yah:relay(R325, "QED desktop UI (blank slate) + backend wiring")
2//! @yah:at(2026-05-26T04:07:25Z)
3//! @yah:status(open)
4//! @yah:phase(P3)
5//! @yah:parent(Q321)
6//! @arch:see(.yah/docs/working/W063-area-a-ui-design-impl.md)
7//!
8//! @yah:ticket(R325-F2, "Backend: per-step event stream (start/stdout/stderr/end) — tailable feed for live step logs")
9//! @yah:assignee(agent:claude)
10//! @yah:at(2026-05-26T04:09:53Z)
11//! @yah:status(review)
12//! @yah:phase(P3)
13//! @yah:parent(R325)
14//! @yah:depends_on(R325-F1)
15//! @yah:next("R325-T4 (Tauri commands) wraps qed.tail: QedTailParams{run_id, since_cursor, limit} -> QedTailResult{events:Vec<QedEventWire>, next_cursor, run:Option<QedRunWire>}. Poll-to-follow: pass next_cursor back as since_cursor each tick; the StepCard log pane consumes events[], the StepCards consume run.steps.")
16//! @yah:handoff("Landed the qed live per-step event stream + cursor-tailable feed. (1) qed crate: new events.rs (QedEvent{RunStarted,StepStarted,StepOutput,StepFinished,RunFinished} + OutputStream{Stdout,Stderr}); PipelineRunner gained an optional sink via .with_events(UnboundedSender<QedEvent>) (composes with new/new_with_dispatcher/new_remote) + emit() helper. execute_step_local rewritten from blocking std::process::Command::output() to tokio::process with piped stdout/stderr drained line-by-line in concurrent tasks (emitting StepOutput); stderr tail still captured for the StepFailed msg. kill_on_drop(true) so qed.cancel mid-step kills the subprocess (F1 could only cancel between steps). run() emits the lifecycle around each step. (2) rpc crate: QedEventWire (tagged enum, kebab kind, RFC3339 timestamps — same chrono-free decoupling as QedRunWire), QedTailParams{run_id, since_cursor:Option<u64>, limit}, QedTailResult{events, next_cursor, run}, method::QED_TAIL='qed.tail'. (3) camp daemon: QedRunState gained an append-only events:Vec<QedEventWire> buffer; qed_run_handler now attaches a sink + spawns a drain task that pushes wire events AND live-updates meta.steps (StepStarted appends Running step, StepFinished sets terminal — guarded on status==Running so the authoritative terminal write / qed.cancel always wins over straggler events); qed_tail_handler (cursor=index into the buffer, default limit 500) + dispatch arm. (4) CLI: yah qed run now streams step output live (runner moved into a task, channel drained until close). Tests: qed crate 21/21 (3 new), yah --lib r325 9/9 (3 new tail tests), CLI smoke confirmed live stdout/stderr/step markers.")
17//! @yah:verify("cargo test -p qed")
18//! @yah:verify("cargo test -p yah --lib r325")
19//! @yah:verify("cargo check -p rpc -p agent-tools -p yah -p desktop")
20//! @yah:gotcha("The qed.tail `run` snapshot + events buffer are updated by a SEPARATE drain task that can briefly lag the run task's authoritative terminal write. A consumer should keep polling until the last event is RunFinished (don't stop just because run.completed_at is set). Remote (where=remote) still only emits step-level StepStarted/StepFinished — no StepOutput line streaming (execute_step_remote just waits on the yubaba handle); remote line-tail would flow through scryer/task.tail and is a follow-up. All qed runs are still in-memory (run-history persistence is R325-F3) so the event buffer is lost on daemon restart.")
21//!
22//! @yah:ticket(R380-T3, "Migrate qed runner execute_step_remote to TaskPlacement + add --runtime CLI flag")
23//! @yah:assignee(agent:claude)
24//! @yah:at(2026-06-01T21:06:09Z)
25//! @yah:status(review)
26//! @yah:parent(R380)
27//! @yah:next("execute_step_remote at runner.rs:401 builds a ForgeSpec with where_=RemoteAny{tier}. Update to TaskPlacement { location: RemoteAny{tier}, runtime: Container }.")
28//! @yah:next("Add a --runtime native|container CLI flag to `yah qed run` and a per-step `runtime` field in pipeline TOML. Default = native when --where=local, container when --where=remote (preserves current behaviour).")
29//! @yah:next("Pipeline TOML loader (config.rs) reads optional `runtime` per step; surface in QedStep.")
30//! @yah:handoff("T3 complete: qed runner now resolves per-step TaskRuntime and threads it into execute_step_remote. Added QedStep.runtime: Option<TaskRuntime> (serde(default)) so pipeline TOML can pin runtime per step (e.g. `runtime = \"container\"` for build-image steps). PipelineRunner gained resolve_runtime(step) → step.runtime.unwrap_or(default-by-RunWhere): local⇒Native, remote⇒Container. execute_step_remote now takes the resolved runtime and builds TaskPlacement with it. Added a local+container guard in run() that returns InvalidConfig pointing at R380-T6 (the docker-run shim hasn't landed yet — silent fallback to native subprocess would be worse). New CLI flag `--runtime native|container` on `yah qed run` applies as the default for steps without an explicit TOML runtime; per-step TOML always wins (validated by resolve_runtime_step_override_wins). Re-exported task::TaskRuntime from qed::lib to avoid adding a task dep edge to the yah CLI. Updated all 11 QedStep struct literals in builtins.rs/runner.rs/types.rs. Tests: 4 new (resolve_runtime_defaults_from_run_where, resolve_runtime_step_override_wins, local_container_errors_until_t6, parses_optional_runtime_per_step); 33/34 pass (the single failure is the pre-existing test_builtin_release_build_pipeline 4-vs-6 step assertion already flagged in T2). cargo check --workspace clean.")
31//! @yah:next("T6 (docker-run shim) replaces the local+container guard with real execution — delete `local_container_errors_until_t6` test and the matching InvalidConfig branch in run() once task::local exposes a container runtime.")
32//! @yah:verify("cargo test -p qed --lib  # 33 pass (1 pre-existing unrelated failure)")
33//! @yah:verify("cargo check --workspace  # clean")
34//! @yah:verify("cargo run -p yah -- qed run check --runtime=invalid  # exits with clear error")
35//! @yah:gotcha("The local+container guard returns RunnerError::InvalidConfig as a step failure (overall_status becomes Failed). The right shape long-term is a pre-flight validation error before run starts, but that requires a wider validator hook — punt to T6 when local+container actually works.")
36//! @yah:gotcha("RunStatus::Cancelled isn't surfaced by local+container errors (the run finishes Failed normally). Consumers that distinguish cancellation from failure (the desktop StepCard) should look at the StepFailed.msg field for 'R380-T6' until T6 lands.")
37//!
38//! @yah:ticket(R381-T2, "Add ForgeCommand::BuildImage variant + qed::build-image step kind in pipeline TOML")
39//! @yah:assignee(agent:claude)
40//! @yah:at(2026-06-01T21:07:14Z)
41//! @yah:status(review)
42//! @yah:parent(R381)
43//! @yah:next("New ForgeCommand variant BuildImage { dockerfile: PathBuf, context: PathBuf, tag: String, push: bool } in crates/yah/task/src/lib.rs.")
44//! @yah:next("Pipeline TOML: a step with `kind = \"build-image\"` + `image = \"<catalog-or-camp-name>\"` resolves the dockerfile/context via the catalog loader (T1) and constructs the ForgeCommand.")
45//! @yah:next("Output: an ImageRef artifact addressable as ${steps.<step-name>.image} from later steps. Pipeline runner threads artifact resolution.")
46//! @yah:next("build-image steps force runtime=Container; refuse runtime=Native at TOML parse time with a clear error.")
47//! @yah:handoff("BuildImage seam landed end-to-end. task crate: new ForgeCommand::BuildImage { dockerfile: PathBuf, context: PathBuf, tag: String, push: bool } (serde tag = build_image, matches existing snake_case discipline). remote.rs build_workload_spec gains an explicit refusal arm pointing at R381-T5 — no silent fallthrough. qed crate: QedStep grew kind: StepKind (Subprocess|BuildImage, default Subprocess) + image, tag, push fields; argv now defaults to empty so a build-image step doesn't need to declare it. New StepValidationError surfaces four kind-specific errors at parse time: SubprocessMissingArgv, BuildImageHasArgv, BuildImageMissingImage, BuildImageNativeRuntime. PipelineLoader (both load_from_file + load_from_str) validates every step after deserialize — errors are pinned to a single bad step name, not a wall of TOML noise. resolve_runtime forces Container for build-image regardless of run_where (catches the implicit runtime=None case that local default would resolve to Native). run() dispatch matches on step.kind first, then existing (run_where, runtime) for subprocess. New execute_step_build_image stub looks up step.image in the bundled CatalogManifest (real lookup, real error on miss), constructs a ForgeCommand::BuildImage with conventional paths (crates/yah/qed/images/<name>/Dockerfile), then returns StepFailed with a structured 'R381-T4/T5 not yet implemented' message. Re-exported StepKind + StepValidationError from qed::lib. Tests: 12 new across types.rs (7), config.rs (4), runner.rs (3 build-image — forces container, unknown catalog fails, known catalog returns not-implemented), task/src/lib.rs (1 BuildImage round-trip). cargo test -p qed -p task --lib: 58+49 pass, 1 pre-existing unrelated failure (test_builtin_release_build_pipeline 4-vs-6). cargo check --workspace clean.")
48//! @yah:next("T4 owns docker buildx execution: replace execute_step_build_image's StepFailed stub with a real local docker buildx invocation. Stub already builds the correct ForgeCommand::BuildImage — T4 just needs a task::local::build_image_command that shells to `docker buildx build -f <dockerfile> -t <tag> [--push] <context>` with cache-to/cache-from wiring.")
49//! @yah:next("T5 owns BuildKit-in-containerd: extend execute_step_build_image to branch on self.run_where == Remote and synthesize a BuildKit WorkloadSpec instead of returning the stub. remote.rs already refuses ForgeCommand::BuildImage — T5 replaces that arm with a buildctl workload synthesis.")
50//! @yah:next("Artifact threading ($\\{steps.X.image}): not yet wired. Defer until T4 produces a real ImageRef — then thread step.image_outputs: HashMap<String, ImageRef> through PipelineRunner::run() and substitute placeholders in each step's argv/env before execution (mirror the pattern of Pipeline::apply_params). Without real output ImageRefs T2 had nothing useful to substitute.")
51//! @yah:next("Per-camp catalog wiring: execute_step_build_image calls CatalogManifest::bundled() — swap to CatalogManifest::load(camp_root.join('.yah/qed/images')) once the runner accepts a camp root (likely passed via PipelineRunner::with_catalog setter, parallel to with_events).")
52//! @yah:verify("cargo test -p qed --lib")
53//! @yah:verify("cargo test -p task --lib")
54//! @yah:verify("cargo check --workspace")
55//!
56//! @yah:ticket(R407-T2, "QED native-tarball packaging step: musl-static binary + manifest, no systemd unit")
57//! @yah:assignee(agent:claude)
58//! @yah:at(2026-06-02T03:27:28Z)
59//! @yah:status(review)
60//! @yah:phase(P1)
61//! @yah:parent(R407)
62//! @arch:see(.yah/docs/working/W154-yubaba-dual-runtime.md)
63//! @yah:depends_on(R407-T1)
64//! @yah:handoff("Landed package-native-tarball step end-to-end. types: new StepKind::PackageNativeTarball + two QedStep fields (binary_path, triple) + 4 StepValidationError variants. New crates/yah/qed/src/native.rs module owns NativeTarballManifest (forward-compatible TOML shape — name/version/triple/binary/description/env) and pack_native_tarball() — writes bin/<basename> + manifest.toml into a .tar.gz via tar+flate2 (added as deps). runner: execute_step_package_native_tarball() looks up the catalog entry by step.image, GATES on entry.produces.contains(NativeTarball) (W154 catalog-side guard), resolves triple via step.triple ?? publish::resolve_triple(host), copies the binary, packs the tarball at <camp_root>/.yah/cache/native/<image>-<triple>.tar.gz. resolve_runtime() forces Native for this kind even on Remote runners (pure host file I/O — Container would be wrong). Catalog entry.env propagates into the manifest so Kamaji has launch env at deploy time without re-reading the catalog. 16 new tests (4 native pack/unpack, 6 runner happy/gate/missing/triple-host-fallback/remote-force-native, 6 types validation, 4 config parse-time). qed --lib: 111 pass + 1 pre-existing unrelated failure (test_builtin_release_build_pipeline 4-vs-6 step count, already flagged in R407-T1 handoff). cargo check -p qed -p yah clean.")
65//! @yah:verify("cargo test -p qed --lib package_native_tarball")
66//! @yah:verify("cargo test -p qed --lib native::")
67//! @yah:verify("cargo check -p qed -p yah")
68//! @yah:gotcha("No systemd unit is emitted (per W154 Kamaji design). Tarball layout is bin/<basename> + manifest.toml at root; that's the deploy contract — Kamaji readers should accept additive manifest fields.")
69//! @yah:gotcha("manifest.toml version comes from YAH_RELEASE_VERSION env (else compiled CARGO_PKG_VERSION). For multi-platform release tagging the GHA shim is expected to set the env before invoking the packaging step.")
70//! @yah:gotcha("Sigstore signing of the tarball (R407-T5) is NOT wired here — only content packaging. The packaging step writes plaintext .tar.gz; signing extends in T5.")
71//!
72//! @yah:ticket(R407-T5, "Sigstore signing extends to native-tarball artifacts (same trust model)")
73//! @yah:assignee(agent:claude)
74//! @yah:at(2026-06-02T03:27:30Z)
75//! @yah:status(review)
76//! @yah:phase(P2)
77//! @yah:parent(R407)
78//! @arch:see(.yah/docs/working/W154-yubaba-dual-runtime.md)
79//! @yah:depends_on(R407-T2)
80//! @yah:handoff("Landed Sigstore signing seam for native-tarball artifacts end-to-end (W154 'same trust model, different artifact shape'). native.rs: new SigstoreSigner async trait + SignedBlob{signature_path, certificate_path, bundle_path} result struct. CosignSigner shells `cosign sign-blob --yes --output-signature <blob>.sig --output-certificate <blob>.crt --bundle <blob>.bundle <blob>` (extends, not substitutes — `.tar.gz.sig` not `.tar.sig`, so the channel layout shows the signature next to the artifact it covers). LoggingSigner test/dev fake writes placeholder bytes and tracing::warn so a local `yah qed run` doesn't fail when cosign isn't installed. New tarball_stem() + native_tarball_output_path() helpers hoist the on-disk convention out of runner.rs — packaging (T2) now calls the same helper, so pack-then-sign in one pipeline always finds the artifact. types.rs: StepKind::SignNativeTarball variant + three StepValidationError variants (HasArgv / MissingImage / ContainerRuntime). Catalog produces gate applied independently at sign dispatch (not only at pack time) so a stale TOML signing step can't sneak through. runner.rs: PipelineRunner.signer: Arc<dyn SigstoreSigner> field, default LoggingSigner across all three constructors, with_signer setter (composes with with_camp_root / with_events). resolve_runtime forces Native for SignNativeTarball on Remote runners. execute_step_sign_native_tarball resolves <camp_root>/.yah/cache/native/<image>-<triple>.tar.gz via the shared helper, checks file exists (routes operator to `kind = \"package-native-tarball\"` on miss), gates on catalog.produces, calls signer.sign_blob, surfaces clean StepFailed on any failure. 16 new tests: 5 types validation, 4 config parse-time, 1 native::tarball_stem + 1 path helper, 3 LoggingSigner/CosignSigner unit tests, 6 runner tests (pack-then-sign happy path, non-native catalog gate, unknown catalog, missing tarball routes-to-packaging, forces-native-on-remote, with_signer override via CountingSigner). cargo test -p qed --lib: 153 pass + 1 pre-existing unrelated failure (test_builtin_release_build_pipeline 4-vs-6 step count, flagged in R407-T1 and R380-T3 handoffs). cargo check -p yah clean.")
81//! @yah:verify("cargo test -p qed --lib sign_native_tarball")
82//! @yah:verify("cargo test -p qed --lib native::")
83//! @yah:verify("cargo check -p qed -p yah")
84//! @yah:gotcha("Default signer is LoggingSigner (placeholder bytes + tracing::warn). Release CI MUST wire CosignSigner explicitly via PipelineRunner::with_signer(Arc::new(CosignSigner::default())) — picking up the default in CI ships a tarball with stub `.sig/.crt/.bundle` files and Sigstore verify-blob will reject it at deploy time. The CLI doesn't yet auto-detect cosign on PATH; that's a follow-up when a release pipeline actually runs sign-native-tarball end-to-end (today the GHA cosign step still signs OCI images out-of-band per release.yml, native-tarball signing is wired but not yet invoked from a real release pipeline).")
85//! @yah:gotcha("Sign step refuses to sign tarballs from catalog entries that don't declare `produces = [\"native-tarball\"]`. The check is duplicated from packaging on purpose — defense in depth — so a stale signing step left in TOML after a catalog rename can't surface a confusing 'tarball not found' instead of the real 'catalog opt-in missing' error.")
86//!
87//! @yah:ticket(R438-T14, "qed PipelineRunner consumes ForgeExecutor for subprocess steps")
88//! @yah:assignee(bundle-anthropic-ashguard)
89//! @yah:at(2026-06-05T07:26:30Z)
90//! @yah:status(review)
91//! @yah:phase(P2)
92//! @yah:parent(R438)
93//! @yah:next("Add a with_executor(Arc<dyn ForgeExecutor>) setter so the cloud reconciler can share a configured driver without spinning up its own; not strictly required but mirrors with_signer/with_events/with_camp_root.")
94//! @yah:verify("Manual: emits_lifecycle_events_with_streamed_output + failing_step_streams_stderr_and_finishes_failed still pass — QedEvent adapter must preserve per-line streaming and stderr-tail capture for StepFailed.msg")
95//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
96//! @yah:depends_on(R438-T13)
97//! @yah:handoff("T14 landed. qed::PipelineRunner now consumes task::ForgeExecutor for subprocess steps. Changes: (1) Added executor: Arc<dyn ForgeExecutor> field to PipelineRunner; default Arc::new(LocalForgeDriver::new()) across new/new_with_dispatcher/new_remote constructors; with_executor(...) setter mirrors with_signer/with_events/with_camp_root. (2) Replaced execute_step_local + execute_step_local_container with thin wrappers that build a ForgeSpec + ExecContext and call drive_subprocess_step. (3) New private drive_subprocess_step helper spawns an adapter task that forwards ExecEvent::Output -> QedEvent::StepOutput on self.events (the per-line streaming contract from R325-F2 is preserved). Started/Finished events are absorbed; run() still emits its own StepStarted/StepFinished. (4) New top-level helper build_subprocess_spec lowers a QedStep into ForgeSpec{Subprocess{argv,image}, TaskPlacement{Local, runtime}, timeout, label, initiator=Human/qed, mesh_access=None}. (5) Error mapping: Ok(outcome).succeeded() -> Ok(()); Ok(outcome) failed -> StepFailed{msg: outcome.stderr_tail}; Spawn -> StepFailed with friendly 'runtime installed?' prefix; Io -> RunnerError::Io (preserves existing From impl); Unsupported -> RunnerError::InvalidConfig. Build-image / package-native-tarball / sign-native-tarball / musl-static-preflight / execute_step_remote paths unchanged — those don't route through the trait yet (out of scope for T14).")
98//! @yah:handoff("Tests: cargo test -p qed --lib: 165 pass + 1 pre-existing unrelated failure (tests::test_builtin_release_build_pipeline 4-vs-6 step count, already flagged in R380-T3 / R380-T8 / R381-T2 / R407-T2 / R407-T5 handoffs). Critical streaming/lifecycle tests verified individually: emits_lifecycle_events_with_streamed_output, failing_step_streams_stderr_and_finishes_failed, no_sink_runs_silently, local_container_step_routes_through_docker_path, resolve_runtime_defaults_from_run_where, resolve_runtime_step_override_wins, remote_step_success/failure/abort_on_fail — all green.")
99//! @yah:handoff("Coordination note: while T14 was mid-verify, T15's agent landed mid-flight edits to crates/yah/task/src/lib.rs (a `pub use task_runs::Initiator;` plus moving transforms.rs into task) which created a duplicate `use task_runs::Initiator;` (private use on line 126 conflicting with the new `pub use` on line 115). Removed the now-redundant private import to unblock T14's verify. T15's agent owns the new transforms::tests::rejects_recipe_with_struct_image_missing_digest failure (test now gets RecipeError::Parse instead of ImageNotPinned because ImageRef post-R438-T3 tightening rejects struct-form-missing-digest at serde-deserialize time).")
100//! @yah:next("After T15 lands its full workspace verify, confirm cargo check --workspace --locked stays clean and that qed::tests::test_builtin_release_build_pipeline's pre-existing failure is the only remaining miss in qed.")
101//! @yah:next("Optional follow-up: thread executor through execute_step_build_image too — today it still does inline docker buildx spawn + drain. Same shape as T14 but with a BuildImage variant that LocalForgeDriver currently rejects; would require extending LocalForgeDriver to support BuildImage. Not blocking; current architecture stays.")
102//! @yah:next("Optional follow-up: remote dispatch path (execute_step_remote) still calls RemoteForgeDriver directly via self.remote_driver; a RemoteForgeDriver impl of ForgeExecutor would let the runner dispatch through a single executor trait. Symmetric with T14 but blocks on a real consumer needing it.")
103//! @yah:verify("cargo test -p qed --lib  # 165 pass + 1 pre-existing failure (test_builtin_release_build_pipeline)")
104//! @yah:verify("cargo test -p qed --lib emits_lifecycle  # 1 pass (R325-F2 streaming contract preserved through ForgeExecutor adapter)")
105//! @yah:verify("cargo test -p qed --lib failing_step_streams  # 1 pass (stderr_tail captured for StepFailed.msg through ExecOutcome.stderr_tail)")
106//! @yah:verify("cargo test -p qed --lib local_container_step_routes  # 1 pass (container path still routes through docker)")
107//! @yah:verify("cargo test -p qed --lib resolve_runtime  # 2 pass (runtime resolution unchanged)")
108//! @yah:handoff("Verification complete. cargo check --workspace clean (pre-existing desktop warnings only). cargo test -p qed --lib: 165 pass, 1 pre-existing failure (test_builtin_release_build_pipeline step-count 6-vs-4, documented across R380-T3/R380-T8/R381-T2/R407-T2/R407-T5 handoffs). emits_lifecycle_events_with_streamed_output passes. T15 is in review; its workspace verify aligns. with_executor setter is implemented at runner.rs:406. All T14 implementation work was landed by the previous agent session (bundle-anthropic-ashguard).")
109//! @yah:verify("cargo test -p qed --lib  # 165 pass + 1 pre-existing failure (test_builtin_release_build_pipeline)")
110//! @yah:verify("cargo test -p qed --lib emits_lifecycle  # streaming contract preserved")
111//! @yah:verify("cargo check --workspace  # clean")
112//!
113//! @yah:ticket(R488-F2, "Runner recursion for SubPipelineRef::Builtin and ::Path (nested QedRun, parented run_id)")
114//! @yah:assignee(agent:claude)
115//! @yah:at(2026-06-08T02:54:07Z)
116//! @yah:status(review)
117//! @yah:phase(P2)
118//! @yah:parent(R488)
119//! @arch:see(.yah/docs/working/W201-qed-pipeline-composition.md)
120//! @yah:depends_on(R488-F1)
121//! @yah:tier(Cleric)
122//! @yah:handoff("F2 shipped. Runner gained sub_pipeline_resolver field (default NoopSubPipelineResolver) + suppress_publish_outcomes field + with_sub_pipeline_resolver(...) setter. Public run() refactored to a thin wrapper around new pub(crate) run_inner() that returns (QedRunMeta, Vec<ProducedArtifact>) — parent reads child produced across recursion. SubPipeline arm in run_inner() resolves via configured resolver, builds child runner inheriting executor/signer/camp_root/events/outcome_dispatcher/resolver, applies cfg.params via apply_params, sets suppress_publish_outcomes=cfg.propagate.produces, runs via Box::pin(child.run_inner()) (async recursion). Children produces flow into parents produced when propagate.produces=true. Suppression in outcome dispatch skips Outcome::Publish on child only (WardenDeploy + AlmanacRun still fire). New LoaderSubPipelineResolver in config.rs (Builtin via loader.load, Path via load_from_file resolved relative to camp root, GhaWorkflow returns None until W200-F9). PipelineLoader: Clone derive + pub(crate) on qed_dir + load_from_file. New load_and_validate_graph(name) method runs the F1 walker at parse time. ConfigError gained SubPipelineGraph variant. 7 new runner tests: unresolvable-target failure, happy single-child, failure propagation, produces aggregation + child publish suppression (1 publish total), child publish fires when not suppressed (2 publishes total), two-level nesting with single revalidate, param forwarding. cargo test -p qed --lib: 188 pass (7 new) + 1 pre-existing unrelated failure. cargo check --workspace clean (one more QedStep literal in app/yah/cli/src/camp.rs sed-fixed).")
123//! @yah:next("F3 deepens aggregation: PublishingOutcomeDispatcher (the real publish.rs) needs multi-child fan-in coverage — swap F2 CountingDispatcher for a fake ReleasePublisher and assert the staged tree groups artifacts by binary correctly across children.")
124//! @yah:next("F3 confirm continue-on-error semantics for SubPipeline steps: current impl drops child produces on failure; might want partial propagation. Document either way.")
125//! @yah:next("Wire PipelineLoader::load_and_validate_graph into yah qed run entry so users get pre-flight cycle errors instead of mid-recursion failures.")
126//! @yah:verify("cargo test -p qed --lib runner::tests::sub_pipeline (7 tests)")
127//! @yah:verify("cargo test -p qed --lib")
128//! @yah:verify("cargo check --workspace")
129//!
130//! @yah:ticket(R488-F6, "SubPipelineRef::GhaWorkflow arm — wraps a W200 workflow run as a sub-pipeline (closes full-release loop)")
131//! @yah:assignee(agent:claude)
132//! @yah:at(2026-06-08T02:54:42Z)
133//! @yah:status(review)
134//! @yah:phase(P6)
135//! @yah:parent(R488)
136//! @yah:next("Add the GhaWorkflow arm to the SubPipeline resolver — delegates to yah_qed_gha::execute")
137//! @yah:next("Map GhaRunResult.produced into the parent's aggregation; map job_outputs into propagate.outputs")
138//! @yah:next("Author .yah/qed/full-release.toml: child 1 = GhaWorkflow(.github/workflows/release.yml), child 2 = builtin(desktop-release); terminal Outcome::Publish")
139//! @yah:verify("yah qed run full-release executes both children sequentially; one revalidate POST fires after both succeed")
140//! @arch:see(.yah/docs/working/W201-qed-pipeline-composition.md)
141//! @yah:depends_on(R488-F3)
142//! @yah:depends_on(R487-F9)
143//! @yah:tier(Cleric)
144//! @yah:handoff("F6 shipped. (1) runner.rs: execute_step_gha_workflow now returns (Vec<ProducedArtifact>, HashMap<String,String>) — workflow job outputs lifted as `<job_id>.<output_key>` from each successful instance's outputs IndexMap so the enclosing SubPipeline parent's propagate.outputs can address them with the same `<job_id>.<key>` naming convention as GHA's `jobs.<id>.outputs.<key>`. Call site at run_inner() threads workflow_outputs through into step_outputs. (2) .yah/qed/full-release.toml: composite pipeline with two SubPipeline children — child 1 = GhaWorkflow(.github/workflows/release.yml), child 2 = builtin(desktop-release), both with propagate.produces = true; one terminal Outcome::Publish to r2/yah-dev/https://cdn.yah.dev. concurrency_key = cargo-target so it queues behind other cargo-touching pipelines (W155 principle 3). (3) lib.rs: test_full_release_composite_pipeline loads full-release via load_and_validate_graph (parse-time SubPipeline cycle/depth walker) and asserts two SubPipeline children with propagate.produces = true + exactly one terminal Outcome::Publish. Uses CARGO_MANIFEST_DIR-rooted qed_dir so it runs from any cwd. cargo test -p qed --lib: 201 pass + 1 pre-existing failure (test_builtin_release_build_pipeline 4-vs-6 step count, documented across R407-T1/R380-T3/R438-T14/R488-F1/F2/F9 handoffs — not introduced by F6). cargo check -p qed -p yah clean. Verification of the end-to-end `yah qed run full-release` deferred to a host with docker+rust+tauri-cli installed (and a real R2/almanac receiver) — same hermetic constraint F9 documented for the GhaWorkflow step itself.")
145//! @yah:verify("cargo test -p qed --lib test_full_release_composite_pipeline  # graph validates")
146//! @yah:verify("cargo test -p qed --lib  # 201 pass + 1 pre-existing failure")
147//! @yah:verify("cargo check -p qed -p yah  # clean")
148//!
149//! @yah:ticket(R494-F2, "Local-peer resolution: nested QedRun across camp folders, per-peer-camp serialization")
150//! @yah:assignee(agent:claude)
151//! @yah:at(2026-06-08T23:48:09Z)
152//! @yah:status(review)
153//! @yah:phase(P1)
154//! @yah:parent(R494)
155//! @arch:see(.yah/docs/working/W201-qed-pipeline-composition.md)
156//! @yah:depends_on(R494-F1)
157//! @yah:tier(Cleric)
158//! @yah:handoff("F2 shipped. (1) config.rs: PipelineLoader gained peers: PeerConfig field; constructor loads <qed_dir>/peers.toml opportunistically alongside the existing registries.toml load. New with_peers() setter mirrors with_registries() for tests that don't want a peers.toml on disk. (2) LoaderSubPipelineResolver::resolve Peer arm: look up camp in self.loader.peers; if entry.rig.is_some() return None (R494-T5 refines into typed RemotePeerNotYetSupported); else resolve peer camp root relative to this camp (qed_dir.parent().parent() = <this camp root>, then join entry.path), instantiate PipelineLoader::new(<peer root>/.yah/qed), load the named pipeline. Stamp child.concurrency_key = `peer:<camp>` only when the peer's own pipeline didn't set one — gives per-peer-camp serialization for top-level invocations (`yah qed run peer:cheers:publish` + `yah qed run peer:cheers:test` both queue on `peer:cheers` since cheers' target/ is shared). Peers can opt out by setting `concurrency_key = \"@parallel\"` in their TOML. (3) 5 new config::tests: peer_resolver_loads_pipeline_from_sibling_camp (happy path + concurrency_key stamping verified), peer_resolver_preserves_explicit_concurrency_key, peer_resolver_returns_none_for_unknown_camp, peer_resolver_returns_none_for_unknown_pipeline_in_known_camp, peer_resolver_swallows_remote_peers_until_t5_wires_constable. fixture_peer_camp() helper builds a tempdir layout `<tmp>/parent/.yah/qed/peers.toml` + `<tmp>/peers/cheers/.yah/qed/publish.toml` so the resolver exercises real disk IO. cargo test -p qed --lib: 211 pass (5 new) + 1 pre-existing failure (test_builtin_release_build_pipeline 4-vs-6, documented across R407-T1/R380-T3/R438-T14/R488-F1/F2/F6/F9 + R494-F1 handoffs). cargo check -p qed -p yah -p desktop clean.")
159//! @yah:next("Per-peer-camp serialization gap: concurrency_key stamping only takes effect when peer pipelines are invoked at the top level (the queue layer in camp.rs:qed_run_handler keys off concurrency_key). Sub-pipeline recursion inside run_inner bypasses the queue and runs children directly. For the common case (yah's `peer-release` orchestrating cheers->mesofact->rs-hack sequentially via SubPipeline steps in one parent), the parent pipeline's step ordering serializes them; the gap shows up only if a parent declares two peer-children that should serialize but doesn't sequence them. Document this as a v1 limitation or follow-up ticket.")
160//! @yah:next("R494-T3 (peers.toml + peer-release.toml authoring) can now land — the resolver wires up end-to-end. F1's mesofact release-build.toml under external/mesofact/.yah/qed/ is the first concrete peer target.")
161//! @yah:next("R494-F4 (desktop nested-tree shows peer camp label): sub_pipeline_target_label in runner.rs returns `peer:<camp>:<pipeline>` (F1); the desktop QED-pane consumer of QedEvent::SubPipelineStarted.target already gets this string. F4 just needs to render it as a distinct chip rather than collapse into the run-name column.")
162//! @yah:verify("cargo test -p qed --lib config::tests::peer_resolver")
163//! @yah:verify("cargo test -p qed --lib  # 211 pass + 1 pre-existing")
164//! @yah:verify("cargo check -p qed -p yah -p desktop")
165//!
166//! @yah:ticket(R590-F2, "cross-host build-context transport + QedImageBuilder remote dispatch + multi-arch stitch")
167//! @yah:status(review)
168//! @yah:at(2026-07-09T10:04:14Z)
169//! @yah:assignee(agent:bundle-anthropic-ashguard)
170//! @yah:parent(R590)
171//! @yah:verify("yah qed run release --where=remote routes amd64→us-west-002 + arm64→Pi5, each builds natively, artifacts transported, multi-arch manifest pushed. (Currently blocked at deploy by R590-B3.)")
172//! @yah:depends_on(R590-B3)
173//! @yah:gotcha("yah-qed --lib has 5 PRE-EXISTING failures unrelated to F2, from the qed->yah-qed package rename: preflight tests do PackageNotFound{package:\"qed\"} (hardcode old crate name), plus config::parses_on_success_outcomes_from_toml, transform::release_yml_transforms_end_to_end, preflight::audit_workspace..., runner::musl_static_preflight.... F2's own paths (build_image*, remote_subprocess*, velveteen widening) are all green. Don't attribute these to F2.")
174//! @yah:gotcha("Shared working tree churns fast: execute_step_remote was edited concurrently by another session (image-override half) while I did the mesh_tags half -- both R590-F2 helpers now coexist. Expect transient broken builds mid-edit.")
175//! @yah:gotcha("RESOLVED 2026-07-22 (see R590-B5 on .yah/qed/P018-rusty-v8-musl.toml) -- kept for the shape of the trap. NAMING+DIGEST were both symptoms of step.image only accepting a bare CATALOG NAME, which velveteen_exec::default_image::catalog_image hard-codes to ghcr.io/yah-ai/<name>:latest + a compile-time-or-sentinel digest. step_image_override now ALSO accepts a full registry/repo:tag@sha256 ref (any string containing / or @), parsed via ImageRef::parse_pinned, and P018 pins cr.yah.dev/rusty-v8-musl-builder:v149.4.0-amd64@sha256:a1fb9d9c... -- same bytes the transform recipe names. Bare names still route through catalog_image unchanged.")
176//! @yah:next("FIRST: examine the rusty-v8 build result on us-west-002. `ssh -i ~/.ssh/yah struc@100.64.0.4`. Was launched ~2026-07-11 17:12 local (UTC-7), ETA ~1h49m so it's long done by pickup. Check: `sudo ctr -n yah tasks ls` (STOPPED=done/died), `sudo ctr -n yah tasks delete forge-695667cc-213d-4a26-8f56-702fd373ed39` prints the exit code, `sudo journalctl -u kamaji --since '3 hours ago' | grep -iE 'build-v8|ninja|tar|librusty|error|signal'` for the tail. SUCCESS = build-v8.sh wrote /tmp/rusty-v8-musl/librusty_v8-x86_64-unknown-linux-musl.tar.gz INSIDE the (now-exited) container — note: /tmp is the container's tmpfs, gone once the task is deleted, so if it completed, the proof is the exit-0 + the 'wrote tar' log line, not a retrievable file (retrieval is the deferred ArtifactStore leg). If it FAILED, diagnose from the kamaji journal (next likely walls: tmpfs /tmp 24G too small for a full V8 build → ENOSPC; or a gn/ninja/clang toolchain gap in the builder image).")
177//! @yah:next("THEN B9 (yubaba state-poll 404, in the open column): the clean fix is READ-PATH, not a name change (I tried dotting for_forge's name -> DNS-label validation rejected it, reverted). kamaji stamps a yah.ident label = mesh identity (forge.<uuid>) on each container; make kamaji-bin's list() return that label as WorkloadEntry.id (instead of container_id forge-<uuid>), OR make yubaba get_workload_state match against the yah.ident label. Needs a kamaji (or yubaba) cross-build + redeploy — recipe below. This makes `yah qed run rusty-v8-musl` REPORT green instead of 404-timeout-Failed.")
178//! @yah:next("THEN B8 (kamaji ignores image ENTRYPOINT/ENV, open): merge image OCI config into build_oci_spec (process.args = image.Entrypoint ++ argv; env = image.Env overlaid by spec env). Then delete the throwaway .yah/qed/P018-rusty-v8-musl-verify.toml and the real P018-rusty-v8-musl.toml runs as authored.")
179//! @yah:next("CLEANUP: delete .yah/qed/P018-rusty-v8-musl-verify.toml once B8 lands. Archive R590-B3/B5/B7/B10 (all review, signed off) + this relay's reviewed children once the human confirms. B10's 32GB is a stopgap — proper fix is per-step memory from the pipeline.")
180//! @yah:handoff("SESSION 2026-07-11 END STATE. GOAL REACHED: `yah qed run rusty-v8-musl` (no --where) on an arm64 Mac offloads to us-west-002 and builds V8 natively on x86 — proven live, compiling `v8 v149.4.0` when this baton was written. The FULL remote-qed path works: placement Offload -> yubaba admission -> kamaji -> containerd -> image pull -> host-net container -> build-v8.sh clone+compile.")
181//! @yah:handoff("us-west-002 (gamer, x86_64 Debian13, struc@100.64.0.4 via ~/.ssh/yah, passwordless sudo) NOW runs yubaba+kamaji 0.8.19 with ALL of B3/B5/B7 + the B10 client-side fix live. Backups on-box: /usr/local/bin/{yubaba,kamaji}.0.8.18.bak + kamaji.b7-prev.bak; drop-in /tmp/20-mesh-bind.conf.bak. kamaji.service.d/10-log-dir.conf adds ReadWritePaths=/var/log/yah (B7 sibling fix). Builder image ghcr.io/yah-ai/rusty-v8-musl-builder:latest (PRIVATE pkg) is loaded into containerd ns 'yah' (docker pull on Mac -> save|ctr import; nothing auto-pulls it — see below).")
182//! @yah:handoff("FIXES THIS SESSION: B3/B5 (decode+tag-fallback, review). B7 (review): task/remote.rs sets yah.network=host on forge workloads + kamaji build_oci_spec bind-mounts /etc/resolv.conf under host-net. B10 (review): for_forge memory_mb 256->32768 (was SIGKILL'ing builds). Filed still-open: B8 (kamaji drops image ENTRYPOINT/ENV, worked around in P018-verify), B9 (state-poll 404 dot/dash ident mismatch).")
183//! @yah:handoff("CROSS-BUILD + REDEPLOY RECIPE (arm64 Mac): `cd oss/kamaji && DOCKER_DEFAULT_PLATFORM=linux/amd64 YAH_REPO_ROOT=/Users/leif/ss/yah cross build --release -p kamaji-bin --features containerd-integration --target x86_64-unknown-linux-musl` (yubaba: -p yubaba in oss/yubaba). The two env vars are MANDATORY (Cross.toml :main images are amd64-only; repo-root mount for ../qed+../yah-base path-deps). Deploy: scp to /tmp, backup, `sudo install -m0755`, `systemctl restart kamaji` THEN `systemctl restart yubaba` (order dodges the UDS boot-race). yah CLI changes (task/remote.rs, workload-spec) are picked up by a plain `cargo build -p yah` — the fleet run is in-process (F4 offload bypasses the desktop daemon), so NO desktop rebuild needed.")
184//! @yah:handoff("GOTCHA: don't run the plain `yah qed run rusty-v8-musl` for a clean demo until B8 lands — P018 relies on the image entrypoint kamaji drops. Use `rusty-v8-musl-verify` (the throwaway, self-contained argv+env) meanwhile. Both correctly offload; only the container exec differs.")
185//!
186//! @yah:ticket(R590-B11, "rusty-v8-musl build-v8.sh packaging tail fails on Alpine (no mkdir, busybox tar)")
187//! @yah:at(2026-07-12T16:11:54Z)
188//! @yah:status(review)
189//! @yah:assignee(agent:bundle-anthropic-ashguard)
190//! @yah:parent(R590)
191//! @yah:severity(low)
192//! @yah:next("Fix landed in source: build-v8.sh adds mkdir -p $(dirname $OUT) before the tar; Dockerfile apk-adds tar (GNU tar at /usr/bin shadows busybox /bin/tar). Remaining: rebuild+push rusty-v8-musl-builder image (buildx amd64,arm64), re-pin digest in recipe, re-import to box, re-run to prove exit-0 + 'wrote tar' line.")
193//! @yah:verify("yah qed run rusty-v8-musl offloads to us-west-002; build-v8.sh emits 'build-v8: wrote …/librusty_v8-x86_64-unknown-linux-musl.tar.gz' and exits 0.")
194//! @yah:gotcha("V8 itself builds fine — proven on us-west-002 2026-07-11: native x86 cargo build produced target/release/gn_out/obj/librusty_v8.a (145.5M) after ~54m. Only the packaging tail of images/rusty-v8-musl-builder/build-v8.sh failed (exit 1).")
195//! @yah:gotcha("Two Alpine-image regressions (the 2026-06-20 145MB proof ran on the earlier debian image w/ GNU tar): (1) build-v8.sh:116 redirect dies 'nonexistent directory' — the recipe's /tmp/rusty-v8-musl/ parent is never mkdir'd; (2) 'tar: unrecognized option: sort=name' — Alpine default tar is the busybox applet, which rejects GNU --sort/--numeric-owner/--mtime.")
196//!
197//! @yah:ticket(R603-T1, "Persist run+workload binding at remote dispatch: emit yubaba ident on StepStarted + write non-terminal meta")
198//! @yah:status(review)
199//! @yah:at(2026-07-14T23:00:42Z)
200//! @yah:assignee(agent:claude)
201//! @yah:parent(R603)
202//! @yah:next("execute_step_remote (runner.rs ~line 401+): after `let handle = driver.dispatch(...).await?` and `forge_id = handle.id.clone()`, emit a new event carrying the workload binding BEFORE `handle.wait()`. Options: extend QedEvent::StepStarted with `remote_workload: Option<{node,ident}>` OR add QedEvent::StepRemoteDispatched{index,ident,node,at}. Prefer extending StepStarted-adjacent so the events.jsonl records it.")
203//! @yah:next("qed events.rs + rpc QedEventWire: mirror the new field/variant (kebab-case, RFC3339). camp.rs qed_event_to_wire + apply_qed_event_to_meta updated to stamp step.task_run_id at START (not just finish).")
204//! @yah:next("camp drain (spawn_qed_event_drain) + persist: on RunStarted/first StepStarted for a run, write a NON-TERMINAL <run_id>.json (status=running) so load_qed_history surfaces it after restart; include the remote binding (either in meta or a <run_id>.remote.json sidecar). Today persist_qed_run (camp.rs:4608) only writes on terminal.")
205//! @yah:next("Tests: remote step records ident at start; non-terminal meta is written+reloadable; events.jsonl carries the binding.")
206//! @yah:handoff("DONE + verified. Remote qed steps now publish their yubaba workload identity mid-flight so a daemon restart can reattach instead of orphaning the build. Changes: (1) qed events.rs: new QedEvent::StepRemoteDispatched{index,name,forge_id,at}. (2) qed runner.rs execute_step_remote: emits it right after handle.id is known, BEFORE handle.wait(). (3) rpc: mirrored QedEventWire::StepRemoteDispatched (kebab 'step-remote-dispatched'). (4) camp.rs: qed_event_to_wire arm; apply_qed_event_to_meta stamps step.task_run_id at dispatch (was finish-only); drain persists a NON-terminal <run_id>.json on StepRemoteDispatched so load_qed_history surfaces interrupted runs (updated its doc comment too). Terminal persist still overwrites on normal completion.")
207//! @yah:handoff("Verified: cargo check -p qed -p rpc -p yah all clean. New test remote_step_emits_workload_binding_before_finish + the 4 existing remote_step tests all pass (5/5).")
208//! @yah:verify("cargo test --manifest-path oss/qed/crates/qed/Cargo.toml --lib remote_step  # 5/5 pass incl. remote_step_emits_workload_binding_before_finish")
209//! @yah:verify("cargo check -p yah  # clean")
210//! @yah:gotcha("PRE-EXISTING (not this ticket): 5 qed --lib preflight/musl-gate tests fail because they hard-code package name \"qed\" (preflight.rs:361/379/401, runner.rs:7208 musl_preflight_pipeline(\"qed\")) but the crate was renamed to yah-qed. cargo metadata confirms package is 'yah-qed'. Rename-drift from the qed->yah-qed crates.io-prefix migration; independent of R603. Worth a Thief cleanup ticket.")
211//! @yah:gotcha("Tier: Warrior -- delivered.")
212//!
213//! @yah:ticket(R603-T5, "Durable build-worker artifact volume so reconcile resume survives container reaping")
214//! @yah:status(review)
215//! @yah:at(2026-07-15T22:28:31Z)
216//! @yah:assignee(agent:bundle-anthropic-ashguard)
217//! @yah:parent(R603)
218//! @yah:gotcha("Surfaced by R603-T4: resume_terminal_publish_for_remote_step retrieves the produced tar off the build-worker via retrieve_remote_artifacts, but kamaji reaps EXITED containers, so a remote build that finished DURING a daemon outage has its container (and tar) already gone by the time boot-reconcile resumes -> fetch_produced_file errs -> run left Success-but-UNPUBLISHED. T4 handles this best-effort (logs + re-run); this ticket is the robust fix.")
219//! @yah:handoff("MECHANISM (operator-chosen): Option 1 — durable host bind-mount + host read. A remote forge subprocess mounts a host-persistent dir (/var/lib/yah/qed/produced/<forge_id>) at the conventional /yah/produced; the build writes its produced tar there, so the bytes land on the worker HOST fs and outlive kamaji reaping the exited container. Retrieval reads the host path via yubaba (not the container rootfs), which is the R590-F6 deferred transport reshaped as a host read.")
220//! @yah:handoff("SHIPPED (code-complete, unit-tested; NOT yet proven on-box). New shared convention module yah-workload-spec::forge_produced (CONTAINER_DIR=/yah/produced, HOST_ROOT=/var/lib/yah/qed/produced, forge_id_from_ident, host_dir, host_path w/ traversal guard, durable_mount). qed task remote.rs build_workload_spec: Subprocess arm adds the durable produced bind mount. qed runner.rs execute_step_remote: guard rejects a produces path not under /yah/produced at dispatch (InvalidConfig) so it can't silently orphan. yubaba lib.rs: GET /workloads/{ident}/produced host-read handler + deploy mkdirs the dir (ensure_durable_produced_dirs) + reap-on-destroy + 3-day TTL sweep. cloud-client: CloudClient::fetch_produced_file. yubaba_client.rs: MeshYubabaClient::fetch_produced_file sweeps nodes for the ARTIFACT (state 404s post-reap) + re-seeds route.")
221//! @yah:verify("cargo test -p yah-workload-spec --lib forge_produced  # 5/5")
222//! @yah:verify("cargo test -p velveteen --lib remote  # 12/12 (incl. subprocess_workload_carries_durable_produced_mount)")
223//! @yah:verify("cargo test -p yah-qed --lib remote_step  # 6/6 (incl. remote_step_rejects_non_durable_produces_path)")
224//! @yah:verify("cargo test -p yah --lib fetch_produced_file  # 2/2 (sweep + no-node-has-it)")
225//! @yah:verify("cargo test -p yubaba --lib  # 178/178 (deploy handler unbroken)")
226//! @yah:next("ON-BOX PROOF (can't be done from the Mac): cross-build musl yubaba+kamaji carrying these changes (recipe in R590 handoff), redeploy us-west-002, deploy a forge subprocess writing produces under /yah/produced, kill the daemon post-build, restart, confirm boot-reconcile retrieves the tar off the host dir AFTER the container is reaped.")
227//! @yah:next("rusty-v8 recipe: point its output path (YAH_TRANSFORM_OUT / produces) at /yah/produced/… once R590-F6/B11 lands its produces; the new dispatch guard enforces the convention.")
228//! @yah:next("Tune retention once real runs exist: destroy-reap covers the happy path; the 3-day TTL sweep (yubaba PRODUCED_RETENTION) covers orphans — confirm the window fits real build cadence.")
229//!
230//! @yah:ticket(R603-B7, "qed.tail CLI stream dies on long remote steps (os error 35) — a ~58min offloaded build always ends with a spurious error despite succeeding")
231//! @yah:status(review)
232//! @yah:assignee(agent:bundle-anthropic-ashguard)
233//! @yah:at(2026-07-20T23:46:40Z)
234//! @yah:parent(R603)
235//! @yah:handoff("Root cause was the wire timeout, not the socket mode. `qed.tail` was NOT in daemon_client's timeout_for_method allowlist, so every poll ran on the 500ms RPC_TIMEOUT fast-path floor. The CLI follow loop polls ~5/s, so an hour-long offloaded build issues ~15k calls — at 500ms a single transient daemon hiccup is effectively certain, and the loop treated the resulting EAGAIN as fatal (bail 'qed.tail failed'). Same family as R477-F11 / R606-T4.")
236//! @yah:handoff("FIX 1 (crates/yah/agent-tools/src/daemon_client.rs): renamed WRITE_TIMEOUT -> MID_TIMEOUT (it is no longer writes-only) and added QED_TAIL + QED_STATUS to that 10s tier, with the rationale that these are hot poll loops rather than slow single calls. Test timeout_for_method_gives_gated_writes_middle_tier extended to cover both. cargo test -p yah-agent-tools --lib daemon_client GREEN 29/29.")
237//! @yah:handoff("FIX 2 (app/yah/cli/src/qed.rs, run_via_camp_daemon): the tail stream is now treated as a VIEW, not the run. Ok(None) (socket gone, daemon restarting) and Err (wire error) both enter a degraded state instead of bailing: warn once, retry every 2s for up to TAIL_DEGRADED_BUDGET=120s, print 'tail stream reattached' on recovery. Only after the budget expires does it consult qed.status once via the new poll_run_status helper — if the run went terminal while we were blind it finishes normally with that snapshot; otherwise it bails with an honest 'the run may still be executing — check yah qed status <run_id>' rather than implying the build failed.")
238//! @yah:handoff("Together this also makes the CLI follow loop survive a daemon restart, which is the R603 thesis applied to the operator's view: cursor-based qed.tail cold-reads the JSONL log on a fresh DaemonState (camp.rs replay test), so reattach resumes at the right cursor with no duplicate output.")
239//! @yah:verify("cargo test -p yah-agent-tools --lib daemon_client (29/29)")
240//! @yah:verify("cargo check -p yah --bin yah")
241//! @yah:verify("End-to-end (needs a yah rebuild + daemon restart): `yah qed run rusty-v8-musl` should stream for ~58min and exit 0 with '==> pipeline passed', matching `yah qed status <run_id>` instead of dying with os error 35.")
242//! @yah:gotcha("COSMETIC ONLY — never harmed the run. Surfaced 2026-07-20 during the first green rusty-v8-musl build: the CLI died with `qed.tail failed: daemon I/O: Resource temporarily unavailable (os error 35)` at ~51min while the daemon carried the run to success at 58m21s.")
243//! @yah:gotcha("Takes effect only after a `yah` rebuild AND a camp-daemon restart — the fix spans the CLI binary (follow loop) and the shared daemon_client timeout table baked into both.")
244//!
245//! @yah:ticket(R636-B1, "Offloaded build-image bind-mounts the qed host's camp_root onto a different worker host (cross-host context gap)")
246//! @yah:at(2026-07-23T18:48:06Z)
247//! @yah:status(open)
248//! @yah:assignee(agent:bundle-anthropic-ashguard)
249//! @yah:parent(R636)
250//! @yah:severity(high)
251//! @yah:verify("From an arm64 host, `yah qed images build <catalog-image> --platform linux/amd64` builds on us-west-002 and writes/publishes without a camp-root mount error")
252//! @yah:verify("The worker's runc task mounts a worker-local context dir, not the qed host's /Users/... path")
253//! @yah:gotcha("Two OTHER gaps sit in front of this one on the offload path and are already cleared, so don't re-chase them: (1) build-image remote routing used the runner's HOST arch not the step's TARGET arch — an amd64 build from an arm64 Mac went to a tier:arm RPi (us-west-011) that then failed on a loopback yubaba URL; FIXED in R636 via remote_build_image_arch. (2) us-west-002's containerd `yah` namespace lacked moby/buildkit:v0.12.5-rootless; pre-pulled 2026-07-23 (node bootstrap, same class as P018's deferred image pre-pull).")
254//! @yah:next("DEPLOYABLE FIX DESIGN (qed-side only, NO fleet redeploy needed — the workload spec's command+mounts are authored by the qed dispatcher and merely executed by the existing 0.8.20 yubaba/kamaji): in build_image_workload_spec (velveteen-exec/src/remote.rs), when the target worker != qed host, stop bind-mounting camp-host paths. Instead (a) tar the resolved context dir, (b) upload it to a worker-reachable URL (yah-cloud R2 → cdn.yah.dev/yah-cloud/qed-context/<forge_id>.tar.gz; unique key per forge_id sidesteps the CDN-stale gotcha), (c) change buildctl_argv from `--local context=... --local dockerfile=...` to buildkit's remote-context `--opt context=<url> --opt filename=<Dockerfile>`, and (d) drop the two camp-host VolumeMounts. The OCI-archive OUT mount (BUILDKIT_HOST_OUT_DIR, worker-local) stays. Validate live via `yah qed images build rusty-v8-musl-builder --platform linux/amd64` from the arm64 Mac.")
255//! @yah:next("ALTERNATIVE (higher-fidelity, needs redeploy): carry the context inline in WorkloadSpec (new VolumeSource::Inline or a context payload) and have kamaji materialize it to a worker-local dir + bind-mount that. Cleaner model but touches workload-spec + kamaji and only takes effect after the fleet is redeployed off 0.8.20 — so it cannot deliver until a redeploy anyway. Prefer the deployable design above unless kamaji is being redeployed for other reasons.")
256//! @yah:next("AFTER the fix lands: re-run the amd64 build through the offload path to prove it end-to-end, then the workaround's native-on-box step is retired.")
257//! @yah:handoff("2026-07-23: the two upstream gaps on the offload path are CLEARED and the amd64 builder image was DELIVERED via the native-host workaround. (1) build-image remote routing now uses the step's TARGET arch (R636 remote_build_image_arch) — an amd64 build from the arm64 Mac now lands on us-west-002 (tier:x86, mesh 100.64.0.4), not the tier:arm RPi. (2) moby/buildkit:v0.12.5-rootless pre-pulled into us-west-002's `yah` containerd namespace (node bootstrap). With both cleared, the offload dispatch reaches BuildKit and fails ONLY on this ticket's cross-host mount: runc `open /Users/leif/ss/yah: no such file or directory` — the camp Mac's camp_root bind-mounted onto the worker.")
258//! @yah:handoff("DELIVERED anyway (native path): built the amd64 builder image ON us-west-002 with `docker buildx --platform linux/amd64 -o type=oci` (byte-equivalent to what the verb shells, native arch, no emulation), pulled the OCI layout to camp, published via `yah cloud cr push`. LIVE + VERIFIED: cr.yah.dev/rusty-v8-musl-builder:v149.4.0-amd64-r636 @ sha256:7e9f0327255c8b96e65864c6324c9412b03558dd8fcdb50e1958734722171c9d — pulled back, arch=x86_64, baked /usr/local/bin/build-v8.sh has 4x `features simdutf` (the old v149.4.0-amd64 had ZERO). The stale-builder-image root cause of the broken published rusty_v8 artifact is fixed. NOT YET re-pinned into P018 / the transform recipe (busts derivation caches; sequence with the R546 owner) and the actual librusty_v8 artifact still needs a recipe run with this image.")
259//! @yah:handoff("DEFERRED (not blocked): the durable transport fix below was NOT implemented this session — velveteen-exec is a published OSS crate inside the active 0.8.21 release window and a peer was building the workspace (R629); churning it half-validated would be reckless. Sequence post-release.")
260
261use std::sync::Arc;
262
263use async_trait::async_trait;
264use chrono::Utc;
265use observation::ForgeId as ObsForgeId;
266use yah_scryer::service::Scryer;
267use velveteen::{
268    ForgeCommand, ForgeSpec, ForgeStatus, MeshAccess, TaskLocation, TaskPlacement, TaskRuntime,
269};
270use velveteen_exec::{
271    ExecContext, ExecEvent, ForgeExecutor, ForgeExecutorError, LocalForgeDriver, RemoteForgeDriver,
272    WardenClient,
273};
274use task_runs::Initiator;
275use thiserror::Error;
276use uuid::Uuid;
277use workload_spec::{Millis, TierTag};
278
279use tokio::sync::mpsc::UnboundedSender;
280
281use crate::events::{OutputStream, QedEvent};
282use crate::native::{LoggingSigner, SigstoreSigner};
283use crate::types::{
284    OnFail, Outcome, Pipeline, ProducedArtifact, QedRunId, QedRunMeta, QedStep, RunStatus,
285    StepActivation, StepStatus, WorkspaceMode,
286};
287
288/// Dispatches pipeline outcomes (yubaba-deploy, almanac-run) after a pipeline completes.
289///
290/// Implementations are responsible for the actual side-effect. The default stub logs and
291/// no-ops until the respective RPC surfaces stabilise (R040-F4 for yubaba deploy).
292#[async_trait]
293pub trait OutcomeDispatcher: Send + Sync {
294    async fn warden_deploy(&self, service: &str, env: &str) -> Result<(), RunnerError>;
295    async fn almanac_run(&self, pipeline: &str) -> Result<(), RunnerError>;
296    /// Publish the artifacts produced by the run's successful steps into a
297    /// release channel bucket, then fire the almanac revalidate hook (R330-F3).
298    /// The default no-ops so existing impls don't break; the real behaviour
299    /// lives in [`crate::publish::PublishingOutcomeDispatcher`].
300    async fn publish(&self, req: &crate::publish::PublishRequest) -> Result<(), RunnerError> {
301        tracing::info!(
302            provider = %req.provider,
303            bucket = %req.bucket,
304            version = %req.version,
305            artifacts = req.artifacts.len(),
306            "qed outcome: publish skipped (no publishing dispatcher wired)"
307        );
308        Ok(())
309    }
310}
311
312/// Stub dispatcher — logs what it would do but takes no action.
313/// Used by default until yubaba deploy RPC (R040-F4) and almanac are stable.
314pub struct LoggingOutcomeDispatcher;
315
316#[async_trait]
317impl OutcomeDispatcher for LoggingOutcomeDispatcher {
318    async fn warden_deploy(&self, service: &str, env: &str) -> Result<(), RunnerError> {
319        tracing::info!(
320            service,
321            env,
322            "qed outcome: yubaba-deploy skipped (yubaba deploy RPC not yet stable, R040-F4)"
323        );
324        Ok(())
325    }
326
327    async fn almanac_run(&self, pipeline: &str) -> Result<(), RunnerError> {
328        tracing::info!(
329            pipeline,
330            "qed outcome: almanac-run skipped (almanac not yet implemented)"
331        );
332        Ok(())
333    }
334}
335
336#[derive(Error, Debug)]
337pub enum RunnerError {
338    #[error("Step '{step}' failed: {msg}")]
339    StepFailed { step: String, msg: String },
340    #[error("IO error: {0}")]
341    Io(#[from] std::io::Error),
342    #[error("Invalid step configuration: {0}")]
343    InvalidConfig(String),
344    #[error("Remote dispatch error: {0}")]
345    Remote(String),
346    /// A terminal outcome / release-provider adapter failed (R509): missing
347    /// credential slot, unknown provider, vendor API error.
348    #[error("Release outcome error: {0}")]
349    Outcome(String),
350    /// Plan-time toolchain pinning check failed (R507, W208): the host can't
351    /// satisfy one or more `[pipeline.toolchain]` / per-step `toolchain.*` pins
352    /// and no container image provides them. Carries the actionable per-pin
353    /// report from [`crate::toolchain::ToolchainPreflight::error_report`].
354    #[error("{0}")]
355    ToolchainUnsatisfied(String),
356}
357
358/// Where pipeline steps execute.
359///
360/// This is now the operator's **force-override lattice** (R590-F4), not the
361/// router itself: [`Auto`](Self::Auto) is the default, and per-step placement
362/// is *derived* from what each step declares (via
363/// [`resolve_placement`](crate::platform::resolve_placement)). `--where` only
364/// exists to pin the whole run one way for testing.
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub enum RunWhere {
367    /// Policy-derived placement (default, no `--where`): each step runs locally
368    /// unless its declared platform resolves to
369    /// [`Offload`](crate::platform::Resolution::Offload) — a `native = true`
370    /// cross-arch build that can't cross/emulate here — in which case it's
371    /// dispatched to an arch-matched build-worker.
372    Auto,
373    /// Force every step local (`--where=local`): a testing override that
374    /// suppresses offload even for a `native = true` cross-arch step.
375    Local,
376    /// Force every step remote (`--where=remote`): dispatch all steps as
377    /// `task::remote` workloads on a yubaba node.
378    Remote,
379}
380
381/// Pure placement policy (R590-F4): fold the operator's `--where` force-mode
382/// together with a step's platform [`Resolution`] into a concrete
383/// [`Local`](RunWhere::Local) / [`Remote`](RunWhere::Remote) decision. Never
384/// returns [`Auto`](RunWhere::Auto) — that's the *input* mode, resolved away
385/// here.
386///
387/// - `Local` / `Remote` force-modes pass straight through (the `--where`
388///   override wins over policy, by design).
389/// - `Auto` derives from the step: an [`Offload`](crate::platform::Resolution::Offload)
390///   resolution — a `native = true` cross-arch build — routes to the fleet;
391///   every other verdict (NativeCross / CrossDocker / Emulate / Skip) stays
392///   local, where its existing cross/emulate handling applies.
393pub(crate) fn policy_placement(
394    mode: RunWhere,
395    resolution: &crate::platform::Resolution,
396) -> RunWhere {
397    match mode {
398        RunWhere::Local => RunWhere::Local,
399        RunWhere::Remote => RunWhere::Remote,
400        RunWhere::Auto => match resolution {
401            crate::platform::Resolution::Offload { .. } => RunWhere::Remote,
402            _ => RunWhere::Local,
403        },
404    }
405}
406
407/// True when any step in `pipeline` resolves to
408/// [`Offload`](crate::platform::Resolution::Offload) on `host` — i.e. a default
409/// (`--where=auto`) run of it needs fleet access even without `--where=remote`
410/// (R590-F4). The CLI uses this to decide whether to stand up a mesh dispatcher
411/// (via [`PipelineRunner::new_auto`]) or stay on the driverless local path: a
412/// pipeline of ordinary cross-compilable steps needs no cloud wiring at all.
413pub fn pipeline_needs_offload(pipeline: &Pipeline, host: &str) -> bool {
414    pipeline.steps.iter().any(|step| {
415        let p = crate::platform::Platform::compose(
416            host,
417            step.platform.as_ref(),
418            step.triple.as_deref(),
419        );
420        let native = step.platform.as_ref().map(|s| s.native).unwrap_or(false);
421        matches!(
422            crate::platform::resolve_placement(
423                &p.host,
424                p.target.as_deref(),
425                p.container_platform.as_deref(),
426                native,
427            ),
428            crate::platform::Resolution::Offload { .. }
429        )
430    })
431}
432
433/// Map a Docker image tag (`reg/repo:ver`) to a filesystem-safe stem for
434/// OCI archive output under `.yah/cache/images/`. Replaces every byte that
435/// isn't `[A-Za-z0-9_.-]` with `_`.
436fn tag_to_filename(tag: &str) -> String {
437    tag.chars()
438        .map(|c| {
439            if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
440                c
441            } else {
442                '_'
443            }
444        })
445        .collect()
446}
447
448/// Catalog lookup + Dockerfile staging output, shared by local and remote
449/// build-image dispatch.
450struct PreparedBuildImage {
451    camp_root: std::path::PathBuf,
452    dockerfile_path: std::path::PathBuf,
453    buildkit_dir: std::path::PathBuf,
454    archive_path: std::path::PathBuf,
455    tag: String,
456}
457
458/// Placement mesh-tags for a remote **subprocess** step (R590-F2).
459///
460/// When the step declares a target arch via `[platform].target`, return the
461/// arch-matched build-worker selector (`tag:build-worker` + `tier:x86|arm`) so
462/// the run is placed on a node of that arch and executes *natively* — the whole
463/// point of the fleet path is that an arm64 host can drive an
464/// `x86_64-unknown-linux-musl` build on the x86 box (us-west-002) instead of
465/// emulating it locally. No `platform.target` ⇒ empty tags ⇒ any infra node.
466///
467/// Mirrors the build-image path's placement, but keyed off the step's declared
468/// *target* rather than the runner's host triple.
469fn remote_subprocess_mesh_tags(step: &crate::types::QedStep) -> Vec<String> {
470    match step.platform.as_ref().and_then(|p| p.target.as_deref()) {
471        Some(target) => crate::platform::build_worker_mesh_tags(crate::platform::arch_of(target)),
472        None => Vec::new(),
473    }
474}
475
476/// Per-step container image override (R590-F2, finishing the R381 `step.image`
477/// seam) so the argv runs *inside that image* — e.g. `rusty-v8-musl-builder`
478/// executing `build-v8.sh`. `None` ⇒ fall back to the default forge image
479/// (`yah-rust-bun`), preserving the pre-seam behaviour for plain steps. Used by
480/// both the local-container and remote subprocess paths so `image` behaves the
481/// same regardless of `--where`.
482///
483/// Two spellings, distinguished by shape (R590-B5):
484///
485/// - **Full ref** — anything containing `/` or `@`, e.g.
486///   `cr.yah.dev/rusty-v8-musl-builder:v149.4.0-amd64@sha256:…`. Parsed
487///   verbatim through [`workload_spec::ImageRef::parse_pinned`]. This is the
488///   spelling to reach for: it names the registry explicitly (so a pipeline is
489///   not welded to whatever host [`catalog_image`] happens to hard-code) and it
490///   carries a real digest, so the pull is content-addressed rather than
491///   chasing a floating tag.
492/// - **Bare catalog name** — e.g. `yah-rust-bun`. Resolved through
493///   [`velveteen_exec::default_image::catalog_image`] to
494///   `ghcr.io/yah-ai/<name>:latest` plus the compile-time digest, or the
495///   all-zeros [`workload_spec::ImageRef::UNPINNED_DIGEST`] sentinel on dev
496///   builds (which `pull_ref` then degrades to a tag-only pull).
497///
498/// A full ref without a digest is a hard config error, not a silent tag pull —
499/// if you went to the trouble of naming a registry, you get pinning with it.
500///
501/// [`catalog_image`]: velveteen_exec::default_image::catalog_image
502fn step_image_override(
503    step: &crate::types::QedStep,
504) -> Result<Option<workload_spec::ImageRef>, RunnerError> {
505    let Some(image) = step.image.as_deref() else {
506        return Ok(None);
507    };
508    if image.contains('/') || image.contains('@') {
509        return workload_spec::ImageRef::parse_pinned(image)
510            .map(Some)
511            .map_err(|reason| {
512                RunnerError::InvalidConfig(format!(
513                    "step `{}` sets image = {image:?}, which looks like a full registry \
514                     reference but does not parse: {reason}. Either spell it as a bare \
515                     catalog name (`rusty-v8-musl-builder`) or as a digest-pinned ref \
516                     (`cr.yah.dev/rusty-v8-musl-builder:<tag>@sha256:<hex>`).",
517                    step.name,
518                ))
519            });
520    }
521    Ok(Some(velveteen_exec::default_image::catalog_image(image)))
522}
523
524pub struct PipelineRunner {
525    pipeline: Pipeline,
526    run_id: QedRunId,
527    remote_driver: Option<Arc<RemoteForgeDriver>>,
528    run_where: RunWhere,
529    outcome_dispatcher: Arc<dyn OutcomeDispatcher>,
530    /// Optional live-event sink (R325-F2). When set, `run()` emits a
531    /// [`QedEvent`] at each lifecycle boundary; when `None` the runner is
532    /// silent and only the terminal [`QedRunMeta`] is observable.
533    events: Option<UnboundedSender<QedEvent>>,
534    /// Camp root used by build-image steps to locate per-camp images
535    /// (`<camp_root>/.yah/qed/images/<name>/`) and write generated artifacts
536    /// (`<camp_root>/.yah/cache/{buildkit,images}/`). Falls back to
537    /// `std::env::current_dir()` when unset — production callers leave this
538    /// alone; tests override via [`Self::with_camp_root`] to avoid leaking
539    /// `.yah/cache/` into the working directory.
540    camp_root: Option<std::path::PathBuf>,
541    /// Sigstore signer for `kind = "sign-native-tarball"` steps (R407-T5).
542    /// Defaults to [`LoggingSigner`], which writes placeholder bytes and
543    /// logs a warning so a local `yah qed run` doesn't fail when cosign
544    /// isn't installed. Release CI wires [`crate::native::CosignSigner`]
545    /// explicitly via [`Self::with_signer`] so an unsigned tarball never
546    /// silently ships.
547    signer: Arc<dyn SigstoreSigner>,
548    /// Subprocess executor for local `kind = "subprocess"` steps (R438-T14).
549    /// Defaults to [`LocalForgeDriver`]. Override via [`Self::with_executor`]
550    /// when a caller wants to share a configured driver (e.g. the cloud
551    /// reconciler reuses one across many materialize calls).
552    executor: Arc<dyn ForgeExecutor>,
553    /// Resolver for `kind = "sub-pipeline"` steps (R488-F2). Defaults to a
554    /// no-op resolver that returns `None` for every target — production
555    /// callers wire a [`PipelineLoader`]-backed resolver via
556    /// [`Self::with_sub_pipeline_resolver`]. With the default resolver, a
557    /// SubPipeline step's target is unresolvable and the step fails with a
558    /// clear "no resolver configured" message.
559    sub_pipeline_resolver: Arc<dyn crate::types::SubPipelineResolver + Send + Sync>,
560    /// When `true`, this runner's terminal `Outcome::Publish` outcomes are
561    /// suppressed at the end of `run()`. Set on child runners constructed
562    /// for a SubPipeline step where the parent declared
563    /// `propagate.produces = true` — the parent owns the terminal publish,
564    /// so firing it on the child would double-publish and double-revalidate.
565    /// All other outcomes (`WardenDeploy`, `AlmanacRun`) still run.
566    suppress_publish_outcomes: bool,
567    /// Set on child runners spawned by a SubPipeline step (R488-F5). The
568    /// child's terminal [`QedRunMeta`] carries this back to consumers so
569    /// the nested tree can be rebuilt from history alone. `None` on
570    /// top-level runs.
571    parent_run_id: Option<QedRunId>,
572    /// Added to every emitted step `index` so that a resume-from-step run
573    /// (where the pipeline had its leading steps drained) still shows the
574    /// original step position in the UI (e.g. step 6 of 6 instead of 1 of 1).
575    /// Set via [`Self::with_index_offset`] in callers that drain steps.
576    index_offset: usize,
577    /// R499-F3 phase 2: per-step gha-workflow matrix subset. Keyed by
578    /// qed step name; the inner set is the chosen
579    /// [`yah_qed_gha::graph::JobInstance::key`] values (`<job>` for
580    /// non-matrix, `<job>#<row>` for matrix). When a gha-workflow step
581    /// has an entry here, [`Self::execute_step_gha_workflow`] threads
582    /// it into [`yah_qed_gha::Executor::included_instance_keys`] so
583    /// non-selected rows short-circuit to `Skipped`. Steps missing from
584    /// the map run their full matrix. Set via
585    /// [`Self::with_gha_matrix_subset`].
586    gha_matrix_subset: std::collections::HashMap<String, std::collections::HashSet<String>>,
587    /// On-demand override for [`StepActivation::Stubbed`] steps (R506). When
588    /// `true`, the runner ignores `status = "stubbed"` and runs the step the
589    /// same way an `active` step would. Set via [`Self::with_include_stubbed`].
590    /// Defaults to `false`; `enabled = false` is still always honored even
591    /// when this flag is on (the two knobs are orthogonal — `enabled` means
592    /// "explicitly off for this run", `stubbed` means "not implemented yet").
593    include_stubbed: bool,
594    /// Matrix coordinate this runner is executing for (R506). Set by the
595    /// planner when fanning a pipeline over its `[matrix]` block; threaded
596    /// into the `if=` expression context so a step can gate on
597    /// `matrix.<key>` values. `None` for non-matrix runs — `matrix.<key>`
598    /// lookups then return `Null`/falsy via [`yah_qed_gha::Context`] semantics.
599    matrix_coord: Option<crate::matrix::MatrixCoord>,
600    /// Self-detected host triple this runner executes on (R531-T1, W222),
601    /// e.g. `aarch64-apple-darwin`. Detected once at construction via
602    /// [`crate::platform::detect_host_triple`] and threaded into the plan
603    /// context — the GHA executor's `runner.{os,arch}` for workflow steps,
604    /// and (once F2/F3 land) the `host` leg of each step's `Platform` triple
605    /// that `resolve(host, target, container_platform)` reasons over. Override
606    /// via [`Self::with_host_triple`] when the execution host differs from the
607    /// process host (e.g. a remote runner whose triple the daemon knows).
608    host_triple: String,
609    /// Which host-native cross toolchains are installed (R531-T6, W222).
610    /// Probed lazily on first use ([`Self::cross_availability`]) so building a
611    /// runner shells out nothing; seedable via [`Self::with_cross_availability`]
612    /// for tests and for a daemon that knows a remote runner's toolchain set.
613    /// Consumed when a NativeCross step's argv is rewritten onto cargo-zigbuild
614    /// / musl-cross (F5's [`crate::nativecross::plan_native_cross`]).
615    cross_availability: std::sync::OnceLock<crate::nativecross::ToolAvailability>,
616    /// Host-detected toolchain versions for the plan-time pinning check (R507,
617    /// W208). Probed lazily ([`Self::host_toolchains`]) — a runner whose
618    /// pipeline declares no `[toolchain]` pins never shells out — and seedable
619    /// via [`Self::with_host_toolchains`] for tests and for a daemon that knows
620    /// a remote runner's installed versions. Maps pin key → detected version
621    /// (`None` = tool absent on host).
622    host_toolchains: std::sync::OnceLock<std::collections::HashMap<String, Option<String>>>,
623    /// Registry of vendor release adapters (R509) dispatched by
624    /// [`Outcome::Provider`]. Empty by default — the CLI / daemon construction
625    /// sites wire the built-in set via [`Self::with_release_providers`]. An
626    /// `Outcome::Provider` naming an unregistered adapter fails with a typed
627    /// error listing the known names.
628    provider_registry: Arc<crate::provider::ProviderRegistry>,
629    /// Credential resolver passed to vendor adapters at dispatch (R509).
630    /// Defaults to an empty [`crate::provider::MapSecrets`]; production wires
631    /// [`crate::secrets_bridge::SecretsConfig`] over the vault via
632    /// [`Self::with_release_providers`].
633    secrets: Arc<dyn crate::provider::SecretSource>,
634    /// Target git ref for this run (W224, R330-B27) — a branch, tag, or SHA;
635    /// `git checkout` / `git worktree add` already accept any committish, so
636    /// workspace mode (Live/Checkout/Isolated) and ref kind are orthogonal
637    /// axes (don't add a 4th mode for this — fix the ref parameter). Drives
638    /// how the runner positions the workspace before a `gha-workflow` step
639    /// runs, per the pipeline's [`WorkspaceMode`](crate::types::WorkspaceMode).
640    /// `None` ⇒ `HEAD` — build the commit that's already checked out, never
641    /// silently jump to `main` (that would ship the wrong bytes for a
642    /// tag-triggered release). Set by the launch surface (`yah qed run
643    /// --ref`, the QED-tab selector).
644    git_ref: Option<String>,
645    /// The on-disk tree this run actually builds against, positioned once at
646    /// run start per the pipeline's [`WorkspaceMode`] + target ref (W224
647    /// R533-F11). Set by [`Self::run_inner`] before any step executes; every
648    /// step kind then resolves its root through [`Self::resolve_camp_root`],
649    /// which prefers this. Unset until positioned (and on child runners, which
650    /// inherit the parent's already-positioned tree via `camp_root`). For
651    /// `Isolated` mode this is the throwaway worktree path — so a subprocess
652    /// `desktop-release` step builds from the same worktree as the run's
653    /// `gha-workflow` step, not the live camp root.
654    positioned_workspace: std::sync::OnceLock<std::path::PathBuf>,
655}
656
657/// Default [`SubPipelineResolver`] for [`PipelineRunner`] — returns `None`
658/// for every target. Production callers replace it with a
659/// [`PipelineLoader`]-backed resolver via
660/// [`PipelineRunner::with_sub_pipeline_resolver`]; tests pass an
661/// in-memory map. Keeping the default a no-op means a runner with no
662/// SubPipeline steps requires no extra configuration.
663struct NoopSubPipelineResolver;
664
665impl crate::types::SubPipelineResolver for NoopSubPipelineResolver {
666    fn resolve(&self, _target: &crate::types::SubPipelineRef) -> Option<Pipeline> {
667        None
668    }
669}
670
671/// A spawned background sidecar step (R513-F2, W207 Gap #4) being tracked by
672/// [`PipelineRunner::run_inner`] until it is reaped — either when its
673/// `background_until` gate step finishes or at the end of the step loop.
674///
675/// The `join` handle owns the running subprocess future; aborting it drops the
676/// future, which drops the `tokio::process::Child` (spawned with
677/// `kill_on_drop(true)`), which kills the process. That is the whole
678/// reap-on-cancellation story: even an early `return` out of `run_inner` (a
679/// foreground error, or the whole run future being cancelled by `qed.cancel`)
680/// drops this Vec and tears down every live sidecar.
681struct BackgroundTask {
682    /// Index into `run_inner`'s `step_statuses` Vec for the placeholder
683    /// `Running` row, finalized in place at reap.
684    status_index: usize,
685    /// Event index (with offset) for the deferred `StepFinished` emit.
686    event_index: usize,
687    name: String,
688    /// Step name after which to reap; `None` ⇒ reap at end of the loop.
689    until: Option<String>,
690    join: tokio::task::JoinHandle<Result<(), RunnerError>>,
691}
692
693/// Reap one background sidecar (R513-F2), returning its terminal status.
694///
695/// - Still running at reap → abort (kill) → [`RunStatus::Success`]: a healthy
696///   sidecar torn down on schedule is the expected lifecycle, not a failure.
697/// - Already exited on its own with code 0 → `Success`.
698/// - Already exited non-zero (or panicked) → [`RunStatus::Failed`] with the
699///   failure tail: a sidecar that dies mid-pipeline is a genuine problem.
700async fn reap_background(
701    join: tokio::task::JoinHandle<Result<(), RunnerError>>,
702) -> (RunStatus, Option<String>) {
703    if join.is_finished() {
704        match join.await {
705            Ok(Ok(())) => (RunStatus::Success, None),
706            Ok(Err(e)) => {
707                let msg = match e {
708                    RunnerError::StepFailed { msg, .. } => Some(msg),
709                    RunnerError::InvalidConfig(m) => Some(m),
710                    other => Some(other.to_string()),
711                };
712                (RunStatus::Failed, msg)
713            }
714            Err(join_err) => (
715                RunStatus::Failed,
716                Some(format!("background task panicked: {join_err}")),
717            ),
718        }
719    } else {
720        join.abort();
721        let _ = join.await;
722        (RunStatus::Success, None)
723    }
724}
725
726impl PipelineRunner {
727    /// Local execution — steps run as subprocesses on this machine.
728    pub fn new(pipeline: Pipeline) -> Self {
729        let run_id = Uuid::new_v4().to_string();
730        Self {
731            pipeline,
732            run_id,
733            remote_driver: None,
734            run_where: RunWhere::Local,
735            outcome_dispatcher: Arc::new(LoggingOutcomeDispatcher),
736            events: None,
737            camp_root: None,
738            signer: Arc::new(LoggingSigner),
739            executor: Arc::new(LocalForgeDriver::new()),
740            sub_pipeline_resolver: Arc::new(NoopSubPipelineResolver),
741            suppress_publish_outcomes: false,
742            parent_run_id: None,
743            index_offset: 0,
744            gha_matrix_subset: std::collections::HashMap::new(),
745            include_stubbed: false,
746            matrix_coord: None,
747            host_triple: crate::platform::detect_host_triple(),
748            cross_availability: std::sync::OnceLock::new(),
749            host_toolchains: std::sync::OnceLock::new(),
750            provider_registry: Arc::new(crate::provider::ProviderRegistry::new()),
751            secrets: Arc::new(crate::provider::MapSecrets::default()),
752            git_ref: None,
753            positioned_workspace: std::sync::OnceLock::new(),
754        }
755    }
756
757    /// Local execution with a custom outcome dispatcher.
758    pub fn new_with_dispatcher(pipeline: Pipeline, dispatcher: Arc<dyn OutcomeDispatcher>) -> Self {
759        let run_id = Uuid::new_v4().to_string();
760        Self {
761            pipeline,
762            run_id,
763            remote_driver: None,
764            run_where: RunWhere::Local,
765            outcome_dispatcher: dispatcher,
766            events: None,
767            camp_root: None,
768            signer: Arc::new(LoggingSigner),
769            executor: Arc::new(LocalForgeDriver::new()),
770            sub_pipeline_resolver: Arc::new(NoopSubPipelineResolver),
771            suppress_publish_outcomes: false,
772            parent_run_id: None,
773            index_offset: 0,
774            gha_matrix_subset: std::collections::HashMap::new(),
775            include_stubbed: false,
776            matrix_coord: None,
777            host_triple: crate::platform::detect_host_triple(),
778            cross_availability: std::sync::OnceLock::new(),
779            host_toolchains: std::sync::OnceLock::new(),
780            provider_registry: Arc::new(crate::provider::ProviderRegistry::new()),
781            secrets: Arc::new(crate::provider::MapSecrets::default()),
782            git_ref: None,
783            positioned_workspace: std::sync::OnceLock::new(),
784        }
785    }
786
787    /// Attach a live-event sink (R325-F2). Composes with any constructor:
788    /// `PipelineRunner::new(p).with_events(tx)`. The runner emits a
789    /// [`QedEvent`] for run start, each step start, every stdout/stderr line,
790    /// each step finish, and run finish. Send failures (no receiver) are
791    /// ignored — events are best-effort and never block the run.
792    pub fn with_events(mut self, sink: UnboundedSender<QedEvent>) -> Self {
793        self.events = Some(sink);
794        self
795    }
796
797    /// Override the [`OutcomeDispatcher`]. Composes with any constructor —
798    /// notably [`new_remote`](Self::new_remote), which defaults to the
799    /// log-only dispatcher, so a remote run can share the same publishing
800    /// dispatcher the local in-process path uses (R590-F2).
801    pub fn with_dispatcher(mut self, dispatcher: Arc<dyn OutcomeDispatcher>) -> Self {
802        self.outcome_dispatcher = dispatcher;
803        self
804    }
805
806    /// Override the camp root used to resolve per-camp catalog overrides and
807    /// the BuildKit cache + OCI archive output directories. Production
808    /// callers leave this unset (falls back to [`std::env::current_dir`]);
809    /// tests pass a tempdir so generated `.yah/cache/` files don't leak into
810    /// the workspace.
811    pub fn with_camp_root(mut self, root: std::path::PathBuf) -> Self {
812        self.camp_root = Some(root);
813        self
814    }
815
816    /// Set the target git ref for this run (W224, R330-B27) — a branch, tag,
817    /// or commit SHA; `git checkout` / `git worktree add` accept any
818    /// committish. Drives workspace positioning for `gha-workflow` steps per
819    /// the pipeline's [`WorkspaceMode`](crate::types::WorkspaceMode). `None` /
820    /// unset ⇒ `HEAD` (whatever is already checked out). Composes with any
821    /// constructor.
822    pub fn with_ref(mut self, r#ref: Option<String>) -> Self {
823        self.git_ref = r#ref.filter(|r| !r.trim().is_empty());
824        self
825    }
826
827    /// The run's effective target ref — the requested ref, or `HEAD` (build
828    /// the commit that's already checked out; never silently jump to `main`,
829    /// which would build the wrong bytes for a tag-triggered release).
830    fn target_ref(&self) -> &str {
831        self.git_ref.as_deref().unwrap_or("HEAD")
832    }
833
834    /// Attach a Sigstore signer (R407-T5). Composes with any constructor:
835    /// `PipelineRunner::new(p).with_signer(Arc::new(CosignSigner::default()))`.
836    /// Release pipelines MUST call this with a real signer; the default
837    /// [`LoggingSigner`] writes placeholders so local `yah qed run` flows
838    /// don't fail when cosign isn't on PATH.
839    pub fn with_signer(mut self, signer: Arc<dyn SigstoreSigner>) -> Self {
840        self.signer = signer;
841        self
842    }
843
844    /// On-demand runner of `status = "stubbed"` steps (R506). When `true`,
845    /// the runner ignores the stubbed marker and runs the step normally.
846    /// `enabled = false` is still honored regardless. Composes with any
847    /// constructor: `PipelineRunner::new(p).with_include_stubbed(true)`.
848    pub fn with_include_stubbed(mut self, include: bool) -> Self {
849        self.include_stubbed = include;
850        self
851    }
852
853    /// Bind the runner to a matrix coordinate (R506). Set by the planner
854    /// when fanning a pipeline over its `[matrix]` block — the coord shows
855    /// up as `matrix.<key>` in `if=` expressions. Composes with any
856    /// constructor.
857    pub fn with_matrix_coord(mut self, coord: crate::matrix::MatrixCoord) -> Self {
858        self.matrix_coord = Some(coord);
859        self
860    }
861
862    fn resolve_camp_root(&self) -> Result<std::path::PathBuf, RunnerError> {
863        // Once a run has positioned its workspace (W224 R533-F11), every step
864        // builds against that tree — for `Isolated` the throwaway worktree, for
865        // `Checkout`/`Live` the (possibly ref-switched) camp root. This is
866        // the single seam all step kinds share, so threading it here lifts
867        // positioning from the gha-workflow step to the whole run.
868        if let Some(ws) = self.positioned_workspace.get() {
869            return Ok(ws.clone());
870        }
871        if let Some(root) = &self.camp_root {
872            return Ok(root.clone());
873        }
874        std::env::current_dir()
875            .map_err(|e| RunnerError::InvalidConfig(format!("failed to read current dir: {e}")))
876    }
877
878    /// The unpositioned camp root — `self.camp_root` (or the current dir),
879    /// *ignoring* any positioned workspace. Used by [`Self::run_inner`] to feed
880    /// [`Self::prepare_workspace`] the base tree to position from, before the
881    /// positioned workspace is set.
882    fn base_camp_root(&self) -> Result<std::path::PathBuf, RunnerError> {
883        if let Some(root) = &self.camp_root {
884            return Ok(root.clone());
885        }
886        std::env::current_dir()
887            .map_err(|e| RunnerError::InvalidConfig(format!("failed to read current dir: {e}")))
888    }
889
890    /// Position the on-disk tree this *run* builds against, per the pipeline's
891    /// [`WorkspaceMode`] and the run's target ref (W224, R330-B27). Called once
892    /// at run start (R533-F11) — every step kind (subprocess, build-image, sign,
893    /// sub-pipeline, gha-workflow) then builds from the returned tree, so an
894    /// `Isolated` release positions the whole run into one worktree rather than
895    /// only its gha-workflow step.
896    ///
897    /// Returns the effective workspace path plus an optional RAII
898    /// [`WorktreeGuard`] — held by the caller for the lifetime of the *run* so
899    /// an `Isolated` worktree outlives every step and is torn down once the run
900    /// finishes (even on a mid-run error). The dirty check considers tracked
901    /// modifications only (`--untracked-files=no`): untracked files don't change
902    /// which committed bytes a build sees and would otherwise block every run in
903    /// a working camp.
904    fn prepare_workspace(
905        &self,
906        camp_root: &std::path::Path,
907    ) -> Result<(std::path::PathBuf, Option<WorktreeGuard>), RunnerError> {
908        let git_ref = self.target_ref();
909        match self.pipeline.workspace {
910            // Build whatever is on disk — no ref switch, no dirty check.
911            WorkspaceMode::Live => Ok((camp_root.to_path_buf(), None)),
912            // Switch the camp root to the ref, but never over local edits.
913            WorkspaceMode::Checkout => {
914                if git_tree_is_dirty(camp_root)? {
915                    return Err(RunnerError::InvalidConfig(format!(
916                        "workspace mode `checkout` won't run over uncommitted changes in {} — \
917                         commit or stash them, or set the pipeline to `workspace = \"isolated\"` \
918                         (build in a throwaway worktree) or `\"live\"` (build the tree as-is)",
919                        camp_root.display()
920                    )));
921                }
922                run_git(camp_root, &["checkout", git_ref]).map_err(|e| {
923                    RunnerError::InvalidConfig(format!("git checkout {git_ref}: {e}"))
924                })?;
925                Ok((camp_root.to_path_buf(), None))
926            }
927            // Build in a dedicated worktree at the ref; camp root untouched.
928            WorkspaceMode::Isolated => {
929                let worktree = std::env::temp_dir().join(format!("qed-worktree-{}", self.run_id));
930                // A prior crashed run may have left this path registered; clear
931                // it first so `worktree add` doesn't fail on a stale entry.
932                let _ = std::process::Command::new("git")
933                    .current_dir(camp_root)
934                    .args(["worktree", "remove", "--force"])
935                    .arg(&worktree)
936                    .output();
937                run_git(
938                    camp_root,
939                    &["worktree", "add", "--force", &worktree.to_string_lossy(), git_ref],
940                )
941                .map_err(|e| {
942                    RunnerError::InvalidConfig(format!("git worktree add at {git_ref}: {e}"))
943                })?;
944                let guard = WorktreeGuard {
945                    camp_root: camp_root.to_path_buf(),
946                    worktree: worktree.clone(),
947                };
948                Ok((worktree, Some(guard)))
949            }
950        }
951    }
952
953    /// W209: evaluate every `[[bind]]` in the pipeline whose `from`
954    /// references this step's outputs, write the accepted values into the
955    /// source tree, and return the per-bind result list for surfacing in
956    /// [`StepStatus::applied_binds`].
957    ///
958    /// Build → checkin → release inversion in mechanical form: the source
959    /// tree IS the step-to-step plumbing. Downstream steps will read these
960    /// values from disk like any other tool would.
961    ///
962    /// Failures are logged at `warn` and surfaced as an empty result list
963    /// rather than poisoning the run. Per W209 § Safety the diff is the
964    /// review surface; an applier crash on one file doesn't justify
965    /// killing the pipeline (the operator can still inspect what landed
966    /// and what didn't via `git status`).
967    fn apply_step_binds(
968        &self,
969        step: &QedStep,
970        step_outputs: &std::collections::HashMap<String, String>,
971    ) -> Vec<manifest_bind::AppliedBind> {
972        // Cheap pre-filter so we don't even touch the filesystem when
973        // nothing in this pipeline binds against this step.
974        let any_match = self.pipeline.binds.iter().any(|b| match &b.from {
975            manifest_bind::OutputRef::StepOutput { step: s, .. } => s == &step.name,
976            manifest_bind::OutputRef::Uri(_) => false,
977        });
978        if !any_match {
979            return Vec::new();
980        }
981
982        let workspace_root = match self.resolve_camp_root() {
983            Ok(r) => r,
984            Err(e) => {
985                tracing::warn!(
986                    step = %step.name,
987                    error = %e,
988                    "skipping [[bind]] application: cannot resolve workspace root",
989                );
990                return Vec::new();
991            }
992        };
993
994        // Build a single-step OutputMap. Each declared output carries its
995        // typed shape; undeclared keys default to `String` (matches
996        // OutputDecl::kind's serde default) so back-compat steps from
997        // R488-F4 still flow through — the per-bind type check stays the
998        // hard boundary.
999        let mut outputs = manifest_bind::OutputMap::new();
1000        for (key, raw) in step_outputs {
1001            let kind = step
1002                .outputs
1003                .iter()
1004                .find(|o| &o.name == key)
1005                .map(|o| o.kind)
1006                .unwrap_or(manifest_bind::ValueType::String);
1007            outputs.insert(
1008                step.name.clone(),
1009                key.clone(),
1010                manifest_bind::OutputValue::new(kind, raw.clone()),
1011            );
1012        }
1013
1014        // Scope to binds that fire from this step. apply_binds itself
1015        // already filters by `outputs.lookup(&bind.from).is_some()`, but
1016        // doing it here avoids touching files that bind only from other
1017        // steps and keeps the AppliedBind list scoped to the step that
1018        // caused the writes.
1019        let relevant: Vec<manifest_bind::BindSpec> = self
1020            .pipeline
1021            .binds
1022            .iter()
1023            .filter(|b| {
1024                matches!(
1025                    &b.from,
1026                    manifest_bind::OutputRef::StepOutput { step: s, .. } if s == &step.name
1027                )
1028            })
1029            .cloned()
1030            .collect();
1031
1032        match manifest_bind::apply_binds(&outputs, &relevant, &workspace_root) {
1033            Ok(applied) => {
1034                for a in &applied {
1035                    if a.changed {
1036                        tracing::info!(
1037                            step = %step.name,
1038                            file = %a.file.display(),
1039                            path = %a.path,
1040                            from = %a.from,
1041                            "bound output → manifest (changed)",
1042                        );
1043                    }
1044                }
1045                // W209/R510-F6: fire hash-change hooks after the bind
1046                // transaction has committed, for binds that actually changed.
1047                self.fire_change_hooks(&step.name, &applied, &workspace_root);
1048                applied
1049            }
1050            Err(e) => {
1051                tracing::warn!(
1052                    step = %step.name,
1053                    error = %e,
1054                    "manifest-bind apply failed; downstream steps will read pre-bind values",
1055                );
1056                Vec::new()
1057            }
1058        }
1059    }
1060
1061    /// W209/R510-F6: evaluate every `[[on_change]]` hook against the binds
1062    /// this step just committed and perform each matching hook's side effect.
1063    /// Only binds that actually changed bytes fire (the no-op idempotency
1064    /// guarantee lives in [`manifest_bind::fired_hooks`]). `journal` / `event`
1065    /// actions commit to disk inside `dispatch_hook`; `pipeline` actions are
1066    /// surfaced as a logged request — v1 does not auto-cascade pipelines (the
1067    /// reserved `rebind_stop` guard is the design's bound on cascade storms),
1068    /// so the operator enqueues the downstream pipeline explicitly.
1069    ///
1070    /// A hook dispatch failure is logged at `warn` and never poisons the run,
1071    /// mirroring the bind applier's own failure stance (W209 § Safety): the
1072    /// in-tree bind result is the source of truth; the hook is a downstream
1073    /// side effect.
1074    fn fire_change_hooks(
1075        &self,
1076        step_name: &str,
1077        applied: &[manifest_bind::AppliedBind],
1078        workspace_root: &std::path::Path,
1079    ) {
1080        if self.pipeline.on_change.is_empty() {
1081            return;
1082        }
1083        for fired in manifest_bind::fired_hooks(&self.pipeline.on_change, applied) {
1084            match manifest_bind::dispatch_hook(&fired, workspace_root) {
1085                Ok(manifest_bind::HookOutcome::Journaled { file }) => tracing::info!(
1086                    step = %step_name,
1087                    bind = %fired.bind,
1088                    journal = %file.display(),
1089                    "on_change: appended journal line",
1090                ),
1091                Ok(manifest_bind::HookOutcome::EventEmitted { file, kind }) => tracing::info!(
1092                    step = %step_name,
1093                    bind = %fired.bind,
1094                    event = %kind,
1095                    sink = %file.display(),
1096                    "on_change: emitted event",
1097                ),
1098                Ok(manifest_bind::HookOutcome::PipelineRequested { pipeline, params }) => {
1099                    tracing::info!(
1100                        step = %step_name,
1101                        bind = %fired.bind,
1102                        pipeline = %pipeline,
1103                        params = ?params,
1104                        "on_change: pipeline requested (v1 does not auto-cascade — \
1105                         operator enqueues `yah qed run` explicitly)",
1106                    )
1107                }
1108                Err(e) => tracing::warn!(
1109                    step = %step_name,
1110                    bind = %fired.bind,
1111                    error = %e,
1112                    "on_change: hook dispatch failed (bind result stands; hook skipped)",
1113                ),
1114            }
1115        }
1116    }
1117
1118    /// R506: determine whether a step should be skipped, and why. Returns
1119    /// `Some(human-readable reason)` to skip, `None` to dispatch normally.
1120    ///
1121    /// Precedence (declarative gates run before runtime ones, since they
1122    /// can't observe step outputs):
1123    ///   1. `enabled = false` — always wins, even when `include_stubbed`.
1124    ///   2. `activation = "stubbed"` and `!include_stubbed`.
1125    ///   3. `if = "<expr>"` evaluates to a falsy value against the W201-F4
1126    ///      context (matrix coord + accumulated step outputs + env).
1127    ///
1128    /// An `if` expression that fails to parse is treated as falsy with a
1129    /// descriptive reason so the dashboard surfaces the syntax error rather
1130    /// than the runner crashing the whole pipeline mid-run.
1131    fn resolve_skip_reason(
1132        &self,
1133        step: &crate::types::QedStep,
1134        step_context: &std::collections::HashMap<String, std::collections::HashMap<String, String>>,
1135        running_status: RunStatus,
1136    ) -> Option<String> {
1137        if !step.enabled {
1138            return Some("skipped: enabled = false".to_string());
1139        }
1140        if matches!(step.activation, StepActivation::Stubbed) && !self.include_stubbed {
1141            return Some(
1142                "skipped: status = \"stubbed\" (pass --include-stubbed to run anyway)".to_string(),
1143            );
1144        }
1145        if let Some(raw) = step.if_cond.as_deref() {
1146            let body = strip_expr_delimiters(raw);
1147            let ctx = self.build_expr_context(step_context, running_status);
1148            return match yah_qed_gha::evaluate(body, &ctx) {
1149                Ok(v) if v.is_truthy() => None,
1150                Ok(_) => Some(format!("skipped: if = \"{raw}\" evaluated falsy")),
1151                Err(e) => Some(format!("skipped: if = \"{raw}\" parse error: {e}")),
1152            };
1153        }
1154        None
1155    }
1156
1157    /// Map the runner's running aggregate [`RunStatus`] onto the GHA-shaped
1158    /// [`yah_qed_gha::JobStatus`] consumed by `success()`/`failure()`/`always()`/
1159    /// `cancelled()` context functions. The runner has no mid-flight
1160    /// `Cancelled` state (cancel arrives via the abort handle and aborts the
1161    /// whole future), so only `Success` and `Failure` are reachable here —
1162    /// `cancelled()` therefore always evaluates to false from inside a step's
1163    /// `if=` expression, matching GHA semantics where a cancelled job never
1164    /// reaches the next step's gate.
1165    fn running_job_status(status: RunStatus) -> yah_qed_gha::JobStatus {
1166        match status {
1167            RunStatus::Failed => yah_qed_gha::JobStatus::Failure,
1168            _ => yah_qed_gha::JobStatus::Success,
1169        }
1170    }
1171
1172    /// Build the [`yah_qed_gha::Context`] passed to `if=` evaluation. Populates:
1173    ///   - `matrix` from [`Self::matrix_coord`]
1174    ///   - `steps.<name>.outputs.<key>` from the accumulated step context
1175    ///   - `env` from the current process environment
1176    ///   - `job_status` from the cumulative `RunStatus` so
1177    ///     `success()`/`failure()`/`always()`/`cancelled()` reflect the
1178    ///     running aggregate at the moment this step is gated
1179    fn build_expr_context(
1180        &self,
1181        step_context: &std::collections::HashMap<String, std::collections::HashMap<String, String>>,
1182        running_status: RunStatus,
1183    ) -> yah_qed_gha::Context<'static> {
1184        use indexmap::IndexMap;
1185        let mut ctx = yah_qed_gha::Context::new();
1186
1187        // env: process env
1188        let mut env_obj: IndexMap<String, yah_qed_gha::Value> = IndexMap::new();
1189        for (k, v) in std::env::vars() {
1190            env_obj.insert(k, yah_qed_gha::Value::String(v));
1191        }
1192        ctx.env = yah_qed_gha::Value::Object(env_obj);
1193
1194        // matrix: from runner coord (None → leave as None so matrix.<key> → Null)
1195        if let Some(coord) = &self.matrix_coord {
1196            let mut m: IndexMap<String, yah_qed_gha::Value> = IndexMap::new();
1197            for (k, v) in coord {
1198                m.insert(
1199                    k.clone(),
1200                    yah_qed_gha::Value::String(crate::matrix::toml_value_to_str(v)),
1201                );
1202            }
1203            ctx.matrix = Some(yah_qed_gha::Value::Object(m));
1204        }
1205
1206        // steps.<name>.outputs.<key>
1207        let mut steps_obj: IndexMap<String, yah_qed_gha::Value> = IndexMap::new();
1208        for (name, outputs) in step_context {
1209            let mut out_map: IndexMap<String, yah_qed_gha::Value> = IndexMap::new();
1210            for (k, v) in outputs {
1211                out_map.insert(k.clone(), yah_qed_gha::Value::String(v.clone()));
1212            }
1213            let mut step_obj: IndexMap<String, yah_qed_gha::Value> = IndexMap::new();
1214            step_obj.insert("outputs".to_string(), yah_qed_gha::Value::Object(out_map));
1215            steps_obj.insert(name.clone(), yah_qed_gha::Value::Object(step_obj));
1216        }
1217        ctx.steps = yah_qed_gha::Value::Object(steps_obj);
1218
1219        ctx.job_status = Some(Self::running_job_status(running_status));
1220
1221        ctx
1222    }
1223
1224    /// Emit one event to the sink if attached. A closed receiver is a no-op.
1225    fn emit(&self, event: QedEvent) {
1226        if let Some(tx) = &self.events {
1227            let _ = tx.send(event);
1228        }
1229    }
1230
1231    /// Pick the sandboxing runtime for a step.  Explicit `step.runtime`
1232    /// always wins; otherwise default by location (R380-T3):
1233    ///
1234    /// | --where  | runtime |
1235    /// |----------|---------|
1236    /// | local    | Native    |
1237    /// | remote   | Container |
1238    ///
1239    /// The CLI's `--runtime native|container` override is applied by mutating
1240    /// each step's `runtime` field *before* the runner is constructed, so by
1241    /// the time this method runs the per-step value already reflects the
1242    /// CLI choice (TOML-declared values still win over CLI defaults).
1243    fn resolve_runtime(&self, step: &crate::types::QedStep) -> TaskRuntime {
1244        // build-image steps are always Container — parse-time validation
1245        // already rejects explicit `runtime = "native"`, this catches the
1246        // implicit `runtime = None` case where the local default would
1247        // otherwise resolve to Native.
1248        if matches!(step.kind, crate::types::StepKind::BuildImage) {
1249            return TaskRuntime::Container;
1250        }
1251        // package-native-tarball is always Native — it's pure host file I/O
1252        // (read binary, write tar.gz). Parse-time rejects `runtime =
1253        // "container"`; force Native here so the implicit `None` doesn't
1254        // resolve to Container on a Remote runner.
1255        if matches!(step.kind, crate::types::StepKind::PackageNativeTarball) {
1256            return TaskRuntime::Native;
1257        }
1258        // musl-static-preflight shells `cargo metadata` on the host — same
1259        // reasoning as package-native-tarball, always Native.
1260        if matches!(step.kind, crate::types::StepKind::MuslStaticPreflight) {
1261            return TaskRuntime::Native;
1262        }
1263        // sign-native-tarball shells `cosign sign-blob` on the host (and
1264        // writes the .sig/.crt/.bundle next to the artifact). Parse-time
1265        // rejects `runtime = "container"`; force Native here so the implicit
1266        // `None` doesn't resolve to Container on a Remote runner.
1267        if matches!(step.kind, crate::types::StepKind::SignNativeTarball) {
1268            return TaskRuntime::Native;
1269        }
1270        // manifest-stitch shells `docker buildx imagetools create` on the host —
1271        // a registry-only op (R590-F2). It runs where qed runs even under
1272        // `--where=remote` (the per-arch builds fan out to the fleet; the stitch
1273        // does not), so force Native so the implicit `None` doesn't resolve to
1274        // Container on a Remote runner.
1275        if matches!(step.kind, crate::types::StepKind::ManifestStitch) {
1276            return TaskRuntime::Native;
1277        }
1278        // R590-F4: default runtime follows the step's *effective* placement, not
1279        // the raw run_where — an Auto runner that offloads a step to the fleet
1280        // must default it to Container (it runs remote), while its local steps
1281        // stay Native. For a forced Local/Remote runner effective_placement is a
1282        // constant, so this is byte-identical to the pre-F4 default.
1283        step.runtime.unwrap_or(match self.effective_placement(step) {
1284            RunWhere::Remote => TaskRuntime::Container,
1285            // Local, and the unreachable Auto (effective_placement resolves it).
1286            RunWhere::Local | RunWhere::Auto => TaskRuntime::Native,
1287        })
1288    }
1289
1290    /// Remote execution — steps run as `task::remote` workloads dispatched via
1291    /// the provided `WardenClient`.
1292    pub fn new_remote(
1293        pipeline: Pipeline,
1294        scryer: Arc<Scryer>,
1295        yubaba: Arc<dyn WardenClient>,
1296    ) -> Self {
1297        let run_id = Uuid::new_v4().to_string();
1298        let remote_driver = Arc::new(RemoteForgeDriver::new(scryer, yubaba));
1299        Self {
1300            pipeline,
1301            run_id,
1302            remote_driver: Some(remote_driver),
1303            run_where: RunWhere::Remote,
1304            outcome_dispatcher: Arc::new(LoggingOutcomeDispatcher),
1305            events: None,
1306            camp_root: None,
1307            signer: Arc::new(LoggingSigner),
1308            executor: Arc::new(LocalForgeDriver::new()),
1309            sub_pipeline_resolver: Arc::new(NoopSubPipelineResolver),
1310            suppress_publish_outcomes: false,
1311            parent_run_id: None,
1312            index_offset: 0,
1313            gha_matrix_subset: std::collections::HashMap::new(),
1314            include_stubbed: false,
1315            matrix_coord: None,
1316            host_triple: crate::platform::detect_host_triple(),
1317            cross_availability: std::sync::OnceLock::new(),
1318            host_toolchains: std::sync::OnceLock::new(),
1319            provider_registry: Arc::new(crate::provider::ProviderRegistry::new()),
1320            secrets: Arc::new(crate::provider::MapSecrets::default()),
1321            git_ref: None,
1322            positioned_workspace: std::sync::OnceLock::new(),
1323        }
1324    }
1325
1326    /// Policy-derived execution (R590-F4, the default `yah qed run` mode). Like
1327    /// [`new_remote`](Self::new_remote) it wires a fleet dispatcher, but leaves
1328    /// placement on [`RunWhere::Auto`]: local steps run as local subprocesses and
1329    /// only a `native = true` cross-arch step (resolving to
1330    /// [`Offload`](crate::platform::Resolution::Offload)) is dispatched to an
1331    /// arch-matched build-worker — no `--where=remote` flag required. The CLI
1332    /// stands this up only when the pipeline actually needs offload (see
1333    /// [`pipeline_needs_offload`]); a pipeline with no offload step stays on the
1334    /// driverless local path.
1335    pub fn new_auto(
1336        pipeline: Pipeline,
1337        scryer: Arc<Scryer>,
1338        yubaba: Arc<dyn WardenClient>,
1339    ) -> Self {
1340        let mut runner = Self::new_remote(pipeline, scryer, yubaba);
1341        runner.run_where = RunWhere::Auto;
1342        runner
1343    }
1344
1345    /// Attach a custom [`ForgeExecutor`] for local subprocess steps
1346    /// (R438-T14). Composes with any constructor. The default is
1347    /// [`LocalForgeDriver`]; callers override to share a configured driver
1348    /// across multiple runs.
1349    pub fn with_executor(mut self, executor: Arc<dyn ForgeExecutor>) -> Self {
1350        self.executor = executor;
1351        self
1352    }
1353
1354    /// Attach a [`SubPipelineResolver`](crate::types::SubPipelineResolver)
1355    /// for `kind = "sub-pipeline"` steps (R488-F2). Composes with any
1356    /// constructor. The default resolver returns `None` for every target —
1357    /// any SubPipeline step will fail with a clear "no resolver configured"
1358    /// message until this is called. Production callers pass a
1359    /// [`PipelineLoader`](crate::config::PipelineLoader)-backed resolver;
1360    /// tests pass an in-memory map.
1361    pub fn with_sub_pipeline_resolver(
1362        mut self,
1363        resolver: Arc<dyn crate::types::SubPipelineResolver + Send + Sync>,
1364    ) -> Self {
1365        self.sub_pipeline_resolver = resolver;
1366        self
1367    }
1368
1369    /// Wire the vendor release-provider registry + credential source (R509)
1370    /// used to dispatch [`Outcome::Provider`] outcomes (notarize, authenticode,
1371    /// sparkle, …). Composes with any constructor and is inherited by
1372    /// SubPipeline children. The defaults are an empty registry + empty
1373    /// secrets, so a pipeline with no vendor outcomes needs no wiring; a
1374    /// pipeline that *does* declare one fails with a typed unknown-provider
1375    /// error until this is called with a populated registry
1376    /// ([`crate::provider::ProviderRegistry::production`]).
1377    pub fn with_release_providers(
1378        mut self,
1379        registry: Arc<crate::provider::ProviderRegistry>,
1380        secrets: Arc<dyn crate::provider::SecretSource>,
1381    ) -> Self {
1382        self.provider_registry = registry;
1383        self.secrets = secrets;
1384        self
1385    }
1386
1387    /// Offset added to every emitted step `index`. Use this when the
1388    /// pipeline's leading steps were drained for a resume-from-step run so
1389    /// that events still carry the original positions (e.g. step 5 of 6
1390    /// instead of step 0 of 1 after a `drain(0..5)`).
1391    pub fn with_index_offset(mut self, offset: usize) -> Self {
1392        self.index_offset = offset;
1393        self
1394    }
1395
1396    /// R499-F3 phase 2: per-step gha-workflow matrix subset. Each entry
1397    /// maps a qed step name to the chosen instance keys (see
1398    /// [`yah_qed_gha::graph::JobInstance::key`]). Steps absent from the map
1399    /// run their full matrix. Inherited by SubPipeline children.
1400    pub fn with_gha_matrix_subset(
1401        mut self,
1402        subset: std::collections::HashMap<String, std::collections::HashSet<String>>,
1403    ) -> Self {
1404        self.gha_matrix_subset = subset;
1405        self
1406    }
1407
1408    /// Override the self-detected host triple (R531-T1). Constructors default
1409    /// to [`crate::platform::detect_host_triple`] (the process host); callers
1410    /// that know the execution host differs — e.g. a daemon constructing a
1411    /// runner whose steps will land on a remote runner of a known triple —
1412    /// set it explicitly. Composes with any constructor.
1413    pub fn with_host_triple(mut self, triple: impl Into<String>) -> Self {
1414        self.host_triple = triple.into();
1415        self
1416    }
1417
1418    /// Seed the host-native cross-toolchain availability (R531-T6) instead of
1419    /// probing it. Tests use this to drive the NativeCross rewrite
1420    /// deterministically; a daemon constructing a runner for a remote host of a
1421    /// known toolchain set uses it to avoid a wrong local probe. Composes with
1422    /// any constructor; takes effect only if set before the first
1423    /// [`Self::cross_availability`] read.
1424    pub fn with_cross_availability(self, avail: crate::nativecross::ToolAvailability) -> Self {
1425        // OnceLock::set errors only if already initialized; a builder call
1426        // before any step runs is always first, so ignore the result.
1427        let _ = self.cross_availability.set(avail);
1428        self
1429    }
1430
1431    /// The host-native cross toolchains installed on this runner (R531-T6),
1432    /// probed once and cached. The lazy half of the F5/T6 wiring: a runner with
1433    /// no NativeCross-tier step never calls this, so it never shells out.
1434    fn cross_availability(&self) -> crate::nativecross::ToolAvailability {
1435        *self
1436            .cross_availability
1437            .get_or_init(crate::nativecross::ToolAvailability::probe)
1438    }
1439
1440    /// The host triple this runner executes on (R531-T1, W222), e.g.
1441    /// `aarch64-apple-darwin`. Threaded into the GHA `runner.{os,arch}`
1442    /// context and (F2/F3) the `host` leg of each step's `Platform` triple.
1443    pub fn host_triple(&self) -> &str {
1444        &self.host_triple
1445    }
1446
1447    /// Seed the host's detected toolchain versions (R507, W208) instead of
1448    /// probing them. Tests drive the plan-time pinning check deterministically
1449    /// with this; a daemon constructing a runner for a remote host of a known
1450    /// toolchain set uses it to avoid a wrong local probe. Maps pin key →
1451    /// detected version (`None` = tool absent). Takes effect only if set before
1452    /// the first [`Self::host_toolchains`] read.
1453    pub fn with_host_toolchains(
1454        self,
1455        detected: std::collections::HashMap<String, Option<String>>,
1456    ) -> Self {
1457        let _ = self.host_toolchains.set(detected);
1458        self
1459    }
1460
1461    /// The host's detected toolchain versions, probed once and cached (R507).
1462    /// The lazy half of the pinning check: a runner whose pipeline declares no
1463    /// `[toolchain]` pins never calls this, so it never shells out. Probes only
1464    /// the tools actually named across the pipeline + step pins.
1465    fn host_toolchains(&self) -> &std::collections::HashMap<String, Option<String>> {
1466        self.host_toolchains.get_or_init(|| {
1467            let mut keys: Vec<&str> = Vec::new();
1468            if let Some(tc) = &self.pipeline.toolchain {
1469                keys.extend(tc.pins.keys().map(String::as_str));
1470            }
1471            for step in &self.pipeline.steps {
1472                if let Some(tc) = &step.toolchain {
1473                    keys.extend(tc.pins.keys().map(String::as_str));
1474                }
1475            }
1476            crate::toolchain::detect_host_versions(keys)
1477        })
1478    }
1479
1480    /// Whether a step's toolchain is provided by a container image rather than
1481    /// the host (R507, W208). A step that pulls an explicit `image` or pins
1482    /// `runtime = "container"` delegates its toolchain to that image, so the
1483    /// host-side pin check is skipped. Host-native steps (the default) are
1484    /// checked against the host's installed versions.
1485    fn step_satisfied_by_image(&self, step: &crate::types::QedStep) -> bool {
1486        step.image.is_some() || matches!(step.runtime, Some(TaskRuntime::Container))
1487    }
1488
1489    /// Plan-time toolchain pinning check (R507, W208 pillar 3): for every step,
1490    /// overlay its `toolchain.*` overrides onto the pipeline-level
1491    /// `[toolchain]` pins, then resolve each pin against the host's detected
1492    /// versions (or mark it image-provided). Pure given the (seeded or probed)
1493    /// host versions — builds the verdict from the static pipeline, runs
1494    /// nothing. The runner gates `run()` on
1495    /// [`ToolchainPreflight::is_satisfied`](crate::toolchain::ToolchainPreflight::is_satisfied)
1496    /// and fails fast with its error report.
1497    pub fn toolchain_preflight(&self) -> crate::toolchain::ToolchainPreflight {
1498        let host = self.host_toolchains();
1499        let mut entries = Vec::new();
1500        for step in &self.pipeline.steps {
1501            let pins = crate::toolchain::effective_pins(
1502                self.pipeline.toolchain.as_ref(),
1503                step.toolchain.as_ref(),
1504            );
1505            if pins.is_empty() {
1506                continue;
1507            }
1508            let by_image = self.step_satisfied_by_image(step);
1509            for (tool, want) in &pins {
1510                let detected = host.get(tool).and_then(|v| v.as_deref());
1511                let resolution = crate::toolchain::resolve_pin(tool, want, detected, by_image);
1512                entries.push(crate::toolchain::PreflightEntry {
1513                    step: step.name.clone(),
1514                    resolution,
1515                });
1516            }
1517        }
1518        crate::toolchain::ToolchainPreflight { entries }
1519    }
1520
1521    /// Compose a step's full [`Platform`](crate::platform::Platform) triple-set
1522    /// (R531-F2, W222): this runner's self-detected `host`, the step's declared
1523    /// `target` (its `[platform].target`, falling back to the legacy per-kind
1524    /// `triple` field), and the `container_platform` it pulls. This is the
1525    /// value F3's `resolve(host, target, container_platform)` decision table
1526    /// reasons over.
1527    pub fn step_platform(&self, step: &crate::types::QedStep) -> crate::platform::Platform {
1528        crate::platform::Platform::compose(
1529            &self.host_triple,
1530            step.platform.as_ref(),
1531            step.triple.as_deref(),
1532        )
1533    }
1534
1535    /// Resolve how a step's build is satisfied on this runner's host (R531-F3,
1536    /// W222): compose its [`Platform`](crate::platform::Platform) triple-set,
1537    /// then run the cross-first decision table. Feeds the T4 portability
1538    /// preflight and (P2) the container-seam wiring.
1539    pub fn resolve_step(&self, step: &crate::types::QedStep) -> crate::platform::Resolution {
1540        let p = self.step_platform(step);
1541        // R590-F4: thread the step's `native` flag so a `native = true` cross-arch
1542        // build resolves to Offload (real silicon) rather than NativeCross/Emulate.
1543        let native = step.platform.as_ref().map(|s| s.native).unwrap_or(false);
1544        crate::platform::resolve_placement(
1545            &p.host,
1546            p.target.as_deref(),
1547            p.container_platform.as_deref(),
1548            native,
1549        )
1550    }
1551
1552    /// Concrete placement for a step (R590-F4): fold this runner's `--where`
1553    /// force-mode with the step's [`resolve_step`](Self::resolve_step) verdict.
1554    /// Returns [`Local`](RunWhere::Local) or [`Remote`](RunWhere::Remote) only
1555    /// (never `Auto`). For a `Local`/`Remote` runner this is a constant — every
1556    /// step follows the forced mode, preserving the pre-F4 all-local / all-remote
1557    /// behaviour — so only an `Auto` runner routes per-step.
1558    fn effective_placement(&self, step: &crate::types::QedStep) -> RunWhere {
1559        match self.run_where {
1560            // Fast path: a forced runner never inspects the step, so we skip the
1561            // resolve() work (and keep the many resolve_runtime test callers on
1562            // Local/Remote runners resolving to exactly their old default).
1563            RunWhere::Local => RunWhere::Local,
1564            RunWhere::Remote => RunWhere::Remote,
1565            RunWhere::Auto => policy_placement(RunWhere::Auto, &self.resolve_step(step)),
1566        }
1567    }
1568
1569    /// Plan the host-native cross build for a step that resolves to the
1570    /// [`NativeCross`](crate::platform::Resolution::NativeCross) tier (R531-F5,
1571    /// W222) — the concrete cargo-zigbuild / musl-cross invocation that should
1572    /// *replace* the recipe's `cross build` / bare `cargo build` argv.
1573    ///
1574    /// Returns `None` for any step F3 does **not** resolve to NativeCross
1575    /// (those go through emulate / cross-docker / offload, not this tier), and
1576    /// for a NativeCross verdict with no concrete `target` (a plain host build
1577    /// needs no rewrite). For an in-tier step it selects the toolchain against
1578    /// `avail` and rewrites the step's `argv`, yielding the emulation-free
1579    /// plan (or a [`CrossToolUnavailable`](crate::nativecross::CrossToolUnavailable)
1580    /// carrying an install hint).
1581    ///
1582    /// This is the seam T6 wires into the subprocess executor; F5 only defines
1583    /// and tests it — `run()` does not yet route through it.
1584    pub fn native_cross_plan(
1585        &self,
1586        step: &crate::types::QedStep,
1587        avail: &crate::nativecross::ToolAvailability,
1588    ) -> Option<Result<crate::nativecross::NativeCrossPlan, crate::nativecross::CrossToolUnavailable>>
1589    {
1590        if !matches!(
1591            self.resolve_step(step),
1592            crate::platform::Resolution::NativeCross
1593        ) {
1594            return None;
1595        }
1596        let platform = self.step_platform(step);
1597        // A host-arch / absent target is a plain native build — no foreign
1598        // toolchain, nothing for this tier to rewrite.
1599        let target = platform.target.as_deref()?;
1600        if !crate::nativecross::is_native_cross_target(&platform.host, target) {
1601            return None;
1602        }
1603        Some(crate::nativecross::plan_native_cross(
1604            &step.argv,
1605            &platform.host,
1606            target,
1607            avail,
1608        ))
1609    }
1610
1611    /// Portability preflight (R531-T4, W222): one rendered line per step
1612    /// describing what it targets, the host it runs on, and the resolution
1613    /// verdict — so an operator sees where mac and linux will diverge (and at
1614    /// what cost) *before* the run. Pure: builds the lines from the static
1615    /// pipeline, no execution. The `index_offset` is honored so a
1616    /// resume-from-step run still shows original step positions.
1617    pub fn portability_preflight(&self) -> Vec<String> {
1618        self.pipeline
1619            .steps
1620            .iter()
1621            .map(|step| {
1622                let platform = self.step_platform(step);
1623                let resolution = self.resolve_step(step);
1624                crate::platform::preflight_line(&step.name, &platform, &resolution)
1625            })
1626            .collect()
1627    }
1628
1629    /// The run id assigned at construction. Lets a caller (e.g. the camp
1630    /// daemon's `qed.run` handler) register a run as `Running` *before*
1631    /// [`Self::run`] completes, so `qed.status` can observe it in flight.
1632    pub fn run_id(&self) -> &str {
1633        &self.run_id
1634    }
1635
1636    pub async fn run(&self) -> Result<QedRunMeta, RunnerError> {
1637        let (meta, _produced) = self.run_inner().await?;
1638        Ok(meta)
1639    }
1640
1641    /// Same as [`Self::run`] but also returns the aggregated
1642    /// [`ProducedArtifact`] list. Used by SubPipeline recursion (R488-F2):
1643    /// a parent's SubPipeline step calls `run_inner()` on the child runner
1644    /// so it can roll the child's `produced` into its own collection. Public
1645    /// `run()` discards it (callers that need artifacts go through
1646    /// `Outcome::Publish`, not the meta).
1647    pub(crate) async fn run_inner(
1648        &self,
1649    ) -> Result<(QedRunMeta, Vec<crate::types::ProducedArtifact>), RunnerError> {
1650        let mut step_statuses = Vec::new();
1651        let created_at = Utc::now();
1652        let mut overall_status = RunStatus::Success;
1653        // Artifacts declared by steps that *succeed* — handed to an
1654        // Outcome::Publish (R330-F3). A failed step's `produces` is dropped:
1655        // we never publish an artifact a failing step may not have written.
1656        // SubPipeline steps (R488-F2) aggregate their child's `produced` into
1657        // this collection when `propagate.produces = true`.
1658        let mut produced: Vec<crate::types::ProducedArtifact> = Vec::new();
1659        // Named outputs accumulated so far (W201-F4): step_name → {key → value}.
1660        // Used to substitute `${{ steps.X.outputs.Y }}` in later steps'
1661        // argv / env before execution.
1662        let mut step_context: std::collections::HashMap<
1663            String,
1664            std::collections::HashMap<String, String>,
1665        > = std::collections::HashMap::new();
1666        // R513-F2: background sidecar steps spawned but not yet reaped. Reaped
1667        // either when their `background_until` gate step finishes (mid-loop) or
1668        // at the end of the step loop, whichever comes first. The Vec owns the
1669        // `kill_on_drop` task handles, so an early-return drops it and kills
1670        // every live sidecar.
1671        let mut background_tasks: Vec<BackgroundTask> = Vec::new();
1672
1673        self.emit(QedEvent::RunStarted {
1674            total_steps: self.index_offset + self.pipeline.steps.len(),
1675            at: created_at,
1676        });
1677
1678        // R531-T4: portability preflight — log the per-step host/target/
1679        // resolution verdict before executing anything, so divergence (and its
1680        // cost) is legible up front instead of after a wave-three faceplant.
1681        // Report-only: this never gates execution.
1682        for line in self.portability_preflight() {
1683            tracing::info!(target: "qed::preflight", host = %self.host_triple, "{line}");
1684        }
1685
1686        // R507/W208: toolchain pinning preflight — resolve every `[toolchain]`
1687        // pin against the host's installed versions (or mark it image-provided)
1688        // and *fail fast* before any step runs when the host can't satisfy a
1689        // pin. Unlike the portability preflight above this one gates execution:
1690        // a missing Xcode/NDK should stop a multi-hour release at second zero
1691        // with an actionable error, not three waves in. Logged either way.
1692        let toolchain_preflight = self.toolchain_preflight();
1693        for line in toolchain_preflight.report() {
1694            tracing::info!(target: "qed::preflight", host = %self.host_triple, "toolchain: {line}");
1695        }
1696        if let Some(report) = toolchain_preflight.error_report() {
1697            tracing::error!(target: "qed::preflight", host = %self.host_triple, "{report}");
1698            return Err(RunnerError::ToolchainUnsatisfied(report));
1699        }
1700
1701        // R513-F2: background sidecar pre-flight. v1 supports local + native
1702        // subprocess sidecars only, and a `background_until` target must name a
1703        // step that appears *later* in the pipeline. Fail loudly here, before
1704        // any step runs, rather than spawning a sidecar that can never be
1705        // reaped on schedule (a typo'd `background_until`) or routing one
1706        // through a runtime that can't honour `kill_on_drop` teardown.
1707        let step_names: Vec<&str> = self
1708            .pipeline
1709            .steps
1710            .iter()
1711            .map(|s| s.name.as_str())
1712            .collect();
1713        for (i, step) in self.pipeline.steps.iter().enumerate() {
1714            if !step.is_background() {
1715                continue;
1716            }
1717            // R590-F4: gate on the step's *effective* placement, not the raw
1718            // run_where — an Auto runner is fine for a background step that
1719            // resolves local; only a background step that would offload to the
1720            // fleet is rejected (remote sidecars are a separate lifecycle).
1721            if self.effective_placement(step) != RunWhere::Local {
1722                return Err(RunnerError::InvalidConfig(format!(
1723                    "step `{}`: background steps run locally only (R513-F2) — \
1724                     remote sidecars are yubaba-supervised, a separate lifecycle",
1725                    step.name,
1726                )));
1727            }
1728            if self.resolve_runtime(step) != TaskRuntime::Native {
1729                return Err(RunnerError::InvalidConfig(format!(
1730                    "step `{}`: background steps run native only in v1 (R513-F2) — \
1731                     drop `runtime = \"container\"`",
1732                    step.name,
1733                )));
1734            }
1735            if let Some(until) = &step.background_until {
1736                match step_names.iter().position(|n| n == until) {
1737                    None => {
1738                        return Err(RunnerError::InvalidConfig(format!(
1739                            "step `{}`: background_until names unknown step `{until}`",
1740                            step.name,
1741                        )));
1742                    }
1743                    Some(pos) if pos <= i => {
1744                        return Err(RunnerError::InvalidConfig(format!(
1745                            "step `{}`: background_until must name a *later* step, \
1746                             but `{until}` is at or before it — a sidecar reaped on a \
1747                             prior step would never see its gate fire",
1748                            step.name,
1749                        )));
1750                    }
1751                    Some(_) => {}
1752                }
1753            }
1754        }
1755
1756        // W224 R533-F11: position the whole run's workspace ONCE, before any
1757        // step. Top-level runs honour the pipeline's WorkspaceMode (Live /
1758        // Checkout-bail-if-dirty / Isolated worktree); the resulting tree is
1759        // recorded in `positioned_workspace` so every step kind resolves its
1760        // root through it (subprocess `desktop-release` builds from the same
1761        // Isolated worktree as a `gha-workflow` step, not the live camp root).
1762        // Child sub-pipeline runners inherit the parent's already-positioned
1763        // tree via `camp_root` (set at construction), so they skip repositioning
1764        // — re-running a checkout / spinning a second worktree mid-run would be
1765        // wrong. `_run_worktree_guard` is held for the entire step loop so an
1766        // Isolated worktree outlives the whole run and is torn down on drop,
1767        // even when a step below returns early with an error.
1768        let _run_worktree_guard = if self.parent_run_id.is_some() {
1769            None
1770        } else {
1771            let base = self.base_camp_root()?;
1772            let (workspace, guard) = self.prepare_workspace(&base)?;
1773            // OnceLock: this is the only writer (run_inner runs once per runner
1774            // instance) and it fires before the first step, so every step-time
1775            // resolve_camp_root() sees the positioned tree.
1776            let _ = self.positioned_workspace.set(workspace);
1777            guard
1778        };
1779
1780        for (index, step) in self.pipeline.steps.iter().enumerate() {
1781            let event_index = index + self.index_offset;
1782            // Apply accumulated step-output substitution to argv / env before
1783            // the step runs. Clones only when there is something to substitute.
1784            let step_modified: Option<crate::types::QedStep> = if !step_context.is_empty() {
1785                let mut s = step.clone();
1786                s.argv = s
1787                    .argv
1788                    .iter()
1789                    .map(|a| substitute_step_context(a, &step_context))
1790                    .collect();
1791                for v in s.env.values_mut() {
1792                    *v = substitute_step_context(v, &step_context);
1793                }
1794                Some(s)
1795            } else {
1796                None
1797            };
1798            let step = step_modified.as_ref().unwrap_or(step);
1799
1800            // R506: declarative + runtime gating. Resolve the reason (if any)
1801            // *before* emitting StepStarted so a skipped step's lifecycle
1802            // pair carries a Skipped terminal status with no "Running"
1803            // intermediate state on the wire.
1804            let skip_reason = self.resolve_skip_reason(step, &step_context, overall_status);
1805
1806            let started_at = Utc::now();
1807            self.emit(QedEvent::StepStarted {
1808                index: event_index,
1809                name: step.name.clone(),
1810                argv: step.argv.clone(),
1811                env_keys: crate::events::credential_env_keys(std::env::vars()),
1812                at: started_at,
1813            });
1814
1815            if let Some(reason) = skip_reason {
1816                let completed_at = Utc::now();
1817                self.emit(QedEvent::StepFinished {
1818                    index: event_index,
1819                    name: step.name.clone(),
1820                    status: RunStatus::Skipped,
1821                    msg: Some(reason),
1822                    at: completed_at,
1823                });
1824                step_statuses.push(StepStatus {
1825                    name: step.name.clone(),
1826                    task_run_id: None,
1827                    status: RunStatus::Skipped,
1828                    started_at: Some(started_at),
1829                    completed_at: Some(completed_at),
1830                    error: None,
1831                    outputs: std::collections::HashMap::new(),
1832                    applied_binds: Vec::new(),
1833                    jobs: Vec::new(),
1834                });
1835                continue;
1836            }
1837
1838            let runtime = self.resolve_runtime(step);
1839            // R590-F4: derive this step's concrete placement (Local/Remote) from
1840            // the `--where` force-mode + its declared platform. Under the default
1841            // Auto mode a `native = true` cross-arch step routes to the fleet
1842            // here without any `--where=remote` flag.
1843            let placement = self.effective_placement(step);
1844
1845            // R513-F2: a background sidecar is *spawned*, not awaited. Emit only
1846            // its StepStarted (already done above), kick the subprocess onto its
1847            // own task, record a `Running` placeholder row finalized at reap, and
1848            // advance to the next step. Pre-flight above guarantees this is a
1849            // local + native subprocess step. Output collection ($YAH_OUTPUTS) is
1850            // skipped — a long-lived sidecar has no terminal moment to read it
1851            // back, and downstream substitution can't wait on a server that
1852            // never exits.
1853            if step.is_background() {
1854                let spec = build_subprocess_spec(step, TaskRuntime::Native, None);
1855                let camp_root = self.resolve_camp_root()?;
1856                let cwd = match step.cwd.as_ref() {
1857                    Some(rel) => camp_root.join(rel),
1858                    None => camp_root,
1859                };
1860                let env: Vec<(String, String)> =
1861                    step.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1862                let ctx = ExecContext::default().with_cwd(cwd).with_env(env);
1863                let join = self.spawn_background_step(event_index, step, spec, ctx);
1864                let status_index = step_statuses.len();
1865                step_statuses.push(StepStatus {
1866                    name: step.name.clone(),
1867                    task_run_id: None,
1868                    status: RunStatus::Running,
1869                    started_at: Some(started_at),
1870                    completed_at: None,
1871                    error: None,
1872                    outputs: std::collections::HashMap::new(),
1873                    applied_binds: Vec::new(),
1874                    jobs: Vec::new(),
1875                });
1876                background_tasks.push(BackgroundTask {
1877                    status_index,
1878                    event_index,
1879                    name: step.name.clone(),
1880                    until: step.background_until.clone(),
1881                    join,
1882                });
1883                continue;
1884            }
1885
1886            // step_outputs: key → value collected from this step (W201-F4).
1887            // step_jobs: per-job rows when this step wraps a GHA workflow
1888            // (W223 R532-T1); stays empty for every other step kind.
1889            let mut step_jobs: Vec<crate::types::JobRow> = Vec::new();
1890            // R590-F6: when a remote step's produced artifacts are retrieved off
1891            // the build-worker, the path-rewritten list lands here and replaces
1892            // the raw `step.produces` declarations at the aggregation point
1893            // below. Stays `None` for local steps and remote steps with no
1894            // produced artifacts (the common case).
1895            let mut remote_produced: Option<Vec<ProducedArtifact>> = None;
1896            let (result, task_run_id, step_outputs) = match step.kind {
1897                crate::types::StepKind::BuildImage => {
1898                    match self.execute_step_build_image(event_index, step).await {
1899                        Ok(Some(forge_id)) => (
1900                            Ok(()),
1901                            Some(forge_id.to_string()),
1902                            std::collections::HashMap::new(),
1903                        ),
1904                        Ok(None) => (Ok(()), None, std::collections::HashMap::new()),
1905                        Err(e) => (Err(e), None, std::collections::HashMap::new()),
1906                    }
1907                }
1908                crate::types::StepKind::PackageNativeTarball => (
1909                    self.execute_step_package_native_tarball(step).await,
1910                    None,
1911                    std::collections::HashMap::new(),
1912                ),
1913                crate::types::StepKind::MuslStaticPreflight => (
1914                    self.execute_step_musl_static_preflight(step).await,
1915                    None,
1916                    std::collections::HashMap::new(),
1917                ),
1918                crate::types::StepKind::SignNativeTarball => (
1919                    self.execute_step_sign_native_tarball(step).await,
1920                    None,
1921                    std::collections::HashMap::new(),
1922                ),
1923                crate::types::StepKind::SubPipeline => {
1924                    match self
1925                        .execute_step_sub_pipeline(event_index, step, &mut step_jobs)
1926                        .await
1927                    {
1928                        Ok((child_produced, child_outputs)) => {
1929                            // Aggregation happens here (not below) so child
1930                            // produces flow into the parent's `Outcome::Publish`
1931                            // exactly like a sibling step's `produces`. The
1932                            // generic `produced.extend(step.produces.iter())`
1933                            // below is a no-op for SubPipeline (validate
1934                            // rejects direct `produces` on this kind).
1935                            produced.extend(child_produced);
1936                            (Ok(()), None, child_outputs)
1937                        }
1938                        Err(e) => (Err(e), None, std::collections::HashMap::new()),
1939                    }
1940                }
1941                crate::types::StepKind::GhaWorkflow => {
1942                    let cfg = step.gha_workflow.clone();
1943                    let dispatch = match cfg.as_ref() {
1944                        Some(cfg) => self.execute_step_gha_workflow(event_index, step, cfg, &mut step_jobs).await,
1945                        None => Err(RunnerError::InvalidConfig(format!(
1946                            "step `{}`: kind=gha-workflow with no [gha_workflow] block (validate() should have caught this)",
1947                            step.name,
1948                        ))),
1949                    };
1950                    match dispatch {
1951                        Ok((workflow_produced, workflow_outputs)) => {
1952                            // Same aggregation policy as SubPipeline: the
1953                            // GHA child's artifacts flow into the parent's
1954                            // Outcome::Publish in one terminal stage/sync,
1955                            // not N per workflow job. Job-level outputs are
1956                            // surfaced as `<job_id>.<key>` so the enclosing
1957                            // SubPipeline parent's `propagate.outputs` can
1958                            // pick them up (R488-F6).
1959                            produced.extend(workflow_produced);
1960                            (Ok(()), None, workflow_outputs)
1961                        }
1962                        Err(e) => (Err(e), None, std::collections::HashMap::new()),
1963                    }
1964                }
1965                crate::types::StepKind::Import => {
1966                    match self
1967                        .execute_step_import(event_index, step, &mut step_jobs)
1968                        .await
1969                    {
1970                        Ok((import_produced, import_outputs)) => {
1971                            // The imported workflow's expansion rolls up exactly
1972                            // like a GhaWorkflow step (W224 keeps the front-end):
1973                            // produced artifacts into the parent's terminal
1974                            // Outcome::Publish, job-level outputs as `<job>.<key>`.
1975                            produced.extend(import_produced);
1976                            (Ok(()), None, import_outputs)
1977                        }
1978                        Err(e) => (Err(e), None, std::collections::HashMap::new()),
1979                    }
1980                }
1981                crate::types::StepKind::WaitFor => (
1982                    self.execute_step_wait_for(event_index, step).await,
1983                    None,
1984                    std::collections::HashMap::new(),
1985                ),
1986                crate::types::StepKind::ManifestStitch => (
1987                    self.execute_step_manifest_stitch(event_index, step).await,
1988                    None,
1989                    std::collections::HashMap::new(),
1990                ),
1991                crate::types::StepKind::Subprocess => match (placement, runtime) {
1992                    (RunWhere::Local, TaskRuntime::Native) => {
1993                        // Inject $YAH_OUTPUTS so the step can write key=value
1994                        // output lines (W201-F4). Read back after exit regardless
1995                        // of success/failure, then clean up the temp file.
1996                        let outputs_path = std::env::temp_dir()
1997                            .join(format!("yah-qed-{}-{}.env", &self.run_id, index));
1998                        let mut yah_env = std::collections::HashMap::new();
1999                        yah_env.insert(
2000                            "YAH_OUTPUTS".to_string(),
2001                            outputs_path.display().to_string(),
2002                        );
2003                        let result = self
2004                            .execute_step_local(event_index, step, Some(&yah_env))
2005                            .await;
2006                        let collected = parse_yah_outputs(&outputs_path);
2007                        let _ = std::fs::remove_file(&outputs_path);
2008                        (result, None, collected)
2009                    }
2010                    (RunWhere::Local, TaskRuntime::Container) => (
2011                        self.execute_step_local_container(event_index, step).await,
2012                        None,
2013                        std::collections::HashMap::new(),
2014                    ),
2015                    // Auto is resolved to Local/Remote by effective_placement.
2016                    (RunWhere::Remote | RunWhere::Auto, _) => match self.execute_step_remote(event_index, step, runtime).await {
2017                        Ok(forge_id) => {
2018                            // R590-F6 leg 2: retrieve any produced artifacts off
2019                            // the build-worker into camp's content-addressed
2020                            // store before they feed the publish leg. No-op when
2021                            // the step declares no `produces`.
2022                            let retrieve = if step.produces.is_empty() {
2023                                Ok(())
2024                            } else {
2025                                match self.retrieve_remote_artifacts(&forge_id, step).await {
2026                                    Ok(rp) => {
2027                                        remote_produced = Some(rp);
2028                                        Ok(())
2029                                    }
2030                                    Err(e) => Err(e),
2031                                }
2032                            };
2033                            (retrieve, Some(forge_id.to_string()), std::collections::HashMap::new())
2034                        }
2035                        Err(e) => (Err(e), None, std::collections::HashMap::new()),
2036                    },
2037                },
2038            };
2039
2040            // Store outputs in the step context for downstream substitution.
2041            // Stored even when the step failed — a continue-on-error sibling
2042            // may still reference whatever was written before the failure.
2043            if !step_outputs.is_empty() {
2044                step_context.insert(step.name.clone(), step_outputs.clone());
2045            }
2046
2047            let (status, msg) = match &result {
2048                Ok(_) => {
2049                    // R590-F6: a remote step's retrieved (path-rewritten)
2050                    // artifacts replace the raw container-path declarations, so
2051                    // the publish leg reads the bytes landed in camp.
2052                    match remote_produced.take() {
2053                        Some(rp) => produced.extend(rp),
2054                        None => produced.extend(step.produces.iter().cloned()),
2055                    }
2056                    (RunStatus::Success, None)
2057                }
2058                Err(e) => {
2059                    overall_status = RunStatus::Failed;
2060                    let msg = match e {
2061                        RunnerError::StepFailed { msg, .. } => Some(msg.clone()),
2062                        RunnerError::InvalidConfig(m) => Some(m.clone()),
2063                        other => Some(other.to_string()),
2064                    };
2065                    (RunStatus::Failed, msg)
2066                }
2067            };
2068
2069            // W209: when the step succeeded, evaluate every bind whose
2070            // `from` references one of its outputs. Each AppliedBind is
2071            // persisted on the StepStatus so the qed-run tile (F7) and
2072            // hash-change hooks (F6) can drive off it. A failed step skips
2073            // its binds entirely — the source tree should only be touched
2074            // by receipts that came from a clean run. (Prior steps'
2075            // already-written binds remain on disk; the operator triages
2076            // via `git diff`, per W209 § Failure handling.)
2077            let applied_binds = if status == RunStatus::Success {
2078                self.apply_step_binds(step, &step_outputs)
2079            } else {
2080                Vec::new()
2081            };
2082
2083            let completed_at = Utc::now();
2084            // Keep the failure reason on the persisted StepStatus (not only in
2085            // the live StepFinished event) so `qed.status` surfaces *why* a
2086            // step failed after the run ends.
2087            let error = if status == RunStatus::Failed {
2088                msg.clone()
2089            } else {
2090                None
2091            };
2092            self.emit(QedEvent::StepFinished {
2093                index: event_index,
2094                name: step.name.clone(),
2095                status,
2096                msg,
2097                at: completed_at,
2098            });
2099
2100            step_statuses.push(StepStatus {
2101                name: step.name.clone(),
2102                task_run_id,
2103                status,
2104                started_at: Some(started_at),
2105                completed_at: Some(completed_at),
2106                error,
2107                outputs: step_outputs,
2108                applied_binds,
2109                jobs: step_jobs,
2110            });
2111
2112            // R513-F2: reap any background sidecar gated on this step finishing
2113            // (`background_until = step.name`). Reaping here — before the
2114            // `on_fail` break below — means a sidecar is torn down right after
2115            // its gate step regardless of whether that step passed or failed.
2116            let mut i = 0;
2117            while i < background_tasks.len() {
2118                if background_tasks[i].until.as_deref() == Some(step.name.as_str()) {
2119                    let bg = background_tasks.remove(i);
2120                    let (bg_status, bg_msg) = reap_background(bg.join).await;
2121                    let bg_completed_at = Utc::now();
2122                    if bg_status == RunStatus::Failed {
2123                        overall_status = RunStatus::Failed;
2124                    }
2125                    self.emit(QedEvent::StepFinished {
2126                        index: bg.event_index,
2127                        name: bg.name.clone(),
2128                        status: bg_status,
2129                        msg: bg_msg.clone(),
2130                        at: bg_completed_at,
2131                    });
2132                    let row = &mut step_statuses[bg.status_index];
2133                    row.status = bg_status;
2134                    row.completed_at = Some(bg_completed_at);
2135                    row.error = if bg_status == RunStatus::Failed {
2136                        bg_msg
2137                    } else {
2138                        None
2139                    };
2140                } else {
2141                    i += 1;
2142                }
2143            }
2144
2145            if status == RunStatus::Failed && !matches!(step.on_fail, OnFail::Continue) {
2146                break;
2147            }
2148        }
2149
2150        // R513-F2: reap every background sidecar still running at the end of the
2151        // step loop — those with no `background_until` (reap-at-pipeline-end),
2152        // plus any whose gate step was skipped or never reached. Done before
2153        // terminal-outcome selection so a sidecar that *crashed* mid-pipeline
2154        // flips the run to Failed and fires `on_fail`.
2155        for bg in background_tasks.drain(..) {
2156            let (bg_status, bg_msg) = reap_background(bg.join).await;
2157            let bg_completed_at = Utc::now();
2158            if bg_status == RunStatus::Failed {
2159                overall_status = RunStatus::Failed;
2160            }
2161            self.emit(QedEvent::StepFinished {
2162                index: bg.event_index,
2163                name: bg.name.clone(),
2164                status: bg_status,
2165                msg: bg_msg.clone(),
2166                at: bg_completed_at,
2167            });
2168            let row = &mut step_statuses[bg.status_index];
2169            row.status = bg_status;
2170            row.completed_at = Some(bg_completed_at);
2171            row.error = if bg_status == RunStatus::Failed {
2172                bg_msg
2173            } else {
2174                None
2175            };
2176        }
2177
2178        // R513-F4 (W207 Gap #6): always-run `finally:` teardown. Runs after the
2179        // sidecar reap and before terminal-outcome dispatch, unconditionally —
2180        // pass or fail — so artifact/diagnostic teardown (upload Playwright
2181        // traces, `docker compose down`, collect logs) always happens. Two
2182        // deliberate semantics:
2183        //   * Outcome selection uses the *work* status (steps + sidecars),
2184        //     snapshotted here BEFORE finally runs — a flaky teardown never
2185        //     redirects `on_success` → `on_fail`.
2186        //   * Every finally step is attempted (a failure never aborts the rest;
2187        //     teardown should always run to completion). A failed finally step
2188        //     still marks the *run* Failed (tile + `RunFinished`) unless it sets
2189        //     `on_fail = "continue"`.
2190        // Loader validation (`validate_finally`) guarantees these are Subprocess
2191        // steps and never background.
2192        let work_status = overall_status;
2193        let finally_index_base = self.pipeline.steps.len() + self.index_offset;
2194        for (j, step) in self.pipeline.finally.iter().enumerate() {
2195            let event_index = finally_index_base + j;
2196            // A teardown step may reference a prior step's output (e.g. the path
2197            // a test step emitted for its trace bundle), so apply the same
2198            // `${{ steps.X.outputs.Y }}` substitution the main loop uses.
2199            let step_modified: Option<crate::types::QedStep> = if !step_context.is_empty() {
2200                let mut s = step.clone();
2201                s.argv = s
2202                    .argv
2203                    .iter()
2204                    .map(|a| substitute_step_context(a, &step_context))
2205                    .collect();
2206                for v in s.env.values_mut() {
2207                    *v = substitute_step_context(v, &step_context);
2208                }
2209                Some(s)
2210            } else {
2211                None
2212            };
2213            let step = step_modified.as_ref().unwrap_or(step);
2214
2215            let started_at = Utc::now();
2216            self.emit(QedEvent::StepStarted {
2217                index: event_index,
2218                name: step.name.clone(),
2219                argv: step.argv.clone(),
2220                env_keys: crate::events::credential_env_keys(std::env::vars()),
2221                at: started_at,
2222            });
2223
2224            // Honor declarative disable / stub (cheap parity with main steps); a
2225            // finally step is otherwise unconditional — no `if` gate is consulted
2226            // (teardown is always-run by definition).
2227            if !step.enabled || step.activation == crate::types::StepActivation::Stubbed {
2228                let completed_at = Utc::now();
2229                let reason = if !step.enabled {
2230                    "finally step disabled (enabled = false)"
2231                } else {
2232                    "finally step stubbed (status = stubbed)"
2233                };
2234                self.emit(QedEvent::StepFinished {
2235                    index: event_index,
2236                    name: step.name.clone(),
2237                    status: RunStatus::Skipped,
2238                    msg: Some(reason.to_string()),
2239                    at: completed_at,
2240                });
2241                step_statuses.push(StepStatus {
2242                    name: step.name.clone(),
2243                    task_run_id: None,
2244                    status: RunStatus::Skipped,
2245                    started_at: Some(started_at),
2246                    completed_at: Some(completed_at),
2247                    error: None,
2248                    outputs: std::collections::HashMap::new(),
2249                    applied_binds: Vec::new(),
2250                    jobs: Vec::new(),
2251                });
2252                continue;
2253            }
2254
2255            let runtime = self.resolve_runtime(step);
2256            // R590-F4: per-step placement (see the main loop above).
2257            let placement = self.effective_placement(step);
2258            let result = match (placement, runtime) {
2259                (RunWhere::Local, TaskRuntime::Native) => {
2260                    self.execute_step_local(event_index, step, None).await
2261                }
2262                (RunWhere::Local, TaskRuntime::Container) => {
2263                    self.execute_step_local_container(event_index, step).await
2264                }
2265                // Auto is resolved to Local/Remote by effective_placement.
2266                (RunWhere::Remote | RunWhere::Auto, _) => self
2267                    .execute_step_remote(event_index, step, runtime)
2268                    .await
2269                    .map(|_| ()),
2270            };
2271
2272            let (status, msg) = match &result {
2273                Ok(_) => (RunStatus::Success, None),
2274                Err(e) => {
2275                    // A failed teardown marks the run Failed (so it's visible),
2276                    // unless the step opted out with `on_fail = "continue"`. It
2277                    // never aborts the remaining finally steps.
2278                    if !matches!(step.on_fail, OnFail::Continue) {
2279                        overall_status = RunStatus::Failed;
2280                    }
2281                    let msg = match e {
2282                        RunnerError::StepFailed { msg, .. } => Some(msg.clone()),
2283                        RunnerError::InvalidConfig(m) => Some(m.clone()),
2284                        other => Some(other.to_string()),
2285                    };
2286                    (RunStatus::Failed, msg)
2287                }
2288            };
2289            let completed_at = Utc::now();
2290            self.emit(QedEvent::StepFinished {
2291                index: event_index,
2292                name: step.name.clone(),
2293                status,
2294                msg: msg.clone(),
2295                at: completed_at,
2296            });
2297            step_statuses.push(StepStatus {
2298                name: step.name.clone(),
2299                task_run_id: None,
2300                status,
2301                started_at: Some(started_at),
2302                completed_at: Some(completed_at),
2303                error: msg,
2304                outputs: std::collections::HashMap::new(),
2305                applied_binds: Vec::new(),
2306                jobs: Vec::new(),
2307            });
2308        }
2309
2310        // Terminal outcomes (publish / vendor ship / warden deploy) fire off
2311        // the *work* status snapshotted before `finally` ran, so a flaky
2312        // teardown never redirects `on_success` → `on_fail`. Extracted to
2313        // `dispatch_terminal_outcomes` (R603-T4) so the boot reconciler can
2314        // replay this exact chain for a remote run that reached terminal
2315        // Success while the daemon was down.
2316        self.dispatch_terminal_outcomes(work_status, &produced)
2317            .await?;
2318
2319        let completed_at = Utc::now();
2320        self.emit(QedEvent::RunFinished {
2321            status: overall_status,
2322            at: completed_at,
2323        });
2324
2325        Ok((
2326            QedRunMeta {
2327                id: self.run_id.clone(),
2328                pipeline: self.pipeline.name.clone(),
2329                status: overall_status,
2330                created_at,
2331                completed_at: Some(completed_at),
2332                steps: step_statuses,
2333                // Step-level failures carry their reason on the failing
2334                // `StepStatus.error`; a run that completes the step loop has
2335                // no run-level (outside-any-step) failure to report.
2336                failure_reason: None,
2337                parent_run_id: self.parent_run_id.clone(),
2338            },
2339            produced,
2340        ))
2341    }
2342
2343    /// R603-T4: dispatch the pipeline's terminal outcomes (Publish / Provider /
2344    /// WardenDeploy / AlmanacRun) against a run's produced artifacts. Extracted
2345    /// verbatim from `run_inner` so the boot reconciler can replay the publish
2346    /// leg for a remote run that reached terminal Success while the daemon was
2347    /// down — see [`Self::resume_terminal_publish_for_remote_step`].
2348    ///
2349    /// Outcome selection keys off `work_status` (steps + sidecars, snapshotted
2350    /// before `finally`), so a flaky teardown never redirects `on_success` →
2351    /// `on_fail`.
2352    async fn dispatch_terminal_outcomes(
2353        &self,
2354        work_status: RunStatus,
2355        produced: &[ProducedArtifact],
2356    ) -> Result<(), RunnerError> {
2357        let outcomes = match work_status {
2358            RunStatus::Success => &self.pipeline.on_success,
2359            _ => &self.pipeline.on_fail,
2360        };
2361
2362        // Terminal outcomes operate on the run's produced artifacts, resolved
2363        // against camp_root once so relative paths work when the process CWD
2364        // isn't the workspace root (e.g. the Tauri desktop app). A vendor
2365        // adapter that *transforms* artifacts (notarize staples a bundle,
2366        // authenticode signs an `.exe`) folds its result back into `staged` so
2367        // a later outcome in the same chain (sparkle ships the stapled bundle,
2368        // a Publish syncs the signed binary) sees the transformed file (R509).
2369        let version = crate::publish::resolve_release_version();
2370        let mut staged: Vec<ProducedArtifact> = if let Some(root) = &self.camp_root {
2371            produced
2372                .iter()
2373                .map(|a| {
2374                    let p = std::path::Path::new(&a.path);
2375                    if p.is_relative() {
2376                        ProducedArtifact {
2377                            path: root.join(p).to_string_lossy().into_owned(),
2378                            ..a.clone()
2379                        }
2380                    } else {
2381                        a.clone()
2382                    }
2383                })
2384                .collect()
2385        } else {
2386            produced.to_vec()
2387        };
2388
2389        for outcome in outcomes {
2390            match outcome {
2391                Outcome::WardenDeploy { service, env } => {
2392                    self.outcome_dispatcher.warden_deploy(service, env).await?;
2393                }
2394                Outcome::AlmanacRun { pipeline } => {
2395                    self.outcome_dispatcher.almanac_run(pipeline).await?;
2396                }
2397                Outcome::Publish {
2398                    provider,
2399                    bucket,
2400                    prefix,
2401                    base_url,
2402                } => {
2403                    // SubPipeline children with `propagate.produces = true`
2404                    // have their publish suppressed — the parent owns the
2405                    // terminal stage/sync/revalidate. WardenDeploy /
2406                    // AlmanacRun are NOT suppressed (they may need to run
2407                    // per-child regardless of who fires the publish).
2408                    if self.suppress_publish_outcomes {
2409                        tracing::debug!(
2410                            run_id = %self.run_id,
2411                            "suppressing Outcome::Publish on child sub-pipeline run; parent owns the terminal publish"
2412                        );
2413                        continue;
2414                    }
2415                    let req = crate::publish::PublishRequest {
2416                        provider: provider.clone(),
2417                        bucket: bucket.clone(),
2418                        prefix: prefix.clone(),
2419                        base_url: base_url.clone(),
2420                        version: version.clone(),
2421                        artifacts: staged.clone(),
2422                    };
2423                    self.outcome_dispatcher.publish(&req).await?;
2424                }
2425                Outcome::Provider {
2426                    provider,
2427                    with,
2428                    base_url,
2429                } => {
2430                    // Vendor adapters are suppressed on SubPipeline children
2431                    // exactly like Publish — the parent owns the terminal
2432                    // vendor ship, so a child that notarized its own bundle and
2433                    // handed it up would double-submit.
2434                    if self.suppress_publish_outcomes {
2435                        tracing::debug!(
2436                            run_id = %self.run_id,
2437                            provider = %provider,
2438                            "suppressing Outcome::Provider on child sub-pipeline run; parent owns the terminal publish"
2439                        );
2440                        continue;
2441                    }
2442                    // Per-dispatch scratch dir for materialized credentials /
2443                    // generated artifacts; dropped (and cleaned) at arm exit.
2444                    let work = tempfile::tempdir()?;
2445                    let report = {
2446                        let ctx = crate::provider::ProviderContext {
2447                            version: &version,
2448                            artifacts: &staged,
2449                            base_url: base_url.as_deref(),
2450                            config: with,
2451                            work_dir: work.path(),
2452                            secrets: self.secrets.as_ref(),
2453                            // Live run path; the per-adapter dry-run check is a
2454                            // unit-test + `qed validate` plan-time concern.
2455                            dry_run: false,
2456                        };
2457                        self.provider_registry.dispatch(provider, &ctx).await?
2458                    };
2459                    for line in &report.actions {
2460                        tracing::info!(run_id = %self.run_id, provider = %provider, "{line}");
2461                    }
2462                    for url in &report.published {
2463                        tracing::info!(run_id = %self.run_id, provider = %provider, url = %url, "vendor publish");
2464                    }
2465                    // Fold transformed/new artifacts back into the working set
2466                    // so the next outcome in the chain addresses them. An
2467                    // in-place transform (same path) replaces; a new artifact
2468                    // (appcast/delta) appends.
2469                    for art in report.produced {
2470                        match staged.iter_mut().find(|s| s.path == art.path) {
2471                            Some(slot) => *slot = art,
2472                            None => staged.push(art),
2473                        }
2474                    }
2475                }
2476            }
2477        }
2478
2479        Ok(())
2480    }
2481
2482    /// R603-T4: replay the terminal publish for a remote step that finished
2483    /// while the camp daemon was down. The boot reconciler
2484    /// (`camp.rs::finalize_reconciled_run`) rebuilds a fleet-wired runner and
2485    /// calls this once it confirms the persisted yubaba workload reached a
2486    /// terminal Success — retrieving the artifact the build produced off the
2487    /// (possibly already-exited) build-worker and pushing it through the same
2488    /// `on_success` outcome chain a live run would have fired.
2489    ///
2490    /// `step_index` is the pipeline-local index of the remote step whose
2491    /// `produces` we retrieve; `forge_id` is the persisted workload identity
2492    /// (the bare `ObsForgeId` uuid — the mesh `forge.<uuid>` prefix is derived
2493    /// internally by `retrieve_remote_artifacts`).
2494    ///
2495    /// Best-effort on the retrieval leg: kamaji reaps exited containers, so a
2496    /// build that finished *during* the outage may be un-retrievable. This
2497    /// surfaces that as a `RunnerError` (which the caller renders as "artifact
2498    /// reaped, re-run") rather than silently claiming published. The robust fix
2499    /// — the build writing its tar to a durable host volume so retrieval
2500    /// survives reaping — is tracked as follow-up (see the ticket's reaping-
2501    /// window fork).
2502    pub async fn resume_terminal_publish_for_remote_step(
2503        &self,
2504        step_index: usize,
2505        forge_id: &ObsForgeId,
2506    ) -> Result<(), RunnerError> {
2507        let step = self.pipeline.steps.get(step_index).ok_or_else(|| {
2508            RunnerError::InvalidConfig(format!(
2509                "resume: step index {step_index} out of range for pipeline `{}` ({} steps)",
2510                self.pipeline.name,
2511                self.pipeline.steps.len(),
2512            ))
2513        })?;
2514        // A remote step with no `produces` still fires its terminal outcomes
2515        // (a WardenDeploy / AlmanacRun that needs no artifact); retrieval is
2516        // skipped in that case exactly like the live path (run_inner ~1939).
2517        let produced = if step.produces.is_empty() {
2518            Vec::new()
2519        } else {
2520            self.retrieve_remote_artifacts(forge_id, step).await?
2521        };
2522        self.dispatch_terminal_outcomes(RunStatus::Success, &produced)
2523            .await
2524    }
2525
2526    /// Resolve, configure, and run a SubPipeline child step (R488-F2).
2527    ///
2528    /// On success returns the child's [`ProducedArtifact`] list — empty
2529    /// unless `propagate.produces = true` (in which case the parent's
2530    /// `Outcome::Publish` aggregates these). On failure returns a clean
2531    /// `StepFailed` whose `msg` carries the child run's failure tail.
2532    ///
2533    /// The child runner inherits the parent's `executor`, `signer`,
2534    /// `camp_root`, `events`, `outcome_dispatcher`, and
2535    /// `sub_pipeline_resolver` (so nested SubPipelines recurse with the
2536    /// same wiring). When `propagate.produces = true`, the child has its
2537    /// own `Outcome::Publish` suppressed so only the parent fires the
2538    /// terminal stage/sync/revalidate.
2539    /// Returns `(produced, outputs)`:
2540    /// - `produced`: child artifacts to roll up into the parent's publish when
2541    ///   `propagate.produces = true`; empty otherwise.
2542    /// - `outputs`: named outputs from the child run projected per
2543    ///   `propagate.outputs` (W201-F4). The runner scans all child
2544    ///   `StepStatus::outputs` maps and takes the last writer for each
2545    ///   declared name. Empty when `propagate.outputs` is empty.
2546    /// `jobs_out` is forwarded to [`Self::execute_step_gha_workflow`] when the
2547    /// target is a GHA workflow (the short-circuit path), so the wrapping
2548    /// sub-pipeline step's `StepStatus` carries the inlined workflow's per-job
2549    /// rows (W223 R532-T1). Left empty for non-GHA sub-pipeline children —
2550    /// transparency for `Path` / `Builtin` / `Peer` targets is a later phase.
2551    async fn execute_step_sub_pipeline(
2552        &self,
2553        index: usize,
2554        step: &crate::types::QedStep,
2555        jobs_out: &mut Vec<crate::types::JobRow>,
2556    ) -> Result<
2557        (
2558            Vec<crate::types::ProducedArtifact>,
2559            std::collections::HashMap<String, String>,
2560        ),
2561        RunnerError,
2562    > {
2563        let Some(cfg) = step.sub_pipeline.as_ref() else {
2564            return Err(RunnerError::InvalidConfig(format!(
2565                "step `{}`: kind=sub-pipeline with no [sub_pipeline] block (validate() should have caught this)",
2566                step.name
2567            )));
2568        };
2569
2570        let Some(mut child) = self.sub_pipeline_resolver.resolve(&cfg.target) else {
2571            let reason = self
2572                .sub_pipeline_resolver
2573                .unresolved_reason(&cfg.target)
2574                .unwrap_or_else(|| format!(
2575                    "sub-pipeline target unresolvable: {:?} (no resolver configured, or target not found)",
2576                    cfg.target
2577                ));
2578            return Err(RunnerError::StepFailed {
2579                step: step.name.clone(),
2580                msg: reason,
2581            });
2582        };
2583
2584        // Forward params before constructing the child runner — child sees
2585        // its TOML with `{{key}}` placeholders substituted.
2586        child.apply_params(&cfg.params);
2587
2588        // Build a child runner that inherits the parent's wiring. We can't
2589        // use the existing constructors because they reset every field to
2590        // defaults; instead, clone parent shape explicitly.
2591        //
2592        // R487 follow-up: for SubPipelineRef::GhaWorkflow the resolver
2593        // synthesises a single-step pipeline whose only step is
2594        // StepKind::GhaWorkflow. Going through a child runner there is
2595        // pure paperwork that (a) decouples events so the new
2596        // GhaEvent → QedEvent bridge can never fire and (b) wraps any
2597        // inner StepFailed in the generic SubPipeline-level "failed at
2598        // child step `gha-workflow`" string, erasing the per-job +
2599        // stderr-tail detail. Short-circuit: execute the GhaWorkflow
2600        // step directly on `self`, with `self.events` live, then mirror
2601        // the SubPipelineStarted/Finished bookends so consumers still
2602        // see the delegation chip.
2603        if let crate::types::SubPipelineRef::GhaWorkflow { .. } = &cfg.target {
2604            let target_label = sub_pipeline_target_label(&cfg.target);
2605            let stub_child_run_id = Uuid::new_v4().to_string();
2606            self.emit(QedEvent::SubPipelineStarted {
2607                index,
2608                name: step.name.clone(),
2609                target: target_label,
2610                child_run_id: stub_child_run_id.clone(),
2611                at: Utc::now(),
2612            });
2613            // The synthesised pipeline has exactly one step; pull its
2614            // GhaWorkflowConfig back out for the direct call.
2615            let synthesised_step = child.steps.into_iter().next().ok_or_else(|| {
2616                RunnerError::InvalidConfig(format!(
2617                    "step `{}`: GhaWorkflow resolver returned an empty pipeline",
2618                    step.name,
2619                ))
2620            })?;
2621            let synthesised_cfg = synthesised_step.gha_workflow.clone().ok_or_else(|| {
2622                RunnerError::InvalidConfig(format!(
2623                    "step `{}`: synthesised GhaWorkflow step carried no [gha_workflow] block",
2624                    step.name,
2625                ))
2626            })?;
2627            let result = self
2628                .execute_step_gha_workflow(index, &synthesised_step, &synthesised_cfg, jobs_out)
2629                .await;
2630            // W223 R532-F3: opaque opt-out — keep the wrapper a single
2631            // black-box node by dropping the inlined per-job rows. The
2632            // workflow still ran and its status still rolls up below.
2633            if cfg.opaque {
2634                jobs_out.clear();
2635            }
2636            let (status, ret): (RunStatus, Result<_, RunnerError>) = match result {
2637                Ok((produced, outputs)) => {
2638                    // Honour propagate.produces: roll up artifacts only
2639                    // when the parent declared it (mirrors the long-path
2640                    // SubPipeline behavior — the parent's terminal Publish
2641                    // stages everything in one go).
2642                    let out_produced = if cfg.propagate.produces {
2643                        produced
2644                    } else {
2645                        Vec::new()
2646                    };
2647                    (RunStatus::Success, Ok((out_produced, outputs)))
2648                }
2649                Err(e) => (RunStatus::Failed, Err(e)),
2650            };
2651            self.emit(QedEvent::SubPipelineFinished {
2652                index,
2653                name: step.name.clone(),
2654                child_run_id: stub_child_run_id,
2655                status,
2656                at: Utc::now(),
2657            });
2658            return ret;
2659        }
2660
2661        // Peer children execute in the *peer* camp's workspace — the
2662        // resolver reports its root so subprocess steps (`cargo …`) get the
2663        // right cwd. Builtin/Path/GhaWorkflow children return None here and
2664        // inherit the parent's *positioned* workspace (W224 R533-F11): an
2665        // Isolated parent already moved its tree into a worktree, so the child
2666        // must build there too, not in the live camp root. `resolve_camp_root`
2667        // returns the positioned tree (set in run_inner before any step), so the
2668        // child inherits the worktree and — carrying `parent_run_id` — skips its
2669        // own repositioning. Without the peer override a `peer-release` runs
2670        // yubaba's `cargo publish -p workload-spec` from yah's root and fails
2671        // (package not in yah's workspace).
2672        let child_camp_root = self
2673            .sub_pipeline_resolver
2674            .resolved_camp_root(&cfg.target)
2675            .or_else(|| self.resolve_camp_root().ok());
2676
2677        let child_run_id = Uuid::new_v4().to_string();
2678        let child_runner = Self {
2679            pipeline: child,
2680            run_id: child_run_id.clone(),
2681            remote_driver: self.remote_driver.clone(),
2682            run_where: self.run_where,
2683            outcome_dispatcher: self.outcome_dispatcher.clone(),
2684            events: None,
2685            camp_root: child_camp_root,
2686            signer: self.signer.clone(),
2687            executor: self.executor.clone(),
2688            sub_pipeline_resolver: self.sub_pipeline_resolver.clone(),
2689            // Parent owns the terminal publish when propagate.produces is
2690            // set; otherwise the child's own Outcome::Publish (if any)
2691            // fires normally and the child's produced are *not* rolled up
2692            // to the parent (returned as empty below).
2693            suppress_publish_outcomes: cfg.propagate.produces,
2694            parent_run_id: Some(self.run_id.clone()),
2695            index_offset: 0,
2696            // Inherit so a SubPipeline whose child is a gha-workflow
2697            // step still honors the operator's matrix selection.
2698            gha_matrix_subset: self.gha_matrix_subset.clone(),
2699            // Inherit so an `--include-stubbed` pickup of a parent pipeline
2700            // applies recursively to its sub-pipeline children.
2701            include_stubbed: self.include_stubbed,
2702            // Child runs don't inherit the parent's matrix coord — they may
2703            // themselves be matrix-expanded.
2704            matrix_coord: None,
2705            // Inherit the parent's host triple (R531-T1): a SubPipeline child
2706            // executes on the same host, so it shares the parent's platform
2707            // context rather than re-detecting (which would also lose a
2708            // with_host_triple override the parent carried).
2709            host_triple: self.host_triple.clone(),
2710            // Carry the parent's already-probed toolchain set when present so
2711            // a child sub-pipeline doesn't re-probe; otherwise a fresh lazy
2712            // cache (it shares the host, so the result would match anyway).
2713            cross_availability: match self.cross_availability.get() {
2714                Some(a) => std::sync::OnceLock::from(*a),
2715                None => std::sync::OnceLock::new(),
2716            },
2717            // Same rationale (R507): inherit the parent's probed host toolchain
2718            // set when present so a child sub-pipeline doesn't re-probe; the
2719            // child shares the host, so a fresh lazy cache would match anyway.
2720            host_toolchains: match self.host_toolchains.get() {
2721                Some(m) => std::sync::OnceLock::from(m.clone()),
2722                None => std::sync::OnceLock::new(),
2723            },
2724            // Inherit the vendor adapter registry + credential source so a
2725            // child sub-pipeline whose `Outcome::Provider` *isn't* suppressed
2726            // (propagate.produces = false) can still resolve its adapter.
2727            provider_registry: self.provider_registry.clone(),
2728            secrets: self.secrets.clone(),
2729            // Inherit the run's target ref so a sub-pipeline whose child is a
2730            // gha-workflow positions its workspace at the same ref the parent
2731            // run requested (W224).
2732            git_ref: self.git_ref.clone(),
2733            // Child skips repositioning (parent_run_id is Some ⇒ run_inner
2734            // leaves this unset) and inherits the parent's positioned tree via
2735            // camp_root above (W224 R533-F11).
2736            positioned_workspace: std::sync::OnceLock::new(),
2737        };
2738
2739        let target_label = sub_pipeline_target_label(&cfg.target);
2740        self.emit(QedEvent::SubPipelineStarted {
2741            index,
2742            name: step.name.clone(),
2743            target: target_label,
2744            child_run_id: child_run_id.clone(),
2745            at: Utc::now(),
2746        });
2747
2748        // Async recursion needs explicit boxing.
2749        let outcome = Box::pin(child_runner.run_inner()).await;
2750        let (meta, child_produced) = match outcome {
2751            Ok(pair) => pair,
2752            Err(e) => {
2753                // Surface the bookend even when the child runner errored
2754                // before producing a meta — consumers shouldn't see a
2755                // dangling Started without a matching Finished.
2756                self.emit(QedEvent::SubPipelineFinished {
2757                    index,
2758                    name: step.name.clone(),
2759                    child_run_id: child_run_id.clone(),
2760                    status: RunStatus::Failed,
2761                    at: Utc::now(),
2762                });
2763                return Err(e);
2764            }
2765        };
2766
2767        self.emit(QedEvent::SubPipelineFinished {
2768            index,
2769            name: step.name.clone(),
2770            child_run_id: child_run_id.clone(),
2771            status: meta.status,
2772            at: Utc::now(),
2773        });
2774
2775        // W223 R532-F3: generalize transparent-by-default to the non-GHA
2776        // child kinds (Builtin / Path / Peer). The child ran as its own
2777        // pipeline, so its steps are attributed to this wrapping step as
2778        // inlined rows — the same treatment GHA jobs get — unless the step
2779        // opted out via `opaque`. Child qed steps are linear-by-ordering
2780        // (no `depends_on`), so the rows carry no `needs` edges; the report
2781        // and graph render them as a flat sequence under the wrapper. The
2782        // failing-step detail stays on the per-row `error`, mirroring the
2783        // child's own StepStatus.
2784        if !cfg.opaque {
2785            jobs_out.clear();
2786            jobs_out.extend(meta.steps.iter().map(|s| crate::types::JobRow {
2787                id: s.name.clone(),
2788                status: s.status,
2789                error: s.error.clone(),
2790                needs: Vec::new(),
2791            }));
2792        }
2793
2794        if meta.status != RunStatus::Success {
2795            // Surface the child's terminal status as a parent step failure
2796            // with the failing child step's name in the message — operator
2797            // sees both layers without needing to chase the nested run.
2798            let failing = meta
2799                .steps
2800                .iter()
2801                .find(|s| s.status == RunStatus::Failed)
2802                .map(|s| s.name.as_str())
2803                .unwrap_or("<unknown>");
2804            return Err(RunnerError::StepFailed {
2805                step: step.name.clone(),
2806                msg: format!(
2807                    "sub-pipeline `{}` failed at child step `{}` (run_id={})",
2808                    meta.pipeline, failing, meta.id
2809                ),
2810            });
2811        }
2812
2813        // Collect named outputs from child steps per propagate.outputs (W201-F4).
2814        // Scan all child StepStatus::outputs maps; last writer wins for each name.
2815        let propagated_outputs: std::collections::HashMap<String, String> =
2816            if cfg.propagate.outputs.is_empty() {
2817                std::collections::HashMap::new()
2818            } else {
2819                let mut collected: std::collections::HashMap<String, String> =
2820                    std::collections::HashMap::new();
2821                for child_step in &meta.steps {
2822                    for name in &cfg.propagate.outputs {
2823                        if let Some(value) = child_step.outputs.get(name) {
2824                            collected.insert(name.clone(), value.clone());
2825                        }
2826                    }
2827                }
2828                collected
2829            };
2830
2831        let produced = if cfg.propagate.produces {
2832            child_produced
2833        } else {
2834            Vec::new()
2835        };
2836        Ok((produced, propagated_outputs))
2837    }
2838
2839    /// Dispatch a [`StepKind::GhaWorkflow`] step into the native W200 GHA
2840    /// runtime (W200-F9). Reads the workflow YAML at the configured path
2841    /// (resolved relative to the camp root), parses through
2842    /// [`yah_qed_gha::parse_workflow`], executes via [`yah_qed_gha::execute_workflow`]
2843    /// with the tier-1/2 toolkit actions pre-registered (W224 R533-T7 — the
2844    /// tier-3 service overrides were retired). The delegated path produces no
2845    /// native publish artifacts; release artifacts come from native QED
2846    /// publisher steps, so the returned produced-artifact list is always empty.
2847    ///
2848    /// The qed-gha runtime is synchronous; we cross the seam via
2849    /// [`tokio::task::spawn_blocking`] so the runner's tokio reactor stays
2850    /// responsive (long-running workflow legs like `docker buildx build` would
2851    /// otherwise stall the executor).
2852    /// `jobs_out` is populated with one [`crate::types::JobRow`] per GHA job
2853    /// the workflow ran, regardless of overall success/failure, so the wrapping
2854    /// step's `StepStatus` carries the workflow's per-job structure transparently
2855    /// (W223 R532-T1). Left untouched when the run never starts (read / parse /
2856    /// join failure before any job executes).
2857    ///
2858    /// `cfg` is passed explicitly rather than read from `step.gha_workflow` so
2859    /// the same execution path serves both a `kind = gha-workflow` step (which
2860    /// passes its own `[gha_workflow]` block) and a `kind = import` step (R533-F1),
2861    /// whose plan-time expansion synthesizes an equivalent [`GhaWorkflowConfig`]
2862    /// via [`crate::import::expand_import`]. `step` still supplies the step name,
2863    /// matrix-subset key, and event index.
2864    async fn execute_step_gha_workflow(
2865        &self,
2866        event_index: usize,
2867        step: &crate::types::QedStep,
2868        cfg: &crate::types::GhaWorkflowConfig,
2869        jobs_out: &mut Vec<crate::types::JobRow>,
2870    ) -> Result<
2871        (
2872            Vec<crate::types::ProducedArtifact>,
2873            std::collections::HashMap<String, String>,
2874        ),
2875        RunnerError,
2876    > {
2877        // W224 R533-F11: the run already positioned its workspace once (in
2878        // run_inner, per the pipeline's WorkspaceMode + ref); read the
2879        // effective tree here rather than repositioning per gha step. For an
2880        // Isolated run this resolves to the run's worktree, so the workflow
2881        // reads the ref's copy of release.yml from the same tree every other
2882        // step builds in. The run-scoped WorktreeGuard (held in run_inner)
2883        // outlives this step.
2884        let workspace = self.resolve_camp_root()?;
2885        let workflow_path = if cfg.path.is_absolute() {
2886            cfg.path.clone()
2887        } else {
2888            workspace.join(&cfg.path)
2889        };
2890        let step_name = step.name.clone();
2891        let event = cfg.event.clone().unwrap_or_else(|| "push".into());
2892        let inputs = cfg.inputs.clone();
2893        // R531-T1: thread the self-detected host into the GHA plan context so
2894        // workflow steps gating on `runner.arch` see the real host this runner
2895        // executes on (the GHA executor detects its own OS, but QED owns the
2896        // authoritative host triple). Map the Rust arch token to GHA's
2897        // `runner.arch` vocabulary (`X64` / `ARM64`).
2898        let host_arch =
2899            crate::platform::gha_runner_arch(crate::platform::arch_of(&self.host_triple));
2900        // R499-F3 phase 2: matrix subset for this step (if any). Empty
2901        // set isn't a runtime concern — the daemon rejects it before
2902        // ever constructing the runner.
2903        let matrix_subset = self.gha_matrix_subset.get(&step.name).cloned();
2904
2905        // Step index of THIS gha-workflow step in the parent qed pipeline,
2906        // including the resume-time index offset (already baked into
2907        // `event_index` by the call site). The sync sink → async event
2908        // forwarder stamps this on every bridged GhaEvent so the receiver
2909        // can scope the per-job subtree under the right parent step.
2910        let step_index = event_index;
2911        let parent_step_name = step.name.clone();
2912
2913        // Bridge qed_gha's sync std::sync::mpsc sender into our async
2914        // event sink (R325-F2). We spawn a forwarder *before* the blocking
2915        // task so the channel is live the moment the runtime starts
2916        // emitting; the forwarder ends when the blocking task drops its
2917        // sender.
2918        let (gha_tx, gha_rx) = std::sync::mpsc::channel::<yah_qed_gha::GhaEvent>();
2919        let async_events = self.events.clone();
2920        let forwarder_name = parent_step_name.clone();
2921        let forwarder = tokio::task::spawn_blocking(move || {
2922            while let Ok(ev) = gha_rx.recv() {
2923                if let Some(sink) = &async_events {
2924                    let qed_ev = bridge_gha_event(step_index, &forwarder_name, ev);
2925                    let _ = sink.send(qed_ev);
2926                }
2927            }
2928        });
2929
2930        // Sync execution off the reactor — qed_gha is blocking by design (it
2931        // spawns `bash`, `docker`, `git`, etc. via std::process::Command).
2932        let run = tokio::task::spawn_blocking(move || {
2933            let yaml =
2934                std::fs::read_to_string(&workflow_path).map_err(|e| RunnerError::StepFailed {
2935                    step: step_name.clone(),
2936                    msg: format!("read workflow {}: {e}", workflow_path.display()),
2937                })?;
2938            let workflow = yah_qed_gha::parse_workflow(&yaml).map_err(|e| RunnerError::StepFailed {
2939                step: step_name.clone(),
2940                msg: format!("parse {}: {e}", workflow_path.display()),
2941            })?;
2942            let secrets = crate::secrets_bridge::SecretsConfig::load_default().resolve_all();
2943            // R594: inject the docker push-family image builder so the runtime
2944            // actually builds + pushes the workflow's image jobs (local buildx
2945            // now; arch-matched build-worker fleet dispatch in phase C) instead
2946            // of declining them with a tier-3 error. It reads the W200 overlay
2947            // (`.yah/qed/gha-actions.toml` registry_route / registry_auth) to
2948            // retarget the workflow's hard-coded ghcr.io push to a registry the
2949            // local token can write — the Dockerfiles + release.yml stay ghcr.io.
2950            let image_builder = std::sync::Arc::new(crate::image_overlay::QedImageBuilder::new(
2951                &workspace,
2952                secrets.clone(),
2953            ));
2954            // R594: single-host content-addressed artifact store so a job that
2955            // uploads binaries and a later job that downloads them move files
2956            // through an on-disk store — the retired upload/download-artifact
2957            // actions, executed for real. Fleet phase swaps in a transport-backed
2958            // store for cross-host (build-worker) fetches.
2959            let artifact_store =
2960                std::sync::Arc::new(crate::artifact_local::LocalArtifactStore::new());
2961            let mut executor = yah_qed_gha::Executor::new(&workspace)
2962                .with_events(gha_tx)
2963                .with_secrets(secrets)
2964                .with_image_builder(image_builder)
2965                .with_artifact_store(artifact_store);
2966            executor.inputs = inputs_to_value(&inputs);
2967            executor.github = github_context(&event, &workspace);
2968            executor.runner_arch = host_arch;
2969            executor.included_instance_keys = matrix_subset;
2970            let run = yah_qed_gha::execute_workflow(&workflow, &executor).map_err(|e| {
2971                RunnerError::StepFailed {
2972                    step: step_name.clone(),
2973                    msg: format!("execute {}: {e}", workflow_path.display()),
2974                }
2975            })?;
2976            // Lift each job's `needs:` out of the parsed workflow before it's
2977            // dropped — the graph viewer renders these as intra-workflow
2978            // dependency edges between the inlined job nodes (W223 R532-F2).
2979            let needs_by_job: std::collections::HashMap<String, Vec<String>> = workflow
2980                .jobs
2981                .iter()
2982                .map(|(id, job)| (id.clone(), job.needs.clone()))
2983                .collect();
2984            // Drop the executor (and its event sender) so the forwarder loop
2985            // exits cleanly once it has drained the channel.
2986            drop(executor);
2987            Ok::<_, RunnerError>((run, needs_by_job))
2988        })
2989        .await
2990        .map_err(|join_err| RunnerError::StepFailed {
2991            step: step.name.clone(),
2992            msg: format!("gha-workflow task panicked: {join_err}"),
2993        })??;
2994        let (run, needs_by_job) = run;
2995        // Wait for the forwarder to drain any tail events before we return —
2996        // otherwise the parent's `StepFinished` could race ahead of the last
2997        // few GhaStepOutput lines.
2998        let _ = forwarder.await;
2999
3000        // W223 R532-T1: persist the wrapped workflow's per-job structure on the
3001        // wrapping step. Build one row per job regardless of outcome so the
3002        // report renders the workflow transparently — success and skipped rows
3003        // are present too, folding the R516 skip-count into per-row Skipped
3004        // state rather than a trailing sentence. The flattened failure string
3005        // below is still produced (it remains the step-level `error`), but the
3006        // structured rows are now the source of truth for per-job detail.
3007        jobs_out.clear();
3008        jobs_out.extend(run.instances.iter().map(|inst| {
3009            let status = match inst.result {
3010                yah_qed_gha::JobResult::Success => RunStatus::Success,
3011                yah_qed_gha::JobResult::Failure | yah_qed_gha::JobResult::Cancelled => RunStatus::Failed,
3012                yah_qed_gha::JobResult::Skipped => RunStatus::Skipped,
3013            };
3014            let error = matches!(inst.result, yah_qed_gha::JobResult::Failure)
3015                .then(|| gha_job_failure_detail(inst));
3016            crate::types::JobRow {
3017                id: inst.job_id.clone(),
3018                status,
3019                error,
3020                needs: needs_by_job.get(&inst.job_id).cloned().unwrap_or_default(),
3021            }
3022        }));
3023
3024        // W224 R533-T7: an imported/delegated GHA workflow produces NO native
3025        // publish artifacts. The tier-3 `gh-release` override that used to stage
3026        // them is retired; QED's native publisher steps (W208) own release
3027        // artifacts now. The transformer (R533-F4) flags a workflow's release
3028        // step with a native-replacement stanza for the human to wire as a
3029        // native step — those steps emit `produces`, not this delegated path.
3030        let produced: Vec<crate::types::ProducedArtifact> = Vec::new();
3031
3032        // Surface a workflow-level failure as a clean StepFailed enumerating
3033        // EVERY failing job (and the first failing step inside each), with a
3034        // stderr tail per job so operators see *why* without having to chase
3035        // the nested WorkflowRun manually. A single gha-workflow step can fan
3036        // out to many jobs (e.g. image-yah-*); collapsing to just the first
3037        // failure (the old `.find`) silently dropped the rest.
3038        let failing: Vec<&_> = run
3039            .instances
3040            .iter()
3041            .filter(|i| matches!(i.result, yah_qed_gha::JobResult::Failure))
3042            .collect();
3043        if !failing.is_empty() {
3044            let per_job: Vec<String> = failing
3045                .iter()
3046                .map(|job| format!("job `{}` {}", job.job_id, gha_job_failure_detail(job)))
3047                .collect();
3048            // Reconcile the text report with the job graph: the UI renders every
3049            // skipped job too, so a report that names only the failures reads as
3050            // "6 failed" while the screen shows ~20 red/grey rows. Count the
3051            // skips (downstream jobs gated on a failed/skipped dependency) and
3052            // say so explicitly, so the gap between "failed N" and "graph shows
3053            // more" is accounted for rather than mysterious (R516).
3054            let skipped = run
3055                .instances
3056                .iter()
3057                .filter(|i| matches!(i.result, yah_qed_gha::JobResult::Skipped))
3058                .count();
3059            let skip_note = if skipped > 0 {
3060                format!(
3061                    "\n\n{skipped} downstream job(s) skipped — gated on a failed or \
3062                     skipped dependency, not independent failures."
3063                )
3064            } else {
3065                String::new()
3066            };
3067            let msg = if per_job.len() == 1 {
3068                format!(
3069                    "gha-workflow `{}` failed at {}{}",
3070                    cfg.path.display(),
3071                    per_job[0],
3072                    skip_note,
3073                )
3074            } else {
3075                format!(
3076                    "gha-workflow `{}` failed in {} jobs:\n\n{}{}",
3077                    cfg.path.display(),
3078                    per_job.len(),
3079                    per_job.join("\n\n"),
3080                    skip_note,
3081                )
3082            };
3083            return Err(RunnerError::StepFailed {
3084                step: step.name.clone(),
3085                msg,
3086            });
3087        }
3088
3089        // Lift job-level outputs into a flat HashMap so the parent's
3090        // SubPipelineCollect::outputs can address them. Naming scheme:
3091        // `<job_id>.<output_key>` (mirrors GHA's `jobs.<id>.outputs.<key>`
3092        // mental model). The SubPipeline parent declares which names it
3093        // wants in `propagate.outputs` and reads them via
3094        // `${{ steps.<gha-workflow-step>.outputs.<job_id>.<key> }}`.
3095        // R488-F6.
3096        let mut outputs: std::collections::HashMap<String, String> =
3097            std::collections::HashMap::new();
3098        for instance in &run.instances {
3099            if !matches!(instance.result, yah_qed_gha::JobResult::Success) {
3100                continue;
3101            }
3102            for (key, value) in &instance.outputs {
3103                outputs.insert(format!("{}.{}", instance.job_id, key), value.as_str_lossy());
3104            }
3105        }
3106
3107        Ok((produced, outputs))
3108    }
3109
3110    /// Dispatch a [`StepKind::Import`] step (W224 "import, don't emulate";
3111    /// R533-F1). Reads the imported `workflow.yml` source, recomputes its
3112    /// blake3 content hash, checks it against the pinned hash, then expands the
3113    /// source into the native subgraph and executes it.
3114    ///
3115    /// F1's expansion is the single-node [`crate::import::ImportExpansion::Delegated`]
3116    /// form: route through the recast W200 GHA front-end (so the import step
3117    /// actually runs while GHA is canonical). The hash pin is the drift
3118    /// guardrail; under the default **virtual** expansion a drifted source is
3119    /// benign — we re-expand from whatever is on disk, only logging the drift.
3120    /// R533-F4 swaps the expansion body for the mechanical tier-1/2 native map;
3121    /// R533-F6 wires `materialize` (eject to TOML) + the stale-source guard.
3122    async fn execute_step_import(
3123        &self,
3124        event_index: usize,
3125        step: &crate::types::QedStep,
3126        jobs_out: &mut Vec<crate::types::JobRow>,
3127    ) -> Result<
3128        (
3129            Vec<crate::types::ProducedArtifact>,
3130            std::collections::HashMap<String, String>,
3131        ),
3132        RunnerError,
3133    > {
3134        let Some(cfg) = step.import.as_ref() else {
3135            return Err(RunnerError::InvalidConfig(format!(
3136                "step `{}`: kind=import with no [import] block (validate() should have caught this)",
3137                step.name,
3138            )));
3139        };
3140
3141        let camp_root = self.resolve_camp_root()?;
3142        let source_path = if cfg.source.is_absolute() {
3143            cfg.source.clone()
3144        } else {
3145            camp_root.join(&cfg.source)
3146        };
3147
3148        // Read the source so we can pin/verify its hash. A missing source is a
3149        // hard error (unlike a drifted hash) — there's nothing to expand.
3150        let bytes = std::fs::read(&source_path).map_err(|e| RunnerError::StepFailed {
3151            step: step.name.clone(),
3152            msg: format!("read import source {}: {e}", source_path.display()),
3153        })?;
3154        let actual = crate::import::content_hash(&bytes);
3155
3156        // Freshness against the pin. Virtual-by-default (the F1 path): a stale
3157        // source is benign — expand from disk and note the drift. The pin is
3158        // load-bearing for the materialized eject guard (R533-F6), not for the
3159        // virtual run, so we never fail the run here.
3160        match cfg.freshness(&actual) {
3161            crate::import::ImportFreshness::Fresh => {}
3162            crate::import::ImportFreshness::Unpinned => {
3163                tracing::debug!(
3164                    step = %step.name,
3165                    source = %source_path.display(),
3166                    hash = %actual,
3167                    "import: source not yet pinned; expanding virtually (hash recorded for a future eject)",
3168                );
3169            }
3170            crate::import::ImportFreshness::Stale { pinned, actual } => {
3171                tracing::warn!(
3172                    step = %step.name,
3173                    source = %source_path.display(),
3174                    %pinned,
3175                    %actual,
3176                    "import: source drifted from its pinned hash; re-expanding virtually \
3177                     (zero-drift by construction — nothing stored to diverge)",
3178                );
3179            }
3180        }
3181
3182        if cfg.materialize {
3183            tracing::warn!(
3184                step = %step.name,
3185                "import: `materialize = true` is a request to eject to generated TOML, which is \
3186                 an explicit one-time move (`crate::eject::eject` / `qed eject`), not a per-run \
3187                 side-effect; proceeding with virtual expansion this run (R533-F6)",
3188            );
3189        }
3190
3191        // Plan-time expansion. F1 yields a single delegated GHA front-end node;
3192        // F4 will generalize this match with a native-steps arm.
3193        match crate::import::expand_import(cfg) {
3194            crate::import::ImportExpansion::Delegated(gha) => {
3195                self.execute_step_gha_workflow(event_index, step, &gha, jobs_out)
3196                    .await
3197            }
3198        }
3199    }
3200
3201    /// Dispatch a [`StepKind::WaitFor`] step (R513-F3, W207 Gap #5): poll the
3202    /// configured endpoint until it is healthy, then return `Ok(())`; fail the
3203    /// step if it never comes up within `timeout_secs`.
3204    ///
3205    /// The loop emits a live [`QedEvent::StepOutput`] line per attempt so the
3206    /// QED tail shows "waiting … (attempt N)" and, on success, "healthy after
3207    /// Nms" — the same streaming contract a subprocess step has. Cancellation is
3208    /// structural: on `qed.cancel` the whole run future is dropped, which drops
3209    /// this loop mid-`sleep`/probe — no lingering poller.
3210    ///
3211    /// The probe primitives live in [`crate::waitfor`]; this owns only the
3212    /// deadline/interval scheduling and event emission.
3213    async fn execute_step_wait_for(
3214        &self,
3215        event_index: usize,
3216        step: &crate::types::QedStep,
3217    ) -> Result<(), RunnerError> {
3218        let Some(cfg) = step.wait_for.as_ref() else {
3219            return Err(RunnerError::InvalidConfig(format!(
3220                "step `{}`: kind=wait-for with no [wait_for] block (validate() should have caught this)",
3221                step.name,
3222            )));
3223        };
3224
3225        // Resolve the probe shape once, up front, so a malformed URL fails the
3226        // step immediately instead of burning the whole timeout budget retrying
3227        // an un-parseable target.
3228        enum Probe {
3229            Http(crate::waitfor::HttpTarget, Option<u16>),
3230            Tcp(String),
3231        }
3232        let (probe, target_label) = if let Some(url) = cfg.http.as_ref() {
3233            let target = crate::waitfor::parse_http_url(url).map_err(|msg| {
3234                RunnerError::StepFailed {
3235                    step: step.name.clone(),
3236                    msg: format!("wait-for: {msg}"),
3237                }
3238            })?;
3239            (Probe::Http(target, cfg.expect_status), url.clone())
3240        } else if let Some(addr) = cfg.tcp.as_ref() {
3241            (Probe::Tcp(addr.clone()), addr.clone())
3242        } else {
3243            // validate() guarantees exactly one target; defensive only.
3244            return Err(RunnerError::InvalidConfig(format!(
3245                "step `{}`: wait-for with no http/tcp target (validate() should have caught this)",
3246                step.name,
3247            )));
3248        };
3249
3250        let timeout_budget = std::time::Duration::from_secs(cfg.timeout_secs);
3251        let interval = std::time::Duration::from_millis(cfg.interval_ms.max(1));
3252        // Per-attempt timeout: never let a single probe outlast the whole budget.
3253        let attempt_timeout = timeout_budget.min(std::time::Duration::from_secs(5));
3254        let deadline = tokio::time::Instant::now() + timeout_budget;
3255
3256        self.emit(QedEvent::StepOutput {
3257            index: event_index,
3258            name: step.name.clone(),
3259            stream: crate::events::OutputStream::Stdout,
3260            line: format!(
3261                "wait-for: polling {target_label} (timeout {}s, interval {}ms)",
3262                cfg.timeout_secs, cfg.interval_ms,
3263            ),
3264        });
3265
3266        let started = tokio::time::Instant::now();
3267        let mut attempt: u32 = 0;
3268        loop {
3269            attempt += 1;
3270            let outcome: Result<(), String> = match &probe {
3271                Probe::Http(target, expect) => {
3272                    match crate::waitfor::probe_http_once(target, attempt_timeout).await {
3273                        Ok(status) if crate::waitfor::http_status_ok(status, *expect) => Ok(()),
3274                        Ok(status) => Err(match expect {
3275                            Some(want) => format!("HTTP {status} (want {want})"),
3276                            None => format!("HTTP {status} (want 2xx/3xx)"),
3277                        }),
3278                        Err(e) => Err(e),
3279                    }
3280                }
3281                Probe::Tcp(addr) => crate::waitfor::probe_tcp_once(addr, attempt_timeout).await,
3282            };
3283
3284            match outcome {
3285                Ok(()) => {
3286                    let elapsed = started.elapsed().as_millis();
3287                    self.emit(QedEvent::StepOutput {
3288                        index: event_index,
3289                        name: step.name.clone(),
3290                        stream: crate::events::OutputStream::Stdout,
3291                        line: format!(
3292                            "wait-for: {target_label} healthy after {elapsed}ms ({attempt} attempt{})",
3293                            if attempt == 1 { "" } else { "s" },
3294                        ),
3295                    });
3296                    return Ok(());
3297                }
3298                Err(reason) => {
3299                    // Stop if the next interval would push us past the budget —
3300                    // no point sleeping only to give up.
3301                    if tokio::time::Instant::now() + interval >= deadline {
3302                        return Err(RunnerError::StepFailed {
3303                            step: step.name.clone(),
3304                            msg: format!(
3305                                "wait-for: {target_label} never became healthy within {}s \
3306                                 ({attempt} attempts; last: {reason})",
3307                                cfg.timeout_secs,
3308                            ),
3309                        });
3310                    }
3311                    self.emit(QedEvent::StepOutput {
3312                        index: event_index,
3313                        name: step.name.clone(),
3314                        stream: crate::events::OutputStream::Stderr,
3315                        line: format!("wait-for: attempt {attempt} not ready ({reason}); retrying"),
3316                    });
3317                    tokio::time::sleep(interval).await;
3318                }
3319            }
3320        }
3321    }
3322
3323    /// Run one step as a local subprocess via [`Self::executor`] (R438-T14).
3324    ///
3325    /// Builds a `ForgeSpec{Subprocess, TaskPlacement{Local, Native}}` from
3326    /// `step.argv`/`step.cwd`/`step.env` and hands it to the configured
3327    /// `ForgeExecutor`. The executor drains stdout/stderr; an adapter task
3328    /// forwards each [`ExecEvent::Output`] as [`QedEvent::StepOutput`] so the
3329    /// per-line live-stream contract from R325-F2 is preserved. Failure
3330    /// message uses `ExecOutcome.stderr_tail` (same source the inline
3331    /// implementation captured).
3332    /// `extra_env` keys are merged on top of `step.env` — used by `run_inner`
3333    /// to inject `$YAH_OUTPUTS` for output collection (W201-F4) without
3334    /// mutating the step.
3335    async fn execute_step_local(
3336        &self,
3337        index: usize,
3338        step: &crate::types::QedStep,
3339        extra_env: Option<&std::collections::HashMap<String, String>>,
3340    ) -> Result<(), RunnerError> {
3341        if step.argv.is_empty() {
3342            return Err(RunnerError::InvalidConfig("step argv is empty".to_string()));
3343        }
3344
3345        // R531-T6: if F3 resolves this step to the NativeCross tier (a
3346        // foreign-arch crossable target), route its build onto the host-native
3347        // cross toolchain (cargo-zigbuild / musl-cross) instead of running the
3348        // recipe's `cross build` verbatim — the mesofact "stop using the amd64
3349        // container, use zigbuild" fix. Native-only per W224: imported GHA
3350        // steps lift their target at import time, they don't reach this seam.
3351        let mut cross_env: Vec<(String, String)> = Vec::new();
3352        let effective_step;
3353        let step: &crate::types::QedStep =
3354            match self.native_cross_plan(step, &self.cross_availability()) {
3355                Some(Ok(plan)) => {
3356                    tracing::info!(
3357                        target: "qed::nativecross",
3358                        step = %step.name,
3359                        tool = plan.tool.label(),
3360                        "rerouting build to host-native cross: {:?}",
3361                        plan.argv,
3362                    );
3363                    cross_env = plan.env;
3364                    effective_step = crate::types::QedStep {
3365                        argv: plan.argv,
3366                        ..step.clone()
3367                    };
3368                    &effective_step
3369                }
3370                Some(Err(unavailable)) => {
3371                    // No host-native toolchain for a target the table said *should*
3372                    // cross-compile — fail with the install hint rather than fall
3373                    // through to a confusing linker/manifest error.
3374                    return Err(RunnerError::StepFailed {
3375                        step: step.name.clone(),
3376                        msg: unavailable.to_string(),
3377                    });
3378                }
3379                None => step,
3380            };
3381
3382        let spec = build_subprocess_spec(step, TaskRuntime::Native, None);
3383        let camp_root = self.resolve_camp_root()?;
3384        let cwd = match step.cwd.as_ref() {
3385            Some(rel) => camp_root.join(rel),
3386            None => camp_root,
3387        };
3388        let mut merged_env: std::collections::HashMap<String, String> = step
3389            .env
3390            .iter()
3391            .map(|(k, v)| (k.clone(), v.clone()))
3392            .collect();
3393        // Cross-toolchain env (musl-cross linker/CC/AR) underlays the step's own
3394        // env and the output-collection extras, so an explicit step `env` still
3395        // wins on a key collision.
3396        for (k, v) in cross_env {
3397            merged_env.entry(k).or_insert(v);
3398        }
3399        if let Some(extra) = extra_env {
3400            merged_env.extend(extra.iter().map(|(k, v)| (k.clone(), v.clone())));
3401        }
3402        let ctx = ExecContext::default()
3403            .with_cwd(cwd)
3404            .with_env(merged_env.into_iter().collect());
3405        self.drive_subprocess_step(index, step, spec, ctx).await
3406    }
3407
3408    /// Run one step inside a one-shot container (local + container quadrant)
3409    /// via [`Self::executor`].
3410    ///
3411    /// Same flow as [`Self::execute_step_local`] but builds `ForgeSpec` with
3412    /// `runtime = Container` and an [`Subprocess.image`] resolved through
3413    /// [`task::default_image::default_forge_image`]. The container `cwd` is
3414    /// resolved to an absolute path before handoff so the executor's bind
3415    /// mount matches the host's view (matches the prior inline shape from
3416    /// R380-T6).
3417    ///
3418    /// Image: uses [`task::default_image::default_forge_image`] (resolves
3419    /// to `yah-rust-bun` since R381-T8). A per-step image catalog (the
3420    /// rest of R381) lets pipelines pick yah-rust / yah-python / yah-cuda
3421    /// by name via `task::default_image::catalog_image(name)`.
3422    async fn execute_step_local_container(
3423        &self,
3424        index: usize,
3425        step: &crate::types::QedStep,
3426    ) -> Result<(), RunnerError> {
3427        if step.argv.is_empty() {
3428            return Err(RunnerError::InvalidConfig("step argv is empty".to_string()));
3429        }
3430        // R590-F4/R546: a `native = true` cross-arch step demands real silicon of
3431        // its target arch — its whole contract is "no QEMU" (the rusty-v8-musl
3432        // forcing case OOMs under emulation). If such a step reaches the
3433        // local-container path anyway — e.g. a forced `--where local` runner, which
3434        // `effective_placement` resolves to Local WITHOUT inspecting the step and
3435        // so bypasses the Offload routing — running it here can only mean Docker
3436        // silently emulating a foreign-arch image (the `WARNING: The requested
3437        // image's platform (linux/amd64) does not match the detected host platform
3438        // (linux/arm64/v8)` case). That is a hard failure, not a warning: refuse to
3439        // emulate rather than start a build that can't succeed on this host.
3440        if let crate::platform::Resolution::Offload { target } = self.resolve_step(step) {
3441            let tier = crate::platform::build_worker_mesh_tags(crate::platform::arch_of(&target))
3442                .into_iter()
3443                .find(|t| t.starts_with("tier:"))
3444                .unwrap_or_else(|| "tier:?".to_string());
3445            return Err(RunnerError::StepFailed {
3446                step: step.name.clone(),
3447                msg: format!(
3448                    "step '{}' declares a native `{target}` build but is running locally on \
3449                     host `{}`: a native cross-arch build must offload to an arch-matched \
3450                     build-worker (`{tier}`), not emulate under QEMU. Re-run with `--where auto` \
3451                     (policy routes native steps to the fleet) or on a `{target}`-arch host.",
3452                    step.name, self.host_triple,
3453                ),
3454            });
3455        }
3456        // Resolve the cwd that gets bind-mounted into the container. The
3457        // step's optional `cwd` (typically a relative path like
3458        // `packages/yah/ui`) joins onto the camp root so the mount is always
3459        // an absolute path. If neither is set we mount the camp root itself.
3460        let camp_root = self.resolve_camp_root()?;
3461        let mount_cwd = match step.cwd.as_deref() {
3462            Some(rel) => camp_root.join(rel),
3463            None => camp_root,
3464        };
3465        // R590-F2: honor a per-step `image = "<name>"` override (R381 seam);
3466        // fall back to the default forge image (`yah-rust-bun`) for plain steps.
3467        let image = step_image_override(step)?
3468            .unwrap_or_else(velveteen_exec::default_image::default_forge_image);
3469        let spec = build_subprocess_spec(step, TaskRuntime::Container, Some(image));
3470        let ctx = ExecContext::default().with_cwd(mount_cwd).with_env(
3471            step.env
3472                .iter()
3473                .map(|(k, v)| (k.clone(), v.clone()))
3474                .collect(),
3475        );
3476        self.drive_subprocess_step(index, step, spec, ctx).await
3477    }
3478
3479    /// Hand a `(ForgeSpec, ExecContext)` to [`Self::executor`] and translate
3480    /// the outcome back into the qed runner's error vocabulary. An adapter
3481    /// task forwards every [`ExecEvent::Output`] into [`QedEvent::StepOutput`]
3482    /// on the runner's live-event sink — the per-line streaming contract
3483    /// (R325-F2) is preserved through the trait. `Started`/`Finished` events
3484    /// from the executor are absorbed; `run()` already brackets every step
3485    /// with its own `StepStarted`/`StepFinished`.
3486    async fn drive_subprocess_step(
3487        &self,
3488        index: usize,
3489        step: &crate::types::QedStep,
3490        spec: ForgeSpec,
3491        ctx: ExecContext,
3492    ) -> Result<(), RunnerError> {
3493        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<ExecEvent>();
3494        let adapter = {
3495            let events = self.events.clone();
3496            let name = step.name.clone();
3497            tokio::spawn(async move {
3498                while let Some(ev) = rx.recv().await {
3499                    let Some(events) = &events else { continue };
3500                    if let ExecEvent::Output { stream, line } = ev {
3501                        let qed_stream = match stream {
3502                            velveteen_exec::OutputStream::Stdout => OutputStream::Stdout,
3503                            velveteen_exec::OutputStream::Stderr => OutputStream::Stderr,
3504                        };
3505                        let _ = events.send(QedEvent::StepOutput {
3506                            index,
3507                            name: name.clone(),
3508                            stream: qed_stream,
3509                            line,
3510                        });
3511                    }
3512                }
3513            })
3514        };
3515
3516        let outcome_result = self.executor.execute(spec, ctx, Some(tx)).await;
3517        let _ = adapter.await;
3518
3519        match outcome_result {
3520            Ok(outcome) if outcome.succeeded() => Ok(()),
3521            Ok(outcome) => Err(RunnerError::StepFailed {
3522                step: step.name.clone(),
3523                msg: outcome.stderr_tail,
3524            }),
3525            Err(ForgeExecutorError::Spawn(msg)) => Err(RunnerError::StepFailed {
3526                step: step.name.clone(),
3527                msg: format!("failed to spawn (is the runtime installed and accessible?): {msg}"),
3528            }),
3529            Err(ForgeExecutorError::Io(e)) => Err(RunnerError::Io(e)),
3530            Err(ForgeExecutorError::Unsupported(what)) => Err(RunnerError::InvalidConfig(format!(
3531                "subprocess executor: {what}"
3532            ))),
3533        }
3534    }
3535
3536    /// Spawn a background sidecar step (R513-F2) onto its own task and return a
3537    /// [`JoinHandle`] the caller tracks until reap. Mirrors
3538    /// [`Self::drive_subprocess_step`] — same `ExecEvent` → `QedEvent::StepOutput`
3539    /// adapter so a sidecar's logs keep streaming under its step index — but
3540    /// does NOT await completion: the future runs detached so the step loop
3541    /// advances immediately.
3542    ///
3543    /// Only the `executor` + `events` are captured (both cheap `Arc`/`Sender`
3544    /// clones) so the spawned future is `'static`. The inner
3545    /// [`ForgeExecutor::execute`] owns the `kill_on_drop` child, so aborting the
3546    /// returned handle (reap, cancel, or `run_inner` early-return) kills the
3547    /// process.
3548    ///
3549    /// [`JoinHandle`]: tokio::task::JoinHandle
3550    fn spawn_background_step(
3551        &self,
3552        event_index: usize,
3553        step: &crate::types::QedStep,
3554        spec: ForgeSpec,
3555        ctx: ExecContext,
3556    ) -> tokio::task::JoinHandle<Result<(), RunnerError>> {
3557        let executor = self.executor.clone();
3558        let events = self.events.clone();
3559        let name = step.name.clone();
3560        tokio::spawn(async move {
3561            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<ExecEvent>();
3562            let adapter = {
3563                let events = events.clone();
3564                let name = name.clone();
3565                tokio::spawn(async move {
3566                    while let Some(ev) = rx.recv().await {
3567                        let Some(events) = &events else { continue };
3568                        if let ExecEvent::Output { stream, line } = ev {
3569                            let qed_stream = match stream {
3570                                velveteen_exec::OutputStream::Stdout => OutputStream::Stdout,
3571                                velveteen_exec::OutputStream::Stderr => OutputStream::Stderr,
3572                            };
3573                            let _ = events.send(QedEvent::StepOutput {
3574                                index: event_index,
3575                                name: name.clone(),
3576                                stream: qed_stream,
3577                                line,
3578                            });
3579                        }
3580                    }
3581                })
3582            };
3583
3584            let outcome_result = executor.execute(spec, ctx, Some(tx)).await;
3585            let _ = adapter.await;
3586
3587            match outcome_result {
3588                Ok(outcome) if outcome.succeeded() => Ok(()),
3589                Ok(outcome) => Err(RunnerError::StepFailed {
3590                    step: name,
3591                    msg: outcome.stderr_tail,
3592                }),
3593                Err(ForgeExecutorError::Spawn(msg)) => Err(RunnerError::StepFailed {
3594                    step: name,
3595                    msg: format!(
3596                        "failed to spawn (is the runtime installed and accessible?): {msg}"
3597                    ),
3598                }),
3599                Err(ForgeExecutorError::Io(e)) => Err(RunnerError::Io(e)),
3600                Err(ForgeExecutorError::Unsupported(what)) => Err(RunnerError::InvalidConfig(
3601                    format!("subprocess executor: {what}"),
3602                )),
3603            }
3604        })
3605    }
3606}
3607
3608/// Translate a [`yah_qed_gha::GhaEvent`] into the qed-runner's own
3609/// [`crate::QedEvent::Gha*`] variant, stamping the parent step's index and
3610/// name so the desktop pane can scope nested rows under the right step
3611/// (W200 R487 follow-up).
3612fn bridge_gha_event(
3613    step_index: usize,
3614    parent_name: &str,
3615    ev: yah_qed_gha::GhaEvent,
3616) -> crate::QedEvent {
3617    use yah_qed_gha::GhaEvent as G;
3618    let at = chrono::Utc::now();
3619    match ev {
3620        G::JobStarted {
3621            job_id,
3622            matrix_index,
3623            key,
3624            total_steps,
3625        } => crate::QedEvent::GhaJobStarted {
3626            index: step_index,
3627            name: parent_name.to_string(),
3628            job_id,
3629            matrix_index,
3630            job_key: key,
3631            total_steps,
3632            at,
3633        },
3634        G::JobFinished {
3635            job_id: _,
3636            matrix_index: _,
3637            key,
3638            result,
3639        } => crate::QedEvent::GhaJobFinished {
3640            index: step_index,
3641            name: parent_name.to_string(),
3642            job_key: key,
3643            result: gha_result_str(result).to_string(),
3644            at,
3645        },
3646        G::StepStarted {
3647            job_id,
3648            matrix_index,
3649            step_index: gha_step_index,
3650            step_id,
3651            name: step_name,
3652            action_kind,
3653        } => crate::QedEvent::GhaStepStarted {
3654            index: step_index,
3655            name: parent_name.to_string(),
3656            job_key: instance_key(&job_id, matrix_index),
3657            step_index: gha_step_index,
3658            step_id,
3659            step_name,
3660            action_kind,
3661            at,
3662        },
3663        G::StepOutput {
3664            job_id,
3665            matrix_index,
3666            step_index: gha_step_index,
3667            stream,
3668            line,
3669        } => crate::QedEvent::GhaStepOutput {
3670            index: step_index,
3671            name: parent_name.to_string(),
3672            job_key: instance_key(&job_id, matrix_index),
3673            step_index: gha_step_index,
3674            stream: match stream {
3675                yah_qed_gha::GhaOutputStream::Stdout => crate::events::OutputStream::Stdout,
3676                yah_qed_gha::GhaOutputStream::Stderr => crate::events::OutputStream::Stderr,
3677            },
3678            line,
3679        },
3680        G::StepFinished {
3681            job_id,
3682            matrix_index,
3683            step_index: gha_step_index,
3684            conclusion,
3685            msg,
3686            outputs: _,
3687        } => crate::QedEvent::GhaStepFinished {
3688            index: step_index,
3689            name: parent_name.to_string(),
3690            job_key: instance_key(&job_id, matrix_index),
3691            step_index: gha_step_index,
3692            conclusion: gha_conclusion_str(conclusion).to_string(),
3693            msg,
3694            at,
3695        },
3696    }
3697}
3698
3699/// Same key format as [`yah_qed_gha::JobInstance::key`] — `"<job>"` for non-matrix
3700/// jobs, `"<job>#<row>"` for matrix rows. Kept in sync by construction; the
3701/// receiver pairs Start / Finish by exact-string compare.
3702fn instance_key(job_id: &str, matrix_index: Option<usize>) -> String {
3703    match matrix_index {
3704        Some(idx) => format!("{job_id}#{idx}"),
3705        None => job_id.to_string(),
3706    }
3707}
3708
3709fn gha_result_str(r: yah_qed_gha::JobResult) -> &'static str {
3710    match r {
3711        yah_qed_gha::JobResult::Success => "success",
3712        yah_qed_gha::JobResult::Failure => "failure",
3713        yah_qed_gha::JobResult::Cancelled => "cancelled",
3714        yah_qed_gha::JobResult::Skipped => "skipped",
3715    }
3716}
3717
3718fn gha_conclusion_str(c: yah_qed_gha::StepConclusion) -> &'static str {
3719    match c {
3720        yah_qed_gha::StepConclusion::Success => "success",
3721        yah_qed_gha::StepConclusion::Failure => "failure",
3722        yah_qed_gha::StepConclusion::Skipped => "skipped",
3723    }
3724}
3725
3726/// Last `lines` non-blank lines of `stderr`, with qed-gha's internal
3727/// `$GITHUB_ENV` sidechannel marker stripped (see `pop_env_updates` in
3728/// yah_qed_gha::runtime). Empty when there is nothing useful left to show.
3729fn stderr_tail(stderr: &str, lines: usize) -> String {
3730    const ENV_PREFIX: &str = "__qed_gha_env_updates_BEGIN__";
3731    const ENV_SUFFIX: &str = "__qed_gha_env_updates_END__";
3732    let cleaned: String = stderr
3733        .lines()
3734        .filter(|l| {
3735            let t = l.trim();
3736            !t.starts_with(ENV_PREFIX) && !t.starts_with(ENV_SUFFIX) && !t.is_empty()
3737        })
3738        .collect::<Vec<_>>()
3739        .join("\n");
3740    if cleaned.is_empty() {
3741        return String::new();
3742    }
3743    let trimmed: Vec<&str> = cleaned.lines().collect();
3744    let start = trimmed.len().saturating_sub(lines);
3745    trimmed[start..].join("\n")
3746}
3747
3748/// Render the failure detail for one failed GHA job instance: the first failing
3749/// step's name plus its stderr tail. Shared by the flattened step-level failure
3750/// summary (which prefixes the job id) and the structured per-job rows (W223
3751/// R532-T1, where the [`crate::types::JobRow`] already carries the job id, so
3752/// this is the row's `error` text without the redundant prefix).
3753fn gha_job_failure_detail(job: &yah_qed_gha::InstanceRun) -> String {
3754    let failing_step = job
3755        .steps
3756        .iter()
3757        .find(|s| matches!(s.conclusion, yah_qed_gha::StepConclusion::Failure));
3758    match failing_step {
3759        Some(s) => {
3760            let label = s
3761                .name
3762                .clone()
3763                .or_else(|| s.step_id.clone())
3764                .unwrap_or_else(|| "<unnamed>".to_string());
3765            let tail = stderr_tail(&s.stderr, 20);
3766            if tail.is_empty() {
3767                format!("step `{label}` (no stderr)")
3768            } else {
3769                format!("step `{label}`:\n{tail}")
3770            }
3771        }
3772        None => "(no failing step recorded — likely an override / scheduler error)".to_string(),
3773    }
3774}
3775
3776/// Build a minimal `yah_qed_gha::Value` object from a string map. Used to lower
3777/// `[gha_workflow] inputs = { tag = "v1" }` into the runtime's `inputs.*`
3778/// expression context.
3779fn inputs_to_value(inputs: &std::collections::HashMap<String, String>) -> yah_qed_gha::Value {
3780    let mut m: indexmap::IndexMap<String, yah_qed_gha::Value> = indexmap::IndexMap::new();
3781    for (k, v) in inputs {
3782        m.insert(k.clone(), yah_qed_gha::Value::String(v.clone()));
3783    }
3784    yah_qed_gha::Value::Object(m)
3785}
3786
3787/// RAII guard for a `WorkspaceMode::Isolated` git worktree (W224). Dropping it
3788/// runs `git worktree remove --force` so a release run — including one that
3789/// errors mid-step — never leaves an orphaned tree behind. Best-effort: a
3790/// failed removal is swallowed (the next run's pre-add cleanup clears it).
3791#[derive(Debug)]
3792struct WorktreeGuard {
3793    camp_root: std::path::PathBuf,
3794    worktree: std::path::PathBuf,
3795}
3796
3797impl Drop for WorktreeGuard {
3798    fn drop(&mut self) {
3799        let _ = std::process::Command::new("git")
3800            .current_dir(&self.camp_root)
3801            .args(["worktree", "remove", "--force"])
3802            .arg(&self.worktree)
3803            .output();
3804    }
3805}
3806
3807/// Run a git command in `dir`, mapping a non-zero exit to its trimmed stderr.
3808fn run_git(dir: &std::path::Path, args: &[&str]) -> Result<(), String> {
3809    let out = std::process::Command::new("git")
3810        .current_dir(dir)
3811        .args(args)
3812        .output()
3813        .map_err(|e| format!("spawn git: {e}"))?;
3814    if out.status.success() {
3815        Ok(())
3816    } else {
3817        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
3818    }
3819}
3820
3821/// Porcelain path prefixes that are camp *runtime* state, not build source:
3822/// the daemon rewrites the turso databases under `.yah/db/` continuously (and
3823/// the shared-tree peer model sweeps them into `wip` commits), so they show as
3824/// tracked-dirty on essentially every run. They never change which source bytes
3825/// a build compiles or a release tags, so gating a `checkout`/`isolated` run on
3826/// them would refuse every pipeline in a live camp for no safety benefit.
3827const DIRTY_CHECK_IGNORED_PREFIXES: &[&str] = &[".yah/db/"];
3828
3829/// True when the working tree has uncommitted *tracked* changes that matter to
3830/// a build. Untracked files are ignored (`--untracked-files=no`): they don't
3831/// change which committed bytes a build sees, and a working camp almost always
3832/// carries some. Tracked changes confined to [`DIRTY_CHECK_IGNORED_PREFIXES`]
3833/// (camp runtime DBs) are also ignored — see that constant for why.
3834fn git_tree_is_dirty(dir: &std::path::Path) -> Result<bool, RunnerError> {
3835    let out = std::process::Command::new("git")
3836        .current_dir(dir)
3837        .args(["status", "--porcelain", "--untracked-files=no"])
3838        .output()
3839        .map_err(RunnerError::Io)?;
3840    if !out.status.success() {
3841        return Err(RunnerError::InvalidConfig(format!(
3842            "git status failed in {}: {}",
3843            dir.display(),
3844            String::from_utf8_lossy(&out.stderr).trim()
3845        )));
3846    }
3847    let dirty = String::from_utf8_lossy(&out.stdout)
3848        .lines()
3849        .filter(|l| !l.trim().is_empty())
3850        .any(|line| !porcelain_path_is_ignored(line));
3851    Ok(dirty)
3852}
3853
3854/// Given one `git status --porcelain` line (`XY <path>`, or `XY orig -> new`
3855/// for a rename), return true when its path is under a
3856/// [`DIRTY_CHECK_IGNORED_PREFIXES`] runtime prefix. Unknown/short lines are
3857/// treated as *not* ignored (fail safe: a line we can't parse still counts as
3858/// dirty). A rename is ignored only when its destination path is runtime state.
3859fn porcelain_path_is_ignored(line: &str) -> bool {
3860    // Porcelain v1: 2 status columns + a space, then the path (byte 3 on).
3861    let Some(rest) = line.get(3..) else {
3862        return false;
3863    };
3864    // Rename/copy entries read `orig -> new`; the destination is what the tree
3865    // now carries, so key the decision off it.
3866    let path = rest.rsplit(" -> ").next().unwrap_or(rest);
3867    // Git quotes paths with unusual chars ("path"); strip a leading quote so
3868    // the prefix match still fires on the (plain-ASCII) runtime DB paths.
3869    let path = path.trim().trim_start_matches('"');
3870    DIRTY_CHECK_IGNORED_PREFIXES
3871        .iter()
3872        .any(|prefix| path.starts_with(prefix))
3873}
3874
3875/// Synthesize a `github` expression context for a GhaWorkflow step from the
3876/// camp's live git state. `release.yml` references `github.sha` (the
3877/// `:smoke-<sha>` image tag), `github.ref_name` (tarball stage dirs + the
3878/// `!contains(ref_name, '-')` smoke gate) and `github.actor` (ghcr login),
3879/// so leaving these empty produced malformed `:smoke-` tags and `cli--<triple>`
3880/// stage names. We read them from the workspace's git checkout, mirroring what
3881/// a real runner gets from the push event:
3882///   sha      = `git rev-parse HEAD` (full 40-char, matching GHA)
3883///   ref_name = exact tag if HEAD is tagged, else the current branch
3884///   actor    = `git config user.name`
3885/// Each lookup degrades to empty on error (detached/dirty/no-git) rather than
3886/// failing the step — an empty field is no worse than the old behaviour.
3887fn github_context(event_name: &str, workspace: &std::path::Path) -> yah_qed_gha::Value {
3888    let git = |args: &[&str]| -> String {
3889        std::process::Command::new("git")
3890            .current_dir(workspace)
3891            .args(args)
3892            .output()
3893            .ok()
3894            .filter(|o| o.status.success())
3895            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
3896            .unwrap_or_default()
3897    };
3898
3899    let sha = git(&["rev-parse", "HEAD"]);
3900    // Prefer an exact tag (the real release trigger shape) over the branch.
3901    let exact_tag = git(&["describe", "--tags", "--exact-match"]);
3902    let (ref_name, ref_full) = if !exact_tag.is_empty() {
3903        (exact_tag.clone(), format!("refs/tags/{exact_tag}"))
3904    } else {
3905        let branch = git(&["rev-parse", "--abbrev-ref", "HEAD"]);
3906        let full = if branch.is_empty() {
3907            String::new()
3908        } else {
3909            format!("refs/heads/{branch}")
3910        };
3911        (branch, full)
3912    };
3913    let actor = git(&["config", "user.name"]);
3914
3915    let mut m: indexmap::IndexMap<String, yah_qed_gha::Value> = indexmap::IndexMap::new();
3916    m.insert(
3917        "event_name".into(),
3918        yah_qed_gha::Value::String(event_name.into()),
3919    );
3920    m.insert("ref".into(), yah_qed_gha::Value::String(ref_full));
3921    m.insert("ref_name".into(), yah_qed_gha::Value::String(ref_name));
3922    m.insert("sha".into(), yah_qed_gha::Value::String(sha));
3923    m.insert("actor".into(), yah_qed_gha::Value::String(actor));
3924    m.insert(
3925        "event".into(),
3926        yah_qed_gha::Value::Object(indexmap::IndexMap::new()),
3927    );
3928    yah_qed_gha::Value::Object(m)
3929}
3930
3931/// Lower a `QedStep` into a `ForgeSpec` for the local subprocess executor
3932/// (R438-T14). The image is `Some` for the container path and `None` for
3933/// native — the executor branches on `where_.runtime` and rejects a missing
3934/// image when it needs one.
3935fn build_subprocess_spec(
3936    step: &crate::types::QedStep,
3937    runtime: TaskRuntime,
3938    image: Option<workload_spec::ImageRef>,
3939) -> ForgeSpec {
3940    ForgeSpec {
3941        command: ForgeCommand::Subprocess {
3942            argv: step.argv.clone(),
3943            image,
3944        },
3945        where_: TaskPlacement::new(TaskLocation::Local, runtime),
3946        timeout: step.timeout.map(Millis::from_secs),
3947        label: Some(step.name.clone()),
3948        initiator: Initiator::Human { camp: "qed".into() },
3949        mesh_access: MeshAccess::None,
3950    }
3951}
3952
3953/// Substitute `${{ steps.STEP_NAME.outputs.KEY }}` placeholders in `s`
3954/// using the accumulated step context (W201-F4). Unknown placeholders are
3955/// left untouched — downstream tooling (or the W200 expression engine once
3956/// R487-F2 ships) handles them. The pattern is intentionally minimal: no
3957/// expression evaluation, no escaping, no nested references.
3958/// Human-readable token for a [`SubPipelineRef`] (R488-F5). Matches the
3959/// resolver-token discipline used by `validate_sub_pipeline_graph` and the
3960/// in-memory test resolver: `builtin:<name>`, `path:<path>`, `gha:<path>`.
3961/// Surfaced on `QedEvent::SubPipelineStarted.target` so a consumer can label
3962/// the child run without a back-reference to the parent pipeline TOML.
3963fn sub_pipeline_target_label(target: &crate::types::SubPipelineRef) -> String {
3964    match target {
3965        crate::types::SubPipelineRef::Builtin(n) => format!("builtin:{n}"),
3966        crate::types::SubPipelineRef::Path(p) => format!("path:{}", p.display()),
3967        crate::types::SubPipelineRef::GhaWorkflow { path, .. } => {
3968            format!("gha:{}", path.display())
3969        }
3970        crate::types::SubPipelineRef::Peer { camp, pipeline } => {
3971            format!("peer:{camp}:{pipeline}")
3972        }
3973    }
3974}
3975
3976/// R506: peel one layer of `${{ … }}` delimiters off an `if=` body so a
3977/// pipeline author can write either `if = "matrix.target == 'mac'"` or
3978/// `if = "${{ matrix.target == 'mac' }}"` interchangeably. Mirrors GHA's
3979/// implicit-expression-body semantics for the job-level `if:` key.
3980fn strip_expr_delimiters(input: &str) -> &str {
3981    let t = input.trim();
3982    if let Some(inner) = t.strip_prefix("${{").and_then(|s| s.strip_suffix("}}")) {
3983        inner.trim()
3984    } else {
3985        t
3986    }
3987}
3988
3989fn substitute_step_context(
3990    s: &str,
3991    context: &std::collections::HashMap<String, std::collections::HashMap<String, String>>,
3992) -> String {
3993    let mut out = s.to_string();
3994    for (step_name, outputs) in context {
3995        for (key, value) in outputs {
3996            let pattern = format!("${{{{ steps.{step_name}.outputs.{key} }}}}");
3997            out = out.replace(&pattern, value);
3998        }
3999    }
4000    out
4001}
4002
4003/// Parse a `KEY=VALUE\n`-formatted file written by a step to `$YAH_OUTPUTS`.
4004/// Lines that don't contain `=` are silently skipped (e.g. blank lines or
4005/// comment lines). Returns an empty map if the file doesn't exist or can't
4006/// be read — steps that emit no outputs are the common case.
4007fn parse_yah_outputs(path: &std::path::Path) -> std::collections::HashMap<String, String> {
4008    let Ok(content) = std::fs::read_to_string(path) else {
4009        return std::collections::HashMap::new();
4010    };
4011    content
4012        .lines()
4013        .filter_map(|line| {
4014            let (k, v) = line.split_once('=')?;
4015            let k = k.trim().to_string();
4016            if k.is_empty() {
4017                return None;
4018            }
4019            Some((k, v.to_string()))
4020        })
4021        .collect()
4022}
4023
4024impl PipelineRunner {
4025    /// Dispatch a `kind = "build-image"` step.
4026    ///
4027    /// Catalog lookup + Dockerfile staging is shared across local and remote:
4028    ///
4029    /// 1. Look up the catalog entry named by `step.image` (R381-T1 bundled +
4030    ///    per-camp).
4031    /// 2. Compile a Dockerfile via [`crate::images::compile_with_dockerfile_dir`]
4032    ///    (sibling Dockerfile wins; otherwise the TOML layering shorthand is
4033    ///    rendered). Per-camp dir is `<camp_root>/.yah/qed/images/<name>/`.
4034    /// 3. Write the Dockerfile under `.yah/cache/buildkit/<name>.Dockerfile`.
4035    ///
4036    /// Local path then calls [`task::local::build_image_command`] (docker
4037    /// buildx); remote path synthesises a BuildKit-in-containerd workload via
4038    /// [`task::remote::RemoteForgeDriver`] and waits for the terminal status.
4039    /// Both paths surface step output through the shared QedEvent sink — for
4040    /// remote, the per-line stream flows through scryer (`forge.remote`
4041    /// target) rather than this runner directly, mirroring
4042    /// [`Self::execute_step_remote`].
4043    async fn execute_step_build_image(
4044        &self,
4045        index: usize,
4046        step: &crate::types::QedStep,
4047    ) -> Result<Option<ObsForgeId>, RunnerError> {
4048        use std::process::Stdio;
4049        use tokio::io::{AsyncBufReadExt, BufReader};
4050
4051        let prepared = self.prepare_build_image(step)?;
4052
4053        // R633: route on the step's EFFECTIVE placement, not the runner's raw
4054        // `--where`. Under the default `Auto` a `native = true` cross-arch
4055        // build-image step resolves to Offload → Remote, exactly like a
4056        // subprocess step; reading `self.run_where` here made every Auto run
4057        // build on the qed host regardless, which is how a foreign-arch image
4058        // silently came out host-arch (or emulated).
4059        if matches!(self.effective_placement(step), RunWhere::Remote) {
4060            let forge_id = self
4061                .execute_step_build_image_remote(step, &prepared)
4062                .await?;
4063            return Ok(Some(forge_id));
4064        }
4065
4066        // Local docker daemon: refuse to build a foreign platform here. buildx
4067        // would happily do it under QEMU, which for a from-source toolchain
4068        // image is either wrong-by-construction or an OOM — the same refusal
4069        // `execute_step_local_container` makes for foreign-arch container steps.
4070        self.refuse_foreign_platform_locally(step)?;
4071
4072        let context_buf = step
4073            .context
4074            .as_ref()
4075            .map(|ctx| prepared.camp_root.join(ctx));
4076        let context = context_buf
4077            .as_deref()
4078            .unwrap_or_else(|| std::path::Path::new("."));
4079
4080        let cmd = {
4081            let opts = velveteen_exec::local::BuildImageOptions {
4082                dockerfile: &prepared.dockerfile_path,
4083                context,
4084                tag: &prepared.tag,
4085                push: step.push,
4086                load: step.load,
4087                cache_dir: Some(&prepared.buildkit_dir),
4088                oci_archive: if step.push || step.load {
4089                    None
4090                } else {
4091                    Some(&prepared.archive_path)
4092                },
4093                // R633: `platforms` now comes from the step (`platforms = [...]`
4094                // in TOML, `--platform` on `yah qed images build`). Empty keeps
4095                // the pre-R633 behaviour: buildx builds the daemon's own
4096                // platform. Foreign entries were rejected above.
4097                platforms: &step.platforms,
4098                build_args: &[],
4099            };
4100            velveteen_exec::local::build_image_command(&opts)
4101        };
4102
4103        let mut cmd = cmd;
4104        cmd.stdout(Stdio::piped());
4105        cmd.stderr(Stdio::piped());
4106
4107        let mut child = cmd.spawn().map_err(|e| RunnerError::StepFailed {
4108            step: step.name.clone(),
4109            msg: format!("failed to spawn `docker buildx`: {e}"),
4110        })?;
4111        let stdout = child.stdout.take().expect("stdout piped above");
4112        let stderr = child.stderr.take().expect("stderr piped above");
4113
4114        let stdout_task = {
4115            let events = self.events.clone();
4116            let name = step.name.clone();
4117            tokio::spawn(async move {
4118                let mut lines = BufReader::new(stdout).lines();
4119                while let Ok(Some(line)) = lines.next_line().await {
4120                    if let Some(tx) = &events {
4121                        let _ = tx.send(QedEvent::StepOutput {
4122                            index,
4123                            name: name.clone(),
4124                            stream: OutputStream::Stdout,
4125                            line,
4126                        });
4127                    }
4128                }
4129            })
4130        };
4131
4132        let stderr_task = {
4133            let events = self.events.clone();
4134            let name = step.name.clone();
4135            tokio::spawn(async move {
4136                let mut captured: Vec<String> = Vec::new();
4137                let mut lines = BufReader::new(stderr).lines();
4138                while let Ok(Some(line)) = lines.next_line().await {
4139                    if let Some(tx) = &events {
4140                        let _ = tx.send(QedEvent::StepOutput {
4141                            index,
4142                            name: name.clone(),
4143                            stream: OutputStream::Stderr,
4144                            line: line.clone(),
4145                        });
4146                    }
4147                    captured.push(line);
4148                }
4149                captured
4150            })
4151        };
4152
4153        let status = child.wait().await.map_err(|e| RunnerError::StepFailed {
4154            step: step.name.clone(),
4155            msg: format!("waiting on `docker buildx` failed: {e}"),
4156        })?;
4157        let _ = stdout_task.await;
4158        let stderr_lines = stderr_task.await.unwrap_or_default();
4159
4160        if !status.success() {
4161            return Err(RunnerError::StepFailed {
4162                step: step.name.clone(),
4163                msg: stderr_lines.join("\n").trim().to_string(),
4164            });
4165        }
4166        Ok(None)
4167    }
4168
4169    /// `kind = manifest-stitch` (R590-F2): fold N per-arch source images into
4170    /// one multi-arch manifest list via `docker buildx imagetools create`.
4171    ///
4172    /// Registry-only — the per-arch builds already pushed their arch-specific
4173    /// tags to the registry (routed to the arch-matched build-worker fleet);
4174    /// this step just writes the manifest-list tag. It always runs host-native
4175    /// (see [`Self::resolve_runtime`]) even under `--where=remote`, so it shells
4176    /// `docker buildx` on the qed host and streams output like the local
4177    /// build-image path.
4178    async fn execute_step_manifest_stitch(
4179        &self,
4180        event_index: usize,
4181        step: &crate::types::QedStep,
4182    ) -> Result<(), RunnerError> {
4183        use std::process::Stdio;
4184        use tokio::io::{AsyncBufReadExt, BufReader};
4185
4186        let Some(cfg) = step.manifest_stitch.as_ref() else {
4187            return Err(RunnerError::InvalidConfig(format!(
4188                "step `{}`: kind=manifest-stitch with no [manifest_stitch] block (validate() should have caught this)",
4189                step.name,
4190            )));
4191        };
4192
4193        self.emit(QedEvent::StepOutput {
4194            index: event_index,
4195            name: step.name.clone(),
4196            stream: OutputStream::Stdout,
4197            line: format!(
4198                "manifest-stitch: creating `{}` from [{}]",
4199                cfg.target,
4200                cfg.sources.join(", "),
4201            ),
4202        });
4203
4204        let mut cmd = velveteen_exec::local::imagetools_create_command(&cfg.target, &cfg.sources);
4205        cmd.stdout(Stdio::piped());
4206        cmd.stderr(Stdio::piped());
4207
4208        let mut child = cmd.spawn().map_err(|e| RunnerError::StepFailed {
4209            step: step.name.clone(),
4210            msg: format!("failed to spawn `docker buildx imagetools create`: {e}"),
4211        })?;
4212        let stdout = child.stdout.take().expect("stdout piped above");
4213        let stderr = child.stderr.take().expect("stderr piped above");
4214
4215        let stdout_task = {
4216            let events = self.events.clone();
4217            let name = step.name.clone();
4218            tokio::spawn(async move {
4219                let mut lines = BufReader::new(stdout).lines();
4220                while let Ok(Some(line)) = lines.next_line().await {
4221                    if let Some(tx) = &events {
4222                        let _ = tx.send(QedEvent::StepOutput {
4223                            index: event_index,
4224                            name: name.clone(),
4225                            stream: OutputStream::Stdout,
4226                            line,
4227                        });
4228                    }
4229                }
4230            })
4231        };
4232
4233        let stderr_task = {
4234            let events = self.events.clone();
4235            let name = step.name.clone();
4236            tokio::spawn(async move {
4237                let mut captured: Vec<String> = Vec::new();
4238                let mut lines = BufReader::new(stderr).lines();
4239                while let Ok(Some(line)) = lines.next_line().await {
4240                    if let Some(tx) = &events {
4241                        let _ = tx.send(QedEvent::StepOutput {
4242                            index: event_index,
4243                            name: name.clone(),
4244                            stream: OutputStream::Stderr,
4245                            line: line.clone(),
4246                        });
4247                    }
4248                    captured.push(line);
4249                }
4250                captured
4251            })
4252        };
4253
4254        let status = child.wait().await.map_err(|e| RunnerError::StepFailed {
4255            step: step.name.clone(),
4256            msg: format!("waiting on `docker buildx imagetools create` failed: {e}"),
4257        })?;
4258        let _ = stdout_task.await;
4259        let stderr_lines = stderr_task.await.unwrap_or_default();
4260
4261        if !status.success() {
4262            return Err(RunnerError::StepFailed {
4263                step: step.name.clone(),
4264                msg: stderr_lines.join("\n").trim().to_string(),
4265            });
4266        }
4267        Ok(())
4268    }
4269
4270    /// R633: reject a local build-image step whose declared `platforms` include
4271    /// an arch this host cannot build natively.
4272    ///
4273    /// `docker buildx --platform linux/amd64` on an arm64 daemon does not fail —
4274    /// it emulates through QEMU, silently and slowly, and for a from-source
4275    /// toolchain image (rusty-v8-musl-builder) that is either an OOM or a
4276    /// wrong-by-construction artifact. The honest answer is a hard error naming
4277    /// the mesh tier that *can* build it, so the operator's next move is
4278    /// `--where auto` (offload) rather than a six-hour emulated build.
4279    ///
4280    /// An unrecognized platform string is let through: buildx owns that
4281    /// vocabulary and will produce a better message than a guess would.
4282    fn refuse_foreign_platform_locally(
4283        &self,
4284        step: &crate::types::QedStep,
4285    ) -> Result<(), RunnerError> {
4286        let host_arch = crate::platform::arch_of(&self.host_triple);
4287        for platform in &step.platforms {
4288            let Some(want) = crate::platform::docker_platform_arch(platform) else {
4289                continue;
4290            };
4291            if want == host_arch {
4292                continue;
4293            }
4294            let tier = crate::platform::build_worker_mesh_tags(want)
4295                .into_iter()
4296                .find(|t| t.starts_with("tier:"))
4297                .unwrap_or_else(|| "tier:?".to_string());
4298            return Err(RunnerError::StepFailed {
4299                step: step.name.clone(),
4300                msg: format!(
4301                    "build-image step '{}' targets platform `{platform}` ({want}) but this host \
4302                     is `{}`: building it here means QEMU emulation, not a native image. Declare \
4303                     `platform = {{ native = true, target = \"...\" }}` on the step and run with \
4304                     `--where auto` so it routes to a `{tier}` build-worker, or run on a {want} host.",
4305                    step.name, self.host_triple,
4306                ),
4307            });
4308        }
4309        Ok(())
4310    }
4311
4312    /// Shared catalog-lookup + Dockerfile-staging path used by both local and
4313    /// remote build-image dispatch.
4314    fn prepare_build_image(
4315        &self,
4316        step: &crate::types::QedStep,
4317    ) -> Result<PreparedBuildImage, RunnerError> {
4318        let camp_root = self.resolve_camp_root()?;
4319        let camp_images_dir = camp_root.join(".yah/qed/images");
4320        let catalog = crate::images::CatalogManifest::load(&camp_images_dir)
4321            .map_err(|e| RunnerError::InvalidConfig(format!("failed to load catalog: {e}")))?;
4322
4323        let image_name = step.image.as_deref().ok_or_else(|| {
4324            RunnerError::InvalidConfig(format!(
4325                "build-image step `{}` is missing the `image` field (parse-time validation should have caught this)",
4326                step.name
4327            ))
4328        })?;
4329
4330        let entry = catalog.get(image_name).cloned().ok_or_else(|| {
4331            let known: Vec<&str> = catalog.names();
4332            RunnerError::StepFailed {
4333                step: step.name.clone(),
4334                msg: format!(
4335                    "unknown catalog image `{image_name}` — known: {known:?}. \
4336                     Per-camp images live at `.yah/qed/images/<name>/`."
4337                ),
4338            }
4339        })?;
4340
4341        // R633: resolve the entry's context directory across the whole search
4342        // path, not just the per-camp slot. A bundled entry's Dockerfile lives
4343        // in the qed crate's own `images/<name>/`; looking only under
4344        // `.yah/qed/images/` meant every bundled entry with a real Dockerfile
4345        // silently fell back to its (near-empty) TOML layering.
4346        let image_dir = crate::images::resolve_image_dir(&camp_root, image_name)
4347            .map(|rel| camp_root.join(rel))
4348            .unwrap_or_else(|| camp_images_dir.join(image_name));
4349        let dockerfile_text = crate::images::compile_with_dockerfile_dir(
4350            &entry, &catalog, &image_dir,
4351        )
4352        .map_err(|e| RunnerError::StepFailed {
4353            step: step.name.clone(),
4354            msg: format!("Dockerfile compile failed for `{image_name}`: {e}"),
4355        })?;
4356
4357        let cache_root = camp_root.join(".yah/cache");
4358        let buildkit_dir = cache_root.join("buildkit");
4359        let archive_dir = cache_root.join("images");
4360        std::fs::create_dir_all(&buildkit_dir).map_err(|e| RunnerError::StepFailed {
4361            step: step.name.clone(),
4362            msg: format!("failed to create {}: {e}", buildkit_dir.display()),
4363        })?;
4364        std::fs::create_dir_all(&archive_dir).map_err(|e| RunnerError::StepFailed {
4365            step: step.name.clone(),
4366            msg: format!("failed to create {}: {e}", archive_dir.display()),
4367        })?;
4368
4369        let dockerfile_path = buildkit_dir.join(format!("{image_name}.Dockerfile"));
4370        std::fs::write(&dockerfile_path, &dockerfile_text).map_err(|e| {
4371            RunnerError::StepFailed {
4372                step: step.name.clone(),
4373                msg: format!("failed to write {}: {e}", dockerfile_path.display()),
4374            }
4375        })?;
4376
4377        let tag = step
4378            .tag
4379            .clone()
4380            .unwrap_or_else(|| format!("{image_name}:dev"));
4381        let safe_tag = tag_to_filename(&tag);
4382        let archive_path = archive_dir.join(format!("{safe_tag}.tar"));
4383
4384        Ok(PreparedBuildImage {
4385            camp_root,
4386            dockerfile_path,
4387            buildkit_dir,
4388            archive_path,
4389            tag,
4390        })
4391    }
4392
4393    /// Remote build-image dispatch: hand the staged Dockerfile + context to
4394    /// the yubaba via a BuildKit-in-containerd workload (R381-T5).
4395    ///
4396    /// Mirrors [`Self::execute_step_remote`] but for `ForgeCommand::BuildImage`.
4397    /// The host-side dockerfile + context paths are passed verbatim to yubaba
4398    /// as bind-mount targets; this assumes the yubaba node has access to those
4399    /// paths (single-machine sim/dogfood case). Cross-host context shipping
4400    /// (R091 artifact transport) is its own follow-up.
4401    /// The arch a remote build-image step should be routed to (R636).
4402    ///
4403    /// A build-image step that resolves to [`Offload`](crate::platform::Resolution::Offload)
4404    /// — i.e. it declares a foreign-arch target via `platform.native = true` —
4405    /// must build on a worker of that *target* arch, so the tier is derived from
4406    /// the Offload target. Any other resolution means the step has no cross-arch
4407    /// target (a plain host-native build-image forced remote with `--where
4408    /// remote`), so it builds for the runner's own arch. Returns an owned
4409    /// `String` because the Offload target is owned.
4410    fn remote_build_image_arch(&self, step: &crate::types::QedStep) -> String {
4411        match self.resolve_step(step) {
4412            crate::platform::Resolution::Offload { target } => {
4413                crate::platform::arch_of(&target).to_string()
4414            }
4415            _ => crate::platform::arch_of(&self.host_triple).to_string(),
4416        }
4417    }
4418
4419    async fn execute_step_build_image_remote(
4420        &self,
4421        step: &crate::types::QedStep,
4422        prepared: &PreparedBuildImage,
4423    ) -> Result<ObsForgeId, RunnerError> {
4424        let driver = self.remote_driver.as_ref().ok_or_else(|| {
4425            RunnerError::InvalidConfig(format!(
4426                "build-image step `{}` dispatched remote but no remote dispatcher \
4427                 is wired",
4428                step.name,
4429            ))
4430        })?;
4431
4432        let spec = ForgeSpec {
4433            command: ForgeCommand::BuildImage {
4434                dockerfile: prepared.dockerfile_path.clone(),
4435                context: prepared.camp_root.clone(),
4436                tags: vec![prepared.tag.clone()],
4437                // The catalog build-image path targets the worker's native arch
4438                // (placement below routes to an arch-matched build-worker), so
4439                // no explicit `--platform`. Multi-arch is stitched from N native
4440                // builds by the imagetools step, not requested here. Catalog
4441                // images take no build-args today.
4442                platforms: vec![],
4443                build_args: vec![],
4444                push: step.push,
4445                load: step.load,
4446            },
4447            where_: TaskPlacement::new(
4448                TaskLocation::RemoteAny {
4449                    tier: TierTag("infra".into()),
4450                    // R594/R636: route to a build-worker matching the step's
4451                    // TARGET arch, not the runner's host arch. A `yah qed images
4452                    // build --platform linux/amd64` from an arm64 Mac declares a
4453                    // `native = true` x86 target and must land on a `tier:x86`
4454                    // worker — deriving the tier from `self.host_triple` (arm64)
4455                    // instead sent it to a `tier:arm` RPi that then failed on an
4456                    // unreachable loopback URL. `remote_build_image_arch` reads
4457                    // the step's Offload target and falls back to the host arch
4458                    // only when the step declares no cross-arch target (a plain
4459                    // host-native build-image under `--where remote`).
4460                    mesh_tags: crate::platform::build_worker_mesh_tags(
4461                        &self.remote_build_image_arch(step),
4462                    ),
4463                },
4464                TaskRuntime::Container,
4465            ),
4466            timeout: step.timeout.map(Millis::from_secs),
4467            label: Some(step.name.clone()),
4468            initiator: Initiator::Human { camp: "qed".into() },
4469            mesh_access: MeshAccess::None,
4470        };
4471
4472        let handle = driver
4473            .start(spec)
4474            .await
4475            .map_err(|e| RunnerError::Remote(e.to_string()))?;
4476        let forge_id = handle.id.clone();
4477        let status = handle.wait().await;
4478
4479        match status {
4480            ForgeStatus::Done { exit_code: 0, .. } => Ok(forge_id),
4481            ForgeStatus::Done { exit_code, .. } => Err(RunnerError::StepFailed {
4482                step: step.name.clone(),
4483                msg: format!("buildkit exited with code {exit_code}"),
4484            }),
4485            ForgeStatus::TimedOut { .. } => Err(RunnerError::StepFailed {
4486                step: step.name.clone(),
4487                msg: "build-image step timed out".into(),
4488            }),
4489            ForgeStatus::Killed { signal, .. } => Err(RunnerError::StepFailed {
4490                step: step.name.clone(),
4491                msg: format!("buildkit killed by signal {signal}"),
4492            }),
4493            ForgeStatus::Lost { reason } => Err(RunnerError::StepFailed {
4494                step: step.name.clone(),
4495                msg: format!("buildkit lost: {reason}"),
4496            }),
4497            ForgeStatus::Pending | ForgeStatus::Running => {
4498                unreachable!("ForgeRunHandle::wait returns a terminal status")
4499            }
4500        }
4501    }
4502
4503    /// Dispatch a `kind = "package-native-tarball"` step (R407-T2).
4504    ///
4505    /// Pure host file I/O — there is no remote variant. Looks up the catalog
4506    /// entry named by `step.image`, asserts it declares
4507    /// [`crate::images::ProduceTarget::NativeTarball`], then writes a
4508    /// `<camp_root>/.yah/cache/native/<image>-<triple>.tar.gz` containing the
4509    /// static musl binary at `step.binary_path` plus a `manifest.toml`
4510    /// describing the workload-spec. The manifest carries the catalog entry's
4511    /// `env` map and `description` so Kamaji knows how to launch the
4512    /// workload without re-reading the catalog at deploy time.
4513    ///
4514    /// Cross-compile preflight (R407-T3) is the gate that ensures
4515    /// `step.binary_path` is actually musl-static before this step runs — by
4516    /// the time we get here the binary is assumed to be correctly targeted.
4517    async fn execute_step_package_native_tarball(
4518        &self,
4519        step: &crate::types::QedStep,
4520    ) -> Result<(), RunnerError> {
4521        let camp_root = self.resolve_camp_root()?;
4522        let camp_images_dir = camp_root.join(".yah/qed/images");
4523        let catalog = crate::images::CatalogManifest::load(&camp_images_dir)
4524            .map_err(|e| RunnerError::InvalidConfig(format!("failed to load catalog: {e}")))?;
4525
4526        let image_name = step.image.as_deref().ok_or_else(|| {
4527            RunnerError::InvalidConfig(format!(
4528                "package-native-tarball step `{}` is missing `image` \
4529                 (parse-time validation should have caught this)",
4530                step.name
4531            ))
4532        })?;
4533        let entry = catalog.get(image_name).cloned().ok_or_else(|| {
4534            let known: Vec<&str> = catalog.names();
4535            RunnerError::StepFailed {
4536                step: step.name.clone(),
4537                msg: format!(
4538                    "unknown catalog image `{image_name}` — known: {known:?}. \
4539                     Per-camp images live at `.yah/qed/images/<name>/`."
4540                ),
4541            }
4542        })?;
4543
4544        if !entry
4545            .produces
4546            .contains(&crate::images::ProduceTarget::NativeTarball)
4547        {
4548            return Err(RunnerError::StepFailed {
4549                step: step.name.clone(),
4550                msg: format!(
4551                    "catalog entry `{image_name}` does not declare \
4552                     `produces = [\"native-tarball\"]` — add `native-tarball` to \
4553                     its `produces` list (alone or alongside `oci-image`) in \
4554                     `.yah/qed/images/{image_name}.toml`."
4555                ),
4556            });
4557        }
4558
4559        let binary_rel = step.binary_path.as_deref().ok_or_else(|| {
4560            RunnerError::InvalidConfig(format!(
4561                "package-native-tarball step `{}` is missing `binary_path` \
4562                 (parse-time validation should have caught this)",
4563                step.name
4564            ))
4565        })?;
4566        let binary_path = if std::path::Path::new(binary_rel).is_absolute() {
4567            std::path::PathBuf::from(binary_rel)
4568        } else {
4569            camp_root.join(binary_rel)
4570        };
4571        if !binary_path.is_file() {
4572            return Err(RunnerError::StepFailed {
4573                step: step.name.clone(),
4574                msg: format!(
4575                    "binary not found at `{}` — declare the upstream build step \
4576                     in `produces` and chain it before this packaging step.",
4577                    binary_path.display()
4578                ),
4579            });
4580        }
4581
4582        let triple = step
4583            .triple
4584            .clone()
4585            .unwrap_or_else(|| crate::publish::resolve_triple(None));
4586
4587        let bin_basename = binary_path
4588            .file_name()
4589            .and_then(|n| n.to_str())
4590            .ok_or_else(|| {
4591                RunnerError::InvalidConfig(format!(
4592                    "binary path `{}` has no filename component",
4593                    binary_path.display(),
4594                ))
4595            })?
4596            .to_string();
4597
4598        let mut env: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
4599        for (k, v) in &entry.env {
4600            env.insert(k.clone(), v.clone());
4601        }
4602
4603        let manifest = crate::native::NativeTarballManifest {
4604            name: entry.name.clone(),
4605            version: crate::publish::resolve_release_version(),
4606            triple: triple.clone(),
4607            binary: format!("bin/{bin_basename}"),
4608            description: if entry.description.is_empty() {
4609                None
4610            } else {
4611                Some(entry.description.clone())
4612            },
4613            env,
4614        };
4615
4616        let output_path =
4617            crate::native::native_tarball_output_path(&camp_root, &entry.name, &triple);
4618
4619        crate::native::pack_native_tarball(&binary_path, &manifest, &output_path).map_err(|e| {
4620            RunnerError::StepFailed {
4621                step: step.name.clone(),
4622                msg: format!(
4623                    "failed to pack native tarball at {}: {e}",
4624                    output_path.display()
4625                ),
4626            }
4627        })?;
4628
4629        Ok(())
4630    }
4631
4632    /// Dispatch a `kind = "sign-native-tarball"` step (R407-T5, W154).
4633    ///
4634    /// Sigstore signing extends to native-tarball artifacts under the same
4635    /// keyless-OIDC trust model used for OCI images today (cosign signs the
4636    /// registry digest; here cosign signs the on-disk blob). The signer
4637    /// (attached via [`Self::with_signer`]) writes `.sig`, `.crt`, and
4638    /// `.bundle` next to the artifact; `cosign verify-blob --bundle ...`
4639    /// at deploy time confirms the GHA workflow identity matches the
4640    /// release pipeline's expected regex.
4641    ///
4642    /// The tarball path is resolved via
4643    /// [`crate::native::native_tarball_output_path`] — same convention as
4644    /// packaging, so a pipeline that runs `package-native-tarball` then
4645    /// `sign-native-tarball` with the same `image` + `triple` always finds
4646    /// the artifact. A pre-flight check on the catalog entry's `produces`
4647    /// list refuses to sign tarballs from entries that didn't declare
4648    /// `native-tarball` (catches a stale step that survived a catalog
4649    /// rename).
4650    async fn execute_step_sign_native_tarball(
4651        &self,
4652        step: &crate::types::QedStep,
4653    ) -> Result<(), RunnerError> {
4654        let camp_root = self.resolve_camp_root()?;
4655        let camp_images_dir = camp_root.join(".yah/qed/images");
4656        let catalog = crate::images::CatalogManifest::load(&camp_images_dir)
4657            .map_err(|e| RunnerError::InvalidConfig(format!("failed to load catalog: {e}")))?;
4658
4659        let image_name = step.image.as_deref().ok_or_else(|| {
4660            RunnerError::InvalidConfig(format!(
4661                "sign-native-tarball step `{}` is missing `image` \
4662                 (parse-time validation should have caught this)",
4663                step.name
4664            ))
4665        })?;
4666        let entry = catalog.get(image_name).cloned().ok_or_else(|| {
4667            let known: Vec<&str> = catalog.names();
4668            RunnerError::StepFailed {
4669                step: step.name.clone(),
4670                msg: format!(
4671                    "unknown catalog image `{image_name}` — known: {known:?}. \
4672                     Per-camp images live at `.yah/qed/images/<name>/`."
4673                ),
4674            }
4675        })?;
4676
4677        if !entry
4678            .produces
4679            .contains(&crate::images::ProduceTarget::NativeTarball)
4680        {
4681            return Err(RunnerError::StepFailed {
4682                step: step.name.clone(),
4683                msg: format!(
4684                    "catalog entry `{image_name}` does not declare \
4685                     `produces = [\"native-tarball\"]` — sign-native-tarball \
4686                     refuses to sign artifacts the catalog hasn't opted in to. \
4687                     Update `.yah/qed/images/{image_name}.toml` (or drop this \
4688                     sign step)."
4689                ),
4690            });
4691        }
4692
4693        let triple = step
4694            .triple
4695            .clone()
4696            .unwrap_or_else(|| crate::publish::resolve_triple(None));
4697        let tarball_path =
4698            crate::native::native_tarball_output_path(&camp_root, &entry.name, &triple);
4699        if !tarball_path.is_file() {
4700            return Err(RunnerError::StepFailed {
4701                step: step.name.clone(),
4702                msg: format!(
4703                    "native tarball not found at `{}` — run \
4704                     `kind = \"package-native-tarball\"` for `{image_name}` \
4705                     before signing.",
4706                    tarball_path.display()
4707                ),
4708            });
4709        }
4710
4711        let signed =
4712            self.signer
4713                .sign_blob(&tarball_path)
4714                .await
4715                .map_err(|e| RunnerError::StepFailed {
4716                    step: step.name.clone(),
4717                    msg: format!(
4718                        "cosign sign-blob failed for `{}`: {e}",
4719                        tarball_path.display(),
4720                    ),
4721                })?;
4722
4723        tracing::info!(
4724            tarball = %tarball_path.display(),
4725            signature = %signed.signature_path.display(),
4726            certificate = %signed.certificate_path.display(),
4727            bundle = signed.bundle_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default(),
4728            "qed sign-native-tarball: artifact signed"
4729        );
4730        Ok(())
4731    }
4732
4733    /// Dispatch a `kind = "musl-static-preflight"` step (R407-T3).
4734    ///
4735    /// Walks `step.package`'s transitive dep closure via `cargo metadata`
4736    /// and fails the step (with a `NotMuslSafe` error listing the offenders)
4737    /// if any crate in [`crate::preflight::KNOWN_GLIBC_ONLY_CRATES`] appears.
4738    /// The error message routes the pipeline author to the container
4739    /// fallback (`runtime = "container"`) rather than letting the
4740    /// downstream `cargo build --target=*-musl` step die with a confusing
4741    /// linker error.
4742    async fn execute_step_musl_static_preflight(
4743        &self,
4744        step: &crate::types::QedStep,
4745    ) -> Result<(), RunnerError> {
4746        let camp_root = self.resolve_camp_root()?;
4747        let package = step.package.as_deref().ok_or_else(|| {
4748            RunnerError::InvalidConfig(format!(
4749                "musl-static-preflight step `{}` is missing `package` \
4750                 (parse-time validation should have caught this)",
4751                step.name
4752            ))
4753        })?;
4754        let package = package.to_string();
4755        let step_name = step.name.clone();
4756        let camp_root_clone = camp_root.clone();
4757        // cargo metadata blocks while it resolves the dep graph — push it
4758        // off the async runtime so a slow workspace doesn't starve other
4759        // tasks (e.g. event drain).
4760        let result = tokio::task::spawn_blocking(move || {
4761            crate::preflight::check_musl_compatibility(&camp_root_clone, &package)
4762        })
4763        .await
4764        .map_err(|e| RunnerError::StepFailed {
4765            step: step_name.clone(),
4766            msg: format!("preflight task panicked: {e}"),
4767        })?;
4768        result.map_err(|e| RunnerError::StepFailed {
4769            step: step_name,
4770            msg: e.to_string(),
4771        })?;
4772        Ok(())
4773    }
4774
4775    async fn execute_step_remote(
4776        &self,
4777        index: usize,
4778        step: &crate::types::QedStep,
4779        runtime: TaskRuntime,
4780    ) -> Result<ObsForgeId, RunnerError> {
4781        // R590-F4: a forced-remote runner always has a driver, but an Auto runner
4782        // that policy-routed this step to the fleet needs one wired too. Surface a
4783        // clear config error instead of panicking when a policy-derived offload
4784        // ran without a mesh dispatcher.
4785        let driver = self.remote_driver.as_ref().ok_or_else(|| {
4786            RunnerError::InvalidConfig(format!(
4787                "step `{}` resolves to Offload (needs an arch-matched build-worker) \
4788                 but no remote dispatcher is wired — run with fleet access, or force \
4789                 `--where=local` to build it here",
4790                step.name,
4791            ))
4792        })?;
4793
4794        // R590-F2: when the step declares a cross-arch target via
4795        // `[platform].target`, pin placement to an arch-matched build-worker so
4796        // an arm64 host can drive an x86 build on the x86 box (us-west-002).
4797        let mesh_tags = remote_subprocess_mesh_tags(step);
4798
4799        // R590-F2 milestone-1 (2): per-step container image override (finishing
4800        // the R381 `step.image` seam). A subprocess step may name its own
4801        // catalog image (e.g. `rusty-v8-musl-builder`) to run its argv inside,
4802        // instead of the default forge image (`yah-rust-bun`). `None` keeps the
4803        // default-image behaviour for plain steps.
4804        let image = step_image_override(step)?;
4805
4806        // R603-T5: a remote step's declared `produces` must be written under the
4807        // durable produced dir (`/yah/produced`), which build_workload_spec
4808        // host-bind-mounts so the bytes survive kamaji reaping the exited
4809        // container. A produces path outside it would be retrieved off the
4810        // container rootfs — lost the moment the container is reaped after a
4811        // daemon outage (the exact R603-T4 failure this ticket closes). Fail
4812        // fast at dispatch with a clear pointer rather than silently orphan.
4813        for artifact in &step.produces {
4814            let path = std::path::Path::new(&artifact.path);
4815            if !workload_spec::forge_produced::is_durable_path(path) {
4816                return Err(RunnerError::InvalidConfig(format!(
4817                    "step `{}` declares produced artifact `{}`, but remote produced \
4818                     artifacts must be written under `{}` so they survive the \
4819                     build-worker reaping the container (R603-T5). Point the \
4820                     build's output path at `{}/…`.",
4821                    step.name,
4822                    artifact.path,
4823                    workload_spec::forge_produced::CONTAINER_DIR,
4824                    workload_spec::forge_produced::CONTAINER_DIR,
4825                )));
4826            }
4827        }
4828
4829        let spec = ForgeSpec {
4830            command: ForgeCommand::Subprocess {
4831                argv: step.argv.clone(),
4832                image,
4833            },
4834            where_: TaskPlacement::new(
4835                TaskLocation::RemoteAny {
4836                    tier: TierTag("infra".into()),
4837                    mesh_tags,
4838                },
4839                runtime,
4840            ),
4841            timeout: step.timeout.map(Millis::from_secs),
4842            label: Some(step.name.clone()),
4843            // Camp name will be threaded through once yubaba RPC stabilises (R091).
4844            initiator: Initiator::Human { camp: "qed".into() },
4845            mesh_access: MeshAccess::None,
4846        };
4847
4848        // Adapter: forward yubaba log lines into the runner's live sink as
4849        // StepOutput, mirroring the local subprocess path (R508). Without this
4850        // a yubaba-dispatched step only surfaced its log lines post-run via
4851        // scryer; now they stream into qed.tail / the desktop pane live.
4852        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<ExecEvent>();
4853        let adapter = {
4854            let events = self.events.clone();
4855            let name = step.name.clone();
4856            tokio::spawn(async move {
4857                while let Some(ev) = rx.recv().await {
4858                    let Some(events) = &events else { continue };
4859                    if let ExecEvent::Output { stream, line } = ev {
4860                        let qed_stream = match stream {
4861                            velveteen_exec::OutputStream::Stdout => OutputStream::Stdout,
4862                            velveteen_exec::OutputStream::Stderr => OutputStream::Stderr,
4863                        };
4864                        let _ = events.send(QedEvent::StepOutput {
4865                            index,
4866                            name: name.clone(),
4867                            stream: qed_stream,
4868                            line,
4869                        });
4870                    }
4871                }
4872            })
4873        };
4874
4875        let handle = driver
4876            .start_with_sink(spec, Some(tx))
4877            .await
4878            .map_err(|e| RunnerError::Remote(e.to_string()))?;
4879
4880        let forge_id = handle.id.clone();
4881        // R603-T1: publish the workload identity the moment it exists, before we
4882        // block on `wait()`. The camp daemon persists this as a non-terminal run
4883        // record so a daemon restart mid-build can reattach to the workload
4884        // (R603-T2) rather than orphaning it.
4885        self.emit(QedEvent::StepRemoteDispatched {
4886            index,
4887            name: step.name.clone(),
4888            forge_id: forge_id.to_string(),
4889            at: Utc::now(),
4890        });
4891        let status = handle.wait().await;
4892        // Drain any remaining buffered lines before the step is marked done.
4893        let _ = adapter.await;
4894
4895        match status {
4896            ForgeStatus::Done { exit_code: 0, .. } => Ok(forge_id),
4897            ForgeStatus::Done { exit_code, .. } => Err(RunnerError::StepFailed {
4898                step: step.name.clone(),
4899                msg: format!("exited with code {exit_code}"),
4900            }),
4901            ForgeStatus::TimedOut { .. } => Err(RunnerError::StepFailed {
4902                step: step.name.clone(),
4903                msg: "step timed out".into(),
4904            }),
4905            ForgeStatus::Killed { signal, .. } => Err(RunnerError::StepFailed {
4906                step: step.name.clone(),
4907                msg: format!("killed by signal {signal}"),
4908            }),
4909            ForgeStatus::Lost { reason } => Err(RunnerError::StepFailed {
4910                step: step.name.clone(),
4911                msg: format!("lost: {reason}"),
4912            }),
4913            ForgeStatus::Pending | ForgeStatus::Running => {
4914                unreachable!("ForgeRunHandle::wait returns a terminal status")
4915            }
4916        }
4917    }
4918
4919    /// R590-F6 leg 2: after a remote step exits successfully, pull the files it
4920    /// declared in [`QedStep::produces`] off the build-worker and land them in
4921    /// camp's content-addressed store (`<camp_root>/.yah/cache/artifacts/`).
4922    ///
4923    /// Returns the produced-artifact list with each `path` rewritten to the
4924    /// landed local file, so the publish leg ([`crate::types::Outcome::Publish`]
4925    /// / the W164 derived-static-asset reconciler) reads the retrieved bytes
4926    /// instead of the unreachable container-side path — this FEEDS R546-T3's
4927    /// bootstrap publish, it does not duplicate it.
4928    ///
4929    /// Only called for remote steps that actually declare `produces`; a step
4930    /// with none (rusty-v8-musl today) never enters this path, so retrieval
4931    /// cannot regress the on-box green that R590-B5 unblocks.
4932    async fn retrieve_remote_artifacts(
4933        &self,
4934        forge_id: &ObsForgeId,
4935        step: &crate::types::QedStep,
4936    ) -> Result<Vec<ProducedArtifact>, RunnerError> {
4937        let driver = self.remote_driver.as_ref().ok_or_else(|| {
4938            RunnerError::InvalidConfig(format!(
4939                "step `{}` declares produced artifacts to retrieve but no remote \
4940                 dispatcher is wired",
4941                step.name,
4942            ))
4943        })?;
4944        let store = crate::artifact_retrieval::ContentAddressedStore::new(
4945            self.resolve_camp_root()?.join(".yah/cache/artifacts"),
4946        );
4947
4948        let mut retrieved = Vec::with_capacity(step.produces.len());
4949        for artifact in &step.produces {
4950            let remote_path = std::path::Path::new(&artifact.path);
4951            let bytes = driver
4952                .fetch_produced_file(forge_id, remote_path)
4953                .await
4954                .map_err(|e| RunnerError::StepFailed {
4955                    step: step.name.clone(),
4956                    msg: format!(
4957                        "retrieving produced artifact `{}` off the build-worker: {e}",
4958                        artifact.path
4959                    ),
4960                })?;
4961            let landed = store.land(&bytes).map_err(RunnerError::Io)?;
4962            retrieved.push(ProducedArtifact {
4963                binary: artifact.binary.clone(),
4964                path: landed.path.to_string_lossy().into_owned(),
4965                triple: artifact.triple.clone(),
4966            });
4967        }
4968        Ok(retrieved)
4969    }
4970}
4971
4972// ─── Tests ────────────────────────────────────────────────────────────────────
4973
4974#[cfg(test)]
4975mod tests {
4976    use super::*;
4977    use yah_scryer::service::{Scryer, ScryerConfig};
4978    use std::collections::HashMap;
4979    use tempfile::TempDir;
4980    use tokio::sync::mpsc;
4981    use workload_spec::MeshIdent;
4982
4983    #[test]
4984    fn stderr_tail_strips_env_markers_and_keeps_last_n_lines() {
4985        let stderr = "first\nsecond\n__qed_gha_env_updates_BEGIN__\nFOO\tbar\n__qed_gha_env_updates_END__\nthird\nfourth\nfifth\n";
4986        let out = stderr_tail(stderr, 3);
4987        // Markers + FOO line stripped (FOO\tbar starts with neither prefix
4988        // so it'll appear — that's OK as it shows env-update side-effect).
4989        assert!(!out.contains("__qed_gha_env_updates_BEGIN__"));
4990        assert!(!out.contains("__qed_gha_env_updates_END__"));
4991        let lines: Vec<&str> = out.lines().collect();
4992        assert_eq!(lines.len(), 3);
4993        assert_eq!(lines, vec!["third", "fourth", "fifth"]);
4994    }
4995
4996    #[test]
4997    fn stderr_tail_returns_empty_when_only_env_markers() {
4998        let stderr = "__qed_gha_env_updates_BEGIN__\n__qed_gha_env_updates_END__\n";
4999        assert_eq!(stderr_tail(stderr, 10), "");
5000    }
5001
5002    #[test]
5003    fn stderr_tail_empty_input_is_empty() {
5004        assert_eq!(stderr_tail("", 10), "");
5005    }
5006
5007    fn make_scryer(dir: &TempDir) -> Arc<Scryer> {
5008        let cfg = ScryerConfig::new(dir.path().join("events.db"));
5009        Arc::new(Scryer::new(cfg, None).unwrap())
5010    }
5011
5012    fn one_step_pipeline(name: &str, argv: Vec<String>) -> Pipeline {
5013        Pipeline {
5014            name: name.to_string(),
5015            label: name.to_string(),
5016            steps: vec![crate::types::QedStep {
5017                background: false,
5018                background_until: None,
5019                wait_for: None,
5020                manifest_stitch: None,
5021                name: "step-1".to_string(),
5022                argv,
5023                cwd: None,
5024                env: HashMap::new(),
5025                timeout: None,
5026                on_fail: OnFail::Abort,
5027                produces: Vec::new(),
5028                runtime: None,
5029                kind: crate::types::StepKind::Subprocess,
5030                image: None,
5031                tag: None,
5032                push: false,
5033                platforms: Vec::new(),
5034                binary_path: None,
5035                triple: None,
5036                package: None,
5037                context: None,
5038                load: false,
5039                sub_pipeline: None,
5040                gha_workflow: None,
5041                import: None,
5042                matrix: None,
5043                enabled: true,
5044                activation: StepActivation::Active,
5045                if_cond: None,
5046                platform: None,
5047                toolchain: None,
5048                outputs: Vec::new(),
5049            }],
5050            params: HashMap::new(),
5051            on_success: vec![],
5052            on_fail: vec![],
5053            triggers: vec![],
5054            concurrency_key: None,
5055            placement: crate::types::Placement::Anywhere,
5056            workspace: crate::types::WorkspaceMode::Live,
5057            wraps: None,
5058            matrix: None,
5059            toolchain: None,
5060            binds: Vec::new(),
5061            on_change: Vec::new(),
5062            finally: Vec::new(),
5063        }
5064    }
5065
5066    // ── R507/W208 toolchain pinning preflight ──────────────────────────────
5067
5068    fn tc_spec(pairs: &[(&str, &str)]) -> crate::toolchain::ToolchainSpec {
5069        crate::toolchain::ToolchainSpec {
5070            pins: pairs
5071                .iter()
5072                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
5073                .collect(),
5074        }
5075    }
5076
5077    fn host_map(pairs: &[(&str, Option<&str>)]) -> HashMap<String, Option<String>> {
5078        pairs
5079            .iter()
5080            .map(|(k, v)| ((*k).to_string(), v.map(str::to_string)))
5081            .collect()
5082    }
5083
5084    #[test]
5085    fn toolchain_preflight_passes_when_host_satisfies_pin() {
5086        let mut pipeline = one_step_pipeline("p", vec!["echo".into(), "hi".into()]);
5087        pipeline.toolchain = Some(tc_spec(&[("rust", "1.84.0")]));
5088        let runner = PipelineRunner::new(pipeline)
5089            .with_host_toolchains(host_map(&[("rust", Some("1.84.0"))]));
5090        let pf = runner.toolchain_preflight();
5091        assert!(pf.is_satisfied(), "{:?}", pf.report());
5092        assert_eq!(pf.entries.len(), 1);
5093    }
5094
5095    #[test]
5096    fn toolchain_preflight_blocks_on_missing_tool() {
5097        // noisetable's release.apple pins xcode=15.4; a host without it blocks.
5098        let mut pipeline = one_step_pipeline("p", vec!["echo".into(), "hi".into()]);
5099        pipeline.toolchain = Some(tc_spec(&[("xcode", "15.4")]));
5100        let runner =
5101            PipelineRunner::new(pipeline).with_host_toolchains(host_map(&[("xcode", None)]));
5102        let pf = runner.toolchain_preflight();
5103        assert!(!pf.is_satisfied());
5104        let report = pf.error_report().expect("blocking ⇒ report");
5105        assert!(report.contains("xcode"));
5106        assert!(report.contains("15.4"));
5107    }
5108
5109    #[tokio::test]
5110    async fn run_fails_fast_when_host_cannot_satisfy_pin() {
5111        // The gate fires before any step executes — even a bare `echo` never
5112        // runs when the host can't satisfy the pin.
5113        let mut pipeline = one_step_pipeline("p", vec!["echo".into(), "hi".into()]);
5114        pipeline.toolchain = Some(tc_spec(&[("xcode", "15.4")]));
5115        let runner = PipelineRunner::new(pipeline)
5116            .with_host_toolchains(host_map(&[("xcode", Some("15.2"))]));
5117        let err = runner.run().await.unwrap_err();
5118        match err {
5119            RunnerError::ToolchainUnsatisfied(report) => {
5120                assert!(report.contains("xcode"));
5121                assert!(report.contains("15.4"));
5122                assert!(
5123                    report.contains("15.2"),
5124                    "report names the host version: {report}"
5125                );
5126            }
5127            other => panic!("expected ToolchainUnsatisfied, got {other:?}"),
5128        }
5129    }
5130
5131    #[test]
5132    fn step_toolchain_override_beats_pipeline_pin() {
5133        // Pipeline pins ndk=r27; the step overrides to r26d. Host has ndk 26.3
5134        // — which the pipeline pin (27) would reject but the step override
5135        // (r26d → 26) satisfies. A satisfied preflight proves the override won.
5136        let mut pipeline = one_step_pipeline("p", vec!["echo".into(), "hi".into()]);
5137        pipeline.toolchain = Some(tc_spec(&[("ndk", "r27")]));
5138        pipeline.steps[0].toolchain = Some(tc_spec(&[("ndk", "r26d")]));
5139        let runner = PipelineRunner::new(pipeline)
5140            .with_host_toolchains(host_map(&[("ndk", Some("26.3.11579264"))]));
5141        let pf = runner.toolchain_preflight();
5142        assert!(
5143            pf.is_satisfied(),
5144            "step r26d override should win: {:?}",
5145            pf.report()
5146        );
5147        // Sanity: the *pipeline* pin alone (no override) would block this host.
5148        let mut blocked = one_step_pipeline("p", vec!["echo".into(), "hi".into()]);
5149        blocked.toolchain = Some(tc_spec(&[("ndk", "r27")]));
5150        let blocked_runner = PipelineRunner::new(blocked)
5151            .with_host_toolchains(host_map(&[("ndk", Some("26.3.11579264"))]));
5152        assert!(!blocked_runner.toolchain_preflight().is_satisfied());
5153    }
5154
5155    #[test]
5156    fn containerized_step_satisfies_pin_via_image() {
5157        // A step that pulls an image delegates its toolchain to that image, so
5158        // a host missing Xcode entirely still passes the preflight.
5159        let mut pipeline = one_step_pipeline("p", vec!["echo".into(), "hi".into()]);
5160        pipeline.toolchain = Some(tc_spec(&[("xcode", "15.4")]));
5161        pipeline.steps[0].image = Some("apple-builder:15.4".into());
5162        let runner =
5163            PipelineRunner::new(pipeline).with_host_toolchains(host_map(&[("xcode", None)]));
5164        let pf = runner.toolchain_preflight();
5165        assert!(pf.is_satisfied());
5166        assert!(matches!(
5167            pf.entries[0].resolution,
5168            crate::toolchain::PinResolution::SatisfiedByImage { .. }
5169        ));
5170    }
5171
5172    #[test]
5173    fn no_toolchain_block_means_no_preflight_entries() {
5174        let pipeline = one_step_pipeline("p", vec!["echo".into(), "hi".into()]);
5175        // Seed an empty host map so this never shells out.
5176        let runner = PipelineRunner::new(pipeline).with_host_toolchains(HashMap::new());
5177        let pf = runner.toolchain_preflight();
5178        assert!(pf.is_satisfied());
5179        assert!(pf.entries.is_empty());
5180    }
5181
5182    // ── Scripted yubaba for qed tests ──────────────────────────────────────
5183
5184    struct ScriptedWarden {
5185        lines: Vec<String>,
5186        exit_code: i32,
5187        /// R590-F6: container-path → bytes the finished container produced,
5188        /// served by `fetch_produced_file`.
5189        produced_files: HashMap<std::path::PathBuf, Vec<u8>>,
5190    }
5191
5192    impl ScriptedWarden {
5193        fn new(lines: Vec<String>, exit_code: i32) -> Self {
5194            Self { lines, exit_code, produced_files: HashMap::new() }
5195        }
5196
5197        /// Seed a produced file so `fetch_produced_file` serves `bytes` at
5198        /// `path` (R590-F6 retrieval test).
5199        fn with_produced_file(mut self, path: impl Into<std::path::PathBuf>, bytes: Vec<u8>) -> Self {
5200            self.produced_files.insert(path.into(), bytes);
5201            self
5202        }
5203    }
5204
5205    #[async_trait::async_trait]
5206    impl WardenClient for ScriptedWarden {
5207        async fn deploy(
5208            &self,
5209            _spec: &workload_spec::WorkloadSpec,
5210        ) -> Result<(), velveteen_exec::RemoteForgeError> {
5211            Ok(())
5212        }
5213
5214        async fn connect_logs(
5215            &self,
5216            _ident: &MeshIdent,
5217        ) -> Result<mpsc::Receiver<String>, velveteen_exec::RemoteForgeError> {
5218            let (tx, rx) = mpsc::channel(64);
5219            let lines = self.lines.clone();
5220            tokio::spawn(async move {
5221                for line in lines {
5222                    let _ = tx.send(line).await;
5223                }
5224            });
5225            Ok(rx)
5226        }
5227
5228        async fn teardown(&self, _ident: &MeshIdent) -> Result<(), velveteen_exec::RemoteForgeError> {
5229            Ok(())
5230        }
5231
5232        async fn exit_code(
5233            &self,
5234            _ident: &MeshIdent,
5235        ) -> Result<Option<i32>, velveteen_exec::RemoteForgeError> {
5236            Ok(Some(self.exit_code))
5237        }
5238
5239        async fn fetch_produced_file(
5240            &self,
5241            _ident: &MeshIdent,
5242            remote_path: &std::path::Path,
5243        ) -> Result<Vec<u8>, velveteen_exec::RemoteForgeError> {
5244            self.produced_files.get(remote_path).cloned().ok_or_else(|| {
5245                velveteen_exec::RemoteForgeError::Fetch(format!(
5246                    "no produced file scripted at {}",
5247                    remote_path.display()
5248                ))
5249            })
5250        }
5251    }
5252
5253    /// Remote path happy: single step exits 0, task_run_id populated in step status.
5254    #[tokio::test]
5255    async fn remote_step_success() {
5256        let dir = TempDir::new().unwrap();
5257        let scryer = make_scryer(&dir);
5258        let yubaba = Arc::new(ScriptedWarden {
5259            lines: vec!["build ok".to_string()],
5260            exit_code: 0,
5261            produced_files: HashMap::new(),
5262        });
5263
5264        let pipeline = one_step_pipeline("test-remote", vec!["true".to_string()]);
5265        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba);
5266        let meta = runner.run().await.unwrap();
5267
5268        assert_eq!(meta.status, RunStatus::Success);
5269        assert_eq!(meta.steps.len(), 1);
5270        assert!(
5271            meta.steps[0].task_run_id.is_some(),
5272            "remote step should record task_run_id"
5273        );
5274    }
5275
5276    /// R590-F6 leg 2 end-to-end: a remote step that declares `produces` has its
5277    /// output tarball retrieved off the (scripted) build-worker and landed in
5278    /// camp's content-addressed store — the landed file's BLAKE3 equals the
5279    /// hash of the bytes the worker emitted (no bytes lost/rewritten in
5280    /// transit). This is the retrieval unit path the ticket's verify names.
5281    #[tokio::test]
5282    async fn remote_step_retrieves_produced_artifact_content_addressed() {
5283        let dir = TempDir::new().unwrap();
5284        let camp = TempDir::new().unwrap();
5285        let scryer = make_scryer(&dir);
5286
5287        // R603-T5: produced artifacts must live under the durable dir so they
5288        // survive the build-worker reaping the container.
5289        let container_path = "/yah/produced/librusty_v8-x86_64-unknown-linux-musl.tar.gz";
5290        let payload = b"deterministic librusty_v8 tar bytes \x00\x01\x02\xff".to_vec();
5291        let expected_blake3 = blake3::hash(&payload).to_hex().to_string();
5292
5293        let yubaba = Arc::new(
5294            ScriptedWarden::new(vec!["v8 build complete".into()], 0)
5295                .with_produced_file(container_path, payload.clone()),
5296        );
5297
5298        let mut pipeline = one_step_pipeline("rusty-v8-musl", vec!["build-v8.sh".to_string()]);
5299        pipeline.steps[0].produces = vec![ProducedArtifact {
5300            binary: "rusty-v8".into(),
5301            path: container_path.into(),
5302            triple: Some("x86_64-unknown-linux-musl".into()),
5303        }];
5304
5305        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba)
5306            .with_camp_root(camp.path().to_path_buf());
5307        let meta = runner.run().await.unwrap();
5308
5309        assert_eq!(meta.status, RunStatus::Success, "retrieval must not fail the step");
5310
5311        // The tar landed content-addressed under camp's artifact store, and its
5312        // on-disk bytes re-hash to the worker's BLAKE3 — preservation proven.
5313        let landed = camp.path().join(".yah/cache/artifacts").join(&expected_blake3);
5314        assert!(landed.exists(), "retrieved artifact must land at <camp>/.yah/cache/artifacts/<blake3>");
5315        let on_disk = std::fs::read(&landed).unwrap();
5316        assert_eq!(on_disk, payload, "bytes must survive the transport unchanged");
5317        assert_eq!(blake3::hash(&on_disk).to_hex().to_string(), expected_blake3);
5318    }
5319
5320    /// Remote path failure: non-zero exit code propagates as Failed status.
5321    #[tokio::test]
5322    async fn remote_step_failure() {
5323        let dir = TempDir::new().unwrap();
5324        let scryer = make_scryer(&dir);
5325        let yubaba = Arc::new(ScriptedWarden {
5326            lines: vec!["error: something went wrong".to_string()],
5327            exit_code: 1,
5328            produced_files: HashMap::new(),
5329        });
5330
5331        let pipeline = one_step_pipeline("test-remote-fail", vec!["false".to_string()]);
5332        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba);
5333        let meta = runner.run().await.unwrap();
5334
5335        assert_eq!(meta.status, RunStatus::Failed);
5336        assert_eq!(meta.steps[0].status, RunStatus::Failed);
5337    }
5338
5339    /// R508: a yubaba-dispatched step streams its log lines into the live
5340    /// event sink as `StepOutput` *during* the run — not just into scryer
5341    /// post-completion. The scripted yubaba emits two lines; both surface as
5342    /// StepOutput events carrying the step's index and name.
5343    #[tokio::test]
5344    async fn remote_step_streams_output_to_sink() {
5345        let dir = TempDir::new().unwrap();
5346        let scryer = make_scryer(&dir);
5347        let yubaba = Arc::new(ScriptedWarden {
5348            lines: vec!["remote line 1".to_string(), "remote line 2".to_string()],
5349            exit_code: 0,
5350            produced_files: HashMap::new(),
5351        });
5352
5353        let (tx, mut rx) = mpsc::unbounded_channel();
5354        let pipeline = one_step_pipeline("test-remote-stream", vec!["true".to_string()]);
5355        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba).with_events(tx);
5356        let meta = runner.run().await.unwrap();
5357        assert_eq!(meta.status, RunStatus::Success);
5358
5359        let mut lines = Vec::new();
5360        while let Ok(ev) = rx.try_recv() {
5361            if let QedEvent::StepOutput { index, name, line, .. } = ev {
5362                assert_eq!(index, 0, "single-step pipeline → index 0");
5363                assert_eq!(name, "step-1", "StepOutput carries the step name");
5364                lines.push(line);
5365            }
5366        }
5367        assert_eq!(
5368            lines,
5369            vec!["remote line 1".to_string(), "remote line 2".to_string()],
5370            "both yubaba log lines must stream through as StepOutput",
5371        );
5372    }
5373
5374    /// R603-T1: a remote step publishes its yubaba workload id via
5375    /// `StepRemoteDispatched` BEFORE the step finishes, so the camp daemon can
5376    /// persist a reattachable non-terminal record. Asserts the event carries a
5377    /// non-empty forge id and arrives strictly before `StepFinished` for that
5378    /// index (the ordering boot-reconcile relies on).
5379    #[tokio::test]
5380    async fn remote_step_emits_workload_binding_before_finish() {
5381        let dir = TempDir::new().unwrap();
5382        let scryer = make_scryer(&dir);
5383        let yubaba = Arc::new(ScriptedWarden {
5384            lines: vec!["build ok".to_string()],
5385            exit_code: 0,
5386            produced_files: HashMap::new(),
5387        });
5388
5389        let (tx, mut rx) = mpsc::unbounded_channel();
5390        let pipeline = one_step_pipeline("test-remote-binding", vec!["true".to_string()]);
5391        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba).with_events(tx);
5392        let meta = runner.run().await.unwrap();
5393        assert_eq!(meta.status, RunStatus::Success);
5394
5395        // Walk the event stream in order: the dispatch event must appear, carry a
5396        // non-empty forge id, and precede the StepFinished for index 0.
5397        let mut dispatched_forge: Option<String> = None;
5398        let mut saw_finished = false;
5399        while let Ok(ev) = rx.try_recv() {
5400            match ev {
5401                QedEvent::StepRemoteDispatched { index, forge_id, .. } => {
5402                    assert_eq!(index, 0, "single-step pipeline → index 0");
5403                    assert!(!forge_id.is_empty(), "dispatch event must carry a workload id");
5404                    assert!(!saw_finished, "dispatch must precede StepFinished");
5405                    dispatched_forge = Some(forge_id);
5406                }
5407                QedEvent::StepFinished { index: 0, .. } => saw_finished = true,
5408                _ => {}
5409            }
5410        }
5411        let forge = dispatched_forge.expect("remote step must emit StepRemoteDispatched");
5412        // The same id ends up on the terminal step status (task_run_id), so the
5413        // persisted record and the live binding agree.
5414        assert_eq!(
5415            meta.steps[0].task_run_id.as_deref(),
5416            Some(forge.as_str()),
5417            "dispatched forge id must match the step's recorded task_run_id",
5418        );
5419    }
5420
5421    /// R603-T5: a remote step whose `produces` path is NOT under the durable
5422    /// dir (`/yah/produced`) is rejected at dispatch — its output would be read
5423    /// off the container rootfs and lost the moment the worker reaps the exited
5424    /// container. The run fails with a clear pointer instead of silently
5425    /// orphaning the artifact.
5426    #[tokio::test]
5427    async fn remote_step_rejects_non_durable_produces_path() {
5428        let dir = TempDir::new().unwrap();
5429        let scryer = make_scryer(&dir);
5430        let yubaba = Arc::new(ScriptedWarden {
5431            lines: vec![],
5432            exit_code: 0,
5433            produced_files: HashMap::new(),
5434        });
5435
5436        let mut pipeline = one_step_pipeline("test-bad-produces", vec!["true".to_string()]);
5437        pipeline.steps[0].produces = vec![ProducedArtifact {
5438            binary: "rusty-v8".to_string(),
5439            // Under /tmp, not /yah/produced → not reap-durable.
5440            path: "/tmp/librusty_v8.tar.gz".to_string(),
5441            triple: None,
5442        }];
5443
5444        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba);
5445        let meta = runner.run().await.unwrap();
5446        assert_eq!(
5447            meta.status,
5448            RunStatus::Failed,
5449            "a non-durable produces path must fail the run"
5450        );
5451        let err = meta.steps[0].error.clone().unwrap_or_default();
5452        assert!(
5453            err.contains("/yah/produced"),
5454            "error must point at the durable dir convention; got {err:?}"
5455        );
5456    }
5457
5458    /// Remote path: second step skipped when first fails with on_fail=Abort.
5459    #[tokio::test]
5460    async fn remote_abort_on_fail() {
5461        let dir = TempDir::new().unwrap();
5462        let scryer = make_scryer(&dir);
5463        let yubaba = Arc::new(ScriptedWarden {
5464            lines: vec![],
5465            exit_code: 1,
5466            produced_files: HashMap::new(),
5467        });
5468
5469        let mut pipeline = one_step_pipeline("test-abort", vec!["false".to_string()]);
5470        pipeline.steps.push(crate::types::QedStep {
5471            background: false,
5472            background_until: None,
5473            wait_for: None,
5474            manifest_stitch: None,
5475            name: "step-2".to_string(),
5476            argv: vec!["true".to_string()],
5477            cwd: None,
5478            env: HashMap::new(),
5479            timeout: None,
5480            on_fail: OnFail::Abort,
5481            produces: Vec::new(),
5482            runtime: None,
5483            kind: crate::types::StepKind::Subprocess,
5484            image: None,
5485            tag: None,
5486            push: false,
5487            platforms: Vec::new(),
5488            binary_path: None,
5489            triple: None,
5490            package: None,
5491            context: None,
5492            load: false,
5493            sub_pipeline: None,
5494            gha_workflow: None,
5495            import: None,
5496            matrix: None,
5497            enabled: true,
5498            activation: StepActivation::Active,
5499            if_cond: None,
5500            platform: None,
5501            toolchain: None,
5502            outputs: Vec::new(),
5503        });
5504
5505        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba);
5506        let meta = runner.run().await.unwrap();
5507
5508        assert_eq!(meta.status, RunStatus::Failed);
5509        assert_eq!(
5510            meta.steps.len(),
5511            1,
5512            "step-2 should be skipped after step-1 fails"
5513        );
5514    }
5515
5516    // ── Outcome dispatch tests ─────────────────────────────────────────────
5517
5518    use crate::types::Outcome;
5519    use std::sync::Mutex;
5520
5521    struct RecordingDispatcher {
5522        calls: Mutex<Vec<String>>,
5523    }
5524
5525    impl RecordingDispatcher {
5526        fn new() -> Arc<Self> {
5527            Arc::new(Self {
5528                calls: Mutex::new(vec![]),
5529            })
5530        }
5531
5532        fn recorded(&self) -> Vec<String> {
5533            self.calls.lock().unwrap().clone()
5534        }
5535    }
5536
5537    #[async_trait::async_trait]
5538    impl OutcomeDispatcher for RecordingDispatcher {
5539        async fn warden_deploy(&self, service: &str, env: &str) -> Result<(), RunnerError> {
5540            self.calls
5541                .lock()
5542                .unwrap()
5543                .push(format!("yubaba-deploy:{service}:{env}"));
5544            Ok(())
5545        }
5546
5547        async fn almanac_run(&self, pipeline: &str) -> Result<(), RunnerError> {
5548            self.calls
5549                .lock()
5550                .unwrap()
5551                .push(format!("almanac-run:{pipeline}"));
5552            Ok(())
5553        }
5554
5555        async fn publish(&self, req: &crate::publish::PublishRequest) -> Result<(), RunnerError> {
5556            // Record the bucket + how many artifacts the run collected, so a
5557            // test can assert that only *successful* steps' artifacts arrive.
5558            self.calls.lock().unwrap().push(format!(
5559                "publish:{}:{}",
5560                req.bucket,
5561                req.artifacts.len()
5562            ));
5563            Ok(())
5564        }
5565    }
5566
5567    fn pipeline_with_outcomes(
5568        on_success: Vec<Outcome>,
5569        on_fail: Vec<Outcome>,
5570        argv: Vec<String>,
5571    ) -> Pipeline {
5572        Pipeline {
5573            name: "test".to_string(),
5574            label: "test".to_string(),
5575            steps: vec![crate::types::QedStep {
5576                background: false,
5577                background_until: None,
5578                wait_for: None,
5579                manifest_stitch: None,
5580                name: "step-1".to_string(),
5581                argv,
5582                cwd: None,
5583                env: HashMap::new(),
5584                timeout: None,
5585                on_fail: OnFail::Abort,
5586                produces: Vec::new(),
5587                runtime: None,
5588                kind: crate::types::StepKind::Subprocess,
5589                image: None,
5590                tag: None,
5591                push: false,
5592                platforms: Vec::new(),
5593                binary_path: None,
5594                triple: None,
5595                package: None,
5596                context: None,
5597                load: false,
5598                sub_pipeline: None,
5599                gha_workflow: None,
5600                import: None,
5601                matrix: None,
5602                enabled: true,
5603                activation: StepActivation::Active,
5604                if_cond: None,
5605                platform: None,
5606                toolchain: None,
5607                outputs: Vec::new(),
5608            }],
5609            params: HashMap::new(),
5610            on_success,
5611            on_fail,
5612            triggers: vec![],
5613            concurrency_key: None,
5614            placement: crate::types::Placement::Anywhere,
5615            workspace: crate::types::WorkspaceMode::Live,
5616            wraps: None,
5617            matrix: None,
5618            toolchain: None,
5619            binds: Vec::new(),
5620            on_change: Vec::new(),
5621            finally: Vec::new(),
5622        }
5623    }
5624
5625    /// on_success outcomes are dispatched when the pipeline passes.
5626    #[tokio::test]
5627    async fn dispatches_on_success() {
5628        let dispatcher = RecordingDispatcher::new();
5629        let pipeline = pipeline_with_outcomes(
5630            vec![
5631                Outcome::WardenDeploy {
5632                    service: "yah".into(),
5633                    env: "production".into(),
5634                },
5635                Outcome::AlmanacRun {
5636                    pipeline: "update-index".into(),
5637                },
5638            ],
5639            vec![],
5640            vec!["true".to_string()],
5641        );
5642        let runner = PipelineRunner::new_with_dispatcher(pipeline, dispatcher.clone());
5643        let meta = runner.run().await.unwrap();
5644
5645        assert_eq!(meta.status, RunStatus::Success);
5646        let calls = dispatcher.recorded();
5647        assert_eq!(
5648            calls,
5649            vec!["yubaba-deploy:yah:production", "almanac-run:update-index"]
5650        );
5651    }
5652
5653    /// on_fail outcomes are dispatched when the pipeline fails; on_success is not.
5654    #[tokio::test]
5655    async fn dispatches_on_fail_not_on_success() {
5656        let dispatcher = RecordingDispatcher::new();
5657        let pipeline = pipeline_with_outcomes(
5658            vec![Outcome::WardenDeploy {
5659                service: "yah".into(),
5660                env: "production".into(),
5661            }],
5662            vec![Outcome::AlmanacRun {
5663                pipeline: "notify-failure".into(),
5664            }],
5665            vec!["false".to_string()],
5666        );
5667        let runner = PipelineRunner::new_with_dispatcher(pipeline, dispatcher.clone());
5668        let meta = runner.run().await.unwrap();
5669
5670        assert_eq!(meta.status, RunStatus::Failed);
5671        let calls = dispatcher.recorded();
5672        assert_eq!(calls, vec!["almanac-run:notify-failure"]);
5673    }
5674
5675    /// No outcomes = nothing dispatched.
5676    #[tokio::test]
5677    async fn no_outcomes_no_dispatch() {
5678        let dispatcher = RecordingDispatcher::new();
5679        let pipeline = pipeline_with_outcomes(vec![], vec![], vec!["true".to_string()]);
5680        let runner = PipelineRunner::new_with_dispatcher(pipeline, dispatcher.clone());
5681        runner.run().await.unwrap();
5682        assert!(dispatcher.recorded().is_empty());
5683    }
5684
5685    /// An Outcome::Publish collects the `produces` of *successful* steps and
5686    /// hands them to `dispatcher.publish` (R330-F3). Here the single step
5687    /// declares one artifact and succeeds, so publish sees 1 artifact.
5688    #[tokio::test]
5689    async fn publish_outcome_collects_produced_artifacts() {
5690        let dispatcher = RecordingDispatcher::new();
5691        let mut pipeline = pipeline_with_outcomes(
5692            vec![Outcome::Publish {
5693                provider: "r2".into(),
5694                bucket: "yah-releases".into(),
5695                prefix: None,
5696                base_url: None,
5697            }],
5698            vec![],
5699            vec!["true".to_string()],
5700        );
5701        pipeline.steps[0].produces = vec![crate::types::ProducedArtifact {
5702            binary: "yah".into(),
5703            path: "target/release/yah".into(),
5704            triple: Some("darwin-aarch64".into()),
5705        }];
5706        let runner = PipelineRunner::new_with_dispatcher(pipeline, dispatcher.clone());
5707        let meta = runner.run().await.unwrap();
5708
5709        assert_eq!(meta.status, RunStatus::Success);
5710        assert_eq!(dispatcher.recorded(), vec!["publish:yah-releases:1"]);
5711    }
5712
5713    /// R603-T4: `resume_terminal_publish_for_remote_step` replays the terminal
5714    /// publish for a remote step that finished while the daemon was down. It
5715    /// retrieves the step's `produces` off the (scripted) build-worker and fires
5716    /// the pipeline's `on_success` Outcome::Publish against the LANDED artifact —
5717    /// exactly one publish carrying the one retrieved artifact — WITHOUT
5718    /// re-running the build step. This is the durable-resume path R603-T2's boot
5719    /// reconciler calls once it confirms a persisted remote run reached Success.
5720    #[tokio::test]
5721    async fn resume_publishes_retrieved_remote_artifact() {
5722        let dir = TempDir::new().unwrap();
5723        let camp = TempDir::new().unwrap();
5724        let scryer = make_scryer(&dir);
5725
5726        let container_path = "/tmp/out/librusty_v8-x86_64-unknown-linux-musl.tar.gz";
5727        let payload = b"resumed build tar bytes \x00\x01\x02\xff".to_vec();
5728        let expected_blake3 = blake3::hash(&payload).to_hex().to_string();
5729
5730        let yubaba = Arc::new(
5731            ScriptedWarden::new(vec!["v8 build complete".into()], 0)
5732                .with_produced_file(container_path, payload.clone()),
5733        );
5734
5735        let mut pipeline = pipeline_with_outcomes(
5736            vec![Outcome::Publish {
5737                provider: "r2".into(),
5738                bucket: "yah-releases".into(),
5739                prefix: None,
5740                base_url: None,
5741            }],
5742            vec![],
5743            vec!["build-v8.sh".to_string()],
5744        );
5745        pipeline.steps[0].produces = vec![ProducedArtifact {
5746            binary: "rusty-v8".into(),
5747            path: container_path.into(),
5748            triple: Some("x86_64-unknown-linux-musl".into()),
5749        }];
5750
5751        let dispatcher = RecordingDispatcher::new();
5752        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba)
5753            .with_camp_root(camp.path().to_path_buf())
5754            .with_dispatcher(dispatcher.clone());
5755
5756        // The daemon persisted this bare-uuid workload id at dispatch (R603-T1);
5757        // reconcile hands it back as an ObsForgeId. Resume does NOT run the
5758        // pipeline — the build already finished remotely.
5759        let forge_id = ObsForgeId(Uuid::new_v4());
5760        runner
5761            .resume_terminal_publish_for_remote_step(0, &forge_id)
5762            .await
5763            .expect("resume publishes the retrieved artifact");
5764
5765        // Exactly one publish, carrying the single retrieved artifact.
5766        assert_eq!(dispatcher.recorded(), vec!["publish:yah-releases:1"]);
5767        // The bytes the publish leg saw came off the worker and landed
5768        // content-addressed in camp, not the unreachable container path.
5769        let landed = camp.path().join(".yah/cache/artifacts").join(&expected_blake3);
5770        assert!(landed.exists(), "resume must land the retrieved artifact in camp's store");
5771    }
5772
5773    /// R603-T4 reaping-window fork: if the build finished DURING the outage and
5774    /// kamaji already reaped the container, the produced artifact is
5775    /// un-retrievable. Resume must surface that as an error and fire NO publish —
5776    /// never silently claim published. (Retrieval runs before outcome dispatch,
5777    /// so the `?` short-circuits the publish.)
5778    #[tokio::test]
5779    async fn resume_errors_and_skips_publish_when_artifact_reaped() {
5780        let dir = TempDir::new().unwrap();
5781        let camp = TempDir::new().unwrap();
5782        let scryer = make_scryer(&dir);
5783
5784        // No produced file scripted → fetch_produced_file errors, modelling a
5785        // container kamaji already reaped.
5786        let yubaba = Arc::new(ScriptedWarden::new(vec![], 0));
5787
5788        let mut pipeline = pipeline_with_outcomes(
5789            vec![Outcome::Publish {
5790                provider: "r2".into(),
5791                bucket: "yah-releases".into(),
5792                prefix: None,
5793                base_url: None,
5794            }],
5795            vec![],
5796            vec!["build-v8.sh".to_string()],
5797        );
5798        pipeline.steps[0].produces = vec![ProducedArtifact {
5799            binary: "rusty-v8".into(),
5800            path: "/tmp/out/reaped.tar.gz".into(),
5801            triple: Some("x86_64-unknown-linux-musl".into()),
5802        }];
5803
5804        let dispatcher = RecordingDispatcher::new();
5805        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba)
5806            .with_camp_root(camp.path().to_path_buf())
5807            .with_dispatcher(dispatcher.clone());
5808
5809        let forge_id = ObsForgeId(Uuid::new_v4());
5810        let err = runner
5811            .resume_terminal_publish_for_remote_step(0, &forge_id)
5812            .await
5813            .expect_err("a reaped artifact must surface as an error, not a silent success");
5814        assert!(
5815            matches!(err, RunnerError::StepFailed { .. }),
5816            "retrieval failure should map to StepFailed, got {err:?}",
5817        );
5818        assert!(
5819            dispatcher.recorded().is_empty(),
5820            "no publish may fire when the artifact was reaped",
5821        );
5822    }
5823
5824    /// A failing step's `produces` is dropped — publish only ever runs on
5825    /// on_success outcomes anyway, but guard the collection too.
5826    #[tokio::test]
5827    async fn failed_step_artifacts_not_collected() {
5828        let dispatcher = RecordingDispatcher::new();
5829        let mut pipeline = pipeline_with_outcomes(
5830            vec![],
5831            vec![Outcome::Publish {
5832                provider: "r2".into(),
5833                bucket: "yah-releases".into(),
5834                prefix: None,
5835                base_url: None,
5836            }],
5837            vec!["false".to_string()],
5838        );
5839        pipeline.steps[0].produces = vec![crate::types::ProducedArtifact {
5840            binary: "yah".into(),
5841            path: "target/release/yah".into(),
5842            triple: None,
5843        }];
5844        let runner = PipelineRunner::new_with_dispatcher(pipeline, dispatcher.clone());
5845        let meta = runner.run().await.unwrap();
5846
5847        assert_eq!(meta.status, RunStatus::Failed);
5848        // Publish ran as an on_fail outcome but collected 0 artifacts (the
5849        // producing step failed).
5850        assert_eq!(dispatcher.recorded(), vec!["publish:yah-releases:0"]);
5851    }
5852
5853    // ── R509 Outcome::Provider dispatch wiring ──────────────────────────────
5854
5855    /// Test adapter: records that it ran and (live path) returns a transformed
5856    /// copy of the first input artifact (same path — an in-place transform like
5857    /// notarize) plus one *new* artifact (an appcast), so a downstream outcome
5858    /// can be asserted to see the threaded set.
5859    struct FakeProvider {
5860        calls: Arc<std::sync::atomic::AtomicUsize>,
5861    }
5862
5863    #[async_trait::async_trait]
5864    impl crate::provider::ReleaseProvider for FakeProvider {
5865        fn name(&self) -> &str {
5866            "fake-transform"
5867        }
5868        async fn dispatch(
5869            &self,
5870            ctx: &crate::provider::ProviderContext<'_>,
5871        ) -> Result<crate::provider::ProviderReport, RunnerError> {
5872            self.calls
5873                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5874            // Echo every input back (in-place transform: same paths) and
5875            // append a brand-new appcast artifact.
5876            let mut produced: Vec<ProducedArtifact> = ctx.artifacts.to_vec();
5877            produced.push(ProducedArtifact {
5878                binary: "appcast".into(),
5879                path: "out/appcast.xml".into(),
5880                triple: None,
5881            });
5882            Ok(crate::provider::ProviderReport {
5883                actions: vec!["transformed".into()],
5884                produced,
5885                published: vec!["https://fake/feed.xml".into()],
5886            })
5887        }
5888    }
5889
5890    fn fake_registry(
5891        calls: Arc<std::sync::atomic::AtomicUsize>,
5892    ) -> Arc<crate::provider::ProviderRegistry> {
5893        Arc::new(crate::provider::ProviderRegistry::new().with(Arc::new(FakeProvider { calls })))
5894    }
5895
5896    /// An `Outcome::Provider` dispatches through the wired registry on success.
5897    #[tokio::test]
5898    async fn provider_outcome_dispatches_through_registry() {
5899        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5900        let pipeline = pipeline_with_outcomes(
5901            vec![Outcome::Provider {
5902                provider: "fake-transform".into(),
5903                with: serde_json::Value::Null,
5904                base_url: None,
5905            }],
5906            vec![],
5907            vec!["true".to_string()],
5908        );
5909        let runner = PipelineRunner::new(pipeline).with_release_providers(
5910            fake_registry(calls.clone()),
5911            Arc::new(crate::provider::MapSecrets::default()),
5912        );
5913        let meta = runner.run().await.unwrap();
5914        assert_eq!(meta.status, RunStatus::Success);
5915        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
5916    }
5917
5918    /// A provider transform folds its produced artifacts back into the working
5919    /// set so a *following* `Outcome::Publish` ships the transformed bundle plus
5920    /// any new artifact (the notarize→sparkle / sign→publish chain).
5921    #[tokio::test]
5922    async fn provider_transform_feeds_downstream_publish() {
5923        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5924        let dispatcher = RecordingDispatcher::new();
5925        let mut pipeline = pipeline_with_outcomes(
5926            vec![
5927                Outcome::Provider {
5928                    provider: "fake-transform".into(),
5929                    with: serde_json::Value::Null,
5930                    base_url: None,
5931                },
5932                Outcome::Publish {
5933                    provider: "r2".into(),
5934                    bucket: "yah-releases".into(),
5935                    prefix: None,
5936                    base_url: None,
5937                },
5938            ],
5939            vec![],
5940            vec!["true".to_string()],
5941        );
5942        pipeline.steps[0].produces = vec![ProducedArtifact {
5943            binary: "yah".into(),
5944            path: "target/release/yah".into(),
5945            triple: None,
5946        }];
5947        let runner = PipelineRunner::new_with_dispatcher(pipeline, dispatcher.clone())
5948            .with_release_providers(
5949                fake_registry(calls.clone()),
5950                Arc::new(crate::provider::MapSecrets::default()),
5951            );
5952        let meta = runner.run().await.unwrap();
5953        assert_eq!(meta.status, RunStatus::Success);
5954        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
5955        // The original artifact (replaced in place) + the appended appcast = 2.
5956        assert_eq!(dispatcher.recorded(), vec!["publish:yah-releases:2"]);
5957    }
5958
5959    /// An `Outcome::Provider` naming an unregistered adapter fails the run with
5960    /// a typed error listing the known names (default empty registry).
5961    #[tokio::test]
5962    async fn unknown_provider_outcome_is_typed_error() {
5963        let pipeline = pipeline_with_outcomes(
5964            vec![Outcome::Provider {
5965                provider: "ghost".into(),
5966                with: serde_json::Value::Null,
5967                base_url: None,
5968            }],
5969            vec![],
5970            vec!["true".to_string()],
5971        );
5972        let err = PipelineRunner::new(pipeline).run().await.unwrap_err();
5973        assert!(
5974            matches!(err, RunnerError::Outcome(ref m) if m.contains("ghost")),
5975            "unknown provider surfaces a typed Outcome error: {err}"
5976        );
5977    }
5978
5979    // ── R325-F2 live event-stream tests ────────────────────────────────────
5980
5981    /// A runner with an attached sink emits the full lifecycle in order, with
5982    /// the step's stdout captured as a `StepOutput` line.
5983    #[tokio::test]
5984    async fn emits_lifecycle_events_with_streamed_output() {
5985        let (tx, mut rx) = mpsc::unbounded_channel();
5986        let pipeline = one_step_pipeline(
5987            "test-events",
5988            vec![
5989                "sh".to_string(),
5990                "-c".to_string(),
5991                "echo hello-stdout".to_string(),
5992            ],
5993        );
5994        let runner = PipelineRunner::new(pipeline).with_events(tx);
5995        let meta = runner.run().await.unwrap();
5996        assert_eq!(meta.status, RunStatus::Success);
5997
5998        let mut events = Vec::new();
5999        while let Ok(ev) = rx.try_recv() {
6000            events.push(ev);
6001        }
6002
6003        assert!(
6004            matches!(
6005                events.first(),
6006                Some(QedEvent::RunStarted { total_steps: 1, .. })
6007            ),
6008            "first event is RunStarted, got {:?}",
6009            events.first()
6010        );
6011        assert!(
6012            matches!(
6013                events.last(),
6014                Some(QedEvent::RunFinished {
6015                    status: RunStatus::Success,
6016                    ..
6017                })
6018            ),
6019            "last event is RunFinished/Success, got {:?}",
6020            events.last()
6021        );
6022        assert!(
6023            events
6024                .iter()
6025                .any(|e| matches!(e, QedEvent::StepStarted { index: 0, .. })),
6026            "saw StepStarted for step 0"
6027        );
6028        assert!(
6029            events.iter().any(|e| matches!(
6030                e,
6031                QedEvent::StepFinished {
6032                    index: 0,
6033                    status: RunStatus::Success,
6034                    ..
6035                }
6036            )),
6037            "saw StepFinished/Success for step 0"
6038        );
6039        assert!(
6040            events.iter().any(|e| matches!(
6041                e,
6042                QedEvent::StepOutput { stream: OutputStream::Stdout, line, .. } if line == "hello-stdout"
6043            )),
6044            "captured the echoed stdout line; events={events:?}"
6045        );
6046    }
6047
6048    // ── R513-F2 background sidecar steps (W207 Gap #4) ─────────────────────
6049
6050    /// Build a single subprocess [`QedStep`] named `name` running `argv`,
6051    /// reusing the fully-populated literal in [`one_step_pipeline`] so new
6052    /// fields don't need threading through each background test.
6053    fn mk_step(name: &str, argv: &[&str]) -> crate::types::QedStep {
6054        let mut p = one_step_pipeline("x", argv.iter().map(|s| s.to_string()).collect());
6055        let mut s = p.steps.remove(0);
6056        s.name = name.to_string();
6057        s
6058    }
6059
6060    /// R590-F2: a remote subprocess step's placement mesh-tags come from its
6061    /// declared `[platform].target` arch — so an arm64 host targeting
6062    /// x86_64-unknown-linux-musl is pinned to an x86 build-worker (us-west-002),
6063    /// not left to emulate. No target ⇒ empty (any infra node).
6064    #[test]
6065    fn remote_subprocess_mesh_tags_pins_arch_matched_worker_from_target() {
6066        use crate::platform::PlatformSpec;
6067
6068        let plain = mk_step("plain", &["cargo", "build"]);
6069        assert!(
6070            remote_subprocess_mesh_tags(&plain).is_empty(),
6071            "no platform.target must leave placement unpinned",
6072        );
6073
6074        let mut x86 = mk_step("v8", &["build-v8.sh", "x86_64-unknown-linux-musl", "out.tar.gz"]);
6075        x86.platform = Some(PlatformSpec {
6076            target: Some("x86_64-unknown-linux-musl".into()),
6077            container_platform: None,
6078            native: false,
6079        });
6080        assert_eq!(
6081            remote_subprocess_mesh_tags(&x86),
6082            vec!["tag:build-worker".to_string(), "tier:x86".to_string()],
6083        );
6084
6085        let mut arm = mk_step("arm", &["true"]);
6086        arm.platform = Some(PlatformSpec {
6087            target: Some("aarch64-unknown-linux-musl".into()),
6088            container_platform: None,
6089            native: false,
6090        });
6091        assert_eq!(
6092            remote_subprocess_mesh_tags(&arm),
6093            vec!["tag:build-worker".to_string(), "tier:arm".to_string()],
6094        );
6095    }
6096
6097    /// A `native = true` step whose cross-arch target forces the offload branch
6098    /// of the policy — the `rusty-v8-musl` shape.
6099    fn native_offload_step() -> crate::types::QedStep {
6100        use crate::platform::PlatformSpec;
6101        let mut s = mk_step(
6102            "build-v8",
6103            &["build-v8.sh", "x86_64-unknown-linux-musl", "out.tar.gz"],
6104        );
6105        s.platform = Some(PlatformSpec {
6106            target: Some("x86_64-unknown-linux-musl".into()),
6107            container_platform: None,
6108            native: true,
6109        });
6110        s
6111    }
6112
6113    // ── R590-F4 policy routing ───────────────────────────────────────────────
6114
6115    /// `policy_placement` folds the `--where` force-mode with a step's
6116    /// resolution: force-modes pass through, and Auto routes only Offload
6117    /// verdicts to the fleet.
6118    #[test]
6119    fn policy_placement_forces_and_derives() {
6120        use crate::platform::Resolution;
6121        let offload = Resolution::Offload {
6122            target: "x86_64-unknown-linux-musl".into(),
6123        };
6124        // Force-modes ignore the resolution entirely.
6125        assert_eq!(policy_placement(RunWhere::Local, &offload), RunWhere::Local);
6126        assert_eq!(
6127            policy_placement(RunWhere::Remote, &Resolution::NativeCross),
6128            RunWhere::Remote
6129        );
6130        // Auto derives: Offload → Remote, everything else → Local.
6131        assert_eq!(policy_placement(RunWhere::Auto, &offload), RunWhere::Remote);
6132        assert_eq!(
6133            policy_placement(RunWhere::Auto, &Resolution::NativeCross),
6134            RunWhere::Local
6135        );
6136        assert_eq!(
6137            policy_placement(
6138                RunWhere::Auto,
6139                &Resolution::Emulate {
6140                    docker_platform: "linux/amd64".into()
6141                }
6142            ),
6143            RunWhere::Local
6144        );
6145    }
6146
6147    /// On an arm64 host, the default (Auto) runner routes a `native = true`
6148    /// x86 musl step to the fleet — no `--where=remote` — while an ordinary
6149    /// cross-compilable step stays local. A forced `--where=local` runner keeps
6150    /// even the native step local (the testing override).
6151    #[test]
6152    fn effective_placement_routes_native_offload_under_auto() {
6153        let pipeline = bg_pipeline("v8", vec![native_offload_step()]);
6154        let auto = PipelineRunner::new(pipeline.clone())
6155            .with_host_triple("aarch64-apple-darwin");
6156        // new()/new_with_dispatcher default to Local; flip to Auto to model the
6157        // default CLI mode without standing up a real dispatcher.
6158        let auto = PipelineRunner {
6159            run_where: RunWhere::Auto,
6160            ..auto
6161        };
6162        let step = &auto.pipeline.steps[0];
6163        assert_eq!(auto.effective_placement(step), RunWhere::Remote);
6164        // Runtime for an offloaded step defaults to Container.
6165        assert_eq!(auto.resolve_runtime(step), TaskRuntime::Container);
6166
6167        // An ordinary cross step (native=false) stays local under Auto.
6168        let mut plain = native_offload_step();
6169        plain.platform.as_mut().unwrap().native = false;
6170        let plain_pipeline = bg_pipeline("plain", vec![plain]);
6171        let auto_plain = PipelineRunner {
6172            run_where: RunWhere::Auto,
6173            ..PipelineRunner::new(plain_pipeline).with_host_triple("aarch64-apple-darwin")
6174        };
6175        assert_eq!(
6176            auto_plain.effective_placement(&auto_plain.pipeline.steps[0]),
6177            RunWhere::Local
6178        );
6179
6180        // Force-local keeps the native step local.
6181        let forced = PipelineRunner::new(pipeline).with_host_triple("aarch64-apple-darwin");
6182        assert_eq!(
6183            forced.effective_placement(&forced.pipeline.steps[0]),
6184            RunWhere::Local
6185        );
6186    }
6187
6188    /// On the x86 build-worker itself the native x86 step is host-arch → it runs
6189    /// locally, never re-dispatched (the offload target *is* this host).
6190    #[test]
6191    fn effective_placement_native_step_runs_local_on_matching_host() {
6192        let pipeline = bg_pipeline("v8", vec![native_offload_step()]);
6193        let auto = PipelineRunner {
6194            run_where: RunWhere::Auto,
6195            ..PipelineRunner::new(pipeline).with_host_triple("x86_64-unknown-linux-gnu")
6196        };
6197        assert_eq!(
6198            auto.effective_placement(&auto.pipeline.steps[0]),
6199            RunWhere::Local
6200        );
6201    }
6202
6203    /// `pipeline_needs_offload` tells the CLI whether an Auto run must stand up a
6204    /// mesh dispatcher: true when any native cross step offloads on this host,
6205    /// false for an all-cross-compilable pipeline.
6206    #[test]
6207    fn pipeline_needs_offload_detects_native_cross_step() {
6208        let with_native = bg_pipeline("v8", vec![native_offload_step()]);
6209        assert!(pipeline_needs_offload(&with_native, "aarch64-apple-darwin"));
6210        // Same step on the matching host: host-arch build, no offload.
6211        assert!(!pipeline_needs_offload(&with_native, "x86_64-unknown-linux-gnu"));
6212
6213        let mut plain = native_offload_step();
6214        plain.platform.as_mut().unwrap().native = false;
6215        let no_native = bg_pipeline("plain", vec![plain]);
6216        assert!(!pipeline_needs_offload(&no_native, "aarch64-apple-darwin"));
6217    }
6218
6219    /// An Auto runner that policy-routes a step to Offload but has no dispatcher
6220    /// wired fails with a clear config error instead of panicking.
6221    #[tokio::test]
6222    async fn offload_without_dispatcher_errors_cleanly() {
6223        let pipeline = bg_pipeline("v8", vec![native_offload_step()]);
6224        let auto = PipelineRunner {
6225            run_where: RunWhere::Auto,
6226            ..PipelineRunner::new(pipeline).with_host_triple("aarch64-apple-darwin")
6227        };
6228        let step = auto.pipeline.steps[0].clone();
6229        let err = auto
6230            .execute_step_remote(0, &step, TaskRuntime::Container)
6231            .await
6232            .expect_err("no dispatcher wired must error, not panic");
6233        match err {
6234            RunnerError::InvalidConfig(m) => {
6235                assert!(m.contains("Offload"), "message: {m}");
6236                assert!(m.contains("no remote dispatcher"), "message: {m}");
6237            }
6238            other => panic!("expected InvalidConfig, got {other:?}"),
6239        }
6240    }
6241
6242    /// A forced `--where=local` runner keeps a `native = true` cross-arch step
6243    /// Local (effective_placement never inspects the step), so it would reach
6244    /// the local-container path. That path must REFUSE rather than let Docker
6245    /// silently emulate the foreign-arch image under QEMU — the "fail not a
6246    /// warning" contract for the rusty-v8-musl forcing case. No docker daemon
6247    /// is touched: the guard fires before any container is started.
6248    #[tokio::test]
6249    async fn native_offload_step_refuses_local_container_emulation() {
6250        let pipeline = bg_pipeline("v8", vec![native_offload_step()]);
6251        // Default runner is forced-Local; arm64 host + x86 native target ⇒ the
6252        // step resolves to Offload, so local-container execution == emulation.
6253        let forced = PipelineRunner::new(pipeline).with_host_triple("aarch64-apple-darwin");
6254        let step = forced.pipeline.steps[0].clone();
6255        assert_eq!(forced.effective_placement(&step), RunWhere::Local);
6256
6257        let err = forced
6258            .execute_step_local_container(0, &step)
6259            .await
6260            .expect_err("a native cross-arch step must not emulate locally");
6261        match err {
6262            RunnerError::StepFailed { step: s, msg } => {
6263                assert_eq!(s, "build-v8");
6264                assert!(msg.contains("must offload"), "message: {msg}");
6265                assert!(msg.contains("tier:x86"), "message: {msg}");
6266                assert!(msg.contains("aarch64-apple-darwin"), "message: {msg}");
6267            }
6268            other => panic!("expected StepFailed, got {other:?}"),
6269        }
6270    }
6271
6272    // ── R633: build-image placement + platforms ──────────────────────────────
6273
6274    /// `QedStep::default()` must agree with what serde produces for a step that
6275    /// declares nothing. The field that bites is `enabled`: a *derived* Default
6276    /// makes it `false`, so every `..Default::default()` call site would build a
6277    /// step the runner skips — and a pipeline of skipped steps reports Success,
6278    /// which is a green light over work that never ran.
6279    #[test]
6280    fn qed_step_default_matches_serde_defaults() {
6281        let d = QedStep::default();
6282        assert!(
6283            d.enabled,
6284            "a default step must be enabled, or `..Default::default()` silently builds a no-op"
6285        );
6286        assert_eq!(d.activation, crate::types::StepActivation::Active);
6287        assert_eq!(d.kind, crate::types::StepKind::Subprocess);
6288        assert!(d.platforms.is_empty());
6289
6290        let from_toml: QedStep =
6291            toml::from_str(r#"name = "x""#).expect("a bare step parses on serde defaults");
6292        assert_eq!(from_toml.enabled, d.enabled);
6293        assert_eq!(from_toml.activation, d.activation);
6294        assert_eq!(from_toml.kind, d.kind);
6295    }
6296
6297    /// A `build-image` step for `img`, targeting one docker platform. `native`
6298    /// mirrors what `yah qed images build` synthesizes for a foreign-arch
6299    /// platform.
6300    fn build_image_step(img: &str, platform: &str, target: &str, native: bool) -> QedStep {
6301        use crate::platform::PlatformSpec;
6302        QedStep {
6303            name: format!("build-{img}"),
6304            kind: crate::types::StepKind::BuildImage,
6305            image: Some(img.to_string()),
6306            tag: Some(format!("cr.yah.dev/{img}:dev")),
6307            platforms: vec![platform.to_string()],
6308            platform: Some(PlatformSpec {
6309                target: Some(target.to_string()),
6310                container_platform: Some(platform.to_string()),
6311                native,
6312            }),
6313            ..Default::default()
6314        }
6315    }
6316
6317    /// The R633 routing fix: under the default `Auto`, a `native = true`
6318    /// cross-arch build-image step must resolve to Remote (offload to an
6319    /// arch-matched build-worker) exactly like a subprocess step does.
6320    ///
6321    /// Before the fix `execute_step_build_image` read `self.run_where`, which is
6322    /// `Auto` here and so fell through to the LOCAL docker path — building the
6323    /// foreign image on the qed host, or emulating it.
6324    #[test]
6325    fn build_image_step_offloads_under_auto() {
6326        let step = build_image_step(
6327            "rusty-v8-musl-builder",
6328            "linux/amd64",
6329            "x86_64-unknown-linux-musl",
6330            true,
6331        );
6332        let pipeline = bg_pipeline("images", vec![step.clone()]);
6333        let auto = PipelineRunner::new(pipeline).with_host_triple("aarch64-apple-darwin");
6334        let auto = PipelineRunner {
6335            run_where: RunWhere::Auto,
6336            ..auto
6337        };
6338        assert_eq!(auto.effective_placement(&step), RunWhere::Remote);
6339
6340        // The same step on a matching host is a plain local build — no fleet.
6341        let native_host = PipelineRunner {
6342            run_where: RunWhere::Auto,
6343            ..PipelineRunner::new(bg_pipeline("images", vec![step.clone()]))
6344                .with_host_triple("x86_64-unknown-linux-gnu")
6345        };
6346        assert_eq!(native_host.effective_placement(&step), RunWhere::Local);
6347    }
6348
6349    /// R636: a remote build-image step must route to a worker of the step's
6350    /// TARGET arch, not the runner's host arch. An amd64 image build offloaded
6351    /// from an arm64 Mac has to land on `tier:x86`; deriving the tier from the
6352    /// host sent it to a `tier:arm` node that failed on an unreachable URL.
6353    #[test]
6354    fn remote_build_image_routes_by_target_arch_not_host() {
6355        let amd64 = build_image_step(
6356            "rusty-v8-musl-builder",
6357            "linux/amd64",
6358            "x86_64-unknown-linux-musl",
6359            true,
6360        );
6361        let runner = PipelineRunner {
6362            run_where: RunWhere::Auto,
6363            ..PipelineRunner::new(bg_pipeline("images", vec![amd64.clone()]))
6364                .with_host_triple("aarch64-apple-darwin")
6365        };
6366        // The forcing case: host is arm64, target is x86 → must pick x86.
6367        assert_eq!(runner.remote_build_image_arch(&amd64), "x86_64");
6368        assert_eq!(
6369            crate::platform::build_worker_mesh_tags(&runner.remote_build_image_arch(&amd64)),
6370            vec!["tag:build-worker".to_string(), "tier:x86".to_string()]
6371        );
6372
6373        // A host-native build-image forced remote has no cross target → host arch.
6374        let host_native = build_image_step(
6375            "yah-rust",
6376            "linux/arm64",
6377            "aarch64-unknown-linux-musl",
6378            false,
6379        );
6380        let remote = PipelineRunner {
6381            run_where: RunWhere::Remote,
6382            ..PipelineRunner::new(bg_pipeline("images", vec![host_native.clone()]))
6383                .with_host_triple("aarch64-apple-darwin")
6384        };
6385        assert_eq!(remote.remote_build_image_arch(&host_native), "aarch64");
6386    }
6387
6388    /// A foreign `platforms` entry reaching the LOCAL docker path is a hard
6389    /// error, not a QEMU build. This is the case `--where local` produces:
6390    /// `effective_placement` returns Local without inspecting the step, so the
6391    /// refusal has to live at the build-image seam itself.
6392    #[test]
6393    fn build_image_refuses_foreign_platform_on_local_daemon() {
6394        let step = build_image_step(
6395            "rusty-v8-musl-builder",
6396            "linux/amd64",
6397            "x86_64-unknown-linux-musl",
6398            false,
6399        );
6400        let forced = PipelineRunner::new(bg_pipeline("images", vec![step.clone()]))
6401            .with_host_triple("aarch64-apple-darwin");
6402        assert_eq!(forced.effective_placement(&step), RunWhere::Local);
6403
6404        let err = forced
6405            .refuse_foreign_platform_locally(&step)
6406            .expect_err("a foreign-platform image build must not emulate locally");
6407        match err {
6408            RunnerError::StepFailed { step: s, msg } => {
6409                assert_eq!(s, "build-rusty-v8-musl-builder");
6410                assert!(msg.contains("linux/amd64"), "message: {msg}");
6411                assert!(msg.contains("tier:x86"), "message: {msg}");
6412                assert!(msg.contains("aarch64-apple-darwin"), "message: {msg}");
6413            }
6414            other => panic!("expected StepFailed, got {other:?}"),
6415        }
6416    }
6417
6418    /// The host's own platform is always fine, and an empty `platforms` (every
6419    /// pre-R633 build-image step) must stay a plain host-native build.
6420    #[test]
6421    fn build_image_allows_host_platform_and_empty_platforms() {
6422        let host_step = build_image_step(
6423            "yah-rust",
6424            "linux/arm64",
6425            "aarch64-unknown-linux-musl",
6426            false,
6427        );
6428        let runner = PipelineRunner::new(bg_pipeline("images", vec![host_step.clone()]))
6429            .with_host_triple("aarch64-apple-darwin");
6430        assert!(runner.refuse_foreign_platform_locally(&host_step).is_ok());
6431
6432        let mut legacy = host_step.clone();
6433        legacy.platforms.clear();
6434        assert!(runner.refuse_foreign_platform_locally(&legacy).is_ok());
6435
6436        // An arch buildx knows but we don't is buildx's to reject, not ours —
6437        // guessing here would turn a working build into a false blocker.
6438        let mut exotic = host_step;
6439        exotic.platforms = vec!["linux/riscv64".to_string()];
6440        assert!(runner.refuse_foreign_platform_locally(&exotic).is_ok());
6441    }
6442
6443    /// A multi-step local pipeline (Live workspace, no outcomes) from the
6444    /// given steps.
6445    fn bg_pipeline(name: &str, steps: Vec<crate::types::QedStep>) -> Pipeline {
6446        let mut p = one_step_pipeline(name, vec!["true".to_string()]);
6447        p.steps = steps;
6448        p
6449    }
6450
6451    /// Position of the `StepFinished` event for the named step, if any.
6452    fn finished_pos(events: &[QedEvent], name: &str) -> Option<usize> {
6453        events.iter().position(
6454            |e| matches!(e, QedEvent::StepFinished { name: n, .. } if n == name),
6455        )
6456    }
6457
6458    fn drain_events(rx: &mut mpsc::UnboundedReceiver<QedEvent>) -> Vec<QedEvent> {
6459        let mut events = Vec::new();
6460        while let Ok(ev) = rx.try_recv() {
6461            events.push(ev);
6462        }
6463        events
6464    }
6465
6466    /// A `background = true` sidecar that never exits on its own is spawned (so
6467    /// the loop doesn't block on it), runs alongside the foreground step, and is
6468    /// reaped — killed cleanly, status Success — at the end of the pipeline.
6469    #[tokio::test]
6470    async fn background_step_spawns_and_is_reaped_at_pipeline_end() {
6471        let (tx, mut rx) = mpsc::unbounded_channel();
6472        let server = {
6473            let mut s = mk_step("server", &["sh", "-c", "sleep 30"]);
6474            s.background = true;
6475            s
6476        };
6477        let work = mk_step("work", &["sh", "-c", "echo done"]);
6478        let pipeline = bg_pipeline("bg-end", vec![server, work]);
6479
6480        let runner = PipelineRunner::new(pipeline).with_events(tx);
6481        // Completes promptly despite the sidecar's `sleep 30` — proof the loop
6482        // never awaited it.
6483        let meta = runner.run().await.unwrap();
6484
6485        assert_eq!(meta.status, RunStatus::Success);
6486        let server_row = meta.steps.iter().find(|s| s.name == "server").unwrap();
6487        assert_eq!(
6488            server_row.status,
6489            RunStatus::Success,
6490            "a healthy sidecar killed at teardown is Success, not a failure"
6491        );
6492        assert!(server_row.completed_at.is_some());
6493
6494        let events = drain_events(&mut rx);
6495        // The sidecar's StepFinished lands after the foreground step's — it was
6496        // reaped at the end of the loop.
6497        let server_fin = finished_pos(&events, "server").expect("server finished");
6498        let work_fin = finished_pos(&events, "work").expect("work finished");
6499        assert!(
6500            work_fin < server_fin,
6501            "background server reaped after foreground work; events={events:?}"
6502        );
6503    }
6504
6505    /// `background_until = "gate"` reaps the sidecar the moment the gate step
6506    /// finishes — before any later step runs.
6507    #[tokio::test]
6508    async fn background_until_reaps_after_named_step() {
6509        let (tx, mut rx) = mpsc::unbounded_channel();
6510        let server = {
6511            let mut s = mk_step("server", &["sh", "-c", "sleep 30"]);
6512            s.background_until = Some("gate".to_string());
6513            s
6514        };
6515        let gate = mk_step("gate", &["sh", "-c", "echo gate"]);
6516        let after = mk_step("after", &["sh", "-c", "echo after"]);
6517        let pipeline = bg_pipeline("bg-until", vec![server, gate, after]);
6518
6519        let meta = PipelineRunner::new(pipeline)
6520            .with_events(tx)
6521            .run()
6522            .await
6523            .unwrap();
6524        assert_eq!(meta.status, RunStatus::Success);
6525
6526        let events = drain_events(&mut rx);
6527        let gate_fin = finished_pos(&events, "gate").expect("gate finished");
6528        let server_fin = finished_pos(&events, "server").expect("server finished");
6529        let after_fin = finished_pos(&events, "after").expect("after finished");
6530        assert!(
6531            gate_fin < server_fin && server_fin < after_fin,
6532            "server reaped after gate, before after; events={events:?}"
6533        );
6534    }
6535
6536    /// A sidecar that *exits non-zero on its own* before reap is a genuine
6537    /// failure: its step is Failed and the run flips to Failed (so `on_fail`
6538    /// fires). The gate step's sleep guarantees the crasher has exited by reap.
6539    #[tokio::test]
6540    async fn background_sidecar_crash_fails_the_run() {
6541        let crasher = {
6542            let mut s = mk_step("crasher", &["sh", "-c", "exit 7"]);
6543            s.background_until = Some("gate".to_string());
6544            s
6545        };
6546        let gate = mk_step("gate", &["sh", "-c", "sleep 0.3; echo gate"]);
6547        let pipeline = bg_pipeline("bg-crash", vec![crasher, gate]);
6548
6549        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
6550        assert_eq!(
6551            meta.status,
6552            RunStatus::Failed,
6553            "a sidecar that crashed mid-pipeline flips the run to Failed"
6554        );
6555        let crasher_row = meta.steps.iter().find(|s| s.name == "crasher").unwrap();
6556        assert_eq!(crasher_row.status, RunStatus::Failed);
6557    }
6558
6559    /// Pre-flight rejects a `background_until` that names a step at-or-before
6560    /// the sidecar — the gate would never fire, so fail loudly at run start.
6561    #[tokio::test]
6562    async fn background_until_earlier_step_is_rejected() {
6563        let early = mk_step("early", &["sh", "-c", "echo early"]);
6564        let server = {
6565            let mut s = mk_step("server", &["sh", "-c", "sleep 30"]);
6566            s.background_until = Some("early".to_string());
6567            s
6568        };
6569        let pipeline = bg_pipeline("bg-bad-order", vec![early, server]);
6570
6571        let err = PipelineRunner::new(pipeline).run().await.unwrap_err();
6572        assert!(
6573            matches!(err, RunnerError::InvalidConfig(ref m) if m.contains("later")),
6574            "expected later-step InvalidConfig, got {err:?}"
6575        );
6576    }
6577
6578    /// Pre-flight rejects a `background_until` naming a nonexistent step.
6579    #[tokio::test]
6580    async fn background_until_unknown_step_is_rejected() {
6581        let server = {
6582            let mut s = mk_step("server", &["sh", "-c", "sleep 30"]);
6583            s.background_until = Some("nope".to_string());
6584            s
6585        };
6586        let work = mk_step("work", &["sh", "-c", "echo done"]);
6587        let pipeline = bg_pipeline("bg-bad-name", vec![server, work]);
6588
6589        let err = PipelineRunner::new(pipeline).run().await.unwrap_err();
6590        assert!(
6591            matches!(err, RunnerError::InvalidConfig(ref m) if m.contains("unknown step")),
6592            "expected unknown-step InvalidConfig, got {err:?}"
6593        );
6594    }
6595
6596    // ── R513-F3 wait-for health-gate steps (W207 Gap #5) ──────────────────
6597
6598    /// Build a `kind = wait-for` step from a [`crate::types::WaitForConfig`],
6599    /// reusing the populated literal from [`mk_step`] so new QedStep fields
6600    /// don't have to be threaded through each test.
6601    fn mk_wait_for(name: &str, cfg: crate::types::WaitForConfig) -> crate::types::QedStep {
6602        let mut s = mk_step(name, &["unused"]);
6603        s.argv = vec![];
6604        s.kind = crate::types::StepKind::WaitFor;
6605        s.wait_for = Some(cfg);
6606        s
6607    }
6608
6609    /// A `tcp` wait-for against a live listener passes immediately and the run
6610    /// goes green.
6611    #[tokio::test]
6612    async fn wait_for_tcp_passes_against_live_listener() {
6613        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6614        let addr = listener.local_addr().unwrap().to_string();
6615        // Hold the listener alive for the duration of the run.
6616        let _accept = tokio::spawn(async move {
6617            let _ = listener.accept().await;
6618        });
6619
6620        let gate = mk_wait_for(
6621            "wait:db",
6622            crate::types::WaitForConfig {
6623                http: None,
6624                tcp: Some(addr),
6625                expect_status: None,
6626                timeout_secs: 5,
6627                interval_ms: 50,
6628            },
6629        );
6630        let work = mk_step("work", &["sh", "-c", "echo done"]);
6631        let pipeline = bg_pipeline("wf-tcp", vec![gate, work]);
6632
6633        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
6634        assert_eq!(meta.status, RunStatus::Success);
6635        let gate_row = meta.steps.iter().find(|s| s.name == "wait:db").unwrap();
6636        assert_eq!(gate_row.status, RunStatus::Success);
6637    }
6638
6639    /// An `http` wait-for polls a server that is initially down, then becomes
6640    /// healthy mid-budget — the gate passes once the endpoint answers 200.
6641    #[tokio::test]
6642    async fn wait_for_http_passes_once_server_comes_up() {
6643        // Reserve a port, free it, and only start serving after a short delay —
6644        // so the first poll(s) fail with connect-refused and a later one
6645        // succeeds, exercising the retry loop.
6646        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6647        let addr = probe.local_addr().unwrap();
6648        drop(probe);
6649
6650        tokio::spawn(async move {
6651            tokio::time::sleep(std::time::Duration::from_millis(150)).await;
6652            let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
6653            loop {
6654                let Ok((mut sock, _)) = listener.accept().await else {
6655                    break;
6656                };
6657                use tokio::io::{AsyncReadExt, AsyncWriteExt};
6658                let mut scratch = [0u8; 1024];
6659                let _ = sock.read(&mut scratch).await;
6660                let _ = sock
6661                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
6662                    .await;
6663            }
6664        });
6665
6666        let gate = mk_wait_for(
6667            "wait:ready",
6668            crate::types::WaitForConfig {
6669                http: Some(format!("http://{addr}/health")),
6670                tcp: None,
6671                expect_status: None,
6672                timeout_secs: 5,
6673                interval_ms: 50,
6674            },
6675        );
6676        let (tx, mut rx) = mpsc::unbounded_channel();
6677        let pipeline = bg_pipeline("wf-http", vec![gate]);
6678        let meta = PipelineRunner::new(pipeline).with_events(tx).run().await.unwrap();
6679        assert_eq!(meta.status, RunStatus::Success);
6680
6681        // The success line names the endpoint as healthy.
6682        let events = drain_events(&mut rx);
6683        assert!(
6684            events.iter().any(|e| matches!(
6685                e,
6686                QedEvent::StepOutput { line, .. } if line.contains("healthy after")
6687            )),
6688            "emitted a 'healthy after' progress line; events={events:?}"
6689        );
6690    }
6691
6692    /// A wait-for whose endpoint never comes up fails the step (and the run)
6693    /// once the timeout budget elapses, with a "never became healthy" message.
6694    #[tokio::test]
6695    async fn wait_for_times_out_when_endpoint_never_healthy() {
6696        // A port nothing listens on.
6697        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6698        let addr = probe.local_addr().unwrap().to_string();
6699        drop(probe);
6700
6701        let gate = mk_wait_for(
6702            "wait:never",
6703            crate::types::WaitForConfig {
6704                http: None,
6705                tcp: Some(addr),
6706                expect_status: None,
6707                timeout_secs: 1,
6708                interval_ms: 100,
6709            },
6710        );
6711        let pipeline = bg_pipeline("wf-timeout", vec![gate]);
6712        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
6713        assert_eq!(meta.status, RunStatus::Failed);
6714        let row = meta.steps.iter().find(|s| s.name == "wait:never").unwrap();
6715        assert_eq!(row.status, RunStatus::Failed);
6716        let err = row.error.as_deref().unwrap_or_default();
6717        assert!(
6718            err.contains("never became healthy"),
6719            "timeout surfaces a clear message; got {err:?}"
6720        );
6721    }
6722
6723    /// An `https://` URL is rejected up front with a pointed message rather than
6724    /// silently failing a plaintext GET against a TLS port for the whole budget.
6725    #[tokio::test]
6726    async fn wait_for_https_fails_fast() {
6727        let gate = mk_wait_for(
6728            "wait:tls",
6729            crate::types::WaitForConfig {
6730                http: Some("https://localhost:8443/health".to_string()),
6731                tcp: None,
6732                expect_status: None,
6733                timeout_secs: 30, // long budget; must NOT be consumed
6734                interval_ms: 100,
6735            },
6736        );
6737        let pipeline = bg_pipeline("wf-tls", vec![gate]);
6738        let started = std::time::Instant::now();
6739        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
6740        assert_eq!(meta.status, RunStatus::Failed);
6741        assert!(
6742            started.elapsed() < std::time::Duration::from_secs(5),
6743            "https rejection is immediate, not after the 30s budget"
6744        );
6745        let row = meta.steps.iter().find(|s| s.name == "wait:tls").unwrap();
6746        let err = row.error.as_deref().unwrap_or_default();
6747        assert!(err.contains("https"), "names the https limitation; got {err:?}");
6748    }
6749
6750    // ── R513-F4 finally: always-run teardown (W207 Gap #6) ────────────────
6751
6752    /// A `finally` step runs after a passing pipeline, after the main step, and
6753    /// the run stays green.
6754    #[tokio::test]
6755    async fn finally_runs_after_successful_pipeline() {
6756        let (tx, mut rx) = mpsc::unbounded_channel();
6757        let mut pipeline = bg_pipeline("fin-ok", vec![mk_step("work", &["sh", "-c", "echo work"])]);
6758        pipeline.finally = vec![mk_step("teardown", &["sh", "-c", "echo teardown"])];
6759
6760        let meta = PipelineRunner::new(pipeline).with_events(tx).run().await.unwrap();
6761        assert_eq!(meta.status, RunStatus::Success);
6762        let td = meta.steps.iter().find(|s| s.name == "teardown").unwrap();
6763        assert_eq!(td.status, RunStatus::Success);
6764
6765        let events = drain_events(&mut rx);
6766        let work_fin = finished_pos(&events, "work").expect("work finished");
6767        let td_fin = finished_pos(&events, "teardown").expect("teardown finished");
6768        assert!(work_fin < td_fin, "finally runs after the main step; events={events:?}");
6769    }
6770
6771    /// A `finally` step runs even when the pipeline body failed — that's the
6772    /// whole point (upload traces on a failed test run).
6773    #[tokio::test]
6774    async fn finally_runs_even_when_pipeline_fails() {
6775        let mut pipeline =
6776            bg_pipeline("fin-onfail", vec![mk_step("work", &["sh", "-c", "exit 1"])]);
6777        pipeline.finally = vec![mk_step("teardown", &["sh", "-c", "echo cleaned"])];
6778
6779        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
6780        assert_eq!(meta.status, RunStatus::Failed, "body failed → run failed");
6781        let td = meta.steps.iter().find(|s| s.name == "teardown").unwrap();
6782        assert_eq!(
6783            td.status,
6784            RunStatus::Success,
6785            "teardown still ran despite the body failure"
6786        );
6787    }
6788
6789    /// A failing `finally` step marks the run Failed, but outcome selection keys
6790    /// off the *work* status — so a green body still fires `on_success`.
6791    #[tokio::test]
6792    async fn finally_failure_marks_run_failed_but_on_success_still_fires() {
6793        let dispatcher = RecordingDispatcher::new();
6794        let mut pipeline = pipeline_with_outcomes(
6795            vec![Outcome::WardenDeploy {
6796                service: "yah".into(),
6797                env: "production".into(),
6798            }],
6799            vec![Outcome::AlmanacRun {
6800                pipeline: "should-not-run".into(),
6801            }],
6802            vec!["true".to_string()], // body passes
6803        );
6804        pipeline.finally = vec![mk_step("teardown", &["sh", "-c", "exit 3"])];
6805
6806        let runner = PipelineRunner::new_with_dispatcher(pipeline, dispatcher.clone());
6807        let meta = runner.run().await.unwrap();
6808
6809        // The run is Failed (teardown broke)…
6810        assert_eq!(meta.status, RunStatus::Failed);
6811        // …but the on_success outcome fired (work passed), and on_fail did NOT.
6812        assert_eq!(
6813            dispatcher.recorded(),
6814            vec!["yubaba-deploy:yah:production"],
6815            "outcome selection uses work-status, not the teardown failure"
6816        );
6817    }
6818
6819    /// `on_fail = "continue"` on a `finally` step keeps a teardown failure from
6820    /// marking the run Failed.
6821    #[tokio::test]
6822    async fn finally_continue_on_fail_keeps_run_green() {
6823        let mut teardown = mk_step("teardown", &["sh", "-c", "exit 1"]);
6824        teardown.on_fail = OnFail::Continue;
6825        let mut pipeline = bg_pipeline("fin-cont", vec![mk_step("work", &["sh", "-c", "true"])]);
6826        pipeline.finally = vec![teardown];
6827
6828        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
6829        assert_eq!(
6830            meta.status,
6831            RunStatus::Success,
6832            "continue-on-fail teardown failure doesn't fail the run"
6833        );
6834        let td = meta.steps.iter().find(|s| s.name == "teardown").unwrap();
6835        assert_eq!(td.status, RunStatus::Failed, "the step itself still records Failed");
6836    }
6837
6838    /// Every `finally` step is attempted even if an earlier one fails (best-effort
6839    /// teardown — a failure never aborts the rest).
6840    #[tokio::test]
6841    async fn all_finally_steps_run_even_if_one_fails() {
6842        let mut pipeline = bg_pipeline("fin-all", vec![mk_step("work", &["sh", "-c", "true"])]);
6843        pipeline.finally = vec![
6844            mk_step("teardown-a", &["sh", "-c", "exit 1"]), // fails (Abort default)
6845            mk_step("teardown-b", &["sh", "-c", "echo b"]), // must still run
6846        ];
6847
6848        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
6849        assert_eq!(meta.status, RunStatus::Failed);
6850        let a = meta.steps.iter().find(|s| s.name == "teardown-a").unwrap();
6851        let b = meta.steps.iter().find(|s| s.name == "teardown-b").unwrap();
6852        assert_eq!(a.status, RunStatus::Failed);
6853        assert_eq!(
6854            b.status,
6855            RunStatus::Success,
6856            "teardown-b ran despite teardown-a failing"
6857        );
6858    }
6859
6860    /// A failing step streams stderr; the failure status reaches RunFinished
6861    /// and the stderr tail surfaces in the StepFailed message.
6862    #[tokio::test]
6863    async fn failing_step_streams_stderr_and_finishes_failed() {
6864        let (tx, mut rx) = mpsc::unbounded_channel();
6865        let pipeline = one_step_pipeline(
6866            "test-events-fail",
6867            vec![
6868                "sh".to_string(),
6869                "-c".to_string(),
6870                "echo boom >&2; exit 1".to_string(),
6871            ],
6872        );
6873        let runner = PipelineRunner::new(pipeline).with_events(tx);
6874        let meta = runner.run().await.unwrap();
6875        assert_eq!(meta.status, RunStatus::Failed);
6876
6877        // The failure reason is persisted on the terminal StepStatus, not only
6878        // in the live event stream — so `qed.status` can explain *why* a step
6879        // failed after the run ends.
6880        let failed = &meta.steps[0];
6881        assert_eq!(failed.status, RunStatus::Failed);
6882        let err = failed
6883            .error
6884            .as_deref()
6885            .expect("failed step carries an error reason");
6886        assert!(
6887            err.contains("boom"),
6888            "error tail carries stderr; got {err:?}"
6889        );
6890
6891        let mut events = Vec::new();
6892        while let Ok(ev) = rx.try_recv() {
6893            events.push(ev);
6894        }
6895
6896        assert!(
6897            events.iter().any(|e| matches!(
6898                e,
6899                QedEvent::StepOutput { stream: OutputStream::Stderr, line, .. } if line == "boom"
6900            )),
6901            "captured the stderr line; events={events:?}"
6902        );
6903        assert!(
6904            matches!(
6905                events.last(),
6906                Some(QedEvent::RunFinished {
6907                    status: RunStatus::Failed,
6908                    ..
6909                })
6910            ),
6911            "last event is RunFinished/Failed, got {:?}",
6912            events.last()
6913        );
6914    }
6915
6916    /// No sink attached = `run()` still completes and returns terminal meta.
6917    #[tokio::test]
6918    async fn no_sink_runs_silently() {
6919        let pipeline = one_step_pipeline("test-silent", vec!["true".to_string()]);
6920        let runner = PipelineRunner::new(pipeline);
6921        let meta = runner.run().await.unwrap();
6922        assert_eq!(meta.status, RunStatus::Success);
6923    }
6924
6925    // ── R531-T1 host-triple self-detection ──────────────────────────────────
6926
6927    /// A runner self-detects its host triple at construction, and the value
6928    /// is a well-formed triple matching the process host.
6929    #[test]
6930    fn runner_self_detects_host_triple() {
6931        let pipeline = one_step_pipeline("host", vec!["true".to_string()]);
6932        let runner = PipelineRunner::new(pipeline);
6933        assert_eq!(runner.host_triple(), crate::platform::detect_host_triple());
6934        assert_eq!(
6935            crate::platform::arch_of(runner.host_triple()),
6936            std::env::consts::ARCH,
6937        );
6938    }
6939
6940    /// `with_host_triple` overrides the detected host — the seam the daemon
6941    /// uses when a runner's steps land on a remote host of a known triple.
6942    #[test]
6943    fn with_host_triple_overrides_detection() {
6944        let pipeline = one_step_pipeline("host", vec!["true".to_string()]);
6945        let runner = PipelineRunner::new(pipeline).with_host_triple("x86_64-unknown-linux-gnu");
6946        assert_eq!(runner.host_triple(), "x86_64-unknown-linux-gnu");
6947    }
6948
6949    /// `step_platform` composes the runner's host with the step's declared
6950    /// target (R531-F2), and falls back to the legacy `triple` field.
6951    #[test]
6952    fn step_platform_composes_host_with_step_target() {
6953        let pipeline = one_step_pipeline("build", vec!["true".to_string()]);
6954        let runner = PipelineRunner::new(pipeline).with_host_triple("aarch64-apple-darwin");
6955
6956        // Declared [platform].target wins.
6957        let mut step = runner.pipeline.steps[0].clone();
6958        step.platform = Some(crate::platform::PlatformSpec {
6959            target: Some("x86_64-unknown-linux-musl".into()),
6960            container_platform: Some("linux/amd64".into()),
6961            native: false,
6962        });
6963        let p = runner.step_platform(&step);
6964        assert_eq!(p.host, "aarch64-apple-darwin");
6965        assert_eq!(p.target.as_deref(), Some("x86_64-unknown-linux-musl"));
6966        assert!(p.container_is_foreign_arch(), "amd64 image on arm64 host");
6967
6968        // Legacy `triple` field is lifted when no [platform] block is set.
6969        let mut legacy = runner.pipeline.steps[0].clone();
6970        legacy.triple = Some("x86_64-unknown-linux-musl".into());
6971        let p2 = runner.step_platform(&legacy);
6972        assert_eq!(p2.target.as_deref(), Some("x86_64-unknown-linux-musl"));
6973        assert!(p2.is_cross_arch());
6974    }
6975
6976    /// The portability preflight renders one line per step with the resolved
6977    /// verdict (R531-T4), honoring the runner's host override.
6978    #[test]
6979    fn portability_preflight_renders_one_line_per_step() {
6980        let pipeline = one_step_pipeline("build", vec!["true".to_string()]);
6981        let mut runner = PipelineRunner::new(pipeline).with_host_triple("aarch64-apple-darwin");
6982        // Give the single step a cross target.
6983        let mut steps = runner.pipeline.steps.clone();
6984        steps[0].platform = Some(crate::platform::PlatformSpec {
6985            target: Some("x86_64-unknown-linux-musl".into()),
6986            container_platform: None,
6987            native: false,
6988        });
6989        runner.pipeline.steps = steps;
6990
6991        let lines = runner.portability_preflight();
6992        assert_eq!(lines.len(), 1);
6993        assert!(
6994            lines[0].contains("targets x86_64-unknown-linux-musl")
6995                && lines[0].contains("host aarch64-apple-darwin")
6996                && lines[0].contains("NativeCross"),
6997            "preflight line: {}",
6998            lines[0]
6999        );
7000    }
7001
7002    /// `native_cross_plan` (R531-F5) gates on the NativeCross verdict and a
7003    /// foreign target, then routes the step's argv to zigbuild. A host-arch
7004    /// (plain native) step and a non-NativeCross verdict both yield `None`.
7005    #[test]
7006    fn native_cross_plan_routes_foreign_target_to_zigbuild() {
7007        let pipeline = one_step_pipeline(
7008            "build",
7009            vec!["cross".into(), "build".into(), "--release".into()],
7010        );
7011        let runner = PipelineRunner::new(pipeline).with_host_triple("aarch64-apple-darwin");
7012
7013        // Foreign-arch musl target on an arm64 mac → NativeCross tier.
7014        let mut foreign = runner.pipeline.steps[0].clone();
7015        foreign.platform = Some(crate::platform::PlatformSpec {
7016            target: Some("x86_64-unknown-linux-musl".into()),
7017            container_platform: Some("linux/amd64".into()),
7018            native: false,
7019        });
7020        let plan = runner
7021            .native_cross_plan(&foreign, &crate::nativecross::ToolAvailability::FULL)
7022            .expect("foreign-target NativeCross step yields a plan")
7023            .expect("toolchain available");
7024        assert_eq!(plan.tool, crate::nativecross::CrossTool::CargoZigbuild);
7025        assert_eq!(plan.argv[1], "zigbuild");
7026        assert!(plan.argv.iter().any(|a| a == "x86_64-unknown-linux-musl"));
7027
7028        // Host-arch target → plain native build, not this tier → None.
7029        let mut native = runner.pipeline.steps[0].clone();
7030        native.platform = Some(crate::platform::PlatformSpec {
7031            target: Some("aarch64-unknown-linux-gnu".into()),
7032            container_platform: None,
7033            native: false,
7034        });
7035        assert!(runner
7036            .native_cross_plan(&native, &crate::nativecross::ToolAvailability::FULL)
7037            .is_none());
7038
7039        // No target at all → None.
7040        let bare = runner.pipeline.steps[0].clone();
7041        assert!(runner
7042            .native_cross_plan(&bare, &crate::nativecross::ToolAvailability::FULL)
7043            .is_none());
7044    }
7045
7046    /// Captures the argv + env a step is dispatched with, so a test can assert
7047    /// what the subprocess seam actually received (R531-T6).
7048    #[derive(Default)]
7049    struct CapturingExecutor {
7050        seen: std::sync::Mutex<Option<(Vec<String>, Vec<(String, String)>)>>,
7051    }
7052
7053    #[async_trait::async_trait]
7054    impl ForgeExecutor for CapturingExecutor {
7055        async fn execute(
7056            &self,
7057            spec: ForgeSpec,
7058            ctx: ExecContext,
7059            _sink: Option<tokio::sync::mpsc::UnboundedSender<ExecEvent>>,
7060        ) -> Result<velveteen_exec::ExecOutcome, ForgeExecutorError> {
7061            let argv = match spec.command {
7062                ForgeCommand::Subprocess { argv, .. } => argv,
7063                _ => Vec::new(),
7064            };
7065            *self.seen.lock().unwrap() = Some((argv, ctx.env));
7066            Ok(velveteen_exec::ExecOutcome {
7067                status: ForgeStatus::Done {
7068                    exit_code: 0,
7069                    ended_at: 0,
7070                },
7071                stderr_tail: String::new(),
7072            })
7073        }
7074    }
7075
7076    /// Build a single-step Native runner whose one step carries a cross
7077    /// `target`, wired to `exec` and a seeded toolchain availability — the
7078    /// fixture for the T6 execution-path tests.
7079    fn native_cross_runner(
7080        camp: &std::path::Path,
7081        argv: Vec<String>,
7082        target: &str,
7083        avail: crate::nativecross::ToolAvailability,
7084        exec: std::sync::Arc<CapturingExecutor>,
7085    ) -> PipelineRunner {
7086        let mut pipeline = one_step_pipeline("build-musl", argv);
7087        pipeline.steps[0].platform = Some(crate::platform::PlatformSpec {
7088            target: Some(target.to_string()),
7089            container_platform: None,
7090            native: false,
7091        });
7092        PipelineRunner::new(pipeline)
7093            .with_host_triple("aarch64-apple-darwin")
7094            .with_camp_root(camp.to_path_buf())
7095            .with_cross_availability(avail)
7096            .with_executor(exec)
7097    }
7098
7099    /// T6 end-to-end: a NativeCross step's `cross build` argv is rewritten to
7100    /// `cargo zigbuild … --target T` *before* it reaches the executor.
7101    #[tokio::test]
7102    async fn execute_step_local_reroutes_native_cross_to_zigbuild() {
7103        let camp = tempfile::tempdir().unwrap();
7104        let exec = std::sync::Arc::new(CapturingExecutor::default());
7105        let runner = native_cross_runner(
7106            camp.path(),
7107            vec!["cross".into(), "build".into(), "--release".into()],
7108            "x86_64-unknown-linux-musl",
7109            crate::nativecross::ToolAvailability::FULL,
7110            exec.clone(),
7111        );
7112        let step = runner.pipeline.steps[0].clone();
7113        runner.execute_step_local(0, &step, None).await.unwrap();
7114
7115        let (argv, _env) = exec.seen.lock().unwrap().clone().unwrap();
7116        assert_eq!(&argv[..2], &["cargo".to_string(), "zigbuild".to_string()]);
7117        assert!(argv.iter().any(|a| a == "x86_64-unknown-linux-musl"));
7118    }
7119
7120    /// T6: with zig absent but a musl-cross toolchain present, the fallback
7121    /// keeps `cargo build` and injects the linker/CC/AR env.
7122    #[tokio::test]
7123    async fn execute_step_local_musl_cross_fallback_injects_linker_env() {
7124        let camp = tempfile::tempdir().unwrap();
7125        let exec = std::sync::Arc::new(CapturingExecutor::default());
7126        let runner = native_cross_runner(
7127            camp.path(),
7128            vec!["cargo".into(), "build".into()],
7129            "x86_64-unknown-linux-musl",
7130            crate::nativecross::ToolAvailability {
7131                zigbuild: false,
7132                musl_cross: true,
7133            },
7134            exec.clone(),
7135        );
7136        let step = runner.pipeline.steps[0].clone();
7137        runner.execute_step_local(0, &step, None).await.unwrap();
7138
7139        let (argv, env) = exec.seen.lock().unwrap().clone().unwrap();
7140        assert_eq!(&argv[..2], &["cargo".to_string(), "build".to_string()]);
7141        assert!(
7142            env.iter()
7143                .any(|(k, _)| k == "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER"),
7144            "musl-cross linker env injected: {env:?}"
7145        );
7146    }
7147
7148    /// T6: a NativeCross step with no host-native toolchain installed fails
7149    /// with the actionable install hint instead of a raw linker error.
7150    #[tokio::test]
7151    async fn execute_step_local_fails_with_hint_when_no_toolchain() {
7152        let camp = tempfile::tempdir().unwrap();
7153        let exec = std::sync::Arc::new(CapturingExecutor::default());
7154        let runner = native_cross_runner(
7155            camp.path(),
7156            vec!["cross".into(), "build".into()],
7157            "x86_64-unknown-linux-musl",
7158            crate::nativecross::ToolAvailability::NONE,
7159            exec.clone(),
7160        );
7161        let step = runner.pipeline.steps[0].clone();
7162
7163        let err = runner.execute_step_local(0, &step, None).await.unwrap_err();
7164        match err {
7165            RunnerError::StepFailed { msg, .. } => {
7166                assert!(msg.contains("cargo-zigbuild"), "actionable hint: {msg}");
7167            }
7168            other => panic!("expected StepFailed with hint, got {other:?}"),
7169        }
7170    }
7171
7172    // ── R380-T3 runtime resolution tests ────────────────────────────────────
7173
7174    /// resolve_runtime defaults from RunWhere when the step doesn't pin a
7175    /// runtime: local ⇒ Native, remote ⇒ Container.
7176    #[test]
7177    fn resolve_runtime_defaults_from_run_where() {
7178        let local_pipeline = one_step_pipeline("local", vec!["true".to_string()]);
7179        let local_runner = PipelineRunner::new(local_pipeline);
7180        assert_eq!(
7181            local_runner.resolve_runtime(&local_runner.pipeline.steps[0]),
7182            TaskRuntime::Native,
7183        );
7184
7185        let dir = TempDir::new().unwrap();
7186        let scryer = make_scryer(&dir);
7187        let yubaba = Arc::new(ScriptedWarden {
7188            lines: vec![],
7189            exit_code: 0,
7190            produced_files: HashMap::new(),
7191        });
7192        let remote_pipeline = one_step_pipeline("remote", vec!["true".to_string()]);
7193        let remote_runner = PipelineRunner::new_remote(remote_pipeline, scryer, yubaba);
7194        assert_eq!(
7195            remote_runner.resolve_runtime(&remote_runner.pipeline.steps[0]),
7196            TaskRuntime::Container,
7197        );
7198    }
7199
7200    /// An explicit step.runtime always wins over the RunWhere default.
7201    #[test]
7202    fn resolve_runtime_step_override_wins() {
7203        let mut pipeline = one_step_pipeline("override", vec!["true".to_string()]);
7204        pipeline.steps[0].runtime = Some(TaskRuntime::Container);
7205        let runner = PipelineRunner::new(pipeline);
7206        assert_eq!(
7207            runner.resolve_runtime(&runner.pipeline.steps[0]),
7208            TaskRuntime::Container,
7209            "step.runtime=Container must override --where=local default Native",
7210        );
7211    }
7212
7213    /// R590-F2: a subprocess step's `image = "<name>"` resolves to a catalog
7214    /// ImageRef (the R381 seam) so the argv runs inside that image; no `image`
7215    /// ⇒ None (driver uses the default forge image).
7216    #[test]
7217    fn step_image_override_resolves_catalog_image() {
7218        let mut step = mk_step("v8", &["build-v8.sh"]);
7219        assert!(
7220            step_image_override(&step).expect("no image is not an error").is_none(),
7221            "no image ⇒ None",
7222        );
7223
7224        step.image = Some("rusty-v8-musl-builder".into());
7225        let img = step_image_override(&step)
7226            .expect("bare catalog name resolves")
7227            .expect("image override resolves");
7228        assert_eq!(img.registry, "ghcr.io");
7229        assert_eq!(img.repository, "yah-ai/rusty-v8-musl-builder");
7230    }
7231
7232    /// R590-B5: a full `registry/repo:tag@sha256:…` ref bypasses the catalog's
7233    /// hard-coded `ghcr.io/yah-ai` prefix entirely and pulls from the named
7234    /// registry — the cr.yah.dev path for rusty-v8-musl.
7235    #[test]
7236    fn step_image_override_accepts_full_pinned_ref() {
7237        let mut step = mk_step("v8", &["build-v8.sh"]);
7238        let digest = "sha256:a1fb9d9cc631dcb844fbbb949dc65a80be1d532fa80868c4df5ed4b21939f9a4";
7239        step.image = Some(format!(
7240            "cr.yah.dev/rusty-v8-musl-builder:v149.4.0-amd64@{digest}"
7241        ));
7242
7243        let img = step_image_override(&step)
7244            .expect("full ref parses")
7245            .expect("image override resolves");
7246        assert_eq!(img.registry, "cr.yah.dev");
7247        assert_eq!(img.repository, "rusty-v8-musl-builder");
7248        assert_eq!(img.tag, "v149.4.0-amd64");
7249        assert_eq!(img.digest, digest);
7250        assert!(img.is_pinned(), "a full ref carries a real digest");
7251        assert_eq!(
7252            img.pull_ref(),
7253            format!("cr.yah.dev/rusty-v8-musl-builder:v149.4.0-amd64@{digest}"),
7254            "the runtime pulls the exact published ref, not a floating :latest",
7255        );
7256    }
7257
7258    /// A full ref without a digest is a config error, not a silent tag pull.
7259    #[test]
7260    fn step_image_override_rejects_unpinned_full_ref() {
7261        let mut step = mk_step("v8", &["build-v8.sh"]);
7262        step.image = Some("cr.yah.dev/rusty-v8-musl-builder:v149.4.0-amd64".into());
7263
7264        let err = step_image_override(&step).expect_err("bare-tag full ref rejects");
7265        assert!(
7266            matches!(err, RunnerError::InvalidConfig(ref m) if m.contains("digest-pinned")),
7267            "error must name the missing pin, got {err:?}",
7268        );
7269    }
7270
7271    /// Local + container routes through `task::local::local_container_command`
7272    /// → `docker run --rm`. The full happy-path (real docker daemon, pull a
7273    /// public image, exit 0) is exercised by the `#[ignore]` smoke test
7274    /// `task::local::tests::local_container_run_exits_with_code`.
7275    ///
7276    /// Here we only verify the run reaches the local+container branch and
7277    /// reports a clean step failure on environments without docker — without
7278    /// regressing back to the pre-T6 InvalidConfig pre-check.
7279    #[tokio::test]
7280    async fn local_container_step_routes_through_docker_path() {
7281        let mut pipeline = one_step_pipeline(
7282            "local-container",
7283            // bogus binary so we don't accidentally test against a real
7284            // docker image even if the CLI happens to be installed
7285            vec!["__nonexistent_binary_for_docker_test__".to_string()],
7286        );
7287        pipeline.steps[0].runtime = Some(TaskRuntime::Container);
7288        let runner = PipelineRunner::new(pipeline);
7289        let meta = runner.run().await.unwrap();
7290        assert_eq!(meta.status, RunStatus::Failed);
7291        assert_eq!(meta.steps[0].status, RunStatus::Failed);
7292        // task_run_id stays None — that field tracks remote dispatch only.
7293        assert!(meta.steps[0].task_run_id.is_none());
7294    }
7295
7296    fn build_image_pipeline(image: &str) -> Pipeline {
7297        Pipeline {
7298            name: "image".to_string(),
7299            label: "Bake image".to_string(),
7300            steps: vec![crate::types::QedStep {
7301                background: false,
7302                background_until: None,
7303                wait_for: None,
7304                manifest_stitch: None,
7305                name: "bake".to_string(),
7306                argv: Vec::new(),
7307                cwd: None,
7308                env: HashMap::new(),
7309                timeout: None,
7310                on_fail: OnFail::Abort,
7311                produces: Vec::new(),
7312                runtime: None,
7313                kind: crate::types::StepKind::BuildImage,
7314                image: Some(image.to_string()),
7315                tag: None,
7316                push: false,
7317                platforms: Vec::new(),
7318                binary_path: None,
7319                triple: None,
7320                package: None,
7321                context: None,
7322                load: false,
7323                sub_pipeline: None,
7324                gha_workflow: None,
7325                import: None,
7326                matrix: None,
7327                enabled: true,
7328                activation: StepActivation::Active,
7329                if_cond: None,
7330                platform: None,
7331                toolchain: None,
7332                outputs: Vec::new(),
7333            }],
7334            params: HashMap::new(),
7335            on_success: vec![],
7336            on_fail: vec![],
7337            triggers: vec![],
7338            concurrency_key: None,
7339            placement: crate::types::Placement::Anywhere,
7340            workspace: crate::types::WorkspaceMode::Live,
7341            wraps: None,
7342            matrix: None,
7343            toolchain: None,
7344            binds: Vec::new(),
7345            on_change: Vec::new(),
7346            finally: Vec::new(),
7347        }
7348    }
7349
7350    /// build-image steps force Container regardless of run_where=Local (which
7351    /// would otherwise default to Native).
7352    #[test]
7353    fn build_image_step_forces_container_runtime() {
7354        let pipeline = build_image_pipeline("yah-rust");
7355        let runner = PipelineRunner::new(pipeline);
7356        assert_eq!(
7357            runner.resolve_runtime(&runner.pipeline.steps[0]),
7358            TaskRuntime::Container,
7359        );
7360    }
7361
7362    /// Unknown catalog image surfaces as a StepFailed at dispatch time.
7363    #[tokio::test]
7364    async fn build_image_unknown_catalog_entry_fails() {
7365        let camp = TempDir::new().unwrap();
7366        let pipeline = build_image_pipeline("yah-bogus-not-real");
7367        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
7368        let meta = runner.run().await.unwrap();
7369        assert_eq!(meta.status, RunStatus::Failed);
7370        assert_eq!(meta.steps[0].status, RunStatus::Failed);
7371    }
7372
7373    /// Remote build-image dispatch round-trips through the BuildKit workload
7374    /// path (R381-T5). The scripted yubaba accepts the deploy, emits no logs,
7375    /// and reports exit 0; the runner surfaces a Success status and records
7376    /// the task_run_id of the forge run.
7377    #[tokio::test]
7378    async fn build_image_remote_dispatch_round_trip() {
7379        let dir = TempDir::new().unwrap();
7380        let scryer = make_scryer(&dir);
7381        let yubaba = Arc::new(ScriptedWarden {
7382            lines: vec![],
7383            exit_code: 0,
7384            produced_files: HashMap::new(),
7385        });
7386        let pipeline = build_image_pipeline("yah-rust");
7387        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba)
7388            .with_camp_root(dir.path().to_path_buf());
7389        let meta = runner.run().await.unwrap();
7390        assert_eq!(meta.status, RunStatus::Success);
7391        assert_eq!(meta.steps[0].status, RunStatus::Success);
7392        assert!(
7393            meta.steps[0].task_run_id.is_some(),
7394            "remote build-image step must record its ForgeId as task_run_id",
7395        );
7396    }
7397
7398    /// Remote build-image surfaces a non-zero buildkit exit as a step failure.
7399    #[tokio::test]
7400    async fn build_image_remote_dispatch_failure_surfaces() {
7401        let dir = TempDir::new().unwrap();
7402        let scryer = make_scryer(&dir);
7403        let yubaba = Arc::new(ScriptedWarden {
7404            lines: vec!["dockerfile parse error".into()],
7405            exit_code: 2,
7406            produced_files: HashMap::new(),
7407        });
7408        let pipeline = build_image_pipeline("yah-rust");
7409        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba)
7410            .with_camp_root(dir.path().to_path_buf());
7411        let meta = runner.run().await.unwrap();
7412        assert_eq!(meta.status, RunStatus::Failed);
7413        assert_eq!(meta.steps[0].status, RunStatus::Failed);
7414    }
7415
7416    /// A per-camp catalog entry that extends a nonexistent parent surfaces
7417    /// the compile error as a StepFailed *before* we shell to docker.
7418    #[tokio::test]
7419    async fn build_image_compile_error_surfaces_before_docker() {
7420        let camp = TempDir::new().unwrap();
7421        let images = camp.path().join(".yah/qed/images");
7422        std::fs::create_dir_all(&images).unwrap();
7423        std::fs::write(
7424            images.join("bad-entry.toml"),
7425            r#"
7426[image]
7427name        = "bad-entry"
7428extends     = "does-not-exist"
7429description = "extends a typo"
7430"#,
7431        )
7432        .unwrap();
7433
7434        let pipeline = build_image_pipeline("bad-entry");
7435        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
7436        let meta = runner.run().await.unwrap();
7437        assert_eq!(meta.status, RunStatus::Failed);
7438        assert_eq!(meta.steps[0].status, RunStatus::Failed);
7439        // No docker artifacts should have been written.
7440        assert!(!camp.path().join(".yah/cache/buildkit").exists());
7441    }
7442
7443    /// tag_to_filename replaces characters that aren't safe for OCI archive
7444    /// filenames (slashes from registry/repo, colons from tags).
7445    #[test]
7446    fn tag_to_filename_makes_oci_archive_path_safe() {
7447        assert_eq!(tag_to_filename("yah-rust:dev"), "yah-rust_dev");
7448        assert_eq!(
7449            tag_to_filename("ghcr.io/yah-ai/yah-python:v1.2.3"),
7450            "ghcr.io_yah-ai_yah-python_v1.2.3",
7451        );
7452    }
7453
7454    /// End-to-end smoke: build a one-line Dockerfile via the full qed →
7455    /// task::local::build_image_command path. Requires docker + buildx on
7456    /// PATH; marked #[ignore] so CI without docker doesn't fail.
7457    ///
7458    /// Run locally:
7459    /// ```sh
7460    /// cargo test -p qed --lib build_image_local_buildx_actually_builds -- --include-ignored
7461    /// ```
7462    #[tokio::test]
7463    #[ignore]
7464    async fn build_image_local_buildx_actually_builds() {
7465        let camp = TempDir::new().unwrap();
7466        let images = camp.path().join(".yah/qed/images/yah-smoke");
7467        std::fs::create_dir_all(&images).unwrap();
7468        // Tiny Dockerfile that should build in a couple seconds against alpine.
7469        std::fs::write(
7470            images.join("Dockerfile"),
7471            "FROM alpine:3\nRUN echo smoke-image\n",
7472        )
7473        .unwrap();
7474        std::fs::write(
7475            images.join("image.toml"),
7476            r#"
7477[image]
7478name        = "yah-smoke"
7479base        = "alpine:3"
7480description = "smoke test image"
7481"#,
7482        )
7483        .unwrap();
7484
7485        let pipeline = build_image_pipeline("yah-smoke");
7486        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
7487        let meta = runner.run().await.unwrap();
7488        assert_eq!(
7489            meta.status,
7490            RunStatus::Success,
7491            "build-image should succeed; check docker buildx is available"
7492        );
7493        // Generated Dockerfile staged under cache/buildkit.
7494        assert!(camp
7495            .path()
7496            .join(".yah/cache/buildkit/yah-smoke.Dockerfile")
7497            .is_file());
7498        // OCI archive should be produced (push=false default).
7499        assert!(camp
7500            .path()
7501            .join(".yah/cache/images/yah-smoke_dev.tar")
7502            .is_file());
7503    }
7504
7505    // ── R407-T2 package-native-tarball runner tests ─────────────────────────
7506
7507    /// Build a pipeline that packages a pre-built binary into a native
7508    /// tarball. The test always writes a dummy binary at `binary_rel` so we
7509    /// don't depend on a real cross build.
7510    fn package_native_tarball_pipeline(image: &str, binary_rel: &str, triple: &str) -> Pipeline {
7511        Pipeline {
7512            name: "pack".to_string(),
7513            label: "Package native tarball".to_string(),
7514            steps: vec![crate::types::QedStep {
7515                background: false,
7516                background_until: None,
7517                wait_for: None,
7518                manifest_stitch: None,
7519                name: "pack".to_string(),
7520                argv: Vec::new(),
7521                cwd: None,
7522                env: HashMap::new(),
7523                timeout: None,
7524                on_fail: OnFail::Abort,
7525                produces: Vec::new(),
7526                runtime: None,
7527                kind: crate::types::StepKind::PackageNativeTarball,
7528                image: Some(image.to_string()),
7529                tag: None,
7530                push: false,
7531                platforms: Vec::new(),
7532                binary_path: Some(binary_rel.to_string()),
7533                triple: Some(triple.to_string()),
7534                package: None,
7535                context: None,
7536                load: false,
7537                sub_pipeline: None,
7538                gha_workflow: None,
7539                import: None,
7540                matrix: None,
7541                enabled: true,
7542                activation: StepActivation::Active,
7543                if_cond: None,
7544                platform: None,
7545                toolchain: None,
7546                outputs: Vec::new(),
7547            }],
7548            params: HashMap::new(),
7549            on_success: vec![],
7550            on_fail: vec![],
7551            triggers: vec![],
7552            concurrency_key: None,
7553            placement: crate::types::Placement::Anywhere,
7554            workspace: crate::types::WorkspaceMode::Live,
7555            wraps: None,
7556            matrix: None,
7557            toolchain: None,
7558            binds: Vec::new(),
7559            on_change: Vec::new(),
7560            finally: Vec::new(),
7561        }
7562    }
7563
7564    fn stage_native_tarball_camp(image_name: &str, produces: &str, binary_rel: &str) -> TempDir {
7565        let camp = TempDir::new().unwrap();
7566        let images = camp.path().join(".yah/qed/images");
7567        std::fs::create_dir_all(&images).unwrap();
7568        std::fs::write(
7569            images.join(format!("{image_name}.toml")),
7570            format!(
7571                r#"
7572[image]
7573name        = "{image_name}"
7574base        = "scratch"
7575description = "Native musl-static workload"
7576produces    = [{produces}]
7577
7578[image.env]
7579RUST_LOG = "info"
7580"#,
7581            ),
7582        )
7583        .unwrap();
7584        let bin_path = camp.path().join(binary_rel);
7585        std::fs::create_dir_all(bin_path.parent().unwrap()).unwrap();
7586        std::fs::write(&bin_path, b"\x7fELF-fake-musl-binary").unwrap();
7587        camp
7588    }
7589
7590    /// Happy path: catalog entry declares `native-tarball`, binary exists,
7591    /// runner emits `.yah/cache/native/<image>-<triple>.tar.gz`.
7592    #[tokio::test]
7593    async fn package_native_tarball_writes_tar_gz_with_manifest() {
7594        use flate2::read::GzDecoder;
7595        use std::io::Read;
7596
7597        let binary_rel = "target/x86_64-unknown-linux-musl/release/yubaba";
7598        let triple = "x86_64-unknown-linux-musl";
7599        let camp = stage_native_tarball_camp("yah-yubaba", "\"native-tarball\"", binary_rel);
7600
7601        let pipeline = package_native_tarball_pipeline("yah-yubaba", binary_rel, triple);
7602        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
7603        let meta = runner.run().await.unwrap();
7604        assert_eq!(meta.status, RunStatus::Success);
7605
7606        let out = camp
7607            .path()
7608            .join(".yah/cache/native/yah-yubaba-x86_64-unknown-linux-musl.tar.gz");
7609        assert!(out.is_file(), "tarball at {}", out.display());
7610
7611        let f = std::fs::File::open(&out).unwrap();
7612        let gz = GzDecoder::new(f);
7613        let mut archive = tar::Archive::new(gz);
7614        let mut seen: Vec<(String, Vec<u8>)> = Vec::new();
7615        for entry in archive.entries().unwrap() {
7616            let mut entry = entry.unwrap();
7617            let path = entry.path().unwrap().to_string_lossy().into_owned();
7618            let mut buf = Vec::new();
7619            entry.read_to_end(&mut buf).unwrap();
7620            seen.push((path, buf));
7621        }
7622        seen.sort_by(|a, b| a.0.cmp(&b.0));
7623        assert_eq!(seen[0].0, "bin/yubaba");
7624        assert_eq!(seen[0].1, b"\x7fELF-fake-musl-binary");
7625        assert_eq!(seen[1].0, "manifest.toml");
7626        let text = std::str::from_utf8(&seen[1].1).unwrap();
7627        let manifest: crate::native::NativeTarballManifest =
7628            toml::from_str(text).expect("manifest.toml parses");
7629        assert_eq!(manifest.name, "yah-yubaba");
7630        assert_eq!(manifest.triple, triple);
7631        assert_eq!(manifest.binary, "bin/yubaba");
7632        // Catalog env propagates into the manifest.
7633        assert_eq!(
7634            manifest.env.get("RUST_LOG").map(String::as_str),
7635            Some("info")
7636        );
7637    }
7638
7639    /// Catalog entry that only declares `produces = ["oci-image"]` (the
7640    /// default) is rejected at dispatch time — protects against accidentally
7641    /// packaging a non-musl image as a native tarball.
7642    #[tokio::test]
7643    async fn package_native_tarball_rejects_non_native_catalog_entry() {
7644        let binary_rel = "target/release/yubaba";
7645        let camp = stage_native_tarball_camp("yah-yubaba", "\"oci-image\"", binary_rel);
7646        let pipeline = package_native_tarball_pipeline("yah-yubaba", binary_rel, "darwin-aarch64");
7647        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
7648        let meta = runner.run().await.unwrap();
7649        assert_eq!(meta.status, RunStatus::Failed);
7650        assert_eq!(meta.steps[0].status, RunStatus::Failed);
7651    }
7652
7653    /// Both-target entries (`["oci-image", "native-tarball"]`) are accepted —
7654    /// W154's container-and-native peer model.
7655    #[tokio::test]
7656    async fn package_native_tarball_accepts_both_targets_entry() {
7657        let binary_rel = "target/x86_64-unknown-linux-musl/release/yubaba";
7658        let camp = stage_native_tarball_camp(
7659            "yah-yubaba",
7660            "\"oci-image\", \"native-tarball\"",
7661            binary_rel,
7662        );
7663        let pipeline =
7664            package_native_tarball_pipeline("yah-yubaba", binary_rel, "x86_64-unknown-linux-musl");
7665        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
7666        let meta = runner.run().await.unwrap();
7667        assert_eq!(meta.status, RunStatus::Success);
7668        assert!(camp
7669            .path()
7670            .join(".yah/cache/native/yah-yubaba-x86_64-unknown-linux-musl.tar.gz")
7671            .is_file());
7672    }
7673
7674    /// Unknown catalog name surfaces as StepFailed (mirrors build-image
7675    /// dispatch shape).
7676    #[tokio::test]
7677    async fn package_native_tarball_unknown_catalog_fails() {
7678        let camp = TempDir::new().unwrap();
7679        let bin = camp.path().join("target/release/yubaba");
7680        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
7681        std::fs::write(&bin, b"x").unwrap();
7682        let pipeline = package_native_tarball_pipeline(
7683            "yah-bogus-not-real",
7684            "target/release/yubaba",
7685            "darwin-aarch64",
7686        );
7687        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
7688        let meta = runner.run().await.unwrap();
7689        assert_eq!(meta.status, RunStatus::Failed);
7690    }
7691
7692    /// Missing binary surfaces a clean StepFailed (not an IO panic).
7693    #[tokio::test]
7694    async fn package_native_tarball_missing_binary_fails_cleanly() {
7695        let camp = TempDir::new().unwrap();
7696        let images = camp.path().join(".yah/qed/images");
7697        std::fs::create_dir_all(&images).unwrap();
7698        std::fs::write(
7699            images.join("yah-yubaba.toml"),
7700            r#"
7701[image]
7702name        = "yah-yubaba"
7703base        = "scratch"
7704description = "Native"
7705produces    = ["native-tarball"]
7706"#,
7707        )
7708        .unwrap();
7709        let pipeline = package_native_tarball_pipeline(
7710            "yah-yubaba",
7711            "target/x86_64-unknown-linux-musl/release/yubaba",
7712            "x86_64-unknown-linux-musl",
7713        );
7714        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
7715        let meta = runner.run().await.unwrap();
7716        assert_eq!(meta.status, RunStatus::Failed);
7717        // Nothing should have landed under .yah/cache/native.
7718        assert!(!camp.path().join(".yah/cache/native").exists());
7719    }
7720
7721    /// Triple defaults to the build host when omitted — proves
7722    /// `publish::resolve_triple(None)` is the fallback used at packaging time.
7723    #[tokio::test]
7724    async fn package_native_tarball_triple_defaults_to_host() {
7725        let binary_rel = "target/release/yubaba";
7726        let camp = stage_native_tarball_camp("yah-yubaba", "\"native-tarball\"", binary_rel);
7727
7728        // Same pipeline but with triple=None.
7729        let mut pipeline = package_native_tarball_pipeline("yah-yubaba", binary_rel, "ignored");
7730        pipeline.steps[0].triple = None;
7731
7732        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
7733        let meta = runner.run().await.unwrap();
7734        assert_eq!(meta.status, RunStatus::Success);
7735
7736        let host_triple = crate::publish::resolve_triple(None);
7737        let expected = camp
7738            .path()
7739            .join(format!(".yah/cache/native/yah-yubaba-{host_triple}.tar.gz"));
7740        assert!(
7741            expected.is_file(),
7742            "expected {} to exist",
7743            expected.display()
7744        );
7745    }
7746
7747    /// PackageNativeTarball is always Native runtime, even on a Remote runner —
7748    /// the implicit `None` must not get auto-forced to Container.
7749    #[test]
7750    fn package_native_tarball_step_forces_native_runtime_on_remote() {
7751        let dir = TempDir::new().unwrap();
7752        let scryer = make_scryer(&dir);
7753        let yubaba = Arc::new(ScriptedWarden {
7754            lines: vec![],
7755            exit_code: 0,
7756            produced_files: HashMap::new(),
7757        });
7758        let pipeline = package_native_tarball_pipeline(
7759            "yah-yubaba",
7760            "target/x86_64-unknown-linux-musl/release/yubaba",
7761            "x86_64-unknown-linux-musl",
7762        );
7763        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba);
7764        assert_eq!(
7765            runner.resolve_runtime(&runner.pipeline.steps[0]),
7766            TaskRuntime::Native,
7767        );
7768    }
7769
7770    // ── R407-T3 musl-static-preflight runner tests ──────────────────────────
7771
7772    fn musl_preflight_pipeline(package: &str) -> Pipeline {
7773        Pipeline {
7774            name: "preflight".to_string(),
7775            label: "musl-static preflight".to_string(),
7776            steps: vec![crate::types::QedStep {
7777                background: false,
7778                background_until: None,
7779                wait_for: None,
7780                manifest_stitch: None,
7781                name: "musl-gate".to_string(),
7782                argv: Vec::new(),
7783                cwd: None,
7784                env: HashMap::new(),
7785                timeout: None,
7786                on_fail: OnFail::Abort,
7787                produces: Vec::new(),
7788                runtime: None,
7789                kind: crate::types::StepKind::MuslStaticPreflight,
7790                image: None,
7791                tag: None,
7792                push: false,
7793                platforms: Vec::new(),
7794                binary_path: None,
7795                triple: None,
7796                package: Some(package.to_string()),
7797                context: None,
7798                load: false,
7799                sub_pipeline: None,
7800                gha_workflow: None,
7801                import: None,
7802                matrix: None,
7803                enabled: true,
7804                activation: StepActivation::Active,
7805                if_cond: None,
7806                platform: None,
7807                toolchain: None,
7808                outputs: Vec::new(),
7809            }],
7810            params: HashMap::new(),
7811            on_success: vec![],
7812            on_fail: vec![],
7813            triggers: vec![],
7814            concurrency_key: None,
7815            placement: crate::types::Placement::Anywhere,
7816            workspace: crate::types::WorkspaceMode::Live,
7817            wraps: None,
7818            matrix: None,
7819            toolchain: None,
7820            binds: Vec::new(),
7821            on_change: Vec::new(),
7822            finally: Vec::new(),
7823        }
7824    }
7825
7826    fn workspace_root() -> std::path::PathBuf {
7827        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
7828            .ancestors()
7829            .find(|p| p.join("Cargo.lock").is_file())
7830            .expect("workspace root has Cargo.lock")
7831            .to_path_buf()
7832    }
7833
7834    /// Happy path: gating the qed crate itself passes — qed is musl-clean by
7835    /// design (no openssl-sys, no dbus, no cuda).
7836    #[tokio::test]
7837    async fn musl_static_preflight_passes_clean_workspace_package() {
7838        let pipeline = musl_preflight_pipeline("qed");
7839        let runner = PipelineRunner::new(pipeline).with_camp_root(workspace_root());
7840        let meta = runner.run().await.unwrap();
7841        assert_eq!(meta.status, RunStatus::Success);
7842    }
7843
7844    /// Unknown workspace package surfaces a clean StepFailed (not a panic).
7845    #[tokio::test]
7846    async fn musl_static_preflight_unknown_package_fails_cleanly() {
7847        let pipeline = musl_preflight_pipeline("definitely-not-a-real-package");
7848        let runner = PipelineRunner::new(pipeline).with_camp_root(workspace_root());
7849        let meta = runner.run().await.unwrap();
7850        assert_eq!(meta.status, RunStatus::Failed);
7851        assert_eq!(meta.steps[0].status, RunStatus::Failed);
7852    }
7853
7854    /// MuslStaticPreflight is always Native runtime, even on a Remote runner.
7855    #[test]
7856    fn musl_static_preflight_forces_native_runtime_on_remote() {
7857        let dir = TempDir::new().unwrap();
7858        let scryer = make_scryer(&dir);
7859        let yubaba = Arc::new(ScriptedWarden {
7860            lines: vec![],
7861            exit_code: 0,
7862            produced_files: HashMap::new(),
7863        });
7864        let pipeline = musl_preflight_pipeline("yubaba");
7865        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba);
7866        assert_eq!(
7867            runner.resolve_runtime(&runner.pipeline.steps[0]),
7868            TaskRuntime::Native,
7869        );
7870    }
7871
7872    /// The actionable container-fallback hint surfaces in the step's failure
7873    /// message — operators reading the failed StepStatus get the routing
7874    /// recommendation immediately.
7875    #[test]
7876    fn musl_gate_error_message_routes_to_container_fallback() {
7877        use crate::preflight::{check_dep_list, MuslPreflightError};
7878        let err = check_dep_list("yubaba", ["openssl-sys"]).unwrap_err();
7879        let msg = err.to_string();
7880        assert!(
7881            msg.contains("container fallback"),
7882            "msg routes to container: {msg}"
7883        );
7884        assert!(
7885            msg.contains("runtime = \"container\""),
7886            "msg names the toml fix: {msg}"
7887        );
7888        assert!(
7889            matches!(err, MuslPreflightError::NotMuslSafe { ref offenders, .. } if offenders == &["openssl-sys".to_string()]),
7890        );
7891    }
7892
7893    // ── R407-T5 sign-native-tarball runner tests ────────────────────────────
7894
7895    /// Build a pipeline that packages then signs a native tarball, exercising
7896    /// the same image+triple → on-disk-path convention both steps share.
7897    fn pack_and_sign_pipeline(image: &str, binary_rel: &str, triple: &str) -> Pipeline {
7898        Pipeline {
7899            name: "pack-and-sign".to_string(),
7900            label: "Package + sign native tarball".to_string(),
7901            steps: vec![
7902                crate::types::QedStep {
7903                    background: false,
7904                    background_until: None,
7905                    wait_for: None,
7906                    manifest_stitch: None,
7907                    name: "pack".to_string(),
7908                    argv: Vec::new(),
7909                    cwd: None,
7910                    env: HashMap::new(),
7911                    timeout: None,
7912                    on_fail: OnFail::Abort,
7913                    produces: Vec::new(),
7914                    runtime: None,
7915                    kind: crate::types::StepKind::PackageNativeTarball,
7916                    image: Some(image.to_string()),
7917                    tag: None,
7918                    push: false,
7919                    platforms: Vec::new(),
7920                    binary_path: Some(binary_rel.to_string()),
7921                    triple: Some(triple.to_string()),
7922                    package: None,
7923                    context: None,
7924                    load: false,
7925                    sub_pipeline: None,
7926                    gha_workflow: None,
7927                    import: None,
7928                    matrix: None,
7929                    enabled: true,
7930                    activation: StepActivation::Active,
7931                    if_cond: None,
7932                    platform: None,
7933                    toolchain: None,
7934                    outputs: Vec::new(),
7935                },
7936                crate::types::QedStep {
7937                    background: false,
7938                    background_until: None,
7939                    wait_for: None,
7940                    manifest_stitch: None,
7941                    name: "sign".to_string(),
7942                    argv: Vec::new(),
7943                    cwd: None,
7944                    env: HashMap::new(),
7945                    timeout: None,
7946                    on_fail: OnFail::Abort,
7947                    produces: Vec::new(),
7948                    runtime: None,
7949                    kind: crate::types::StepKind::SignNativeTarball,
7950                    image: Some(image.to_string()),
7951                    tag: None,
7952                    push: false,
7953                    platforms: Vec::new(),
7954                    binary_path: None,
7955                    triple: Some(triple.to_string()),
7956                    package: None,
7957                    context: None,
7958                    load: false,
7959                    sub_pipeline: None,
7960                    gha_workflow: None,
7961                    import: None,
7962                    matrix: None,
7963                    enabled: true,
7964                    activation: StepActivation::Active,
7965                    if_cond: None,
7966                    platform: None,
7967                    toolchain: None,
7968                    outputs: Vec::new(),
7969                },
7970            ],
7971            params: HashMap::new(),
7972            on_success: vec![],
7973            on_fail: vec![],
7974            triggers: vec![],
7975            concurrency_key: None,
7976            placement: crate::types::Placement::Anywhere,
7977            workspace: crate::types::WorkspaceMode::Live,
7978            wraps: None,
7979            matrix: None,
7980            toolchain: None,
7981            binds: Vec::new(),
7982            on_change: Vec::new(),
7983            finally: Vec::new(),
7984        }
7985    }
7986
7987    /// Sign-only pipeline (no pack step) — for asserting the "tarball must
7988    /// already exist" gate without coupling to the packaging step.
7989    fn sign_only_pipeline(image: &str, triple: &str) -> Pipeline {
7990        Pipeline {
7991            name: "sign".to_string(),
7992            label: "Sign native tarball".to_string(),
7993            steps: vec![crate::types::QedStep {
7994                background: false,
7995                background_until: None,
7996                wait_for: None,
7997                manifest_stitch: None,
7998                name: "sign".to_string(),
7999                argv: Vec::new(),
8000                cwd: None,
8001                env: HashMap::new(),
8002                timeout: None,
8003                on_fail: OnFail::Abort,
8004                produces: Vec::new(),
8005                runtime: None,
8006                kind: crate::types::StepKind::SignNativeTarball,
8007                image: Some(image.to_string()),
8008                tag: None,
8009                push: false,
8010                platforms: Vec::new(),
8011                binary_path: None,
8012                triple: Some(triple.to_string()),
8013                package: None,
8014                context: None,
8015                load: false,
8016                sub_pipeline: None,
8017                gha_workflow: None,
8018                import: None,
8019                matrix: None,
8020                enabled: true,
8021                activation: StepActivation::Active,
8022                if_cond: None,
8023                platform: None,
8024                toolchain: None,
8025                outputs: Vec::new(),
8026            }],
8027            params: HashMap::new(),
8028            on_success: vec![],
8029            on_fail: vec![],
8030            triggers: vec![],
8031            concurrency_key: None,
8032            placement: crate::types::Placement::Anywhere,
8033            workspace: crate::types::WorkspaceMode::Live,
8034            wraps: None,
8035            matrix: None,
8036            toolchain: None,
8037            binds: Vec::new(),
8038            on_change: Vec::new(),
8039            finally: Vec::new(),
8040        }
8041    }
8042
8043    /// Happy path: pack-then-sign in one pipeline writes the tarball and
8044    /// then `.sig`, `.crt`, `.bundle` next to it. Uses the default
8045    /// LoggingSigner — exercising the same trust shape as cosign without
8046    /// requiring a cosign install in the test sandbox.
8047    #[tokio::test]
8048    async fn sign_native_tarball_pack_then_sign_writes_sig_crt_bundle() {
8049        let binary_rel = "target/x86_64-unknown-linux-musl/release/yubaba";
8050        let triple = "x86_64-unknown-linux-musl";
8051        let camp = stage_native_tarball_camp("yah-yubaba", "\"native-tarball\"", binary_rel);
8052
8053        let pipeline = pack_and_sign_pipeline("yah-yubaba", binary_rel, triple);
8054        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
8055        let meta = runner.run().await.unwrap();
8056        assert_eq!(meta.status, RunStatus::Success);
8057        assert_eq!(meta.steps[0].status, RunStatus::Success); // pack
8058        assert_eq!(meta.steps[1].status, RunStatus::Success); // sign
8059
8060        let tarball = camp
8061            .path()
8062            .join(".yah/cache/native/yah-yubaba-x86_64-unknown-linux-musl.tar.gz");
8063        assert!(tarball.is_file());
8064        for suffix in [".sig", ".crt", ".bundle"] {
8065            let mut name = tarball.file_name().unwrap().to_os_string();
8066            name.push(suffix);
8067            let p = tarball.with_file_name(name);
8068            assert!(p.is_file(), "expected {} to exist", p.display());
8069        }
8070    }
8071
8072    /// Catalog entry without `native-tarball` in `produces` is refused at
8073    /// sign time — same gate as packaging, applied independently so a
8074    /// signing step picked up from old TOML can't sneak through.
8075    #[tokio::test]
8076    async fn sign_native_tarball_rejects_non_native_catalog_entry() {
8077        let binary_rel = "target/release/yubaba";
8078        let camp = stage_native_tarball_camp("yah-yubaba", "\"oci-image\"", binary_rel);
8079        let pipeline = sign_only_pipeline("yah-yubaba", "x86_64-unknown-linux-musl");
8080        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
8081        let meta = runner.run().await.unwrap();
8082        assert_eq!(meta.status, RunStatus::Failed);
8083        assert_eq!(meta.steps[0].status, RunStatus::Failed);
8084    }
8085
8086    /// Unknown catalog name surfaces as StepFailed (mirrors packaging dispatch).
8087    #[tokio::test]
8088    async fn sign_native_tarball_unknown_catalog_fails() {
8089        let camp = TempDir::new().unwrap();
8090        let pipeline = sign_only_pipeline("yah-bogus-not-real", "x86_64-unknown-linux-musl");
8091        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
8092        let meta = runner.run().await.unwrap();
8093        assert_eq!(meta.status, RunStatus::Failed);
8094    }
8095
8096    /// Missing tarball (sign called without pack) surfaces a clean StepFailed
8097    /// whose message routes the operator to the packaging step.
8098    #[tokio::test]
8099    async fn sign_native_tarball_missing_tarball_routes_to_packaging() {
8100        let camp = TempDir::new().unwrap();
8101        let images = camp.path().join(".yah/qed/images");
8102        std::fs::create_dir_all(&images).unwrap();
8103        std::fs::write(
8104            images.join("yah-yubaba.toml"),
8105            r#"
8106[image]
8107name        = "yah-yubaba"
8108base        = "scratch"
8109description = "Native"
8110produces    = ["native-tarball"]
8111"#,
8112        )
8113        .unwrap();
8114        let pipeline = sign_only_pipeline("yah-yubaba", "x86_64-unknown-linux-musl");
8115        let runner = PipelineRunner::new(pipeline).with_camp_root(camp.path().to_path_buf());
8116        let meta = runner.run().await.unwrap();
8117        assert_eq!(meta.status, RunStatus::Failed);
8118        // Nothing should have been signed.
8119        assert!(!camp.path().join(".yah/cache/native").exists());
8120    }
8121
8122    /// SignNativeTarball is always Native runtime, even on a Remote runner —
8123    /// the implicit `None` must not get auto-forced to Container.
8124    #[test]
8125    fn sign_native_tarball_forces_native_runtime_on_remote() {
8126        let dir = TempDir::new().unwrap();
8127        let scryer = make_scryer(&dir);
8128        let yubaba = Arc::new(ScriptedWarden {
8129            lines: vec![],
8130            exit_code: 0,
8131            produced_files: HashMap::new(),
8132        });
8133        let pipeline = sign_only_pipeline("yah-yubaba", "x86_64-unknown-linux-musl");
8134        let runner = PipelineRunner::new_remote(pipeline, scryer, yubaba);
8135        assert_eq!(
8136            runner.resolve_runtime(&runner.pipeline.steps[0]),
8137            TaskRuntime::Native,
8138        );
8139    }
8140
8141    /// `with_signer(...)` replaces the default LoggingSigner — release CI
8142    /// uses this seam to wire a real CosignSigner.
8143    #[tokio::test]
8144    async fn sign_native_tarball_uses_attached_signer() {
8145        use std::sync::atomic::{AtomicUsize, Ordering};
8146
8147        struct CountingSigner {
8148            calls: AtomicUsize,
8149        }
8150        #[async_trait]
8151        impl SigstoreSigner for CountingSigner {
8152            async fn sign_blob(
8153                &self,
8154                blob_path: &std::path::Path,
8155            ) -> std::io::Result<crate::native::SignedBlob> {
8156                self.calls.fetch_add(1, Ordering::SeqCst);
8157                // Mirror the LoggingSigner shape so the runner's success log
8158                // remains coherent.
8159                crate::native::LoggingSigner.sign_blob(blob_path).await
8160            }
8161        }
8162
8163        let binary_rel = "target/x86_64-unknown-linux-musl/release/yubaba";
8164        let triple = "x86_64-unknown-linux-musl";
8165        let camp = stage_native_tarball_camp("yah-yubaba", "\"native-tarball\"", binary_rel);
8166
8167        let signer = Arc::new(CountingSigner {
8168            calls: AtomicUsize::new(0),
8169        });
8170        let pipeline = pack_and_sign_pipeline("yah-yubaba", binary_rel, triple);
8171        let runner = PipelineRunner::new(pipeline)
8172            .with_camp_root(camp.path().to_path_buf())
8173            .with_signer(signer.clone());
8174        let meta = runner.run().await.unwrap();
8175        assert_eq!(meta.status, RunStatus::Success);
8176        assert_eq!(signer.calls.load(Ordering::SeqCst), 1);
8177    }
8178
8179    // ─── SubPipeline recursion (R488-F2) ────────────────────────────────────
8180
8181    use crate::types::{
8182        ProducedArtifact, SubPipelineCollect, SubPipelineConfig, SubPipelineRef,
8183        SubPipelineResolver,
8184    };
8185
8186    /// In-memory resolver — maps a ref-token string to a Pipeline. The same
8187    /// token discipline the walker uses, so resolver + walker stay aligned.
8188    struct MapResolver(std::collections::HashMap<String, Pipeline>);
8189
8190    impl SubPipelineResolver for MapResolver {
8191        fn resolve(&self, target: &SubPipelineRef) -> Option<Pipeline> {
8192            let key = match target {
8193                SubPipelineRef::Builtin(n) => format!("builtin:{n}"),
8194                SubPipelineRef::Path(p) => format!("path:{}", p.display()),
8195                SubPipelineRef::GhaWorkflow { path, .. } => format!("gha:{}", path.display()),
8196                SubPipelineRef::Peer { camp, pipeline } => format!("peer:{camp}:{pipeline}"),
8197            };
8198            self.0.get(&key).cloned()
8199        }
8200    }
8201
8202    fn shell_step(name: &str, argv: Vec<&str>) -> crate::types::QedStep {
8203        crate::types::QedStep {
8204            background: false,
8205            background_until: None,
8206            wait_for: None,
8207            manifest_stitch: None,
8208            name: name.into(),
8209            argv: argv.into_iter().map(String::from).collect(),
8210            cwd: None,
8211            env: HashMap::new(),
8212            timeout: None,
8213            on_fail: OnFail::Abort,
8214            produces: Vec::new(),
8215            runtime: None,
8216            kind: crate::types::StepKind::Subprocess,
8217            image: None,
8218            tag: None,
8219            push: false,
8220            platforms: Vec::new(),
8221            binary_path: None,
8222            triple: None,
8223            package: None,
8224            context: None,
8225            load: false,
8226            sub_pipeline: None,
8227            gha_workflow: None,
8228            import: None,
8229            matrix: None,
8230            enabled: true,
8231            activation: StepActivation::Active,
8232            if_cond: None,
8233            platform: None,
8234            toolchain: None,
8235            outputs: Vec::new(),
8236        }
8237    }
8238
8239    fn producing_step(name: &str, binary: &str, path: &str) -> crate::types::QedStep {
8240        let mut s = shell_step(name, vec!["true"]);
8241        s.produces = vec![ProducedArtifact {
8242            binary: binary.into(),
8243            path: path.into(),
8244            triple: None,
8245        }];
8246        s
8247    }
8248
8249    /// R603-B6: `QedStep::timeout` is SECONDS. The runner used to lower it with
8250    /// `Millis::from_ms`, so P018's `timeout = 9000` ("2.5h cap") became 9
8251    /// seconds and killed every long remote step at 1/1000th of its budget —
8252    /// the rusty-v8 build died at ~9s after ~57min of real work on the worker.
8253    /// Latent locally only because the local driver never enforces
8254    /// `spec.timeout`. Lock the unit at the lowering boundary.
8255    #[test]
8256    fn step_timeout_is_seconds_not_millis() {
8257        let mut s = shell_step("build-v8-musl", vec!["true"]);
8258        s.timeout = Some(9000); // P018's real value: a 2.5h cap
8259        let spec = build_subprocess_spec(&s, TaskRuntime::Container, None);
8260        assert_eq!(
8261            spec.timeout.expect("timeout lowered").as_ms(),
8262            9_000_000,
8263            "9000s must lower to 9_000_000ms (2.5h); from_ms would give 9000ms = 9s"
8264        );
8265
8266        // No timeout stays absent (unbounded), not zero.
8267        let none = shell_step("no-budget", vec!["true"]);
8268        assert!(build_subprocess_spec(&none, TaskRuntime::Native, None)
8269            .timeout
8270            .is_none());
8271    }
8272
8273    fn sub_step(
8274        name: &str,
8275        target: SubPipelineRef,
8276        propagate_produces: bool,
8277    ) -> crate::types::QedStep {
8278        crate::types::QedStep {
8279            background: false,
8280            background_until: None,
8281            wait_for: None,
8282            manifest_stitch: None,
8283            name: name.into(),
8284            argv: Vec::new(),
8285            cwd: None,
8286            env: HashMap::new(),
8287            timeout: None,
8288            on_fail: OnFail::Abort,
8289            produces: Vec::new(),
8290            runtime: None,
8291            kind: crate::types::StepKind::SubPipeline,
8292            image: None,
8293            tag: None,
8294            push: false,
8295            platforms: Vec::new(),
8296            binary_path: None,
8297            triple: None,
8298            package: None,
8299            context: None,
8300            load: false,
8301            sub_pipeline: Some(SubPipelineConfig {
8302                target,
8303                params: HashMap::new(),
8304                propagate: SubPipelineCollect {
8305                    produces: propagate_produces,
8306                    outputs: Vec::new(),
8307                },
8308                opaque: false,
8309            }),
8310            outputs: Vec::new(),
8311            gha_workflow: None,
8312            import: None,
8313            matrix: None,
8314            enabled: true,
8315            activation: crate::types::StepActivation::Active,
8316            if_cond: None,
8317            platform: None,
8318            toolchain: None,
8319        }
8320    }
8321
8322    fn make_pipeline(name: &str, steps: Vec<crate::types::QedStep>) -> Pipeline {
8323        Pipeline {
8324            name: name.into(),
8325            label: name.into(),
8326            steps,
8327            params: HashMap::new(),
8328            on_success: vec![],
8329            on_fail: vec![],
8330            triggers: vec![],
8331            concurrency_key: None,
8332            placement: crate::types::Placement::Anywhere,
8333            // Test fixtures run in throwaway tempdirs that aren't real git
8334            // checkouts, so use Live (build the tree as-is) — the default
8335            // Checkout mode would try `git checkout main` and fail. Workspace
8336            // positioning itself is covered by the dedicated WorkspaceMode tests.
8337            workspace: crate::types::WorkspaceMode::Live,
8338            wraps: None,
8339            matrix: None,
8340            toolchain: None,
8341            binds: Vec::new(),
8342            on_change: Vec::new(),
8343            finally: Vec::new(),
8344        }
8345    }
8346
8347    // ── W224 WorkspaceMode positioning (decision table) ──────────────────────
8348
8349    /// Build a `main`-branch git repo with one committed file in a tempdir.
8350    fn init_git_repo() -> tempfile::TempDir {
8351        let tmp = tempfile::tempdir().unwrap();
8352        let git = |args: &[&str]| {
8353            let ok = std::process::Command::new("git")
8354                .current_dir(tmp.path())
8355                .args(args)
8356                .output()
8357                .unwrap()
8358                .status
8359                .success();
8360            assert!(ok, "git {args:?} failed");
8361        };
8362        git(&["init", "-b", "main"]);
8363        git(&["config", "user.email", "t@t.t"]);
8364        git(&["config", "user.name", "t"]);
8365        std::fs::write(tmp.path().join("f.txt"), "v1").unwrap();
8366        git(&["add", "."]);
8367        git(&["commit", "-m", "init"]);
8368        tmp
8369    }
8370
8371    fn pipeline_with_workspace(mode: crate::types::WorkspaceMode) -> Pipeline {
8372        let mut p = make_pipeline("ws", vec![]);
8373        p.workspace = mode;
8374        p
8375    }
8376
8377    #[test]
8378    fn workspace_live_returns_camp_root_without_touching_git() {
8379        // Live works even in a non-git dir — no status/checkout is run.
8380        let tmp = tempfile::tempdir().unwrap();
8381        let runner = PipelineRunner::new(pipeline_with_workspace(crate::types::WorkspaceMode::Live))
8382            .with_camp_root(tmp.path().to_path_buf());
8383        let (ws, guard) = runner.prepare_workspace(tmp.path()).unwrap();
8384        assert_eq!(ws, tmp.path());
8385        assert!(guard.is_none(), "Live needs no worktree guard");
8386    }
8387
8388    #[test]
8389    fn workspace_checkout_clean_switches_to_ref_in_place() {
8390        let repo = init_git_repo();
8391        let runner =
8392            PipelineRunner::new(pipeline_with_workspace(crate::types::WorkspaceMode::Checkout))
8393                .with_camp_root(repo.path().to_path_buf());
8394        let (ws, guard) = runner.prepare_workspace(repo.path()).unwrap();
8395        assert_eq!(ws, repo.path(), "checkout positions the camp root itself");
8396        assert!(guard.is_none());
8397    }
8398
8399    #[test]
8400    fn workspace_checkout_bails_on_dirty_tracked_change() {
8401        let repo = init_git_repo();
8402        // Dirty a tracked file → checkout must refuse rather than clobber it.
8403        std::fs::write(repo.path().join("f.txt"), "dirty").unwrap();
8404        let runner =
8405            PipelineRunner::new(pipeline_with_workspace(crate::types::WorkspaceMode::Checkout))
8406                .with_camp_root(repo.path().to_path_buf());
8407        let err = runner.prepare_workspace(repo.path()).unwrap_err();
8408        assert!(
8409            matches!(&err, RunnerError::InvalidConfig(m) if m.contains("uncommitted")),
8410            "expected a dirty-tree refusal, got {err:?}"
8411        );
8412    }
8413
8414    #[test]
8415    fn workspace_checkout_ignores_untracked_files() {
8416        let repo = init_git_repo();
8417        // An untracked file is not "dirty" for checkout purposes.
8418        std::fs::write(repo.path().join("scratch.txt"), "new").unwrap();
8419        let runner =
8420            PipelineRunner::new(pipeline_with_workspace(crate::types::WorkspaceMode::Checkout))
8421                .with_camp_root(repo.path().to_path_buf());
8422        assert!(runner.prepare_workspace(repo.path()).is_ok());
8423    }
8424
8425    #[test]
8426    fn workspace_checkout_ignores_dirty_runtime_db_only() {
8427        let repo = init_git_repo();
8428        let git = |args: &[&str]| {
8429            assert!(
8430                std::process::Command::new("git")
8431                    .current_dir(repo.path())
8432                    .args(args)
8433                    .output()
8434                    .unwrap()
8435                    .status
8436                    .success(),
8437                "git {args:?} failed"
8438            );
8439        };
8440        // Commit a runtime DB file so it is *tracked* (mirrors the real camp,
8441        // where the daemon's turso DBs are swept into wip commits).
8442        std::fs::create_dir_all(repo.path().join(".yah/db")).unwrap();
8443        std::fs::write(repo.path().join(".yah/db/task-runs.turso-wal"), b"v1").unwrap();
8444        git(&["add", "."]);
8445        git(&["commit", "-m", "track runtime db"]);
8446        // Now dirty ONLY the runtime DB — as the daemon does on every run.
8447        std::fs::write(repo.path().join(".yah/db/task-runs.turso-wal"), b"v2-churn").unwrap();
8448        let runner =
8449            PipelineRunner::new(pipeline_with_workspace(crate::types::WorkspaceMode::Checkout))
8450                .with_camp_root(repo.path().to_path_buf());
8451        assert!(
8452            runner.prepare_workspace(repo.path()).is_ok(),
8453            "a dirty tree confined to .yah/db runtime state must not bail checkout"
8454        );
8455        // But a real source edit alongside the DB churn still bails.
8456        std::fs::write(repo.path().join("f.txt"), "real edit").unwrap();
8457        assert!(
8458            matches!(
8459                runner.prepare_workspace(repo.path()),
8460                Err(RunnerError::InvalidConfig(m)) if m.contains("uncommitted")
8461            ),
8462            "a tracked source edit must still refuse checkout even amid DB churn"
8463        );
8464    }
8465
8466    #[test]
8467    fn porcelain_path_is_ignored_classifies_runtime_vs_source() {
8468        // Runtime DB churn → ignored.
8469        assert!(porcelain_path_is_ignored(" M .yah/db/task-runs.turso-wal"));
8470        assert!(porcelain_path_is_ignored("MM .yah/db/gnome_queue.turso"));
8471        // Source edits → not ignored.
8472        assert!(!porcelain_path_is_ignored(" M src/main.rs"));
8473        assert!(!porcelain_path_is_ignored(" M .yah/qed/P018-rusty-v8-musl.toml"));
8474        // A rename INTO the runtime dir keys off the destination.
8475        assert!(porcelain_path_is_ignored("R  old.db -> .yah/db/task-runs.turso"));
8476        assert!(!porcelain_path_is_ignored("R  .yah/db/x.turso -> src/moved.rs"));
8477        // Unparseable/short lines fail safe (counted as dirty).
8478        assert!(!porcelain_path_is_ignored(""));
8479        assert!(!porcelain_path_is_ignored("M"));
8480    }
8481
8482    #[test]
8483    fn workspace_isolated_builds_in_a_worktree_and_guard_cleans_up() {
8484        let repo = init_git_repo();
8485        let runner =
8486            PipelineRunner::new(pipeline_with_workspace(crate::types::WorkspaceMode::Isolated))
8487                .with_camp_root(repo.path().to_path_buf());
8488        let (ws, guard) = runner.prepare_workspace(repo.path()).unwrap();
8489        assert_ne!(ws, repo.path(), "isolated builds in a separate worktree");
8490        assert!(ws.join("f.txt").exists(), "worktree carries the committed tree");
8491        assert!(guard.is_some());
8492        let wt = ws.clone();
8493        drop(guard);
8494        assert!(!wt.join("f.txt").exists(), "guard tears the worktree down on drop");
8495    }
8496
8497    #[test]
8498    fn workspace_isolated_leaves_a_dirty_camp_root_untouched() {
8499        let repo = init_git_repo();
8500        // Uncommitted edits in the camp root are fine for isolated — it never
8501        // touches them, it builds from a fresh worktree at the committed ref.
8502        std::fs::write(repo.path().join("f.txt"), "dirty").unwrap();
8503        let runner =
8504            PipelineRunner::new(pipeline_with_workspace(crate::types::WorkspaceMode::Isolated))
8505                .with_camp_root(repo.path().to_path_buf());
8506        let (ws, guard) = runner.prepare_workspace(repo.path()).unwrap();
8507        assert_eq!(std::fs::read_to_string(repo.path().join("f.txt")).unwrap(), "dirty");
8508        assert_eq!(
8509            std::fs::read_to_string(ws.join("f.txt")).unwrap(),
8510            "v1",
8511            "worktree has committed bytes"
8512        );
8513        drop(guard);
8514    }
8515
8516    // ── R330-B27: ref (not hardcoded "main") drives Checkout/Isolated ───────
8517
8518    /// Build a `main`-branch git repo with two commits: `v1.0.0` tags the
8519    /// first, `main` moves on to a second. Distinguishes "the tag's commit"
8520    /// from "main's commit" for the tests below — before this ticket,
8521    /// `target_branch()` silently fell back to `"main"` whenever no ref was
8522    /// requested, so a tag-triggered release would build main's bytes.
8523    fn init_git_repo_with_tag() -> tempfile::TempDir {
8524        let tmp = tempfile::tempdir().unwrap();
8525        let git = |args: &[&str]| {
8526            let ok = std::process::Command::new("git")
8527                .current_dir(tmp.path())
8528                .args(args)
8529                .output()
8530                .unwrap()
8531                .status
8532                .success();
8533            assert!(ok, "git {args:?} failed");
8534        };
8535        git(&["init", "-b", "main"]);
8536        git(&["config", "user.email", "t@t.t"]);
8537        git(&["config", "user.name", "t"]);
8538        std::fs::write(tmp.path().join("f.txt"), "v1").unwrap();
8539        git(&["add", "."]);
8540        git(&["commit", "-m", "v1"]);
8541        git(&["tag", "v1.0.0"]);
8542        std::fs::write(tmp.path().join("f.txt"), "v2-on-main").unwrap();
8543        git(&["add", "."]);
8544        git(&["commit", "-m", "advance main"]);
8545        tmp
8546    }
8547
8548    fn git_rev_parse(dir: &std::path::Path, rev: &str) -> String {
8549        let out = std::process::Command::new("git")
8550            .current_dir(dir)
8551            .args(["rev-parse", rev])
8552            .output()
8553            .unwrap();
8554        assert!(out.status.success(), "git rev-parse {rev} failed");
8555        String::from_utf8_lossy(&out.stdout).trim().to_string()
8556    }
8557
8558    #[test]
8559    fn workspace_checkout_with_no_ref_positions_at_head_not_main() {
8560        let repo = init_git_repo_with_tag();
8561        // Detach HEAD at the tag — mirrors a CI runner that already checked
8562        // out a tag push before invoking `yah qed run`.
8563        run_git(repo.path(), &["checkout", "v1.0.0"]).unwrap();
8564        let tag_sha = git_rev_parse(repo.path(), "HEAD");
8565        let main_sha = git_rev_parse(repo.path(), "main");
8566        assert_ne!(tag_sha, main_sha, "fixture sanity: tag and main differ");
8567
8568        // No with_ref() call — must not fall back to "main".
8569        let runner =
8570            PipelineRunner::new(pipeline_with_workspace(crate::types::WorkspaceMode::Checkout))
8571                .with_camp_root(repo.path().to_path_buf());
8572        let (ws, _guard) = runner.prepare_workspace(repo.path()).unwrap();
8573        assert_eq!(ws, repo.path());
8574        assert_eq!(
8575            git_rev_parse(repo.path(), "HEAD"),
8576            tag_sha,
8577            "checkout with no explicit ref stays at the checked-out tag, not main"
8578        );
8579    }
8580
8581    #[test]
8582    fn workspace_isolated_with_no_ref_positions_at_head_not_main() {
8583        let repo = init_git_repo_with_tag();
8584        // Same detached-HEAD-at-a-tag setup as the Checkout test above.
8585        run_git(repo.path(), &["checkout", "v1.0.0"]).unwrap();
8586        let tag_sha = git_rev_parse(repo.path(), "HEAD");
8587        let main_sha = git_rev_parse(repo.path(), "main");
8588        assert_ne!(tag_sha, main_sha, "fixture sanity: tag and main differ");
8589
8590        // No with_ref() call — the pre-fix code would `git worktree add
8591        // <path> main` here and silently build main's bytes.
8592        let runner =
8593            PipelineRunner::new(pipeline_with_workspace(crate::types::WorkspaceMode::Isolated))
8594                .with_camp_root(repo.path().to_path_buf());
8595        let (ws, guard) = runner.prepare_workspace(repo.path()).unwrap();
8596        assert_eq!(
8597            git_rev_parse(&ws, "HEAD"),
8598            tag_sha,
8599            "isolated worktree with no explicit ref positions at HEAD (the tag), not main"
8600        );
8601        drop(guard);
8602    }
8603
8604    #[test]
8605    fn workspace_isolated_with_explicit_ref_builds_worktree_at_that_commit() {
8606        let repo = init_git_repo_with_tag();
8607        // HEAD stays on main; the run explicitly requests the tag instead —
8608        // the R330-B27 "plumb the trigger ref" shape (a tag-fired run passing
8609        // its tag explicitly rather than relying on ambient HEAD state).
8610        let tag_sha = git_rev_parse(repo.path(), "v1.0.0");
8611        let main_sha = git_rev_parse(repo.path(), "main");
8612        assert_ne!(tag_sha, main_sha, "fixture sanity: tag and main differ");
8613
8614        let runner =
8615            PipelineRunner::new(pipeline_with_workspace(crate::types::WorkspaceMode::Isolated))
8616                .with_camp_root(repo.path().to_path_buf())
8617                .with_ref(Some("v1.0.0".to_string()));
8618        let (ws, guard) = runner.prepare_workspace(repo.path()).unwrap();
8619        let worktree_sha = git_rev_parse(&ws, "HEAD");
8620        assert_eq!(
8621            worktree_sha, tag_sha,
8622            "explicit ref=<tag> builds the worktree at the tag's commit"
8623        );
8624        assert_ne!(
8625            worktree_sha, main_sha,
8626            "must not build main's bytes when a tag ref is requested"
8627        );
8628        drop(guard);
8629    }
8630
8631    // ── W224 R533-F11: whole-run positioning reaches non-gha steps ────────────
8632
8633    /// An `Isolated` run positions the tree ONCE at run start and every
8634    /// subprocess step builds in that worktree — not the live camp root — with a
8635    /// single run-scoped guard that outlives all steps and tears the worktree
8636    /// down when the run returns. This is the desktop-release-builds-from-the-
8637    /// worktree fix: before F11 only the gha-workflow step was repositioned.
8638    #[tokio::test]
8639    async fn run_level_isolated_positions_every_step_in_the_worktree() {
8640        let repo = init_git_repo();
8641        let mut pipeline = one_step_pipeline(
8642            "iso",
8643            vec![
8644                "sh".into(),
8645                "-c".into(),
8646                // Record cwd for the assertion and drop a build artifact in it.
8647                "echo cwd=$(pwd) >> \"$YAH_OUTPUTS\"; echo built > built.txt".into(),
8648            ],
8649        );
8650        pipeline.workspace = crate::types::WorkspaceMode::Isolated;
8651        // A second step reads the file the first wrote: it only succeeds if the
8652        // worktree survives BETWEEN steps (one shared guard, not per-step).
8653        let mut step2 = pipeline.steps[0].clone();
8654        step2.name = "step-2".into();
8655        step2.argv = vec![
8656            "sh".into(),
8657            "-c".into(),
8658            "cat built.txt && echo cwd=$(pwd) >> \"$YAH_OUTPUTS\"".into(),
8659        ];
8660        pipeline.steps.push(step2);
8661
8662        let runner = PipelineRunner::new(pipeline).with_camp_root(repo.path().to_path_buf());
8663        let meta = runner.run().await.unwrap();
8664        assert_eq!(meta.status, RunStatus::Success, "{:?}", meta.steps);
8665
8666        let cwd1 = meta.steps[0].outputs.get("cwd").expect("step-1 cwd");
8667        let cwd2 = meta.steps[1].outputs.get("cwd").expect("step-2 cwd");
8668        assert_eq!(cwd1, cwd2, "every step in the run shares the one worktree");
8669        assert!(
8670            cwd1.contains("qed-worktree-"),
8671            "subprocess step ran in the run's isolated worktree, got {cwd1}"
8672        );
8673        assert!(
8674            !repo.path().join("built.txt").exists(),
8675            "the build artifact landed in the worktree, never the live camp root"
8676        );
8677        // run() has returned ⇒ the run-scoped guard dropped ⇒ worktree is gone.
8678        assert!(
8679            !std::path::Path::new(cwd1).exists(),
8680            "the run-scoped worktree is torn down once the run completes"
8681        );
8682    }
8683
8684    /// `Live` leaves every step on the camp root as-is (no git, works in a
8685    /// non-repo tempdir) — the run-level positioning is a no-op for Live.
8686    #[tokio::test]
8687    async fn run_level_live_keeps_steps_on_the_camp_root() {
8688        let tmp = tempfile::tempdir().unwrap();
8689        let mut pipeline = one_step_pipeline(
8690            "live",
8691            vec![
8692                "sh".into(),
8693                "-c".into(),
8694                "echo cwd=$(pwd) >> \"$YAH_OUTPUTS\"".into(),
8695            ],
8696        );
8697        pipeline.workspace = crate::types::WorkspaceMode::Live;
8698        let runner = PipelineRunner::new(pipeline).with_camp_root(tmp.path().to_path_buf());
8699        let meta = runner.run().await.unwrap();
8700        assert_eq!(meta.status, RunStatus::Success, "{:?}", meta.steps);
8701        let cwd = meta.steps[0].outputs.get("cwd").expect("cwd");
8702        assert_eq!(
8703            std::path::Path::new(cwd).canonicalize().unwrap(),
8704            tmp.path().canonicalize().unwrap(),
8705            "Live builds the camp root in place"
8706        );
8707    }
8708
8709    /// Checkout-bail-if-dirty now fires at the *run* level (not only for a
8710    /// gha-workflow step): an ordinary `run()` of a subprocess pipeline over a
8711    /// dirty tree refuses rather than silently building surprise bytes.
8712    #[tokio::test]
8713    async fn run_level_checkout_bails_on_dirty_tree_before_any_step() {
8714        let repo = init_git_repo();
8715        std::fs::write(repo.path().join("f.txt"), "dirty").unwrap();
8716        let mut pipeline = one_step_pipeline("co", vec!["echo".into(), "hi".into()]);
8717        pipeline.workspace = crate::types::WorkspaceMode::Checkout;
8718        let runner = PipelineRunner::new(pipeline).with_camp_root(repo.path().to_path_buf());
8719        let err = runner.run().await.unwrap_err();
8720        assert!(
8721            matches!(&err, RunnerError::InvalidConfig(m) if m.contains("uncommitted")),
8722            "expected a run-level dirty-tree refusal, got {err:?}"
8723        );
8724    }
8725
8726    /// Counts publish and revalidate calls so we can assert "single publish"
8727    /// behaviour across composite runs.
8728    #[derive(Default)]
8729    struct CountingDispatcher {
8730        publishes: Mutex<u32>,
8731    }
8732
8733    #[async_trait::async_trait]
8734    impl OutcomeDispatcher for CountingDispatcher {
8735        async fn warden_deploy(&self, _s: &str, _e: &str) -> Result<(), RunnerError> {
8736            Ok(())
8737        }
8738        async fn almanac_run(&self, _p: &str) -> Result<(), RunnerError> {
8739            Ok(())
8740        }
8741        async fn publish(&self, _req: &crate::publish::PublishRequest) -> Result<(), RunnerError> {
8742            *self.publishes.lock().unwrap() += 1;
8743            Ok(())
8744        }
8745    }
8746
8747    #[tokio::test]
8748    async fn sub_pipeline_resolves_unresolvable_with_clear_error() {
8749        let root = make_pipeline(
8750            "root",
8751            vec![sub_step(
8752                "compose",
8753                SubPipelineRef::Builtin("does-not-exist".into()),
8754                false,
8755            )],
8756        );
8757        // Default NoopSubPipelineResolver — every resolve returns None.
8758        let runner = PipelineRunner::new(root);
8759        let meta = runner.run().await.unwrap();
8760        assert_eq!(meta.status, RunStatus::Failed);
8761        let step = meta.steps.iter().find(|s| s.name == "compose").unwrap();
8762        assert_eq!(step.status, RunStatus::Failed);
8763    }
8764
8765    /// Resolver that publishes a typed [`unresolved_reason`] — used to assert
8766    /// the runner surfaces the typed message in `StepFailed.msg` for the
8767    /// R494-T5 remote-peer path.
8768    struct DiagnosticResolver(String);
8769    impl SubPipelineResolver for DiagnosticResolver {
8770        fn resolve(&self, _target: &SubPipelineRef) -> Option<Pipeline> {
8771            None
8772        }
8773        fn unresolved_reason(&self, _target: &SubPipelineRef) -> Option<String> {
8774            Some(self.0.clone())
8775        }
8776    }
8777
8778    #[tokio::test]
8779    async fn sub_pipeline_unresolved_surfaces_resolver_typed_reason() {
8780        // R494-T5: when the resolver publishes an unresolved_reason (e.g.
8781        // "remote peer not yet supported"), the runner's StepFailed.msg
8782        // carries that message verbatim instead of the generic "target
8783        // unresolvable" debug tail.
8784        let peer_target = SubPipelineRef::Peer {
8785            camp: "cheers".into(),
8786            pipeline: "publish".into(),
8787        };
8788        let typed = "remote peer `cheers` lives on rig `rig-tokyo-1` (R494-T5)".to_string();
8789        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> =
8790            Arc::new(DiagnosticResolver(typed.clone()));
8791        let runner = PipelineRunner::new(make_pipeline(
8792            "root",
8793            vec![sub_step("remote", peer_target.clone(), false)],
8794        ))
8795        .with_sub_pipeline_resolver(resolver);
8796        let err = runner
8797            .execute_step_sub_pipeline(0, &sub_step("remote", peer_target, false), &mut Vec::new())
8798            .await
8799            .expect_err("expected StepFailed");
8800        match err {
8801            RunnerError::StepFailed { msg, .. } => assert_eq!(msg, typed),
8802            other => panic!("expected StepFailed, got: {other:?}"),
8803        }
8804    }
8805
8806    #[tokio::test]
8807    async fn sub_pipeline_runs_child_to_completion() {
8808        // root has one SubPipeline step → child has one trivial run step.
8809        let child = make_pipeline("child", vec![shell_step("ok", vec!["true"])]);
8810        let root = make_pipeline(
8811            "root",
8812            vec![sub_step(
8813                "compose",
8814                SubPipelineRef::Builtin("child".into()),
8815                false,
8816            )],
8817        );
8818        let mut map = std::collections::HashMap::new();
8819        map.insert("builtin:child".to_string(), child);
8820        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
8821        let runner = PipelineRunner::new(root).with_sub_pipeline_resolver(resolver);
8822        let meta = runner.run().await.unwrap();
8823        assert_eq!(meta.status, RunStatus::Success);
8824        let step = meta.steps.iter().find(|s| s.name == "compose").unwrap();
8825        assert_eq!(step.status, RunStatus::Success);
8826    }
8827
8828    #[tokio::test]
8829    async fn sub_pipeline_failure_propagates_to_parent() {
8830        let child = make_pipeline("child", vec![shell_step("boom", vec!["false"])]);
8831        let root = make_pipeline(
8832            "root",
8833            vec![sub_step(
8834                "compose",
8835                SubPipelineRef::Builtin("child".into()),
8836                false,
8837            )],
8838        );
8839        let mut map = std::collections::HashMap::new();
8840        map.insert("builtin:child".to_string(), child);
8841        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
8842        let runner = PipelineRunner::new(root).with_sub_pipeline_resolver(resolver);
8843        let meta = runner.run().await.unwrap();
8844        assert_eq!(meta.status, RunStatus::Failed);
8845    }
8846
8847    #[tokio::test]
8848    async fn sub_pipeline_inlines_child_steps_as_rows_by_default() {
8849        // W223 R532-F3: transparent-by-default generalizes beyond GHA. A
8850        // Builtin (or Path / Peer) child's steps are attributed to the wrapping
8851        // step as inlined rows — one per child step, in order, carrying status
8852        // and (on failure) the child step's error. Child qed steps are linear,
8853        // so the rows have no `needs` edges.
8854        let child = make_pipeline(
8855            "child",
8856            vec![
8857                shell_step("prep", vec!["true"]),
8858                shell_step("build", vec!["false"]), // fails
8859                shell_step("publish", vec!["true"]),
8860            ],
8861        );
8862        let root = make_pipeline(
8863            "root",
8864            vec![sub_step(
8865                "compose",
8866                SubPipelineRef::Builtin("child".into()),
8867                false,
8868            )],
8869        );
8870        let mut map = std::collections::HashMap::new();
8871        map.insert("builtin:child".to_string(), child);
8872        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
8873        let runner = PipelineRunner::new(root).with_sub_pipeline_resolver(resolver);
8874        let meta = runner.run().await.unwrap();
8875
8876        let step = meta.steps.iter().find(|s| s.name == "compose").unwrap();
8877        // The wrapping step carries one row per child step that ran (the
8878        // child aborts after `build` fails, so `publish` never runs).
8879        assert_eq!(
8880            step.jobs.iter().map(|j| j.id.as_str()).collect::<Vec<_>>(),
8881            vec!["prep", "build"],
8882            "child steps inline as rows in order, stopping at the abort",
8883        );
8884        assert_eq!(step.jobs[0].status, RunStatus::Success);
8885        assert_eq!(step.jobs[1].status, RunStatus::Failed);
8886        assert!(
8887            step.jobs[1].error.is_some(),
8888            "the failed child step's error carries onto the inlined row",
8889        );
8890        assert!(
8891            step.jobs.iter().all(|j| j.needs.is_empty()),
8892            "linear qed child steps carry no needs edges",
8893        );
8894    }
8895
8896    #[tokio::test]
8897    async fn opaque_sub_pipeline_suppresses_inlined_rows() {
8898        // W223 R532-F3: the `opaque` opt-out keeps the wrapper a single
8899        // black-box node — the child still runs and its status rolls up, but
8900        // no per-child rows are inlined.
8901        let child = make_pipeline(
8902            "child",
8903            vec![shell_step("a", vec!["true"]), shell_step("b", vec!["true"])],
8904        );
8905        let mut wrap = sub_step("compose", SubPipelineRef::Builtin("child".into()), false);
8906        wrap.sub_pipeline.as_mut().unwrap().opaque = true;
8907        let root = make_pipeline("root", vec![wrap]);
8908        let mut map = std::collections::HashMap::new();
8909        map.insert("builtin:child".to_string(), child);
8910        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
8911        let runner = PipelineRunner::new(root).with_sub_pipeline_resolver(resolver);
8912        let meta = runner.run().await.unwrap();
8913
8914        let step = meta.steps.iter().find(|s| s.name == "compose").unwrap();
8915        assert_eq!(
8916            step.status,
8917            RunStatus::Success,
8918            "child still ran + rolled up"
8919        );
8920        assert!(
8921            step.jobs.is_empty(),
8922            "opaque opt-out suppresses the inlined per-child rows",
8923        );
8924    }
8925
8926    /// R487 follow-up: when a `SubPipelineRef::GhaWorkflow` child step's
8927    /// inner workflow fails, the parent's `StepFailed.msg` must carry the
8928    /// inner stderr tail and the failing job/step name — NOT the generic
8929    /// "failed at child step `gha-workflow`" wrapper that the long
8930    /// SubPipeline path produces. Verifies the short-circuit in
8931    /// `execute_step_sub_pipeline` (R487 follow-up).
8932    #[tokio::test]
8933    async fn gha_workflow_subpipeline_surfaces_stderr_tail_to_parent() {
8934        let tmp = tempfile::tempdir().unwrap();
8935        let wf_path = tmp.path().join("fail.yml");
8936        std::fs::write(
8937            &wf_path,
8938            r#"
8939name: fail
8940on: push
8941jobs:
8942  blow-up:
8943    runs-on: ubuntu-latest
8944    steps:
8945      - name: emit then fail
8946        run: |
8947          echo "boom-marker-9b7c"
8948          echo "fatal: nothing to see here" 1>&2
8949          exit 17
8950"#,
8951        )
8952        .unwrap();
8953
8954        // Synthesised one-step pipeline carrying the GhaWorkflow step,
8955        // exactly as `LoaderSubPipelineResolver::resolve` would build it.
8956        let step = crate::types::QedStep {
8957            background: false,
8958            background_until: None,
8959            wait_for: None,
8960            manifest_stitch: None,
8961            name: "gha-workflow".to_string(),
8962            argv: Vec::new(),
8963            cwd: None,
8964            env: HashMap::new(),
8965            timeout: None,
8966            on_fail: OnFail::Abort,
8967            produces: Vec::new(),
8968            runtime: None,
8969            kind: crate::types::StepKind::GhaWorkflow,
8970            image: None,
8971            tag: None,
8972            push: false,
8973            platforms: Vec::new(),
8974            binary_path: None,
8975            triple: None,
8976            package: None,
8977            context: None,
8978            load: false,
8979            sub_pipeline: None,
8980            outputs: Vec::new(),
8981            import: None,
8982            gha_workflow: Some(crate::types::GhaWorkflowConfig {
8983                path: wf_path.clone(),
8984                event: None,
8985                inputs: HashMap::new(),
8986            }),
8987            matrix: None,
8988            enabled: true,
8989            activation: crate::types::StepActivation::Active,
8990            if_cond: None,
8991            platform: None,
8992            toolchain: None,
8993        };
8994        let child = Pipeline {
8995            name: "fail".into(),
8996            label: "fail".into(),
8997            steps: vec![step],
8998            params: HashMap::new(),
8999            on_success: vec![],
9000            on_fail: vec![],
9001            triggers: vec![],
9002            concurrency_key: None,
9003            placement: Default::default(),
9004            workspace: crate::types::WorkspaceMode::Live, // test fixture isn't a git checkout
9005            wraps: None,
9006            matrix: None,
9007            toolchain: None,
9008            binds: Vec::new(),
9009            on_change: Vec::new(),
9010            finally: Vec::new(),
9011        };
9012
9013        let mut map = std::collections::HashMap::new();
9014        map.insert(format!("gha:{}", wf_path.display()), child);
9015        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
9016
9017        // Parent: one SubPipeline step targeting our GHA workflow.
9018        let root = make_pipeline(
9019            "root",
9020            vec![sub_step(
9021                "wrap",
9022                SubPipelineRef::GhaWorkflow {
9023                    path: wf_path.clone(),
9024                    event: None,
9025                    inputs: HashMap::new(),
9026                },
9027                false,
9028            )],
9029        );
9030
9031        // Capture parent's event stream so we can inspect the
9032        // StepFinished.msg the consumer would see.
9033        let (tx, mut rx) = mpsc::unbounded_channel();
9034        let runner = PipelineRunner::new(root)
9035            .with_sub_pipeline_resolver(resolver)
9036            .with_camp_root(tmp.path().to_path_buf())
9037            .with_events(tx);
9038        let meta = runner.run().await.unwrap();
9039        assert_eq!(meta.status, RunStatus::Failed);
9040
9041        // Walk the event stream for the parent's StepFinished on step 0.
9042        let mut step_fail_msg: Option<String> = None;
9043        let mut saw_subpipeline_started = false;
9044        let mut saw_subpipeline_finished = false;
9045        while let Ok(ev) = rx.try_recv() {
9046            match &ev {
9047                QedEvent::StepFinished {
9048                    index: 0,
9049                    msg,
9050                    status,
9051                    ..
9052                } => {
9053                    if *status == RunStatus::Failed {
9054                        step_fail_msg = msg.clone();
9055                    }
9056                }
9057                QedEvent::SubPipelineStarted { index: 0, .. } => {
9058                    saw_subpipeline_started = true;
9059                }
9060                QedEvent::SubPipelineFinished {
9061                    index: 0, status, ..
9062                } => {
9063                    if *status == RunStatus::Failed {
9064                        saw_subpipeline_finished = true;
9065                    }
9066                }
9067                _ => {}
9068            }
9069        }
9070        assert!(
9071            saw_subpipeline_started,
9072            "short-circuit must still emit SubPipelineStarted bookend",
9073        );
9074        assert!(
9075            saw_subpipeline_finished,
9076            "short-circuit must still emit SubPipelineFinished bookend with failed status",
9077        );
9078        let msg = step_fail_msg.expect("parent StepFinished carries a failure msg");
9079        assert!(
9080            msg.contains("blow-up"),
9081            "msg should name the failing job (got: {msg})",
9082        );
9083        assert!(
9084            msg.contains("emit then fail"),
9085            "msg should name the failing step (got: {msg})",
9086        );
9087        assert!(
9088            msg.contains("fatal: nothing to see here"),
9089            "msg should carry the stderr tail (got: {msg})",
9090        );
9091        // And — critically — the inner tail should NOT be wrapped in the
9092        // generic SubPipeline "failed at child step `gha-workflow`" string
9093        // that the long path produces.
9094        assert!(
9095            !msg.contains("failed at child step `gha-workflow`"),
9096            "short-circuit should bypass the SubPipeline-wrapper msg (got: {msg})",
9097        );
9098    }
9099
9100    #[tokio::test]
9101    async fn gha_workflow_subpipeline_persists_per_job_rows() {
9102        // W223 R532-T1: a wrapped GHA workflow is a *disregarded entity* — its
9103        // jobs are persisted as structured per-job rows under the wrapping
9104        // step's StepStatus, rather than collapsed into one flattened failure
9105        // string. One job succeeds, one fails (carrying its stderr-tail detail),
9106        // and one downstream job `needs` the failing one so it is skipped — the
9107        // R516 skip-count becomes a per-row Skipped state, not a trailing
9108        // sentence.
9109        let tmp = tempfile::tempdir().unwrap();
9110        let wf_path = tmp.path().join("mix.yml");
9111        std::fs::write(
9112            &wf_path,
9113            r#"
9114name: mix
9115on: push
9116jobs:
9117  ok:
9118    runs-on: ubuntu-latest
9119    steps:
9120      - name: succeed
9121        run: echo "all good"
9122  boom:
9123    runs-on: ubuntu-latest
9124    steps:
9125      - name: emit then fail
9126        run: |
9127          echo "fatal: kaboom-7f3a" 1>&2
9128          exit 9
9129  downstream:
9130    runs-on: ubuntu-latest
9131    needs: boom
9132    steps:
9133      - name: never runs
9134        run: echo "should be skipped"
9135"#,
9136        )
9137        .unwrap();
9138
9139        // Synthesised one-step pipeline carrying the GhaWorkflow step, exactly
9140        // as `LoaderSubPipelineResolver::resolve` would build it.
9141        let step = crate::types::QedStep {
9142            background: false,
9143            background_until: None,
9144            wait_for: None,
9145            manifest_stitch: None,
9146            name: "gha-workflow".to_string(),
9147            argv: Vec::new(),
9148            cwd: None,
9149            env: HashMap::new(),
9150            timeout: None,
9151            on_fail: OnFail::Abort,
9152            produces: Vec::new(),
9153            runtime: None,
9154            kind: crate::types::StepKind::GhaWorkflow,
9155            image: None,
9156            tag: None,
9157            push: false,
9158            platforms: Vec::new(),
9159            binary_path: None,
9160            triple: None,
9161            package: None,
9162            context: None,
9163            load: false,
9164            sub_pipeline: None,
9165            outputs: Vec::new(),
9166            import: None,
9167            gha_workflow: Some(crate::types::GhaWorkflowConfig {
9168                path: wf_path.clone(),
9169                event: None,
9170                inputs: HashMap::new(),
9171            }),
9172            matrix: None,
9173            enabled: true,
9174            activation: crate::types::StepActivation::Active,
9175            if_cond: None,
9176            platform: None,
9177            toolchain: None,
9178        };
9179        let child = Pipeline {
9180            name: "mix".into(),
9181            label: "mix".into(),
9182            steps: vec![step],
9183            params: HashMap::new(),
9184            on_success: vec![],
9185            on_fail: vec![],
9186            triggers: vec![],
9187            concurrency_key: None,
9188            placement: Default::default(),
9189            workspace: crate::types::WorkspaceMode::Live, // test fixture isn't a git checkout
9190            wraps: None,
9191            matrix: None,
9192            toolchain: None,
9193            binds: Vec::new(),
9194            on_change: Vec::new(),
9195            finally: Vec::new(),
9196        };
9197
9198        let mut map = std::collections::HashMap::new();
9199        map.insert(format!("gha:{}", wf_path.display()), child);
9200        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
9201
9202        let root = make_pipeline(
9203            "root",
9204            vec![sub_step(
9205                "wrap",
9206                SubPipelineRef::GhaWorkflow {
9207                    path: wf_path.clone(),
9208                    event: None,
9209                    inputs: HashMap::new(),
9210                },
9211                false,
9212            )],
9213        );
9214
9215        let runner = PipelineRunner::new(root)
9216            .with_sub_pipeline_resolver(resolver)
9217            .with_camp_root(tmp.path().to_path_buf());
9218        let meta = runner.run().await.unwrap();
9219        assert_eq!(meta.status, RunStatus::Failed);
9220
9221        // The wrapping step (index 0) carries one row per GHA job.
9222        let wrap = &meta.steps[0];
9223        assert_eq!(
9224            wrap.jobs.len(),
9225            3,
9226            "all three jobs should produce rows (got: {:?})",
9227            wrap.jobs.iter().map(|j| &j.id).collect::<Vec<_>>(),
9228        );
9229        let row = |id: &str| {
9230            wrap.jobs
9231                .iter()
9232                .find(|j| j.id == id)
9233                .unwrap_or_else(|| panic!("missing row for job {id}"))
9234        };
9235
9236        assert_eq!(row("ok").status, RunStatus::Success);
9237        assert!(row("ok").error.is_none(), "success row carries no error");
9238
9239        let boom = row("boom");
9240        assert_eq!(boom.status, RunStatus::Failed);
9241        let err = boom
9242            .error
9243            .as_ref()
9244            .expect("failed job row carries stderr-tail detail");
9245        assert!(
9246            err.contains("emit then fail"),
9247            "row error names the failing step (got: {err})",
9248        );
9249        assert!(
9250            err.contains("kaboom-7f3a"),
9251            "row error carries the stderr tail (got: {err})",
9252        );
9253
9254        let down = row("downstream");
9255        assert_eq!(
9256            down.status,
9257            RunStatus::Skipped,
9258            "downstream gated on a failed dep is a Skipped row, not a trailing skip-count",
9259        );
9260        assert!(down.error.is_none(), "skipped row carries no error");
9261        // W223 R532-F2: the intra-workflow `needs:` edge is persisted so the
9262        // graph viewer can render it as a real dependency edge.
9263        assert_eq!(
9264            down.needs,
9265            vec!["boom".to_string()],
9266            "downstream's needs edge is carried on the row",
9267        );
9268        assert!(
9269            row("ok").needs.is_empty(),
9270            "a job with no needs has an empty edge list"
9271        );
9272    }
9273
9274    #[tokio::test]
9275    async fn sub_pipeline_aggregates_produces_when_propagate_set() {
9276        // Child has a producing step + its own Outcome::Publish that we
9277        // expect SUPPRESSED because parent claims propagate.produces.
9278        let mut child = make_pipeline(
9279            "child",
9280            vec![producing_step("emit", "yah", "target/release/yah")],
9281        );
9282        child.on_success = vec![Outcome::Publish {
9283            provider: "r2".into(),
9284            bucket: "yah-releases".into(),
9285            prefix: None,
9286            base_url: None,
9287        }];
9288
9289        // Parent: SubPipeline child with propagate.produces=true + its own
9290        // Outcome::Publish. We expect ONE publish total (the parent's),
9291        // confirming both suppression on child and aggregation on parent.
9292        let mut root = make_pipeline(
9293            "root",
9294            vec![sub_step(
9295                "compose",
9296                SubPipelineRef::Builtin("child".into()),
9297                true,
9298            )],
9299        );
9300        root.on_success = vec![Outcome::Publish {
9301            provider: "r2".into(),
9302            bucket: "yah-releases".into(),
9303            prefix: None,
9304            base_url: None,
9305        }];
9306
9307        let mut map = std::collections::HashMap::new();
9308        map.insert("builtin:child".to_string(), child);
9309        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
9310
9311        let dispatcher = Arc::new(CountingDispatcher::default());
9312        let runner = PipelineRunner::new_with_dispatcher(root, dispatcher.clone())
9313            .with_sub_pipeline_resolver(resolver);
9314        let meta = runner.run().await.unwrap();
9315        assert_eq!(meta.status, RunStatus::Success);
9316        assert_eq!(
9317            *dispatcher.publishes.lock().unwrap(),
9318            1,
9319            "exactly one publish — parent fires, child suppressed"
9320        );
9321    }
9322
9323    #[tokio::test]
9324    async fn sub_pipeline_child_publish_fires_when_propagate_unset() {
9325        // Mirror of the above but propagate.produces=false — child's own
9326        // Outcome::Publish should fire, parent's too. Total: 2.
9327        let mut child = make_pipeline(
9328            "child",
9329            vec![producing_step("emit", "yah", "target/release/yah")],
9330        );
9331        child.on_success = vec![Outcome::Publish {
9332            provider: "r2".into(),
9333            bucket: "yah-releases".into(),
9334            prefix: None,
9335            base_url: None,
9336        }];
9337
9338        let mut root = make_pipeline(
9339            "root",
9340            vec![sub_step(
9341                "compose",
9342                SubPipelineRef::Builtin("child".into()),
9343                false,
9344            )],
9345        );
9346        root.on_success = vec![Outcome::Publish {
9347            provider: "r2".into(),
9348            bucket: "yah-releases".into(),
9349            prefix: None,
9350            base_url: None,
9351        }];
9352
9353        let mut map = std::collections::HashMap::new();
9354        map.insert("builtin:child".to_string(), child);
9355        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
9356
9357        let dispatcher = Arc::new(CountingDispatcher::default());
9358        let runner = PipelineRunner::new_with_dispatcher(root, dispatcher.clone())
9359            .with_sub_pipeline_resolver(resolver);
9360        let meta = runner.run().await.unwrap();
9361        assert_eq!(meta.status, RunStatus::Success);
9362        assert_eq!(
9363            *dispatcher.publishes.lock().unwrap(),
9364            2,
9365            "two publishes — child fires its own + parent fires its own"
9366        );
9367    }
9368
9369    #[tokio::test]
9370    async fn sub_pipeline_nested_two_levels_works() {
9371        // root -> mid -> leaf. propagate.produces all the way up.
9372        let leaf = make_pipeline(
9373            "leaf",
9374            vec![producing_step("emit", "yah", "target/release/yah")],
9375        );
9376        let mid = make_pipeline(
9377            "mid",
9378            vec![sub_step(
9379                "descend",
9380                SubPipelineRef::Builtin("leaf".into()),
9381                true,
9382            )],
9383        );
9384        let mut root = make_pipeline(
9385            "root",
9386            vec![sub_step(
9387                "compose",
9388                SubPipelineRef::Builtin("mid".into()),
9389                true,
9390            )],
9391        );
9392        root.on_success = vec![Outcome::Publish {
9393            provider: "r2".into(),
9394            bucket: "yah-releases".into(),
9395            prefix: None,
9396            base_url: None,
9397        }];
9398
9399        let mut map = std::collections::HashMap::new();
9400        map.insert("builtin:leaf".to_string(), leaf);
9401        map.insert("builtin:mid".to_string(), mid);
9402        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
9403
9404        let dispatcher = Arc::new(CountingDispatcher::default());
9405        let runner = PipelineRunner::new_with_dispatcher(root, dispatcher.clone())
9406            .with_sub_pipeline_resolver(resolver);
9407        let meta = runner.run().await.unwrap();
9408        assert_eq!(meta.status, RunStatus::Success);
9409        assert_eq!(
9410            *dispatcher.publishes.lock().unwrap(),
9411            1,
9412            "single revalidate even across two SubPipeline edges"
9413        );
9414    }
9415
9416    // ─── F3: multi-child publish fan-in + continue-on-error ─────────────────
9417
9418    /// Recording publisher that captures the staged tree on each sync, so
9419    /// tests can assert "what would have been uploaded" without a real R2
9420    /// account. Differs from `publish::tests::RecordingPublisher` by
9421    /// exposing every staged file (not just one manifest) so we can verify
9422    /// multi-binary fan-in across SubPipeline children.
9423    #[derive(Default)]
9424    struct StageRecorder {
9425        syncs: Mutex<u32>,
9426        revalidates: Mutex<u32>,
9427        /// Channel keys (`<binary>/<version>/<triple>/<file>` or
9428        /// `<binary>/release-manifest.json`) observed across all syncs.
9429        files: Mutex<Vec<String>>,
9430    }
9431
9432    #[async_trait::async_trait]
9433    impl crate::publish::ReleasePublisher for StageRecorder {
9434        async fn sync(
9435            &self,
9436            staging_dir: &std::path::Path,
9437            _provider: &str,
9438            _bucket: &str,
9439            _prefix: Option<&str>,
9440        ) -> Result<(), RunnerError> {
9441            *self.syncs.lock().unwrap() += 1;
9442            let mut walker = vec![staging_dir.to_path_buf()];
9443            while let Some(dir) = walker.pop() {
9444                for entry in std::fs::read_dir(&dir).unwrap() {
9445                    let entry = entry.unwrap();
9446                    let path = entry.path();
9447                    if path.is_dir() {
9448                        walker.push(path);
9449                    } else {
9450                        let rel = path
9451                            .strip_prefix(staging_dir)
9452                            .unwrap()
9453                            .to_string_lossy()
9454                            .into_owned();
9455                        self.files.lock().unwrap().push(rel);
9456                    }
9457                }
9458            }
9459            self.files.lock().unwrap().sort();
9460            Ok(())
9461        }
9462
9463        async fn revalidate(&self) -> Result<(), RunnerError> {
9464            *self.revalidates.lock().unwrap() += 1;
9465            Ok(())
9466        }
9467    }
9468
9469    #[tokio::test]
9470    async fn sub_pipeline_multi_child_fan_in_groups_by_binary_with_single_publish() {
9471        // Three children producing different binaries (yah, desktop,
9472        // mesofact) all rolled up into the parent. The parent's single
9473        // Outcome::Publish should fire ONCE with a staged tree containing
9474        // all three binaries' files + per-binary manifests, and exactly
9475        // one revalidate POST. Exercises the full chain F2 wired:
9476        //   parent.run -> child.run_inner (x3) -> aggregate produced
9477        //              -> parent's PublishingOutcomeDispatcher.publish
9478        //              -> stage_release (lays out the tree)
9479        //              -> StageRecorder.sync (one call, sees all binaries)
9480        //              -> StageRecorder.revalidate (one call total).
9481        let tmp = TempDir::new().unwrap();
9482        let yah_path = tmp.path().join("yah");
9483        std::fs::write(&yah_path, b"YAH").unwrap();
9484        let desktop_path = tmp.path().join("desktop");
9485        std::fs::write(&desktop_path, b"DESKTOP").unwrap();
9486        let mesofact_path = tmp.path().join("mesofact");
9487        std::fs::write(&mesofact_path, b"MESOFACT").unwrap();
9488
9489        let child_cli = make_pipeline(
9490            "child-cli",
9491            vec![producing_step(
9492                "build-cli",
9493                "yah",
9494                yah_path.to_string_lossy().as_ref(),
9495            )],
9496        );
9497        let child_desktop = make_pipeline(
9498            "child-desktop",
9499            vec![producing_step(
9500                "build-desktop",
9501                "desktop",
9502                desktop_path.to_string_lossy().as_ref(),
9503            )],
9504        );
9505        let child_mesofact = make_pipeline(
9506            "child-mesofact",
9507            vec![producing_step(
9508                "build-mesofact",
9509                "mesofact",
9510                mesofact_path.to_string_lossy().as_ref(),
9511            )],
9512        );
9513
9514        let mut root = make_pipeline(
9515            "full-release",
9516            vec![
9517                sub_step(
9518                    "compose-cli",
9519                    SubPipelineRef::Builtin("child-cli".into()),
9520                    true,
9521                ),
9522                sub_step(
9523                    "compose-desktop",
9524                    SubPipelineRef::Builtin("child-desktop".into()),
9525                    true,
9526                ),
9527                sub_step(
9528                    "compose-mesofact",
9529                    SubPipelineRef::Builtin("child-mesofact".into()),
9530                    true,
9531                ),
9532            ],
9533        );
9534        root.on_success = vec![Outcome::Publish {
9535            provider: "r2".into(),
9536            bucket: "yah-releases".into(),
9537            prefix: None,
9538            base_url: Some("https://releases.yah.dev".into()),
9539        }];
9540
9541        let mut map = std::collections::HashMap::new();
9542        map.insert("builtin:child-cli".to_string(), child_cli);
9543        map.insert("builtin:child-desktop".to_string(), child_desktop);
9544        map.insert("builtin:child-mesofact".to_string(), child_mesofact);
9545        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
9546
9547        // Pin the version so the staged path is deterministic. SAFETY:
9548        // single-threaded test; set + clear locally.
9549        std::env::set_var("YAH_RELEASE_VERSION", "1.2.3");
9550
9551        let recorder = Arc::new(StageRecorder::default());
9552        struct ArcRecorder(Arc<StageRecorder>);
9553        #[async_trait::async_trait]
9554        impl crate::publish::ReleasePublisher for ArcRecorder {
9555            async fn sync(
9556                &self,
9557                d: &std::path::Path,
9558                p: &str,
9559                b: &str,
9560                pre: Option<&str>,
9561            ) -> Result<(), RunnerError> {
9562                self.0.sync(d, p, b, pre).await
9563            }
9564            async fn revalidate(&self) -> Result<(), RunnerError> {
9565                self.0.revalidate().await
9566            }
9567        }
9568        let dispatcher = Arc::new(crate::publish::PublishingOutcomeDispatcher::new(
9569            ArcRecorder(recorder.clone()),
9570        ));
9571        let runner = PipelineRunner::new_with_dispatcher(root, dispatcher)
9572            .with_sub_pipeline_resolver(resolver);
9573        let meta = runner.run().await.unwrap();
9574        std::env::remove_var("YAH_RELEASE_VERSION");
9575
9576        assert_eq!(meta.status, RunStatus::Success);
9577        assert_eq!(
9578            *recorder.syncs.lock().unwrap(),
9579            1,
9580            "single sync across all children"
9581        );
9582        assert_eq!(
9583            *recorder.revalidates.lock().unwrap(),
9584            1,
9585            "single revalidate POST"
9586        );
9587
9588        let files = recorder.files.lock().unwrap();
9589        // Three per-binary shared manifests + three per-(binary,triple) stable
9590        // manifests (single triple in this fan-in: darwin-aarch64) + three
9591        // binary files = 9 staged objects. The per-triple stable manifests
9592        // were added in R330-B8 for cross-stage merge fan-in.
9593        assert_eq!(files.len(), 9, "staged tree contents: {files:?}");
9594        assert!(files.iter().any(|f| f == "yah/release-manifest.json"));
9595        assert!(files.iter().any(|f| f == "desktop/release-manifest.json"));
9596        assert!(files.iter().any(|f| f == "mesofact/release-manifest.json"));
9597        assert!(files
9598            .iter()
9599            .any(|f| f == "yah/release-manifest-darwin-aarch64.json"));
9600        assert!(files
9601            .iter()
9602            .any(|f| f == "desktop/release-manifest-darwin-aarch64.json"));
9603        assert!(files
9604            .iter()
9605            .any(|f| f == "mesofact/release-manifest-darwin-aarch64.json"));
9606        assert!(files.iter().any(|f| f.starts_with("yah/1.2.3/")));
9607        assert!(files.iter().any(|f| f.starts_with("desktop/1.2.3/")));
9608        assert!(files.iter().any(|f| f.starts_with("mesofact/1.2.3/")));
9609    }
9610
9611    #[tokio::test]
9612    async fn sub_pipeline_failed_child_with_continue_on_error_does_not_abort_parent() {
9613        // Pins the F2 open question: a SubPipeline step with on_fail =
9614        // Continue marks itself failed but the parent loop proceeds to
9615        // subsequent steps. The child's produced are dropped (current
9616        // implementation only aggregates on success — documented behaviour).
9617        let bad_child = make_pipeline("bad", vec![shell_step("boom", vec!["false"])]);
9618        let mut sub = sub_step("compose", SubPipelineRef::Builtin("bad".into()), false);
9619        sub.on_fail = OnFail::Continue;
9620        let after = shell_step("after", vec!["true"]);
9621        let root = make_pipeline("root", vec![sub, after]);
9622
9623        let mut map = std::collections::HashMap::new();
9624        map.insert("builtin:bad".to_string(), bad_child);
9625        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
9626        let runner = PipelineRunner::new(root).with_sub_pipeline_resolver(resolver);
9627        let meta = runner.run().await.unwrap();
9628
9629        // Overall status is Failed (any failed step flips it regardless of
9630        // on_fail policy), but the subsequent `after` step still ran
9631        // because Continue suppresses the early break.
9632        assert_eq!(meta.status, RunStatus::Failed);
9633        let compose = meta.steps.iter().find(|s| s.name == "compose").unwrap();
9634        assert_eq!(compose.status, RunStatus::Failed);
9635        let after_step = meta.steps.iter().find(|s| s.name == "after").unwrap();
9636        assert_eq!(
9637            after_step.status,
9638            RunStatus::Success,
9639            "after step ran despite child failure"
9640        );
9641    }
9642
9643    #[tokio::test]
9644    async fn sub_pipeline_forwards_params_to_child() {
9645        // Child step has a `{{greeting}}` arg; parent's SubPipeline params
9646        // substitute it before the child runs.
9647        let child = make_pipeline(
9648            "child",
9649            vec![shell_step("echo", vec!["true", "{{greeting}}"])],
9650        );
9651        let mut step = sub_step("compose", SubPipelineRef::Builtin("child".into()), false);
9652        if let Some(cfg) = step.sub_pipeline.as_mut() {
9653            cfg.params
9654                .insert("greeting".to_string(), "hello".to_string());
9655        }
9656        let root = make_pipeline("root", vec![step]);
9657
9658        let mut map = std::collections::HashMap::new();
9659        map.insert("builtin:child".to_string(), child);
9660        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
9661        let runner = PipelineRunner::new(root).with_sub_pipeline_resolver(resolver);
9662        let meta = runner.run().await.unwrap();
9663        // Successful = `true hello` exited 0. We don't capture argv here but
9664        // a `false {{greeting}}` would fail the same; this proves the step
9665        // ran post-substitution.
9666        assert_eq!(meta.status, RunStatus::Success);
9667    }
9668
9669    // ─── Named output exposure (R488-F4) ────────────────────────────────────
9670
9671    /// Step 1 writes an output via $YAH_OUTPUTS; step 2 references it in
9672    /// argv via `${{ steps.step1.outputs.digest }}` — the runner substitutes
9673    /// the value before execution so step 2 receives the resolved string.
9674    #[tokio::test]
9675    async fn step_outputs_substituted_into_sibling_argv() {
9676        // step1: writes digest=abc123 to $YAH_OUTPUTS via a shell one-liner.
9677        // step2: echoes the substitution placeholder — if substitution worked,
9678        //        argv will have been rewritten to "echo abc123" before
9679        //        execution, and the step exits 0.
9680        let step1 = shell_step(
9681            "step1",
9682            vec!["sh", "-c", "echo digest=abc123 >> \"$YAH_OUTPUTS\""],
9683        );
9684        // step2's argv contains the placeholder; the runner rewrites it
9685        // before passing to the executor.
9686        let step2 = shell_step(
9687            "step2",
9688            vec![
9689                "sh",
9690                "-c",
9691                "test \"$1\" = abc123",
9692                "--",
9693                "${{ steps.step1.outputs.digest }}",
9694            ],
9695        );
9696        let pipeline = make_pipeline("p", vec![step1, step2]);
9697        let runner = PipelineRunner::new(pipeline);
9698        let meta = runner.run().await.unwrap();
9699        assert_eq!(
9700            meta.status,
9701            RunStatus::Success,
9702            "step2 should receive substituted value"
9703        );
9704        let s1 = meta.steps.iter().find(|s| s.name == "step1").unwrap();
9705        assert_eq!(
9706            s1.outputs.get("digest").map(|s| s.as_str()),
9707            Some("abc123"),
9708            "step1 outputs map should contain captured value"
9709        );
9710    }
9711
9712    /// Step 1 writes KEY=VALUE to $YAH_OUTPUTS; the runner collects it into
9713    /// StepStatus::outputs regardless of whether the step declared it in
9714    /// the `outputs` field.
9715    #[tokio::test]
9716    async fn step_outputs_captured_in_step_status() {
9717        let step = shell_step(
9718            "emit",
9719            vec![
9720                "sh",
9721                "-c",
9722                "printf 'foo=bar\\nbaz=qux\\n' >> \"$YAH_OUTPUTS\"",
9723            ],
9724        );
9725        let pipeline = make_pipeline("p", vec![step]);
9726        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
9727        assert_eq!(meta.status, RunStatus::Success);
9728        let s = meta.steps.iter().find(|s| s.name == "emit").unwrap();
9729        assert_eq!(s.outputs.get("foo").map(|s| s.as_str()), Some("bar"));
9730        assert_eq!(s.outputs.get("baz").map(|s| s.as_str()), Some("qux"));
9731    }
9732
9733    /// W209 F3: a `[[bind]]` whose `from` references step1's output fires
9734    /// mid-pipeline; step2 reads the new value off disk like any other
9735    /// tool. Confirms the build→checkin→release inversion at the
9736    /// mechanical layer: the source tree IS the step-to-step plumbing.
9737    #[tokio::test]
9738    async fn pipeline_bind_writes_manifest_mid_run_visible_to_next_step() {
9739        const HASH_A: &str = "fb0afc9f3d966f5347c6dfd335adab12f1dc8ee6df18cf9e9ff90fe86f0416c0";
9740        let workspace = TempDir::new().unwrap();
9741        let manifest_path = workspace.path().join("workload.toml");
9742        std::fs::write(
9743            &manifest_path,
9744            "name = \"whisper\"\nblake3 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n",
9745        )
9746        .unwrap();
9747
9748        let mut step1 = shell_step(
9749            "publish",
9750            vec![
9751                "sh",
9752                "-c",
9753                &format!("echo discovered=\"{HASH_A}\" >> \"$YAH_OUTPUTS\""),
9754            ],
9755        );
9756        step1.outputs = vec![crate::types::OutputDecl {
9757            name: "discovered".into(),
9758            description: None,
9759            kind: manifest_bind::ValueType::Blake3Hex,
9760            validate: None,
9761        }];
9762
9763        // Step 2 reads the on-disk manifest and asserts the new hash is
9764        // there. If apply_binds didn't fire mid-pipeline, this fails.
9765        let step2 = shell_step(
9766            "consume",
9767            vec!["sh", "-c", &format!("grep -q '{HASH_A}' workload.toml")],
9768        );
9769
9770        let mut pipeline = make_pipeline("publish-then-consume", vec![step1, step2]);
9771        pipeline.binds = vec![manifest_bind::BindSpec {
9772            file: "workload.toml".into(),
9773            path: "blake3".into(),
9774            from: manifest_bind::OutputRef::parse("publish.outputs.discovered").unwrap(),
9775            intent: manifest_bind::Intent::Keyword(manifest_bind::IntentKeyword::Latest),
9776            cross_workspace: false,
9777            schema: None,
9778        }];
9779
9780        let runner = PipelineRunner::new(pipeline).with_camp_root(workspace.path().to_path_buf());
9781        let meta = runner.run().await.unwrap();
9782
9783        assert_eq!(
9784            meta.status,
9785            RunStatus::Success,
9786            "step2 must see the bound value"
9787        );
9788        let s1 = meta.steps.iter().find(|s| s.name == "publish").unwrap();
9789        assert_eq!(
9790            s1.applied_binds.len(),
9791            1,
9792            "publish step should record one bind"
9793        );
9794        assert!(
9795            s1.applied_binds[0].changed,
9796            "first run flips the placeholder"
9797        );
9798        assert_eq!(s1.applied_binds[0].new, HASH_A);
9799
9800        // Idempotent: a re-run sees the same hash, writes nothing, but
9801        // still records the AppliedBind entry with changed=false.
9802        let mut step1b = shell_step(
9803            "publish",
9804            vec![
9805                "sh",
9806                "-c",
9807                &format!("echo discovered=\"{HASH_A}\" >> \"$YAH_OUTPUTS\""),
9808            ],
9809        );
9810        step1b.outputs = vec![crate::types::OutputDecl {
9811            name: "discovered".into(),
9812            description: None,
9813            kind: manifest_bind::ValueType::Blake3Hex,
9814            validate: None,
9815        }];
9816        let step2b = shell_step(
9817            "consume",
9818            vec!["sh", "-c", &format!("grep -q '{HASH_A}' workload.toml")],
9819        );
9820        let mut pipeline2 = make_pipeline("publish-then-consume", vec![step1b, step2b]);
9821        pipeline2.binds = vec![manifest_bind::BindSpec {
9822            file: "workload.toml".into(),
9823            path: "blake3".into(),
9824            from: manifest_bind::OutputRef::parse("publish.outputs.discovered").unwrap(),
9825            intent: manifest_bind::Intent::Keyword(manifest_bind::IntentKeyword::Latest),
9826            cross_workspace: false,
9827            schema: None,
9828        }];
9829        let meta2 = PipelineRunner::new(pipeline2)
9830            .with_camp_root(workspace.path().to_path_buf())
9831            .run()
9832            .await
9833            .unwrap();
9834        let s1b = meta2.steps.iter().find(|s| s.name == "publish").unwrap();
9835        assert_eq!(s1b.applied_binds.len(), 1);
9836        assert!(!s1b.applied_binds[0].changed, "re-run is a no-op on disk");
9837    }
9838
9839    /// W209 F3: a failed step does NOT fire its binds. The source tree is
9840    /// the ledger; partial states are only written for steps that
9841    /// succeeded.
9842    #[tokio::test]
9843    async fn pipeline_bind_skipped_when_producing_step_fails() {
9844        const HASH_A: &str = "fb0afc9f3d966f5347c6dfd335adab12f1dc8ee6df18cf9e9ff90fe86f0416c0";
9845        let workspace = TempDir::new().unwrap();
9846        let manifest_path = workspace.path().join("workload.toml");
9847        std::fs::write(
9848            &manifest_path,
9849            "name = \"whisper\"\nblake3 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n",
9850        )
9851        .unwrap();
9852        let before = std::fs::read_to_string(&manifest_path).unwrap();
9853
9854        // Step writes the output line THEN exits non-zero. Output is
9855        // collected, but apply_binds must be gated on success.
9856        let mut step1 = shell_step(
9857            "publish",
9858            vec![
9859                "sh",
9860                "-c",
9861                &format!("echo discovered=\"{HASH_A}\" >> \"$YAH_OUTPUTS\"; exit 1"),
9862            ],
9863        );
9864        step1.outputs = vec![crate::types::OutputDecl {
9865            name: "discovered".into(),
9866            description: None,
9867            kind: manifest_bind::ValueType::Blake3Hex,
9868            validate: None,
9869        }];
9870
9871        let mut pipeline = make_pipeline("publish-fails", vec![step1]);
9872        pipeline.binds = vec![manifest_bind::BindSpec {
9873            file: "workload.toml".into(),
9874            path: "blake3".into(),
9875            from: manifest_bind::OutputRef::parse("publish.outputs.discovered").unwrap(),
9876            intent: manifest_bind::Intent::Keyword(manifest_bind::IntentKeyword::Latest),
9877            cross_workspace: false,
9878            schema: None,
9879        }];
9880
9881        let meta = PipelineRunner::new(pipeline)
9882            .with_camp_root(workspace.path().to_path_buf())
9883            .run()
9884            .await
9885            .unwrap();
9886        assert_eq!(meta.status, RunStatus::Failed);
9887        let s = meta.steps.iter().find(|s| s.name == "publish").unwrap();
9888        assert!(
9889            s.applied_binds.is_empty(),
9890            "failed step must not fire binds"
9891        );
9892        // Manifest on disk is untouched.
9893        assert_eq!(std::fs::read_to_string(&manifest_path).unwrap(), before);
9894    }
9895
9896    /// W209/R510-F6: a `[[on_change]]` journal hook fires exactly once when a
9897    /// bind changes the manifest, and zero times when a re-run rewrites the
9898    /// same value (no-op). This is the doc's hash-change-hook verification
9899    /// criterion driven end-to-end through the runner.
9900    #[tokio::test]
9901    async fn on_change_journal_fires_once_on_change_zero_on_noop() {
9902        const HASH_A: &str = "fb0afc9f3d966f5347c6dfd335adab12f1dc8ee6df18cf9e9ff90fe86f0416c0";
9903        let workspace = TempDir::new().unwrap();
9904        let manifest_path = workspace.path().join("workload.toml");
9905        std::fs::write(
9906            &manifest_path,
9907            "name = \"whisper\"\nblake3 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n",
9908        )
9909        .unwrap();
9910        let journal_rel = ".yah/qed/whisper.journal";
9911
9912        let build_pipeline = || {
9913            let mut step1 = shell_step(
9914                "publish",
9915                vec![
9916                    "sh",
9917                    "-c",
9918                    &format!("echo discovered=\"{HASH_A}\" >> \"$YAH_OUTPUTS\""),
9919                ],
9920            );
9921            step1.outputs = vec![crate::types::OutputDecl {
9922                name: "discovered".into(),
9923                description: None,
9924                kind: manifest_bind::ValueType::Blake3Hex,
9925                validate: None,
9926            }];
9927            let mut pipeline = make_pipeline("publish-with-hook", vec![step1]);
9928            pipeline.binds = vec![manifest_bind::BindSpec {
9929                file: "workload.toml".into(),
9930                path: "blake3".into(),
9931                from: manifest_bind::OutputRef::parse("publish.outputs.discovered").unwrap(),
9932                intent: manifest_bind::Intent::Keyword(manifest_bind::IntentKeyword::Latest),
9933                cross_workspace: false,
9934                schema: None,
9935            }];
9936            pipeline.on_change = vec![manifest_bind::OnChangeHook {
9937                bind: "blake3".into(),
9938                action: manifest_bind::OnChangeAction::Journal {
9939                    journal: journal_rel.into(),
9940                },
9941            }];
9942            pipeline
9943        };
9944
9945        // First run: the zero-sentinel flips to HASH_A → bind changed → hook fires.
9946        let meta = PipelineRunner::new(build_pipeline())
9947            .with_camp_root(workspace.path().to_path_buf())
9948            .run()
9949            .await
9950            .unwrap();
9951        assert_eq!(meta.status, RunStatus::Success);
9952        let journal_abs = workspace.path().join(journal_rel);
9953        let after_first = std::fs::read_to_string(&journal_abs).unwrap();
9954        assert_eq!(
9955            after_first.lines().count(),
9956            1,
9957            "hook fires once on real change"
9958        );
9959        assert!(
9960            after_first.contains(HASH_A),
9961            "journal records the new value"
9962        );
9963
9964        // Second run: same hash → no-op rewrite → hook must NOT fire again.
9965        let meta2 = PipelineRunner::new(build_pipeline())
9966            .with_camp_root(workspace.path().to_path_buf())
9967            .run()
9968            .await
9969            .unwrap();
9970        assert_eq!(meta2.status, RunStatus::Success);
9971        let after_second = std::fs::read_to_string(&journal_abs).unwrap();
9972        assert_eq!(
9973            after_second.lines().count(),
9974            1,
9975            "no-op rewrite must not append a second journal line",
9976        );
9977    }
9978
9979    /// W212/R518-P4: early cutoff is **value-equality based**, not run-count
9980    /// based. When a step (re)produces output byte-identical to what the
9981    /// manifest already holds — even on the *first* run — the bind is
9982    /// `changed = false`, so the on_change hook never fires. This is the
9983    /// Bazel/Nix property: a rebuild whose output didn't change does not
9984    /// propagate downstream, regardless of why the rebuild ran.
9985    #[tokio::test]
9986    async fn on_change_early_cutoff_when_output_already_matches() {
9987        const HASH_A: &str = "fb0afc9f3d966f5347c6dfd335adab12f1dc8ee6df18cf9e9ff90fe86f0416c0";
9988        let workspace = TempDir::new().unwrap();
9989        let manifest_path = workspace.path().join("workload.toml");
9990        // Manifest ALREADY holds HASH_A — no prior run, no sentinel.
9991        std::fs::write(
9992            &manifest_path,
9993            format!("name = \"whisper\"\nblake3 = \"{HASH_A}\"\n"),
9994        )
9995        .unwrap();
9996        let journal_rel = ".yah/qed/whisper.journal";
9997
9998        let mut step1 = shell_step(
9999            "publish",
10000            vec![
10001                "sh",
10002                "-c",
10003                &format!("echo discovered=\"{HASH_A}\" >> \"$YAH_OUTPUTS\""),
10004            ],
10005        );
10006        step1.outputs = vec![crate::types::OutputDecl {
10007            name: "discovered".into(),
10008            description: None,
10009            kind: manifest_bind::ValueType::Blake3Hex,
10010            validate: None,
10011        }];
10012        let mut pipeline = make_pipeline("publish-noop", vec![step1]);
10013        pipeline.binds = vec![manifest_bind::BindSpec {
10014            file: "workload.toml".into(),
10015            path: "blake3".into(),
10016            from: manifest_bind::OutputRef::parse("publish.outputs.discovered").unwrap(),
10017            intent: manifest_bind::Intent::Keyword(manifest_bind::IntentKeyword::Latest),
10018            cross_workspace: false,
10019            schema: None,
10020        }];
10021        pipeline.on_change = vec![manifest_bind::OnChangeHook {
10022            bind: "blake3".into(),
10023            action: manifest_bind::OnChangeAction::Journal {
10024                journal: journal_rel.into(),
10025            },
10026        }];
10027
10028        let meta = PipelineRunner::new(pipeline)
10029            .with_camp_root(workspace.path().to_path_buf())
10030            .run()
10031            .await
10032            .unwrap();
10033        assert_eq!(meta.status, RunStatus::Success);
10034
10035        // The predicate accepted the value, but the bytes already matched →
10036        // changed=false → no hook fired → no journal file at all.
10037        let s = meta.steps.iter().find(|s| s.name == "publish").unwrap();
10038        assert!(
10039            s.applied_binds.iter().all(|b| !b.changed),
10040            "bind to an already-matching value must be changed=false",
10041        );
10042        assert!(
10043            !workspace.path().join(journal_rel).exists(),
10044            "early cutoff: an unchanged output must not fire the on_change hook",
10045        );
10046    }
10047
10048    /// SubPipeline step with propagate.outputs propagates named child outputs
10049    /// to the parent step context so subsequent sibling steps can reference
10050    /// `${{ steps.<child-step-name>.outputs.<key> }}`.
10051    #[tokio::test]
10052    async fn sub_pipeline_propagates_named_outputs_to_parent_context() {
10053        // Inner child pipeline: one step that writes "result=42" to $YAH_OUTPUTS.
10054        let child_step = shell_step(
10055            "inner",
10056            vec!["sh", "-c", "echo result=42 >> \"$YAH_OUTPUTS\""],
10057        );
10058        let child = make_pipeline("child", vec![child_step]);
10059
10060        // SubPipeline step propagates the "result" output.
10061        let mut sub = sub_step("compose", SubPipelineRef::Builtin("child".into()), false);
10062        if let Some(cfg) = sub.sub_pipeline.as_mut() {
10063            cfg.propagate.outputs = vec!["result".to_string()];
10064        }
10065
10066        // A sibling step after the SubPipeline step references the propagated output.
10067        let sibling = shell_step(
10068            "check",
10069            vec![
10070                "sh",
10071                "-c",
10072                "test \"$1\" = 42",
10073                "--",
10074                "${{ steps.compose.outputs.result }}",
10075            ],
10076        );
10077
10078        let root = make_pipeline("root", vec![sub, sibling]);
10079        let mut map = std::collections::HashMap::new();
10080        map.insert("builtin:child".to_string(), child);
10081        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
10082        let runner = PipelineRunner::new(root).with_sub_pipeline_resolver(resolver);
10083        let meta = runner.run().await.unwrap();
10084        assert_eq!(
10085            meta.status,
10086            RunStatus::Success,
10087            "sibling should receive child output via parent step context"
10088        );
10089        let compose = meta.steps.iter().find(|s| s.name == "compose").unwrap();
10090        assert_eq!(
10091            compose.outputs.get("result").map(|s| s.as_str()),
10092            Some("42"),
10093            "SubPipeline step status should carry propagated outputs"
10094        );
10095    }
10096
10097    // ---------- R488-F5: event-stream wiring for sub-pipelines ----------
10098
10099    #[tokio::test]
10100    async fn sub_pipeline_emits_started_finished_bookends_with_child_run_id() {
10101        // Parent has two SubPipeline steps, each invoking a distinct child.
10102        // Assert: each parent SubPipeline step is wrapped by
10103        // SubPipelineStarted{child_run_id=X} ... SubPipelineFinished{child_run_id=X, status=Success}.
10104        // The child's own RunStarted/Step*/RunFinished events do NOT leak
10105        // onto the parent's stream (the child sink is decoupled).
10106        let child_a = make_pipeline("child-a", vec![shell_step("ok", vec!["true"])]);
10107        let child_b = make_pipeline("child-b", vec![shell_step("ok", vec!["true"])]);
10108        let root = make_pipeline(
10109            "root",
10110            vec![
10111                sub_step(
10112                    "compose-a",
10113                    SubPipelineRef::Builtin("child-a".into()),
10114                    false,
10115                ),
10116                sub_step(
10117                    "compose-b",
10118                    SubPipelineRef::Path(".yah/qed/child-b.toml".into()),
10119                    false,
10120                ),
10121            ],
10122        );
10123        let mut map = std::collections::HashMap::new();
10124        map.insert("builtin:child-a".to_string(), child_a);
10125        map.insert("path:.yah/qed/child-b.toml".to_string(), child_b);
10126        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
10127
10128        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
10129        let runner = PipelineRunner::new(root)
10130            .with_events(tx)
10131            .with_sub_pipeline_resolver(resolver);
10132        let parent_run_id = runner.run_id().to_string();
10133        let meta = runner.run().await.unwrap();
10134        assert_eq!(meta.status, RunStatus::Success);
10135        assert!(meta.parent_run_id.is_none(), "top-level run has no parent");
10136
10137        let mut events = Vec::new();
10138        while let Ok(e) = rx.try_recv() {
10139            events.push(e);
10140        }
10141
10142        let starts: Vec<_> = events
10143            .iter()
10144            .filter_map(|e| match e {
10145                QedEvent::SubPipelineStarted {
10146                    name,
10147                    target,
10148                    child_run_id,
10149                    ..
10150                } => Some((name.clone(), target.clone(), child_run_id.clone())),
10151                _ => None,
10152            })
10153            .collect();
10154        let finishes: Vec<_> = events
10155            .iter()
10156            .filter_map(|e| match e {
10157                QedEvent::SubPipelineFinished {
10158                    name,
10159                    child_run_id,
10160                    status,
10161                    ..
10162                } => Some((name.clone(), child_run_id.clone(), *status)),
10163                _ => None,
10164            })
10165            .collect();
10166
10167        assert_eq!(starts.len(), 2, "two SubPipelineStarted events");
10168        assert_eq!(finishes.len(), 2, "two SubPipelineFinished events");
10169
10170        assert_eq!(starts[0].0, "compose-a");
10171        assert_eq!(starts[0].1, "builtin:child-a");
10172        assert_eq!(starts[1].0, "compose-b");
10173        assert_eq!(starts[1].1, "path:.yah/qed/child-b.toml");
10174
10175        // Each finish pairs with the same step + child_run_id as its start,
10176        // and both children terminated Success.
10177        for (start, finish) in starts.iter().zip(finishes.iter()) {
10178            assert_eq!(start.0, finish.0, "start/finish name match");
10179            assert_eq!(start.2, finish.1, "start/finish child_run_id match");
10180            assert_eq!(finish.2, RunStatus::Success);
10181            assert_ne!(start.2, parent_run_id, "child run_id distinct from parent");
10182        }
10183
10184        // Child events DO NOT leak onto the parent's stream: zero RunStarted
10185        // events for the children (only the parent's own RunStarted).
10186        let run_started_count = events
10187            .iter()
10188            .filter(|e| matches!(e, QedEvent::RunStarted { .. }))
10189            .count();
10190        assert_eq!(
10191            run_started_count, 1,
10192            "only parent's RunStarted on the parent stream"
10193        );
10194    }
10195
10196    #[tokio::test]
10197    async fn sub_pipeline_finished_emits_failed_status_when_child_fails() {
10198        let child = make_pipeline("child", vec![shell_step("boom", vec!["false"])]);
10199        let root = make_pipeline(
10200            "root",
10201            vec![sub_step(
10202                "compose",
10203                SubPipelineRef::Builtin("child".into()),
10204                false,
10205            )],
10206        );
10207        let mut map = std::collections::HashMap::new();
10208        map.insert("builtin:child".to_string(), child);
10209        let resolver: Arc<dyn SubPipelineResolver + Send + Sync> = Arc::new(MapResolver(map));
10210
10211        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
10212        let runner = PipelineRunner::new(root)
10213            .with_events(tx)
10214            .with_sub_pipeline_resolver(resolver);
10215        let meta = runner.run().await.unwrap();
10216        assert_eq!(meta.status, RunStatus::Failed);
10217
10218        let mut finished = None;
10219        while let Ok(e) = rx.try_recv() {
10220            if let QedEvent::SubPipelineFinished { status, .. } = e {
10221                finished = Some(status);
10222            }
10223        }
10224        assert_eq!(
10225            finished,
10226            Some(RunStatus::Failed),
10227            "child failure surfaces on SubPipelineFinished.status"
10228        );
10229    }
10230
10231    // ── R506 step gating tests ────────────────────────────────────────────
10232
10233    fn gating_step(name: &str) -> crate::types::QedStep {
10234        crate::types::QedStep {
10235            background: false,
10236            background_until: None,
10237            wait_for: None,
10238            manifest_stitch: None,
10239            name: name.to_string(),
10240            // echo always succeeds — distinguishes "ran" from "skipped" by
10241            // looking at the terminal status, not by relying on a failure.
10242            argv: vec!["echo".into(), "ran".into()],
10243            cwd: None,
10244            env: HashMap::new(),
10245            timeout: None,
10246            on_fail: OnFail::Abort,
10247            produces: Vec::new(),
10248            runtime: None,
10249            kind: crate::types::StepKind::Subprocess,
10250            image: None,
10251            tag: None,
10252            push: false,
10253            platforms: Vec::new(),
10254            binary_path: None,
10255            triple: None,
10256            package: None,
10257            context: None,
10258            load: false,
10259            sub_pipeline: None,
10260            outputs: Vec::new(),
10261            gha_workflow: None,
10262            import: None,
10263            matrix: None,
10264            enabled: true,
10265            activation: crate::types::StepActivation::Active,
10266            if_cond: None,
10267            platform: None,
10268            toolchain: None,
10269        }
10270    }
10271
10272    #[tokio::test]
10273    async fn r506_enabled_false_step_is_skipped() {
10274        let mut s = gating_step("disabled");
10275        s.enabled = false;
10276        let pipeline = Pipeline {
10277            name: "p".into(),
10278            label: "p".into(),
10279            steps: vec![s],
10280            params: HashMap::new(),
10281            on_success: vec![],
10282            on_fail: vec![],
10283            triggers: vec![],
10284            concurrency_key: None,
10285            placement: crate::types::Placement::Anywhere,
10286            workspace: crate::types::WorkspaceMode::Live,
10287            wraps: None,
10288            matrix: None,
10289            toolchain: None,
10290            binds: Vec::new(),
10291            on_change: Vec::new(),
10292            finally: Vec::new(),
10293        };
10294        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
10295        assert_eq!(
10296            meta.status,
10297            RunStatus::Success,
10298            "skipped step doesn't fail the run"
10299        );
10300        assert_eq!(meta.steps[0].status, RunStatus::Skipped);
10301    }
10302
10303    #[tokio::test]
10304    async fn r506_stubbed_step_is_skipped_by_default() {
10305        let mut s = gating_step("stubbed");
10306        s.activation = crate::types::StepActivation::Stubbed;
10307        let pipeline = Pipeline {
10308            name: "p".into(),
10309            label: "p".into(),
10310            steps: vec![s],
10311            params: HashMap::new(),
10312            on_success: vec![],
10313            on_fail: vec![],
10314            triggers: vec![],
10315            concurrency_key: None,
10316            placement: crate::types::Placement::Anywhere,
10317            workspace: crate::types::WorkspaceMode::Live,
10318            wraps: None,
10319            matrix: None,
10320            toolchain: None,
10321            binds: Vec::new(),
10322            on_change: Vec::new(),
10323            finally: Vec::new(),
10324        };
10325        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
10326        assert_eq!(meta.steps[0].status, RunStatus::Skipped);
10327    }
10328
10329    #[tokio::test]
10330    async fn r506_include_stubbed_overrides_stubbed_marker() {
10331        let mut s = gating_step("stubbed");
10332        s.activation = crate::types::StepActivation::Stubbed;
10333        let pipeline = Pipeline {
10334            name: "p".into(),
10335            label: "p".into(),
10336            steps: vec![s],
10337            params: HashMap::new(),
10338            on_success: vec![],
10339            on_fail: vec![],
10340            triggers: vec![],
10341            concurrency_key: None,
10342            placement: crate::types::Placement::Anywhere,
10343            workspace: crate::types::WorkspaceMode::Live,
10344            wraps: None,
10345            matrix: None,
10346            toolchain: None,
10347            binds: Vec::new(),
10348            on_change: Vec::new(),
10349            finally: Vec::new(),
10350        };
10351        let meta = PipelineRunner::new(pipeline)
10352            .with_include_stubbed(true)
10353            .run()
10354            .await
10355            .unwrap();
10356        assert_eq!(
10357            meta.steps[0].status,
10358            RunStatus::Success,
10359            "--include-stubbed runs a stubbed step like an active one"
10360        );
10361    }
10362
10363    #[tokio::test]
10364    async fn r506_include_stubbed_does_not_override_enabled_false() {
10365        let mut s = gating_step("disabled");
10366        s.enabled = false;
10367        s.activation = crate::types::StepActivation::Stubbed; // both knobs set
10368        let pipeline = Pipeline {
10369            name: "p".into(),
10370            label: "p".into(),
10371            steps: vec![s],
10372            params: HashMap::new(),
10373            on_success: vec![],
10374            on_fail: vec![],
10375            triggers: vec![],
10376            concurrency_key: None,
10377            placement: crate::types::Placement::Anywhere,
10378            workspace: crate::types::WorkspaceMode::Live,
10379            wraps: None,
10380            matrix: None,
10381            toolchain: None,
10382            binds: Vec::new(),
10383            on_change: Vec::new(),
10384            finally: Vec::new(),
10385        };
10386        let meta = PipelineRunner::new(pipeline)
10387            .with_include_stubbed(true)
10388            .run()
10389            .await
10390            .unwrap();
10391        assert_eq!(
10392            meta.steps[0].status,
10393            RunStatus::Skipped,
10394            "enabled = false always wins over --include-stubbed"
10395        );
10396    }
10397
10398    #[tokio::test]
10399    async fn r506_if_falsy_skips_step() {
10400        let mut s = gating_step("conditional");
10401        s.if_cond = Some("matrix.target == 'ios-device'".into());
10402        let pipeline = Pipeline {
10403            name: "p".into(),
10404            label: "p".into(),
10405            steps: vec![s],
10406            params: HashMap::new(),
10407            on_success: vec![],
10408            on_fail: vec![],
10409            triggers: vec![],
10410            concurrency_key: None,
10411            placement: crate::types::Placement::Anywhere,
10412            workspace: crate::types::WorkspaceMode::Live,
10413            wraps: None,
10414            matrix: None,
10415            toolchain: None,
10416            binds: Vec::new(),
10417            on_change: Vec::new(),
10418            finally: Vec::new(),
10419        };
10420        let mut coord = indexmap::IndexMap::new();
10421        coord.insert(
10422            "target".to_string(),
10423            toml::Value::String("macos-native".into()),
10424        );
10425        let meta = PipelineRunner::new(pipeline)
10426            .with_matrix_coord(coord)
10427            .run()
10428            .await
10429            .unwrap();
10430        assert_eq!(meta.steps[0].status, RunStatus::Skipped);
10431    }
10432
10433    #[tokio::test]
10434    async fn r506_if_truthy_runs_step() {
10435        let mut s = gating_step("conditional");
10436        s.if_cond = Some("matrix.target == 'ios-device'".into());
10437        let pipeline = Pipeline {
10438            name: "p".into(),
10439            label: "p".into(),
10440            steps: vec![s],
10441            params: HashMap::new(),
10442            on_success: vec![],
10443            on_fail: vec![],
10444            triggers: vec![],
10445            concurrency_key: None,
10446            placement: crate::types::Placement::Anywhere,
10447            workspace: crate::types::WorkspaceMode::Live,
10448            wraps: None,
10449            matrix: None,
10450            toolchain: None,
10451            binds: Vec::new(),
10452            on_change: Vec::new(),
10453            finally: Vec::new(),
10454        };
10455        let mut coord = indexmap::IndexMap::new();
10456        coord.insert(
10457            "target".to_string(),
10458            toml::Value::String("ios-device".into()),
10459        );
10460        let meta = PipelineRunner::new(pipeline)
10461            .with_matrix_coord(coord)
10462            .run()
10463            .await
10464            .unwrap();
10465        assert_eq!(meta.steps[0].status, RunStatus::Success);
10466    }
10467
10468    #[tokio::test]
10469    async fn r506_if_with_expression_delimiters_strips_braces() {
10470        let mut s = gating_step("conditional");
10471        s.if_cond = Some("${{ matrix.target == 'ios-device' }}".into());
10472        let pipeline = Pipeline {
10473            name: "p".into(),
10474            label: "p".into(),
10475            steps: vec![s],
10476            params: HashMap::new(),
10477            on_success: vec![],
10478            on_fail: vec![],
10479            triggers: vec![],
10480            concurrency_key: None,
10481            placement: crate::types::Placement::Anywhere,
10482            workspace: crate::types::WorkspaceMode::Live,
10483            wraps: None,
10484            matrix: None,
10485            toolchain: None,
10486            binds: Vec::new(),
10487            on_change: Vec::new(),
10488            finally: Vec::new(),
10489        };
10490        let mut coord = indexmap::IndexMap::new();
10491        coord.insert(
10492            "target".to_string(),
10493            toml::Value::String("ios-device".into()),
10494        );
10495        let meta = PipelineRunner::new(pipeline)
10496            .with_matrix_coord(coord)
10497            .run()
10498            .await
10499            .unwrap();
10500        assert_eq!(meta.steps[0].status, RunStatus::Success);
10501    }
10502
10503    // ── R506 phase 2: success()/failure()/always()/cancelled() ────────────
10504    //
10505    // The runner tracks the cumulative `overall_status` mid-run and feeds it
10506    // into the expr context as `job_status` so an `if=` can ask "did anything
10507    // fail above me?". `cancelled()` is always false from inside a step gate
10508    // because cancellation aborts the whole future, never reaches the next
10509    // step (matches GHA semantics).
10510
10511    fn failing_step(name: &str) -> crate::types::QedStep {
10512        let mut s = gating_step(name);
10513        // `false` exits non-zero on every Unix host — simplest deterministic
10514        // failure that doesn't depend on a missing binary.
10515        s.argv = vec!["false".into()];
10516        s.on_fail = OnFail::Continue;
10517        s
10518    }
10519
10520    #[tokio::test]
10521    async fn r506_if_always_runs_after_failure() {
10522        let mut gated = gating_step("cleanup");
10523        gated.if_cond = Some("always()".into());
10524        let pipeline = Pipeline {
10525            name: "p".into(),
10526            label: "p".into(),
10527            steps: vec![failing_step("bad"), gated],
10528            params: HashMap::new(),
10529            on_success: vec![],
10530            on_fail: vec![],
10531            triggers: vec![],
10532            concurrency_key: None,
10533            placement: crate::types::Placement::Anywhere,
10534            workspace: crate::types::WorkspaceMode::Live,
10535            wraps: None,
10536            matrix: None,
10537            toolchain: None,
10538            binds: Vec::new(),
10539            on_change: Vec::new(),
10540            finally: Vec::new(),
10541        };
10542        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
10543        assert_eq!(meta.steps[0].status, RunStatus::Failed);
10544        assert_eq!(
10545            meta.steps[1].status,
10546            RunStatus::Success,
10547            "always() runs even after a prior failure"
10548        );
10549    }
10550
10551    #[tokio::test]
10552    async fn r506_if_failure_runs_only_after_failure() {
10553        let mut gated = gating_step("only-on-fail");
10554        gated.if_cond = Some("failure()".into());
10555        let pipeline = Pipeline {
10556            name: "p".into(),
10557            label: "p".into(),
10558            steps: vec![failing_step("bad"), gated],
10559            params: HashMap::new(),
10560            on_success: vec![],
10561            on_fail: vec![],
10562            triggers: vec![],
10563            concurrency_key: None,
10564            placement: crate::types::Placement::Anywhere,
10565            workspace: crate::types::WorkspaceMode::Live,
10566            wraps: None,
10567            matrix: None,
10568            toolchain: None,
10569            binds: Vec::new(),
10570            on_change: Vec::new(),
10571            finally: Vec::new(),
10572        };
10573        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
10574        assert_eq!(meta.steps[1].status, RunStatus::Success);
10575    }
10576
10577    #[tokio::test]
10578    async fn r506_if_failure_skips_when_all_green() {
10579        let mut gated = gating_step("only-on-fail");
10580        gated.if_cond = Some("failure()".into());
10581        let pipeline = Pipeline {
10582            name: "p".into(),
10583            label: "p".into(),
10584            steps: vec![gating_step("ok"), gated],
10585            params: HashMap::new(),
10586            on_success: vec![],
10587            on_fail: vec![],
10588            triggers: vec![],
10589            concurrency_key: None,
10590            placement: crate::types::Placement::Anywhere,
10591            workspace: crate::types::WorkspaceMode::Live,
10592            wraps: None,
10593            matrix: None,
10594            toolchain: None,
10595            binds: Vec::new(),
10596            on_change: Vec::new(),
10597            finally: Vec::new(),
10598        };
10599        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
10600        assert_eq!(meta.steps[1].status, RunStatus::Skipped);
10601    }
10602
10603    #[tokio::test]
10604    async fn r506_if_success_skips_after_failure() {
10605        let mut gated = gating_step("only-on-success");
10606        gated.if_cond = Some("success()".into());
10607        let pipeline = Pipeline {
10608            name: "p".into(),
10609            label: "p".into(),
10610            steps: vec![failing_step("bad"), gated],
10611            params: HashMap::new(),
10612            on_success: vec![],
10613            on_fail: vec![],
10614            triggers: vec![],
10615            concurrency_key: None,
10616            placement: crate::types::Placement::Anywhere,
10617            workspace: crate::types::WorkspaceMode::Live,
10618            wraps: None,
10619            matrix: None,
10620            toolchain: None,
10621            binds: Vec::new(),
10622            on_change: Vec::new(),
10623            finally: Vec::new(),
10624        };
10625        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
10626        assert_eq!(meta.steps[1].status, RunStatus::Skipped);
10627    }
10628
10629    #[tokio::test]
10630    async fn r506_if_cancelled_is_always_false_mid_run() {
10631        let mut gated = gating_step("on-cancel");
10632        gated.if_cond = Some("cancelled()".into());
10633        let pipeline = Pipeline {
10634            name: "p".into(),
10635            label: "p".into(),
10636            steps: vec![gated],
10637            params: HashMap::new(),
10638            on_success: vec![],
10639            on_fail: vec![],
10640            triggers: vec![],
10641            concurrency_key: None,
10642            placement: crate::types::Placement::Anywhere,
10643            workspace: crate::types::WorkspaceMode::Live,
10644            wraps: None,
10645            matrix: None,
10646            toolchain: None,
10647            binds: Vec::new(),
10648            on_change: Vec::new(),
10649            finally: Vec::new(),
10650        };
10651        let meta = PipelineRunner::new(pipeline).run().await.unwrap();
10652        assert_eq!(
10653            meta.steps[0].status,
10654            RunStatus::Skipped,
10655            "cancelled() is unreachable from inside an if= gate; always evaluates false"
10656        );
10657    }
10658}