Skip to main content

piw/state/
reader.rs

1//! Read-only access to the canonical Pi Workflows SQLite database.
2
3use crate::protocol::{PageKind, PatchOp};
4use crate::state::types::{
5    DefinitionSnapshot, Manifest, ManifestPaths, RunState, SessionBinding, SessionCapture,
6    SessionEntryRecord, SessionEventRecord, TraceEvent, DEFINITION_SNAPSHOT_SCHEMA,
7    RUN_STATE_SCHEMA,
8};
9use anyhow::{bail, Context, Result};
10use chrono::{TimeZone, Utc};
11use rusqlite::{Connection, OpenFlags, OptionalExtension};
12use serde_json::{json, Value};
13use std::collections::BTreeMap;
14use std::path::Path;
15
16pub const VIEWER_PAGE_SIZE: u64 = 256;
17
18const APPLICATION_ID: i64 = 0x5049_5746;
19const USER_VERSION: i64 = 1;
20const SCHEMA_NAME: &str = "pi-workflows-state";
21const APP_VERSION: &str = "0.13.3";
22pub const SCHEMA_DIGEST: [u8; 32] = [
23    0xb5, 0xbc, 0xda, 0x10, 0x34, 0xe2, 0xee, 0x30, 0x13, 0xd8, 0xee, 0x2c, 0xc6, 0x5d, 0xcf, 0xe8,
24    0xa2, 0xad, 0x6e, 0x6a, 0xd8, 0xab, 0x1e, 0xeb, 0xab, 0x7f, 0x89, 0x0d, 0xe6, 0x00, 0x31, 0xed,
25];
26const RESET_INSTRUCTION: &str = "Pi Workflows durable state is incompatible. Move or remove the old workflow state, then create a new state.sqlite database.";
27
28type LoadedSession = (
29    Option<SessionBinding>,
30    Vec<SessionEntryRecord>,
31    Vec<SessionEventRecord>,
32    Option<SessionCapture>,
33);
34
35#[derive(Clone, Copy, Debug, Default)]
36pub struct ProjectionCursors {
37    pub step: Option<u64>,
38    pub trace: Option<u64>,
39    pub trace_step: Option<u64>,
40    pub session_entry: Option<u64>,
41    pub session_event: Option<u64>,
42    pub settings: Option<u64>,
43    pub follow_ups: Option<u64>,
44    pub updates: Option<u64>,
45}
46
47type LoadedSessionWindow = (
48    Option<SessionBinding>,
49    Vec<SessionEntryRecord>,
50    u64,
51    u64,
52    Vec<SessionEventRecord>,
53    u64,
54    u64,
55    Option<SessionCapture>,
56    Option<Value>,
57);
58
59#[derive(Debug, Clone)]
60pub struct LoadedRun {
61    pub manifest: Manifest,
62    pub state: RunState,
63    pub graph_steps: Vec<crate::state::types::StepRecord>,
64    pub taken_transitions: Vec<String>,
65    pub graph_cursor: u64,
66    pub step_start: u64,
67    pub step_total: u64,
68    pub snapshot: Option<DefinitionSnapshot>,
69    pub trace: Vec<TraceEvent>,
70    pub trace_start: u64,
71    pub trace_total: u64,
72    pub session_binding: Option<SessionBinding>,
73    pub session_entries: Vec<SessionEntryRecord>,
74    pub session_entry_start: u64,
75    pub session_entry_total: u64,
76    pub session_events: Vec<SessionEventRecord>,
77    pub session_event_start: u64,
78    pub session_event_total: u64,
79    pub session_capture: Option<SessionCapture>,
80    pub session_replay_checkpoint: Option<Value>,
81    pub settings_scopes: Vec<Value>,
82    pub settings_start: u64,
83    pub settings_total: u64,
84    pub follow_up_queue: Option<Value>,
85    pub follow_up_start: u64,
86    pub follow_up_total: u64,
87    pub update_start: u64,
88    pub update_total: u64,
89    pub possibly_interrupted: bool,
90    pub presentation_revision: u64,
91}
92
93#[derive(Debug, Clone)]
94pub struct RunIndexRow {
95    pub manifest: Manifest,
96    pub live: bool,
97    pub possibly_interrupted: bool,
98    pub presentation_revision: u64,
99    pub retained_from_revision: u64,
100    pub lease_owner_id: Option<String>,
101    pub lease_expires_at: Option<i64>,
102}
103
104#[derive(Debug, Clone)]
105pub struct ViewerTargetDelta {
106    pub target_type: String,
107    pub target_key: String,
108    pub patch: Vec<PatchOp>,
109}
110
111#[derive(Debug, Clone)]
112pub struct ViewerRevisionDelta {
113    pub revision: u64,
114    pub targets: Vec<ViewerTargetDelta>,
115}
116
117#[derive(Debug, Clone)]
118pub struct ProjectionPage {
119    pub start: u64,
120    pub total: u64,
121    pub items: Vec<Value>,
122    pub graph_cursor: Option<u64>,
123    pub graph_steps: Option<Vec<crate::state::types::StepRecord>>,
124    pub taken_transitions: Option<Vec<String>>,
125    pub replay_checkpoint: Option<Value>,
126}
127
128pub enum ViewerDeltaRead {
129    Deltas {
130        current_revision: u64,
131        deltas: Vec<ViewerRevisionDelta>,
132    },
133    SnapshotRequired {
134        current_revision: u64,
135        retained_from_revision: u64,
136    },
137}
138
139pub struct ProjectionReader {
140    connection: Connection,
141}
142
143impl ProjectionReader {
144    pub fn open(database_path: &Path) -> Result<Self> {
145        Ok(Self {
146            connection: open(database_path)?,
147        })
148    }
149
150    pub fn data_version(&self) -> Result<u64> {
151        Ok(self
152            .connection
153            .pragma_query_value(None, "data_version", |row| row.get(0))?)
154    }
155
156    pub fn list_run_index(&self) -> Result<Vec<RunIndexRow>> {
157        list_run_index(&self.connection)
158    }
159
160    pub fn read_window(&self, run_id: &str, cursors: ProjectionCursors) -> Result<LoadedRun> {
161        let transaction = self.connection.unchecked_transaction()?;
162        let loaded = read_run_from_connection(&transaction, run_id, Some(cursors))?;
163        transaction.commit()?;
164        Ok(loaded)
165    }
166
167    pub fn read_page(
168        &self,
169        run_id: &str,
170        kind: PageKind,
171        cursor: u64,
172    ) -> Result<(u64, ProjectionPage)> {
173        let transaction = self.connection.unchecked_transaction()?;
174        let revision = transaction.query_row(
175            "SELECT presentation_revision FROM viewer_runs WHERE run_id = ?1",
176            [run_id],
177            |row| row.get(0),
178        )?;
179        let page = match kind {
180            PageKind::Steps => read_step_page(&transaction, run_id, Some(cursor))?,
181            PageKind::Trace | PageKind::TraceAtStep => {
182                let trace_cursor = if kind == PageKind::TraceAtStep {
183                    trace_cursor_for_step(&transaction, run_id, cursor)?
184                } else {
185                    Some(cursor)
186                };
187                let (items, start, total) = read_trace_window(&transaction, run_id, trace_cursor)?;
188                ProjectionPage {
189                    start,
190                    total,
191                    items: items
192                        .into_iter()
193                        .map(serde_json::to_value)
194                        .collect::<Result<Vec<_>, _>>()?,
195                    graph_cursor: None,
196                    graph_steps: None,
197                    taken_transitions: None,
198                    replay_checkpoint: None,
199                }
200            }
201            PageKind::SessionEntries => {
202                read_session_entry_page(&transaction, run_id, Some(cursor))?
203            }
204            PageKind::SessionEvents => read_session_event_page(&transaction, run_id, Some(cursor))?,
205            PageKind::Settings => read_settings_page(&transaction, run_id, Some(cursor))?,
206            PageKind::FollowUps => read_follow_up_page(&transaction, run_id, Some(cursor))?,
207            PageKind::Updates => read_update_page(&transaction, run_id, Some(cursor))?,
208        };
209        transaction.commit()?;
210        Ok((revision, page))
211    }
212
213    pub fn read_deltas(&self, run_id: &str, after_revision: u64) -> Result<ViewerDeltaRead> {
214        let (current_revision, retained_from_revision): (u64, u64) = self.connection.query_row(
215            "SELECT presentation_revision, retained_from_revision
216             FROM viewer_runs WHERE run_id = ?1",
217            [run_id],
218            |row| Ok((row.get(0)?, row.get(1)?)),
219        )?;
220        if after_revision == 0
221            || after_revision > current_revision
222            || after_revision < retained_from_revision.saturating_sub(1)
223        {
224            return Ok(ViewerDeltaRead::SnapshotRequired {
225                current_revision,
226                retained_from_revision,
227            });
228        }
229        let mut statement = self.connection.prepare(
230            "SELECT presentation_revision, target_type, target_key, patch_hash
231             FROM viewer_deltas
232             WHERE run_id = ?1 AND presentation_revision > ?2
233             ORDER BY presentation_revision, delta_index",
234        )?;
235        let rows = statement.query_map(rusqlite::params![run_id, after_revision], |row| {
236            Ok((
237                row.get::<_, u64>(0)?,
238                row.get::<_, String>(1)?,
239                row.get::<_, String>(2)?,
240                row.get::<_, Vec<u8>>(3)?,
241            ))
242        })?;
243        let mut grouped: BTreeMap<u64, Vec<ViewerTargetDelta>> = BTreeMap::new();
244        for row in rows {
245            let (revision, target_type, target_key, patch_hash) = row?;
246            let patch = serde_json::from_value(read_json_blob(&self.connection, &patch_hash)?)?;
247            grouped
248                .entry(revision)
249                .or_default()
250                .push(ViewerTargetDelta {
251                    target_type,
252                    target_key,
253                    patch,
254                });
255        }
256        let contiguous = grouped
257            .keys()
258            .copied()
259            .eq((after_revision + 1)..=current_revision);
260        if !contiguous {
261            return Ok(ViewerDeltaRead::SnapshotRequired {
262                current_revision,
263                retained_from_revision,
264            });
265        }
266        Ok(ViewerDeltaRead::Deltas {
267            current_revision,
268            deltas: grouped
269                .into_iter()
270                .map(|(revision, targets)| ViewerRevisionDelta { revision, targets })
271                .collect(),
272        })
273    }
274}
275
276pub fn read_run(database_path: &Path, run_id: &str) -> Result<LoadedRun> {
277    let connection = open(database_path)?;
278    read_run_from_connection(&connection, run_id, None)
279}
280
281fn read_run_from_connection(
282    connection: &Connection,
283    run_id: &str,
284    cursors: Option<ProjectionCursors>,
285) -> Result<LoadedRun> {
286    let row = connection
287        .query_row(
288            "SELECT d.definition_hash, l.owner_id, l.expires_at
289             FROM runs r
290             JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
291             JOIN leases l ON l.resource_id = r.resource_id
292             WHERE r.run_id = ?1",
293            [run_id],
294            |row| {
295                Ok((
296                    row.get::<_, Vec<u8>>(0)?,
297                    row.get::<_, Option<String>>(1)?,
298                    row.get::<_, Option<i64>>(2)?,
299                ))
300            },
301        )
302        .optional()?;
303    let Some((definition_hash, owner_id, lease_expires_at)) = row else {
304        bail!("workflow run not found: {run_id}");
305    };
306    let definition_value = read_json_blob(connection, &definition_hash)?;
307    let snapshot: DefinitionSnapshot = serde_json::from_value(definition_value.clone())?;
308    if snapshot.schema != DEFINITION_SNAPSHOT_SCHEMA {
309        bail!(
310            "unsupported workflow definition schema: {}",
311            snapshot.schema
312        );
313    }
314    let step_total: u64 = connection.query_row(
315        "SELECT count(*) FROM run_steps WHERE run_id = ?1",
316        [run_id],
317        |row| row.get(0),
318    )?;
319    let step_start = cursors.map_or(0, |cursors| page_start(step_total, cursors.step));
320    let mut update_page = read_update_page(
321        connection,
322        run_id,
323        cursors.and_then(|cursors| cursors.updates),
324    )?;
325    if cursors.is_none() && update_page.total > update_page.items.len() as u64 {
326        update_page.items = read_updates_range(connection, run_id, 0, -1)?;
327        update_page.start = 0;
328    }
329    let update_start = update_page.start;
330    let update_total = update_page.total;
331    let state = read_state(
332        connection,
333        run_id,
334        &definition_value,
335        cursors.map(|_| step_start),
336        update_page.items,
337    )?;
338    let graph_cursor = cursors.map_or_else(
339        || step_total.saturating_sub(1),
340        |cursors| {
341            cursors
342                .step
343                .unwrap_or_else(|| step_total.saturating_sub(1))
344                .min(step_total.saturating_sub(1))
345        },
346    );
347    let (graph_steps, taken_transitions) = match cursors {
348        Some(_) => (
349            read_graph_steps(connection, run_id, graph_cursor)?,
350            read_taken_transitions(connection, run_id, graph_cursor)?,
351        ),
352        None => (
353            state.steps.clone(),
354            state
355                .steps
356                .windows(2)
357                .map(|pair| format!("{}->{}", pair[0].node_id, pair[1].node_id))
358                .collect::<std::collections::BTreeSet<_>>()
359                .into_iter()
360                .collect(),
361        ),
362    };
363    let (trace, trace_start, trace_total) = match cursors {
364        Some(cursors) => {
365            let trace_cursor = match cursors.trace_step {
366                Some(step) => trace_cursor_for_step(connection, run_id, step)?,
367                None => cursors.trace,
368            };
369            read_trace_window(connection, run_id, trace_cursor)?
370        }
371        None => {
372            let trace = read_trace(connection, run_id)?;
373            let total = trace.len() as u64;
374            (trace, 0, total)
375        }
376    };
377    let (
378        session_binding,
379        session_entries,
380        session_entry_start,
381        session_entry_total,
382        session_events,
383        session_event_start,
384        session_event_total,
385        session_capture,
386        session_replay_checkpoint,
387    ) = match cursors {
388        Some(cursors) => read_session_window(
389            connection,
390            run_id,
391            cursors.session_entry,
392            cursors.session_event,
393        )?,
394        None => {
395            let (binding, entries, events, capture) = read_session(connection, run_id)?;
396            let entry_total = entries.len() as u64;
397            let event_total = events.len() as u64;
398            (
399                binding,
400                entries,
401                0,
402                entry_total,
403                events,
404                0,
405                event_total,
406                capture,
407                None,
408            )
409        }
410    };
411    let mut settings_page = read_settings_page(
412        connection,
413        run_id,
414        cursors.and_then(|cursors| cursors.settings),
415    )?;
416    if cursors.is_none() && settings_page.total > settings_page.items.len() as u64 {
417        settings_page.items = read_settings_range(connection, run_id, 0, -1)?;
418        settings_page.start = 0;
419    }
420    let mut follow_up_page = read_follow_up_page(
421        connection,
422        run_id,
423        cursors.and_then(|cursors| cursors.follow_ups),
424    )?;
425    if cursors.is_none() && follow_up_page.total > follow_up_page.items.len() as u64 {
426        follow_up_page.items = read_follow_up_range(connection, run_id, 0, -1)?;
427        follow_up_page.start = 0;
428    }
429    let follow_up_queue = read_follow_up_state(connection, run_id)?.map(|presentation_state| {
430        json!({
431            "presentationState": presentation_state,
432            "items": follow_up_page.items,
433        })
434    });
435    let presentation_revision = connection.query_row(
436        "SELECT presentation_revision FROM viewer_runs WHERE run_id = ?1",
437        [run_id],
438        |row| row.get(0),
439    )?;
440    let manifest = manifest_from_state(&state);
441    let possibly_interrupted = state.status.label() == "running"
442        && (owner_id.is_none()
443            || lease_expires_at
444                .is_none_or(|expires_at| expires_at <= Utc::now().timestamp_millis()));
445    Ok(LoadedRun {
446        manifest,
447        state,
448        graph_steps,
449        taken_transitions,
450        graph_cursor,
451        step_start,
452        step_total,
453        snapshot: Some(snapshot),
454        trace,
455        trace_start,
456        trace_total,
457        session_binding,
458        session_entries,
459        session_entry_start,
460        session_entry_total,
461        session_events,
462        session_event_start,
463        session_event_total,
464        session_capture,
465        session_replay_checkpoint,
466        settings_scopes: settings_page.items,
467        settings_start: settings_page.start,
468        settings_total: settings_page.total,
469        follow_up_queue,
470        follow_up_start: follow_up_page.start,
471        follow_up_total: follow_up_page.total,
472        update_start,
473        update_total,
474        possibly_interrupted,
475        presentation_revision,
476    })
477}
478
479pub fn list_runs(database_path: &Path) -> Vec<(String, Manifest)> {
480    let Ok(connection) = open(database_path) else {
481        return Vec::new();
482    };
483    list_run_index(&connection)
484        .unwrap_or_default()
485        .into_iter()
486        .map(|row| (row.manifest.run_id.clone(), row.manifest))
487        .collect()
488}
489
490fn list_run_index(connection: &Connection) -> Result<Vec<RunIndexRow>> {
491    let now = Utc::now().timestamp_millis();
492    let mut statement = connection.prepare(
493        "SELECT r.run_id, d.workflow_name, r.title, r.status,
494                r.created_at, r.finished_at,
495                v.presentation_revision, v.retained_from_revision,
496                l.owner_id, l.expires_at,
497                s.source_type, s.source_ref, s.source_revision
498         FROM runs r
499         JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
500         JOIN viewer_runs v ON v.run_id = r.run_id
501         JOIN leases l ON l.resource_id = r.resource_id
502         LEFT JOIN run_sources s ON s.run_id = r.run_id AND s.mount_path = ''
503         ORDER BY r.created_at DESC, r.run_id DESC",
504    )?;
505    let rows = statement.query_map([], |row| {
506        Ok((
507            row.get::<_, String>(0)?,
508            row.get::<_, String>(1)?,
509            row.get::<_, Option<String>>(2)?,
510            row.get::<_, String>(3)?,
511            row.get::<_, i64>(4)?,
512            row.get::<_, Option<i64>>(5)?,
513            row.get::<_, u64>(6)?,
514            row.get::<_, u64>(7)?,
515            row.get::<_, Option<String>>(8)?,
516            row.get::<_, Option<i64>>(9)?,
517            row.get::<_, Option<String>>(10)?,
518            row.get::<_, Option<String>>(11)?,
519            row.get::<_, Option<String>>(12)?,
520        ))
521    })?;
522    let mut index = Vec::new();
523    for row in rows {
524        let (
525            run_id,
526            workflow_name,
527            run_title,
528            status_value,
529            started_at,
530            finished_at,
531            presentation_revision,
532            retained_from_revision,
533            owner_id,
534            expires_at,
535            source_type,
536            source_ref,
537            source_revision,
538        ) = row?;
539        let status = parse_run_status(&status_value)?;
540        let workflow_source = match (source_type.as_deref(), source_ref, source_revision) {
541            (Some("builtin"), Some(id), Some(revision)) => {
542                Some(crate::state::types::WorkflowSource::Builtin { id, revision })
543            }
544            (Some("file"), Some(path), Some(hash)) => {
545                Some(crate::state::types::WorkflowSource::File { path, hash })
546            }
547            (None, None, None) => None,
548            _ => bail!("workflow run source is incomplete: {run_id}"),
549        };
550        index.push(RunIndexRow {
551            manifest: Manifest {
552                schema: "pi-workflows.sqlite-view.v1".to_string(),
553                run_id,
554                workflow_name,
555                run_title,
556                workflow_source,
557                started_at: timestamp(started_at),
558                finished_at: finished_at.map(timestamp),
559                status,
560                trace_schema: "pi-workflows.event.v1".to_string(),
561                paths: ManifestPaths {
562                    workflow: String::new(),
563                    state: String::new(),
564                    trace: String::new(),
565                    session: None,
566                    artifacts: None,
567                },
568            },
569            live: !status.is_terminal(),
570            possibly_interrupted: status.label() == "running"
571                && (owner_id.is_none() || expires_at.is_none_or(|value| value <= now)),
572            presentation_revision,
573            retained_from_revision,
574            lease_owner_id: owner_id,
575            lease_expires_at: expires_at,
576        });
577    }
578    Ok(index)
579}
580
581fn parse_run_status(value: &str) -> Result<crate::state::types::RunStatus> {
582    use crate::state::types::RunStatus;
583    match value {
584        "queued" => Ok(RunStatus::Queued),
585        "running" => Ok(RunStatus::Running),
586        "waiting" => Ok(RunStatus::Waiting),
587        "completed" => Ok(RunStatus::Completed),
588        "failed" => Ok(RunStatus::Failed),
589        "timed_out" => Ok(RunStatus::TimedOut),
590        "cancelled" => Ok(RunStatus::Cancelled),
591        _ => bail!("workflow run status is invalid: {value}"),
592    }
593}
594
595pub fn with_artifact_placeholders(value: &Value) -> Value {
596    value.clone()
597}
598
599pub fn resolve_artifacts(value: &Value, _database_path: &Path, _max_bytes: u64) -> Value {
600    value.clone()
601}
602
603pub fn validate_database(database_path: &Path) -> Result<()> {
604    open(database_path).map(drop)
605}
606
607fn open(path: &Path) -> Result<Connection> {
608    let connection = Connection::open_with_flags(
609        path,
610        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
611    )
612    .with_context(|| format!("could not open {}", path.display()))?;
613    connection.pragma_update(None, "query_only", true)?;
614    connection.pragma_update(None, "foreign_keys", true)?;
615    let application_id: i64 =
616        connection.pragma_query_value(None, "application_id", |row| row.get(0))?;
617    let user_version: i64 =
618        connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
619    if application_id != APPLICATION_ID || user_version != USER_VERSION {
620        bail!(RESET_INSTRUCTION);
621    }
622    let schema = connection
623        .query_row(
624            "SELECT schema_name, schema_version, schema_digest, app_version
625             FROM schema_meta WHERE id = 1",
626            [],
627            |row| {
628                Ok((
629                    row.get::<_, String>(0)?,
630                    row.get::<_, i64>(1)?,
631                    row.get::<_, Vec<u8>>(2)?,
632                    row.get::<_, String>(3)?,
633                ))
634            },
635        )
636        .optional()
637        .unwrap_or(None);
638    if !matches!(
639        schema,
640        Some((name, version, digest, app_version))
641            if name == SCHEMA_NAME
642                && version == USER_VERSION
643                && digest.as_slice() == SCHEMA_DIGEST
644                && app_version == APP_VERSION
645    ) {
646        bail!(RESET_INSTRUCTION);
647    }
648    Ok(connection)
649}
650
651fn read_json_blob(connection: &Connection, hash: &[u8]) -> Result<Value> {
652    let content: Vec<u8> = connection.query_row(
653        "SELECT content FROM blobs WHERE blob_hash = ?1 AND media_type = 'application/json'",
654        [hash],
655        |row| row.get(0),
656    )?;
657    Ok(serde_json::from_slice(&content)?)
658}
659
660fn read_text_blob(connection: &Connection, hash: &[u8]) -> Result<String> {
661    let content: Vec<u8> = connection.query_row(
662        "SELECT content FROM blobs WHERE blob_hash = ?1 AND media_type = 'text/plain'",
663        [hash],
664        |row| row.get(0),
665    )?;
666    Ok(String::from_utf8(content)?)
667}
668
669fn read_settings_range(
670    connection: &Connection,
671    run_id: &str,
672    start: u64,
673    limit: i64,
674) -> Result<Vec<Value>> {
675    let mut statement = connection.prepare(
676        "SELECT s.scope_id, s.mount_path, s.invocation, s.current_hash, r.revision
677         FROM workflow_settings s
678         JOIN resources r ON r.resource_id = s.resource_id
679         WHERE s.active_run_id = ?1
680         ORDER BY s.mount_path, s.invocation LIMIT ?2 OFFSET ?3",
681    )?;
682    let rows = statement.query_map(rusqlite::params![run_id, limit, start], |row| {
683        Ok((
684            row.get::<_, String>(0)?,
685            row.get::<_, String>(1)?,
686            row.get::<_, u64>(2)?,
687            row.get::<_, Vec<u8>>(3)?,
688            row.get::<_, u64>(4)?,
689        ))
690    })?;
691    let mut items = Vec::new();
692    for row in rows {
693        let (scope_id, mount_path, invocation, settings_hash, change_number) = row?;
694        items.push(json!({
695            "scopeId": scope_id,
696            "mountPath": mount_path,
697            "invocation": invocation,
698            "changeNumber": change_number,
699            "settingsHash": encode_hex(&settings_hash),
700        }));
701    }
702    Ok(items)
703}
704
705fn read_settings_page(
706    connection: &Connection,
707    run_id: &str,
708    cursor: Option<u64>,
709) -> Result<ProjectionPage> {
710    let total: u64 = connection.query_row(
711        "SELECT count(*) FROM workflow_settings WHERE active_run_id = ?1",
712        [run_id],
713        |row| row.get(0),
714    )?;
715    let start = page_start(total, cursor);
716    Ok(projection_page(
717        start,
718        total,
719        read_settings_range(connection, run_id, start, VIEWER_PAGE_SIZE as i64)?,
720    ))
721}
722
723fn read_follow_up_range(
724    connection: &Connection,
725    run_id: &str,
726    start: u64,
727    limit: i64,
728) -> Result<Vec<Value>> {
729    let mut statement = connection.prepare(
730        "SELECT follow_up_id, order_number, status, source_type, session_entry_id
731         FROM workflow_follow_ups
732         WHERE run_id = ?1 ORDER BY order_number LIMIT ?2 OFFSET ?3",
733    )?;
734    let rows = statement.query_map(rusqlite::params![run_id, limit, start], |row| {
735        Ok(json!({
736            "followUpId": row.get::<_, String>(0)?,
737            "order": row.get::<_, u64>(1)?,
738            "state": row.get::<_, String>(2)?,
739            "source": row.get::<_, String>(3)?,
740            "sessionEntryId": row.get::<_, Option<String>>(4)?,
741        }))
742    })?;
743    Ok(rows.collect::<Result<Vec<_>, _>>()?)
744}
745
746fn read_follow_up_page(
747    connection: &Connection,
748    run_id: &str,
749    cursor: Option<u64>,
750) -> Result<ProjectionPage> {
751    let total: u64 = connection.query_row(
752        "SELECT count(*) FROM workflow_follow_ups WHERE run_id = ?1",
753        [run_id],
754        |row| row.get(0),
755    )?;
756    let start = page_start(total, cursor);
757    Ok(projection_page(
758        start,
759        total,
760        read_follow_up_range(connection, run_id, start, VIEWER_PAGE_SIZE as i64)?,
761    ))
762}
763
764fn read_follow_up_state(connection: &Connection, run_id: &str) -> Result<Option<String>> {
765    Ok(connection
766        .query_row(
767            "SELECT presentation_state FROM workflow_follow_up_queues WHERE run_id = ?1",
768            [run_id],
769            |row| row.get::<_, String>(0),
770        )
771        .optional()?)
772}
773
774fn read_state(
775    connection: &Connection,
776    run_id: &str,
777    definition: &Value,
778    step_start: Option<u64>,
779    updates: Vec<Value>,
780) -> Result<RunState> {
781    let row = connection.query_row(
782        "SELECT r.resource_id, d.workflow_name, r.parent_run_id, r.title, r.status,
783                r.paused, r.status_detail, r.input_hash, r.final_output_hash, r.error_hash,
784                r.definition_digest, r.created_at, r.updated_at, r.finished_at,
785                resources.revision
786         FROM runs r
787         JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
788         JOIN resources ON resources.resource_id = r.resource_id
789         WHERE r.run_id = ?1",
790        [run_id],
791        |row| {
792            Ok((
793                row.get::<_, String>(0)?,
794                row.get::<_, String>(1)?,
795                row.get::<_, Option<String>>(2)?,
796                row.get::<_, Option<String>>(3)?,
797                row.get::<_, String>(4)?,
798                row.get::<_, i64>(5)?,
799                row.get::<_, Option<String>>(6)?,
800                row.get::<_, Vec<u8>>(7)?,
801                row.get::<_, Option<Vec<u8>>>(8)?,
802                row.get::<_, Option<Vec<u8>>>(9)?,
803                row.get::<_, Vec<u8>>(10)?,
804                row.get::<_, i64>(11)?,
805                row.get::<_, i64>(12)?,
806                row.get::<_, Option<i64>>(13)?,
807                row.get::<_, u64>(14)?,
808            ))
809        },
810    )?;
811    let (
812        _resource_id,
813        workflow_name,
814        parent_run_id,
815        title,
816        status,
817        paused,
818        status_detail,
819        input_hash,
820        final_output_hash,
821        error_hash,
822        definition_digest,
823        created_at,
824        updated_at,
825        finished_at,
826        revision,
827    ) = row;
828
829    let steps = read_steps(connection, run_id, step_start)?;
830    let mut outputs = serde_json::Map::new();
831    let mut results = serde_json::Map::new();
832    for step in &steps {
833        let outcome = step
834            .get("outcome")
835            .and_then(Value::as_str)
836            .unwrap_or("failed");
837        let node_id = step
838            .get("nodeId")
839            .and_then(Value::as_str)
840            .unwrap_or_default();
841        let started = step
842            .get("startedAt")
843            .and_then(Value::as_str)
844            .unwrap_or_default();
845        let finished = step
846            .get("finishedAt")
847            .and_then(Value::as_str)
848            .unwrap_or_default();
849        let duration = chrono::DateTime::parse_from_rfc3339(finished)
850            .ok()
851            .zip(chrono::DateTime::parse_from_rfc3339(started).ok())
852            .map_or(0, |(end, start)| (end - start).num_milliseconds());
853        let mut result = json!({
854            "attemptId": step.get("attemptId").cloned().unwrap_or(Value::Null),
855            "nodeId": node_id,
856            "nodeType": step.get("nodeType").cloned().unwrap_or(Value::Null),
857            "outcome": outcome,
858            "startedAt": started,
859            "finishedAt": finished,
860            "durationMs": duration,
861        });
862        if outcome == "ok" {
863            let output = step.get("output").cloned().unwrap_or(Value::Null);
864            outputs.insert(node_id.to_string(), output.clone());
865            result["output"] = output;
866        } else {
867            outputs.remove(node_id);
868        }
869        if let Some(error) = step.get("error") {
870            result["error"] = error.clone();
871        }
872        results.insert(node_id.to_string(), result.clone());
873        if let Some(mount_path) = exit_mount_path(definition, node_id) {
874            if outcome == "ok" {
875                let output = step.get("output").cloned().unwrap_or(Value::Null);
876                outputs.insert(mount_path.clone(), output.clone());
877                result["nodeId"] = json!(mount_path);
878                result["output"] = output;
879                results.insert(mount_path, result);
880            }
881        }
882    }
883
884    let mut state = json!({
885        "schema": RUN_STATE_SCHEMA,
886        "traceSeq": revision,
887        "runId": run_id,
888        "workflowName": workflow_name,
889        "startedAt": timestamp(created_at),
890        "updatedAt": timestamp(updated_at),
891        "status": status,
892        "input": read_json_blob(connection, &input_hash)?,
893        "outputs": outputs,
894        "results": results,
895        "steps": steps,
896    });
897    if let Some(value) = parent_run_id {
898        state["parentRunId"] = json!(value);
899    }
900    if let Some(value) = title {
901        state["runTitle"] = json!(value);
902    }
903    if let Some(value) = status_detail {
904        state["statusDetail"] = json!(value);
905    }
906    if paused != 0 {
907        state["paused"] = json!(true);
908    }
909    if let Some(value) = finished_at {
910        state["finishedAt"] = json!(timestamp(value));
911    }
912    if let Some(hash) = final_output_hash {
913        state["finalOutput"] = read_json_blob(connection, &hash)?;
914    }
915    if let Some(hash) = error_hash {
916        state["error"] = json!(read_text_blob(connection, &hash)?);
917    }
918    let carried: u64 = connection.query_row(
919        "SELECT count(*) FROM run_steps s
920         JOIN node_attempts a ON a.attempt_id = s.attempt_id
921         WHERE s.run_id = ?1 AND a.run_id <> s.run_id",
922        [run_id],
923        |row| row.get(0),
924    )?;
925    if carried != 0 {
926        state["carriedStepCount"] = json!(carried);
927    }
928    if status == "running" {
929        if let Some((attempt_id, node_id, started_at, scope_id, change_number, settings_hash)) =
930            connection
931                .query_row(
932                    "SELECT attempt_id, node_id, started_at,
933                        settings_scope_id, settings_change_number, settings_hash
934                 FROM node_attempts
935                 WHERE run_id = ?1 AND status IN ('pending', 'running', 'waiting', 'interrupted')",
936                    [run_id],
937                    |row| {
938                        Ok((
939                            row.get::<_, String>(0)?,
940                            row.get::<_, String>(1)?,
941                            row.get::<_, Option<i64>>(2)?,
942                            row.get::<_, Option<String>>(3)?,
943                            row.get::<_, Option<u64>>(4)?,
944                            row.get::<_, Option<Vec<u8>>>(5)?,
945                        ))
946                    },
947                )
948                .optional()?
949        {
950            state["currentAttemptId"] = json!(attempt_id);
951            state["currentNode"] = json!(node_id);
952            if let Some(value) = started_at {
953                state["currentNodeStartedAt"] = json!(timestamp(value));
954            }
955            match (scope_id, change_number, settings_hash) {
956                (Some(scope_id), Some(change_number), Some(settings_hash)) => {
957                    state["currentSettingsScopeId"] = json!(scope_id);
958                    state["currentSettingsChangeNumber"] = json!(change_number);
959                    state["currentSettingsHash"] = json!(encode_hex(&settings_hash));
960                }
961                (None, None, None) => {}
962                _ => bail!("active workflow settings binding is incomplete"),
963            }
964        }
965    }
966    if status == "waiting" {
967        if let Some(node_id) = state["steps"]
968            .as_array()
969            .and_then(|values| values.last())
970            .and_then(|step| step.get("nodeId"))
971        {
972            state["waitingOn"] = node_id.clone();
973        }
974    }
975    let (root_source, mounted_sources) = read_sources(connection, run_id, definition)?;
976    if let Some(source) = root_source {
977        state["workflowSource"] = source;
978    }
979    if !mounted_sources.is_empty() {
980        state["workflowSources"] = json!(mounted_sources);
981    }
982    let has_composed_mounts = definition
983        .pointer("/composition/mounts")
984        .and_then(Value::as_array)
985        .is_some_and(|mounts| !mounts.is_empty());
986    if !state["workflowSources"].is_null() || has_composed_mounts {
987        state["definitionDigest"] = json!(format!("sha256:{}", encode_hex(&definition_digest)));
988    }
989    if !updates.is_empty() {
990        state["updates"] = json!(updates);
991    }
992    if let Some(receipt) = read_human_decision_receipt(connection, run_id)? {
993        state["humanDecision"] = receipt;
994    }
995    Ok(serde_json::from_value(state)?)
996}
997
998fn read_graph_steps(
999    connection: &Connection,
1000    run_id: &str,
1001    cutoff: u64,
1002) -> Result<Vec<crate::state::types::StepRecord>> {
1003    let mut statement = connection.prepare(
1004        "WITH ranked AS (
1005           SELECT s.step_index, a.attempt_id, a.node_id, a.node_type, a.status,
1006                  a.settings_scope_id, a.settings_change_number, a.settings_hash,
1007                  a.started_at, a.finished_at,
1008                  row_number() OVER (
1009                    PARTITION BY a.node_id ORDER BY s.step_index DESC
1010                  ) AS position
1011           FROM run_steps s
1012           JOIN node_attempts a ON a.attempt_id = s.attempt_id
1013           WHERE s.run_id = ?1 AND s.step_index <= ?2
1014         )
1015         SELECT attempt_id, node_id, node_type, status,
1016                settings_scope_id, settings_change_number, settings_hash,
1017                started_at, finished_at
1018         FROM ranked WHERE position = 1 ORDER BY step_index",
1019    )?;
1020    let rows = statement.query_map(rusqlite::params![run_id, cutoff], |row| {
1021        Ok((
1022            row.get::<_, String>(0)?,
1023            row.get::<_, String>(1)?,
1024            row.get::<_, String>(2)?,
1025            row.get::<_, String>(3)?,
1026            row.get::<_, Option<String>>(4)?,
1027            row.get::<_, Option<u64>>(5)?,
1028            row.get::<_, Option<Vec<u8>>>(6)?,
1029            row.get::<_, i64>(7)?,
1030            row.get::<_, i64>(8)?,
1031        ))
1032    })?;
1033    let mut steps = Vec::new();
1034    for row in rows {
1035        let (
1036            attempt_id,
1037            node_id,
1038            node_type,
1039            status,
1040            settings_scope_id,
1041            settings_change_number,
1042            settings_hash,
1043            started_at,
1044            finished_at,
1045        ) = row?;
1046        let mut value = json!({
1047            "attemptId": attempt_id,
1048            "nodeId": node_id,
1049            "nodeType": node_type,
1050            "outcome": outcome_for_status(&status)?,
1051            "startedAt": timestamp(started_at),
1052            "finishedAt": timestamp(finished_at),
1053            "prompt": null,
1054            "output": null,
1055        });
1056        if let Some(scope_id) = settings_scope_id {
1057            value["settingsScopeId"] = json!(scope_id);
1058        }
1059        if let Some(change_number) = settings_change_number {
1060            value["settingsChangeNumber"] = json!(change_number);
1061        }
1062        if let Some(hash) = settings_hash {
1063            value["settingsHash"] = json!(encode_hex(&hash));
1064        }
1065        steps.push(serde_json::from_value(value)?);
1066    }
1067    Ok(steps)
1068}
1069
1070fn read_taken_transitions(
1071    connection: &Connection,
1072    run_id: &str,
1073    cutoff: u64,
1074) -> Result<Vec<String>> {
1075    let mut statement = connection.prepare(
1076        "WITH ordered AS (
1077           SELECT s.step_index, a.node_id,
1078                  lag(a.node_id) OVER (ORDER BY s.step_index) AS previous_node
1079           FROM run_steps s
1080           JOIN node_attempts a ON a.attempt_id = s.attempt_id
1081           WHERE s.run_id = ?1 AND s.step_index <= ?2
1082         )
1083         SELECT DISTINCT previous_node, node_id
1084         FROM ordered WHERE previous_node IS NOT NULL
1085         ORDER BY previous_node, node_id",
1086    )?;
1087    let rows = statement.query_map(rusqlite::params![run_id, cutoff], |row| {
1088        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1089    })?;
1090    rows.map(|row| row.map(|(from, to)| format!("{from}->{to}")))
1091        .collect::<Result<Vec<_>, _>>()
1092        .map_err(Into::into)
1093}
1094
1095fn read_steps(connection: &Connection, run_id: &str, start: Option<u64>) -> Result<Vec<Value>> {
1096    let mut statement = connection.prepare(
1097        "SELECT a.attempt_id, a.node_id, a.node_type, a.status,
1098                a.prompt_hash, a.output_hash, s.output_override_hash, a.receipt_hash, a.error_hash,
1099                prompt_entry.entry_hash, response_entry.entry_hash,
1100                first_link.entry_id, last_link.entry_id,
1101                a.settings_scope_id, a.settings_change_number, a.settings_hash,
1102                a.started_at, a.finished_at
1103         FROM run_steps s JOIN node_attempts a ON a.attempt_id = s.attempt_id
1104         LEFT JOIN attempt_entries prompt_link
1105           ON prompt_link.attempt_id = a.attempt_id AND prompt_link.role = 'prompt'
1106         LEFT JOIN session_entries prompt_entry
1107           ON prompt_entry.segment_id = prompt_link.segment_id AND prompt_entry.entry_id = prompt_link.entry_id
1108         LEFT JOIN attempt_entries response_link
1109           ON response_link.attempt_id = a.attempt_id AND response_link.role = 'response'
1110         LEFT JOIN session_entries response_entry
1111           ON response_entry.segment_id = response_link.segment_id AND response_entry.entry_id = response_link.entry_id
1112         LEFT JOIN attempt_entries first_link
1113           ON first_link.attempt_id = a.attempt_id AND first_link.role = 'first'
1114         LEFT JOIN attempt_entries last_link
1115           ON last_link.attempt_id = a.attempt_id AND last_link.role = 'last'
1116         WHERE s.run_id = ?1 AND (?2 IS NULL OR s.step_index >= ?2)
1117         ORDER BY s.step_index LIMIT ?3",
1118    )?;
1119    let limit = start.map_or(-1_i64, |_| VIEWER_PAGE_SIZE as i64);
1120    let rows = statement.query_map(rusqlite::params![run_id, start, limit], |row| {
1121        Ok((
1122            row.get::<_, String>(0)?,
1123            row.get::<_, String>(1)?,
1124            row.get::<_, String>(2)?,
1125            row.get::<_, String>(3)?,
1126            row.get::<_, Option<Vec<u8>>>(4)?,
1127            row.get::<_, Option<Vec<u8>>>(5)?,
1128            row.get::<_, Option<Vec<u8>>>(6)?,
1129            row.get::<_, Option<Vec<u8>>>(7)?,
1130            row.get::<_, Option<Vec<u8>>>(8)?,
1131            row.get::<_, Option<Vec<u8>>>(9)?,
1132            row.get::<_, Option<Vec<u8>>>(10)?,
1133            row.get::<_, Option<String>>(11)?,
1134            row.get::<_, Option<String>>(12)?,
1135            row.get::<_, Option<String>>(13)?,
1136            row.get::<_, Option<u64>>(14)?,
1137            row.get::<_, Option<Vec<u8>>>(15)?,
1138            row.get::<_, i64>(16)?,
1139            row.get::<_, i64>(17)?,
1140        ))
1141    })?;
1142    let mut steps = Vec::new();
1143    for row in rows {
1144        let (
1145            attempt_id,
1146            node_id,
1147            node_type,
1148            status,
1149            stored_prompt_hash,
1150            output_hash,
1151            override_hash,
1152            receipt_hash,
1153            error_hash,
1154            prompt_hash,
1155            response_hash,
1156            first_entry_id,
1157            last_entry_id,
1158            settings_scope_id,
1159            settings_change_number,
1160            settings_hash,
1161            started_at,
1162            finished_at,
1163        ) = row?;
1164        let receipt = receipt_hash
1165            .as_deref()
1166            .map(|hash| read_json_blob(connection, hash))
1167            .transpose()?
1168            .unwrap_or_else(|| json!({}));
1169        let prompt = if let Some(hash) = prompt_hash.as_deref() {
1170            let entry = read_json_blob(connection, hash)?;
1171            prompt_from_entry(&entry).map_or(Value::Null, Value::String)
1172        } else if let Some(hash) = stored_prompt_hash.as_deref() {
1173            Value::String(read_text_blob(connection, hash)?)
1174        } else {
1175            Value::Null
1176        };
1177        let output = if let Some(hash) = override_hash.as_deref().or(output_hash.as_deref()) {
1178            read_json_blob(connection, hash)?
1179        } else if let Some(hash) = response_hash.as_deref() {
1180            assistant_output_from_entry(&read_json_blob(connection, hash)?)?
1181        } else {
1182            Value::Null
1183        };
1184        let mut step = json!({
1185            "attemptId": attempt_id,
1186            "nodeId": node_id,
1187            "nodeType": node_type,
1188            "outcome": outcome_for_status(&status)?,
1189            "startedAt": timestamp(started_at),
1190            "finishedAt": timestamp(finished_at),
1191            "prompt": prompt,
1192            "output": output,
1193        });
1194        if let Some(hash) = error_hash {
1195            step["error"] = json!(read_text_blob(connection, &hash)?);
1196        }
1197        if let Some(value) = receipt.get("action") {
1198            step["action"] = value.clone();
1199        }
1200        if let Some(value) = receipt.get("assistantMessage") {
1201            step["assistantMessage"] = value.clone();
1202        }
1203        if let (Some(first), Some(last)) = (first_entry_id, last_entry_id) {
1204            step["conversation"] = json!({ "firstEntryId": first, "lastEntryId": last });
1205        }
1206        match (settings_scope_id, settings_change_number, settings_hash) {
1207            (Some(scope_id), Some(change_number), Some(settings_hash)) => {
1208                step["settingsScopeId"] = json!(scope_id);
1209                step["settingsChangeNumber"] = json!(change_number);
1210                step["settingsHash"] = json!(encode_hex(&settings_hash));
1211            }
1212            (None, None, None) => {}
1213            _ => bail!("saved workflow settings binding is incomplete"),
1214        }
1215        steps.push(step);
1216    }
1217    Ok(steps)
1218}
1219
1220fn read_sources(
1221    connection: &Connection,
1222    run_id: &str,
1223    definition: &Value,
1224) -> Result<(Option<Value>, Vec<Value>)> {
1225    let mut statement = connection.prepare(
1226        "SELECT mount_path, source_type, source_ref, source_revision
1227         FROM run_sources WHERE run_id = ?1 ORDER BY mount_path",
1228    )?;
1229    let rows = statement
1230        .query_map([run_id], |row| {
1231            Ok((
1232                row.get::<_, String>(0)?,
1233                row.get::<_, String>(1)?,
1234                row.get::<_, String>(2)?,
1235                row.get::<_, String>(3)?,
1236            ))
1237        })?
1238        .collect::<Result<Vec<_>, _>>()?;
1239    let mut root = None;
1240    let mut mounted = Vec::new();
1241    for (mount_path, source_type, source_ref, source_revision) in rows {
1242        let source = if source_type == "builtin" {
1243            json!({ "kind": "builtin", "id": source_ref, "revision": source_revision })
1244        } else {
1245            json!({ "kind": "file", "path": source_ref, "hash": source_revision })
1246        };
1247        if mount_path.is_empty() {
1248            if source["kind"] != "file"
1249                || !source["path"]
1250                    .as_str()
1251                    .is_some_and(|value| value.starts_with("inline:"))
1252            {
1253                root = Some(source);
1254            }
1255            continue;
1256        }
1257        let workflow_name = definition["composition"]["mounts"]
1258            .as_array()
1259            .and_then(|mounts| {
1260                mounts.iter().find(|mount| {
1261                    mount["mountPath"].as_array().is_some_and(|parts| {
1262                        parts
1263                            .iter()
1264                            .filter_map(Value::as_str)
1265                            .collect::<Vec<_>>()
1266                            .join("/")
1267                            == mount_path
1268                    })
1269                })
1270            })
1271            .and_then(|mount| mount["workflowName"].as_str())
1272            .unwrap_or(&mount_path);
1273        mounted.push(json!({
1274            "mountPath": mount_path.split('/').collect::<Vec<_>>(),
1275            "workflowName": workflow_name,
1276            "source": source,
1277        }));
1278    }
1279    Ok((root, mounted))
1280}
1281
1282fn read_updates_range(
1283    connection: &Connection,
1284    run_id: &str,
1285    start: u64,
1286    limit: i64,
1287) -> Result<Vec<Value>> {
1288    let mut statement = connection.prepare(
1289        "WITH latest AS (
1290           SELECT u.update_id, u.run_revision, a.node_id, u.attempt_id,
1291                  u.update_type, u.update_key, u.data_hash, u.recorded_at,
1292                  row_number() OVER (
1293                    PARTITION BY u.update_type, u.update_key
1294                    ORDER BY u.run_revision DESC
1295                  ) AS position
1296           FROM workflow_updates u
1297           JOIN node_attempts a ON a.attempt_id = u.attempt_id
1298           WHERE a.run_id = ?1
1299         )
1300         SELECT update_id, run_revision, node_id, attempt_id,
1301                update_type, update_key, data_hash, recorded_at
1302         FROM latest WHERE position = 1
1303         ORDER BY run_revision LIMIT ?2 OFFSET ?3",
1304    )?;
1305    let rows = statement.query_map(rusqlite::params![run_id, limit, start], |row| {
1306        Ok((
1307            row.get::<_, String>(0)?,
1308            row.get::<_, u64>(1)?,
1309            row.get::<_, String>(2)?,
1310            row.get::<_, String>(3)?,
1311            row.get::<_, String>(4)?,
1312            row.get::<_, String>(5)?,
1313            row.get::<_, Vec<u8>>(6)?,
1314            row.get::<_, i64>(7)?,
1315        ))
1316    })?;
1317    let mut values = Vec::new();
1318    for row in rows {
1319        let (update_id, seq, node_id, attempt_id, kind, key, hash, at) = row?;
1320        values.push(json!({
1321            "updateId": update_id, "seq": seq, "at": timestamp(at), "runId": run_id,
1322            "nodeId": node_id, "attemptId": attempt_id, "type": kind, "key": key,
1323            "data": read_json_blob(connection, &hash)?,
1324        }));
1325    }
1326    values.sort_by_key(|value| value["seq"].as_u64().unwrap_or_default());
1327    Ok(values)
1328}
1329
1330fn read_update_page(
1331    connection: &Connection,
1332    run_id: &str,
1333    cursor: Option<u64>,
1334) -> Result<ProjectionPage> {
1335    let total: u64 = connection.query_row(
1336        "SELECT count(*) FROM (
1337           SELECT 1 FROM workflow_updates u
1338           JOIN node_attempts a ON a.attempt_id = u.attempt_id
1339           WHERE a.run_id = ?1 GROUP BY u.update_type, u.update_key
1340         )",
1341        [run_id],
1342        |row| row.get(0),
1343    )?;
1344    let start = page_start(total, cursor);
1345    Ok(projection_page(
1346        start,
1347        total,
1348        read_updates_range(connection, run_id, start, VIEWER_PAGE_SIZE as i64)?,
1349    ))
1350}
1351
1352fn read_human_decision_receipt(connection: &Connection, run_id: &str) -> Result<Option<Value>> {
1353    let row = connection
1354        .query_row(
1355            "SELECT d.request_hash, r.response_hash FROM continuations c
1356         JOIN human_decisions d ON d.decision_id = c.decision_id
1357         JOIN human_decision_resolutions r ON r.decision_id = c.decision_id
1358         WHERE c.continuation_run_id = ?1 AND r.outcome = 'accepted'",
1359            [run_id],
1360            |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Vec<u8>>(1)?)),
1361        )
1362        .optional()?;
1363    let Some((request_hash, decision_hash)) = row else {
1364        return Ok(None);
1365    };
1366    let request = read_json_blob(connection, &request_hash)?;
1367    let decision = read_json_blob(connection, &decision_hash)?;
1368    Ok(Some(json!({
1369        "schema": "pi-workflows.human-decision-receipt.v1",
1370        "decisionId": request["decisionId"], "requestDigest": request["requestDigest"],
1371        "nodeId": request["nodeId"], "response": decision["response"],
1372        "provenance": decision["provenance"], "acceptedAt": decision["acceptedAt"],
1373        "answerDigest": decision["answerDigest"], "subjectDigest": decision["subjectDigest"],
1374        "presentationDigest": decision["presentationDigest"], "revision": decision["revision"],
1375    })))
1376}
1377
1378fn prompt_from_entry(entry: &Value) -> Option<String> {
1379    if let Some(text) = entry.get("content").and_then(Value::as_str) {
1380        return Some(text.to_string());
1381    }
1382    entry.get("content")?.as_array().map(|parts| {
1383        parts
1384            .iter()
1385            .filter_map(|part| part.get("text").and_then(Value::as_str))
1386            .collect::<Vec<_>>()
1387            .join("\n")
1388    })
1389}
1390
1391fn assistant_output_from_entry(entry: &Value) -> Result<Value> {
1392    let Some(parts) = entry["message"]["content"].as_array() else {
1393        bail!("assistant response entry is invalid");
1394    };
1395    let text = parts
1396        .iter()
1397        .filter(|part| part["type"] == "text")
1398        .filter_map(|part| part["text"].as_str())
1399        .collect::<Vec<_>>()
1400        .join("\n");
1401    if text.trim().is_empty() {
1402        bail!("assistant response entry has no visible text");
1403    }
1404    Ok(Value::String(text))
1405}
1406
1407fn outcome_for_status(status: &str) -> Result<&'static str> {
1408    match status {
1409        "completed" => Ok("ok"),
1410        "failed" => Ok("failed"),
1411        "timed_out" => Ok("timed_out"),
1412        "cancelled" => Ok("cancelled"),
1413        _ => bail!("workflow step has nonterminal status: {status}"),
1414    }
1415}
1416
1417fn exit_mount_path(definition: &Value, node_id: &str) -> Option<String> {
1418    let node = definition.get("nodes")?.get(node_id)?;
1419    if node.get("includeTransition")?.as_str()? != "exit" {
1420        return None;
1421    }
1422    Some(
1423        node.get("mountPath")?
1424            .as_array()?
1425            .iter()
1426            .filter_map(Value::as_str)
1427            .collect::<Vec<_>>()
1428            .join("/"),
1429    )
1430}
1431
1432fn encode_hex(bytes: &[u8]) -> String {
1433    const HEX: &[u8; 16] = b"0123456789abcdef";
1434    let mut output = String::with_capacity(bytes.len() * 2);
1435    for byte in bytes {
1436        output.push(HEX[(byte >> 4) as usize] as char);
1437        output.push(HEX[(byte & 0x0f) as usize] as char);
1438    }
1439    output
1440}
1441
1442fn read_trace(connection: &Connection, run_id: &str) -> Result<Vec<TraceEvent>> {
1443    let resource_id: String = connection.query_row(
1444        "SELECT resource_id FROM runs WHERE run_id = ?1",
1445        [run_id],
1446        |row| row.get(0),
1447    )?;
1448    let mut statement = connection.prepare(
1449        "SELECT resource_revision, event_type, payload_hash, recorded_at
1450         FROM events WHERE resource_id = ?1 ORDER BY resource_revision",
1451    )?;
1452    let rows = statement.query_map([resource_id], |row| {
1453        Ok((
1454            row.get::<_, u64>(0)?,
1455            row.get::<_, String>(1)?,
1456            row.get::<_, Option<Vec<u8>>>(2)?,
1457            row.get::<_, i64>(3)?,
1458        ))
1459    })?;
1460    let mut events = Vec::new();
1461    for row in rows {
1462        let (seq, event_type, payload_hash, recorded_at) = row?;
1463        let envelope = match payload_hash {
1464            Some(hash) => read_json_blob(connection, &hash)?,
1465            None => json!({}),
1466        };
1467        let payload = envelope
1468            .get("payload")
1469            .cloned()
1470            .unwrap_or_else(|| json!({}));
1471        let mut event = json!({
1472            "seq": seq,
1473            "at": timestamp(recorded_at),
1474            "runId": run_id,
1475            "scope": envelope.get("scope").and_then(Value::as_str).unwrap_or("run"),
1476            "type": event_type,
1477            "payload": payload,
1478        });
1479        if let Some(node_id) = envelope.get("nodeId") {
1480            event["nodeId"] = node_id.clone();
1481        }
1482        if let Some(attempt_id) = envelope.get("attemptId") {
1483            event["attemptId"] = attempt_id.clone();
1484        }
1485        events.push(serde_json::from_value(event)?);
1486    }
1487    Ok(events)
1488}
1489
1490fn read_trace_window(
1491    connection: &Connection,
1492    run_id: &str,
1493    cursor: Option<u64>,
1494) -> Result<(Vec<TraceEvent>, u64, u64)> {
1495    let resource_id: String = connection.query_row(
1496        "SELECT resource_id FROM runs WHERE run_id = ?1",
1497        [run_id],
1498        |row| row.get(0),
1499    )?;
1500    let total: u64 = connection.query_row(
1501        "SELECT count(*) FROM events WHERE resource_id = ?1",
1502        [&resource_id],
1503        |row| row.get(0),
1504    )?;
1505    let start = page_start(total, cursor);
1506    let mut statement = connection.prepare(
1507        "SELECT resource_revision, event_type, payload_hash, recorded_at
1508         FROM events
1509         WHERE resource_id = ?1 AND resource_revision > ?2
1510         ORDER BY resource_revision LIMIT ?3",
1511    )?;
1512    let rows = statement.query_map(
1513        rusqlite::params![resource_id, start, VIEWER_PAGE_SIZE],
1514        |row| {
1515            Ok((
1516                row.get::<_, u64>(0)?,
1517                row.get::<_, String>(1)?,
1518                row.get::<_, Option<Vec<u8>>>(2)?,
1519                row.get::<_, i64>(3)?,
1520            ))
1521        },
1522    )?;
1523    let mut events = Vec::new();
1524    for row in rows {
1525        let (seq, event_type, payload_hash, recorded_at) = row?;
1526        let envelope = match payload_hash {
1527            Some(hash) => read_json_blob(connection, &hash)?,
1528            None => json!({}),
1529        };
1530        let mut event = json!({
1531            "seq": seq,
1532            "at": timestamp(recorded_at),
1533            "runId": run_id,
1534            "scope": envelope.get("scope").and_then(Value::as_str).unwrap_or("run"),
1535            "type": event_type,
1536            "payload": envelope.get("payload").cloned().unwrap_or_else(|| json!({})),
1537        });
1538        if let Some(node_id) = envelope.get("nodeId") {
1539            event["nodeId"] = node_id.clone();
1540        }
1541        if let Some(attempt_id) = envelope.get("attemptId") {
1542            event["attemptId"] = attempt_id.clone();
1543        }
1544        events.push(serde_json::from_value(event)?);
1545    }
1546    Ok((events, start, total))
1547}
1548
1549fn trace_cursor_for_step(
1550    connection: &Connection,
1551    run_id: &str,
1552    step_index: u64,
1553) -> Result<Option<u64>> {
1554    let timestamp = connection
1555        .query_row(
1556            "SELECT COALESCE(a.finished_at, a.started_at)
1557             FROM run_steps s
1558             JOIN node_attempts a ON a.attempt_id = s.attempt_id
1559             WHERE s.run_id = ?1 AND s.step_index = ?2",
1560            rusqlite::params![run_id, step_index],
1561            |row| row.get::<_, Option<i64>>(0),
1562        )
1563        .optional()?
1564        .flatten();
1565    let Some(timestamp) = timestamp else {
1566        return Ok(None);
1567    };
1568    let count: u64 = connection.query_row(
1569        "SELECT count(*)
1570         FROM events e JOIN runs r ON r.resource_id = e.resource_id
1571         WHERE r.run_id = ?1 AND e.recorded_at <= ?2",
1572        rusqlite::params![run_id, timestamp],
1573        |row| row.get(0),
1574    )?;
1575    Ok(count.checked_sub(1))
1576}
1577
1578fn read_session(connection: &Connection, run_id: &str) -> Result<LoadedSession> {
1579    let mut segments_statement = connection.prepare(
1580        "SELECT segment_id, binding_hash, status, entry_count, event_count,
1581                failure_hash
1582         FROM session_segments
1583         WHERE run_id = ?1
1584         ORDER BY created_at, segment_id",
1585    )?;
1586    let segment_rows = segments_statement
1587        .query_map([run_id], |row| {
1588            Ok((
1589                row.get::<_, String>(0)?,
1590                row.get::<_, Option<Vec<u8>>>(1)?,
1591                row.get::<_, String>(2)?,
1592                row.get::<_, u64>(3)?,
1593                row.get::<_, u64>(4)?,
1594                row.get::<_, Option<Vec<u8>>>(5)?,
1595            ))
1596        })?
1597        .collect::<Result<Vec<_>, _>>()?;
1598    if segment_rows.is_empty() {
1599        return Ok((None, Vec::new(), Vec::new(), None));
1600    }
1601
1602    let mut binding = None;
1603    let mut entries = Vec::new();
1604    let mut events = Vec::new();
1605    let mut status = "complete".to_string();
1606    let mut failure = None;
1607
1608    for (segment_id, binding_hash, segment_status, _entry_count, _event_count, failure_hash) in
1609        segment_rows
1610    {
1611        if let Some(hash) = binding_hash {
1612            binding = Some(serde_json::from_value(read_json_blob(connection, &hash)?)?);
1613        }
1614        if segment_status == "failed" {
1615            status = "failed".to_string();
1616            if failure.is_none() {
1617                if let Some(hash) = failure_hash {
1618                    failure = Some(read_json_blob(connection, &hash)?);
1619                }
1620            }
1621        } else if segment_status == "recording" && status != "failed" {
1622            status = "recording".to_string();
1623        }
1624
1625        let mut entries_statement = connection.prepare(
1626            "SELECT entry_hash, recorded_at
1627             FROM session_entries WHERE segment_id = ?1 ORDER BY entry_seq",
1628        )?;
1629        let segment_entries = entries_statement
1630            .query_map([&segment_id], |row| {
1631                Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?))
1632            })?
1633            .collect::<Result<Vec<_>, _>>()?;
1634        for (hash, at) in segment_entries {
1635            entries.push(serde_json::from_value(json!({
1636                "seq": entries.len() + 1,
1637                "at": timestamp(at),
1638                "entry": read_json_blob(connection, &hash)?,
1639            }))?);
1640        }
1641
1642        let mut events_statement = connection.prepare(
1643            "SELECT event_type, node_id, attempt_id, turn_id,
1644                    message_id, tool_call_id, payload_hash, recorded_at
1645             FROM session_events WHERE segment_id = ?1 ORDER BY event_seq",
1646        )?;
1647        let segment_events = events_statement
1648            .query_map([&segment_id], |row| {
1649                Ok((
1650                    row.get::<_, String>(0)?,
1651                    row.get::<_, String>(1)?,
1652                    row.get::<_, String>(2)?,
1653                    row.get::<_, Option<String>>(3)?,
1654                    row.get::<_, Option<String>>(4)?,
1655                    row.get::<_, Option<String>>(5)?,
1656                    row.get::<_, Vec<u8>>(6)?,
1657                    row.get::<_, i64>(7)?,
1658                ))
1659            })?
1660            .collect::<Result<Vec<_>, _>>()?;
1661        for (event_type, node_id, attempt_id, turn_id, message_id, tool_call_id, hash, at) in
1662            segment_events
1663        {
1664            let mut value = json!({
1665                "seq": events.len() + 1,
1666                "at": timestamp(at),
1667                "nodeId": node_id,
1668                "attemptId": attempt_id,
1669                "type": event_type,
1670                "payload": read_json_blob(connection, &hash)?,
1671            });
1672            if let Some(turn_id) = turn_id {
1673                value["turnId"] = json!(turn_id);
1674            }
1675            if let Some(message_id) = message_id {
1676                value["messageId"] = json!(message_id);
1677            }
1678            if let Some(tool_call_id) = tool_call_id {
1679                value["toolCallId"] = json!(tool_call_id);
1680            }
1681            events.push(serde_json::from_value(value)?);
1682        }
1683    }
1684
1685    let mut capture = json!({
1686        "schema": "pi-workflows.session-capture.v1",
1687        "eventSchema": "pi-workflows.session-event.v1",
1688        "status": status,
1689        "eventCount": events.len(),
1690        "entryCount": entries.len(),
1691        "lastEventSeq": events.len(),
1692    });
1693    if let Some(failure) = failure {
1694        capture["failure"] = failure;
1695    }
1696    Ok((
1697        binding,
1698        entries,
1699        events,
1700        Some(serde_json::from_value(capture)?),
1701    ))
1702}
1703
1704fn read_step_page(
1705    connection: &Connection,
1706    run_id: &str,
1707    cursor: Option<u64>,
1708) -> Result<ProjectionPage> {
1709    let total: u64 = connection.query_row(
1710        "SELECT count(*) FROM run_steps WHERE run_id = ?1",
1711        [run_id],
1712        |row| row.get(0),
1713    )?;
1714    let start = page_start(total, cursor);
1715    let graph_cursor = cursor
1716        .unwrap_or_else(|| total.saturating_sub(1))
1717        .min(total.saturating_sub(1));
1718    Ok(ProjectionPage {
1719        start,
1720        total,
1721        items: read_steps(connection, run_id, Some(start))?,
1722        graph_cursor: Some(graph_cursor),
1723        graph_steps: Some(read_graph_steps(connection, run_id, graph_cursor)?),
1724        taken_transitions: Some(read_taken_transitions(connection, run_id, graph_cursor)?),
1725        replay_checkpoint: None,
1726    })
1727}
1728
1729fn read_session_entry_page(
1730    connection: &Connection,
1731    run_id: &str,
1732    cursor: Option<u64>,
1733) -> Result<ProjectionPage> {
1734    let total: u64 = connection.query_row(
1735        "SELECT count(*) FROM session_entries WHERE run_id = ?1",
1736        [run_id],
1737        |row| row.get(0),
1738    )?;
1739    let start = page_start(total, cursor);
1740    let mut statement = connection.prepare(
1741        "SELECT entry_hash, recorded_at
1742         FROM session_entries
1743         WHERE run_id = ?1 AND run_seq > ?2
1744         ORDER BY run_seq LIMIT ?3",
1745    )?;
1746    let rows = statement.query_map(rusqlite::params![run_id, start, VIEWER_PAGE_SIZE], |row| {
1747        Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?))
1748    })?;
1749    let mut items = Vec::new();
1750    for (index, row) in rows.enumerate() {
1751        let (hash, at) = row?;
1752        items.push(json!({
1753            "seq": start + index as u64 + 1,
1754            "at": timestamp(at),
1755            "entry": read_json_blob(connection, &hash)?,
1756        }));
1757    }
1758    Ok(ProjectionPage {
1759        start,
1760        total,
1761        items,
1762        graph_cursor: None,
1763        graph_steps: None,
1764        taken_transitions: None,
1765        replay_checkpoint: None,
1766    })
1767}
1768
1769fn read_session_event_page(
1770    connection: &Connection,
1771    run_id: &str,
1772    cursor: Option<u64>,
1773) -> Result<ProjectionPage> {
1774    let total: u64 = connection.query_row(
1775        "SELECT count(*) FROM session_events WHERE run_id = ?1",
1776        [run_id],
1777        |row| row.get(0),
1778    )?;
1779    let start = session_page_start(total, cursor);
1780    let items = read_session_events_range(connection, run_id, start, VIEWER_PAGE_SIZE as i64)?
1781        .into_iter()
1782        .map(serde_json::to_value)
1783        .collect::<Result<Vec<_>, _>>()?;
1784    Ok(ProjectionPage {
1785        start,
1786        total,
1787        items,
1788        graph_cursor: None,
1789        graph_steps: None,
1790        taken_transitions: None,
1791        replay_checkpoint: session_replay_checkpoint(connection, run_id, start)?,
1792    })
1793}
1794
1795fn read_session_events_range(
1796    connection: &Connection,
1797    run_id: &str,
1798    start: u64,
1799    limit: i64,
1800) -> Result<Vec<SessionEventRecord>> {
1801    let mut statement = connection.prepare(
1802        "SELECT e.event_type, e.node_id, e.attempt_id, e.turn_id,
1803                e.message_id, e.tool_call_id, e.payload_hash, e.recorded_at,
1804                s.step_index
1805         FROM session_events e
1806         LEFT JOIN run_steps s ON s.run_id = e.run_id AND s.attempt_id = e.attempt_id
1807         WHERE e.run_id = ?1 AND e.run_seq > ?2
1808         ORDER BY e.run_seq LIMIT ?3",
1809    )?;
1810    let rows = statement.query_map(rusqlite::params![run_id, start, limit], |row| {
1811        Ok((
1812            row.get::<_, String>(0)?,
1813            row.get::<_, String>(1)?,
1814            row.get::<_, String>(2)?,
1815            row.get::<_, Option<String>>(3)?,
1816            row.get::<_, Option<String>>(4)?,
1817            row.get::<_, Option<String>>(5)?,
1818            row.get::<_, Vec<u8>>(6)?,
1819            row.get::<_, i64>(7)?,
1820            row.get::<_, Option<u64>>(8)?,
1821        ))
1822    })?;
1823    let mut events = Vec::new();
1824    for (index, row) in rows.enumerate() {
1825        let (
1826            event_type,
1827            node_id,
1828            attempt_id,
1829            turn_id,
1830            message_id,
1831            tool_call_id,
1832            hash,
1833            at,
1834            step_index,
1835        ) = row?;
1836        let mut value = json!({
1837            "seq": start + index as u64 + 1,
1838            "at": timestamp(at),
1839            "nodeId": node_id,
1840            "attemptId": attempt_id,
1841            "type": event_type,
1842            "payload": read_json_blob(connection, &hash)?,
1843        });
1844        if let Some(step_index) = step_index {
1845            value["stepIndex"] = json!(step_index);
1846        }
1847        if let Some(turn_id) = turn_id {
1848            value["turnId"] = json!(turn_id);
1849        }
1850        if let Some(message_id) = message_id {
1851            value["messageId"] = json!(message_id);
1852        }
1853        if let Some(tool_call_id) = tool_call_id {
1854            value["toolCallId"] = json!(tool_call_id);
1855        }
1856        events.push(serde_json::from_value(value)?);
1857    }
1858    Ok(events)
1859}
1860
1861fn session_replay_checkpoint(
1862    connection: &Connection,
1863    run_id: &str,
1864    start: u64,
1865) -> Result<Option<Value>> {
1866    if start == 0 {
1867        return Ok(None);
1868    }
1869    let hash = connection
1870        .query_row(
1871            "SELECT state_hash FROM viewer_session_checkpoints
1872             WHERE run_id = ?1 AND event_seq = ?2",
1873            rusqlite::params![run_id, start],
1874            |row| row.get::<_, Vec<u8>>(0),
1875        )
1876        .optional()?;
1877    let Some(hash) = hash else {
1878        bail!("session replay checkpoint is missing for run {run_id} at {start}");
1879    };
1880    Ok(Some(read_json_blob(connection, &hash)?))
1881}
1882
1883fn read_session_window(
1884    connection: &Connection,
1885    run_id: &str,
1886    entry_cursor: Option<u64>,
1887    event_cursor: Option<u64>,
1888) -> Result<LoadedSessionWindow> {
1889    let mut segments_statement = connection.prepare(
1890        "SELECT binding_hash, status, entry_count, event_count, failure_hash
1891         FROM session_segments
1892         WHERE run_id = ?1
1893         ORDER BY created_at, segment_id",
1894    )?;
1895    let segment_rows = segments_statement
1896        .query_map([run_id], |row| {
1897            Ok((
1898                row.get::<_, Option<Vec<u8>>>(0)?,
1899                row.get::<_, String>(1)?,
1900                row.get::<_, u64>(2)?,
1901                row.get::<_, u64>(3)?,
1902                row.get::<_, Option<Vec<u8>>>(4)?,
1903            ))
1904        })?
1905        .collect::<Result<Vec<_>, _>>()?;
1906    if segment_rows.is_empty() {
1907        return Ok((None, Vec::new(), 0, 0, Vec::new(), 0, 0, None, None));
1908    }
1909
1910    let mut binding = None;
1911    let mut status = "complete".to_string();
1912    let mut failure = None;
1913    let mut entry_total = 0u64;
1914    let mut event_total = 0u64;
1915    for (binding_hash, segment_status, entry_count, event_count, failure_hash) in &segment_rows {
1916        entry_total += entry_count;
1917        event_total += event_count;
1918        if let Some(hash) = binding_hash {
1919            binding = Some(serde_json::from_value(read_json_blob(connection, hash)?)?);
1920        }
1921        if segment_status == "failed" {
1922            status = "failed".to_string();
1923            if failure.is_none() {
1924                if let Some(hash) = failure_hash {
1925                    failure = Some(read_json_blob(connection, hash)?);
1926                }
1927            }
1928        } else if segment_status == "recording" && status != "failed" {
1929            status = "recording".to_string();
1930        }
1931    }
1932
1933    let entry_start = page_start(entry_total, entry_cursor);
1934    let mut entry_statement = connection.prepare(
1935        "SELECT entry_hash, recorded_at
1936         FROM session_entries
1937         WHERE run_id = ?1 AND run_seq > ?2
1938         ORDER BY run_seq
1939         LIMIT ?3",
1940    )?;
1941    let entry_rows = entry_statement.query_map(
1942        rusqlite::params![run_id, entry_start, VIEWER_PAGE_SIZE],
1943        |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),
1944    )?;
1945    let mut entries = Vec::new();
1946    for (index, row) in entry_rows.enumerate() {
1947        let (hash, at) = row?;
1948        entries.push(serde_json::from_value(json!({
1949            "seq": entry_start + index as u64 + 1,
1950            "at": timestamp(at),
1951            "entry": read_json_blob(connection, &hash)?,
1952        }))?);
1953    }
1954
1955    let event_start = session_page_start(event_total, event_cursor);
1956    let mut event_statement = connection.prepare(
1957        "SELECT e.event_type, e.node_id, e.attempt_id, e.turn_id,
1958                e.message_id, e.tool_call_id, e.payload_hash, e.recorded_at,
1959                s.step_index
1960         FROM session_events e
1961         LEFT JOIN run_steps s ON s.run_id = e.run_id AND s.attempt_id = e.attempt_id
1962         WHERE e.run_id = ?1 AND e.run_seq > ?2
1963         ORDER BY e.run_seq
1964         LIMIT ?3",
1965    )?;
1966    let event_rows = event_statement.query_map(
1967        rusqlite::params![run_id, event_start, VIEWER_PAGE_SIZE],
1968        |row| {
1969            Ok((
1970                row.get::<_, String>(0)?,
1971                row.get::<_, String>(1)?,
1972                row.get::<_, String>(2)?,
1973                row.get::<_, Option<String>>(3)?,
1974                row.get::<_, Option<String>>(4)?,
1975                row.get::<_, Option<String>>(5)?,
1976                row.get::<_, Vec<u8>>(6)?,
1977                row.get::<_, i64>(7)?,
1978                row.get::<_, Option<u64>>(8)?,
1979            ))
1980        },
1981    )?;
1982    let mut events = Vec::new();
1983    for (index, row) in event_rows.enumerate() {
1984        let (
1985            event_type,
1986            node_id,
1987            attempt_id,
1988            turn_id,
1989            message_id,
1990            tool_call_id,
1991            hash,
1992            at,
1993            step_index,
1994        ) = row?;
1995        let mut value = json!({
1996            "seq": event_start + index as u64 + 1,
1997            "at": timestamp(at),
1998            "nodeId": node_id,
1999            "attemptId": attempt_id,
2000            "type": event_type,
2001            "payload": read_json_blob(connection, &hash)?,
2002        });
2003        if let Some(step_index) = step_index {
2004            value["stepIndex"] = json!(step_index);
2005        }
2006        if let Some(turn_id) = turn_id {
2007            value["turnId"] = json!(turn_id);
2008        }
2009        if let Some(message_id) = message_id {
2010            value["messageId"] = json!(message_id);
2011        }
2012        if let Some(tool_call_id) = tool_call_id {
2013            value["toolCallId"] = json!(tool_call_id);
2014        }
2015        events.push(serde_json::from_value(value)?);
2016    }
2017
2018    let mut capture = json!({
2019        "schema": "pi-workflows.session-capture.v1",
2020        "eventSchema": "pi-workflows.session-event.v1",
2021        "status": status,
2022        "eventCount": event_total,
2023        "entryCount": entry_total,
2024        "lastEventSeq": event_total,
2025    });
2026    if let Some(failure) = failure {
2027        capture["failure"] = failure;
2028    }
2029    Ok((
2030        binding,
2031        entries,
2032        entry_start,
2033        entry_total,
2034        events,
2035        event_start,
2036        event_total,
2037        Some(serde_json::from_value(capture)?),
2038        session_replay_checkpoint(connection, run_id, event_start)?,
2039    ))
2040}
2041
2042fn projection_page(start: u64, total: u64, items: Vec<Value>) -> ProjectionPage {
2043    ProjectionPage {
2044        start,
2045        total,
2046        items,
2047        graph_cursor: None,
2048        graph_steps: None,
2049        taken_transitions: None,
2050        replay_checkpoint: None,
2051    }
2052}
2053
2054fn session_page_start(total: u64, cursor: Option<u64>) -> u64 {
2055    if total <= VIEWER_PAGE_SIZE {
2056        return 0;
2057    }
2058    let selected = cursor
2059        .unwrap_or(total.saturating_sub(1))
2060        .min(total.saturating_sub(1));
2061    (selected / VIEWER_PAGE_SIZE) * VIEWER_PAGE_SIZE
2062}
2063
2064fn page_start(total: u64, cursor: Option<u64>) -> u64 {
2065    if total <= VIEWER_PAGE_SIZE {
2066        return 0;
2067    }
2068    let center = cursor
2069        .unwrap_or(total.saturating_sub(1))
2070        .min(total.saturating_sub(1));
2071    center
2072        .saturating_sub(VIEWER_PAGE_SIZE / 2)
2073        .min(total - VIEWER_PAGE_SIZE)
2074}
2075
2076fn manifest_from_state(state: &RunState) -> Manifest {
2077    Manifest {
2078        schema: "pi-workflows.sqlite-view.v1".to_string(),
2079        run_id: state.run_id.clone(),
2080        workflow_name: state.workflow_name.clone(),
2081        run_title: state.run_title.clone(),
2082        workflow_source: state.workflow_source.clone(),
2083        started_at: state.started_at.clone(),
2084        finished_at: state.finished_at.clone(),
2085        status: state.status,
2086        trace_schema: "pi-workflows.event.v1".to_string(),
2087        paths: ManifestPaths {
2088            workflow: String::new(),
2089            state: String::new(),
2090            trace: String::new(),
2091            session: None,
2092            artifacts: None,
2093        },
2094    }
2095}
2096
2097fn timestamp(milliseconds: i64) -> String {
2098    Utc.timestamp_millis_opt(milliseconds)
2099        .single()
2100        .map(|value| value.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
2101        .unwrap_or_else(|| "1970-01-01T00:00:00.000Z".to_string())
2102}