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 = "startedAt")]
99    pub started_at: String,
100    #[serde(rename = "finishedAt", skip_serializing_if = "Option::is_none")]
101    pub finished_at: Option<String>,
102    #[serde(rename = "updatedAt")]
103    pub updated_at: String,
104    pub status: RunStatus,
105    pub input: Value,
106    pub outputs: BTreeMap<String, Value>,
107    pub results: BTreeMap<String, NodeResult>,
108    pub steps: Vec<StepRecord>,
109    #[serde(rename = "currentNode", skip_serializing_if = "Option::is_none")]
110    pub current_node: Option<String>,
111    #[serde(rename = "currentAttemptId", skip_serializing_if = "Option::is_none")]
112    pub current_attempt_id: Option<String>,
113    #[serde(
114        rename = "currentNodeStartedAt",
115        skip_serializing_if = "Option::is_none"
116    )]
117    pub current_node_started_at: Option<String>,
118    #[serde(rename = "statusDetail", skip_serializing_if = "Option::is_none")]
119    pub status_detail: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub paused: Option<bool>,
122    #[serde(rename = "waitingOn", skip_serializing_if = "Option::is_none")]
123    pub waiting_on: Option<String>,
124    #[serde(rename = "finalOutput", skip_serializing_if = "Option::is_none")]
125    pub final_output: Option<Value>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub error: Option<String>,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct NodeResult {
132    #[serde(rename = "attemptId")]
133    pub attempt_id: String,
134    #[serde(rename = "nodeId")]
135    pub node_id: String,
136    #[serde(rename = "nodeType")]
137    pub node_type: String,
138    pub outcome: NodeOutcome,
139    #[serde(rename = "startedAt")]
140    pub started_at: String,
141    #[serde(rename = "finishedAt")]
142    pub finished_at: String,
143    #[serde(rename = "durationMs")]
144    pub duration_ms: f64,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub output: Option<Value>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub error: Option<String>,
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct StepRecord {
153    #[serde(rename = "attemptId")]
154    pub attempt_id: String,
155    #[serde(rename = "nodeId")]
156    pub node_id: String,
157    #[serde(rename = "nodeType")]
158    pub node_type: String,
159    pub outcome: NodeOutcome,
160    #[serde(rename = "startedAt")]
161    pub started_at: String,
162    #[serde(rename = "finishedAt")]
163    pub finished_at: String,
164    /// Full prompt for agent steps (`null` otherwise); may be an
165    /// externalized `$artifact` object in persisted form.
166    pub prompt: Value,
167    pub output: Value,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub error: Option<String>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub action: Option<ActionReceipt>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub conversation: Option<ConversationRange>,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct ActionReceipt {
178    #[serde(rename = "actionType")]
179    pub action_type: String,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub command: Option<String>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub args: Option<Vec<String>>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub cwd: Option<String>,
186    #[serde(rename = "exitCode", skip_serializing_if = "Option::is_none")]
187    pub exit_code: Option<Value>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub signal: Option<Value>,
190    #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
191    pub duration_ms: Option<f64>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct ConversationRange {
196    #[serde(rename = "firstEntryId")]
197    pub first_entry_id: String,
198    #[serde(rename = "lastEntryId")]
199    pub last_entry_id: String,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203pub struct TraceEvent {
204    pub seq: u64,
205    pub at: String,
206    pub scope: String,
207    #[serde(rename = "type")]
208    pub event_type: String,
209    #[serde(rename = "runId")]
210    pub run_id: String,
211    #[serde(rename = "nodeId", skip_serializing_if = "Option::is_none")]
212    pub node_id: Option<String>,
213    #[serde(rename = "attemptId", skip_serializing_if = "Option::is_none")]
214    pub attempt_id: Option<String>,
215    pub payload: Value,
216}
217
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219pub struct SessionBinding {
220    pub schema: String,
221    #[serde(rename = "runId")]
222    pub run_id: String,
223    #[serde(rename = "piSessionId")]
224    pub pi_session_id: String,
225    #[serde(rename = "piSessionFile", skip_serializing_if = "Option::is_none")]
226    pub pi_session_file: Option<String>,
227    pub cwd: String,
228    #[serde(rename = "boundAt")]
229    pub bound_at: String,
230}
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct SessionEntryRecord {
234    pub seq: u64,
235    pub at: String,
236    pub entry: Value,
237}
238
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub struct SessionEventRecord {
241    pub seq: u64,
242    pub at: String,
243    #[serde(rename = "nodeId")]
244    pub node_id: String,
245    #[serde(rename = "attemptId")]
246    pub attempt_id: String,
247    #[serde(rename = "turnId", skip_serializing_if = "Option::is_none")]
248    pub turn_id: Option<String>,
249    #[serde(rename = "messageId", skip_serializing_if = "Option::is_none")]
250    pub message_id: Option<String>,
251    #[serde(rename = "toolCallId", skip_serializing_if = "Option::is_none")]
252    pub tool_call_id: Option<String>,
253    #[serde(rename = "type")]
254    pub event_type: String,
255    pub payload: Value,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "snake_case")]
260pub enum SessionCaptureStatus {
261    Recording,
262    Complete,
263    Failed,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct SessionCaptureFailure {
268    #[serde(rename = "failedAt")]
269    pub failed_at: String,
270    pub code: String,
271    pub message: String,
272}
273
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
275pub struct SessionCapture {
276    pub schema: String,
277    #[serde(rename = "eventSchema")]
278    pub event_schema: String,
279    pub status: SessionCaptureStatus,
280    #[serde(rename = "eventCount")]
281    pub event_count: u64,
282    #[serde(rename = "entryCount")]
283    pub entry_count: u64,
284    #[serde(rename = "lastEventSeq")]
285    pub last_event_seq: u64,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub failure: Option<SessionCaptureFailure>,
288}
289
290// --- Definition snapshot ---
291
292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
293pub struct DefinitionSnapshot {
294    pub schema: String,
295    pub name: String,
296    #[serde(rename = "startAt")]
297    pub start_at: String,
298    /// Insertion order matters (BFS fallback order, display order), so the
299    /// map preserves the document order of the JSON object.
300    pub nodes: serde_json::Map<String, Value>,
301    pub edges: Vec<EdgeDef>,
302}
303
304impl DefinitionSnapshot {
305    pub fn node_type(&self, node_id: &str) -> Option<&str> {
306        self.nodes
307            .get(node_id)?
308            .get("nodeType")
309            .and_then(Value::as_str)
310    }
311
312    pub fn node_ids(&self) -> impl Iterator<Item = &str> {
313        self.nodes.keys().map(String::as_str)
314    }
315}
316
317/// A workflow edge: either a simple `from -> to` or a labelled switch.
318#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319#[serde(untagged)]
320pub enum EdgeDef {
321    Simple { from: String, to: String },
322    Switch { from: String, switch: SwitchDef },
323}
324
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326pub struct SwitchDef {
327    pub on: String,
328    /// Case order matters for edge expansion; preserve document order.
329    pub cases: serde_json::Map<String, Value>,
330}
331
332/// An artifact reference extracted from a `{"$artifact": …}` sentinel.
333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
334pub struct ArtifactRef {
335    pub path: String,
336    #[serde(rename = "mediaType")]
337    pub media_type: String,
338    pub bytes: u64,
339    pub sha256: String,
340}
341
342/// Detect the `$artifact` sentinel: an object whose only key is `$artifact`.
343pub fn as_artifact_ref(value: &Value) -> Option<ArtifactRef> {
344    let object = value.as_object()?;
345    if object.len() != 1 {
346        return None;
347    }
348    serde_json::from_value(object.get("$artifact")?.clone()).ok()
349}
350
351/// Unwrap one level of `{"$escaped": …}` if present.
352pub fn as_escaped(value: &Value) -> Option<&Value> {
353    let object = value.as_object()?;
354    if object.len() != 1 {
355        return None;
356    }
357    object.get("$escaped")
358}