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, Eq, Serialize, Deserialize)]
54#[serde(tag = "kind", rename_all = "snake_case")]
55pub enum WorkflowSource {
56    Builtin { id: String, revision: String },
57    File { path: String, hash: String },
58}
59
60impl WorkflowSource {
61    pub fn display(&self) -> String {
62        match self {
63            WorkflowSource::Builtin { id, revision } => format!("builtin:{id}@{revision}"),
64            WorkflowSource::File { path, .. } => path.clone(),
65        }
66    }
67}
68
69#[cfg(test)]
70mod workflow_source_tests {
71    use super::WorkflowSource;
72
73    #[test]
74    fn parses_and_displays_each_source_kind() {
75        let builtin: WorkflowSource =
76            serde_json::from_str(r#"{"kind":"builtin","id":"monitor","revision":"1"}"#)
77                .expect("built-in source should parse");
78        let file: WorkflowSource =
79            serde_json::from_str(r#"{"kind":"file","path":"/tmp/demo.workflow.ts","hash":"abc"}"#)
80                .expect("file source should parse");
81
82        assert_eq!(builtin.display(), "builtin:monitor@1");
83        assert_eq!(file.display(), "/tmp/demo.workflow.ts");
84    }
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct Manifest {
89    pub schema: String,
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 = "workflowSource", skip_serializing_if = "Option::is_none")]
97    pub workflow_source: Option<WorkflowSource>,
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    pub status: RunStatus,
103    #[serde(rename = "traceSchema")]
104    pub trace_schema: String,
105    pub paths: ManifestPaths,
106}
107
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct ManifestPaths {
110    pub workflow: String,
111    pub state: String,
112    pub trace: String,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub session: Option<String>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub artifacts: Option<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct RunState {
121    pub schema: String,
122    #[serde(rename = "traceSeq")]
123    pub trace_seq: u64,
124    #[serde(rename = "runId")]
125    pub run_id: String,
126    #[serde(rename = "workflowName")]
127    pub workflow_name: String,
128    #[serde(rename = "runTitle", skip_serializing_if = "Option::is_none")]
129    pub run_title: Option<String>,
130    #[serde(rename = "workflowSource", skip_serializing_if = "Option::is_none")]
131    pub workflow_source: Option<WorkflowSource>,
132    #[serde(rename = "parentRunId", skip_serializing_if = "Option::is_none")]
133    pub parent_run_id: Option<String>,
134    #[serde(rename = "startedAt")]
135    pub started_at: String,
136    #[serde(rename = "finishedAt", skip_serializing_if = "Option::is_none")]
137    pub finished_at: Option<String>,
138    #[serde(rename = "updatedAt")]
139    pub updated_at: String,
140    pub status: RunStatus,
141    pub input: Value,
142    pub outputs: BTreeMap<String, Value>,
143    pub results: BTreeMap<String, NodeResult>,
144    pub steps: Vec<StepRecord>,
145    #[serde(rename = "currentNode", skip_serializing_if = "Option::is_none")]
146    pub current_node: Option<String>,
147    #[serde(rename = "currentAttemptId", skip_serializing_if = "Option::is_none")]
148    pub current_attempt_id: Option<String>,
149    #[serde(
150        rename = "currentNodeStartedAt",
151        skip_serializing_if = "Option::is_none"
152    )]
153    pub current_node_started_at: Option<String>,
154    #[serde(rename = "statusDetail", skip_serializing_if = "Option::is_none")]
155    pub status_detail: Option<String>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub paused: Option<bool>,
158    #[serde(rename = "waitingOn", skip_serializing_if = "Option::is_none")]
159    pub waiting_on: Option<String>,
160    #[serde(rename = "finalOutput", skip_serializing_if = "Option::is_none")]
161    pub final_output: Option<Value>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub error: Option<String>,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct NodeResult {
168    #[serde(rename = "attemptId")]
169    pub attempt_id: String,
170    #[serde(rename = "nodeId")]
171    pub node_id: String,
172    #[serde(rename = "nodeType")]
173    pub node_type: String,
174    pub outcome: NodeOutcome,
175    #[serde(rename = "startedAt")]
176    pub started_at: String,
177    #[serde(rename = "finishedAt")]
178    pub finished_at: String,
179    #[serde(rename = "durationMs")]
180    pub duration_ms: f64,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub output: Option<Value>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub error: Option<String>,
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188pub struct StepRecord {
189    #[serde(rename = "attemptId")]
190    pub attempt_id: String,
191    #[serde(rename = "nodeId")]
192    pub node_id: String,
193    #[serde(rename = "nodeType")]
194    pub node_type: String,
195    pub outcome: NodeOutcome,
196    #[serde(rename = "startedAt")]
197    pub started_at: String,
198    #[serde(rename = "finishedAt")]
199    pub finished_at: String,
200    /// Full prompt for agent steps (`null` otherwise); may be an
201    /// externalized `$artifact` object in persisted form.
202    pub prompt: Value,
203    pub output: Value,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub error: Option<String>,
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub action: Option<ActionReceipt>,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub conversation: Option<ConversationRange>,
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213pub struct ActionReceipt {
214    #[serde(rename = "actionType")]
215    pub action_type: String,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub command: Option<String>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub args: Option<Vec<String>>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub cwd: Option<String>,
222    #[serde(rename = "exitCode", skip_serializing_if = "Option::is_none")]
223    pub exit_code: Option<Value>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub signal: Option<Value>,
226    #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
227    pub duration_ms: Option<f64>,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct ConversationRange {
232    #[serde(rename = "firstEntryId")]
233    pub first_entry_id: String,
234    #[serde(rename = "lastEntryId")]
235    pub last_entry_id: String,
236}
237
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239pub struct TraceEvent {
240    pub seq: u64,
241    pub at: String,
242    pub scope: String,
243    #[serde(rename = "type")]
244    pub event_type: String,
245    #[serde(rename = "runId")]
246    pub run_id: String,
247    #[serde(rename = "nodeId", skip_serializing_if = "Option::is_none")]
248    pub node_id: Option<String>,
249    #[serde(rename = "attemptId", skip_serializing_if = "Option::is_none")]
250    pub attempt_id: Option<String>,
251    pub payload: Value,
252}
253
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255pub struct SessionBinding {
256    pub schema: String,
257    #[serde(rename = "runId")]
258    pub run_id: String,
259    #[serde(rename = "piSessionId")]
260    pub pi_session_id: String,
261    #[serde(rename = "piSessionFile", skip_serializing_if = "Option::is_none")]
262    pub pi_session_file: Option<String>,
263    pub cwd: String,
264    #[serde(rename = "boundAt")]
265    pub bound_at: String,
266}
267
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269pub struct SessionEntryRecord {
270    pub seq: u64,
271    pub at: String,
272    pub entry: Value,
273}
274
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276pub struct SessionEventRecord {
277    pub seq: u64,
278    pub at: String,
279    #[serde(rename = "nodeId")]
280    pub node_id: String,
281    #[serde(rename = "attemptId")]
282    pub attempt_id: String,
283    #[serde(rename = "turnId", skip_serializing_if = "Option::is_none")]
284    pub turn_id: Option<String>,
285    #[serde(rename = "messageId", skip_serializing_if = "Option::is_none")]
286    pub message_id: Option<String>,
287    #[serde(rename = "toolCallId", skip_serializing_if = "Option::is_none")]
288    pub tool_call_id: Option<String>,
289    #[serde(rename = "type")]
290    pub event_type: String,
291    pub payload: Value,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(rename_all = "snake_case")]
296pub enum SessionCaptureStatus {
297    Recording,
298    Complete,
299    Failed,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct SessionCaptureFailure {
304    #[serde(rename = "failedAt")]
305    pub failed_at: String,
306    pub code: String,
307    pub message: String,
308}
309
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311pub struct SessionCapture {
312    pub schema: String,
313    #[serde(rename = "eventSchema")]
314    pub event_schema: String,
315    pub status: SessionCaptureStatus,
316    #[serde(rename = "eventCount")]
317    pub event_count: u64,
318    #[serde(rename = "entryCount")]
319    pub entry_count: u64,
320    #[serde(rename = "lastEventSeq")]
321    pub last_event_seq: u64,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub failure: Option<SessionCaptureFailure>,
324}
325
326// --- Definition snapshot ---
327
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct DefinitionSnapshot {
330    pub schema: String,
331    pub name: String,
332    #[serde(rename = "startAt")]
333    pub start_at: String,
334    /// Insertion order matters (BFS fallback order, display order), so the
335    /// map preserves the document order of the JSON object.
336    pub nodes: serde_json::Map<String, Value>,
337    pub edges: Vec<EdgeDef>,
338}
339
340impl DefinitionSnapshot {
341    pub fn node_type(&self, node_id: &str) -> Option<&str> {
342        self.nodes
343            .get(node_id)?
344            .get("nodeType")
345            .and_then(Value::as_str)
346    }
347
348    pub fn node_action_execution(&self, node_id: &str) -> Option<&str> {
349        self.nodes
350            .get(node_id)?
351            .get("actionExecution")
352            .and_then(Value::as_str)
353    }
354
355    pub fn node_ids(&self) -> impl Iterator<Item = &str> {
356        self.nodes.keys().map(String::as_str)
357    }
358}
359
360/// A workflow edge: either a simple `from -> to` or a labelled switch.
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362#[serde(untagged)]
363pub enum EdgeDef {
364    Simple { from: String, to: String },
365    Switch { from: String, switch: SwitchDef },
366}
367
368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
369pub struct SwitchDef {
370    pub on: String,
371    /// Case order matters for edge expansion; preserve document order.
372    pub cases: serde_json::Map<String, Value>,
373}
374
375/// An artifact reference extracted from a `{"$artifact": …}` sentinel.
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377pub struct ArtifactRef {
378    pub path: String,
379    #[serde(rename = "mediaType")]
380    pub media_type: String,
381    pub bytes: u64,
382    pub sha256: String,
383}
384
385/// Detect the `$artifact` sentinel: an object whose only key is `$artifact`.
386pub fn as_artifact_ref(value: &Value) -> Option<ArtifactRef> {
387    let object = value.as_object()?;
388    if object.len() != 1 {
389        return None;
390    }
391    serde_json::from_value(object.get("$artifact")?.clone()).ok()
392}
393
394/// Unwrap one level of `{"$escaped": …}` if present.
395pub fn as_escaped(value: &Value) -> Option<&Value> {
396    let object = value.as_object()?;
397    if object.len() != 1 {
398        return None;
399    }
400    object.get("$escaped")
401}