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, skip_serializing_if = "Option::is_none")]
215 pub workflow_ref: Option<String>,
216
217 #[serde(default)]
219 pub mode: LifecycleMode,
220
221 #[serde(default)]
222 pub status: SessionStatus,
223
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub workspace_id: Option<String>,
226
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub mission_id: Option<String>,
229
230 #[serde(skip_serializing_if = "Option::is_none")]
231 pub closed_at: Option<String>,
232
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub close_artifact_id: Option<String>,
235
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub summary: Option<String>,
238
239 #[serde(default)]
240 pub participants: Participants,
241
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub hosts: Vec<HostInfo>,
244
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub tools: Vec<ToolInfo>,
247
248 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 pub authorized_tools: Vec<String>,
251
252 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub start_commit_sha: Option<String>,
259
260 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub room: Option<RoomInfo>,
264}
265
266impl SessionManifest {
267 pub fn new(session_id: String, actor: String, started_at: String, started_at_ms: u64) -> Self {
269 Self {
270 session_id,
271 name: None,
272 actor,
273 started_at,
274 started_at_ms,
275 artifact_count: 0,
276 root_artifact_id: None,
277 workflow_ref: None,
278 mode: LifecycleMode::default(),
279 status: SessionStatus::Active,
280 workspace_id: None,
281 mission_id: None,
282 closed_at: None,
283 close_artifact_id: None,
284 summary: None,
285 participants: Participants::default(),
286 hosts: Vec::new(),
287 tools: Vec::new(),
288 authorized_tools: Vec::new(),
289 start_commit_sha: None,
290 room: None,
291 }
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn deserialize_legacy_manifest() {
301 let json = r#"{
303 "session_id": "ssn_abc123",
304 "name": "test",
305 "actor": "ship://local",
306 "started_at": "2026-04-05T08:00:00Z",
307 "started_at_ms": 1743843600000,
308 "artifact_count": 5,
309 "root_artifact_id": "art_deadbeef"
310 }"#;
311 let m: SessionManifest = serde_json::from_str(json).unwrap();
312 assert_eq!(m.session_id, "ssn_abc123");
313 assert_eq!(m.workflow_ref, None);
314 assert_eq!(m.mode, LifecycleMode::AutoWorkspace);
315 assert_eq!(m.status, SessionStatus::Active);
316 assert_eq!(m.participants.total_agents, 0);
317 }
318
319 #[test]
320 fn roundtrip_full_manifest() {
321 let m = SessionManifest {
322 session_id: "ssn_001".into(),
323 name: Some("daily dev".into()),
324 actor: "agent://claude".into(),
325 started_at: "2026-04-05T08:00:00Z".into(),
326 started_at_ms: 1743843600000,
327 artifact_count: 12,
328 root_artifact_id: Some("art_root".into()),
329 workflow_ref: Some("art_workflow".into()),
330 mode: LifecycleMode::Manual,
331 status: SessionStatus::Completed,
332 workspace_id: Some("ws_abc".into()),
333 mission_id: None,
334 closed_at: Some("2026-04-05T12:00:00Z".into()),
335 close_artifact_id: Some("art_close".into()),
336 summary: Some("Fixed auth bug".into()),
337 participants: Participants {
338 root_agent_instance_id: Some("ai_root_1".into()),
339 final_output_agent_instance_id: Some("ai_review_2".into()),
340 total_agents: 6,
341 spawned_subagents: 4,
342 handoffs: 7,
343 max_depth: 3,
344 hosts: 2,
345 tool_runtimes: 5,
346 },
347 hosts: vec![HostInfo {
348 host_id: "host_1".into(),
349 hostname: Some("macbook".into()),
350 os: Some("darwin".into()),
351 arch: Some("arm64".into()),
352 }],
353 tools: vec![ToolInfo {
354 tool_id: "tool_1".into(),
355 tool_name: "claude-code".into(),
356 tool_runtime_id: Some("rt_cc1".into()),
357 invocation_count: 42,
358 }],
359 authorized_tools: vec!["read_file".into(), "write_file".into()],
360 start_commit_sha: Some("abc1234567890abcdef1234567890abcdef12345".into()),
361 room: Some(RoomInfo {
362 room_id: "room_001".into(),
363 host_pubkey: "AbCdEf123".into(),
364 invitation_authority: InvitationAuthority::DelegatedTo {
365 delegates: vec!["DeLeGaTe1".into()],
366 },
367 workflow_ref: Some("wf_abc".into()),
368 checkpoint_every_actions: Some(50),
369 participants: vec!["art_part_1".into(), "art_part_2".into()],
370 }),
371 };
372 let json = serde_json::to_string_pretty(&m).unwrap();
373 let m2: SessionManifest = serde_json::from_str(&json).unwrap();
374 assert_eq!(m2.session_id, "ssn_001");
375 assert_eq!(m2.workflow_ref.as_deref(), Some("art_workflow"));
376 assert_eq!(m2.participants.total_agents, 6);
377 assert_eq!(m2.hosts.len(), 1);
378 assert_eq!(m2.room.as_ref().unwrap().room_id, "room_001");
379 assert_eq!(m2.room.as_ref().unwrap().participants.len(), 2);
380 }
381
382 #[test]
383 fn legacy_manifest_has_no_room() {
384 let json = r#"{
388 "session_id": "ssn_legacy",
389 "actor": "ship://local",
390 "started_at": "2026-04-05T08:00:00Z",
391 "started_at_ms": 1743843600000,
392 "artifact_count": 0
393 }"#;
394 let m: SessionManifest = serde_json::from_str(json).unwrap();
395 assert!(m.room.is_none());
396 }
397
398 #[test]
399 fn room_omitted_from_json_when_absent() {
400 let m = SessionManifest::new(
403 "ssn_plain".into(),
404 "ship://local".into(),
405 "2026-04-05T08:00:00Z".into(),
406 1743843600000,
407 );
408 let json = serde_json::to_string(&m).unwrap();
409 assert!(!json.contains("\"room\""));
410 }
411
412 #[test]
413 fn invitation_authority_defaults_to_host_only() {
414 assert_eq!(
415 InvitationAuthority::default(),
416 InvitationAuthority::HostOnly
417 );
418 }
419}