1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "snake_case")]
8#[derive(Default)]
9pub enum LifecycleMode {
10 Manual,
12 #[default]
14 AutoWorkspace,
15 DailyRollup,
17}
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct Participants {
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub root_agent_instance_id: Option<String>,
25
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub final_output_agent_instance_id: Option<String>,
29
30 #[serde(default)]
32 pub total_agents: u32,
33
34 #[serde(default)]
36 pub spawned_subagents: u32,
37
38 #[serde(default)]
40 pub handoffs: u32,
41
42 #[serde(default)]
44 pub max_depth: u32,
45
46 #[serde(default)]
48 pub hosts: u32,
49
50 #[serde(default)]
52 pub tool_runtimes: u32,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct HostInfo {
58 pub host_id: String,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub hostname: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub os: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub arch: Option<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ToolInfo {
70 pub tool_id: String,
71 pub tool_name: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub tool_runtime_id: Option<String>,
74 #[serde(default)]
75 pub invocation_count: u32,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "snake_case")]
81#[derive(Default)]
82pub enum SessionStatus {
83 #[default]
84 Active,
85 Completed,
86 Failed,
87 Abandoned,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(tag = "kind", rename_all = "snake_case")]
95#[derive(Default)]
96pub enum InvitationAuthority {
97 #[default]
99 HostOnly,
100 DelegatedTo { delegates: Vec<String> },
102 Open,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct RoomInfo {
134 pub room_id: String,
138
139 pub host_pubkey: String,
144
145 #[serde(default)]
146 pub invitation_authority: InvitationAuthority,
147
148 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub workflow_ref: Option<String>,
153
154 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub checkpoint_every_actions: Option<u32>,
163
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub participants: Vec<String>,
169}
170
171impl RoomInfo {
172 pub fn new(room_id: impl Into<String>, host_pubkey: impl Into<String>) -> Self {
173 Self {
174 room_id: room_id.into(),
175 host_pubkey: host_pubkey.into(),
176 invitation_authority: InvitationAuthority::default(),
177 workflow_ref: None,
178 checkpoint_every_actions: None,
179 participants: Vec::new(),
180 }
181 }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct SessionManifest {
191 pub session_id: String,
192
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub name: Option<String>,
195
196 pub actor: String,
197
198 pub started_at: String,
199
200 #[serde(default)]
201 pub started_at_ms: u64,
202
203 #[serde(default)]
204 pub artifact_count: u64,
205
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub root_artifact_id: Option<String>,
208
209 #[serde(default)]
211 pub mode: LifecycleMode,
212
213 #[serde(default)]
214 pub status: SessionStatus,
215
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub workspace_id: Option<String>,
218
219 #[serde(skip_serializing_if = "Option::is_none")]
220 pub mission_id: Option<String>,
221
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub closed_at: Option<String>,
224
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub close_artifact_id: Option<String>,
227
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub summary: Option<String>,
230
231 #[serde(default)]
232 pub participants: Participants,
233
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 pub hosts: Vec<HostInfo>,
236
237 #[serde(default, skip_serializing_if = "Vec::is_empty")]
238 pub tools: Vec<ToolInfo>,
239
240 #[serde(default, skip_serializing_if = "Vec::is_empty")]
242 pub authorized_tools: Vec<String>,
243
244 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub start_commit_sha: Option<String>,
251
252 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub room: Option<RoomInfo>,
256}
257
258impl SessionManifest {
259 pub fn new(session_id: String, actor: String, started_at: String, started_at_ms: u64) -> Self {
261 Self {
262 session_id,
263 name: None,
264 actor,
265 started_at,
266 started_at_ms,
267 artifact_count: 0,
268 root_artifact_id: None,
269 mode: LifecycleMode::default(),
270 status: SessionStatus::Active,
271 workspace_id: None,
272 mission_id: None,
273 closed_at: None,
274 close_artifact_id: None,
275 summary: None,
276 participants: Participants::default(),
277 hosts: Vec::new(),
278 tools: Vec::new(),
279 authorized_tools: Vec::new(),
280 start_commit_sha: None,
281 room: None,
282 }
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn deserialize_legacy_manifest() {
292 let json = r#"{
294 "session_id": "ssn_abc123",
295 "name": "test",
296 "actor": "ship://local",
297 "started_at": "2026-04-05T08:00:00Z",
298 "started_at_ms": 1743843600000,
299 "artifact_count": 5,
300 "root_artifact_id": "art_deadbeef"
301 }"#;
302 let m: SessionManifest = serde_json::from_str(json).unwrap();
303 assert_eq!(m.session_id, "ssn_abc123");
304 assert_eq!(m.mode, LifecycleMode::AutoWorkspace);
305 assert_eq!(m.status, SessionStatus::Active);
306 assert_eq!(m.participants.total_agents, 0);
307 }
308
309 #[test]
310 fn roundtrip_full_manifest() {
311 let m = SessionManifest {
312 session_id: "ssn_001".into(),
313 name: Some("daily dev".into()),
314 actor: "agent://claude".into(),
315 started_at: "2026-04-05T08:00:00Z".into(),
316 started_at_ms: 1743843600000,
317 artifact_count: 12,
318 root_artifact_id: Some("art_root".into()),
319 mode: LifecycleMode::Manual,
320 status: SessionStatus::Completed,
321 workspace_id: Some("ws_abc".into()),
322 mission_id: None,
323 closed_at: Some("2026-04-05T12:00:00Z".into()),
324 close_artifact_id: Some("art_close".into()),
325 summary: Some("Fixed auth bug".into()),
326 participants: Participants {
327 root_agent_instance_id: Some("ai_root_1".into()),
328 final_output_agent_instance_id: Some("ai_review_2".into()),
329 total_agents: 6,
330 spawned_subagents: 4,
331 handoffs: 7,
332 max_depth: 3,
333 hosts: 2,
334 tool_runtimes: 5,
335 },
336 hosts: vec![HostInfo {
337 host_id: "host_1".into(),
338 hostname: Some("macbook".into()),
339 os: Some("darwin".into()),
340 arch: Some("arm64".into()),
341 }],
342 tools: vec![ToolInfo {
343 tool_id: "tool_1".into(),
344 tool_name: "claude-code".into(),
345 tool_runtime_id: Some("rt_cc1".into()),
346 invocation_count: 42,
347 }],
348 authorized_tools: vec!["read_file".into(), "write_file".into()],
349 start_commit_sha: Some("abc1234567890abcdef1234567890abcdef12345".into()),
350 room: Some(RoomInfo {
351 room_id: "room_001".into(),
352 host_pubkey: "AbCdEf123".into(),
353 invitation_authority: InvitationAuthority::DelegatedTo {
354 delegates: vec!["DeLeGaTe1".into()],
355 },
356 workflow_ref: Some("wf_abc".into()),
357 checkpoint_every_actions: Some(50),
358 participants: vec!["art_part_1".into(), "art_part_2".into()],
359 }),
360 };
361 let json = serde_json::to_string_pretty(&m).unwrap();
362 let m2: SessionManifest = serde_json::from_str(&json).unwrap();
363 assert_eq!(m2.session_id, "ssn_001");
364 assert_eq!(m2.participants.total_agents, 6);
365 assert_eq!(m2.hosts.len(), 1);
366 assert_eq!(m2.room.as_ref().unwrap().room_id, "room_001");
367 assert_eq!(m2.room.as_ref().unwrap().participants.len(), 2);
368 }
369
370 #[test]
371 fn legacy_manifest_has_no_room() {
372 let json = r#"{
376 "session_id": "ssn_legacy",
377 "actor": "ship://local",
378 "started_at": "2026-04-05T08:00:00Z",
379 "started_at_ms": 1743843600000,
380 "artifact_count": 0
381 }"#;
382 let m: SessionManifest = serde_json::from_str(json).unwrap();
383 assert!(m.room.is_none());
384 }
385
386 #[test]
387 fn room_omitted_from_json_when_absent() {
388 let m = SessionManifest::new(
391 "ssn_plain".into(),
392 "ship://local".into(),
393 "2026-04-05T08:00:00Z".into(),
394 1743843600000,
395 );
396 let json = serde_json::to_string(&m).unwrap();
397 assert!(!json.contains("\"room\""));
398 }
399
400 #[test]
401 fn invitation_authority_defaults_to_host_only() {
402 assert_eq!(
403 InvitationAuthority::default(),
404 InvitationAuthority::HostOnly
405 );
406 }
407}