1use crate::state::types::{
4 DefinitionSnapshot, Manifest, ManifestPaths, RunState, SessionBinding, SessionCapture,
5 SessionEntryRecord, SessionEventRecord, TraceEvent, DEFINITION_SNAPSHOT_SCHEMA,
6 RUN_STATE_SCHEMA,
7};
8use anyhow::{bail, Context, Result};
9use chrono::{TimeZone, Utc};
10use rusqlite::{Connection, OpenFlags, OptionalExtension};
11use serde_json::{json, Value};
12use std::path::Path;
13
14const APPLICATION_ID: i64 = 0x5049_5746;
15const USER_VERSION: i64 = 1;
16const SCHEMA_NAME: &str = "pi-workflows-state";
17const APP_VERSION: &str = "0.13.3";
18pub const SCHEMA_DIGEST: [u8; 32] = [
19 0x46, 0x6d, 0xdb, 0xae, 0x92, 0x1f, 0x26, 0x19, 0xff, 0x81, 0xdb, 0x56, 0x73, 0x95, 0x18, 0xe7,
20 0xef, 0x68, 0xfa, 0x0a, 0x23, 0xbf, 0xf8, 0x0d, 0xf0, 0x29, 0x6b, 0x3a, 0xfd, 0x01, 0xc8, 0x56,
21];
22const RESET_INSTRUCTION: &str = "Pi Workflows durable state is incompatible. Move or remove the old workflow state, then create a new state.sqlite database.";
23
24type LoadedSession = (
25 Option<SessionBinding>,
26 Vec<SessionEntryRecord>,
27 Vec<SessionEventRecord>,
28 Option<SessionCapture>,
29);
30
31#[derive(Debug, Clone)]
32pub struct LoadedRun {
33 pub manifest: Manifest,
34 pub state: RunState,
35 pub snapshot: Option<DefinitionSnapshot>,
36 pub trace: Vec<TraceEvent>,
37 pub session_binding: Option<SessionBinding>,
38 pub session_entries: Vec<SessionEntryRecord>,
39 pub session_events: Vec<SessionEventRecord>,
40 pub session_capture: Option<SessionCapture>,
41 pub settings_scopes: Vec<Value>,
42 pub follow_up_queue: Option<Value>,
43 pub possibly_interrupted: bool,
44}
45
46pub fn read_run(database_path: &Path, run_id: &str) -> Result<LoadedRun> {
47 let connection = open(database_path)?;
48 let row = connection
49 .query_row(
50 "SELECT d.definition_hash, l.owner_id, l.expires_at
51 FROM runs r
52 JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
53 JOIN leases l ON l.resource_id = r.resource_id
54 WHERE r.run_id = ?1",
55 [run_id],
56 |row| {
57 Ok((
58 row.get::<_, Vec<u8>>(0)?,
59 row.get::<_, Option<String>>(1)?,
60 row.get::<_, Option<i64>>(2)?,
61 ))
62 },
63 )
64 .optional()?;
65 let Some((definition_hash, owner_id, lease_expires_at)) = row else {
66 bail!("workflow run not found: {run_id}");
67 };
68 let definition_value = read_json_blob(&connection, &definition_hash)?;
69 let snapshot: DefinitionSnapshot = serde_json::from_value(definition_value.clone())?;
70 if snapshot.schema != DEFINITION_SNAPSHOT_SCHEMA {
71 bail!(
72 "unsupported workflow definition schema: {}",
73 snapshot.schema
74 );
75 }
76 let state = read_state(&connection, run_id, &definition_value)?;
77 let trace = read_trace(&connection, run_id)?;
78 let (session_binding, session_entries, session_events, session_capture) =
79 read_session(&connection, run_id)?;
80 let settings_scopes = read_settings(&connection, run_id)?;
81 let follow_up_queue = read_follow_ups(&connection, run_id)?;
82 let manifest = manifest_from_state(&state);
83 let possibly_interrupted = state.status.label() == "running"
84 && (owner_id.is_none()
85 || lease_expires_at
86 .is_none_or(|expires_at| expires_at <= Utc::now().timestamp_millis()));
87 Ok(LoadedRun {
88 manifest,
89 state,
90 snapshot: Some(snapshot),
91 trace,
92 session_binding,
93 session_entries,
94 session_events,
95 session_capture,
96 settings_scopes,
97 follow_up_queue,
98 possibly_interrupted,
99 })
100}
101
102pub fn list_runs(database_path: &Path) -> Vec<(String, Manifest)> {
103 let Ok(connection) = open(database_path) else {
104 return Vec::new();
105 };
106 let Ok(mut statement) = connection.prepare("SELECT run_id FROM runs ORDER BY created_at DESC")
107 else {
108 return Vec::new();
109 };
110 let Ok(ids) = statement.query_map([], |row| row.get::<_, String>(0)) else {
111 return Vec::new();
112 };
113 ids.filter_map(|id| {
114 let id = id.ok()?;
115 let run = read_run(database_path, &id).ok()?;
116 Some((id, run.manifest))
117 })
118 .collect()
119}
120
121pub fn with_artifact_placeholders(value: &Value) -> Value {
122 value.clone()
123}
124
125pub fn resolve_artifacts(value: &Value, _database_path: &Path, _max_bytes: u64) -> Value {
126 value.clone()
127}
128
129pub fn validate_database(database_path: &Path) -> Result<()> {
130 open(database_path).map(drop)
131}
132
133fn open(path: &Path) -> Result<Connection> {
134 let connection = Connection::open_with_flags(
135 path,
136 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
137 )
138 .with_context(|| format!("could not open {}", path.display()))?;
139 connection.pragma_update(None, "query_only", true)?;
140 connection.pragma_update(None, "foreign_keys", true)?;
141 let application_id: i64 =
142 connection.pragma_query_value(None, "application_id", |row| row.get(0))?;
143 let user_version: i64 =
144 connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
145 if application_id != APPLICATION_ID || user_version != USER_VERSION {
146 bail!(RESET_INSTRUCTION);
147 }
148 let schema = connection
149 .query_row(
150 "SELECT schema_name, schema_version, schema_digest, app_version
151 FROM schema_meta WHERE id = 1",
152 [],
153 |row| {
154 Ok((
155 row.get::<_, String>(0)?,
156 row.get::<_, i64>(1)?,
157 row.get::<_, Vec<u8>>(2)?,
158 row.get::<_, String>(3)?,
159 ))
160 },
161 )
162 .optional()
163 .unwrap_or(None);
164 if !matches!(
165 schema,
166 Some((name, version, digest, app_version))
167 if name == SCHEMA_NAME
168 && version == USER_VERSION
169 && digest.as_slice() == SCHEMA_DIGEST
170 && app_version == APP_VERSION
171 ) {
172 bail!(RESET_INSTRUCTION);
173 }
174 Ok(connection)
175}
176
177fn read_json_blob(connection: &Connection, hash: &[u8]) -> Result<Value> {
178 let content: Vec<u8> = connection.query_row(
179 "SELECT content FROM blobs WHERE blob_hash = ?1 AND media_type = 'application/json'",
180 [hash],
181 |row| row.get(0),
182 )?;
183 Ok(serde_json::from_slice(&content)?)
184}
185
186fn read_text_blob(connection: &Connection, hash: &[u8]) -> Result<String> {
187 let content: Vec<u8> = connection.query_row(
188 "SELECT content FROM blobs WHERE blob_hash = ?1 AND media_type = 'text/plain'",
189 [hash],
190 |row| row.get(0),
191 )?;
192 Ok(String::from_utf8(content)?)
193}
194
195fn read_settings(connection: &Connection, run_id: &str) -> Result<Vec<Value>> {
196 let mut statement = connection.prepare(
197 "SELECT s.scope_id, s.mount_path, s.invocation, s.current_hash, r.revision
198 FROM workflow_settings s
199 JOIN resources r ON r.resource_id = s.resource_id
200 WHERE s.active_run_id = ?1
201 ORDER BY s.mount_path, s.invocation",
202 )?;
203 let rows = statement.query_map([run_id], |row| {
204 Ok((
205 row.get::<_, String>(0)?,
206 row.get::<_, String>(1)?,
207 row.get::<_, u64>(2)?,
208 row.get::<_, Vec<u8>>(3)?,
209 row.get::<_, u64>(4)?,
210 ))
211 })?;
212 let mut scopes = Vec::new();
213 for row in rows {
214 let (scope_id, mount_path, invocation, settings_hash, change_number) = row?;
215 scopes.push(json!({
216 "scopeId": scope_id,
217 "mountPath": mount_path,
218 "invocation": invocation,
219 "changeNumber": change_number,
220 "settingsHash": encode_hex(&settings_hash),
221 }));
222 }
223 Ok(scopes)
224}
225
226fn read_follow_ups(connection: &Connection, run_id: &str) -> Result<Option<Value>> {
227 let queue = connection
228 .query_row(
229 "SELECT presentation_state FROM workflow_follow_up_queues WHERE run_id = ?1",
230 [run_id],
231 |row| row.get::<_, String>(0),
232 )
233 .optional()?;
234 let Some(presentation_state) = queue else {
235 return Ok(None);
236 };
237 let mut statement = connection.prepare(
238 "SELECT follow_up_id, order_number, status, source_type, session_entry_id
239 FROM workflow_follow_ups WHERE run_id = ?1 ORDER BY order_number",
240 )?;
241 let rows = statement.query_map([run_id], |row| {
242 Ok(json!({
243 "followUpId": row.get::<_, String>(0)?,
244 "order": row.get::<_, u64>(1)?,
245 "state": row.get::<_, String>(2)?,
246 "source": row.get::<_, String>(3)?,
247 "sessionEntryId": row.get::<_, Option<String>>(4)?,
248 }))
249 })?;
250 let items = rows.collect::<Result<Vec<_>, _>>()?;
251 Ok(Some(json!({
252 "presentationState": presentation_state,
253 "items": items,
254 })))
255}
256
257fn read_state(connection: &Connection, run_id: &str, definition: &Value) -> Result<RunState> {
258 let row = connection.query_row(
259 "SELECT r.resource_id, d.workflow_name, r.parent_run_id, r.title, r.status,
260 r.paused, r.status_detail, r.input_hash, r.final_output_hash, r.error_hash,
261 r.definition_digest, r.created_at, r.updated_at, r.finished_at,
262 resources.revision
263 FROM runs r
264 JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
265 JOIN resources ON resources.resource_id = r.resource_id
266 WHERE r.run_id = ?1",
267 [run_id],
268 |row| {
269 Ok((
270 row.get::<_, String>(0)?,
271 row.get::<_, String>(1)?,
272 row.get::<_, Option<String>>(2)?,
273 row.get::<_, Option<String>>(3)?,
274 row.get::<_, String>(4)?,
275 row.get::<_, i64>(5)?,
276 row.get::<_, Option<String>>(6)?,
277 row.get::<_, Vec<u8>>(7)?,
278 row.get::<_, Option<Vec<u8>>>(8)?,
279 row.get::<_, Option<Vec<u8>>>(9)?,
280 row.get::<_, Vec<u8>>(10)?,
281 row.get::<_, i64>(11)?,
282 row.get::<_, i64>(12)?,
283 row.get::<_, Option<i64>>(13)?,
284 row.get::<_, u64>(14)?,
285 ))
286 },
287 )?;
288 let (
289 _resource_id,
290 workflow_name,
291 parent_run_id,
292 title,
293 status,
294 paused,
295 status_detail,
296 input_hash,
297 final_output_hash,
298 error_hash,
299 definition_digest,
300 created_at,
301 updated_at,
302 finished_at,
303 revision,
304 ) = row;
305
306 let steps = read_steps(connection, run_id)?;
307 let mut outputs = serde_json::Map::new();
308 let mut results = serde_json::Map::new();
309 for step in &steps {
310 let outcome = step
311 .get("outcome")
312 .and_then(Value::as_str)
313 .unwrap_or("failed");
314 let node_id = step
315 .get("nodeId")
316 .and_then(Value::as_str)
317 .unwrap_or_default();
318 let started = step
319 .get("startedAt")
320 .and_then(Value::as_str)
321 .unwrap_or_default();
322 let finished = step
323 .get("finishedAt")
324 .and_then(Value::as_str)
325 .unwrap_or_default();
326 let duration = chrono::DateTime::parse_from_rfc3339(finished)
327 .ok()
328 .zip(chrono::DateTime::parse_from_rfc3339(started).ok())
329 .map_or(0, |(end, start)| (end - start).num_milliseconds());
330 let mut result = json!({
331 "attemptId": step.get("attemptId").cloned().unwrap_or(Value::Null),
332 "nodeId": node_id,
333 "nodeType": step.get("nodeType").cloned().unwrap_or(Value::Null),
334 "outcome": outcome,
335 "startedAt": started,
336 "finishedAt": finished,
337 "durationMs": duration,
338 });
339 if outcome == "ok" {
340 let output = step.get("output").cloned().unwrap_or(Value::Null);
341 outputs.insert(node_id.to_string(), output.clone());
342 result["output"] = output;
343 } else {
344 outputs.remove(node_id);
345 }
346 if let Some(error) = step.get("error") {
347 result["error"] = error.clone();
348 }
349 results.insert(node_id.to_string(), result.clone());
350 if let Some(mount_path) = exit_mount_path(definition, node_id) {
351 if outcome == "ok" {
352 let output = step.get("output").cloned().unwrap_or(Value::Null);
353 outputs.insert(mount_path.clone(), output.clone());
354 result["nodeId"] = json!(mount_path);
355 result["output"] = output;
356 results.insert(mount_path, result);
357 }
358 }
359 }
360
361 let mut state = json!({
362 "schema": RUN_STATE_SCHEMA,
363 "traceSeq": revision,
364 "runId": run_id,
365 "workflowName": workflow_name,
366 "startedAt": timestamp(created_at),
367 "updatedAt": timestamp(updated_at),
368 "status": status,
369 "input": read_json_blob(connection, &input_hash)?,
370 "outputs": outputs,
371 "results": results,
372 "steps": steps,
373 });
374 if let Some(value) = parent_run_id {
375 state["parentRunId"] = json!(value);
376 }
377 if let Some(value) = title {
378 state["runTitle"] = json!(value);
379 }
380 if let Some(value) = status_detail {
381 state["statusDetail"] = json!(value);
382 }
383 if paused != 0 {
384 state["paused"] = json!(true);
385 }
386 if let Some(value) = finished_at {
387 state["finishedAt"] = json!(timestamp(value));
388 }
389 if let Some(hash) = final_output_hash {
390 state["finalOutput"] = read_json_blob(connection, &hash)?;
391 }
392 if let Some(hash) = error_hash {
393 state["error"] = json!(read_text_blob(connection, &hash)?);
394 }
395 let carried: u64 = connection.query_row(
396 "SELECT count(*) FROM run_steps s
397 JOIN node_attempts a ON a.attempt_id = s.attempt_id
398 WHERE s.run_id = ?1 AND a.run_id <> s.run_id",
399 [run_id],
400 |row| row.get(0),
401 )?;
402 if carried != 0 {
403 state["carriedStepCount"] = json!(carried);
404 }
405 if status == "running" {
406 if let Some((attempt_id, node_id, started_at, scope_id, change_number, settings_hash)) =
407 connection
408 .query_row(
409 "SELECT attempt_id, node_id, started_at,
410 settings_scope_id, settings_change_number, settings_hash
411 FROM node_attempts
412 WHERE run_id = ?1 AND status IN ('pending', 'running', 'waiting', 'interrupted')",
413 [run_id],
414 |row| {
415 Ok((
416 row.get::<_, String>(0)?,
417 row.get::<_, String>(1)?,
418 row.get::<_, Option<i64>>(2)?,
419 row.get::<_, Option<String>>(3)?,
420 row.get::<_, Option<u64>>(4)?,
421 row.get::<_, Option<Vec<u8>>>(5)?,
422 ))
423 },
424 )
425 .optional()?
426 {
427 state["currentAttemptId"] = json!(attempt_id);
428 state["currentNode"] = json!(node_id);
429 if let Some(value) = started_at {
430 state["currentNodeStartedAt"] = json!(timestamp(value));
431 }
432 match (scope_id, change_number, settings_hash) {
433 (Some(scope_id), Some(change_number), Some(settings_hash)) => {
434 state["currentSettingsScopeId"] = json!(scope_id);
435 state["currentSettingsChangeNumber"] = json!(change_number);
436 state["currentSettingsHash"] = json!(encode_hex(&settings_hash));
437 }
438 (None, None, None) => {}
439 _ => bail!("active workflow settings binding is incomplete"),
440 }
441 }
442 }
443 if status == "waiting" {
444 if let Some(node_id) = state["steps"]
445 .as_array()
446 .and_then(|values| values.last())
447 .and_then(|step| step.get("nodeId"))
448 {
449 state["waitingOn"] = node_id.clone();
450 }
451 }
452 let (root_source, mounted_sources) = read_sources(connection, run_id, definition)?;
453 if let Some(source) = root_source {
454 state["workflowSource"] = source;
455 }
456 if !mounted_sources.is_empty() {
457 state["workflowSources"] = json!(mounted_sources);
458 }
459 let has_composed_mounts = definition
460 .pointer("/composition/mounts")
461 .and_then(Value::as_array)
462 .is_some_and(|mounts| !mounts.is_empty());
463 if !state["workflowSources"].is_null() || has_composed_mounts {
464 state["definitionDigest"] = json!(format!("sha256:{}", encode_hex(&definition_digest)));
465 }
466 let updates = read_updates(connection, run_id)?;
467 if !updates.is_empty() {
468 state["updates"] = json!(updates);
469 }
470 if let Some(receipt) = read_human_decision_receipt(connection, run_id)? {
471 state["humanDecision"] = receipt;
472 }
473 Ok(serde_json::from_value(state)?)
474}
475
476fn read_steps(connection: &Connection, run_id: &str) -> Result<Vec<Value>> {
477 let mut statement = connection.prepare(
478 "SELECT a.attempt_id, a.node_id, a.node_type, a.status,
479 a.prompt_hash, a.output_hash, s.output_override_hash, a.receipt_hash, a.error_hash,
480 prompt_entry.entry_hash, response_entry.entry_hash,
481 first_link.entry_id, last_link.entry_id,
482 a.settings_scope_id, a.settings_change_number, a.settings_hash,
483 a.started_at, a.finished_at
484 FROM run_steps s JOIN node_attempts a ON a.attempt_id = s.attempt_id
485 LEFT JOIN attempt_entries prompt_link
486 ON prompt_link.attempt_id = a.attempt_id AND prompt_link.role = 'prompt'
487 LEFT JOIN session_entries prompt_entry
488 ON prompt_entry.segment_id = prompt_link.segment_id AND prompt_entry.entry_id = prompt_link.entry_id
489 LEFT JOIN attempt_entries response_link
490 ON response_link.attempt_id = a.attempt_id AND response_link.role = 'response'
491 LEFT JOIN session_entries response_entry
492 ON response_entry.segment_id = response_link.segment_id AND response_entry.entry_id = response_link.entry_id
493 LEFT JOIN attempt_entries first_link
494 ON first_link.attempt_id = a.attempt_id AND first_link.role = 'first'
495 LEFT JOIN attempt_entries last_link
496 ON last_link.attempt_id = a.attempt_id AND last_link.role = 'last'
497 WHERE s.run_id = ?1 ORDER BY s.step_index",
498 )?;
499 let rows = statement.query_map([run_id], |row| {
500 Ok((
501 row.get::<_, String>(0)?,
502 row.get::<_, String>(1)?,
503 row.get::<_, String>(2)?,
504 row.get::<_, String>(3)?,
505 row.get::<_, Option<Vec<u8>>>(4)?,
506 row.get::<_, Option<Vec<u8>>>(5)?,
507 row.get::<_, Option<Vec<u8>>>(6)?,
508 row.get::<_, Option<Vec<u8>>>(7)?,
509 row.get::<_, Option<Vec<u8>>>(8)?,
510 row.get::<_, Option<Vec<u8>>>(9)?,
511 row.get::<_, Option<Vec<u8>>>(10)?,
512 row.get::<_, Option<String>>(11)?,
513 row.get::<_, Option<String>>(12)?,
514 row.get::<_, Option<String>>(13)?,
515 row.get::<_, Option<u64>>(14)?,
516 row.get::<_, Option<Vec<u8>>>(15)?,
517 row.get::<_, i64>(16)?,
518 row.get::<_, i64>(17)?,
519 ))
520 })?;
521 let mut steps = Vec::new();
522 for row in rows {
523 let (
524 attempt_id,
525 node_id,
526 node_type,
527 status,
528 stored_prompt_hash,
529 output_hash,
530 override_hash,
531 receipt_hash,
532 error_hash,
533 prompt_hash,
534 response_hash,
535 first_entry_id,
536 last_entry_id,
537 settings_scope_id,
538 settings_change_number,
539 settings_hash,
540 started_at,
541 finished_at,
542 ) = row?;
543 let receipt = receipt_hash
544 .as_deref()
545 .map(|hash| read_json_blob(connection, hash))
546 .transpose()?
547 .unwrap_or_else(|| json!({}));
548 let prompt = if let Some(hash) = prompt_hash.as_deref() {
549 let entry = read_json_blob(connection, hash)?;
550 prompt_from_entry(&entry).map_or(Value::Null, Value::String)
551 } else if let Some(hash) = stored_prompt_hash.as_deref() {
552 Value::String(read_text_blob(connection, hash)?)
553 } else {
554 Value::Null
555 };
556 let output = if let Some(hash) = override_hash.as_deref().or(output_hash.as_deref()) {
557 read_json_blob(connection, hash)?
558 } else if let Some(hash) = response_hash.as_deref() {
559 assistant_output_from_entry(&read_json_blob(connection, hash)?)?
560 } else {
561 Value::Null
562 };
563 let mut step = json!({
564 "attemptId": attempt_id,
565 "nodeId": node_id,
566 "nodeType": node_type,
567 "outcome": outcome_for_status(&status)?,
568 "startedAt": timestamp(started_at),
569 "finishedAt": timestamp(finished_at),
570 "prompt": prompt,
571 "output": output,
572 });
573 if let Some(hash) = error_hash {
574 step["error"] = json!(read_text_blob(connection, &hash)?);
575 }
576 if let Some(value) = receipt.get("action") {
577 step["action"] = value.clone();
578 }
579 if let Some(value) = receipt.get("assistantMessage") {
580 step["assistantMessage"] = value.clone();
581 }
582 if let (Some(first), Some(last)) = (first_entry_id, last_entry_id) {
583 step["conversation"] = json!({ "firstEntryId": first, "lastEntryId": last });
584 }
585 match (settings_scope_id, settings_change_number, settings_hash) {
586 (Some(scope_id), Some(change_number), Some(settings_hash)) => {
587 step["settingsScopeId"] = json!(scope_id);
588 step["settingsChangeNumber"] = json!(change_number);
589 step["settingsHash"] = json!(encode_hex(&settings_hash));
590 }
591 (None, None, None) => {}
592 _ => bail!("saved workflow settings binding is incomplete"),
593 }
594 steps.push(step);
595 }
596 Ok(steps)
597}
598
599fn read_sources(
600 connection: &Connection,
601 run_id: &str,
602 definition: &Value,
603) -> Result<(Option<Value>, Vec<Value>)> {
604 let mut statement = connection.prepare(
605 "SELECT mount_path, source_type, source_ref, source_revision
606 FROM run_sources WHERE run_id = ?1 ORDER BY mount_path",
607 )?;
608 let rows = statement
609 .query_map([run_id], |row| {
610 Ok((
611 row.get::<_, String>(0)?,
612 row.get::<_, String>(1)?,
613 row.get::<_, String>(2)?,
614 row.get::<_, String>(3)?,
615 ))
616 })?
617 .collect::<Result<Vec<_>, _>>()?;
618 let mut root = None;
619 let mut mounted = Vec::new();
620 for (mount_path, source_type, source_ref, source_revision) in rows {
621 let source = if source_type == "builtin" {
622 json!({ "kind": "builtin", "id": source_ref, "revision": source_revision })
623 } else {
624 json!({ "kind": "file", "path": source_ref, "hash": source_revision })
625 };
626 if mount_path.is_empty() {
627 if source["kind"] != "file"
628 || !source["path"]
629 .as_str()
630 .is_some_and(|value| value.starts_with("inline:"))
631 {
632 root = Some(source);
633 }
634 continue;
635 }
636 let workflow_name = definition["composition"]["mounts"]
637 .as_array()
638 .and_then(|mounts| {
639 mounts.iter().find(|mount| {
640 mount["mountPath"].as_array().is_some_and(|parts| {
641 parts
642 .iter()
643 .filter_map(Value::as_str)
644 .collect::<Vec<_>>()
645 .join("/")
646 == mount_path
647 })
648 })
649 })
650 .and_then(|mount| mount["workflowName"].as_str())
651 .unwrap_or(&mount_path);
652 mounted.push(json!({
653 "mountPath": mount_path.split('/').collect::<Vec<_>>(),
654 "workflowName": workflow_name,
655 "source": source,
656 }));
657 }
658 Ok((root, mounted))
659}
660
661fn read_updates(connection: &Connection, run_id: &str) -> Result<Vec<Value>> {
662 let mut statement = connection.prepare(
663 "SELECT u.update_id, u.run_revision, a.node_id, u.attempt_id,
664 u.update_type, u.update_key, u.data_hash, u.recorded_at
665 FROM workflow_updates u JOIN node_attempts a ON a.attempt_id = u.attempt_id
666 WHERE a.run_id = ?1 ORDER BY u.run_revision",
667 )?;
668 let rows = statement.query_map([run_id], |row| {
669 Ok((
670 row.get::<_, String>(0)?,
671 row.get::<_, u64>(1)?,
672 row.get::<_, String>(2)?,
673 row.get::<_, String>(3)?,
674 row.get::<_, String>(4)?,
675 row.get::<_, String>(5)?,
676 row.get::<_, Vec<u8>>(6)?,
677 row.get::<_, i64>(7)?,
678 ))
679 })?;
680 let mut current = std::collections::BTreeMap::new();
681 for row in rows {
682 let (update_id, seq, node_id, attempt_id, kind, key, hash, at) = row?;
683 current.insert(
684 (kind.clone(), key.clone()),
685 json!({
686 "updateId": update_id, "seq": seq, "at": timestamp(at), "runId": run_id,
687 "nodeId": node_id, "attemptId": attempt_id, "type": kind, "key": key,
688 "data": read_json_blob(connection, &hash)?,
689 }),
690 );
691 }
692 let mut values = current.into_values().collect::<Vec<_>>();
693 values.sort_by_key(|value| value["seq"].as_u64().unwrap_or_default());
694 Ok(values)
695}
696
697fn read_human_decision_receipt(connection: &Connection, run_id: &str) -> Result<Option<Value>> {
698 let row = connection
699 .query_row(
700 "SELECT d.request_hash, r.response_hash FROM continuations c
701 JOIN human_decisions d ON d.decision_id = c.decision_id
702 JOIN human_decision_resolutions r ON r.decision_id = c.decision_id
703 WHERE c.continuation_run_id = ?1 AND r.outcome = 'accepted'",
704 [run_id],
705 |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Vec<u8>>(1)?)),
706 )
707 .optional()?;
708 let Some((request_hash, decision_hash)) = row else {
709 return Ok(None);
710 };
711 let request = read_json_blob(connection, &request_hash)?;
712 let decision = read_json_blob(connection, &decision_hash)?;
713 Ok(Some(json!({
714 "schema": "pi-workflows.human-decision-receipt.v1",
715 "decisionId": request["decisionId"], "requestDigest": request["requestDigest"],
716 "nodeId": request["nodeId"], "response": decision["response"],
717 "provenance": decision["provenance"], "acceptedAt": decision["acceptedAt"],
718 "answerDigest": decision["answerDigest"], "subjectDigest": decision["subjectDigest"],
719 "presentationDigest": decision["presentationDigest"], "revision": decision["revision"],
720 })))
721}
722
723fn prompt_from_entry(entry: &Value) -> Option<String> {
724 if let Some(text) = entry.get("content").and_then(Value::as_str) {
725 return Some(text.to_string());
726 }
727 entry.get("content")?.as_array().map(|parts| {
728 parts
729 .iter()
730 .filter_map(|part| part.get("text").and_then(Value::as_str))
731 .collect::<Vec<_>>()
732 .join("\n")
733 })
734}
735
736fn assistant_output_from_entry(entry: &Value) -> Result<Value> {
737 let Some(parts) = entry["message"]["content"].as_array() else {
738 bail!("assistant response entry is invalid");
739 };
740 let text = parts
741 .iter()
742 .filter(|part| part["type"] == "text")
743 .filter_map(|part| part["text"].as_str())
744 .collect::<Vec<_>>()
745 .join("\n");
746 if text.trim().is_empty() {
747 bail!("assistant response entry has no visible text");
748 }
749 Ok(Value::String(text))
750}
751
752fn outcome_for_status(status: &str) -> Result<&'static str> {
753 match status {
754 "completed" => Ok("ok"),
755 "failed" => Ok("failed"),
756 "timed_out" => Ok("timed_out"),
757 "cancelled" => Ok("cancelled"),
758 _ => bail!("workflow step has nonterminal status: {status}"),
759 }
760}
761
762fn exit_mount_path(definition: &Value, node_id: &str) -> Option<String> {
763 let node = definition.get("nodes")?.get(node_id)?;
764 if node.get("includeTransition")?.as_str()? != "exit" {
765 return None;
766 }
767 Some(
768 node.get("mountPath")?
769 .as_array()?
770 .iter()
771 .filter_map(Value::as_str)
772 .collect::<Vec<_>>()
773 .join("/"),
774 )
775}
776
777fn encode_hex(bytes: &[u8]) -> String {
778 const HEX: &[u8; 16] = b"0123456789abcdef";
779 let mut output = String::with_capacity(bytes.len() * 2);
780 for byte in bytes {
781 output.push(HEX[(byte >> 4) as usize] as char);
782 output.push(HEX[(byte & 0x0f) as usize] as char);
783 }
784 output
785}
786
787fn read_trace(connection: &Connection, run_id: &str) -> Result<Vec<TraceEvent>> {
788 let resource_id: String = connection.query_row(
789 "SELECT resource_id FROM runs WHERE run_id = ?1",
790 [run_id],
791 |row| row.get(0),
792 )?;
793 let mut statement = connection.prepare(
794 "SELECT resource_revision, event_type, payload_hash, recorded_at
795 FROM events WHERE resource_id = ?1 ORDER BY resource_revision",
796 )?;
797 let rows = statement.query_map([resource_id], |row| {
798 Ok((
799 row.get::<_, u64>(0)?,
800 row.get::<_, String>(1)?,
801 row.get::<_, Option<Vec<u8>>>(2)?,
802 row.get::<_, i64>(3)?,
803 ))
804 })?;
805 let mut events = Vec::new();
806 for row in rows {
807 let (seq, event_type, payload_hash, recorded_at) = row?;
808 let envelope = match payload_hash {
809 Some(hash) => read_json_blob(connection, &hash)?,
810 None => json!({}),
811 };
812 let payload = envelope
813 .get("payload")
814 .cloned()
815 .unwrap_or_else(|| json!({}));
816 let mut event = json!({
817 "seq": seq,
818 "at": timestamp(recorded_at),
819 "runId": run_id,
820 "scope": envelope.get("scope").and_then(Value::as_str).unwrap_or("run"),
821 "type": event_type,
822 "payload": payload,
823 });
824 if let Some(node_id) = envelope.get("nodeId") {
825 event["nodeId"] = node_id.clone();
826 }
827 if let Some(attempt_id) = envelope.get("attemptId") {
828 event["attemptId"] = attempt_id.clone();
829 }
830 events.push(serde_json::from_value(event)?);
831 }
832 Ok(events)
833}
834
835fn read_session(connection: &Connection, run_id: &str) -> Result<LoadedSession> {
836 let mut segments_statement = connection.prepare(
837 "SELECT segment_id, binding_hash, status, entry_count, event_count,
838 failure_hash
839 FROM session_segments
840 WHERE run_id = ?1
841 ORDER BY created_at, segment_id",
842 )?;
843 let segment_rows = segments_statement
844 .query_map([run_id], |row| {
845 Ok((
846 row.get::<_, String>(0)?,
847 row.get::<_, Option<Vec<u8>>>(1)?,
848 row.get::<_, String>(2)?,
849 row.get::<_, u64>(3)?,
850 row.get::<_, u64>(4)?,
851 row.get::<_, Option<Vec<u8>>>(5)?,
852 ))
853 })?
854 .collect::<Result<Vec<_>, _>>()?;
855 if segment_rows.is_empty() {
856 return Ok((None, Vec::new(), Vec::new(), None));
857 }
858
859 let mut binding = None;
860 let mut entries = Vec::new();
861 let mut events = Vec::new();
862 let mut status = "complete".to_string();
863 let mut failure = None;
864
865 for (segment_id, binding_hash, segment_status, _entry_count, _event_count, failure_hash) in
866 segment_rows
867 {
868 if binding.is_none() {
869 if let Some(hash) = binding_hash {
870 binding = Some(serde_json::from_value(read_json_blob(connection, &hash)?)?);
871 }
872 }
873 if segment_status == "failed" {
874 status = "failed".to_string();
875 if failure.is_none() {
876 if let Some(hash) = failure_hash {
877 failure = Some(read_json_blob(connection, &hash)?);
878 }
879 }
880 } else if segment_status == "recording" && status != "failed" {
881 status = "recording".to_string();
882 }
883
884 let mut entries_statement = connection.prepare(
885 "SELECT entry_hash, recorded_at
886 FROM session_entries WHERE segment_id = ?1 ORDER BY entry_seq",
887 )?;
888 let segment_entries = entries_statement
889 .query_map([&segment_id], |row| {
890 Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?))
891 })?
892 .collect::<Result<Vec<_>, _>>()?;
893 for (hash, at) in segment_entries {
894 entries.push(serde_json::from_value(json!({
895 "seq": entries.len() + 1,
896 "at": timestamp(at),
897 "entry": read_json_blob(connection, &hash)?,
898 }))?);
899 }
900
901 let mut events_statement = connection.prepare(
902 "SELECT event_type, node_id, attempt_id, turn_id,
903 message_id, tool_call_id, payload_hash, recorded_at
904 FROM session_events WHERE segment_id = ?1 ORDER BY event_seq",
905 )?;
906 let segment_events = events_statement
907 .query_map([&segment_id], |row| {
908 Ok((
909 row.get::<_, String>(0)?,
910 row.get::<_, String>(1)?,
911 row.get::<_, String>(2)?,
912 row.get::<_, Option<String>>(3)?,
913 row.get::<_, Option<String>>(4)?,
914 row.get::<_, Option<String>>(5)?,
915 row.get::<_, Vec<u8>>(6)?,
916 row.get::<_, i64>(7)?,
917 ))
918 })?
919 .collect::<Result<Vec<_>, _>>()?;
920 for (event_type, node_id, attempt_id, turn_id, message_id, tool_call_id, hash, at) in
921 segment_events
922 {
923 let mut value = json!({
924 "seq": events.len() + 1,
925 "at": timestamp(at),
926 "nodeId": node_id,
927 "attemptId": attempt_id,
928 "type": event_type,
929 "payload": read_json_blob(connection, &hash)?,
930 });
931 if let Some(turn_id) = turn_id {
932 value["turnId"] = json!(turn_id);
933 }
934 if let Some(message_id) = message_id {
935 value["messageId"] = json!(message_id);
936 }
937 if let Some(tool_call_id) = tool_call_id {
938 value["toolCallId"] = json!(tool_call_id);
939 }
940 events.push(serde_json::from_value(value)?);
941 }
942 }
943
944 let mut capture = json!({
945 "schema": "pi-workflows.session-capture.v1",
946 "eventSchema": "pi-workflows.session-event.v1",
947 "status": status,
948 "eventCount": events.len(),
949 "entryCount": entries.len(),
950 "lastEventSeq": events.len(),
951 });
952 if let Some(failure) = failure {
953 capture["failure"] = failure;
954 }
955 Ok((
956 binding,
957 entries,
958 events,
959 Some(serde_json::from_value(capture)?),
960 ))
961}
962
963fn manifest_from_state(state: &RunState) -> Manifest {
964 Manifest {
965 schema: "pi-workflows.sqlite-view.v1".to_string(),
966 run_id: state.run_id.clone(),
967 workflow_name: state.workflow_name.clone(),
968 run_title: state.run_title.clone(),
969 workflow_source: state.workflow_source.clone(),
970 started_at: state.started_at.clone(),
971 finished_at: state.finished_at.clone(),
972 status: state.status,
973 trace_schema: "pi-workflows.event.v1".to_string(),
974 paths: ManifestPaths {
975 workflow: String::new(),
976 state: String::new(),
977 trace: String::new(),
978 session: None,
979 artifacts: None,
980 },
981 }
982}
983
984fn timestamp(milliseconds: i64) -> String {
985 Utc.timestamp_millis_opt(milliseconds)
986 .single()
987 .map(|value| value.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
988 .unwrap_or_else(|| "1970-01-01T00:00:00.000Z".to_string())
989}