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
184fn enable_wal(conn: &Connection, root: &Path) -> Result<(), StoreError> {
191 let mode: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
192 if mode.eq_ignore_ascii_case("wal") {
193 Ok(())
194 } else {
195 Err(StoreError::JournalModeNotWal {
196 root: root.display().to_string(),
197 mode,
198 })
199 }
200}
201
202impl Store {
203 pub fn open(root: impl AsRef<Path>) -> Result<Self, StoreError> {
209 let root = root.as_ref().to_path_buf();
210 fs::create_dir_all(&root)?;
211 fs::create_dir_all(root.join("evidence"))?;
212 let conn = Connection::open(root.join("pointlock.db"))?;
213 enable_wal(&conn, &root)?;
215 conn.pragma_update(None, "synchronous", "FULL")?;
216 conn.pragma_update(None, "foreign_keys", "ON")?;
217 conn.execute_batch(DDL)?;
218 Ok(Store {
219 conn,
220 root,
221 fold_cache: std::collections::HashMap::new(),
222 })
223 }
224
225 pub fn root(&self) -> &Path {
227 &self.root
228 }
229
230 pub fn begin_run(&mut self, new_run: NewRun) -> Result<String, StoreError> {
235 let run_id = new_run
236 .run_id
237 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
238 let exists: bool = self.conn.query_row(
239 "SELECT EXISTS(SELECT 1 FROM run WHERE run_id = ?1)",
240 [&run_id],
241 |row| row.get(0),
242 )?;
243 if exists {
244 return Err(StoreError::DuplicateRun(run_id));
245 }
246 let params_json = to_canonical_json(&new_run.params_snapshot);
247 let binding_json = to_canonical_json(&serde_json::to_value(&new_run.binding)?);
248 self.conn.execute(
249 "INSERT INTO run (run_id, flow_id, ir_hash, lockfile_digest, params_snapshot, \
250 binding, status, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
251 params![
252 run_id,
253 new_run.flow_id.as_str(),
254 new_run.ir_hash.as_str(),
255 new_run.lockfile_digest.as_str(),
256 params_json,
257 binding_json,
258 RunStatus::Running.as_str(),
259 new_run.created_at_ms as i64,
260 ],
261 )?;
262 Ok(run_id)
263 }
264
265 pub fn append_event(
276 &mut self,
277 run_id: &str,
278 at_ms: u64,
279 run_path: &RunPath,
280 payload: &RunLogPayload,
281 ) -> Result<u64, StoreError> {
282 let tx = self
283 .conn
284 .transaction_with_behavior(TransactionBehavior::Immediate)?;
285 let meta = read_meta(&tx, run_id)?;
286 let seq: i64 = tx.query_row(
287 "SELECT COALESCE(MAX(seq), 0) + 1 FROM run_log WHERE run_id = ?1",
288 [run_id],
289 |row| row.get(0),
290 )?;
291 let run_path_json = to_canonical_json(&serde_json::to_value(run_path)?);
292 let payload_json = to_canonical_json(&serde_json::to_value(payload)?);
293 tx.execute(
294 "INSERT INTO run_log (run_id, seq, type, at_ms, run_path, payload) \
295 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
296 params![
297 run_id,
298 seq,
299 payload.event_type(),
300 at_ms as i64,
301 run_path_json,
302 payload_json,
303 ],
304 )?;
305 let event = RunLogEvent {
306 run_id: run_id.to_owned(),
307 seq: seq as u64,
308 at_ms,
309 run_path: run_path.clone(),
310 payload: payload.clone(),
311 };
312 let folded = match self.fold_cache.get_mut(run_id) {
313 Some((cached_seq, state)) if *cached_seq + 1 == seq as u64 => {
314 if let Err(err) = state.apply(&event) {
315 self.fold_cache.remove(run_id);
317 return Err(err.into());
318 }
319 *cached_seq = seq as u64;
320 state.clone().finish()
321 }
322 _ => {
323 let events = read_events(&tx, run_id)?;
324 let state = fold_state(&meta, &events)?;
325 self.fold_cache
326 .insert(run_id.to_owned(), (seq as u64, state.clone()));
327 state.finish()
328 }
329 };
330 let view_json = to_canonical_json(&serde_json::to_value(&folded.view)?);
331 tx.execute(
332 "INSERT INTO checkpoint (run_id, log_seq, view) VALUES (?1, ?2, ?3) \
333 ON CONFLICT(run_id) DO UPDATE SET log_seq = excluded.log_seq, view = excluded.view",
334 params![run_id, seq, view_json],
335 )?;
336 tx.execute(
337 "UPDATE run SET status = ?2 WHERE run_id = ?1",
338 params![run_id, folded.status.as_str()],
339 )?;
340 tx.commit()?;
341 Ok(seq as u64)
342 }
343
344 pub fn write_action_intent(
354 &mut self,
355 run_id: &str,
356 at_ms: u64,
357 run_path: &RunPath,
358 call_id: &str,
359 args_snapshot: Value,
360 dispatch: Option<IntentDispatch>,
361 ) -> Result<u64, StoreError> {
362 let (chain_index, channel, action_name) = match dispatch {
363 Some(dispatch) => (
364 Some(dispatch.chain_index),
365 Some(dispatch.channel),
366 Some(dispatch.action_name),
367 ),
368 None => (None, None, None),
369 };
370 self.append_event(
371 run_id,
372 at_ms,
373 run_path,
374 &RunLogPayload::ActionIntent {
375 call_id: call_id.to_owned(),
376 args_snapshot,
377 chain_index,
378 channel,
379 action_name,
380 },
381 )
382 }
383
384 pub fn submit_human_response(
421 &mut self,
422 run_id: &str,
423 request_id: &str,
424 actor: &str,
425 at_ms: u64,
426 response: Value,
427 ) -> Result<u64, StoreError> {
428 let reject = |reason: HumanResponseRejection| StoreError::HumanResponseRejected {
429 run_id: run_id.to_owned(),
430 request_id: request_id.to_owned(),
431 reason,
432 };
433 let events = self.events(run_id)?;
434 let request = events
436 .iter()
437 .find_map(|event| match &event.payload {
438 RunLogPayload::HumanRequested {
439 request_id: rid,
440 purpose,
441 mode,
442 decisions,
443 output_schema,
444 deadline_at_ms,
445 ..
446 } if rid == request_id => Some((
447 event.run_path.clone(),
448 *purpose,
449 *mode,
450 decisions.clone(),
451 output_schema.clone(),
452 *deadline_at_ms,
453 )),
454 _ => None,
455 })
456 .ok_or_else(|| reject(HumanResponseRejection::UnknownRequest))?;
457 let (run_path, purpose, mode, decisions, output_schema, deadline_at_ms) = request;
458 let finally_responded = events.iter().any(|event| match &event.payload {
461 RunLogPayload::HumanResponded {
462 request_id: rid,
463 purpose,
464 response,
465 ..
466 } if rid == request_id => {
467 !(*purpose == HumanPurpose::Supervision
468 && response.get("decision").and_then(Value::as_str) == Some("suspend"))
469 }
470 _ => false,
471 });
472 if finally_responded {
473 return Err(reject(HumanResponseRejection::AlreadyResponded));
474 }
475 if let Some(deadline) = deadline_at_ms
477 && at_ms > deadline
478 {
479 return Err(reject(HumanResponseRejection::DeadlineExpired {
480 deadline_at_ms: deadline,
481 received_at_ms: at_ms,
482 }));
483 }
484 let pending = self
487 .materialized_checkpoint(run_id)?
488 .and_then(|(_, view)| view.human_pending)
489 .is_some_and(|pending| pending.request_id == request_id);
490 if !pending {
491 return Err(reject(HumanResponseRejection::Settled));
492 }
493 validate_response_shape(
495 purpose,
496 mode,
497 decisions.as_deref(),
498 &output_schema,
499 &response,
500 )
501 .map_err(|reason| reject(HumanResponseRejection::InvalidShape { reason }))?;
502 self.append_event(
503 run_id,
504 at_ms,
505 &run_path,
506 &RunLogPayload::HumanResponded {
507 request_id: request_id.to_owned(),
508 purpose,
509 response,
510 actor: actor.to_owned(),
511 },
512 )
513 }
514
515 pub fn rebuild_checkpoint(&self, run_id: &str) -> Result<CheckpointView, StoreError> {
519 Ok(self.refold(run_id)?.view)
520 }
521
522 pub fn verify_checkpoint(&self, run_id: &str) -> Result<CheckpointView, StoreError> {
531 let meta = read_meta(&self.conn, run_id)?;
532 let events = read_events(&self.conn, run_id)?;
533 let (log_seq, stored_json): (i64, String) = self
534 .conn
535 .query_row(
536 "SELECT log_seq, view FROM checkpoint WHERE run_id = ?1",
537 [run_id],
538 |row| Ok((row.get(0)?, row.get(1)?)),
539 )
540 .optional()?
541 .ok_or_else(|| StoreError::NoCheckpoint(run_id.to_owned()))?;
542 let head = events.last().map(|event| event.seq).unwrap_or(0);
543 if log_seq as u64 != head {
544 return Err(StoreError::StaleCheckpoint {
545 run_id: run_id.to_owned(),
546 materialized_seq: log_seq as u64,
547 log_seq: head,
548 });
549 }
550 let stored: CheckpointView = serde_json::from_str(&stored_json)?;
551 let folded = fold_checkpoint(&meta, &events)?;
552 if folded.view != stored {
553 return Err(StoreError::CheckpointMismatch {
554 run_id: run_id.to_owned(),
555 log_seq: log_seq as u64,
556 materialized: to_canonical_json(&serde_json::to_value(&stored)?),
557 rebuilt: to_canonical_json(&serde_json::to_value(&folded.view)?),
558 });
559 }
560 let stored_status = self.run_status(run_id)?;
561 if stored_status != folded.status {
562 return Err(StoreError::StatusMismatch {
563 run_id: run_id.to_owned(),
564 stored: stored_status.as_str().to_owned(),
565 folded: folded.status.as_str().to_owned(),
566 });
567 }
568 Ok(folded.view)
569 }
570
571 pub fn put_evidence(
581 &mut self,
582 bytes: &[u8],
583 media_type: &str,
584 ) -> Result<EvidencePut, StoreError> {
585 let digest = sha256_hex(bytes);
586 let rel_path = format!(
587 "evidence/sha256/{}/{}/{}",
588 &digest[0..2],
589 &digest[2..4],
590 digest
591 );
592 let abs_path = self.root.join(&rel_path);
593 let deduplicated = abs_path.exists();
594 if !deduplicated {
595 let parent = abs_path.parent().expect("evidence path has a parent");
596 fs::create_dir_all(parent)?;
597 let tmp_path = parent.join(format!(".{}.tmp.{}", digest, std::process::id()));
598 struct TempGuard<'a>(&'a Path, bool);
601 impl Drop for TempGuard<'_> {
602 fn drop(&mut self) {
603 if self.1 {
604 let _ = fs::remove_file(self.0);
605 }
606 }
607 }
608 let mut guard = TempGuard(&tmp_path, true);
609 {
610 let mut file = fs::File::create(&tmp_path)?;
611 file.write_all(bytes)?;
612 file.sync_all()?;
613 }
614 fs::rename(&tmp_path, &abs_path)?;
615 guard.1 = false;
616 #[cfg(unix)]
618 fs::File::open(parent)?.sync_all()?;
619 }
620 self.conn.execute(
621 "INSERT OR IGNORE INTO evidence (sha256, media_type, byte_size, local_path) \
622 VALUES (?1, ?2, ?3, ?4)",
623 params![digest, media_type, bytes.len() as i64, rel_path],
624 )?;
625 Ok(EvidencePut {
626 sha256: digest,
627 local_path: rel_path,
628 abs_path,
629 byte_size: bytes.len() as u64,
630 deduplicated,
631 })
632 }
633
634 pub fn link_evidence(
638 &mut self,
639 run_id: &str,
640 seq: u64,
641 asset_id: &str,
642 sha256: &str,
643 ) -> Result<(), StoreError> {
644 self.conn.execute(
645 "INSERT OR IGNORE INTO evidence_ref (run_id, seq, asset_id, sha256) \
646 VALUES (?1, ?2, ?3, ?4)",
647 params![run_id, seq as i64, asset_id, sha256],
648 )?;
649 Ok(())
650 }
651
652 pub fn run_meta(&self, run_id: &str) -> Result<RunMeta, StoreError> {
654 read_meta(&self.conn, run_id)
655 }
656
657 pub fn run_status(&self, run_id: &str) -> Result<RunStatus, StoreError> {
659 let status: String = self
660 .conn
661 .query_row(
662 "SELECT status FROM run WHERE run_id = ?1",
663 [run_id],
664 |row| row.get(0),
665 )
666 .optional()?
667 .ok_or_else(|| StoreError::UnknownRun(run_id.to_owned()))?;
668 RunStatus::parse(&status).ok_or_else(|| StoreError::Corrupt {
669 run_id: run_id.to_owned(),
670 reason: format!("run.status holds unknown value {status:?}"),
671 })
672 }
673
674 pub fn revision(&self, run_id: &str) -> Result<u64, StoreError> {
679 let _ = read_meta(&self.conn, run_id)?;
680 let head: i64 = self.conn.query_row(
681 "SELECT COALESCE(MAX(seq), 0) FROM run_log WHERE run_id = ?1",
682 [run_id],
683 |row| row.get(0),
684 )?;
685 Ok(head as u64)
686 }
687
688 pub fn global_revision(&self) -> Result<u64, StoreError> {
692 let total: i64 = self.conn.query_row(
693 "SELECT COALESCE(SUM(head), 0) FROM \
694 (SELECT MAX(seq) AS head FROM run_log GROUP BY run_id)",
695 [],
696 |row| row.get(0),
697 )?;
698 Ok(total as u64)
699 }
700
701 pub fn evidence_meta(&self, sha256: &str) -> Result<Option<EvidenceMeta>, StoreError> {
705 let row: Option<(String, String)> = self
706 .conn
707 .query_row(
708 "SELECT media_type, local_path FROM evidence WHERE sha256 = ?1",
709 [sha256],
710 |row| Ok((row.get(0)?, row.get(1)?)),
711 )
712 .optional()?;
713 Ok(row.map(|(media_type, local_path)| EvidenceMeta {
714 media_type,
715 abs_path: self.root.join(&local_path),
716 local_path,
717 }))
718 }
719
720 pub fn list_runs(&self) -> Result<Vec<RunListEntry>, StoreError> {
723 let mut statement = self.conn.prepare(
724 "SELECT run_id, flow_id, ir_hash, status, created_at_ms \
725 FROM run ORDER BY created_at_ms, run_id",
726 )?;
727 let rows = statement.query_map([], |row| {
728 Ok((
729 row.get::<_, String>(0)?,
730 row.get::<_, String>(1)?,
731 row.get::<_, String>(2)?,
732 row.get::<_, String>(3)?,
733 row.get::<_, i64>(4)?,
734 ))
735 })?;
736 let mut runs = Vec::new();
737 for row in rows {
738 let (run_id, flow_id, ir_hash, status, created_at_ms) = row?;
739 let status = RunStatus::parse(&status).ok_or_else(|| StoreError::Corrupt {
740 run_id: run_id.clone(),
741 reason: format!("run.status holds unknown value {status:?}"),
742 })?;
743 runs.push(RunListEntry {
744 run_id,
745 flow_id,
746 ir_hash,
747 status,
748 created_at_ms: created_at_ms as u64,
749 });
750 }
751 Ok(runs)
752 }
753
754 pub fn events(&self, run_id: &str) -> Result<Vec<RunLogEvent>, StoreError> {
756 let _ = read_meta(&self.conn, run_id)?;
758 read_events(&self.conn, run_id)
759 }
760
761 pub fn materialized_checkpoint(
764 &self,
765 run_id: &str,
766 ) -> Result<Option<(u64, CheckpointView)>, StoreError> {
767 let row: Option<(i64, String)> = self
768 .conn
769 .query_row(
770 "SELECT log_seq, view FROM checkpoint WHERE run_id = ?1",
771 [run_id],
772 |row| Ok((row.get(0)?, row.get(1)?)),
773 )
774 .optional()?;
775 match row {
776 None => Ok(None),
777 Some((log_seq, view_json)) => {
778 let view: CheckpointView = serde_json::from_str(&view_json)?;
779 Ok(Some((log_seq as u64, view)))
780 }
781 }
782 }
783
784 fn refold(&self, run_id: &str) -> Result<FoldedRun, StoreError> {
785 let meta = read_meta(&self.conn, run_id)?;
786 let events = read_events(&self.conn, run_id)?;
787 Ok(fold_checkpoint(&meta, &events)?)
788 }
789}
790
791fn validate_response_shape(
796 purpose: HumanPurpose,
797 mode: Option<HumanMode>,
798 decisions: Option<&[String]>,
799 output_schema: &Option<JsonSchemaDocument>,
800 response: &Value,
801) -> Result<(), String> {
802 let Some(object) = response.as_object() else {
803 return Err(format!("response must be a JSON object, got {response}"));
804 };
805 let (value_key, expects_input) = match (purpose, mode) {
806 (HumanPurpose::Supervision, _) => ("decision", false),
807 (HumanPurpose::Step, Some(HumanMode::Confirm)) => ("decision", false),
808 (HumanPurpose::Step, Some(HumanMode::Judge)) => ("status", false),
809 (HumanPurpose::Step, Some(HumanMode::ProvideInput)) => ("input", true),
810 (HumanPurpose::Step, Some(HumanMode::RepairWorld)) => ("decision", false),
811 (HumanPurpose::Step, None) => {
812 return Err("the request carries no mode for its step purpose".to_owned());
813 }
814 };
815 for key in object.keys() {
816 if key != value_key && key != "note" {
817 return Err(format!("unexpected field '{key}'"));
818 }
819 }
820 if let Some(note) = object.get("note")
821 && !note.is_string()
822 {
823 return Err(format!("'note' must be a string, got {note}"));
824 }
825 let Some(value) = object.get(value_key) else {
826 return Err(format!("missing required field '{value_key}'"));
827 };
828 if expects_input {
829 let Some(schema) = output_schema else {
832 return Err("the request carries no outputSchema for provideInput".to_owned());
833 };
834 return jsonschema::validate(schema.as_value(), value)
835 .map_err(|error| format!("input failed the outputSchema: {error}"));
836 }
837 let Some(label) = value.as_str() else {
838 return Err(format!("'{value_key}' must be a string, got {value}"));
839 };
840 match (purpose, mode) {
841 (HumanPurpose::Supervision, _) => match label {
842 "proceed" | "abort" | "suspend" => Ok(()),
844 other => Err(format!(
845 "supervision decision must be proceed|abort|suspend, got '{other}'"
846 )),
847 },
848 (_, Some(HumanMode::Confirm)) => {
849 let labels = decisions.unwrap_or(&[]);
852 if labels.iter().any(|candidate| candidate == label) {
853 Ok(())
854 } else {
855 Err(format!(
856 "confirm decision '{label}' is not one of the request's labels {labels:?}"
857 ))
858 }
859 }
860 (_, Some(HumanMode::Judge)) => {
861 if !matches!(label, "pass" | "fail" | "unknown") {
864 return Err(format!(
865 "judge status must be pass|fail|unknown, got '{label}'"
866 ));
867 }
868 if let Some(subset) = decisions
869 && !subset.iter().any(|candidate| candidate == label)
870 {
871 return Err(format!(
872 "judge status '{label}' is outside the request's declared subset {subset:?}"
873 ));
874 }
875 Ok(())
876 }
877 (_, Some(HumanMode::RepairWorld)) => {
878 if let Some(declared) = decisions {
888 return if declared.iter().any(|candidate| candidate == label) {
889 Ok(())
890 } else {
891 Err(format!(
892 "repairWorld decision '{label}' is not one of the request's \
893 labels {declared:?}"
894 ))
895 };
896 }
897 match label {
898 "done" | "cannotRepair" => Ok(()),
899 other => Err(format!(
900 "repairWorld decision must be done|cannotRepair (06 §2.1), got '{other}'"
901 )),
902 }
903 }
904 _ => unreachable!("provideInput and mode-less step requests returned above"),
905 }
906}
907
908fn sha256_hex(bytes: &[u8]) -> String {
909 use sha2::{Digest, Sha256};
910 let digest = Sha256::digest(bytes);
911 let mut hex = String::with_capacity(64);
912 for byte in digest {
913 let _ = write!(hex, "{byte:02x}");
914 }
915 hex
916}
917
918fn read_meta(conn: &Connection, run_id: &str) -> Result<RunMeta, StoreError> {
919 let row: Option<(String, String, String, String, String, i64)> = conn
920 .query_row(
921 "SELECT flow_id, ir_hash, lockfile_digest, params_snapshot, binding, created_at_ms \
922 FROM run WHERE run_id = ?1",
923 [run_id],
924 |row| {
925 Ok((
926 row.get(0)?,
927 row.get(1)?,
928 row.get(2)?,
929 row.get(3)?,
930 row.get(4)?,
931 row.get(5)?,
932 ))
933 },
934 )
935 .optional()?;
936 let Some((flow_id, ir_hash, lockfile_digest, params_json, binding_json, created_at_ms)) = row
937 else {
938 return Err(StoreError::UnknownRun(run_id.to_owned()));
939 };
940 let corrupt = |reason: String| StoreError::Corrupt {
941 run_id: run_id.to_owned(),
942 reason,
943 };
944 Ok(RunMeta {
945 run_id: run_id.to_owned(),
946 flow_id: FlowId::new(flow_id).map_err(|e| corrupt(e.to_string()))?,
947 ir_hash: Hash::new(ir_hash).map_err(|e| corrupt(e.to_string()))?,
948 lockfile_digest: Hash::new(lockfile_digest).map_err(|e| corrupt(e.to_string()))?,
949 params_snapshot: serde_json::from_str(¶ms_json)?,
950 binding: serde_json::from_str(&binding_json)?,
951 created_at_ms: created_at_ms as u64,
952 })
953}
954
955fn read_events(conn: &Connection, run_id: &str) -> Result<Vec<RunLogEvent>, StoreError> {
956 let mut stmt = conn.prepare(
957 "SELECT seq, type, at_ms, run_path, payload FROM run_log \
958 WHERE run_id = ?1 ORDER BY seq",
959 )?;
960 let rows = stmt.query_map([run_id], |row| {
961 Ok((
962 row.get::<_, i64>(0)?,
963 row.get::<_, String>(1)?,
964 row.get::<_, i64>(2)?,
965 row.get::<_, String>(3)?,
966 row.get::<_, String>(4)?,
967 ))
968 })?;
969 let mut events = Vec::new();
970 for row in rows {
971 let (seq, event_type, at_ms, run_path_json, payload_json) = row?;
972 let payload: RunLogPayload = serde_json::from_str(&payload_json)?;
973 if payload.event_type() != event_type {
976 return Err(StoreError::Corrupt {
977 run_id: run_id.to_owned(),
978 reason: format!(
979 "run_log seq {seq}: type column {event_type:?} disagrees with payload \
980 discriminant {:?}",
981 payload.event_type()
982 ),
983 });
984 }
985 let run_path: RunPath = serde_json::from_str(&run_path_json)?;
986 events.push(RunLogEvent {
987 run_id: run_id.to_owned(),
988 seq: seq as u64,
989 at_ms: at_ms as u64,
990 run_path,
991 payload,
992 });
993 }
994 Ok(events)
995}
996
997#[cfg(test)]
998mod tests {
999 use super::*;
1000
1001 #[test]
1005 fn enable_wal_fails_closed_when_the_pragma_answers_another_mode() {
1006 let dir = std::env::temp_dir().join(format!("pointlock-store-wal-{}", std::process::id()));
1007 let _ = fs::remove_dir_all(&dir);
1008 fs::create_dir_all(&dir).expect("create dir");
1009 let db = dir.join("pointlock.db");
1010 Connection::open(&db)
1011 .expect("create db")
1012 .execute_batch("CREATE TABLE t (a)")
1013 .expect("ddl");
1014 let conn = Connection::open_with_flags(
1015 format!("file:{}?immutable=1", db.display()),
1016 rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_URI,
1017 )
1018 .expect("open immutable");
1019 let err = enable_wal(&conn, &dir).expect_err("WAL cannot take on an immutable db");
1020 assert!(
1021 matches!(&err, StoreError::JournalModeNotWal { mode, root }
1022 if mode == "delete" && root == &dir.display().to_string()),
1023 "got {err:?}"
1024 );
1025 let _ = fs::remove_dir_all(&dir);
1026 }
1027}