1use 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
51pub const DATA_SUMMARY_PREFIX: &str = "workspace data:";
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum DataHookKind {
60 Clone,
61 Migrate,
62 Reset,
63 SkewCheck,
64}
65
66impl DataHookKind {
67 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 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
90pub(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
113fn 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
143pub(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 (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
170pub(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 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 let Some(provider) = self.workspace_provider.clone() else {
214 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#[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 #[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 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 #[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 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 #[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}