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 harness: d.get("harness").and_then(Value::as_str).map(str::to_string),
547 node_count: 0,
548 open_discussions: 0,
549 pending_spinoffs: 0,
550 parent_run_id: opt_run_id(&events_path, ev, d, "parent_run_id")?,
551 parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
552 };
553 Ok(vec![ProjectionOp::Manifest(m)])
554}
555
556fn reduce_run_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
557 let mut m = match read_manifest_opt(paths)? {
558 Some(m) => m,
559 None => return Ok(vec![]),
560 };
561 let new_status = require_status(ev, paths.events())?;
562 if m.status.is_terminal() {
565 trace_terminal_noop(ev, m.status, new_status);
566 return Ok(vec![]);
567 }
568 if m.status == new_status {
569 return Ok(vec![]);
570 }
571 m.status = new_status;
572 m.updated_at = ev.ts;
573 Ok(vec![ProjectionOp::Manifest(m)])
574}
575
576fn tmux_identity_from_data(d: &Value) -> Option<TmuxIdentity> {
587 let nonempty = |key| {
588 d.get(key)
589 .and_then(Value::as_str)
590 .map(str::trim)
591 .filter(|s| !s.is_empty())
592 .map(str::to_string)
593 };
594 let session = nonempty("tmux_session")?;
595 let window_id = nonempty("tmux_window_id")?;
596 Some(TmuxIdentity {
597 socket: nonempty("tmux_socket"),
598 session,
599 window_id,
600 pane_id: nonempty("tmux_pane_id"),
603 })
604}
605
606fn reduce_node_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
607 let events_path = paths.events();
608 let node_id = require_envelope_node_id(&events_path, ev)?;
611 if read_node_opt(paths, &node_id)?.is_some() {
613 return Ok(vec![]);
614 }
615 let d = &ev.data;
616 let kind =
617 data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
618 path: events_path.clone(),
619 reason: format!(
620 "event seq={} kind=node.created missing/invalid `kind`",
621 ev.seq
622 ),
623 })?;
624 let n = Node {
625 schema_version: STATE_SCHEMA_VERSION,
626 node_id,
627 run_id: paths.run_id.clone(),
629 parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
630 kind,
631 status: Status::Pending,
632 task: d.get("task").and_then(Value::as_str).map(str::to_string),
633 worktree_path: d
634 .get("worktree_path")
635 .and_then(Value::as_str)
636 .map(str::to_string),
637 branch: d.get("branch").and_then(Value::as_str).map(str::to_string),
638 base_sha: d
639 .get("base_sha")
640 .and_then(Value::as_str)
641 .filter(|s| !s.is_empty())
642 .map(str::to_string),
643 tmux_window: d
644 .get("tmux_window")
645 .and_then(Value::as_str)
646 .map(str::to_string),
647 tmux_identity: tmux_identity_from_data(d),
648 agent_pid: optional_i32(d, "agent_pid", &events_path, ev)?,
649 agent_pid_start_time: optional_ts(d, "agent_pid_start_time", &events_path, ev)?,
650 supervisor_pid: optional_i32(d, "supervisor_pid", &events_path, ev)?,
651 children: Vec::new(),
652 started_at: Some(ev.ts),
653 updated_at: ev.ts,
654 last_report: None,
655 last_processed_report_seq_by_child: serde_json::Map::default(),
656 retry_attempts: 0,
657 };
658 let mut ops = vec![ProjectionOp::Node(n)];
659 if let Some(mut m) = read_manifest_opt(paths)? {
660 m.updated_at = ev.ts;
665 ops.push(ProjectionOp::Manifest(m));
666 }
667 Ok(ops)
668}
669
670fn reduce_node_retry(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
689 let events_path = paths.events();
690 let node_id = require_envelope_node_id(&events_path, ev)?;
691 let mut n = match read_node_opt(paths, &node_id)? {
692 Some(n) => n,
693 None => return Ok(vec![]),
694 };
695 if n.status.is_terminal() {
698 tracing::debug!(
699 target: "octl_core::reducer",
700 seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
701 "no-op: node.retry against terminal node"
702 );
703 return Ok(vec![]);
704 }
705 let d = &ev.data;
706 n.branch = d.get("branch").and_then(Value::as_str).map(str::to_string);
709 n.base_sha = d
710 .get("base_sha")
711 .and_then(Value::as_str)
712 .filter(|s| !s.is_empty())
713 .map(str::to_string);
714 n.worktree_path = d
715 .get("worktree_path")
716 .and_then(Value::as_str)
717 .map(str::to_string);
718 n.tmux_window = d
719 .get("tmux_window")
720 .and_then(Value::as_str)
721 .map(str::to_string);
722 n.tmux_identity = tmux_identity_from_data(d);
723 n.agent_pid = optional_i32(d, "agent_pid", &events_path, ev)?;
724 n.agent_pid_start_time = optional_ts(d, "agent_pid_start_time", &events_path, ev)?;
725 n.status = Status::Pending;
726 n.started_at = Some(ev.ts);
727 n.updated_at = ev.ts;
728 n.last_report = None;
729 n.retry_attempts = d
737 .get("attempt")
738 .and_then(Value::as_u64)
739 .map_or_else(|| n.retry_attempts.saturating_add(1), |a| a as u32);
740 Ok(vec![ProjectionOp::Node(n)])
741}
742
743fn reduce_node_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
744 let events_path = paths.events();
745 let node_id = require_envelope_node_id(&events_path, ev)?;
746 let mut n = match read_node_opt(paths, &node_id)? {
747 Some(n) => n,
748 None => return Ok(vec![]),
749 };
750 let new_status = require_status(ev, events_path)?;
751 if n.status.is_terminal() {
754 trace_terminal_noop(ev, n.status, new_status);
755 return Ok(vec![]);
756 }
757 if n.status == new_status {
758 return Ok(vec![]);
759 }
760 n.status = new_status;
761 n.updated_at = ev.ts;
762 Ok(vec![ProjectionOp::Node(n)])
763}
764
765fn reduce_node_report(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
766 let events_path = paths.events();
767 let node_id = require_envelope_node_id(&events_path, ev)?;
768 let mut n = match read_node_opt(paths, &node_id)? {
769 Some(n) => n,
770 None => return Ok(vec![]),
771 };
772 if n.status.is_terminal() {
784 if matches!(n.status, Status::Failed | Status::Done)
823 && report_is_confirmed_explicit_merge(&ev.data)
824 {
825 if n.last_report.as_ref() == Some(&ev.data) && n.status == Status::Done {
826 return Ok(vec![]);
827 }
828 tracing::info!(
829 target: "octl_core::reducer",
830 seq = ev.seq, kind = %ev.kind, node_id = %node_id, prior = ?n.status,
831 "adopting late explicit-merge report against terminal node (invariant #5 teardown)"
832 );
833 n.last_report = Some(ev.data.clone());
834 n.status = Status::Done;
838 n.updated_at = ev.ts;
839 return Ok(vec![ProjectionOp::Node(n)]);
840 }
841 tracing::debug!(
842 target: "octl_core::reducer",
843 seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
844 "no-op: node.report against terminal node"
845 );
846 return Ok(vec![]);
847 }
848 let new_status = report_terminal_status(&events_path, ev)?;
856 n.last_report = Some(ev.data.clone());
857 n.status = new_status;
858 n.updated_at = ev.ts;
859 Ok(vec![ProjectionOp::Node(n)])
860}
861
862fn trace_terminal_noop(ev: &Event, current: Status, incoming: Status) {
870 if current == incoming {
871 tracing::debug!(
872 target: "octl_core::reducer",
873 seq = ev.seq, kind = %ev.kind, status = ?current,
874 "no-op: status re-applied to terminal target"
875 );
876 } else {
877 tracing::warn!(
878 target: "octl_core::reducer",
879 seq = ev.seq, kind = %ev.kind, current = ?current, incoming = ?incoming,
880 "no-op: ignored conflicting transition from terminal target"
881 );
882 }
883}
884
885pub const VIA_EXPLICIT_MERGE: &str = "explicit-merge";
891
892fn report_is_confirmed_explicit_merge(data: &Value) -> bool {
907 let via = data.get("via").and_then(Value::as_str) == Some(VIA_EXPLICIT_MERGE);
908 let success = matches!(data.get("success"), Some(Value::Bool(true)));
909 let not_cancelled = matches!(
910 data.get("cancelled"),
911 None | Some(Value::Null | Value::Bool(false))
912 );
913 via && success && not_cancelled
914}
915
916fn report_terminal_status(events_path: &Path, ev: &Event) -> Result<Status> {
925 let corrupt = |reason: String| Error::CorruptEventLog {
926 path: events_path.to_path_buf(),
927 reason,
928 };
929 let cancelled = optional_bool(events_path, ev, &ev.data, "cancelled")?.unwrap_or(false);
930 let success = optional_bool(events_path, ev, &ev.data, "success")?;
931 if cancelled {
932 if success == Some(true) {
933 return Err(corrupt(format!(
934 "event seq={} kind=node.report has contradictory `success: true` with `cancelled: true`",
935 ev.seq
936 )));
937 }
938 Ok(Status::Cancelled)
939 } else {
940 match success {
941 Some(true) => Ok(Status::Done),
942 Some(false) => Ok(Status::Failed),
943 None => Err(corrupt(format!(
944 "event seq={} kind=node.report must set boolean `success` or `cancelled: true`",
945 ev.seq
946 ))),
947 }
948 }
949}
950
951fn reduce_discussion_opened(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
952 let events_path = paths.events();
953 let d = &ev.data;
954 let discussion_id = DiscussionId::parse_str(want_str(&events_path, ev, d, "discussion_id")?)
955 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
956 if read_discussion_opt(paths, &discussion_id)?.is_some() {
957 return Ok(vec![]);
958 }
959 let node_id = want_node_id_with_fallback(&events_path, ev, d, "node_id")?;
960 let options = d
961 .get("options")
962 .and_then(Value::as_array)
963 .map(|a| {
964 a.iter()
965 .filter_map(|v| v.as_str().map(str::to_string))
966 .collect()
967 })
968 .unwrap_or_default();
969 let disc = Discussion {
970 schema_version: STATE_SCHEMA_VERSION,
971 discussion_id,
972 run_id: paths.run_id.clone(),
973 node_id,
974 opened_at: ev.ts,
975 severity: d
976 .get("severity")
977 .and_then(Value::as_str)
978 .unwrap_or("discuss")
979 .to_string(),
980 topic: want_str(&events_path, ev, d, "topic")?.to_string(),
981 context: d.get("context").and_then(Value::as_str).map(str::to_string),
982 options,
983 status: DiscussionStatus::Open,
984 resolution: None,
985 note: None,
986 resolved_at: None,
987 };
988 let mut ops = vec![ProjectionOp::Discussion(disc)];
989 if let Some(mut m) = read_manifest_opt(paths)? {
990 m.updated_at = ev.ts;
993 ops.push(ProjectionOp::Manifest(m));
994 }
995 Ok(ops)
996}
997
998fn reduce_discussion_resolved(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
999 let events_path = paths.events();
1000 let id = DiscussionId::parse_str(want_str(&events_path, ev, &ev.data, "discussion_id")?)
1001 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1002 let mut disc = match read_discussion_opt(paths, &id)? {
1003 Some(d) => d,
1004 None => return Ok(vec![]),
1005 };
1006 if matches!(disc.status, DiscussionStatus::Resolved) {
1007 return Ok(vec![]);
1008 }
1009 disc.status = DiscussionStatus::Resolved;
1010 disc.resolution = Some(want_str(&events_path, ev, &ev.data, "resolution")?.to_string());
1016 disc.note = optional_str(&events_path, ev, &ev.data, "note")?;
1017 disc.resolved_at = Some(ev.ts);
1018 let mut ops = vec![ProjectionOp::Discussion(disc)];
1019 if let Some(mut m) = read_manifest_opt(paths)? {
1020 m.updated_at = ev.ts;
1026 ops.push(ProjectionOp::Manifest(m));
1027 }
1028 Ok(ops)
1029}
1030
1031fn reduce_spinoff_proposed(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1032 let events_path = paths.events();
1033 let d = &ev.data;
1034 let proposal_id = ProposalId::parse_str(want_str(&events_path, ev, d, "proposal_id")?)
1035 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1036 if read_spinoff_opt(paths, &proposal_id)?.is_some() {
1037 return Ok(vec![]);
1038 }
1039 let proposed_kind =
1040 data_kind(d.get("proposed_kind").unwrap_or(&Value::Null)).ok_or_else(|| {
1041 Error::CorruptEventLog {
1042 path: events_path.clone(),
1043 reason: format!(
1044 "event seq={} kind=spinoff.proposed missing/invalid `proposed_kind`",
1045 ev.seq
1046 ),
1047 }
1048 })?;
1049 let node_id = want_node_id_with_fallback(&events_path, ev, d, "node_id")?;
1050 let s = SpinoffProposal {
1051 schema_version: STATE_SCHEMA_VERSION,
1052 proposal_id,
1053 run_id: paths.run_id.clone(),
1054 node_id,
1055 proposed_at: ev.ts,
1056 proposed_title: want_str(&events_path, ev, d, "proposed_title")?.to_string(),
1057 proposed_kind,
1058 rationale: d
1059 .get("rationale")
1060 .and_then(Value::as_str)
1061 .map(str::to_string),
1062 status: SpinoffStatus::Proposed,
1063 accepted_as_issue_slug: None,
1064 rejected_reason: None,
1065 resolved_at: None,
1066 };
1067 let mut ops = vec![ProjectionOp::Spinoff(s)];
1068 if let Some(mut m) = read_manifest_opt(paths)? {
1069 m.updated_at = ev.ts;
1072 ops.push(ProjectionOp::Manifest(m));
1073 }
1074 Ok(ops)
1075}
1076
1077fn reduce_spinoff_approved(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1078 let events_path = paths.events();
1079 let id = ProposalId::parse_str(want_str(&events_path, ev, &ev.data, "proposal_id")?)
1080 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1081 let mut s = match read_spinoff_opt(paths, &id)? {
1082 Some(s) => s,
1083 None => return Ok(vec![]),
1084 };
1085 if matches!(s.status, SpinoffStatus::Approved | SpinoffStatus::Rejected) {
1086 return Ok(vec![]);
1087 }
1088 s.status = SpinoffStatus::Approved;
1089 s.accepted_as_issue_slug = ev
1090 .data
1091 .get("issue_slug")
1092 .and_then(Value::as_str)
1093 .map(str::to_string);
1094 s.resolved_at = Some(ev.ts);
1095 let mut ops = vec![ProjectionOp::Spinoff(s)];
1096 if let Some(mut m) = read_manifest_opt(paths)? {
1097 m.updated_at = ev.ts;
1103 ops.push(ProjectionOp::Manifest(m));
1104 }
1105 Ok(ops)
1106}
1107
1108fn reduce_spinoff_rejected(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1109 let events_path = paths.events();
1110 let id = ProposalId::parse_str(want_str(&events_path, ev, &ev.data, "proposal_id")?)
1111 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1112 let mut s = match read_spinoff_opt(paths, &id)? {
1113 Some(s) => s,
1114 None => return Ok(vec![]),
1115 };
1116 if matches!(s.status, SpinoffStatus::Approved | SpinoffStatus::Rejected) {
1117 return Ok(vec![]);
1118 }
1119 s.status = SpinoffStatus::Rejected;
1120 s.rejected_reason = ev
1121 .data
1122 .get("reason")
1123 .and_then(Value::as_str)
1124 .map(str::to_string);
1125 s.resolved_at = Some(ev.ts);
1126 let mut ops = vec![ProjectionOp::Spinoff(s)];
1127 if let Some(mut m) = read_manifest_opt(paths)? {
1128 m.updated_at = ev.ts;
1134 ops.push(ProjectionOp::Manifest(m));
1135 }
1136 Ok(ops)
1137}
1138
1139fn reduce_child_spawned(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1140 let events_path = paths.events();
1143 let parent_node_id = ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
1144 path: events_path.clone(),
1145 reason: format!(
1146 "event seq={} kind=child.spawned missing parent `node_id`",
1147 ev.seq
1148 ),
1149 })?;
1150 let child_run_id = RunId::parse_str(want_str(&events_path, ev, &ev.data, "child_run_id")?)
1151 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1152 let child_node_id = NodeId::parse_str(
1153 ev.data
1154 .get("child_node_id")
1155 .and_then(Value::as_str)
1156 .unwrap_or("n-0001"),
1157 )
1158 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1159 let mut n = match read_node_opt(paths, &parent_node_id)? {
1160 Some(n) => n,
1161 None => return Ok(vec![]),
1162 };
1163 let new_ref = ChildRef {
1164 run_id: child_run_id,
1165 node_id: child_node_id,
1166 };
1167 if n.children.iter().any(|c| c == &new_ref) {
1168 return Ok(vec![]);
1171 }
1172 n.children.push(new_ref);
1173 n.updated_at = ev.ts;
1174 Ok(vec![ProjectionOp::Node(n)])
1175}
1176
1177fn reduce_supervisor_attached(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1188 let events_path = paths.events();
1189 let node_id = require_envelope_node_id(&events_path, ev)?;
1190 let raw = ev
1191 .data
1192 .get("pid")
1193 .and_then(Value::as_i64)
1194 .ok_or_else(|| Error::CorruptEventLog {
1195 path: events_path.clone(),
1196 reason: format!(
1197 "event seq={} kind=supervisor.attached missing/invalid `pid`",
1198 ev.seq
1199 ),
1200 })?;
1201 let pid = i32::try_from(raw).map_err(|_| Error::CorruptEventLog {
1202 path: events_path.clone(),
1203 reason: format!(
1204 "event seq={} kind=supervisor.attached `pid` out of i32 range: {raw}",
1205 ev.seq
1206 ),
1207 })?;
1208 let mut n = match read_node_opt(paths, &node_id)? {
1209 Some(n) => n,
1210 None => return Ok(vec![]),
1211 };
1212 if n.supervisor_pid == Some(pid) {
1213 return Ok(vec![]);
1214 }
1215 n.supervisor_pid = Some(pid);
1216 n.updated_at = ev.ts;
1217 Ok(vec![ProjectionOp::Node(n)])
1218}
1219
1220fn reduce_supervisor_cursor_advanced(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1231 let events_path = paths.events();
1232 let node_id = require_envelope_node_id(&events_path, ev)?;
1233 let child_run_id = want_str(&events_path, ev, &ev.data, "child_run_id")?;
1234 RunId::parse_str(child_run_id).map_err(|e| corrupt_id(&events_path, ev, &e))?;
1238 let report_seq = ev
1239 .data
1240 .get("report_seq")
1241 .and_then(Value::as_u64)
1242 .ok_or_else(|| Error::CorruptEventLog {
1243 path: events_path.clone(),
1244 reason: format!(
1245 "event seq={} kind=supervisor.cursor_advanced missing/invalid `report_seq`",
1246 ev.seq
1247 ),
1248 })?;
1249 let mut n = match read_node_opt(paths, &node_id)? {
1250 Some(n) => n,
1251 None => return Ok(vec![]),
1252 };
1253 if let Some(prev) = n
1254 .last_processed_report_seq_by_child
1255 .get(child_run_id)
1256 .and_then(Value::as_u64)
1257 {
1258 if report_seq <= prev {
1259 return Ok(vec![]);
1260 }
1261 }
1262 n.last_processed_report_seq_by_child
1263 .insert(child_run_id.to_string(), Value::from(report_seq));
1264 n.updated_at = ev.ts;
1265 Ok(vec![ProjectionOp::Node(n)])
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270 use super::*;
1271 use crate::schema::Event;
1272 use chrono::Utc;
1273 use tempfile::TempDir;
1274
1275 fn event(run_id: &str) -> Event {
1276 Event {
1277 ts: Utc::now(),
1278 seq: 1,
1279 kind: "run.status".into(),
1280 run_id: RunId::parse_str(run_id).unwrap(),
1281 node_id: None,
1282 idempotency_key: None,
1283 data: serde_json::json!({ "status": "running" }),
1284 }
1285 }
1286
1287 #[test]
1288 fn orchestrator_decision_and_discuss_critical_reduce_to_noop() {
1289 let tmp = TempDir::new().unwrap();
1293 let run_id = "01jxsnap000000000000000000";
1294 let rid = RunId::parse_str(run_id).unwrap();
1295 let dir = crate::run_dir(tmp.path(), &rid);
1296 std::fs::create_dir_all(&dir).unwrap();
1297 let paths = RunPaths::new(dir, run_id).unwrap();
1298
1299 let mut created = event(run_id);
1302 created.kind = "run.created".into();
1303 created.data = serde_json::json!({
1304 "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1305 });
1306 apply_event(&paths, &created).expect("run.created applies");
1307 let manifest_before = std::fs::read(paths.manifest()).unwrap();
1308
1309 for (seq, kind) in [(10u64, "orchestrator.decision"), (11, "discuss.critical")] {
1310 let mut ev = event(run_id);
1311 ev.seq = seq;
1312 ev.kind = kind.into();
1313 ev.data = serde_json::json!({ "summary": "x", "arbitrary": [1, 2, 3] });
1315 let ops = reduce_event_to_ops(&paths, &ev).expect("audit kind reduces cleanly");
1316 assert!(ops.is_empty(), "{kind} must plan no projection ops");
1317 apply_event(&paths, &ev).expect("audit kind applies as no-op");
1319 }
1320
1321 assert_eq!(
1323 std::fs::read(paths.manifest()).unwrap(),
1324 manifest_before,
1325 "audit events must not mutate the manifest"
1326 );
1327 assert!(!paths.nodes_dir().exists(), "no node projection created");
1328 }
1329
1330 #[test]
1331 fn run_created_folds_harness_when_present_and_defaults_none() {
1332 let tmp = TempDir::new().unwrap();
1333
1334 let run_id = "01jxhrnsaa0000000000000001";
1336 let rid = RunId::parse_str(run_id).unwrap();
1337 let dir = crate::run_dir(tmp.path(), &rid);
1338 std::fs::create_dir_all(&dir).unwrap();
1339 let paths = RunPaths::new(dir, run_id).unwrap();
1340 let mut created = event(run_id);
1341 created.kind = "run.created".into();
1342 created.data = serde_json::json!({
1343 "kind": "spinoff", "lifecycle": "autonomous", "title": "t",
1344 "harness": "pi", "harness_source": "flag",
1345 });
1346 apply_event(&paths, &created).expect("run.created applies");
1347 let m = read_manifest_opt(&paths).unwrap().unwrap();
1348 assert_eq!(m.harness.as_deref(), Some("pi"));
1349
1350 let run_id2 = "01jxhrnsaa0000000000000002";
1352 let rid2 = RunId::parse_str(run_id2).unwrap();
1353 let dir2 = crate::run_dir(tmp.path(), &rid2);
1354 std::fs::create_dir_all(&dir2).unwrap();
1355 let paths2 = RunPaths::new(dir2, run_id2).unwrap();
1356 let mut created2 = event(run_id2);
1357 created2.kind = "run.created".into();
1358 created2.data =
1359 serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
1360 apply_event(&paths2, &created2).expect("run.created applies");
1361 let m2 = read_manifest_opt(&paths2).unwrap().unwrap();
1362 assert_eq!(m2.harness, None);
1363 }
1364
1365 fn bootstrap_retry_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1368 let rid = RunId::parse_str(run_id).unwrap();
1369 let dir = crate::run_dir(tmp.path(), &rid);
1370 std::fs::create_dir_all(&dir).unwrap();
1371 let paths = RunPaths::new(dir, run_id).unwrap();
1372 let mut created = event(run_id);
1373 created.kind = "run.created".into();
1374 created.data =
1375 serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
1376 apply_event(&paths, &created).expect("run.created applies");
1377 let mut node = event(run_id);
1378 node.seq = 2;
1379 node.kind = "node.created".into();
1380 node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1381 node.data = serde_json::json!({
1382 "kind": "spinoff",
1383 "branch": "wt/foo",
1384 "worktree_path": "/tmp/old-wt",
1385 "agent_pid": 111,
1386 });
1387 apply_event(&paths, &node).expect("node.created applies");
1388 paths
1389 }
1390
1391 #[test]
1395 fn node_retry_rewires_node_and_increments_attempts() {
1396 let tmp = TempDir::new().unwrap();
1397 let run_id = "01jxsnap000000000000000000";
1398 let paths = bootstrap_retry_node(&tmp, run_id);
1399
1400 let mut retry = event(run_id);
1401 retry.seq = 3;
1402 retry.kind = "node.retry".into();
1403 retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1404 retry.data = serde_json::json!({
1405 "attempt": 1,
1406 "reason": "agent-died",
1407 "branch": "wt/foo-r1",
1408 "base_sha": "a".repeat(40),
1409 "worktree_path": "/tmp/new-wt",
1410 "agent_pid": 222,
1411 "tmux_session": "s",
1412 "tmux_window_id": "@9",
1413 });
1414 apply_event(&paths, &retry).expect("node.retry applies");
1415
1416 let n = read_n0001(&paths);
1417 assert_eq!(n.retry_attempts, 1, "attempt bound incremented");
1418 assert_eq!(
1419 n.branch.as_deref(),
1420 Some("wt/foo-r1"),
1421 "rewired to new branch"
1422 );
1423 assert_eq!(n.worktree_path.as_deref(), Some("/tmp/new-wt"));
1424 assert_eq!(n.agent_pid, Some(222), "rewired to new agent pid");
1425 assert_eq!(n.status, Status::Pending, "node returns to pending");
1426 assert!(n.last_report.is_none());
1427 assert_eq!(
1428 n.tmux_identity.as_ref().map(|t| t.window_id.as_str()),
1429 Some("@9"),
1430 "rewired tmux identity"
1431 );
1432
1433 let mut retry2 = event(run_id);
1435 retry2.seq = 4;
1436 retry2.kind = "node.retry".into();
1437 retry2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1438 retry2.data = serde_json::json!({
1439 "attempt": 2, "reason": "agent-died", "branch": "wt/foo-r2",
1440 "worktree_path": "/tmp/new-wt-2", "agent_pid": 333,
1441 });
1442 apply_event(&paths, &retry2).expect("node.retry applies");
1443 assert_eq!(read_n0001(&paths).retry_attempts, 2);
1444 }
1445
1446 #[test]
1450 fn node_retry_against_terminal_node_is_noop() {
1451 let tmp = TempDir::new().unwrap();
1452 let run_id = "01jxsnap000000000000000000";
1453 let paths = bootstrap_retry_node(&tmp, run_id);
1454
1455 let mut report = event(run_id);
1457 report.seq = 3;
1458 report.kind = "node.report".into();
1459 report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1460 report.data = serde_json::json!({ "success": true });
1461 apply_event(&paths, &report).expect("node.report applies");
1462 assert_eq!(read_n0001(&paths).status, Status::Done);
1463
1464 let mut retry = event(run_id);
1465 retry.seq = 4;
1466 retry.kind = "node.retry".into();
1467 retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1468 retry.data = serde_json::json!({
1469 "attempt": 1, "reason": "agent-died", "branch": "wt/foo-r1",
1470 "worktree_path": "/tmp/new-wt", "agent_pid": 222,
1471 });
1472 apply_event(&paths, &retry).expect("node.retry applies as no-op");
1473
1474 let n = read_n0001(&paths);
1475 assert_eq!(n.status, Status::Done, "terminal node not resurrected");
1476 assert_eq!(n.retry_attempts, 0, "no increment against terminal node");
1477 assert_eq!(n.agent_pid, Some(111), "not rewired");
1478 }
1479
1480 #[test]
1481 fn apply_event_rejects_event_from_a_different_run() {
1482 let tmp = TempDir::new().unwrap();
1483 let run_id = "01jxsnap000000000000000000";
1484 let rid = RunId::parse_str(run_id).unwrap();
1485 let dir = crate::run_dir(tmp.path(), &rid);
1486 std::fs::create_dir_all(&dir).unwrap();
1487 let paths = RunPaths::new(dir, run_id).unwrap();
1488
1489 let foreign = event("02jxsnap000000000000000000");
1491 let err = apply_event(&paths, &foreign).expect_err("cross-run event must be rejected");
1492 assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
1493
1494 let mine = event(run_id);
1497 apply_event(&paths, &mine).expect("matching run_id must be accepted");
1498 }
1499
1500 #[test]
1501 fn tmux_identity_from_data_reads_qualified_fields() {
1502 let d = serde_json::json!({
1503 "tmux_socket": "/private/tmp/tmux-501/default",
1504 "tmux_session": "octl",
1505 "tmux_window_id": "@42",
1506 });
1507 let id = tmux_identity_from_data(&d).expect("qualified identity");
1508 assert_eq!(id.socket.as_deref(), Some("/private/tmp/tmux-501/default"));
1509 assert_eq!(id.session, "octl");
1510 assert_eq!(id.window_id, "@42");
1511 assert_eq!(id.pane_id, None);
1513
1514 let d2 = serde_json::json!({
1516 "tmux_socket": null,
1517 "tmux_session": "octl",
1518 "tmux_window_id": "@7",
1519 });
1520 let id2 = tmux_identity_from_data(&d2).expect("identity without socket");
1521 assert_eq!(id2.socket, None);
1522 assert_eq!(id2.window_id, "@7");
1523
1524 let d3 = serde_json::json!({
1526 "tmux_session": "octl",
1527 "tmux_window_id": "@42",
1528 "tmux_pane_id": "%7",
1529 });
1530 let id3 = tmux_identity_from_data(&d3).expect("identity with pane");
1531 assert_eq!(id3.pane_id.as_deref(), Some("%7"));
1532 assert_eq!(id3.capture_target(), "%7");
1533
1534 let d4 = serde_json::json!({
1537 "tmux_session": "octl",
1538 "tmux_window_id": "@42",
1539 "tmux_pane_id": null,
1540 });
1541 let id4 = tmux_identity_from_data(&d4).expect("identity with null pane");
1542 assert_eq!(id4.pane_id, None);
1543 assert_eq!(id4.capture_target(), "@42");
1544 }
1545
1546 #[test]
1547 fn tmux_identity_from_data_back_compat_is_none() {
1548 let legacy = serde_json::json!({ "tmux_window": "🚀 wt/x" });
1550 assert!(tmux_identity_from_data(&legacy).is_none());
1551 let partial = serde_json::json!({ "tmux_window_id": "@42" });
1553 assert!(tmux_identity_from_data(&partial).is_none());
1554 }
1555
1556 #[test]
1559 fn node_created_populates_tmux_identity() {
1560 let tmp = TempDir::new().unwrap();
1561 let run_id = "01jxsnap000000000000000000";
1562 let rid = RunId::parse_str(run_id).unwrap();
1563 let dir = crate::run_dir(tmp.path(), &rid);
1564 std::fs::create_dir_all(&dir).unwrap();
1565 let paths = RunPaths::new(dir, run_id).unwrap();
1566
1567 let mut ev = event(run_id);
1568 ev.seq = 2;
1569 ev.kind = "node.created".into();
1570 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1571 ev.data = serde_json::json!({
1572 "kind": "spinoff",
1573 "tmux_window": "🚀 wt/x",
1574 "tmux_socket": "/private/tmp/tmux-501/default",
1575 "tmux_session": "octl",
1576 "tmux_window_id": "@42",
1577 });
1578 apply_event(&paths, &ev).expect("node.created applies");
1579 let n = read_node_opt(&paths, &NodeId::parse_str("n-0001").unwrap())
1580 .unwrap()
1581 .unwrap();
1582 let id = n.tmux_identity.expect("qualified identity recorded");
1583 assert_eq!(id.session, "octl");
1584 assert_eq!(id.window_id, "@42");
1585 assert_eq!(n.tmux_window.as_deref(), Some("🚀 wt/x"));
1586
1587 let run2 = "02jxsnap000000000000000000";
1589 let rid2 = RunId::parse_str(run2).unwrap();
1590 let dir2 = crate::run_dir(tmp.path(), &rid2);
1591 std::fs::create_dir_all(&dir2).unwrap();
1592 let paths2 = RunPaths::new(dir2, run2).unwrap();
1593 let mut ev2 = event(run2);
1594 ev2.seq = 2;
1595 ev2.kind = "node.created".into();
1596 ev2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1597 ev2.data = serde_json::json!({ "kind": "spinoff", "tmux_window": "🚀 wt/y" });
1598 apply_event(&paths2, &ev2).expect("legacy node.created applies");
1599 let n2 = read_node_opt(&paths2, &NodeId::parse_str("n-0001").unwrap())
1600 .unwrap()
1601 .unwrap();
1602 assert!(n2.tmux_identity.is_none());
1603 assert_eq!(n2.tmux_window.as_deref(), Some("🚀 wt/y"));
1604 }
1605
1606 fn seed_run_with_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1609 let rid = RunId::parse_str(run_id).unwrap();
1610 let dir = crate::run_dir(tmp.path(), &rid);
1611 std::fs::create_dir_all(&dir).unwrap();
1612 let paths = RunPaths::new(dir, run_id).unwrap();
1613
1614 let mut created = event(run_id);
1615 created.kind = "run.created".into();
1616 created.data = serde_json::json!({
1617 "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1618 });
1619 apply_event(&paths, &created).expect("run.created applies");
1620
1621 let mut node = event(run_id);
1622 node.seq = 2;
1623 node.kind = "node.created".into();
1624 node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1625 node.data = serde_json::json!({ "kind": "spinoff" });
1626 apply_event(&paths, &node).expect("node.created applies");
1627 paths
1628 }
1629
1630 fn read_n0001(paths: &RunPaths) -> Node {
1631 read_node_opt(paths, &NodeId::parse_str("n-0001").unwrap())
1632 .unwrap()
1633 .unwrap()
1634 }
1635
1636 #[test]
1640 fn supervisor_attached_sets_supervisor_pid() {
1641 let tmp = TempDir::new().unwrap();
1642 let run_id = "01jxsnap000000000000000000";
1643 let paths = seed_run_with_node(&tmp, run_id);
1644 assert_eq!(read_n0001(&paths).supervisor_pid, None);
1645
1646 let mut ev = event(run_id);
1647 ev.seq = 3;
1648 ev.kind = "supervisor.attached".into();
1649 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1650 ev.data = serde_json::json!({ "pid": 47820 });
1651 apply_event(&paths, &ev).expect("supervisor.attached applies");
1652 assert_eq!(read_n0001(&paths).supervisor_pid, Some(47820));
1653 }
1654
1655 #[test]
1658 fn supervisor_attached_latest_wins_and_idempotent_on_replay() {
1659 let tmp = TempDir::new().unwrap();
1660 let run_id = "01jxsnap000000000000000000";
1661 let paths = seed_run_with_node(&tmp, run_id);
1662
1663 let mut ev = event(run_id);
1664 ev.kind = "supervisor.attached".into();
1665 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1666
1667 ev.seq = 3;
1668 ev.data = serde_json::json!({ "pid": 100 });
1669 apply_event(&paths, &ev).expect("first attach applies");
1670 assert_eq!(read_n0001(&paths).supervisor_pid, Some(100));
1671
1672 ev.seq = 4;
1674 ev.data = serde_json::json!({ "pid": 200 });
1675 apply_event(&paths, &ev).expect("second attach applies");
1676 let after_second = read_n0001(&paths);
1677 assert_eq!(after_second.supervisor_pid, Some(200));
1678
1679 let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
1682 assert!(ops.is_empty(), "re-applying same pid must plan no ops");
1683 apply_event(&paths, &ev).expect("replay applies as no-op");
1684 assert_eq!(read_n0001(&paths).updated_at, after_second.updated_at);
1685 }
1686
1687 #[test]
1690 fn supervisor_cursor_advanced_sets_report_cursor() {
1691 let tmp = TempDir::new().unwrap();
1692 let run_id = "01jxsnap000000000000000000";
1693 let paths = seed_run_with_node(&tmp, run_id);
1694 let child = "02jxsnap000000000000000000";
1695
1696 let mut ev = event(run_id);
1697 ev.seq = 3;
1698 ev.kind = "supervisor.cursor_advanced".into();
1699 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1700 ev.data = serde_json::json!({ "child_run_id": child, "report_seq": 7 });
1701 apply_event(&paths, &ev).expect("cursor_advanced applies");
1702
1703 let n = read_n0001(&paths);
1704 assert_eq!(
1705 n.last_processed_report_seq_by_child.get(child),
1706 Some(&Value::from(7u64))
1707 );
1708 }
1709
1710 #[test]
1715 fn supervisor_cursor_advanced_is_monotonic_and_idempotent() {
1716 let tmp = TempDir::new().unwrap();
1717 let run_id = "01jxsnap000000000000000000";
1718 let paths = seed_run_with_node(&tmp, run_id);
1719 let child_a = "02jxsnap000000000000000000";
1720 let child_b = "03jxsnap000000000000000000";
1721
1722 let mut ev = event(run_id);
1723 ev.kind = "supervisor.cursor_advanced".into();
1724 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1725
1726 ev.seq = 3;
1727 ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 5 });
1728 apply_event(&paths, &ev).expect("seq 5 applies");
1729
1730 let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
1732 assert!(ops.is_empty(), "re-applying same cursor must plan no ops");
1733
1734 ev.seq = 4;
1736 ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 3 });
1737 let ops = reduce_event_to_ops(&paths, &ev).expect("older seq reduces cleanly");
1738 assert!(ops.is_empty(), "older seq must plan no ops");
1739 apply_event(&paths, &ev).expect("older seq applies as no-op");
1740 assert_eq!(
1741 read_n0001(&paths)
1742 .last_processed_report_seq_by_child
1743 .get(child_a),
1744 Some(&Value::from(5u64))
1745 );
1746
1747 ev.seq = 5;
1749 ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 9 });
1750 apply_event(&paths, &ev).expect("higher seq applies");
1751 ev.seq = 6;
1752 ev.data = serde_json::json!({ "child_run_id": child_b, "report_seq": 1 });
1753 apply_event(&paths, &ev).expect("second child applies");
1754
1755 let n = read_n0001(&paths);
1756 assert_eq!(
1757 n.last_processed_report_seq_by_child.get(child_a),
1758 Some(&Value::from(9u64))
1759 );
1760 assert_eq!(
1761 n.last_processed_report_seq_by_child.get(child_b),
1762 Some(&Value::from(1u64))
1763 );
1764 }
1765
1766 #[test]
1769 fn supervisor_state_events_reject_malformed_payloads() {
1770 let tmp = TempDir::new().unwrap();
1771 let run_id = "01jxsnap000000000000000000";
1772 let paths = seed_run_with_node(&tmp, run_id);
1773 let nid = Some(NodeId::parse_str("n-0001").unwrap());
1774
1775 let mut ev = event(run_id);
1777 ev.seq = 3;
1778 ev.kind = "supervisor.attached".into();
1779 ev.node_id = nid.clone();
1780 ev.data = serde_json::json!({});
1781 assert!(matches!(
1782 reduce_event_to_ops(&paths, &ev),
1783 Err(Error::CorruptEventLog { .. })
1784 ));
1785
1786 ev.node_id = None;
1788 ev.data = serde_json::json!({ "pid": 1 });
1789 assert!(matches!(
1790 reduce_event_to_ops(&paths, &ev),
1791 Err(Error::CorruptEventLog { .. })
1792 ));
1793
1794 let mut ev2 = event(run_id);
1796 ev2.seq = 4;
1797 ev2.kind = "supervisor.cursor_advanced".into();
1798 ev2.node_id = nid.clone();
1799 ev2.data = serde_json::json!({ "child_run_id": "../etc", "report_seq": 1 });
1800 assert!(matches!(
1801 reduce_event_to_ops(&paths, &ev2),
1802 Err(Error::CorruptEventLog { .. })
1803 ));
1804
1805 ev2.data = serde_json::json!({ "child_run_id": "02jxsnap000000000000000000" });
1807 assert!(matches!(
1808 reduce_event_to_ops(&paths, &ev2),
1809 Err(Error::CorruptEventLog { .. })
1810 ));
1811 }
1812
1813 #[cfg(unix)]
1822 fn projection_inodes(paths: &RunPaths) -> std::collections::BTreeMap<PathBuf, u64> {
1823 use std::os::unix::fs::MetadataExt;
1824 let mut consider = vec![paths.manifest()];
1825 for dir in [
1826 paths.nodes_dir(),
1827 paths.discussions_dir(),
1828 paths.spinoffs_dir(),
1829 ] {
1830 if let Ok(rd) = std::fs::read_dir(&dir) {
1831 for ent in rd.flatten() {
1832 let p = ent.path();
1833 if p.extension().and_then(|s| s.to_str()) == Some("json") {
1834 consider.push(p);
1835 }
1836 }
1837 }
1838 }
1839 let mut map = std::collections::BTreeMap::new();
1840 for p in consider {
1841 if let Ok(md) = std::fs::symlink_metadata(&p) {
1842 if md.file_type().is_file() {
1843 map.insert(p, md.ino());
1844 }
1845 }
1846 }
1847 map
1848 }
1849
1850 #[cfg(unix)]
1859 fn assert_plan_matches_apply(paths: &RunPaths, ev: &Event, expect_writes: bool) {
1860 use std::collections::BTreeSet;
1861 let before = projection_inodes(paths);
1862 let planned: BTreeSet<PathBuf> = plan_projections(paths, ev)
1863 .unwrap_or_else(|e| panic!("plan_projections({}) errored: {e:?}", ev.kind))
1864 .into_iter()
1865 .collect();
1866 apply_event(paths, ev)
1867 .unwrap_or_else(|e| panic!("apply_event({}) errored: {e:?}", ev.kind));
1868 let after = projection_inodes(paths);
1869 let touched: BTreeSet<PathBuf> = after
1870 .iter()
1871 .filter(|(p, ino)| before.get(*p) != Some(*ino))
1872 .map(|(p, _)| p.clone())
1873 .collect();
1874 assert_eq!(
1875 planned, touched,
1876 "kind={}: plan_projections must name exactly the files apply_event writes",
1877 ev.kind
1878 );
1879 if expect_writes {
1880 assert!(
1881 !touched.is_empty(),
1882 "kind={}: expected this event to write at least one projection",
1883 ev.kind
1884 );
1885 }
1886 }
1887
1888 #[cfg(unix)]
1895 #[test]
1896 fn plan_projections_matches_apply_for_every_kind() {
1897 let tmp = TempDir::new().unwrap();
1898 let run_id = "01jxsnap000000000000000000";
1899 let rid = RunId::parse_str(run_id).unwrap();
1900 let dir = crate::run_dir(tmp.path(), &rid);
1901 std::fs::create_dir_all(&dir).unwrap();
1902 let paths = RunPaths::new(dir, run_id).unwrap();
1903 let nid = || Some(NodeId::parse_str("n-0001").unwrap());
1904 let disc_id = "d-pqrstuvwxy";
1905 let prop_id = "s-spinaaaaaa";
1906 let child = "02jxsnap000000000000000000";
1907
1908 let mut next_seq = 0u64;
1910 let mut at = |kind: &str, node_id, data| {
1911 next_seq += 1;
1912 Event {
1913 ts: Utc::now(),
1914 seq: next_seq,
1915 kind: kind.into(),
1916 run_id: rid.clone(),
1917 node_id,
1918 idempotency_key: None,
1919 data,
1920 }
1921 };
1922
1923 assert_plan_matches_apply(
1925 &paths,
1926 &at(
1927 "run.created",
1928 None,
1929 serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
1930 ),
1931 true,
1932 );
1933 assert_plan_matches_apply(
1935 &paths,
1936 &at(
1937 "run.status",
1938 None,
1939 serde_json::json!({ "status": "running" }),
1940 ),
1941 true,
1942 );
1943 assert_plan_matches_apply(
1945 &paths,
1946 &at(
1947 "node.created",
1948 nid(),
1949 serde_json::json!({ "kind": "spinoff" }),
1950 ),
1951 true,
1952 );
1953 assert_plan_matches_apply(
1955 &paths,
1956 &at(
1957 "node.status",
1958 nid(),
1959 serde_json::json!({ "status": "running" }),
1960 ),
1961 true,
1962 );
1963 assert_plan_matches_apply(
1965 &paths,
1966 &at(
1967 "discussion.opened",
1968 None,
1969 serde_json::json!({ "discussion_id": disc_id, "node_id": "n-0001", "topic": "t" }),
1970 ),
1971 true,
1972 );
1973 assert_plan_matches_apply(
1975 &paths,
1976 &at(
1977 "discussion.resolved",
1978 None,
1979 serde_json::json!({ "discussion_id": disc_id, "resolution": "keep" }),
1980 ),
1981 true,
1982 );
1983 assert_plan_matches_apply(
1985 &paths,
1986 &at(
1987 "spinoff.proposed",
1988 None,
1989 serde_json::json!({
1990 "proposal_id": prop_id, "node_id": "n-0001",
1991 "proposed_title": "p", "proposed_kind": "spinoff"
1992 }),
1993 ),
1994 true,
1995 );
1996 assert_plan_matches_apply(
1998 &paths,
1999 &at(
2000 "spinoff.approved",
2001 None,
2002 serde_json::json!({ "proposal_id": prop_id, "issue_slug": "x" }),
2003 ),
2004 true,
2005 );
2006 assert_plan_matches_apply(
2008 &paths,
2009 &at(
2010 "supervisor.attached",
2011 nid(),
2012 serde_json::json!({ "pid": 4242 }),
2013 ),
2014 true,
2015 );
2016 assert_plan_matches_apply(
2018 &paths,
2019 &at(
2020 "supervisor.cursor_advanced",
2021 nid(),
2022 serde_json::json!({ "child_run_id": child, "report_seq": 3 }),
2023 ),
2024 true,
2025 );
2026 assert_plan_matches_apply(
2028 &paths,
2029 &at(
2030 "child.spawned",
2031 nid(),
2032 serde_json::json!({ "child_run_id": child, "child_node_id": "n-0001" }),
2033 ),
2034 true,
2035 );
2036 assert_plan_matches_apply(
2038 &paths,
2039 &at("node.report", nid(), serde_json::json!({ "success": true })),
2040 true,
2041 );
2042 assert_plan_matches_apply(
2045 &paths,
2046 &at(
2047 "node.status",
2048 nid(),
2049 serde_json::json!({ "status": "failed" }),
2050 ),
2051 false,
2052 );
2053 for kind in [
2055 "supervisor.exited",
2056 "orchestrator.decision",
2057 "discuss.critical",
2058 "cleanup.window_missing",
2059 ] {
2060 assert_plan_matches_apply(&paths, &at(kind, None, serde_json::json!({})), false);
2061 }
2062
2063 let prop2 = "s-spinbbbbbb";
2066 assert_plan_matches_apply(
2067 &paths,
2068 &at(
2069 "spinoff.proposed",
2070 None,
2071 serde_json::json!({
2072 "proposal_id": prop2, "node_id": "n-0001",
2073 "proposed_title": "p2", "proposed_kind": "spinoff"
2074 }),
2075 ),
2076 true,
2077 );
2078 assert_plan_matches_apply(
2079 &paths,
2080 &at(
2081 "spinoff.rejected",
2082 None,
2083 serde_json::json!({ "proposal_id": prop2, "reason": "no" }),
2084 ),
2085 true,
2086 );
2087 }
2088}