Skip to main content

sloop/run_store/
evidence.rs

1use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
2use serde::{Deserialize, Serialize};
3
4use super::{ProjectCommitEvidence, RunStore};
5use crate::db::StoreError;
6
7/// One appended `run_evidence` row: a kind plus kind-specific JSON facts.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct EvidenceRecord {
10    pub kind: &'static str,
11    pub data_json: String,
12}
13
14/// Where in a stage execution a log row was written. An execution whose
15/// `result_check` runs an independent actor records both phases as two rows
16/// sharing `stage_index` and `attempt`; every other execution is judged
17/// without a second process and records its action alone.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum StagePhase {
20    Action,
21    Check,
22}
23
24impl StagePhase {
25    pub fn as_str(self) -> &'static str {
26        match self {
27            Self::Action => "action",
28            Self::Check => "check",
29        }
30    }
31
32    /// Anything that is not the check phase is the action phase: rows written
33    /// before the split carry no phase of their own and are all actions.
34    fn parse(value: &str) -> Self {
35        match value {
36            "check" => Self::Check,
37            _ => Self::Action,
38        }
39    }
40}
41
42/// One row of a run's ordered stage-evidence log.
43///
44/// Rows are produced and read in log order — the storage assigns each a
45/// per-run `seq` at insert and hands them back in it — because the walk
46/// replays them rather than looking them up by stage. The resolved stage
47/// verdict rides on the last row of an execution; earlier rows carry only what
48/// their own actor produced and leave `state` absent.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct StageRecord {
51    pub stage_index: usize,
52    pub stage: String,
53    /// 1 for a stage's first execution, incrementing each time the walk
54    /// re-enters it.
55    pub attempt: u32,
56    pub phase: StagePhase,
57    /// `passed` or `failed` once the execution resolved; absent on a row the
58    /// resolution does not ride on.
59    pub state: Option<String>,
60    pub started_at_ms: i64,
61    pub finished_at_ms: i64,
62    pub exit_code: Option<i32>,
63    pub output_ref: String,
64    pub verdict_source: Option<String>,
65    pub reason: Option<String>,
66}
67
68/// One panel reviewer's report, as the storage takes it. The seat's key is
69/// separate from the report's content because only the key is authoritative:
70/// it comes from the reviewer's credential.
71#[derive(Debug, Clone, Copy)]
72pub struct PanelReportRecord<'a> {
73    pub stage: &'a str,
74    pub stage_index: usize,
75    pub attempt: u32,
76    pub reviewer_index: usize,
77    pub verdict: &'a str,
78    pub confidence: &'a str,
79    pub reason: &'a str,
80}
81
82/// Durable watchdog intent, recorded before the agent process group is killed.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct OutputStallEvidence {
85    pub stage: String,
86    pub last_output_at_ms: i64,
87    pub threshold_ms: i64,
88    pub last_output_sequence: Option<u64>,
89}
90
91pub(crate) mod tx {
92    use rusqlite::{Transaction, params};
93
94    use super::{EvidenceRecord, OutputStallEvidence, PanelReportRecord, StageRecord};
95
96    pub(crate) fn delete_for_run(
97        transaction: &Transaction<'_>,
98        run_id: &str,
99    ) -> rusqlite::Result<usize> {
100        let evidence = transaction.execute(
101            "DELETE FROM run_evidence WHERE run_id = ?1",
102            params![run_id],
103        )?;
104        let stages =
105            transaction.execute("DELETE FROM stage_runs WHERE run_id = ?1", params![run_id])?;
106        Ok(evidence + stages)
107    }
108
109    pub(crate) fn record_settlement(
110        transaction: &Transaction<'_>,
111        run_id: &str,
112        evidence: &[EvidenceRecord],
113        now_ms: i64,
114    ) -> rusqlite::Result<()> {
115        for record in evidence {
116            transaction.execute(
117                "INSERT OR IGNORE INTO run_evidence
118                     (run_id, kind, observed_at_ms, dedupe_key, data_json)
119                 VALUES (?1, ?2, ?3, 'settlement:' || ?1 || ':' || ?2, ?4)",
120                params![run_id, record.kind, now_ms, record.data_json],
121            )?;
122        }
123        Ok(())
124    }
125
126    /// Records what the run's primary agent stage did on its `attempt`-th
127    /// execution.
128    ///
129    /// These rows are the run's exit *story*, not a log of exits: they carry
130    /// the code the run settles on, the commits the ticket is credited with,
131    /// and the rejection that puts a target on cooldown. A backward edge can
132    /// re-enter the agent stage, so each execution overwrites the last rather
133    /// than being ignored behind it — a resumed run must settle on what the
134    /// most recent agent actually did, and `attempt` says which execution that
135    /// was. Rows whose fact no longer holds (a vendor rejection the re-run did
136    /// not repeat) are removed rather than left to speak for an exit that
137    /// never had them.
138    #[allow(clippy::too_many_arguments)]
139    pub(crate) fn record_agent_exit(
140        transaction: &Transaction<'_>,
141        run_id: &str,
142        attempt: u32,
143        exit_code: Option<i32>,
144        capture_complete: bool,
145        commits_json: &str,
146        vendor_error: Option<&crate::vendor_error::VendorErrorMatch>,
147        cooldown_until_ms: Option<i64>,
148        now_ms: i64,
149    ) -> rusqlite::Result<()> {
150        let evidence = [
151            EvidenceRecord {
152                kind: "exit_classified",
153                data_json: serde_json::json!({"exit_code": exit_code, "attempt": attempt})
154                    .to_string(),
155            },
156            EvidenceRecord {
157                kind: "commits_observed",
158                data_json: commits_json.to_owned(),
159            },
160        ];
161        for record in evidence {
162            record_stage_evidence(transaction, run_id, record.kind, &record.data_json, now_ms)?;
163        }
164        match vendor_error {
165            Some(vendor_error) => record_stage_evidence(
166                transaction,
167                run_id,
168                "vendor_error_classified",
169                &vendor_error.evidence_json(cooldown_until_ms),
170                now_ms,
171            )?,
172            None => delete_settlement(transaction, run_id, "vendor_error_classified")?,
173        }
174        if capture_complete {
175            delete_settlement(transaction, run_id, "capture_incomplete")?;
176        } else {
177            record_stage_evidence(transaction, run_id, "capture_incomplete", "{}", now_ms)?;
178        }
179        Ok(())
180    }
181
182    fn delete_settlement(
183        transaction: &Transaction<'_>,
184        run_id: &str,
185        kind: &str,
186    ) -> rusqlite::Result<()> {
187        transaction.execute(
188            "DELETE FROM run_evidence
189             WHERE run_id = ?1 AND dedupe_key = 'settlement:' || ?1 || ':' || ?2",
190            params![run_id, kind],
191        )?;
192        Ok(())
193    }
194
195    /// Appends one stage execution's rows to the run's log, in the order
196    /// given. `seq` is read off the run's own tail inside this transaction, so
197    /// two executions racing to append can never claim the same position.
198    pub(crate) fn append_stage_rows(
199        transaction: &Transaction<'_>,
200        run_id: &str,
201        rows: &[StageRecord],
202    ) -> rusqlite::Result<()> {
203        for row in rows {
204            let evidence_json = serde_json::json!({
205                "output": row.output_ref,
206                "verdict_source": row.verdict_source,
207                "reason": row.reason,
208            })
209            .to_string();
210            // A re-executed stage overwrites its own row rather than
211            // duplicating it, and `seq` is deliberately absent from the update
212            // so the rewrite keeps the log position it first took.
213            transaction.execute(
214                "INSERT INTO stage_runs
215                     (run_id, seq, stage_index, stage, state, attempt, phase,
216                      started_at_ms, finished_at_ms, exit_code, evidence_json)
217                 VALUES (?1,
218                         (SELECT COALESCE(MAX(seq), 0) + 1
219                          FROM stage_runs WHERE run_id = ?1),
220                         ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
221                 ON CONFLICT(run_id, stage_index, attempt, phase) DO UPDATE SET
222                     stage = excluded.stage,
223                     state = excluded.state,
224                     started_at_ms = excluded.started_at_ms,
225                     finished_at_ms = excluded.finished_at_ms,
226                     exit_code = excluded.exit_code,
227                     evidence_json = excluded.evidence_json",
228                params![
229                    run_id,
230                    row.stage_index as i64,
231                    row.stage,
232                    row.state,
233                    row.attempt,
234                    row.phase.as_str(),
235                    row.started_at_ms,
236                    row.finished_at_ms,
237                    row.exit_code,
238                    evidence_json,
239                ],
240            )?;
241        }
242        Ok(())
243    }
244
245    pub(crate) fn record_stage_evidence(
246        transaction: &Transaction<'_>,
247        run_id: &str,
248        kind: &str,
249        data_json: &str,
250        now_ms: i64,
251    ) -> rusqlite::Result<()> {
252        transaction.execute(
253            "INSERT INTO run_evidence
254                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
255             VALUES (?1, ?2, ?3, 'settlement:' || ?1 || ':' || ?2, ?4)
256             ON CONFLICT(dedupe_key) DO UPDATE SET
257                 observed_at_ms = excluded.observed_at_ms,
258                 data_json = excluded.data_json",
259            params![run_id, kind, now_ms, data_json],
260        )?;
261        Ok(())
262    }
263
264    pub(crate) fn clear_stage_process(
265        transaction: &Transaction<'_>,
266        run_id: &str,
267    ) -> rusqlite::Result<()> {
268        transaction.execute(
269            "DELETE FROM run_evidence
270             WHERE run_id = ?1 AND dedupe_key = 'settlement:' || ?1 || ':stage_process'",
271            params![run_id],
272        )?;
273        Ok(())
274    }
275
276    pub(crate) fn record_cancel_requested(
277        transaction: &Transaction<'_>,
278        run_id: &str,
279        now_ms: i64,
280    ) -> rusqlite::Result<()> {
281        transaction.execute(
282            "INSERT OR IGNORE INTO run_evidence
283                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
284             VALUES (?1, 'cancel_requested', ?2, 'cancel_requested:' || ?1, '{}')",
285            params![run_id, now_ms],
286        )?;
287        Ok(())
288    }
289
290    pub(crate) fn record_output_stall(
291        transaction: &Transaction<'_>,
292        run_id: &str,
293        evidence: &OutputStallEvidence,
294        now_ms: i64,
295    ) -> rusqlite::Result<bool> {
296        let inserted = transaction.execute(
297            "INSERT OR IGNORE INTO run_evidence
298                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
299             SELECT id, 'output_stall', ?2, 'output_stall:' || id, ?3
300             FROM runs
301             WHERE id = ?1 AND state = 'running' AND exited_at_ms IS NULL",
302            params![run_id, now_ms, serde_json::to_string(evidence).unwrap()],
303        )?;
304        Ok(inserted == 1)
305    }
306
307    /// Records a worker's self-reported verdict for the execution it is
308    /// running. The key carries the attempt as well as the stage: a stage a
309    /// backward edge re-enters gets one report per execution, and without the
310    /// attempt the second worker's report would be discarded as a duplicate of
311    /// the first.
312    ///
313    /// `confidence` is stored beside the verdict and never consulted by it, so
314    /// one worker's `--confidence` means exactly what a panel reviewer's does:
315    /// evidence an operator reads, not a weight any decision uses.
316    #[allow(clippy::too_many_arguments)]
317    pub(crate) fn record_stage_verdict(
318        transaction: &Transaction<'_>,
319        run_id: &str,
320        stage: &str,
321        attempt: u32,
322        verdict: &str,
323        confidence: &str,
324        reason: Option<&str>,
325        now_ms: i64,
326    ) -> rusqlite::Result<bool> {
327        let dedupe_key = format!("verdict:{run_id}:{stage}:{attempt}");
328        let data_json = serde_json::json!({
329            "stage": stage,
330            "attempt": attempt,
331            "verdict": verdict,
332            "confidence": confidence,
333            "reason": reason,
334        })
335        .to_string();
336        let inserted = transaction.execute(
337            "INSERT OR IGNORE INTO run_evidence
338                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
339             VALUES (?1, 'stage_verdict', ?2, ?3, ?4)",
340            params![run_id, now_ms, dedupe_key, data_json],
341        )?;
342        Ok(inserted == 1)
343    }
344
345    /// Records one panel reviewer's report, append-only and one-shot.
346    ///
347    /// The key is the seat — `(run, stage_index, attempt, reviewer)` — and it
348    /// comes from the reviewer's credential, never from its arguments. `INSERT
349    /// OR IGNORE` is what makes the credential one-shot: a second `verdict`
350    /// call from the same reviewer inserts nothing and is refused, so a
351    /// reviewer cannot revise its own report once cast, and a `return_to`
352    /// re-run reports onto fresh rows because its attempt differs.
353    #[allow(clippy::too_many_arguments)]
354    pub(crate) fn record_panel_report(
355        transaction: &Transaction<'_>,
356        run_id: &str,
357        report: &PanelReportRecord<'_>,
358        now_ms: i64,
359    ) -> rusqlite::Result<bool> {
360        let dedupe_key = format!(
361            "panel:{run_id}:{}:{}:{}",
362            report.stage_index, report.attempt, report.reviewer_index
363        );
364        let data_json = serde_json::json!({
365            "stage": report.stage,
366            "stage_index": report.stage_index,
367            "attempt": report.attempt,
368            "reviewer": report.reviewer_index,
369            "verdict": report.verdict,
370            "confidence": report.confidence,
371            "reason": report.reason,
372        })
373        .to_string();
374        let inserted = transaction.execute(
375            "INSERT OR IGNORE INTO run_evidence
376                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
377             VALUES (?1, 'panel_report', ?2, ?3, ?4)",
378            params![run_id, now_ms, dedupe_key, data_json],
379        )?;
380        Ok(inserted == 1)
381    }
382
383    pub(crate) fn record_external_merge(
384        transaction: &Transaction<'_>,
385        run_id: &str,
386        data_json: &str,
387        now_ms: i64,
388    ) -> rusqlite::Result<bool> {
389        let inserted = transaction.execute(
390            "INSERT OR IGNORE INTO run_evidence
391                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
392             VALUES (?1, 'external_merge_observed', ?2, 'external_merge:' || ?1, ?3)",
393            params![run_id, now_ms, data_json],
394        )?;
395        Ok(inserted == 1)
396    }
397}
398
399/// The run's whole stage-evidence log, in `seq` order. Callers replay it; none
400/// of them look a stage up by name.
401fn stage_log(connection: &Connection, run_id: &str) -> rusqlite::Result<Vec<StageRecord>> {
402    let mut statement = connection.prepare(
403        "SELECT stage_index, stage, attempt, phase, state, started_at_ms, finished_at_ms,
404                exit_code, evidence_json
405         FROM stage_runs WHERE run_id = ?1 ORDER BY seq",
406    )?;
407    statement
408        .query_map(params![run_id], |row| {
409            let evidence = row
410                .get::<_, Option<String>>(8)?
411                .as_deref()
412                .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok());
413            let field = |name: &str| {
414                evidence
415                    .as_ref()
416                    .and_then(|value| value[name].as_str())
417                    .map(str::to_owned)
418            };
419            let state: Option<String> = row.get(4)?;
420            Ok(StageRecord {
421                stage_index: row.get::<_, i64>(0)? as usize,
422                stage: row.get(1)?,
423                attempt: row.get(2)?,
424                phase: StagePhase::parse(&row.get::<_, String>(3)?),
425                started_at_ms: row.get(5)?,
426                finished_at_ms: row.get(6)?,
427                exit_code: row.get(7)?,
428                output_ref: field("output").unwrap_or_default(),
429                // A resolved row written before the source was recorded was
430                // judged by its own exit, which is what the old default said.
431                verdict_source: field("verdict_source")
432                    .or_else(|| state.is_some().then(|| "exit_code".to_owned())),
433                reason: field("reason"),
434                state,
435            })
436        })?
437        .collect::<Result<Vec<_>, _>>()
438}
439
440fn cancellation_requested(connection: &Connection, run_id: &str) -> rusqlite::Result<bool> {
441    let found: Option<i64> = connection
442        .query_row(
443            "SELECT 1 FROM run_evidence
444             WHERE run_id = ?1 AND kind = 'cancel_requested'",
445            params![run_id],
446            |row| row.get(0),
447        )
448        .optional()?;
449    Ok(found.is_some())
450}
451
452fn output_stall(
453    connection: &Connection,
454    run_id: &str,
455) -> rusqlite::Result<Option<OutputStallEvidence>> {
456    let data = connection
457        .query_row(
458            "SELECT data_json FROM run_evidence
459             WHERE run_id = ?1 AND kind = 'output_stall'
460             ORDER BY sequence DESC LIMIT 1",
461            params![run_id],
462            |row| row.get::<_, String>(0),
463        )
464        .optional()?;
465    Ok(data.and_then(|data| serde_json::from_str(&data).ok()))
466}
467
468fn commit_evidence_for_project(
469    connection: &Connection,
470    project_id: &str,
471) -> rusqlite::Result<Vec<ProjectCommitEvidence>> {
472    let mut statement = connection.prepare(
473        "SELECT r.id, r.ticket_id, e.data_json
474         FROM run_evidence e
475         JOIN runs r ON r.id = e.run_id
476         JOIN tickets t ON t.id = r.ticket_id
477         WHERE t.project_id = ?1 AND e.kind = 'commits_observed'
478         ORDER BY r.ticket_id, r.created_at_ms, r.id, e.sequence",
479    )?;
480    statement
481        .query_map(params![project_id], |row| {
482            Ok(ProjectCommitEvidence {
483                run_id: row.get(0)?,
484                ticket_id: row.get(1)?,
485                data_json: row.get(2)?,
486            })
487        })?
488        .collect::<Result<Vec<_>, _>>()
489}
490
491fn run_evidence(connection: &Connection, run_id: &str) -> rusqlite::Result<Vec<(String, String)>> {
492    let mut statement = connection
493        .prepare("SELECT kind, data_json FROM run_evidence WHERE run_id = ?1 ORDER BY sequence")?;
494    statement
495        .query_map(params![run_id], |row| Ok((row.get(0)?, row.get(1)?)))?
496        .collect::<Result<Vec<_>, _>>()
497}
498
499fn vendor_error_for_run(connection: &Connection, run_id: &str) -> rusqlite::Result<Option<String>> {
500    connection
501        .query_row(
502            "SELECT data_json FROM run_evidence
503             WHERE run_id = ?1 AND kind = 'vendor_error_classified'
504             ORDER BY sequence DESC LIMIT 1",
505            params![run_id],
506            |row| row.get(0),
507        )
508        .optional()
509}
510
511fn latest_vendor_error_for_ticket(
512    connection: &Connection,
513    ticket_id: &str,
514) -> rusqlite::Result<Option<String>> {
515    connection
516        .query_row(
517            "SELECT e.data_json FROM run_evidence e
518             JOIN runs r ON r.id = e.run_id
519             WHERE r.id = (SELECT latest.id FROM runs latest
520                           WHERE latest.ticket_id = ?1
521                           ORDER BY latest.created_at_ms DESC, latest.id DESC LIMIT 1)
522               AND e.kind = 'vendor_error_classified'
523             ORDER BY e.sequence DESC LIMIT 1",
524            params![ticket_id],
525            |row| row.get(0),
526        )
527        .optional()
528}
529
530impl RunStore {
531    /// Appends one stage execution's rows atomically: a crash can leave the
532    /// execution unrecorded, never half-recorded.
533    pub(crate) fn append_stage_rows(
534        &self,
535        run_id: &str,
536        rows: &[StageRecord],
537    ) -> Result<(), StoreError> {
538        self.write(TransactionBehavior::Deferred, |transaction| {
539            tx::append_stage_rows(transaction, run_id, rows)
540        })
541    }
542
543    pub(crate) fn stage_log(&self, run_id: &str) -> Result<Vec<StageRecord>, StoreError> {
544        stage_log(&self.db.lock(), run_id).map_err(StoreError::from)
545    }
546
547    pub(crate) fn record_stage_evidence(
548        &self,
549        run_id: &str,
550        kind: &str,
551        data_json: &str,
552        now_ms: i64,
553    ) -> Result<(), StoreError> {
554        self.write(TransactionBehavior::Deferred, |transaction| {
555            tx::record_stage_evidence(transaction, run_id, kind, data_json, now_ms)
556        })
557    }
558
559    pub(crate) fn clear_stage_process(&self, run_id: &str) -> Result<(), StoreError> {
560        self.write(TransactionBehavior::Deferred, |transaction| {
561            tx::clear_stage_process(transaction, run_id)
562        })
563    }
564
565    pub(crate) fn record_cancel_requested(
566        &self,
567        run_id: &str,
568        now_ms: i64,
569    ) -> Result<(), StoreError> {
570        self.write(TransactionBehavior::Deferred, |transaction| {
571            tx::record_cancel_requested(transaction, run_id, now_ms)
572        })
573    }
574
575    pub(crate) fn cancellation_requested(&self, run_id: &str) -> Result<bool, StoreError> {
576        cancellation_requested(&self.db.lock(), run_id).map_err(StoreError::from)
577    }
578
579    pub(crate) fn record_output_stall(
580        &self,
581        run_id: &str,
582        evidence: &OutputStallEvidence,
583        now_ms: i64,
584    ) -> Result<bool, StoreError> {
585        self.write(TransactionBehavior::Immediate, |transaction| {
586            tx::record_output_stall(transaction, run_id, evidence, now_ms)
587        })
588    }
589
590    pub(crate) fn output_stall(
591        &self,
592        run_id: &str,
593    ) -> Result<Option<OutputStallEvidence>, StoreError> {
594        output_stall(&self.db.lock(), run_id).map_err(StoreError::from)
595    }
596
597    #[allow(clippy::too_many_arguments)]
598    pub(crate) fn record_stage_verdict(
599        &self,
600        run_id: &str,
601        stage: &str,
602        attempt: u32,
603        verdict: &str,
604        confidence: &str,
605        reason: Option<&str>,
606        now_ms: i64,
607    ) -> Result<bool, StoreError> {
608        self.write(TransactionBehavior::Deferred, |transaction| {
609            tx::record_stage_verdict(
610                transaction,
611                run_id,
612                stage,
613                attempt,
614                verdict,
615                confidence,
616                reason,
617                now_ms,
618            )
619        })
620    }
621
622    pub(crate) fn record_panel_report(
623        &self,
624        run_id: &str,
625        report: &PanelReportRecord<'_>,
626        now_ms: i64,
627    ) -> Result<bool, StoreError> {
628        self.write(TransactionBehavior::Deferred, |transaction| {
629            tx::record_panel_report(transaction, run_id, report, now_ms)
630        })
631    }
632
633    pub(crate) fn commit_evidence_for_project(
634        &self,
635        project_id: &str,
636    ) -> Result<Vec<ProjectCommitEvidence>, StoreError> {
637        commit_evidence_for_project(&self.db.lock(), project_id).map_err(StoreError::from)
638    }
639
640    pub(crate) fn run_evidence(&self, run_id: &str) -> Result<Vec<(String, String)>, StoreError> {
641        run_evidence(&self.db.lock(), run_id).map_err(StoreError::from)
642    }
643
644    pub(crate) fn vendor_error_for_run(
645        &self,
646        run_id: &str,
647    ) -> Result<Option<crate::vendor_error::VendorErrorMatch>, StoreError> {
648        let data = vendor_error_for_run(&self.db.lock(), run_id)?;
649        Ok(data.and_then(|data| serde_json::from_str(&data).ok()))
650    }
651
652    pub(crate) fn latest_vendor_error_for_ticket(
653        &self,
654        ticket_id: &str,
655    ) -> Result<Option<crate::vendor_error::VendorErrorMatch>, StoreError> {
656        let data = latest_vendor_error_for_ticket(&self.db.lock(), ticket_id)?;
657        Ok(data.and_then(|data| serde_json::from_str(&data).ok()))
658    }
659}
660
661#[cfg(test)]
662mod tests {
663    use std::path::Path;
664
665    use tempfile::tempdir;
666
667    use crate::db::{Db, LEGACY_STAGE_TABLE, REVERT_TRIGGER_RENAME, StoreError};
668    use crate::outcome::Outcome;
669    use crate::run_store::test_support::{claim_run, open_seeded, settle_run};
670    use crate::run_store::{
671        Exit, ExitClaim, ExitDenial, RunExit, RunStart, RunState, RunStore, StagePhase,
672        StageRecord, Start,
673    };
674    use crate::vendor_error::{VendorErrorClass, VendorErrorMatch};
675
676    fn stage_row(
677        stage_index: usize,
678        stage: &str,
679        attempt: u32,
680        phase: StagePhase,
681        state: Option<&str>,
682    ) -> StageRecord {
683        StageRecord {
684            stage_index,
685            stage: stage.to_owned(),
686            attempt,
687            phase,
688            state: state.map(str::to_owned),
689            started_at_ms: 3_000,
690            finished_at_ms: 3_100,
691            exit_code: Some(0),
692            output_ref: "runs/R1/output.ndjson".into(),
693            verdict_source: state.map(|_| "exit_code".to_owned()),
694            reason: None,
695        }
696    }
697
698    /// `(stage_index, stage, attempt, phase, state)` for every row of the log,
699    /// in the order the storage hands them back.
700    fn log_shape(store: &RunStore) -> Vec<(usize, String, u32, StagePhase, Option<String>)> {
701        store
702            .stage_log("R1")
703            .unwrap()
704            .into_iter()
705            .map(|row| {
706                (
707                    row.stage_index,
708                    row.stage,
709                    row.attempt,
710                    row.phase,
711                    row.state,
712                )
713            })
714            .collect()
715    }
716
717    fn reopen(path: &Path) -> RunStore {
718        RunStore::from_db(Db::open(path, 4_000).unwrap())
719    }
720
721    fn running_r1(store: &RunStore) {
722        claim_run(store, "R1", "{}", "{}", 2_000);
723        assert_eq!(
724            store.begin("R1", "branch", "/worktree", 2_050).unwrap(),
725            Start::Granted
726        );
727        assert_eq!(
728            store
729                .start(
730                    &RunStart {
731                        run_id: "R1",
732                        branch: "branch",
733                        worktree_path: "/worktree",
734                        pid: 123,
735                        pid_start_time: Some(456),
736                        process_group_id: 123,
737                        worker_token: "token",
738                        worker_socket_path: "/runtime/R1.sock",
739                    },
740                    2_100,
741                )
742                .unwrap(),
743            Start::Granted
744        );
745    }
746
747    #[test]
748    fn agent_exit_and_stage_results_are_checkpointed_idempotently() {
749        let directory = tempdir().unwrap();
750        let store = open_seeded(&directory.path().join("sloop.db"));
751        running_r1(&store);
752
753        assert_eq!(
754            store
755                .record_agent_exit(
756                    "R1",
757                    1,
758                    Some(0),
759                    true,
760                    r#"{"count":1,"oids":["abc"]}"#,
761                    None,
762                    None,
763                    2_200,
764                )
765                .unwrap(),
766            ExitClaim::Claimed
767        );
768        store
769            .record_stage_evidence(
770                "R1",
771                "test_result",
772                r#"{"passed":true,"exit_code":0}"#,
773                2_300,
774            )
775            .unwrap();
776        store
777            .record_stage_evidence(
778                "R1",
779                "test_result",
780                r#"{"passed":true,"exit_code":0}"#,
781                2_400,
782            )
783            .unwrap();
784
785        let run = store.run("R1").unwrap().unwrap();
786        assert_eq!(run.state, "driving");
787        assert_eq!(run.exit_code, Some(0));
788        assert_eq!(
789            store.recoverable_runs().unwrap()[0].state,
790            RunState::Driving
791        );
792        let evidence = store.run_evidence("R1").unwrap();
793        assert_eq!(
794            evidence
795                .iter()
796                .filter(|(kind, _)| kind == "test_result")
797                .count(),
798            1
799        );
800    }
801
802    #[test]
803    fn agent_exit_checkpoint_is_an_exclusive_ownership_handoff() {
804        let directory = tempdir().unwrap();
805        let store = open_seeded(&directory.path().join("sloop.db"));
806        running_r1(&store);
807
808        let exit = RunExit {
809            run_id: "R1",
810            attempt: 1,
811            exit_code: Some(0),
812            capture_complete: true,
813            commits_json: r#"{"oids":["abc"]}"#,
814            vendor_error: None,
815            cooldown_until_ms: None,
816        };
817        assert_eq!(store.record_exit(&exit, 2_200).unwrap(), Exit::Granted);
818        assert_eq!(store.run("R1").unwrap().unwrap().state, "driving");
819        let evidence = store.run_evidence("R1").unwrap();
820        assert!(evidence.iter().any(|(kind, _)| kind == "exit_classified"));
821        assert!(evidence.iter().any(|(kind, _)| kind == "commits_observed"));
822
823        assert_eq!(
824            store.record_exit(&exit, 2_300).unwrap(),
825            Exit::Denied(ExitDenial::AlreadyClaimed {
826                state: "driving".into(),
827            })
828        );
829        let run = store.run("R1").unwrap().unwrap();
830        assert_eq!(run.state, "driving");
831        assert_eq!(run.exit_code, Some(0));
832        assert_eq!(store.run_evidence("R1").unwrap(), evidence);
833    }
834
835    #[test]
836    fn agent_exit_checkpoint_reports_terminal_and_missing_runs() {
837        let directory = tempdir().unwrap();
838        let store = open_seeded(&directory.path().join("sloop.db"));
839        running_r1(&store);
840        settle_run(&store, "R1", Some(0), Outcome::Merged, &[], None, 2_200);
841
842        let claim = store
843            .record_agent_exit(
844                "R1",
845                1,
846                Some(0),
847                true,
848                r#"{"count":0,"oids":[]}"#,
849                None,
850                None,
851                2_300,
852            )
853            .unwrap();
854        assert_eq!(
855            claim,
856            ExitClaim::AlreadyClaimed {
857                state: "merged".into()
858            }
859        );
860        assert_eq!(store.run("R1").unwrap().unwrap().state, "merged");
861
862        let missing = store.record_agent_exit(
863            "R9",
864            1,
865            Some(0),
866            true,
867            r#"{"count":0,"oids":[]}"#,
868            None,
869            None,
870            2_300,
871        );
872        assert!(matches!(missing, Err(StoreError::RunNotFound { .. })));
873    }
874
875    /// The log's order is the order rows were appended, not the order of any
876    /// key on them: a stage re-entered later reads back after the stages that
877    /// ran in between, which is what makes the fold's replay faithful.
878    #[test]
879    fn the_stage_log_reads_back_in_append_order() {
880        let directory = tempdir().unwrap();
881        let store = open_seeded(&directory.path().join("sloop.db"));
882        running_r1(&store);
883
884        store
885            .append_stage_rows(
886                "R1",
887                &[stage_row(0, "build", 1, StagePhase::Action, Some("passed"))],
888            )
889            .unwrap();
890        // One execution of `verify`, judged by an independent actor: two rows
891        // sharing a stage and an attempt, and only the last carries a verdict.
892        store
893            .append_stage_rows(
894                "R1",
895                &[
896                    stage_row(1, "verify", 1, StagePhase::Action, None),
897                    stage_row(1, "verify", 1, StagePhase::Check, Some("failed")),
898                ],
899            )
900            .unwrap();
901        store
902            .append_stage_rows(
903                "R1",
904                &[stage_row(0, "build", 2, StagePhase::Action, Some("passed"))],
905            )
906            .unwrap();
907
908        assert_eq!(
909            log_shape(&store),
910            [
911                (
912                    0,
913                    "build".into(),
914                    1,
915                    StagePhase::Action,
916                    Some("passed".into())
917                ),
918                (1, "verify".into(), 1, StagePhase::Action, None),
919                (
920                    1,
921                    "verify".into(),
922                    1,
923                    StagePhase::Check,
924                    Some("failed".into())
925                ),
926                (
927                    0,
928                    "build".into(),
929                    2,
930                    StagePhase::Action,
931                    Some("passed".into())
932                ),
933            ]
934        );
935        let unresolved = &store.stage_log("R1").unwrap()[1];
936        assert_eq!(unresolved.verdict_source, None);
937        assert_eq!(unresolved.output_ref, "runs/R1/output.ndjson");
938    }
939
940    /// Re-appending a row the natural key already holds corrects it in place.
941    /// It must not take a new position: a rewrite that jumped to the tail
942    /// would reorder history the fold has already replayed.
943    #[test]
944    fn rewriting_a_stage_row_keeps_the_position_it_first_took() {
945        let directory = tempdir().unwrap();
946        let store = open_seeded(&directory.path().join("sloop.db"));
947        running_r1(&store);
948
949        store
950            .append_stage_rows(
951                "R1",
952                &[
953                    stage_row(0, "build", 1, StagePhase::Action, Some("passed")),
954                    stage_row(1, "test", 1, StagePhase::Action, Some("failed")),
955                ],
956            )
957            .unwrap();
958        store
959            .append_stage_rows(
960                "R1",
961                &[stage_row(0, "build", 1, StagePhase::Action, Some("failed"))],
962            )
963            .unwrap();
964
965        assert_eq!(
966            log_shape(&store),
967            [
968                (
969                    0,
970                    "build".into(),
971                    1,
972                    StagePhase::Action,
973                    Some("failed".into())
974                ),
975                (
976                    1,
977                    "test".into(),
978                    1,
979                    StagePhase::Action,
980                    Some("failed".into())
981                ),
982            ]
983        );
984    }
985
986    /// Rows written before the table became a log are one whole execution
987    /// each. They backfill in the order the linear walk produced them, so a
988    /// run planted in the old shape replays to the same position afterwards.
989    #[test]
990    fn version_thirteen_backfills_the_stage_log_in_walk_order() {
991        let directory = tempdir().unwrap();
992        let path = directory.path().join("sloop.db");
993        let store = open_seeded(&path);
994        running_r1(&store);
995        drop(store);
996
997        let connection = rusqlite::Connection::open(&path).unwrap();
998        connection
999            .execute_batch(&format!(
1000                "DROP TABLE stage_runs;
1001                 CREATE TABLE {LEGACY_STAGE_TABLE} (
1002                     run_id          TEXT NOT NULL REFERENCES runs(id),
1003                     stage_index     INTEGER NOT NULL,
1004                     stage           TEXT NOT NULL,
1005                     state           TEXT NOT NULL,
1006                     attempt         INTEGER NOT NULL DEFAULT 1,
1007                     started_at_ms   INTEGER,
1008                     finished_at_ms  INTEGER,
1009                     exit_code       INTEGER,
1010                     evidence_json   TEXT,
1011                     PRIMARY KEY (run_id, stage_index, attempt)
1012                 );
1013                 INSERT INTO {LEGACY_STAGE_TABLE}
1014                     (run_id, stage_index, stage, state, attempt, started_at_ms,
1015                      finished_at_ms, exit_code, evidence_json)
1016                 VALUES
1017                     ('R1', 1, 'test', 'passed', 1, 3000, 3100, 0, NULL),
1018                     ('R1', 0, 'build', 'passed', 1, 2900, 3000, 0,
1019                      '{{\"output\":\"runs/R1/output.ndjson\",\"verdict_source\":\"reported\"}}');
1020                 PRAGMA user_version = 13;"
1021            ))
1022            .unwrap();
1023        connection.execute_batch(REVERT_TRIGGER_RENAME).unwrap();
1024        drop(connection);
1025
1026        let store = reopen(&path);
1027        assert_eq!(
1028            log_shape(&store),
1029            [
1030                (
1031                    0,
1032                    "build".into(),
1033                    1,
1034                    StagePhase::Action,
1035                    Some("passed".into())
1036                ),
1037                (
1038                    1,
1039                    "test".into(),
1040                    1,
1041                    StagePhase::Action,
1042                    Some("passed".into())
1043                ),
1044            ]
1045        );
1046        let migrated = store.stage_log("R1").unwrap();
1047        assert_eq!(migrated[0].verdict_source.as_deref(), Some("reported"));
1048        // A row that never recorded a source was judged by its own exit, which
1049        // is what the pre-log reader assumed.
1050        assert_eq!(migrated[1].verdict_source.as_deref(), Some("exit_code"));
1051
1052        // The migrated log still appends: the next row takes the position
1053        // after the two that were backfilled.
1054        store
1055            .append_stage_rows(
1056                "R1",
1057                &[stage_row(2, "merge", 1, StagePhase::Action, Some("passed"))],
1058            )
1059            .unwrap();
1060        assert_eq!(
1061            store
1062                .db()
1063                .lock()
1064                .query_row(
1065                    "SELECT seq FROM stage_runs WHERE run_id = 'R1' AND stage = 'merge'",
1066                    [],
1067                    |row| row.get::<_, i64>(0)
1068                )
1069                .unwrap(),
1070            3
1071        );
1072    }
1073
1074    #[test]
1075    fn run_store_deserializes_vendor_errors_and_returns_typed_project_evidence() {
1076        let directory = tempdir().unwrap();
1077        let store = open_seeded(&directory.path().join("sloop.db"));
1078        running_r1(&store);
1079        let vendor_error = VendorErrorMatch {
1080            class: VendorErrorClass::RateLimited,
1081            vendor: "claude".into(),
1082            rule_id: "capacity".into(),
1083            diagnostic: "try later".into(),
1084        };
1085
1086        store
1087            .record_agent_exit(
1088                "R1",
1089                1,
1090                Some(1),
1091                true,
1092                r#"{"oids":["abc"]}"#,
1093                Some(&vendor_error),
1094                Some(9_000),
1095                2_200,
1096            )
1097            .unwrap();
1098
1099        assert_eq!(
1100            store.vendor_error_for_run("R1").unwrap(),
1101            Some(vendor_error.clone())
1102        );
1103        assert_eq!(
1104            store.latest_vendor_error_for_ticket("T1").unwrap(),
1105            Some(vendor_error)
1106        );
1107        let records = store.commit_evidence_for_project("default").unwrap();
1108        assert_eq!(records.len(), 1);
1109        assert_eq!(records[0].run_id, "R1");
1110        assert_eq!(records[0].ticket_id, "T1");
1111        assert_eq!(records[0].data_json, r#"{"oids":["abc"]}"#);
1112    }
1113}