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")]
8#[derive(Default)]
9pub enum LifecycleMode {
10    /// User explicitly starts and ends the session.
11    Manual,
12    /// Auto-starts when registered agents begin activity in a watched workspace.
13    #[default]
14    AutoWorkspace,
15    /// Day-level session with optional mission segments.
16    DailyRollup,
17}
18
19/// Summary of all participants in a session.
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct Participants {
22    /// Instance ID of the root agent that initiated the session.
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub root_agent_instance_id: Option<String>,
25
26    /// Instance ID of the agent that produced the final output.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub final_output_agent_instance_id: Option<String>,
29
30    /// Total number of distinct agents involved.
31    #[serde(default)]
32    pub total_agents: u32,
33
34    /// Number of sub-agents spawned during the session.
35    #[serde(default)]
36    pub spawned_subagents: u32,
37
38    /// Total number of handoffs between agents.
39    #[serde(default)]
40    pub handoffs: u32,
41
42    /// Deepest agent delegation chain depth.
43    #[serde(default)]
44    pub max_depth: u32,
45
46    /// Number of distinct hosts involved.
47    #[serde(default)]
48    pub hosts: u32,
49
50    /// Number of distinct tool runtimes involved.
51    #[serde(default)]
52    pub tool_runtimes: u32,
53}
54
55/// Information about a host involved in the session.
56#[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/// Information about a tool runtime involved in the session.
68#[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/// Session status.
79#[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/// Who may mint invitations for a room. Mirrors the Q3 decision in
91/// `docs/specs/agent-invitations-rooms.md`: HostOnly is the default,
92/// DelegatedTo and Open are explicit opt-in.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(tag = "kind", rename_all = "snake_case")]
95#[derive(Default)]
96pub enum InvitationAuthority {
97    /// Only the room's host key may mint invitations.
98    #[default]
99    HostOnly,
100    /// The host plus a named list of delegate pubkeys may mint invitations.
101    DelegatedTo { delegates: Vec<String> },
102    /// Any current participant may mint invitations.
103    Open,
104}
105
106/// Room wrapper around a session, per `docs/specs/agent-invitations-rooms.md`
107/// Phase 2 ("room concept"). A room is a session whose participant set is
108/// expected to evolve over time via invitations rather than being fixed at
109/// start; this struct carries the fields the spec proposes on top of the
110/// plain session/invitation/participant primitives that already ship.
111///
112/// `room` is `Option` on `SessionManifest` -- most sessions are not rooms.
113/// Absent entirely on legacy manifests and on any session that never calls
114/// `treeship room create`.
115///
116/// **Signed, but not yet enforced.** `SessionManifest` is local working
117/// state; the signed artifact is the `session.v1` receipt. As of #266 the
118/// composer DOES copy this field into that receipt (`receipt.rs`, in
119/// `compose_with_custody`), so `room` -- including `invitation_authority` --
120/// is bound into the DSSE-signed bytes.
121///
122/// That closes half the gap. It does NOT make `invitation_authority`
123/// trustworthy as an authorization input: the receipt attests what the host
124/// wrote at close time, and nothing verifies that the invitations actually
125/// minted in the session conform to it. So a receipt can honestly attest
126/// `DelegatedTo{[X]}` while an invitation from Y sits in the same session.
127///
128/// The remaining work is conformance checking -- the spec's Phase 3
129/// `participation_conformance` row -- and until it lands, treat
130/// `invitation_authority` as a signed CLAIM, not an enforced rule. `treeship
131/// room` today displays it and gates nothing, which is the honest posture.
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct RoomInfo {
134    /// Stable room identifier, distinct from `session_id` -- a room can in
135    /// principle outlive the session that hosts it (roadmap; today the two
136    /// are 1:1).
137    pub room_id: String,
138
139    /// The room's signing authority. Base64url-no-pad Ed25519 public key,
140    /// same encoding as `SessionParticipantStatement::joining_agent`. This
141    /// is the pubkey invitations are issued under and that a joining
142    /// agent's participant event is countersigned by.
143    pub host_pubkey: String,
144
145    #[serde(default)]
146    pub invitation_authority: InvitationAuthority,
147
148    /// Optional workflow this room's participants are bound to (Phase 3 of
149    /// the spec, PR #107 -- carried here now so the field name is settled
150    /// before that lands).
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub workflow_ref: Option<String>,
153
154    /// How often the room commits a Merkle checkpoint, independent of
155    /// session close, expressed as an action count. Typed rather than the
156    /// free-form string the spec's prose examples use ("50actions", "15m")
157    /// because this value is headed for canonical bytes once room joins
158    /// the signed receipt; a duration-based cadence can be added as a
159    /// separate typed variant if/when something actually needs it, rather
160    /// than smuggling units inside a string now.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub checkpoint_every_actions: Option<u32>,
163
164    /// Finalized (both-signed) participant artifact ids, in join order.
165    /// A pending (single-signed, not yet countersigned) join does not
166    /// appear here.
167    #[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/// Enhanced session manifest for Session Receipt v1.
185///
186/// Backward-compatible with the original CLI SessionManifest:
187/// all new fields use `#[serde(default)]` so old session.json files
188/// deserialize without error.
189#[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    /// Signed workflow declaration selected before this session began.
210    ///
211    /// The session-start root action carries the same reference in its signed
212    /// `meta`, so this local manifest field is a convenience copy rather than
213    /// the authority. The composed `session.v1` receipt mirrors it as well.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub workflow_ref: Option<String>,
216
217    // --- v1 fields below ---
218    #[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    /// Tools declared as authorized for this session (from declaration.json).
249    #[serde(default, skip_serializing_if = "Vec::is_empty")]
250    pub authorized_tools: Vec<String>,
251
252    /// Git HEAD SHA captured at session start, when the project is a
253    /// git repo. Used by session::close to compute committed-during-
254    /// session changes via `git diff <sha>..HEAD` for the
255    /// reconciliation pass. Absent for non-git projects or for
256    /// sessions started before this field existed.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub start_commit_sha: Option<String>,
259
260    /// Set by `treeship room create`. Absent for ordinary (non-room)
261    /// sessions and for any manifest written before this field existed.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub room: Option<RoomInfo>,
264}
265
266impl SessionManifest {
267    /// Create a new manifest with required fields; v1 fields default.
268    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        // Old format without v1 fields should still deserialize
302        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        // A manifest predating the `room` field must still deserialize,
385        // with `room` defaulting to `None` -- same backward-compat
386        // contract every other v1 field already follows.
387        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        // Ordinary (non-room) sessions shouldn't grow a `"room": null` in
401        // every session.json on disk.
402        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}