1use crate::phase_id::PhaseId;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::time::{SystemTime, UNIX_EPOCH};
24use tracing::warn;
25
26pub fn events_path(project_root: &Path) -> PathBuf {
28 project_root.join(".devflow").join("events.jsonl")
29}
30
31const SCHEMA_VERSION: u32 = 1;
33
34pub fn emit(project_root: &Path, phase: PhaseId, event: &str, fields: serde_json::Value) {
37 let mut line = serde_json::json!({
38 "v": SCHEMA_VERSION,
39 "ts": unix_now(),
40 "phase": phase,
41 "event": event,
42 });
43 match fields {
44 serde_json::Value::Object(map) => {
45 let base = line.as_object_mut().expect("line is an object");
46 for (key, value) in map {
47 base.entry(key).or_insert(value);
50 }
51 }
52 serde_json::Value::Null => {}
53 other => {
54 line["data"] = other;
55 }
56 }
57 let path = events_path(project_root);
58 if let Some(parent) = path.parent()
59 && let Err(err) = crate::workflow::ensure_devflow_dir(parent)
60 {
61 warn!("could not create events dir: {err}");
62 return;
63 }
64 let result = std::fs::OpenOptions::new()
65 .create(true)
66 .append(true)
67 .open(&path)
68 .and_then(|mut f| f.write_all(format!("{line}\n").as_bytes()));
69 if let Err(err) = result {
70 warn!("could not append to {}: {err}", path.display());
71 }
72}
73
74#[derive(Debug, Clone)]
80pub struct PhaseEventSummary {
81 pub event: serde_json::Value,
82 pub stage_launched_ts: Option<u64>,
83}
84
85pub fn last_events_by_phase(
92 project_root: &Path,
93) -> std::collections::HashMap<PhaseId, PhaseEventSummary> {
94 use std::collections::hash_map::Entry;
95
96 let mut latest: std::collections::HashMap<PhaseId, PhaseEventSummary> =
97 std::collections::HashMap::new();
98 let Ok(contents) = std::fs::read_to_string(events_path(project_root)) else {
99 return latest;
100 };
101 for event in contents
102 .lines()
103 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
104 {
105 let Some(phase) = PhaseId::from_json(event.get("phase")) else {
106 continue;
107 };
108 let launch_ts = (event.get("event").and_then(|e| e.as_str()) == Some("stage_launched"))
109 .then(|| event.get("ts").and_then(|t| t.as_u64()))
110 .flatten();
111 match latest.entry(phase) {
115 Entry::Occupied(mut occupied) => {
116 let summary = occupied.get_mut();
117 summary.event = event;
118 if let Some(ts) = launch_ts {
119 summary.stage_launched_ts = Some(ts);
120 }
121 }
122 Entry::Vacant(vacant) => {
123 vacant.insert(PhaseEventSummary {
124 event,
125 stage_launched_ts: launch_ts,
126 });
127 }
128 }
129 }
130 latest
131}
132
133pub fn last_event_for_phase(project_root: &Path, phase: PhaseId) -> Option<serde_json::Value> {
135 last_events_by_phase(project_root)
136 .remove(&phase)
137 .map(|summary| summary.event)
138}
139
140pub fn last_event_of_kind_for_phase(
151 project_root: &Path,
152 phase: PhaseId,
153 event: &str,
154) -> Option<serde_json::Value> {
155 let contents = std::fs::read_to_string(events_path(project_root)).ok()?;
156 let mut last = None;
157 for value in contents
158 .lines()
159 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
160 {
161 if phase.matches_json(value.get("phase"))
162 && value.get("event").and_then(|e| e.as_str()) == Some(event)
163 {
164 last = Some(value);
165 }
166 }
167 last
168}
169
170pub fn has_event_for_phase(project_root: &Path, phase: PhaseId, event: &str) -> bool {
174 last_event_of_kind_for_phase(project_root, phase, event).is_some()
175}
176
177pub fn describe(event: &serde_json::Value) -> String {
179 let kind = event
180 .get("event")
181 .and_then(|e| e.as_str())
182 .unwrap_or("unknown");
183 let detail = ["to", "stage", "status", "hook", "reason"]
184 .iter()
185 .find_map(|key| event.get(*key).and_then(|v| v.as_str()));
186 match detail {
187 Some(detail) => format!("{kind} ({detail})"),
188 None => kind.to_string(),
189 }
190}
191
192fn unix_now() -> u64 {
193 SystemTime::now()
194 .duration_since(UNIX_EPOCH)
195 .map(|d| d.as_secs())
196 .unwrap_or(0)
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn read_lines(root: &Path) -> Vec<serde_json::Value> {
204 std::fs::read_to_string(events_path(root))
205 .unwrap_or_default()
206 .lines()
207 .map(|l| serde_json::from_str(l).expect("every line parses as JSON"))
208 .collect()
209 }
210
211 #[test]
212 fn emit_appends_parseable_lines_with_envelope_fields() {
213 let dir = tempfile::tempdir().unwrap();
214 emit(
215 dir.path(),
216 PhaseId::new(14),
217 "transition",
218 serde_json::json!({"from": "code", "to": "validate"}),
219 );
220 emit(
221 dir.path(),
222 PhaseId::new(15),
223 "gate_fired",
224 serde_json::json!({"stage": "ship"}),
225 );
226
227 let lines = read_lines(dir.path());
228 assert_eq!(lines.len(), 2);
229 for line in &lines {
230 assert_eq!(line["v"], 1);
231 assert!(line["ts"].as_u64().is_some());
232 assert!(line["phase"].as_u64().is_some());
233 assert!(line["event"].as_str().is_some());
234 }
235 assert_eq!(lines[0]["phase"], 14);
236 assert_eq!(lines[0]["from"], "code");
237 assert_eq!(lines[1]["phase"], 15);
238 assert_eq!(lines[1]["stage"], "ship");
239 }
240
241 #[test]
242 fn emit_never_lets_payload_forge_envelope_keys() {
243 let dir = tempfile::tempdir().unwrap();
244 emit(
245 dir.path(),
246 PhaseId::new(7),
247 "transition",
248 serde_json::json!({"phase": 99, "event": "forged", "note": "kept"}),
249 );
250
251 let lines = read_lines(dir.path());
252 assert_eq!(lines[0]["phase"], 7, "envelope phase must win");
253 assert_eq!(lines[0]["event"], "transition", "envelope event must win");
254 assert_eq!(lines[0]["note"], "kept");
255 }
256
257 #[test]
258 fn last_event_for_phase_filters_by_phase() {
259 let dir = tempfile::tempdir().unwrap();
260 emit(
261 dir.path(),
262 PhaseId::new(1),
263 "workflow_started",
264 serde_json::Value::Null,
265 );
266 emit(
267 dir.path(),
268 PhaseId::new(2),
269 "workflow_started",
270 serde_json::Value::Null,
271 );
272 emit(
273 dir.path(),
274 PhaseId::new(1),
275 "transition",
276 serde_json::json!({"to": "plan"}),
277 );
278
279 let last = last_event_for_phase(dir.path(), PhaseId::new(1)).expect("phase 1 events exist");
280 assert_eq!(last["event"], "transition");
281 let other =
282 last_event_for_phase(dir.path(), PhaseId::new(2)).expect("phase 2 events exist");
283 assert_eq!(other["event"], "workflow_started");
284 assert!(last_event_for_phase(dir.path(), PhaseId::new(3)).is_none());
285 }
286
287 #[test]
289 fn last_events_by_phase_collects_latest_per_phase_in_one_pass() {
290 let dir = tempfile::tempdir().unwrap();
291 emit(
292 dir.path(),
293 PhaseId::new(1),
294 "workflow_started",
295 serde_json::Value::Null,
296 );
297 emit(
298 dir.path(),
299 PhaseId::new(2),
300 "workflow_started",
301 serde_json::Value::Null,
302 );
303 emit(
304 dir.path(),
305 PhaseId::new(1),
306 "transition",
307 serde_json::json!({"to": "plan"}),
308 );
309 emit(
310 dir.path(),
311 PhaseId::new(2),
312 "gate_fired",
313 serde_json::json!({"stage": "ship"}),
314 );
315
316 let latest = last_events_by_phase(dir.path());
317 assert_eq!(latest.len(), 2);
318 assert_eq!(latest[&PhaseId::new(1)].event["event"], "transition");
319 assert_eq!(latest[&PhaseId::new(2)].event["event"], "gate_fired");
320 assert!(last_events_by_phase(&dir.path().join("empty")).is_empty());
321 }
322
323 #[test]
328 fn last_events_by_phase_tracks_newest_stage_launched_ts_across_the_pass() {
329 let dir = tempfile::tempdir().unwrap();
330
331 emit(
333 dir.path(),
334 PhaseId::new(1),
335 "workflow_started",
336 serde_json::Value::Null,
337 );
338 assert_eq!(
339 last_events_by_phase(dir.path())[&PhaseId::new(1)].stage_launched_ts,
340 None
341 );
342
343 emit(
345 dir.path(),
346 PhaseId::new(1),
347 "stage_launched",
348 serde_json::json!({"stage": "define"}),
349 );
350 let first_ts = last_events_by_phase(dir.path())[&PhaseId::new(1)]
351 .stage_launched_ts
352 .expect("stage_launched recorded a timestamp");
353
354 emit(
357 dir.path(),
358 PhaseId::new(1),
359 "transition",
360 serde_json::json!({"to": "plan"}),
361 );
362 let after_transition = last_events_by_phase(dir.path());
363 assert_eq!(
364 after_transition[&PhaseId::new(1)].stage_launched_ts,
365 Some(first_ts)
366 );
367 assert_eq!(
368 after_transition[&PhaseId::new(1)].event["event"],
369 "transition"
370 );
371
372 emit(
374 dir.path(),
375 PhaseId::new(1),
376 "stage_launched",
377 serde_json::json!({"stage": "plan"}),
378 );
379 let path = events_path(dir.path());
380 let mut contents = std::fs::read_to_string(&path).unwrap();
381 contents.push_str("{truncated\n");
383 std::fs::write(&path, contents).unwrap();
384
385 let latest = last_events_by_phase(dir.path());
386 let newest_ts = latest[&PhaseId::new(1)]
387 .stage_launched_ts
388 .expect("newest stage_launched timestamp present");
389 assert!(newest_ts >= first_ts, "newest launch must not be older");
390 assert_eq!(latest[&PhaseId::new(1)].event["event"], "stage_launched");
391 }
392
393 #[test]
394 fn last_event_skips_corrupt_lines() {
395 let dir = tempfile::tempdir().unwrap();
396 emit(
397 dir.path(),
398 PhaseId::new(4),
399 "workflow_started",
400 serde_json::Value::Null,
401 );
402 let path = events_path(dir.path());
403 let mut contents = std::fs::read_to_string(&path).unwrap();
404 contents.push_str("{truncated\n");
405 std::fs::write(&path, contents).unwrap();
406
407 let last =
408 last_event_for_phase(dir.path(), PhaseId::new(4)).expect("valid line still found");
409 assert_eq!(last["event"], "workflow_started");
410 }
411
412 const MARKER_EVENT: &str = "marker_event";
417
418 #[test]
419 fn last_event_of_kind_for_phase_filters_by_phase_and_event_name() {
420 let dir = tempfile::tempdir().unwrap();
421 emit(
422 dir.path(),
423 PhaseId::new(1),
424 "workflow_finished",
425 serde_json::Value::Null,
426 );
427 emit(
428 dir.path(),
429 PhaseId::new(1),
430 MARKER_EVENT,
431 serde_json::json!({"stage": "ship"}),
432 );
433 emit(
434 dir.path(),
435 PhaseId::new(2),
436 MARKER_EVENT,
437 serde_json::json!({"stage": "ship"}),
438 );
439
440 let phase1 = last_event_of_kind_for_phase(dir.path(), PhaseId::new(1), MARKER_EVENT)
441 .expect("phase 1 marker event exists");
442 assert_eq!(phase1["stage"], "ship");
443 assert!(has_event_for_phase(
444 dir.path(),
445 PhaseId::new(1),
446 MARKER_EVENT
447 ));
448 assert!(!has_event_for_phase(
449 dir.path(),
450 PhaseId::new(3),
451 MARKER_EVENT
452 ));
453 assert!(last_event_of_kind_for_phase(dir.path(), PhaseId::new(3), MARKER_EVENT).is_none());
454 }
455
456 #[test]
457 fn last_event_of_kind_for_phase_skips_corrupt_lines() {
458 let dir = tempfile::tempdir().unwrap();
459 emit(
460 dir.path(),
461 PhaseId::new(4),
462 MARKER_EVENT,
463 serde_json::Value::Null,
464 );
465 let path = events_path(dir.path());
466 let mut contents = std::fs::read_to_string(&path).unwrap();
467 contents.push_str("{truncated\n");
468 std::fs::write(&path, contents).unwrap();
469
470 assert!(has_event_for_phase(
471 dir.path(),
472 PhaseId::new(4),
473 MARKER_EVENT
474 ));
475 }
476
477 #[test]
478 fn describe_prefers_detail_fields() {
479 assert_eq!(
480 describe(&serde_json::json!({"event": "transition", "to": "ship"})),
481 "transition (ship)"
482 );
483 assert_eq!(
484 describe(&serde_json::json!({"event": "workflow_finished"})),
485 "workflow_finished"
486 );
487 assert_eq!(describe(&serde_json::json!({})), "unknown");
488 }
489
490 #[test]
491 fn emit_is_fail_soft_on_unwritable_path() {
492 let dir = tempfile::tempdir().unwrap();
495 std::fs::write(dir.path().join(".devflow"), "not a dir").unwrap();
496 emit(
497 dir.path(),
498 PhaseId::new(1),
499 "transition",
500 serde_json::Value::Null,
501 );
502 }
503}