1use std::path::{Path, PathBuf};
49
50use chrono::{DateTime, Utc};
51use serde_json::Value;
52
53use crate::error::{Error, Result};
54use crate::paths::RunPaths;
55use crate::projections::{
56 read_discussion_opt, read_manifest_opt, read_node_opt, read_spinoff_opt, write_discussion,
57 write_manifest, write_node, write_spinoff,
58};
59use crate::schema::{
60 ChildRef, Discussion, DiscussionId, DiscussionStatus, Event, IdValidationError, Kind,
61 Lifecycle, Manifest, Node, NodeId, ProposalId, RunId, SpinoffProposal, SpinoffStatus, Status,
62 TmuxIdentity, STATE_SCHEMA_VERSION,
63};
64
65fn corrupt_id(events_path: &Path, ev: &Event, e: &IdValidationError) -> Error {
71 Error::CorruptEventLog {
72 path: events_path.to_path_buf(),
73 reason: format!("event seq={} kind={}: {e}", ev.seq, ev.kind),
74 }
75}
76
77fn opt_run_id(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<RunId>> {
81 match d.get(field) {
82 None | Some(Value::Null) => Ok(None),
83 Some(Value::String(s)) => RunId::parse_str(s)
84 .map(Some)
85 .map_err(|e| corrupt_id(events_path, ev, &e)),
86 Some(_) => Err(Error::CorruptEventLog {
87 path: events_path.to_path_buf(),
88 reason: format!(
89 "event seq={} kind={} `{field}` must be a JSON string or null",
90 ev.seq, ev.kind
91 ),
92 }),
93 }
94}
95
96fn opt_node_id(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<NodeId>> {
98 match d.get(field) {
99 None | Some(Value::Null) => Ok(None),
100 Some(Value::String(s)) => NodeId::parse_str(s)
101 .map(Some)
102 .map_err(|e| corrupt_id(events_path, ev, &e)),
103 Some(_) => Err(Error::CorruptEventLog {
104 path: events_path.to_path_buf(),
105 reason: format!(
106 "event seq={} kind={} `{field}` must be a JSON string or null",
107 ev.seq, ev.kind
108 ),
109 }),
110 }
111}
112
113fn want_node_id_with_fallback(
117 events_path: &Path,
118 ev: &Event,
119 d: &Value,
120 field: &str,
121) -> Result<NodeId> {
122 let s = d
123 .get(field)
124 .and_then(Value::as_str)
125 .or(ev.node_id.as_ref().map(NodeId::as_str))
126 .ok_or_else(|| Error::CorruptEventLog {
127 path: events_path.to_path_buf(),
128 reason: format!("event seq={} kind={} missing `{field}`", ev.seq, ev.kind),
129 })?;
130 NodeId::parse_str(s).map_err(|e| corrupt_id(events_path, ev, &e))
131}
132
133fn data_kind(v: &Value) -> Option<Kind> {
134 serde_json::from_value(v.clone()).ok()
135}
136
137fn data_status(v: &Value) -> Option<Status> {
138 serde_json::from_value(v.clone()).ok()
139}
140
141fn require_status(ev: &Event, path: PathBuf) -> Result<Status> {
142 data_status(ev.data.get("status").unwrap_or(&Value::Null)).ok_or_else(|| {
143 Error::CorruptEventLog {
144 path,
145 reason: format!("{} missing/invalid `status`", ev.kind),
146 }
147 })
148}
149
150fn want_str<'a>(events_path: &Path, ev: &Event, d: &'a Value, field: &str) -> Result<&'a str> {
151 d.get(field)
152 .and_then(Value::as_str)
153 .ok_or_else(|| Error::CorruptEventLog {
154 path: events_path.to_path_buf(),
155 reason: format!(
156 "event seq={} kind={} missing `{field}` string field",
157 ev.seq, ev.kind
158 ),
159 })
160}
161
162fn optional_str(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<String>> {
166 match d.get(field) {
167 None | Some(Value::Null) => Ok(None),
168 Some(Value::String(s)) => Ok(Some(s.clone())),
169 Some(_) => Err(Error::CorruptEventLog {
170 path: events_path.to_path_buf(),
171 reason: format!(
172 "event seq={} kind={} `{field}` must be a JSON string or null",
173 ev.seq, ev.kind
174 ),
175 }),
176 }
177}
178
179fn optional_bool(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<bool>> {
185 match d.get(field) {
186 None | Some(Value::Null) => Ok(None),
187 Some(Value::Bool(b)) => Ok(Some(*b)),
188 Some(_) => Err(Error::CorruptEventLog {
189 path: events_path.to_path_buf(),
190 reason: format!(
191 "event seq={} kind={} `{field}` must be a JSON boolean or null",
192 ev.seq, ev.kind
193 ),
194 }),
195 }
196}
197
198fn optional_i32(d: &Value, field: &str, events_path: &Path, ev: &Event) -> Result<Option<i32>> {
199 match d.get(field) {
200 None | Some(Value::Null) => Ok(None),
201 Some(v) => {
202 let raw = v.as_i64().ok_or_else(|| Error::CorruptEventLog {
203 path: events_path.to_path_buf(),
204 reason: format!(
205 "event seq={} kind={} `{field}` must be integer",
206 ev.seq, ev.kind
207 ),
208 })?;
209 i32::try_from(raw)
210 .map(Some)
211 .map_err(|_| Error::CorruptEventLog {
212 path: events_path.to_path_buf(),
213 reason: format!(
214 "event seq={} kind={} `{field}` out of i32 range: {raw}",
215 ev.seq, ev.kind
216 ),
217 })
218 }
219 }
220}
221
222fn optional_ts(
223 d: &Value,
224 field: &str,
225 events_path: &Path,
226 ev: &Event,
227) -> Result<Option<DateTime<Utc>>> {
228 match d.get(field) {
229 None | Some(Value::Null) => Ok(None),
230 Some(Value::String(s)) => DateTime::parse_from_rfc3339(s)
231 .map(|dt| Some(dt.with_timezone(&Utc)))
232 .map_err(|_| Error::CorruptEventLog {
233 path: events_path.to_path_buf(),
234 reason: format!(
235 "event seq={} kind={} `{field}` not RFC3339",
236 ev.seq, ev.kind
237 ),
238 }),
239 Some(_) => Err(Error::CorruptEventLog {
240 path: events_path.to_path_buf(),
241 reason: format!(
242 "event seq={} kind={} `{field}` must be RFC3339 string or null",
243 ev.seq, ev.kind
244 ),
245 }),
246 }
247}
248
249pub(crate) enum ProjectionOp {
259 Manifest(Manifest),
261 Node(Node),
263 Discussion(Discussion),
265 Spinoff(SpinoffProposal),
267}
268
269pub(crate) fn commit_ops(paths: &RunPaths, ops: Vec<ProjectionOp>) -> Result<()> {
277 for op in ops {
278 match op {
279 ProjectionOp::Manifest(m) => write_manifest(paths, &m)?,
280 ProjectionOp::Node(n) => write_node(paths, &n)?,
281 ProjectionOp::Discussion(d) => write_discussion(paths, &d)?,
282 ProjectionOp::Spinoff(s) => write_spinoff(paths, &s)?,
283 }
284 }
285 Ok(())
286}
287
288pub(crate) fn reduce_event_to_ops(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
304 if ev.run_id != paths.run_id {
308 return Err(Error::CorruptEventLog {
309 path: paths.events(),
310 reason: format!(
311 "event seq={} envelope run_id {:?} does not match run {:?}",
312 ev.seq,
313 ev.run_id.as_str(),
314 paths.run_id.as_str()
315 ),
316 });
317 }
318 #[allow(clippy::match_same_arms)]
321 match ev.kind.as_str() {
322 "run.created" => reduce_run_created(paths, ev),
323 "run.status" => reduce_run_status(paths, ev),
324 "node.created" => reduce_node_created(paths, ev),
325 "node.status" => reduce_node_status(paths, ev),
326 "node.report" => reduce_node_report(paths, ev),
327 "node.retry" => reduce_node_retry(paths, ev),
328 "discussion.opened" => reduce_discussion_opened(paths, ev),
329 "discussion.resolved" => reduce_discussion_resolved(paths, ev),
330 "spinoff.proposed" => reduce_spinoff_proposed(paths, ev),
331 "spinoff.approved" => reduce_spinoff_approved(paths, ev),
332 "spinoff.rejected" => reduce_spinoff_rejected(paths, ev),
333 "child.spawned" => reduce_child_spawned(paths, ev),
334 "supervisor.attached" => reduce_supervisor_attached(paths, ev),
335 "supervisor.cursor_advanced" => reduce_supervisor_cursor_advanced(paths, ev),
336 "supervisor.exited" => Ok(vec![]),
337 "orchestrator.decision" | "discuss.critical" => Ok(vec![]),
345 "run.notified" => Ok(vec![]),
352 "cleanup.window_missing"
377 | "cleanup.worktree_missing"
378 | "cleanup.branch_remove_failed"
379 | "cleanup.branch_preserved"
380 | "cleanup.session_killed"
381 | "cleanup.session_retained" => Ok(vec![]),
382 "supervisor.child_id_quarantined" => Ok(vec![]),
392 _ => Ok(vec![]),
393 }
394}
395
396fn op_path(paths: &RunPaths, op: &ProjectionOp) -> PathBuf {
402 match op {
403 ProjectionOp::Manifest(_) => paths.manifest(),
404 ProjectionOp::Node(n) => paths.node(&n.node_id),
405 ProjectionOp::Discussion(d) => paths.discussion(&d.discussion_id),
406 ProjectionOp::Spinoff(s) => paths.spinoff(&s.proposal_id),
407 }
408}
409
410pub fn plan_projections(paths: &RunPaths, event: &Event) -> Result<Vec<PathBuf>> {
432 let ops = reduce_event_to_ops(paths, event)?;
433 Ok(ops.iter().map(|op| op_path(paths, op)).collect())
434}
435
436pub(crate) fn apply_event(paths: &RunPaths, ev: &Event) -> Result<()> {
452 let ops = reduce_event_to_ops(paths, ev)?;
453 commit_ops(paths, ops)
454}
455
456#[cfg(test)]
465pub(crate) fn validate_event(paths: &RunPaths, ev: &Event) -> Result<()> {
466 reduce_event_to_ops(paths, ev).map(|_| ())
467}
468
469fn require_envelope_node_id(events_path: &Path, ev: &Event) -> Result<NodeId> {
473 ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
474 path: events_path.to_path_buf(),
475 reason: format!(
476 "event seq={} kind={} missing top-level `node_id`",
477 ev.seq, ev.kind
478 ),
479 })
480}
481
482fn reduce_run_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
483 if let Some(existing) = read_manifest_opt(paths)? {
487 if existing.run_id != ev.run_id {
488 return Err(Error::CorruptEventLog {
489 path: paths.manifest(),
490 reason: format!(
491 "run.created run_id={} conflicts with existing manifest run_id={}",
492 ev.run_id, existing.run_id
493 ),
494 });
495 }
496 return Ok(vec![]);
497 }
498 let events_path = paths.events();
499 let d = &ev.data;
500 let kind =
501 data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
502 path: events_path.clone(),
503 reason: "run.created missing/invalid `kind`".into(),
504 })?;
505 let lifecycle: Lifecycle = serde_json::from_value(
506 d.get("lifecycle").cloned().unwrap_or(Value::Null),
507 )
508 .map_err(|_| Error::CorruptEventLog {
509 path: events_path.clone(),
510 reason: "run.created missing/invalid `lifecycle`".into(),
511 })?;
512 let title = want_str(&events_path, ev, d, "title")?.to_string();
513 let m = Manifest {
514 schema_version: STATE_SCHEMA_VERSION,
515 applied_seq: 0,
518 run_id: paths.run_id.clone(),
520 kind,
521 lifecycle,
522 title,
523 status: Status::Pending,
524 created_at: ev.ts,
525 updated_at: ev.ts,
526 source_repo: d
527 .get("source_repo")
528 .and_then(Value::as_str)
529 .map(str::to_string),
530 source_branch: d
531 .get("source_branch")
532 .and_then(Value::as_str)
533 .map(str::to_string),
534 worktree_root: d
535 .get("worktree_root")
536 .and_then(Value::as_str)
537 .map(str::to_string),
538 managed_tmux_session: d
539 .get("managed_tmux_session")
540 .and_then(Value::as_str)
541 .map(str::to_string),
542 notify_cmd: d
543 .get("notify_cmd")
544 .and_then(Value::as_str)
545 .map(str::to_string),
546 node_count: 0,
547 open_discussions: 0,
548 pending_spinoffs: 0,
549 parent_run_id: opt_run_id(&events_path, ev, d, "parent_run_id")?,
550 parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
551 };
552 Ok(vec![ProjectionOp::Manifest(m)])
553}
554
555fn reduce_run_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
556 let mut m = match read_manifest_opt(paths)? {
557 Some(m) => m,
558 None => return Ok(vec![]),
559 };
560 let new_status = require_status(ev, paths.events())?;
561 if m.status.is_terminal() {
564 trace_terminal_noop(ev, m.status, new_status);
565 return Ok(vec![]);
566 }
567 if m.status == new_status {
568 return Ok(vec![]);
569 }
570 m.status = new_status;
571 m.updated_at = ev.ts;
572 Ok(vec![ProjectionOp::Manifest(m)])
573}
574
575fn tmux_identity_from_data(d: &Value) -> Option<TmuxIdentity> {
586 let nonempty = |key| {
587 d.get(key)
588 .and_then(Value::as_str)
589 .map(str::trim)
590 .filter(|s| !s.is_empty())
591 .map(str::to_string)
592 };
593 let session = nonempty("tmux_session")?;
594 let window_id = nonempty("tmux_window_id")?;
595 Some(TmuxIdentity {
596 socket: nonempty("tmux_socket"),
597 session,
598 window_id,
599 pane_id: nonempty("tmux_pane_id"),
602 })
603}
604
605fn reduce_node_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
606 let events_path = paths.events();
607 let node_id = require_envelope_node_id(&events_path, ev)?;
610 if read_node_opt(paths, &node_id)?.is_some() {
612 return Ok(vec![]);
613 }
614 let d = &ev.data;
615 let kind =
616 data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
617 path: events_path.clone(),
618 reason: format!(
619 "event seq={} kind=node.created missing/invalid `kind`",
620 ev.seq
621 ),
622 })?;
623 let n = Node {
624 schema_version: STATE_SCHEMA_VERSION,
625 node_id,
626 run_id: paths.run_id.clone(),
628 parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
629 kind,
630 status: Status::Pending,
631 task: d.get("task").and_then(Value::as_str).map(str::to_string),
632 worktree_path: d
633 .get("worktree_path")
634 .and_then(Value::as_str)
635 .map(str::to_string),
636 branch: d.get("branch").and_then(Value::as_str).map(str::to_string),
637 base_sha: d
638 .get("base_sha")
639 .and_then(Value::as_str)
640 .filter(|s| !s.is_empty())
641 .map(str::to_string),
642 tmux_window: d
643 .get("tmux_window")
644 .and_then(Value::as_str)
645 .map(str::to_string),
646 tmux_identity: tmux_identity_from_data(d),
647 agent_pid: optional_i32(d, "agent_pid", &events_path, ev)?,
648 agent_pid_start_time: optional_ts(d, "agent_pid_start_time", &events_path, ev)?,
649 supervisor_pid: optional_i32(d, "supervisor_pid", &events_path, ev)?,
650 children: Vec::new(),
651 started_at: Some(ev.ts),
652 updated_at: ev.ts,
653 last_report: None,
654 last_processed_report_seq_by_child: serde_json::Map::default(),
655 retry_attempts: 0,
656 };
657 let mut ops = vec![ProjectionOp::Node(n)];
658 if let Some(mut m) = read_manifest_opt(paths)? {
659 m.updated_at = ev.ts;
664 ops.push(ProjectionOp::Manifest(m));
665 }
666 Ok(ops)
667}
668
669fn reduce_node_retry(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
688 let events_path = paths.events();
689 let node_id = require_envelope_node_id(&events_path, ev)?;
690 let mut n = match read_node_opt(paths, &node_id)? {
691 Some(n) => n,
692 None => return Ok(vec![]),
693 };
694 if n.status.is_terminal() {
697 tracing::debug!(
698 target: "octl_core::reducer",
699 seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
700 "no-op: node.retry against terminal node"
701 );
702 return Ok(vec![]);
703 }
704 let d = &ev.data;
705 n.branch = d.get("branch").and_then(Value::as_str).map(str::to_string);
708 n.base_sha = d
709 .get("base_sha")
710 .and_then(Value::as_str)
711 .filter(|s| !s.is_empty())
712 .map(str::to_string);
713 n.worktree_path = d
714 .get("worktree_path")
715 .and_then(Value::as_str)
716 .map(str::to_string);
717 n.tmux_window = d
718 .get("tmux_window")
719 .and_then(Value::as_str)
720 .map(str::to_string);
721 n.tmux_identity = tmux_identity_from_data(d);
722 n.agent_pid = optional_i32(d, "agent_pid", &events_path, ev)?;
723 n.agent_pid_start_time = optional_ts(d, "agent_pid_start_time", &events_path, ev)?;
724 n.status = Status::Pending;
725 n.started_at = Some(ev.ts);
726 n.updated_at = ev.ts;
727 n.last_report = None;
728 n.retry_attempts = d
736 .get("attempt")
737 .and_then(Value::as_u64)
738 .map_or_else(|| n.retry_attempts.saturating_add(1), |a| a as u32);
739 Ok(vec![ProjectionOp::Node(n)])
740}
741
742fn reduce_node_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
743 let events_path = paths.events();
744 let node_id = require_envelope_node_id(&events_path, ev)?;
745 let mut n = match read_node_opt(paths, &node_id)? {
746 Some(n) => n,
747 None => return Ok(vec![]),
748 };
749 let new_status = require_status(ev, events_path)?;
750 if n.status.is_terminal() {
753 trace_terminal_noop(ev, n.status, new_status);
754 return Ok(vec![]);
755 }
756 if n.status == new_status {
757 return Ok(vec![]);
758 }
759 n.status = new_status;
760 n.updated_at = ev.ts;
761 Ok(vec![ProjectionOp::Node(n)])
762}
763
764fn reduce_node_report(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
765 let events_path = paths.events();
766 let node_id = require_envelope_node_id(&events_path, ev)?;
767 let mut n = match read_node_opt(paths, &node_id)? {
768 Some(n) => n,
769 None => return Ok(vec![]),
770 };
771 if n.status.is_terminal() {
783 if matches!(n.status, Status::Failed | Status::Done)
822 && report_is_confirmed_explicit_merge(&ev.data)
823 {
824 if n.last_report.as_ref() == Some(&ev.data) && n.status == Status::Done {
825 return Ok(vec![]);
826 }
827 tracing::info!(
828 target: "octl_core::reducer",
829 seq = ev.seq, kind = %ev.kind, node_id = %node_id, prior = ?n.status,
830 "adopting late explicit-merge report against terminal node (invariant #5 teardown)"
831 );
832 n.last_report = Some(ev.data.clone());
833 n.status = Status::Done;
837 n.updated_at = ev.ts;
838 return Ok(vec![ProjectionOp::Node(n)]);
839 }
840 tracing::debug!(
841 target: "octl_core::reducer",
842 seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
843 "no-op: node.report against terminal node"
844 );
845 return Ok(vec![]);
846 }
847 let new_status = report_terminal_status(&events_path, ev)?;
855 n.last_report = Some(ev.data.clone());
856 n.status = new_status;
857 n.updated_at = ev.ts;
858 Ok(vec![ProjectionOp::Node(n)])
859}
860
861fn trace_terminal_noop(ev: &Event, current: Status, incoming: Status) {
869 if current == incoming {
870 tracing::debug!(
871 target: "octl_core::reducer",
872 seq = ev.seq, kind = %ev.kind, status = ?current,
873 "no-op: status re-applied to terminal target"
874 );
875 } else {
876 tracing::warn!(
877 target: "octl_core::reducer",
878 seq = ev.seq, kind = %ev.kind, current = ?current, incoming = ?incoming,
879 "no-op: ignored conflicting transition from terminal target"
880 );
881 }
882}
883
884pub const VIA_EXPLICIT_MERGE: &str = "explicit-merge";
890
891fn report_is_confirmed_explicit_merge(data: &Value) -> bool {
906 let via = data.get("via").and_then(Value::as_str) == Some(VIA_EXPLICIT_MERGE);
907 let success = matches!(data.get("success"), Some(Value::Bool(true)));
908 let not_cancelled = matches!(
909 data.get("cancelled"),
910 None | Some(Value::Null | Value::Bool(false))
911 );
912 via && success && not_cancelled
913}
914
915fn report_terminal_status(events_path: &Path, ev: &Event) -> Result<Status> {
924 let corrupt = |reason: String| Error::CorruptEventLog {
925 path: events_path.to_path_buf(),
926 reason,
927 };
928 let cancelled = optional_bool(events_path, ev, &ev.data, "cancelled")?.unwrap_or(false);
929 let success = optional_bool(events_path, ev, &ev.data, "success")?;
930 if cancelled {
931 if success == Some(true) {
932 return Err(corrupt(format!(
933 "event seq={} kind=node.report has contradictory `success: true` with `cancelled: true`",
934 ev.seq
935 )));
936 }
937 Ok(Status::Cancelled)
938 } else {
939 match success {
940 Some(true) => Ok(Status::Done),
941 Some(false) => Ok(Status::Failed),
942 None => Err(corrupt(format!(
943 "event seq={} kind=node.report must set boolean `success` or `cancelled: true`",
944 ev.seq
945 ))),
946 }
947 }
948}
949
950fn reduce_discussion_opened(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
951 let events_path = paths.events();
952 let d = &ev.data;
953 let discussion_id = DiscussionId::parse_str(want_str(&events_path, ev, d, "discussion_id")?)
954 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
955 if read_discussion_opt(paths, &discussion_id)?.is_some() {
956 return Ok(vec![]);
957 }
958 let node_id = want_node_id_with_fallback(&events_path, ev, d, "node_id")?;
959 let options = d
960 .get("options")
961 .and_then(Value::as_array)
962 .map(|a| {
963 a.iter()
964 .filter_map(|v| v.as_str().map(str::to_string))
965 .collect()
966 })
967 .unwrap_or_default();
968 let disc = Discussion {
969 schema_version: STATE_SCHEMA_VERSION,
970 discussion_id,
971 run_id: paths.run_id.clone(),
972 node_id,
973 opened_at: ev.ts,
974 severity: d
975 .get("severity")
976 .and_then(Value::as_str)
977 .unwrap_or("discuss")
978 .to_string(),
979 topic: want_str(&events_path, ev, d, "topic")?.to_string(),
980 context: d.get("context").and_then(Value::as_str).map(str::to_string),
981 options,
982 status: DiscussionStatus::Open,
983 resolution: None,
984 note: None,
985 resolved_at: None,
986 };
987 let mut ops = vec![ProjectionOp::Discussion(disc)];
988 if let Some(mut m) = read_manifest_opt(paths)? {
989 m.updated_at = ev.ts;
992 ops.push(ProjectionOp::Manifest(m));
993 }
994 Ok(ops)
995}
996
997fn reduce_discussion_resolved(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
998 let events_path = paths.events();
999 let id = DiscussionId::parse_str(want_str(&events_path, ev, &ev.data, "discussion_id")?)
1000 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1001 let mut disc = match read_discussion_opt(paths, &id)? {
1002 Some(d) => d,
1003 None => return Ok(vec![]),
1004 };
1005 if matches!(disc.status, DiscussionStatus::Resolved) {
1006 return Ok(vec![]);
1007 }
1008 disc.status = DiscussionStatus::Resolved;
1009 disc.resolution = Some(want_str(&events_path, ev, &ev.data, "resolution")?.to_string());
1015 disc.note = optional_str(&events_path, ev, &ev.data, "note")?;
1016 disc.resolved_at = Some(ev.ts);
1017 let mut ops = vec![ProjectionOp::Discussion(disc)];
1018 if let Some(mut m) = read_manifest_opt(paths)? {
1019 m.updated_at = ev.ts;
1025 ops.push(ProjectionOp::Manifest(m));
1026 }
1027 Ok(ops)
1028}
1029
1030fn reduce_spinoff_proposed(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1031 let events_path = paths.events();
1032 let d = &ev.data;
1033 let proposal_id = ProposalId::parse_str(want_str(&events_path, ev, d, "proposal_id")?)
1034 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1035 if read_spinoff_opt(paths, &proposal_id)?.is_some() {
1036 return Ok(vec![]);
1037 }
1038 let proposed_kind =
1039 data_kind(d.get("proposed_kind").unwrap_or(&Value::Null)).ok_or_else(|| {
1040 Error::CorruptEventLog {
1041 path: events_path.clone(),
1042 reason: format!(
1043 "event seq={} kind=spinoff.proposed missing/invalid `proposed_kind`",
1044 ev.seq
1045 ),
1046 }
1047 })?;
1048 let node_id = want_node_id_with_fallback(&events_path, ev, d, "node_id")?;
1049 let s = SpinoffProposal {
1050 schema_version: STATE_SCHEMA_VERSION,
1051 proposal_id,
1052 run_id: paths.run_id.clone(),
1053 node_id,
1054 proposed_at: ev.ts,
1055 proposed_title: want_str(&events_path, ev, d, "proposed_title")?.to_string(),
1056 proposed_kind,
1057 rationale: d
1058 .get("rationale")
1059 .and_then(Value::as_str)
1060 .map(str::to_string),
1061 status: SpinoffStatus::Proposed,
1062 accepted_as_issue_slug: None,
1063 rejected_reason: None,
1064 resolved_at: None,
1065 };
1066 let mut ops = vec![ProjectionOp::Spinoff(s)];
1067 if let Some(mut m) = read_manifest_opt(paths)? {
1068 m.updated_at = ev.ts;
1071 ops.push(ProjectionOp::Manifest(m));
1072 }
1073 Ok(ops)
1074}
1075
1076fn reduce_spinoff_approved(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1077 let events_path = paths.events();
1078 let id = ProposalId::parse_str(want_str(&events_path, ev, &ev.data, "proposal_id")?)
1079 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1080 let mut s = match read_spinoff_opt(paths, &id)? {
1081 Some(s) => s,
1082 None => return Ok(vec![]),
1083 };
1084 if matches!(s.status, SpinoffStatus::Approved | SpinoffStatus::Rejected) {
1085 return Ok(vec![]);
1086 }
1087 s.status = SpinoffStatus::Approved;
1088 s.accepted_as_issue_slug = ev
1089 .data
1090 .get("issue_slug")
1091 .and_then(Value::as_str)
1092 .map(str::to_string);
1093 s.resolved_at = Some(ev.ts);
1094 let mut ops = vec![ProjectionOp::Spinoff(s)];
1095 if let Some(mut m) = read_manifest_opt(paths)? {
1096 m.updated_at = ev.ts;
1102 ops.push(ProjectionOp::Manifest(m));
1103 }
1104 Ok(ops)
1105}
1106
1107fn reduce_spinoff_rejected(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1108 let events_path = paths.events();
1109 let id = ProposalId::parse_str(want_str(&events_path, ev, &ev.data, "proposal_id")?)
1110 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1111 let mut s = match read_spinoff_opt(paths, &id)? {
1112 Some(s) => s,
1113 None => return Ok(vec![]),
1114 };
1115 if matches!(s.status, SpinoffStatus::Approved | SpinoffStatus::Rejected) {
1116 return Ok(vec![]);
1117 }
1118 s.status = SpinoffStatus::Rejected;
1119 s.rejected_reason = ev
1120 .data
1121 .get("reason")
1122 .and_then(Value::as_str)
1123 .map(str::to_string);
1124 s.resolved_at = Some(ev.ts);
1125 let mut ops = vec![ProjectionOp::Spinoff(s)];
1126 if let Some(mut m) = read_manifest_opt(paths)? {
1127 m.updated_at = ev.ts;
1133 ops.push(ProjectionOp::Manifest(m));
1134 }
1135 Ok(ops)
1136}
1137
1138fn reduce_child_spawned(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1139 let events_path = paths.events();
1142 let parent_node_id = ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
1143 path: events_path.clone(),
1144 reason: format!(
1145 "event seq={} kind=child.spawned missing parent `node_id`",
1146 ev.seq
1147 ),
1148 })?;
1149 let child_run_id = RunId::parse_str(want_str(&events_path, ev, &ev.data, "child_run_id")?)
1150 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1151 let child_node_id = NodeId::parse_str(
1152 ev.data
1153 .get("child_node_id")
1154 .and_then(Value::as_str)
1155 .unwrap_or("n-0001"),
1156 )
1157 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1158 let mut n = match read_node_opt(paths, &parent_node_id)? {
1159 Some(n) => n,
1160 None => return Ok(vec![]),
1161 };
1162 let new_ref = ChildRef {
1163 run_id: child_run_id,
1164 node_id: child_node_id,
1165 };
1166 if n.children.iter().any(|c| c == &new_ref) {
1167 return Ok(vec![]);
1170 }
1171 n.children.push(new_ref);
1172 n.updated_at = ev.ts;
1173 Ok(vec![ProjectionOp::Node(n)])
1174}
1175
1176fn reduce_supervisor_attached(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1187 let events_path = paths.events();
1188 let node_id = require_envelope_node_id(&events_path, ev)?;
1189 let raw = ev
1190 .data
1191 .get("pid")
1192 .and_then(Value::as_i64)
1193 .ok_or_else(|| Error::CorruptEventLog {
1194 path: events_path.clone(),
1195 reason: format!(
1196 "event seq={} kind=supervisor.attached missing/invalid `pid`",
1197 ev.seq
1198 ),
1199 })?;
1200 let pid = i32::try_from(raw).map_err(|_| Error::CorruptEventLog {
1201 path: events_path.clone(),
1202 reason: format!(
1203 "event seq={} kind=supervisor.attached `pid` out of i32 range: {raw}",
1204 ev.seq
1205 ),
1206 })?;
1207 let mut n = match read_node_opt(paths, &node_id)? {
1208 Some(n) => n,
1209 None => return Ok(vec![]),
1210 };
1211 if n.supervisor_pid == Some(pid) {
1212 return Ok(vec![]);
1213 }
1214 n.supervisor_pid = Some(pid);
1215 n.updated_at = ev.ts;
1216 Ok(vec![ProjectionOp::Node(n)])
1217}
1218
1219fn reduce_supervisor_cursor_advanced(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1230 let events_path = paths.events();
1231 let node_id = require_envelope_node_id(&events_path, ev)?;
1232 let child_run_id = want_str(&events_path, ev, &ev.data, "child_run_id")?;
1233 RunId::parse_str(child_run_id).map_err(|e| corrupt_id(&events_path, ev, &e))?;
1237 let report_seq = ev
1238 .data
1239 .get("report_seq")
1240 .and_then(Value::as_u64)
1241 .ok_or_else(|| Error::CorruptEventLog {
1242 path: events_path.clone(),
1243 reason: format!(
1244 "event seq={} kind=supervisor.cursor_advanced missing/invalid `report_seq`",
1245 ev.seq
1246 ),
1247 })?;
1248 let mut n = match read_node_opt(paths, &node_id)? {
1249 Some(n) => n,
1250 None => return Ok(vec![]),
1251 };
1252 if let Some(prev) = n
1253 .last_processed_report_seq_by_child
1254 .get(child_run_id)
1255 .and_then(Value::as_u64)
1256 {
1257 if report_seq <= prev {
1258 return Ok(vec![]);
1259 }
1260 }
1261 n.last_processed_report_seq_by_child
1262 .insert(child_run_id.to_string(), Value::from(report_seq));
1263 n.updated_at = ev.ts;
1264 Ok(vec![ProjectionOp::Node(n)])
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269 use super::*;
1270 use crate::schema::Event;
1271 use chrono::Utc;
1272 use tempfile::TempDir;
1273
1274 fn event(run_id: &str) -> Event {
1275 Event {
1276 ts: Utc::now(),
1277 seq: 1,
1278 kind: "run.status".into(),
1279 run_id: RunId::parse_str(run_id).unwrap(),
1280 node_id: None,
1281 idempotency_key: None,
1282 data: serde_json::json!({ "status": "running" }),
1283 }
1284 }
1285
1286 #[test]
1287 fn orchestrator_decision_and_discuss_critical_reduce_to_noop() {
1288 let tmp = TempDir::new().unwrap();
1292 let run_id = "01jxsnap000000000000000000";
1293 let rid = RunId::parse_str(run_id).unwrap();
1294 let dir = crate::run_dir(tmp.path(), &rid);
1295 std::fs::create_dir_all(&dir).unwrap();
1296 let paths = RunPaths::new(dir, run_id).unwrap();
1297
1298 let mut created = event(run_id);
1301 created.kind = "run.created".into();
1302 created.data = serde_json::json!({
1303 "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1304 });
1305 apply_event(&paths, &created).expect("run.created applies");
1306 let manifest_before = std::fs::read(paths.manifest()).unwrap();
1307
1308 for (seq, kind) in [(10u64, "orchestrator.decision"), (11, "discuss.critical")] {
1309 let mut ev = event(run_id);
1310 ev.seq = seq;
1311 ev.kind = kind.into();
1312 ev.data = serde_json::json!({ "summary": "x", "arbitrary": [1, 2, 3] });
1314 let ops = reduce_event_to_ops(&paths, &ev).expect("audit kind reduces cleanly");
1315 assert!(ops.is_empty(), "{kind} must plan no projection ops");
1316 apply_event(&paths, &ev).expect("audit kind applies as no-op");
1318 }
1319
1320 assert_eq!(
1322 std::fs::read(paths.manifest()).unwrap(),
1323 manifest_before,
1324 "audit events must not mutate the manifest"
1325 );
1326 assert!(!paths.nodes_dir().exists(), "no node projection created");
1327 }
1328
1329 fn bootstrap_retry_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1332 let rid = RunId::parse_str(run_id).unwrap();
1333 let dir = crate::run_dir(tmp.path(), &rid);
1334 std::fs::create_dir_all(&dir).unwrap();
1335 let paths = RunPaths::new(dir, run_id).unwrap();
1336 let mut created = event(run_id);
1337 created.kind = "run.created".into();
1338 created.data =
1339 serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
1340 apply_event(&paths, &created).expect("run.created applies");
1341 let mut node = event(run_id);
1342 node.seq = 2;
1343 node.kind = "node.created".into();
1344 node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1345 node.data = serde_json::json!({
1346 "kind": "spinoff",
1347 "branch": "wt/foo",
1348 "worktree_path": "/tmp/old-wt",
1349 "agent_pid": 111,
1350 });
1351 apply_event(&paths, &node).expect("node.created applies");
1352 paths
1353 }
1354
1355 #[test]
1359 fn node_retry_rewires_node_and_increments_attempts() {
1360 let tmp = TempDir::new().unwrap();
1361 let run_id = "01jxsnap000000000000000000";
1362 let paths = bootstrap_retry_node(&tmp, run_id);
1363
1364 let mut retry = event(run_id);
1365 retry.seq = 3;
1366 retry.kind = "node.retry".into();
1367 retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1368 retry.data = serde_json::json!({
1369 "attempt": 1,
1370 "reason": "agent-died",
1371 "branch": "wt/foo-r1",
1372 "base_sha": "a".repeat(40),
1373 "worktree_path": "/tmp/new-wt",
1374 "agent_pid": 222,
1375 "tmux_session": "s",
1376 "tmux_window_id": "@9",
1377 });
1378 apply_event(&paths, &retry).expect("node.retry applies");
1379
1380 let n = read_n0001(&paths);
1381 assert_eq!(n.retry_attempts, 1, "attempt bound incremented");
1382 assert_eq!(
1383 n.branch.as_deref(),
1384 Some("wt/foo-r1"),
1385 "rewired to new branch"
1386 );
1387 assert_eq!(n.worktree_path.as_deref(), Some("/tmp/new-wt"));
1388 assert_eq!(n.agent_pid, Some(222), "rewired to new agent pid");
1389 assert_eq!(n.status, Status::Pending, "node returns to pending");
1390 assert!(n.last_report.is_none());
1391 assert_eq!(
1392 n.tmux_identity.as_ref().map(|t| t.window_id.as_str()),
1393 Some("@9"),
1394 "rewired tmux identity"
1395 );
1396
1397 let mut retry2 = event(run_id);
1399 retry2.seq = 4;
1400 retry2.kind = "node.retry".into();
1401 retry2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1402 retry2.data = serde_json::json!({
1403 "attempt": 2, "reason": "agent-died", "branch": "wt/foo-r2",
1404 "worktree_path": "/tmp/new-wt-2", "agent_pid": 333,
1405 });
1406 apply_event(&paths, &retry2).expect("node.retry applies");
1407 assert_eq!(read_n0001(&paths).retry_attempts, 2);
1408 }
1409
1410 #[test]
1414 fn node_retry_against_terminal_node_is_noop() {
1415 let tmp = TempDir::new().unwrap();
1416 let run_id = "01jxsnap000000000000000000";
1417 let paths = bootstrap_retry_node(&tmp, run_id);
1418
1419 let mut report = event(run_id);
1421 report.seq = 3;
1422 report.kind = "node.report".into();
1423 report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1424 report.data = serde_json::json!({ "success": true });
1425 apply_event(&paths, &report).expect("node.report applies");
1426 assert_eq!(read_n0001(&paths).status, Status::Done);
1427
1428 let mut retry = event(run_id);
1429 retry.seq = 4;
1430 retry.kind = "node.retry".into();
1431 retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1432 retry.data = serde_json::json!({
1433 "attempt": 1, "reason": "agent-died", "branch": "wt/foo-r1",
1434 "worktree_path": "/tmp/new-wt", "agent_pid": 222,
1435 });
1436 apply_event(&paths, &retry).expect("node.retry applies as no-op");
1437
1438 let n = read_n0001(&paths);
1439 assert_eq!(n.status, Status::Done, "terminal node not resurrected");
1440 assert_eq!(n.retry_attempts, 0, "no increment against terminal node");
1441 assert_eq!(n.agent_pid, Some(111), "not rewired");
1442 }
1443
1444 #[test]
1445 fn apply_event_rejects_event_from_a_different_run() {
1446 let tmp = TempDir::new().unwrap();
1447 let run_id = "01jxsnap000000000000000000";
1448 let rid = RunId::parse_str(run_id).unwrap();
1449 let dir = crate::run_dir(tmp.path(), &rid);
1450 std::fs::create_dir_all(&dir).unwrap();
1451 let paths = RunPaths::new(dir, run_id).unwrap();
1452
1453 let foreign = event("02jxsnap000000000000000000");
1455 let err = apply_event(&paths, &foreign).expect_err("cross-run event must be rejected");
1456 assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
1457
1458 let mine = event(run_id);
1461 apply_event(&paths, &mine).expect("matching run_id must be accepted");
1462 }
1463
1464 #[test]
1465 fn tmux_identity_from_data_reads_qualified_fields() {
1466 let d = serde_json::json!({
1467 "tmux_socket": "/private/tmp/tmux-501/default",
1468 "tmux_session": "octl",
1469 "tmux_window_id": "@42",
1470 });
1471 let id = tmux_identity_from_data(&d).expect("qualified identity");
1472 assert_eq!(id.socket.as_deref(), Some("/private/tmp/tmux-501/default"));
1473 assert_eq!(id.session, "octl");
1474 assert_eq!(id.window_id, "@42");
1475 assert_eq!(id.pane_id, None);
1477
1478 let d2 = serde_json::json!({
1480 "tmux_socket": null,
1481 "tmux_session": "octl",
1482 "tmux_window_id": "@7",
1483 });
1484 let id2 = tmux_identity_from_data(&d2).expect("identity without socket");
1485 assert_eq!(id2.socket, None);
1486 assert_eq!(id2.window_id, "@7");
1487
1488 let d3 = serde_json::json!({
1490 "tmux_session": "octl",
1491 "tmux_window_id": "@42",
1492 "tmux_pane_id": "%7",
1493 });
1494 let id3 = tmux_identity_from_data(&d3).expect("identity with pane");
1495 assert_eq!(id3.pane_id.as_deref(), Some("%7"));
1496 assert_eq!(id3.capture_target(), "%7");
1497
1498 let d4 = serde_json::json!({
1501 "tmux_session": "octl",
1502 "tmux_window_id": "@42",
1503 "tmux_pane_id": null,
1504 });
1505 let id4 = tmux_identity_from_data(&d4).expect("identity with null pane");
1506 assert_eq!(id4.pane_id, None);
1507 assert_eq!(id4.capture_target(), "@42");
1508 }
1509
1510 #[test]
1511 fn tmux_identity_from_data_back_compat_is_none() {
1512 let legacy = serde_json::json!({ "tmux_window": "🚀 wt/x" });
1514 assert!(tmux_identity_from_data(&legacy).is_none());
1515 let partial = serde_json::json!({ "tmux_window_id": "@42" });
1517 assert!(tmux_identity_from_data(&partial).is_none());
1518 }
1519
1520 #[test]
1523 fn node_created_populates_tmux_identity() {
1524 let tmp = TempDir::new().unwrap();
1525 let run_id = "01jxsnap000000000000000000";
1526 let rid = RunId::parse_str(run_id).unwrap();
1527 let dir = crate::run_dir(tmp.path(), &rid);
1528 std::fs::create_dir_all(&dir).unwrap();
1529 let paths = RunPaths::new(dir, run_id).unwrap();
1530
1531 let mut ev = event(run_id);
1532 ev.seq = 2;
1533 ev.kind = "node.created".into();
1534 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1535 ev.data = serde_json::json!({
1536 "kind": "spinoff",
1537 "tmux_window": "🚀 wt/x",
1538 "tmux_socket": "/private/tmp/tmux-501/default",
1539 "tmux_session": "octl",
1540 "tmux_window_id": "@42",
1541 });
1542 apply_event(&paths, &ev).expect("node.created applies");
1543 let n = read_node_opt(&paths, &NodeId::parse_str("n-0001").unwrap())
1544 .unwrap()
1545 .unwrap();
1546 let id = n.tmux_identity.expect("qualified identity recorded");
1547 assert_eq!(id.session, "octl");
1548 assert_eq!(id.window_id, "@42");
1549 assert_eq!(n.tmux_window.as_deref(), Some("🚀 wt/x"));
1550
1551 let run2 = "02jxsnap000000000000000000";
1553 let rid2 = RunId::parse_str(run2).unwrap();
1554 let dir2 = crate::run_dir(tmp.path(), &rid2);
1555 std::fs::create_dir_all(&dir2).unwrap();
1556 let paths2 = RunPaths::new(dir2, run2).unwrap();
1557 let mut ev2 = event(run2);
1558 ev2.seq = 2;
1559 ev2.kind = "node.created".into();
1560 ev2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1561 ev2.data = serde_json::json!({ "kind": "spinoff", "tmux_window": "🚀 wt/y" });
1562 apply_event(&paths2, &ev2).expect("legacy node.created applies");
1563 let n2 = read_node_opt(&paths2, &NodeId::parse_str("n-0001").unwrap())
1564 .unwrap()
1565 .unwrap();
1566 assert!(n2.tmux_identity.is_none());
1567 assert_eq!(n2.tmux_window.as_deref(), Some("🚀 wt/y"));
1568 }
1569
1570 fn seed_run_with_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1573 let rid = RunId::parse_str(run_id).unwrap();
1574 let dir = crate::run_dir(tmp.path(), &rid);
1575 std::fs::create_dir_all(&dir).unwrap();
1576 let paths = RunPaths::new(dir, run_id).unwrap();
1577
1578 let mut created = event(run_id);
1579 created.kind = "run.created".into();
1580 created.data = serde_json::json!({
1581 "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1582 });
1583 apply_event(&paths, &created).expect("run.created applies");
1584
1585 let mut node = event(run_id);
1586 node.seq = 2;
1587 node.kind = "node.created".into();
1588 node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1589 node.data = serde_json::json!({ "kind": "spinoff" });
1590 apply_event(&paths, &node).expect("node.created applies");
1591 paths
1592 }
1593
1594 fn read_n0001(paths: &RunPaths) -> Node {
1595 read_node_opt(paths, &NodeId::parse_str("n-0001").unwrap())
1596 .unwrap()
1597 .unwrap()
1598 }
1599
1600 #[test]
1604 fn supervisor_attached_sets_supervisor_pid() {
1605 let tmp = TempDir::new().unwrap();
1606 let run_id = "01jxsnap000000000000000000";
1607 let paths = seed_run_with_node(&tmp, run_id);
1608 assert_eq!(read_n0001(&paths).supervisor_pid, None);
1609
1610 let mut ev = event(run_id);
1611 ev.seq = 3;
1612 ev.kind = "supervisor.attached".into();
1613 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1614 ev.data = serde_json::json!({ "pid": 47820 });
1615 apply_event(&paths, &ev).expect("supervisor.attached applies");
1616 assert_eq!(read_n0001(&paths).supervisor_pid, Some(47820));
1617 }
1618
1619 #[test]
1622 fn supervisor_attached_latest_wins_and_idempotent_on_replay() {
1623 let tmp = TempDir::new().unwrap();
1624 let run_id = "01jxsnap000000000000000000";
1625 let paths = seed_run_with_node(&tmp, run_id);
1626
1627 let mut ev = event(run_id);
1628 ev.kind = "supervisor.attached".into();
1629 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1630
1631 ev.seq = 3;
1632 ev.data = serde_json::json!({ "pid": 100 });
1633 apply_event(&paths, &ev).expect("first attach applies");
1634 assert_eq!(read_n0001(&paths).supervisor_pid, Some(100));
1635
1636 ev.seq = 4;
1638 ev.data = serde_json::json!({ "pid": 200 });
1639 apply_event(&paths, &ev).expect("second attach applies");
1640 let after_second = read_n0001(&paths);
1641 assert_eq!(after_second.supervisor_pid, Some(200));
1642
1643 let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
1646 assert!(ops.is_empty(), "re-applying same pid must plan no ops");
1647 apply_event(&paths, &ev).expect("replay applies as no-op");
1648 assert_eq!(read_n0001(&paths).updated_at, after_second.updated_at);
1649 }
1650
1651 #[test]
1654 fn supervisor_cursor_advanced_sets_report_cursor() {
1655 let tmp = TempDir::new().unwrap();
1656 let run_id = "01jxsnap000000000000000000";
1657 let paths = seed_run_with_node(&tmp, run_id);
1658 let child = "02jxsnap000000000000000000";
1659
1660 let mut ev = event(run_id);
1661 ev.seq = 3;
1662 ev.kind = "supervisor.cursor_advanced".into();
1663 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1664 ev.data = serde_json::json!({ "child_run_id": child, "report_seq": 7 });
1665 apply_event(&paths, &ev).expect("cursor_advanced applies");
1666
1667 let n = read_n0001(&paths);
1668 assert_eq!(
1669 n.last_processed_report_seq_by_child.get(child),
1670 Some(&Value::from(7u64))
1671 );
1672 }
1673
1674 #[test]
1679 fn supervisor_cursor_advanced_is_monotonic_and_idempotent() {
1680 let tmp = TempDir::new().unwrap();
1681 let run_id = "01jxsnap000000000000000000";
1682 let paths = seed_run_with_node(&tmp, run_id);
1683 let child_a = "02jxsnap000000000000000000";
1684 let child_b = "03jxsnap000000000000000000";
1685
1686 let mut ev = event(run_id);
1687 ev.kind = "supervisor.cursor_advanced".into();
1688 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1689
1690 ev.seq = 3;
1691 ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 5 });
1692 apply_event(&paths, &ev).expect("seq 5 applies");
1693
1694 let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
1696 assert!(ops.is_empty(), "re-applying same cursor must plan no ops");
1697
1698 ev.seq = 4;
1700 ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 3 });
1701 let ops = reduce_event_to_ops(&paths, &ev).expect("older seq reduces cleanly");
1702 assert!(ops.is_empty(), "older seq must plan no ops");
1703 apply_event(&paths, &ev).expect("older seq applies as no-op");
1704 assert_eq!(
1705 read_n0001(&paths)
1706 .last_processed_report_seq_by_child
1707 .get(child_a),
1708 Some(&Value::from(5u64))
1709 );
1710
1711 ev.seq = 5;
1713 ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 9 });
1714 apply_event(&paths, &ev).expect("higher seq applies");
1715 ev.seq = 6;
1716 ev.data = serde_json::json!({ "child_run_id": child_b, "report_seq": 1 });
1717 apply_event(&paths, &ev).expect("second child applies");
1718
1719 let n = read_n0001(&paths);
1720 assert_eq!(
1721 n.last_processed_report_seq_by_child.get(child_a),
1722 Some(&Value::from(9u64))
1723 );
1724 assert_eq!(
1725 n.last_processed_report_seq_by_child.get(child_b),
1726 Some(&Value::from(1u64))
1727 );
1728 }
1729
1730 #[test]
1733 fn supervisor_state_events_reject_malformed_payloads() {
1734 let tmp = TempDir::new().unwrap();
1735 let run_id = "01jxsnap000000000000000000";
1736 let paths = seed_run_with_node(&tmp, run_id);
1737 let nid = Some(NodeId::parse_str("n-0001").unwrap());
1738
1739 let mut ev = event(run_id);
1741 ev.seq = 3;
1742 ev.kind = "supervisor.attached".into();
1743 ev.node_id = nid.clone();
1744 ev.data = serde_json::json!({});
1745 assert!(matches!(
1746 reduce_event_to_ops(&paths, &ev),
1747 Err(Error::CorruptEventLog { .. })
1748 ));
1749
1750 ev.node_id = None;
1752 ev.data = serde_json::json!({ "pid": 1 });
1753 assert!(matches!(
1754 reduce_event_to_ops(&paths, &ev),
1755 Err(Error::CorruptEventLog { .. })
1756 ));
1757
1758 let mut ev2 = event(run_id);
1760 ev2.seq = 4;
1761 ev2.kind = "supervisor.cursor_advanced".into();
1762 ev2.node_id = nid.clone();
1763 ev2.data = serde_json::json!({ "child_run_id": "../etc", "report_seq": 1 });
1764 assert!(matches!(
1765 reduce_event_to_ops(&paths, &ev2),
1766 Err(Error::CorruptEventLog { .. })
1767 ));
1768
1769 ev2.data = serde_json::json!({ "child_run_id": "02jxsnap000000000000000000" });
1771 assert!(matches!(
1772 reduce_event_to_ops(&paths, &ev2),
1773 Err(Error::CorruptEventLog { .. })
1774 ));
1775 }
1776
1777 #[cfg(unix)]
1786 fn projection_inodes(paths: &RunPaths) -> std::collections::BTreeMap<PathBuf, u64> {
1787 use std::os::unix::fs::MetadataExt;
1788 let mut consider = vec![paths.manifest()];
1789 for dir in [
1790 paths.nodes_dir(),
1791 paths.discussions_dir(),
1792 paths.spinoffs_dir(),
1793 ] {
1794 if let Ok(rd) = std::fs::read_dir(&dir) {
1795 for ent in rd.flatten() {
1796 let p = ent.path();
1797 if p.extension().and_then(|s| s.to_str()) == Some("json") {
1798 consider.push(p);
1799 }
1800 }
1801 }
1802 }
1803 let mut map = std::collections::BTreeMap::new();
1804 for p in consider {
1805 if let Ok(md) = std::fs::symlink_metadata(&p) {
1806 if md.file_type().is_file() {
1807 map.insert(p, md.ino());
1808 }
1809 }
1810 }
1811 map
1812 }
1813
1814 #[cfg(unix)]
1823 fn assert_plan_matches_apply(paths: &RunPaths, ev: &Event, expect_writes: bool) {
1824 use std::collections::BTreeSet;
1825 let before = projection_inodes(paths);
1826 let planned: BTreeSet<PathBuf> = plan_projections(paths, ev)
1827 .unwrap_or_else(|e| panic!("plan_projections({}) errored: {e:?}", ev.kind))
1828 .into_iter()
1829 .collect();
1830 apply_event(paths, ev)
1831 .unwrap_or_else(|e| panic!("apply_event({}) errored: {e:?}", ev.kind));
1832 let after = projection_inodes(paths);
1833 let touched: BTreeSet<PathBuf> = after
1834 .iter()
1835 .filter(|(p, ino)| before.get(*p) != Some(*ino))
1836 .map(|(p, _)| p.clone())
1837 .collect();
1838 assert_eq!(
1839 planned, touched,
1840 "kind={}: plan_projections must name exactly the files apply_event writes",
1841 ev.kind
1842 );
1843 if expect_writes {
1844 assert!(
1845 !touched.is_empty(),
1846 "kind={}: expected this event to write at least one projection",
1847 ev.kind
1848 );
1849 }
1850 }
1851
1852 #[cfg(unix)]
1859 #[test]
1860 fn plan_projections_matches_apply_for_every_kind() {
1861 let tmp = TempDir::new().unwrap();
1862 let run_id = "01jxsnap000000000000000000";
1863 let rid = RunId::parse_str(run_id).unwrap();
1864 let dir = crate::run_dir(tmp.path(), &rid);
1865 std::fs::create_dir_all(&dir).unwrap();
1866 let paths = RunPaths::new(dir, run_id).unwrap();
1867 let nid = || Some(NodeId::parse_str("n-0001").unwrap());
1868 let disc_id = "d-pqrstuvwxy";
1869 let prop_id = "s-spinaaaaaa";
1870 let child = "02jxsnap000000000000000000";
1871
1872 let mut next_seq = 0u64;
1874 let mut at = |kind: &str, node_id, data| {
1875 next_seq += 1;
1876 Event {
1877 ts: Utc::now(),
1878 seq: next_seq,
1879 kind: kind.into(),
1880 run_id: rid.clone(),
1881 node_id,
1882 idempotency_key: None,
1883 data,
1884 }
1885 };
1886
1887 assert_plan_matches_apply(
1889 &paths,
1890 &at(
1891 "run.created",
1892 None,
1893 serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
1894 ),
1895 true,
1896 );
1897 assert_plan_matches_apply(
1899 &paths,
1900 &at(
1901 "run.status",
1902 None,
1903 serde_json::json!({ "status": "running" }),
1904 ),
1905 true,
1906 );
1907 assert_plan_matches_apply(
1909 &paths,
1910 &at(
1911 "node.created",
1912 nid(),
1913 serde_json::json!({ "kind": "spinoff" }),
1914 ),
1915 true,
1916 );
1917 assert_plan_matches_apply(
1919 &paths,
1920 &at(
1921 "node.status",
1922 nid(),
1923 serde_json::json!({ "status": "running" }),
1924 ),
1925 true,
1926 );
1927 assert_plan_matches_apply(
1929 &paths,
1930 &at(
1931 "discussion.opened",
1932 None,
1933 serde_json::json!({ "discussion_id": disc_id, "node_id": "n-0001", "topic": "t" }),
1934 ),
1935 true,
1936 );
1937 assert_plan_matches_apply(
1939 &paths,
1940 &at(
1941 "discussion.resolved",
1942 None,
1943 serde_json::json!({ "discussion_id": disc_id, "resolution": "keep" }),
1944 ),
1945 true,
1946 );
1947 assert_plan_matches_apply(
1949 &paths,
1950 &at(
1951 "spinoff.proposed",
1952 None,
1953 serde_json::json!({
1954 "proposal_id": prop_id, "node_id": "n-0001",
1955 "proposed_title": "p", "proposed_kind": "spinoff"
1956 }),
1957 ),
1958 true,
1959 );
1960 assert_plan_matches_apply(
1962 &paths,
1963 &at(
1964 "spinoff.approved",
1965 None,
1966 serde_json::json!({ "proposal_id": prop_id, "issue_slug": "x" }),
1967 ),
1968 true,
1969 );
1970 assert_plan_matches_apply(
1972 &paths,
1973 &at(
1974 "supervisor.attached",
1975 nid(),
1976 serde_json::json!({ "pid": 4242 }),
1977 ),
1978 true,
1979 );
1980 assert_plan_matches_apply(
1982 &paths,
1983 &at(
1984 "supervisor.cursor_advanced",
1985 nid(),
1986 serde_json::json!({ "child_run_id": child, "report_seq": 3 }),
1987 ),
1988 true,
1989 );
1990 assert_plan_matches_apply(
1992 &paths,
1993 &at(
1994 "child.spawned",
1995 nid(),
1996 serde_json::json!({ "child_run_id": child, "child_node_id": "n-0001" }),
1997 ),
1998 true,
1999 );
2000 assert_plan_matches_apply(
2002 &paths,
2003 &at("node.report", nid(), serde_json::json!({ "success": true })),
2004 true,
2005 );
2006 assert_plan_matches_apply(
2009 &paths,
2010 &at(
2011 "node.status",
2012 nid(),
2013 serde_json::json!({ "status": "failed" }),
2014 ),
2015 false,
2016 );
2017 for kind in [
2019 "supervisor.exited",
2020 "orchestrator.decision",
2021 "discuss.critical",
2022 "cleanup.window_missing",
2023 ] {
2024 assert_plan_matches_apply(&paths, &at(kind, None, serde_json::json!({})), false);
2025 }
2026
2027 let prop2 = "s-spinbbbbbb";
2030 assert_plan_matches_apply(
2031 &paths,
2032 &at(
2033 "spinoff.proposed",
2034 None,
2035 serde_json::json!({
2036 "proposal_id": prop2, "node_id": "n-0001",
2037 "proposed_title": "p2", "proposed_kind": "spinoff"
2038 }),
2039 ),
2040 true,
2041 );
2042 assert_plan_matches_apply(
2043 &paths,
2044 &at(
2045 "spinoff.rejected",
2046 None,
2047 serde_json::json!({ "proposal_id": prop2, "reason": "no" }),
2048 ),
2049 true,
2050 );
2051 }
2052}