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::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            transaction.execute(
211                "INSERT INTO stage_runs
212                     (run_id, seq, stage_index, stage, state, attempt, phase,
213                      started_at_ms, finished_at_ms, exit_code, evidence_json)
214                 VALUES (?1,
215                         (SELECT COALESCE(MAX(seq), 0) + 1
216                          FROM stage_runs WHERE run_id = ?1),
217                         ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
218                 ON CONFLICT(run_id, stage_index, attempt, phase) DO UPDATE SET
219                     stage = excluded.stage,
220                     state = excluded.state,
221                     started_at_ms = excluded.started_at_ms,
222                     finished_at_ms = excluded.finished_at_ms,
223                     exit_code = excluded.exit_code,
224                     evidence_json = excluded.evidence_json",
225                params![
226                    run_id,
227                    row.stage_index as i64,
228                    row.stage,
229                    row.state,
230                    row.attempt,
231                    row.phase.as_str(),
232                    row.started_at_ms,
233                    row.finished_at_ms,
234                    row.exit_code,
235                    evidence_json,
236                ],
237            )?;
238        }
239        Ok(())
240    }
241
242    pub(crate) fn record_stage_evidence(
243        transaction: &Transaction<'_>,
244        run_id: &str,
245        kind: &str,
246        data_json: &str,
247        now_ms: i64,
248    ) -> rusqlite::Result<()> {
249        transaction.execute(
250            "INSERT INTO run_evidence
251                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
252             VALUES (?1, ?2, ?3, 'settlement:' || ?1 || ':' || ?2, ?4)
253             ON CONFLICT(dedupe_key) DO UPDATE SET
254                 observed_at_ms = excluded.observed_at_ms,
255                 data_json = excluded.data_json",
256            params![run_id, kind, now_ms, data_json],
257        )?;
258        Ok(())
259    }
260
261    pub(crate) fn clear_stage_process(
262        transaction: &Transaction<'_>,
263        run_id: &str,
264    ) -> rusqlite::Result<()> {
265        transaction.execute(
266            "DELETE FROM run_evidence
267             WHERE run_id = ?1 AND dedupe_key = 'settlement:' || ?1 || ':stage_process'",
268            params![run_id],
269        )?;
270        Ok(())
271    }
272
273    pub(crate) fn record_cancel_requested(
274        transaction: &Transaction<'_>,
275        run_id: &str,
276        now_ms: i64,
277    ) -> rusqlite::Result<()> {
278        transaction.execute(
279            "INSERT OR IGNORE INTO run_evidence
280                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
281             VALUES (?1, 'cancel_requested', ?2, 'cancel_requested:' || ?1, '{}')",
282            params![run_id, now_ms],
283        )?;
284        Ok(())
285    }
286
287    pub(crate) fn record_output_stall(
288        transaction: &Transaction<'_>,
289        run_id: &str,
290        evidence: &OutputStallEvidence,
291        now_ms: i64,
292    ) -> rusqlite::Result<bool> {
293        let inserted = transaction.execute(
294            "INSERT OR IGNORE INTO run_evidence
295                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
296             SELECT id, 'output_stall', ?2, 'output_stall:' || id, ?3
297             FROM runs
298             WHERE id = ?1 AND state = 'running' AND exited_at_ms IS NULL",
299            params![run_id, now_ms, serde_json::to_string(evidence).unwrap()],
300        )?;
301        Ok(inserted == 1)
302    }
303
304    /// Records a worker's self-reported verdict for the execution it is
305    /// running. The key carries the attempt as well as the stage: a stage a
306    /// backward edge re-enters gets one report per execution, and without the
307    /// attempt the second worker's report would be discarded as a duplicate of
308    /// the first.
309    ///
310    /// `confidence` is stored beside the verdict and never consulted by it, so
311    /// one worker's `--confidence` means exactly what a panel reviewer's does:
312    /// evidence an operator reads, not a weight any decision uses.
313    #[allow(clippy::too_many_arguments)]
314    pub(crate) fn record_stage_verdict(
315        transaction: &Transaction<'_>,
316        run_id: &str,
317        stage: &str,
318        attempt: u32,
319        verdict: &str,
320        confidence: &str,
321        reason: Option<&str>,
322        now_ms: i64,
323    ) -> rusqlite::Result<bool> {
324        let dedupe_key = format!("verdict:{run_id}:{stage}:{attempt}");
325        let data_json = serde_json::json!({
326            "stage": stage,
327            "attempt": attempt,
328            "verdict": verdict,
329            "confidence": confidence,
330            "reason": reason,
331        })
332        .to_string();
333        let inserted = transaction.execute(
334            "INSERT OR IGNORE INTO run_evidence
335                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
336             VALUES (?1, 'stage_verdict', ?2, ?3, ?4)",
337            params![run_id, now_ms, dedupe_key, data_json],
338        )?;
339        Ok(inserted == 1)
340    }
341
342    /// Records one panel reviewer's report, append-only and one-shot.
343    ///
344    /// The key is the seat — `(run, stage_index, attempt, reviewer)` — and it
345    /// comes from the reviewer's credential, never from its arguments. `INSERT
346    /// OR IGNORE` is what makes the credential one-shot: a second `verdict`
347    /// call from the same reviewer inserts nothing and is refused, so a
348    /// reviewer cannot revise its own report once cast, and a `return_to`
349    /// re-run reports onto fresh rows because its attempt differs.
350    #[allow(clippy::too_many_arguments)]
351    pub(crate) fn record_panel_report(
352        transaction: &Transaction<'_>,
353        run_id: &str,
354        report: &PanelReportRecord<'_>,
355        now_ms: i64,
356    ) -> rusqlite::Result<bool> {
357        let dedupe_key = format!(
358            "panel:{run_id}:{}:{}:{}",
359            report.stage_index, report.attempt, report.reviewer_index
360        );
361        let data_json = serde_json::json!({
362            "stage": report.stage,
363            "stage_index": report.stage_index,
364            "attempt": report.attempt,
365            "reviewer": report.reviewer_index,
366            "verdict": report.verdict,
367            "confidence": report.confidence,
368            "reason": report.reason,
369        })
370        .to_string();
371        let inserted = transaction.execute(
372            "INSERT OR IGNORE INTO run_evidence
373                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
374             VALUES (?1, 'panel_report', ?2, ?3, ?4)",
375            params![run_id, now_ms, dedupe_key, data_json],
376        )?;
377        Ok(inserted == 1)
378    }
379
380    pub(crate) fn record_external_merge(
381        transaction: &Transaction<'_>,
382        run_id: &str,
383        data_json: &str,
384        now_ms: i64,
385    ) -> rusqlite::Result<bool> {
386        let inserted = transaction.execute(
387            "INSERT OR IGNORE INTO run_evidence
388                 (run_id, kind, observed_at_ms, dedupe_key, data_json)
389             VALUES (?1, 'external_merge_observed', ?2, 'external_merge:' || ?1, ?3)",
390            params![run_id, now_ms, data_json],
391        )?;
392        Ok(inserted == 1)
393    }
394}
395
396/// The run's whole stage-evidence log, in `seq` order. Callers replay it; none
397/// of them look a stage up by name.
398fn stage_log(connection: &Connection, run_id: &str) -> rusqlite::Result<Vec<StageRecord>> {
399    let mut statement = connection.prepare(
400        "SELECT stage_index, stage, attempt, phase, state, started_at_ms, finished_at_ms,
401                exit_code, evidence_json
402         FROM stage_runs WHERE run_id = ?1 ORDER BY seq",
403    )?;
404    statement
405        .query_map(params![run_id], |row| {
406            let evidence = row
407                .get::<_, Option<String>>(8)?
408                .as_deref()
409                .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok());
410            let field = |name: &str| {
411                evidence
412                    .as_ref()
413                    .and_then(|value| value[name].as_str())
414                    .map(str::to_owned)
415            };
416            let state: Option<String> = row.get(4)?;
417            Ok(StageRecord {
418                stage_index: row.get::<_, i64>(0)? as usize,
419                stage: row.get(1)?,
420                attempt: row.get(2)?,
421                phase: StagePhase::parse(&row.get::<_, String>(3)?),
422                started_at_ms: row.get(5)?,
423                finished_at_ms: row.get(6)?,
424                exit_code: row.get(7)?,
425                output_ref: field("output").unwrap_or_default(),
426                verdict_source: field("verdict_source")
427                    .or_else(|| state.is_some().then(|| "exit_code".to_owned())),
428                reason: field("reason"),
429                state,
430            })
431        })?
432        .collect::<Result<Vec<_>, _>>()
433}
434
435fn cancellation_requested(connection: &Connection, run_id: &str) -> rusqlite::Result<bool> {
436    let found: Option<i64> = connection
437        .query_row(
438            "SELECT 1 FROM run_evidence
439             WHERE run_id = ?1 AND kind = 'cancel_requested'",
440            params![run_id],
441            |row| row.get(0),
442        )
443        .optional()?;
444    Ok(found.is_some())
445}
446
447fn output_stall(
448    connection: &Connection,
449    run_id: &str,
450) -> rusqlite::Result<Option<OutputStallEvidence>> {
451    let data = connection
452        .query_row(
453            "SELECT data_json FROM run_evidence
454             WHERE run_id = ?1 AND kind = 'output_stall'
455             ORDER BY sequence DESC LIMIT 1",
456            params![run_id],
457            |row| row.get::<_, String>(0),
458        )
459        .optional()?;
460    Ok(data.and_then(|data| serde_json::from_str(&data).ok()))
461}
462
463fn commit_evidence_for_project(
464    connection: &Connection,
465    project_id: &str,
466) -> rusqlite::Result<Vec<ProjectCommitEvidence>> {
467    let mut statement = connection.prepare(
468        "SELECT r.id, r.ticket_id, e.data_json
469         FROM run_evidence e
470         JOIN runs r ON r.id = e.run_id
471         JOIN tickets t ON t.id = r.ticket_id
472         WHERE t.project_id = ?1 AND e.kind = 'commits_observed'
473         ORDER BY r.ticket_id, r.created_at_ms, r.id, e.sequence",
474    )?;
475    statement
476        .query_map(params![project_id], |row| {
477            Ok(ProjectCommitEvidence {
478                run_id: row.get(0)?,
479                ticket_id: row.get(1)?,
480                data_json: row.get(2)?,
481            })
482        })?
483        .collect::<Result<Vec<_>, _>>()
484}
485
486fn run_evidence(connection: &Connection, run_id: &str) -> rusqlite::Result<Vec<(String, String)>> {
487    let mut statement = connection
488        .prepare("SELECT kind, data_json FROM run_evidence WHERE run_id = ?1 ORDER BY sequence")?;
489    statement
490        .query_map(params![run_id], |row| Ok((row.get(0)?, row.get(1)?)))?
491        .collect::<Result<Vec<_>, _>>()
492}
493
494fn vendor_error_for_run(connection: &Connection, run_id: &str) -> rusqlite::Result<Option<String>> {
495    connection
496        .query_row(
497            "SELECT data_json FROM run_evidence
498             WHERE run_id = ?1 AND kind = 'vendor_error_classified'
499             ORDER BY sequence DESC LIMIT 1",
500            params![run_id],
501            |row| row.get(0),
502        )
503        .optional()
504}
505
506fn latest_vendor_error_for_ticket(
507    connection: &Connection,
508    ticket_id: &str,
509) -> rusqlite::Result<Option<String>> {
510    connection
511        .query_row(
512            "SELECT e.data_json FROM run_evidence e
513             JOIN runs r ON r.id = e.run_id
514             WHERE r.id = (SELECT latest.id FROM runs latest
515                           WHERE latest.ticket_id = ?1
516                           ORDER BY latest.created_at_ms DESC, latest.id DESC LIMIT 1)
517               AND e.kind = 'vendor_error_classified'
518             ORDER BY e.sequence DESC LIMIT 1",
519            params![ticket_id],
520            |row| row.get(0),
521        )
522        .optional()
523}
524
525impl RunStore {
526    /// Appends one stage execution's rows atomically: a crash can leave the
527    /// execution unrecorded, never half-recorded.
528    pub(crate) fn append_stage_rows(
529        &self,
530        run_id: &str,
531        rows: &[StageRecord],
532    ) -> Result<(), StoreError> {
533        self.write(TransactionBehavior::Deferred, |transaction| {
534            tx::append_stage_rows(transaction, run_id, rows)
535        })
536    }
537
538    pub(crate) fn stage_log(&self, run_id: &str) -> Result<Vec<StageRecord>, StoreError> {
539        stage_log(&self.db.lock(), run_id).map_err(StoreError::from)
540    }
541
542    pub(crate) fn record_stage_evidence(
543        &self,
544        run_id: &str,
545        kind: &str,
546        data_json: &str,
547        now_ms: i64,
548    ) -> Result<(), StoreError> {
549        self.write(TransactionBehavior::Deferred, |transaction| {
550            tx::record_stage_evidence(transaction, run_id, kind, data_json, now_ms)
551        })
552    }
553
554    pub(crate) fn clear_stage_process(&self, run_id: &str) -> Result<(), StoreError> {
555        self.write(TransactionBehavior::Deferred, |transaction| {
556            tx::clear_stage_process(transaction, run_id)
557        })
558    }
559
560    pub(crate) fn record_cancel_requested(
561        &self,
562        run_id: &str,
563        now_ms: i64,
564    ) -> Result<(), StoreError> {
565        self.write(TransactionBehavior::Deferred, |transaction| {
566            tx::record_cancel_requested(transaction, run_id, now_ms)
567        })
568    }
569
570    pub(crate) fn cancellation_requested(&self, run_id: &str) -> Result<bool, StoreError> {
571        cancellation_requested(&self.db.lock(), run_id).map_err(StoreError::from)
572    }
573
574    pub(crate) fn record_output_stall(
575        &self,
576        run_id: &str,
577        evidence: &OutputStallEvidence,
578        now_ms: i64,
579    ) -> Result<bool, StoreError> {
580        self.write(TransactionBehavior::Immediate, |transaction| {
581            tx::record_output_stall(transaction, run_id, evidence, now_ms)
582        })
583    }
584
585    pub(crate) fn output_stall(
586        &self,
587        run_id: &str,
588    ) -> Result<Option<OutputStallEvidence>, StoreError> {
589        output_stall(&self.db.lock(), run_id).map_err(StoreError::from)
590    }
591
592    #[allow(clippy::too_many_arguments)]
593    pub(crate) fn record_stage_verdict(
594        &self,
595        run_id: &str,
596        stage: &str,
597        attempt: u32,
598        verdict: &str,
599        confidence: &str,
600        reason: Option<&str>,
601        now_ms: i64,
602    ) -> Result<bool, StoreError> {
603        self.write(TransactionBehavior::Deferred, |transaction| {
604            tx::record_stage_verdict(
605                transaction,
606                run_id,
607                stage,
608                attempt,
609                verdict,
610                confidence,
611                reason,
612                now_ms,
613            )
614        })
615    }
616
617    pub(crate) fn record_panel_report(
618        &self,
619        run_id: &str,
620        report: &PanelReportRecord<'_>,
621        now_ms: i64,
622    ) -> Result<bool, StoreError> {
623        self.write(TransactionBehavior::Deferred, |transaction| {
624            tx::record_panel_report(transaction, run_id, report, now_ms)
625        })
626    }
627
628    pub(crate) fn commit_evidence_for_project(
629        &self,
630        project_id: &str,
631    ) -> Result<Vec<ProjectCommitEvidence>, StoreError> {
632        commit_evidence_for_project(&self.db.lock(), project_id).map_err(StoreError::from)
633    }
634
635    pub(crate) fn run_evidence(&self, run_id: &str) -> Result<Vec<(String, String)>, StoreError> {
636        run_evidence(&self.db.lock(), run_id).map_err(StoreError::from)
637    }
638
639    pub(crate) fn vendor_error_for_run(
640        &self,
641        run_id: &str,
642    ) -> Result<Option<crate::vendor::VendorErrorMatch>, StoreError> {
643        let data = vendor_error_for_run(&self.db.lock(), run_id)?;
644        Ok(data.and_then(|data| serde_json::from_str(&data).ok()))
645    }
646
647    pub(crate) fn latest_vendor_error_for_ticket(
648        &self,
649        ticket_id: &str,
650    ) -> Result<Option<crate::vendor::VendorErrorMatch>, StoreError> {
651        let data = latest_vendor_error_for_ticket(&self.db.lock(), ticket_id)?;
652        Ok(data.and_then(|data| serde_json::from_str(&data).ok()))
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use std::path::Path;
659
660    use tempfile::tempdir;
661
662    use crate::db::{Db, LEGACY_STAGE_TABLE, REVERT_TRIGGER_RENAME, StoreError};
663    use crate::outcome::Outcome;
664    use crate::run_store::test_support::{claim_run, open_seeded, settle_run};
665    use crate::run_store::{
666        Exit, ExitClaim, ExitDenial, RunExit, RunStart, RunState, RunStore, StagePhase,
667        StageRecord, Start,
668    };
669    use crate::vendor::{VendorErrorClass, VendorErrorMatch};
670
671    fn stage_row(
672        stage_index: usize,
673        stage: &str,
674        attempt: u32,
675        phase: StagePhase,
676        state: Option<&str>,
677    ) -> StageRecord {
678        StageRecord {
679            stage_index,
680            stage: stage.to_owned(),
681            attempt,
682            phase,
683            state: state.map(str::to_owned),
684            started_at_ms: 3_000,
685            finished_at_ms: 3_100,
686            exit_code: Some(0),
687            output_ref: "runs/R1/output.ndjson".into(),
688            verdict_source: state.map(|_| "exit_code".to_owned()),
689            reason: None,
690        }
691    }
692
693    /// `(stage_index, stage, attempt, phase, state)` for every row of the log,
694    /// in the order the storage hands them back.
695    fn log_shape(store: &RunStore) -> Vec<(usize, String, u32, StagePhase, Option<String>)> {
696        store
697            .stage_log("R1")
698            .unwrap()
699            .into_iter()
700            .map(|row| {
701                (
702                    row.stage_index,
703                    row.stage,
704                    row.attempt,
705                    row.phase,
706                    row.state,
707                )
708            })
709            .collect()
710    }
711
712    fn reopen(path: &Path) -> RunStore {
713        RunStore::from_db(Db::open(path, 4_000).unwrap())
714    }
715
716    fn running_r1(store: &RunStore) {
717        claim_run(store, "R1", "{}", "{}", 2_000);
718        assert_eq!(
719            store.begin("R1", "branch", "/worktree", 2_050).unwrap(),
720            Start::Granted
721        );
722        assert_eq!(
723            store
724                .start(
725                    &RunStart {
726                        run_id: "R1",
727                        branch: "branch",
728                        worktree_path: "/worktree",
729                        pid: 123,
730                        pid_start_time: Some(456),
731                        process_group_id: 123,
732                        worker_token: "token",
733                        worker_socket_path: "/runtime/R1.sock",
734                    },
735                    2_100,
736                )
737                .unwrap(),
738            Start::Granted
739        );
740    }
741
742    #[test]
743    fn agent_exit_and_stage_results_are_checkpointed_idempotently() {
744        let directory = tempdir().unwrap();
745        let store = open_seeded(&directory.path().join("sloop.db"));
746        running_r1(&store);
747
748        assert_eq!(
749            store
750                .record_agent_exit(
751                    "R1",
752                    1,
753                    Some(0),
754                    true,
755                    r#"{"count":1,"oids":["abc"]}"#,
756                    None,
757                    None,
758                    2_200,
759                )
760                .unwrap(),
761            ExitClaim::Claimed
762        );
763        store
764            .record_stage_evidence(
765                "R1",
766                "test_result",
767                r#"{"passed":true,"exit_code":0}"#,
768                2_300,
769            )
770            .unwrap();
771        store
772            .record_stage_evidence(
773                "R1",
774                "test_result",
775                r#"{"passed":true,"exit_code":0}"#,
776                2_400,
777            )
778            .unwrap();
779
780        let run = store.run("R1").unwrap().unwrap();
781        assert_eq!(run.state, "driving");
782        assert_eq!(run.exit_code, Some(0));
783        assert_eq!(
784            store.recoverable_runs().unwrap()[0].state,
785            RunState::Driving
786        );
787        let evidence = store.run_evidence("R1").unwrap();
788        assert_eq!(
789            evidence
790                .iter()
791                .filter(|(kind, _)| kind == "test_result")
792                .count(),
793            1
794        );
795    }
796
797    #[test]
798    fn agent_exit_checkpoint_is_an_exclusive_ownership_handoff() {
799        let directory = tempdir().unwrap();
800        let store = open_seeded(&directory.path().join("sloop.db"));
801        running_r1(&store);
802
803        let exit = RunExit {
804            run_id: "R1",
805            attempt: 1,
806            exit_code: Some(0),
807            capture_complete: true,
808            commits_json: r#"{"oids":["abc"]}"#,
809            vendor_error: None,
810            cooldown_until_ms: None,
811        };
812        assert_eq!(store.record_exit(&exit, 2_200).unwrap(), Exit::Granted);
813        assert_eq!(store.run("R1").unwrap().unwrap().state, "driving");
814        let evidence = store.run_evidence("R1").unwrap();
815        assert!(evidence.iter().any(|(kind, _)| kind == "exit_classified"));
816        assert!(evidence.iter().any(|(kind, _)| kind == "commits_observed"));
817
818        assert_eq!(
819            store.record_exit(&exit, 2_300).unwrap(),
820            Exit::Denied(ExitDenial::AlreadyClaimed {
821                state: "driving".into(),
822            })
823        );
824        let run = store.run("R1").unwrap().unwrap();
825        assert_eq!(run.state, "driving");
826        assert_eq!(run.exit_code, Some(0));
827        assert_eq!(store.run_evidence("R1").unwrap(), evidence);
828    }
829
830    #[test]
831    fn agent_exit_checkpoint_reports_terminal_and_missing_runs() {
832        let directory = tempdir().unwrap();
833        let store = open_seeded(&directory.path().join("sloop.db"));
834        running_r1(&store);
835        settle_run(&store, "R1", Some(0), Outcome::Merged, &[], None, 2_200);
836
837        let claim = store
838            .record_agent_exit(
839                "R1",
840                1,
841                Some(0),
842                true,
843                r#"{"count":0,"oids":[]}"#,
844                None,
845                None,
846                2_300,
847            )
848            .unwrap();
849        assert_eq!(
850            claim,
851            ExitClaim::AlreadyClaimed {
852                state: "merged".into()
853            }
854        );
855        assert_eq!(store.run("R1").unwrap().unwrap().state, "merged");
856
857        let missing = store.record_agent_exit(
858            "R9",
859            1,
860            Some(0),
861            true,
862            r#"{"count":0,"oids":[]}"#,
863            None,
864            None,
865            2_300,
866        );
867        assert!(matches!(missing, Err(StoreError::RunNotFound { .. })));
868    }
869
870    /// The log's order is the order rows were appended, not the order of any
871    /// key on them: a stage re-entered later reads back after the stages that
872    /// ran in between, which is what makes the fold's replay faithful.
873    #[test]
874    fn the_stage_log_reads_back_in_append_order() {
875        let directory = tempdir().unwrap();
876        let store = open_seeded(&directory.path().join("sloop.db"));
877        running_r1(&store);
878
879        store
880            .append_stage_rows(
881                "R1",
882                &[stage_row(0, "build", 1, StagePhase::Action, Some("passed"))],
883            )
884            .unwrap();
885        store
886            .append_stage_rows(
887                "R1",
888                &[
889                    stage_row(1, "verify", 1, StagePhase::Action, None),
890                    stage_row(1, "verify", 1, StagePhase::Check, Some("failed")),
891                ],
892            )
893            .unwrap();
894        store
895            .append_stage_rows(
896                "R1",
897                &[stage_row(0, "build", 2, StagePhase::Action, Some("passed"))],
898            )
899            .unwrap();
900
901        assert_eq!(
902            log_shape(&store),
903            [
904                (
905                    0,
906                    "build".into(),
907                    1,
908                    StagePhase::Action,
909                    Some("passed".into())
910                ),
911                (1, "verify".into(), 1, StagePhase::Action, None),
912                (
913                    1,
914                    "verify".into(),
915                    1,
916                    StagePhase::Check,
917                    Some("failed".into())
918                ),
919                (
920                    0,
921                    "build".into(),
922                    2,
923                    StagePhase::Action,
924                    Some("passed".into())
925                ),
926            ]
927        );
928        let unresolved = &store.stage_log("R1").unwrap()[1];
929        assert_eq!(unresolved.verdict_source, None);
930        assert_eq!(unresolved.output_ref, "runs/R1/output.ndjson");
931    }
932
933    /// Re-appending a row the natural key already holds corrects it in place.
934    /// It must not take a new position: a rewrite that jumped to the tail
935    /// would reorder history the fold has already replayed.
936    #[test]
937    fn rewriting_a_stage_row_keeps_the_position_it_first_took() {
938        let directory = tempdir().unwrap();
939        let store = open_seeded(&directory.path().join("sloop.db"));
940        running_r1(&store);
941
942        store
943            .append_stage_rows(
944                "R1",
945                &[
946                    stage_row(0, "build", 1, StagePhase::Action, Some("passed")),
947                    stage_row(1, "test", 1, StagePhase::Action, Some("failed")),
948                ],
949            )
950            .unwrap();
951        store
952            .append_stage_rows(
953                "R1",
954                &[stage_row(0, "build", 1, StagePhase::Action, Some("failed"))],
955            )
956            .unwrap();
957
958        assert_eq!(
959            log_shape(&store),
960            [
961                (
962                    0,
963                    "build".into(),
964                    1,
965                    StagePhase::Action,
966                    Some("failed".into())
967                ),
968                (
969                    1,
970                    "test".into(),
971                    1,
972                    StagePhase::Action,
973                    Some("failed".into())
974                ),
975            ]
976        );
977    }
978
979    /// Rows written before the table became a log are one whole execution
980    /// each. They backfill in the order the linear walk produced them, so a
981    /// run planted in the old shape replays to the same position afterwards.
982    #[test]
983    fn version_thirteen_backfills_the_stage_log_in_walk_order() {
984        let directory = tempdir().unwrap();
985        let path = directory.path().join("sloop.db");
986        let store = open_seeded(&path);
987        running_r1(&store);
988        drop(store);
989
990        let connection = rusqlite::Connection::open(&path).unwrap();
991        connection
992            .execute_batch(&format!(
993                "DROP TABLE stage_runs;
994                 CREATE TABLE {LEGACY_STAGE_TABLE} (
995                     run_id          TEXT NOT NULL REFERENCES runs(id),
996                     stage_index     INTEGER NOT NULL,
997                     stage           TEXT NOT NULL,
998                     state           TEXT NOT NULL,
999                     attempt         INTEGER NOT NULL DEFAULT 1,
1000                     started_at_ms   INTEGER,
1001                     finished_at_ms  INTEGER,
1002                     exit_code       INTEGER,
1003                     evidence_json   TEXT,
1004                     PRIMARY KEY (run_id, stage_index, attempt)
1005                 );
1006                 INSERT INTO {LEGACY_STAGE_TABLE}
1007                     (run_id, stage_index, stage, state, attempt, started_at_ms,
1008                      finished_at_ms, exit_code, evidence_json)
1009                 VALUES
1010                     ('R1', 1, 'test', 'passed', 1, 3000, 3100, 0, NULL),
1011                     ('R1', 0, 'build', 'passed', 1, 2900, 3000, 0,
1012                      '{{\"output\":\"runs/R1/output.ndjson\",\"verdict_source\":\"reported\"}}');
1013                 PRAGMA user_version = 13;"
1014            ))
1015            .unwrap();
1016        connection.execute_batch(REVERT_TRIGGER_RENAME).unwrap();
1017        drop(connection);
1018
1019        let store = reopen(&path);
1020        assert_eq!(
1021            log_shape(&store),
1022            [
1023                (
1024                    0,
1025                    "build".into(),
1026                    1,
1027                    StagePhase::Action,
1028                    Some("passed".into())
1029                ),
1030                (
1031                    1,
1032                    "test".into(),
1033                    1,
1034                    StagePhase::Action,
1035                    Some("passed".into())
1036                ),
1037            ]
1038        );
1039        let migrated = store.stage_log("R1").unwrap();
1040        assert_eq!(migrated[0].verdict_source.as_deref(), Some("reported"));
1041        assert_eq!(migrated[1].verdict_source.as_deref(), Some("exit_code"));
1042
1043        store
1044            .append_stage_rows(
1045                "R1",
1046                &[stage_row(2, "merge", 1, StagePhase::Action, Some("passed"))],
1047            )
1048            .unwrap();
1049        assert_eq!(
1050            store
1051                .db()
1052                .lock()
1053                .query_row(
1054                    "SELECT seq FROM stage_runs WHERE run_id = 'R1' AND stage = 'merge'",
1055                    [],
1056                    |row| row.get::<_, i64>(0)
1057                )
1058                .unwrap(),
1059            3
1060        );
1061    }
1062
1063    #[test]
1064    fn run_store_deserializes_vendor_errors_and_returns_typed_project_evidence() {
1065        let directory = tempdir().unwrap();
1066        let store = open_seeded(&directory.path().join("sloop.db"));
1067        running_r1(&store);
1068        let vendor_error = VendorErrorMatch {
1069            class: VendorErrorClass::RateLimited,
1070            vendor: "claude".into(),
1071            rule_id: "capacity".into(),
1072            diagnostic: "try later".into(),
1073        };
1074
1075        store
1076            .record_agent_exit(
1077                "R1",
1078                1,
1079                Some(1),
1080                true,
1081                r#"{"oids":["abc"]}"#,
1082                Some(&vendor_error),
1083                Some(9_000),
1084                2_200,
1085            )
1086            .unwrap();
1087
1088        assert_eq!(
1089            store.vendor_error_for_run("R1").unwrap(),
1090            Some(vendor_error.clone())
1091        );
1092        assert_eq!(
1093            store.latest_vendor_error_for_ticket("T1").unwrap(),
1094            Some(vendor_error)
1095        );
1096        let records = store.commit_evidence_for_project("default").unwrap();
1097        assert_eq!(records.len(), 1);
1098        assert_eq!(records[0].run_id, "R1");
1099        assert_eq!(records[0].ticket_id, "T1");
1100        assert_eq!(records[0].data_json, r#"{"oids":["abc"]}"#);
1101    }
1102}