1use rusqlite::backup::Backup;
2use rusqlite::blob::Blob;
3use rusqlite::{
4 Connection, ErrorCode, MAIN_DB, OpenFlags, OptionalExtension, Transaction, TransactionBehavior,
5 params,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10use std::fs::{self, OpenOptions};
11use std::io::{self, Cursor, Read, Seek, SeekFrom, Write};
12use std::path::{Path, PathBuf};
13use std::time::{Duration, Instant};
14use tempfile::{NamedTempFile, tempfile};
15
16use crate::config;
17
18const SCHEMA_VERSION: i64 = 1;
19const ZSTD_LEVEL: i32 = 3;
20const COMPRESSION_PERCENT: u64 = 95;
21const MAX_FRAME_BYTES: u64 = 256 * 1024 * 1024;
22const INGEST_SCHEMA_VERSION: u32 = 1;
23const ARCHIVE_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
24const ARCHIVE_REF_PREFIX: &str = "yr_";
25const ARCHIVE_REF_LEN: usize = 35;
26
27const SCHEMA: &str = r"
28CREATE TABLE sessions (
29 id INTEGER PRIMARY KEY,
30 agent TEXT NOT NULL,
31 account TEXT NOT NULL,
32 source_session_id TEXT NOT NULL,
33 started_at_ms INTEGER,
34 UNIQUE (agent, account, source_session_id)
35);
36
37CREATE TABLE tool_calls (
38 id INTEGER PRIMARY KEY,
39 session_id INTEGER NOT NULL REFERENCES sessions(id),
40 source_call_id TEXT NOT NULL,
41 archive_ref TEXT NOT NULL UNIQUE CHECK (
42 length(archive_ref) = 35
43 AND substr(archive_ref, 1, 3) = 'yr_'
44 AND substr(archive_ref, 4) NOT GLOB '*[^0-9a-f]*'
45 ),
46 tool_name TEXT NOT NULL,
47 provider TEXT,
48 model TEXT,
49 working_directory TEXT,
50 started_at_ms INTEGER NOT NULL,
51 requires_streams INTEGER NOT NULL CHECK (requires_streams IN (0, 1)),
52 finished_at_ms INTEGER,
53 status TEXT NOT NULL CHECK (status IN ('started', 'finished')),
54 is_error INTEGER CHECK (is_error IN (0, 1)),
55 executed INTEGER CHECK (executed IN (0, 1)),
56 UNIQUE (session_id, source_call_id)
57);
58
59CREATE TABLE payloads (
60 sha256 BLOB PRIMARY KEY CHECK (length(sha256) = 32),
61 compression TEXT NOT NULL CHECK (compression IN ('none', 'zstd')),
62 uncompressed_byte_length INTEGER NOT NULL CHECK (uncompressed_byte_length >= 0),
63 body BLOB NOT NULL
64);
65
66CREATE TABLE snapshots (
67 tool_call_id INTEGER NOT NULL REFERENCES tool_calls(id) ON DELETE CASCADE,
68 subject TEXT NOT NULL CHECK (subject IN ('input', 'result', 'result_text', 'source_output', 'stdout', 'stderr')),
69 stage TEXT NOT NULL CHECK (stage IN ('before', 'after')),
70 media_type TEXT NOT NULL,
71 source_completeness TEXT CHECK (source_completeness IN ('complete', 'incomplete', 'unknown')),
72 captured_at_ms INTEGER NOT NULL,
73 payload_sha256 BLOB NOT NULL REFERENCES payloads(sha256),
74 PRIMARY KEY (tool_call_id, subject, stage),
75 CHECK (
76 (subject = 'result_text' AND stage = 'before' AND source_completeness IS NOT NULL)
77 OR (subject <> 'result_text' AND source_completeness IS NULL)
78 )
79);
80
81CREATE INDEX tool_calls_started_at_idx ON tool_calls(started_at_ms);
82CREATE INDEX tool_calls_tool_name_idx ON tool_calls(tool_name);
83CREATE INDEX snapshots_payload_idx ON snapshots(payload_sha256);
84";
85
86#[derive(Clone, Debug, Deserialize, Serialize)]
87#[serde(rename_all = "camelCase")]
88pub struct SessionIdentity {
89 pub agent: String,
90 pub account: String,
91 pub source_session_id: String,
92 pub started_at_ms: Option<i64>,
93}
94
95#[derive(Clone, Debug, Deserialize, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct CallIdentity {
98 pub source_call_id: String,
99 pub tool_name: String,
100 pub provider: Option<String>,
101 pub model: Option<String>,
102 pub working_directory: Option<String>,
103 pub started_at_ms: i64,
104 pub requires_streams: bool,
105}
106
107#[derive(Clone, Debug)]
108pub struct ArchiveKey {
109 pub session: SessionIdentity,
110 pub source_call_id: String,
111}
112
113#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
114#[serde(rename_all = "snake_case")]
115pub enum SourceCompleteness {
116 Complete,
117 Incomplete,
118 Unknown,
119}
120
121impl SourceCompleteness {
122 #[must_use]
123 pub const fn as_str(self) -> &'static str {
124 match self {
125 Self::Complete => "complete",
126 Self::Incomplete => "incomplete",
127 Self::Unknown => "unknown",
128 }
129 }
130}
131
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
133pub enum SourceName {
134 Stdout,
135 Stderr,
136 SourceOutput,
137 ResultText,
138}
139
140impl SourceName {
141 #[must_use]
142 pub const fn as_str(self) -> &'static str {
143 match self {
144 Self::Stdout => "stdout",
145 Self::Stderr => "stderr",
146 Self::SourceOutput => "source_output",
147 Self::ResultText => "result_text",
148 }
149 }
150}
151
152pub struct VerifiedSource {
153 pub name: SourceName,
154 pub completeness: SourceCompleteness,
155 pub media_type: String,
156 pub body: std::fs::File,
157 pub byte_length: u64,
158}
159
160#[derive(Debug, Deserialize)]
161#[serde(
162 tag = "operation",
163 rename_all = "snake_case",
164 rename_all_fields = "camelCase",
165 deny_unknown_fields
166)]
167enum IngestOperation {
168 BeginCall {
169 request_id: u64,
170 schema_version: u32,
171 session: SessionIdentity,
172 call: CallIdentity,
173 input_before: Value,
174 input_after: Value,
175 captured_at_ms: i64,
176 },
177 ResultBefore {
178 request_id: u64,
179 schema_version: u32,
180 session: SessionIdentity,
181 source_call_id: String,
182 result: Value,
183 full_output_path: Option<PathBuf>,
184 captured_at_ms: i64,
185 },
186 ResultText {
187 request_id: u64,
188 schema_version: u32,
189 session: SessionIdentity,
190 source_call_id: String,
191 text: String,
192 source_completeness: SourceCompleteness,
193 captured_at_ms: i64,
194 },
195 StageResult {
196 request_id: u64,
197 schema_version: u32,
198 session: SessionIdentity,
199 source_call_id: String,
200 result: Value,
201 is_error: bool,
202 captured_at_ms: i64,
203 },
204 FinishCall {
205 request_id: u64,
206 schema_version: u32,
207 session: SessionIdentity,
208 source_call_id: String,
209 result: Value,
210 is_error: bool,
211 require_pre_result: bool,
212 finished_at_ms: i64,
213 },
214 UpdateFinalResult {
215 request_id: u64,
216 schema_version: u32,
217 session: SessionIdentity,
218 source_call_id: String,
219 result: Value,
220 is_error: bool,
221 finished_at_ms: i64,
222 },
223}
224
225impl IngestOperation {
226 const fn request_id(&self) -> u64 {
227 match self {
228 Self::BeginCall { request_id, .. }
229 | Self::ResultBefore { request_id, .. }
230 | Self::ResultText { request_id, .. }
231 | Self::StageResult { request_id, .. }
232 | Self::FinishCall { request_id, .. }
233 | Self::UpdateFinalResult { request_id, .. } => *request_id,
234 }
235 }
236
237 const fn schema_version(&self) -> u32 {
238 match self {
239 Self::BeginCall { schema_version, .. }
240 | Self::ResultBefore { schema_version, .. }
241 | Self::ResultText { schema_version, .. }
242 | Self::StageResult { schema_version, .. }
243 | Self::FinishCall { schema_version, .. }
244 | Self::UpdateFinalResult { schema_version, .. } => *schema_version,
245 }
246 }
247}
248
249#[derive(Debug, Serialize)]
250#[serde(rename_all = "camelCase")]
251struct IngestAck<'a> {
252 request_id: u64,
253 ok: bool,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 archive_ref: Option<&'a str>,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 error: Option<&'a str>,
258}
259
260#[derive(Debug, Eq, PartialEq)]
261pub struct ArchiveStats {
262 pub sessions: i64,
263 pub calls: i64,
264 pub incomplete_calls: i64,
265 pub logical_payload_bytes: i64,
266 pub stored_payload_bytes: i64,
267 pub database_bytes: u64,
268 pub oldest_call_ms: Option<i64>,
269 pub newest_call_ms: Option<i64>,
270}
271
272#[derive(Debug, Default, Eq, PartialEq)]
273pub struct VerifyReport {
274 pub incomplete_calls: i64,
275 pub errors: Vec<String>,
276}
277
278pub struct Archive {
279 connection: Connection,
280 path: PathBuf,
281}
282
283impl Archive {
284 pub fn open() -> Result<Self, String> {
290 let archive = config::load()?.archive;
291 Self::open_path_with_policy(archive.path, archive.is_default_path)
292 }
293
294 pub fn open_read_only() -> Result<Self, String> {
300 let path = archive_path()?;
301 require_private_archive_path(&path)?;
302 let connection = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
303 .map_err(|error| {
304 format!(
305 "could not open archive {} read-only: {error}",
306 path.display()
307 )
308 })?;
309 connection
310 .busy_timeout(ARCHIVE_BUSY_TIMEOUT)
311 .map_err(|error| format!("could not set archive busy timeout: {error}"))?;
312 let version: i64 = connection
313 .pragma_query_value(None, "user_version", |row| row.get(0))
314 .map_err(|error| format!("could not read archive schema version: {error}"))?;
315 if version != SCHEMA_VERSION {
316 return Err(format!(
317 "archive schema version {version}, expected {SCHEMA_VERSION}"
318 ));
319 }
320 if !schema_is_current(&connection)? {
321 return Err(
322 "archive schema requires migration; open it with a writable YARP command first"
323 .to_owned(),
324 );
325 }
326 Ok(Self { connection, path })
327 }
328
329 pub fn open_path(path: PathBuf) -> Result<Self, String> {
335 Self::open_path_with_policy(path, false)
336 }
337
338 fn open_path_with_policy(
339 path: PathBuf,
340 repair_existing_directory: bool,
341 ) -> Result<Self, String> {
342 prepare_path(&path, repair_existing_directory)?;
343 let mut connection = Connection::open(&path)
344 .map_err(|error| format!("could not open archive {}: {error}", path.display()))?;
345 connection
346 .busy_timeout(ARCHIVE_BUSY_TIMEOUT)
347 .map_err(|error| format!("could not set archive busy timeout: {error}"))?;
348 connection
349 .pragma_update(None, "foreign_keys", "ON")
350 .map_err(|error| format!("could not enable archive foreign keys: {error}"))?;
351 enable_wal_mode(&connection)?;
352 connection
353 .pragma_update(None, "synchronous", "FULL")
354 .map_err(|error| format!("could not set archive sync mode: {error}"))?;
355
356 let version: i64 = connection
357 .pragma_query_value(None, "user_version", |row| row.get(0))
358 .map_err(|error| format!("could not read archive schema version: {error}"))?;
359 match version {
360 0 => initialize_schema(&mut connection)?,
361 SCHEMA_VERSION => ensure_current_schema(&mut connection, &path)?,
362 newer if newer > SCHEMA_VERSION => {
363 return Err(format!(
364 "archive schema version {newer} is newer than supported version {SCHEMA_VERSION}"
365 ));
366 }
367 older => {
368 return Err(format!(
369 "archive schema version {older} has no supported migration"
370 ));
371 }
372 }
373 set_file_mode(&path, 0o600)?;
374 Ok(Self { connection, path })
375 }
376
377 pub fn begin_call(
383 &mut self,
384 session: &SessionIdentity,
385 call: &CallIdentity,
386 input_before: &Value,
387 input_after: &Value,
388 captured_at_ms: i64,
389 ) -> Result<String, String> {
390 let before = canonical_json(input_before)?;
391 let after = canonical_json(input_after)?;
392 let transaction = self.transaction()?;
393 let session_id = ensure_session(&transaction, session)?;
394 let call_id = ensure_call(&transaction, session_id, call)?;
395 let archive_ref = call_archive_ref(&transaction, call_id)?;
396 insert_snapshot_bytes(
397 &transaction,
398 call_id,
399 "input",
400 "before",
401 "application/json",
402 captured_at_ms,
403 &before,
404 )?;
405 insert_snapshot_bytes(
406 &transaction,
407 call_id,
408 "input",
409 "after",
410 "application/json",
411 captured_at_ms,
412 &after,
413 )?;
414 transaction
415 .commit()
416 .map_err(|error| format!("could not commit tool call: {error}"))?;
417 Ok(archive_ref)
418 }
419
420 pub fn result_before(
426 &mut self,
427 session: &SessionIdentity,
428 source_call_id: &str,
429 result: &Value,
430 full_output_path: Option<&Path>,
431 captured_at_ms: i64,
432 ) -> Result<(), String> {
433 let body = canonical_json(result)?;
434 let mut full_output = full_output_path
435 .map(|path| {
436 let file = OpenOptions::new().read(true).open(path).map_err(|error| {
437 format!(
438 "could not open source full output {}: {error}",
439 path.display()
440 )
441 })?;
442 let metadata = file.metadata().map_err(|error| {
443 format!(
444 "could not inspect source full output {}: {error}",
445 path.display()
446 )
447 })?;
448 if !metadata.is_file() {
449 return Err(format!(
450 "source full output {} is not a regular file",
451 path.display()
452 ));
453 }
454 Ok((file, metadata.len()))
455 })
456 .transpose()?;
457 let transaction = self.transaction()?;
458 let call_id = find_call(&transaction, session, source_call_id)?;
459 insert_snapshot_bytes(
460 &transaction,
461 call_id,
462 "result",
463 "before",
464 "application/json",
465 captured_at_ms,
466 &body,
467 )?;
468 if let Some((file, original_length)) = &mut full_output {
469 insert_snapshot_reader(
470 &transaction,
471 call_id,
472 "source_output",
473 "before",
474 stream_media_type(file)?,
475 captured_at_ms,
476 file,
477 )?;
478 let final_length = file
479 .metadata()
480 .map_err(|error| format!("could not recheck source full output: {error}"))?
481 .len();
482 if final_length != *original_length {
483 return Err(format!(
484 "source full output changed size while archiving: {original_length} to {final_length} bytes"
485 ));
486 }
487 }
488 transaction
489 .commit()
490 .map_err(|error| format!("could not commit pre-YARP result: {error}"))
491 }
492
493 pub fn result_text(
500 &mut self,
501 session: &SessionIdentity,
502 source_call_id: &str,
503 text: &str,
504 completeness: SourceCompleteness,
505 captured_at_ms: i64,
506 ) -> Result<String, String> {
507 let transaction = self.transaction()?;
508 let call_id = find_call(&transaction, session, source_call_id)?;
509 insert_snapshot_reader_with_completeness(
510 &transaction,
511 call_id,
512 "result_text",
513 "before",
514 "text/plain; charset=utf-8",
515 Some(completeness.as_str()),
516 captured_at_ms,
517 &mut Cursor::new(text.as_bytes()),
518 )?;
519 let archive_ref = call_archive_ref(&transaction, call_id)?;
520 transaction
521 .commit()
522 .map_err(|error| format!("could not commit result text: {error}"))?;
523 Ok(archive_ref)
524 }
525
526 pub fn archive_ref(&self, key: &ArchiveKey) -> Result<String, String> {
532 self.connection
533 .query_row(
534 "SELECT c.archive_ref
535 FROM tool_calls c
536 JOIN sessions s ON s.id = c.session_id
537 WHERE s.agent = ?1 AND s.account = ?2 AND s.source_session_id = ?3
538 AND c.source_call_id = ?4",
539 params![
540 key.session.agent,
541 key.session.account,
542 key.session.source_session_id,
543 key.source_call_id
544 ],
545 |row| row.get(0),
546 )
547 .map_err(|error| format!("could not resolve archive call reference: {error}"))
548 }
549
550 pub fn searchable_sources(&self, archive_ref: &str) -> Result<Vec<VerifiedSource>, String> {
557 validate_archive_ref(archive_ref)?;
558 let call_id: i64 = self
559 .connection
560 .query_row(
561 "SELECT id FROM tool_calls WHERE archive_ref = ?1",
562 [archive_ref],
563 |row| row.get(0),
564 )
565 .map_err(|error| format!("could not find archive reference {archive_ref}: {error}"))?;
566 let stdout = self.snapshot_exists(call_id, "stdout", "before")?;
567 let stderr = self.snapshot_exists(call_id, "stderr", "before")?;
568 if stdout || stderr {
569 let mut sources = Vec::with_capacity(2);
570 if stdout {
571 sources.push(self.verified_source(
572 call_id,
573 SourceName::Stdout,
574 SourceCompleteness::Complete,
575 )?);
576 }
577 if stderr {
578 sources.push(self.verified_source(
579 call_id,
580 SourceName::Stderr,
581 SourceCompleteness::Complete,
582 )?);
583 }
584 return Ok(sources);
585 }
586 if self.snapshot_exists(call_id, "result_text", "before")? {
587 let value: String = self
588 .connection
589 .query_row(
590 "SELECT source_completeness FROM snapshots
591 WHERE tool_call_id = ?1 AND subject = 'result_text' AND stage = 'before'",
592 [call_id],
593 |row| row.get(0),
594 )
595 .map_err(|error| format!("could not read result text completeness: {error}"))?;
596 let completeness = parse_completeness(&value)?;
597 return Ok(vec![self.verified_source(
598 call_id,
599 SourceName::ResultText,
600 completeness,
601 )?]);
602 }
603 if self.snapshot_exists(call_id, "source_output", "before")? {
604 return Ok(vec![self.verified_source(
605 call_id,
606 SourceName::SourceOutput,
607 SourceCompleteness::Complete,
608 )?]);
609 }
610 Err(format!(
611 "archive reference {archive_ref} has no searchable source"
612 ))
613 }
614
615 pub fn stage_result(
622 &mut self,
623 session: &SessionIdentity,
624 source_call_id: &str,
625 result: &Value,
626 is_error: bool,
627 captured_at_ms: i64,
628 ) -> Result<(), String> {
629 let body = canonical_json(result)?;
630 let transaction = self.transaction()?;
631 let call_id = find_call(&transaction, session, source_call_id)?;
632 require_call_status(&transaction, call_id, source_call_id, "started")?;
633 validate_completion(&transaction, call_id, true)?;
634 insert_snapshot_bytes(
635 &transaction,
636 call_id,
637 "result",
638 "after",
639 "application/json",
640 captured_at_ms,
641 &body,
642 )?;
643 transaction
644 .execute(
645 "UPDATE tool_calls SET is_error = ?1, executed = 1 WHERE id = ?2",
646 params![i64::from(is_error), call_id],
647 )
648 .map_err(|error| format!("could not stage tool result: {error}"))?;
649 transaction
650 .commit()
651 .map_err(|error| format!("could not commit staged tool result: {error}"))
652 }
653
654 pub fn finish_call(
660 &mut self,
661 session: &SessionIdentity,
662 source_call_id: &str,
663 result: &Value,
664 is_error: bool,
665 require_pre_result: bool,
666 finished_at_ms: i64,
667 ) -> Result<(), String> {
668 let body = canonical_json(result)?;
669 let transaction = self.transaction()?;
670 let call_id = find_call(&transaction, session, source_call_id)?;
671 validate_completion(&transaction, call_id, require_pre_result)?;
672 insert_snapshot_bytes(
673 &transaction,
674 call_id,
675 "result",
676 "after",
677 "application/json",
678 finished_at_ms,
679 &body,
680 )?;
681 transaction
682 .execute(
683 "UPDATE tool_calls
684 SET finished_at_ms = ?1, status = 'finished', is_error = ?2, executed = ?3
685 WHERE id = ?4",
686 params![
687 finished_at_ms,
688 i64::from(is_error),
689 i64::from(require_pre_result),
690 call_id
691 ],
692 )
693 .map_err(|error| format!("could not finish tool call: {error}"))?;
694 transaction
695 .commit()
696 .map_err(|error| format!("could not commit final tool result: {error}"))
697 }
698
699 pub fn update_final_result(
705 &mut self,
706 session: &SessionIdentity,
707 source_call_id: &str,
708 result: &Value,
709 is_error: bool,
710 finished_at_ms: i64,
711 ) -> Result<(), String> {
712 let body = canonical_json(result)?;
713 let transaction = self.transaction()?;
714 let call_id = find_call(&transaction, session, source_call_id)?;
715 let status: String = transaction
716 .query_row(
717 "SELECT status FROM tool_calls WHERE id = ?1",
718 [call_id],
719 |row| row.get(0),
720 )
721 .map_err(|error| format!("could not read final tool call status: {error}"))?;
722 if status == "finished" {
723 let expected_sha = Sha256::digest(&body).to_vec();
724 let stored: (Vec<u8>, bool, i64, bool) = transaction
725 .query_row(
726 "SELECT s.payload_sha256, c.is_error, c.finished_at_ms, c.executed
727 FROM tool_calls c
728 JOIN snapshots s ON s.tool_call_id = c.id
729 WHERE c.id = ?1 AND s.subject = 'result' AND s.stage = 'after'",
730 [call_id],
731 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
732 )
733 .map_err(|error| format!("could not check finalized tool result: {error}"))?;
734 if stored == (expected_sha, is_error, finished_at_ms, true) {
735 return transaction
736 .commit()
737 .map_err(|error| format!("could not commit repeated final result: {error}"));
738 }
739 return Err(format!(
740 "tool call {source_call_id} was already finalized with different content or metadata"
741 ));
742 }
743 if status != "started" {
744 return Err(format!(
745 "tool call {source_call_id} has status {status}, expected started"
746 ));
747 }
748 validate_completion(&transaction, call_id, true)?;
749 let replaced_sha = replace_snapshot_bytes(
750 &transaction,
751 call_id,
752 "result",
753 "after",
754 "application/json",
755 finished_at_ms,
756 &body,
757 )?;
758 transaction
759 .execute(
760 "UPDATE tool_calls
761 SET finished_at_ms = ?1, status = 'finished', is_error = ?2, executed = 1
762 WHERE id = ?3",
763 params![finished_at_ms, i64::from(is_error), call_id],
764 )
765 .map_err(|error| format!("could not finalize staged tool call: {error}"))?;
766 transaction
767 .execute(
768 "DELETE FROM payloads
769 WHERE sha256 = ?1
770 AND NOT EXISTS (
771 SELECT 1 FROM snapshots WHERE snapshots.payload_sha256 = payloads.sha256
772 )",
773 [&replaced_sha],
774 )
775 .map_err(|error| format!("could not remove replaced final payload: {error}"))?;
776 transaction
777 .commit()
778 .map_err(|error| format!("could not commit final staged tool result: {error}"))
779 }
780
781 pub fn capture_streams(
787 &mut self,
788 key: &ArchiveKey,
789 captured_at_ms: i64,
790 stdout_before: &mut (impl Read + Seek),
791 stderr_before: &mut (impl Read + Seek),
792 stdout_after: &[u8],
793 stderr_after: &[u8],
794 ) -> Result<(), String> {
795 let transaction = self.transaction()?;
796 let call_id = find_call(&transaction, &key.session, &key.source_call_id)?;
797 insert_snapshot_reader(
798 &transaction,
799 call_id,
800 "stdout",
801 "before",
802 stream_media_type(stdout_before)?,
803 captured_at_ms,
804 stdout_before,
805 )?;
806 insert_snapshot_reader(
807 &transaction,
808 call_id,
809 "stderr",
810 "before",
811 stream_media_type(stderr_before)?,
812 captured_at_ms,
813 stderr_before,
814 )?;
815 insert_snapshot_bytes(
816 &transaction,
817 call_id,
818 "stdout",
819 "after",
820 stream_media_type(&mut Cursor::new(stdout_after))?,
821 captured_at_ms,
822 stdout_after,
823 )?;
824 insert_snapshot_bytes(
825 &transaction,
826 call_id,
827 "stderr",
828 "after",
829 stream_media_type(&mut Cursor::new(stderr_after))?,
830 captured_at_ms,
831 stderr_after,
832 )?;
833 transaction
834 .commit()
835 .map_err(|error| format!("could not commit shell streams: {error}"))
836 }
837
838 pub fn capture_passthrough_streams(
844 &mut self,
845 key: &ArchiveKey,
846 captured_at_ms: i64,
847 stdout: &mut (impl Read + Seek),
848 stderr: &mut (impl Read + Seek),
849 ) -> Result<(), String> {
850 let transaction = self.transaction()?;
851 let call_id = find_call(&transaction, &key.session, &key.source_call_id)?;
852 let stdout_type = stream_media_type(stdout)?;
853 insert_snapshot_reader(
854 &transaction,
855 call_id,
856 "stdout",
857 "before",
858 stdout_type,
859 captured_at_ms,
860 stdout,
861 )?;
862 insert_snapshot_reader(
863 &transaction,
864 call_id,
865 "stdout",
866 "after",
867 stdout_type,
868 captured_at_ms,
869 stdout,
870 )?;
871 let stderr_type = stream_media_type(stderr)?;
872 insert_snapshot_reader(
873 &transaction,
874 call_id,
875 "stderr",
876 "before",
877 stderr_type,
878 captured_at_ms,
879 stderr,
880 )?;
881 insert_snapshot_reader(
882 &transaction,
883 call_id,
884 "stderr",
885 "after",
886 stderr_type,
887 captured_at_ms,
888 stderr,
889 )?;
890 transaction
891 .commit()
892 .map_err(|error| format!("could not commit passthrough shell streams: {error}"))
893 }
894
895 pub fn restore_streams(
901 &self,
902 key: &ArchiveKey,
903 mut stdout: impl Write,
904 mut stderr: impl Write,
905 ) -> Result<(), String> {
906 let call_id: i64 = self
907 .connection
908 .query_row(
909 "SELECT c.id
910 FROM tool_calls c
911 JOIN sessions s ON s.id = c.session_id
912 WHERE s.agent = ?1 AND s.account = ?2 AND s.source_session_id = ?3
913 AND c.source_call_id = ?4",
914 params![
915 key.session.agent,
916 key.session.account,
917 key.session.source_session_id,
918 key.source_call_id
919 ],
920 |row| row.get(0),
921 )
922 .map_err(|error| format!("could not find tool call {}: {error}", key.source_call_id))?;
923 let mut restored_stdout = self.verified_snapshot(call_id, "stdout", "before")?;
924 let mut restored_stderr = self.verified_snapshot(call_id, "stderr", "before")?;
925 io::copy(&mut restored_stdout, &mut stdout)
926 .map_err(|error| format!("could not write restored stdout: {error}"))?;
927 io::copy(&mut restored_stderr, &mut stderr)
928 .map_err(|error| format!("could not write restored stderr: {error}"))?;
929 Ok(())
930 }
931
932 pub fn stats(&self) -> Result<ArchiveStats, String> {
938 let row = self
939 .connection
940 .query_row(
941 "SELECT
942 (SELECT count(*) FROM sessions),
943 (SELECT count(*) FROM tool_calls),
944 (SELECT count(*) FROM tool_calls WHERE status = 'started'),
945 (SELECT coalesce(sum(uncompressed_byte_length), 0) FROM payloads),
946 (SELECT coalesce(sum(length(body)), 0) FROM payloads),
947 (SELECT min(started_at_ms) FROM tool_calls),
948 (SELECT max(started_at_ms) FROM tool_calls)",
949 [],
950 |row| {
951 Ok((
952 row.get(0)?,
953 row.get(1)?,
954 row.get(2)?,
955 row.get(3)?,
956 row.get(4)?,
957 row.get(5)?,
958 row.get(6)?,
959 ))
960 },
961 )
962 .map_err(|error| format!("could not read archive statistics: {error}"))?;
963 let database_bytes = archive_file_bytes(&self.path)?;
964 Ok(ArchiveStats {
965 sessions: row.0,
966 calls: row.1,
967 incomplete_calls: row.2,
968 logical_payload_bytes: row.3,
969 stored_payload_bytes: row.4,
970 oldest_call_ms: row.5,
971 newest_call_ms: row.6,
972 database_bytes,
973 })
974 }
975
976 pub fn verify(&self) -> Result<VerifyReport, String> {
982 let mut report = VerifyReport::default();
983 let integrity: String = self
984 .connection
985 .query_row("PRAGMA integrity_check", [], |row| row.get(0))
986 .map_err(|error| format!("could not run SQLite integrity check: {error}"))?;
987 if integrity != "ok" {
988 report
989 .errors
990 .push(format!("SQLite integrity check: {integrity}"));
991 }
992 let foreign_keys: i64 = self
993 .connection
994 .query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
995 row.get(0)
996 })
997 .map_err(|error| format!("could not check archive foreign keys: {error}"))?;
998 if foreign_keys > 0 {
999 report
1000 .errors
1001 .push(format!("{foreign_keys} foreign key violation(s)"));
1002 }
1003 let version: i64 = self
1004 .connection
1005 .pragma_query_value(None, "user_version", |row| row.get(0))
1006 .map_err(|error| format!("could not read archive schema version: {error}"))?;
1007 if version != SCHEMA_VERSION {
1008 report.errors.push(format!(
1009 "archive schema version {version}, expected {SCHEMA_VERSION}"
1010 ));
1011 }
1012 report.incomplete_calls = self
1013 .connection
1014 .query_row(
1015 "SELECT count(*) FROM tool_calls WHERE status = 'started'",
1016 [],
1017 |row| row.get(0),
1018 )
1019 .map_err(|error| format!("could not count incomplete calls: {error}"))?;
1020 self.verify_constrained_values(&mut report)?;
1021 self.verify_snapshot_sets(&mut report)?;
1022 let orphaned: i64 = self
1023 .connection
1024 .query_row(
1025 "SELECT count(*) FROM payloads p WHERE NOT EXISTS (SELECT 1 FROM snapshots s WHERE s.payload_sha256 = p.sha256)",
1026 [],
1027 |row| row.get(0),
1028 )
1029 .map_err(|error| format!("could not check unreferenced payloads: {error}"))?;
1030 if orphaned > 0 {
1031 report
1032 .errors
1033 .push(format!("{orphaned} unreferenced payload(s)"));
1034 }
1035 self.verify_payloads(&mut report)?;
1036 verify_permissions(&self.path, &mut report)?;
1037 Ok(report)
1038 }
1039
1040 pub fn prune_before(&mut self, timestamp_ms: i64) -> Result<i64, String> {
1046 let transaction = self.transaction()?;
1047 let deleted = transaction
1048 .execute(
1049 "DELETE FROM tool_calls WHERE status = 'finished' AND finished_at_ms < ?1",
1050 [timestamp_ms],
1051 )
1052 .map_err(|error| format!("could not prune tool calls: {error}"))?;
1053 transaction
1054 .execute(
1055 "DELETE FROM payloads WHERE NOT EXISTS (SELECT 1 FROM snapshots WHERE snapshots.payload_sha256 = payloads.sha256)",
1056 [],
1057 )
1058 .map_err(|error| format!("could not prune unreferenced payloads: {error}"))?;
1059 transaction
1060 .commit()
1061 .map_err(|error| format!("could not commit archive prune: {error}"))?;
1062 self.connection
1063 .execute_batch("PRAGMA incremental_vacuum")
1064 .map_err(|error| format!("could not vacuum archive: {error}"))?;
1065 i64::try_from(deleted).map_err(|_| "pruned call count does not fit in i64".to_owned())
1066 }
1067
1068 fn verify_constrained_values(&self, report: &mut VerifyReport) -> Result<(), String> {
1069 let checks = [
1070 (
1071 "SELECT count(*) FROM tool_calls
1072 WHERE status NOT IN ('started', 'finished')",
1073 "tool call(s) with invalid status",
1074 ),
1075 (
1076 "SELECT count(*) FROM tool_calls
1077 WHERE requires_streams NOT IN (0, 1)",
1078 "tool call(s) with invalid requires_streams",
1079 ),
1080 (
1081 "SELECT count(*) FROM tool_calls
1082 WHERE is_error IS NOT NULL AND is_error NOT IN (0, 1)",
1083 "tool call(s) with invalid is_error",
1084 ),
1085 (
1086 "SELECT count(*) FROM tool_calls
1087 WHERE executed IS NOT NULL AND executed NOT IN (0, 1)",
1088 "tool call(s) with invalid executed",
1089 ),
1090 (
1091 "SELECT count(*) FROM tool_calls
1092 WHERE (status = 'started' AND (
1093 finished_at_ms IS NOT NULL
1094 OR (is_error IS NULL) != (executed IS NULL)
1095 OR executed = 0
1096 ))
1097 OR (status = 'finished' AND (finished_at_ms IS NULL OR is_error IS NULL OR executed IS NULL))",
1098 "tool call(s) with inconsistent lifecycle state",
1099 ),
1100 (
1101 "SELECT count(*) FROM snapshots
1102 WHERE subject NOT IN ('input', 'result', 'result_text', 'source_output', 'stdout', 'stderr')",
1103 "snapshot(s) with invalid subject",
1104 ),
1105 (
1106 "SELECT count(*) FROM snapshots
1107 WHERE (subject = 'result_text' AND (stage != 'before' OR source_completeness NOT IN ('complete', 'incomplete', 'unknown')))
1108 OR (subject != 'result_text' AND source_completeness IS NOT NULL)",
1109 "snapshot(s) with invalid source completeness",
1110 ),
1111 (
1112 "SELECT count(*) FROM snapshots WHERE stage NOT IN ('before', 'after')",
1113 "snapshot(s) with invalid stage",
1114 ),
1115 (
1116 "SELECT count(*) FROM snapshots
1117 WHERE (subject IN ('input', 'result') AND media_type != 'application/json')
1118 OR (subject = 'result_text' AND media_type != 'text/plain; charset=utf-8')
1119 OR (subject IN ('source_output', 'stdout', 'stderr') AND media_type NOT IN ('text/plain; charset=utf-8', 'application/octet-stream'))",
1120 "snapshot(s) with invalid media type",
1121 ),
1122 (
1123 "SELECT count(*) FROM tool_calls
1124 WHERE length(archive_ref) != 35
1125 OR substr(archive_ref, 1, 3) != 'yr_'
1126 OR substr(archive_ref, 4) GLOB '*[^0-9a-f]*'",
1127 "tool call(s) with invalid archive reference",
1128 ),
1129 (
1130 "SELECT count(*) FROM payloads
1131 WHERE length(sha256) != 32
1132 OR compression NOT IN ('none', 'zstd')
1133 OR uncompressed_byte_length < 0",
1134 "payload(s) with invalid constrained values",
1135 ),
1136 ];
1137 for (query, message) in checks {
1138 let invalid: i64 = self
1139 .connection
1140 .query_row(query, [], |row| row.get(0))
1141 .map_err(|error| format!("could not verify constrained values: {error}"))?;
1142 if invalid > 0 {
1143 report.errors.push(format!("{invalid} {message}"));
1144 }
1145 }
1146 Ok(())
1147 }
1148
1149 fn verify_snapshot_sets(&self, report: &mut VerifyReport) -> Result<(), String> {
1150 let checks = [
1151 (
1152 "SELECT count(*) FROM tool_calls c
1153 WHERE NOT EXISTS (SELECT 1 FROM snapshots s WHERE s.tool_call_id = c.id AND s.subject = 'input' AND s.stage = 'before')
1154 OR NOT EXISTS (SELECT 1 FROM snapshots s WHERE s.tool_call_id = c.id AND s.subject = 'input' AND s.stage = 'after')",
1155 "call(s) missing input snapshots",
1156 ),
1157 (
1158 "SELECT count(*) FROM tool_calls c
1159 WHERE c.status = 'finished'
1160 AND NOT EXISTS (SELECT 1 FROM snapshots s WHERE s.tool_call_id = c.id AND s.subject = 'result' AND s.stage = 'after')",
1161 "finished call(s) missing final results",
1162 ),
1163 (
1164 "SELECT count(*) FROM tool_calls c
1165 WHERE c.status = 'finished' AND c.executed = 1
1166 AND NOT EXISTS (
1167 SELECT 1 FROM snapshots s
1168 WHERE s.tool_call_id = c.id AND s.subject = 'result' AND s.stage = 'before'
1169 )",
1170 "executed call(s) missing pre-YARP results",
1171 ),
1172 (
1173 "SELECT count(*) FROM tool_calls c
1174 WHERE c.status = 'finished' AND c.requires_streams = 1 AND c.executed = 1
1175 AND 4 != (
1176 SELECT count(*) FROM snapshots s
1177 WHERE s.tool_call_id = c.id
1178 AND s.subject IN ('stdout', 'stderr')
1179 AND s.stage IN ('before', 'after')
1180 )",
1181 "shell call(s) missing complete stream snapshots",
1182 ),
1183 ];
1184 for (query, message) in checks {
1185 let missing: i64 = self
1186 .connection
1187 .query_row(query, [], |row| row.get(0))
1188 .map_err(|error| format!("could not verify snapshot sets: {error}"))?;
1189 if missing > 0 {
1190 report.errors.push(format!("{missing} {message}"));
1191 }
1192 }
1193 Ok(())
1194 }
1195
1196 fn snapshot_exists(&self, call_id: i64, subject: &str, stage: &str) -> Result<bool, String> {
1197 self.connection
1198 .query_row(
1199 "SELECT EXISTS(
1200 SELECT 1 FROM snapshots
1201 WHERE tool_call_id = ?1 AND subject = ?2 AND stage = ?3
1202 )",
1203 params![call_id, subject, stage],
1204 |row| row.get(0),
1205 )
1206 .map_err(|error| format!("could not inspect {subject}/{stage} snapshot: {error}"))
1207 }
1208
1209 fn verified_source(
1210 &self,
1211 call_id: i64,
1212 name: SourceName,
1213 completeness: SourceCompleteness,
1214 ) -> Result<VerifiedSource, String> {
1215 let subject = name.as_str();
1216 let (media_type, byte_length): (String, i64) = self
1217 .connection
1218 .query_row(
1219 "SELECT s.media_type, p.uncompressed_byte_length
1220 FROM snapshots s
1221 JOIN payloads p ON p.sha256 = s.payload_sha256
1222 WHERE s.tool_call_id = ?1 AND s.subject = ?2 AND s.stage = 'before'",
1223 params![call_id, subject],
1224 |row| Ok((row.get(0)?, row.get(1)?)),
1225 )
1226 .map_err(|error| format!("could not inspect {subject}/before snapshot: {error}"))?;
1227 let byte_length = u64::try_from(byte_length)
1228 .map_err(|_| format!("invalid {subject}/before byte length"))?;
1229 let body = self.verified_snapshot(call_id, subject, "before")?;
1230 Ok(VerifiedSource {
1231 name,
1232 completeness,
1233 media_type,
1234 body,
1235 byte_length,
1236 })
1237 }
1238
1239 fn verified_snapshot(
1240 &self,
1241 call_id: i64,
1242 subject: &str,
1243 stage: &str,
1244 ) -> Result<std::fs::File, String> {
1245 let (rowid, expected_sha, compression, expected_length): (i64, Vec<u8>, String, i64) = self
1246 .connection
1247 .query_row(
1248 "SELECT p.rowid, p.sha256, p.compression, p.uncompressed_byte_length
1249 FROM snapshots s
1250 JOIN payloads p ON p.sha256 = s.payload_sha256
1251 WHERE s.tool_call_id = ?1 AND s.subject = ?2 AND s.stage = ?3",
1252 params![call_id, subject, stage],
1253 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1254 )
1255 .map_err(|error| format!("could not find {subject}/{stage} snapshot: {error}"))?;
1256 let blob = self
1257 .connection
1258 .blob_open(MAIN_DB, "payloads", "body", rowid, true)
1259 .map_err(|error| format!("could not open {subject}/{stage} payload: {error}"))?;
1260 let mut reader: Box<dyn Read> = match compression.as_str() {
1261 "none" => Box::new(blob),
1262 "zstd" => Box::new(
1263 zstd::stream::read::Decoder::new(blob)
1264 .map_err(|error| format!("could not decode {subject}/{stage}: {error}"))?,
1265 ),
1266 unknown => return Err(format!("unknown payload compression {unknown}")),
1267 };
1268 let expected_length = u64::try_from(expected_length)
1269 .map_err(|_| format!("invalid {subject}/{stage} payload length {expected_length}"))?;
1270 let mut restored = tempfile().map_err(|error| {
1271 format!("could not create {subject}/{stage} restore spool: {error}")
1272 })?;
1273 let mut hasher = Sha256::new();
1274 let mut length = 0_u64;
1275 let mut buffer = vec![0_u8; 64 * 1024];
1276 loop {
1277 let count = reader
1278 .read(&mut buffer)
1279 .map_err(|error| format!("could not read {subject}/{stage} payload: {error}"))?;
1280 if count == 0 {
1281 break;
1282 }
1283 restored
1284 .write_all(&buffer[..count])
1285 .map_err(|error| format!("could not spool {subject}/{stage} payload: {error}"))?;
1286 hasher.update(&buffer[..count]);
1287 length = length.saturating_add(count as u64);
1288 }
1289 let actual_sha: [u8; 32] = hasher.finalize().into();
1290 if length != expected_length || actual_sha.as_slice() != expected_sha {
1291 return Err(format!(
1292 "{subject}/{stage} payload failed integrity verification"
1293 ));
1294 }
1295 restored.seek(SeekFrom::Start(0)).map_err(|error| {
1296 format!("could not rewind {subject}/{stage} restore spool: {error}")
1297 })?;
1298 Ok(restored)
1299 }
1300
1301 fn transaction(&mut self) -> Result<Transaction<'_>, String> {
1302 self.connection
1303 .transaction_with_behavior(TransactionBehavior::Immediate)
1304 .map_err(|error| format!("could not start archive transaction: {error}"))
1305 }
1306
1307 fn verify_payloads(&self, report: &mut VerifyReport) -> Result<(), String> {
1308 let mut statement = self
1309 .connection
1310 .prepare(
1311 "SELECT rowid, sha256, compression, uncompressed_byte_length, length(body) FROM payloads",
1312 )
1313 .map_err(|error| format!("could not prepare payload verification: {error}"))?;
1314 let rows = statement
1315 .query_map([], |row| {
1316 Ok((
1317 row.get::<_, i64>(0)?,
1318 row.get::<_, Vec<u8>>(1)?,
1319 row.get::<_, String>(2)?,
1320 row.get::<_, i64>(3)?,
1321 row.get::<_, i64>(4)?,
1322 ))
1323 })
1324 .map_err(|error| format!("could not list archive payloads: {error}"))?
1325 .collect::<Result<Vec<_>, _>>()
1326 .map_err(|error| format!("could not read archive payload metadata: {error}"))?;
1327 drop(statement);
1328
1329 for (rowid, expected_sha, compression, expected_length, _) in rows {
1330 let blob = self
1331 .connection
1332 .blob_open(MAIN_DB, "payloads", "body", rowid, true)
1333 .map_err(|error| format!("could not open payload {rowid}: {error}"))?;
1334 let mut reader: Box<dyn Read> = match compression.as_str() {
1335 "none" => Box::new(blob),
1336 "zstd" => Box::new(
1337 zstd::stream::read::Decoder::new(blob)
1338 .map_err(|error| format!("could not decode payload {rowid}: {error}"))?,
1339 ),
1340 unknown => {
1341 report
1342 .errors
1343 .push(format!("payload {rowid} has unknown compression {unknown}"));
1344 continue;
1345 }
1346 };
1347 let mut hasher = Sha256::new();
1348 let mut length = 0_u64;
1349 let mut buffer = vec![0_u8; 64 * 1024];
1350 loop {
1351 let count = reader
1352 .read(&mut buffer)
1353 .map_err(|error| format!("could not read payload {rowid}: {error}"))?;
1354 if count == 0 {
1355 break;
1356 }
1357 hasher.update(&buffer[..count]);
1358 length = length.saturating_add(count as u64);
1359 }
1360 if length != u64::try_from(expected_length).unwrap_or(u64::MAX) {
1361 report.errors.push(format!(
1362 "payload {rowid} length {length}, expected {expected_length}"
1363 ));
1364 }
1365 if hasher.finalize().as_slice() != expected_sha {
1366 report
1367 .errors
1368 .push(format!("payload {rowid} SHA-256 mismatch"));
1369 }
1370 }
1371 Ok(())
1372 }
1373}
1374
1375pub fn run_ingest(input: impl Read, output: impl Write) -> Result<(), String> {
1381 run_ingest_with_archive(Archive::open()?, input, output)
1382}
1383
1384fn run_ingest_with_archive(
1385 mut archive: Archive,
1386 mut input: impl Read,
1387 mut output: impl Write,
1388) -> Result<(), String> {
1389 loop {
1390 let mut length_bytes = [0_u8; 8];
1391 match input.read(&mut length_bytes[..1]) {
1392 Ok(0) => return Ok(()),
1393 Ok(1) => {}
1394 Ok(_) => unreachable!("one-byte read returned more than one byte"),
1395 Err(error) => return Err(format!("could not read ingest frame length: {error}")),
1396 }
1397 input
1398 .read_exact(&mut length_bytes[1..])
1399 .map_err(|error| format!("truncated ingest frame length: {error}"))?;
1400 let length = u64::from_be_bytes(length_bytes);
1401 if length == 0 || length > MAX_FRAME_BYTES {
1402 return Err(format!("invalid ingest frame length {length}"));
1403 }
1404 let frame_length = usize::try_from(length)
1405 .map_err(|_| format!("ingest frame length {length} does not fit in memory"))?;
1406 let mut frame = vec![0_u8; frame_length];
1407 input
1408 .read_exact(&mut frame)
1409 .map_err(|error| format!("could not read ingest frame: {error}"))?;
1410 let operation: IngestOperation = serde_json::from_slice(&frame)
1411 .map_err(|error| format!("invalid ingest frame: {error}"))?;
1412 let request_id = operation.request_id();
1413 let result = apply_operation(&mut archive, operation);
1414 let archive_ref = result.as_ref().ok().and_then(|value| value.as_deref());
1415 let error = result.as_ref().err().map(String::as_str);
1416 let ack = IngestAck {
1417 request_id,
1418 ok: result.is_ok(),
1419 archive_ref,
1420 error,
1421 };
1422 serde_json::to_writer(&mut output, &ack)
1423 .map_err(|error| format!("could not write ingest acknowledgement: {error}"))?;
1424 output
1425 .write_all(b"\n")
1426 .and_then(|()| output.flush())
1427 .map_err(|error| format!("could not flush ingest acknowledgement: {error}"))?;
1428 }
1429}
1430
1431fn apply_operation(
1432 archive: &mut Archive,
1433 operation: IngestOperation,
1434) -> Result<Option<String>, String> {
1435 let schema_version = operation.schema_version();
1436 if schema_version != INGEST_SCHEMA_VERSION {
1437 return Err(format!(
1438 "unsupported ingest schema version {schema_version}; expected {INGEST_SCHEMA_VERSION}"
1439 ));
1440 }
1441 match operation {
1442 IngestOperation::BeginCall {
1443 session,
1444 call,
1445 input_before,
1446 input_after,
1447 captured_at_ms,
1448 ..
1449 } => archive
1450 .begin_call(&session, &call, &input_before, &input_after, captured_at_ms)
1451 .map(Some),
1452 IngestOperation::ResultBefore {
1453 session,
1454 source_call_id,
1455 result,
1456 full_output_path,
1457 captured_at_ms,
1458 ..
1459 } => archive
1460 .result_before(
1461 &session,
1462 &source_call_id,
1463 &result,
1464 full_output_path.as_deref(),
1465 captured_at_ms,
1466 )
1467 .map(|()| None),
1468 IngestOperation::ResultText {
1469 session,
1470 source_call_id,
1471 text,
1472 source_completeness,
1473 captured_at_ms,
1474 ..
1475 } => archive
1476 .result_text(
1477 &session,
1478 &source_call_id,
1479 &text,
1480 source_completeness,
1481 captured_at_ms,
1482 )
1483 .map(Some),
1484 IngestOperation::StageResult {
1485 session,
1486 source_call_id,
1487 result,
1488 is_error,
1489 captured_at_ms,
1490 ..
1491 } => archive
1492 .stage_result(&session, &source_call_id, &result, is_error, captured_at_ms)
1493 .map(|()| None),
1494 IngestOperation::FinishCall {
1495 session,
1496 source_call_id,
1497 result,
1498 is_error,
1499 require_pre_result,
1500 finished_at_ms,
1501 ..
1502 } => archive
1503 .finish_call(
1504 &session,
1505 &source_call_id,
1506 &result,
1507 is_error,
1508 require_pre_result,
1509 finished_at_ms,
1510 )
1511 .map(|()| None),
1512 IngestOperation::UpdateFinalResult {
1513 session,
1514 source_call_id,
1515 result,
1516 is_error,
1517 finished_at_ms,
1518 ..
1519 } => archive
1520 .update_final_result(&session, &source_call_id, &result, is_error, finished_at_ms)
1521 .map(|()| None),
1522 }
1523}
1524
1525pub fn archive_path() -> Result<PathBuf, String> {
1531 config::load().map(|resolved| resolved.archive.path)
1532}
1533
1534fn enable_wal_mode(connection: &Connection) -> Result<(), String> {
1535 const ATTEMPT_TIMEOUT: Duration = Duration::from_millis(25);
1536 const RETRY_DELAY: Duration = Duration::from_millis(10);
1537 connection
1538 .busy_timeout(ATTEMPT_TIMEOUT)
1539 .map_err(|error| format!("could not set WAL retry timeout: {error}"))?;
1540 let deadline = Instant::now() + ARCHIVE_BUSY_TIMEOUT;
1541 loop {
1542 match connection.pragma_update(None, "journal_mode", "WAL") {
1543 Ok(()) => {
1544 connection
1545 .busy_timeout(ARCHIVE_BUSY_TIMEOUT)
1546 .map_err(|error| format!("could not restore archive busy timeout: {error}"))?;
1547 return Ok(());
1548 }
1549 Err(error) if is_lock_error(&error) && Instant::now() < deadline => {
1550 std::thread::sleep(RETRY_DELAY);
1551 }
1552 Err(error) => return Err(format!("could not enable archive WAL mode: {error}")),
1553 }
1554 }
1555}
1556
1557fn is_lock_error(error: &rusqlite::Error) -> bool {
1558 matches!(
1559 error,
1560 rusqlite::Error::SqliteFailure(inner, _)
1561 if matches!(inner.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
1562 )
1563}
1564
1565fn initialize_schema(connection: &mut Connection) -> Result<(), String> {
1566 connection
1567 .pragma_update(None, "auto_vacuum", "INCREMENTAL")
1568 .map_err(|error| format!("could not enable incremental auto-vacuum: {error}"))?;
1569 let transaction = connection
1570 .transaction_with_behavior(TransactionBehavior::Exclusive)
1571 .map_err(|error| format!("could not start archive migration: {error}"))?;
1572 let version: i64 = transaction
1573 .pragma_query_value(None, "user_version", |row| row.get(0))
1574 .map_err(|error| format!("could not recheck archive schema version: {error}"))?;
1575 match version {
1576 0 => {
1577 transaction
1578 .execute_batch(SCHEMA)
1579 .map_err(|error| format!("could not create archive schema: {error}"))?;
1580 transaction
1581 .pragma_update(None, "user_version", SCHEMA_VERSION)
1582 .map_err(|error| format!("could not set archive schema version: {error}"))?;
1583 }
1584 SCHEMA_VERSION => {}
1585 other => {
1586 return Err(format!(
1587 "archive schema version changed to {other} while initializing; expected 0 or {SCHEMA_VERSION}"
1588 ));
1589 }
1590 }
1591 transaction
1592 .commit()
1593 .map_err(|error| format!("could not commit archive schema: {error}"))
1594}
1595
1596fn schema_is_current(connection: &Connection) -> Result<bool, String> {
1597 let archive_ref = table_has_column(connection, "tool_calls", "archive_ref")?;
1598 let source_completeness = table_has_column(connection, "snapshots", "source_completeness")?;
1599 if archive_ref != source_completeness {
1600 return Err("archive schema has a partial indexed-output migration".to_owned());
1601 }
1602 if !archive_ref {
1603 return Ok(false);
1604 }
1605 let snapshots_sql: String = connection
1606 .query_row(
1607 "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'snapshots'",
1608 [],
1609 |row| row.get(0),
1610 )
1611 .map_err(|error| format!("could not inspect snapshots schema: {error}"))?;
1612 Ok(snapshots_sql.contains("result_text") && snapshots_sql.contains("source_completeness"))
1613}
1614
1615fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool, String> {
1616 let query = format!("SELECT count(*) FROM pragma_table_info('{table}') WHERE name = ?1");
1617 connection
1618 .query_row(&query, [column], |row| row.get::<_, i64>(0))
1619 .map(|count| count == 1)
1620 .map_err(|error| format!("could not inspect {table}.{column}: {error}"))
1621}
1622
1623fn ensure_current_schema(connection: &mut Connection, path: &Path) -> Result<(), String> {
1624 if schema_is_current(connection)? {
1625 return Ok(());
1626 }
1627 create_migration_backup(connection, path)?;
1628 migrate_indexed_output_schema(connection)
1629}
1630
1631fn create_migration_backup(connection: &Connection, path: &Path) -> Result<(), String> {
1632 let parent = path
1633 .parent()
1634 .ok_or_else(|| "archive path has no parent for migration backup".to_owned())?;
1635 let required = archive_file_bytes(path)?.saturating_mul(2);
1636 let available = fs2::available_space(parent)
1637 .map_err(|error| format!("could not measure archive backup space: {error}"))?;
1638 if available < required {
1639 return Err(format!(
1640 "archive migration needs at least {required} free bytes, found {available}"
1641 ));
1642 }
1643 let mut backup_name = path
1644 .file_name()
1645 .ok_or_else(|| "archive path has no file name".to_owned())?
1646 .to_os_string();
1647 backup_name.push(".pre-indexed-output-v1.backup");
1648 let backup_path = parent.join(backup_name);
1649 if backup_path.exists() {
1650 return Err(format!(
1651 "archive migration backup already exists: {}",
1652 backup_path.display()
1653 ));
1654 }
1655 let temporary = NamedTempFile::new_in(parent)
1656 .map_err(|error| format!("could not create migration backup: {error}"))?;
1657 set_file_mode(temporary.path(), 0o600)?;
1658 let mut destination = Connection::open(temporary.path())
1659 .map_err(|error| format!("could not open migration backup: {error}"))?;
1660 let backup = Backup::new(connection, &mut destination)
1661 .map_err(|error| format!("could not start archive backup: {error}"))?;
1662 backup
1663 .run_to_completion(128, Duration::from_millis(10), None)
1664 .map_err(|error| format!("could not write archive backup: {error}"))?;
1665 drop(backup);
1666 let integrity: String = destination
1667 .query_row("PRAGMA integrity_check", [], |row| row.get(0))
1668 .map_err(|error| format!("could not verify migration backup: {error}"))?;
1669 if integrity != "ok" {
1670 return Err(format!(
1671 "migration backup integrity check failed: {integrity}"
1672 ));
1673 }
1674 destination
1675 .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")
1676 .map_err(|error| format!("could not checkpoint migration backup: {error}"))?;
1677 drop(destination);
1678 temporary
1679 .as_file()
1680 .sync_all()
1681 .map_err(|error| format!("could not sync migration backup: {error}"))?;
1682 temporary
1683 .persist(&backup_path)
1684 .map_err(|error| format!("could not install migration backup: {}", error.error))?;
1685 set_file_mode(&backup_path, 0o600)?;
1686 sync_directory(parent)
1687}
1688
1689#[expect(
1690 clippy::too_many_lines,
1691 reason = "the migration is one ordered transaction with shared invariants and rollback"
1692)]
1693fn migrate_indexed_output_schema(connection: &mut Connection) -> Result<(), String> {
1694 let transaction = connection
1695 .transaction_with_behavior(TransactionBehavior::Exclusive)
1696 .map_err(|error| format!("could not start indexed-output migration: {error}"))?;
1697 if table_has_column(&transaction, "tool_calls", "archive_ref")?
1698 || table_has_column(&transaction, "snapshots", "source_completeness")?
1699 {
1700 return Err("archive schema changed while preparing indexed-output migration".to_owned());
1701 }
1702 let calls_before: i64 = transaction
1703 .query_row("SELECT count(*) FROM tool_calls", [], |row| row.get(0))
1704 .map_err(|error| format!("could not count calls before migration: {error}"))?;
1705 let snapshots_before: i64 = transaction
1706 .query_row("SELECT count(*) FROM snapshots", [], |row| row.get(0))
1707 .map_err(|error| format!("could not count snapshots before migration: {error}"))?;
1708 transaction
1709 .execute_batch("ALTER TABLE tool_calls ADD COLUMN archive_ref TEXT")
1710 .map_err(|error| format!("could not add archive references: {error}"))?;
1711 transaction
1712 .execute(
1713 "UPDATE tool_calls SET archive_ref = 'yr_' || lower(hex(randomblob(16)))",
1714 [],
1715 )
1716 .map_err(|error| format!("could not backfill archive references: {error}"))?;
1717 for _ in 0..3 {
1718 let duplicates: i64 = transaction
1719 .query_row(
1720 "SELECT count(*) - count(DISTINCT archive_ref) FROM tool_calls",
1721 [],
1722 |row| row.get(0),
1723 )
1724 .map_err(|error| format!("could not check archive reference collisions: {error}"))?;
1725 if duplicates == 0 {
1726 break;
1727 }
1728 transaction
1729 .execute(
1730 "UPDATE tool_calls
1731 SET archive_ref = 'yr_' || lower(hex(randomblob(16)))
1732 WHERE id IN (
1733 SELECT later.id
1734 FROM tool_calls later
1735 JOIN tool_calls earlier
1736 ON earlier.archive_ref = later.archive_ref AND earlier.id < later.id
1737 )",
1738 [],
1739 )
1740 .map_err(|error| format!("could not retry archive reference collisions: {error}"))?;
1741 }
1742 let duplicates: i64 = transaction
1743 .query_row(
1744 "SELECT count(*) - count(DISTINCT archive_ref) FROM tool_calls",
1745 [],
1746 |row| row.get(0),
1747 )
1748 .map_err(|error| format!("could not verify archive reference uniqueness: {error}"))?;
1749 if duplicates != 0 {
1750 return Err("archive reference collisions remained after three retries".to_owned());
1751 }
1752 transaction
1753 .execute_batch(
1754 "CREATE TABLE tool_calls_new (
1755 id INTEGER PRIMARY KEY,
1756 session_id INTEGER NOT NULL REFERENCES sessions(id),
1757 source_call_id TEXT NOT NULL,
1758 archive_ref TEXT NOT NULL UNIQUE CHECK (
1759 length(archive_ref) = 35
1760 AND substr(archive_ref, 1, 3) = 'yr_'
1761 AND substr(archive_ref, 4) NOT GLOB '*[^0-9a-f]*'
1762 ),
1763 tool_name TEXT NOT NULL,
1764 provider TEXT,
1765 model TEXT,
1766 working_directory TEXT,
1767 started_at_ms INTEGER NOT NULL,
1768 requires_streams INTEGER NOT NULL CHECK (requires_streams IN (0, 1)),
1769 finished_at_ms INTEGER,
1770 status TEXT NOT NULL CHECK (status IN ('started', 'finished')),
1771 is_error INTEGER CHECK (is_error IN (0, 1)),
1772 executed INTEGER CHECK (executed IN (0, 1)),
1773 UNIQUE (session_id, source_call_id)
1774 );
1775 INSERT INTO tool_calls_new
1776 SELECT id, session_id, source_call_id, archive_ref, tool_name, provider, model,
1777 working_directory, started_at_ms, requires_streams, finished_at_ms, status,
1778 is_error, executed
1779 FROM tool_calls;
1780 CREATE TABLE snapshots_new (
1781 tool_call_id INTEGER NOT NULL REFERENCES tool_calls_new(id) ON DELETE CASCADE,
1782 subject TEXT NOT NULL CHECK (subject IN ('input', 'result', 'result_text', 'source_output', 'stdout', 'stderr')),
1783 stage TEXT NOT NULL CHECK (stage IN ('before', 'after')),
1784 media_type TEXT NOT NULL,
1785 source_completeness TEXT CHECK (source_completeness IN ('complete', 'incomplete', 'unknown')),
1786 captured_at_ms INTEGER NOT NULL,
1787 payload_sha256 BLOB NOT NULL REFERENCES payloads(sha256),
1788 PRIMARY KEY (tool_call_id, subject, stage),
1789 CHECK (
1790 (subject = 'result_text' AND stage = 'before' AND source_completeness IS NOT NULL)
1791 OR (subject <> 'result_text' AND source_completeness IS NULL)
1792 )
1793 );
1794 INSERT INTO snapshots_new (
1795 tool_call_id, subject, stage, media_type, source_completeness,
1796 captured_at_ms, payload_sha256
1797 )
1798 SELECT tool_call_id, subject, stage, media_type, NULL,
1799 captured_at_ms, payload_sha256
1800 FROM snapshots;
1801 DROP TABLE snapshots;
1802 DROP TABLE tool_calls;
1803 ALTER TABLE tool_calls_new RENAME TO tool_calls;
1804 ALTER TABLE snapshots_new RENAME TO snapshots;
1805 CREATE INDEX tool_calls_started_at_idx ON tool_calls(started_at_ms);
1806 CREATE INDEX tool_calls_tool_name_idx ON tool_calls(tool_name);
1807 CREATE INDEX snapshots_payload_idx ON snapshots(payload_sha256);",
1808 )
1809 .map_err(|error| format!("could not rebuild indexed-output tables: {error}"))?;
1810 let calls_after: i64 = transaction
1811 .query_row("SELECT count(*) FROM tool_calls", [], |row| row.get(0))
1812 .map_err(|error| format!("could not count calls after migration: {error}"))?;
1813 let snapshots_after: i64 = transaction
1814 .query_row("SELECT count(*) FROM snapshots", [], |row| row.get(0))
1815 .map_err(|error| format!("could not count snapshots after migration: {error}"))?;
1816 if calls_before != calls_after || snapshots_before != snapshots_after {
1817 return Err("archive row counts changed during migration".to_owned());
1818 }
1819 let invalid_refs: i64 = transaction
1820 .query_row(
1821 "SELECT count(*) FROM tool_calls
1822 WHERE length(archive_ref) != 35
1823 OR substr(archive_ref, 1, 3) != 'yr_'
1824 OR substr(archive_ref, 4) GLOB '*[^0-9a-f]*'",
1825 [],
1826 |row| row.get(0),
1827 )
1828 .map_err(|error| format!("could not verify migrated archive references: {error}"))?;
1829 let foreign_keys: i64 = transaction
1830 .query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
1831 row.get(0)
1832 })
1833 .map_err(|error| format!("could not verify migrated foreign keys: {error}"))?;
1834 if invalid_refs != 0 || foreign_keys != 0 {
1835 return Err(format!(
1836 "archive migration verification failed: {invalid_refs} invalid references, {foreign_keys} foreign key violations"
1837 ));
1838 }
1839 transaction
1840 .commit()
1841 .map_err(|error| format!("could not commit indexed-output migration: {error}"))
1842}
1843
1844fn ensure_session(transaction: &Transaction<'_>, session: &SessionIdentity) -> Result<i64, String> {
1845 transaction
1846 .execute(
1847 "INSERT INTO sessions (agent, account, source_session_id, started_at_ms)
1848 VALUES (?1, ?2, ?3, ?4)
1849 ON CONFLICT(agent, account, source_session_id) DO UPDATE SET
1850 started_at_ms = coalesce(sessions.started_at_ms, excluded.started_at_ms)",
1851 params![
1852 session.agent,
1853 session.account,
1854 session.source_session_id,
1855 session.started_at_ms
1856 ],
1857 )
1858 .map_err(|error| format!("could not store archive session: {error}"))?;
1859 transaction
1860 .query_row(
1861 "SELECT id FROM sessions WHERE agent = ?1 AND account = ?2 AND source_session_id = ?3",
1862 params![session.agent, session.account, session.source_session_id],
1863 |row| row.get(0),
1864 )
1865 .map_err(|error| format!("could not find archive session: {error}"))
1866}
1867
1868fn ensure_call(
1869 transaction: &Transaction<'_>,
1870 session_id: i64,
1871 call: &CallIdentity,
1872) -> Result<i64, String> {
1873 let existing: Option<i64> = transaction
1874 .query_row(
1875 "SELECT id FROM tool_calls WHERE session_id = ?1 AND source_call_id = ?2",
1876 params![session_id, call.source_call_id],
1877 |row| row.get(0),
1878 )
1879 .optional()
1880 .map_err(|error| format!("could not check stored tool call: {error}"))?;
1881 if existing.is_none() {
1882 let mut inserted = false;
1883 for _ in 0..3 {
1884 let archive_ref = random_archive_ref(transaction)?;
1885 match transaction.execute(
1886 "INSERT INTO tool_calls (
1887 session_id, source_call_id, archive_ref, tool_name, provider, model,
1888 working_directory, started_at_ms, requires_streams, status
1889 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'started')",
1890 params![
1891 session_id,
1892 call.source_call_id,
1893 archive_ref,
1894 call.tool_name,
1895 call.provider,
1896 call.model,
1897 call.working_directory,
1898 call.started_at_ms,
1899 i64::from(call.requires_streams)
1900 ],
1901 ) {
1902 Ok(1) => {
1903 inserted = true;
1904 break;
1905 }
1906 Ok(_) => return Err("could not store tool call".to_owned()),
1907 Err(rusqlite::Error::SqliteFailure(inner, _))
1908 if inner.code == ErrorCode::ConstraintViolation => {}
1909 Err(error) => return Err(format!("could not store tool call: {error}")),
1910 }
1911 }
1912 if !inserted {
1913 return Err(
1914 "could not allocate a unique archive reference after three attempts".to_owned(),
1915 );
1916 }
1917 }
1918 let stored = transaction
1919 .query_row(
1920 "SELECT id, archive_ref, tool_name, provider, model, working_directory, started_at_ms, requires_streams
1921 FROM tool_calls WHERE session_id = ?1 AND source_call_id = ?2",
1922 params![session_id, call.source_call_id],
1923 |row| {
1924 Ok((
1925 row.get::<_, i64>(0)?,
1926 row.get::<_, String>(1)?,
1927 row.get::<_, String>(2)?,
1928 row.get::<_, Option<String>>(3)?,
1929 row.get::<_, Option<String>>(4)?,
1930 row.get::<_, Option<String>>(5)?,
1931 row.get::<_, i64>(6)?,
1932 row.get::<_, bool>(7)?,
1933 ))
1934 },
1935 )
1936 .map_err(|error| format!("could not find stored tool call: {error}"))?;
1937 validate_archive_ref(&stored.1)?;
1938 if stored.2 != call.tool_name
1939 || stored.3 != call.provider
1940 || stored.4 != call.model
1941 || stored.5 != call.working_directory
1942 || stored.6 != call.started_at_ms
1943 || stored.7 != call.requires_streams
1944 {
1945 return Err(format!(
1946 "tool call {} was already stored with different metadata",
1947 call.source_call_id
1948 ));
1949 }
1950 Ok(stored.0)
1951}
1952
1953fn random_archive_ref(transaction: &Transaction<'_>) -> Result<String, String> {
1954 let archive_ref: String = transaction
1955 .query_row("SELECT 'yr_' || lower(hex(randomblob(16)))", [], |row| {
1956 row.get(0)
1957 })
1958 .map_err(|error| format!("could not generate archive reference: {error}"))?;
1959 validate_archive_ref(&archive_ref)?;
1960 Ok(archive_ref)
1961}
1962
1963fn validate_archive_ref(archive_ref: &str) -> Result<(), String> {
1964 if archive_ref.len() != ARCHIVE_REF_LEN
1965 || !archive_ref.starts_with(ARCHIVE_REF_PREFIX)
1966 || !archive_ref[ARCHIVE_REF_PREFIX.len()..]
1967 .bytes()
1968 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1969 {
1970 return Err(
1971 "archive reference must be yr_ followed by 32 lowercase hexadecimal digits".to_owned(),
1972 );
1973 }
1974 Ok(())
1975}
1976
1977fn call_archive_ref(transaction: &Transaction<'_>, call_id: i64) -> Result<String, String> {
1978 let archive_ref: String = transaction
1979 .query_row(
1980 "SELECT archive_ref FROM tool_calls WHERE id = ?1",
1981 [call_id],
1982 |row| row.get(0),
1983 )
1984 .map_err(|error| format!("could not read archive reference: {error}"))?;
1985 validate_archive_ref(&archive_ref)?;
1986 Ok(archive_ref)
1987}
1988
1989fn parse_completeness(value: &str) -> Result<SourceCompleteness, String> {
1990 match value {
1991 "complete" => Ok(SourceCompleteness::Complete),
1992 "incomplete" => Ok(SourceCompleteness::Incomplete),
1993 "unknown" => Ok(SourceCompleteness::Unknown),
1994 _ => Err(format!("invalid source completeness {value}")),
1995 }
1996}
1997
1998fn find_call(
1999 transaction: &Transaction<'_>,
2000 session: &SessionIdentity,
2001 source_call_id: &str,
2002) -> Result<i64, String> {
2003 transaction
2004 .query_row(
2005 "SELECT c.id
2006 FROM tool_calls c
2007 JOIN sessions s ON s.id = c.session_id
2008 WHERE s.agent = ?1 AND s.account = ?2 AND s.source_session_id = ?3
2009 AND c.source_call_id = ?4",
2010 params![
2011 session.agent,
2012 session.account,
2013 session.source_session_id,
2014 source_call_id
2015 ],
2016 |row| row.get(0),
2017 )
2018 .map_err(|error| format!("could not find tool call {source_call_id}: {error}"))
2019}
2020
2021fn require_call_status(
2022 transaction: &Transaction<'_>,
2023 call_id: i64,
2024 source_call_id: &str,
2025 expected: &str,
2026) -> Result<(), String> {
2027 let status: String = transaction
2028 .query_row(
2029 "SELECT status FROM tool_calls WHERE id = ?1",
2030 [call_id],
2031 |row| row.get(0),
2032 )
2033 .map_err(|error| format!("could not read tool call status: {error}"))?;
2034 if status != expected {
2035 return Err(format!(
2036 "tool call {source_call_id} has status {status}, expected {expected}"
2037 ));
2038 }
2039 Ok(())
2040}
2041
2042fn validate_completion(
2043 transaction: &Transaction<'_>,
2044 call_id: i64,
2045 require_pre_result: bool,
2046) -> Result<(), String> {
2047 let requires_streams: bool = transaction
2048 .query_row(
2049 "SELECT requires_streams FROM tool_calls WHERE id = ?1",
2050 [call_id],
2051 |row| row.get(0),
2052 )
2053 .map_err(|error| format!("could not read tool call requirements: {error}"))?;
2054 if requires_streams && require_pre_result {
2055 let streams: i64 = transaction
2056 .query_row(
2057 "SELECT count(*) FROM snapshots
2058 WHERE tool_call_id = ?1
2059 AND subject IN ('stdout', 'stderr')
2060 AND stage IN ('before', 'after')",
2061 [call_id],
2062 |row| row.get(0),
2063 )
2064 .map_err(|error| format!("could not check shell stream snapshots: {error}"))?;
2065 if streams != 4 {
2066 return Err(format!(
2067 "shell tool call is missing stream snapshots: found {streams} of 4"
2068 ));
2069 }
2070 }
2071 if require_pre_result {
2072 let has_result: bool = transaction
2073 .query_row(
2074 "SELECT EXISTS(
2075 SELECT 1 FROM snapshots
2076 WHERE tool_call_id = ?1 AND subject = 'result' AND stage = 'before'
2077 )",
2078 [call_id],
2079 |row| row.get(0),
2080 )
2081 .map_err(|error| format!("could not check pre-YARP result snapshot: {error}"))?;
2082 if !has_result {
2083 return Err("executed tool call is missing its pre-YARP result".to_owned());
2084 }
2085 }
2086 Ok(())
2087}
2088
2089fn insert_snapshot_bytes(
2090 transaction: &Transaction<'_>,
2091 call_id: i64,
2092 subject: &str,
2093 stage: &str,
2094 media_type: &str,
2095 captured_at_ms: i64,
2096 body: &[u8],
2097) -> Result<(), String> {
2098 insert_snapshot_reader(
2099 transaction,
2100 call_id,
2101 subject,
2102 stage,
2103 media_type,
2104 captured_at_ms,
2105 &mut Cursor::new(body),
2106 )
2107}
2108
2109fn replace_snapshot_bytes(
2110 transaction: &Transaction<'_>,
2111 call_id: i64,
2112 subject: &str,
2113 stage: &str,
2114 media_type: &str,
2115 captured_at_ms: i64,
2116 body: &[u8],
2117) -> Result<Vec<u8>, String> {
2118 let replaced_sha: Vec<u8> = transaction
2119 .query_row(
2120 "SELECT payload_sha256 FROM snapshots
2121 WHERE tool_call_id = ?1 AND subject = ?2 AND stage = ?3",
2122 params![call_id, subject, stage],
2123 |row| row.get(0),
2124 )
2125 .map_err(|error| format!("could not find {subject}/{stage} snapshot: {error}"))?;
2126 let sha = insert_payload(transaction, &mut Cursor::new(body))?;
2127 let updated = transaction
2128 .execute(
2129 "UPDATE snapshots
2130 SET media_type = ?1, captured_at_ms = ?2, payload_sha256 = ?3
2131 WHERE tool_call_id = ?4 AND subject = ?5 AND stage = ?6",
2132 params![media_type, captured_at_ms, sha, call_id, subject, stage],
2133 )
2134 .map_err(|error| format!("could not replace {subject}/{stage} snapshot: {error}"))?;
2135 if updated != 1 {
2136 return Err(format!("snapshot {subject}/{stage} does not exist"));
2137 }
2138 Ok(replaced_sha)
2139}
2140
2141fn insert_snapshot_reader(
2142 transaction: &Transaction<'_>,
2143 call_id: i64,
2144 subject: &str,
2145 stage: &str,
2146 media_type: &str,
2147 captured_at_ms: i64,
2148 reader: &mut (impl Read + Seek),
2149) -> Result<(), String> {
2150 insert_snapshot_reader_with_completeness(
2151 transaction,
2152 call_id,
2153 subject,
2154 stage,
2155 media_type,
2156 None,
2157 captured_at_ms,
2158 reader,
2159 )
2160}
2161
2162#[expect(
2163 clippy::too_many_arguments,
2164 reason = "snapshot identity and source metadata are one strict database record"
2165)]
2166fn insert_snapshot_reader_with_completeness(
2167 transaction: &Transaction<'_>,
2168 call_id: i64,
2169 subject: &str,
2170 stage: &str,
2171 media_type: &str,
2172 source_completeness: Option<&str>,
2173 captured_at_ms: i64,
2174 reader: &mut (impl Read + Seek),
2175) -> Result<(), String> {
2176 let sha = insert_payload(transaction, reader)?;
2177 let inserted = transaction
2178 .execute(
2179 "INSERT INTO snapshots (
2180 tool_call_id, subject, stage, media_type, source_completeness,
2181 captured_at_ms, payload_sha256
2182 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
2183 ON CONFLICT(tool_call_id, subject, stage) DO NOTHING",
2184 params![
2185 call_id,
2186 subject,
2187 stage,
2188 media_type,
2189 source_completeness,
2190 captured_at_ms,
2191 sha
2192 ],
2193 )
2194 .map_err(|error| format!("could not store {subject}/{stage} snapshot: {error}"))?;
2195 if inserted == 0 {
2196 let existing: (Vec<u8>, Option<String>) = transaction
2197 .query_row(
2198 "SELECT payload_sha256, source_completeness FROM snapshots
2199 WHERE tool_call_id = ?1 AND subject = ?2 AND stage = ?3",
2200 params![call_id, subject, stage],
2201 |row| Ok((row.get(0)?, row.get(1)?)),
2202 )
2203 .map_err(|error| format!("could not check existing snapshot: {error}"))?;
2204 if existing != (sha, source_completeness.map(str::to_owned)) {
2205 return Err(format!(
2206 "snapshot {subject}/{stage} already exists with different content or completeness"
2207 ));
2208 }
2209 }
2210 Ok(())
2211}
2212
2213fn insert_payload(
2214 transaction: &Transaction<'_>,
2215 reader: &mut (impl Read + Seek),
2216) -> Result<Vec<u8>, String> {
2217 reader
2218 .seek(SeekFrom::Start(0))
2219 .map_err(|error| format!("could not rewind payload: {error}"))?;
2220 let mut hasher = Sha256::new();
2221 let mut raw_length = 0_u64;
2222 let mut buffer = vec![0_u8; 64 * 1024];
2223 loop {
2224 let count = reader
2225 .read(&mut buffer)
2226 .map_err(|error| format!("could not read payload: {error}"))?;
2227 if count == 0 {
2228 break;
2229 }
2230 hasher.update(&buffer[..count]);
2231 raw_length = raw_length.saturating_add(count as u64);
2232 }
2233 let sha = hasher.finalize().to_vec();
2234 let exists = transaction
2235 .query_row("SELECT 1 FROM payloads WHERE sha256 = ?1", [&sha], |_| {
2236 Ok(())
2237 })
2238 .optional()
2239 .map_err(|error| format!("could not check payload digest: {error}"))?
2240 .is_some();
2241 if exists {
2242 return Ok(sha);
2243 }
2244
2245 reader
2246 .seek(SeekFrom::Start(0))
2247 .map_err(|error| format!("could not rewind payload for compression: {error}"))?;
2248 let mut compressed = NamedTempFile::new()
2249 .map_err(|error| format!("could not create compressed payload spool: {error}"))?;
2250 zstd::stream::copy_encode(&mut *reader, &mut compressed, ZSTD_LEVEL)
2251 .map_err(|error| format!("could not compress payload: {error}"))?;
2252 compressed
2253 .flush()
2254 .map_err(|error| format!("could not flush compressed payload: {error}"))?;
2255 let compressed_length = compressed
2256 .as_file()
2257 .metadata()
2258 .map_err(|error| format!("could not stat compressed payload: {error}"))?
2259 .len();
2260 let use_compressed =
2261 compressed_length.saturating_mul(100) <= raw_length.saturating_mul(COMPRESSION_PERCENT);
2262 let stored_length = if use_compressed {
2263 compressed_length
2264 } else {
2265 raw_length
2266 };
2267 let stored_length_i64 = i64::try_from(stored_length)
2268 .map_err(|_| format!("payload is too large for SQLite: {stored_length} bytes"))?;
2269 let raw_length_i64 = i64::try_from(raw_length)
2270 .map_err(|_| format!("payload is too large: {raw_length} bytes"))?;
2271 transaction
2272 .execute(
2273 "INSERT INTO payloads (sha256, compression, uncompressed_byte_length, body)
2274 VALUES (?1, ?2, ?3, zeroblob(?4))",
2275 params![
2276 sha,
2277 if use_compressed { "zstd" } else { "none" },
2278 raw_length_i64,
2279 stored_length_i64
2280 ],
2281 )
2282 .map_err(|error| format!("could not insert payload: {error}"))?;
2283 let rowid: i64 = transaction
2284 .query_row(
2285 "SELECT rowid FROM payloads WHERE sha256 = ?1",
2286 [&sha],
2287 |row| row.get(0),
2288 )
2289 .map_err(|error| format!("could not locate payload body: {error}"))?;
2290 let mut blob = transaction
2291 .blob_open(MAIN_DB, "payloads", "body", rowid, false)
2292 .map_err(|error| format!("could not open payload body: {error}"))?;
2293 if use_compressed {
2294 compressed
2295 .seek(SeekFrom::Start(0))
2296 .map_err(|error| format!("could not rewind compressed payload: {error}"))?;
2297 copy_exact(&mut compressed, &mut blob, stored_length)?;
2298 } else {
2299 reader
2300 .seek(SeekFrom::Start(0))
2301 .map_err(|error| format!("could not rewind raw payload: {error}"))?;
2302 copy_exact(reader, &mut blob, stored_length)?;
2303 }
2304 Ok(sha)
2305}
2306
2307fn copy_exact(reader: &mut impl Read, blob: &mut Blob<'_>, expected: u64) -> Result<(), String> {
2308 let copied =
2309 io::copy(reader, blob).map_err(|error| format!("could not write payload body: {error}"))?;
2310 if copied != expected {
2311 return Err(format!(
2312 "payload body length changed while storing it: expected {expected}, copied {copied}"
2313 ));
2314 }
2315 Ok(())
2316}
2317
2318fn canonical_json(value: &Value) -> Result<Vec<u8>, String> {
2319 serde_jcs::to_vec(value)
2320 .map_err(|error| format!("could not canonicalize JSON payload: {error}"))
2321}
2322
2323fn stream_media_type(reader: &mut (impl Read + Seek)) -> Result<&'static str, String> {
2324 reader
2325 .seek(SeekFrom::Start(0))
2326 .map_err(|error| format!("could not rewind stream for UTF-8 check: {error}"))?;
2327 let mut valid = true;
2328 let mut carry = Vec::new();
2329 let mut buffer = vec![0_u8; 64 * 1024];
2330 while valid {
2331 let count = reader
2332 .read(&mut buffer)
2333 .map_err(|error| format!("could not inspect stream encoding: {error}"))?;
2334 if count == 0 {
2335 break;
2336 }
2337 let mut chunk = std::mem::take(&mut carry);
2338 chunk.extend_from_slice(&buffer[..count]);
2339 if let Err(error) = std::str::from_utf8(&chunk) {
2340 if error.error_len().is_some() {
2341 valid = false;
2342 } else {
2343 carry.extend_from_slice(&chunk[error.valid_up_to()..]);
2344 if carry.len() > 3 {
2345 valid = false;
2346 }
2347 }
2348 }
2349 }
2350 if !carry.is_empty() {
2351 valid = false;
2352 }
2353 reader
2354 .seek(SeekFrom::Start(0))
2355 .map_err(|error| format!("could not rewind inspected stream: {error}"))?;
2356 Ok(if valid {
2357 "text/plain; charset=utf-8"
2358 } else {
2359 "application/octet-stream"
2360 })
2361}
2362
2363fn sync_directory(path: &Path) -> Result<(), String> {
2364 let directory = fs::File::open(path)
2365 .map_err(|error| format!("could not open {} for sync: {error}", path.display()))?;
2366 directory
2367 .sync_all()
2368 .map_err(|error| format!("could not sync {}: {error}", path.display()))
2369}
2370
2371fn archive_file_bytes(path: &Path) -> Result<u64, String> {
2372 let main = fs::metadata(path)
2373 .map_err(|error| format!("could not stat archive {}: {error}", path.display()))?
2374 .len();
2375 let companions = ["-wal", "-shm"].into_iter().map(|suffix| {
2376 let mut value = path.as_os_str().to_owned();
2377 value.push(suffix);
2378 PathBuf::from(value)
2379 });
2380 Ok(companions.fold(main, |total, companion| {
2381 total.saturating_add(fs::metadata(companion).map_or(0, |metadata| metadata.len()))
2382 }))
2383}
2384
2385fn prepare_path(path: &Path, repair_existing_directory: bool) -> Result<(), String> {
2386 let parent = path
2387 .parent()
2388 .ok_or_else(|| format!("archive path {} has no parent", path.display()))?;
2389 let parent_existed = parent.exists();
2390 create_archive_directory(parent)?;
2391 if !parent_existed || repair_existing_directory {
2392 set_file_mode(parent, 0o700)?;
2393 } else {
2394 require_private_directory(parent)?;
2395 }
2396 let mut options = OpenOptions::new();
2397 options.create(true).read(true).write(true);
2398 #[cfg(unix)]
2399 {
2400 use std::os::unix::fs::OpenOptionsExt as _;
2401 options.mode(0o600);
2402 }
2403 options
2404 .open(path)
2405 .map_err(|error| format!("could not create archive {}: {error}", path.display()))?;
2406 set_file_mode(path, 0o600)
2407}
2408
2409fn create_archive_directory(path: &Path) -> Result<(), String> {
2410 let mut builder = fs::DirBuilder::new();
2411 builder.recursive(true);
2412 #[cfg(unix)]
2413 {
2414 use std::os::unix::fs::DirBuilderExt as _;
2415 builder.mode(0o700);
2416 }
2417 builder.create(path).map_err(|error| {
2418 format!(
2419 "could not create archive directory {}: {error}",
2420 path.display()
2421 )
2422 })
2423}
2424
2425#[cfg(unix)]
2426fn require_private_archive_path(path: &Path) -> Result<(), String> {
2427 use std::os::unix::fs::PermissionsExt as _;
2428 require_private_directory(
2429 path.parent()
2430 .ok_or_else(|| format!("archive path {} has no parent", path.display()))?,
2431 )?;
2432 let metadata = fs::symlink_metadata(path)
2433 .map_err(|error| format!("could not inspect archive {}: {error}", path.display()))?;
2434 if metadata.file_type().is_symlink() || !metadata.is_file() {
2435 return Err(format!(
2436 "archive {} must be a regular non-symlink file",
2437 path.display()
2438 ));
2439 }
2440 let mode = metadata.permissions().mode() & 0o777;
2441 if mode & 0o077 != 0 {
2442 return Err(format!(
2443 "archive {} has mode {mode:o}, expected 600",
2444 path.display()
2445 ));
2446 }
2447 Ok(())
2448}
2449
2450#[cfg(not(unix))]
2451fn require_private_archive_path(_path: &Path) -> Result<(), String> {
2452 Ok(())
2453}
2454
2455#[cfg(unix)]
2456fn require_private_directory(path: &Path) -> Result<(), String> {
2457 use std::os::unix::fs::PermissionsExt as _;
2458 let mode = fs::metadata(path)
2459 .map_err(|error| format!("could not inspect {}: {error}", path.display()))?
2460 .permissions()
2461 .mode()
2462 & 0o777;
2463 if mode & 0o077 != 0 {
2464 return Err(format!(
2465 "archive directory {} has mode {mode:o}, expected 700; use a private directory",
2466 path.display()
2467 ));
2468 }
2469 Ok(())
2470}
2471
2472#[cfg(not(unix))]
2473fn require_private_directory(_path: &Path) -> Result<(), String> {
2474 Ok(())
2475}
2476
2477#[cfg(unix)]
2478fn set_file_mode(path: &Path, mode: u32) -> Result<(), String> {
2479 use std::os::unix::fs::PermissionsExt as _;
2480 fs::set_permissions(path, fs::Permissions::from_mode(mode))
2481 .map_err(|error| format!("could not set permissions on {}: {error}", path.display()))
2482}
2483
2484#[cfg(not(unix))]
2485fn set_file_mode(_path: &Path, _mode: u32) -> Result<(), String> {
2486 Ok(())
2487}
2488
2489#[cfg(unix)]
2490fn verify_permissions(path: &Path, report: &mut VerifyReport) -> Result<(), String> {
2491 use std::os::unix::fs::PermissionsExt as _;
2492 let directory = path
2493 .parent()
2494 .ok_or_else(|| format!("archive path {} has no parent", path.display()))?;
2495 let directory_mode = fs::metadata(directory)
2496 .map_err(|error| format!("could not stat archive directory: {error}"))?
2497 .permissions()
2498 .mode()
2499 & 0o777;
2500 if directory_mode != 0o700 {
2501 report.errors.push(format!(
2502 "archive directory mode is {directory_mode:o}, expected 700"
2503 ));
2504 }
2505 let file_mode = fs::metadata(path)
2506 .map_err(|error| format!("could not stat archive file: {error}"))?
2507 .permissions()
2508 .mode()
2509 & 0o777;
2510 if file_mode != 0o600 {
2511 report
2512 .errors
2513 .push(format!("archive file mode is {file_mode:o}, expected 600"));
2514 }
2515 Ok(())
2516}
2517
2518#[cfg(not(unix))]
2519fn verify_permissions(_path: &Path, _report: &mut VerifyReport) -> Result<(), String> {
2520 Ok(())
2521}
2522
2523#[cfg(test)]
2524mod tests {
2525 use super::*;
2526 #[cfg(unix)]
2527 use std::os::unix::fs::PermissionsExt as _;
2528 use tempfile::TempDir;
2529
2530 fn archive() -> (TempDir, Archive) {
2531 let directory = TempDir::new().expect("temp directory");
2532 let archive = Archive::open_path(directory.path().join("data/tool-calls.sqlite3"))
2533 .expect("open archive");
2534 (directory, archive)
2535 }
2536
2537 fn session() -> SessionIdentity {
2538 SessionIdentity {
2539 agent: "pi".to_owned(),
2540 account: "test".to_owned(),
2541 source_session_id: "session-1".to_owned(),
2542 started_at_ms: Some(10),
2543 }
2544 }
2545
2546 fn call() -> CallIdentity {
2547 call_with_id("call-1")
2548 }
2549
2550 fn call_with_id(source_call_id: &str) -> CallIdentity {
2551 CallIdentity {
2552 source_call_id: source_call_id.to_owned(),
2553 tool_name: "read".to_owned(),
2554 provider: Some("openai".to_owned()),
2555 model: Some("gpt".to_owned()),
2556 working_directory: Some("/tmp".to_owned()),
2557 started_at_ms: 20,
2558 requires_streams: false,
2559 }
2560 }
2561
2562 fn framed(value: &Value) -> Vec<u8> {
2563 let body = serde_json::to_vec(value).expect("json");
2564 let mut frame = Vec::new();
2565 frame.extend_from_slice(&(body.len() as u64).to_be_bytes());
2566 frame.extend_from_slice(&body);
2567 frame
2568 }
2569
2570 #[test]
2571 fn stores_and_verifies_a_complete_call() {
2572 let (_directory, mut archive) = archive();
2573 archive
2574 .begin_call(
2575 &session(),
2576 &call(),
2577 &serde_json::json!({"path": "file"}),
2578 &serde_json::json!({"path": "file"}),
2579 20,
2580 )
2581 .expect("begin");
2582 archive
2583 .result_before(
2584 &session(),
2585 "call-1",
2586 &serde_json::json!({"content": "raw"}),
2587 None,
2588 30,
2589 )
2590 .expect("before");
2591 archive
2592 .finish_call(
2593 &session(),
2594 "call-1",
2595 &serde_json::json!({"content": "pruned"}),
2596 false,
2597 true,
2598 40,
2599 )
2600 .expect("finish");
2601
2602 let stats = archive.stats().expect("stats");
2603 assert_eq!(stats.sessions, 1);
2604 assert_eq!(stats.calls, 1);
2605 assert_eq!(stats.incomplete_calls, 0);
2606 assert!(stats.logical_payload_bytes > 0);
2607 assert!(archive.verify().expect("verify").errors.is_empty());
2608 }
2609
2610 #[test]
2611 fn finalizes_the_staged_result_after_other_hooks() {
2612 let (_directory, mut archive) = archive();
2613 archive
2614 .begin_call(
2615 &session(),
2616 &call(),
2617 &serde_json::json!({}),
2618 &serde_json::json!({}),
2619 20,
2620 )
2621 .expect("begin");
2622 archive
2623 .result_before(
2624 &session(),
2625 "call-1",
2626 &serde_json::json!({"content": "before"}),
2627 None,
2628 30,
2629 )
2630 .expect("before");
2631 archive
2632 .stage_result(
2633 &session(),
2634 "call-1",
2635 &serde_json::json!({"content": "provisional"}),
2636 false,
2637 40,
2638 )
2639 .expect("stage");
2640 let staged_report = archive.verify().expect("verify staged call");
2641 assert_eq!(staged_report.incomplete_calls, 1);
2642 assert!(staged_report.errors.is_empty());
2643 archive
2644 .update_final_result(
2645 &session(),
2646 "call-1",
2647 &serde_json::json!({"content": "final"}),
2648 true,
2649 50,
2650 )
2651 .expect("update final");
2652 archive
2653 .update_final_result(
2654 &session(),
2655 "call-1",
2656 &serde_json::json!({"content": "final"}),
2657 true,
2658 50,
2659 )
2660 .expect("repeat committed final update");
2661
2662 let call_id: i64 = archive
2663 .connection
2664 .query_row("SELECT id FROM tool_calls", [], |row| row.get(0))
2665 .expect("call id");
2666 let mut restored = archive
2667 .verified_snapshot(call_id, "result", "after")
2668 .expect("final result");
2669 let mut body = Vec::new();
2670 restored.read_to_end(&mut body).expect("read final result");
2671 assert_eq!(body, br#"{"content":"final"}"#);
2672 let metadata: (i64, i64) = archive
2673 .connection
2674 .query_row(
2675 "SELECT is_error, finished_at_ms FROM tool_calls WHERE id = ?1",
2676 [call_id],
2677 |row| Ok((row.get(0)?, row.get(1)?)),
2678 )
2679 .expect("final metadata");
2680 assert_eq!(metadata, (1, 50));
2681 assert!(archive.verify().expect("verify").errors.is_empty());
2682 }
2683
2684 #[test]
2685 fn failed_finalization_keeps_the_staged_call_incomplete() {
2686 let (_directory, mut archive) = archive();
2687 archive
2688 .begin_call(
2689 &session(),
2690 &call(),
2691 &serde_json::json!({}),
2692 &serde_json::json!({}),
2693 20,
2694 )
2695 .expect("begin");
2696 archive
2697 .result_before(
2698 &session(),
2699 "call-1",
2700 &serde_json::json!({"content": "before"}),
2701 None,
2702 30,
2703 )
2704 .expect("before");
2705 archive
2706 .stage_result(
2707 &session(),
2708 "call-1",
2709 &serde_json::json!({"content": "provisional"}),
2710 false,
2711 40,
2712 )
2713 .expect("stage");
2714 archive
2715 .connection
2716 .execute_batch(
2717 "CREATE TEMP TRIGGER reject_finish
2718 BEFORE UPDATE OF status ON tool_calls
2719 WHEN NEW.status = 'finished'
2720 BEGIN SELECT RAISE(ABORT, 'simulated final write failure'); END;",
2721 )
2722 .expect("failure trigger");
2723
2724 let error = archive
2725 .update_final_result(
2726 &session(),
2727 "call-1",
2728 &serde_json::json!({"content": "final"}),
2729 false,
2730 50,
2731 )
2732 .expect_err("finalization failure");
2733 assert!(error.contains("simulated final write failure"));
2734 assert_eq!(archive.stats().expect("stats").incomplete_calls, 1);
2735 let stored: (String, Option<i64>) = archive
2736 .connection
2737 .query_row("SELECT status, finished_at_ms FROM tool_calls", [], |row| {
2738 Ok((row.get(0)?, row.get(1)?))
2739 })
2740 .expect("stored state");
2741 assert_eq!(stored, ("started".to_owned(), None));
2742 }
2743
2744 #[test]
2745 fn deduplicates_unchanged_snapshots() {
2746 let (_directory, mut archive) = archive();
2747 let value = serde_json::json!({"same": true});
2748 archive
2749 .begin_call(&session(), &call(), &value, &value, 20)
2750 .expect("begin");
2751 let payloads: i64 = archive
2752 .connection
2753 .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0))
2754 .expect("count");
2755 assert_eq!(payloads, 1);
2756 }
2757
2758 #[test]
2759 fn duplicate_operations_are_idempotent_but_conflicts_fail() {
2760 let (_directory, mut archive) = archive();
2761 let value = serde_json::json!({"same": true});
2762 archive
2763 .begin_call(&session(), &call(), &value, &value, 20)
2764 .expect("begin");
2765 archive
2766 .begin_call(&session(), &call(), &value, &value, 20)
2767 .expect("retry");
2768 let error = archive
2769 .begin_call(
2770 &session(),
2771 &call(),
2772 &serde_json::json!({"different": true}),
2773 &value,
2774 20,
2775 )
2776 .expect_err("conflict");
2777 assert!(error.contains("different content"));
2778 }
2779
2780 #[test]
2781 fn marks_interrupted_calls_without_failing_verification() {
2782 let (_directory, mut archive) = archive();
2783 archive
2784 .begin_call(
2785 &session(),
2786 &call(),
2787 &serde_json::json!({}),
2788 &serde_json::json!({}),
2789 20,
2790 )
2791 .expect("begin");
2792 let report = archive.verify().expect("verify");
2793 assert_eq!(report.incomplete_calls, 1);
2794 assert!(report.errors.is_empty());
2795 }
2796
2797 #[test]
2798 fn stores_binary_and_text_streams() {
2799 let (_directory, mut archive) = archive();
2800 let archive_ref = archive
2801 .begin_call(
2802 &session(),
2803 &call(),
2804 &serde_json::json!({}),
2805 &serde_json::json!({}),
2806 20,
2807 )
2808 .expect("begin");
2809 let key = ArchiveKey {
2810 session: session(),
2811 source_call_id: "call-1".to_owned(),
2812 };
2813 let mut stdout = Cursor::new(b"hello\n".to_vec());
2814 let mut stderr = Cursor::new(vec![0xff, 0x00]);
2815 archive
2816 .capture_streams(&key, 30, &mut stdout, &mut stderr, b"hello\n", b"short")
2817 .expect("streams");
2818 let media: Vec<String> = archive
2819 .connection
2820 .prepare("SELECT media_type FROM snapshots WHERE subject IN ('stdout', 'stderr') ORDER BY subject, stage")
2821 .expect("prepare")
2822 .query_map([], |row| row.get(0))
2823 .expect("query")
2824 .collect::<Result<_, _>>()
2825 .expect("rows");
2826 assert!(media.contains(&"application/octet-stream".to_owned()));
2827 assert!(media.contains(&"text/plain; charset=utf-8".to_owned()));
2828
2829 archive
2830 .result_text(
2831 &session(),
2832 "call-1",
2833 "capped wrapped summary\n",
2834 SourceCompleteness::Unknown,
2835 40,
2836 )
2837 .expect("wrapped summary fallback");
2838 let sources = archive.searchable_sources(&archive_ref).expect("sources");
2839 assert_eq!(
2840 sources.iter().map(|source| source.name).collect::<Vec<_>>(),
2841 vec![SourceName::Stdout, SourceName::Stderr]
2842 );
2843 }
2844
2845 #[test]
2846 fn stores_exact_source_full_output_with_the_result() {
2847 let (directory, mut archive) = archive();
2848 let archive_ref = archive
2849 .begin_call(
2850 &session(),
2851 &call(),
2852 &serde_json::json!({}),
2853 &serde_json::json!({}),
2854 20,
2855 )
2856 .expect("begin");
2857 let full_output_path = directory.path().join("pi-full-output.log");
2858 let expected = b"complete output\n\xff";
2859 fs::write(&full_output_path, expected).expect("full output");
2860 archive
2861 .result_before(
2862 &session(),
2863 "call-1",
2864 &serde_json::json!({"content": "truncated"}),
2865 Some(&full_output_path),
2866 30,
2867 )
2868 .expect("result before");
2869
2870 let call_id: i64 = archive
2871 .connection
2872 .query_row("SELECT id FROM tool_calls", [], |row| row.get(0))
2873 .expect("call id");
2874 let mut restored = archive
2875 .verified_snapshot(call_id, "source_output", "before")
2876 .expect("source output");
2877 let mut actual = Vec::new();
2878 restored
2879 .read_to_end(&mut actual)
2880 .expect("read source output");
2881 assert_eq!(actual, expected);
2882
2883 archive
2884 .result_text(
2885 &session(),
2886 "call-1",
2887 "exact host text\n",
2888 SourceCompleteness::Incomplete,
2889 40,
2890 )
2891 .expect("result text");
2892 let mut sources = archive.searchable_sources(&archive_ref).expect("sources");
2893 assert_eq!(sources.len(), 1);
2894 assert_eq!(sources[0].name, SourceName::ResultText);
2895 let mut host_text = String::new();
2896 sources[0]
2897 .body
2898 .read_to_string(&mut host_text)
2899 .expect("result text body");
2900 assert_eq!(host_text, "exact host text\n");
2901 }
2902
2903 #[test]
2904 fn prunes_only_finished_calls() {
2905 let (_directory, mut archive) = archive();
2906 archive
2907 .begin_call(
2908 &session(),
2909 &call(),
2910 &serde_json::json!({}),
2911 &serde_json::json!({}),
2912 20,
2913 )
2914 .expect("begin");
2915 archive
2916 .finish_call(
2917 &session(),
2918 "call-1",
2919 &serde_json::json!({}),
2920 false,
2921 false,
2922 40,
2923 )
2924 .expect("finish");
2925 assert_eq!(archive.prune_before(50).expect("prune"), 1);
2926 let stats = archive.stats().expect("stats");
2927 assert_eq!(stats.calls, 0);
2928 assert_eq!(stats.logical_payload_bytes, 0);
2929 }
2930
2931 #[test]
2932 fn migrates_the_original_version_one_schema_in_place_with_a_private_backup() {
2933 let directory = TempDir::new().expect("temp directory");
2934 let path = directory.path().join("yarp/tool-calls.sqlite3");
2935 fs::create_dir(path.parent().expect("parent")).expect("archive directory");
2936 set_file_mode(path.parent().expect("parent"), 0o700).expect("private directory");
2937 let connection = Connection::open(&path).expect("old database");
2938 connection
2939 .execute_batch(include_str!("../tests/fixtures/archive-v1.sql"))
2940 .expect("old schema");
2941 drop(connection);
2942 set_file_mode(&path, 0o600).expect("private database");
2943
2944 let archive = Archive::open_path(path.clone()).expect("migrate archive");
2945 let archive_ref: String = archive
2946 .connection
2947 .query_row("SELECT archive_ref FROM tool_calls", [], |row| row.get(0))
2948 .expect("archive ref");
2949 validate_archive_ref(&archive_ref).expect("valid archive ref");
2950 assert!(
2951 table_has_column(&archive.connection, "snapshots", "source_completeness")
2952 .expect("schema column")
2953 );
2954 drop(archive);
2955
2956 let backup = path.with_file_name("tool-calls.sqlite3.pre-indexed-output-v1.backup");
2957 assert!(backup.is_file());
2958 #[cfg(unix)]
2959 assert_eq!(
2960 fs::metadata(&backup)
2961 .expect("backup metadata")
2962 .permissions()
2963 .mode()
2964 & 0o777,
2965 0o600
2966 );
2967 let reopened = Archive::open_path(path).expect("reopen migrated archive");
2968 let reopened_ref: String = reopened
2969 .connection
2970 .query_row("SELECT archive_ref FROM tool_calls", [], |row| row.get(0))
2971 .expect("stable archive ref");
2972 assert_eq!(archive_ref, reopened_ref);
2973 }
2974
2975 #[test]
2976 fn result_text_is_on_demand_and_canonical_source_selection_is_stable() {
2977 let (_directory, mut archive) = archive();
2978 let archive_ref = archive
2979 .begin_call(
2980 &session(),
2981 &call(),
2982 &serde_json::json!({}),
2983 &serde_json::json!({}),
2984 20,
2985 )
2986 .expect("begin");
2987 let repeated_ref = archive
2988 .result_text(
2989 &session(),
2990 "call-1",
2991 "first\nerror\nlast\n",
2992 SourceCompleteness::Incomplete,
2993 30,
2994 )
2995 .expect("result text");
2996 assert_eq!(archive_ref, repeated_ref);
2997 let mut sources = archive.searchable_sources(&archive_ref).expect("sources");
2998 assert_eq!(sources.len(), 1);
2999 assert_eq!(sources[0].name, SourceName::ResultText);
3000 assert_eq!(sources[0].completeness, SourceCompleteness::Incomplete);
3001 let mut body = String::new();
3002 sources[0]
3003 .body
3004 .read_to_string(&mut body)
3005 .expect("source body");
3006 assert_eq!(body, "first\nerror\nlast\n");
3007 assert!(archive.verify().expect("verify").errors.is_empty());
3008 }
3009
3010 #[test]
3011 fn reopens_the_current_schema_without_migration() {
3012 let directory = TempDir::new().expect("temp directory");
3013 let path = directory.path().join("yarp/tool-calls.sqlite3");
3014 drop(Archive::open_path(path.clone()).expect("create"));
3015 let reopened = Archive::open_path(path).expect("reopen");
3016 assert_eq!(reopened.stats().expect("stats").calls, 0);
3017 }
3018
3019 #[test]
3020 fn rejects_newer_schema_versions() {
3021 let directory = TempDir::new().expect("temp directory");
3022 let path = directory.path().join("yarp/tool-calls.sqlite3");
3023 fs::create_dir(path.parent().expect("parent")).expect("archive directory");
3024 set_file_mode(path.parent().expect("parent"), 0o700).expect("private directory");
3025 let connection = Connection::open(&path).expect("sqlite");
3026 connection
3027 .pragma_update(None, "user_version", SCHEMA_VERSION + 1)
3028 .expect("version");
3029 drop(connection);
3030 let error = Archive::open_path(path).err().expect("newer version error");
3031 assert!(error.contains("newer than supported"));
3032 }
3033
3034 #[test]
3035 fn compresses_large_repetitive_payloads() {
3036 let (_directory, mut archive) = archive();
3037 let value = serde_json::json!({"output": "x".repeat(32 * 1024)});
3038 archive
3039 .begin_call(&session(), &call(), &value, &value, 20)
3040 .expect("begin");
3041 let compression: String = archive
3042 .connection
3043 .query_row("SELECT compression FROM payloads", [], |row| row.get(0))
3044 .expect("compression");
3045 assert_eq!(compression, "zstd");
3046 }
3047
3048 #[test]
3049 fn keeps_small_payloads_uncompressed() {
3050 let (_directory, mut archive) = archive();
3051 archive
3052 .begin_call(
3053 &session(),
3054 &call(),
3055 &serde_json::json!({"value": "short"}),
3056 &serde_json::json!({"value": "short"}),
3057 20,
3058 )
3059 .expect("begin");
3060 let compression: String = archive
3061 .connection
3062 .query_row("SELECT compression FROM payloads", [], |row| row.get(0))
3063 .expect("compression");
3064 assert_eq!(compression, "none");
3065 }
3066
3067 #[test]
3068 fn synthetic_archive_stays_below_storage_budget() {
3069 let (_directory, mut archive) = archive();
3070 let value = serde_json::json!({"output": "archive fixture ".repeat(64 * 1024)});
3071 archive
3072 .begin_call(&session(), &call(), &value, &value, 20)
3073 .expect("begin");
3074 archive
3075 .result_before(&session(), "call-1", &value, None, 30)
3076 .expect("result before");
3077 archive
3078 .finish_call(&session(), "call-1", &value, false, true, 40)
3079 .expect("finish");
3080 let referenced_bytes: i64 = archive
3081 .connection
3082 .query_row(
3083 "SELECT sum(p.uncompressed_byte_length)
3084 FROM snapshots s JOIN payloads p ON p.sha256 = s.payload_sha256",
3085 [],
3086 |row| row.get(0),
3087 )
3088 .expect("logical snapshot bytes");
3089 let stats = archive.stats().expect("stats");
3090 assert!(stats.database_bytes < u64::try_from(referenced_bytes * 3 / 10).expect("budget"));
3091 }
3092
3093 #[test]
3094 fn detects_corrupt_compressed_payloads() {
3095 let (_directory, mut archive) = archive();
3096 let value = serde_json::json!({"output": "x".repeat(32 * 1024)});
3097 archive
3098 .begin_call(&session(), &call(), &value, &value, 20)
3099 .expect("begin");
3100 archive
3101 .connection
3102 .execute("UPDATE payloads SET body = x'00'", [])
3103 .expect("corrupt");
3104 let error = archive.verify().expect_err("corruption");
3105 assert!(error.contains("payload"));
3106 }
3107
3108 #[test]
3109 fn restore_rejects_corrupt_payloads_before_writing_output() {
3110 let (_directory, mut archive) = archive();
3111 let mut shell_call = call();
3112 shell_call.requires_streams = true;
3113 archive
3114 .begin_call(
3115 &session(),
3116 &shell_call,
3117 &serde_json::json!({}),
3118 &serde_json::json!({}),
3119 20,
3120 )
3121 .expect("begin");
3122 archive
3123 .capture_streams(
3124 &ArchiveKey {
3125 session: session(),
3126 source_call_id: "call-1".to_owned(),
3127 },
3128 30,
3129 &mut Cursor::new(b"raw stdout"),
3130 &mut Cursor::new(b"raw stderr"),
3131 b"after stdout",
3132 b"after stderr",
3133 )
3134 .expect("streams");
3135 archive
3136 .connection
3137 .execute(
3138 "UPDATE payloads SET body = ?1
3139 WHERE sha256 = (
3140 SELECT payload_sha256 FROM snapshots
3141 WHERE subject = 'stdout' AND stage = 'before'
3142 )",
3143 [b"BAD stdout".as_slice()],
3144 )
3145 .expect("corrupt stdout");
3146
3147 let mut stdout = Vec::new();
3148 let mut stderr = Vec::new();
3149 let error = archive
3150 .restore_streams(
3151 &ArchiveKey {
3152 session: session(),
3153 source_call_id: "call-1".to_owned(),
3154 },
3155 &mut stdout,
3156 &mut stderr,
3157 )
3158 .expect_err("corrupt restore");
3159 assert!(error.contains("integrity verification"));
3160 assert!(stdout.is_empty());
3161 assert!(stderr.is_empty());
3162 }
3163
3164 #[test]
3165 fn verification_rejects_values_inserted_without_check_constraints() {
3166 let (_directory, mut archive) = archive();
3167 archive
3168 .begin_call(
3169 &session(),
3170 &call(),
3171 &serde_json::json!({}),
3172 &serde_json::json!({}),
3173 20,
3174 )
3175 .expect("begin");
3176 archive
3177 .connection
3178 .pragma_update(None, "ignore_check_constraints", "ON")
3179 .expect("ignore constraints");
3180 archive
3181 .connection
3182 .execute(
3183 "INSERT INTO snapshots (
3184 tool_call_id, subject, stage, media_type, captured_at_ms, payload_sha256
3185 )
3186 SELECT tool_call_id, 'other', stage, media_type, captured_at_ms, payload_sha256
3187 FROM snapshots WHERE subject = 'input' AND stage = 'before'",
3188 [],
3189 )
3190 .expect("invalid snapshot");
3191
3192 let report = archive.verify().expect("verify");
3193 assert!(
3194 report
3195 .errors
3196 .iter()
3197 .any(|error| error.contains("invalid subject"))
3198 );
3199 }
3200
3201 #[test]
3202 fn concurrent_first_open_initializes_the_schema_once() {
3203 let directory = TempDir::new().expect("temp directory");
3204 let path = directory.path().join("yarp/tool-calls.sqlite3");
3205 let barrier = std::sync::Arc::new(std::sync::Barrier::new(16));
3206 let threads: Vec<_> = (0..16)
3207 .map(|_| {
3208 let path = path.clone();
3209 let barrier = std::sync::Arc::clone(&barrier);
3210 std::thread::spawn(move || {
3211 barrier.wait();
3212 Archive::open_path(path)
3213 .and_then(|archive| archive.stats())
3214 .expect("open archive")
3215 })
3216 })
3217 .collect();
3218 for thread in threads {
3219 assert_eq!(thread.join().expect("join").calls, 0);
3220 }
3221 assert!(
3222 Archive::open_path(path)
3223 .expect("reopen")
3224 .verify()
3225 .expect("verify")
3226 .errors
3227 .is_empty()
3228 );
3229 }
3230
3231 #[test]
3232 fn concurrent_writers_keep_every_call() {
3233 let directory = TempDir::new().expect("temp directory");
3234 let path = directory.path().join("yarp/tool-calls.sqlite3");
3235 drop(Archive::open_path(path.clone()).expect("create"));
3236 let threads: Vec<_> = (0..8)
3237 .map(|index| {
3238 let path = path.clone();
3239 std::thread::spawn(move || {
3240 let mut archive = Archive::open_path(path).expect("open writer");
3241 let call = call_with_id(&format!("call-{index}"));
3242 archive
3243 .begin_call(
3244 &session(),
3245 &call,
3246 &serde_json::json!({"index": index}),
3247 &serde_json::json!({"index": index}),
3248 20,
3249 )
3250 .expect("write call");
3251 })
3252 })
3253 .collect();
3254 for thread in threads {
3255 thread.join().expect("join");
3256 }
3257 let archive = Archive::open_path(path).expect("reopen");
3258 assert_eq!(archive.stats().expect("stats").calls, 8);
3259 assert_eq!(archive.verify().expect("verify").incomplete_calls, 8);
3260 }
3261
3262 #[test]
3263 fn refuses_to_finish_executed_calls_without_required_snapshots() {
3264 let (_directory, mut archive) = archive();
3265 archive
3266 .begin_call(
3267 &session(),
3268 &call(),
3269 &serde_json::json!({}),
3270 &serde_json::json!({}),
3271 20,
3272 )
3273 .expect("begin");
3274 let error = archive
3275 .finish_call(
3276 &session(),
3277 "call-1",
3278 &serde_json::json!({}),
3279 false,
3280 true,
3281 40,
3282 )
3283 .expect_err("missing pre-result");
3284 assert!(error.contains("missing its pre-YARP result"));
3285 assert_eq!(archive.stats().expect("stats").incomplete_calls, 1);
3286 }
3287
3288 #[test]
3289 fn finishes_preflight_rejected_shell_calls_without_streams() {
3290 let (_directory, mut archive) = archive();
3291 let mut shell_call = call();
3292 shell_call.requires_streams = true;
3293 archive
3294 .begin_call(
3295 &session(),
3296 &shell_call,
3297 &serde_json::json!({}),
3298 &serde_json::json!({}),
3299 20,
3300 )
3301 .expect("begin");
3302 archive
3303 .finish_call(
3304 &session(),
3305 "call-1",
3306 &serde_json::json!({"error": "blocked"}),
3307 true,
3308 false,
3309 40,
3310 )
3311 .expect("finish preflight rejection");
3312 assert!(archive.verify().expect("verify").errors.is_empty());
3313 }
3314
3315 #[test]
3316 fn requires_a_pre_result_for_executed_shell_calls() {
3317 let (_directory, mut archive) = archive();
3318 let mut shell_call = call();
3319 shell_call.requires_streams = true;
3320 archive
3321 .begin_call(
3322 &session(),
3323 &shell_call,
3324 &serde_json::json!({}),
3325 &serde_json::json!({}),
3326 20,
3327 )
3328 .expect("begin");
3329 archive
3330 .capture_streams(
3331 &ArchiveKey {
3332 session: session(),
3333 source_call_id: "call-1".to_owned(),
3334 },
3335 30,
3336 &mut Cursor::new(b"stdout"),
3337 &mut Cursor::new(b"stderr"),
3338 b"stdout",
3339 b"stderr",
3340 )
3341 .expect("streams");
3342 let error = archive
3343 .finish_call(
3344 &session(),
3345 "call-1",
3346 &serde_json::json!({}),
3347 false,
3348 true,
3349 40,
3350 )
3351 .expect_err("missing pre-result");
3352 assert!(error.contains("missing its pre-YARP result"));
3353 }
3354
3355 #[test]
3356 fn requires_every_shell_stream_before_finishing() {
3357 let (_directory, mut archive) = archive();
3358 let mut shell_call = call();
3359 shell_call.requires_streams = true;
3360 archive
3361 .begin_call(
3362 &session(),
3363 &shell_call,
3364 &serde_json::json!({}),
3365 &serde_json::json!({}),
3366 20,
3367 )
3368 .expect("begin");
3369 archive
3370 .result_before(&session(), "call-1", &serde_json::json!({}), None, 30)
3371 .expect("before result");
3372 let error = archive
3373 .finish_call(
3374 &session(),
3375 "call-1",
3376 &serde_json::json!({}),
3377 false,
3378 true,
3379 40,
3380 )
3381 .expect_err("missing streams");
3382 assert!(error.contains("found 0 of 4"));
3383 assert_eq!(archive.stats().expect("stats").incomplete_calls, 1);
3384 }
3385
3386 #[test]
3387 fn rejects_truncated_and_oversized_ingest_frames() {
3388 let (_directory, first_archive) = archive();
3389 let truncated = run_ingest_with_archive(first_archive, Cursor::new(vec![0, 1]), Vec::new())
3390 .expect_err("truncated length");
3391 assert!(truncated.contains("truncated ingest frame length"));
3392
3393 let (_directory, second_archive) = archive();
3394 let oversized = (MAX_FRAME_BYTES + 1).to_be_bytes().to_vec();
3395 let error = run_ingest_with_archive(second_archive, Cursor::new(oversized), Vec::new())
3396 .expect_err("oversized frame");
3397 assert!(error.contains("invalid ingest frame length"));
3398 }
3399
3400 #[test]
3401 fn rejects_unsupported_ingest_schema_versions() {
3402 let (_directory, archive) = archive();
3403 let operation = serde_json::json!({
3404 "operation": "begin_call",
3405 "requestId": 1,
3406 "schemaVersion": 2,
3407 "session": session(),
3408 "call": call(),
3409 "inputBefore": {},
3410 "inputAfter": {},
3411 "capturedAtMs": 20
3412 });
3413 let mut output = Vec::new();
3414 run_ingest_with_archive(archive, Cursor::new(framed(&operation)), &mut output)
3415 .expect("protocol response");
3416 let ack: Value = serde_json::from_slice(&output).expect("ack");
3417 assert_eq!(ack["ok"], false);
3418 assert!(
3419 ack["error"]
3420 .as_str()
3421 .is_some_and(|error| error.contains("unsupported ingest"))
3422 );
3423 }
3424
3425 #[test]
3426 fn ingest_protocol_handles_every_operation() {
3427 let (directory, archive) = archive();
3428 let full_output_path = directory.path().join("full-output.log");
3429 fs::write(&full_output_path, b"complete output").expect("full output");
3430 let operations = [
3431 serde_json::json!({
3432 "operation": "begin_call",
3433 "requestId": 1,
3434 "schemaVersion": 1,
3435 "session": session(),
3436 "call": call(),
3437 "inputBefore": {"path": "a"},
3438 "inputAfter": {"path": "a"},
3439 "capturedAtMs": 20
3440 }),
3441 serde_json::json!({
3442 "operation": "result_before",
3443 "requestId": 2,
3444 "schemaVersion": 1,
3445 "session": session(),
3446 "sourceCallId": "call-1",
3447 "result": {"content": "before"},
3448 "fullOutputPath": full_output_path,
3449 "capturedAtMs": 30
3450 }),
3451 serde_json::json!({
3452 "operation": "stage_result",
3453 "requestId": 3,
3454 "schemaVersion": 1,
3455 "session": session(),
3456 "sourceCallId": "call-1",
3457 "result": {"content": "after"},
3458 "isError": false,
3459 "capturedAtMs": 40
3460 }),
3461 serde_json::json!({
3462 "operation": "update_final_result",
3463 "requestId": 4,
3464 "schemaVersion": 1,
3465 "session": session(),
3466 "sourceCallId": "call-1",
3467 "result": {"content": "final"},
3468 "isError": false,
3469 "finishedAtMs": 50
3470 }),
3471 ];
3472 let input: Vec<u8> = operations.iter().flat_map(framed).collect();
3473 let mut output = Vec::new();
3474 run_ingest_with_archive(archive, Cursor::new(input), &mut output).expect("ingest");
3475 let acknowledgements = String::from_utf8(output).expect("UTF-8");
3476 assert_eq!(acknowledgements.lines().count(), 4);
3477 assert!(acknowledgements.contains("\"requestId\":4"));
3478 let reopened = Archive::open_path(directory.path().join("data/tool-calls.sqlite3"))
3479 .expect("reopen archive");
3480 let source_outputs: i64 = reopened
3481 .connection
3482 .query_row(
3483 "SELECT count(*) FROM snapshots WHERE subject = 'source_output'",
3484 [],
3485 |row| row.get(0),
3486 )
3487 .expect("source outputs");
3488 assert_eq!(source_outputs, 1);
3489 }
3490
3491 #[cfg(unix)]
3492 #[test]
3493 fn repairs_archive_permissions_on_open() {
3494 use std::os::unix::fs::PermissionsExt as _;
3495 let directory = TempDir::new().expect("temp directory");
3496 let data = directory.path().join("yarp");
3497 let path = data.join("tool-calls.sqlite3");
3498 drop(Archive::open_path(path.clone()).expect("create"));
3499 fs::set_permissions(&data, fs::Permissions::from_mode(0o777)).expect("directory mode");
3500 fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).expect("file mode");
3501 let archive = Archive::open_path_with_policy(path, true).expect("repair");
3502 assert!(archive.verify().expect("verify").errors.is_empty());
3503 }
3504
3505 #[cfg(unix)]
3506 #[test]
3507 fn refuses_to_modify_an_unrelated_public_directory() {
3508 use std::os::unix::fs::PermissionsExt as _;
3509 let directory = TempDir::new().expect("temp directory");
3510 let public = directory.path().join("yarp");
3511 fs::create_dir(&public).expect("public directory");
3512 fs::set_permissions(&public, fs::Permissions::from_mode(0o777)).expect("public mode");
3513 let error = Archive::open_path(public.join("archive.sqlite3"))
3514 .err()
3515 .expect("insecure directory error");
3516 assert!(error.contains("use a private directory"));
3517 let mode = fs::metadata(public).expect("metadata").permissions().mode() & 0o777;
3518 assert_eq!(mode, 0o777);
3519 }
3520
3521 #[test]
3522 fn ingest_protocol_acknowledges_committed_frames() {
3523 let directory = TempDir::new().expect("temp directory");
3524 let archive = Archive::open_path(directory.path().join("yarp/tool-calls.sqlite3"))
3525 .expect("open archive");
3526 let operation = serde_json::json!({
3527 "operation": "begin_call",
3528 "requestId": 7,
3529 "schemaVersion": 1,
3530 "session": session(),
3531 "call": call(),
3532 "inputBefore": {"path": "a"},
3533 "inputAfter": {"path": "a"},
3534 "capturedAtMs": 20
3535 });
3536 let body = serde_json::to_vec(&operation).expect("json");
3537 let mut input = Vec::new();
3538 input.extend_from_slice(&(body.len() as u64).to_be_bytes());
3539 input.extend_from_slice(&body);
3540 let mut output = Vec::new();
3541 run_ingest_with_archive(archive, Cursor::new(input), &mut output).expect("ingest");
3542 let ack: Value = serde_json::from_slice(&output).expect("ack");
3543 assert_eq!(ack["requestId"], 7);
3544 assert_eq!(ack["ok"], true);
3545 }
3546}