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 _ => Ok(vec![]),
383 }
384}
385
386fn op_path(paths: &RunPaths, op: &ProjectionOp) -> PathBuf {
392 match op {
393 ProjectionOp::Manifest(_) => paths.manifest(),
394 ProjectionOp::Node(n) => paths.node(&n.node_id),
395 ProjectionOp::Discussion(d) => paths.discussion(&d.discussion_id),
396 ProjectionOp::Spinoff(s) => paths.spinoff(&s.proposal_id),
397 }
398}
399
400pub fn plan_projections(paths: &RunPaths, event: &Event) -> Result<Vec<PathBuf>> {
422 let ops = reduce_event_to_ops(paths, event)?;
423 Ok(ops.iter().map(|op| op_path(paths, op)).collect())
424}
425
426pub(crate) fn apply_event(paths: &RunPaths, ev: &Event) -> Result<()> {
442 let ops = reduce_event_to_ops(paths, ev)?;
443 commit_ops(paths, ops)
444}
445
446#[cfg(test)]
455pub(crate) fn validate_event(paths: &RunPaths, ev: &Event) -> Result<()> {
456 reduce_event_to_ops(paths, ev).map(|_| ())
457}
458
459fn require_envelope_node_id(events_path: &Path, ev: &Event) -> Result<NodeId> {
463 ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
464 path: events_path.to_path_buf(),
465 reason: format!(
466 "event seq={} kind={} missing top-level `node_id`",
467 ev.seq, ev.kind
468 ),
469 })
470}
471
472fn reduce_run_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
473 if let Some(existing) = read_manifest_opt(paths)? {
477 if existing.run_id != ev.run_id {
478 return Err(Error::CorruptEventLog {
479 path: paths.manifest(),
480 reason: format!(
481 "run.created run_id={} conflicts with existing manifest run_id={}",
482 ev.run_id, existing.run_id
483 ),
484 });
485 }
486 return Ok(vec![]);
487 }
488 let events_path = paths.events();
489 let d = &ev.data;
490 let kind =
491 data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
492 path: events_path.clone(),
493 reason: "run.created missing/invalid `kind`".into(),
494 })?;
495 let lifecycle: Lifecycle = serde_json::from_value(
496 d.get("lifecycle").cloned().unwrap_or(Value::Null),
497 )
498 .map_err(|_| Error::CorruptEventLog {
499 path: events_path.clone(),
500 reason: "run.created missing/invalid `lifecycle`".into(),
501 })?;
502 let title = want_str(&events_path, ev, d, "title")?.to_string();
503 let m = Manifest {
504 schema_version: STATE_SCHEMA_VERSION,
505 applied_seq: 0,
508 run_id: paths.run_id.clone(),
510 kind,
511 lifecycle,
512 title,
513 status: Status::Pending,
514 created_at: ev.ts,
515 updated_at: ev.ts,
516 source_repo: d
517 .get("source_repo")
518 .and_then(Value::as_str)
519 .map(str::to_string),
520 source_branch: d
521 .get("source_branch")
522 .and_then(Value::as_str)
523 .map(str::to_string),
524 worktree_root: d
525 .get("worktree_root")
526 .and_then(Value::as_str)
527 .map(str::to_string),
528 managed_tmux_session: d
529 .get("managed_tmux_session")
530 .and_then(Value::as_str)
531 .map(str::to_string),
532 notify_cmd: d
533 .get("notify_cmd")
534 .and_then(Value::as_str)
535 .map(str::to_string),
536 node_count: 0,
537 open_discussions: 0,
538 pending_spinoffs: 0,
539 parent_run_id: opt_run_id(&events_path, ev, d, "parent_run_id")?,
540 parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
541 };
542 Ok(vec![ProjectionOp::Manifest(m)])
543}
544
545fn reduce_run_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
546 let mut m = match read_manifest_opt(paths)? {
547 Some(m) => m,
548 None => return Ok(vec![]),
549 };
550 let new_status = require_status(ev, paths.events())?;
551 if m.status.is_terminal() {
554 trace_terminal_noop(ev, m.status, new_status);
555 return Ok(vec![]);
556 }
557 if m.status == new_status {
558 return Ok(vec![]);
559 }
560 m.status = new_status;
561 m.updated_at = ev.ts;
562 Ok(vec![ProjectionOp::Manifest(m)])
563}
564
565fn tmux_identity_from_data(d: &Value) -> Option<TmuxIdentity> {
576 let nonempty = |key| {
577 d.get(key)
578 .and_then(Value::as_str)
579 .map(str::trim)
580 .filter(|s| !s.is_empty())
581 .map(str::to_string)
582 };
583 let session = nonempty("tmux_session")?;
584 let window_id = nonempty("tmux_window_id")?;
585 Some(TmuxIdentity {
586 socket: nonempty("tmux_socket"),
587 session,
588 window_id,
589 pane_id: nonempty("tmux_pane_id"),
592 })
593}
594
595fn reduce_node_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
596 let events_path = paths.events();
597 let node_id = require_envelope_node_id(&events_path, ev)?;
600 if read_node_opt(paths, &node_id)?.is_some() {
602 return Ok(vec![]);
603 }
604 let d = &ev.data;
605 let kind =
606 data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
607 path: events_path.clone(),
608 reason: format!(
609 "event seq={} kind=node.created missing/invalid `kind`",
610 ev.seq
611 ),
612 })?;
613 let n = Node {
614 schema_version: STATE_SCHEMA_VERSION,
615 node_id,
616 run_id: paths.run_id.clone(),
618 parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
619 kind,
620 status: Status::Pending,
621 task: d.get("task").and_then(Value::as_str).map(str::to_string),
622 worktree_path: d
623 .get("worktree_path")
624 .and_then(Value::as_str)
625 .map(str::to_string),
626 branch: d.get("branch").and_then(Value::as_str).map(str::to_string),
627 base_sha: d
628 .get("base_sha")
629 .and_then(Value::as_str)
630 .filter(|s| !s.is_empty())
631 .map(str::to_string),
632 tmux_window: d
633 .get("tmux_window")
634 .and_then(Value::as_str)
635 .map(str::to_string),
636 tmux_identity: tmux_identity_from_data(d),
637 agent_pid: optional_i32(d, "agent_pid", &events_path, ev)?,
638 agent_pid_start_time: optional_ts(d, "agent_pid_start_time", &events_path, ev)?,
639 supervisor_pid: optional_i32(d, "supervisor_pid", &events_path, ev)?,
640 children: Vec::new(),
641 started_at: Some(ev.ts),
642 updated_at: ev.ts,
643 last_report: None,
644 last_processed_report_seq_by_child: serde_json::Map::default(),
645 retry_attempts: 0,
646 };
647 let mut ops = vec![ProjectionOp::Node(n)];
648 if let Some(mut m) = read_manifest_opt(paths)? {
649 m.updated_at = ev.ts;
654 ops.push(ProjectionOp::Manifest(m));
655 }
656 Ok(ops)
657}
658
659fn reduce_node_retry(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
678 let events_path = paths.events();
679 let node_id = require_envelope_node_id(&events_path, ev)?;
680 let mut n = match read_node_opt(paths, &node_id)? {
681 Some(n) => n,
682 None => return Ok(vec![]),
683 };
684 if n.status.is_terminal() {
687 tracing::debug!(
688 target: "octl_core::reducer",
689 seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
690 "no-op: node.retry against terminal node"
691 );
692 return Ok(vec![]);
693 }
694 let d = &ev.data;
695 n.branch = d.get("branch").and_then(Value::as_str).map(str::to_string);
698 n.base_sha = d
699 .get("base_sha")
700 .and_then(Value::as_str)
701 .filter(|s| !s.is_empty())
702 .map(str::to_string);
703 n.worktree_path = d
704 .get("worktree_path")
705 .and_then(Value::as_str)
706 .map(str::to_string);
707 n.tmux_window = d
708 .get("tmux_window")
709 .and_then(Value::as_str)
710 .map(str::to_string);
711 n.tmux_identity = tmux_identity_from_data(d);
712 n.agent_pid = optional_i32(d, "agent_pid", &events_path, ev)?;
713 n.agent_pid_start_time = optional_ts(d, "agent_pid_start_time", &events_path, ev)?;
714 n.status = Status::Pending;
715 n.started_at = Some(ev.ts);
716 n.updated_at = ev.ts;
717 n.last_report = None;
718 n.retry_attempts = d
726 .get("attempt")
727 .and_then(Value::as_u64)
728 .map_or_else(|| n.retry_attempts.saturating_add(1), |a| a as u32);
729 Ok(vec![ProjectionOp::Node(n)])
730}
731
732fn reduce_node_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
733 let events_path = paths.events();
734 let node_id = require_envelope_node_id(&events_path, ev)?;
735 let mut n = match read_node_opt(paths, &node_id)? {
736 Some(n) => n,
737 None => return Ok(vec![]),
738 };
739 let new_status = require_status(ev, events_path)?;
740 if n.status.is_terminal() {
743 trace_terminal_noop(ev, n.status, new_status);
744 return Ok(vec![]);
745 }
746 if n.status == new_status {
747 return Ok(vec![]);
748 }
749 n.status = new_status;
750 n.updated_at = ev.ts;
751 Ok(vec![ProjectionOp::Node(n)])
752}
753
754fn reduce_node_report(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
755 let events_path = paths.events();
756 let node_id = require_envelope_node_id(&events_path, ev)?;
757 let mut n = match read_node_opt(paths, &node_id)? {
758 Some(n) => n,
759 None => return Ok(vec![]),
760 };
761 if n.status.is_terminal() {
773 if matches!(n.status, Status::Failed | Status::Done)
812 && report_is_confirmed_explicit_merge(&ev.data)
813 {
814 if n.last_report.as_ref() == Some(&ev.data) && n.status == Status::Done {
815 return Ok(vec![]);
816 }
817 tracing::info!(
818 target: "octl_core::reducer",
819 seq = ev.seq, kind = %ev.kind, node_id = %node_id, prior = ?n.status,
820 "adopting late explicit-merge report against terminal node (invariant #5 teardown)"
821 );
822 n.last_report = Some(ev.data.clone());
823 n.status = Status::Done;
827 n.updated_at = ev.ts;
828 return Ok(vec![ProjectionOp::Node(n)]);
829 }
830 tracing::debug!(
831 target: "octl_core::reducer",
832 seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
833 "no-op: node.report against terminal node"
834 );
835 return Ok(vec![]);
836 }
837 let new_status = report_terminal_status(&events_path, ev)?;
845 n.last_report = Some(ev.data.clone());
846 n.status = new_status;
847 n.updated_at = ev.ts;
848 Ok(vec![ProjectionOp::Node(n)])
849}
850
851fn trace_terminal_noop(ev: &Event, current: Status, incoming: Status) {
859 if current == incoming {
860 tracing::debug!(
861 target: "octl_core::reducer",
862 seq = ev.seq, kind = %ev.kind, status = ?current,
863 "no-op: status re-applied to terminal target"
864 );
865 } else {
866 tracing::warn!(
867 target: "octl_core::reducer",
868 seq = ev.seq, kind = %ev.kind, current = ?current, incoming = ?incoming,
869 "no-op: ignored conflicting transition from terminal target"
870 );
871 }
872}
873
874pub const VIA_EXPLICIT_MERGE: &str = "explicit-merge";
880
881fn report_is_confirmed_explicit_merge(data: &Value) -> bool {
896 let via = data.get("via").and_then(Value::as_str) == Some(VIA_EXPLICIT_MERGE);
897 let success = matches!(data.get("success"), Some(Value::Bool(true)));
898 let not_cancelled = matches!(
899 data.get("cancelled"),
900 None | Some(Value::Null | Value::Bool(false))
901 );
902 via && success && not_cancelled
903}
904
905fn report_terminal_status(events_path: &Path, ev: &Event) -> Result<Status> {
914 let corrupt = |reason: String| Error::CorruptEventLog {
915 path: events_path.to_path_buf(),
916 reason,
917 };
918 let cancelled = optional_bool(events_path, ev, &ev.data, "cancelled")?.unwrap_or(false);
919 let success = optional_bool(events_path, ev, &ev.data, "success")?;
920 if cancelled {
921 if success == Some(true) {
922 return Err(corrupt(format!(
923 "event seq={} kind=node.report has contradictory `success: true` with `cancelled: true`",
924 ev.seq
925 )));
926 }
927 Ok(Status::Cancelled)
928 } else {
929 match success {
930 Some(true) => Ok(Status::Done),
931 Some(false) => Ok(Status::Failed),
932 None => Err(corrupt(format!(
933 "event seq={} kind=node.report must set boolean `success` or `cancelled: true`",
934 ev.seq
935 ))),
936 }
937 }
938}
939
940fn reduce_discussion_opened(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
941 let events_path = paths.events();
942 let d = &ev.data;
943 let discussion_id = DiscussionId::parse_str(want_str(&events_path, ev, d, "discussion_id")?)
944 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
945 if read_discussion_opt(paths, &discussion_id)?.is_some() {
946 return Ok(vec![]);
947 }
948 let node_id = want_node_id_with_fallback(&events_path, ev, d, "node_id")?;
949 let options = d
950 .get("options")
951 .and_then(Value::as_array)
952 .map(|a| {
953 a.iter()
954 .filter_map(|v| v.as_str().map(str::to_string))
955 .collect()
956 })
957 .unwrap_or_default();
958 let disc = Discussion {
959 schema_version: STATE_SCHEMA_VERSION,
960 discussion_id,
961 run_id: paths.run_id.clone(),
962 node_id,
963 opened_at: ev.ts,
964 severity: d
965 .get("severity")
966 .and_then(Value::as_str)
967 .unwrap_or("discuss")
968 .to_string(),
969 topic: want_str(&events_path, ev, d, "topic")?.to_string(),
970 context: d.get("context").and_then(Value::as_str).map(str::to_string),
971 options,
972 status: DiscussionStatus::Open,
973 resolution: None,
974 note: None,
975 resolved_at: None,
976 };
977 let mut ops = vec![ProjectionOp::Discussion(disc)];
978 if let Some(mut m) = read_manifest_opt(paths)? {
979 m.updated_at = ev.ts;
982 ops.push(ProjectionOp::Manifest(m));
983 }
984 Ok(ops)
985}
986
987fn reduce_discussion_resolved(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
988 let events_path = paths.events();
989 let id = DiscussionId::parse_str(want_str(&events_path, ev, &ev.data, "discussion_id")?)
990 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
991 let mut disc = match read_discussion_opt(paths, &id)? {
992 Some(d) => d,
993 None => return Ok(vec![]),
994 };
995 if matches!(disc.status, DiscussionStatus::Resolved) {
996 return Ok(vec![]);
997 }
998 disc.status = DiscussionStatus::Resolved;
999 disc.resolution = Some(want_str(&events_path, ev, &ev.data, "resolution")?.to_string());
1005 disc.note = optional_str(&events_path, ev, &ev.data, "note")?;
1006 disc.resolved_at = Some(ev.ts);
1007 let mut ops = vec![ProjectionOp::Discussion(disc)];
1008 if let Some(mut m) = read_manifest_opt(paths)? {
1009 m.updated_at = ev.ts;
1015 ops.push(ProjectionOp::Manifest(m));
1016 }
1017 Ok(ops)
1018}
1019
1020fn reduce_spinoff_proposed(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1021 let events_path = paths.events();
1022 let d = &ev.data;
1023 let proposal_id = ProposalId::parse_str(want_str(&events_path, ev, d, "proposal_id")?)
1024 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1025 if read_spinoff_opt(paths, &proposal_id)?.is_some() {
1026 return Ok(vec![]);
1027 }
1028 let proposed_kind =
1029 data_kind(d.get("proposed_kind").unwrap_or(&Value::Null)).ok_or_else(|| {
1030 Error::CorruptEventLog {
1031 path: events_path.clone(),
1032 reason: format!(
1033 "event seq={} kind=spinoff.proposed missing/invalid `proposed_kind`",
1034 ev.seq
1035 ),
1036 }
1037 })?;
1038 let node_id = want_node_id_with_fallback(&events_path, ev, d, "node_id")?;
1039 let s = SpinoffProposal {
1040 schema_version: STATE_SCHEMA_VERSION,
1041 proposal_id,
1042 run_id: paths.run_id.clone(),
1043 node_id,
1044 proposed_at: ev.ts,
1045 proposed_title: want_str(&events_path, ev, d, "proposed_title")?.to_string(),
1046 proposed_kind,
1047 rationale: d
1048 .get("rationale")
1049 .and_then(Value::as_str)
1050 .map(str::to_string),
1051 status: SpinoffStatus::Proposed,
1052 accepted_as_issue_slug: None,
1053 rejected_reason: None,
1054 resolved_at: None,
1055 };
1056 let mut ops = vec![ProjectionOp::Spinoff(s)];
1057 if let Some(mut m) = read_manifest_opt(paths)? {
1058 m.updated_at = ev.ts;
1061 ops.push(ProjectionOp::Manifest(m));
1062 }
1063 Ok(ops)
1064}
1065
1066fn reduce_spinoff_approved(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1067 let events_path = paths.events();
1068 let id = ProposalId::parse_str(want_str(&events_path, ev, &ev.data, "proposal_id")?)
1069 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1070 let mut s = match read_spinoff_opt(paths, &id)? {
1071 Some(s) => s,
1072 None => return Ok(vec![]),
1073 };
1074 if matches!(s.status, SpinoffStatus::Approved | SpinoffStatus::Rejected) {
1075 return Ok(vec![]);
1076 }
1077 s.status = SpinoffStatus::Approved;
1078 s.accepted_as_issue_slug = ev
1079 .data
1080 .get("issue_slug")
1081 .and_then(Value::as_str)
1082 .map(str::to_string);
1083 s.resolved_at = Some(ev.ts);
1084 let mut ops = vec![ProjectionOp::Spinoff(s)];
1085 if let Some(mut m) = read_manifest_opt(paths)? {
1086 m.updated_at = ev.ts;
1092 ops.push(ProjectionOp::Manifest(m));
1093 }
1094 Ok(ops)
1095}
1096
1097fn reduce_spinoff_rejected(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1098 let events_path = paths.events();
1099 let id = ProposalId::parse_str(want_str(&events_path, ev, &ev.data, "proposal_id")?)
1100 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1101 let mut s = match read_spinoff_opt(paths, &id)? {
1102 Some(s) => s,
1103 None => return Ok(vec![]),
1104 };
1105 if matches!(s.status, SpinoffStatus::Approved | SpinoffStatus::Rejected) {
1106 return Ok(vec![]);
1107 }
1108 s.status = SpinoffStatus::Rejected;
1109 s.rejected_reason = ev
1110 .data
1111 .get("reason")
1112 .and_then(Value::as_str)
1113 .map(str::to_string);
1114 s.resolved_at = Some(ev.ts);
1115 let mut ops = vec![ProjectionOp::Spinoff(s)];
1116 if let Some(mut m) = read_manifest_opt(paths)? {
1117 m.updated_at = ev.ts;
1123 ops.push(ProjectionOp::Manifest(m));
1124 }
1125 Ok(ops)
1126}
1127
1128fn reduce_child_spawned(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1129 let events_path = paths.events();
1132 let parent_node_id = ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
1133 path: events_path.clone(),
1134 reason: format!(
1135 "event seq={} kind=child.spawned missing parent `node_id`",
1136 ev.seq
1137 ),
1138 })?;
1139 let child_run_id = RunId::parse_str(want_str(&events_path, ev, &ev.data, "child_run_id")?)
1140 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1141 let child_node_id = NodeId::parse_str(
1142 ev.data
1143 .get("child_node_id")
1144 .and_then(Value::as_str)
1145 .unwrap_or("n-0001"),
1146 )
1147 .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1148 let mut n = match read_node_opt(paths, &parent_node_id)? {
1149 Some(n) => n,
1150 None => return Ok(vec![]),
1151 };
1152 let new_ref = ChildRef {
1153 run_id: child_run_id,
1154 node_id: child_node_id,
1155 };
1156 if n.children.iter().any(|c| c == &new_ref) {
1157 return Ok(vec![]);
1160 }
1161 n.children.push(new_ref);
1162 n.updated_at = ev.ts;
1163 Ok(vec![ProjectionOp::Node(n)])
1164}
1165
1166fn reduce_supervisor_attached(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1177 let events_path = paths.events();
1178 let node_id = require_envelope_node_id(&events_path, ev)?;
1179 let raw = ev
1180 .data
1181 .get("pid")
1182 .and_then(Value::as_i64)
1183 .ok_or_else(|| Error::CorruptEventLog {
1184 path: events_path.clone(),
1185 reason: format!(
1186 "event seq={} kind=supervisor.attached missing/invalid `pid`",
1187 ev.seq
1188 ),
1189 })?;
1190 let pid = i32::try_from(raw).map_err(|_| Error::CorruptEventLog {
1191 path: events_path.clone(),
1192 reason: format!(
1193 "event seq={} kind=supervisor.attached `pid` out of i32 range: {raw}",
1194 ev.seq
1195 ),
1196 })?;
1197 let mut n = match read_node_opt(paths, &node_id)? {
1198 Some(n) => n,
1199 None => return Ok(vec![]),
1200 };
1201 if n.supervisor_pid == Some(pid) {
1202 return Ok(vec![]);
1203 }
1204 n.supervisor_pid = Some(pid);
1205 n.updated_at = ev.ts;
1206 Ok(vec![ProjectionOp::Node(n)])
1207}
1208
1209fn reduce_supervisor_cursor_advanced(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1220 let events_path = paths.events();
1221 let node_id = require_envelope_node_id(&events_path, ev)?;
1222 let child_run_id = want_str(&events_path, ev, &ev.data, "child_run_id")?;
1223 RunId::parse_str(child_run_id).map_err(|e| corrupt_id(&events_path, ev, &e))?;
1227 let report_seq = ev
1228 .data
1229 .get("report_seq")
1230 .and_then(Value::as_u64)
1231 .ok_or_else(|| Error::CorruptEventLog {
1232 path: events_path.clone(),
1233 reason: format!(
1234 "event seq={} kind=supervisor.cursor_advanced missing/invalid `report_seq`",
1235 ev.seq
1236 ),
1237 })?;
1238 let mut n = match read_node_opt(paths, &node_id)? {
1239 Some(n) => n,
1240 None => return Ok(vec![]),
1241 };
1242 if let Some(prev) = n
1243 .last_processed_report_seq_by_child
1244 .get(child_run_id)
1245 .and_then(Value::as_u64)
1246 {
1247 if report_seq <= prev {
1248 return Ok(vec![]);
1249 }
1250 }
1251 n.last_processed_report_seq_by_child
1252 .insert(child_run_id.to_string(), Value::from(report_seq));
1253 n.updated_at = ev.ts;
1254 Ok(vec![ProjectionOp::Node(n)])
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259 use super::*;
1260 use crate::schema::Event;
1261 use chrono::Utc;
1262 use tempfile::TempDir;
1263
1264 fn event(run_id: &str) -> Event {
1265 Event {
1266 ts: Utc::now(),
1267 seq: 1,
1268 kind: "run.status".into(),
1269 run_id: RunId::parse_str(run_id).unwrap(),
1270 node_id: None,
1271 idempotency_key: None,
1272 data: serde_json::json!({ "status": "running" }),
1273 }
1274 }
1275
1276 #[test]
1277 fn orchestrator_decision_and_discuss_critical_reduce_to_noop() {
1278 let tmp = TempDir::new().unwrap();
1282 let run_id = "01jxsnap000000000000000000";
1283 let rid = RunId::parse_str(run_id).unwrap();
1284 let dir = crate::run_dir(tmp.path(), &rid);
1285 std::fs::create_dir_all(&dir).unwrap();
1286 let paths = RunPaths::new(dir, run_id).unwrap();
1287
1288 let mut created = event(run_id);
1291 created.kind = "run.created".into();
1292 created.data = serde_json::json!({
1293 "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1294 });
1295 apply_event(&paths, &created).expect("run.created applies");
1296 let manifest_before = std::fs::read(paths.manifest()).unwrap();
1297
1298 for (seq, kind) in [(10u64, "orchestrator.decision"), (11, "discuss.critical")] {
1299 let mut ev = event(run_id);
1300 ev.seq = seq;
1301 ev.kind = kind.into();
1302 ev.data = serde_json::json!({ "summary": "x", "arbitrary": [1, 2, 3] });
1304 let ops = reduce_event_to_ops(&paths, &ev).expect("audit kind reduces cleanly");
1305 assert!(ops.is_empty(), "{kind} must plan no projection ops");
1306 apply_event(&paths, &ev).expect("audit kind applies as no-op");
1308 }
1309
1310 assert_eq!(
1312 std::fs::read(paths.manifest()).unwrap(),
1313 manifest_before,
1314 "audit events must not mutate the manifest"
1315 );
1316 assert!(!paths.nodes_dir().exists(), "no node projection created");
1317 }
1318
1319 fn bootstrap_retry_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1322 let rid = RunId::parse_str(run_id).unwrap();
1323 let dir = crate::run_dir(tmp.path(), &rid);
1324 std::fs::create_dir_all(&dir).unwrap();
1325 let paths = RunPaths::new(dir, run_id).unwrap();
1326 let mut created = event(run_id);
1327 created.kind = "run.created".into();
1328 created.data =
1329 serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
1330 apply_event(&paths, &created).expect("run.created applies");
1331 let mut node = event(run_id);
1332 node.seq = 2;
1333 node.kind = "node.created".into();
1334 node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1335 node.data = serde_json::json!({
1336 "kind": "spinoff",
1337 "branch": "wt/foo",
1338 "worktree_path": "/tmp/old-wt",
1339 "agent_pid": 111,
1340 });
1341 apply_event(&paths, &node).expect("node.created applies");
1342 paths
1343 }
1344
1345 #[test]
1349 fn node_retry_rewires_node_and_increments_attempts() {
1350 let tmp = TempDir::new().unwrap();
1351 let run_id = "01jxsnap000000000000000000";
1352 let paths = bootstrap_retry_node(&tmp, run_id);
1353
1354 let mut retry = event(run_id);
1355 retry.seq = 3;
1356 retry.kind = "node.retry".into();
1357 retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1358 retry.data = serde_json::json!({
1359 "attempt": 1,
1360 "reason": "agent-died",
1361 "branch": "wt/foo-r1",
1362 "base_sha": "a".repeat(40),
1363 "worktree_path": "/tmp/new-wt",
1364 "agent_pid": 222,
1365 "tmux_session": "s",
1366 "tmux_window_id": "@9",
1367 });
1368 apply_event(&paths, &retry).expect("node.retry applies");
1369
1370 let n = read_n0001(&paths);
1371 assert_eq!(n.retry_attempts, 1, "attempt bound incremented");
1372 assert_eq!(
1373 n.branch.as_deref(),
1374 Some("wt/foo-r1"),
1375 "rewired to new branch"
1376 );
1377 assert_eq!(n.worktree_path.as_deref(), Some("/tmp/new-wt"));
1378 assert_eq!(n.agent_pid, Some(222), "rewired to new agent pid");
1379 assert_eq!(n.status, Status::Pending, "node returns to pending");
1380 assert!(n.last_report.is_none());
1381 assert_eq!(
1382 n.tmux_identity.as_ref().map(|t| t.window_id.as_str()),
1383 Some("@9"),
1384 "rewired tmux identity"
1385 );
1386
1387 let mut retry2 = event(run_id);
1389 retry2.seq = 4;
1390 retry2.kind = "node.retry".into();
1391 retry2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1392 retry2.data = serde_json::json!({
1393 "attempt": 2, "reason": "agent-died", "branch": "wt/foo-r2",
1394 "worktree_path": "/tmp/new-wt-2", "agent_pid": 333,
1395 });
1396 apply_event(&paths, &retry2).expect("node.retry applies");
1397 assert_eq!(read_n0001(&paths).retry_attempts, 2);
1398 }
1399
1400 #[test]
1404 fn node_retry_against_terminal_node_is_noop() {
1405 let tmp = TempDir::new().unwrap();
1406 let run_id = "01jxsnap000000000000000000";
1407 let paths = bootstrap_retry_node(&tmp, run_id);
1408
1409 let mut report = event(run_id);
1411 report.seq = 3;
1412 report.kind = "node.report".into();
1413 report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1414 report.data = serde_json::json!({ "success": true });
1415 apply_event(&paths, &report).expect("node.report applies");
1416 assert_eq!(read_n0001(&paths).status, Status::Done);
1417
1418 let mut retry = event(run_id);
1419 retry.seq = 4;
1420 retry.kind = "node.retry".into();
1421 retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1422 retry.data = serde_json::json!({
1423 "attempt": 1, "reason": "agent-died", "branch": "wt/foo-r1",
1424 "worktree_path": "/tmp/new-wt", "agent_pid": 222,
1425 });
1426 apply_event(&paths, &retry).expect("node.retry applies as no-op");
1427
1428 let n = read_n0001(&paths);
1429 assert_eq!(n.status, Status::Done, "terminal node not resurrected");
1430 assert_eq!(n.retry_attempts, 0, "no increment against terminal node");
1431 assert_eq!(n.agent_pid, Some(111), "not rewired");
1432 }
1433
1434 #[test]
1435 fn apply_event_rejects_event_from_a_different_run() {
1436 let tmp = TempDir::new().unwrap();
1437 let run_id = "01jxsnap000000000000000000";
1438 let rid = RunId::parse_str(run_id).unwrap();
1439 let dir = crate::run_dir(tmp.path(), &rid);
1440 std::fs::create_dir_all(&dir).unwrap();
1441 let paths = RunPaths::new(dir, run_id).unwrap();
1442
1443 let foreign = event("02jxsnap000000000000000000");
1445 let err = apply_event(&paths, &foreign).expect_err("cross-run event must be rejected");
1446 assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
1447
1448 let mine = event(run_id);
1451 apply_event(&paths, &mine).expect("matching run_id must be accepted");
1452 }
1453
1454 #[test]
1455 fn tmux_identity_from_data_reads_qualified_fields() {
1456 let d = serde_json::json!({
1457 "tmux_socket": "/private/tmp/tmux-501/default",
1458 "tmux_session": "octl",
1459 "tmux_window_id": "@42",
1460 });
1461 let id = tmux_identity_from_data(&d).expect("qualified identity");
1462 assert_eq!(id.socket.as_deref(), Some("/private/tmp/tmux-501/default"));
1463 assert_eq!(id.session, "octl");
1464 assert_eq!(id.window_id, "@42");
1465 assert_eq!(id.pane_id, None);
1467
1468 let d2 = serde_json::json!({
1470 "tmux_socket": null,
1471 "tmux_session": "octl",
1472 "tmux_window_id": "@7",
1473 });
1474 let id2 = tmux_identity_from_data(&d2).expect("identity without socket");
1475 assert_eq!(id2.socket, None);
1476 assert_eq!(id2.window_id, "@7");
1477
1478 let d3 = serde_json::json!({
1480 "tmux_session": "octl",
1481 "tmux_window_id": "@42",
1482 "tmux_pane_id": "%7",
1483 });
1484 let id3 = tmux_identity_from_data(&d3).expect("identity with pane");
1485 assert_eq!(id3.pane_id.as_deref(), Some("%7"));
1486 assert_eq!(id3.capture_target(), "%7");
1487
1488 let d4 = serde_json::json!({
1491 "tmux_session": "octl",
1492 "tmux_window_id": "@42",
1493 "tmux_pane_id": null,
1494 });
1495 let id4 = tmux_identity_from_data(&d4).expect("identity with null pane");
1496 assert_eq!(id4.pane_id, None);
1497 assert_eq!(id4.capture_target(), "@42");
1498 }
1499
1500 #[test]
1501 fn tmux_identity_from_data_back_compat_is_none() {
1502 let legacy = serde_json::json!({ "tmux_window": "🚀 wt/x" });
1504 assert!(tmux_identity_from_data(&legacy).is_none());
1505 let partial = serde_json::json!({ "tmux_window_id": "@42" });
1507 assert!(tmux_identity_from_data(&partial).is_none());
1508 }
1509
1510 #[test]
1513 fn node_created_populates_tmux_identity() {
1514 let tmp = TempDir::new().unwrap();
1515 let run_id = "01jxsnap000000000000000000";
1516 let rid = RunId::parse_str(run_id).unwrap();
1517 let dir = crate::run_dir(tmp.path(), &rid);
1518 std::fs::create_dir_all(&dir).unwrap();
1519 let paths = RunPaths::new(dir, run_id).unwrap();
1520
1521 let mut ev = event(run_id);
1522 ev.seq = 2;
1523 ev.kind = "node.created".into();
1524 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1525 ev.data = serde_json::json!({
1526 "kind": "spinoff",
1527 "tmux_window": "🚀 wt/x",
1528 "tmux_socket": "/private/tmp/tmux-501/default",
1529 "tmux_session": "octl",
1530 "tmux_window_id": "@42",
1531 });
1532 apply_event(&paths, &ev).expect("node.created applies");
1533 let n = read_node_opt(&paths, &NodeId::parse_str("n-0001").unwrap())
1534 .unwrap()
1535 .unwrap();
1536 let id = n.tmux_identity.expect("qualified identity recorded");
1537 assert_eq!(id.session, "octl");
1538 assert_eq!(id.window_id, "@42");
1539 assert_eq!(n.tmux_window.as_deref(), Some("🚀 wt/x"));
1540
1541 let run2 = "02jxsnap000000000000000000";
1543 let rid2 = RunId::parse_str(run2).unwrap();
1544 let dir2 = crate::run_dir(tmp.path(), &rid2);
1545 std::fs::create_dir_all(&dir2).unwrap();
1546 let paths2 = RunPaths::new(dir2, run2).unwrap();
1547 let mut ev2 = event(run2);
1548 ev2.seq = 2;
1549 ev2.kind = "node.created".into();
1550 ev2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1551 ev2.data = serde_json::json!({ "kind": "spinoff", "tmux_window": "🚀 wt/y" });
1552 apply_event(&paths2, &ev2).expect("legacy node.created applies");
1553 let n2 = read_node_opt(&paths2, &NodeId::parse_str("n-0001").unwrap())
1554 .unwrap()
1555 .unwrap();
1556 assert!(n2.tmux_identity.is_none());
1557 assert_eq!(n2.tmux_window.as_deref(), Some("🚀 wt/y"));
1558 }
1559
1560 fn seed_run_with_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1563 let rid = RunId::parse_str(run_id).unwrap();
1564 let dir = crate::run_dir(tmp.path(), &rid);
1565 std::fs::create_dir_all(&dir).unwrap();
1566 let paths = RunPaths::new(dir, run_id).unwrap();
1567
1568 let mut created = event(run_id);
1569 created.kind = "run.created".into();
1570 created.data = serde_json::json!({
1571 "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1572 });
1573 apply_event(&paths, &created).expect("run.created applies");
1574
1575 let mut node = event(run_id);
1576 node.seq = 2;
1577 node.kind = "node.created".into();
1578 node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1579 node.data = serde_json::json!({ "kind": "spinoff" });
1580 apply_event(&paths, &node).expect("node.created applies");
1581 paths
1582 }
1583
1584 fn read_n0001(paths: &RunPaths) -> Node {
1585 read_node_opt(paths, &NodeId::parse_str("n-0001").unwrap())
1586 .unwrap()
1587 .unwrap()
1588 }
1589
1590 #[test]
1594 fn supervisor_attached_sets_supervisor_pid() {
1595 let tmp = TempDir::new().unwrap();
1596 let run_id = "01jxsnap000000000000000000";
1597 let paths = seed_run_with_node(&tmp, run_id);
1598 assert_eq!(read_n0001(&paths).supervisor_pid, None);
1599
1600 let mut ev = event(run_id);
1601 ev.seq = 3;
1602 ev.kind = "supervisor.attached".into();
1603 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1604 ev.data = serde_json::json!({ "pid": 47820 });
1605 apply_event(&paths, &ev).expect("supervisor.attached applies");
1606 assert_eq!(read_n0001(&paths).supervisor_pid, Some(47820));
1607 }
1608
1609 #[test]
1612 fn supervisor_attached_latest_wins_and_idempotent_on_replay() {
1613 let tmp = TempDir::new().unwrap();
1614 let run_id = "01jxsnap000000000000000000";
1615 let paths = seed_run_with_node(&tmp, run_id);
1616
1617 let mut ev = event(run_id);
1618 ev.kind = "supervisor.attached".into();
1619 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1620
1621 ev.seq = 3;
1622 ev.data = serde_json::json!({ "pid": 100 });
1623 apply_event(&paths, &ev).expect("first attach applies");
1624 assert_eq!(read_n0001(&paths).supervisor_pid, Some(100));
1625
1626 ev.seq = 4;
1628 ev.data = serde_json::json!({ "pid": 200 });
1629 apply_event(&paths, &ev).expect("second attach applies");
1630 let after_second = read_n0001(&paths);
1631 assert_eq!(after_second.supervisor_pid, Some(200));
1632
1633 let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
1636 assert!(ops.is_empty(), "re-applying same pid must plan no ops");
1637 apply_event(&paths, &ev).expect("replay applies as no-op");
1638 assert_eq!(read_n0001(&paths).updated_at, after_second.updated_at);
1639 }
1640
1641 #[test]
1644 fn supervisor_cursor_advanced_sets_report_cursor() {
1645 let tmp = TempDir::new().unwrap();
1646 let run_id = "01jxsnap000000000000000000";
1647 let paths = seed_run_with_node(&tmp, run_id);
1648 let child = "02jxsnap000000000000000000";
1649
1650 let mut ev = event(run_id);
1651 ev.seq = 3;
1652 ev.kind = "supervisor.cursor_advanced".into();
1653 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1654 ev.data = serde_json::json!({ "child_run_id": child, "report_seq": 7 });
1655 apply_event(&paths, &ev).expect("cursor_advanced applies");
1656
1657 let n = read_n0001(&paths);
1658 assert_eq!(
1659 n.last_processed_report_seq_by_child.get(child),
1660 Some(&Value::from(7u64))
1661 );
1662 }
1663
1664 #[test]
1669 fn supervisor_cursor_advanced_is_monotonic_and_idempotent() {
1670 let tmp = TempDir::new().unwrap();
1671 let run_id = "01jxsnap000000000000000000";
1672 let paths = seed_run_with_node(&tmp, run_id);
1673 let child_a = "02jxsnap000000000000000000";
1674 let child_b = "03jxsnap000000000000000000";
1675
1676 let mut ev = event(run_id);
1677 ev.kind = "supervisor.cursor_advanced".into();
1678 ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1679
1680 ev.seq = 3;
1681 ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 5 });
1682 apply_event(&paths, &ev).expect("seq 5 applies");
1683
1684 let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
1686 assert!(ops.is_empty(), "re-applying same cursor must plan no ops");
1687
1688 ev.seq = 4;
1690 ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 3 });
1691 let ops = reduce_event_to_ops(&paths, &ev).expect("older seq reduces cleanly");
1692 assert!(ops.is_empty(), "older seq must plan no ops");
1693 apply_event(&paths, &ev).expect("older seq applies as no-op");
1694 assert_eq!(
1695 read_n0001(&paths)
1696 .last_processed_report_seq_by_child
1697 .get(child_a),
1698 Some(&Value::from(5u64))
1699 );
1700
1701 ev.seq = 5;
1703 ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 9 });
1704 apply_event(&paths, &ev).expect("higher seq applies");
1705 ev.seq = 6;
1706 ev.data = serde_json::json!({ "child_run_id": child_b, "report_seq": 1 });
1707 apply_event(&paths, &ev).expect("second child applies");
1708
1709 let n = read_n0001(&paths);
1710 assert_eq!(
1711 n.last_processed_report_seq_by_child.get(child_a),
1712 Some(&Value::from(9u64))
1713 );
1714 assert_eq!(
1715 n.last_processed_report_seq_by_child.get(child_b),
1716 Some(&Value::from(1u64))
1717 );
1718 }
1719
1720 #[test]
1723 fn supervisor_state_events_reject_malformed_payloads() {
1724 let tmp = TempDir::new().unwrap();
1725 let run_id = "01jxsnap000000000000000000";
1726 let paths = seed_run_with_node(&tmp, run_id);
1727 let nid = Some(NodeId::parse_str("n-0001").unwrap());
1728
1729 let mut ev = event(run_id);
1731 ev.seq = 3;
1732 ev.kind = "supervisor.attached".into();
1733 ev.node_id = nid.clone();
1734 ev.data = serde_json::json!({});
1735 assert!(matches!(
1736 reduce_event_to_ops(&paths, &ev),
1737 Err(Error::CorruptEventLog { .. })
1738 ));
1739
1740 ev.node_id = None;
1742 ev.data = serde_json::json!({ "pid": 1 });
1743 assert!(matches!(
1744 reduce_event_to_ops(&paths, &ev),
1745 Err(Error::CorruptEventLog { .. })
1746 ));
1747
1748 let mut ev2 = event(run_id);
1750 ev2.seq = 4;
1751 ev2.kind = "supervisor.cursor_advanced".into();
1752 ev2.node_id = nid.clone();
1753 ev2.data = serde_json::json!({ "child_run_id": "../etc", "report_seq": 1 });
1754 assert!(matches!(
1755 reduce_event_to_ops(&paths, &ev2),
1756 Err(Error::CorruptEventLog { .. })
1757 ));
1758
1759 ev2.data = serde_json::json!({ "child_run_id": "02jxsnap000000000000000000" });
1761 assert!(matches!(
1762 reduce_event_to_ops(&paths, &ev2),
1763 Err(Error::CorruptEventLog { .. })
1764 ));
1765 }
1766
1767 #[cfg(unix)]
1776 fn projection_inodes(paths: &RunPaths) -> std::collections::BTreeMap<PathBuf, u64> {
1777 use std::os::unix::fs::MetadataExt;
1778 let mut consider = vec![paths.manifest()];
1779 for dir in [
1780 paths.nodes_dir(),
1781 paths.discussions_dir(),
1782 paths.spinoffs_dir(),
1783 ] {
1784 if let Ok(rd) = std::fs::read_dir(&dir) {
1785 for ent in rd.flatten() {
1786 let p = ent.path();
1787 if p.extension().and_then(|s| s.to_str()) == Some("json") {
1788 consider.push(p);
1789 }
1790 }
1791 }
1792 }
1793 let mut map = std::collections::BTreeMap::new();
1794 for p in consider {
1795 if let Ok(md) = std::fs::symlink_metadata(&p) {
1796 if md.file_type().is_file() {
1797 map.insert(p, md.ino());
1798 }
1799 }
1800 }
1801 map
1802 }
1803
1804 #[cfg(unix)]
1813 fn assert_plan_matches_apply(paths: &RunPaths, ev: &Event, expect_writes: bool) {
1814 use std::collections::BTreeSet;
1815 let before = projection_inodes(paths);
1816 let planned: BTreeSet<PathBuf> = plan_projections(paths, ev)
1817 .unwrap_or_else(|e| panic!("plan_projections({}) errored: {e:?}", ev.kind))
1818 .into_iter()
1819 .collect();
1820 apply_event(paths, ev)
1821 .unwrap_or_else(|e| panic!("apply_event({}) errored: {e:?}", ev.kind));
1822 let after = projection_inodes(paths);
1823 let touched: BTreeSet<PathBuf> = after
1824 .iter()
1825 .filter(|(p, ino)| before.get(*p) != Some(*ino))
1826 .map(|(p, _)| p.clone())
1827 .collect();
1828 assert_eq!(
1829 planned, touched,
1830 "kind={}: plan_projections must name exactly the files apply_event writes",
1831 ev.kind
1832 );
1833 if expect_writes {
1834 assert!(
1835 !touched.is_empty(),
1836 "kind={}: expected this event to write at least one projection",
1837 ev.kind
1838 );
1839 }
1840 }
1841
1842 #[cfg(unix)]
1849 #[test]
1850 fn plan_projections_matches_apply_for_every_kind() {
1851 let tmp = TempDir::new().unwrap();
1852 let run_id = "01jxsnap000000000000000000";
1853 let rid = RunId::parse_str(run_id).unwrap();
1854 let dir = crate::run_dir(tmp.path(), &rid);
1855 std::fs::create_dir_all(&dir).unwrap();
1856 let paths = RunPaths::new(dir, run_id).unwrap();
1857 let nid = || Some(NodeId::parse_str("n-0001").unwrap());
1858 let disc_id = "d-pqrstuvwxy";
1859 let prop_id = "s-spinaaaaaa";
1860 let child = "02jxsnap000000000000000000";
1861
1862 let mut next_seq = 0u64;
1864 let mut at = |kind: &str, node_id, data| {
1865 next_seq += 1;
1866 Event {
1867 ts: Utc::now(),
1868 seq: next_seq,
1869 kind: kind.into(),
1870 run_id: rid.clone(),
1871 node_id,
1872 idempotency_key: None,
1873 data,
1874 }
1875 };
1876
1877 assert_plan_matches_apply(
1879 &paths,
1880 &at(
1881 "run.created",
1882 None,
1883 serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
1884 ),
1885 true,
1886 );
1887 assert_plan_matches_apply(
1889 &paths,
1890 &at(
1891 "run.status",
1892 None,
1893 serde_json::json!({ "status": "running" }),
1894 ),
1895 true,
1896 );
1897 assert_plan_matches_apply(
1899 &paths,
1900 &at(
1901 "node.created",
1902 nid(),
1903 serde_json::json!({ "kind": "spinoff" }),
1904 ),
1905 true,
1906 );
1907 assert_plan_matches_apply(
1909 &paths,
1910 &at(
1911 "node.status",
1912 nid(),
1913 serde_json::json!({ "status": "running" }),
1914 ),
1915 true,
1916 );
1917 assert_plan_matches_apply(
1919 &paths,
1920 &at(
1921 "discussion.opened",
1922 None,
1923 serde_json::json!({ "discussion_id": disc_id, "node_id": "n-0001", "topic": "t" }),
1924 ),
1925 true,
1926 );
1927 assert_plan_matches_apply(
1929 &paths,
1930 &at(
1931 "discussion.resolved",
1932 None,
1933 serde_json::json!({ "discussion_id": disc_id, "resolution": "keep" }),
1934 ),
1935 true,
1936 );
1937 assert_plan_matches_apply(
1939 &paths,
1940 &at(
1941 "spinoff.proposed",
1942 None,
1943 serde_json::json!({
1944 "proposal_id": prop_id, "node_id": "n-0001",
1945 "proposed_title": "p", "proposed_kind": "spinoff"
1946 }),
1947 ),
1948 true,
1949 );
1950 assert_plan_matches_apply(
1952 &paths,
1953 &at(
1954 "spinoff.approved",
1955 None,
1956 serde_json::json!({ "proposal_id": prop_id, "issue_slug": "x" }),
1957 ),
1958 true,
1959 );
1960 assert_plan_matches_apply(
1962 &paths,
1963 &at(
1964 "supervisor.attached",
1965 nid(),
1966 serde_json::json!({ "pid": 4242 }),
1967 ),
1968 true,
1969 );
1970 assert_plan_matches_apply(
1972 &paths,
1973 &at(
1974 "supervisor.cursor_advanced",
1975 nid(),
1976 serde_json::json!({ "child_run_id": child, "report_seq": 3 }),
1977 ),
1978 true,
1979 );
1980 assert_plan_matches_apply(
1982 &paths,
1983 &at(
1984 "child.spawned",
1985 nid(),
1986 serde_json::json!({ "child_run_id": child, "child_node_id": "n-0001" }),
1987 ),
1988 true,
1989 );
1990 assert_plan_matches_apply(
1992 &paths,
1993 &at("node.report", nid(), serde_json::json!({ "success": true })),
1994 true,
1995 );
1996 assert_plan_matches_apply(
1999 &paths,
2000 &at(
2001 "node.status",
2002 nid(),
2003 serde_json::json!({ "status": "failed" }),
2004 ),
2005 false,
2006 );
2007 for kind in [
2009 "supervisor.exited",
2010 "orchestrator.decision",
2011 "discuss.critical",
2012 "cleanup.window_missing",
2013 ] {
2014 assert_plan_matches_apply(&paths, &at(kind, None, serde_json::json!({})), false);
2015 }
2016
2017 let prop2 = "s-spinbbbbbb";
2020 assert_plan_matches_apply(
2021 &paths,
2022 &at(
2023 "spinoff.proposed",
2024 None,
2025 serde_json::json!({
2026 "proposal_id": prop2, "node_id": "n-0001",
2027 "proposed_title": "p2", "proposed_kind": "spinoff"
2028 }),
2029 ),
2030 true,
2031 );
2032 assert_plan_matches_apply(
2033 &paths,
2034 &at(
2035 "spinoff.rejected",
2036 None,
2037 serde_json::json!({ "proposal_id": prop2, "reason": "no" }),
2038 ),
2039 true,
2040 );
2041 }
2042}