1use std::fmt::Write as _;
29use std::fs;
30use std::io::Write as _;
31use std::path::{Path, PathBuf};
32
33use pointlock_ir::{
34 BindingState, CheckpointView, FlowId, Hash, HumanMode, HumanPurpose, JsonSchemaDocument,
35 RunLogEvent, RunLogPayload, RunPath, to_canonical_json,
36};
37use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
38use serde_json::Value;
39
40use crate::error::{HumanResponseRejection, StoreError};
41use crate::fold::{FoldState, FoldedRun, RunMeta, RunStatus, fold_checkpoint, fold_state};
42
43const DDL: &str = "
49CREATE TABLE IF NOT EXISTS run (
50 run_id TEXT PRIMARY KEY,
51 flow_id TEXT NOT NULL,
52 ir_hash TEXT NOT NULL,
53 lockfile_digest TEXT NOT NULL,
54 params_snapshot TEXT NOT NULL,
55 binding TEXT NOT NULL,
56 status TEXT NOT NULL CHECK (status IN
57 ('running','suspended','awaitingHuman','finished')),
58 created_at_ms INTEGER NOT NULL
59);
60
61CREATE TABLE IF NOT EXISTS run_log (
62 run_id TEXT NOT NULL REFERENCES run(run_id),
63 seq INTEGER NOT NULL,
64 type TEXT NOT NULL,
65 at_ms INTEGER NOT NULL,
66 run_path TEXT NOT NULL,
67 payload TEXT NOT NULL,
68 PRIMARY KEY (run_id, seq)
69) WITHOUT ROWID;
70
71CREATE TABLE IF NOT EXISTS checkpoint (
72 run_id TEXT PRIMARY KEY REFERENCES run(run_id),
73 log_seq INTEGER NOT NULL,
74 view TEXT NOT NULL
75);
76
77CREATE TABLE IF NOT EXISTS evidence (
78 sha256 TEXT PRIMARY KEY,
79 media_type TEXT NOT NULL,
80 byte_size INTEGER NOT NULL,
81 local_path TEXT NOT NULL
82);
83
84CREATE TABLE IF NOT EXISTS evidence_ref (
85 run_id TEXT NOT NULL,
86 seq INTEGER NOT NULL,
87 asset_id TEXT NOT NULL,
88 sha256 TEXT NOT NULL REFERENCES evidence(sha256),
89 PRIMARY KEY (run_id, seq, asset_id)
90);
91";
92
93#[derive(Debug, Clone)]
97pub struct IntentDispatch {
98 pub chain_index: u32,
100 pub channel: pointlock_ir::ActChannel,
102 pub action_name: pointlock_ir::ActionName,
104}
105
106#[derive(Debug, Clone)]
110pub struct NewRun {
111 pub run_id: Option<String>,
113 pub flow_id: FlowId,
115 pub ir_hash: Hash,
117 pub lockfile_digest: Hash,
119 pub params_snapshot: Value,
121 pub binding: BindingState,
123 pub created_at_ms: u64,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct RunListEntry {
130 pub run_id: String,
132 pub flow_id: String,
134 pub ir_hash: String,
136 pub status: RunStatus,
138 pub created_at_ms: u64,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct EvidenceMeta {
145 pub media_type: String,
147 pub local_path: String,
149 pub abs_path: PathBuf,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct EvidencePut {
156 pub sha256: String,
158 pub local_path: String,
162 pub abs_path: PathBuf,
164 pub byte_size: u64,
166 pub deduplicated: bool,
168}
169
170pub struct Store {
173 conn: Connection,
174 root: PathBuf,
175 fold_cache: std::collections::HashMap<String, (u64, FoldState)>,
182}
183
184impl Store {
185 pub fn open(root: impl AsRef<Path>) -> Result<Self, StoreError> {
191 let root = root.as_ref().to_path_buf();
192 fs::create_dir_all(&root)?;
193 fs::create_dir_all(root.join("evidence"))?;
194 let conn = Connection::open(root.join("pointlock.db"))?;
195 let mode: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
197 debug_assert_eq!(mode.to_ascii_lowercase(), "wal");
198 conn.pragma_update(None, "synchronous", "FULL")?;
199 conn.pragma_update(None, "foreign_keys", "ON")?;
200 conn.execute_batch(DDL)?;
201 Ok(Store {
202 conn,
203 root,
204 fold_cache: std::collections::HashMap::new(),
205 })
206 }
207
208 pub fn root(&self) -> &Path {
210 &self.root
211 }
212
213 pub fn begin_run(&mut self, new_run: NewRun) -> Result<String, StoreError> {
218 let run_id = new_run
219 .run_id
220 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
221 let exists: bool = self.conn.query_row(
222 "SELECT EXISTS(SELECT 1 FROM run WHERE run_id = ?1)",
223 [&run_id],
224 |row| row.get(0),
225 )?;
226 if exists {
227 return Err(StoreError::DuplicateRun(run_id));
228 }
229 let params_json = to_canonical_json(&new_run.params_snapshot);
230 let binding_json = to_canonical_json(&serde_json::to_value(&new_run.binding)?);
231 self.conn.execute(
232 "INSERT INTO run (run_id, flow_id, ir_hash, lockfile_digest, params_snapshot, \
233 binding, status, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
234 params![
235 run_id,
236 new_run.flow_id.as_str(),
237 new_run.ir_hash.as_str(),
238 new_run.lockfile_digest.as_str(),
239 params_json,
240 binding_json,
241 RunStatus::Running.as_str(),
242 new_run.created_at_ms as i64,
243 ],
244 )?;
245 Ok(run_id)
246 }
247
248 pub fn append_event(
259 &mut self,
260 run_id: &str,
261 at_ms: u64,
262 run_path: &RunPath,
263 payload: &RunLogPayload,
264 ) -> Result<u64, StoreError> {
265 let tx = self
266 .conn
267 .transaction_with_behavior(TransactionBehavior::Immediate)?;
268 let meta = read_meta(&tx, run_id)?;
269 let seq: i64 = tx.query_row(
270 "SELECT COALESCE(MAX(seq), 0) + 1 FROM run_log WHERE run_id = ?1",
271 [run_id],
272 |row| row.get(0),
273 )?;
274 let run_path_json = to_canonical_json(&serde_json::to_value(run_path)?);
275 let payload_json = to_canonical_json(&serde_json::to_value(payload)?);
276 tx.execute(
277 "INSERT INTO run_log (run_id, seq, type, at_ms, run_path, payload) \
278 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
279 params![
280 run_id,
281 seq,
282 payload.event_type(),
283 at_ms as i64,
284 run_path_json,
285 payload_json,
286 ],
287 )?;
288 let event = RunLogEvent {
289 run_id: run_id.to_owned(),
290 seq: seq as u64,
291 at_ms,
292 run_path: run_path.clone(),
293 payload: payload.clone(),
294 };
295 let folded = match self.fold_cache.get_mut(run_id) {
296 Some((cached_seq, state)) if *cached_seq + 1 == seq as u64 => {
297 if let Err(err) = state.apply(&event) {
298 self.fold_cache.remove(run_id);
300 return Err(err.into());
301 }
302 *cached_seq = seq as u64;
303 state.clone().finish()
304 }
305 _ => {
306 let events = read_events(&tx, run_id)?;
307 let state = fold_state(&meta, &events)?;
308 self.fold_cache
309 .insert(run_id.to_owned(), (seq as u64, state.clone()));
310 state.finish()
311 }
312 };
313 let view_json = to_canonical_json(&serde_json::to_value(&folded.view)?);
314 tx.execute(
315 "INSERT INTO checkpoint (run_id, log_seq, view) VALUES (?1, ?2, ?3) \
316 ON CONFLICT(run_id) DO UPDATE SET log_seq = excluded.log_seq, view = excluded.view",
317 params![run_id, seq, view_json],
318 )?;
319 tx.execute(
320 "UPDATE run SET status = ?2 WHERE run_id = ?1",
321 params![run_id, folded.status.as_str()],
322 )?;
323 tx.commit()?;
324 Ok(seq as u64)
325 }
326
327 pub fn write_action_intent(
337 &mut self,
338 run_id: &str,
339 at_ms: u64,
340 run_path: &RunPath,
341 call_id: &str,
342 args_snapshot: Value,
343 dispatch: Option<IntentDispatch>,
344 ) -> Result<u64, StoreError> {
345 let (chain_index, channel, action_name) = match dispatch {
346 Some(dispatch) => (
347 Some(dispatch.chain_index),
348 Some(dispatch.channel),
349 Some(dispatch.action_name),
350 ),
351 None => (None, None, None),
352 };
353 self.append_event(
354 run_id,
355 at_ms,
356 run_path,
357 &RunLogPayload::ActionIntent {
358 call_id: call_id.to_owned(),
359 args_snapshot,
360 chain_index,
361 channel,
362 action_name,
363 },
364 )
365 }
366
367 pub fn submit_human_response(
404 &mut self,
405 run_id: &str,
406 request_id: &str,
407 actor: &str,
408 at_ms: u64,
409 response: Value,
410 ) -> Result<u64, StoreError> {
411 let reject = |reason: HumanResponseRejection| StoreError::HumanResponseRejected {
412 run_id: run_id.to_owned(),
413 request_id: request_id.to_owned(),
414 reason,
415 };
416 let events = self.events(run_id)?;
417 let request = events
419 .iter()
420 .find_map(|event| match &event.payload {
421 RunLogPayload::HumanRequested {
422 request_id: rid,
423 purpose,
424 mode,
425 decisions,
426 output_schema,
427 deadline_at_ms,
428 ..
429 } if rid == request_id => Some((
430 event.run_path.clone(),
431 *purpose,
432 *mode,
433 decisions.clone(),
434 output_schema.clone(),
435 *deadline_at_ms,
436 )),
437 _ => None,
438 })
439 .ok_or_else(|| reject(HumanResponseRejection::UnknownRequest))?;
440 let (run_path, purpose, mode, decisions, output_schema, deadline_at_ms) = request;
441 let finally_responded = events.iter().any(|event| match &event.payload {
444 RunLogPayload::HumanResponded {
445 request_id: rid,
446 purpose,
447 response,
448 ..
449 } if rid == request_id => {
450 !(*purpose == HumanPurpose::Supervision
451 && response.get("decision").and_then(Value::as_str) == Some("suspend"))
452 }
453 _ => false,
454 });
455 if finally_responded {
456 return Err(reject(HumanResponseRejection::AlreadyResponded));
457 }
458 if let Some(deadline) = deadline_at_ms
460 && at_ms > deadline
461 {
462 return Err(reject(HumanResponseRejection::DeadlineExpired {
463 deadline_at_ms: deadline,
464 received_at_ms: at_ms,
465 }));
466 }
467 let pending = self
470 .materialized_checkpoint(run_id)?
471 .and_then(|(_, view)| view.human_pending)
472 .is_some_and(|pending| pending.request_id == request_id);
473 if !pending {
474 return Err(reject(HumanResponseRejection::Settled));
475 }
476 validate_response_shape(
478 purpose,
479 mode,
480 decisions.as_deref(),
481 &output_schema,
482 &response,
483 )
484 .map_err(|reason| reject(HumanResponseRejection::InvalidShape { reason }))?;
485 self.append_event(
486 run_id,
487 at_ms,
488 &run_path,
489 &RunLogPayload::HumanResponded {
490 request_id: request_id.to_owned(),
491 purpose,
492 response,
493 actor: actor.to_owned(),
494 },
495 )
496 }
497
498 pub fn rebuild_checkpoint(&self, run_id: &str) -> Result<CheckpointView, StoreError> {
502 Ok(self.refold(run_id)?.view)
503 }
504
505 pub fn verify_checkpoint(&self, run_id: &str) -> Result<CheckpointView, StoreError> {
514 let meta = read_meta(&self.conn, run_id)?;
515 let events = read_events(&self.conn, run_id)?;
516 let (log_seq, stored_json): (i64, String) = self
517 .conn
518 .query_row(
519 "SELECT log_seq, view FROM checkpoint WHERE run_id = ?1",
520 [run_id],
521 |row| Ok((row.get(0)?, row.get(1)?)),
522 )
523 .optional()?
524 .ok_or_else(|| StoreError::NoCheckpoint(run_id.to_owned()))?;
525 let head = events.last().map(|event| event.seq).unwrap_or(0);
526 if log_seq as u64 != head {
527 return Err(StoreError::StaleCheckpoint {
528 run_id: run_id.to_owned(),
529 materialized_seq: log_seq as u64,
530 log_seq: head,
531 });
532 }
533 let stored: CheckpointView = serde_json::from_str(&stored_json)?;
534 let folded = fold_checkpoint(&meta, &events)?;
535 if folded.view != stored {
536 return Err(StoreError::CheckpointMismatch {
537 run_id: run_id.to_owned(),
538 log_seq: log_seq as u64,
539 materialized: to_canonical_json(&serde_json::to_value(&stored)?),
540 rebuilt: to_canonical_json(&serde_json::to_value(&folded.view)?),
541 });
542 }
543 let stored_status = self.run_status(run_id)?;
544 if stored_status != folded.status {
545 return Err(StoreError::StatusMismatch {
546 run_id: run_id.to_owned(),
547 stored: stored_status.as_str().to_owned(),
548 folded: folded.status.as_str().to_owned(),
549 });
550 }
551 Ok(folded.view)
552 }
553
554 pub fn put_evidence(
564 &mut self,
565 bytes: &[u8],
566 media_type: &str,
567 ) -> Result<EvidencePut, StoreError> {
568 let digest = sha256_hex(bytes);
569 let rel_path = format!(
570 "evidence/sha256/{}/{}/{}",
571 &digest[0..2],
572 &digest[2..4],
573 digest
574 );
575 let abs_path = self.root.join(&rel_path);
576 let deduplicated = abs_path.exists();
577 if !deduplicated {
578 let parent = abs_path.parent().expect("evidence path has a parent");
579 fs::create_dir_all(parent)?;
580 let tmp_path = parent.join(format!(".{}.tmp.{}", digest, std::process::id()));
581 {
582 let mut file = fs::File::create(&tmp_path)?;
583 file.write_all(bytes)?;
584 file.sync_all()?;
585 }
586 fs::rename(&tmp_path, &abs_path)?;
587 #[cfg(unix)]
589 fs::File::open(parent)?.sync_all()?;
590 }
591 self.conn.execute(
592 "INSERT OR IGNORE INTO evidence (sha256, media_type, byte_size, local_path) \
593 VALUES (?1, ?2, ?3, ?4)",
594 params![digest, media_type, bytes.len() as i64, rel_path],
595 )?;
596 Ok(EvidencePut {
597 sha256: digest,
598 local_path: rel_path,
599 abs_path,
600 byte_size: bytes.len() as u64,
601 deduplicated,
602 })
603 }
604
605 pub fn link_evidence(
609 &mut self,
610 run_id: &str,
611 seq: u64,
612 asset_id: &str,
613 sha256: &str,
614 ) -> Result<(), StoreError> {
615 self.conn.execute(
616 "INSERT OR IGNORE INTO evidence_ref (run_id, seq, asset_id, sha256) \
617 VALUES (?1, ?2, ?3, ?4)",
618 params![run_id, seq as i64, asset_id, sha256],
619 )?;
620 Ok(())
621 }
622
623 pub fn run_meta(&self, run_id: &str) -> Result<RunMeta, StoreError> {
625 read_meta(&self.conn, run_id)
626 }
627
628 pub fn run_status(&self, run_id: &str) -> Result<RunStatus, StoreError> {
630 let status: String = self
631 .conn
632 .query_row(
633 "SELECT status FROM run WHERE run_id = ?1",
634 [run_id],
635 |row| row.get(0),
636 )
637 .optional()?
638 .ok_or_else(|| StoreError::UnknownRun(run_id.to_owned()))?;
639 RunStatus::parse(&status).ok_or_else(|| StoreError::Corrupt {
640 run_id: run_id.to_owned(),
641 reason: format!("run.status holds unknown value {status:?}"),
642 })
643 }
644
645 pub fn revision(&self, run_id: &str) -> Result<u64, StoreError> {
650 let _ = read_meta(&self.conn, run_id)?;
651 let head: i64 = self.conn.query_row(
652 "SELECT COALESCE(MAX(seq), 0) FROM run_log WHERE run_id = ?1",
653 [run_id],
654 |row| row.get(0),
655 )?;
656 Ok(head as u64)
657 }
658
659 pub fn global_revision(&self) -> Result<u64, StoreError> {
663 let total: i64 = self.conn.query_row(
664 "SELECT COALESCE(SUM(head), 0) FROM \
665 (SELECT MAX(seq) AS head FROM run_log GROUP BY run_id)",
666 [],
667 |row| row.get(0),
668 )?;
669 Ok(total as u64)
670 }
671
672 pub fn evidence_meta(&self, sha256: &str) -> Result<Option<EvidenceMeta>, StoreError> {
676 let row: Option<(String, String)> = self
677 .conn
678 .query_row(
679 "SELECT media_type, local_path FROM evidence WHERE sha256 = ?1",
680 [sha256],
681 |row| Ok((row.get(0)?, row.get(1)?)),
682 )
683 .optional()?;
684 Ok(row.map(|(media_type, local_path)| EvidenceMeta {
685 media_type,
686 abs_path: self.root.join(&local_path),
687 local_path,
688 }))
689 }
690
691 pub fn list_runs(&self) -> Result<Vec<RunListEntry>, StoreError> {
694 let mut statement = self.conn.prepare(
695 "SELECT run_id, flow_id, ir_hash, status, created_at_ms \
696 FROM run ORDER BY created_at_ms, run_id",
697 )?;
698 let rows = statement.query_map([], |row| {
699 Ok((
700 row.get::<_, String>(0)?,
701 row.get::<_, String>(1)?,
702 row.get::<_, String>(2)?,
703 row.get::<_, String>(3)?,
704 row.get::<_, i64>(4)?,
705 ))
706 })?;
707 let mut runs = Vec::new();
708 for row in rows {
709 let (run_id, flow_id, ir_hash, status, created_at_ms) = row?;
710 let status = RunStatus::parse(&status).ok_or_else(|| StoreError::Corrupt {
711 run_id: run_id.clone(),
712 reason: format!("run.status holds unknown value {status:?}"),
713 })?;
714 runs.push(RunListEntry {
715 run_id,
716 flow_id,
717 ir_hash,
718 status,
719 created_at_ms: created_at_ms as u64,
720 });
721 }
722 Ok(runs)
723 }
724
725 pub fn events(&self, run_id: &str) -> Result<Vec<RunLogEvent>, StoreError> {
727 let _ = read_meta(&self.conn, run_id)?;
729 read_events(&self.conn, run_id)
730 }
731
732 pub fn materialized_checkpoint(
735 &self,
736 run_id: &str,
737 ) -> Result<Option<(u64, CheckpointView)>, StoreError> {
738 let row: Option<(i64, String)> = self
739 .conn
740 .query_row(
741 "SELECT log_seq, view FROM checkpoint WHERE run_id = ?1",
742 [run_id],
743 |row| Ok((row.get(0)?, row.get(1)?)),
744 )
745 .optional()?;
746 match row {
747 None => Ok(None),
748 Some((log_seq, view_json)) => {
749 let view: CheckpointView = serde_json::from_str(&view_json)?;
750 Ok(Some((log_seq as u64, view)))
751 }
752 }
753 }
754
755 fn refold(&self, run_id: &str) -> Result<FoldedRun, StoreError> {
756 let meta = read_meta(&self.conn, run_id)?;
757 let events = read_events(&self.conn, run_id)?;
758 Ok(fold_checkpoint(&meta, &events)?)
759 }
760}
761
762fn validate_response_shape(
767 purpose: HumanPurpose,
768 mode: Option<HumanMode>,
769 decisions: Option<&[String]>,
770 output_schema: &Option<JsonSchemaDocument>,
771 response: &Value,
772) -> Result<(), String> {
773 let Some(object) = response.as_object() else {
774 return Err(format!("response must be a JSON object, got {response}"));
775 };
776 let (value_key, expects_input) = match (purpose, mode) {
777 (HumanPurpose::Supervision, _) => ("decision", false),
778 (HumanPurpose::Step, Some(HumanMode::Confirm)) => ("decision", false),
779 (HumanPurpose::Step, Some(HumanMode::Judge)) => ("status", false),
780 (HumanPurpose::Step, Some(HumanMode::ProvideInput)) => ("input", true),
781 (HumanPurpose::Step, Some(HumanMode::RepairWorld)) => ("decision", false),
782 (HumanPurpose::Step, None) => {
783 return Err("the request carries no mode for its step purpose".to_owned());
784 }
785 };
786 for key in object.keys() {
787 if key != value_key && key != "note" {
788 return Err(format!("unexpected field '{key}'"));
789 }
790 }
791 if let Some(note) = object.get("note")
792 && !note.is_string()
793 {
794 return Err(format!("'note' must be a string, got {note}"));
795 }
796 let Some(value) = object.get(value_key) else {
797 return Err(format!("missing required field '{value_key}'"));
798 };
799 if expects_input {
800 let Some(schema) = output_schema else {
803 return Err("the request carries no outputSchema for provideInput".to_owned());
804 };
805 return jsonschema::validate(schema.as_value(), value)
806 .map_err(|error| format!("input failed the outputSchema: {error}"));
807 }
808 let Some(label) = value.as_str() else {
809 return Err(format!("'{value_key}' must be a string, got {value}"));
810 };
811 match (purpose, mode) {
812 (HumanPurpose::Supervision, _) => match label {
813 "proceed" | "abort" | "suspend" => Ok(()),
815 other => Err(format!(
816 "supervision decision must be proceed|abort|suspend, got '{other}'"
817 )),
818 },
819 (_, Some(HumanMode::Confirm)) => {
820 let labels = decisions.unwrap_or(&[]);
823 if labels.iter().any(|candidate| candidate == label) {
824 Ok(())
825 } else {
826 Err(format!(
827 "confirm decision '{label}' is not one of the request's labels {labels:?}"
828 ))
829 }
830 }
831 (_, Some(HumanMode::Judge)) => {
832 if !matches!(label, "pass" | "fail" | "unknown") {
835 return Err(format!(
836 "judge status must be pass|fail|unknown, got '{label}'"
837 ));
838 }
839 if let Some(subset) = decisions
840 && !subset.iter().any(|candidate| candidate == label)
841 {
842 return Err(format!(
843 "judge status '{label}' is outside the request's declared subset {subset:?}"
844 ));
845 }
846 Ok(())
847 }
848 (_, Some(HumanMode::RepairWorld)) => {
849 if let Some(declared) = decisions {
859 return if declared.iter().any(|candidate| candidate == label) {
860 Ok(())
861 } else {
862 Err(format!(
863 "repairWorld decision '{label}' is not one of the request's \
864 labels {declared:?}"
865 ))
866 };
867 }
868 match label {
869 "done" | "cannotRepair" => Ok(()),
870 other => Err(format!(
871 "repairWorld decision must be done|cannotRepair (06 §2.1), got '{other}'"
872 )),
873 }
874 }
875 _ => unreachable!("provideInput and mode-less step requests returned above"),
876 }
877}
878
879fn sha256_hex(bytes: &[u8]) -> String {
880 use sha2::{Digest, Sha256};
881 let digest = Sha256::digest(bytes);
882 let mut hex = String::with_capacity(64);
883 for byte in digest {
884 let _ = write!(hex, "{byte:02x}");
885 }
886 hex
887}
888
889fn read_meta(conn: &Connection, run_id: &str) -> Result<RunMeta, StoreError> {
890 let row: Option<(String, String, String, String, String, i64)> = conn
891 .query_row(
892 "SELECT flow_id, ir_hash, lockfile_digest, params_snapshot, binding, created_at_ms \
893 FROM run WHERE run_id = ?1",
894 [run_id],
895 |row| {
896 Ok((
897 row.get(0)?,
898 row.get(1)?,
899 row.get(2)?,
900 row.get(3)?,
901 row.get(4)?,
902 row.get(5)?,
903 ))
904 },
905 )
906 .optional()?;
907 let Some((flow_id, ir_hash, lockfile_digest, params_json, binding_json, created_at_ms)) = row
908 else {
909 return Err(StoreError::UnknownRun(run_id.to_owned()));
910 };
911 let corrupt = |reason: String| StoreError::Corrupt {
912 run_id: run_id.to_owned(),
913 reason,
914 };
915 Ok(RunMeta {
916 run_id: run_id.to_owned(),
917 flow_id: FlowId::new(flow_id).map_err(|e| corrupt(e.to_string()))?,
918 ir_hash: Hash::new(ir_hash).map_err(|e| corrupt(e.to_string()))?,
919 lockfile_digest: Hash::new(lockfile_digest).map_err(|e| corrupt(e.to_string()))?,
920 params_snapshot: serde_json::from_str(¶ms_json)?,
921 binding: serde_json::from_str(&binding_json)?,
922 created_at_ms: created_at_ms as u64,
923 })
924}
925
926fn read_events(conn: &Connection, run_id: &str) -> Result<Vec<RunLogEvent>, StoreError> {
927 let mut stmt = conn.prepare(
928 "SELECT seq, type, at_ms, run_path, payload FROM run_log \
929 WHERE run_id = ?1 ORDER BY seq",
930 )?;
931 let rows = stmt.query_map([run_id], |row| {
932 Ok((
933 row.get::<_, i64>(0)?,
934 row.get::<_, String>(1)?,
935 row.get::<_, i64>(2)?,
936 row.get::<_, String>(3)?,
937 row.get::<_, String>(4)?,
938 ))
939 })?;
940 let mut events = Vec::new();
941 for row in rows {
942 let (seq, event_type, at_ms, run_path_json, payload_json) = row?;
943 let payload: RunLogPayload = serde_json::from_str(&payload_json)?;
944 if payload.event_type() != event_type {
947 return Err(StoreError::Corrupt {
948 run_id: run_id.to_owned(),
949 reason: format!(
950 "run_log seq {seq}: type column {event_type:?} disagrees with payload \
951 discriminant {:?}",
952 payload.event_type()
953 ),
954 });
955 }
956 let run_path: RunPath = serde_json::from_str(&run_path_json)?;
957 events.push(RunLogEvent {
958 run_id: run_id.to_owned(),
959 seq: seq as u64,
960 at_ms: at_ms as u64,
961 run_path,
962 payload,
963 });
964 }
965 Ok(events)
966}