Skip to main content

piw/state/
reader.rs

1//! Read-only access to the canonical Pi Workflows SQLite database.
2
3use 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}