Skip to main content

artifactize_core/store/
receipts.rs

1use crate::types::{
2    Fingerprint, RequestId, RequestStatus, ReuseKey, RunId, RunStatus, SessionId, Timestamp,
3};
4use std::{
5    collections::{BTreeMap, BTreeSet},
6    path::{Path, PathBuf},
7    time::Duration,
8};
9
10use rusqlite::{OptionalExtension, params};
11use serde::{Deserialize, Serialize};
12
13use tokio_rusqlite::Connection;
14
15use super::{ExecutionOptions, Origin, Producer, Provenance, STATE_SCHEMA_VERSION};
16use crate::{
17    config::ProfileKind,
18    workspace::{canonical_target, outside_workspace, prepare_directory},
19};
20
21pub const DATABASE: &str = "state.sqlite";
22/// Yield between WAL initialization races instead of spinning, still within the
23/// shared SQLite contention deadline used by the outer initialization loop.
24const INITIALIZATION_RETRY_INTERVAL: Duration = Duration::from_millis(20);
25
26/// The five tables of a state and the indexes its lookups use.
27///
28/// - `runs`: one row per Run, its changing state without its definitions.
29/// - `run_definitions`: one row per Run with definitions: the Artifacts, evals and relations it
30///   was started with. They are fixed for the Run's life, written once with the Run and never
31///   updated, so a change of the Run never rewrites them.
32/// - `requests`: one row per eval of a Run, at its `ordinal` in the Run's selection order;
33///   `claimed_by` and `claimed_at` hold the Human claim of a waiting request.
34/// - `executions`: one row per execution. `backend` names the Agent backend it runs on, and
35///   a RUNNING execution with a backend holds one of that backend's machine-wide slots. A
36///   completed GREEN/RED execution with a key is a record of the key's history while
37///   `completed_at` (sortable), `bytes` and `last_used` are set; the cache GC and `cache rm`
38///   clear them, and the execution stays.
39/// - `state_meta`: named values of the state itself: `id`, a random UUID made when the
40///   database is created, which identifies the state in Agent session references.
41///
42/// `runs`, `requests` and `executions` carry a `revision` that [`REVISIONS`] raises on every
43/// update of the row, so a reader learns which rows changed by comparing numbers.
44const SCHEMA: &str =
45    "CREATE TABLE runs(id TEXT PRIMARY KEY, repo TEXT NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL, request_key TEXT, revision INTEGER NOT NULL DEFAULT 0);
46    CREATE UNIQUE INDEX run_request_key ON runs(repo,request_key) WHERE request_key IS NOT NULL;
47    CREATE TABLE run_definitions(run_id TEXT PRIMARY KEY REFERENCES runs(id), data TEXT NOT NULL);
48    CREATE TABLE requests(id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id), eval_id TEXT NOT NULL, ordinal INTEGER NOT NULL, execution_id TEXT REFERENCES executions(id), status TEXT NOT NULL, claimed_by TEXT, claimed_at TEXT, data TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 0, UNIQUE(run_id, eval_id), UNIQUE(run_id, ordinal));
49    CREATE TABLE executions(id TEXT PRIMARY KEY, key TEXT, eval_def_hash TEXT NOT NULL, status TEXT NOT NULL, owner_pid INTEGER NOT NULL, owner_start_time INTEGER NOT NULL, backend TEXT, profile_identity TEXT, completed_at TEXT, bytes INTEGER, last_used TEXT, data TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 0);
50    CREATE TABLE state_meta(name TEXT PRIMARY KEY, value TEXT NOT NULL);
51    CREATE INDEX active_request_execution ON requests(execution_id) WHERE status IN ('QUEUED','RUNNING','WAITING_HUMAN');
52    CREATE UNIQUE INDEX active_key ON executions(key,profile_identity) WHERE key IS NOT NULL AND status IN ('RUNNING','WAITING_HUMAN');
53    CREATE INDEX key_history ON executions(key, completed_at);
54    CREATE INDEX definition_history ON executions(eval_def_hash, completed_at);
55    CREATE INDEX running_backend ON executions(backend) WHERE status='RUNNING';";
56
57/// Raise a row's `revision` on every update that does not set it. Writers need not know about
58/// it: the trigger's own update leaves `revision` changed, so it never fires again. The indexes
59/// let readers count, list and compare revisions without reading a record's data:
60/// `request_status` counts a Run's requests by status, `request_marks` and `execution_marks`
61/// hold the revisions of a Run's requests and their executions, `request_claim` the Human
62/// claims, and `request_execution` names the Runs an execution's change touches.
63pub(super) const REVISIONS: &str =
64    "CREATE TRIGGER run_revision AFTER UPDATE ON runs FOR EACH ROW WHEN NEW.revision=OLD.revision BEGIN UPDATE runs SET revision=OLD.revision+1 WHERE rowid=NEW.rowid; END;
65    CREATE TRIGGER request_revision AFTER UPDATE ON requests FOR EACH ROW WHEN NEW.revision=OLD.revision BEGIN UPDATE requests SET revision=OLD.revision+1 WHERE rowid=NEW.rowid; END;
66    CREATE TRIGGER execution_revision AFTER UPDATE ON executions FOR EACH ROW WHEN NEW.revision=OLD.revision BEGIN UPDATE executions SET revision=OLD.revision+1 WHERE rowid=NEW.rowid; END;
67    CREATE INDEX request_status ON requests(status,run_id);
68    CREATE INDEX request_marks ON requests(run_id,ordinal,id,execution_id,revision);
69    CREATE INDEX request_execution ON requests(execution_id,run_id);
70    CREATE INDEX request_claim ON requests(execution_id,id,revision) WHERE claimed_by IS NOT NULL;
71    CREATE INDEX execution_marks ON executions(id,revision);";
72
73/// Versions before the supported additive upgrade boundary are refused.
74pub const EARLIER_STATE: &str = "This state was written by an earlier artifactize. Start a new state (set ARTIFACTIZE_STATE_HOME or move the old one away). Only schema 6 and later supported versions can be upgraded.";
75
76/// Why a state database of schema `version`, neither this one nor uninitialized, is refused.
77pub fn schema_error(version: u32) -> String {
78    match version {
79        0 => "Unsupported state schema version: 0.".into(),
80        version if version < STATE_SCHEMA_VERSION => EARLIER_STATE.into(),
81        version => {
82            format!("Unsupported state schema version: {version}; a newer artifactize wrote it.")
83        }
84    }
85}
86
87#[derive(Debug, thiserror::Error)]
88pub enum Error {
89    #[error(transparent)]
90    Sql(#[from] rusqlite::Error),
91    #[error(transparent)]
92    Json(#[from] serde_json::Error),
93    #[error("{0}")]
94    Invalid(String),
95    /// A saved record that does not decode: the Run it belongs to cannot be opened.
96    #[error("{0}")]
97    Unreadable(super::Unreadable),
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(try_from = "RunWire", into = "RunWire")]
102pub struct Run {
103    pub id: RunId,
104    pub repo_path: PathBuf,
105    /// Optional display metadata; schema 5 Runs written before this remain readable.
106    pub repository: crate::repository::Identity,
107    pub state_dir: PathBuf,
108    pub state: super::RunState,
109    pub modes: crate::runs::RunModes,
110    pub control: Option<crate::runs::Control>,
111    pub request_key: Option<crate::types::RequestKey>,
112    /// What the requester said it is working on; for people only.
113    pub comment: Option<crate::types::RunComment>,
114    pub created_at: Timestamp,
115    pub selection: crate::project::selection::Selection,
116    pub profile: Option<crate::project::selection::ProfileSelection>,
117    pub definitions: super::definitions::Definitions,
118    pub recursive: bool,
119    pub force: bool,
120    pub jobs: Option<usize>,
121    /// Fingerprints computed at once; absent in Runs saved before 0.5.
122    pub fingerprint_jobs: Option<usize>,
123    pub max_executions: Option<u64>,
124    pub executions_started: u64,
125    pub wait_timeout_ms: Option<Duration>,
126    pub wait_timed_out: bool,
127    /// Kinds whose evals only reuse a result; one with nothing to reuse is not executed.
128    pub reuse_only: BTreeSet<ProfileKind>,
129    pub validation: super::Validation,
130    /// Agent backends this Run stopped admitting reviews on, in the order they stopped.
131    pub stopped_backends: Vec<StoppedBackend>,
132    /// Verdicts the Run took from saved results for evals it has no request for, such as the
133    /// dependencies of a partial Run, so readers judge gates on the Run's own evidence.
134    pub evidence: BTreeMap<crate::types::EvalId, crate::types::RequestStatus>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub(crate) struct RunWire {
140    pub id: RunId,
141    #[serde(with = "crate::platform::path_serde")]
142    pub repo_path: PathBuf,
143    /// Optional display metadata; schema 5 Runs written before this remain readable.
144    #[serde(flatten)]
145    pub repository: crate::repository::Identity,
146    #[serde(with = "crate::platform::path_serde")]
147    pub state_dir: PathBuf,
148    #[serde(default, skip_serializing_if = "crate::runs::RunModes::is_default")]
149    pub modes: crate::runs::RunModes,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub control: Option<crate::runs::Control>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub request_key: Option<crate::types::RequestKey>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub comment: Option<crate::types::RunComment>,
156    pub status: RunStatus,
157    pub created_at: Timestamp,
158    pub completed_at: Option<Timestamp>,
159    pub selection: crate::project::selection::Selection,
160    #[serde(default)]
161    pub profile: Option<crate::project::selection::ProfileSelection>,
162    #[serde(default)]
163    pub definitions: super::definitions::Definitions,
164    #[serde(default)]
165    pub recursive: bool,
166    #[serde(default)]
167    pub force: bool,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub jobs: Option<usize>,
170    /// Fingerprints computed at once; absent in Runs saved before 0.5.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub fingerprint_jobs: Option<usize>,
173    #[serde(default)]
174    pub max_executions: Option<u64>,
175    #[serde(default)]
176    pub executions_started: u64,
177    #[serde(default, with = "super::wait_timeout")]
178    pub wait_timeout_ms: Option<Duration>,
179    #[serde(default)]
180    pub wait_timed_out: bool,
181    /// Kinds whose evals only reuse a result; one with nothing to reuse is not executed.
182    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
183    pub reuse_only: BTreeSet<ProfileKind>,
184    pub validation: super::Validation,
185    pub error: Option<String>,
186    /// Agent backends this Run stopped admitting reviews on, in the order they stopped.
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub stopped_backends: Vec<StoppedBackend>,
189    /// Verdicts the Run took from saved results for evals it has no request for, such as the
190    /// dependencies of a partial Run, so readers judge gates on the Run's own evidence.
191    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
192    pub evidence: BTreeMap<crate::types::EvalId, crate::types::RequestStatus>,
193}
194
195impl TryFrom<RunWire> for Run {
196    type Error = String;
197    fn try_from(wire: RunWire) -> Result<Self, Self::Error> {
198        let state = super::RunState::from_parts(wire.status, wire.completed_at, wire.error)?;
199        Ok(Self {
200            state,
201            modes: wire.modes,
202            control: wire.control,
203            request_key: wire.request_key,
204            comment: wire.comment,
205            id: wire.id,
206            repo_path: wire.repo_path,
207            repository: wire.repository,
208            state_dir: wire.state_dir,
209            created_at: wire.created_at,
210            selection: wire.selection,
211            profile: wire.profile,
212            definitions: wire.definitions,
213            recursive: wire.recursive,
214            force: wire.force,
215            jobs: wire.jobs,
216            fingerprint_jobs: wire.fingerprint_jobs,
217            max_executions: wire.max_executions,
218            executions_started: wire.executions_started,
219            wait_timeout_ms: wire.wait_timeout_ms,
220            wait_timed_out: wire.wait_timed_out,
221            reuse_only: wire.reuse_only,
222            validation: wire.validation,
223            stopped_backends: wire.stopped_backends,
224            evidence: wire.evidence,
225        })
226    }
227}
228impl From<Run> for RunWire {
229    fn from(record: Run) -> Self {
230        Self {
231            modes: record.modes.clone(),
232            control: record.control.clone(),
233            request_key: record.request_key.clone(),
234            comment: record.comment.clone(),
235            status: record.status(),
236            completed_at: record.completed_at(),
237            error: record.error().map(str::to_owned),
238            id: record.id,
239            repo_path: record.repo_path,
240            repository: record.repository,
241            state_dir: record.state_dir,
242            created_at: record.created_at,
243            selection: record.selection,
244            profile: record.profile,
245            definitions: record.definitions,
246            recursive: record.recursive,
247            force: record.force,
248            jobs: record.jobs,
249            fingerprint_jobs: record.fingerprint_jobs,
250            max_executions: record.max_executions,
251            executions_started: record.executions_started,
252            wait_timeout_ms: record.wait_timeout_ms,
253            wait_timed_out: record.wait_timed_out,
254            reuse_only: record.reuse_only,
255            validation: record.validation,
256            stopped_backends: record.stopped_backends,
257            evidence: record.evidence,
258        }
259    }
260}
261impl Run {
262    pub fn status(&self) -> RunStatus {
263        self.state.status()
264    }
265    pub fn completed_at(&self) -> Option<Timestamp> {
266        self.state.completed_at()
267    }
268    pub fn error(&self) -> Option<&str> {
269        self.state.error()
270    }
271}
272
273/// An Agent backend a Run stopped after an AUTHENTICATION or QUOTA failure, which every
274/// later review on it would repeat.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct StoppedBackend {
278    pub backend: crate::config::Backend,
279    pub error_code: crate::types::BackendStopCode,
280    pub eval_id: crate::types::EvalId,
281    pub request_id: RequestId,
282    pub error: String,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(try_from = "RequestWire", into = "RequestWire")]
287pub struct Request {
288    pub state: super::RequestState,
289    pub id: RequestId,
290    pub run_id: RunId,
291    pub eval_id: crate::types::EvalId,
292    pub target: crate::types::ArtifactName,
293    pub title: String,
294    pub profile: crate::config::StoredProfile,
295    pub requested_profile: crate::config::StoredProfile,
296    pub eval_def_hash: crate::types::DefinitionHash,
297    /// The requested execution options, or a reused result's.
298    pub options: ExecutionOptions,
299    pub execution_id: Option<crate::types::ExecutionId>,
300    pub provenance: Option<Provenance>,
301    /// Usage spent by this request; a reused request spent none.
302    pub usage: Option<Vec<crate::llm::Attempt>>,
303    /// The reused execution's original usage, never counted as spent.
304    pub reused_usage: Option<Vec<crate::llm::Attempt>>,
305    /// The reused result came from the live execution this request waited for, not a
306    /// completed record. Saved only; output reports it as `source.kind` ([`crate::query::source`]).
307    pub joined: bool,
308    /// Who produced a reused result.
309    pub producer: Option<Producer>,
310    /// The Human claimant who submitted a reused result.
311    pub reviewer: Option<crate::types::ReviewerId>,
312    /// The remote store, publisher and publication time of a result reused from a mirror.
313    pub origin: Option<Origin>,
314    /// The Agent review's session id ([`crate::agent::session_id`]), which every request of
315    /// the review sends as its prompt-cache identity. Absent when no Agent review ran, as on
316    /// reuse, and on requests saved before it existed.
317    pub session_id: Option<SessionId>,
318    /// Where the saved conversation behind this request's result lives: its own review's,
319    /// or for a reused result the producing review's. Absent when none was saved.
320    pub session: Option<crate::agent::session::SessionRef>,
321    pub human_definition: Option<super::HumanDefinition>,
322    pub payload: crate::config::StoredPayload,
323    pub references: BTreeMap<crate::config::ReferenceName, crate::types::ArtifactName>,
324    pub deps: Vec<crate::types::ArtifactName>,
325    pub force: bool,
326    /// The target Artifact's fingerprint.
327    pub fingerprint: Option<Fingerprint>,
328    /// The reuse key; absent without a fingerprint on every Artifact the eval depends on.
329    pub key: Option<ReuseKey>,
330    /// Each Artifact the key covers, with its fingerprint.
331    pub fingerprints: std::collections::BTreeMap<crate::types::ArtifactName, Fingerprint>,
332    pub created_at: Timestamp,
333    pub started_at: Option<Timestamp>,
334    pub cwd: PathBuf,
335    pub run_dir: Option<PathBuf>,
336    pub argv: Option<Vec<String>>,
337    pub child: Option<super::ChildIdentity>,
338    pub blocked_reason: Option<String>,
339    /// Unfulfilled Evals behind this request’s dependency gates.
340    pub blocked_by: Vec<super::Blocker>,
341    /// What a queued request waits for besides a job; `blocked_reason` says it in words.
342    pub queue: Option<QueueReason>,
343}
344
345/// What a queued request waits for besides a job slot of its Run.
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(tag = "kind", rename_all = "camelCase")]
348pub enum QueueReason {
349    /// A free machine-wide slot of this backend (`limits.json`).
350    Slot { backend: crate::config::Backend },
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
354#[serde(rename_all = "camelCase")]
355pub(crate) struct RequestWire {
356    pub id: RequestId,
357    pub run_id: RunId,
358    pub eval_id: crate::types::EvalId,
359    pub target: crate::types::ArtifactName,
360    pub title: String,
361    pub profile: crate::config::StoredProfile,
362    pub requested_profile: crate::config::StoredProfile,
363    pub eval_def_hash: crate::types::DefinitionHash,
364    /// The requested execution options, or a reused result's.
365    #[serde(default)]
366    pub options: ExecutionOptions,
367    pub execution_id: Option<crate::types::ExecutionId>,
368    pub provenance: Option<Provenance>,
369    /// Usage spent by this request; a reused request spent none.
370    pub usage: Option<Vec<crate::llm::Attempt>>,
371    /// The reused execution's original usage, never counted as spent.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub reused_usage: Option<Vec<crate::llm::Attempt>>,
374    /// The reused result came from the live execution this request waited for, not a
375    /// completed record. Saved only; output reports it as `source.kind` ([`crate::query::source`]).
376    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
377    pub joined: bool,
378    /// Who produced a reused result.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub producer: Option<Producer>,
381    /// The Human claimant who submitted a reused result.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub reviewer: Option<crate::types::ReviewerId>,
384    /// The remote store, publisher and publication time of a result reused from a mirror.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub origin: Option<Origin>,
387    /// The Agent review's session id ([`crate::agent::session_id`]), which every request of
388    /// the review sends as its prompt-cache identity. Absent when no Agent review ran, as on
389    /// reuse, and on requests saved before it existed.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub session_id: Option<SessionId>,
392    /// Where the saved conversation behind this request's result lives: its own review's,
393    /// or for a reused result the producing review's. Absent when none was saved.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub session: Option<crate::agent::session::SessionRef>,
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub human_definition: Option<super::HumanDefinition>,
398    pub payload: crate::config::StoredPayload,
399    pub references: BTreeMap<crate::config::ReferenceName, crate::types::ArtifactName>,
400    pub deps: Vec<crate::types::ArtifactName>,
401    #[serde(default)]
402    pub force: bool,
403    /// The target Artifact's fingerprint.
404    #[serde(default)]
405    pub fingerprint: Option<Fingerprint>,
406    /// The reuse key; absent without a fingerprint on every Artifact the eval depends on.
407    #[serde(default)]
408    pub key: Option<ReuseKey>,
409    /// Each Artifact the key covers, with its fingerprint.
410    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
411    pub fingerprints: std::collections::BTreeMap<crate::types::ArtifactName, Fingerprint>,
412    pub status: RequestStatus,
413    pub created_at: Timestamp,
414    pub started_at: Option<Timestamp>,
415    pub completed_at: Option<Timestamp>,
416    #[serde(with = "crate::platform::path_serde")]
417    pub cwd: PathBuf,
418    #[serde(default, with = "crate::platform::path_serde::option")]
419    pub run_dir: Option<PathBuf>,
420    pub argv: Option<Vec<String>>,
421    pub child: Option<super::ChildIdentity>,
422    pub result: Option<super::ExecutionResult>,
423    pub error: Option<String>,
424    pub error_code: Option<crate::types::FailureCode>,
425    pub blocked_reason: Option<String>,
426    /// Unfulfilled Evals behind this request’s dependency gates.
427    #[serde(default, skip_serializing_if = "Vec::is_empty")]
428    pub blocked_by: Vec<super::Blocker>,
429    /// Absent from requests saved before it existed, which read as waiting for a job.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub queue: Option<QueueReason>,
432}
433
434impl TryFrom<RequestWire> for Request {
435    type Error = String;
436    fn try_from(wire: RequestWire) -> Result<Self, Self::Error> {
437        let state = super::RequestState::from_parts(
438            wire.status,
439            wire.result,
440            wire.error,
441            wire.error_code,
442            wire.completed_at,
443        )?;
444        Ok(Self {
445            state,
446            id: wire.id,
447            run_id: wire.run_id,
448            eval_id: wire.eval_id,
449            target: wire.target,
450            title: wire.title,
451            profile: wire.profile,
452            requested_profile: wire.requested_profile,
453            eval_def_hash: wire.eval_def_hash,
454            options: wire.options,
455            execution_id: wire.execution_id,
456            provenance: wire.provenance,
457            usage: wire.usage,
458            reused_usage: wire.reused_usage,
459            joined: wire.joined,
460            producer: wire.producer,
461            reviewer: wire.reviewer,
462            origin: wire.origin,
463            session_id: wire.session_id,
464            session: wire.session,
465            human_definition: wire.human_definition,
466            payload: wire.payload,
467            references: wire.references,
468            deps: wire.deps,
469            force: wire.force,
470            fingerprint: wire.fingerprint,
471            key: wire.key,
472            fingerprints: wire.fingerprints,
473            created_at: wire.created_at,
474            started_at: wire.started_at,
475            cwd: wire.cwd,
476            run_dir: wire.run_dir,
477            argv: wire.argv,
478            child: wire.child,
479            blocked_reason: wire.blocked_reason,
480            blocked_by: wire.blocked_by,
481            queue: wire.queue,
482        })
483    }
484}
485impl From<Request> for RequestWire {
486    fn from(record: Request) -> Self {
487        Self {
488            status: record.status(),
489            result: record.result().cloned(),
490            error: record.error().map(str::to_owned),
491            error_code: record.error_code(),
492            completed_at: record.completed_at(),
493            id: record.id,
494            run_id: record.run_id,
495            eval_id: record.eval_id,
496            target: record.target,
497            title: record.title,
498            profile: record.profile,
499            requested_profile: record.requested_profile,
500            eval_def_hash: record.eval_def_hash,
501            options: record.options,
502            execution_id: record.execution_id,
503            provenance: record.provenance,
504            usage: record.usage,
505            reused_usage: record.reused_usage,
506            joined: record.joined,
507            producer: record.producer,
508            reviewer: record.reviewer,
509            origin: record.origin,
510            session_id: record.session_id,
511            session: record.session,
512            human_definition: record.human_definition,
513            payload: record.payload,
514            references: record.references,
515            deps: record.deps,
516            force: record.force,
517            fingerprint: record.fingerprint,
518            key: record.key,
519            fingerprints: record.fingerprints,
520            created_at: record.created_at,
521            started_at: record.started_at,
522            cwd: record.cwd,
523            run_dir: record.run_dir,
524            argv: record.argv,
525            child: record.child,
526            blocked_reason: record.blocked_reason,
527            blocked_by: record.blocked_by,
528            queue: record.queue,
529        }
530    }
531}
532impl Request {
533    pub fn status(&self) -> crate::types::RequestStatus {
534        self.state.status()
535    }
536    pub fn result(&self) -> Option<&super::ExecutionResult> {
537        self.state.result()
538    }
539    pub fn error(&self) -> Option<&str> {
540        self.state.error()
541    }
542    pub fn error_code(&self) -> Option<crate::types::FailureCode> {
543        self.state.error_code()
544    }
545    pub fn completed_at(&self) -> Option<crate::types::Timestamp> {
546        self.state.completed_at()
547    }
548}
549
550#[derive(Debug, Serialize, Deserialize)]
551pub struct RunView {
552    #[serde(flatten)]
553    pub run: Run,
554    pub requests: Vec<std::sync::Arc<Request>>,
555    #[serde(default, skip_serializing_if = "Vec::is_empty")]
556    pub unreadable: Vec<super::Unreadable>,
557}
558
559#[derive(Clone)]
560pub struct Receipts {
561    pub(super) connection: NotifyingConnection,
562}
563
564/// A test writer closes its SQLite worker before the caller's temporary state is removed.
565#[cfg(any(test, feature = "test-support"))]
566pub struct OwnedReceipts(Receipts);
567
568#[cfg(any(test, feature = "test-support"))]
569impl OwnedReceipts {
570    pub async fn open(state: &Path, repo: &Path) -> Result<Self, String> {
571        Receipts::open(state, repo).await.map(Self)
572    }
573}
574
575#[cfg(any(test, feature = "test-support"))]
576impl std::ops::Deref for OwnedReceipts {
577    type Target = Receipts;
578    fn deref(&self) -> &Self::Target {
579        &self.0
580    }
581}
582
583#[cfg(any(test, feature = "test-support"))]
584impl Drop for OwnedReceipts {
585    fn drop(&mut self) {
586        let connection = self.0.connection.inner.clone();
587        // Drop can run on a current-thread Tokio test runtime, including during unwinding.
588        // A separate joined thread avoids nesting runtimes or detaching SQLite teardown.
589        let closed = std::thread::spawn(move || {
590            tokio::runtime::Builder::new_current_thread()
591                .build()
592                .expect("create the test writer's teardown runtime")
593                .block_on(connection.close())
594        })
595        .join();
596        if !std::thread::panicking() {
597            closed
598                .expect("join the test writer's teardown")
599                .expect("close the test writer's SQLite connection");
600        }
601    }
602}
603
604#[cfg(test)]
605mod ownership_tests {
606    use super::*;
607
608    #[tokio::test]
609    async fn failed_writer_initialization_releases_sqlite_before_returning_the_schema_error() {
610        let root = crate::test_os::tempdir();
611        let state = root.path().join("state");
612        let repo = root.path().join("repo");
613        std::fs::create_dir(&state).unwrap();
614        std::fs::create_dir(&repo).unwrap();
615        let database = state.join(DATABASE);
616        let connection = rusqlite::Connection::open(&database).unwrap();
617        connection
618            .execute_batch("CREATE TABLE unrelated(value TEXT)")
619            .unwrap();
620        drop(connection);
621        let error = match OwnedReceipts::open(&state, &repo).await {
622            Ok(_) => panic!("an unversioned nonempty database is not our schema"),
623            Err(error) => error,
624        };
625        assert!(error.contains(&schema_error(0)), "{error}");
626        assert!(crate::test_os::database_released(&database).unwrap());
627        root.close().unwrap();
628    }
629
630    #[tokio::test]
631    async fn explicit_writer_close_releases_sqlite_before_return_and_closes_clones() {
632        let root = crate::test_os::tempdir();
633        let state = root.path().join("state");
634        let repo = root.path().join("repo");
635        std::fs::create_dir(&repo).unwrap();
636        let writer = OwnedReceipts::open(&state, &repo).await.unwrap();
637        let retained = Receipts::clone(&writer);
638        let database = state.join(DATABASE);
639        assert!(!crate::test_os::database_released(&database).unwrap());
640        Receipts::clone(&writer).close().await.unwrap();
641        assert!(crate::test_os::database_released(&database).unwrap());
642        assert_eq!(retained.state_id().await.unwrap_err(), "ConnectionClosed");
643        root.close().unwrap();
644    }
645
646    #[tokio::test]
647    async fn owned_writer_closes_before_temp_state_removal_on_success_and_unwind() {
648        for unwind in [false, true] {
649            let root = crate::test_os::tempdir();
650            let state = root.path().join("state");
651            let repo = root.path().join("repo");
652            std::fs::create_dir(&repo).unwrap();
653            let writer = OwnedReceipts::open(&state, &repo).await.unwrap();
654            assert!(writer.state_id().await.is_ok());
655            // Even another writer handle cannot retain the worker after fixture teardown.
656            let retained = Receipts::clone(&writer);
657            if unwind {
658                let failed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
659                    let _writer = writer;
660                    panic!("fixture assertion failure");
661                }));
662                assert!(failed.is_err());
663            } else {
664                drop(writer);
665            }
666            let error = retained.state_id().await.unwrap_err();
667            assert_eq!(error, "ConnectionClosed");
668            root.close().unwrap();
669        }
670    }
671}
672
673/// Notify at the DB-worker completion boundary, even if the awaiting caller was cancelled.
674/// total_changes is conservative (rolled-back changes can invalidate harmlessly). Only a
675/// finished autocommit edge can publish; no event is an audit record or proof of success.
676#[derive(Clone)]
677pub(crate) struct NotifyingConnection {
678    inner: Connection,
679    publisher: crate::changes::Publisher,
680    /// The connection notes which Runs its writes touch ([`super::touched`]).
681    noting: std::sync::Arc<std::sync::atomic::AtomicBool>,
682}
683impl NotifyingConnection {
684    pub(super) async fn finish<T>(self, result: Result<T, String>) -> Result<T, String> {
685        super::connection::finish(self.inner, result).await
686    }
687    pub(crate) fn new(inner: Connection, state: &Path) -> Self {
688        Self {
689            inner,
690            publisher: crate::changes::Publisher::new(state),
691            noting: Default::default(),
692        }
693    }
694    pub(crate) async fn call<F, R, E>(&self, function: F) -> Result<R, tokio_rusqlite::Error<E>>
695    where
696        F: FnOnce(&mut rusqlite::Connection) -> Result<R, E> + Send + 'static,
697        R: Send + 'static,
698        E: Send + 'static,
699    {
700        let publisher = self.publisher.clone();
701        let noting = self.noting.clone();
702        self.inner
703            .call(move |db| {
704                if !noting.load(std::sync::atomic::Ordering::Relaxed)
705                    && db.is_autocommit()
706                    && super::touched::install(db).unwrap_or(false)
707                {
708                    noting.store(true, std::sync::atomic::Ordering::Relaxed);
709                }
710                let before = db.total_changes();
711                let result = function(db);
712                if db.is_autocommit() && db.total_changes() != before {
713                    for change in super::touched::take(db) {
714                        publisher.notify(change);
715                    }
716                }
717                result
718            })
719            .await
720    }
721}
722
723impl Receipts {
724    /// Close the shared SQLite connection before releasing its state files. Any cloned
725    /// receipts also become closed; call this only after all operations on them finish.
726    pub async fn close(self) -> Result<(), String> {
727        self.connection.finish(Ok(())).await
728    }
729
730    pub async fn open(state: &Path, repo: &Path) -> Result<Self, String> {
731        let state = prepare_directory(state, repo).map_err(|e| e.to_string())?;
732        check_files(&state)?;
733        let state_id: crate::types::StateId = crate::agent::uuid()?.parse()?;
734        let connection = Connection::open(state.join(DATABASE))
735            .await
736            .map_err(|e| e.to_string())?;
737        let connection = NotifyingConnection::new(connection, &state);
738        // WAL initialization retries share the database contention budget.
739        let deadline = tokio::time::Instant::now() + super::SQLITE_BUSY_TIMEOUT;
740        loop {
741            let state_id = state_id.clone();
742            let initialized = connection
743                .call(move |db| -> Result<(), Error> {
744                    super::wait_when_busy(db)?;
745                    db.pragma_update(None, "foreign_keys", true)?;
746                    let version: u32 =
747                        db.pragma_query_value(None, "user_version", |row| row.get(0))?;
748                    if version != 0
749                        && !(super::migrations::FIRST_VERSION..=STATE_SCHEMA_VERSION)
750                            .contains(&version)
751                    {
752                        return Err(Error::Invalid(schema_error(version)));
753                    }
754                    db.pragma_update(None, "journal_mode", "WAL")?;
755                    let transaction =
756                        db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
757                    // Publish the schema, the state's id and the version together; readers see
758                    // an empty snapshot until commit.
759                    super::migrations::apply(&transaction)?;
760                    if !schema_initialized(&transaction)? {
761                        transaction.execute_batch(SCHEMA)?;
762                        transaction.execute_batch(REVISIONS)?;
763                        transaction.execute(
764                            "INSERT INTO state_meta(name,value) VALUES ('id',?)",
765                            [&state_id],
766                        )?;
767                        transaction.pragma_update(None, "user_version", STATE_SCHEMA_VERSION)?;
768                    }
769                    transaction.commit()?;
770                    Ok(())
771                })
772                .await;
773            match initialized {
774                Ok(()) => break,
775                Err(tokio_rusqlite::Error::Error(Error::Sql(rusqlite::Error::SqliteFailure(
776                    error,
777                    _,
778                )))) if error.code == rusqlite::ErrorCode::DatabaseBusy
779                    && tokio::time::Instant::now() < deadline =>
780                {
781                    // Concurrent first opens can race when enabling WAL despite the busy handler.
782                    tokio::time::sleep(INITIALIZATION_RETRY_INTERVAL).await;
783                }
784                Err(error) => return connection.finish(Err(error.to_string())).await,
785            }
786        }
787        Ok(Self { connection })
788    }
789
790    /// This state's stable id ([`read_state_id`]).
791    pub async fn state_id(&self) -> Result<crate::types::StateId, String> {
792        self.connection
793            .call(|db| -> Result<crate::types::StateId, Error> {
794                Ok(
795                    db.query_row("SELECT value FROM state_meta WHERE name='id'", [], |row| {
796                        row.get(0)
797                    })?,
798                )
799            })
800            .await
801            .map_err(|e| e.to_string())
802    }
803
804    pub async fn create_run(&self, run: &Run, requests: &[Request]) -> Result<(), String> {
805        for request in requests {
806            if request.run_id != run.id {
807                return Err("Request belongs to another Run.".into());
808            }
809        }
810        let run = run.clone();
811        let requests = requests.to_vec();
812        self.connection
813            .call(move |db| -> Result<(), Error> {
814                let transaction = db.transaction()?;
815                super::run_record::insert(&transaction, &run)?;
816                for (ordinal, request) in requests.iter().enumerate() {
817                    transaction.execute(
818                        "INSERT INTO requests(id,run_id,eval_id,ordinal,status,data) VALUES (?,?,?,?,?,?)",
819                        params![
820                            request.id,
821                            run.id,
822                            request.eval_id,
823                            ordinal as i64,
824                            request.status(),
825                            serde_json::to_string(request)?
826                        ],
827                    )?;
828                }
829                transaction.commit()?;
830                Ok(())
831            })
832            .await
833            .map_err(|e| e.to_string())
834    }
835
836    pub async fn save_request(&self, request: &Request) -> Result<(), String> {
837        let request = request.clone();
838        self.connection
839            .call(move |db| update_request(db, &request))
840            .await
841            .map_err(|e| e.to_string())
842    }
843
844    pub async fn save_run(&self, run: &Run) -> Result<(), String> {
845        let run = run.clone();
846        self.connection
847            .call(move |db| -> Result<(), Error> {
848                let transaction =
849                    db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
850                super::run_control::save(&transaction, run)?;
851                transaction.commit()?;
852                Ok(())
853            })
854            .await
855            .map_err(|e| e.to_string())
856    }
857
858    pub async fn finish(&self, run: &Run, requests: &[Request]) -> Result<(), String> {
859        let run = run.clone();
860        let requests = requests.to_vec();
861        self.connection
862            .call(move |db| -> Result<(), Error> {
863                let transaction =
864                    db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
865                for request in requests {
866                    // A concurrent submission may already have settled this saved request.
867                    if request.status().is_human_wait() {
868                        continue;
869                    }
870                    transaction.execute("UPDATE requests SET status=?,data=?,execution_id=? WHERE id=? AND status NOT IN ('GREEN','RED','ERROR')", params![request.status(),serde_json::to_string(&request)?,request.execution_id,request.id])?;
871                }
872                super::run_control::save(&transaction, run)?;
873                transaction.commit()?;
874                Ok(())
875            })
876            .await
877            .map_err(|e| e.to_string())
878    }
879}
880
881pub(super) fn update_request(db: &rusqlite::Connection, request: &Request) -> Result<(), Error> {
882    if db.execute(
883        "UPDATE requests SET status=?,data=?,execution_id=? WHERE id=?",
884        params![
885            request.status(),
886            serde_json::to_string(request)?,
887            request.execution_id,
888            request.id
889        ],
890    )? != 1
891    {
892        return Err(Error::Invalid("Review request not found.".into()));
893    }
894    Ok(())
895}
896
897pub(super) fn schema_initialized(transaction: &rusqlite::Transaction<'_>) -> Result<bool, Error> {
898    let version: u32 = transaction.pragma_query_value(None, "user_version", |row| row.get(0))?;
899    // Check both in one snapshot: initialization may commit between separate reads.
900    if version == 0
901        && !transaction.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master)", [], |row| {
902            row.get::<_, bool>(0)
903        })?
904    {
905        return Ok(false);
906    }
907    if version != STATE_SCHEMA_VERSION {
908        return Err(Error::Invalid(schema_error(version)));
909    }
910    Ok(true)
911}
912
913/// Reject non-regular state files and a database of another schema before any read or write.
914pub(super) fn check_files(state: &Path) -> Result<(), String> {
915    match state_schema(state)? {
916        Some(version)
917            if (super::migrations::FIRST_VERSION..STATE_SCHEMA_VERSION).contains(&version) =>
918        {
919            super::migrations::open_existing(state)
920        }
921        Some(version) if version != 0 && version != STATE_SCHEMA_VERSION => {
922            Err(schema_error(version))
923        }
924        _ => Ok(()),
925    }
926}
927
928/// The state database's schema version, read without creating it; `None` without a database.
929/// `doctor` reports it.
930pub fn state_schema(state: &Path) -> Result<Option<u32>, String> {
931    let state = canonical_target(state).map_err(|e| e.to_string())?;
932    regular_files(&state)?;
933    let database = state.join(DATABASE);
934    if !database.try_exists().map_err(|e| e.to_string())? {
935        return Ok(None);
936    }
937    let read = || -> Result<u32, rusqlite::Error> {
938        let db = rusqlite::Connection::open_with_flags(
939            &database,
940            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
941        )?;
942        super::wait_when_busy(&db)?;
943        db.pragma_query_value(None, "user_version", |row| row.get(0))
944    };
945    let version = read().map_err(|e| e.to_string())?;
946    if (super::migrations::FIRST_VERSION..STATE_SCHEMA_VERSION).contains(&version) {
947        super::migrations::open_existing(&state)?;
948        return read().map(Some).map_err(|e| e.to_string());
949    }
950    Ok(Some(version))
951}
952
953/// The state's stable id, read without creating anything; `None` until a command that writes
954/// the state has opened it.
955pub fn read_state_id(state: &Path) -> Result<Option<crate::types::StateId>, String> {
956    let state = canonical_target(state).map_err(|e| e.to_string())?;
957    check_files(&state)?;
958    let database = state.join(DATABASE);
959    if !database.try_exists().map_err(|e| e.to_string())? {
960        return Ok(None);
961    }
962    let read = || -> Result<Option<crate::types::StateId>, Error> {
963        let mut db = rusqlite::Connection::open_with_flags(
964            &database,
965            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
966        )?;
967        super::wait_when_busy(&db)?;
968        let transaction = db.transaction()?;
969        if !schema_initialized(&transaction)? {
970            return Ok(None);
971        }
972        Ok(transaction
973            .query_row("SELECT value FROM state_meta WHERE name='id'", [], |row| {
974                row.get(0)
975            })
976            .optional()?)
977    };
978    read().map_err(|e| e.to_string())
979}
980
981pub(crate) fn regular_files(state: &Path) -> Result<(), String> {
982    regular_files_with_retry(state, || {
983        std::thread::sleep(super::TRANSIENT_ACCESS_BACKOFF);
984    })
985}
986
987/// The probe and retry path is shared with the seam test; only the between-attempt effect
988/// differs, so a fixture can release a pending-delete handle exactly after a failed probe.
989pub(super) fn regular_files_with_retry(
990    state: &Path,
991    mut before_retry: impl FnMut(),
992) -> Result<(), String> {
993    for suffix in ["", "-wal", "-shm"] {
994        let path = state.join(format!("{DATABASE}{suffix}"));
995        match super::retry_transient_access(|| crate::platform::path_kind(&path), &mut before_retry)
996        {
997            Ok(kind) if kind != crate::platform::FileKind::File => {
998                return Err(format!(
999                    "State files must be regular files: {}",
1000                    crate::platform::path_text(&path)
1001                ));
1002            }
1003            Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
1004                return Err(error.to_string());
1005            }
1006            _ => {}
1007        }
1008    }
1009    Ok(())
1010}
1011
1012#[derive(Debug, Clone, Serialize)]
1013#[serde(rename_all = "camelCase")]
1014pub struct LastRequest {
1015    pub run_id: RunId,
1016    pub verdict: RequestStatus,
1017    #[serde(skip_serializing_if = "Option::is_none")]
1018    pub fingerprint: Option<Fingerprint>,
1019}
1020
1021/// Last attempts are audit pointers, never evidence for a new current-input query.
1022pub async fn read_latest_requests(
1023    state: &Path,
1024    repo: &Path,
1025) -> Result<std::collections::BTreeMap<crate::types::EvalId, LastRequest>, String> {
1026    let state = canonical_target(state).map_err(|e| e.to_string())?;
1027    outside_workspace(repo, &state).map_err(|e| e.to_string())?;
1028    check_files(&state)?;
1029    if !state
1030        .join(DATABASE)
1031        .try_exists()
1032        .map_err(|e| e.to_string())?
1033    {
1034        return Ok(Default::default());
1035    }
1036    let connection = Connection::open_with_flags(
1037        state.join(DATABASE),
1038        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
1039    )
1040    .await
1041    .map_err(|e| e.to_string())?;
1042    let repo = repo.to_path_buf();
1043    let result = connection
1044        .call(move |db| -> Result<_, Error> {
1045            super::wait_when_busy(db)?;
1046            let transaction = db.transaction()?;
1047            if !schema_initialized(&transaction)? {
1048                return Ok(Default::default());
1049            }
1050            let latest = {
1051                let mut statement = transaction.prepare(
1052                    "SELECT data,run_id FROM (
1053                SELECT q.eval_id,q.data,q.run_id,
1054                    row_number() OVER (PARTITION BY q.eval_id ORDER BY r.rowid DESC) AS rank
1055                FROM requests q JOIN runs r ON r.id=q.run_id
1056                WHERE r.repo=?
1057            ) WHERE rank=1 ORDER BY eval_id",
1058                )?;
1059                // A Run's record is large and most latest requests share a few Runs: read and
1060                // decode each Run once, not once per eval.
1061                let mut run_data = transaction.prepare("SELECT data FROM runs WHERE id=?")?;
1062                let mut readable = std::collections::BTreeMap::<String, bool>::new();
1063                statement
1064                    .query_map([crate::platform::path_text(&repo)], |row| {
1065                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1066                    })?
1067                    .filter_map(|row| match row {
1068                        Err(error) => Some(Err(error)),
1069                        Ok((data, run)) => {
1070                            let run_readable = match readable.get(&run) {
1071                                Some(known) => *known,
1072                                None => {
1073                                    let known = match run_data
1074                                        .query_row([&run], |row| row.get::<_, String>(0))
1075                                    {
1076                                        Ok(run_data) => {
1077                                            super::unreadable::evidence::<Run>("run", &run_data)
1078                                                .is_some()
1079                                        }
1080                                        Err(error) => return Some(Err(error)),
1081                                    };
1082                                    readable.insert(run, known);
1083                                    known
1084                                }
1085                            };
1086                            run_readable
1087                                .then(|| super::unreadable::evidence::<Request>("request", &data))
1088                                .flatten()
1089                                .map(|request| {
1090                                    Ok((
1091                                        request.eval_id.clone(),
1092                                        LastRequest {
1093                                            run_id: request.run_id.clone(),
1094                                            verdict: request.status(),
1095                                            fingerprint: request.fingerprint,
1096                                        },
1097                                    ))
1098                                })
1099                        }
1100                    })
1101                    .collect::<Result<_, _>>()?
1102            };
1103            transaction.commit()?;
1104            Ok(latest)
1105        })
1106        .await
1107        .map_err(|e| e.to_string());
1108    super::connection::finish(connection, result).await
1109}