Skip to main content

haz_exec/
run_task.rs

1//! Single-task lifecycle: cache lookup, hit-path restoration, or
2//! miss-path spawn-and-capture with success-only store.
3//!
4//! [`run_task`] is the async entry point. It does no scheduling, no
5//! cap accounting, no mutex acquisition, no cancellation, and emits
6//! no presentation-layer bytes itself: captured streams flow to the
7//! caller through the [`RunObserver`] trait. Concrete observer
8//! implementations distinguish the two `EXEC-016` modes: `live`
9//! (per-line `[project:task] `-tag-prefixed writes to a shared
10//! parent sink) and `buffered` (one contiguous block per stream on
11//! task completion).
12//!
13//! The function composes the cache-key derivation
14//! ([`haz_cache_key::build_cache_key`]) with the
15//! [`crate::process::ProcessSpawner`] trait
16//! into the executor's smallest
17//! end-to-end unit. A scheduler layer combines many [`run_task`]
18//! calls with the workspace's concurrency caps and mutex hold-set
19//! (`EXEC-004..007`).
20//!
21//! # Spec coverage
22//!
23//! - `EXEC-007` step 1 -- key derivation and lookup precede every
24//!   other concern. Mutex acquisition is not implemented by this
25//!   function; that responsibility belongs to a scheduler layer.
26//! - `EXEC-007` step 2 -- cache-hit restoration runs mutex-free.
27//! - `EXEC-009` -- terminal classification: zero exit is
28//!   [`RunState::Succeeded`]; non-zero or signalled is
29//!   [`RunState::Failed`]. A cache hit is [`RunState::Succeeded`].
30//!   The cancelled state of `EXEC-009`'s third option is not
31//!   reachable from this function: cancellation is not implemented
32//!   here.
33//! - `EXEC-016` -- the full `stdout` and `stderr` byte streams of
34//!   every fresh run are captured (the cache stores them per
35//!   `CACHE-012`) and surfaced through [`RunObserver`].
36//! - `EXEC-017` -- on a cache hit, the recorded `stdout` and
37//!   `stderr` flow through [`RunObserver`] with the same
38//!   mode-agnostic signal as a fresh run.
39//! - `CACHE-008` (runtime) -- the spawned process sees only the
40//!   effective env: allow-listed host values plus task-level
41//!   overrides, override wins on collision. The spawn-plan
42//!   builder assembles that env vector; the std backend's
43//!   `env_clear()` ensures the child sees this set and nothing
44//!   else.
45//! - `CACHE-014..016` -- [`haz_cache::CacheReader::lookup`] folds every
46//!   failure into a clean miss, so no [`RunTaskError`] variant
47//!   covers it.
48//! - `CACHE-017..018` -- the cache store fires only when the fresh
49//!   run's exit status was zero. The store call itself surfaces
50//!   as [`RunTaskError::StoreFailed`].
51//! - `CACHE-019` -- restoration uses [`haz_cache::CacheWriter::restore`],
52//!   surfacing [`haz_cache::RestoredStreams`] through the observer.
53//!
54//! # Out of scope for this function
55//!
56//! - Concurrency caps (`EXEC-004..006`) and the canonical-order
57//!   tie-breaking of `EXEC-003`: scheduler-level concerns.
58//! - Mutex acquisition (`EXEC-007` step 3) and `EXEC-006`
59//!   condition 3: scheduler-level concerns.
60//! - Failure cascade and the `skipped` state (`EXEC-009..011`):
61//!   scheduler-level concerns.
62//! - Cancellation (`EXEC-012..015`): not implemented in this
63//!   function.
64//! - Live-mode per-line tag prefixing and atomic sink writes
65//!   (`EXEC-016` presentation): implemented inside concrete
66//!   [`RunObserver`] types.
67//! - Runtime cycle (`EXEC-019`) and output-overlap (`EXEC-020`)
68//!   detection: scheduler-level concerns.
69
70use std::collections::BTreeMap;
71use std::ffi::OsString;
72use std::io;
73use std::path::{Path, PathBuf};
74
75use snafu::{ResultExt, Snafu};
76use tokio::io::AsyncReadExt;
77use tokio_util::sync::CancellationToken;
78
79use haz_cache::{CacheWriter, Hasher, RestoreError, StoreError, StoreInputs, StoredOutput};
80use haz_dag::graph::TaskGraph;
81use haz_domain::action::{ShellType, TaskAction};
82use haz_domain::env::{EnvSettings, EnvVarName};
83use haz_domain::path::{CanonicalPath, OutputSpec, ParseAbsoluteError, PathPattern, ProjectRoot};
84use haz_domain::project::Project;
85use haz_domain::settings::cache::HashAlgo;
86use haz_domain::task::Task;
87use haz_domain::task_id::TaskId;
88use haz_domain::workspace::Workspace;
89use haz_vfs::{EntryKind, Filesystem, FsError, WritableFilesystem};
90
91use crate::process::{
92    ExitStatus, Process, ProcessError, ProcessSpawner, Signal, SpawnPlan, Spawned,
93};
94use haz_cache_key::pattern_walk::{
95    GlobMatchAction, GlobWalk, glob_walk_origin, host_path_from_segments,
96    literal_workspace_segments, workspace_absolute_string_from_segments,
97};
98use haz_cache_key::{BuildKeyError, PredecessorStreamHashes, build_cache_key};
99
100/// Where a [`CompletedRecord`]'s success came from.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub enum RunSource {
103    /// The cache held a valid entry for the derived key
104    /// (`CACHE-015`); restoration materialised the outputs and the
105    /// recorded streams were surfaced through the observer.
106    CacheHit,
107    /// The cache missed; the executor spawned the task's command,
108    /// captured its stdout and stderr, and (on success) stored the
109    /// result for future hits.
110    FreshRun,
111}
112
113/// Terminal classification of a single-task run per `EXEC-009`.
114///
115/// The variants exhaustively cover the spec's three terminal
116/// classifications: succeeded, failed, and cancelled-by-executor.
117/// The cancelled variant is produced only by the spawn-step
118/// future when the run-context's cancellation token fires while
119/// the child is in flight; until that wiring lands no
120/// [`run_task`] call returns [`RunState::Cancelled`].
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122pub enum RunState {
123    /// A fresh run with exit status zero, or a cache hit (a hit
124    /// asserts a prior successful run per `CACHE-018`).
125    Succeeded,
126    /// A fresh run whose command terminated with a non-zero exit
127    /// status or whose process was killed by a signal not initiated
128    /// by the executor.
129    Failed,
130    /// A fresh run whose child was signalled by the executor in
131    /// response to the run's cancellation token firing
132    /// (`EXEC-009` cancelled state, `EXEC-013` step 2). Distinct
133    /// from [`Self::Failed`] so the run summary can tell
134    /// executor-initiated termination apart from a task that
135    /// failed under its own power.
136    Cancelled,
137}
138
139/// Run-record fields produced by one [`run_task`] invocation: the
140/// observation of a task that the scheduler admitted into the
141/// lookup-then-spawn pipeline and that reached a run classification
142/// (`EXEC-009`).
143///
144/// Carries enough information for a scheduler layer to feed the
145/// per-`TaskId` `predecessor_streams` map that
146/// [`haz_cache_key::build_cache_key`] consumes: `stdout_hash` and
147/// `stderr_hash` are always present, whether the run was a cache
148/// hit (sourced from the manifest's recorded hashes per
149/// `CACHE-011`) or a fresh run (computed from the captured byte
150/// buffers under the cache's active [`HashAlgo`]).
151///
152/// A scheduler-level cascade decision produces
153/// [`RunOutcome::Skipped`] instead (`EXEC-010` / `EXEC-011`);
154/// [`run_task`] itself never produces a Skipped because skipped
155/// tasks never enter the pipeline.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct CompletedRecord {
158    /// The task this record describes.
159    pub task: TaskId,
160    /// Whether the success came from a cache hit or a fresh run.
161    pub source: RunSource,
162    /// Terminal classification per `EXEC-009`.
163    pub state: RunState,
164    /// Process exit status for a fresh run, [`None`] for a cache
165    /// hit (no process ran).
166    pub exit_status: Option<ExitStatus>,
167    /// Hash of the run's captured stdout under the cache's
168    /// [`HashAlgo`]. For [`RunSource::CacheHit`] this is read from
169    /// the manifest; for [`RunSource::FreshRun`] it is computed
170    /// from the captured byte buffer.
171    pub stdout_hash: [u8; 32],
172    /// Hash of the run's captured stderr under the cache's
173    /// [`HashAlgo`].
174    pub stderr_hash: [u8; 32],
175    /// Workspace-absolute paths the task materialised on disk.
176    ///
177    /// Populated only for [`RunState::Succeeded`]; empty for
178    /// [`RunState::Failed`] and [`RunState::Cancelled`]. The
179    /// source is the executor's output-resolution pass for a
180    /// fresh run and [`haz_cache::Manifest::outputs`] for a
181    /// cache hit.
182    ///
183    /// Consumed by the scheduler's runtime DAG validation pass
184    /// (`EXEC-019` runtime cycle detection and `EXEC-020` runtime
185    /// output-overlap detection) to derive runtime producer-
186    /// matching edges and to populate the output-claim tracker.
187    pub materialised_outputs: Vec<CanonicalPath>,
188}
189
190/// Record of a task that the scheduler cascade-skipped because an
191/// upstream task failed (`EXEC-010` / `EXEC-011`).
192///
193/// A skipped task is never started by the executor and never
194/// produces a cache entry. The `cause` field names the root
195/// failing task (NOT an intermediate cascade-skipped predecessor),
196/// so the run summary can attribute every skip to a single
197/// actually-failed task.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct SkipRecord {
200    /// The task that was skipped.
201    pub task: TaskId,
202    /// Reason the task was skipped, naming the originating
203    /// failure.
204    pub cause: SkipCause,
205}
206
207/// Reason a task was cascade-skipped per `EXEC-011`.
208///
209/// The `upstream` field on each variant carries the root failing
210/// task identity, not the immediate hard predecessor: when `A`
211/// fails and the cascade marks `B` (direct hard child) and `C`
212/// (hard child of `B`), both record `upstream = A`.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum SkipCause {
215    /// A transitively-upstream task reached
216    /// [`RunState::Failed`] (per `EXEC-009`: non-zero exit,
217    /// non-executor signal, or executor-imposed timeout).
218    UpstreamFailed {
219        /// Identity of the originally-failed upstream task.
220        upstream: TaskId,
221    },
222    /// A transitively-upstream task surfaced a [`RunTaskError`]
223    /// (executor-level failure: cache derive, spawn, wait,
224    /// stream-read, output-resolution, or store). Distinct from
225    /// [`Self::UpstreamFailed`] so the run summary can tell
226    /// "the upstream task ran and failed" from "the executor
227    /// could not even run the upstream task".
228    UpstreamErrored {
229        /// Identity of the upstream task whose [`run_task`] call
230        /// returned an [`Err`].
231        upstream: TaskId,
232    },
233    /// The task was identified as a member of a runtime cycle
234    /// detected by `EXEC-019`. The scheduler stops admitting
235    /// further tasks once the cycle is observed; cycle members
236    /// still in the ready set never start. The cycle's full node
237    /// set and the offending edge appear in the run's
238    /// [`crate::run_graph::RunGraphOutcome::invariant_violations`]
239    /// diagnostic; this per-task cause attributes the skip to
240    /// that workspace-level event without rewriting the cycle
241    /// node's outcome to a misleading "failed" classification.
242    RuntimeCycle,
243}
244
245/// Record of a task that ended in the executor-initiated
246/// cancelled state per `EXEC-009` / `EXEC-013`.
247///
248/// The three variants distinguish the three structural shapes a
249/// cancellation can take in the run-graph view:
250///
251/// - [`Self::SignaledInFlight`] is the only shape a single-task
252///   spawn-step future can produce: the cancellation token fired
253///   while the child was running, and the executor sent SIGTERM
254///   (and possibly SIGKILL after `EXEC-014`'s grace period).
255/// - [`Self::UpstreamCancelled`] is the cascade counterpart to
256///   [`SkipCause::UpstreamFailed`]: a hard descendant of a
257///   cancelled task is itself cancelled. `upstream` carries the
258///   root cancelled task, not the immediate hard predecessor
259///   (same root-cause attribution as [`SkipRecord`]).
260/// - [`Self::RunCancelled`] covers tasks that were still in
261///   `state.ready` (or whose lookup-step was in flight) when the
262///   scheduler observed the cancellation signal and drained the
263///   admission front. These tasks never made it to the spawn
264///   step; no process was started or signalled.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub enum CancelledRecord {
267    /// The task was admitted, spawned, and signalled by the
268    /// executor in response to the cancellation token firing.
269    /// `exit_status` records the child's exit (typically
270    /// signal-terminated, but a polite child may exit cleanly
271    /// after SIGTERM); the captured stream hashes are present
272    /// for diagnostic purposes even though `EXEC-015` blocks
273    /// the cache store.
274    SignaledInFlight {
275        /// The task that was signalled.
276        task: TaskId,
277        /// The child's exit status after the signal flow ran to
278        /// completion.
279        exit_status: ExitStatus,
280        /// Hash of captured stdout under the cache's
281        /// [`HashAlgo`], for diagnostic / predecessor-streams use.
282        stdout_hash: [u8; 32],
283        /// Hash of captured stderr under the cache's
284        /// [`HashAlgo`], for diagnostic / predecessor-streams use.
285        stderr_hash: [u8; 32],
286    },
287    /// The task was cascade-cancelled because a transitively-
288    /// upstream task entered the cancelled state. Mirrors
289    /// [`SkipCause::UpstreamFailed`] for the cancellation flow.
290    UpstreamCancelled {
291        /// The task that was cascade-cancelled.
292        task: TaskId,
293        /// The root cancelled task that triggered the cascade.
294        upstream: TaskId,
295    },
296    /// The task was in `state.ready` (or its lookup-step was in
297    /// flight) when the scheduler observed the cancellation
298    /// signal. No process was spawned; the task simply never
299    /// entered the spawn-step pipeline.
300    RunCancelled {
301        /// The task whose admission was drained on cancel.
302        task: TaskId,
303    },
304}
305
306impl CancelledRecord {
307    /// The task this record describes, available on every
308    /// variant.
309    #[must_use]
310    pub fn task(&self) -> &TaskId {
311        match self {
312            Self::SignaledInFlight { task, .. }
313            | Self::UpstreamCancelled { task, .. }
314            | Self::RunCancelled { task } => task,
315        }
316    }
317}
318
319/// Terminal state of one task in a [`crate::run_graph`] invocation.
320///
321/// `Completed` wraps a [`CompletedRecord`] for any task that the
322/// scheduler admitted into the lookup-then-spawn pipeline and
323/// that reached a run classification (`EXEC-009`); `Skipped`
324/// wraps a [`SkipRecord`] for any task the cascade marked
325/// do-not-schedule before admission (`EXEC-010` / `EXEC-011`);
326/// `Cancelled` wraps a [`CancelledRecord`] for any task that
327/// the cancellation flow (`EXEC-012..015`) reached, either
328/// directly (signalled in flight, drained from the ready set)
329/// or via cascade.
330///
331/// [`run_task`] itself returns the bare [`CompletedRecord`]
332/// (never wrapped in this enum) because a single-task lifecycle
333/// has no cascade to inspect.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub enum RunOutcome {
336    /// A task that ran to a [`CompletedRecord`] (succeeded or
337    /// failed per `EXEC-009`).
338    Completed(CompletedRecord),
339    /// A task the scheduler cascade-skipped before admission.
340    Skipped(SkipRecord),
341    /// A task the cancellation flow caught at any point (in
342    /// flight, on the ready set, or via cascade).
343    Cancelled(CancelledRecord),
344}
345
346impl RunOutcome {
347    /// The task this outcome describes, available on every
348    /// variant.
349    #[must_use]
350    pub fn task(&self) -> &TaskId {
351        match self {
352            Self::Completed(record) => &record.task,
353            Self::Skipped(record) => &record.task,
354            Self::Cancelled(record) => record.task(),
355        }
356    }
357}
358
359/// Observation surface for one [`run_task`] invocation.
360///
361/// The four callbacks define a mode-agnostic surface: byte chunks
362/// flow through [`RunObserver::on_stdout`] and
363/// [`RunObserver::on_stderr`] for both fresh runs and cache hits.
364/// Concrete implementations distinguish the `EXEC-016` presentation
365/// modes:
366///
367/// - A `buffered` observer collects the chunks per task and emits
368///   one contiguous block to the parent process on
369///   [`RunObserver::on_task_finished`].
370/// - A `live` observer prefixes each line with `[project:task] `
371///   and writes it to a shared sink with per-line atomicity.
372///
373/// Implementations MAY use interior mutability; the trait methods
374/// take `&self` so that one observer can serve many concurrent
375/// [`run_task`] calls (the scheduler exercises this via concurrent
376/// invocations).
377pub trait RunObserver {
378    /// Called once at the start of the run, before any cache work.
379    fn on_task_started(&self, task: &TaskId);
380
381    /// One chunk of captured stdout bytes. For a cache hit, called
382    /// exactly once with the recorded stdout in full. For a fresh
383    /// run, the call shape depends on the observer's presentation
384    /// strategy: a buffered observer typically receives a single
385    /// call with the whole captured stdout; a live observer may
386    /// receive many sub-line chunks as bytes flow from the pipe.
387    fn on_stdout(&self, task: &TaskId, bytes: &[u8]);
388
389    /// One chunk of captured stderr bytes. Same call shape as
390    /// [`Self::on_stdout`].
391    fn on_stderr(&self, task: &TaskId, bytes: &[u8]);
392
393    /// Called once at the end of the run with the run-record
394    /// classification. Fires only for tasks the scheduler
395    /// admitted into the lookup-then-spawn pipeline; cascade-
396    /// skipped tasks surface through
397    /// [`Self::on_task_skipped`] instead.
398    fn on_task_finished(&self, task: &TaskId, record: &CompletedRecord);
399
400    /// Called once at the moment the scheduler marks `task`
401    /// cascade-skipped per `EXEC-010` / `EXEC-011`. Fires before
402    /// the next admission round; NEVER paired with
403    /// [`Self::on_task_started`] or [`Self::on_task_finished`]
404    /// for the same task.
405    fn on_task_skipped(&self, task: &TaskId, record: &SkipRecord);
406
407    /// Called once at the moment the cancellation flow records a
408    /// terminal state for `task` per `EXEC-012..015`. The
409    /// [`CancelledRecord`] variant distinguishes the three
410    /// shapes: an in-flight task that was signalled by the
411    /// executor; a cascade descendant of a cancelled task; or a
412    /// task drained from `state.ready` on cancel-fire. For an
413    /// in-flight task, [`Self::on_task_started`] has already
414    /// fired and [`Self::on_task_finished`] does NOT fire; for
415    /// the other two shapes, no other lifecycle callback fires
416    /// for the same task.
417    fn on_task_cancelled(&self, task: &TaskId, record: &CancelledRecord);
418}
419
420/// Failure modes of [`run_task`].
421///
422/// The variants enumerate only the *executor-level* failures: cases
423/// where the lifecycle could not be carried through to a meaningful
424/// [`CompletedRecord`]. Task-level failures (non-zero exit, signalled
425/// exit) surface as [`Ok(CompletedRecord { state: Failed, .. })`]
426/// per `EXEC-009`, not as an [`Err`] here.
427#[derive(Debug, Snafu)]
428#[snafu(visibility(pub(crate)))]
429pub enum RunTaskError {
430    /// Cache-key derivation failed before lookup could be attempted.
431    #[snafu(display("failed to derive cache key: {source}"))]
432    BuildKeyFailed {
433        /// Originating [`BuildKeyError`].
434        source: BuildKeyError,
435    },
436
437    /// [`haz_cache::CacheWriter::restore`] failed during the cache-hit
438    /// path (`CACHE-019`). Cache misses are not errors per
439    /// `CACHE-016`, so this variant covers only the successful-
440    /// lookup-then-restore-failed shape.
441    #[snafu(display("failed to restore cache entry: {source}"))]
442    RestoreFailed {
443        /// Originating [`RestoreError`].
444        source: RestoreError,
445    },
446
447    /// The [`ProcessSpawner`] refused to start the child (executable
448    /// not found, permission denied, fork failure, etc.). Distinct
449    /// from `EXEC-009`'s failed state: that rule covers commands
450    /// that *did* run; this variant is for commands that never did.
451    #[snafu(display("failed to spawn process: {source}"))]
452    SpawnFailed {
453        /// Originating [`ProcessError`].
454        source: ProcessError,
455    },
456
457    /// [`crate::process::Process::wait`] surfaced an I/O failure
458    /// while reaping the child. Rare on healthy hosts; usually indicates
459    /// that the child was reaped out from under us by a parent
460    /// signal handler or that the OS lost the descriptor.
461    #[snafu(display("failed to wait for spawned process: {source}"))]
462    WaitFailed {
463        /// Originating [`ProcessError`].
464        source: ProcessError,
465    },
466
467    /// Reading the child's stdout or stderr pipe to EOF failed
468    /// during stream capture. The reader task that returned this
469    /// error is the one whose stream did NOT reach EOF; the other
470    /// stream's capture may or may not have completed.
471    #[snafu(display("failed to read captured stream: {source}"))]
472    CapturedStreamReadFailed {
473        /// Which of the two streams the failure came from, for
474        /// diagnostic precision (the underlying [`io::Error`] often
475        /// looks the same on both pipes).
476        stream: CapturedStream,
477        /// Underlying I/O error.
478        source: io::Error,
479    },
480
481    /// Walking the filesystem to resolve a task's `outputs`
482    /// patterns failed (output-side parallel of
483    /// [`BuildKeyError::InputPatternResolutionFailed`]). Distinct
484    /// from [`Self::OutputNotARegularFile`] and
485    /// [`Self::OutputDeclaredButNotProduced`] so the run summary
486    /// can tell "walk failed" from "the task did not honour its
487    /// declared outputs".
488    #[snafu(display(
489        "failed to resolve output patterns under: {}: {source}",
490        root.display()
491    ))]
492    OutputPatternResolutionFailed {
493        /// The absolute path being walked when the failure occurred.
494        root: PathBuf,
495        /// Underlying filesystem error.
496        source: FsError,
497    },
498
499    /// Reading the Unix permission bits of a matched output file
500    /// failed. The bytes themselves are read inside
501    /// [`haz_cache::CacheWriter::store`]; this variant covers only the
502    /// mode lookup the executor performs to build
503    /// [`haz_cache::StoredOutput`].
504    #[snafu(display("failed to read mode of output file: {}: {source}", path.display()))]
505    OutputModeReadFailed {
506        /// The output file whose mode could not be read.
507        path: PathBuf,
508        /// Underlying filesystem error.
509        source: FsError,
510    },
511
512    /// A matched output path is not a regular file (a directory,
513    /// symlink to a non-file, socket, FIFO, etc.). The cache only
514    /// stores regular-file blobs (`CACHE-013`); non-file matches
515    /// cannot be ingested.
516    #[snafu(display("output path is not a regular file: {}", path.display()))]
517    OutputNotARegularFile {
518        /// Absolute path of the offending entry.
519        path: PathBuf,
520    },
521
522    /// A literal output pattern named a path the task did not
523    /// produce (the file does not exist on disk after a successful
524    /// run). Distinct from the input-side
525    /// [`BuildKeyError::InputPatternResolutionFailed`] because the
526    /// caller's intent is the inverse: outputs are a contract the
527    /// task is supposed to honour; a missing literal output is a
528    /// task-level bug, not a filesystem accident.
529    #[snafu(display("task declared output but did not produce it: {}", path.display()))]
530    OutputDeclaredButNotProduced {
531        /// Absolute path of the declared-but-missing output.
532        path: PathBuf,
533    },
534
535    /// [`haz_cache::CacheWriter::store`] failed while persisting a
536    /// successful run as a cache entry (`CACHE-017`).
537    #[snafu(display("failed to store cache entry: {source}"))]
538    StoreFailed {
539        /// Originating [`StoreError`].
540        source: StoreError,
541    },
542
543    /// A workspace-absolute path produced by the executor's
544    /// output-resolution pass failed to parse as a
545    /// [`CanonicalPath`].
546    ///
547    /// Indicates an internal invariant violation: the resolver
548    /// builds each path string from already-validated
549    /// [`haz_domain::path::segment::PathSegment`]s, so the parse
550    /// is expected to succeed unconditionally. The variant exists
551    /// so the failure surfaces typed rather than panicking; in
552    /// practice it should never fire.
553    #[snafu(display("materialised output path is not workspace-absolute: {path}: {source}"))]
554    MaterialisedOutputPathInvalid {
555        /// The offending path string.
556        path: String,
557        /// Originating parse error.
558        source: ParseAbsoluteError,
559    },
560}
561
562/// Which of the two captured byte streams a
563/// [`RunTaskError::CapturedStreamReadFailed`] refers to.
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
565pub enum CapturedStream {
566    /// The child's standard output pipe.
567    Stdout,
568    /// The child's standard error pipe.
569    Stderr,
570}
571
572/// Borrowed bundle of long-lived state that every [`run_task`]
573/// invocation in a given `haz` run shares.
574///
575/// The split between this context and the per-task arguments
576/// matches a scheduler's natural shape: one `RunContext` covers
577/// the entire scheduling loop, while `task`, `predecessor_streams`,
578/// and `created_at_unix` change between individual [`run_task`]
579/// calls.
580pub struct RunContext<'a, F, S, O>
581where
582    F: WritableFilesystem,
583    S: ProcessSpawner,
584    O: RunObserver,
585{
586    /// Filesystem the executor reads inputs / outputs / cache state
587    /// through.
588    pub fs: &'a F,
589    /// Cache handle (lookup, restore, store).
590    pub cache: &'a CacheWriter<F>,
591    /// Process spawner (a test-double mock under `cfg(test)`,
592    /// [`crate::std_impl::StdProcessSpawner`] in production).
593    pub spawner: &'a S,
594    /// Observer that receives lifecycle events and captured streams.
595    pub observer: &'a O,
596    /// Validated workspace state.
597    pub workspace: &'a Workspace,
598    /// Validated dependency graph.
599    pub graph: &'a TaskGraph,
600    /// Host-environment snapshot. Names not present here are
601    /// treated as absent per `CACHE-008`.
602    pub host_env: &'a BTreeMap<EnvVarName, String>,
603    /// Active hash algorithm for cache-key derivation, input /
604    /// output content hashing, and captured-stream hashing.
605    pub algo: HashAlgo,
606    /// Cancellation signal for the run (`EXEC-012` triggers).
607    /// The scheduler and per-task spawn-step futures observe
608    /// this token: when it fires, the scheduler stops admitting
609    /// new tasks and in-flight futures send SIGTERM to their
610    /// children. The token is long-lived ambient state owned by
611    /// the run's caller (typically `haz-cli` installing an OS
612    /// signal handler); tests pass a never-cancelled token.
613    pub cancel: &'a CancellationToken,
614}
615
616/// Run one task per its workspace declaration, cache-aware.
617///
618/// Lifecycle:
619///
620/// 1. [`RunObserver::on_task_started`] fires.
621/// 2. [`haz_cache_key::build_cache_key`] derives the key from
622///    `task`'s declared inputs, env contribution, and hard-edge
623///    predecessors' captured stream hashes.
624/// 3. [`haz_cache::CacheReader::lookup`] is consulted:
625///    - On a hit, [`haz_cache::CacheWriter::restore`] materialises the
626///      manifest's outputs at their workspace-absolute paths;
627///      `stdout` and `stderr` flow through the observer; the
628///      function returns [`RunSource::CacheHit`] with the
629///      manifest's recorded stream hashes.
630///    - On a miss, the function builds a [`crate::process::SpawnPlan`]
631///      from the task's [`haz_domain::action::TaskAction`] and the
632///      effective env (`CACHE-008` runtime view: allow-listed host
633///      values plus task overrides, override wins), spawns through
634///      `spawner`, captures both streams to memory, awaits exit,
635///      emits the streams through the observer, classifies per
636///      `EXEC-009`. On success the outputs are enumerated and
637///      [`haz_cache::CacheWriter::store`] persists the entry. On
638///      failure no store fires.
639/// 4. [`RunObserver::on_task_finished`] fires with the terminal
640///    [`CompletedRecord`].
641///
642/// The function is async because spawning, waiting, and stream
643/// capture all use the tokio runtime. Filesystem and cache calls
644/// are sync and run on the calling worker thread; callers that want
645/// to off-load the cache-key derivation (which hashes input file
646/// bytes) and the store phase (which hashes output file bytes) from
647/// an async context can wrap the function in
648/// [`tokio::task::spawn_blocking`] -- both phases dominate the
649/// runtime cost of a single-task lifecycle.
650///
651/// `created_at_unix` is caller-supplied so the function stays a
652/// pure function of its arguments: tests pin the value, the
653/// scheduler passes the current wall-clock time.
654///
655/// # Errors
656///
657/// Returns a [`RunTaskError`] for any executor-level failure
658/// (cache-key derivation, restore, spawn, wait, stream read,
659/// output resolution, store). Task-level failures (non-zero exit
660/// or signalled exit) are reported as
661/// `Ok(CompletedRecord { state: Failed, .. })` per `EXEC-009`;
662/// the cache is not consulted for store in that branch
663/// (`CACHE-018`).
664/// Carrier for the borrows + computed values
665/// [`cache_lookup_phase`] hands back to the caller (typically the
666/// scheduler in [`crate::run_graph::run_graph`]).
667///
668/// The struct lets `EXEC-007` step 1 (cache lookup) be performed
669/// independently of `EXEC-007` step 2 (restore) and step 3 (spawn);
670/// the scheduler interposes its mutex-compatibility check between
671/// the lookup and the spawn branch.
672///
673/// Lifetime `'ws` ties the `project` and `task_def` borrows to the
674/// workspace borrow held inside the supplying [`RunContext`].
675#[derive(Debug)]
676pub struct TaskLookup<'ws> {
677    /// Bearing project of `task` (looked up once so neither
678    /// downstream branch has to repeat the search).
679    pub project: &'ws Project,
680    /// Bearing task definition (action, mutex, env, etc.).
681    pub task_def: &'ws Task,
682    /// Computed cache key.
683    pub key: haz_cache::CacheKey,
684    /// Manifest of the matching cache entry, if any. `Some(_)`
685    /// drives the restore branch; `None` drives the spawn branch.
686    pub manifest: Option<haz_cache::Manifest>,
687}
688
689/// `EXEC-007` step 1 of the single-task lifecycle: resolve the
690/// bearing project + task, derive the cache key, and consult
691/// the cache. No mutex hold is taken; no observer event fires.
692///
693/// Returns a [`TaskLookup`] the caller drives forward into either
694/// [`restore_from_hit`] (manifest present) or [`run_fresh`]
695/// (manifest absent). The scheduler interposes its `EXEC-006`
696/// condition-3 mutex check between this function and
697/// [`run_fresh`].
698///
699/// # Errors
700///
701/// - [`RunTaskError::BuildKeyFailed`] when the task is missing from
702///   the workspace, or when cache-key derivation fails for any
703///   reason (input pattern resolution, file read, etc.).
704pub fn cache_lookup_phase<'ws, F, S, O>(
705    ctx: &RunContext<'ws, F, S, O>,
706    task: &TaskId,
707    predecessor_streams: &BTreeMap<TaskId, PredecessorStreamHashes>,
708) -> Result<TaskLookup<'ws>, RunTaskError>
709where
710    F: WritableFilesystem,
711    S: ProcessSpawner,
712    O: RunObserver,
713{
714    let project =
715        ctx.workspace
716            .projects
717            .get(&task.project)
718            .ok_or_else(|| RunTaskError::BuildKeyFailed {
719                source: BuildKeyError::TaskNotInWorkspace { task: task.clone() },
720            })?;
721    let task_def = project
722        .tasks
723        .get(&task.task)
724        .ok_or_else(|| RunTaskError::BuildKeyFailed {
725            source: BuildKeyError::TaskNotInWorkspace { task: task.clone() },
726        })?;
727
728    let key = build_cache_key(
729        ctx.fs,
730        ctx.workspace,
731        ctx.graph,
732        task,
733        ctx.host_env,
734        predecessor_streams,
735        ctx.algo,
736    )
737    .context(BuildKeyFailedSnafu)?;
738
739    let manifest = ctx.cache.reader().lookup(&key);
740    Ok(TaskLookup {
741        project,
742        task_def,
743        key,
744        manifest,
745    })
746}
747
748/// Single-task lifecycle entry point: composes
749/// [`cache_lookup_phase`], [`restore_from_hit`], and
750/// [`run_fresh`] without any mutex orchestration.
751///
752/// Treats every task as if its mutex were always compatible.
753/// Callers that need `EXEC-006` condition 3 / `EXEC-007` mutex
754/// semantics (i.e., the scheduler) MUST drive the three phases
755/// directly.
756///
757/// Fires [`RunObserver::on_task_started`] before the lookup step
758/// and [`RunObserver::on_task_finished`] after the record is in
759/// hand.
760///
761/// # Errors
762///
763/// Surfaces the first error encountered across the composed
764/// phases; see the docs on each underlying function for the
765/// variants reachable from each phase.
766pub async fn run_task<F, S, O>(
767    ctx: &RunContext<'_, F, S, O>,
768    task: &TaskId,
769    predecessor_streams: &BTreeMap<TaskId, PredecessorStreamHashes>,
770    created_at_unix: u64,
771) -> Result<CompletedRecord, RunTaskError>
772where
773    F: WritableFilesystem,
774    S: ProcessSpawner,
775    O: RunObserver,
776{
777    ctx.observer.on_task_started(task);
778
779    let lookup = cache_lookup_phase(ctx, task, predecessor_streams)?;
780
781    let record = if let Some(manifest) = lookup.manifest.as_ref() {
782        restore_from_hit(ctx, task, manifest)?
783    } else {
784        run_fresh(
785            ctx,
786            task,
787            lookup.project,
788            lookup.task_def,
789            &lookup.key,
790            created_at_unix,
791        )
792        .await?
793    };
794
795    ctx.observer.on_task_finished(task, &record);
796    Ok(record)
797}
798
799/// Cache-hit branch: restore the manifest's outputs, emit the
800/// recorded streams to the observer, and build the matching
801/// [`CompletedRecord`] (`EXEC-007` step 2, `EXEC-017`,
802/// `CACHE-019`).
803///
804/// Per `MUTEX-007`, this function MUST NOT touch any mutex hold
805/// set: a cache hit reproduces the recorded effect via file
806/// writes and stream emission and never touches the resource the
807/// mutex protects.
808///
809/// Does NOT fire [`RunObserver::on_task_started`] or
810/// [`RunObserver::on_task_finished`]; the caller drives the
811/// observer lifecycle so the start/finish pair stays balanced
812/// across all three phases.
813///
814/// # Errors
815///
816/// - [`RunTaskError::RestoreFailed`] when the cache restore step
817///   fails (a `manifest` field references a blob that cannot be
818///   read, a destination cannot be written, etc.).
819pub fn restore_from_hit<F, S, O>(
820    ctx: &RunContext<'_, F, S, O>,
821    task: &TaskId,
822    manifest: &haz_cache::Manifest,
823) -> Result<CompletedRecord, RunTaskError>
824where
825    F: WritableFilesystem,
826    S: ProcessSpawner,
827    O: RunObserver,
828{
829    let restored = ctx.cache.restore(manifest).context(RestoreFailedSnafu)?;
830    ctx.observer.on_stdout(task, &restored.stdout);
831    ctx.observer.on_stderr(task, &restored.stderr);
832    let materialised_outputs = manifest
833        .outputs
834        .iter()
835        .map(|blob| blob.workspace_absolute_path.clone())
836        .collect();
837    Ok(CompletedRecord {
838        task: task.clone(),
839        source: RunSource::CacheHit,
840        state: RunState::Succeeded,
841        exit_status: None,
842        stdout_hash: manifest.stdout_hash,
843        stderr_hash: manifest.stderr_hash,
844        materialised_outputs,
845    })
846}
847
848/// Cache-miss branch (`EXEC-007` step 3, compatible mutex path):
849/// spawn the task's command, capture both streams, classify per
850/// `EXEC-009`, and on success persist the run to the cache.
851///
852/// Reader futures run concurrently with `process.wait()` so a child
853/// that fills its stdout/stderr pipe buffer never blocks waiting for
854/// the parent to drain (the classic pipe-deadlock shape). Stream
855/// hashes are computed under [`RunContext::algo`] from the captured
856/// byte buffers (`CACHE-011` corollary for fresh runs).
857///
858/// Per `MUTEX-006`, the caller MUST acquire the task's mutex
859/// before invoking this function and MUST release it after the
860/// returned future resolves (regardless of success or failure).
861/// This function does not consult any hold set itself.
862///
863/// Does NOT fire [`RunObserver::on_task_started`] or
864/// [`RunObserver::on_task_finished`]; the caller drives the
865/// observer lifecycle.
866///
867/// # Errors
868///
869/// - [`RunTaskError::SpawnFailed`] when the process spawner
870///   rejects the [`SpawnPlan`].
871/// - [`RunTaskError::WaitFailed`] when waiting on the spawned
872///   process surfaces an OS error.
873/// - [`RunTaskError::CapturedStreamReadFailed`] when reading
874///   either captured stream errors.
875/// - The output-resolution and store error families when the run
876///   succeeded and the cache-store step fails.
877pub async fn run_fresh<F, S, O>(
878    ctx: &RunContext<'_, F, S, O>,
879    task: &TaskId,
880    project: &Project,
881    task_def: &Task,
882    key: &haz_cache::CacheKey,
883    created_at_unix: u64,
884) -> Result<CompletedRecord, RunTaskError>
885where
886    F: WritableFilesystem,
887    S: ProcessSpawner,
888    O: RunObserver,
889{
890    let plan = build_spawn_plan(
891        ctx.workspace.root.as_path(),
892        project,
893        task_def,
894        ctx.host_env,
895    );
896
897    let Spawned {
898        mut process,
899        mut stdout,
900        mut stderr,
901    } = ctx.spawner.spawn(&plan).await.context(SpawnFailedSnafu)?;
902
903    let mut stdout_bytes = Vec::new();
904    let mut stderr_bytes = Vec::new();
905    let grace = ctx.workspace.settings.execution.cancel_grace.as_duration();
906    let ((wait_result, was_cancelled), stdout_result, stderr_result) = tokio::join!(
907        await_exit_with_cancel(&mut process, ctx.cancel, grace),
908        AsyncReadExt::read_to_end(&mut stdout, &mut stdout_bytes),
909        AsyncReadExt::read_to_end(&mut stderr, &mut stderr_bytes),
910    );
911
912    let exit_status = wait_result.context(WaitFailedSnafu)?;
913    stdout_result.context(CapturedStreamReadFailedSnafu {
914        stream: CapturedStream::Stdout,
915    })?;
916    stderr_result.context(CapturedStreamReadFailedSnafu {
917        stream: CapturedStream::Stderr,
918    })?;
919
920    ctx.observer.on_stdout(task, &stdout_bytes);
921    ctx.observer.on_stderr(task, &stderr_bytes);
922
923    let stdout_hash = hash_bytes(ctx.algo, &stdout_bytes);
924    let stderr_hash = hash_bytes(ctx.algo, &stderr_bytes);
925
926    let (state, materialised_outputs) = if was_cancelled {
927        // `EXEC-015`: cancelled runs never hit the store branch
928        // even on a zero exit code; cancellation is neither
929        // success nor a half-success.
930        (RunState::Cancelled, Vec::new())
931    } else if exit_status.success() {
932        let outputs_owned =
933            resolve_output_files(ctx.fs, ctx.workspace, project, &task_def.outputs)?;
934        let materialised = canonical_paths_from_owned(&outputs_owned)?;
935        store_successful_run(
936            ctx,
937            key,
938            &outputs_owned,
939            &stdout_bytes,
940            &stderr_bytes,
941            created_at_unix,
942        )?;
943        (RunState::Succeeded, materialised)
944    } else {
945        (RunState::Failed, Vec::new())
946    };
947
948    Ok(CompletedRecord {
949        task: task.clone(),
950        source: RunSource::FreshRun,
951        state,
952        exit_status: Some(exit_status),
953        stdout_hash,
954        stderr_hash,
955        materialised_outputs,
956    })
957}
958
959/// Wait for the spawned child to exit, observing the run's
960/// cancellation token.
961///
962/// On the normal path the function delegates to [`Process::wait`]
963/// and returns `(Ok(status), false)`. When the cancellation token
964/// fires while the child is still running, the function:
965///
966/// 1. Sends [`Signal::Terminate`] (best-effort; the error is
967///    swallowed so Windows hosts that surface
968///    [`std::io::ErrorKind::Unsupported`] still escalate).
969/// 2. Races a fresh [`Process::wait`] against
970///    [`tokio::time::sleep`] of `grace`.
971/// 3. If the sleep wins, sends [`Signal::Kill`] (best-effort) and
972///    awaits the child.
973///
974/// In both cancellation paths the second element of the returned
975/// tuple is `true`; the caller classifies the run as
976/// [`RunState::Cancelled`]. A `grace` of [`Duration::ZERO`]
977/// collapses the SIGTERM-to-SIGKILL window: SIGKILL fires
978/// immediately after the SIGTERM (`EXEC-014`).
979async fn await_exit_with_cancel<P>(
980    process: &mut P,
981    cancel: &CancellationToken,
982    grace: std::time::Duration,
983) -> (Result<ExitStatus, ProcessError>, bool)
984where
985    P: Process,
986{
987    tokio::select! {
988        biased;
989        () = cancel.cancelled() => {
990            let _ = process.send_signal(Signal::Terminate);
991            tokio::select! {
992                result = process.wait() => (result, true),
993                () = tokio::time::sleep(grace) => {
994                    let _ = process.send_signal(Signal::Kill);
995                    (process.wait().await, true)
996                }
997            }
998        }
999        result = process.wait() => (result, false),
1000    }
1001}
1002
1003/// Persist the successful run as a cache entry under `key`
1004/// (`CACHE-017`, `CACHE-018`).
1005///
1006/// Called only when the fresh run's exit status was zero; the
1007/// caller has already classified the run as succeeded and has
1008/// already resolved the task's declared outputs (so the same
1009/// resolved Vec can drive both the cache store and the
1010/// scheduler's materialised-outputs view).
1011fn store_successful_run<F, S, O>(
1012    ctx: &RunContext<'_, F, S, O>,
1013    key: &haz_cache::CacheKey,
1014    outputs_owned: &[OwnedOutputFile],
1015    stdout: &[u8],
1016    stderr: &[u8],
1017    created_at_unix: u64,
1018) -> Result<(), RunTaskError>
1019where
1020    F: WritableFilesystem,
1021    S: ProcessSpawner,
1022    O: RunObserver,
1023{
1024    let stored_outputs: Vec<StoredOutput<'_>> = outputs_owned
1025        .iter()
1026        .map(|f| StoredOutput {
1027            workspace_absolute_path: &f.workspace_absolute_path,
1028            on_disk_path: &f.on_disk_path,
1029            mode: f.mode,
1030        })
1031        .collect();
1032    let inputs = StoreInputs {
1033        outputs: &stored_outputs,
1034        stdout,
1035        stderr,
1036        created_at_unix,
1037    };
1038    ctx.cache.store(key, &inputs).context(StoreFailedSnafu)
1039}
1040
1041/// Parse the workspace-absolute path string of each
1042/// [`OwnedOutputFile`] into a [`CanonicalPath`] for the
1043/// scheduler's runtime DAG validation pass.
1044///
1045/// The strings come from
1046/// [`haz_cache_key::pattern_walk::workspace_absolute_string_from_segments`]
1047/// where every segment was validated upstream by
1048/// [`PathSegment`]'s constructors, so the parse should never
1049/// fail in practice; the [`Result`] exists so a hypothetical
1050/// invariant violation surfaces as a typed
1051/// [`RunTaskError::MaterialisedOutputPathInvalid`] rather than
1052/// a panic.
1053fn canonical_paths_from_owned(
1054    files: &[OwnedOutputFile],
1055) -> Result<Vec<CanonicalPath>, RunTaskError> {
1056    files
1057        .iter()
1058        .map(|f| {
1059            CanonicalPath::parse_workspace_absolute(&f.workspace_absolute_path).context(
1060                MaterialisedOutputPathInvalidSnafu {
1061                    path: f.workspace_absolute_path.clone(),
1062                },
1063            )
1064        })
1065        .collect()
1066}
1067
1068/// Hash `bytes` under the active [`HashAlgo`]. Used to compute the
1069/// captured-stream hashes recorded on a fresh-run
1070/// [`CompletedRecord`].
1071fn hash_bytes(algo: HashAlgo, bytes: &[u8]) -> [u8; 32] {
1072    let mut hasher = Hasher::new(algo);
1073    hasher.update(bytes);
1074    hasher.finalize()
1075}
1076
1077/// Assemble the [`SpawnPlan`] that [`run_task`]'s miss branch hands
1078/// to the [`ProcessSpawner`].
1079///
1080/// Three pieces are derived from `task_def`:
1081///
1082/// - `program` and `args` from `task_def.action`. A
1083///   [`TaskAction::Command`] splits as `program = argv.head`,
1084///   `args = argv.tail`. A [`TaskAction::Shell`] becomes
1085///   `program = <shell-binary-name>`, `args = ["-c", script]`.
1086/// - `cwd` is the project's host path: `workspace_host` walked
1087///   through the project root's segments. An implicit-mode
1088///   project ([`ProjectRoot::WorkspaceRoot`], per `DISC-003`) lands
1089///   at `workspace_host` itself with no further pushing.
1090/// - `env` is the effective env per `CACHE-008` runtime view:
1091///   names in `task_def.env.from_host` propagate the value the
1092///   host snapshot recorded for them, names absent from the host
1093///   simply do not appear in the env vector; `task_def.env.overrides`
1094///   entries are layered on top, override winning on collision.
1095///   The std backend's `env_clear()` ensures the child sees this
1096///   set and nothing else.
1097fn build_spawn_plan(
1098    workspace_host: &Path,
1099    project: &Project,
1100    task_def: &Task,
1101    host_env: &BTreeMap<EnvVarName, String>,
1102) -> SpawnPlan {
1103    let (program, args) = program_and_args(&task_def.action);
1104    SpawnPlan {
1105        program,
1106        args,
1107        env: build_env_vec(&task_def.env, host_env),
1108        cwd: project_cwd(workspace_host, &project.root),
1109    }
1110}
1111
1112/// Walk `workspace_host` through the project root's segments. For
1113/// [`ProjectRoot::WorkspaceRoot`] (implicit-mode project per
1114/// `DISC-003`) returns the workspace host path verbatim.
1115fn project_cwd(workspace_host: &Path, project_root: &ProjectRoot) -> PathBuf {
1116    let mut p = workspace_host.to_path_buf();
1117    if let ProjectRoot::Nested(canonical) = project_root {
1118        for seg in canonical.segments() {
1119            p.push(seg.as_str());
1120        }
1121    }
1122    p
1123}
1124
1125/// Convert a [`TaskAction`] into the
1126/// (`program`, `args`) pair the spawner consumes.
1127fn program_and_args(action: &TaskAction) -> (OsString, Vec<OsString>) {
1128    match action {
1129        TaskAction::Command(nonempty_argv) => {
1130            let program = OsString::from(nonempty_argv.head.as_str());
1131            let args = nonempty_argv.tail.iter().map(OsString::from).collect();
1132            (program, args)
1133        }
1134        TaskAction::Shell { script, shell } => {
1135            let program = OsString::from(shell_binary_name(shell));
1136            let args = vec![OsString::from("-c"), OsString::from(script)];
1137            (program, args)
1138        }
1139    }
1140}
1141
1142/// Resolve [`ShellType`] to the binary name passed as `argv[0]` to
1143/// `exec`.
1144///
1145/// First-class variants emit their canonical identifiers (`sh`,
1146/// `bash`); [`ShellType::Other`] emits the validated name verbatim.
1147/// The shell is resolved through `PATH` at exec time, matching how
1148/// `system(3)` and `/bin/sh -c` behave on POSIX hosts.
1149fn shell_binary_name(shell: &ShellType) -> &str {
1150    match shell {
1151        ShellType::Sh => "sh",
1152        ShellType::Bash => "bash",
1153        ShellType::Other(name) => AsRef::<str>::as_ref(name.as_ref()),
1154    }
1155}
1156
1157/// Assemble the effective env vector per `CACHE-008` runtime view.
1158///
1159/// Steps:
1160///
1161/// 1. For each name in `env.from_host`, look it up in `host_env`.
1162///    Present names contribute their host value; absent names
1163///    contribute nothing (the child simply will not see that name).
1164/// 2. For each entry in `env.overrides`, set the name to the
1165///    override value, replacing any same-name entry from step 1
1166///    (`CACHE-008` "overrides wins on collision").
1167///
1168/// Names not in either map do not appear; the std spawner's
1169/// `env_clear()` ensures the child's env is exactly the returned
1170/// set.
1171///
1172/// Order in the returned vector is lexicographic by name (the
1173/// [`BTreeMap`] keeps it so). The std spawner preserves order on
1174/// `Command::env`; either order works on POSIX hosts because the
1175/// child sees a set, not a sequence, but lexicographic ordering
1176/// makes test assertions deterministic.
1177fn build_env_vec(
1178    env: &EnvSettings,
1179    host_env: &BTreeMap<EnvVarName, String>,
1180) -> Vec<(OsString, OsString)> {
1181    let mut effective: BTreeMap<OsString, OsString> = BTreeMap::new();
1182    for name in &env.from_host {
1183        if let Some(value) = host_env.get(name) {
1184            effective.insert(
1185                OsString::from(AsRef::<str>::as_ref(name.as_ref())),
1186                OsString::from(value),
1187            );
1188        }
1189    }
1190    for (name, value) in &env.overrides {
1191        effective.insert(
1192            OsString::from(AsRef::<str>::as_ref(name.as_ref())),
1193            OsString::from(value),
1194        );
1195    }
1196    effective.into_iter().collect()
1197}
1198
1199/// One file resolved from a task's `outputs` patterns, paired with
1200/// the host filesystem path it lives at and the Unix permission bits
1201/// recorded for it.
1202///
1203/// `workspace_absolute_path` is the workspace-anchored string (rooted
1204/// at `/`) the cache library records in the manifest;
1205/// `on_disk_path` is the real host path the cache reads to ingest the
1206/// blob; `mode` is the value [`haz_cache::StoredOutput::mode`]
1207/// receives on store. Borrowed views of these fields drop straight
1208/// into [`haz_cache::StoredOutput`].
1209#[derive(Debug, Clone, PartialEq, Eq)]
1210struct OwnedOutputFile {
1211    workspace_absolute_path: String,
1212    on_disk_path: PathBuf,
1213    mode: u32,
1214}
1215
1216/// Resolve every [`OutputSpec`] declared on a task against `fs` and
1217/// record each match's host path and Unix mode.
1218///
1219/// Output-side mirror of the input resolver in
1220/// [`haz_cache_key::resolve_input_files`]:
1221/// a [`PathPattern::Literal`] contributes exactly one entry on a
1222/// regular-file match and a typed failure otherwise; a
1223/// [`PathPattern::Glob`] contributes zero or more entries through
1224/// the [`GlobWalk`] machinery. Errors surface the first failure
1225/// encountered while walking, reading metadata, or reading the
1226/// permission bits; later outputs in the same task are not
1227/// attempted.
1228///
1229/// Diagnostic divergence from the input resolver: a literal output
1230/// that resolves to [`FsError::NotFound`] surfaces as
1231/// [`RunTaskError::OutputDeclaredButNotProduced`] rather than
1232/// [`RunTaskError::OutputPatternResolutionFailed`]. Outputs are a
1233/// contract the task is supposed to honour, so a missing literal
1234/// output is a task-level failure, not a filesystem accident.
1235///
1236/// Symlink semantics follow the input resolver: a symlink-to-file
1237/// contributes the link's own workspace-absolute path, while the
1238/// mode bits come from the canonical target via
1239/// [`Filesystem::permissions`] (which follows symlinks).
1240fn resolve_output_files<F: Filesystem>(
1241    fs: &F,
1242    workspace: &Workspace,
1243    project: &Project,
1244    outputs: &[OutputSpec],
1245) -> Result<Vec<OwnedOutputFile>, RunTaskError> {
1246    let workspace_host = workspace.root.as_path();
1247    let action = OutputAction;
1248    let mut out = Vec::new();
1249
1250    for spec in outputs {
1251        match spec.pattern() {
1252            PathPattern::Literal(haz_path) => {
1253                resolve_literal_output(
1254                    fs,
1255                    workspace_host,
1256                    &project.root,
1257                    haz_path,
1258                    &action,
1259                    &mut out,
1260                )?;
1261            }
1262            PathPattern::Glob(glob_pattern) => {
1263                let glob = glob_pattern.compile();
1264                let matcher = glob.compile_matcher();
1265                let (walk_host, workspace_prefix, candidate_prefix) =
1266                    glob_walk_origin(workspace_host, &project.root, glob_pattern.anchor());
1267                let walker = GlobWalk {
1268                    fs,
1269                    matcher: &matcher,
1270                    candidate_prefix,
1271                    workspace_prefix,
1272                    action: &action,
1273                };
1274                let mut walk_rel: Vec<String> = Vec::new();
1275                walker.walk(&walk_host, &mut walk_rel, &mut out)?;
1276            }
1277        }
1278    }
1279
1280    Ok(out)
1281}
1282
1283/// Resolve a literal [`OutputSpec`] under the supplied project root
1284/// and dispatch to the per-match action on a regular-file match.
1285///
1286/// Splits the failure space three ways:
1287///
1288/// - [`FsError::NotFound`] becomes
1289///   [`RunTaskError::OutputDeclaredButNotProduced`].
1290/// - Any other [`FsError`] from the metadata read becomes
1291///   [`RunTaskError::OutputPatternResolutionFailed`].
1292/// - A non-regular-file kind becomes
1293///   [`RunTaskError::OutputNotARegularFile`].
1294fn resolve_literal_output<F: Filesystem>(
1295    fs: &F,
1296    workspace_host: &Path,
1297    project_root: &ProjectRoot,
1298    haz_path: &haz_domain::path::HazPath,
1299    action: &OutputAction,
1300    out: &mut Vec<OwnedOutputFile>,
1301) -> Result<(), RunTaskError> {
1302    let ws_segments = literal_workspace_segments(haz_path, project_root);
1303    let host = host_path_from_segments(workspace_host, &ws_segments);
1304
1305    let meta = match fs.metadata(&host) {
1306        Ok(m) => m,
1307        Err(FsError::NotFound { path }) => {
1308            return Err(RunTaskError::OutputDeclaredButNotProduced { path });
1309        }
1310        Err(source) => {
1311            return Err(RunTaskError::OutputPatternResolutionFailed { root: host, source });
1312        }
1313    };
1314    if meta.kind != EntryKind::File {
1315        return Err(RunTaskError::OutputNotARegularFile { path: host });
1316    }
1317
1318    let workspace_absolute_path = workspace_absolute_string_from_segments(&ws_segments);
1319    action.on_match(fs, &host, workspace_absolute_path, out)
1320}
1321
1322/// Per-match action for output resolution: record the matched file's
1323/// host path and Unix mode. Walk-level [`FsError`]s surface as
1324/// [`RunTaskError::OutputPatternResolutionFailed`]; mode-read failures
1325/// surface as [`RunTaskError::OutputModeReadFailed`].
1326///
1327/// The action carries no state: the host snapshot, project root,
1328/// and workspace path projection are all threaded through the
1329/// [`GlobWalk`] machinery.
1330struct OutputAction;
1331
1332impl<F: Filesystem> GlobMatchAction<F> for OutputAction {
1333    type Output = OwnedOutputFile;
1334    type Error = RunTaskError;
1335
1336    fn map_walk_error(&self, root: PathBuf, source: FsError) -> RunTaskError {
1337        RunTaskError::OutputPatternResolutionFailed { root, source }
1338    }
1339
1340    fn on_match(
1341        &self,
1342        fs: &F,
1343        host_path: &Path,
1344        workspace_absolute_path: String,
1345        out: &mut Vec<OwnedOutputFile>,
1346    ) -> Result<(), RunTaskError> {
1347        let mode = fs
1348            .permissions(host_path)
1349            .context(OutputModeReadFailedSnafu {
1350                path: host_path.to_path_buf(),
1351            })?;
1352        out.push(OwnedOutputFile {
1353            workspace_absolute_path,
1354            on_disk_path: host_path.to_path_buf(),
1355            mode,
1356        });
1357        Ok(())
1358    }
1359}
1360
1361#[cfg(test)]
1362mod tests {
1363    use std::collections::{BTreeMap, BTreeSet};
1364    use std::ffi::OsString;
1365    use std::path::{Path, PathBuf};
1366    use std::str::FromStr;
1367
1368    use nonempty::NonEmpty;
1369
1370    use haz_domain::action::{ShellType, TaskAction};
1371    use haz_domain::env::{EnvSettings, EnvVarName};
1372    use haz_domain::name::{ProjectName, TaskName};
1373    use haz_domain::path::{CanonicalPath, HazPath, InputSpec, ProjectRoot};
1374    use haz_domain::project::Project;
1375    use haz_domain::task::Task;
1376
1377    use super::{build_env_vec, build_spawn_plan, project_cwd, shell_binary_name};
1378
1379    fn env_name(s: &str) -> EnvVarName {
1380        EnvVarName::try_new(s).unwrap()
1381    }
1382
1383    fn project_name(s: &str) -> ProjectName {
1384        ProjectName::try_new(s).unwrap()
1385    }
1386
1387    fn task_name(s: &str) -> TaskName {
1388        TaskName::try_new(s).unwrap()
1389    }
1390
1391    fn nested_project(name: &str, root: &str) -> Project {
1392        Project {
1393            name: project_name(name),
1394            root: ProjectRoot::Nested(
1395                CanonicalPath::from_absolute(&HazPath::parse(root).unwrap()).unwrap(),
1396            ),
1397            tags: BTreeSet::new(),
1398            tasks: BTreeMap::new(),
1399        }
1400    }
1401
1402    fn implicit_project(name: &str) -> Project {
1403        Project {
1404            name: project_name(name),
1405            root: ProjectRoot::WorkspaceRoot,
1406            tags: BTreeSet::new(),
1407            tasks: BTreeMap::new(),
1408        }
1409    }
1410
1411    fn task_with(action: TaskAction, env: EnvSettings) -> Task {
1412        Task {
1413            name: task_name("run"),
1414            action,
1415            inputs: Vec::<InputSpec>::new(),
1416            outputs: Vec::new(),
1417            deps: Vec::new(),
1418            weak_deps: Vec::new(),
1419            mutex: None,
1420            env,
1421        }
1422    }
1423
1424    fn command(argv: &[&str]) -> TaskAction {
1425        TaskAction::Command(
1426            NonEmpty::from_vec(argv.iter().map(|s| (*s).to_owned()).collect())
1427                .expect("non-empty argv"),
1428        )
1429    }
1430
1431    fn shell(script: &str, shell_type: ShellType) -> TaskAction {
1432        TaskAction::Shell {
1433            script: script.to_owned(),
1434            shell: shell_type,
1435        }
1436    }
1437
1438    // ---- shell_binary_name ----
1439
1440    #[test]
1441    fn shell_binary_name_for_sh() {
1442        assert_eq!(shell_binary_name(&ShellType::Sh), "sh");
1443    }
1444
1445    #[test]
1446    fn shell_binary_name_for_bash() {
1447        assert_eq!(shell_binary_name(&ShellType::Bash), "bash");
1448    }
1449
1450    #[test]
1451    fn shell_binary_name_for_other_uses_validated_name() {
1452        let name = haz_domain::action::NonEmptyAsciiName::from_str("zsh").unwrap();
1453        assert_eq!(shell_binary_name(&ShellType::Other(name)), "zsh");
1454    }
1455
1456    // ---- project_cwd ----
1457
1458    #[test]
1459    fn project_cwd_for_nested_appends_segments() {
1460        let p = nested_project("lib_core", "/lib_core");
1461        let cwd = project_cwd(Path::new("/abs/ws"), &p.root);
1462        assert_eq!(cwd, PathBuf::from("/abs/ws/lib_core"));
1463    }
1464
1465    #[test]
1466    fn project_cwd_for_deep_nested_walks_every_segment() {
1467        let p = nested_project("frontend", "/web/frontend");
1468        let cwd = project_cwd(Path::new("/abs/ws"), &p.root);
1469        assert_eq!(cwd, PathBuf::from("/abs/ws/web/frontend"));
1470    }
1471
1472    #[test]
1473    fn project_cwd_for_implicit_mode_is_workspace_host() {
1474        let p = implicit_project("root");
1475        let cwd = project_cwd(Path::new("/abs/ws"), &p.root);
1476        assert_eq!(cwd, PathBuf::from("/abs/ws"));
1477    }
1478
1479    // ---- build_env_vec ----
1480
1481    fn env_settings(from_host: &[&str], overrides: &[(&str, &str)]) -> EnvSettings {
1482        EnvSettings {
1483            from_host: from_host.iter().map(|s| env_name(s)).collect(),
1484            overrides: overrides
1485                .iter()
1486                .map(|(k, v)| (env_name(k), (*v).to_owned()))
1487                .collect(),
1488        }
1489    }
1490
1491    fn host_snapshot(entries: &[(&str, &str)]) -> BTreeMap<EnvVarName, String> {
1492        entries
1493            .iter()
1494            .map(|(k, v)| (env_name(k), (*v).to_owned()))
1495            .collect()
1496    }
1497
1498    fn osstr_pairs(slice: &[(&str, &str)]) -> Vec<(OsString, OsString)> {
1499        slice
1500            .iter()
1501            .map(|(k, v)| (OsString::from(*k), OsString::from(*v)))
1502            .collect()
1503    }
1504
1505    #[test]
1506    fn build_env_vec_from_host_present_name_propagates() {
1507        let settings = env_settings(&["PATH"], &[]);
1508        let host = host_snapshot(&[("PATH", "/usr/bin")]);
1509        let got = build_env_vec(&settings, &host);
1510        assert_eq!(got, osstr_pairs(&[("PATH", "/usr/bin")]));
1511    }
1512
1513    #[test]
1514    fn build_env_vec_from_host_absent_name_is_excluded() {
1515        // CACHE-008: a from_host name absent from the host
1516        // snapshot contributes the `0x00` absent marker to the
1517        // cache key, but does NOT appear in the child's runtime
1518        // env. The child simply does not see the variable.
1519        let settings = env_settings(&["NEVER_SET"], &[]);
1520        let host = host_snapshot(&[("OTHER", "v")]);
1521        let got = build_env_vec(&settings, &host);
1522        assert!(
1523            got.is_empty(),
1524            "absent from_host names must not enter the child env, got {got:?}",
1525        );
1526    }
1527
1528    #[test]
1529    fn build_env_vec_override_wins_on_collision() {
1530        // CACHE-008 "overrides wins on collision": at runtime the
1531        // child sees the override value, not the host value.
1532        let settings = env_settings(&["X"], &[("X", "override-val")]);
1533        let host = host_snapshot(&[("X", "host-val")]);
1534        let got = build_env_vec(&settings, &host);
1535        assert_eq!(got, osstr_pairs(&[("X", "override-val")]));
1536    }
1537
1538    #[test]
1539    fn build_env_vec_override_only_entries_propagate() {
1540        let settings = env_settings(&[], &[("HAZ_MODE", "ci")]);
1541        let host = host_snapshot(&[]);
1542        let got = build_env_vec(&settings, &host);
1543        assert_eq!(got, osstr_pairs(&[("HAZ_MODE", "ci")]));
1544    }
1545
1546    #[test]
1547    fn build_env_vec_unrelated_host_names_do_not_appear() {
1548        // Cache soundness corollary: a host variable NOT named in
1549        // either from_host or overrides MUST NOT enter the child's
1550        // env, even though it sits in the host snapshot.
1551        let settings = env_settings(&["PATH"], &[]);
1552        let host = host_snapshot(&[("PATH", "/usr/bin"), ("HOME", "/home/me")]);
1553        let got = build_env_vec(&settings, &host);
1554        assert_eq!(got, osstr_pairs(&[("PATH", "/usr/bin")]));
1555    }
1556
1557    #[test]
1558    fn build_env_vec_result_is_lexicographic() {
1559        let settings = env_settings(&["ZULU", "ALPHA", "BRAVO"], &[]);
1560        let host = host_snapshot(&[("ALPHA", "a"), ("BRAVO", "b"), ("ZULU", "z")]);
1561        let got = build_env_vec(&settings, &host);
1562        let names: Vec<&OsString> = got.iter().map(|(k, _)| k).collect();
1563        assert_eq!(
1564            names,
1565            vec![
1566                &OsString::from("ALPHA"),
1567                &OsString::from("BRAVO"),
1568                &OsString::from("ZULU"),
1569            ]
1570        );
1571    }
1572
1573    #[test]
1574    fn build_env_vec_empty_value_is_kept() {
1575        // An empty-string value is a legitimate POSIX env entry,
1576        // and distinct from absence (which CACHE-008 separates via
1577        // the 0x00 marker). Make sure we propagate the empty string
1578        // rather than collapsing it to "absent".
1579        let settings = env_settings(&["X"], &[]);
1580        let host = host_snapshot(&[("X", "")]);
1581        let got = build_env_vec(&settings, &host);
1582        assert_eq!(got, osstr_pairs(&[("X", "")]));
1583    }
1584
1585    // ---- build_spawn_plan ----
1586
1587    #[test]
1588    fn build_spawn_plan_command_maps_argv_head_and_tail() {
1589        let p = nested_project("proj", "/proj");
1590        let t = task_with(
1591            command(&["cargo", "build", "--release"]),
1592            EnvSettings::default(),
1593        );
1594        let plan = build_spawn_plan(Path::new("/ws"), &p, &t, &BTreeMap::new());
1595
1596        assert_eq!(plan.program, OsString::from("cargo"));
1597        assert_eq!(
1598            plan.args,
1599            vec![OsString::from("build"), OsString::from("--release")],
1600        );
1601    }
1602
1603    #[test]
1604    fn build_spawn_plan_command_with_single_arg_has_empty_args() {
1605        let p = nested_project("proj", "/proj");
1606        let t = task_with(command(&["true"]), EnvSettings::default());
1607        let plan = build_spawn_plan(Path::new("/ws"), &p, &t, &BTreeMap::new());
1608
1609        assert_eq!(plan.program, OsString::from("true"));
1610        assert!(plan.args.is_empty());
1611    }
1612
1613    #[test]
1614    fn build_spawn_plan_shell_uses_dash_c_and_script() {
1615        let p = nested_project("proj", "/proj");
1616        let t = task_with(shell("echo hi", ShellType::Sh), EnvSettings::default());
1617        let plan = build_spawn_plan(Path::new("/ws"), &p, &t, &BTreeMap::new());
1618
1619        assert_eq!(plan.program, OsString::from("sh"));
1620        assert_eq!(
1621            plan.args,
1622            vec![OsString::from("-c"), OsString::from("echo hi")],
1623        );
1624    }
1625
1626    #[test]
1627    fn build_spawn_plan_shell_other_uses_named_binary() {
1628        let p = nested_project("proj", "/proj");
1629        let other = haz_domain::action::NonEmptyAsciiName::from_str("zsh").unwrap();
1630        let t = task_with(
1631            shell("echo hi", ShellType::Other(other)),
1632            EnvSettings::default(),
1633        );
1634        let plan = build_spawn_plan(Path::new("/ws"), &p, &t, &BTreeMap::new());
1635
1636        assert_eq!(plan.program, OsString::from("zsh"));
1637        assert_eq!(
1638            plan.args,
1639            vec![OsString::from("-c"), OsString::from("echo hi")],
1640        );
1641    }
1642
1643    #[test]
1644    fn build_spawn_plan_sets_cwd_to_project_host_path_for_nested() {
1645        let p = nested_project("frontend", "/web/frontend");
1646        let t = task_with(command(&["true"]), EnvSettings::default());
1647        let plan = build_spawn_plan(Path::new("/abs/ws"), &p, &t, &BTreeMap::new());
1648
1649        assert_eq!(plan.cwd, PathBuf::from("/abs/ws/web/frontend"));
1650    }
1651
1652    #[test]
1653    fn build_spawn_plan_sets_cwd_to_workspace_host_for_implicit_project() {
1654        let p = implicit_project("root");
1655        let t = task_with(command(&["true"]), EnvSettings::default());
1656        let plan = build_spawn_plan(Path::new("/abs/ws"), &p, &t, &BTreeMap::new());
1657
1658        assert_eq!(plan.cwd, PathBuf::from("/abs/ws"));
1659    }
1660
1661    #[test]
1662    fn build_spawn_plan_env_reflects_from_host_overrides_and_excludes_unrelated() {
1663        // End-to-end: feed a representative env config into the
1664        // whole helper and confirm the SpawnPlan.env matches the
1665        // CACHE-008 runtime view exactly.
1666        let settings = env_settings(
1667            &["PATH", "MISSING"],
1668            &[("HAZ_MODE", "ci"), ("PATH", "/override/bin")],
1669        );
1670        let p = nested_project("proj", "/proj");
1671        let t = task_with(command(&["true"]), settings);
1672
1673        let host = host_snapshot(&[("PATH", "/usr/bin"), ("UNRELATED", "should-not-appear")]);
1674        let plan = build_spawn_plan(Path::new("/ws"), &p, &t, &host);
1675
1676        assert_eq!(
1677            plan.env,
1678            osstr_pairs(&[("HAZ_MODE", "ci"), ("PATH", "/override/bin"),]),
1679            "override wins over from_host; UNRELATED and MISSING (absent) are excluded",
1680        );
1681    }
1682
1683    // ---- resolve_output_files ----
1684
1685    mod output_resolution {
1686        use std::collections::{BTreeMap, BTreeSet};
1687        use std::path::PathBuf;
1688
1689        use haz_domain::path::{
1690            CanonicalPath, HazPath, OutputSpec, ProjectRoot, WorkspaceRootPath,
1691        };
1692        use haz_domain::project::Project;
1693        use haz_domain::settings::WorkspaceSettings;
1694        use haz_domain::workspace::Workspace;
1695        use haz_vfs::WritableFilesystem;
1696        use haz_vfs_testing::MemFilesystem;
1697
1698        use super::super::{OwnedOutputFile, RunTaskError, resolve_output_files};
1699
1700        const WORKSPACE_HOST: &str = "/ws";
1701        const PROJECT_HOST: &str = "/ws/proj";
1702
1703        fn nested_project() -> Project {
1704            Project {
1705                name: haz_domain::name::ProjectName::try_new("proj").unwrap(),
1706                root: ProjectRoot::Nested(
1707                    CanonicalPath::from_absolute(&HazPath::parse("/proj").unwrap()).unwrap(),
1708                ),
1709                tags: BTreeSet::new(),
1710                tasks: BTreeMap::new(),
1711            }
1712        }
1713
1714        fn implicit_project() -> Project {
1715            Project {
1716                name: haz_domain::name::ProjectName::try_new("root").unwrap(),
1717                root: ProjectRoot::WorkspaceRoot,
1718                tags: BTreeSet::new(),
1719                tasks: BTreeMap::new(),
1720            }
1721        }
1722
1723        fn workspace_with(project: &Project) -> Workspace {
1724            let mut projects = BTreeMap::new();
1725            projects.insert(project.name.clone(), project.clone());
1726            Workspace {
1727                root: WorkspaceRootPath::try_new(PathBuf::from(WORKSPACE_HOST)).unwrap(),
1728                projects,
1729                overlays: BTreeMap::new(),
1730                settings: WorkspaceSettings::default(),
1731            }
1732        }
1733
1734        fn paths_of(files: &[OwnedOutputFile]) -> BTreeSet<String> {
1735            files
1736                .iter()
1737                .map(|f| f.workspace_absolute_path.clone())
1738                .collect()
1739        }
1740
1741        fn host_paths_of(files: &[OwnedOutputFile]) -> BTreeSet<PathBuf> {
1742            files.iter().map(|f| f.on_disk_path.clone()).collect()
1743        }
1744
1745        fn modes_by_workspace_path(files: &[OwnedOutputFile]) -> BTreeMap<String, u32> {
1746            files
1747                .iter()
1748                .map(|f| (f.workspace_absolute_path.clone(), f.mode))
1749                .collect()
1750        }
1751
1752        #[test]
1753        fn literal_hit_returns_one_output_with_recorded_mode() {
1754            let mut fs = MemFilesystem::new();
1755            fs.add_dir(PROJECT_HOST).unwrap();
1756            fs.add_file_with_mode(format!("{PROJECT_HOST}/bundle.js"), b"data".to_vec(), 0o640)
1757                .unwrap();
1758
1759            let project = nested_project();
1760            let workspace = workspace_with(&project);
1761            let outputs = vec![OutputSpec::parse("bundle.js").unwrap()];
1762
1763            let result = resolve_output_files(&fs, &workspace, &project, &outputs).unwrap();
1764            assert_eq!(result.len(), 1);
1765            assert_eq!(result[0].workspace_absolute_path, "/proj/bundle.js");
1766            assert_eq!(result[0].on_disk_path, PathBuf::from("/ws/proj/bundle.js"));
1767            assert_eq!(result[0].mode & 0o7777, 0o640);
1768        }
1769
1770        #[test]
1771        fn literal_workspace_absolute_resolves_under_workspace_root() {
1772            let mut fs = MemFilesystem::new();
1773            fs.add_dir("/ws/dist").unwrap();
1774            fs.add_file_with_mode("/ws/dist/main.js", b"x".to_vec(), 0o755)
1775                .unwrap();
1776            fs.add_dir(PROJECT_HOST).unwrap();
1777
1778            let project = nested_project();
1779            let workspace = workspace_with(&project);
1780            let outputs = vec![OutputSpec::parse("/dist/main.js").unwrap()];
1781
1782            let result = resolve_output_files(&fs, &workspace, &project, &outputs).unwrap();
1783            assert_eq!(result.len(), 1);
1784            assert_eq!(result[0].workspace_absolute_path, "/dist/main.js");
1785            assert_eq!(result[0].on_disk_path, PathBuf::from("/ws/dist/main.js"));
1786            assert_eq!(result[0].mode & 0o7777, 0o755);
1787        }
1788
1789        #[test]
1790        fn literal_missing_surfaces_output_declared_but_not_produced() {
1791            // Outputs are a contract the task is supposed to honour;
1792            // a missing literal must NOT collapse into the input-side
1793            // PatternResolutionFailed shape.
1794            let mut fs = MemFilesystem::new();
1795            fs.add_dir(PROJECT_HOST).unwrap();
1796
1797            let project = nested_project();
1798            let workspace = workspace_with(&project);
1799            let outputs = vec![OutputSpec::parse("absent.txt").unwrap()];
1800
1801            match resolve_output_files(&fs, &workspace, &project, &outputs) {
1802                Err(RunTaskError::OutputDeclaredButNotProduced { path }) => {
1803                    assert_eq!(path, PathBuf::from("/ws/proj/absent.txt"));
1804                }
1805                other => panic!("expected OutputDeclaredButNotProduced, got {other:?}"),
1806            }
1807        }
1808
1809        #[test]
1810        fn literal_pointing_at_directory_surfaces_output_not_a_regular_file() {
1811            let mut fs = MemFilesystem::new();
1812            fs.add_dir(format!("{PROJECT_HOST}/subdir")).unwrap();
1813
1814            let project = nested_project();
1815            let workspace = workspace_with(&project);
1816            let outputs = vec![OutputSpec::parse("subdir").unwrap()];
1817
1818            match resolve_output_files(&fs, &workspace, &project, &outputs) {
1819                Err(RunTaskError::OutputNotARegularFile { path }) => {
1820                    assert_eq!(path, PathBuf::from("/ws/proj/subdir"));
1821                }
1822                other => panic!("expected OutputNotARegularFile, got {other:?}"),
1823            }
1824        }
1825
1826        #[test]
1827        fn glob_multi_match_collects_every_matching_file_with_its_mode() {
1828            let mut fs = MemFilesystem::new();
1829            fs.add_dir(PROJECT_HOST).unwrap();
1830            fs.add_file_with_mode(format!("{PROJECT_HOST}/a.js"), b"a".to_vec(), 0o644)
1831                .unwrap();
1832            fs.add_file_with_mode(format!("{PROJECT_HOST}/b.js"), b"b".to_vec(), 0o600)
1833                .unwrap();
1834            // A non-matching neighbour MUST NOT contribute.
1835            fs.add_file_with_mode(
1836                format!("{PROJECT_HOST}/keep.txt"),
1837                b"ignored".to_vec(),
1838                0o644,
1839            )
1840            .unwrap();
1841
1842            let project = nested_project();
1843            let workspace = workspace_with(&project);
1844            let outputs = vec![OutputSpec::parse("*.js").unwrap()];
1845
1846            let result = resolve_output_files(&fs, &workspace, &project, &outputs).unwrap();
1847            assert_eq!(result.len(), 2);
1848            assert_eq!(
1849                paths_of(&result),
1850                BTreeSet::from(["/proj/a.js".to_owned(), "/proj/b.js".to_owned()]),
1851            );
1852            let modes = modes_by_workspace_path(&result);
1853            assert_eq!(
1854                modes.get("/proj/a.js").copied().map(|m| m & 0o7777),
1855                Some(0o644)
1856            );
1857            assert_eq!(
1858                modes.get("/proj/b.js").copied().map(|m| m & 0o7777),
1859                Some(0o600)
1860            );
1861        }
1862
1863        #[test]
1864        fn glob_no_match_returns_empty_contribution() {
1865            let mut fs = MemFilesystem::new();
1866            fs.add_dir(PROJECT_HOST).unwrap();
1867            fs.add_file(format!("{PROJECT_HOST}/only.txt"), b"x".to_vec())
1868                .unwrap();
1869
1870            let project = nested_project();
1871            let workspace = workspace_with(&project);
1872            let outputs = vec![OutputSpec::parse("*.js").unwrap()];
1873
1874            let result = resolve_output_files(&fs, &workspace, &project, &outputs).unwrap();
1875            assert!(result.is_empty());
1876        }
1877
1878        #[test]
1879        fn glob_nested_double_star_recurses_into_subdirectories() {
1880            let mut fs = MemFilesystem::new();
1881            fs.add_dir(format!("{PROJECT_HOST}/dist")).unwrap();
1882            fs.add_dir(format!("{PROJECT_HOST}/dist/inner")).unwrap();
1883            fs.add_file(format!("{PROJECT_HOST}/dist/top.js"), b"top".to_vec())
1884                .unwrap();
1885            fs.add_file(
1886                format!("{PROJECT_HOST}/dist/inner/deep.js"),
1887                b"deep".to_vec(),
1888            )
1889            .unwrap();
1890            // Outside the `dist/` prefix; MUST NOT be matched.
1891            fs.add_file(format!("{PROJECT_HOST}/other.js"), b"other".to_vec())
1892                .unwrap();
1893
1894            let project = nested_project();
1895            let workspace = workspace_with(&project);
1896            let outputs = vec![OutputSpec::parse("dist/**/*.js").unwrap()];
1897
1898            let result = resolve_output_files(&fs, &workspace, &project, &outputs).unwrap();
1899            assert_eq!(result.len(), 2);
1900            assert_eq!(
1901                paths_of(&result),
1902                BTreeSet::from([
1903                    "/proj/dist/top.js".to_owned(),
1904                    "/proj/dist/inner/deep.js".to_owned(),
1905                ]),
1906            );
1907            assert_eq!(
1908                host_paths_of(&result),
1909                BTreeSet::from([
1910                    PathBuf::from("/ws/proj/dist/top.js"),
1911                    PathBuf::from("/ws/proj/dist/inner/deep.js"),
1912                ]),
1913            );
1914        }
1915
1916        #[test]
1917        fn glob_symlink_to_file_records_link_path_with_target_mode() {
1918            // Mirrors the input-side symlink contract: both the
1919            // real file and the symlink that points at it are
1920            // distinct contributions; each one's mode comes from
1921            // Filesystem::permissions (which follows symlinks),
1922            // so both entries report the target file's mode bits.
1923            let mut fs = MemFilesystem::new();
1924            fs.add_dir(PROJECT_HOST).unwrap();
1925            fs.add_file_with_mode(
1926                format!("{PROJECT_HOST}/real.txt"),
1927                b"real bytes".to_vec(),
1928                0o600,
1929            )
1930            .unwrap();
1931            fs.add_symlink(
1932                format!("{PROJECT_HOST}/link.txt"),
1933                format!("{PROJECT_HOST}/real.txt"),
1934            )
1935            .unwrap();
1936
1937            let project = nested_project();
1938            let workspace = workspace_with(&project);
1939            let outputs = vec![OutputSpec::parse("*.txt").unwrap()];
1940
1941            let result = resolve_output_files(&fs, &workspace, &project, &outputs).unwrap();
1942            assert_eq!(
1943                result.len(),
1944                2,
1945                "both the real file and the symlink to it are distinct contributions",
1946            );
1947            assert_eq!(
1948                paths_of(&result),
1949                BTreeSet::from(["/proj/real.txt".to_owned(), "/proj/link.txt".to_owned(),]),
1950            );
1951            // Both entries see the target's mode bits, because
1952            // permissions follows the symlink.
1953            for file in &result {
1954                assert_eq!(
1955                    file.mode & 0o7777,
1956                    0o600,
1957                    "{} should report target's mode",
1958                    file.workspace_absolute_path,
1959                );
1960            }
1961        }
1962
1963        #[test]
1964        fn implicit_mode_project_relative_literal_is_workspace_absolute() {
1965            // ProjectRoot::WorkspaceRoot: project-relative output
1966            // lands at the workspace root directly, with no extra
1967            // prefix.
1968            let mut fs = MemFilesystem::new();
1969            fs.add_dir(WORKSPACE_HOST).unwrap();
1970            fs.add_file_with_mode(
1971                format!("{WORKSPACE_HOST}/manifest.json"),
1972                b"{}".to_vec(),
1973                0o644,
1974            )
1975            .unwrap();
1976
1977            let project = implicit_project();
1978            let workspace = workspace_with(&project);
1979            let outputs = vec![OutputSpec::parse("manifest.json").unwrap()];
1980
1981            let result = resolve_output_files(&fs, &workspace, &project, &outputs).unwrap();
1982            assert_eq!(result.len(), 1);
1983            assert_eq!(result[0].workspace_absolute_path, "/manifest.json");
1984            assert_eq!(result[0].on_disk_path, PathBuf::from("/ws/manifest.json"));
1985            assert_eq!(result[0].mode & 0o7777, 0o644);
1986        }
1987
1988        #[test]
1989        fn glob_walk_error_surfaces_output_pattern_resolution_failed() {
1990            // The project root directory does not exist on disk;
1991            // the glob walker's read_dir fails on entry and the
1992            // action's map_walk_error wraps it as
1993            // OutputPatternResolutionFailed (NOT the literal-side
1994            // OutputDeclaredButNotProduced shape, which only
1995            // applies to literal patterns).
1996            let fs = MemFilesystem::new();
1997
1998            let project = nested_project();
1999            let workspace = workspace_with(&project);
2000            let outputs = vec![OutputSpec::parse("*.js").unwrap()];
2001
2002            match resolve_output_files(&fs, &workspace, &project, &outputs) {
2003                Err(RunTaskError::OutputPatternResolutionFailed { root, .. }) => {
2004                    assert_eq!(root, PathBuf::from("/ws/proj"));
2005                }
2006                other => panic!("expected OutputPatternResolutionFailed, got {other:?}"),
2007            }
2008        }
2009
2010        #[test]
2011        fn permissions_round_trip_through_set_permissions() {
2012            // Sanity check: a freshly-set mode is faithfully
2013            // surfaced via the output resolver. Guards against
2014            // future MemFilesystem regressions where set_permissions
2015            // and permissions get out of sync.
2016            let mut fs = MemFilesystem::new();
2017            fs.add_dir(PROJECT_HOST).unwrap();
2018            fs.add_file(format!("{PROJECT_HOST}/bin"), b"!".to_vec())
2019                .unwrap();
2020            fs.set_permissions(std::path::Path::new("/ws/proj/bin"), 0o751)
2021                .unwrap();
2022
2023            let project = nested_project();
2024            let workspace = workspace_with(&project);
2025            let outputs = vec![OutputSpec::parse("bin").unwrap()];
2026
2027            let result = resolve_output_files(&fs, &workspace, &project, &outputs).unwrap();
2028            assert_eq!(result.len(), 1);
2029            assert_eq!(result[0].mode & 0o7777, 0o751);
2030        }
2031    }
2032
2033    // ---- end-to-end run_task ----
2034
2035    mod end_to_end {
2036        use std::collections::{BTreeMap, BTreeSet};
2037        use std::path::PathBuf;
2038        use std::sync::Mutex;
2039
2040        use nonempty::NonEmpty;
2041
2042        use haz_cache::CacheWriter;
2043        use haz_dag::graph::TaskGraph;
2044        use haz_domain::action::TaskAction;
2045        use haz_domain::env::{EnvSettings, EnvVarName};
2046        use haz_domain::name::{ProjectName, TaskName};
2047        use haz_domain::path::{
2048            CanonicalPath, HazPath, OutputSpec, ProjectRoot, WorkspaceRootPath,
2049        };
2050        use haz_domain::project::Project;
2051        use haz_domain::settings::WorkspaceSettings;
2052        use haz_domain::settings::cache::HashAlgo;
2053        use haz_domain::task::Task;
2054        use haz_domain::task_id::TaskId;
2055        use haz_domain::workspace::Workspace;
2056        use haz_vfs::{Filesystem, FsError, WritableFilesystem};
2057        use haz_vfs_testing::MemFilesystem;
2058
2059        use crate::mock_impl::{MockProcessSpawner, MockSpec};
2060        use haz_cache_key::BuildKeyError;
2061
2062        use super::super::{
2063            CancelledRecord, CompletedRecord, RunContext, RunObserver, RunSource, RunState,
2064            RunTaskError, SkipRecord, run_task,
2065        };
2066        use tokio_util::sync::CancellationToken;
2067
2068        const WORKSPACE_HOST: &str = "/ws";
2069        const PROJECT_HOST: &str = "/ws/proj";
2070
2071        fn env_var(s: &str) -> EnvVarName {
2072            EnvVarName::try_new(s).unwrap()
2073        }
2074
2075        fn task_id_for(project: &str, task: &str) -> TaskId {
2076            TaskId {
2077                project: ProjectName::try_new(project).unwrap(),
2078                task: TaskName::try_new(task).unwrap(),
2079            }
2080        }
2081
2082        fn command(argv: &[&str]) -> TaskAction {
2083            TaskAction::Command(
2084                NonEmpty::from_vec(argv.iter().map(|s| (*s).to_owned()).collect())
2085                    .expect("non-empty argv"),
2086            )
2087        }
2088
2089        /// Event captured by [`Recorder`]. The cache-hit branch and
2090        /// the fresh-run branch both flow through the same observer
2091        /// surface, so the captured event stream is mode-agnostic.
2092        #[derive(Debug, Clone, PartialEq, Eq)]
2093        enum RecordedEvent {
2094            Started(TaskId),
2095            Stdout(TaskId, Vec<u8>),
2096            Stderr(TaskId, Vec<u8>),
2097            Finished(TaskId, CompletedRecord),
2098        }
2099
2100        /// Test-double [`RunObserver`]: records every lifecycle
2101        /// callback into an in-memory log the assertions inspect.
2102        #[derive(Default)]
2103        struct Recorder {
2104            events: Mutex<Vec<RecordedEvent>>,
2105        }
2106
2107        impl Recorder {
2108            fn events(&self) -> Vec<RecordedEvent> {
2109                self.events.lock().unwrap().clone()
2110            }
2111        }
2112
2113        impl RunObserver for Recorder {
2114            fn on_task_started(&self, task: &TaskId) {
2115                self.events
2116                    .lock()
2117                    .unwrap()
2118                    .push(RecordedEvent::Started(task.clone()));
2119            }
2120            fn on_stdout(&self, task: &TaskId, bytes: &[u8]) {
2121                self.events
2122                    .lock()
2123                    .unwrap()
2124                    .push(RecordedEvent::Stdout(task.clone(), bytes.to_vec()));
2125            }
2126            fn on_stderr(&self, task: &TaskId, bytes: &[u8]) {
2127                self.events
2128                    .lock()
2129                    .unwrap()
2130                    .push(RecordedEvent::Stderr(task.clone(), bytes.to_vec()));
2131            }
2132            fn on_task_finished(&self, task: &TaskId, record: &CompletedRecord) {
2133                self.events
2134                    .lock()
2135                    .unwrap()
2136                    .push(RecordedEvent::Finished(task.clone(), record.clone()));
2137            }
2138            fn on_task_skipped(&self, _task: &TaskId, _record: &SkipRecord) {
2139                // run_task never produces a Skipped, so this
2140                // recorder leaves the path unrecorded.
2141            }
2142            fn on_task_cancelled(&self, _task: &TaskId, _record: &CancelledRecord) {
2143                // run_task never produces a Cancelled until the
2144                // spawn-step cancellation flow lands; recorder
2145                // leaves the path unrecorded for now.
2146            }
2147        }
2148
2149        /// Common end-to-end fixture: one `proj:build` task with the
2150        /// supplied action/env/outputs, a workspace + graph + cache
2151        /// backed by a fresh [`MemFilesystem`] rooted at `/ws/proj`.
2152        struct Fixture {
2153            cache: CacheWriter<MemFilesystem>,
2154            workspace: Workspace,
2155            graph: TaskGraph,
2156            task_id: TaskId,
2157            host_env: BTreeMap<EnvVarName, String>,
2158            cancel: CancellationToken,
2159        }
2160
2161        impl Fixture {
2162            fn new(action: TaskAction, env: EnvSettings, outputs: Vec<OutputSpec>) -> Self {
2163                let mut fs = MemFilesystem::new();
2164                fs.add_dir(WORKSPACE_HOST).unwrap();
2165                fs.add_dir(PROJECT_HOST).unwrap();
2166
2167                let task = Task {
2168                    name: TaskName::try_new("build").unwrap(),
2169                    action,
2170                    inputs: Vec::new(),
2171                    outputs,
2172                    deps: Vec::new(),
2173                    weak_deps: Vec::new(),
2174                    mutex: None,
2175                    env,
2176                };
2177                let project = Project {
2178                    name: ProjectName::try_new("proj").unwrap(),
2179                    root: ProjectRoot::Nested(
2180                        CanonicalPath::from_absolute(&HazPath::parse("/proj").unwrap()).unwrap(),
2181                    ),
2182                    tags: BTreeSet::new(),
2183                    tasks: BTreeMap::from([(task.name.clone(), task)]),
2184                };
2185                let task_id = task_id_for("proj", "build");
2186                let workspace = Workspace {
2187                    root: WorkspaceRootPath::try_new(PathBuf::from(WORKSPACE_HOST)).unwrap(),
2188                    projects: BTreeMap::from([(project.name.clone(), project)]),
2189                    overlays: BTreeMap::new(),
2190                    settings: WorkspaceSettings::default(),
2191                };
2192                let graph = TaskGraph {
2193                    nodes: BTreeSet::from([task_id.clone()]),
2194                    edges: BTreeSet::new(),
2195                };
2196                let cache =
2197                    CacheWriter::new(fs, std::path::Path::new(WORKSPACE_HOST), HashAlgo::Blake3);
2198                Self {
2199                    cache,
2200                    workspace,
2201                    graph,
2202                    task_id,
2203                    host_env: BTreeMap::new(),
2204                    cancel: CancellationToken::new(),
2205                }
2206            }
2207        }
2208
2209        fn make_ctx<'a>(
2210            fixture: &'a Fixture,
2211            spawner: &'a MockProcessSpawner,
2212            observer: &'a Recorder,
2213        ) -> RunContext<'a, MemFilesystem, MockProcessSpawner, Recorder> {
2214            RunContext {
2215                fs: fixture.cache.fs(),
2216                cache: &fixture.cache,
2217                spawner,
2218                observer,
2219                workspace: &fixture.workspace,
2220                graph: &fixture.graph,
2221                host_env: &fixture.host_env,
2222                algo: HashAlgo::Blake3,
2223                cancel: &fixture.cancel,
2224            }
2225        }
2226
2227        #[tokio::test]
2228        async fn cache_miss_success_records_observer_events_and_persists_manifest() {
2229            let fixture = Fixture::new(command(&["true"]), EnvSettings::default(), Vec::new());
2230            let spawner = MockProcessSpawner::new();
2231            spawner.push_spec(MockSpec {
2232                stdout: b"out\n".to_vec(),
2233                stderr: b"err\n".to_vec(),
2234                exit_code: 0,
2235                ..MockSpec::default()
2236            });
2237            let observer = Recorder::default();
2238            let ctx = make_ctx(&fixture, &spawner, &observer);
2239
2240            let outcome = run_task(&ctx, &fixture.task_id, &BTreeMap::new(), 1_700_000_000)
2241                .await
2242                .expect("baseline run should succeed");
2243
2244            assert_eq!(outcome.source, RunSource::FreshRun);
2245            assert_eq!(outcome.state, RunState::Succeeded);
2246            assert!(outcome.exit_status.is_some_and(|s| s.success()));
2247            assert_eq!(outcome.task, fixture.task_id);
2248
2249            let events = observer.events();
2250            assert_eq!(events.len(), 4, "expected 4 events, got {events:?}");
2251            assert_eq!(events[0], RecordedEvent::Started(fixture.task_id.clone()));
2252            assert_eq!(
2253                events[1],
2254                RecordedEvent::Stdout(fixture.task_id.clone(), b"out\n".to_vec()),
2255            );
2256            assert_eq!(
2257                events[2],
2258                RecordedEvent::Stderr(fixture.task_id.clone(), b"err\n".to_vec()),
2259            );
2260            match &events[3] {
2261                RecordedEvent::Finished(id, finished_outcome) => {
2262                    assert_eq!(id, &fixture.task_id);
2263                    assert_eq!(finished_outcome, &outcome);
2264                }
2265                other => panic!("expected Finished event, got {other:?}"),
2266            }
2267        }
2268
2269        #[tokio::test]
2270        async fn cache_miss_then_second_call_hits_cache() {
2271            let fixture = Fixture::new(command(&["true"]), EnvSettings::default(), Vec::new());
2272            let spawner = MockProcessSpawner::new();
2273            spawner.push_spec(MockSpec {
2274                stdout: b"out\n".to_vec(),
2275                stderr: b"err\n".to_vec(),
2276                exit_code: 0,
2277                ..MockSpec::default()
2278            });
2279
2280            let observer1 = Recorder::default();
2281            let ctx1 = make_ctx(&fixture, &spawner, &observer1);
2282            let first = run_task(&ctx1, &fixture.task_id, &BTreeMap::new(), 1)
2283                .await
2284                .unwrap();
2285            assert_eq!(first.source, RunSource::FreshRun);
2286
2287            // Second call: same key, manifest present, expect hit.
2288            let observer2 = Recorder::default();
2289            let ctx2 = make_ctx(&fixture, &spawner, &observer2);
2290            let second = run_task(&ctx2, &fixture.task_id, &BTreeMap::new(), 2)
2291                .await
2292                .unwrap();
2293
2294            assert_eq!(second.source, RunSource::CacheHit);
2295            assert_eq!(second.state, RunState::Succeeded);
2296            assert!(second.exit_status.is_none());
2297            assert_eq!(second.stdout_hash, first.stdout_hash);
2298            assert_eq!(second.stderr_hash, first.stderr_hash);
2299
2300            // The hit branch surfaces the recorded streams through
2301            // the observer (EXEC-017).
2302            let events = observer2.events();
2303            assert!(
2304                events.iter().any(|e| matches!(
2305                    e,
2306                    RecordedEvent::Stdout(_, bytes) if bytes == b"out\n"
2307                )),
2308                "missing recorded stdout in hit-path events: {events:?}",
2309            );
2310            assert!(
2311                events.iter().any(|e| matches!(
2312                    e,
2313                    RecordedEvent::Stderr(_, bytes) if bytes == b"err\n"
2314                )),
2315                "missing recorded stderr in hit-path events: {events:?}",
2316            );
2317
2318            // The mock was only called once; the second run did NOT
2319            // respawn.
2320            assert_eq!(
2321                spawner.spawns().len(),
2322                1,
2323                "second call must not respawn, got {} total spawns",
2324                spawner.spawns().len(),
2325            );
2326        }
2327
2328        #[tokio::test]
2329        async fn cache_018_failed_run_does_not_store_a_cache_entry() {
2330            // CACHE-018: a run whose process exit status is non-zero
2331            // (or signalled, or timed out) MUST NOT produce a cache
2332            // entry. The cache root MUST remain absent and a follow-up
2333            // invocation MUST observe a fresh run (not a hit) at the
2334            // same key.
2335            let fixture = Fixture::new(command(&["false"]), EnvSettings::default(), Vec::new());
2336            let spawner = MockProcessSpawner::new();
2337            spawner.push_spec(MockSpec {
2338                stdout: Vec::new(),
2339                stderr: b"error\n".to_vec(),
2340                exit_code: 1,
2341                ..MockSpec::default()
2342            });
2343            let observer = Recorder::default();
2344            let ctx = make_ctx(&fixture, &spawner, &observer);
2345
2346            let outcome = run_task(&ctx, &fixture.task_id, &BTreeMap::new(), 1)
2347                .await
2348                .unwrap();
2349
2350            assert_eq!(outcome.source, RunSource::FreshRun);
2351            assert_eq!(outcome.state, RunState::Failed);
2352            assert!(outcome.exit_status.is_some_and(|s| !s.success()));
2353
2354            // The captured failure-side stderr still flowed through
2355            // the observer.
2356            assert!(
2357                observer.events().iter().any(|e| matches!(
2358                    e,
2359                    RecordedEvent::Stderr(_, bytes) if bytes == b"error\n"
2360                )),
2361                "failure-side stderr must reach the observer",
2362            );
2363
2364            // Direct CACHE-018 check: the cache root MUST NOT have
2365            // been created. CACHE-010 mandates lazy creation on first
2366            // store, and a failed run is not a store; the cache
2367            // directory therefore stays absent.
2368            let cache_root_meta = fixture.cache.fs().metadata(fixture.cache.cache_root());
2369            assert!(
2370                matches!(cache_root_meta, Err(FsError::NotFound { .. })),
2371                "cache root must remain absent after a failed run; got {cache_root_meta:?}",
2372            );
2373
2374            // Second call: nothing stored, expect another miss.
2375            spawner.push_spec(MockSpec {
2376                stdout: Vec::new(),
2377                stderr: b"again\n".to_vec(),
2378                exit_code: 1,
2379                ..MockSpec::default()
2380            });
2381            let observer2 = Recorder::default();
2382            let ctx2 = make_ctx(&fixture, &spawner, &observer2);
2383            let outcome2 = run_task(&ctx2, &fixture.task_id, &BTreeMap::new(), 2)
2384                .await
2385                .unwrap();
2386            assert_eq!(outcome2.source, RunSource::FreshRun);
2387            assert_eq!(spawner.spawns().len(), 2);
2388        }
2389
2390        #[tokio::test]
2391        async fn cache_miss_success_stores_outputs_and_restore_overwrites_target() {
2392            // The task declares one output; pre-write it to
2393            // simulate the command producing it. After a fresh run,
2394            // the cache holds the output; a subsequent hit restores
2395            // the cached bytes even when the workspace copy has been
2396            // tampered with.
2397            let fixture = Fixture::new(
2398                command(&["true"]),
2399                EnvSettings::default(),
2400                vec![OutputSpec::parse("artifact.bin").unwrap()],
2401            );
2402            fixture
2403                .cache
2404                .fs()
2405                .write_file(
2406                    std::path::Path::new("/ws/proj/artifact.bin"),
2407                    b"artifact-bytes",
2408                )
2409                .unwrap();
2410
2411            let spawner = MockProcessSpawner::new();
2412            spawner.push_spec(MockSpec::default());
2413            let observer = Recorder::default();
2414            let ctx = make_ctx(&fixture, &spawner, &observer);
2415
2416            let first = run_task(&ctx, &fixture.task_id, &BTreeMap::new(), 1)
2417                .await
2418                .unwrap();
2419            assert_eq!(first.source, RunSource::FreshRun);
2420            assert_eq!(first.state, RunState::Succeeded);
2421
2422            // Tamper with the target. The hit branch's restore
2423            // (CACHE-019) should overwrite this with the cached
2424            // bytes.
2425            fixture
2426                .cache
2427                .fs()
2428                .write_file(std::path::Path::new("/ws/proj/artifact.bin"), b"garbage")
2429                .unwrap();
2430
2431            let observer2 = Recorder::default();
2432            let ctx2 = make_ctx(&fixture, &spawner, &observer2);
2433            let second = run_task(&ctx2, &fixture.task_id, &BTreeMap::new(), 2)
2434                .await
2435                .unwrap();
2436            assert_eq!(second.source, RunSource::CacheHit);
2437
2438            let restored_bytes = fixture
2439                .cache
2440                .fs()
2441                .read(std::path::Path::new("/ws/proj/artifact.bin"))
2442                .unwrap();
2443            assert_eq!(restored_bytes, b"artifact-bytes");
2444        }
2445
2446        #[tokio::test]
2447        async fn cache_023_hit_is_observationally_equivalent_to_fresh_run() {
2448            // CACHE-023: a cache hit MUST produce, from the
2449            // perspective of every external observer that consults
2450            // only declared outputs and captured streams, a state
2451            // indistinguishable from a successful fresh run.
2452            //
2453            // Setup: one task with one declared output and a mock
2454            // command that emits non-empty stdout AND stderr. Run
2455            // once (miss -> fresh). Delete the output file so the
2456            // second run cannot accidentally observe stale bytes.
2457            // Run again (hit -> restore). Compare the three
2458            // externally-visible byte streams.
2459            const OUTPUT_BYTES: &[u8] = b"\x00built\x01artifact\xFF";
2460            const STDOUT_BYTES: &[u8] = b"hello from the task\n";
2461            const STDERR_BYTES: &[u8] = b"warning: be careful\n";
2462
2463            let fixture = Fixture::new(
2464                command(&["true"]),
2465                EnvSettings::default(),
2466                vec![OutputSpec::parse("artifact.bin").unwrap()],
2467            );
2468
2469            // Pre-write the workspace file so the fresh run can
2470            // hash it into the cache as the task's "produced"
2471            // output.
2472            let artifact_path = std::path::Path::new("/ws/proj/artifact.bin");
2473            fixture
2474                .cache
2475                .fs()
2476                .write_file(artifact_path, OUTPUT_BYTES)
2477                .unwrap();
2478
2479            let spawner = MockProcessSpawner::new();
2480            spawner.push_spec(MockSpec {
2481                stdout: STDOUT_BYTES.to_vec(),
2482                stderr: STDERR_BYTES.to_vec(),
2483                exit_code: 0,
2484                ..MockSpec::default()
2485            });
2486
2487            // Fresh run.
2488            let observer_fresh = Recorder::default();
2489            let ctx_fresh = make_ctx(&fixture, &spawner, &observer_fresh);
2490            let fresh = run_task(&ctx_fresh, &fixture.task_id, &BTreeMap::new(), 1)
2491                .await
2492                .unwrap();
2493            assert_eq!(fresh.source, RunSource::FreshRun);
2494            assert_eq!(fresh.state, RunState::Succeeded);
2495
2496            let fresh_output = fixture.cache.fs().read(artifact_path).unwrap();
2497            let fresh_stdout = concat_stdout(&observer_fresh.events());
2498            let fresh_stderr = concat_stderr(&observer_fresh.events());
2499
2500            // Overwrite the workspace artefact with distinctive
2501            // garbage so the second run cannot accidentally
2502            // observe stale-but-equal bytes; the assertion below
2503            // proves the restore put the cached bytes back. The
2504            // cache entry under .haz/cache/ is untouched.
2505            fixture
2506                .cache
2507                .fs()
2508                .write_file(artifact_path, b"garbage-should-be-overwritten")
2509                .unwrap();
2510
2511            // Second run: cache hit, restoration only.
2512            let observer_hit = Recorder::default();
2513            let ctx_hit = make_ctx(&fixture, &spawner, &observer_hit);
2514            let hit = run_task(&ctx_hit, &fixture.task_id, &BTreeMap::new(), 2)
2515                .await
2516                .unwrap();
2517            assert_eq!(hit.source, RunSource::CacheHit);
2518            assert_eq!(hit.state, RunState::Succeeded);
2519
2520            let hit_output = fixture.cache.fs().read(artifact_path).unwrap();
2521            let hit_stdout = concat_stdout(&observer_hit.events());
2522            let hit_stderr = concat_stderr(&observer_hit.events());
2523
2524            // The three externally-visible byte sequences MUST
2525            // match the fresh-run values exactly.
2526            assert_eq!(
2527                fresh_output, hit_output,
2528                "CACHE-023: declared output bytes differ between fresh and hit",
2529            );
2530            assert_eq!(
2531                fresh_stdout, hit_stdout,
2532                "CACHE-023: captured stdout differs between fresh and hit",
2533            );
2534            assert_eq!(
2535                fresh_stderr, hit_stderr,
2536                "CACHE-023: captured stderr differs between fresh and hit",
2537            );
2538
2539            // And the bytes themselves agree with the source-of-
2540            // truth values, not some accidentally-equal corruption.
2541            assert_eq!(hit_output, OUTPUT_BYTES);
2542            assert_eq!(hit_stdout, STDOUT_BYTES);
2543            assert_eq!(hit_stderr, STDERR_BYTES);
2544
2545            // Exactly one spawn happened (the fresh run); the hit
2546            // did not re-spawn.
2547            assert_eq!(spawner.spawns().len(), 1);
2548        }
2549
2550        fn concat_stdout(events: &[RecordedEvent]) -> Vec<u8> {
2551            let mut acc = Vec::new();
2552            for event in events {
2553                if let RecordedEvent::Stdout(_, bytes) = event {
2554                    acc.extend_from_slice(bytes);
2555                }
2556            }
2557            acc
2558        }
2559
2560        fn concat_stderr(events: &[RecordedEvent]) -> Vec<u8> {
2561            let mut acc = Vec::new();
2562            for event in events {
2563                if let RecordedEvent::Stderr(_, bytes) = event {
2564                    acc.extend_from_slice(bytes);
2565                }
2566            }
2567            acc
2568        }
2569
2570        #[tokio::test]
2571        async fn task_not_in_workspace_surfaces_build_key_failed() {
2572            let fixture = Fixture::new(command(&["true"]), EnvSettings::default(), Vec::new());
2573            let spawner = MockProcessSpawner::new();
2574            let observer = Recorder::default();
2575            let ctx = make_ctx(&fixture, &spawner, &observer);
2576
2577            let absent = task_id_for("absent_project", "build");
2578
2579            match run_task(&ctx, &absent, &BTreeMap::new(), 1).await {
2580                Err(RunTaskError::BuildKeyFailed {
2581                    source: BuildKeyError::TaskNotInWorkspace { task },
2582                }) => assert_eq!(task, absent),
2583                other => panic!("expected BuildKeyFailed(TaskNotInWorkspace), got {other:?}"),
2584            }
2585
2586            // on_task_started fires before the lookup phase: the
2587            // wrapper marks the lifecycle as entered before any
2588            // workspace resolution attempt. on_task_finished does
2589            // NOT fire because no CompletedRecord was produced.
2590            let events = observer.events();
2591            assert_eq!(
2592                events.len(),
2593                1,
2594                "expected one Started event, got {events:?}"
2595            );
2596            assert!(
2597                matches!(events[0], RecordedEvent::Started(ref id) if id == &absent),
2598                "expected Started(absent), got {events:?}",
2599            );
2600            // No spawn either.
2601            assert!(spawner.spawns().is_empty());
2602        }
2603
2604        #[tokio::test]
2605        async fn spawn_plan_env_reflects_cache_008_runtime_view() {
2606            let env = EnvSettings {
2607                from_host: BTreeSet::from([env_var("PATH"), env_var("MISSING")]),
2608                overrides: BTreeMap::from([
2609                    (env_var("HAZ_MODE"), "ci".to_owned()),
2610                    (env_var("PATH"), "/override/bin".to_owned()),
2611                ]),
2612            };
2613            let mut fixture = Fixture::new(command(&["true"]), env, Vec::new());
2614            fixture
2615                .host_env
2616                .insert(env_var("PATH"), "/usr/bin".to_owned());
2617            fixture
2618                .host_env
2619                .insert(env_var("UNRELATED"), "should-not-appear".to_owned());
2620
2621            let spawner = MockProcessSpawner::new();
2622            spawner.push_spec(MockSpec::default());
2623            let observer = Recorder::default();
2624            let ctx = make_ctx(&fixture, &spawner, &observer);
2625
2626            let _ = run_task(&ctx, &fixture.task_id, &BTreeMap::new(), 1)
2627                .await
2628                .unwrap();
2629
2630            let records = spawner.spawns();
2631            assert_eq!(records.len(), 1);
2632            let env_vec = &records[0].plan.env;
2633            let env_names: BTreeSet<String> = env_vec
2634                .iter()
2635                .map(|(k, _)| k.to_str().unwrap().to_owned())
2636                .collect();
2637            // Override-only entries propagate; from_host present
2638            // names propagate (with override winning on
2639            // collision); from_host absent names are excluded;
2640            // unrelated host names are excluded.
2641            assert_eq!(
2642                env_names,
2643                BTreeSet::from(["HAZ_MODE".to_owned(), "PATH".to_owned()]),
2644            );
2645
2646            let path_value = env_vec
2647                .iter()
2648                .find(|(k, _)| k.to_str() == Some("PATH"))
2649                .map(|(_, v)| v.to_str().unwrap().to_owned());
2650            assert_eq!(path_value.as_deref(), Some("/override/bin"));
2651        }
2652    }
2653}