use crate::error::Result;
use crate::orchestrator::MissionEngine;
use crate::workspace_contract::DataHooks;
use crate::workspace_gate::{CommandOutcome, GATE_REASON_PREFIX};
use crate::workspace_provider::ProgressSink;
pub const DATA_SUMMARY_PREFIX: &str = "workspace data:";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataHookKind {
Clone,
Migrate,
Reset,
SkewCheck,
}
impl DataHookKind {
pub fn as_str(self) -> &'static str {
match self {
DataHookKind::Clone => "clone",
DataHookKind::Migrate => "migrate",
DataHookKind::Reset => "reset",
DataHookKind::SkewCheck => "skewCheck",
}
}
pub fn gate_kind(self) -> &'static str {
match self {
DataHookKind::Clone => "data clone hook",
DataHookKind::Migrate => "data migrate hook",
DataHookKind::Reset => "data reset hook",
DataHookKind::SkewCheck => "data skewCheck hook",
}
}
}
pub(crate) fn hook_outcome(
hook: DataHookKind,
command: &str,
code: Option<i32>,
output_tail: String,
progress: &mut ProgressSink<'_>,
) -> Result<Option<CommandOutcome>> {
let outcome = CommandOutcome {
ordinal: 1,
total: 1,
command: command.to_string(),
code,
output_tail,
};
report_hook_outcome(hook, &outcome, progress)?;
Ok((!outcome.ok()).then_some(outcome))
}
fn report_hook_outcome(
hook: DataHookKind,
outcome: &CommandOutcome,
progress: &mut ProgressSink<'_>,
) -> Result<()> {
let summary = if outcome.ok() {
format!(
"{DATA_SUMMARY_PREFIX} {} `{}` → ok ({})",
hook.as_str(),
outcome.command,
outcome.exit_phrase()
)
} else {
format!(
"{DATA_SUMMARY_PREFIX} {} `{}` → FAILED ({}) — blocking mission (owner: repo-setup)",
hook.as_str(),
outcome.command,
outcome.exit_phrase()
)
};
let tail = outcome.output_tail.trim();
let detail = (!tail.is_empty()).then(|| tail.to_string());
progress(&summary, detail)
}
pub(crate) fn skew_block_reason(data: Option<&DataHooks>, failed: &CommandOutcome) -> String {
let action = match (data.and_then(|d| d.migrate.as_ref()), data.and_then(|d| d.reset.as_ref())) {
(Some(migrate), Some(reset)) => format!(
"run the data migrate hook (`{migrate}`) or the data reset hook (`{reset}`), then resume"
),
(Some(migrate), None) => {
format!("run the data migrate hook (`{migrate}`), then resume")
}
(None, Some(reset)) => format!("run the data reset hook (`{reset}`), then resume"),
(None, None) => "fix the data skew, then resume".to_string(),
};
crate::scrub::scrub(&format!(
"{GATE_REASON_PREFIX} data skewCheck failed (owner: repo-setup): `{}` {}: {} — {action}",
failed.command,
failed.exit_phrase(),
failed.output_tail.trim(),
))
}
pub(crate) fn reset_block_reason(failed: &CommandOutcome) -> String {
crate::scrub::scrub(&format!(
"{GATE_REASON_PREFIX} data reset hook failed (owner: repo-setup): `{}` {}: {} — \
fix the data reset hook or the dataset it restores, then resume",
failed.command,
failed.exit_phrase(),
failed.output_tail.trim(),
))
}
impl MissionEngine {
pub(crate) async fn run_data_reset_between_rounds(&mut self) -> Result<bool> {
let reset = self
.workspace_handle
.as_ref()
.and_then(|handle| handle.contract.as_ref())
.and_then(|contract| contract.data.as_ref())
.filter(|data| data.reset_between_rounds)
.and_then(|data| data.reset.clone());
let Some(command) = reset else {
return Ok(false);
};
let Some(provider) = self.workspace_provider.clone() else {
return Ok(false);
};
let handle = self
.workspace_handle
.clone()
.expect("a reset hook implies a provisioned handle");
let failed = {
let mut progress = |summary: &str, detail: Option<String>| -> Result<()> {
self.emit_decision(summary, detail)
};
provider
.run_data_hook(&handle, DataHookKind::Reset, &command, &mut progress)
.await?
};
match failed {
None => Ok(false),
Some(failed) => {
self.block_with_gate_reason(reset_block_reason(&failed))?;
Ok(true)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace_contract::parse_workspace_contract;
fn outcome(command: &str, code: Option<i32>, tail: &str) -> CommandOutcome {
CommandOutcome {
ordinal: 1,
total: 1,
command: command.to_string(),
code,
output_tail: tail.to_string(),
}
}
fn data(json: &[u8]) -> DataHooks {
parse_workspace_contract(json)
.expect("valid contract")
.data
.expect("data hooks")
}
#[test]
fn data_hook_decision_lines_carry_command_exit_and_tail() {
let mut lines: Vec<(String, Option<String>)> = Vec::new();
let mut sink = |summary: &str, detail: Option<String>| -> Result<()> {
lines.push((summary.to_string(), detail));
Ok(())
};
let passed = hook_outcome(
DataHookKind::Clone,
"pg_dump golden | psql workspace",
Some(0),
"100 rows copied".to_string(),
&mut sink,
)
.expect("report");
assert!(passed.is_none(), "a passing hook reports and returns None");
let failed = hook_outcome(
DataHookKind::Migrate,
"sqlx migrate run",
Some(1),
"relation already exists".to_string(),
&mut sink,
)
.expect("report");
assert_eq!(
failed.expect("a failing hook returns its outcome").code,
Some(1)
);
assert_eq!(
lines,
vec![
(
"workspace data: clone `pg_dump golden | psql workspace` → ok (exit code 0)"
.to_string(),
Some("100 rows copied".to_string()),
),
(
"workspace data: migrate `sqlx migrate run` → FAILED (exit code 1) — blocking mission (owner: repo-setup)"
.to_string(),
Some("relation already exists".to_string()),
),
]
);
let mut lines: Vec<(String, Option<String>)> = Vec::new();
let mut sink = |summary: &str, detail: Option<String>| -> Result<()> {
lines.push((summary.to_string(), detail));
Ok(())
};
hook_outcome(
DataHookKind::Reset,
"seed",
Some(0),
String::new(),
&mut sink,
)
.expect("report");
assert_eq!(lines[0].1, None, "no tail ⇒ no detail: {lines:?}");
}
#[test]
fn skew_block_reason_is_owned_actionable_and_scrubbed() {
let hooks = data(
br#"{"schemaVersion": 1, "data": {
"migrate": "sqlx migrate run",
"reset": "reseed",
"skewCheck": "sqlx migrate info --check"
}}"#,
);
let failed = outcome(
"sqlx migrate info --check",
Some(1),
"token sk-ant-api03-a1b2c3d4e5f6 rejected",
);
let reason = skew_block_reason(Some(&hooks), &failed);
assert!(reason.starts_with("workspace gate:"), "{reason}");
assert!(reason.contains("data skewCheck failed"), "{reason}");
assert!(reason.contains("owner: repo-setup"), "{reason}");
assert!(reason.contains("`sqlx migrate info --check`"), "{reason}");
assert!(reason.contains("exit code 1"), "{reason}");
assert!(
reason.contains("run the data migrate hook (`sqlx migrate run`) or the data reset hook (`reseed`), then resume"),
"the action names both declared hooks: {reason}"
);
assert!(
!reason.contains("sk-ant-api03-a1b2c3d4e5f6"),
"the tail is scrubbed: {reason}"
);
assert!(reason.contains("[REDACTED]"), "{reason}");
let migrate_only =
data(br#"{"schemaVersion": 1, "data": {"migrate": "m", "skewCheck": "c"}}"#);
let reason = skew_block_reason(Some(&migrate_only), &failed);
assert!(
reason.contains("run the data migrate hook (`m`), then resume"),
"{reason}"
);
assert!(!reason.contains("reset hook"), "{reason}");
let reset_only = data(br#"{"schemaVersion": 1, "data": {"reset": "r", "skewCheck": "c"}}"#);
let reason = skew_block_reason(Some(&reset_only), &failed);
assert!(
reason.contains("run the data reset hook (`r`), then resume"),
"{reason}"
);
}
#[test]
fn reset_block_reason_matches_the_owned_shape() {
let failed = outcome("dropdb workspace", None, "connection refused");
let reason = reset_block_reason(&failed);
assert!(reason.starts_with("workspace gate:"), "{reason}");
assert!(reason.contains("data reset hook failed"), "{reason}");
assert!(reason.contains("owner: repo-setup"), "{reason}");
assert!(reason.contains("`dropdb workspace`"), "{reason}");
assert!(reason.contains("no exit code"), "{reason}");
assert!(reason.contains("connection refused"), "{reason}");
assert!(reason.contains("then resume"), "{reason}");
}
#[test]
fn data_hook_kind_names_match_the_contract_fields() {
assert_eq!(DataHookKind::Clone.as_str(), "clone");
assert_eq!(DataHookKind::Migrate.as_str(), "migrate");
assert_eq!(DataHookKind::Reset.as_str(), "reset");
assert_eq!(DataHookKind::SkewCheck.as_str(), "skewCheck");
assert_eq!(DataHookKind::Clone.gate_kind(), "data clone hook");
assert_eq!(DataHookKind::Migrate.gate_kind(), "data migrate hook");
assert_eq!(DataHookKind::Reset.gate_kind(), "data reset hook");
assert_eq!(DataHookKind::SkewCheck.gate_kind(), "data skewCheck hook");
}
}