Skip to main content

yarp_cli/
archive.rs

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    /// Open the configured archive and initialize schema version 1 when needed.
285    ///
286    /// # Errors
287    ///
288    /// Returns an error when the path, permissions, `SQLite` database, or schema is invalid.
289    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    /// Open the configured archive without performing any writes.
295    ///
296    /// # Errors
297    ///
298    /// Returns an error when the archive is missing, unreadable, or uses another schema version.
299    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    /// Open an archive at an explicit path.
330    ///
331    /// # Errors
332    ///
333    /// Returns an error when the path, permissions, `SQLite` database, or schema is invalid.
334    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    /// Store one call and its input snapshots before execution.
378    ///
379    /// # Errors
380    ///
381    /// Returns an error when JSON encoding or the `SQLite` transaction fails.
382    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    /// Store the result exposed before YARP result processing.
421    ///
422    /// # Errors
423    ///
424    /// Returns an error when the call is missing or the payload cannot be committed.
425    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    /// Store the exact host-exposed result text selected for post-result reduction.
494    ///
495    /// # Errors
496    ///
497    /// Returns an error when the call is missing, completeness is invalid, or the snapshot cannot
498    /// be committed.
499    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    /// Resolve the stable reference for one internally identified call.
527    ///
528    /// # Errors
529    ///
530    /// Returns an error when the call or reference is missing.
531    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    /// Resolve and verify the canonical recovery sources for one call reference.
551    ///
552    /// # Errors
553    ///
554    /// Returns an error when the reference is malformed, missing, or any selected payload fails
555    /// integrity verification.
556    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    /// Store the provisional post-YARP result while leaving the call incomplete.
616    ///
617    /// # Errors
618    ///
619    /// Returns an error when the call is missing, required snapshots are missing, or the payload
620    /// cannot be committed.
621    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    /// Store the finalized result and mark the call finished.
655    ///
656    /// # Errors
657    ///
658    /// Returns an error when the call is missing or the payload cannot be committed.
659    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    /// Finalize a staged call with the result after every Pi result hook has run.
700    ///
701    /// # Errors
702    ///
703    /// Returns an error when the call is not staged or the final result cannot be committed.
704    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    /// Store exact shell streams before and after pruning.
782    ///
783    /// # Errors
784    ///
785    /// Returns an error when a stream cannot be read or the transaction cannot commit.
786    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    /// Store exact shell streams when a wrapper deliberately passed output through unchanged.
839    ///
840    /// # Errors
841    ///
842    /// Returns an error when a stream cannot be read or the transaction cannot commit.
843    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    /// Restore the exact pre-pruning shell streams for one call.
896    ///
897    /// # Errors
898    ///
899    /// Returns an error when the call, snapshots, or payload bodies cannot be read.
900    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    /// Read aggregate archive statistics without reading payload content.
933    ///
934    /// # Errors
935    ///
936    /// Returns an error when `SQLite` queries or filesystem metadata reads fail.
937    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    /// Verify `SQLite` integrity, relationships, snapshots, payloads, and permissions.
977    ///
978    /// # Errors
979    ///
980    /// Returns an error when verification itself cannot read the archive.
981    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    /// Delete finished calls older than the given Unix millisecond timestamp.
1041    ///
1042    /// # Errors
1043    ///
1044    /// Returns an error when deletion, payload cleanup, or vacuuming fails.
1045    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
1375/// Read framed archive operations and acknowledge each committed transaction.
1376///
1377/// # Errors
1378///
1379/// Returns an error when the archive, frame, operation, or acknowledgement is invalid.
1380pub 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
1525/// Resolve the archive path from the YARP configuration.
1526///
1527/// # Errors
1528///
1529/// Returns an error when configuration or path resolution fails.
1530pub 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}