treeship-core 0.24.0

Portable trust receipts for agent workflows - core library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! Enhanced session manifest for Session Receipt v1.

use serde::{Deserialize, Serialize};

/// Session lifecycle mode.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleMode {
    /// User explicitly starts and ends the session.
    Manual,
    /// Auto-starts when registered agents begin activity in a watched workspace.
    AutoWorkspace,
    /// Day-level session with optional mission segments.
    DailyRollup,
}

impl Default for LifecycleMode {
    fn default() -> Self {
        Self::AutoWorkspace
    }
}

/// Summary of all participants in a session.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Participants {
    /// Instance ID of the root agent that initiated the session.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub root_agent_instance_id: Option<String>,

    /// Instance ID of the agent that produced the final output.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub final_output_agent_instance_id: Option<String>,

    /// Total number of distinct agents involved.
    #[serde(default)]
    pub total_agents: u32,

    /// Number of sub-agents spawned during the session.
    #[serde(default)]
    pub spawned_subagents: u32,

    /// Total number of handoffs between agents.
    #[serde(default)]
    pub handoffs: u32,

    /// Deepest agent delegation chain depth.
    #[serde(default)]
    pub max_depth: u32,

    /// Number of distinct hosts involved.
    #[serde(default)]
    pub hosts: u32,

    /// Number of distinct tool runtimes involved.
    #[serde(default)]
    pub tool_runtimes: u32,
}

/// Information about a host involved in the session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostInfo {
    pub host_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hostname: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub os: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub arch: Option<String>,
}

/// Information about a tool runtime involved in the session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolInfo {
    pub tool_id: String,
    pub tool_name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_runtime_id: Option<String>,
    #[serde(default)]
    pub invocation_count: u32,
}

/// Session status.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SessionStatus {
    Active,
    Completed,
    Failed,
    Abandoned,
}

impl Default for SessionStatus {
    fn default() -> Self {
        Self::Active
    }
}

/// Who may mint invitations for a room. Mirrors the Q3 decision in
/// `docs/specs/agent-invitations-rooms.md`: HostOnly is the default,
/// DelegatedTo and Open are explicit opt-in.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InvitationAuthority {
    /// Only the room's host key may mint invitations.
    HostOnly,
    /// The host plus a named list of delegate pubkeys may mint invitations.
    DelegatedTo { delegates: Vec<String> },
    /// Any current participant may mint invitations.
    Open,
}

impl Default for InvitationAuthority {
    fn default() -> Self {
        Self::HostOnly
    }
}

/// Room wrapper around a session, per `docs/specs/agent-invitations-rooms.md`
/// Phase 2 ("room concept"). A room is a session whose participant set is
/// expected to evolve over time via invitations rather than being fixed at
/// start; this struct carries the fields the spec proposes on top of the
/// plain session/invitation/participant primitives that already ship.
///
/// `room` is `Option` on `SessionManifest` -- most sessions are not rooms.
/// Absent entirely on legacy manifests and on any session that never calls
/// `treeship room create`.
///
/// **Signed, but not yet enforced.** `SessionManifest` is local working
/// state; the signed artifact is the `session.v1` receipt. As of #266 the
/// composer DOES copy this field into that receipt (`receipt.rs`, in
/// `compose_with_custody`), so `room` -- including `invitation_authority` --
/// is bound into the DSSE-signed bytes.
///
/// That closes half the gap. It does NOT make `invitation_authority`
/// trustworthy as an authorization input: the receipt attests what the host
/// wrote at close time, and nothing verifies that the invitations actually
/// minted in the session conform to it. So a receipt can honestly attest
/// `DelegatedTo{[X]}` while an invitation from Y sits in the same session.
///
/// The remaining work is conformance checking -- the spec's Phase 3
/// `participation_conformance` row -- and until it lands, treat
/// `invitation_authority` as a signed CLAIM, not an enforced rule. `treeship
/// room` today displays it and gates nothing, which is the honest posture.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RoomInfo {
    /// Stable room identifier, distinct from `session_id` -- a room can in
    /// principle outlive the session that hosts it (roadmap; today the two
    /// are 1:1).
    pub room_id: String,

    /// The room's signing authority. Base64url-no-pad Ed25519 public key,
    /// same encoding as `SessionParticipantStatement::joining_agent`. This
    /// is the pubkey invitations are issued under and that a joining
    /// agent's participant event is countersigned by.
    pub host_pubkey: String,

    #[serde(default)]
    pub invitation_authority: InvitationAuthority,

    /// Optional workflow this room's participants are bound to (Phase 3 of
    /// the spec, PR #107 -- carried here now so the field name is settled
    /// before that lands).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workflow_ref: Option<String>,

    /// How often the room commits a Merkle checkpoint, independent of
    /// session close, expressed as an action count. Typed rather than the
    /// free-form string the spec's prose examples use ("50actions", "15m")
    /// because this value is headed for canonical bytes once room joins
    /// the signed receipt; a duration-based cadence can be added as a
    /// separate typed variant if/when something actually needs it, rather
    /// than smuggling units inside a string now.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub checkpoint_every_actions: Option<u32>,

    /// Finalized (both-signed) participant artifact ids, in join order.
    /// A pending (single-signed, not yet countersigned) join does not
    /// appear here.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub participants: Vec<String>,
}

impl RoomInfo {
    pub fn new(room_id: impl Into<String>, host_pubkey: impl Into<String>) -> Self {
        Self {
            room_id: room_id.into(),
            host_pubkey: host_pubkey.into(),
            invitation_authority: InvitationAuthority::default(),
            workflow_ref: None,
            checkpoint_every_actions: None,
            participants: Vec::new(),
        }
    }
}

/// Enhanced session manifest for Session Receipt v1.
///
/// Backward-compatible with the original CLI SessionManifest:
/// all new fields use `#[serde(default)]` so old session.json files
/// deserialize without error.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionManifest {
    pub session_id: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,

    pub actor: String,

    pub started_at: String,

    #[serde(default)]
    pub started_at_ms: u64,

    #[serde(default)]
    pub artifact_count: u64,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub root_artifact_id: Option<String>,

    // --- v1 fields below ---
    #[serde(default)]
    pub mode: LifecycleMode,

    #[serde(default)]
    pub status: SessionStatus,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub workspace_id: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub mission_id: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub closed_at: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub close_artifact_id: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,

    #[serde(default)]
    pub participants: Participants,

    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hosts: Vec<HostInfo>,

    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tools: Vec<ToolInfo>,

    /// Tools declared as authorized for this session (from declaration.json).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub authorized_tools: Vec<String>,

    /// Git HEAD SHA captured at session start, when the project is a
    /// git repo. Used by session::close to compute committed-during-
    /// session changes via `git diff <sha>..HEAD` for the
    /// reconciliation pass. Absent for non-git projects or for
    /// sessions started before this field existed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub start_commit_sha: Option<String>,

    /// Set by `treeship room create`. Absent for ordinary (non-room)
    /// sessions and for any manifest written before this field existed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub room: Option<RoomInfo>,
}

impl SessionManifest {
    /// Create a new manifest with required fields; v1 fields default.
    pub fn new(session_id: String, actor: String, started_at: String, started_at_ms: u64) -> Self {
        Self {
            session_id,
            name: None,
            actor,
            started_at,
            started_at_ms,
            artifact_count: 0,
            root_artifact_id: None,
            mode: LifecycleMode::default(),
            status: SessionStatus::Active,
            workspace_id: None,
            mission_id: None,
            closed_at: None,
            close_artifact_id: None,
            summary: None,
            participants: Participants::default(),
            hosts: Vec::new(),
            tools: Vec::new(),
            authorized_tools: Vec::new(),
            start_commit_sha: None,
            room: None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn deserialize_legacy_manifest() {
        // Old format without v1 fields should still deserialize
        let json = r#"{
            "session_id": "ssn_abc123",
            "name": "test",
            "actor": "ship://local",
            "started_at": "2026-04-05T08:00:00Z",
            "started_at_ms": 1743843600000,
            "artifact_count": 5,
            "root_artifact_id": "art_deadbeef"
        }"#;
        let m: SessionManifest = serde_json::from_str(json).unwrap();
        assert_eq!(m.session_id, "ssn_abc123");
        assert_eq!(m.mode, LifecycleMode::AutoWorkspace);
        assert_eq!(m.status, SessionStatus::Active);
        assert_eq!(m.participants.total_agents, 0);
    }

    #[test]
    fn roundtrip_full_manifest() {
        let m = SessionManifest {
            session_id: "ssn_001".into(),
            name: Some("daily dev".into()),
            actor: "agent://claude".into(),
            started_at: "2026-04-05T08:00:00Z".into(),
            started_at_ms: 1743843600000,
            artifact_count: 12,
            root_artifact_id: Some("art_root".into()),
            mode: LifecycleMode::Manual,
            status: SessionStatus::Completed,
            workspace_id: Some("ws_abc".into()),
            mission_id: None,
            closed_at: Some("2026-04-05T12:00:00Z".into()),
            close_artifact_id: Some("art_close".into()),
            summary: Some("Fixed auth bug".into()),
            participants: Participants {
                root_agent_instance_id: Some("ai_root_1".into()),
                final_output_agent_instance_id: Some("ai_review_2".into()),
                total_agents: 6,
                spawned_subagents: 4,
                handoffs: 7,
                max_depth: 3,
                hosts: 2,
                tool_runtimes: 5,
            },
            hosts: vec![HostInfo {
                host_id: "host_1".into(),
                hostname: Some("macbook".into()),
                os: Some("darwin".into()),
                arch: Some("arm64".into()),
            }],
            tools: vec![ToolInfo {
                tool_id: "tool_1".into(),
                tool_name: "claude-code".into(),
                tool_runtime_id: Some("rt_cc1".into()),
                invocation_count: 42,
            }],
            authorized_tools: vec!["read_file".into(), "write_file".into()],
            start_commit_sha: Some("abc1234567890abcdef1234567890abcdef12345".into()),
            room: Some(RoomInfo {
                room_id: "room_001".into(),
                host_pubkey: "AbCdEf123".into(),
                invitation_authority: InvitationAuthority::DelegatedTo {
                    delegates: vec!["DeLeGaTe1".into()],
                },
                workflow_ref: Some("wf_abc".into()),
                checkpoint_every_actions: Some(50),
                participants: vec!["art_part_1".into(), "art_part_2".into()],
            }),
        };
        let json = serde_json::to_string_pretty(&m).unwrap();
        let m2: SessionManifest = serde_json::from_str(&json).unwrap();
        assert_eq!(m2.session_id, "ssn_001");
        assert_eq!(m2.participants.total_agents, 6);
        assert_eq!(m2.hosts.len(), 1);
        assert_eq!(m2.room.as_ref().unwrap().room_id, "room_001");
        assert_eq!(m2.room.as_ref().unwrap().participants.len(), 2);
    }

    #[test]
    fn legacy_manifest_has_no_room() {
        // A manifest predating the `room` field must still deserialize,
        // with `room` defaulting to `None` -- same backward-compat
        // contract every other v1 field already follows.
        let json = r#"{
            "session_id": "ssn_legacy",
            "actor": "ship://local",
            "started_at": "2026-04-05T08:00:00Z",
            "started_at_ms": 1743843600000,
            "artifact_count": 0
        }"#;
        let m: SessionManifest = serde_json::from_str(json).unwrap();
        assert!(m.room.is_none());
    }

    #[test]
    fn room_omitted_from_json_when_absent() {
        // Ordinary (non-room) sessions shouldn't grow a `"room": null` in
        // every session.json on disk.
        let m = SessionManifest::new(
            "ssn_plain".into(),
            "ship://local".into(),
            "2026-04-05T08:00:00Z".into(),
            1743843600000,
        );
        let json = serde_json::to_string(&m).unwrap();
        assert!(!json.contains("\"room\""));
    }

    #[test]
    fn invitation_authority_defaults_to_host_only() {
        assert_eq!(
            InvitationAuthority::default(),
            InvitationAuthority::HostOnly
        );
    }
}