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