Skip to main content

piw/state/
types.rs

1//! Serde types for the workflow documents carried in host-owned run views.
2//! Unknown document fields are tolerated, but the client envelope is strict.
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::BTreeMap;
7
8pub const RUN_BUNDLE_SCHEMA: &str = "pi-workflows.run-run.v1";
9pub const RUN_STATE_SCHEMA: &str = "pi-workflows.run-state.v1";
10pub const DEFINITION_SNAPSHOT_SCHEMA: &str = "pi-workflows.definition-snapshot.v1";
11pub const SESSION_BINDING_SCHEMA: &str = "pi-workflows.session-binding.v1";
12pub const SESSION_EVENT_SCHEMA: &str = "pi-workflows.session-event.v1";
13pub const SESSION_CAPTURE_SCHEMA: &str = "pi-workflows.session-capture.v1";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum RunStatus {
18    Queued,
19    Running,
20    Waiting,
21    Paused,
22    Completed,
23    Failed,
24    TimedOut,
25    Cancelled,
26    Ambiguous,
27}
28
29impl RunStatus {
30    pub fn is_terminal(self) -> bool {
31        matches!(
32            self,
33            RunStatus::Completed | RunStatus::Failed | RunStatus::TimedOut | RunStatus::Cancelled
34        )
35    }
36
37    pub fn label(self) -> &'static str {
38        match self {
39            RunStatus::Queued => "queued",
40            RunStatus::Running => "running",
41            RunStatus::Waiting => "waiting",
42            RunStatus::Paused => "paused",
43            RunStatus::Completed => "completed",
44            RunStatus::Failed => "failed",
45            RunStatus::TimedOut => "timed_out",
46            RunStatus::Cancelled => "cancelled",
47            RunStatus::Ambiguous => "ambiguous",
48        }
49    }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum NodeOutcome {
55    Ok,
56    TimedOut,
57    Failed,
58    Cancelled,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(tag = "kind", rename_all = "snake_case")]
63pub enum WorkflowSource {
64    Builtin { id: String, revision: String },
65    File { path: String, hash: String },
66}
67
68impl WorkflowSource {
69    pub fn display(&self) -> String {
70        match self {
71            WorkflowSource::Builtin { id, revision } => format!("builtin:{id}@{revision}"),
72            WorkflowSource::File { path, .. } => path.clone(),
73        }
74    }
75}
76
77#[cfg(test)]
78mod workflow_source_tests {
79    use super::WorkflowSource;
80
81    #[test]
82    fn parses_and_displays_each_source_kind() {
83        let builtin: WorkflowSource =
84            serde_json::from_str(r#"{"kind":"builtin","id":"monitor","revision":"1"}"#)
85                .expect("built-in source should parse");
86        let file: WorkflowSource =
87            serde_json::from_str(r#"{"kind":"file","path":"/tmp/demo.workflow.ts","hash":"abc"}"#)
88                .expect("file source should parse");
89
90        assert_eq!(builtin.display(), "builtin:monitor@1");
91        assert_eq!(file.display(), "/tmp/demo.workflow.ts");
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct Manifest {
97    pub schema: String,
98    #[serde(rename = "runId")]
99    pub run_id: String,
100    #[serde(rename = "workflowName")]
101    pub workflow_name: String,
102    #[serde(rename = "runTitle", skip_serializing_if = "Option::is_none")]
103    pub run_title: Option<String>,
104    #[serde(rename = "workflowSource", skip_serializing_if = "Option::is_none")]
105    pub workflow_source: Option<WorkflowSource>,
106    #[serde(rename = "startedAt")]
107    pub started_at: String,
108    #[serde(rename = "finishedAt", skip_serializing_if = "Option::is_none")]
109    pub finished_at: Option<String>,
110    pub status: RunStatus,
111    #[serde(rename = "traceSchema")]
112    pub trace_schema: String,
113    pub paths: ManifestPaths,
114}
115
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct ManifestPaths {
118    pub workflow: String,
119    pub state: String,
120    pub trace: String,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub session: Option<String>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub artifacts: Option<String>,
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct RunState {
129    pub schema: String,
130    #[serde(rename = "traceSeq")]
131    pub trace_seq: u64,
132    #[serde(rename = "runId")]
133    pub run_id: String,
134    #[serde(rename = "workflowName")]
135    pub workflow_name: String,
136    #[serde(rename = "runTitle", skip_serializing_if = "Option::is_none")]
137    pub run_title: Option<String>,
138    #[serde(rename = "workflowSource", skip_serializing_if = "Option::is_none")]
139    pub workflow_source: Option<WorkflowSource>,
140    #[serde(rename = "parentRunId", skip_serializing_if = "Option::is_none")]
141    pub parent_run_id: Option<String>,
142    #[serde(rename = "carriedStepCount", skip_serializing_if = "Option::is_none")]
143    pub carried_step_count: Option<u64>,
144    #[serde(rename = "workflowSources", skip_serializing_if = "Option::is_none")]
145    pub workflow_sources: Option<Vec<Value>>,
146    #[serde(rename = "definitionDigest", skip_serializing_if = "Option::is_none")]
147    pub definition_digest: Option<String>,
148    #[serde(rename = "startedAt")]
149    pub started_at: String,
150    #[serde(rename = "finishedAt", skip_serializing_if = "Option::is_none")]
151    pub finished_at: Option<String>,
152    #[serde(rename = "updatedAt")]
153    pub updated_at: String,
154    pub status: RunStatus,
155    pub input: Value,
156    pub outputs: BTreeMap<String, Value>,
157    pub results: BTreeMap<String, Value>,
158    pub steps: Vec<StepRecord>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub updates: Option<Vec<Value>>,
161    #[serde(rename = "currentNode", skip_serializing_if = "Option::is_none")]
162    pub current_node: Option<String>,
163    #[serde(rename = "currentAttemptId", skip_serializing_if = "Option::is_none")]
164    pub current_attempt_id: Option<String>,
165    #[serde(
166        rename = "currentNodeStartedAt",
167        skip_serializing_if = "Option::is_none"
168    )]
169    pub current_node_started_at: Option<String>,
170    #[serde(
171        rename = "currentSettingsScopeId",
172        skip_serializing_if = "Option::is_none"
173    )]
174    pub current_settings_scope_id: Option<String>,
175    #[serde(
176        rename = "currentSettingsChangeNumber",
177        skip_serializing_if = "Option::is_none"
178    )]
179    pub current_settings_change_number: Option<u64>,
180    #[serde(
181        rename = "currentSettingsHash",
182        skip_serializing_if = "Option::is_none"
183    )]
184    pub current_settings_hash: Option<String>,
185    #[serde(rename = "statusDetail", skip_serializing_if = "Option::is_none")]
186    pub status_detail: Option<String>,
187    #[serde(rename = "humanDecision", skip_serializing_if = "Option::is_none")]
188    pub human_decision: Option<Value>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub paused: Option<bool>,
191    #[serde(rename = "waitingOn", skip_serializing_if = "Option::is_none")]
192    pub waiting_on: Option<String>,
193    #[serde(rename = "finalOutput", skip_serializing_if = "Option::is_none")]
194    pub final_output: Option<Value>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub error: Option<String>,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200pub struct NodeResult {
201    #[serde(rename = "attemptId")]
202    pub attempt_id: String,
203    #[serde(rename = "nodeId")]
204    pub node_id: String,
205    #[serde(rename = "nodeType")]
206    pub node_type: String,
207    pub outcome: NodeOutcome,
208    #[serde(rename = "startedAt")]
209    pub started_at: String,
210    #[serde(rename = "finishedAt")]
211    pub finished_at: String,
212    #[serde(rename = "durationMs")]
213    pub duration_ms: f64,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub output: Option<Value>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub error: Option<String>,
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221pub struct StepRecord {
222    #[serde(rename = "attemptId")]
223    pub attempt_id: String,
224    #[serde(rename = "nodeId")]
225    pub node_id: String,
226    #[serde(rename = "nodeType")]
227    pub node_type: String,
228    pub outcome: NodeOutcome,
229    #[serde(rename = "startedAt")]
230    pub started_at: String,
231    #[serde(rename = "finishedAt")]
232    pub finished_at: String,
233    /// Full prompt for agent steps (`null` otherwise); may be an
234    /// externalized `$artifact` object in persisted form.
235    pub prompt: Value,
236    pub output: Value,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub error: Option<String>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub action: Option<ActionReceipt>,
241    #[serde(rename = "assistantMessage", skip_serializing_if = "Option::is_none")]
242    pub assistant_message: Option<Value>,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub conversation: Option<ConversationRange>,
245    #[serde(rename = "settingsScopeId", skip_serializing_if = "Option::is_none")]
246    pub settings_scope_id: Option<String>,
247    #[serde(
248        rename = "settingsChangeNumber",
249        skip_serializing_if = "Option::is_none"
250    )]
251    pub settings_change_number: Option<u64>,
252    #[serde(rename = "settingsHash", skip_serializing_if = "Option::is_none")]
253    pub settings_hash: Option<String>,
254}
255
256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
257pub struct ActionReceipt {
258    #[serde(rename = "actionType")]
259    pub action_type: String,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub command: Option<String>,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub args: Option<Vec<String>>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub cwd: Option<String>,
266    #[serde(rename = "exitCode", skip_serializing_if = "Option::is_none")]
267    pub exit_code: Option<Value>,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub signal: Option<Value>,
270    #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
271    pub duration_ms: Option<f64>,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct ConversationRange {
276    #[serde(rename = "firstEntryId")]
277    pub first_entry_id: String,
278    #[serde(rename = "lastEntryId")]
279    pub last_entry_id: String,
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub struct TraceEvent {
284    pub seq: u64,
285    pub at: String,
286    pub scope: String,
287    #[serde(rename = "type")]
288    pub event_type: String,
289    #[serde(rename = "runId")]
290    pub run_id: String,
291    #[serde(rename = "nodeId", skip_serializing_if = "Option::is_none")]
292    pub node_id: Option<String>,
293    #[serde(rename = "attemptId", skip_serializing_if = "Option::is_none")]
294    pub attempt_id: Option<String>,
295    pub payload: Value,
296}
297
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299pub struct SessionBinding {
300    pub schema: String,
301    #[serde(rename = "runId")]
302    pub run_id: String,
303    #[serde(rename = "piSessionId")]
304    pub pi_session_id: String,
305    #[serde(rename = "piSessionFile", skip_serializing_if = "Option::is_none")]
306    pub pi_session_file: Option<String>,
307    pub cwd: String,
308    #[serde(rename = "boundAt")]
309    pub bound_at: String,
310}
311
312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
313pub struct SessionEntryRecord {
314    pub seq: u64,
315    pub at: String,
316    pub entry: Value,
317}
318
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
320pub struct SessionEventRecord {
321    pub seq: u64,
322    pub at: String,
323    #[serde(rename = "nodeId")]
324    pub node_id: String,
325    #[serde(rename = "attemptId")]
326    pub attempt_id: String,
327    #[serde(rename = "turnId", skip_serializing_if = "Option::is_none")]
328    pub turn_id: Option<String>,
329    #[serde(rename = "messageId", skip_serializing_if = "Option::is_none")]
330    pub message_id: Option<String>,
331    #[serde(rename = "toolCallId", skip_serializing_if = "Option::is_none")]
332    pub tool_call_id: Option<String>,
333    #[serde(rename = "type")]
334    pub event_type: String,
335    pub payload: Value,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum SessionCaptureStatus {
341    Recording,
342    Complete,
343    Failed,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347pub struct SessionCaptureFailure {
348    #[serde(rename = "failedAt")]
349    pub failed_at: String,
350    pub code: String,
351    pub message: String,
352}
353
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct SessionCapture {
356    pub schema: String,
357    #[serde(rename = "eventSchema")]
358    pub event_schema: String,
359    pub status: SessionCaptureStatus,
360    #[serde(rename = "eventCount")]
361    pub event_count: u64,
362    #[serde(rename = "entryCount")]
363    pub entry_count: u64,
364    #[serde(rename = "lastEventSeq")]
365    pub last_event_seq: u64,
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub failure: Option<SessionCaptureFailure>,
368}
369
370// --- Definition snapshot ---
371
372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
373pub struct DefinitionSnapshot {
374    pub schema: String,
375    pub name: String,
376    #[serde(rename = "startAt")]
377    pub start_at: String,
378    /// Insertion order matters (BFS fallback order, display order), so the
379    /// map preserves the document order of the JSON object.
380    pub nodes: serde_json::Map<String, Value>,
381    pub edges: Vec<EdgeDef>,
382}
383
384impl DefinitionSnapshot {
385    pub fn node_type(&self, node_id: &str) -> Option<&str> {
386        self.nodes
387            .get(node_id)?
388            .get("nodeType")
389            .and_then(Value::as_str)
390    }
391
392    pub fn node_action_execution(&self, node_id: &str) -> Option<&str> {
393        self.nodes
394            .get(node_id)?
395            .get("actionExecution")
396            .and_then(Value::as_str)
397    }
398
399    pub fn node_ids(&self) -> impl Iterator<Item = &str> {
400        self.nodes.keys().map(String::as_str)
401    }
402}
403
404/// A workflow edge: either a simple `from -> to` or a labelled switch.
405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
406#[serde(untagged)]
407pub enum EdgeDef {
408    Simple { from: String, to: String },
409    Switch { from: String, switch: SwitchDef },
410}
411
412#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
413pub struct SwitchDef {
414    pub on: String,
415    /// Case order matters for edge expansion; preserve document order.
416    pub cases: serde_json::Map<String, Value>,
417}
418
419/// An artifact reference extracted from a `{"$artifact": …}` sentinel.
420#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
421pub struct ArtifactRef {
422    pub path: String,
423    #[serde(rename = "mediaType")]
424    pub media_type: String,
425    pub bytes: u64,
426    pub sha256: String,
427}
428
429/// Detect the `$artifact` sentinel: an object whose only key is `$artifact`.
430pub fn as_artifact_ref(value: &Value) -> Option<ArtifactRef> {
431    let object = value.as_object()?;
432    if object.len() != 1 {
433        return None;
434    }
435    serde_json::from_value(object.get("$artifact")?.clone()).ok()
436}
437
438/// Unwrap one level of `{"$escaped": …}` if present.
439pub fn as_escaped(value: &Value) -> Option<&Value> {
440    let object = value.as_object()?;
441    if object.len() != 1 {
442        return None;
443    }
444    object.get("$escaped")
445}