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)]
pub struct SupervisorView {
pub pid: Option<u32>,
pub alive: bool,
}
impl SupervisorView {
pub fn probe(paths: &RunPaths) -> Self {
match pid_file::read_pid_record(&paths.supervisor_pid()) {
Some((pid, start_time)) => Self {
pid: Some(pid),
alive: pid_file::pid_live_with_identity(pid, start_time),
},
None => Self {
pid: None,
alive: false,
},
}
}
fn unknown() -> Self {
Self {
pid: None,
alive: false,
}
}
}
#[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 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(),
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 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,
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,
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,
"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",
"node_count": 0,
"supervisor": { "pid": null, "alive": false },
"stalled": false,
"stillborn": false,
})
);
}
#[test]
fn supervisor_probe_reads_pid_file() {
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!(!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!(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!(!v.alive, "a dead recorded pid must read not-alive");
}
}