use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde_json::Value;
use crate::error::{Error, Result};
use crate::paths::RunPaths;
use crate::projections::{
read_discussion_opt, read_manifest_opt, read_node_opt, read_spinoff_opt, write_discussion,
write_manifest, write_node, write_spinoff,
};
use crate::schema::{
ChildRef, Discussion, DiscussionId, DiscussionStatus, Event, IdValidationError, Kind,
Lifecycle, Manifest, Node, NodeId, ProposalId, RunId, SpinoffProposal, SpinoffStatus, Status,
TmuxIdentity, STATE_SCHEMA_VERSION,
};
fn corrupt_id(events_path: &Path, ev: &Event, e: &IdValidationError) -> Error {
Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!("event seq={} kind={}: {e}", ev.seq, ev.kind),
}
}
fn opt_run_id(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<RunId>> {
match d.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => RunId::parse_str(s)
.map(Some)
.map_err(|e| corrupt_id(events_path, ev, &e)),
Some(_) => Err(Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` must be a JSON string or null",
ev.seq, ev.kind
),
}),
}
}
fn opt_node_id(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<NodeId>> {
match d.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => NodeId::parse_str(s)
.map(Some)
.map_err(|e| corrupt_id(events_path, ev, &e)),
Some(_) => Err(Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` must be a JSON string or null",
ev.seq, ev.kind
),
}),
}
}
fn want_node_id_with_fallback(
events_path: &Path,
ev: &Event,
d: &Value,
field: &str,
) -> Result<NodeId> {
let s = d
.get(field)
.and_then(Value::as_str)
.or(ev.node_id.as_ref().map(NodeId::as_str))
.ok_or_else(|| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!("event seq={} kind={} missing `{field}`", ev.seq, ev.kind),
})?;
NodeId::parse_str(s).map_err(|e| corrupt_id(events_path, ev, &e))
}
fn data_kind(v: &Value) -> Option<Kind> {
serde_json::from_value(v.clone()).ok()
}
fn data_status(v: &Value) -> Option<Status> {
serde_json::from_value(v.clone()).ok()
}
fn require_status(ev: &Event, path: PathBuf) -> Result<Status> {
data_status(ev.data.get("status").unwrap_or(&Value::Null)).ok_or_else(|| {
Error::CorruptEventLog {
path,
reason: format!("{} missing/invalid `status`", ev.kind),
}
})
}
fn want_str<'a>(events_path: &Path, ev: &Event, d: &'a Value, field: &str) -> Result<&'a str> {
d.get(field)
.and_then(Value::as_str)
.ok_or_else(|| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} missing `{field}` string field",
ev.seq, ev.kind
),
})
}
fn optional_str(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<String>> {
match d.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` must be a JSON string or null",
ev.seq, ev.kind
),
}),
}
}
fn optional_bool(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<bool>> {
match d.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(_) => Err(Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` must be a JSON boolean or null",
ev.seq, ev.kind
),
}),
}
}
fn optional_i32(d: &Value, field: &str, events_path: &Path, ev: &Event) -> Result<Option<i32>> {
match d.get(field) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let raw = v.as_i64().ok_or_else(|| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` must be integer",
ev.seq, ev.kind
),
})?;
i32::try_from(raw)
.map(Some)
.map_err(|_| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` out of i32 range: {raw}",
ev.seq, ev.kind
),
})
}
}
}
fn optional_ts(
d: &Value,
field: &str,
events_path: &Path,
ev: &Event,
) -> Result<Option<DateTime<Utc>>> {
match d.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => DateTime::parse_from_rfc3339(s)
.map(|dt| Some(dt.with_timezone(&Utc)))
.map_err(|_| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` not RFC3339",
ev.seq, ev.kind
),
}),
Some(_) => Err(Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` must be RFC3339 string or null",
ev.seq, ev.kind
),
}),
}
}
pub(crate) enum ProjectionOp {
Manifest(Manifest),
Node(Node),
Discussion(Discussion),
Spinoff(SpinoffProposal),
}
pub(crate) fn commit_ops(paths: &RunPaths, ops: Vec<ProjectionOp>) -> Result<()> {
for op in ops {
match op {
ProjectionOp::Manifest(m) => write_manifest(paths, &m)?,
ProjectionOp::Node(n) => write_node(paths, &n)?,
ProjectionOp::Discussion(d) => write_discussion(paths, &d)?,
ProjectionOp::Spinoff(s) => write_spinoff(paths, &s)?,
}
}
Ok(())
}
pub(crate) fn reduce_event_to_ops(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
if ev.run_id != paths.run_id {
return Err(Error::CorruptEventLog {
path: paths.events(),
reason: format!(
"event seq={} envelope run_id {:?} does not match run {:?}",
ev.seq,
ev.run_id.as_str(),
paths.run_id.as_str()
),
});
}
#[allow(clippy::match_same_arms)]
match ev.kind.as_str() {
"run.created" => reduce_run_created(paths, ev),
"run.status" => reduce_run_status(paths, ev),
"node.created" => reduce_node_created(paths, ev),
"node.status" => reduce_node_status(paths, ev),
"node.report" => reduce_node_report(paths, ev),
"node.retry" => reduce_node_retry(paths, ev),
"discussion.opened" => reduce_discussion_opened(paths, ev),
"discussion.resolved" => reduce_discussion_resolved(paths, ev),
"spinoff.proposed" => reduce_spinoff_proposed(paths, ev),
"spinoff.approved" => reduce_spinoff_approved(paths, ev),
"spinoff.rejected" => reduce_spinoff_rejected(paths, ev),
"child.spawned" => reduce_child_spawned(paths, ev),
"supervisor.attached" => reduce_supervisor_attached(paths, ev),
"supervisor.cursor_advanced" => reduce_supervisor_cursor_advanced(paths, ev),
"supervisor.exited" => Ok(vec![]),
"orchestrator.decision" | "discuss.critical" => Ok(vec![]),
"run.notified" => Ok(vec![]),
"cleanup.window_missing"
| "cleanup.worktree_missing"
| "cleanup.branch_remove_failed"
| "cleanup.branch_preserved"
| "cleanup.session_killed"
| "cleanup.session_retained" => Ok(vec![]),
"supervisor.child_id_quarantined" => Ok(vec![]),
_ => Ok(vec![]),
}
}
fn op_path(paths: &RunPaths, op: &ProjectionOp) -> PathBuf {
match op {
ProjectionOp::Manifest(_) => paths.manifest(),
ProjectionOp::Node(n) => paths.node(&n.node_id),
ProjectionOp::Discussion(d) => paths.discussion(&d.discussion_id),
ProjectionOp::Spinoff(s) => paths.spinoff(&s.proposal_id),
}
}
pub fn plan_projections(paths: &RunPaths, event: &Event) -> Result<Vec<PathBuf>> {
let ops = reduce_event_to_ops(paths, event)?;
Ok(ops.iter().map(|op| op_path(paths, op)).collect())
}
pub(crate) fn apply_event(paths: &RunPaths, ev: &Event) -> Result<()> {
let ops = reduce_event_to_ops(paths, ev)?;
commit_ops(paths, ops)
}
#[cfg(test)]
pub(crate) fn validate_event(paths: &RunPaths, ev: &Event) -> Result<()> {
reduce_event_to_ops(paths, ev).map(|_| ())
}
fn require_envelope_node_id(events_path: &Path, ev: &Event) -> Result<NodeId> {
ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} missing top-level `node_id`",
ev.seq, ev.kind
),
})
}
fn reduce_run_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
if let Some(existing) = read_manifest_opt(paths)? {
if existing.run_id != ev.run_id {
return Err(Error::CorruptEventLog {
path: paths.manifest(),
reason: format!(
"run.created run_id={} conflicts with existing manifest run_id={}",
ev.run_id, existing.run_id
),
});
}
return Ok(vec![]);
}
let events_path = paths.events();
let d = &ev.data;
let kind =
data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
path: events_path.clone(),
reason: "run.created missing/invalid `kind`".into(),
})?;
let lifecycle: Lifecycle = serde_json::from_value(
d.get("lifecycle").cloned().unwrap_or(Value::Null),
)
.map_err(|_| Error::CorruptEventLog {
path: events_path.clone(),
reason: "run.created missing/invalid `lifecycle`".into(),
})?;
let title = want_str(&events_path, ev, d, "title")?.to_string();
let m = Manifest {
schema_version: STATE_SCHEMA_VERSION,
applied_seq: 0,
run_id: paths.run_id.clone(),
kind,
lifecycle,
title,
status: Status::Pending,
created_at: ev.ts,
updated_at: ev.ts,
source_repo: d
.get("source_repo")
.and_then(Value::as_str)
.map(str::to_string),
source_branch: d
.get("source_branch")
.and_then(Value::as_str)
.map(str::to_string),
worktree_root: d
.get("worktree_root")
.and_then(Value::as_str)
.map(str::to_string),
managed_tmux_session: d
.get("managed_tmux_session")
.and_then(Value::as_str)
.map(str::to_string),
notify_cmd: d
.get("notify_cmd")
.and_then(Value::as_str)
.map(str::to_string),
node_count: 0,
open_discussions: 0,
pending_spinoffs: 0,
parent_run_id: opt_run_id(&events_path, ev, d, "parent_run_id")?,
parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
};
Ok(vec![ProjectionOp::Manifest(m)])
}
fn reduce_run_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let mut m = match read_manifest_opt(paths)? {
Some(m) => m,
None => return Ok(vec![]),
};
let new_status = require_status(ev, paths.events())?;
if m.status.is_terminal() {
trace_terminal_noop(ev, m.status, new_status);
return Ok(vec![]);
}
if m.status == new_status {
return Ok(vec![]);
}
m.status = new_status;
m.updated_at = ev.ts;
Ok(vec![ProjectionOp::Manifest(m)])
}
fn tmux_identity_from_data(d: &Value) -> Option<TmuxIdentity> {
let nonempty = |key| {
d.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let session = nonempty("tmux_session")?;
let window_id = nonempty("tmux_window_id")?;
Some(TmuxIdentity {
socket: nonempty("tmux_socket"),
session,
window_id,
pane_id: nonempty("tmux_pane_id"),
})
}
fn reduce_node_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
if read_node_opt(paths, &node_id)?.is_some() {
return Ok(vec![]);
}
let d = &ev.data;
let kind =
data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=node.created missing/invalid `kind`",
ev.seq
),
})?;
let n = Node {
schema_version: STATE_SCHEMA_VERSION,
node_id,
run_id: paths.run_id.clone(),
parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
kind,
status: Status::Pending,
task: d.get("task").and_then(Value::as_str).map(str::to_string),
worktree_path: d
.get("worktree_path")
.and_then(Value::as_str)
.map(str::to_string),
branch: d.get("branch").and_then(Value::as_str).map(str::to_string),
base_sha: d
.get("base_sha")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string),
tmux_window: d
.get("tmux_window")
.and_then(Value::as_str)
.map(str::to_string),
tmux_identity: tmux_identity_from_data(d),
agent_pid: optional_i32(d, "agent_pid", &events_path, ev)?,
agent_pid_start_time: optional_ts(d, "agent_pid_start_time", &events_path, ev)?,
supervisor_pid: optional_i32(d, "supervisor_pid", &events_path, ev)?,
children: Vec::new(),
started_at: Some(ev.ts),
updated_at: ev.ts,
last_report: None,
last_processed_report_seq_by_child: serde_json::Map::default(),
retry_attempts: 0,
};
let mut ops = vec![ProjectionOp::Node(n)];
if let Some(mut m) = read_manifest_opt(paths)? {
m.updated_at = ev.ts;
ops.push(ProjectionOp::Manifest(m));
}
Ok(ops)
}
fn reduce_node_retry(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
if n.status.is_terminal() {
tracing::debug!(
target: "octl_core::reducer",
seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
"no-op: node.retry against terminal node"
);
return Ok(vec![]);
}
let d = &ev.data;
n.branch = d.get("branch").and_then(Value::as_str).map(str::to_string);
n.base_sha = d
.get("base_sha")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
n.worktree_path = d
.get("worktree_path")
.and_then(Value::as_str)
.map(str::to_string);
n.tmux_window = d
.get("tmux_window")
.and_then(Value::as_str)
.map(str::to_string);
n.tmux_identity = tmux_identity_from_data(d);
n.agent_pid = optional_i32(d, "agent_pid", &events_path, ev)?;
n.agent_pid_start_time = optional_ts(d, "agent_pid_start_time", &events_path, ev)?;
n.status = Status::Pending;
n.started_at = Some(ev.ts);
n.updated_at = ev.ts;
n.last_report = None;
n.retry_attempts = d
.get("attempt")
.and_then(Value::as_u64)
.map_or_else(|| n.retry_attempts.saturating_add(1), |a| a as u32);
Ok(vec![ProjectionOp::Node(n)])
}
fn reduce_node_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
let new_status = require_status(ev, events_path)?;
if n.status.is_terminal() {
trace_terminal_noop(ev, n.status, new_status);
return Ok(vec![]);
}
if n.status == new_status {
return Ok(vec![]);
}
n.status = new_status;
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
fn reduce_node_report(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
if n.status.is_terminal() {
if matches!(n.status, Status::Failed | Status::Done)
&& report_is_confirmed_explicit_merge(&ev.data)
{
if n.last_report.as_ref() == Some(&ev.data) && n.status == Status::Done {
return Ok(vec![]);
}
tracing::info!(
target: "octl_core::reducer",
seq = ev.seq, kind = %ev.kind, node_id = %node_id, prior = ?n.status,
"adopting late explicit-merge report against terminal node (invariant #5 teardown)"
);
n.last_report = Some(ev.data.clone());
n.status = Status::Done;
n.updated_at = ev.ts;
return Ok(vec![ProjectionOp::Node(n)]);
}
tracing::debug!(
target: "octl_core::reducer",
seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
"no-op: node.report against terminal node"
);
return Ok(vec![]);
}
let new_status = report_terminal_status(&events_path, ev)?;
n.last_report = Some(ev.data.clone());
n.status = new_status;
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
fn trace_terminal_noop(ev: &Event, current: Status, incoming: Status) {
if current == incoming {
tracing::debug!(
target: "octl_core::reducer",
seq = ev.seq, kind = %ev.kind, status = ?current,
"no-op: status re-applied to terminal target"
);
} else {
tracing::warn!(
target: "octl_core::reducer",
seq = ev.seq, kind = %ev.kind, current = ?current, incoming = ?incoming,
"no-op: ignored conflicting transition from terminal target"
);
}
}
pub const VIA_EXPLICIT_MERGE: &str = "explicit-merge";
fn report_is_confirmed_explicit_merge(data: &Value) -> bool {
let via = data.get("via").and_then(Value::as_str) == Some(VIA_EXPLICIT_MERGE);
let success = matches!(data.get("success"), Some(Value::Bool(true)));
let not_cancelled = matches!(
data.get("cancelled"),
None | Some(Value::Null | Value::Bool(false))
);
via && success && not_cancelled
}
fn report_terminal_status(events_path: &Path, ev: &Event) -> Result<Status> {
let corrupt = |reason: String| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason,
};
let cancelled = optional_bool(events_path, ev, &ev.data, "cancelled")?.unwrap_or(false);
let success = optional_bool(events_path, ev, &ev.data, "success")?;
if cancelled {
if success == Some(true) {
return Err(corrupt(format!(
"event seq={} kind=node.report has contradictory `success: true` with `cancelled: true`",
ev.seq
)));
}
Ok(Status::Cancelled)
} else {
match success {
Some(true) => Ok(Status::Done),
Some(false) => Ok(Status::Failed),
None => Err(corrupt(format!(
"event seq={} kind=node.report must set boolean `success` or `cancelled: true`",
ev.seq
))),
}
}
}
fn reduce_discussion_opened(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let d = &ev.data;
let discussion_id = DiscussionId::parse_str(want_str(&events_path, ev, d, "discussion_id")?)
.map_err(|e| corrupt_id(&events_path, ev, &e))?;
if read_discussion_opt(paths, &discussion_id)?.is_some() {
return Ok(vec![]);
}
let node_id = want_node_id_with_fallback(&events_path, ev, d, "node_id")?;
let options = d
.get("options")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let disc = Discussion {
schema_version: STATE_SCHEMA_VERSION,
discussion_id,
run_id: paths.run_id.clone(),
node_id,
opened_at: ev.ts,
severity: d
.get("severity")
.and_then(Value::as_str)
.unwrap_or("discuss")
.to_string(),
topic: want_str(&events_path, ev, d, "topic")?.to_string(),
context: d.get("context").and_then(Value::as_str).map(str::to_string),
options,
status: DiscussionStatus::Open,
resolution: None,
note: None,
resolved_at: None,
};
let mut ops = vec![ProjectionOp::Discussion(disc)];
if let Some(mut m) = read_manifest_opt(paths)? {
m.updated_at = ev.ts;
ops.push(ProjectionOp::Manifest(m));
}
Ok(ops)
}
fn reduce_discussion_resolved(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let id = DiscussionId::parse_str(want_str(&events_path, ev, &ev.data, "discussion_id")?)
.map_err(|e| corrupt_id(&events_path, ev, &e))?;
let mut disc = match read_discussion_opt(paths, &id)? {
Some(d) => d,
None => return Ok(vec![]),
};
if matches!(disc.status, DiscussionStatus::Resolved) {
return Ok(vec![]);
}
disc.status = DiscussionStatus::Resolved;
disc.resolution = Some(want_str(&events_path, ev, &ev.data, "resolution")?.to_string());
disc.note = optional_str(&events_path, ev, &ev.data, "note")?;
disc.resolved_at = Some(ev.ts);
let mut ops = vec![ProjectionOp::Discussion(disc)];
if let Some(mut m) = read_manifest_opt(paths)? {
m.updated_at = ev.ts;
ops.push(ProjectionOp::Manifest(m));
}
Ok(ops)
}
fn reduce_spinoff_proposed(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let d = &ev.data;
let proposal_id = ProposalId::parse_str(want_str(&events_path, ev, d, "proposal_id")?)
.map_err(|e| corrupt_id(&events_path, ev, &e))?;
if read_spinoff_opt(paths, &proposal_id)?.is_some() {
return Ok(vec![]);
}
let proposed_kind =
data_kind(d.get("proposed_kind").unwrap_or(&Value::Null)).ok_or_else(|| {
Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=spinoff.proposed missing/invalid `proposed_kind`",
ev.seq
),
}
})?;
let node_id = want_node_id_with_fallback(&events_path, ev, d, "node_id")?;
let s = SpinoffProposal {
schema_version: STATE_SCHEMA_VERSION,
proposal_id,
run_id: paths.run_id.clone(),
node_id,
proposed_at: ev.ts,
proposed_title: want_str(&events_path, ev, d, "proposed_title")?.to_string(),
proposed_kind,
rationale: d
.get("rationale")
.and_then(Value::as_str)
.map(str::to_string),
status: SpinoffStatus::Proposed,
accepted_as_issue_slug: None,
rejected_reason: None,
resolved_at: None,
};
let mut ops = vec![ProjectionOp::Spinoff(s)];
if let Some(mut m) = read_manifest_opt(paths)? {
m.updated_at = ev.ts;
ops.push(ProjectionOp::Manifest(m));
}
Ok(ops)
}
fn reduce_spinoff_approved(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let id = ProposalId::parse_str(want_str(&events_path, ev, &ev.data, "proposal_id")?)
.map_err(|e| corrupt_id(&events_path, ev, &e))?;
let mut s = match read_spinoff_opt(paths, &id)? {
Some(s) => s,
None => return Ok(vec![]),
};
if matches!(s.status, SpinoffStatus::Approved | SpinoffStatus::Rejected) {
return Ok(vec![]);
}
s.status = SpinoffStatus::Approved;
s.accepted_as_issue_slug = ev
.data
.get("issue_slug")
.and_then(Value::as_str)
.map(str::to_string);
s.resolved_at = Some(ev.ts);
let mut ops = vec![ProjectionOp::Spinoff(s)];
if let Some(mut m) = read_manifest_opt(paths)? {
m.updated_at = ev.ts;
ops.push(ProjectionOp::Manifest(m));
}
Ok(ops)
}
fn reduce_spinoff_rejected(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let id = ProposalId::parse_str(want_str(&events_path, ev, &ev.data, "proposal_id")?)
.map_err(|e| corrupt_id(&events_path, ev, &e))?;
let mut s = match read_spinoff_opt(paths, &id)? {
Some(s) => s,
None => return Ok(vec![]),
};
if matches!(s.status, SpinoffStatus::Approved | SpinoffStatus::Rejected) {
return Ok(vec![]);
}
s.status = SpinoffStatus::Rejected;
s.rejected_reason = ev
.data
.get("reason")
.and_then(Value::as_str)
.map(str::to_string);
s.resolved_at = Some(ev.ts);
let mut ops = vec![ProjectionOp::Spinoff(s)];
if let Some(mut m) = read_manifest_opt(paths)? {
m.updated_at = ev.ts;
ops.push(ProjectionOp::Manifest(m));
}
Ok(ops)
}
fn reduce_child_spawned(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let parent_node_id = ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=child.spawned missing parent `node_id`",
ev.seq
),
})?;
let child_run_id = RunId::parse_str(want_str(&events_path, ev, &ev.data, "child_run_id")?)
.map_err(|e| corrupt_id(&events_path, ev, &e))?;
let child_node_id = NodeId::parse_str(
ev.data
.get("child_node_id")
.and_then(Value::as_str)
.unwrap_or("n-0001"),
)
.map_err(|e| corrupt_id(&events_path, ev, &e))?;
let mut n = match read_node_opt(paths, &parent_node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
let new_ref = ChildRef {
run_id: child_run_id,
node_id: child_node_id,
};
if n.children.iter().any(|c| c == &new_ref) {
return Ok(vec![]);
}
n.children.push(new_ref);
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
fn reduce_supervisor_attached(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let raw = ev
.data
.get("pid")
.and_then(Value::as_i64)
.ok_or_else(|| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=supervisor.attached missing/invalid `pid`",
ev.seq
),
})?;
let pid = i32::try_from(raw).map_err(|_| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=supervisor.attached `pid` out of i32 range: {raw}",
ev.seq
),
})?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
if n.supervisor_pid == Some(pid) {
return Ok(vec![]);
}
n.supervisor_pid = Some(pid);
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
fn reduce_supervisor_cursor_advanced(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let child_run_id = want_str(&events_path, ev, &ev.data, "child_run_id")?;
RunId::parse_str(child_run_id).map_err(|e| corrupt_id(&events_path, ev, &e))?;
let report_seq = ev
.data
.get("report_seq")
.and_then(Value::as_u64)
.ok_or_else(|| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=supervisor.cursor_advanced missing/invalid `report_seq`",
ev.seq
),
})?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
if let Some(prev) = n
.last_processed_report_seq_by_child
.get(child_run_id)
.and_then(Value::as_u64)
{
if report_seq <= prev {
return Ok(vec![]);
}
}
n.last_processed_report_seq_by_child
.insert(child_run_id.to_string(), Value::from(report_seq));
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::Event;
use chrono::Utc;
use tempfile::TempDir;
fn event(run_id: &str) -> Event {
Event {
ts: Utc::now(),
seq: 1,
kind: "run.status".into(),
run_id: RunId::parse_str(run_id).unwrap(),
node_id: None,
idempotency_key: None,
data: serde_json::json!({ "status": "running" }),
}
}
#[test]
fn orchestrator_decision_and_discuss_critical_reduce_to_noop() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let mut created = event(run_id);
created.kind = "run.created".into();
created.data = serde_json::json!({
"kind": "spinoff", "lifecycle": "autonomous", "title": "t"
});
apply_event(&paths, &created).expect("run.created applies");
let manifest_before = std::fs::read(paths.manifest()).unwrap();
for (seq, kind) in [(10u64, "orchestrator.decision"), (11, "discuss.critical")] {
let mut ev = event(run_id);
ev.seq = seq;
ev.kind = kind.into();
ev.data = serde_json::json!({ "summary": "x", "arbitrary": [1, 2, 3] });
let ops = reduce_event_to_ops(&paths, &ev).expect("audit kind reduces cleanly");
assert!(ops.is_empty(), "{kind} must plan no projection ops");
apply_event(&paths, &ev).expect("audit kind applies as no-op");
}
assert_eq!(
std::fs::read(paths.manifest()).unwrap(),
manifest_before,
"audit events must not mutate the manifest"
);
assert!(!paths.nodes_dir().exists(), "no node projection created");
}
fn bootstrap_retry_node(tmp: &TempDir, run_id: &str) -> RunPaths {
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let mut created = event(run_id);
created.kind = "run.created".into();
created.data =
serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
apply_event(&paths, &created).expect("run.created applies");
let mut node = event(run_id);
node.seq = 2;
node.kind = "node.created".into();
node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
node.data = serde_json::json!({
"kind": "spinoff",
"branch": "wt/foo",
"worktree_path": "/tmp/old-wt",
"agent_pid": 111,
});
apply_event(&paths, &node).expect("node.created applies");
paths
}
#[test]
fn node_retry_rewires_node_and_increments_attempts() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = bootstrap_retry_node(&tmp, run_id);
let mut retry = event(run_id);
retry.seq = 3;
retry.kind = "node.retry".into();
retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
retry.data = serde_json::json!({
"attempt": 1,
"reason": "agent-died",
"branch": "wt/foo-r1",
"base_sha": "a".repeat(40),
"worktree_path": "/tmp/new-wt",
"agent_pid": 222,
"tmux_session": "s",
"tmux_window_id": "@9",
});
apply_event(&paths, &retry).expect("node.retry applies");
let n = read_n0001(&paths);
assert_eq!(n.retry_attempts, 1, "attempt bound incremented");
assert_eq!(
n.branch.as_deref(),
Some("wt/foo-r1"),
"rewired to new branch"
);
assert_eq!(n.worktree_path.as_deref(), Some("/tmp/new-wt"));
assert_eq!(n.agent_pid, Some(222), "rewired to new agent pid");
assert_eq!(n.status, Status::Pending, "node returns to pending");
assert!(n.last_report.is_none());
assert_eq!(
n.tmux_identity.as_ref().map(|t| t.window_id.as_str()),
Some("@9"),
"rewired tmux identity"
);
let mut retry2 = event(run_id);
retry2.seq = 4;
retry2.kind = "node.retry".into();
retry2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
retry2.data = serde_json::json!({
"attempt": 2, "reason": "agent-died", "branch": "wt/foo-r2",
"worktree_path": "/tmp/new-wt-2", "agent_pid": 333,
});
apply_event(&paths, &retry2).expect("node.retry applies");
assert_eq!(read_n0001(&paths).retry_attempts, 2);
}
#[test]
fn node_retry_against_terminal_node_is_noop() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = bootstrap_retry_node(&tmp, run_id);
let mut report = event(run_id);
report.seq = 3;
report.kind = "node.report".into();
report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
report.data = serde_json::json!({ "success": true });
apply_event(&paths, &report).expect("node.report applies");
assert_eq!(read_n0001(&paths).status, Status::Done);
let mut retry = event(run_id);
retry.seq = 4;
retry.kind = "node.retry".into();
retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
retry.data = serde_json::json!({
"attempt": 1, "reason": "agent-died", "branch": "wt/foo-r1",
"worktree_path": "/tmp/new-wt", "agent_pid": 222,
});
apply_event(&paths, &retry).expect("node.retry applies as no-op");
let n = read_n0001(&paths);
assert_eq!(n.status, Status::Done, "terminal node not resurrected");
assert_eq!(n.retry_attempts, 0, "no increment against terminal node");
assert_eq!(n.agent_pid, Some(111), "not rewired");
}
#[test]
fn apply_event_rejects_event_from_a_different_run() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let foreign = event("02jxsnap000000000000000000");
let err = apply_event(&paths, &foreign).expect_err("cross-run event must be rejected");
assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
let mine = event(run_id);
apply_event(&paths, &mine).expect("matching run_id must be accepted");
}
#[test]
fn tmux_identity_from_data_reads_qualified_fields() {
let d = serde_json::json!({
"tmux_socket": "/private/tmp/tmux-501/default",
"tmux_session": "octl",
"tmux_window_id": "@42",
});
let id = tmux_identity_from_data(&d).expect("qualified identity");
assert_eq!(id.socket.as_deref(), Some("/private/tmp/tmux-501/default"));
assert_eq!(id.session, "octl");
assert_eq!(id.window_id, "@42");
assert_eq!(id.pane_id, None);
let d2 = serde_json::json!({
"tmux_socket": null,
"tmux_session": "octl",
"tmux_window_id": "@7",
});
let id2 = tmux_identity_from_data(&d2).expect("identity without socket");
assert_eq!(id2.socket, None);
assert_eq!(id2.window_id, "@7");
let d3 = serde_json::json!({
"tmux_session": "octl",
"tmux_window_id": "@42",
"tmux_pane_id": "%7",
});
let id3 = tmux_identity_from_data(&d3).expect("identity with pane");
assert_eq!(id3.pane_id.as_deref(), Some("%7"));
assert_eq!(id3.capture_target(), "%7");
let d4 = serde_json::json!({
"tmux_session": "octl",
"tmux_window_id": "@42",
"tmux_pane_id": null,
});
let id4 = tmux_identity_from_data(&d4).expect("identity with null pane");
assert_eq!(id4.pane_id, None);
assert_eq!(id4.capture_target(), "@42");
}
#[test]
fn tmux_identity_from_data_back_compat_is_none() {
let legacy = serde_json::json!({ "tmux_window": "🚀 wt/x" });
assert!(tmux_identity_from_data(&legacy).is_none());
let partial = serde_json::json!({ "tmux_window_id": "@42" });
assert!(tmux_identity_from_data(&partial).is_none());
}
#[test]
fn node_created_populates_tmux_identity() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let mut ev = event(run_id);
ev.seq = 2;
ev.kind = "node.created".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.data = serde_json::json!({
"kind": "spinoff",
"tmux_window": "🚀 wt/x",
"tmux_socket": "/private/tmp/tmux-501/default",
"tmux_session": "octl",
"tmux_window_id": "@42",
});
apply_event(&paths, &ev).expect("node.created applies");
let n = read_node_opt(&paths, &NodeId::parse_str("n-0001").unwrap())
.unwrap()
.unwrap();
let id = n.tmux_identity.expect("qualified identity recorded");
assert_eq!(id.session, "octl");
assert_eq!(id.window_id, "@42");
assert_eq!(n.tmux_window.as_deref(), Some("🚀 wt/x"));
let run2 = "02jxsnap000000000000000000";
let rid2 = RunId::parse_str(run2).unwrap();
let dir2 = crate::run_dir(tmp.path(), &rid2);
std::fs::create_dir_all(&dir2).unwrap();
let paths2 = RunPaths::new(dir2, run2).unwrap();
let mut ev2 = event(run2);
ev2.seq = 2;
ev2.kind = "node.created".into();
ev2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev2.data = serde_json::json!({ "kind": "spinoff", "tmux_window": "🚀 wt/y" });
apply_event(&paths2, &ev2).expect("legacy node.created applies");
let n2 = read_node_opt(&paths2, &NodeId::parse_str("n-0001").unwrap())
.unwrap()
.unwrap();
assert!(n2.tmux_identity.is_none());
assert_eq!(n2.tmux_window.as_deref(), Some("🚀 wt/y"));
}
fn seed_run_with_node(tmp: &TempDir, run_id: &str) -> RunPaths {
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let mut created = event(run_id);
created.kind = "run.created".into();
created.data = serde_json::json!({
"kind": "spinoff", "lifecycle": "autonomous", "title": "t"
});
apply_event(&paths, &created).expect("run.created applies");
let mut node = event(run_id);
node.seq = 2;
node.kind = "node.created".into();
node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
node.data = serde_json::json!({ "kind": "spinoff" });
apply_event(&paths, &node).expect("node.created applies");
paths
}
fn read_n0001(paths: &RunPaths) -> Node {
read_node_opt(paths, &NodeId::parse_str("n-0001").unwrap())
.unwrap()
.unwrap()
}
#[test]
fn supervisor_attached_sets_supervisor_pid() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
assert_eq!(read_n0001(&paths).supervisor_pid, None);
let mut ev = event(run_id);
ev.seq = 3;
ev.kind = "supervisor.attached".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.data = serde_json::json!({ "pid": 47820 });
apply_event(&paths, &ev).expect("supervisor.attached applies");
assert_eq!(read_n0001(&paths).supervisor_pid, Some(47820));
}
#[test]
fn supervisor_attached_latest_wins_and_idempotent_on_replay() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
let mut ev = event(run_id);
ev.kind = "supervisor.attached".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.seq = 3;
ev.data = serde_json::json!({ "pid": 100 });
apply_event(&paths, &ev).expect("first attach applies");
assert_eq!(read_n0001(&paths).supervisor_pid, Some(100));
ev.seq = 4;
ev.data = serde_json::json!({ "pid": 200 });
apply_event(&paths, &ev).expect("second attach applies");
let after_second = read_n0001(&paths);
assert_eq!(after_second.supervisor_pid, Some(200));
let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
assert!(ops.is_empty(), "re-applying same pid must plan no ops");
apply_event(&paths, &ev).expect("replay applies as no-op");
assert_eq!(read_n0001(&paths).updated_at, after_second.updated_at);
}
#[test]
fn supervisor_cursor_advanced_sets_report_cursor() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
let child = "02jxsnap000000000000000000";
let mut ev = event(run_id);
ev.seq = 3;
ev.kind = "supervisor.cursor_advanced".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.data = serde_json::json!({ "child_run_id": child, "report_seq": 7 });
apply_event(&paths, &ev).expect("cursor_advanced applies");
let n = read_n0001(&paths);
assert_eq!(
n.last_processed_report_seq_by_child.get(child),
Some(&Value::from(7u64))
);
}
#[test]
fn supervisor_cursor_advanced_is_monotonic_and_idempotent() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
let child_a = "02jxsnap000000000000000000";
let child_b = "03jxsnap000000000000000000";
let mut ev = event(run_id);
ev.kind = "supervisor.cursor_advanced".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.seq = 3;
ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 5 });
apply_event(&paths, &ev).expect("seq 5 applies");
let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
assert!(ops.is_empty(), "re-applying same cursor must plan no ops");
ev.seq = 4;
ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 3 });
let ops = reduce_event_to_ops(&paths, &ev).expect("older seq reduces cleanly");
assert!(ops.is_empty(), "older seq must plan no ops");
apply_event(&paths, &ev).expect("older seq applies as no-op");
assert_eq!(
read_n0001(&paths)
.last_processed_report_seq_by_child
.get(child_a),
Some(&Value::from(5u64))
);
ev.seq = 5;
ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 9 });
apply_event(&paths, &ev).expect("higher seq applies");
ev.seq = 6;
ev.data = serde_json::json!({ "child_run_id": child_b, "report_seq": 1 });
apply_event(&paths, &ev).expect("second child applies");
let n = read_n0001(&paths);
assert_eq!(
n.last_processed_report_seq_by_child.get(child_a),
Some(&Value::from(9u64))
);
assert_eq!(
n.last_processed_report_seq_by_child.get(child_b),
Some(&Value::from(1u64))
);
}
#[test]
fn supervisor_state_events_reject_malformed_payloads() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
let nid = Some(NodeId::parse_str("n-0001").unwrap());
let mut ev = event(run_id);
ev.seq = 3;
ev.kind = "supervisor.attached".into();
ev.node_id = nid.clone();
ev.data = serde_json::json!({});
assert!(matches!(
reduce_event_to_ops(&paths, &ev),
Err(Error::CorruptEventLog { .. })
));
ev.node_id = None;
ev.data = serde_json::json!({ "pid": 1 });
assert!(matches!(
reduce_event_to_ops(&paths, &ev),
Err(Error::CorruptEventLog { .. })
));
let mut ev2 = event(run_id);
ev2.seq = 4;
ev2.kind = "supervisor.cursor_advanced".into();
ev2.node_id = nid.clone();
ev2.data = serde_json::json!({ "child_run_id": "../etc", "report_seq": 1 });
assert!(matches!(
reduce_event_to_ops(&paths, &ev2),
Err(Error::CorruptEventLog { .. })
));
ev2.data = serde_json::json!({ "child_run_id": "02jxsnap000000000000000000" });
assert!(matches!(
reduce_event_to_ops(&paths, &ev2),
Err(Error::CorruptEventLog { .. })
));
}
#[cfg(unix)]
fn projection_inodes(paths: &RunPaths) -> std::collections::BTreeMap<PathBuf, u64> {
use std::os::unix::fs::MetadataExt;
let mut consider = vec![paths.manifest()];
for dir in [
paths.nodes_dir(),
paths.discussions_dir(),
paths.spinoffs_dir(),
] {
if let Ok(rd) = std::fs::read_dir(&dir) {
for ent in rd.flatten() {
let p = ent.path();
if p.extension().and_then(|s| s.to_str()) == Some("json") {
consider.push(p);
}
}
}
}
let mut map = std::collections::BTreeMap::new();
for p in consider {
if let Ok(md) = std::fs::symlink_metadata(&p) {
if md.file_type().is_file() {
map.insert(p, md.ino());
}
}
}
map
}
#[cfg(unix)]
fn assert_plan_matches_apply(paths: &RunPaths, ev: &Event, expect_writes: bool) {
use std::collections::BTreeSet;
let before = projection_inodes(paths);
let planned: BTreeSet<PathBuf> = plan_projections(paths, ev)
.unwrap_or_else(|e| panic!("plan_projections({}) errored: {e:?}", ev.kind))
.into_iter()
.collect();
apply_event(paths, ev)
.unwrap_or_else(|e| panic!("apply_event({}) errored: {e:?}", ev.kind));
let after = projection_inodes(paths);
let touched: BTreeSet<PathBuf> = after
.iter()
.filter(|(p, ino)| before.get(*p) != Some(*ino))
.map(|(p, _)| p.clone())
.collect();
assert_eq!(
planned, touched,
"kind={}: plan_projections must name exactly the files apply_event writes",
ev.kind
);
if expect_writes {
assert!(
!touched.is_empty(),
"kind={}: expected this event to write at least one projection",
ev.kind
);
}
}
#[cfg(unix)]
#[test]
fn plan_projections_matches_apply_for_every_kind() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let nid = || Some(NodeId::parse_str("n-0001").unwrap());
let disc_id = "d-pqrstuvwxy";
let prop_id = "s-spinaaaaaa";
let child = "02jxsnap000000000000000000";
let mut next_seq = 0u64;
let mut at = |kind: &str, node_id, data| {
next_seq += 1;
Event {
ts: Utc::now(),
seq: next_seq,
kind: kind.into(),
run_id: rid.clone(),
node_id,
idempotency_key: None,
data,
}
};
assert_plan_matches_apply(
&paths,
&at(
"run.created",
None,
serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"run.status",
None,
serde_json::json!({ "status": "running" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"node.created",
nid(),
serde_json::json!({ "kind": "spinoff" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"node.status",
nid(),
serde_json::json!({ "status": "running" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"discussion.opened",
None,
serde_json::json!({ "discussion_id": disc_id, "node_id": "n-0001", "topic": "t" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"discussion.resolved",
None,
serde_json::json!({ "discussion_id": disc_id, "resolution": "keep" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"spinoff.proposed",
None,
serde_json::json!({
"proposal_id": prop_id, "node_id": "n-0001",
"proposed_title": "p", "proposed_kind": "spinoff"
}),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"spinoff.approved",
None,
serde_json::json!({ "proposal_id": prop_id, "issue_slug": "x" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"supervisor.attached",
nid(),
serde_json::json!({ "pid": 4242 }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"supervisor.cursor_advanced",
nid(),
serde_json::json!({ "child_run_id": child, "report_seq": 3 }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"child.spawned",
nid(),
serde_json::json!({ "child_run_id": child, "child_node_id": "n-0001" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at("node.report", nid(), serde_json::json!({ "success": true })),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"node.status",
nid(),
serde_json::json!({ "status": "failed" }),
),
false,
);
for kind in [
"supervisor.exited",
"orchestrator.decision",
"discuss.critical",
"cleanup.window_missing",
] {
assert_plan_matches_apply(&paths, &at(kind, None, serde_json::json!({})), false);
}
let prop2 = "s-spinbbbbbb";
assert_plan_matches_apply(
&paths,
&at(
"spinoff.proposed",
None,
serde_json::json!({
"proposal_id": prop2, "node_id": "n-0001",
"proposed_title": "p2", "proposed_kind": "spinoff"
}),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"spinoff.rejected",
None,
serde_json::json!({ "proposal_id": prop2, "reason": "no" }),
),
true,
);
}
}