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