Skip to main content

kranz_engine/
workspace_provider.rs

1//! The WorkspaceProvider seam (design D-B/D-E in
2//! `docs/scoping/workspace-contract.md`, ticket
3//! `.kranz/tickets/workspace-provider-seam.md`) — who supplies the mission's
4//! **runnable environment**, separate from [`crate::backend::AgentBackend`]
5//! (who drives model sessions) and from `sandbox.provider` (process
6//! containment; M7). A container may one day implement both Sandbox and
7//! Workspace, but the APIs stay separate: sandbox = blast radius, workspace =
8//! bootstrap/services/readiness/previews.
9//!
10//! The run loop drives the seam once per `run()` invocation, BEFORE the
11//! first worker/validator spawns:
12//!
13//! ```text
14//! provider.provision(spec) -> WorkspaceHandle      (workspace.provisioned)
15//! provider.readiness(handle) -> ReadinessOutcome   (the workspace gate; workspace.readiness)
16//!     … golden-data clone → migrate → bootstrap → readiness → skewCheck …
17//! … workers/validators run in handle.cwd …
18//!     … validation_round re-seeds via provider.run_data_hook(reset) when
19//!       the data block opts in (resetBetweenRounds) …
20//! provider.teardown(handle, mode)                  (workspace.teardown)
21//! ```
22//!
23//! This build ships three implementations:
24//!
25//! - [`LocalWorktreeProvider`] (v1):
26//!   - **Provision REUSES the existing isolation machinery — it does not
27//!     rebuild it.** In worktree mode `run()` has already created the mission
28//!     integration worktree (`setup_mission_worktree`) before the seam drive
29//!     runs; in checkout mode the repo root is the execution cwd. Provision
30//!     resolves that cwd into the handle and stamps the env sessions already
31//!     get (`KRANZ_BASE_SHA` via the [`crate::runner::contract_env`] idiom —
32//!     never secret values). Preview placeholders come from the contract's
33//!     `previews[]` with their URL templates UNFILLED (D-E: previews are
34//!     artifacts once the services behind them are ready; v1 records the
35//!     placeholder, never a fabricated URL).
36//!   - **Readiness IS the workspace bootstrap + readiness gate** (design D-C):
37//!     the gate's phase execution moved under this seam
38//!     ([`crate::workspace_gate`] keeps the helpers and the block/lift
39//!     policy), so the provision path has a single owner. Behavior is
40//!     byte-identical to the pre-seam gate: same block reasons, same
41//!     `orchestrator.decision` start/pass/fail events, plus the additive
42//!     `workspace.*` lifecycle events alongside.
43//!   - **Teardown: local-worktree is ALWAYS [`TeardownMode::Keep`],**
44//!     even when `workspace.teardownMode` configures hibernate/destroy —
45//!     the integration worktree's filesystem lifecycle stays with the
46//!     existing mission-branch/merge machinery (merge semantics
47//!     unchanged). A `workspace.teardown` event records the provider call
48//!     and its outcome, not the filesystem outcome.
49//! - [`crate::workspace_container::LocalContainerProvider`] (ticket
50//!   `local-container-workspace`): a per-mission compose project with
51//!   dynamic ports and contract health/readiness inside the container
52//!   network. See that module's docs for the network model, port policy,
53//!   and real Hibernate/Destroy teardown semantics.
54//! - [`crate::workspace_remote::RemoteWorkspaceProvider`] (ticket
55//!   `workspace-remote-coder-provider`): a thin adapter over a Coder-shaped
56//!   substrate (injectable [`crate::workspace_remote::SubstrateClient`]) —
57//!   provision from a pinned template, substrate-reported readiness,
58//!   preview/takeover URLs, secret NAMES injected by the substrate. See that
59//!   module's docs for the config gate, owner taxonomy, and v1 honesty
60//!   notes.
61//!
62//! The two local providers share the gate phase shapes below: `run_gate_phase` (host
63//! execution) and [`report_gate_outcomes`] (the pass/fail decision lines the
64//! container provider reuses after running the same commands via
65//! `compose exec`).
66//!
67//! Provider selection: additive mission config `workspace.provider`
68//! (absent = `local-worktree`). Unknown names FAIL CLOSED via [`resolve`] —
69//! at plan approval (the [`pin`] consent artifact) AND again at run start —
70//! never a silent fallback to local. `"remote"` additionally requires its
71//! `workspace.remote.*` config block complete, failing closed with the
72//! missing key named. Runtime detection for `"container"`
73//! happens at PROVISION (run start, before any spend), keeping approval-time
74//! resolution/pinning pure: a runtime-less host fails closed at run start
75//! with the reason named.
76
77use crate::error::{EngineError, Result};
78use crate::events::EventKind;
79use crate::orchestrator::MissionEngine;
80use crate::types::{MissionStatus, WorkerIsolation, WorkspaceConfig, WorkspacePin};
81use crate::workspace_contract::WorkspaceContract;
82use crate::workspace_gate::{
83    self, CommandOutcome, GatePhase, BOOTSTRAP_SUMMARY_PREFIX, READINESS_SUMMARY_PREFIX,
84};
85use std::collections::HashMap;
86use std::path::PathBuf;
87
88/// The provider kinds this build knows: local-worktree, the local-container
89/// provider (ticket `local-container-workspace`), and the remote
90/// Coder-shaped substrate adapter (ticket `workspace-remote-coder-provider`).
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum WorkspaceProviderKind {
93    /// Today's isolation cwd: the mission integration worktree (worktree
94    /// mode) or the repo root (checkout mode).
95    LocalWorktree,
96    /// Per-mission compose project (dynamic ports, in-network readiness) —
97    /// [`crate::workspace_container::LocalContainerProvider`].
98    Container,
99    /// Thin Coder-shaped substrate adapter —
100    /// [`crate::workspace_remote::RemoteWorkspaceProvider`].
101    Remote,
102}
103
104impl WorkspaceProviderKind {
105    /// The wire/config name (`workspace.provider`, `workspace.provisioned`).
106    pub fn as_str(self) -> &'static str {
107        match self {
108            WorkspaceProviderKind::LocalWorktree => "local-worktree",
109            WorkspaceProviderKind::Container => "container",
110            WorkspaceProviderKind::Remote => "remote",
111        }
112    }
113}
114
115/// What `teardown` should do with the workspace. The engine drives the
116/// configured `workspace.teardownMode` (see [`teardown_mode`]) when a run
117/// reaches a TERMINAL state and [`TeardownMode::Keep`] otherwise — a
118/// blocked/paused mission keeps its workspace for resume. Local-worktree
119/// is always Keep regardless of the configured mode (see
120/// [`effective_teardown_mode`]).
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum TeardownMode {
123    /// Leave the workspace in place (resume, inspection, takeover).
124    Keep,
125    /// Provider-owned idle suspension (remote/container providers).
126    Hibernate,
127    /// Release the workspace entirely.
128    Destroy,
129}
130
131impl TeardownMode {
132    /// The wire name recorded in `workspace.teardown`.
133    pub fn as_str(self) -> &'static str {
134        match self {
135            TeardownMode::Keep => "keep",
136            TeardownMode::Hibernate => "hibernate",
137            TeardownMode::Destroy => "destroy",
138        }
139    }
140
141    /// The `workspace.teardown` outcome `state` recorded when the provider
142    /// call SUCCEEDS with this mode (ticket `workspace-idle-hibernate`).
143    pub fn success_state(self) -> &'static str {
144        match self {
145            TeardownMode::Keep => "kept",
146            TeardownMode::Hibernate => "stopped",
147            TeardownMode::Destroy => "destroyed",
148        }
149    }
150}
151
152/// Parse the additive `workspace.teardownMode` mission config (ticket
153/// `workspace-idle-hibernate`) into the mode the engine drives when a run
154/// reaches a TERMINAL state. Absent = [`TeardownMode::Keep`] (today's
155/// behavior). Unknown modes FAIL CLOSED with the config key and its
156/// operator owner named — never a silent default to keep (which would leak
157/// a workspace the operator meant destroyed, nor destroy one they meant
158/// kept). Validated at run start, before any side effect — the same
159/// fail-closed backstop [`resolve`] is for `workspace.provider`.
160pub fn teardown_mode(config: &WorkspaceConfig) -> Result<TeardownMode> {
161    match config.teardown_mode.as_deref() {
162        None => Ok(TeardownMode::Keep),
163        Some(name) if name == TeardownMode::Keep.as_str() => Ok(TeardownMode::Keep),
164        Some(name) if name == TeardownMode::Hibernate.as_str() => Ok(TeardownMode::Hibernate),
165        Some(name) if name == TeardownMode::Destroy.as_str() => Ok(TeardownMode::Destroy),
166        Some(other) => Err(EngineError::Config(format!(
167            "workspace.teardownMode {other:?} is not a known teardown mode \
168             (this build provides {:?}, {:?}, and {:?} only; owner: operator — fix the \
169             workspace.teardownMode config key); refusing rather than silently \
170             defaulting to {:?}",
171            TeardownMode::Keep.as_str(),
172            TeardownMode::Hibernate.as_str(),
173            TeardownMode::Destroy.as_str(),
174            TeardownMode::Keep.as_str()
175        ))),
176    }
177}
178
179/// The mode the engine actually drives at the end of a run (ticket
180/// `workspace-idle-hibernate`): the configured `workspace.teardownMode`
181/// when the run ended TERMINAL (Complete/Failed/Abandoned), else Keep —
182/// a blocked/paused mission keeps its workspace for resume.
183/// Local-worktree is ALWAYS Keep regardless of the configured mode: its
184/// filesystem lifecycle belongs to the mission-branch/merge machinery,
185/// so a configured hibernate/destroy records an honest `keep`.
186pub(crate) fn effective_teardown_mode(
187    kind: WorkspaceProviderKind,
188    run_terminal: bool,
189    configured: TeardownMode,
190) -> TeardownMode {
191    match kind {
192        WorkspaceProviderKind::LocalWorktree => TeardownMode::Keep,
193        WorkspaceProviderKind::Container | WorkspaceProviderKind::Remote if run_terminal => {
194            configured
195        }
196        WorkspaceProviderKind::Container | WorkspaceProviderKind::Remote => TeardownMode::Keep,
197    }
198}
199
200/// The OPERATOR-owned half of every gate command's environment, carried from
201/// mission config through [`ProvisionSpec`] onto the [`WorkspaceHandle`] so
202/// each of the four contract-declared command lanes (bootstrap, readiness,
203/// data hooks, `disk.prune`) builds the same env from the same inputs.
204#[derive(Debug, Clone)]
205pub struct GateEnvPolicy {
206    /// The ONE scratch `HOME`/`TMPDIR`/`CARGO_HOME` every gate phase of this
207    /// mission run shares (follow-up review, M-1: a per-phase home deleted
208    /// bootstrap's output before readiness could see it).
209    pub home: PathBuf,
210    /// The operator's `contractEnvPassthrough` — the second party in the
211    /// two-party consent a contract `secrets[]` name needs before it crosses
212    /// (follow-up review, H-6). Operator-only by construction: the key is in
213    /// `config.rs`'s `PROJECT_LAYER_REFUSED`, so repo content cannot set it.
214    pub passthrough: Vec<String>,
215}
216
217impl GateEnvPolicy {
218    /// The policy for one mission run: the gate home under the mission's own
219    /// writable `runs/` dir, plus the operator's passthrough list.
220    pub fn for_mission(runtime_dir: &std::path::Path, passthrough: &[String]) -> Self {
221        Self {
222            home: crate::workspace_gate::mission_gate_home(runtime_dir),
223            passthrough: passthrough.to_vec(),
224        }
225    }
226}
227
228/// Everything a provider needs to provision one mission's workspace.
229#[derive(Debug, Clone)]
230pub struct ProvisionSpec {
231    pub mission_id: String,
232    /// The mission execution root — the integration worktree in worktree
233    /// mode, the repo root in checkout mode, resolved by `run()`'s existing
234    /// isolation machinery (the local provider reuses it, never recreates
235    /// it).
236    pub repo_root: PathBuf,
237    /// The mission-owned runtime dir (`.kranz/missions/<id>/` on the primary
238    /// side, gitignored): container providers write their compose project
239    /// files under it — never inside the worktree, whose lifecycle belongs
240    /// to the mission-branch machinery. Unused by the local provider.
241    pub runtime_dir: PathBuf,
242    /// Base SHA pinned at approval; the handle env carries it as
243    /// `KRANZ_BASE_SHA` (the `contract_env` idiom every contract-command
244    /// execution context shares).
245    pub base_sha: Option<String>,
246    /// The workspace contract read from the live base branch, when present
247    /// (`None` = today's worktree-only behavior, readiness trivially ready).
248    pub contract: Option<WorkspaceContract>,
249    /// The operator-owned gate env inputs every provider copies onto its
250    /// handle (see [`GateEnvPolicy`]).
251    pub gate_env: GateEnvPolicy,
252}
253
254/// A contracted preview with its URL template UNFILLED — the services behind
255/// it do not exist yet, so v1 never fabricates a URL (D-E).
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct PreviewPlaceholder {
258    pub name: String,
259    pub url_template: String,
260}
261
262/// The provisioned workspace: what sessions run against. Carries everything
263/// `readiness`/`teardown` need, so the provider stays stateless across the
264/// three calls (a future remote provider's handle would carry the workspace
265/// id / connection info instead).
266#[derive(Debug, Clone)]
267pub struct WorkspaceHandle {
268    /// Session cwd (the mission execution root).
269    pub cwd: PathBuf,
270    /// Extra env for sessions in this workspace — `KRANZ_BASE_SHA` when a
271    /// base SHA was pinned, exactly what validation-contract commands get
272    /// today. Never secret values.
273    pub env: HashMap<String, String>,
274    /// Contract previews. Local-worktree keeps the URL templates UNFILLED
275    /// (D-E); the container provider substitutes `{port}` ONLY with an
276    /// actually-assigned dynamic host port (never fabricated).
277    pub previews: Vec<PreviewPlaceholder>,
278    /// The contract this workspace was provisioned against, so `readiness`
279    /// executes exactly what `provision` saw.
280    pub contract: Option<WorkspaceContract>,
281    /// Provider-specific detail recorded on `workspace.provisioned` — the
282    /// container provider's compose project name. `None` for local-worktree
283    /// and for contract-less provisions.
284    pub detail: Option<String>,
285    /// Container-provider state (compose project/file, assigned ports);
286    /// `None` for local-worktree and contract-less provisions.
287    pub container: Option<crate::workspace_container::ContainerWorkspace>,
288    /// Remote-provider state (substrate workspace id/name, takeover URL,
289    /// name-matched previews, poll outcome); `None` for local kinds and
290    /// contract-less provisions.
291    pub remote: Option<crate::workspace_remote::RemoteWorkspace>,
292    /// The operator-owned gate env inputs, copied from the spec: the shared
293    /// gate HOME and the operator's `contractEnvPassthrough`. Every gate
294    /// command lane builds its env from this plus [`Self::env`] and the
295    /// contract (see `workspace_gate::gate_command_env`).
296    pub gate_env: GateEnvPolicy,
297}
298
299/// What `readiness` concluded. `Ready` = spend may start (no contract, or
300/// bootstrap + readiness all passed). `Failed` carries the failing phase's
301/// kind ("bootstrap command" / "readiness check" / "data clone hook" /
302/// "data migrate hook") and first failing command outcome, so the engine can
303/// block with the gate's established reason shape.
304#[derive(Debug)]
305pub enum ReadinessOutcome {
306    Ready,
307    Failed {
308        kind: &'static str,
309        failed: CommandOutcome,
310    },
311    /// The data block's `skewCheck` failed: migration/version skew between
312    /// the golden dataset and the workspace code (design D-D). A DISTINCT
313    /// outcome from a readiness flake — the engine Blocks with the skew
314    /// reason (the migrate/reset hook named as the action), never with the
315    /// generic readiness shape.
316    DataSkew {
317        failed: CommandOutcome,
318    },
319    /// The provider/substrate itself failed (remote substrate reported the
320    /// workspace failed or never became ready inside the poll bound). A
321    /// DISTINCT outcome from a contract-command failure: the engine Blocks
322    /// with owner `provider` (never `repo-setup`), and the block is NOT
323    /// gate-prefixed — a later gate pass does not auto-lift it; the operator
324    /// unblocks after the substrate recovers.
325    ProviderFailed {
326        /// The provider's scrubbed failure detail (workspace name + reason).
327        detail: String,
328    },
329}
330
331/// The gate progress sink handed to [`WorkspaceProvider::readiness`]: the
332/// provider reports the gate's established start/pass/fail decision lines
333/// through it, and the engine folds them onto the `orchestrator.decision`
334/// audit channel byte-identically to the pre-seam gate (including WHEN they
335/// appear relative to the commands running).
336pub type ProgressSink<'a> = dyn FnMut(&str, Option<String>) -> Result<()> + Send + 'a;
337
338/// The seam: provision a runnable environment, prove it ready, tear it down.
339/// Distinct from [`crate::backend::AgentBackend`] — the backend drives model
340/// sessions INSIDE the workspace this trait supplies.
341#[async_trait::async_trait]
342pub trait WorkspaceProvider: Send + Sync {
343    /// Which kind this provider is (recorded in `workspace.provisioned`).
344    fn kind(&self) -> WorkspaceProviderKind;
345
346    /// Establish the workspace for one mission and return its handle.
347    async fn provision(&self, spec: &ProvisionSpec) -> Result<WorkspaceHandle>;
348
349    /// Prove the workspace ready for spend (see [`ProgressSink`]).
350    async fn readiness(
351        &self,
352        handle: &WorkspaceHandle,
353        progress: &mut ProgressSink<'_>,
354    ) -> Result<ReadinessOutcome>;
355
356    /// Tear the workspace down per `mode` (see [`TeardownMode`] for v1
357    /// local-worktree semantics).
358    async fn teardown(&self, handle: WorkspaceHandle, mode: TeardownMode) -> Result<()>;
359
360    /// Run one declared golden-data hook (design D-D) inside this workspace
361    /// and report its `workspace data:` decision line through `progress`.
362    /// Used by `readiness` (clone/migrate/skewCheck) and by the engine's
363    /// reset-between-rounds drive from `validation_round` — one method so
364    /// the reset fires through the same execution path as the provision-time
365    /// hooks. Returns the failing [`CommandOutcome`] only on failure.
366    ///
367    /// The default runs the command with the gate's env discipline (the
368    /// shared bounded shell runner in the handle's cwd, with the CLEARED
369    /// gate env `workspace_gate::gate_command_env` builds — the handle's env,
370    /// the mission's shared gate HOME, the fixed operational allowlist, and
371    /// only those `secrets[]` names the OPERATOR also granted; never the
372    /// engine's ambient environment; 2026-09-01 adversarial audit H4,
373    /// follow-up review H-6/M-1/M-2). The
374    /// container provider overrides to exec INSIDE the container network, so
375    /// data hooks never run on the host when a container workspace exists.
376    async fn run_data_hook(
377        &self,
378        handle: &WorkspaceHandle,
379        hook: crate::workspace_data::DataHookKind,
380        command: &str,
381        progress: &mut ProgressSink<'_>,
382    ) -> Result<Option<CommandOutcome>> {
383        let env = crate::workspace_gate::gate_command_env(
384            &handle.gate_env,
385            &handle.env,
386            handle.contract.as_ref(),
387        );
388        let (code, output_tail) =
389            crate::command_exec::run_shell_command_with_code_cleared(&handle.cwd, command, &env)
390                .await;
391        crate::workspace_data::hook_outcome(hook, command, code, output_tail, progress)
392    }
393}
394
395/// Resolve the configured `workspace.provider` into a provider instance.
396/// Absent (or `"local-worktree"`) selects today's local worktree;
397/// `"container"` selects the local-container provider; `"remote"` selects
398/// the Coder-shaped substrate adapter — ONLY with complete
399/// `workspace.remote.*` config, failing closed with the missing key named.
400/// Unknown names FAIL CLOSED with a clear error naming the
401/// `workspace.provider` config key and its operator owner — an unprovisioned
402/// run must never silently fall back to a provider the operator did not ask
403/// for. Resolution stays pure (no host detection, no env reads, no network):
404/// a runtime-less host selecting `"container"`, or a token-less environment
405/// selecting `"remote"`, fails closed at provision (run start, before any
406/// spend).
407pub fn resolve(config: &WorkspaceConfig) -> Result<Box<dyn WorkspaceProvider>> {
408    match config.provider.as_deref() {
409        None => Ok(Box::new(LocalWorktreeProvider)),
410        Some(name) if name == WorkspaceProviderKind::LocalWorktree.as_str() => {
411            Ok(Box::new(LocalWorktreeProvider))
412        }
413        Some(name) if name == WorkspaceProviderKind::Container.as_str() => Ok(Box::new(
414            crate::workspace_container::LocalContainerProvider::new(),
415        )),
416        Some(name) if name == WorkspaceProviderKind::Remote.as_str() => Ok(Box::new(
417            crate::workspace_remote::RemoteWorkspaceProvider::from_config(config.remote.as_ref())?,
418        )),
419        Some(other) => Err(EngineError::Config(format!(
420            "workspace.provider {other:?} is not a known workspace provider \
421             (this build provides {:?}, {:?}, and {:?} only; owner: operator — fix the \
422             workspace.provider config key); refusing rather than silently \
423             falling back",
424            WorkspaceProviderKind::LocalWorktree.as_str(),
425            WorkspaceProviderKind::Container.as_str(),
426            WorkspaceProviderKind::Remote.as_str()
427        ))),
428    }
429}
430
431/// Pin the effective workspace provider identity at plan approval (design
432/// D-B, ticket `workspace-provider-pin-at-approval`) — the consent artifact
433/// `approve_plan` emits as `workspace.provider.pinned` immediately before
434/// `plan.approved`. Resolution IS the seam's fail-closed [`resolve`], so an
435/// unknown `workspace.provider` name (or incomplete `workspace.remote.*`
436/// config) refuses here, at approval time, BEFORE any branch/commit side
437/// effect — a misspelled name never silently defaults (owner: operator).
438/// Local-worktree-only missions pin too: the pin makes the default explicit
439/// and honest (D-H: source isolation, not a runnable workspace).
440///
441/// Per-kind field meanings (see [`WorkspacePin`]): local kinds pin the
442/// isolation mode + contract schemaVersion; the remote kind pins the
443/// CONFIGURED substrate template/image id and the ADAPTER version — the pin
444/// stays pure, with no substrate contact at approval.
445pub fn pin(
446    config: &WorkspaceConfig,
447    isolation: WorkerIsolation,
448    contract: Option<&WorkspaceContract>,
449) -> Result<WorkspacePin> {
450    let resolved = resolve(config)?;
451    if resolved.kind() == WorkspaceProviderKind::Remote {
452        // resolve() already failed closed on incomplete remote config; the
453        // re-validation here is the same pure check.
454        let remote = crate::workspace_remote::RemoteConfig::require(config.remote.as_ref())?;
455        return Ok(WorkspacePin {
456            provider: resolved.kind().as_str().to_string(),
457            template: remote.template,
458            version: crate::workspace_remote::ADAPTER_VERSION.to_string(),
459        });
460    }
461    let template = match isolation {
462        WorkerIsolation::Worktree => "worktree",
463        WorkerIsolation::Checkout => "checkout",
464    };
465    let version = match contract {
466        Some(contract) => contract.schema_version.to_string(),
467        None => "none".to_string(),
468    };
469    Ok(WorkspacePin {
470        provider: resolved.kind().as_str().to_string(),
471        template: template.to_string(),
472        version,
473    })
474}
475
476/// The local-worktree provider (v1's only implementation): provisions
477/// today's isolation cwd via the existing worktree machinery, runs the
478/// workspace gate's bootstrap/readiness phases for readiness, and treats
479/// teardown as a recorded no-op (the worktree lifecycle stays with the
480/// mission-branch/merge machinery).
481pub struct LocalWorktreeProvider;
482
483#[async_trait::async_trait]
484impl WorkspaceProvider for LocalWorktreeProvider {
485    fn kind(&self) -> WorkspaceProviderKind {
486        WorkspaceProviderKind::LocalWorktree
487    }
488
489    async fn provision(&self, spec: &ProvisionSpec) -> Result<WorkspaceHandle> {
490        if !spec.repo_root.is_dir() {
491            return Err(EngineError::InvalidState(format!(
492                "local-worktree provision: execution cwd {} does not exist",
493                spec.repo_root.display()
494            )));
495        }
496        let env = crate::runner::contract_env(spec.base_sha.as_deref());
497        let previews = spec
498            .contract
499            .iter()
500            .flat_map(|contract| &contract.previews)
501            .map(|preview| PreviewPlaceholder {
502                name: preview.name.clone(),
503                url_template: preview.url_template.clone(),
504            })
505            .collect();
506        Ok(WorkspaceHandle {
507            cwd: spec.repo_root.clone(),
508            env,
509            previews,
510            contract: spec.contract.clone(),
511            detail: None,
512            container: None,
513            remote: None,
514            gate_env: spec.gate_env.clone(),
515        })
516    }
517
518    async fn readiness(
519        &self,
520        handle: &WorkspaceHandle,
521        progress: &mut ProgressSink<'_>,
522    ) -> Result<ReadinessOutcome> {
523        let Some(contract) = &handle.contract else {
524            // No contract: the gate is a no-op (byte-identical pre-gate
525            // behavior) — trivially ready, no progress lines.
526            return Ok(ReadinessOutcome::Ready);
527        };
528
529        // 0. golden data clone/migrate (design D-D) — after provision,
530        //    before bootstrap. Undeclared hooks skip silently. A failure
531        //    folds into the gate's block shape with a data-hook kind.
532        if let Some(data) = &contract.data {
533            for (hook, command) in [
534                (crate::workspace_data::DataHookKind::Clone, &data.clone),
535                (crate::workspace_data::DataHookKind::Migrate, &data.migrate),
536            ] {
537                if let Some(command) = command {
538                    if let Some(failed) =
539                        self.run_data_hook(handle, hook, command, progress).await?
540                    {
541                        return Ok(ReadinessOutcome::Failed {
542                            kind: hook.gate_kind(),
543                            failed,
544                        });
545                    }
546                }
547            }
548        }
549
550        // 1. bootstrap — ordered, stop at first failure. Commands run with
551        //    the same CLEARED env discipline as validation-contract commands
552        //    (`workspace_gate::gate_command_env`): the mission's shared gate
553        //    HOME, the toolchain cache locations, the handle's
554        //    `KRANZ_BASE_SHA`, the fixed operational allowlist, and only the
555        //    `secrets[]` names the operator also granted. Both clauses of the
556        //    old comment here ("inherited env", "the merge gates' stripped
557        //    env is deliberately NOT used") stopped being true with H4.
558        if let Some(failed) = run_gate_phase(
559            &GatePhase {
560                kind: "bootstrap command",
561                unit: "command",
562                plural: "commands",
563                prefix: BOOTSTRAP_SUMMARY_PREFIX,
564                commands: &contract.bootstrap,
565                stop_at_first_failure: true,
566            },
567            handle,
568            progress,
569        )
570        .await?
571        {
572            return Ok(ReadinessOutcome::Failed {
573                kind: "bootstrap command",
574                failed,
575            });
576        }
577
578        // 2. readiness — every check runs; all must pass.
579        if let Some(failed) = run_gate_phase(
580            &GatePhase {
581                kind: "readiness check",
582                unit: "check",
583                plural: "checks",
584                prefix: READINESS_SUMMARY_PREFIX,
585                commands: &contract.readiness,
586                stop_at_first_failure: false,
587            },
588            handle,
589            progress,
590        )
591        .await?
592        {
593            return Ok(ReadinessOutcome::Failed {
594                kind: "readiness check",
595                failed,
596            });
597        }
598
599        // 3. golden data skewCheck (design D-D) — the last readiness step.
600        //    Its failure is the SKEW case: a distinct outcome the engine
601        //    Blocks on with the migrate/reset action named, never a generic
602        //    readiness failure.
603        if let Some(command) = contract
604            .data
605            .as_ref()
606            .and_then(|data| data.skew_check.as_ref())
607        {
608            if let Some(failed) = self
609                .run_data_hook(
610                    handle,
611                    crate::workspace_data::DataHookKind::SkewCheck,
612                    command,
613                    progress,
614                )
615                .await?
616            {
617                return Ok(ReadinessOutcome::DataSkew { failed });
618            }
619        }
620
621        Ok(ReadinessOutcome::Ready)
622    }
623
624    async fn teardown(&self, _handle: WorkspaceHandle, _mode: TeardownMode) -> Result<()> {
625        // The engine only ever drives Keep here (effective_teardown_mode:
626        // local-worktree is always Keep); any mode is a recorded no-op
627        // regardless — the integration worktree's lifecycle stays with the
628        // existing mission-branch/merge machinery.
629        Ok(())
630    }
631}
632
633/// Run one gate phase (bootstrap or readiness), reporting the start and
634/// pass/fail lines through `progress` at the same points the pre-seam gate
635/// emitted its decisions. Returns the first failing [`CommandOutcome`]
636/// (bootstrap: also the last one run; readiness: the first of possibly
637/// several, all of which ran).
638async fn run_gate_phase(
639    phase: &GatePhase<'_>,
640    handle: &WorkspaceHandle,
641    progress: &mut ProgressSink<'_>,
642) -> Result<Option<CommandOutcome>> {
643    progress(
644        &format!(
645            "{} running {} {}",
646            phase.prefix,
647            phase.commands.len(),
648            phase.plural
649        ),
650        None,
651    )?;
652    let outcomes = workspace_gate::run_gate_commands(
653        &handle.cwd,
654        phase,
655        &handle.gate_env,
656        &handle.env,
657        handle.contract.as_ref(),
658    )
659    .await;
660    report_gate_outcomes(phase, outcomes, progress)
661}
662
663/// The pass/fail half of a gate phase, split from execution so the
664/// container provider — which runs the same phases via `compose exec`
665/// instead of on the host — reports byte-identical decision lines. Takes
666/// the phase's already-computed outcomes; returns the first failing one.
667pub(crate) fn report_gate_outcomes(
668    phase: &GatePhase<'_>,
669    outcomes: Vec<CommandOutcome>,
670    progress: &mut ProgressSink<'_>,
671) -> Result<Option<CommandOutcome>> {
672    let n = phase.commands.len();
673    match outcomes.iter().find(|o| !o.ok()) {
674        None => {
675            progress(
676                &format!("{} {n}/{n} {} ok", phase.prefix, phase.plural),
677                Some(workspace_gate::outcomes_detail(phase.kind, &outcomes)),
678            )?;
679            Ok(None)
680        }
681        Some(failed) => {
682            progress(
683                &format!(
684                    "{} FAILED at {} {}/{n} — blocking mission (owner: repo-setup)",
685                    phase.prefix, phase.unit, failed.ordinal
686                ),
687                Some(workspace_gate::outcomes_detail(phase.kind, &outcomes)),
688            )?;
689            Ok(Some(failed.clone()))
690        }
691    }
692}
693
694impl MissionEngine {
695    /// The seam drive (design D-B/D-C), run once at the top of `run_loop`
696    /// before any worker/validator spawns: provider.provision →
697    /// provider.readiness (= the workspace gate) → workers. Returns
698    /// `Ok(None)` when the run may proceed; `Ok(Some(Blocked))` when a gate
699    /// failure blocked the first incomplete milestone with owner
700    /// `repo-setup`.
701    ///
702    /// Byte-identical to the pre-seam gate's semantics — same block reasons,
703    /// same `orchestrator.decision` events — PLUS the additive `workspace.*`
704    /// lifecycle events (D-E): `workspace.provisioned` on every run (the
705    /// workspace exists, contract or not), `workspace.readiness` only when a
706    /// contract drove a real bootstrap/readiness execution (a contract-less
707    /// run must not imply a runnable environment — D-H).
708    pub(crate) async fn provision_workspace(
709        &mut self,
710        provider: &dyn WorkspaceProvider,
711    ) -> Result<Option<MissionStatus>> {
712        // The contract is read from the LIVE BASE branch (merge.rs's
713        // `live_base_sha` idiom): base-branch-owned in BOTH isolation modes
714        // (a mission branch cannot weaken the contract that gates its own
715        // spend), and a committed operator fix on base is picked up on
716        // resume.
717        let base_branch = self.state.mission.base_branch.clone();
718        let contract =
719            crate::workspace_contract::load_workspace_contract_at_ref(&self.repo, &base_branch)?;
720        let runtime_dir = self.paths.mission_dir();
721        // The operator's `contractEnvPassthrough` is the second party in the
722        // two-party consent a contract `secrets[]` name needs (follow-up
723        // review, H-6): repo content declares what its commands need, the
724        // operator declares what may leave the host, and only the
725        // intersection crosses. The key is operator-only by construction —
726        // `config.rs`'s PROJECT_LAYER_REFUSED rejects it from the project
727        // layer for exactly this reason.
728        let spec = ProvisionSpec {
729            mission_id: self.state.mission.id.clone(),
730            repo_root: self.active_root().to_path_buf(),
731            gate_env: GateEnvPolicy::for_mission(
732                &runtime_dir,
733                &self.state.config.contract_env_passthrough,
734            ),
735            runtime_dir,
736            base_sha: self.state.mission.base_sha.clone(),
737            contract,
738        };
739        let handle = provider.provision(&spec).await?;
740        self.emit(EventKind::WorkspaceProvisioned {
741            provider: provider.kind().as_str().to_string(),
742            cwd: handle.cwd.display().to_string(),
743            detail: handle.detail.clone(),
744            // Remote kind (ticket workspace-remote-coder-provider): the
745            // substrate's takeover URL and name-matched previews ride the
746            // provisioned event so the endpoint/report can surface them;
747            // absent for local kinds.
748            takeover: handle
749                .remote
750                .as_ref()
751                .and_then(|remote| remote.takeover.clone()),
752            previews: handle.remote.as_ref().map(|remote| remote.previews.clone()),
753        })?;
754        let has_contract = handle.contract.is_some();
755        let outcome = {
756            // The gate's start/pass/fail lines land on the established
757            // orchestrator.decision audit channel, scrubbed by emit_decision.
758            let mut progress = |summary: &str, detail: Option<String>| -> Result<()> {
759                self.emit_decision(summary, detail)
760            };
761            provider.readiness(&handle, &mut progress).await?
762        };
763        // The handle outlives readiness so run() can record teardown at the
764        // end of the run (a blocked run keeps its workspace for resume).
765        self.workspace_handle = Some(handle);
766        match outcome {
767            // No contract: byte-identical pre-gate behavior — no readiness
768            // artifact and no gate-owned block lifting.
769            ReadinessOutcome::Ready if !has_contract => Ok(None),
770            ReadinessOutcome::Ready => {
771                self.emit(EventKind::WorkspaceReadinessReport {
772                    outcome: "ready".to_string(),
773                    detail: None,
774                })?;
775                // Gate passed: lift a gate-owned block left by a previous
776                // run() (the operator fixed the environment; resume must not
777                // wedge on a block whose precondition is gone).
778                self.lift_gate_block()?;
779                Ok(None)
780            }
781            ReadinessOutcome::Failed { kind, failed } => {
782                self.emit(EventKind::WorkspaceReadinessReport {
783                    outcome: "failed".to_string(),
784                    // The gate's scrubbed reason shape (command, exit code,
785                    // owner, bounded tail) — the same string the block below
786                    // records; event-append scrubs again (defense in depth).
787                    detail: Some(workspace_gate::gate_block_reason(kind, &failed)),
788                })?;
789                self.block_on_gate_failure(kind, &failed)
790            }
791            ReadinessOutcome::DataSkew { failed } => {
792                // The SKEW case (design D-D): a distinct, owned Block — the
793                // reason names the data block's skewCheck, its exit, a
794                // scrubbed tail, the repo-setup owner, and the action (run
795                // the declared migrate/reset hook, then resume). Never a
796                // generic readiness failure and never a validator finding.
797                // The gate prefix keeps the block liftable on resume once
798                // the environment is fixed.
799                let data = self
800                    .workspace_handle
801                    .as_ref()
802                    .and_then(|handle| handle.contract.as_ref())
803                    .and_then(|contract| contract.data.clone());
804                let reason = crate::workspace_data::skew_block_reason(data.as_ref(), &failed);
805                self.emit(EventKind::WorkspaceReadinessReport {
806                    outcome: "skew".to_string(),
807                    detail: Some(reason.clone()),
808                })?;
809                self.block_with_gate_reason(reason)
810            }
811            ReadinessOutcome::ProviderFailed { detail } => {
812                // The PROVIDER-owned case (design D-C's owner taxonomy): the
813                // substrate itself failed — never a contract-command
814                // (repo-setup) failure and never a config (operator) one.
815                // The reason's distinct prefix keeps the block OUT of the
816                // gate's auto-lift path: the operator unblocks once the
817                // substrate recovers.
818                let reason = crate::workspace_remote::provider_block_reason(&detail);
819                self.emit(EventKind::WorkspaceReadinessReport {
820                    outcome: "failed".to_string(),
821                    detail: Some(reason.clone()),
822                })?;
823                self.block_with_gate_reason(reason)
824            }
825        }
826    }
827
828    /// Record provider teardown at the end of a `run()`, driving `mode` —
829    /// the [`effective_teardown_mode`] the caller selected (the configured
830    /// `workspace.teardownMode` on terminal runs, Keep otherwise;
831    /// local-worktree always Keep). The `workspace.teardown` event records
832    /// the ACTUAL mode driven and its outcome `state` (`kept`/`stopped`/
833    /// `destroyed`, or `failed`). Best-effort and never fatal, mirroring
834    /// `teardown_mission_worktree`: the run's result is already decided, so
835    /// a teardown failure is logged as a scrubbed decision (the workspace
836    /// may still be live — the operator is told to release it manually) and
837    /// recorded as `state: "failed"`, never propagated and never masking
838    /// the mission's outcome; an append failure is logged, not propagated.
839    ///
840    /// Note on Abandoned: an out-of-engine `kranz abandon` appends
841    /// `mission.abandoned` without a live provider handle, so no teardown
842    /// is driven there — a residual remote/container workspace's lifecycle
843    /// is the substrate's idle policy (`workspace.remote.idleAfterHours`)
844    /// or manual cleanup, never kranz-scheduled.
845    pub(crate) async fn teardown_workspace(
846        &mut self,
847        provider: &dyn WorkspaceProvider,
848        mode: TeardownMode,
849    ) {
850        let Some(handle) = self.workspace_handle.take() else {
851            return; // never provisioned this run (e.g. resolve/provision failed)
852        };
853        // The mission's ONE shared gate home (follow-up review, M-1) is
854        // removed HERE, not per phase: teardown is the last point any
855        // contract-declared command can fire (`disk.prune` runs inside the
856        // provider call below), so anything earlier would delete a home a
857        // later phase still needs.
858        let gate_home = handle.gate_env.home.clone();
859        let state = match provider.teardown(handle, mode).await {
860            Ok(()) => mode.success_state(),
861            Err(e) => {
862                // The failure NEVER masks the run's outcome: record the
863                // reason as a decision (summary stable, the scrubbed
864                // provider error in the untruncated detail — the workspace
865                // may still be live, which cost tooling and the operator
866                // must see) and fold the outcome as failed below.
867                if let Err(append) = self.emit_decision(
868                    &format!(
869                        "workspace teardown ({}) failed — the mission outcome stands; the \
870                         workspace may still be live (owner: operator — release it manually)",
871                        mode.as_str()
872                    ),
873                    Some(e.to_string()),
874                ) {
875                    tracing::warn!(error = %append, "workspace teardown failure decision append failed");
876                }
877                "failed"
878            }
879        };
880        workspace_gate::remove_gate_home(&gate_home);
881        if let Err(e) = self.emit(EventKind::WorkspaceTeardown {
882            mode: mode.as_str().to_string(),
883            state: Some(state.to_string()),
884        }) {
885            tracing::warn!(error = %e, "workspace.teardown append failed");
886        }
887    }
888}
889
890// ---------------------------------------------------------------------------
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895    use crate::test_shell::{file_exists, if_file_exists, write_line};
896    use crate::workspace_contract::parse_workspace_contract;
897
898    fn ws_config(provider: Option<&str>) -> WorkspaceConfig {
899        WorkspaceConfig {
900            provider: provider.map(str::to_string),
901            remote: None,
902            teardown_mode: None,
903        }
904    }
905
906    fn remote_block() -> crate::types::RemoteWorkspaceConfig {
907        crate::types::RemoteWorkspaceConfig {
908            base_url: Some("https://coder.internal.example.com".to_string()),
909            template: Some("tmpl-baked-ami".to_string()),
910            token_env: Some("CODER_SESSION_TOKEN".to_string()),
911            idle_after_hours: None,
912        }
913    }
914
915    fn spec(
916        root: PathBuf,
917        base_sha: Option<&str>,
918        contract: Option<WorkspaceContract>,
919    ) -> ProvisionSpec {
920        spec_with_passthrough(root, base_sha, contract, &[])
921    }
922
923    fn spec_with_passthrough(
924        root: PathBuf,
925        base_sha: Option<&str>,
926        contract: Option<WorkspaceContract>,
927        passthrough: &[String],
928    ) -> ProvisionSpec {
929        let runtime_dir = root.join(".kranz").join("missions").join("m-test");
930        ProvisionSpec {
931            mission_id: "m-test".to_string(),
932            gate_env: GateEnvPolicy::for_mission(&runtime_dir, passthrough),
933            runtime_dir,
934            repo_root: root,
935            base_sha: base_sha.map(|s| s.to_string()),
936            contract,
937        }
938    }
939
940    fn contract(json: &[u8]) -> WorkspaceContract {
941        parse_workspace_contract(json).expect("valid contract")
942    }
943
944    /// Collect progress lines the gate would emit.
945    #[derive(Default)]
946    struct Progress(Vec<(String, Option<String>)>);
947
948    impl Progress {
949        fn sink(&mut self) -> impl FnMut(&str, Option<String>) -> Result<()> + Send + use<'_> {
950            |summary, detail| {
951                self.0.push((summary.to_string(), detail));
952                Ok(())
953            }
954        }
955
956        fn summaries(&self) -> Vec<&str> {
957            self.0.iter().map(|(s, _)| s.as_str()).collect()
958        }
959    }
960
961    #[test]
962    fn resolve_defaults_to_local_worktree_and_fails_closed_on_unknown_names() {
963        assert_eq!(
964            resolve(&ws_config(None))
965                .expect("absent = local-worktree")
966                .kind(),
967            WorkspaceProviderKind::LocalWorktree
968        );
969        assert_eq!(
970            resolve(&ws_config(Some("local-worktree")))
971                .expect("explicit local-worktree")
972                .kind(),
973            WorkspaceProviderKind::LocalWorktree
974        );
975        for unknown in ["coder", "local-container", "Local-Worktree"] {
976            let err = resolve(&ws_config(Some(unknown)))
977                .err()
978                .expect("unknown providers fail closed");
979            let msg = err.to_string();
980            assert!(msg.contains("workspace.provider"), "{msg}");
981            assert!(msg.contains(&format!("{unknown:?}")), "{msg}");
982            assert!(msg.contains("\"local-worktree\""), "{msg}");
983            assert!(msg.contains("\"container\""), "{msg}");
984            assert!(msg.contains("\"remote\""), "{msg}");
985            assert!(msg.contains("only"), "{msg}");
986            assert!(msg.contains("owner: operator"), "{msg}");
987        }
988    }
989
990    /// `"container"` resolves to the local-container provider (ticket
991    /// `local-container-workspace`) — purely, with no host runtime
992    /// detection, so approval-time pinning works on runtime-less hosts and
993    /// the no-runtime refusal lands at provision (run start, before spend).
994    #[test]
995    fn resolve_container_picks_the_local_container_provider() {
996        assert_eq!(
997            resolve(&ws_config(Some("container")))
998                .expect("container is a known provider")
999                .kind(),
1000            WorkspaceProviderKind::Container
1001        );
1002        assert_eq!(WorkspaceProviderKind::Container.as_str(), "container");
1003    }
1004
1005    /// `"remote"` resolves to the substrate adapter ONLY with complete
1006    /// `workspace.remote.*` config; each missing key (or an absent `remote`
1007    /// block) fails CLOSED with the key named and the operator owner —
1008    /// never a silent fallback to local (ticket
1009    /// `workspace-remote-coder-provider`).
1010    #[test]
1011    fn remote_workspace_resolve_picks_the_adapter_only_with_complete_config() {
1012        let complete = WorkspaceConfig {
1013            provider: Some("remote".to_string()),
1014            remote: Some(remote_block()),
1015            teardown_mode: None,
1016        };
1017        assert_eq!(
1018            resolve(&complete)
1019                .expect("complete remote config resolves")
1020                .kind(),
1021            WorkspaceProviderKind::Remote
1022        );
1023        assert_eq!(WorkspaceProviderKind::Remote.as_str(), "remote");
1024
1025        for (remote, missing_key) in [
1026            (None, "workspace.remote.baseUrl"),
1027            (
1028                Some(crate::types::RemoteWorkspaceConfig {
1029                    template: Some("tmpl".to_string()),
1030                    token_env: Some("CODER_SESSION_TOKEN".to_string()),
1031                    ..Default::default()
1032                }),
1033                "workspace.remote.baseUrl",
1034            ),
1035            (
1036                Some(crate::types::RemoteWorkspaceConfig {
1037                    base_url: Some("https://coder.internal.example.com".to_string()),
1038                    token_env: Some("CODER_SESSION_TOKEN".to_string()),
1039                    ..Default::default()
1040                }),
1041                "workspace.remote.template",
1042            ),
1043            (
1044                Some(crate::types::RemoteWorkspaceConfig {
1045                    base_url: Some("https://coder.internal.example.com".to_string()),
1046                    template: Some("tmpl".to_string()),
1047                    ..Default::default()
1048                }),
1049                "workspace.remote.tokenEnv",
1050            ),
1051        ] {
1052            let config = WorkspaceConfig {
1053                provider: Some("remote".to_string()),
1054                remote,
1055                teardown_mode: None,
1056            };
1057            let err = resolve(&config)
1058                .err()
1059                .expect("incomplete remote config fails closed");
1060            let msg = err.to_string();
1061            assert!(msg.contains(missing_key), "names the missing key: {msg}");
1062            assert!(msg.contains("owner: operator"), "{msg}");
1063            assert!(
1064                msg.contains("refusing rather than silently falling back"),
1065                "{msg}"
1066            );
1067        }
1068    }
1069
1070    /// `workspace.teardownMode` (ticket `workspace-idle-hibernate`): absent
1071    /// and the three known modes parse; unknown modes fail closed naming the
1072    /// config key, the bad value, the known modes, and the operator owner —
1073    /// never a silent default.
1074    #[test]
1075    fn teardown_mode_config_parses_modes_and_refuses_unknown_names() {
1076        assert_eq!(
1077            teardown_mode(&ws_config(None)).expect("absent = keep"),
1078            TeardownMode::Keep
1079        );
1080        for (name, expected) in [
1081            ("keep", TeardownMode::Keep),
1082            ("hibernate", TeardownMode::Hibernate),
1083            ("destroy", TeardownMode::Destroy),
1084        ] {
1085            let config = WorkspaceConfig {
1086                teardown_mode: Some(name.to_string()),
1087                ..ws_config(None)
1088            };
1089            assert_eq!(teardown_mode(&config).expect("known mode parses"), expected);
1090        }
1091        // The wire shape (camelCase) parses from mission config JSON.
1092        let config: WorkspaceConfig =
1093            serde_json::from_str(r#"{"teardownMode": "hibernate"}"#).expect("config JSON parses");
1094        assert_eq!(
1095            teardown_mode(&config).expect("wire mode parses"),
1096            TeardownMode::Hibernate
1097        );
1098
1099        for unknown in ["stop", "Hibernate", "down", "pause"] {
1100            let config = WorkspaceConfig {
1101                teardown_mode: Some(unknown.to_string()),
1102                ..ws_config(None)
1103            };
1104            let err = teardown_mode(&config).expect_err("unknown teardown modes fail closed");
1105            let msg = err.to_string();
1106            assert!(msg.contains("workspace.teardownMode"), "{msg}");
1107            assert!(msg.contains(&format!("{unknown:?}")), "{msg}");
1108            assert!(msg.contains("\"keep\""), "{msg}");
1109            assert!(msg.contains("\"hibernate\""), "{msg}");
1110            assert!(msg.contains("\"destroy\""), "{msg}");
1111            assert!(msg.contains("only"), "{msg}");
1112            assert!(msg.contains("owner: operator"), "{msg}");
1113        }
1114    }
1115
1116    /// The mode the engine actually drives (ticket `workspace-idle-hibernate`):
1117    /// the configured mode ONLY at terminal run ends, Keep otherwise — and
1118    /// local-worktree ALWAYS Keep regardless of the configured mode, because
1119    /// the integration worktree's filesystem lifecycle belongs to the
1120    /// mission-branch/merge machinery (a configured hibernate/destroy on a
1121    /// local-worktree mission records an honest `keep`).
1122    #[test]
1123    fn effective_teardown_mode_is_terminal_gated_and_local_is_always_keep() {
1124        for kind in [
1125            WorkspaceProviderKind::Container,
1126            WorkspaceProviderKind::Remote,
1127        ] {
1128            assert_eq!(
1129                effective_teardown_mode(kind, true, TeardownMode::Hibernate),
1130                TeardownMode::Hibernate,
1131                "terminal drives the configured mode for {kind:?}"
1132            );
1133            assert_eq!(
1134                effective_teardown_mode(kind, true, TeardownMode::Destroy),
1135                TeardownMode::Destroy
1136            );
1137            assert_eq!(
1138                effective_teardown_mode(kind, true, TeardownMode::Keep),
1139                TeardownMode::Keep
1140            );
1141            for configured in [
1142                TeardownMode::Keep,
1143                TeardownMode::Hibernate,
1144                TeardownMode::Destroy,
1145            ] {
1146                assert_eq!(
1147                    effective_teardown_mode(kind, false, configured),
1148                    TeardownMode::Keep,
1149                    "a non-terminal end keeps the workspace for resume ({kind:?}, {configured:?})"
1150                );
1151            }
1152        }
1153        // Local-worktree: always Keep — terminal or not, configured or not.
1154        for run_terminal in [true, false] {
1155            for configured in [
1156                TeardownMode::Keep,
1157                TeardownMode::Hibernate,
1158                TeardownMode::Destroy,
1159            ] {
1160                assert_eq!(
1161                    effective_teardown_mode(
1162                        WorkspaceProviderKind::LocalWorktree,
1163                        run_terminal,
1164                        configured
1165                    ),
1166                    TeardownMode::Keep,
1167                    "local-worktree is always Keep (terminal={run_terminal}, {configured:?})"
1168                );
1169            }
1170        }
1171    }
1172
1173    /// The approval-time pin (D-B): local-worktree pins the isolation mode as
1174    /// its template and the contract's schemaVersion as its version (`"none"`
1175    /// without a contract) — the default made explicit and honest (D-H).
1176    /// Unknown provider names fail closed with the config key + owner named,
1177    /// exactly like run-start resolution.
1178    #[test]
1179    fn pin_records_isolation_mode_and_contract_version() {
1180        let contract = contract(br#"{"schemaVersion": 1, "readiness": ["exit 0"]}"#);
1181
1182        let pinned =
1183            pin(&ws_config(None), WorkerIsolation::Worktree, Some(&contract)).expect("pin");
1184        assert_eq!(
1185            pinned,
1186            WorkspacePin {
1187                provider: "local-worktree".to_string(),
1188                template: "worktree".to_string(),
1189                version: "1".to_string(),
1190            }
1191        );
1192
1193        let pinned = pin(
1194            &ws_config(Some("local-worktree")),
1195            WorkerIsolation::Checkout,
1196            Some(&contract),
1197        )
1198        .expect("explicit local-worktree pins too");
1199        assert_eq!(pinned.template, "checkout");
1200        assert_eq!(pinned.version, "1");
1201
1202        // No contract ⇒ the honest "none" version — never imply a contract
1203        // schema that does not exist.
1204        let pinned =
1205            pin(&ws_config(None), WorkerIsolation::Worktree, None).expect("pin without contract");
1206        assert_eq!(pinned.provider, "local-worktree");
1207        assert_eq!(pinned.version, "none");
1208
1209        let pinned = pin(
1210            &ws_config(Some("container")),
1211            WorkerIsolation::Worktree,
1212            Some(&contract),
1213        )
1214        .expect("container pins at approval (pure resolution)");
1215        assert_eq!(pinned.provider, "container");
1216        assert_eq!(pinned.version, "1");
1217
1218        let err = pin(&ws_config(Some("coder")), WorkerIsolation::Worktree, None)
1219            .expect_err("a misspelled provider never silently defaults");
1220        let msg = err.to_string();
1221        assert!(msg.contains("workspace.provider"), "{msg}");
1222        assert!(msg.contains("\"coder\""), "{msg}");
1223        assert!(msg.contains("owner: operator"), "{msg}");
1224    }
1225
1226    /// The remote pin (D-B, ticket `workspace-remote-coder-provider`):
1227    /// provider `"remote"`, template = the CONFIGURED substrate
1228    /// template/image id, version = the adapter version — all populated at
1229    /// approval, purely (no substrate contact). Incomplete remote config
1230    /// refuses approval with the missing key named.
1231    #[test]
1232    fn remote_workspace_pin_populates_provider_template_and_adapter_version() {
1233        let contract = contract(br#"{"schemaVersion": 1, "readiness": ["exit 0"]}"#);
1234        let config = WorkspaceConfig {
1235            provider: Some("remote".to_string()),
1236            remote: Some(remote_block()),
1237            teardown_mode: None,
1238        };
1239        let pinned = pin(&config, WorkerIsolation::Worktree, Some(&contract))
1240            .expect("remote pins at approval");
1241        assert_eq!(
1242            pinned,
1243            WorkspacePin {
1244                provider: "remote".to_string(),
1245                template: "tmpl-baked-ami".to_string(),
1246                version: "coder-v1".to_string(),
1247            },
1248            "configured template + adapter version, not the contract schema"
1249        );
1250
1251        let incomplete = WorkspaceConfig {
1252            provider: Some("remote".to_string()),
1253            remote: Some(crate::types::RemoteWorkspaceConfig {
1254                template: Some("tmpl".to_string()),
1255                token_env: Some("CODER_SESSION_TOKEN".to_string()),
1256                ..Default::default()
1257            }),
1258            teardown_mode: None,
1259        };
1260        let err = pin(&incomplete, WorkerIsolation::Worktree, Some(&contract))
1261            .expect_err("incomplete remote config refuses approval");
1262        assert!(
1263            err.to_string().contains("workspace.remote.baseUrl"),
1264            "{err}"
1265        );
1266    }
1267
1268    #[tokio::test]
1269    async fn local_worktree_provision_carries_cwd_env_previews_and_contract() {
1270        let dir = tempfile::tempdir().expect("tempdir");
1271        let contract = contract(
1272            br#"{
1273                "schemaVersion": 1,
1274                "readiness": ["echo ok"],
1275                "previews": [{ "name": "app", "urlTemplate": "http://localhost:{port}/" }]
1276            }"#,
1277        );
1278        let handle = LocalWorktreeProvider
1279            .provision(&spec(
1280                dir.path().to_path_buf(),
1281                Some("deadbeefcafe"),
1282                Some(contract),
1283            ))
1284            .await
1285            .expect("provision");
1286
1287        assert_eq!(handle.cwd, dir.path());
1288        assert_eq!(
1289            handle.env.get("KRANZ_BASE_SHA").map(String::as_str),
1290            Some("deadbeefcafe"),
1291            "the handle env carries the pinned base sha (contract_env idiom)"
1292        );
1293        assert_eq!(handle.env.len(), 1, "nothing but KRANZ_BASE_SHA");
1294        // Preview placeholders keep the URL template UNFILLED — never a
1295        // fabricated URL for services that do not exist (D-E).
1296        assert_eq!(
1297            handle.previews,
1298            vec![PreviewPlaceholder {
1299                name: "app".to_string(),
1300                url_template: "http://localhost:{port}/".to_string(),
1301            }]
1302        );
1303        assert!(handle.contract.is_some());
1304        assert!(
1305            handle.detail.is_none() && handle.container.is_none(),
1306            "local-worktree records no provider detail or container state"
1307        );
1308    }
1309
1310    #[tokio::test]
1311    async fn local_worktree_provision_without_base_sha_or_contract_is_minimal() {
1312        let dir = tempfile::tempdir().expect("tempdir");
1313        let handle = LocalWorktreeProvider
1314            .provision(&spec(dir.path().to_path_buf(), None, None))
1315            .await
1316            .expect("provision");
1317        assert!(handle.env.is_empty(), "no base sha pinned ⇒ no env");
1318        assert!(handle.previews.is_empty());
1319        assert!(handle.contract.is_none());
1320    }
1321
1322    #[tokio::test]
1323    async fn local_worktree_provision_refuses_a_missing_cwd() {
1324        let dir = tempfile::tempdir().expect("tempdir");
1325        let missing = dir.path().join("no-such-worktree");
1326        let err = LocalWorktreeProvider
1327            .provision(&spec(missing.clone(), None, None))
1328            .await
1329            .expect_err("a missing execution cwd is a provision error");
1330        assert!(err.to_string().contains("does not exist"), "{err}");
1331    }
1332
1333    #[tokio::test]
1334    async fn readiness_without_contract_is_ready_and_silent() {
1335        let dir = tempfile::tempdir().expect("tempdir");
1336        let handle = LocalWorktreeProvider
1337            .provision(&spec(dir.path().to_path_buf(), None, None))
1338            .await
1339            .expect("provision");
1340        let mut progress = Progress::default();
1341        let outcome = LocalWorktreeProvider
1342            .readiness(&handle, &mut progress.sink())
1343            .await
1344            .expect("readiness");
1345        assert!(matches!(outcome, ReadinessOutcome::Ready));
1346        assert!(
1347            progress.0.is_empty(),
1348            "no contract ⇒ no gate decision lines (byte-identical pre-gate behavior)"
1349        );
1350    }
1351
1352    /// Native-shell assertion that the handle's env reaches gate commands
1353    /// (`sh` on Unix, `cmd` on Windows — same idiom as the validation
1354    /// contract's command environment tests).
1355    fn base_sha_assertion_command(expected: &str) -> String {
1356        #[cfg(unix)]
1357        {
1358            format!("test \"$KRANZ_BASE_SHA\" = '{expected}'")
1359        }
1360        #[cfg(windows)]
1361        {
1362            format!("if \"%KRANZ_BASE_SHA%\"==\"{expected}\" (exit /b 0) else (exit /b 1)")
1363        }
1364    }
1365
1366    /// Readiness IS the gate (single owner of the provision path): the
1367    /// bootstrap phase runs before the readiness phase in the handle's cwd
1368    /// WITH the handle's env, and the progress lines are the gate's
1369    /// established shapes.
1370    #[tokio::test]
1371    async fn readiness_runs_bootstrap_then_readiness_with_gate_progress() {
1372        let dir = tempfile::tempdir().expect("tempdir");
1373        let contract_json = format!(
1374            r#"{{
1375                "schemaVersion": 1,
1376                "bootstrap": ["echo boot > .boot-marker"],
1377                "readiness": [{}]
1378            }}"#,
1379            serde_json::to_string(&base_sha_assertion_command("deadbeefcafe")).unwrap()
1380        );
1381        let contract = contract(contract_json.as_bytes());
1382        let handle = LocalWorktreeProvider
1383            .provision(&spec(
1384                dir.path().to_path_buf(),
1385                Some("deadbeefcafe"),
1386                Some(contract),
1387            ))
1388            .await
1389            .expect("provision");
1390        let mut progress = Progress::default();
1391        let outcome = LocalWorktreeProvider
1392            .readiness(&handle, &mut progress.sink())
1393            .await
1394            .expect("readiness");
1395
1396        assert!(
1397            matches!(outcome, ReadinessOutcome::Ready),
1398            "readiness passed ⇒ the KRANZ_BASE_SHA assertion saw the handle env: {outcome:?}"
1399        );
1400        assert!(
1401            dir.path().join(".boot-marker").exists(),
1402            "bootstrap ran in the provisioned cwd"
1403        );
1404        assert_eq!(
1405            progress.summaries(),
1406            vec![
1407                "workspace bootstrap: running 1 commands",
1408                "workspace bootstrap: 1/1 commands ok",
1409                "workspace readiness: running 1 checks",
1410                "workspace readiness: 1/1 checks ok",
1411            ]
1412        );
1413    }
1414
1415    /// H4 (2026-09-01 adversarial audit): workspace bootstrap / readiness /
1416    /// data-hook commands ran with `clear_env = false`, so
1417    /// `.kranz/workspace.json` — which executes at mission start, before any
1418    /// agent spawns, and which a merged worker commit can edit — got every
1419    /// ambient credential the engine holds. The contract's `secrets[]` list
1420    /// was the stated justification but filtered nothing: it was referenced
1421    /// only by the remote provider.
1422    #[cfg(unix)]
1423    #[tokio::test]
1424    async fn readiness_commands_see_only_the_contracts_declared_secrets() {
1425        let _guard = crate::agent_env::EnvTestGuard::engage(&[
1426            ("KRANZ_SECRET_TEST", "undeclared-and-must-not-cross"),
1427            ("GH_TOKEN", "ghp_poison"),
1428            ("DATABASE_URL", "postgres://declared"),
1429        ]);
1430
1431        let dir = tempfile::tempdir().expect("tempdir");
1432        let contract = contract(
1433            br#"{
1434                "schemaVersion": 1,
1435                "secrets": ["DATABASE_URL"],
1436                "readiness": [
1437                  "test -z \"$KRANZ_SECRET_TEST\"",
1438                  "test -z \"$GH_TOKEN\"",
1439                  "test \"$DATABASE_URL\" = 'postgres://declared'"
1440                ]
1441            }"#,
1442        );
1443        // H-6 (follow-up review): the contract's declaration is only the
1444        // FIRST party. `DATABASE_URL` crosses because the OPERATOR's
1445        // `contractEnvPassthrough` names it too; `GH_TOKEN` is not on that
1446        // list, so a contract that named it would still get nothing.
1447        let handle = LocalWorktreeProvider
1448            .provision(&spec_with_passthrough(
1449                dir.path().to_path_buf(),
1450                None,
1451                Some(contract),
1452                &["DATABASE_URL".to_string()],
1453            ))
1454            .await
1455            .expect("provision");
1456        let mut progress = Progress::default();
1457        let outcome = LocalWorktreeProvider
1458            .readiness(&handle, &mut progress.sink())
1459            .await
1460            .expect("readiness");
1461
1462        assert!(
1463            matches!(outcome, ReadinessOutcome::Ready),
1464            "undeclared ambient secrets must not cross, the two-party one must: {outcome:?}"
1465        );
1466    }
1467
1468    /// H-6 (follow-up review): a contract that names a credential the
1469    /// OPERATOR never granted gets nothing — repo content cannot choose which
1470    /// ambient credentials cross. `.kranz/workspace.json` runs at mission
1471    /// start, before any agent spawns, and is ordinary repo content a merged
1472    /// worker commit can edit, so its `secrets[]` is an attacker-choosable
1473    /// list; the operator's `contractEnvPassthrough` is the second party.
1474    #[cfg(unix)]
1475    #[tokio::test]
1476    async fn a_repo_declared_secret_the_operator_never_granted_does_not_cross() {
1477        let _guard = crate::agent_env::EnvTestGuard::engage(&[("GH_TOKEN", "ghp_poison")]);
1478
1479        let dir = tempfile::tempdir().expect("tempdir");
1480        let contract = contract(
1481            br#"{
1482                "schemaVersion": 1,
1483                "secrets": ["GH_TOKEN"],
1484                "readiness": ["test -z \"$GH_TOKEN\""]
1485            }"#,
1486        );
1487        let handle = LocalWorktreeProvider
1488            .provision(&spec(dir.path().to_path_buf(), None, Some(contract)))
1489            .await
1490            .expect("provision");
1491        let mut progress = Progress::default();
1492        let outcome = LocalWorktreeProvider
1493            .readiness(&handle, &mut progress.sink())
1494            .await
1495            .expect("readiness");
1496
1497        assert!(
1498            matches!(outcome, ReadinessOutcome::Ready),
1499            "the repo named GH_TOKEN and the operator did not, so it must not cross: {outcome:?}"
1500        );
1501    }
1502
1503    /// M-1 (follow-up review): every gate phase used to get a FRESH
1504    /// deleted-on-drop `HOME`, so a bootstrap that installed a toolchain
1505    /// (`rustup toolchain install`, `pnpm setup && pnpm install -g turbo`)
1506    /// had its work deleted before readiness ran, and the mission blocked on
1507    /// a readiness failure the operator could not reproduce by hand. The
1508    /// phases now share one home per mission run, under the mission's own
1509    /// writable `runs/` dir.
1510    #[cfg(unix)]
1511    #[tokio::test]
1512    async fn bootstrap_output_in_home_survives_into_readiness_and_the_data_hooks() {
1513        let dir = tempfile::tempdir().expect("tempdir");
1514        let contract = contract(
1515            br#"{
1516                "schemaVersion": 1,
1517                "bootstrap": ["mkdir -p \"$HOME/bin\" && echo installed > \"$HOME/bin/turbo\""],
1518                "readiness": ["test -f \"$HOME/bin/turbo\""],
1519                "data": {
1520                  "migrate": "true",
1521                  "skewCheck": "test -f \"$HOME/bin/turbo\""
1522                }
1523            }"#,
1524        );
1525        let handle = LocalWorktreeProvider
1526            .provision(&spec(dir.path().to_path_buf(), None, Some(contract)))
1527            .await
1528            .expect("provision");
1529        let mut progress = Progress::default();
1530        let outcome = LocalWorktreeProvider
1531            .readiness(&handle, &mut progress.sink())
1532            .await
1533            .expect("readiness");
1534
1535        assert!(
1536            matches!(outcome, ReadinessOutcome::Ready),
1537            "readiness and the skewCheck hook must see what bootstrap installed in HOME: \
1538             {outcome:?}"
1539        );
1540        assert_eq!(
1541            handle.gate_env.home,
1542            dir.path()
1543                .join(".kranz")
1544                .join("missions")
1545                .join("m-test")
1546                .join("runs")
1547                .join("workspace-gate"),
1548            "the shared home lives under the mission's own writable runs/ dir"
1549        );
1550        assert!(
1551            handle.gate_env.home.join("bin").join("turbo").is_file(),
1552            "the home outlives the phases; teardown is what removes it"
1553        );
1554    }
1555
1556    /// The same discipline on the golden-data hook path, which shares the
1557    /// gate's env and was the third command shape H4 named.
1558    #[cfg(unix)]
1559    #[tokio::test]
1560    async fn data_hooks_see_only_the_contracts_declared_secrets() {
1561        let _guard =
1562            crate::agent_env::EnvTestGuard::engage(&[("KRANZ_SECRET_TEST", "must-not-cross")]);
1563
1564        let dir = tempfile::tempdir().expect("tempdir");
1565        let contract = contract(
1566            br#"{
1567                "schemaVersion": 1,
1568                "data": {"clone": "test -z \"$KRANZ_SECRET_TEST\""}
1569            }"#,
1570        );
1571        let handle = LocalWorktreeProvider
1572            .provision(&spec(dir.path().to_path_buf(), None, Some(contract)))
1573            .await
1574            .expect("provision");
1575        let mut progress = Progress::default();
1576        let failed = LocalWorktreeProvider
1577            .run_data_hook(
1578                &handle,
1579                crate::workspace_data::DataHookKind::Clone,
1580                "test -z \"$KRANZ_SECRET_TEST\"",
1581                &mut progress.sink(),
1582            )
1583            .await
1584            .expect("hook ran");
1585
1586        assert!(
1587            failed.is_none(),
1588            "an ambient secret reached the data hook: {failed:?}"
1589        );
1590    }
1591
1592    #[tokio::test]
1593    async fn readiness_bootstrap_failure_stops_before_readiness() {
1594        let dir = tempfile::tempdir().expect("tempdir");
1595        let contract = contract(
1596            br#"{
1597                "schemaVersion": 1,
1598                "bootstrap": ["exit 42"],
1599                "readiness": ["echo never-runs > .readiness-marker"]
1600            }"#,
1601        );
1602        let handle = LocalWorktreeProvider
1603            .provision(&spec(dir.path().to_path_buf(), None, Some(contract)))
1604            .await
1605            .expect("provision");
1606        let mut progress = Progress::default();
1607        let outcome = LocalWorktreeProvider
1608            .readiness(&handle, &mut progress.sink())
1609            .await
1610            .expect("readiness");
1611
1612        let ReadinessOutcome::Failed { kind, failed } = outcome else {
1613            panic!("bootstrap failure must be Failed, got {outcome:?}");
1614        };
1615        assert_eq!(kind, "bootstrap command");
1616        assert_eq!(failed.code, Some(42));
1617        assert!(
1618            !dir.path().join(".readiness-marker").exists(),
1619            "bootstrap stop-at-first-failure: readiness never ran"
1620        );
1621        assert_eq!(
1622            progress.summaries(),
1623            vec![
1624                "workspace bootstrap: running 1 commands",
1625                "workspace bootstrap: FAILED at command 1/1 — blocking mission (owner: repo-setup)",
1626            ]
1627        );
1628    }
1629
1630    #[tokio::test]
1631    async fn readiness_check_failure_reports_after_bootstrap_passed() {
1632        let dir = tempfile::tempdir().expect("tempdir");
1633        let contract = contract(
1634            br#"{
1635                "schemaVersion": 1,
1636                "bootstrap": ["echo boot > .boot-marker"],
1637                "readiness": ["exit 3"]
1638            }"#,
1639        );
1640        let handle = LocalWorktreeProvider
1641            .provision(&spec(dir.path().to_path_buf(), None, Some(contract)))
1642            .await
1643            .expect("provision");
1644        let mut progress = Progress::default();
1645        let outcome = LocalWorktreeProvider
1646            .readiness(&handle, &mut progress.sink())
1647            .await
1648            .expect("readiness");
1649
1650        let ReadinessOutcome::Failed { kind, failed } = outcome else {
1651            panic!("readiness failure must be Failed, got {outcome:?}");
1652        };
1653        assert_eq!(kind, "readiness check");
1654        assert_eq!(failed.code, Some(3));
1655        assert!(
1656            dir.path().join(".boot-marker").exists(),
1657            "bootstrap ran to completion first"
1658        );
1659        assert_eq!(
1660            progress.summaries(),
1661            vec![
1662                "workspace bootstrap: running 1 commands",
1663                "workspace bootstrap: 1/1 commands ok",
1664                "workspace readiness: running 1 checks",
1665                "workspace readiness: FAILED at check 1/1 — blocking mission (owner: repo-setup)",
1666            ]
1667        );
1668    }
1669
1670    // -----------------------------------------------------------------------
1671    // Golden-data hooks (design D-D, ticket golden-data-hooks)
1672    // -----------------------------------------------------------------------
1673
1674    /// Lifecycle order (D-D), proven by markers each step asserts before
1675    /// writing its own: clone → migrate → bootstrap → readiness → skewCheck.
1676    /// Shell lines come from [`crate::test_shell`] so they parse under BOTH
1677    /// `sh -c` and `cmd /C`; `test -f` was previously inlined here as though
1678    /// portable, but cmd.exe has no such builtin and Windows ships no
1679    /// `test.exe`, so this only passed where Git's `usr/bin` was on PATH.
1680    #[tokio::test]
1681    async fn readiness_runs_data_hooks_in_lifecycle_order() {
1682        let dir = tempfile::tempdir().expect("tempdir");
1683        let clone_cmd = write_line("cloned", ".clone-marker");
1684        let migrate_cmd = if_file_exists(".clone-marker", &write_line("mig", ".migrate-marker"));
1685        let skew_cmd = if_file_exists(".boot-marker", &write_line("checked", ".skew-marker"));
1686        let bootstrap_cmd = if_file_exists(".migrate-marker", &write_line("boot", ".boot-marker"));
1687        let readiness_cmd = file_exists(".boot-marker");
1688        let contract = contract(
1689            format!(
1690                r#"{{
1691                "schemaVersion": 1,
1692                "data": {{
1693                    "clone": "{clone_cmd}",
1694                    "migrate": "{migrate_cmd}",
1695                    "skewCheck": "{skew_cmd}"
1696                }},
1697                "bootstrap": ["{bootstrap_cmd}"],
1698                "readiness": ["{readiness_cmd}"]
1699            }}"#
1700            )
1701            .as_bytes(),
1702        );
1703        let handle = LocalWorktreeProvider
1704            .provision(&spec(dir.path().to_path_buf(), None, Some(contract)))
1705            .await
1706            .expect("provision");
1707        let mut progress = Progress::default();
1708        let outcome = LocalWorktreeProvider
1709            .readiness(&handle, &mut progress.sink())
1710            .await
1711            .expect("readiness");
1712
1713        assert!(
1714            matches!(outcome, ReadinessOutcome::Ready),
1715            "every marker assertion passed ⇒ the hooks ran in order: {outcome:?}"
1716        );
1717        for marker in [".clone-marker", ".migrate-marker", ".skew-marker"] {
1718            assert!(dir.path().join(marker).exists(), "{marker} written");
1719        }
1720        assert_eq!(
1721            progress.summaries(),
1722            vec![
1723                format!("workspace data: clone `{clone_cmd}` → ok (exit code 0)"),
1724                format!("workspace data: migrate `{migrate_cmd}` → ok (exit code 0)"),
1725                "workspace bootstrap: running 1 commands".to_string(),
1726                "workspace bootstrap: 1/1 commands ok".to_string(),
1727                "workspace readiness: running 1 checks".to_string(),
1728                "workspace readiness: 1/1 checks ok".to_string(),
1729                format!("workspace data: skewCheck `{skew_cmd}` → ok (exit code 0)"),
1730            ]
1731        );
1732    }
1733
1734    /// A clone/migrate failure folds into the gate's block shape with a
1735    /// data-hook kind, stopping before every later phase.
1736    #[tokio::test]
1737    async fn readiness_data_clone_failure_stops_before_migrate_and_bootstrap() {
1738        let dir = tempfile::tempdir().expect("tempdir");
1739        let readiness_cmd = file_exists(".boot-marker");
1740        let contract = contract(
1741            format!(
1742                r#"{{
1743                "schemaVersion": 1,
1744                "data": {{
1745                    "clone": "exit 42",
1746                    "migrate": "echo mig > .migrate-marker"
1747                }},
1748                "bootstrap": ["echo boot > .boot-marker"],
1749                "readiness": ["{readiness_cmd}"]
1750            }}"#
1751            )
1752            .as_bytes(),
1753        );
1754        let handle = LocalWorktreeProvider
1755            .provision(&spec(dir.path().to_path_buf(), None, Some(contract)))
1756            .await
1757            .expect("provision");
1758        let mut progress = Progress::default();
1759        let outcome = LocalWorktreeProvider
1760            .readiness(&handle, &mut progress.sink())
1761            .await
1762            .expect("readiness");
1763
1764        let ReadinessOutcome::Failed { kind, failed } = outcome else {
1765            panic!("a clone failure must be Failed, got {outcome:?}");
1766        };
1767        assert_eq!(kind, "data clone hook");
1768        assert_eq!(failed.code, Some(42));
1769        assert!(
1770            !dir.path().join(".migrate-marker").exists(),
1771            "the data phase stops at the first failure: migrate never ran"
1772        );
1773        assert!(
1774            !dir.path().join(".boot-marker").exists(),
1775            "bootstrap never ran after a data-hook failure"
1776        );
1777        assert_eq!(
1778            progress.summaries(),
1779            vec![
1780                "workspace data: clone `exit 42` → FAILED (exit code 42) — blocking mission (owner: repo-setup)",
1781            ]
1782        );
1783    }
1784
1785    /// skewCheck failure AFTER bootstrap+readiness passed is the distinct
1786    /// DataSkew outcome (D-D) — never a generic readiness failure.
1787    #[tokio::test]
1788    async fn readiness_skew_failure_is_the_distinct_skew_outcome() {
1789        let dir = tempfile::tempdir().expect("tempdir");
1790        let readiness_cmd = file_exists(".boot-marker");
1791        let contract = contract(
1792            format!(
1793                r#"{{
1794                "schemaVersion": 1,
1795                "data": {{
1796                    "migrate": "echo mig > .migrate-marker",
1797                    "skewCheck": "exit 1"
1798                }},
1799                "bootstrap": ["echo boot > .boot-marker"],
1800                "readiness": ["{readiness_cmd}"]
1801            }}"#
1802            )
1803            .as_bytes(),
1804        );
1805        let handle = LocalWorktreeProvider
1806            .provision(&spec(dir.path().to_path_buf(), None, Some(contract)))
1807            .await
1808            .expect("provision");
1809        let mut progress = Progress::default();
1810        let outcome = LocalWorktreeProvider
1811            .readiness(&handle, &mut progress.sink())
1812            .await
1813            .expect("readiness");
1814
1815        let ReadinessOutcome::DataSkew { failed } = outcome else {
1816            panic!("a skewCheck failure must be DataSkew, got {outcome:?}");
1817        };
1818        assert_eq!(failed.code, Some(1));
1819        assert!(
1820            dir.path().join(".boot-marker").exists(),
1821            "bootstrap and readiness passed before the skew check ran"
1822        );
1823        assert_eq!(
1824            progress.summaries(),
1825            vec![
1826                "workspace data: migrate `echo mig > .migrate-marker` → ok (exit code 0)",
1827                "workspace bootstrap: running 1 commands",
1828                "workspace bootstrap: 1/1 commands ok",
1829                "workspace readiness: running 1 checks",
1830                "workspace readiness: 1/1 checks ok",
1831                "workspace data: skewCheck `exit 1` → FAILED (exit code 1) — blocking mission (owner: repo-setup)",
1832            ]
1833        );
1834    }
1835
1836    /// The default `run_data_hook` (the reset-between-rounds path) runs in
1837    /// the handle's cwd with the handle's env.
1838    #[tokio::test]
1839    async fn run_data_hook_executes_in_the_workspace_with_handle_env() {
1840        let dir = tempfile::tempdir().expect("tempdir");
1841        let contract = contract(
1842            br#"{"schemaVersion": 1, "data": {"reset": "seed", "resetBetweenRounds": true}}"#,
1843        );
1844        let handle = LocalWorktreeProvider
1845            .provision(&spec(
1846                dir.path().to_path_buf(),
1847                Some("deadbeefcafe"),
1848                Some(contract),
1849            ))
1850            .await
1851            .expect("provision");
1852        let mut progress = Progress::default();
1853        let failed = LocalWorktreeProvider
1854            .run_data_hook(
1855                &handle,
1856                crate::workspace_data::DataHookKind::Reset,
1857                &base_sha_assertion_command("deadbeefcafe"),
1858                &mut progress.sink(),
1859            )
1860            .await
1861            .expect("run_data_hook");
1862        assert!(
1863            failed.is_none(),
1864            "the KRANZ_BASE_SHA assertion saw the handle env: {failed:?}"
1865        );
1866        assert_eq!(progress.summaries().len(), 1);
1867        assert!(
1868            progress.summaries()[0].starts_with("workspace data: reset `"),
1869            "{:?}",
1870            progress.summaries()
1871        );
1872    }
1873
1874    #[tokio::test]
1875    async fn teardown_accepts_every_mode_as_a_recorded_no_op() {
1876        let dir = tempfile::tempdir().expect("tempdir");
1877        for mode in [
1878            TeardownMode::Keep,
1879            TeardownMode::Hibernate,
1880            TeardownMode::Destroy,
1881        ] {
1882            let handle = LocalWorktreeProvider
1883                .provision(&spec(dir.path().to_path_buf(), None, None))
1884                .await
1885                .expect("provision");
1886            LocalWorktreeProvider
1887                .teardown(handle, mode)
1888                .await
1889                .expect("teardown is always Ok for local-worktree");
1890            assert!(
1891                dir.path().exists(),
1892                "teardown never touches the filesystem ({mode:?})"
1893            );
1894        }
1895        assert_eq!(TeardownMode::Keep.as_str(), "keep");
1896        assert_eq!(TeardownMode::Hibernate.as_str(), "hibernate");
1897        assert_eq!(TeardownMode::Destroy.as_str(), "destroy");
1898        assert_eq!(
1899            WorkspaceProviderKind::LocalWorktree.as_str(),
1900            "local-worktree"
1901        );
1902    }
1903}