Skip to main content

muse_codes/io/
mod.rs

1//! Typed models of the `muse exec --json` JSONL event stream.
2//!
3//! Every stdout line is one [`MuseRecord`] — an event-sourced journal
4//! envelope with a lazily-typed payload. The envelope is fully typed; the
5//! payload stays raw JSON on the record (so round-trips are byte-faithful
6//! and unknown future payload types survive) and is lifted into a
7//! [`MusePayload`] on demand via [`MuseRecord::typed_payload`].
8//!
9//! Shapes in this module are derived from **captured real output** of
10//! Muse Code (see `test_cases/*.jsonl`), not from documentation — the wire
11//! is the contract. Payload types not yet observed (the journal also
12//! records approvals, edits, and subagent lifecycle under a live provider)
13//! deserialize as [`MusePayload::Unknown`] rather than failing.
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18/// One line of the `muse exec --json` stream: the journal envelope.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct MuseRecord {
21    /// Envelope schema version (observed: `1`).
22    pub schema_version: u32,
23    /// Unique record id (UUIDv7-style, monotonic within a stream).
24    pub id: String,
25    /// The stream this record belongs to.
26    pub stream: StreamRef,
27    /// 1-based position within `stream`.
28    pub sequence: u64,
29    /// Microseconds since the Unix epoch.
30    pub recorded_at: u64,
31    pub record_type: RecordType,
32    pub durability: Durability,
33    /// Id of the command that caused this record.
34    pub causation_id: String,
35    /// Dotted payload discriminator, e.g. `run.output.delta`.
36    pub payload_type: String,
37    /// Version of the payload's own schema (observed: `1`).
38    pub payload_schema_version: u32,
39    /// Raw payload — lift with [`MuseRecord::typed_payload`].
40    pub payload: Value,
41}
42
43impl MuseRecord {
44    /// Parse the payload into its typed form based on `payload_type`.
45    ///
46    /// Unknown payload types return [`MusePayload::Unknown`] carrying the
47    /// raw value; a payload that fails to match its expected shape is a
48    /// deserialization error (wire drift worth surfacing, not masking).
49    pub fn typed_payload(&self) -> serde_json::Result<MusePayload> {
50        MusePayload::from_parts(&self.payload_type, self.payload.clone())
51    }
52}
53
54/// Reference to a journal stream.
55#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
56pub struct StreamRef {
57    pub kind: StreamKind,
58    pub id: String,
59}
60
61/// Journal stream classes.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum StreamKind {
65    Session,
66    Run,
67    Task,
68}
69
70/// Journal record classes.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum RecordType {
74    /// Replay-exact state reconciliation (e.g. command acceptance).
75    Reconciliation,
76    /// Durable domain event.
77    Event,
78    /// Ephemeral progress/status (e.g. output deltas).
79    Status,
80}
81
82/// Whether the record survives restart/replay.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum Durability {
86    Durable,
87    Ephemeral,
88}
89
90/// Typed payload of a [`MuseRecord`], discriminated by `payload_type`.
91#[derive(Debug, Clone, PartialEq)]
92pub enum MusePayload {
93    /// `runtime.command.accepted`
94    CommandAccepted(CommandAccepted),
95    /// `session.run.linked`
96    SessionRunLinked(SessionRunLinked),
97    /// `turn.input.user`
98    TurnInputUser(TurnInputUser),
99    /// `run.lifecycle.started`
100    RunStarted(RunStarted),
101    /// `run.model.configured`
102    ModelConfigured(ModelConfigured),
103    /// `run.output.delta`
104    RunOutputDelta(RunOutputDelta),
105    /// `tool.result`
106    ToolResult(ToolResult),
107    /// `run.terminal.completed` (and any future `run.terminal.*`)
108    RunTerminal(RunTerminal),
109    /// `task.stream.linked`
110    TaskStreamLinked(TaskStreamLinked),
111    /// `task.lifecycle.*`
112    TaskLifecycle(TaskLifecycle),
113    /// A payload type not yet known to this crate — preserved verbatim.
114    Unknown {
115        payload_type: String,
116        payload: Value,
117    },
118}
119
120impl MusePayload {
121    pub fn from_parts(payload_type: &str, payload: Value) -> serde_json::Result<Self> {
122        Ok(match payload_type {
123            "runtime.command.accepted" => {
124                MusePayload::CommandAccepted(serde_json::from_value(payload)?)
125            }
126            "session.run.linked" => MusePayload::SessionRunLinked(serde_json::from_value(payload)?),
127            "turn.input.user" => MusePayload::TurnInputUser(serde_json::from_value(payload)?),
128            "run.lifecycle.started" => MusePayload::RunStarted(serde_json::from_value(payload)?),
129            "run.model.configured" => {
130                MusePayload::ModelConfigured(serde_json::from_value(payload)?)
131            }
132            "tool.result" => MusePayload::ToolResult(serde_json::from_value(payload)?),
133            "run.output.delta" => MusePayload::RunOutputDelta(serde_json::from_value(payload)?),
134            t if t.starts_with("run.terminal.") => {
135                MusePayload::RunTerminal(serde_json::from_value(payload)?)
136            }
137            "task.stream.linked" => MusePayload::TaskStreamLinked(serde_json::from_value(payload)?),
138            t if t.starts_with("task.lifecycle.") => {
139                MusePayload::TaskLifecycle(serde_json::from_value(payload)?)
140            }
141            other => MusePayload::Unknown {
142                payload_type: other.to_string(),
143                payload,
144            },
145        })
146    }
147}
148
149/// `runtime.command.accepted` — the runtime took ownership of a submitted
150/// command (`command_kind`, e.g. `turn.submit`).
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct CommandAccepted {
153    pub kind: String,
154    pub command_id: String,
155    pub command_kind: String,
156    pub client_id: Option<String>,
157    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
158    pub extra: serde_json::Map<String, Value>,
159}
160
161/// `session.run.linked` — a run stream was attached to the session.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct SessionRunLinked {
164    pub kind: String,
165    pub command_id: String,
166    pub run_stream: StreamRef,
167    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
168    pub extra: serde_json::Map<String, Value>,
169}
170
171/// `turn.input.user` — the user prompt as the runtime recorded it.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct TurnInputUser {
174    pub kind: String,
175    pub command_id: String,
176    pub prompt: String,
177    pub run_stream: StreamRef,
178    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
179    pub extra: serde_json::Map<String, Value>,
180}
181
182/// `run.lifecycle.started`
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct RunStarted {
185    pub kind: String,
186    pub command_id: String,
187    pub prompt: String,
188    pub run_stream: StreamRef,
189    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
190    pub extra: serde_json::Map<String, Value>,
191}
192
193/// `run.output.delta` — streamed model/agent output text.
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct RunOutputDelta {
196    pub kind: String,
197    pub command_id: String,
198    pub run_stream: StreamRef,
199    pub text: String,
200    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
201    pub extra: serde_json::Map<String, Value>,
202}
203
204/// `run.model.configured` — which model/profile/provider the run resolved
205/// to (live providers only; the echo provider never emits it).
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207pub struct ModelConfigured {
208    pub kind: String,
209    pub command_id: String,
210    pub run_stream: StreamRef,
211    pub model_id: String,
212    pub display_label: String,
213    pub profile_id: String,
214    pub provider_id: String,
215    /// How the model was chosen (`startup` observed).
216    pub source: String,
217    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
218    pub extra: serde_json::Map<String, Value>,
219}
220
221/// `tool.result` — outcome of one tool invocation (live providers only).
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223pub struct ToolResult {
224    pub kind: String,
225    pub command_id: String,
226    pub run_stream: StreamRef,
227    /// Provider call id this result answers.
228    pub call_id: String,
229    /// Result text as shown to the model (including failure prose).
230    pub text: String,
231    /// Correlation summary — observed `{outcome, tool_name}`, open-shaped.
232    pub correlation_facts: Value,
233    /// Populated for file-editing tools; open-shaped.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub edit_facts: Option<Value>,
236    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
237    pub extra: serde_json::Map<String, Value>,
238}
239
240/// `run.terminal.*` — the run reached a terminal state. `terminal` carries
241/// the state (`completed` observed); `text` the final output; `reason` is
242/// populated on abnormal endings.
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244pub struct RunTerminal {
245    pub kind: String,
246    pub command_id: String,
247    pub run_stream: StreamRef,
248    pub terminal: String,
249    pub reason: Option<String>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub text: Option<String>,
252    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
253    pub extra: serde_json::Map<String, Value>,
254}
255
256/// `task.stream.linked` — a task stream was attached to a run.
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258pub struct TaskStreamLinked {
259    pub kind: String,
260    pub command_id: String,
261    pub run_stream: StreamRef,
262    pub task_id: String,
263    pub task_stream: StreamRef,
264    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
265    pub extra: serde_json::Map<String, Value>,
266}
267
268/// `task.lifecycle.*` — one step in a task's lifecycle state machine.
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270pub struct TaskLifecycle {
271    pub kind: String,
272    pub command_id: String,
273    pub run_stream: StreamRef,
274    pub task_id: String,
275    pub task_stream: StreamRef,
276    pub event: TaskLifecycleEvent,
277    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
278    pub extra: serde_json::Map<String, Value>,
279}
280
281/// The `event` member of [`TaskLifecycle`], tagged by `kind`.
282///
283/// Observed lifecycle: `proposed → accepted → started → (scheduled →
284/// side_effect_intent →) completed | failed`.
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286#[serde(tag = "kind", rename_all = "snake_case")]
287pub enum TaskLifecycleEvent {
288    Proposed {
289        task_id: String,
290        /// Dotted task class, e.g. `model.unknown.response` or
291        /// `reminder.agent.plugin:<plugin>:<name>`.
292        task_kind: String,
293    },
294    Accepted {
295        task_id: String,
296    },
297    Started {
298        task_id: String,
299        /// Tracing span id (live providers attach one; echo does not).
300        #[serde(default, skip_serializing_if = "Option::is_none")]
301        span_id: Option<String>,
302    },
303    Scheduled {
304        task_id: String,
305        idempotency_key: String,
306    },
307    SideEffectIntent {
308        task_id: String,
309        idempotency_key: String,
310        operation: String,
311        policy_decision: String,
312        parent_task_id: Option<String>,
313        cancellation_handle: Option<Value>,
314    },
315    /// Free-form progress (`message` + faceted `details`), e.g. model
316    /// stream attempts.
317    Status {
318        task_id: String,
319        message: String,
320        details: Value,
321    },
322    /// Streamed task output chunk (e.g. tool stdout summaries).
323    Output {
324        task_id: String,
325        chunk: String,
326    },
327    Completed {
328        task_id: String,
329    },
330    Cancelled {
331        task_id: String,
332        reason: String,
333    },
334    Rejected {
335        task_id: String,
336        reason: String,
337    },
338    Failed {
339        task_id: String,
340        reason: String,
341    },
342    /// A lifecycle kind not yet known to this crate.
343    #[serde(untagged)]
344    Unknown(Value),
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use serde_json::json;
351
352    #[test]
353    fn unknown_payload_type_is_preserved_not_error() {
354        let p = MusePayload::from_parts("subagent.lifecycle.spawned", json!({"x": 1})).unwrap();
355        match p {
356            MusePayload::Unknown {
357                payload_type,
358                payload,
359            } => {
360                assert_eq!(payload_type, "subagent.lifecycle.spawned");
361                assert_eq!(payload, json!({"x": 1}));
362            }
363            other => panic!("expected Unknown, got {other:?}"),
364        }
365    }
366
367    #[test]
368    fn task_lifecycle_failed_carries_reason() {
369        let e: TaskLifecycleEvent = serde_json::from_value(json!({
370            "kind": "failed",
371            "task_id": "t1",
372            "reason": "provider does not support base instructions"
373        }))
374        .unwrap();
375        assert!(matches!(e, TaskLifecycleEvent::Failed { ref reason, .. }
376            if reason.contains("base instructions")));
377    }
378}