use chrono::{DateTime, Utc};
use serde::Serialize;
use octl_core::{Manifest, NodeId, RunId, RunPaths};
use super::{kind_kebab, lifecycle_kebab, status_kebab};
use crate::supervise::pid_file;
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum SupervisorState {
Alive,
Dead,
NotRecorded,
Unreadable,
Unknown,
}
impl SupervisorState {
fn is_alive(self) -> bool {
matches!(self, SupervisorState::Alive)
}
}
#[derive(Serialize)]
pub struct SupervisorView {
pub pid: Option<u32>,
pub state: SupervisorState,
pub alive: bool,
}
impl SupervisorView {
fn new(pid: Option<u32>, state: SupervisorState) -> Self {
Self {
pid,
state,
alive: state.is_alive(),
}
}
pub fn probe(paths: &RunPaths) -> Self {
match pid_file::classify_pid_record(&paths.supervisor_pid()) {
pid_file::PidRecord::Present { pid, start_time } => {
let state = if pid_file::pid_live_with_identity(pid, start_time) {
SupervisorState::Alive
} else {
SupervisorState::Dead
};
Self::new(Some(pid), state)
}
pid_file::PidRecord::Absent => Self::new(None, SupervisorState::NotRecorded),
pid_file::PidRecord::Unreadable => Self::new(None, SupervisorState::Unreadable),
}
}
fn unknown() -> Self {
Self::new(None, SupervisorState::Unknown)
}
pub fn presumed_working(&self) -> bool {
!matches!(
self.state,
SupervisorState::Dead | SupervisorState::NotRecorded
)
}
}
#[derive(Serialize)]
pub struct ManifestView<'a> {
pub schema_version: u32,
pub run_id: &'a RunId,
pub kind: &'static str,
pub lifecycle: &'static str,
pub title: &'a str,
pub status: &'static str,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub source_repo: Option<&'a str>,
pub source_branch: Option<&'a str>,
pub worktree_root: Option<&'a str>,
pub harness: Option<&'a str>,
pub node_count: u32,
pub parent_run_id: Option<&'a RunId>,
pub parent_node_id: Option<&'a NodeId>,
}
impl<'a> From<&'a Manifest> for ManifestView<'a> {
fn from(m: &'a Manifest) -> Self {
Self {
schema_version: m.schema_version,
run_id: &m.run_id,
kind: kind_kebab(m.kind),
lifecycle: lifecycle_kebab(m.lifecycle),
title: &m.title,
status: status_kebab(m.status),
created_at: m.created_at,
updated_at: m.updated_at,
source_repo: m.source_repo.as_deref(),
source_branch: m.source_branch.as_deref(),
worktree_root: m.worktree_root.as_deref(),
harness: m.harness.as_deref(),
node_count: m.node_count,
parent_run_id: m.parent_run_id.as_ref(),
parent_node_id: m.parent_node_id.as_ref(),
}
}
}
#[allow(clippy::struct_excessive_bools)] #[derive(Serialize)]
pub struct RunSummary {
pub run_id: String,
pub kind: String,
pub lifecycle: &'static str,
pub status: String,
pub title: String,
pub created_at: DateTime<Utc>,
pub source_branch: Option<String>,
pub worktree_path: Option<String>,
pub harness: Option<String>,
pub node_count: u32,
pub supervisor: SupervisorView,
pub stalled: bool,
pub stillborn: bool,
pub attention_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub attention: Option<crate::run::attention::AttentionView>,
pub awaiting_input: bool,
pub open_discussion_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub awaiting_input_detail: Option<crate::run::awaiting_input::AwaitingInputView>,
}
impl RunSummary {
#[must_use]
pub fn with_supervisor(mut self, supervisor: SupervisorView) -> Self {
self.supervisor = supervisor;
self
}
#[must_use]
pub fn with_worktree_path(mut self, worktree_path: Option<String>) -> Self {
self.worktree_path = worktree_path;
self
}
#[must_use]
pub fn with_stalled(mut self, stalled: bool) -> Self {
self.stalled = stalled;
self
}
#[must_use]
pub fn with_stillborn(mut self, stillborn: bool) -> Self {
self.stillborn = stillborn;
self
}
#[must_use]
pub fn with_attention(
mut self,
attention: Option<crate::run::attention::AttentionView>,
) -> Self {
self.attention_required = attention.is_some();
if self.attention_required {
self.stalled = false;
self.stillborn = false;
}
self.attention = attention;
self
}
#[must_use]
pub fn with_awaiting_input(
mut self,
awaiting: Option<crate::run::awaiting_input::AwaitingInputView>,
) -> Self {
let awaiting = (!matches!(self.status.as_str(), "done" | "failed" | "cancelled"))
.then_some(awaiting)
.flatten();
self.awaiting_input = awaiting.is_some();
self.open_discussion_count = awaiting.as_ref().map_or(0, |v| v.open_discussion_count);
self.awaiting_input_detail = awaiting;
self
}
}
impl From<&Manifest> for RunSummary {
fn from(m: &Manifest) -> Self {
Self {
run_id: m.run_id.to_string(),
kind: kind_kebab(m.kind).to_string(),
lifecycle: lifecycle_kebab(m.lifecycle),
status: status_kebab(m.status).to_string(),
title: m.title.clone(),
created_at: m.created_at,
source_branch: m.source_branch.clone(),
worktree_path: None,
harness: m.harness.clone(),
node_count: m.node_count,
supervisor: SupervisorView::unknown(),
stalled: false,
stillborn: false,
attention_required: false,
attention: None,
awaiting_input: false,
open_discussion_count: 0,
awaiting_input_detail: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use octl_core::{Kind, Lifecycle, Status};
use serde_json::json;
fn ts() -> DateTime<Utc> {
"2024-01-01T00:00:00Z".parse().unwrap()
}
fn sample() -> Manifest {
Manifest {
schema_version: 1,
applied_seq: 1,
run_id: RunId::parse_str("01arz3ndektsv4rrffq69g5fav").unwrap(),
kind: Kind::Spinoff,
lifecycle: Lifecycle::Autonomous,
title: "seed-run".to_string(),
status: Status::Pending,
created_at: ts(),
updated_at: ts(),
source_repo: None,
source_branch: None,
worktree_root: None,
managed_tmux_session: None,
notify_cmd: None,
harness: None,
node_count: 0,
parent_run_id: None,
parent_node_id: None,
}
}
#[test]
fn view_pins_wire_shape() {
let m = sample();
let got = serde_json::to_value(ManifestView::from(&m)).unwrap();
assert_eq!(
got,
json!({
"schema_version": 1,
"run_id": "01arz3ndektsv4rrffq69g5fav",
"kind": "spinoff",
"lifecycle": "autonomous",
"title": "seed-run",
"status": "pending",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"source_repo": null,
"source_branch": null,
"worktree_root": null,
"harness": null,
"node_count": 0,
"parent_run_id": null,
"parent_node_id": null,
})
);
}
#[test]
fn applied_seq_does_not_leak() {
let base = serde_json::to_value(ManifestView::from(&sample())).unwrap();
let mut bumped = sample();
bumped.applied_seq = 999;
let after = serde_json::to_value(ManifestView::from(&bumped)).unwrap();
assert_eq!(base, after, "applied_seq leaked into run DTO");
assert!(
after.get("applied_seq").is_none(),
"applied_seq must be absent from the wire contract"
);
}
#[test]
fn summary_pins_wire_shape() {
let m = sample();
let got = serde_json::to_value(RunSummary::from(&m)).unwrap();
assert_eq!(
got,
json!({
"run_id": "01arz3ndektsv4rrffq69g5fav",
"kind": "spinoff",
"lifecycle": "autonomous",
"status": "pending",
"title": "seed-run",
"created_at": "2024-01-01T00:00:00Z",
"source_branch": null,
"worktree_path": null,
"harness": null,
"node_count": 0,
"supervisor": { "pid": null, "state": "unknown", "alive": false },
"stalled": false,
"stillborn": false,
"attention_required": false,
"awaiting_input": false,
"open_discussion_count": 0,
})
);
}
#[test]
fn awaiting_input_is_additive_to_stall_but_suppressed_when_terminal() {
use octl_core::AwaitingInput;
let open = AwaitingInput {
opened_at: ts(),
event_seq: 9,
discussion_items: vec![json!({
"topic": "scope", "options": ["small"],
"recommended_default": "small"
})],
};
let view = crate::run::awaiting_input::AwaitingInputView::build(&open, ts());
let live = RunSummary::from(&sample())
.with_stalled(true)
.with_awaiting_input(Some(view.clone()));
assert!(live.stalled);
assert!(live.awaiting_input);
assert_eq!(live.awaiting_input_detail.unwrap().event_seq, 9);
let mut terminal = sample();
terminal.status = Status::Done;
let done = RunSummary::from(&terminal).with_awaiting_input(Some(view));
assert!(!done.awaiting_input);
assert_eq!(done.open_discussion_count, 0);
}
#[test]
fn attention_view_flattens_onto_summary() {
use crate::run::attention::AttentionView;
use octl_core::WorkerExit;
let now: DateTime<Utc> = "2024-01-01T00:10:00Z".parse().unwrap();
let exit = WorkerExit {
code: Some(0),
signal: None,
at: "2024-01-01T00:00:00Z".parse().unwrap(),
};
let view = AttentionView::build(
"01arz3ndektsv4rrffq69g5fav",
now,
&exit,
Some(4242),
Some("/tmp/wt/seed".to_string()),
Some("main".to_string()),
);
let got =
serde_json::to_value(RunSummary::from(&sample()).with_attention(Some(view))).unwrap();
assert_eq!(got["attention_required"], json!(true));
assert_eq!(got["attention"]["pending_age_secs"], json!(600));
assert_eq!(got["attention"]["exited_at"], json!("2024-01-01T00:00:00Z"));
assert_eq!(got["attention"]["worker_pid"], json!(4242));
assert_eq!(got["attention"]["worktree_path"], json!("/tmp/wt/seed"));
assert_eq!(got["attention"]["source_branch"], json!("main"));
assert_eq!(
got["attention"]["reason"],
json!(crate::run::attention::ATTENTION_REASON)
);
assert!(got["attention"]["resume_hint"]
.as_str()
.unwrap()
.contains("run merge"));
let plain = serde_json::to_value(RunSummary::from(&sample()).with_attention(None)).unwrap();
assert_eq!(plain["attention_required"], json!(false));
assert!(plain.get("attention").is_none());
}
#[test]
fn attention_suppresses_stall_on_summary() {
use crate::run::attention::AttentionView;
use octl_core::WorkerExit;
let exit = WorkerExit {
code: Some(0),
signal: None,
at: ts(),
};
let view = AttentionView::build("r", ts(), &exit, None, None, None);
let got = serde_json::to_value(
RunSummary::from(&sample())
.with_stalled(true)
.with_stillborn(true)
.with_attention(Some(view)),
)
.unwrap();
assert_eq!(got["attention_required"], json!(true));
assert_eq!(
got["stalled"],
json!(false),
"attention must suppress stall"
);
assert_eq!(got["stillborn"], json!(false));
}
#[test]
fn supervisor_probe_resolves_distinct_states() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("01arz3ndektsv4rrffq69g5fav");
std::fs::create_dir_all(&run_dir).unwrap();
let paths = RunPaths::new(run_dir, "01arz3ndektsv4rrffq69g5fav").unwrap();
let v = SupervisorView::probe(&paths);
assert_eq!(v.pid, None);
assert_eq!(v.state, SupervisorState::NotRecorded);
assert!(!v.alive);
let our_pid = std::process::id();
pid_file::write_pid(&paths.supervisor_pid(), our_pid).unwrap();
let v = SupervisorView::probe(&paths);
assert_eq!(v.pid, Some(our_pid));
assert_eq!(v.state, SupervisorState::Alive);
assert!(v.alive, "our own recorded pid must read alive");
std::fs::write(paths.supervisor_pid(), "2147483646").unwrap();
let v = SupervisorView::probe(&paths);
assert_eq!(v.pid, Some(2_147_483_646));
assert_eq!(v.state, SupervisorState::Dead);
assert!(!v.alive, "a dead recorded pid must read not-alive");
std::fs::write(paths.supervisor_pid(), "not-a-pid").unwrap();
let v = SupervisorView::probe(&paths);
assert_eq!(v.pid, None);
assert_eq!(v.state, SupervisorState::Unreadable);
assert!(!v.alive);
}
#[test]
fn alive_boolean_tracks_state() {
assert!(SupervisorView::new(Some(1), SupervisorState::Alive).alive);
for state in [
SupervisorState::Dead,
SupervisorState::NotRecorded,
SupervisorState::Unreadable,
SupervisorState::Unknown,
] {
assert!(
!SupervisorView::new(None, state).alive,
"{state:?} must not read alive"
);
}
}
#[test]
#[cfg(unix)]
fn supervisor_probe_symlink_is_unreadable() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("01arz3ndektsv4rrffq69g5fav");
std::fs::create_dir_all(&run_dir).unwrap();
let paths = RunPaths::new(run_dir, "01arz3ndektsv4rrffq69g5fav").unwrap();
let target = dir.path().join("elsewhere.pid");
std::fs::write(&target, format!("{}", std::process::id())).unwrap();
std::os::unix::fs::symlink(&target, paths.supervisor_pid()).unwrap();
let v = SupervisorView::probe(&paths);
assert_eq!(v.state, SupervisorState::Unreadable);
assert_eq!(v.pid, None);
assert!(!v.alive);
}
#[test]
fn presumed_working_suppresses_indeterminate_states() {
let flaggable = |s| !SupervisorView::new(None, s).presumed_working();
assert!(!flaggable(SupervisorState::Alive));
assert!(flaggable(SupervisorState::Dead));
assert!(flaggable(SupervisorState::NotRecorded));
assert!(
!flaggable(SupervisorState::Unreadable),
"Unreadable is indeterminate: must NOT flag stillborn/orphaned"
);
assert!(
!flaggable(SupervisorState::Unknown),
"Unknown is indeterminate: must NOT flag stillborn/orphaned"
);
}
#[test]
fn supervisor_state_wire_spellings() {
for (state, wire) in [
(SupervisorState::Alive, "alive"),
(SupervisorState::Dead, "dead"),
(SupervisorState::NotRecorded, "not-recorded"),
(SupervisorState::Unreadable, "unreadable"),
(SupervisorState::Unknown, "unknown"),
] {
assert_eq!(serde_json::to_value(state).unwrap(), json!(wire));
}
}
}