Skip to main content

kranz_engine/
workspace_data.rs

1//! Golden-data hooks (design D-D in `docs/scoping/workspace-contract.md`,
2//! ticket `.kranz/tickets/golden-data-hooks.md`) — the optional `data` block's
3//! `clone` / `migrate` / `reset` / `skewCheck` commands that provision a
4//! de-identified golden dataset into the workspace before agents run.
5//!
6//! This module owns the hook vocabulary and the outcome shapes; EXECUTION
7//! stays with the [`crate::workspace_provider`] seam (host shell for
8//! local-worktree, `compose exec` for the container provider — a container
9//! workspace never runs data hooks on the host), and the reset-between-rounds
10//! drive lives here on [`MissionEngine`] because it fires from
11//! `validation_round`, outside the provision/readiness drive.
12//!
13//! Lifecycle (all steps skip silently when the contract's `data` block does
14//! not declare them — repos without a `data` block are byte-identical):
15//!
16//! ```text
17//! provision → data clone → data migrate → bootstrap → readiness → data skewCheck
18//!                                                                        │failure
19//!                                              Blocked, owner repo-setup ◀─┘
20//!                                              (the SKEW case: the reason
21//!                                               names the migrate/reset
22//!                                               hook to run, then resume)
23//! validation_round: data reset (when resetBetweenRounds opts in) → validators
24//! ```
25//!
26//! Owned outcomes, never flake (D-D): a skewCheck failure is
27//! [`crate::workspace_provider::ReadinessOutcome::DataSkew`] — a distinct
28//! outcome that Blocks with the migrate/reset action named, never a generic
29//! readiness failure and never a validator finding. Clone/migrate failures
30//! fold into the gate's established block shape (kind "data clone hook" /
31//! "data migrate hook"). A reset failure Blocks with the same owned shape
32//! before any validator spawns. All block reasons carry the
33//! [`crate::workspace_gate::GATE_REASON_PREFIX`] so a fixed environment
34//! lifts them on resume like any other gate block.
35//!
36//! Decision events reuse the `orchestrator.decision` audit channel with a
37//! [`DATA_SUMMARY_PREFIX`] summary (no new event kinds): hook name, command,
38//! exit, and a scrubbed bounded tail in the detail — mirroring the bootstrap
39//! gate's decision shape. Secret *values* never appear: the contract carries
40//! names only, hooks run with the gate's env discipline (inherited env plus
41//! the handle's `KRANZ_BASE_SHA` — the existing machinery, no new secrets
42//! channel), and tails are scrubbed here AND again at event-append (defense
43//! in depth).
44
45use crate::error::Result;
46use crate::orchestrator::MissionEngine;
47use crate::workspace_contract::DataHooks;
48use crate::workspace_gate::{CommandOutcome, GATE_REASON_PREFIX};
49use crate::workspace_provider::ProgressSink;
50
51/// `orchestrator.decision` summary prefix for every data-hook step
52/// (`workspace data: clone ...` / `... migrate ...` / `... reset ...` /
53/// `... skewCheck ...`).
54pub const DATA_SUMMARY_PREFIX: &str = "workspace data:";
55
56/// The four golden-data hooks, in lifecycle vocabulary. Wire/decision names
57/// match the contract's camelCase fields.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum DataHookKind {
60    Clone,
61    Migrate,
62    Reset,
63    SkewCheck,
64}
65
66impl DataHookKind {
67    /// The decision-line name (`workspace data: {name} ...`).
68    pub fn as_str(self) -> &'static str {
69        match self {
70            DataHookKind::Clone => "clone",
71            DataHookKind::Migrate => "migrate",
72            DataHookKind::Reset => "reset",
73            DataHookKind::SkewCheck => "skewCheck",
74        }
75    }
76
77    /// The block-reason kind for a hook failure that folds into the gate's
78    /// established reason shape ("{kind} 1/1 failed"). Skew/reset failures
79    /// use their own reason builders below instead.
80    pub fn gate_kind(self) -> &'static str {
81        match self {
82            DataHookKind::Clone => "data clone hook",
83            DataHookKind::Migrate => "data migrate hook",
84            DataHookKind::Reset => "data reset hook",
85            DataHookKind::SkewCheck => "data skewCheck hook",
86        }
87    }
88}
89
90/// Build the [`CommandOutcome`] for one executed data hook and report its
91/// decision line — the shared tail of every provider's `run_data_hook` (the
92/// providers differ only in HOW the command runs: host shell vs
93/// `compose exec`). Returns the outcome only when the hook FAILED, mirroring
94/// `report_gate_outcomes`.
95pub(crate) fn hook_outcome(
96    hook: DataHookKind,
97    command: &str,
98    code: Option<i32>,
99    output_tail: String,
100    progress: &mut ProgressSink<'_>,
101) -> Result<Option<CommandOutcome>> {
102    let outcome = CommandOutcome {
103        ordinal: 1,
104        total: 1,
105        command: command.to_string(),
106        code,
107        output_tail,
108    };
109    report_hook_outcome(hook, &outcome, progress)?;
110    Ok((!outcome.ok()).then_some(outcome))
111}
112
113/// The pass/fail decision line for one data hook — one line per hook (hooks
114/// are single commands, so the gate's "running n / n/n ok" pair collapses):
115/// `workspace data: {hook} `{command}` → ok|FAILED ({exit phrase})`, with the
116/// scrubbed bounded tail as detail when non-empty (scrubbed again by
117/// `emit_decision`, same as the gate's details).
118fn report_hook_outcome(
119    hook: DataHookKind,
120    outcome: &CommandOutcome,
121    progress: &mut ProgressSink<'_>,
122) -> Result<()> {
123    let summary = if outcome.ok() {
124        format!(
125            "{DATA_SUMMARY_PREFIX} {} `{}` → ok ({})",
126            hook.as_str(),
127            outcome.command,
128            outcome.exit_phrase()
129        )
130    } else {
131        format!(
132            "{DATA_SUMMARY_PREFIX} {} `{}` → FAILED ({}) — blocking mission (owner: repo-setup)",
133            hook.as_str(),
134            outcome.command,
135            outcome.exit_phrase()
136        )
137    };
138    let tail = outcome.output_tail.trim();
139    let detail = (!tail.is_empty()).then(|| tail.to_string());
140    progress(&summary, detail)
141}
142
143/// The skew Block reason (D-D): names the data block's skewCheck, its exit,
144/// a scrubbed bounded tail, the repo-setup owner, AND the action — run the
145/// declared migrate/reset hook (named), then resume. Carries the gate
146/// prefix so a fixed environment lifts the block on resume
147/// ([`GATE_REASON_PREFIX`]); it is a distinct outcome, never a readiness
148/// flake's generic reason.
149pub(crate) fn skew_block_reason(data: Option<&DataHooks>, failed: &CommandOutcome) -> String {
150    let action = match (data.and_then(|d| d.migrate.as_ref()), data.and_then(|d| d.reset.as_ref())) {
151        (Some(migrate), Some(reset)) => format!(
152            "run the data migrate hook (`{migrate}`) or the data reset hook (`{reset}`), then resume"
153        ),
154        (Some(migrate), None) => {
155            format!("run the data migrate hook (`{migrate}`), then resume")
156        }
157        (None, Some(reset)) => format!("run the data reset hook (`{reset}`), then resume"),
158        // Unreachable: contract validation refuses skewCheck without a
159        // migrate or reset hook. Defensive only — stay actionable anyway.
160        (None, None) => "fix the data skew, then resume".to_string(),
161    };
162    crate::scrub::scrub(&format!(
163        "{GATE_REASON_PREFIX} data skewCheck failed (owner: repo-setup): `{}` {}: {} — {action}",
164        failed.command,
165        failed.exit_phrase(),
166        failed.output_tail.trim(),
167    ))
168}
169
170/// The reset-failure Block reason — the same owned shape as skew (hook,
171/// exit, scrubbed bounded tail, owner, action). A failed reset means the
172/// re-seed itself is broken, so the action is to fix the hook or the
173/// dataset it restores; the gate prefix keeps the block liftable on resume.
174pub(crate) fn reset_block_reason(failed: &CommandOutcome) -> String {
175    crate::scrub::scrub(&format!(
176        "{GATE_REASON_PREFIX} data reset hook failed (owner: repo-setup): `{}` {}: {} — \
177         fix the data reset hook or the dataset it restores, then resume",
178        failed.command,
179        failed.exit_phrase(),
180        failed.output_tail.trim(),
181    ))
182}
183
184impl MissionEngine {
185    /// The reset-between-rounds drive (design D-D): when the provisioned
186    /// workspace's contract data block opts in (`resetBetweenRounds`) and
187    /// declares a `reset` hook, re-seed the golden dataset BEFORE the
188    /// validation round's first validator spawn, so every round judges the
189    /// same baseline. Returns `Ok(true)` when a reset failure Blocked the
190    /// first incomplete milestone (same owned shape as skew — never a
191    /// validator finding); the caller returns early and the loop's blocked
192    /// branch parks the mission. `Ok(false)` when no reset ran (no handle,
193    /// no contract, flag off, or hook undeclared — byte-identical behavior)
194    /// or the reset passed.
195    ///
196    /// Execution routes through the resolved provider's `run_data_hook`, so
197    /// a container workspace re-seeds INSIDE the container, never on the
198    /// host. Idempotent-by-contract (same discipline as bootstrap): a resume
199    /// re-runs the hook on the next validation round.
200    pub(crate) async fn run_data_reset_between_rounds(&mut self) -> Result<bool> {
201        let reset = self
202            .workspace_handle
203            .as_ref()
204            .and_then(|handle| handle.contract.as_ref())
205            .and_then(|contract| contract.data.as_ref())
206            .filter(|data| data.reset_between_rounds)
207            .and_then(|data| data.reset.clone());
208        let Some(command) = reset else {
209            return Ok(false);
210        };
211        // Both cloned/Arc-shared out of `self` so the progress closure can
212        // emit decisions inline (same WHEN discipline as the gate).
213        let Some(provider) = self.workspace_provider.clone() else {
214            // Defensive: validation rounds outside run() (unit tests) carry
215            // no provider — treat as no reset.
216            return Ok(false);
217        };
218        let handle = self
219            .workspace_handle
220            .clone()
221            .expect("a reset hook implies a provisioned handle");
222        let failed = {
223            let mut progress = |summary: &str, detail: Option<String>| -> Result<()> {
224                self.emit_decision(summary, detail)
225            };
226            provider
227                .run_data_hook(&handle, DataHookKind::Reset, &command, &mut progress)
228                .await?
229        };
230        match failed {
231            None => Ok(false),
232            Some(failed) => {
233                self.block_with_gate_reason(reset_block_reason(&failed))?;
234                Ok(true)
235            }
236        }
237    }
238}
239
240// ---------------------------------------------------------------------------
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::workspace_contract::parse_workspace_contract;
246
247    fn outcome(command: &str, code: Option<i32>, tail: &str) -> CommandOutcome {
248        CommandOutcome {
249            ordinal: 1,
250            total: 1,
251            command: command.to_string(),
252            code,
253            output_tail: tail.to_string(),
254        }
255    }
256
257    fn data(json: &[u8]) -> DataHooks {
258        parse_workspace_contract(json)
259            .expect("valid contract")
260            .data
261            .expect("data hooks")
262    }
263
264    /// The decision line mirrors the gate's shape: hook name, command, exit,
265    /// and the tail as detail; failures say who owns the block.
266    #[test]
267    fn data_hook_decision_lines_carry_command_exit_and_tail() {
268        let mut lines: Vec<(String, Option<String>)> = Vec::new();
269        let mut sink = |summary: &str, detail: Option<String>| -> Result<()> {
270            lines.push((summary.to_string(), detail));
271            Ok(())
272        };
273
274        let passed = hook_outcome(
275            DataHookKind::Clone,
276            "pg_dump golden | psql workspace",
277            Some(0),
278            "100 rows copied".to_string(),
279            &mut sink,
280        )
281        .expect("report");
282        assert!(passed.is_none(), "a passing hook reports and returns None");
283        let failed = hook_outcome(
284            DataHookKind::Migrate,
285            "sqlx migrate run",
286            Some(1),
287            "relation already exists".to_string(),
288            &mut sink,
289        )
290        .expect("report");
291        assert_eq!(
292            failed.expect("a failing hook returns its outcome").code,
293            Some(1)
294        );
295
296        assert_eq!(
297            lines,
298            vec![
299                (
300                    "workspace data: clone `pg_dump golden | psql workspace` → ok (exit code 0)"
301                        .to_string(),
302                    Some("100 rows copied".to_string()),
303                ),
304                (
305                    "workspace data: migrate `sqlx migrate run` → FAILED (exit code 1) — blocking mission (owner: repo-setup)"
306                        .to_string(),
307                    Some("relation already exists".to_string()),
308                ),
309            ]
310        );
311
312        // An empty tail carries no detail line.
313        let mut lines: Vec<(String, Option<String>)> = Vec::new();
314        let mut sink = |summary: &str, detail: Option<String>| -> Result<()> {
315            lines.push((summary.to_string(), detail));
316            Ok(())
317        };
318        hook_outcome(
319            DataHookKind::Reset,
320            "seed",
321            Some(0),
322            String::new(),
323            &mut sink,
324        )
325        .expect("report");
326        assert_eq!(lines[0].1, None, "no tail ⇒ no detail: {lines:?}");
327    }
328
329    /// The skew reason is the distinct, owned, actionable outcome (D-D): the
330    /// gate prefix (so resume lifts it), the skewCheck named with its exit,
331    /// a scrubbed tail, the repo-setup owner, and the migrate/reset action
332    /// naming whichever hooks are declared.
333    #[test]
334    fn skew_block_reason_is_owned_actionable_and_scrubbed() {
335        let hooks = data(
336            br#"{"schemaVersion": 1, "data": {
337                "migrate": "sqlx migrate run",
338                "reset": "reseed",
339                "skewCheck": "sqlx migrate info --check"
340            }}"#,
341        );
342        let failed = outcome(
343            "sqlx migrate info --check",
344            Some(1),
345            "token sk-ant-api03-a1b2c3d4e5f6 rejected",
346        );
347        let reason = skew_block_reason(Some(&hooks), &failed);
348        assert!(reason.starts_with("workspace gate:"), "{reason}");
349        assert!(reason.contains("data skewCheck failed"), "{reason}");
350        assert!(reason.contains("owner: repo-setup"), "{reason}");
351        assert!(reason.contains("`sqlx migrate info --check`"), "{reason}");
352        assert!(reason.contains("exit code 1"), "{reason}");
353        assert!(
354            reason.contains("run the data migrate hook (`sqlx migrate run`) or the data reset hook (`reseed`), then resume"),
355            "the action names both declared hooks: {reason}"
356        );
357        assert!(
358            !reason.contains("sk-ant-api03-a1b2c3d4e5f6"),
359            "the tail is scrubbed: {reason}"
360        );
361        assert!(reason.contains("[REDACTED]"), "{reason}");
362
363        // Whichever single hook is declared is the one the action names
364        // (validation guarantees at least one).
365        let migrate_only =
366            data(br#"{"schemaVersion": 1, "data": {"migrate": "m", "skewCheck": "c"}}"#);
367        let reason = skew_block_reason(Some(&migrate_only), &failed);
368        assert!(
369            reason.contains("run the data migrate hook (`m`), then resume"),
370            "{reason}"
371        );
372        assert!(!reason.contains("reset hook"), "{reason}");
373
374        let reset_only = data(br#"{"schemaVersion": 1, "data": {"reset": "r", "skewCheck": "c"}}"#);
375        let reason = skew_block_reason(Some(&reset_only), &failed);
376        assert!(
377            reason.contains("run the data reset hook (`r`), then resume"),
378            "{reason}"
379        );
380    }
381
382    /// The reset-failure reason carries the same owned shape (gate prefix,
383    /// hook, exit, scrubbed tail, owner, action).
384    #[test]
385    fn reset_block_reason_matches_the_owned_shape() {
386        let failed = outcome("dropdb workspace", None, "connection refused");
387        let reason = reset_block_reason(&failed);
388        assert!(reason.starts_with("workspace gate:"), "{reason}");
389        assert!(reason.contains("data reset hook failed"), "{reason}");
390        assert!(reason.contains("owner: repo-setup"), "{reason}");
391        assert!(reason.contains("`dropdb workspace`"), "{reason}");
392        assert!(reason.contains("no exit code"), "{reason}");
393        assert!(reason.contains("connection refused"), "{reason}");
394        assert!(reason.contains("then resume"), "{reason}");
395    }
396
397    #[test]
398    fn data_hook_kind_names_match_the_contract_fields() {
399        assert_eq!(DataHookKind::Clone.as_str(), "clone");
400        assert_eq!(DataHookKind::Migrate.as_str(), "migrate");
401        assert_eq!(DataHookKind::Reset.as_str(), "reset");
402        assert_eq!(DataHookKind::SkewCheck.as_str(), "skewCheck");
403        assert_eq!(DataHookKind::Clone.gate_kind(), "data clone hook");
404        assert_eq!(DataHookKind::Migrate.gate_kind(), "data migrate hook");
405        assert_eq!(DataHookKind::Reset.gate_kind(), "data reset hook");
406        assert_eq!(DataHookKind::SkewCheck.gate_kind(), "data skewCheck hook");
407    }
408}