use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MuseRecord {
pub schema_version: u32,
pub id: String,
pub stream: StreamRef,
pub sequence: u64,
pub recorded_at: u64,
pub record_type: RecordType,
pub durability: Durability,
pub causation_id: String,
pub payload_type: String,
pub payload_schema_version: u32,
pub payload: Value,
}
impl MuseRecord {
pub fn typed_payload(&self) -> serde_json::Result<MusePayload> {
MusePayload::from_parts(&self.payload_type, self.payload.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct StreamRef {
pub kind: StreamKind,
pub id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamKind {
Session,
Run,
Task,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecordType {
Reconciliation,
Event,
Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Durability {
Durable,
Ephemeral,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MusePayload {
CommandAccepted(CommandAccepted),
SessionRunLinked(SessionRunLinked),
TurnInputUser(TurnInputUser),
RunStarted(RunStarted),
RunOutputDelta(RunOutputDelta),
RunTerminal(RunTerminal),
TaskStreamLinked(TaskStreamLinked),
TaskLifecycle(TaskLifecycle),
Unknown {
payload_type: String,
payload: Value,
},
}
impl MusePayload {
pub fn from_parts(payload_type: &str, payload: Value) -> serde_json::Result<Self> {
Ok(match payload_type {
"runtime.command.accepted" => {
MusePayload::CommandAccepted(serde_json::from_value(payload)?)
}
"session.run.linked" => MusePayload::SessionRunLinked(serde_json::from_value(payload)?),
"turn.input.user" => MusePayload::TurnInputUser(serde_json::from_value(payload)?),
"run.lifecycle.started" => MusePayload::RunStarted(serde_json::from_value(payload)?),
"run.output.delta" => MusePayload::RunOutputDelta(serde_json::from_value(payload)?),
t if t.starts_with("run.terminal.") => {
MusePayload::RunTerminal(serde_json::from_value(payload)?)
}
"task.stream.linked" => MusePayload::TaskStreamLinked(serde_json::from_value(payload)?),
t if t.starts_with("task.lifecycle.") => {
MusePayload::TaskLifecycle(serde_json::from_value(payload)?)
}
other => MusePayload::Unknown {
payload_type: other.to_string(),
payload,
},
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommandAccepted {
pub kind: String,
pub command_id: String,
pub command_kind: String,
pub client_id: Option<String>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionRunLinked {
pub kind: String,
pub command_id: String,
pub run_stream: StreamRef,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TurnInputUser {
pub kind: String,
pub command_id: String,
pub prompt: String,
pub run_stream: StreamRef,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunStarted {
pub kind: String,
pub command_id: String,
pub prompt: String,
pub run_stream: StreamRef,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunOutputDelta {
pub kind: String,
pub command_id: String,
pub run_stream: StreamRef,
pub text: String,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunTerminal {
pub kind: String,
pub command_id: String,
pub run_stream: StreamRef,
pub terminal: String,
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskStreamLinked {
pub kind: String,
pub command_id: String,
pub run_stream: StreamRef,
pub task_id: String,
pub task_stream: StreamRef,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskLifecycle {
pub kind: String,
pub command_id: String,
pub run_stream: StreamRef,
pub task_id: String,
pub task_stream: StreamRef,
pub event: TaskLifecycleEvent,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TaskLifecycleEvent {
Proposed {
task_id: String,
task_kind: String,
},
Accepted {
task_id: String,
},
Started {
task_id: String,
},
Scheduled {
task_id: String,
idempotency_key: String,
},
SideEffectIntent {
task_id: String,
idempotency_key: String,
operation: String,
policy_decision: String,
parent_task_id: Option<String>,
cancellation_handle: Option<Value>,
},
Completed {
task_id: String,
},
Failed {
task_id: String,
reason: String,
},
#[serde(untagged)]
Unknown(Value),
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn unknown_payload_type_is_preserved_not_error() {
let p = MusePayload::from_parts("subagent.lifecycle.spawned", json!({"x": 1})).unwrap();
match p {
MusePayload::Unknown {
payload_type,
payload,
} => {
assert_eq!(payload_type, "subagent.lifecycle.spawned");
assert_eq!(payload, json!({"x": 1}));
}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn task_lifecycle_failed_carries_reason() {
let e: TaskLifecycleEvent = serde_json::from_value(json!({
"kind": "failed",
"task_id": "t1",
"reason": "provider does not support base instructions"
}))
.unwrap();
assert!(matches!(e, TaskLifecycleEvent::Failed { ref reason, .. }
if reason.contains("base instructions")));
}
}