1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "snake_case")]
8pub enum LifecycleMode {
9 Manual,
11 AutoWorkspace,
13 DailyRollup,
15}
16
17impl Default for LifecycleMode {
18 fn default() -> Self {
19 Self::AutoWorkspace
20 }
21}
22
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct Participants {
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub root_agent_instance_id: Option<String>,
29
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub final_output_agent_instance_id: Option<String>,
33
34 #[serde(default)]
36 pub total_agents: u32,
37
38 #[serde(default)]
40 pub spawned_subagents: u32,
41
42 #[serde(default)]
44 pub handoffs: u32,
45
46 #[serde(default)]
48 pub max_depth: u32,
49
50 #[serde(default)]
52 pub hosts: u32,
53
54 #[serde(default)]
56 pub tool_runtimes: u32,
57}
58
59#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(tag = "kind", rename_all = "snake_case")]
103pub enum InvitationAuthority {
104 HostOnly,
106 DelegatedTo { delegates: Vec<String> },
108 Open,
110}
111
112impl Default for InvitationAuthority {
113 fn default() -> Self {
114 Self::HostOnly
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub struct RoomInfo {
146 pub room_id: String,
150
151 pub host_pubkey: String,
156
157 #[serde(default)]
158 pub invitation_authority: InvitationAuthority,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub workflow_ref: Option<String>,
165
166 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub checkpoint_every_actions: Option<u32>,
175
176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub participants: Vec<String>,
181}
182
183impl RoomInfo {
184 pub fn new(room_id: impl Into<String>, host_pubkey: impl Into<String>) -> Self {
185 Self {
186 room_id: room_id.into(),
187 host_pubkey: host_pubkey.into(),
188 invitation_authority: InvitationAuthority::default(),
189 workflow_ref: None,
190 checkpoint_every_actions: None,
191 participants: Vec::new(),
192 }
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct SessionManifest {
203 pub session_id: String,
204
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub name: Option<String>,
207
208 pub actor: String,
209
210 pub started_at: String,
211
212 #[serde(default)]
213 pub started_at_ms: u64,
214
215 #[serde(default)]
216 pub artifact_count: u64,
217
218 #[serde(skip_serializing_if = "Option::is_none")]
219 pub root_artifact_id: Option<String>,
220
221 #[serde(default)]
223 pub mode: LifecycleMode,
224
225 #[serde(default)]
226 pub status: SessionStatus,
227
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub workspace_id: Option<String>,
230
231 #[serde(skip_serializing_if = "Option::is_none")]
232 pub mission_id: Option<String>,
233
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub closed_at: Option<String>,
236
237 #[serde(skip_serializing_if = "Option::is_none")]
238 pub close_artifact_id: Option<String>,
239
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub summary: Option<String>,
242
243 #[serde(default)]
244 pub participants: Participants,
245
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
247 pub hosts: Vec<HostInfo>,
248
249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 pub tools: Vec<ToolInfo>,
251
252 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 pub authorized_tools: Vec<String>,
255
256 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub start_commit_sha: Option<String>,
263
264 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub room: Option<RoomInfo>,
268}
269
270impl SessionManifest {
271 pub fn new(session_id: String, actor: String, started_at: String, started_at_ms: u64) -> Self {
273 Self {
274 session_id,
275 name: None,
276 actor,
277 started_at,
278 started_at_ms,
279 artifact_count: 0,
280 root_artifact_id: None,
281 mode: LifecycleMode::default(),
282 status: SessionStatus::Active,
283 workspace_id: None,
284 mission_id: None,
285 closed_at: None,
286 close_artifact_id: None,
287 summary: None,
288 participants: Participants::default(),
289 hosts: Vec::new(),
290 tools: Vec::new(),
291 authorized_tools: Vec::new(),
292 start_commit_sha: None,
293 room: None,
294 }
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn deserialize_legacy_manifest() {
304 let json = r#"{
306 "session_id": "ssn_abc123",
307 "name": "test",
308 "actor": "ship://local",
309 "started_at": "2026-04-05T08:00:00Z",
310 "started_at_ms": 1743843600000,
311 "artifact_count": 5,
312 "root_artifact_id": "art_deadbeef"
313 }"#;
314 let m: SessionManifest = serde_json::from_str(json).unwrap();
315 assert_eq!(m.session_id, "ssn_abc123");
316 assert_eq!(m.mode, LifecycleMode::AutoWorkspace);
317 assert_eq!(m.status, SessionStatus::Active);
318 assert_eq!(m.participants.total_agents, 0);
319 }
320
321 #[test]
322 fn roundtrip_full_manifest() {
323 let m = SessionManifest {
324 session_id: "ssn_001".into(),
325 name: Some("daily dev".into()),
326 actor: "agent://claude".into(),
327 started_at: "2026-04-05T08:00:00Z".into(),
328 started_at_ms: 1743843600000,
329 artifact_count: 12,
330 root_artifact_id: Some("art_root".into()),
331 mode: LifecycleMode::Manual,
332 status: SessionStatus::Completed,
333 workspace_id: Some("ws_abc".into()),
334 mission_id: None,
335 closed_at: Some("2026-04-05T12:00:00Z".into()),
336 close_artifact_id: Some("art_close".into()),
337 summary: Some("Fixed auth bug".into()),
338 participants: Participants {
339 root_agent_instance_id: Some("ai_root_1".into()),
340 final_output_agent_instance_id: Some("ai_review_2".into()),
341 total_agents: 6,
342 spawned_subagents: 4,
343 handoffs: 7,
344 max_depth: 3,
345 hosts: 2,
346 tool_runtimes: 5,
347 },
348 hosts: vec![HostInfo {
349 host_id: "host_1".into(),
350 hostname: Some("macbook".into()),
351 os: Some("darwin".into()),
352 arch: Some("arm64".into()),
353 }],
354 tools: vec![ToolInfo {
355 tool_id: "tool_1".into(),
356 tool_name: "claude-code".into(),
357 tool_runtime_id: Some("rt_cc1".into()),
358 invocation_count: 42,
359 }],
360 authorized_tools: vec!["read_file".into(), "write_file".into()],
361 start_commit_sha: Some("abc1234567890abcdef1234567890abcdef12345".into()),
362 room: Some(RoomInfo {
363 room_id: "room_001".into(),
364 host_pubkey: "AbCdEf123".into(),
365 invitation_authority: InvitationAuthority::DelegatedTo {
366 delegates: vec!["DeLeGaTe1".into()],
367 },
368 workflow_ref: Some("wf_abc".into()),
369 checkpoint_every_actions: Some(50),
370 participants: vec!["art_part_1".into(), "art_part_2".into()],
371 }),
372 };
373 let json = serde_json::to_string_pretty(&m).unwrap();
374 let m2: SessionManifest = serde_json::from_str(&json).unwrap();
375 assert_eq!(m2.session_id, "ssn_001");
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}