use crate::errors::{CoreError, CoreResult};
use crate::implementation_readiness::{ReadinessPhase, ReadinessReport, render_readiness_text};
use crate::orchestrate::gates::ensure_planned_implementation_readiness_first;
use crate::orchestrate::plan::{PlannedGate, RunPlan};
use crate::orchestrate::types::{FailurePolicy, GateOutcome, OrchestrateRunStatus};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OrchestrateRun {
pub run_id: String,
pub started_at: String,
pub finished_at: Option<String>,
pub status: OrchestrateRunStatus,
pub preset: String,
pub max_parallel: usize,
pub failure_policy: FailurePolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OrchestrateGateRecord {
pub gate: String,
pub outcome: GateOutcome,
pub finished_at: String,
#[serde(default)]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub readiness: Option<ReadinessReport>,
}
#[must_use]
pub fn orchestrate_readiness_gate_record(
expected_change_id: &str,
report: ReadinessReport,
finished_at: impl Into<String>,
) -> OrchestrateGateRecord {
let phase_is_execute = match report.phase {
ReadinessPhase::Prepare => false,
ReadinessPhase::Execute => true,
};
let change_matches = report.change_id == expected_change_id;
let passed = report.ready && phase_is_execute && change_matches;
let outcome = if passed {
GateOutcome::Pass
} else {
GateOutcome::Fail
};
let error = if !change_matches {
Some(format!(
"implementation-readiness report evaluated change `{}` but orchestration expected `{expected_change_id}`",
report.change_id
))
} else if !phase_is_execute {
Some(
"implementation-readiness orchestration gate requires execute-phase evidence"
.to_string(),
)
} else if !report.ready {
Some(render_readiness_text(&report))
} else {
None
};
OrchestrateGateRecord {
gate: crate::orchestrate::gates::GATE_IMPLEMENTATION_READINESS.to_string(),
outcome,
finished_at: finished_at.into(),
error,
readiness: Some(report),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OrchestrateChangeState {
pub change_id: String,
#[serde(default)]
pub gates: Vec<OrchestrateGateRecord>,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OrchestrateEvent {
pub ts: String,
pub kind: OrchestrateEventKind,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "event", rename_all = "kebab-case")]
pub enum OrchestrateEventKind {
RunStart {
run_id: String,
preset: String,
max_parallel: usize,
failure_policy: FailurePolicy,
},
RunComplete {
run_id: String,
status: OrchestrateRunStatus,
},
GateStart {
run_id: String,
change_id: String,
gate: String,
},
GatePass {
run_id: String,
change_id: String,
gate: String,
},
GateFail {
run_id: String,
change_id: String,
gate: String,
error: String,
},
GateSkip {
run_id: String,
change_id: String,
gate: String,
},
WorkerDispatch {
run_id: String,
change_id: String,
gate: String,
role: String,
},
WorkerComplete {
run_id: String,
change_id: String,
gate: String,
outcome: GateOutcome,
},
RemediationDispatch {
run_id: String,
change_id: String,
failed_gate: String,
},
}
pub fn init_orchestrate_run_state(
ito_path: &Path,
run: &OrchestrateRun,
plan: &RunPlan,
) -> CoreResult<()> {
let root = run_root(ito_path, &run.run_id);
std::fs::create_dir_all(root.join("changes"))
.map_err(|e| CoreError::io("create orchestrate run state directory", e))?;
write_json_pretty(&root.join("run.json"), run)
.map_err(|e| CoreError::io("write orchestrate run.json", e))?;
write_json_pretty(&root.join("plan.json"), plan)
.map_err(|e| CoreError::io("write orchestrate plan.json", e))?;
let events = root.join("events.jsonl");
if !events.exists() {
std::fs::write(&events, "")
.map_err(|e| CoreError::io("create orchestrate events.jsonl", e))?;
}
Ok(())
}
pub fn load_orchestrate_run(ito_path: &Path, run_id: &str) -> CoreResult<OrchestrateRun> {
let path = run_root(ito_path, run_id).join("run.json");
read_json_file(&path, "orchestrate run.json")
}
pub fn load_orchestrate_plan(ito_path: &Path, run_id: &str) -> CoreResult<RunPlan> {
let path = run_root(ito_path, run_id).join("plan.json");
read_json_file(&path, "orchestrate plan.json")
}
pub fn append_orchestrate_event(
ito_path: &Path,
run_id: &str,
event: &OrchestrateEvent,
) -> CoreResult<()> {
let path = run_root(ito_path, run_id).join("events.jsonl");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| CoreError::io("create orchestrate events directory", e))?;
}
let json = serde_json::to_string(event).map_err(|e| CoreError::Serde {
context: "serialize orchestrate event".to_string(),
message: e.to_string(),
})?;
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| {
CoreError::io(
format!("open orchestrate events.jsonl: {}", path.display()),
e,
)
})?;
writeln!(f, "{json}").map_err(|e| CoreError::io("append orchestrate event", e))?;
f.flush()
.map_err(|e| CoreError::io("flush orchestrate event log", e))?;
Ok(())
}
pub fn load_orchestrate_change_state(
ito_path: &Path,
run_id: &str,
change_id: &str,
) -> CoreResult<Option<OrchestrateChangeState>> {
let path = change_state_path(ito_path, run_id, change_id);
if !path.exists() {
return Ok(None);
}
let state = read_json_file(&path, "orchestrate change state")?;
Ok(Some(state))
}
pub fn write_orchestrate_change_state(
ito_path: &Path,
run_id: &str,
state: &OrchestrateChangeState,
) -> CoreResult<()> {
let path = change_state_path(ito_path, run_id, &state.change_id);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| CoreError::io("create orchestrate change state directory", e))?;
}
write_json_pretty(&path, state).map_err(|e| {
CoreError::io(
format!("write orchestrate change state: {}", path.display()),
e,
)
})?;
Ok(())
}
pub fn generate_orchestrate_run_id(now: DateTime<Utc>) -> String {
let ts = now.format("%Y%m%d-%H%M%S").to_string();
let uuid = uuid::Uuid::new_v4().simple().to_string();
let short = uuid.chars().take(8).collect::<String>();
format!("{ts}-{short}")
}
pub fn remaining_gates_for_change(
planned: &[PlannedGate],
state: Option<&OrchestrateChangeState>,
) -> Vec<PlannedGate> {
let planned = ensure_planned_implementation_readiness_first(planned);
let Some(state) = state else {
return planned;
};
let mut outcomes: std::collections::BTreeMap<&str, GateOutcome> =
std::collections::BTreeMap::new();
for g in &state.gates {
outcomes.insert(&g.gate, g.outcome);
}
let mut start = 1;
for (i, g) in planned.iter().enumerate().skip(1) {
match outcomes.get(g.name.as_str()) {
Some(GateOutcome::Pass) | Some(GateOutcome::Skip) => {
start = i + 1;
continue;
}
Some(GateOutcome::Fail) | None => {
start = i;
break;
}
}
}
let mut remaining = planned[start..].to_vec();
remaining.insert(0, planned[0].clone());
remaining
}
fn run_root(ito_path: &Path, run_id: &str) -> PathBuf {
ito_path
.join(".state")
.join("orchestrate")
.join("runs")
.join(run_id)
}
fn change_state_path(ito_path: &Path, run_id: &str, change_id: &str) -> PathBuf {
run_root(ito_path, run_id)
.join("changes")
.join(format!("{change_id}.json"))
}
fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(value)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, json)
}
fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path, label: &str) -> CoreResult<T> {
let contents = std::fs::read_to_string(path)
.map_err(|e| CoreError::io(format!("read {label}: {}", path.display()), e))?;
serde_json::from_str(&contents).map_err(|e| CoreError::Serde {
context: format!("parse {label}"),
message: e.to_string(),
})
}