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'))
}
fn all_rfc4648_base32_lower(s: &str) -> bool {
s.bytes()
.all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b))
}
fn is_canonical_disc_or_proposal_body(body: &str) -> bool {
(body.len() == 26 && all_crockford_lower(body))
|| (body.len() == 10 && all_rfc4648_base32_lower(body))
}
#[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,
})
}
}
}
id_newtype! {
DiscussionId
}
impl DiscussionId {
const EXPECTED: &'static str =
"d- followed by a 26-char lowercase Crockford ULID or a 10-char RFC 4648 base32 lowercase id (a-z2-7)";
pub fn parse_str(s: &str) -> Result<Self, IdValidationError> {
let body = s.strip_prefix("d-").ok_or(IdValidationError::WrongPrefix {
kind: "discussion",
expected: Self::EXPECTED,
})?;
if is_canonical_disc_or_proposal_body(body) {
Ok(Self(s.to_string()))
} else {
Err(IdValidationError::InvalidFormat {
kind: "discussion",
value: s.to_string(),
expected: Self::EXPECTED,
})
}
}
}
id_newtype! {
ProposalId
}
impl ProposalId {
const EXPECTED: &'static str =
"s- followed by a 26-char lowercase Crockford ULID or a 10-char RFC 4648 base32 lowercase id (a-z2-7)";
pub fn parse_str(s: &str) -> Result<Self, IdValidationError> {
let body = s.strip_prefix("s-").ok_or(IdValidationError::WrongPrefix {
kind: "spinoff",
expected: Self::EXPECTED,
})?;
if is_canonical_disc_or_proposal_body(body) {
Ok(Self(s.to_string()))
} else {
Err(IdValidationError::InvalidFormat {
kind: "spinoff",
value: s.to_string(),
expected: Self::EXPECTED,
})
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Kind {
Code,
Spinoff,
Orchestrated,
Research,
TechnicalDecision,
MakeSkill,
FanOut,
Bugfix,
Orchestrate,
}
impl Kind {
#[must_use]
pub const fn wire_name(self) -> &'static str {
match self {
Kind::Code => "code",
Kind::Spinoff => "spinoff",
Kind::Orchestrated => "orchestrated",
Kind::Research => "research",
Kind::TechnicalDecision => "technical-decision",
Kind::MakeSkill => "make-skill",
Kind::FanOut => "fan-out",
Kind::Bugfix => "bugfix",
Kind::Orchestrate => "orchestrate",
}
}
pub const WIRE_NAMES: &'static [&'static str] = &[
Kind::Code.wire_name(),
Kind::Spinoff.wire_name(),
Kind::Orchestrated.wire_name(),
Kind::Research.wire_name(),
Kind::TechnicalDecision.wire_name(),
Kind::MakeSkill.wire_name(),
Kind::FanOut.wire_name(),
Kind::Bugfix.wire_name(),
Kind::Orchestrate.wire_name(),
];
pub fn lifecycle(self) -> Lifecycle {
match self {
Kind::Code | Kind::Orchestrate => Lifecycle::Interactive,
Kind::Spinoff
| Kind::Orchestrated
| Kind::Research
| Kind::TechnicalDecision
| Kind::MakeSkill
| Kind::FanOut
| Kind::Bugfix => Lifecycle::Autonomous,
}
}
#[must_use]
pub fn is_autonomous_single_node_worker(self) -> bool {
match self {
Kind::Spinoff
| Kind::Research
| Kind::TechnicalDecision
| Kind::MakeSkill
| Kind::Bugfix => true,
Kind::Code | Kind::Orchestrate | Kind::FanOut | Kind::Orchestrated => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Lifecycle {
Autonomous,
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)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DiscussionStatus {
Open,
Resolved,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SpinoffStatus {
Proposed,
Approved,
Rejected,
}
#[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>,
pub node_count: u32,
pub open_discussions: u32,
pub pending_spinoffs: 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,
}
#[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 Discussion {
pub schema_version: u32,
pub discussion_id: DiscussionId,
pub run_id: RunId,
pub node_id: NodeId,
pub opened_at: DateTime<Utc>,
pub severity: String,
pub topic: String,
pub context: Option<String>,
#[serde(default)]
pub options: Vec<String>,
pub status: DiscussionStatus,
pub resolution: Option<String>,
#[serde(default)]
pub note: Option<String>,
pub resolved_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpinoffProposal {
pub schema_version: u32,
pub proposal_id: ProposalId,
pub run_id: RunId,
pub node_id: NodeId,
pub proposed_at: DateTime<Utc>,
pub proposed_title: String,
pub proposed_kind: Kind,
pub rationale: Option<String>,
pub status: SpinoffStatus,
pub accepted_as_issue_slug: Option<String>,
pub rejected_reason: Option<String>,
pub resolved_at: Option<DateTime<Utc>>,
}
#[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 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,
Kind::MakeSkill,
Kind::Bugfix,
] {
assert!(
k.is_autonomous_single_node_worker(),
"{k:?} should be retry-eligible"
);
assert_eq!(k.lifecycle(), Lifecycle::Autonomous);
}
for k in [
Kind::Code, Kind::Orchestrate, Kind::FanOut, Kind::Orchestrated, ] {
assert!(
!k.is_autonomous_single_node_worker(),
"{k:?} must NOT be retry-eligible"
);
}
}
#[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 discussion_id_accepts_both_canonical_forms_and_rejects_malformed() {
let gen = crate::new_discussion_id();
assert!(
DiscussionId::parse_str(&gen).is_ok(),
"generator must validate: {gen}"
);
assert!(DiscussionId::parse_str("d-01arz3ndektsv4rrffq69g5fav").is_ok());
assert!(DiscussionId::parse_str("d-ilou234567").is_ok());
assert!(DiscussionId::parse_str("d-abcdefghij").is_ok());
assert!(DiscussionId::parse_str("d-zzzzzzzzzz").is_ok());
assert!(matches!(
DiscussionId::parse_str("s-0123456789"),
Err(IdValidationError::WrongPrefix { .. })
));
for bad in [
"d-0123456789", "d-short", "d-abcdefghijk", "d-01arz3ndektsv4rrffq69g5fa", "d-0123456789012345678901234567", "d-01arz3ndeilov4rrffq69g5fav", "d-ABCDEFGHIJ", "d-abc_def012", "d-", ] {
assert!(
matches!(
DiscussionId::parse_str(bad),
Err(IdValidationError::InvalidFormat { .. })
),
"expected InvalidFormat: {bad:?}",
);
}
for bad in TRAVERSAL_VECTORS {
assert!(
DiscussionId::parse_str(bad).is_err(),
"traversal not rejected: {bad:?}"
);
}
}
#[test]
fn proposal_id_accepts_both_canonical_forms_and_rejects_malformed() {
let gen = crate::new_proposal_id();
assert!(
ProposalId::parse_str(&gen).is_ok(),
"generator must validate: {gen}"
);
assert!(ProposalId::parse_str("s-01arz3ndektsv4rrffq69g5fav").is_ok());
assert!(ProposalId::parse_str("s-uuuuuuuuuu").is_ok());
assert!(matches!(
ProposalId::parse_str("d-0123456789"),
Err(IdValidationError::WrongPrefix { .. })
));
for bad in [
"s-0123456789", "s-short", "s-abcdefghijk", "s-01arz3ndeilov4rrffq69g5fav", "s-ABCDEFGHIJ", "s-abc.def012", "s-", ] {
assert!(
matches!(
ProposalId::parse_str(bad),
Err(IdValidationError::InvalidFormat { .. })
),
"expected InvalidFormat: {bad:?}",
);
}
for bad in TRAVERSAL_VECTORS {
assert!(
ProposalId::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::<DiscussionId>("\"d-../escape\"").is_err());
assert!(serde_json::from_str::<ProposalId>("\"s-abcdefghij\"").is_ok());
assert!(serde_json::from_str::<ProposalId>("\"s-0123456789\"").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]);
}
}