1use 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}