use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleMode {
Manual,
AutoWorkspace,
DailyRollup,
}
impl Default for LifecycleMode {
fn default() -> Self {
Self::AutoWorkspace
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Participants {
#[serde(skip_serializing_if = "Option::is_none")]
pub root_agent_instance_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub final_output_agent_instance_id: Option<String>,
#[serde(default)]
pub total_agents: u32,
#[serde(default)]
pub spawned_subagents: u32,
#[serde(default)]
pub handoffs: u32,
#[serde(default)]
pub max_depth: u32,
#[serde(default)]
pub hosts: u32,
#[serde(default)]
pub tool_runtimes: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostInfo {
pub host_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolInfo {
pub tool_id: String,
pub tool_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_runtime_id: Option<String>,
#[serde(default)]
pub invocation_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SessionStatus {
Active,
Completed,
Failed,
Abandoned,
}
impl Default for SessionStatus {
fn default() -> Self {
Self::Active
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionManifest {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub actor: String,
pub started_at: String,
#[serde(default)]
pub started_at_ms: u64,
#[serde(default)]
pub artifact_count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub root_artifact_id: Option<String>,
#[serde(default)]
pub mode: LifecycleMode,
#[serde(default)]
pub status: SessionStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mission_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub closed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub close_artifact_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default)]
pub participants: Participants,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hosts: Vec<HostInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ToolInfo>,
}
impl SessionManifest {
pub fn new(session_id: String, actor: String, started_at: String, started_at_ms: u64) -> Self {
Self {
session_id,
name: None,
actor,
started_at,
started_at_ms,
artifact_count: 0,
root_artifact_id: None,
mode: LifecycleMode::default(),
status: SessionStatus::Active,
workspace_id: None,
mission_id: None,
closed_at: None,
close_artifact_id: None,
summary: None,
participants: Participants::default(),
hosts: Vec::new(),
tools: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_legacy_manifest() {
let json = r#"{
"session_id": "ssn_abc123",
"name": "test",
"actor": "ship://local",
"started_at": "2026-04-05T08:00:00Z",
"started_at_ms": 1743843600000,
"artifact_count": 5,
"root_artifact_id": "art_deadbeef"
}"#;
let m: SessionManifest = serde_json::from_str(json).unwrap();
assert_eq!(m.session_id, "ssn_abc123");
assert_eq!(m.mode, LifecycleMode::AutoWorkspace);
assert_eq!(m.status, SessionStatus::Active);
assert_eq!(m.participants.total_agents, 0);
}
#[test]
fn roundtrip_full_manifest() {
let m = SessionManifest {
session_id: "ssn_001".into(),
name: Some("daily dev".into()),
actor: "agent://claude".into(),
started_at: "2026-04-05T08:00:00Z".into(),
started_at_ms: 1743843600000,
artifact_count: 12,
root_artifact_id: Some("art_root".into()),
mode: LifecycleMode::Manual,
status: SessionStatus::Completed,
workspace_id: Some("ws_abc".into()),
mission_id: None,
closed_at: Some("2026-04-05T12:00:00Z".into()),
close_artifact_id: Some("art_close".into()),
summary: Some("Fixed auth bug".into()),
participants: Participants {
root_agent_instance_id: Some("ai_root_1".into()),
final_output_agent_instance_id: Some("ai_review_2".into()),
total_agents: 6,
spawned_subagents: 4,
handoffs: 7,
max_depth: 3,
hosts: 2,
tool_runtimes: 5,
},
hosts: vec![HostInfo {
host_id: "host_1".into(),
hostname: Some("macbook".into()),
os: Some("darwin".into()),
arch: Some("arm64".into()),
}],
tools: vec![ToolInfo {
tool_id: "tool_1".into(),
tool_name: "claude-code".into(),
tool_runtime_id: Some("rt_cc1".into()),
invocation_count: 42,
}],
};
let json = serde_json::to_string_pretty(&m).unwrap();
let m2: SessionManifest = serde_json::from_str(&json).unwrap();
assert_eq!(m2.session_id, "ssn_001");
assert_eq!(m2.participants.total_agents, 6);
assert_eq!(m2.hosts.len(), 1);
}
}