use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub const STATE_SCHEMA_VERSION: u32 = 1;
pub const SUPPORTED_STATE_SCHEMAS: &[u32] = &[1];
const CROCKFORD_LOWER: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
fn all_crockford_lower(s: &str) -> bool {
s.bytes().all(|b| CROCKFORD_LOWER.contains(&b))
}
pub fn is_run_id_prefix(s: &str) -> bool {
!s.is_empty()
&& s.len() <= RunId::LEN
&& all_crockford_lower(s)
&& matches!(s.as_bytes().first(), Some(b'0'..=b'7'))
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum IdValidationError {
#[error("invalid {kind} id {value:?}: expected {expected}")]
InvalidFormat {
kind: &'static str,
value: String,
expected: &'static str,
},
#[error("invalid {kind} id: wrong prefix, expected {expected}")]
WrongPrefix {
kind: &'static str,
expected: &'static str,
},
}
impl IdValidationError {
pub fn kind(&self) -> &'static str {
match self {
Self::InvalidFormat { kind, .. } | Self::WrongPrefix { kind, .. } => kind,
}
}
pub fn expected(&self) -> &'static str {
match self {
Self::InvalidFormat { expected, .. } | Self::WrongPrefix { expected, .. } => expected,
}
}
}
macro_rules! id_newtype {
($(#[$m:meta])* $name:ident) => {
$(#[$m])*
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct $name(String);
impl $name {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::str::FromStr for $name {
type Err = IdValidationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse_str(s)
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::fmt::Debug for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}({:?})", stringify!($name), self.0)
}
}
impl serde::Serialize for $name {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl<'de> serde::Deserialize<'de> for $name {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::parse_str(&s).map_err(serde::de::Error::custom)
}
}
};
}
id_newtype! {
RunId
}
impl RunId {
const EXPECTED: &'static str = "26-char lowercase Crockford base32 ULID";
pub const LEN: usize = 26;
pub fn parse_str(s: &str) -> Result<Self, IdValidationError> {
let reject = || IdValidationError::InvalidFormat {
kind: "run",
value: s.to_string(),
expected: Self::EXPECTED,
};
if s.len() != Self::LEN || !all_crockford_lower(s) {
return Err(reject());
}
if !(b'0'..=b'7').contains(&s.as_bytes()[0]) {
return Err(reject());
}
Ok(Self(s.to_string()))
}
}
id_newtype! {
NodeId
}
impl NodeId {
const EXPECTED: &'static str = "n-NNNN (n- followed by 4-10 ASCII digits)";
pub fn parse_str(s: &str) -> Result<Self, IdValidationError> {
let body = s.strip_prefix("n-").ok_or(IdValidationError::WrongPrefix {
kind: "node",
expected: Self::EXPECTED,
})?;
if (4..=10).contains(&body.len()) && body.bytes().all(|b| b.is_ascii_digit()) {
Ok(Self(s.to_string()))
} else {
Err(IdValidationError::InvalidFormat {
kind: "node",
value: s.to_string(),
expected: Self::EXPECTED,
})
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Kind {
Spinoff,
Research,
TechnicalDecision,
FanOut,
#[serde(other)]
Unknown,
}
impl Kind {
#[must_use]
pub const fn wire_name(self) -> &'static str {
match self {
Kind::Spinoff => "spinoff",
Kind::Research => "research",
Kind::TechnicalDecision => "technical-decision",
Kind::FanOut => "fan-out",
Kind::Unknown => "unknown",
}
}
pub const WIRE_NAMES: &'static [&'static str] = &[
Kind::Spinoff.wire_name(),
Kind::Research.wire_name(),
Kind::TechnicalDecision.wire_name(),
Kind::FanOut.wire_name(),
];
pub fn lifecycle(self) -> Lifecycle {
match self {
Kind::Spinoff
| Kind::Research
| Kind::TechnicalDecision
| Kind::FanOut
| Kind::Unknown => Lifecycle::Autonomous,
}
}
#[must_use]
pub fn is_autonomous_single_node_worker(self) -> bool {
match self {
Kind::Spinoff | Kind::Research | Kind::TechnicalDecision => true,
Kind::FanOut | Kind::Unknown => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Lifecycle {
Autonomous,
Interactive,
}
impl Lifecycle {
#[must_use]
pub fn is_interactive(self) -> bool {
matches!(self, Lifecycle::Interactive)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Status {
Pending,
Running,
Blocked,
Done,
Failed,
Cancelled,
}
impl Status {
pub fn is_terminal(self) -> bool {
matches!(self, Status::Done | Status::Failed | Status::Cancelled)
}
}
pub fn aggregate_terminal_status<I>(statuses: I) -> Option<Status>
where
I: IntoIterator<Item = Status>,
{
let mut any = false;
let mut any_failed = false;
let mut any_cancelled = false;
for s in statuses {
any = true;
match s {
Status::Done => {}
Status::Failed => any_failed = true,
Status::Cancelled => any_cancelled = true,
Status::Pending | Status::Running | Status::Blocked => return None,
}
}
if !any {
return None;
}
Some(if any_failed {
Status::Failed
} else if any_cancelled {
Status::Cancelled
} else {
Status::Done
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub schema_version: u32,
#[serde(default)]
pub applied_seq: u64,
pub run_id: RunId,
pub kind: Kind,
pub lifecycle: Lifecycle,
pub title: String,
pub status: Status,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub source_repo: Option<String>,
pub source_branch: Option<String>,
pub worktree_root: Option<String>,
#[serde(default)]
pub managed_tmux_session: Option<String>,
#[serde(default)]
pub notify_cmd: Option<String>,
#[serde(default)]
pub harness: Option<String>,
pub node_count: u32,
pub parent_run_id: Option<RunId>,
pub parent_node_id: Option<NodeId>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChildRef {
pub run_id: RunId,
pub node_id: NodeId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
pub schema_version: u32,
pub node_id: NodeId,
pub run_id: RunId,
pub parent_node_id: Option<NodeId>,
pub kind: Kind,
pub status: Status,
pub task: Option<String>,
pub worktree_path: Option<String>,
pub branch: Option<String>,
#[serde(default)]
pub base_sha: Option<String>,
pub tmux_window: Option<String>,
#[serde(default)]
pub tmux_identity: Option<TmuxIdentity>,
pub agent_pid: Option<i32>,
pub agent_pid_start_time: Option<DateTime<Utc>>,
pub supervisor_pid: Option<i32>,
#[serde(default)]
pub children: Vec<ChildRef>,
pub started_at: Option<DateTime<Utc>>,
pub updated_at: DateTime<Utc>,
pub last_report: Option<Value>,
#[serde(default)]
pub last_processed_report_seq_by_child: Map<String, Value>,
#[serde(default)]
pub retry_attempts: u32,
#[serde(default)]
pub worker_exit: Option<WorkerExit>,
#[serde(default)]
pub pending_merge: Option<Box<MergeTxn>>,
#[serde(default)]
pub first_death_at: Option<DateTime<Utc>>,
#[serde(default)]
pub awaiting_input: Option<Box<AwaitingInput>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AwaitingInput {
pub opened_at: DateTime<Utc>,
pub event_seq: u64,
pub discussion_items: Vec<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MergeTxn {
pub op_id: String,
pub source_branch: String,
pub worker_branch: String,
pub expected_source_oid: String,
pub worker_oid: String,
#[serde(default)]
pub base_sha: Option<String>,
#[serde(default)]
pub driver_pid: Option<i32>,
#[serde(default)]
pub driver_pid_start_secs: Option<u64>,
pub started_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerExit {
#[serde(default)]
pub code: Option<i32>,
#[serde(default)]
pub signal: Option<i32>,
pub at: DateTime<Utc>,
}
impl WorkerExit {
pub fn is_clean(self) -> bool {
self.signal.is_none() && self.code == Some(0)
}
pub fn is_failure(self) -> bool {
!self.is_clean()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TmuxIdentity {
#[serde(default)]
pub socket: Option<String>,
pub session: String,
pub window_id: String,
#[serde(default)]
pub pane_id: Option<String>,
}
impl TmuxIdentity {
pub fn capture_target(&self) -> &str {
self.pane_id
.as_deref()
.filter(|id| !id.is_empty())
.unwrap_or(&self.window_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub ts: DateTime<Utc>,
pub seq: u64,
pub kind: String,
pub run_id: RunId,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub node_id: Option<NodeId>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub idempotency_key: Option<String>,
#[serde(default)]
pub data: Value,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aggregate_terminal_status_is_the_three_way_rule() {
use Status::{Blocked, Cancelled, Done, Failed, Pending, Running};
assert_eq!(aggregate_terminal_status([]), None);
for live in [Pending, Running, Blocked] {
assert_eq!(aggregate_terminal_status([Done, live]), None);
}
assert_eq!(aggregate_terminal_status([Done, Done]), Some(Done));
assert_eq!(aggregate_terminal_status([Done, Failed]), Some(Failed));
assert_eq!(aggregate_terminal_status([Failed, Cancelled]), Some(Failed));
assert_eq!(
aggregate_terminal_status([Cancelled, Cancelled]),
Some(Cancelled)
);
assert_eq!(
aggregate_terminal_status([Done, Cancelled]),
Some(Cancelled)
);
}
#[test]
fn wire_names_match_serde_round_trip() {
for &name in Kind::WIRE_NAMES {
let kind: Kind = serde_json::from_value(Value::String(name.to_string()))
.unwrap_or_else(|_| panic!("WIRE_NAMES entry {name:?} is not a valid Kind"));
assert_eq!(
serde_json::to_value(kind).unwrap(),
Value::String(name.to_string()),
"serde round-trip diverged from wire_name for {name:?}",
);
}
}
#[test]
fn autonomous_single_node_worker_set_is_exact() {
for k in [Kind::Spinoff, Kind::Research, Kind::TechnicalDecision] {
assert!(
k.is_autonomous_single_node_worker(),
"{k:?} should be retry-eligible"
);
assert_eq!(k.lifecycle(), Lifecycle::Autonomous);
}
for k in [
Kind::FanOut, Kind::Unknown, ] {
assert!(
!k.is_autonomous_single_node_worker(),
"{k:?} must NOT be retry-eligible"
);
}
}
#[test]
fn removed_kinds_deserialize_to_unknown() {
for removed in [
"code",
"orchestrate",
"orchestrated",
"bugfix",
"make-skill",
] {
let kind: Kind = serde_json::from_value(Value::String(removed.to_string()))
.expect("a removed kind must still deserialize, not fault");
assert_eq!(kind, Kind::Unknown, "{removed:?} should map to Unknown");
}
assert_eq!(
serde_json::from_value::<Kind>(Value::String("future-kind".into())).unwrap(),
Kind::Unknown
);
for &name in Kind::WIRE_NAMES {
let kind: Kind = serde_json::from_value(Value::String(name.to_string())).unwrap();
assert_ne!(kind, Kind::Unknown, "{name:?} must not fold to Unknown");
}
}
#[test]
fn tmux_identity_deserializes_legacy_state_without_pane_id() {
let absent: TmuxIdentity = serde_json::from_value(serde_json::json!({
"socket": null,
"session": "octl",
"window_id": "@42",
}))
.expect("legacy identity without pane_id must deserialize");
assert_eq!(absent.pane_id, None);
assert_eq!(absent.capture_target(), "@42");
let null: TmuxIdentity = serde_json::from_value(serde_json::json!({
"socket": null,
"session": "octl",
"window_id": "@42",
"pane_id": null,
}))
.expect("identity with explicit null pane_id must deserialize");
assert_eq!(null.pane_id, None);
assert_eq!(null.capture_target(), "@42");
}
#[test]
fn capture_target_prefers_nonempty_pane_id() {
let with_pane = TmuxIdentity {
socket: None,
session: "octl".into(),
window_id: "@42".into(),
pane_id: Some("%7".into()),
};
assert_eq!(with_pane.capture_target(), "%7");
let empty_pane = TmuxIdentity {
pane_id: Some(String::new()),
..with_pane.clone()
};
assert_eq!(empty_pane.capture_target(), "@42");
}
}
#[cfg(test)]
mod id_tests {
use super::*;
const TRAVERSAL_VECTORS: &[&str] = &[
"..",
"../etc",
"a/b",
"a/../b",
".hidden",
"./x",
"foo/bar.json",
"n-0001/../../etc",
"",
];
#[test]
fn run_id_accepts_generator_output_and_rejects_malformed() {
let id = crate::new_run_id();
assert!(
RunId::parse_str(&id).is_ok(),
"generator must validate: {id}"
);
for bad in [
"tooshort",
"01jxsnap0000000000000000000", "01JXSNAP000000000000000000", "01jxiiiiiiiiiiiiiiiiiiiiii", "80000000000000000000000000", "n-0001", ] {
assert!(RunId::parse_str(bad).is_err(), "expected reject: {bad:?}");
}
for bad in TRAVERSAL_VECTORS {
assert!(
RunId::parse_str(bad).is_err(),
"traversal not rejected: {bad:?}"
);
}
}
#[test]
fn node_id_accepts_canonical_and_rejects_malformed() {
for ok in ["n-0001", "n-0010", "n-123456"] {
assert!(NodeId::parse_str(ok).is_ok(), "expected accept: {ok}");
}
assert!(matches!(
NodeId::parse_str("d-0001"),
Err(IdValidationError::WrongPrefix { .. })
));
assert!(matches!(
NodeId::parse_str("0001"),
Err(IdValidationError::WrongPrefix { .. })
));
for bad in [
"n-1", "n-abcd", "n-", "n-00a1", "n-00000000000", ] {
assert!(
matches!(
NodeId::parse_str(bad),
Err(IdValidationError::InvalidFormat { .. })
),
"expected InvalidFormat: {bad:?}",
);
}
for bad in TRAVERSAL_VECTORS {
assert!(
NodeId::parse_str(bad).is_err(),
"traversal not rejected: {bad:?}"
);
}
}
#[test]
fn deserialize_rejects_malformed_ids() {
assert!(serde_json::from_str::<NodeId>("\"n-0001\"").is_ok());
assert!(serde_json::from_str::<NodeId>("\"../../etc\"").is_err());
assert!(serde_json::from_str::<NodeId>("\"n-../escape\"").is_err());
}
#[test]
fn serialize_round_trips_as_bare_string() {
let nid = NodeId::parse_str("n-0042").unwrap();
let json = serde_json::to_string(&nid).unwrap();
assert_eq!(json, "\"n-0042\"");
let back: NodeId = serde_json::from_str(&json).unwrap();
assert_eq!(back, nid);
assert_eq!(nid.as_str(), "n-0042");
assert_eq!(nid.to_string(), "n-0042");
}
#[test]
fn error_exposes_kind_and_expected() {
let err = NodeId::parse_str("n-x").unwrap_err();
assert_eq!(err.kind(), "node");
assert_eq!(err.expected(), "n-NNNN (n- followed by 4-10 ASCII digits)");
}
#[test]
fn event_deserialize_validates_envelope_ids() {
let ok = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"node.created","run_id":"01jxsnap000000000000000000","node_id":"n-0001","data":{}}"#;
assert!(serde_json::from_str::<Event>(ok).is_ok());
let bad_run = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"run.status","run_id":"not-a-ulid","data":{}}"#;
assert!(serde_json::from_str::<Event>(bad_run).is_err());
let bad_node = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"node.status","run_id":"01jxsnap000000000000000000","node_id":"n-1","data":{}}"#;
assert!(serde_json::from_str::<Event>(bad_node).is_err());
}
#[test]
fn from_str_and_ord_delegate_to_inner() {
use std::str::FromStr;
assert!(RunId::from_str("01jxsnap000000000000000000").is_ok());
assert!("n-0001".parse::<NodeId>().is_ok());
assert!("n-x".parse::<NodeId>().is_err());
let a = RunId::parse_str("01jxsnap000000000000000000").unwrap();
let b = RunId::parse_str("02jxsnap000000000000000000").unwrap();
assert!(a < b);
let mut v = vec![b.clone(), a.clone()];
v.sort();
assert_eq!(v, vec![a, b]);
}
}