use serde_json::{json, Value};
use tracing::{info, warn};
use octl_core::{
append_and_apply_unlocked, find_prior_with_key, read_node_opt, NodeId, RunLock, RunPaths,
Status,
};
use crate::run::from_core;
const DEFAULT_NODE_ID: &str = "n-0001";
const SUMMARY_MAX_CHARS: usize = 4096;
const TITLE_MAX_CHARS: usize = 512;
#[must_use]
pub fn maybe_fire(
paths: &RunPaths,
run_id: &str,
notify_cmd: Option<&str>,
status: Status,
kind: &str,
title: &str,
) -> bool {
let Some(cmd) = notify_cmd else {
return true;
};
if !status.is_terminal() {
return true;
}
let summary = env_safe(&read_summary(paths).unwrap_or_default(), SUMMARY_MAX_CHARS);
let title = env_safe(title, TITLE_MAX_CHARS);
let status_str = status_kebab(status);
let key = format!("supervisor-notify:{run_id}");
let guard = match RunLock::acquire(&paths.lock()) {
Ok(g) => g,
Err(e) => {
warn!(
target: "orchestratectl::supervise",
run_id = %run_id,
error = %e,
"could not lock run to fire notify hook; will retry on a later tick"
);
return false;
}
};
let lock = guard.witness();
match find_prior_with_key(&lock, paths, "run.notified", &key) {
Ok(Some(_)) => {
drop(guard);
return true;
}
Ok(None) => { }
Err(e) => {
warn!(
target: "orchestratectl::supervise",
run_id = %run_id,
error = %e,
"could not scan for run.notified marker; will retry on a later tick"
);
drop(guard);
return false;
}
}
spawn_hook(cmd, run_id, status_str, &summary, kind, &title);
if let Err(e) = append_and_apply_unlocked(
&lock,
paths,
"run.notified",
None,
Some(&key),
json!({ "status": status_str }),
) {
warn!(
target: "orchestratectl::supervise",
run_id = %run_id,
error = %e,
"notify hook fired but recording the run.notified marker failed (a restart may re-fire)"
);
}
drop(guard);
true
}
fn spawn_hook(cmd: &str, run_id: &str, status: &str, summary: &str, kind: &str, title: &str) {
let mut command = std::process::Command::new("sh");
command
.arg("-c")
.arg(cmd)
.env("OCTL_RUN_ID", run_id)
.env("OCTL_STATUS", status)
.env("OCTL_SUMMARY", summary)
.env("OCTL_RUN_KIND", kind)
.env("OCTL_RUN_TITLE", title)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
match command.spawn() {
Ok(child) => {
std::thread::spawn(move || {
let mut child = child;
let _ = child.wait();
});
info!(
target: "orchestratectl::supervise",
run_id = %run_id,
status = %status,
"fired run completion notify hook"
);
}
Err(e) => {
warn!(
target: "orchestratectl::supervise",
run_id = %run_id,
error = %e,
"run completion notify hook failed to spawn (not retried; marker already recorded)"
);
}
}
}
fn env_safe(s: &str, max_chars: usize) -> String {
let filtered: String = s.chars().filter(|&c| c != '\0').collect();
if filtered.chars().count() > max_chars {
let mut out: String = filtered.chars().take(max_chars).collect();
out.push('…');
out
} else {
filtered
}
}
fn read_summary(paths: &RunPaths) -> Option<String> {
let node_id = NodeId::parse_str(DEFAULT_NODE_ID).expect("DEFAULT_NODE_ID is a valid node id");
RunLock::with_shared_lock(&paths.lock(), || {
Ok(read_node_opt(paths, &node_id)?.and_then(|n| n.last_report))
})
.map_err(from_core)
.ok()
.flatten()
.as_ref()
.and_then(|r: &Value| r.get("summary"))
.and_then(Value::as_str)
.map(str::to_string)
}
fn status_kebab(status: Status) -> &'static str {
match status {
Status::Done => "done",
Status::Failed => "failed",
Status::Cancelled => "cancelled",
Status::Pending => "pending",
Status::Running => "running",
Status::Blocked => "blocked",
}
}
#[cfg(test)]
mod tests {
use super::*;
use octl_core::append_and_apply_event;
use std::time::{Duration, Instant};
use tempfile::TempDir;
const RID: &str = "01jxwd0000000000000000000w";
fn terminal_run(tmp: &TempDir, summary: &str) -> RunPaths {
let dir = tmp.path().join(RID);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, RID).unwrap();
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
append_and_apply_event(
&paths,
"node.created",
Some(&NodeId::parse_str(DEFAULT_NODE_ID).unwrap()),
None,
json!({ "kind": "spinoff" }),
)
.unwrap();
append_and_apply_event(
&paths,
"node.report",
Some(&NodeId::parse_str(DEFAULT_NODE_ID).unwrap()),
None,
json!({
"success": true,
"failed": false,
"cancelled": false,
"summary": summary,
"discussion_items": [],
"spinoff_proposals": [],
"wrap_up_recommendations": [],
}),
)
.unwrap();
append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "done" }),
)
.unwrap();
paths
}
fn notified_count(paths: &RunPaths) -> usize {
std::fs::read_to_string(paths.events())
.unwrap()
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.filter(|v| v.get("kind").and_then(Value::as_str) == Some("run.notified"))
.count()
}
fn wait_for_content(path: &std::path::Path, expected: &str) -> bool {
let deadline = Instant::now() + Duration::from_secs(3);
loop {
if std::fs::read_to_string(path).is_ok_and(|got| got == expected) {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn fires_hook_with_completion_env() {
let tmp = TempDir::new().unwrap();
let paths = terminal_run(&tmp, "did the thing");
let out = tmp.path().join("hook-out.txt");
let cmd = format!(
"printf '%s|%s|%s|%s' \"$OCTL_RUN_ID\" \"$OCTL_STATUS\" \"$OCTL_SUMMARY\" \"$OCTL_RUN_KIND\" > {}",
out.display()
);
assert!(
maybe_fire(&paths, RID, Some(&cmd), Status::Done, "spinoff", "t"),
"a fired hook settles the notify state"
);
let expected = format!("{RID}|done|did the thing|spinoff");
assert!(
wait_for_content(&out, &expected),
"hook must run and write the completion env into its output file"
);
assert_eq!(notified_count(&paths), 1, "exactly one marker recorded");
}
#[test]
fn repeated_ticks_dedup_via_marker() {
let tmp = TempDir::new().unwrap();
let paths = terminal_run(&tmp, "s");
let counter = tmp.path().join("counter.txt");
let cmd = format!("printf 'x' >> {}", counter.display());
assert!(maybe_fire(
&paths,
RID,
Some(&cmd),
Status::Done,
"spinoff",
"t"
));
assert!(wait_for_content(&counter, "x"));
assert_eq!(notified_count(&paths), 1);
assert!(maybe_fire(
&paths,
RID,
Some(&cmd),
Status::Done,
"spinoff",
"t"
));
assert!(maybe_fire(
&paths,
RID,
Some(&cmd),
Status::Done,
"spinoff",
"t"
));
std::thread::sleep(Duration::from_millis(200));
assert_eq!(notified_count(&paths), 1, "marker recorded once");
assert_eq!(
std::fs::read_to_string(&counter).unwrap(),
"x",
"the hook ran once despite repeated ticks (marker dedup)"
);
}
#[test]
fn preexisting_marker_suppresses_refire() {
let tmp = TempDir::new().unwrap();
let paths = terminal_run(&tmp, "s");
let key = format!("supervisor-notify:{RID}");
append_and_apply_event(
&paths,
"run.notified",
None,
Some(&key),
json!({ "status": "done" }),
)
.unwrap();
let out = tmp.path().join("should-not-exist");
let cmd = format!("touch {}", out.display());
assert!(maybe_fire(
&paths,
RID,
Some(&cmd),
Status::Done,
"spinoff",
"t"
));
std::thread::sleep(Duration::from_millis(150));
assert!(
!out.exists(),
"a pre-existing marker must suppress the hook"
);
assert_eq!(notified_count(&paths), 1, "no second marker recorded");
}
#[test]
fn records_marker_after_firing() {
let tmp = TempDir::new().unwrap();
let paths = terminal_run(&tmp, "s");
assert_eq!(notified_count(&paths), 0, "no marker before firing");
assert!(maybe_fire(
&paths,
RID,
Some("true"),
Status::Done,
"spinoff",
"t"
));
assert_eq!(notified_count(&paths), 1, "marker recorded after firing");
}
#[test]
fn no_notify_cmd_is_a_noop() {
let tmp = TempDir::new().unwrap();
let paths = terminal_run(&tmp, "s");
assert!(
maybe_fire(&paths, RID, None, Status::Done, "spinoff", "t"),
"no hook registered is a settled no-op"
);
assert_eq!(
notified_count(&paths),
0,
"a run with no --notify records no marker and runs nothing"
);
}
#[test]
fn non_terminal_status_never_fires_or_marks() {
let tmp = TempDir::new().unwrap();
let paths = terminal_run(&tmp, "s");
let out = tmp.path().join("should-not-exist");
let cmd = format!("touch {}", out.display());
assert!(maybe_fire(
&paths,
RID,
Some(&cmd),
Status::Running,
"spinoff",
"t"
));
std::thread::sleep(Duration::from_millis(150));
assert!(
!out.exists(),
"hook must not fire for a non-terminal status"
);
assert_eq!(
notified_count(&paths),
0,
"no marker for a non-terminal run"
);
}
#[test]
fn returns_true_on_idempotent_replay() {
let tmp = TempDir::new().unwrap();
let paths = terminal_run(&tmp, "s");
let cmd = "true".to_string();
assert!(maybe_fire(
&paths,
RID,
Some(&cmd),
Status::Done,
"spinoff",
"t"
));
assert!(
maybe_fire(&paths, RID, Some(&cmd), Status::Done, "spinoff", "t"),
"an already-fired hook reports settled"
);
assert_eq!(notified_count(&paths), 1);
}
#[test]
fn env_safe_strips_nul_and_bounds_length() {
assert_eq!(env_safe("a\0b\0c", 100), "abc");
assert_eq!(env_safe("short", 100), "short");
let long = "x".repeat(50);
let got = env_safe(&long, 10);
assert_eq!(got.chars().filter(|&c| c == 'x').count(), 10);
assert!(got.ends_with('…'));
}
#[test]
fn read_summary_returns_report_summary() {
let tmp = TempDir::new().unwrap();
let paths = terminal_run(&tmp, "the summary line");
assert_eq!(read_summary(&paths).as_deref(), Some("the summary line"));
}
}