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