Skip to main content

piw/bundle/
types.rs

1//! Serde types mirroring the run bundle documents specified in
2//! `docs/run-bundles.md`. Unknown fields are tolerated everywhere so bundles
3//! written by newer writers within the same schema version stay readable.
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8
9pub const RUN_BUNDLE_SCHEMA: &str = "pi-workflows.run-bundle.v1";
10pub const RUN_STATE_SCHEMA: &str = "pi-workflows.run-state.v1";
11pub const DEFINITION_SNAPSHOT_SCHEMA: &str = "pi-workflows.definition-snapshot.v1";
12pub const SESSION_BINDING_SCHEMA: &str = "pi-workflows.session-binding.v1";
13pub const SESSION_EVENT_SCHEMA: &str = "pi-workflows.session-event.v1";
14pub const SESSION_CAPTURE_SCHEMA: &str = "pi-workflows.session-capture.v1";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum RunStatus {
19    Running,
20    Waiting,
21    Completed,
22    Failed,
23    TimedOut,
24    Cancelled,
25}
26
27impl RunStatus {
28    pub fn is_terminal(self) -> bool {
29        !matches!(self, RunStatus::Running)
30    }
31
32    pub fn label(self) -> &'static str {
33        match self {
34            RunStatus::Running => "running",
35            RunStatus::Waiting => "waiting",
36            RunStatus::Completed => "completed",
37            RunStatus::Failed => "failed",
38            RunStatus::TimedOut => "timed_out",
39            RunStatus::Cancelled => "cancelled",
40        }
41    }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum NodeOutcome {
47    Ok,
48    TimedOut,
49    Failed,
50    Cancelled,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct Manifest {
55    pub schema: String,
56    #[serde(rename = "runId")]
57    pub run_id: String,
58    #[serde(rename = "workflowName")]
59    pub workflow_name: String,
60    #[serde(rename = "runTitle", skip_serializing_if = "Option::is_none")]
61    pub run_title: Option<String>,
62    #[serde(rename = "workflowPath", skip_serializing_if = "Option::is_none")]
63    pub workflow_path: Option<String>,
64    #[serde(rename = "startedAt")]
65    pub started_at: String,
66    #[serde(rename = "finishedAt", skip_serializing_if = "Option::is_none")]
67    pub finished_at: Option<String>,
68    pub status: RunStatus,
69    #[serde(rename = "traceSchema")]
70    pub trace_schema: String,
71    pub paths: ManifestPaths,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct ManifestPaths {
76    pub workflow: String,
77    pub state: String,
78    pub trace: String,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub session: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub artifacts: Option<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct RunState {
87    pub schema: String,
88    #[serde(rename = "traceSeq")]
89    pub trace_seq: u64,
90    #[serde(rename = "runId")]
91    pub run_id: String,
92    #[serde(rename = "workflowName")]
93    pub workflow_name: String,
94    #[serde(rename = "runTitle", skip_serializing_if = "Option::is_none")]
95    pub run_title: Option<String>,
96    #[serde(rename = "workflowPath", skip_serializing_if = "Option::is_none")]
97    pub workflow_path: Option<String>,
98    #[serde(rename = "workflowHash", skip_serializing_if = "Option::is_none")]
99    pub workflow_hash: Option<String>,
100    #[serde(rename = "parentRunId", skip_serializing_if = "Option::is_none")]
101    pub parent_run_id: Option<String>,
102    #[serde(rename = "startedAt")]
103    pub started_at: String,
104    #[serde(rename = "finishedAt", skip_serializing_if = "Option::is_none")]
105    pub finished_at: Option<String>,
106    #[serde(rename = "updatedAt")]
107    pub updated_at: String,
108    pub status: RunStatus,
109    pub input: Value,
110    pub outputs: BTreeMap<String, Value>,
111    pub results: BTreeMap<String, NodeResult>,
112    pub steps: Vec<StepRecord>,
113    #[serde(rename = "currentNode", skip_serializing_if = "Option::is_none")]
114    pub current_node: Option<String>,
115    #[serde(rename = "currentAttemptId", skip_serializing_if = "Option::is_none")]
116    pub current_attempt_id: Option<String>,
117    #[serde(
118        rename = "currentNodeStartedAt",
119        skip_serializing_if = "Option::is_none"
120    )]
121    pub current_node_started_at: Option<String>,
122    #[serde(rename = "statusDetail", skip_serializing_if = "Option::is_none")]
123    pub status_detail: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub paused: Option<bool>,
126    #[serde(rename = "waitingOn", skip_serializing_if = "Option::is_none")]
127    pub waiting_on: Option<String>,
128    #[serde(rename = "finalOutput", skip_serializing_if = "Option::is_none")]
129    pub final_output: Option<Value>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub error: Option<String>,
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct NodeResult {
136    #[serde(rename = "attemptId")]
137    pub attempt_id: String,
138    #[serde(rename = "nodeId")]
139    pub node_id: String,
140    #[serde(rename = "nodeType")]
141    pub node_type: String,
142    pub outcome: NodeOutcome,
143    #[serde(rename = "startedAt")]
144    pub started_at: String,
145    #[serde(rename = "finishedAt")]
146    pub finished_at: String,
147    #[serde(rename = "durationMs")]
148    pub duration_ms: f64,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub output: Option<Value>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub error: Option<String>,
153}
154
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct StepRecord {
157    #[serde(rename = "attemptId")]
158    pub attempt_id: String,
159    #[serde(rename = "nodeId")]
160    pub node_id: String,
161    #[serde(rename = "nodeType")]
162    pub node_type: String,
163    pub outcome: NodeOutcome,
164    #[serde(rename = "startedAt")]
165    pub started_at: String,
166    #[serde(rename = "finishedAt")]
167    pub finished_at: String,
168    /// Full prompt for agent steps (`null` otherwise); may be an
169    /// externalized `$artifact` object in persisted form.
170    pub prompt: Value,
171    pub output: Value,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub error: Option<String>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub action: Option<ActionReceipt>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub conversation: Option<ConversationRange>,
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct ActionReceipt {
182    #[serde(rename = "actionType")]
183    pub action_type: String,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub command: Option<String>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub args: Option<Vec<String>>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub cwd: Option<String>,
190    #[serde(rename = "exitCode", skip_serializing_if = "Option::is_none")]
191    pub exit_code: Option<Value>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub signal: Option<Value>,
194    #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
195    pub duration_ms: Option<f64>,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct ConversationRange {
200    #[serde(rename = "firstEntryId")]
201    pub first_entry_id: String,
202    #[serde(rename = "lastEntryId")]
203    pub last_entry_id: String,
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207pub struct TraceEvent {
208    pub seq: u64,
209    pub at: String,
210    pub scope: String,
211    #[serde(rename = "type")]
212    pub event_type: String,
213    #[serde(rename = "runId")]
214    pub run_id: String,
215    #[serde(rename = "nodeId", skip_serializing_if = "Option::is_none")]
216    pub node_id: Option<String>,
217    #[serde(rename = "attemptId", skip_serializing_if = "Option::is_none")]
218    pub attempt_id: Option<String>,
219    pub payload: Value,
220}
221
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223pub struct SessionBinding {
224    pub schema: String,
225    #[serde(rename = "runId")]
226    pub run_id: String,
227    #[serde(rename = "piSessionId")]
228    pub pi_session_id: String,
229    #[serde(rename = "piSessionFile", skip_serializing_if = "Option::is_none")]
230    pub pi_session_file: Option<String>,
231    pub cwd: String,
232    #[serde(rename = "boundAt")]
233    pub bound_at: String,
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237pub struct SessionEntryRecord {
238    pub seq: u64,
239    pub at: String,
240    pub entry: Value,
241}
242
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244pub struct SessionEventRecord {
245    pub seq: u64,
246    pub at: String,
247    #[serde(rename = "nodeId")]
248    pub node_id: String,
249    #[serde(rename = "attemptId")]
250    pub attempt_id: String,
251    #[serde(rename = "turnId", skip_serializing_if = "Option::is_none")]
252    pub turn_id: Option<String>,
253    #[serde(rename = "messageId", skip_serializing_if = "Option::is_none")]
254    pub message_id: Option<String>,
255    #[serde(rename = "toolCallId", skip_serializing_if = "Option::is_none")]
256    pub tool_call_id: Option<String>,
257    #[serde(rename = "type")]
258    pub event_type: String,
259    pub payload: Value,
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum SessionCaptureStatus {
265    Recording,
266    Complete,
267    Failed,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct SessionCaptureFailure {
272    #[serde(rename = "failedAt")]
273    pub failed_at: String,
274    pub code: String,
275    pub message: String,
276}
277
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct SessionCapture {
280    pub schema: String,
281    #[serde(rename = "eventSchema")]
282    pub event_schema: String,
283    pub status: SessionCaptureStatus,
284    #[serde(rename = "eventCount")]
285    pub event_count: u64,
286    #[serde(rename = "entryCount")]
287    pub entry_count: u64,
288    #[serde(rename = "lastEventSeq")]
289    pub last_event_seq: u64,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub failure: Option<SessionCaptureFailure>,
292}
293
294// --- Definition snapshot ---
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297pub struct DefinitionSnapshot {
298    pub schema: String,
299    pub name: String,
300    #[serde(rename = "startAt")]
301    pub start_at: String,
302    /// Insertion order matters (BFS fallback order, display order), so the
303    /// map preserves the document order of the JSON object.
304    pub nodes: serde_json::Map<String, Value>,
305    pub edges: Vec<EdgeDef>,
306}
307
308impl DefinitionSnapshot {
309    pub fn node_type(&self, node_id: &str) -> Option<&str> {
310        self.nodes
311            .get(node_id)?
312            .get("nodeType")
313            .and_then(Value::as_str)
314    }
315
316    pub fn node_ids(&self) -> impl Iterator<Item = &str> {
317        self.nodes.keys().map(String::as_str)
318    }
319}
320
321/// A workflow edge: either a simple `from -> to` or a labelled switch.
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(untagged)]
324pub enum EdgeDef {
325    Simple { from: String, to: String },
326    Switch { from: String, switch: SwitchDef },
327}
328
329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330pub struct SwitchDef {
331    pub on: String,
332    /// Case order matters for edge expansion; preserve document order.
333    pub cases: serde_json::Map<String, Value>,
334}
335
336/// An artifact reference extracted from a `{"$artifact": …}` sentinel.
337#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
338pub struct ArtifactRef {
339    pub path: String,
340    #[serde(rename = "mediaType")]
341    pub media_type: String,
342    pub bytes: u64,
343    pub sha256: String,
344}
345
346/// Detect the `$artifact` sentinel: an object whose only key is `$artifact`.
347pub fn as_artifact_ref(value: &Value) -> Option<ArtifactRef> {
348    let object = value.as_object()?;
349    if object.len() != 1 {
350        return None;
351    }
352    serde_json::from_value(object.get("$artifact")?.clone()).ok()
353}
354
355/// Unwrap one level of `{"$escaped": …}` if present.
356pub fn as_escaped(value: &Value) -> Option<&Value> {
357    let object = value.as_object()?;
358    if object.len() != 1 {
359        return None;
360    }
361    object.get("$escaped")
362}