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 open_discussions: u32,
pub pending_spinoffs: 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,
open_discussions: m.open_discussions,
pending_spinoffs: m.pending_spinoffs,
parent_run_id: m.parent_run_id.as_ref(),
parent_node_id: m.parent_node_id.as_ref(),
}
}
}
#[derive(Serialize)]
pub struct RunSummary {
pub run_id: String,
pub kind: String,
pub status: String,
pub title: String,
pub created_at: DateTime<Utc>,
pub harness: Option<String>,
pub node_count: u32,
pub supervisor: SupervisorView,
pub stalled: bool,
pub stillborn: bool,
}
impl RunSummary {
#[must_use]
pub fn with_supervisor(mut self, supervisor: SupervisorView) -> Self {
self.supervisor = supervisor;
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
}
}
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(),
status: status_kebab(m.status).to_string(),
title: m.title.clone(),
created_at: m.created_at,
harness: m.harness.clone(),
node_count: m.node_count,
supervisor: SupervisorView::unknown(),
stalled: false,
stillborn: false,
}
}
}
#[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,
open_discussions: 0,
pending_spinoffs: 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,
"open_discussions": 0,
"pending_spinoffs": 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",
"status": "pending",
"title": "seed-run",
"created_at": "2024-01-01T00:00:00Z",
"harness": null,
"node_count": 0,
"supervisor": { "pid": null, "state": "unknown", "alive": false },
"stalled": false,
"stillborn": 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));
}
}
}