Skip to main content

treeship_core/session/
manifest.rs

1//! Enhanced session manifest for Session Receipt v1.
2
3use serde::{Deserialize, Serialize};
4
5/// Session lifecycle mode.
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "snake_case")]
8pub enum LifecycleMode {
9    /// User explicitly starts and ends the session.
10    Manual,
11    /// Auto-starts when registered agents begin activity in a watched workspace.
12    AutoWorkspace,
13    /// Day-level session with optional mission segments.
14    DailyRollup,
15}
16
17impl Default for LifecycleMode {
18    fn default() -> Self {
19        Self::AutoWorkspace
20    }
21}
22
23/// Summary of all participants in a session.
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct Participants {
26    /// Instance ID of the root agent that initiated the session.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub root_agent_instance_id: Option<String>,
29
30    /// Instance ID of the agent that produced the final output.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub final_output_agent_instance_id: Option<String>,
33
34    /// Total number of distinct agents involved.
35    #[serde(default)]
36    pub total_agents: u32,
37
38    /// Number of sub-agents spawned during the session.
39    #[serde(default)]
40    pub spawned_subagents: u32,
41
42    /// Total number of handoffs between agents.
43    #[serde(default)]
44    pub handoffs: u32,
45
46    /// Deepest agent delegation chain depth.
47    #[serde(default)]
48    pub max_depth: u32,
49
50    /// Number of distinct hosts involved.
51    #[serde(default)]
52    pub hosts: u32,
53
54    /// Number of distinct tool runtimes involved.
55    #[serde(default)]
56    pub tool_runtimes: u32,
57}
58
59/// Information about a host involved in the session.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct HostInfo {
62    pub host_id: String,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub hostname: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub os: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub arch: Option<String>,
69}
70
71/// Information about a tool runtime involved in the session.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ToolInfo {
74    pub tool_id: String,
75    pub tool_name: String,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub tool_runtime_id: Option<String>,
78    #[serde(default)]
79    pub invocation_count: u32,
80}
81
82/// Session status.
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "snake_case")]
85pub enum SessionStatus {
86    Active,
87    Completed,
88    Failed,
89    Abandoned,
90}
91
92impl Default for SessionStatus {
93    fn default() -> Self {
94        Self::Active
95    }
96}
97
98/// Enhanced session manifest for Session Receipt v1.
99///
100/// Backward-compatible with the original CLI SessionManifest:
101/// all new fields use `#[serde(default)]` so old session.json files
102/// deserialize without error.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct SessionManifest {
105    pub session_id: String,
106
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub name: Option<String>,
109
110    pub actor: String,
111
112    pub started_at: String,
113
114    #[serde(default)]
115    pub started_at_ms: u64,
116
117    #[serde(default)]
118    pub artifact_count: u64,
119
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub root_artifact_id: Option<String>,
122
123    // --- v1 fields below ---
124
125    #[serde(default)]
126    pub mode: LifecycleMode,
127
128    #[serde(default)]
129    pub status: SessionStatus,
130
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub workspace_id: Option<String>,
133
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub mission_id: Option<String>,
136
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub closed_at: Option<String>,
139
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub close_artifact_id: Option<String>,
142
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub summary: Option<String>,
145
146    #[serde(default)]
147    pub participants: Participants,
148
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub hosts: Vec<HostInfo>,
151
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub tools: Vec<ToolInfo>,
154
155    /// Tools declared as authorized for this session (from declaration.json).
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub authorized_tools: Vec<String>,
158}
159
160impl SessionManifest {
161    /// Create a new manifest with required fields; v1 fields default.
162    pub fn new(session_id: String, actor: String, started_at: String, started_at_ms: u64) -> Self {
163        Self {
164            session_id,
165            name: None,
166            actor,
167            started_at,
168            started_at_ms,
169            artifact_count: 0,
170            root_artifact_id: None,
171            mode: LifecycleMode::default(),
172            status: SessionStatus::Active,
173            workspace_id: None,
174            mission_id: None,
175            closed_at: None,
176            close_artifact_id: None,
177            summary: None,
178            participants: Participants::default(),
179            hosts: Vec::new(),
180            tools: Vec::new(),
181            authorized_tools: Vec::new(),
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn deserialize_legacy_manifest() {
192        // Old format without v1 fields should still deserialize
193        let json = r#"{
194            "session_id": "ssn_abc123",
195            "name": "test",
196            "actor": "ship://local",
197            "started_at": "2026-04-05T08:00:00Z",
198            "started_at_ms": 1743843600000,
199            "artifact_count": 5,
200            "root_artifact_id": "art_deadbeef"
201        }"#;
202        let m: SessionManifest = serde_json::from_str(json).unwrap();
203        assert_eq!(m.session_id, "ssn_abc123");
204        assert_eq!(m.mode, LifecycleMode::AutoWorkspace);
205        assert_eq!(m.status, SessionStatus::Active);
206        assert_eq!(m.participants.total_agents, 0);
207    }
208
209    #[test]
210    fn roundtrip_full_manifest() {
211        let m = SessionManifest {
212            session_id: "ssn_001".into(),
213            name: Some("daily dev".into()),
214            actor: "agent://claude".into(),
215            started_at: "2026-04-05T08:00:00Z".into(),
216            started_at_ms: 1743843600000,
217            artifact_count: 12,
218            root_artifact_id: Some("art_root".into()),
219            mode: LifecycleMode::Manual,
220            status: SessionStatus::Completed,
221            workspace_id: Some("ws_abc".into()),
222            mission_id: None,
223            closed_at: Some("2026-04-05T12:00:00Z".into()),
224            close_artifact_id: Some("art_close".into()),
225            summary: Some("Fixed auth bug".into()),
226            participants: Participants {
227                root_agent_instance_id: Some("ai_root_1".into()),
228                final_output_agent_instance_id: Some("ai_review_2".into()),
229                total_agents: 6,
230                spawned_subagents: 4,
231                handoffs: 7,
232                max_depth: 3,
233                hosts: 2,
234                tool_runtimes: 5,
235            },
236            hosts: vec![HostInfo {
237                host_id: "host_1".into(),
238                hostname: Some("macbook".into()),
239                os: Some("darwin".into()),
240                arch: Some("arm64".into()),
241            }],
242            tools: vec![ToolInfo {
243                tool_id: "tool_1".into(),
244                tool_name: "claude-code".into(),
245                tool_runtime_id: Some("rt_cc1".into()),
246                invocation_count: 42,
247            }],
248            authorized_tools: vec!["read_file".into(), "write_file".into()],
249        };
250        let json = serde_json::to_string_pretty(&m).unwrap();
251        let m2: SessionManifest = serde_json::from_str(&json).unwrap();
252        assert_eq!(m2.session_id, "ssn_001");
253        assert_eq!(m2.participants.total_agents, 6);
254        assert_eq!(m2.hosts.len(), 1);
255    }
256}