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}