1use crate::errors::{CoreError, CoreResult};
6use crate::orchestrate::plan::{PlannedGate, RunPlan};
7use crate::orchestrate::types::{FailurePolicy, GateOutcome, OrchestrateRunStatus};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::fs::OpenOptions;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct OrchestrateRun {
17 pub run_id: String,
19 pub started_at: String,
21 pub finished_at: Option<String>,
23 pub status: OrchestrateRunStatus,
25 pub preset: String,
27 pub max_parallel: usize,
29 pub failure_policy: FailurePolicy,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct OrchestrateGateRecord {
36 pub gate: String,
38 pub outcome: GateOutcome,
40 pub finished_at: String,
42 #[serde(default)]
43 pub error: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48pub struct OrchestrateChangeState {
50 pub change_id: String,
52 #[serde(default)]
53 pub gates: Vec<OrchestrateGateRecord>,
55 pub updated_at: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct OrchestrateEvent {
62 pub ts: String,
64 pub kind: OrchestrateEventKind,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(tag = "event", rename_all = "kebab-case")]
70pub enum OrchestrateEventKind {
72 RunStart {
74 run_id: String,
76 preset: String,
78 max_parallel: usize,
80 failure_policy: FailurePolicy,
82 },
83 RunComplete {
85 run_id: String,
87 status: OrchestrateRunStatus,
89 },
90 GateStart {
92 run_id: String,
94 change_id: String,
96 gate: String,
98 },
99 GatePass {
101 run_id: String,
103 change_id: String,
105 gate: String,
107 },
108 GateFail {
110 run_id: String,
112 change_id: String,
114 gate: String,
116 error: String,
118 },
119 GateSkip {
121 run_id: String,
123 change_id: String,
125 gate: String,
127 },
128 WorkerDispatch {
130 run_id: String,
132 change_id: String,
134 gate: String,
136 role: String,
138 },
139 WorkerComplete {
141 run_id: String,
143 change_id: String,
145 gate: String,
147 outcome: GateOutcome,
149 },
150 RemediationDispatch {
152 run_id: String,
154 change_id: String,
156 failed_gate: String,
158 },
159}
160
161pub fn init_orchestrate_run_state(
163 ito_path: &Path,
164 run: &OrchestrateRun,
165 plan: &RunPlan,
166) -> CoreResult<()> {
167 let root = run_root(ito_path, &run.run_id);
168 std::fs::create_dir_all(root.join("changes"))
169 .map_err(|e| CoreError::io("create orchestrate run state directory", e))?;
170
171 write_json_pretty(&root.join("run.json"), run)
172 .map_err(|e| CoreError::io("write orchestrate run.json", e))?;
173 write_json_pretty(&root.join("plan.json"), plan)
174 .map_err(|e| CoreError::io("write orchestrate plan.json", e))?;
175
176 let events = root.join("events.jsonl");
178 if !events.exists() {
179 std::fs::write(&events, "")
180 .map_err(|e| CoreError::io("create orchestrate events.jsonl", e))?;
181 }
182
183 Ok(())
184}
185
186pub fn load_orchestrate_run(ito_path: &Path, run_id: &str) -> CoreResult<OrchestrateRun> {
191 let path = run_root(ito_path, run_id).join("run.json");
192 read_json_file(&path, "orchestrate run.json")
193}
194
195pub fn load_orchestrate_plan(ito_path: &Path, run_id: &str) -> CoreResult<RunPlan> {
200 let path = run_root(ito_path, run_id).join("plan.json");
201 read_json_file(&path, "orchestrate plan.json")
202}
203
204pub fn append_orchestrate_event(
206 ito_path: &Path,
207 run_id: &str,
208 event: &OrchestrateEvent,
209) -> CoreResult<()> {
210 let path = run_root(ito_path, run_id).join("events.jsonl");
211 if let Some(parent) = path.parent() {
212 std::fs::create_dir_all(parent)
213 .map_err(|e| CoreError::io("create orchestrate events directory", e))?;
214 }
215
216 let json = serde_json::to_string(event).map_err(|e| CoreError::Serde {
217 context: "serialize orchestrate event".to_string(),
218 message: e.to_string(),
219 })?;
220 let mut f = OpenOptions::new()
221 .create(true)
222 .append(true)
223 .open(&path)
224 .map_err(|e| {
225 CoreError::io(
226 format!("open orchestrate events.jsonl: {}", path.display()),
227 e,
228 )
229 })?;
230 writeln!(f, "{json}").map_err(|e| CoreError::io("append orchestrate event", e))?;
231 f.flush()
232 .map_err(|e| CoreError::io("flush orchestrate event log", e))?;
233 Ok(())
234}
235
236pub fn load_orchestrate_change_state(
238 ito_path: &Path,
239 run_id: &str,
240 change_id: &str,
241) -> CoreResult<Option<OrchestrateChangeState>> {
242 let path = change_state_path(ito_path, run_id, change_id);
243 if !path.exists() {
244 return Ok(None);
245 }
246 let state = read_json_file(&path, "orchestrate change state")?;
247 Ok(Some(state))
248}
249
250pub fn write_orchestrate_change_state(
252 ito_path: &Path,
253 run_id: &str,
254 state: &OrchestrateChangeState,
255) -> CoreResult<()> {
256 let path = change_state_path(ito_path, run_id, &state.change_id);
257 if let Some(parent) = path.parent() {
258 std::fs::create_dir_all(parent)
259 .map_err(|e| CoreError::io("create orchestrate change state directory", e))?;
260 }
261 write_json_pretty(&path, state).map_err(|e| {
262 CoreError::io(
263 format!("write orchestrate change state: {}", path.display()),
264 e,
265 )
266 })?;
267 Ok(())
268}
269
270pub fn generate_orchestrate_run_id(now: DateTime<Utc>) -> String {
272 let ts = now.format("%Y%m%d-%H%M%S").to_string();
273 let uuid = uuid::Uuid::new_v4().simple().to_string();
274 let short = uuid.chars().take(8).collect::<String>();
275 format!("{ts}-{short}")
276}
277
278pub fn remaining_gates_for_change(
283 planned: &[PlannedGate],
284 state: Option<&OrchestrateChangeState>,
285) -> Vec<PlannedGate> {
286 let Some(state) = state else {
287 return planned.to_vec();
288 };
289
290 let mut outcomes: std::collections::BTreeMap<&str, GateOutcome> =
291 std::collections::BTreeMap::new();
292 for g in &state.gates {
293 outcomes.insert(&g.gate, g.outcome);
294 }
295
296 let mut start = 0;
297 for (i, g) in planned.iter().enumerate() {
298 match outcomes.get(g.name.as_str()) {
299 Some(GateOutcome::Pass) | Some(GateOutcome::Skip) => {
300 start = i + 1;
301 continue;
302 }
303 Some(GateOutcome::Fail) | None => {
304 start = i;
305 break;
306 }
307 }
308 }
309
310 planned[start..].to_vec()
311}
312
313fn run_root(ito_path: &Path, run_id: &str) -> PathBuf {
314 ito_path
315 .join(".state")
316 .join("orchestrate")
317 .join("runs")
318 .join(run_id)
319}
320
321fn change_state_path(ito_path: &Path, run_id: &str, change_id: &str) -> PathBuf {
322 run_root(ito_path, run_id)
323 .join("changes")
324 .join(format!("{change_id}.json"))
325}
326
327fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> std::io::Result<()> {
328 let json = serde_json::to_string_pretty(value)
329 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
330 std::fs::write(path, json)
331}
332
333fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path, label: &str) -> CoreResult<T> {
334 let contents = std::fs::read_to_string(path)
335 .map_err(|e| CoreError::io(format!("read {label}: {}", path.display()), e))?;
336 serde_json::from_str(&contents).map_err(|e| CoreError::Serde {
337 context: format!("parse {label}"),
338 message: e.to_string(),
339 })
340}