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    // --- v1 fields below ---
210    #[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    /// Tools declared as authorized for this session (from declaration.json).
241    #[serde(default, skip_serializing_if = "Vec::is_empty")]
242    pub authorized_tools: Vec<String>,
243
244    /// Git HEAD SHA captured at session start, when the project is a
245    /// git repo. Used by session::close to compute committed-during-
246    /// session changes via `git diff <sha>..HEAD` for the
247    /// reconciliation pass. Absent for non-git projects or for
248    /// sessions started before this field existed.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub start_commit_sha: Option<String>,
251
252    /// Set by `treeship room create`. Absent for ordinary (non-room)
253    /// sessions and for any manifest written before this field existed.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub room: Option<RoomInfo>,
256}
257
258impl SessionManifest {
259    /// Create a new manifest with required fields; v1 fields default.
260    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        // Old format without v1 fields should still deserialize
293        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        // A manifest predating the `room` field must still deserialize,
373        // with `room` defaulting to `None` -- same backward-compat
374        // contract every other v1 field already follows.
375        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        // Ordinary (non-room) sessions shouldn't grow a `"room": null` in
389        // every session.json on disk.
390        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}