use octl_core::{
append_and_apply_event, ensure_root, read_manifest, read_node_opt, run_dir, Error, Node,
NodeId, RunId, RunPaths, Status,
};
use serde_json::{json, Value};
use tempfile::TempDir;
struct Harness {
_tmp: TempDir,
paths: RunPaths,
}
impl Harness {
fn new() -> Self {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
ensure_root(root).unwrap();
let run_id = "01jxsnap000000000000000000".to_string();
let dir = run_dir(root, &RunId::parse_str(&run_id).unwrap());
std::fs::create_dir_all(&dir).unwrap();
Self {
paths: RunPaths::new(dir, run_id).unwrap(),
_tmp: tmp,
}
}
fn append(&mut self, kind: &str, node_id: Option<&str>, data: Value) {
self.try_append(kind, node_id, data).unwrap();
}
fn try_append(
&mut self,
kind: &str,
node_id: Option<&str>,
data: Value,
) -> octl_core::Result<()> {
let node_id = node_id.map(|s| NodeId::parse_str(s).unwrap());
append_and_apply_event(&self.paths, kind, node_id.as_ref(), None, data).map(|_| ())
}
fn events_len(&self) -> usize {
octl_core::read_all_events(&self.paths.events())
.expect("events.jsonl is poison-free and re-readable")
.len()
}
fn node(&self, node_id: &str) -> Node {
let nid = NodeId::parse_str(node_id).unwrap();
read_node_opt(&self.paths, &nid).unwrap().unwrap()
}
fn bootstrap_node(&mut self) {
self.append(
"run.created",
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "fixture" }),
);
self.append("run.status", None, json!({ "status": "running" }));
self.append(
"node.created",
Some("n-0001"),
json!({ "kind": "spinoff", "task": "do the thing" }),
);
self.append(
"node.status",
Some("n-0001"),
json!({ "status": "running" }),
);
}
}
#[test]
fn cancelled_node_ignores_late_success_report() {
let mut h = Harness::new();
h.bootstrap_node();
let cancel_report = json!({
"success": false,
"cancelled": true,
"reason": "cancelled by user",
"summary": "Run cancelled before agent reported.",
});
h.append("node.report", Some("n-0001"), cancel_report.clone());
assert_eq!(h.node("n-0001").status, Status::Cancelled);
h.append("node.report", Some("n-0001"), json!({ "success": true }));
let n = h.node("n-0001");
assert_eq!(n.status, Status::Cancelled, "cancel must stick");
assert_eq!(
n.last_report,
Some(cancel_report),
"late report must not overwrite last_report"
);
}
#[test]
fn done_node_ignores_late_failure_report() {
let mut h = Harness::new();
h.bootstrap_node();
let success_report = json!({ "success": true, "summary": "all good" });
h.append("node.report", Some("n-0001"), success_report.clone());
assert_eq!(h.node("n-0001").status, Status::Done);
h.append("node.report", Some("n-0001"), json!({ "success": false }));
let n = h.node("n-0001");
assert_eq!(n.status, Status::Done, "done must stick");
assert_eq!(n.last_report, Some(success_report));
}
#[test]
fn bare_report_payload_is_corrupt() {
let mut h = Harness::new();
h.bootstrap_node();
let before = h.events_len();
let err = h
.try_append("node.report", Some("n-0001"), json!({}))
.unwrap_err();
assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
let n = h.node("n-0001");
assert_eq!(n.status, Status::Running);
assert_eq!(n.last_report, None);
assert_eq!(h.events_len(), before, "rejected event must not be written");
}
#[test]
fn success_and_cancelled_both_true_is_corrupt() {
let mut h = Harness::new();
h.bootstrap_node();
let before = h.events_len();
let err = h
.try_append(
"node.report",
Some("n-0001"),
json!({ "success": true, "cancelled": true, "reason": "x" }),
)
.unwrap_err();
assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
let n = h.node("n-0001");
assert_eq!(n.status, Status::Running);
assert_eq!(n.last_report, None);
assert_eq!(h.events_len(), before, "rejected event must not be written");
}
#[test]
fn run_cancel_then_late_report_keeps_cancel() {
let mut h = Harness::new();
h.bootstrap_node();
h.append("run.status", None, json!({ "status": "cancelled" }));
h.append(
"node.report",
Some("n-0001"),
json!({ "success": false, "cancelled": true, "reason": "cancelled by user" }),
);
assert_eq!(read_manifest(&h.paths).unwrap().status, Status::Cancelled);
assert_eq!(h.node("n-0001").status, Status::Cancelled);
h.append("node.report", Some("n-0001"), json!({ "success": true }));
h.append("run.status", None, json!({ "status": "running" }));
assert_eq!(
read_manifest(&h.paths).unwrap().status,
Status::Cancelled,
"run cancel must stick"
);
assert_eq!(
h.node("n-0001").status,
Status::Cancelled,
"node cancel must stick"
);
}
#[test]
fn failed_node_adopts_late_explicit_merge_report() {
let mut h = Harness::new();
h.bootstrap_node();
let agent_died = json!({
"success": false, "failed": true, "reason": "agent-died",
"summary": "Agent stopped responding.",
});
h.append("node.report", Some("n-0001"), agent_died);
assert_eq!(h.node("n-0001").status, Status::Failed);
let merge = json!({
"success": true, "summary": "merged wt/foo into main", "via": "explicit-merge",
});
h.append("node.report", Some("n-0001"), merge.clone());
let n = h.node("n-0001");
assert_eq!(
n.status,
Status::Done,
"a confirmed late explicit merge reconciles the watchdog-FAILED node to Done"
);
assert_eq!(
n.last_report,
Some(merge),
"the merge report must be adopted onto the projection so teardown is warranted"
);
}
#[test]
fn failed_node_ignores_late_plain_success_report() {
let mut h = Harness::new();
h.bootstrap_node();
let agent_died = json!({ "success": false, "failed": true, "reason": "agent-died" });
h.append("node.report", Some("n-0001"), agent_died.clone());
assert_eq!(h.node("n-0001").status, Status::Failed);
h.append("node.report", Some("n-0001"), json!({ "success": true }));
let n = h.node("n-0001");
assert_eq!(
n.status,
Status::Failed,
"a non-merge late report stays dead"
);
assert_eq!(
n.last_report,
Some(agent_died),
"a non-merge late report must not overwrite last_report"
);
}
#[test]
fn cancelled_node_ignores_late_explicit_merge_report() {
let mut h = Harness::new();
h.bootstrap_node();
let cancel = json!({ "success": false, "cancelled": true, "reason": "cancelled by user" });
h.append("node.report", Some("n-0001"), cancel.clone());
assert_eq!(h.node("n-0001").status, Status::Cancelled);
h.append(
"node.report",
Some("n-0001"),
json!({ "success": true, "via": "explicit-merge", "summary": "merged" }),
);
let n = h.node("n-0001");
assert_eq!(
n.status,
Status::Cancelled,
"a deliberate cancel is not overridden by a later merge"
);
assert_eq!(
n.last_report,
Some(cancel),
"the cancel report is preserved"
);
}
#[test]
fn adopted_explicit_merge_replay_is_idempotent() {
let mut h = Harness::new();
h.bootstrap_node();
h.append(
"node.report",
Some("n-0001"),
json!({ "success": false, "failed": true, "reason": "agent-died" }),
);
let merge = json!({ "success": true, "via": "explicit-merge", "summary": "merged" });
h.append("node.report", Some("n-0001"), merge.clone());
let after_adopt = h.node("n-0001");
assert_eq!(after_adopt.status, Status::Done);
h.append("node.report", Some("n-0001"), merge.clone());
let after_replay = h.node("n-0001");
assert_eq!(after_replay.status, Status::Done);
assert_eq!(after_replay.last_report, Some(merge));
assert_eq!(
after_replay.updated_at, after_adopt.updated_at,
"re-folding the same adopted report must not churn updated_at"
);
}
#[test]
fn done_node_adopts_explicit_merge_marker() {
let mut h = Harness::new();
h.bootstrap_node();
let first = json!({ "success": true, "summary": "work done, not yet merged" });
h.append("node.report", Some("n-0001"), first);
assert_eq!(h.node("n-0001").status, Status::Done);
let merge = json!({ "success": true, "via": "explicit-merge", "summary": "now merged" });
h.append("node.report", Some("n-0001"), merge.clone());
let n = h.node("n-0001");
assert_eq!(n.status, Status::Done);
assert_eq!(
n.last_report,
Some(merge),
"the merge marker must be adopted so any_node_merged_explicitly warrants teardown"
);
}
#[test]
fn malformed_explicit_merge_against_terminal_node_is_not_adopted() {
let mut h = Harness::new();
h.bootstrap_node();
let agent_died = json!({ "success": false, "failed": true, "reason": "agent-died" });
h.append("node.report", Some("n-0001"), agent_died.clone());
assert_eq!(h.node("n-0001").status, Status::Failed);
h.try_append(
"node.report",
Some("n-0001"),
json!({ "success": true, "via": "explicit-merge", "cancelled": "true" }),
)
.expect("a malformed dead report against a terminal node is a clean no-op, not an error");
let n = h.node("n-0001");
assert_eq!(n.status, Status::Failed, "malformed merge must not adopt");
assert_eq!(
n.last_report,
Some(agent_died),
"malformed merge must not overwrite last_report"
);
}
#[test]
fn node_status_terminal_guard() {
let mut h = Harness::new();
h.bootstrap_node();
h.append("node.report", Some("n-0001"), json!({ "success": true }));
assert_eq!(h.node("n-0001").status, Status::Done);
h.append(
"node.status",
Some("n-0001"),
json!({ "status": "running" }),
);
assert_eq!(
h.node("n-0001").status,
Status::Done,
"terminal node must not transition back to running"
);
}
#[test]
fn run_status_terminal_guard() {
let mut h = Harness::new();
h.append(
"run.created",
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "fixture" }),
);
h.append("run.status", None, json!({ "status": "running" }));
h.append("run.status", None, json!({ "status": "done" }));
assert_eq!(read_manifest(&h.paths).unwrap().status, Status::Done);
h.append("run.status", None, json!({ "status": "running" }));
assert_eq!(
read_manifest(&h.paths).unwrap().status,
Status::Done,
"terminal run must not transition back to running"
);
}
#[test]
fn conflicting_terminal_transition_is_noop() {
let mut h = Harness::new();
h.bootstrap_node();
h.append("node.report", Some("n-0001"), json!({ "success": true }));
assert_eq!(h.node("n-0001").status, Status::Done);
h.append(
"node.status",
Some("n-0001"),
json!({ "status": "cancelled" }),
);
assert_eq!(
h.node("n-0001").status,
Status::Done,
"a conflicting terminal status must not overwrite the settled one"
);
}
#[test]
fn non_boolean_success_is_corrupt() {
let mut h = Harness::new();
h.bootstrap_node();
let before = h.events_len();
let err = h
.try_append("node.report", Some("n-0001"), json!({ "success": "true" }))
.unwrap_err();
assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
assert_eq!(h.node("n-0001").status, Status::Running);
assert_eq!(h.events_len(), before, "rejected event must not be written");
}
#[test]
fn non_boolean_cancelled_is_corrupt() {
let mut h = Harness::new();
h.bootstrap_node();
let before = h.events_len();
let err = h
.try_append(
"node.report",
Some("n-0001"),
json!({ "success": false, "cancelled": "true" }),
)
.unwrap_err();
assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
assert_eq!(h.node("n-0001").status, Status::Running);
assert_eq!(h.events_len(), before, "rejected event must not be written");
}
#[test]
fn corrupt_report_against_terminal_node_is_noop() {
let mut h = Harness::new();
h.bootstrap_node();
let report = json!({ "success": true, "summary": "done" });
h.append("node.report", Some("n-0001"), report.clone());
assert_eq!(h.node("n-0001").status, Status::Done);
h.try_append("node.report", Some("n-0001"), json!({}))
.expect("malformed report against terminal node must be a no-op");
let n = h.node("n-0001");
assert_eq!(n.status, Status::Done);
assert_eq!(n.last_report, Some(report), "last_report must be untouched");
}