Skip to main content

sloop/run_store/
runs.rs

1use std::collections::HashMap;
2
3use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
4
5use super::RunStore;
6use crate::db::StoreError;
7
8/// Every value the `runs.state` column can hold.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum RunState {
11    Claimed,
12    /// A supervised agent process is alive for the run's current stage.
13    Running,
14    /// The run's driver holds it, with no agent process of its own to
15    /// identify: it is between stages, or executing one the daemon runs
16    /// itself. The driver is then the run's liveness evidence.
17    Driving,
18    /// A claim rolled back before a process existed.
19    Aborted,
20    Merged,
21    Failed,
22    NeedsReview,
23    Cancelled,
24    RateLimited,
25    Orphaned,
26}
27
28const NONTERMINAL_RUN_STATES: [RunState; 3] =
29    [RunState::Claimed, RunState::Running, RunState::Driving];
30
31impl RunState {
32    pub fn as_str(self) -> &'static str {
33        match self {
34            Self::Claimed => "claimed",
35            Self::Running => "running",
36            Self::Driving => "driving",
37            Self::Aborted => "aborted",
38            Self::Merged => "merged",
39            Self::Failed => "failed",
40            Self::NeedsReview => "needs_review",
41            Self::Cancelled => "cancelled",
42            Self::RateLimited => "rate_limited",
43            Self::Orphaned => "orphaned",
44        }
45    }
46
47    pub(crate) fn from_stored(value: &str) -> Option<Self> {
48        match value {
49            "claimed" => Some(Self::Claimed),
50            "running" => Some(Self::Running),
51            "driving" => Some(Self::Driving),
52            "aborted" => Some(Self::Aborted),
53            "merged" => Some(Self::Merged),
54            "failed" => Some(Self::Failed),
55            "needs_review" => Some(Self::NeedsReview),
56            "cancelled" => Some(Self::Cancelled),
57            "rate_limited" => Some(Self::RateLimited),
58            "orphaned" => Some(Self::Orphaned),
59            _ => None,
60        }
61    }
62
63    /// Reads a state written by an older or newer binary. Unknown values are
64    /// errors so the daemon never acts on a run it cannot classify.
65    pub fn parse(value: &str) -> Result<Self, StoreError> {
66        Self::from_stored(value).ok_or_else(|| StoreError::UnknownRunState {
67            state: value.into(),
68        })
69    }
70
71    pub fn is_terminal(self) -> bool {
72        !NONTERMINAL_RUN_STATES.contains(&self)
73    }
74
75    pub(crate) fn outcome(self) -> Option<crate::outcome::Outcome> {
76        use crate::outcome::Outcome;
77        match self {
78            Self::Merged => Some(Outcome::Merged),
79            Self::Failed => Some(Outcome::Failed),
80            Self::NeedsReview => Some(Outcome::NeedsReview),
81            Self::Cancelled => Some(Outcome::Cancelled),
82            Self::RateLimited => Some(Outcome::RateLimited),
83            Self::Orphaned => Some(Outcome::Orphaned),
84            Self::Claimed | Self::Running | Self::Driving | Self::Aborted => None,
85        }
86    }
87}
88
89impl rusqlite::types::FromSql for RunState {
90    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
91        let text = value.as_str()?;
92        Self::from_stored(text).ok_or_else(|| {
93            rusqlite::types::FromSqlError::Other(Box::new(std::io::Error::new(
94                std::io::ErrorKind::InvalidData,
95                format!("unrecognized run state `{text}`"),
96            )))
97        })
98    }
99}
100
101impl From<crate::outcome::Outcome> for RunState {
102    fn from(outcome: crate::outcome::Outcome) -> Self {
103        use crate::outcome::Outcome;
104        match outcome {
105            Outcome::Merged => Self::Merged,
106            Outcome::Failed => Self::Failed,
107            Outcome::NeedsReview => Self::NeedsReview,
108            Outcome::Cancelled => Self::Cancelled,
109            Outcome::RateLimited => Self::RateLimited,
110            Outcome::Orphaned => Self::Orphaned,
111        }
112    }
113}
114
115fn nonterminal_state_params() -> [&'static str; 3] {
116    [
117        NONTERMINAL_RUN_STATES[0].as_str(),
118        NONTERMINAL_RUN_STATES[1].as_str(),
119        NONTERMINAL_RUN_STATES[2].as_str(),
120    ]
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct ActiveRun {
125    pub id: String,
126    pub ticket_id: String,
127    pub attempt: i64,
128    pub ticket_name: String,
129    pub project_id: String,
130    pub state: String,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct RunRecord {
135    pub id: String,
136    pub ticket_id: String,
137    pub attempt: i64,
138    pub state: String,
139    pub branch: Option<String>,
140    pub worktree_path: Option<String>,
141    pub pid: Option<i64>,
142    pub pid_start_time: Option<i64>,
143    pub process_group_id: Option<i64>,
144    pub exit_code: Option<i64>,
145    pub exited_at_ms: Option<i64>,
146    pub flow_json: Option<String>,
147    pub ticket_json: Option<String>,
148}
149
150pub struct RunAdmission<'a> {
151    pub run_id: &'a str,
152    pub trigger_id: &'a str,
153    pub ticket_id: &'a str,
154    pub flow_json: &'a str,
155    pub ticket_json: &'a str,
156}
157
158pub struct AdmittedRun {
159    pub run_id: String,
160    pub attempt: i64,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct NeedsReviewBranch {
165    pub ticket_id: String,
166    pub run_id: String,
167    pub branch: String,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct WorktreeCleanupCandidate {
172    pub run_id: String,
173    pub ticket_id: String,
174    pub branch: String,
175    pub worktree_path: String,
176    pub cleanup_eligible_at_ms: i64,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct RecoverableRun {
181    pub id: String,
182    pub ticket_id: String,
183    pub target: String,
184    pub state: RunState,
185    pub branch: Option<String>,
186    pub worktree_path: Option<String>,
187    pub pid: Option<i64>,
188    pub pid_start_time: Option<i64>,
189    pub process_group_id: Option<i64>,
190    pub worker_token: Option<String>,
191    pub worker_socket_path: Option<String>,
192    pub exit_code: Option<i64>,
193    pub lease_expires_at_ms: i64,
194    pub flow_json: Option<String>,
195    pub ticket_json: Option<String>,
196}
197
198const RUN_RECORD_SELECT: &str = "SELECT id, ticket_id, attempt, state, branch, worktree_path, pid,
199            pid_start_time, process_group_id, exit_code, exited_at_ms,
200            flow_json, ticket_json
201     FROM runs";
202
203fn run_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RunRecord> {
204    Ok(RunRecord {
205        id: row.get(0)?,
206        ticket_id: row.get(1)?,
207        attempt: row.get(2)?,
208        state: row.get(3)?,
209        branch: row.get(4)?,
210        worktree_path: row.get(5)?,
211        pid: row.get(6)?,
212        pid_start_time: row.get(7)?,
213        process_group_id: row.get(8)?,
214        exit_code: row.get(9)?,
215        exited_at_ms: row.get(10)?,
216        flow_json: row.get(11)?,
217        ticket_json: row.get(12)?,
218    })
219}
220
221/// One row of the activity feed, ordered by `sequence`.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct EventRecord {
224    pub sequence: i64,
225    pub occurred_at_ms: i64,
226    pub kind: String,
227    pub run_id: Option<String>,
228    pub ticket_id: Option<String>,
229    pub data_json: String,
230}
231
232/// One run's wall-clock boundaries, derived from the activity feed. Every
233/// field is optional because a run is observable at each stage of its life:
234/// claimed but not started, started but not finished.
235#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
236pub struct RunTimeline {
237    pub claimed_at_ms: Option<i64>,
238    pub started_at_ms: Option<i64>,
239    pub finished_at_ms: Option<i64>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ProjectNote {
244    pub id: String,
245    pub run_id: String,
246    pub ticket_id: String,
247    pub text: String,
248    pub recorded_at_ms: i64,
249}
250
251pub(crate) mod tx {
252    use rusqlite::{Transaction, params};
253
254    use super::{RunState, WorktreeCleanupCandidate};
255
256    /// Hands a claimed run to its driver: the workspace it will execute every
257    /// stage in becomes durable before the first stage runs, so recovery can
258    /// find the branch and worktree of a run whose first stage never
259    /// checkpointed a process.
260    pub(crate) fn mark_driving(
261        transaction: &Transaction<'_>,
262        run_id: &str,
263        branch: &str,
264        worktree_path: &str,
265        now_ms: i64,
266    ) -> rusqlite::Result<usize> {
267        transaction.execute(
268            "UPDATE runs
269             SET state = ?5, branch = ?2, worktree_path = ?3,
270                 started_at_ms = ?4, updated_at_ms = ?4
271             WHERE id = ?1 AND state = ?6 AND exited_at_ms IS NULL",
272            params![
273                run_id,
274                branch,
275                worktree_path,
276                now_ms,
277                RunState::Driving.as_str(),
278                RunState::Claimed.as_str(),
279            ],
280        )
281    }
282
283    /// Records the agent process an agent stage just launched. The run may be
284    /// driving (the usual case, the driver having taken the workspace first) or
285    /// already claimed, so a launch that raced the handoff still lands.
286    #[allow(clippy::too_many_arguments)]
287    pub(crate) fn mark_running(
288        transaction: &Transaction<'_>,
289        run_id: &str,
290        branch: &str,
291        worktree_path: &str,
292        pid: u32,
293        pid_start_time: Option<i64>,
294        process_group_id: u32,
295        worker_token: &str,
296        worker_socket_path: &str,
297        now_ms: i64,
298    ) -> rusqlite::Result<usize> {
299        transaction.execute(
300            "UPDATE runs
301             SET state = ?10, branch = ?2, worktree_path = ?3, pid = ?4,
302                 pid_start_time = ?5, process_group_id = ?6, worker_token = ?7,
303                 worker_socket_path = ?8,
304                 started_at_ms = COALESCE(started_at_ms, ?9), updated_at_ms = ?9
305             WHERE id = ?1 AND state IN (?11, ?12) AND exited_at_ms IS NULL",
306            params![
307                run_id,
308                branch,
309                worktree_path,
310                i64::from(pid),
311                pid_start_time,
312                i64::from(process_group_id),
313                worker_token,
314                worker_socket_path,
315                now_ms,
316                RunState::Running.as_str(),
317                RunState::Claimed.as_str(),
318                RunState::Driving.as_str(),
319            ],
320        )
321    }
322
323    pub(crate) fn state(
324        transaction: &Transaction<'_>,
325        run_id: &str,
326    ) -> rusqlite::Result<Option<String>> {
327        use rusqlite::OptionalExtension;
328
329        transaction
330            .query_row(
331                "SELECT state FROM runs WHERE id = ?1",
332                params![run_id],
333                |row| row.get(0),
334            )
335            .optional()
336    }
337
338    pub(crate) fn ticket_id(
339        transaction: &Transaction<'_>,
340        run_id: &str,
341    ) -> rusqlite::Result<String> {
342        transaction.query_row(
343            "SELECT ticket_id FROM runs WHERE id = ?1",
344            params![run_id],
345            |row| row.get(0),
346        )
347    }
348
349    pub(crate) fn finish(
350        transaction: &Transaction<'_>,
351        run_id: &str,
352        state: RunState,
353        exit_code: Option<i32>,
354        now_ms: i64,
355    ) -> rusqlite::Result<usize> {
356        transaction.execute(
357            "UPDATE runs
358             SET state = ?2, exited_at_ms = ?3, exit_code = ?4, updated_at_ms = ?3,
359                 cleanup_eligible_at_ms = CASE WHEN ?2 = ?5 THEN ?3 ELSE NULL END
360             WHERE id = ?1 AND exited_at_ms IS NULL",
361            params![
362                run_id,
363                state.as_str(),
364                now_ms,
365                exit_code,
366                RunState::Merged.as_str(),
367            ],
368        )
369    }
370
371    pub(crate) fn state_and_exit(
372        transaction: &Transaction<'_>,
373        run_id: &str,
374    ) -> rusqlite::Result<Option<(String, Option<i64>)>> {
375        use rusqlite::OptionalExtension;
376
377        transaction
378            .query_row(
379                "SELECT state, exited_at_ms FROM runs WHERE id = ?1",
380                params![run_id],
381                |row| Ok((row.get(0)?, row.get(1)?)),
382            )
383            .optional()
384    }
385
386    pub(crate) fn abort(
387        transaction: &Transaction<'_>,
388        run_id: &str,
389        now_ms: i64,
390    ) -> rusqlite::Result<usize> {
391        transaction.execute(
392            "UPDATE runs
393             SET state = ?3, exited_at_ms = ?2, updated_at_ms = ?2
394             WHERE id = ?1 AND exited_at_ms IS NULL",
395            params![run_id, now_ms, RunState::Aborted.as_str()],
396        )
397    }
398
399    pub(crate) fn mark_cleanup_eligible(
400        transaction: &Transaction<'_>,
401        run_id: &str,
402        now_ms: i64,
403    ) -> rusqlite::Result<usize> {
404        transaction.execute(
405            "UPDATE runs SET cleanup_eligible_at_ms = ?2
406             WHERE id = ?1 AND cleanup_eligible_at_ms IS NULL AND cleaned_at_ms IS NULL",
407            params![run_id, now_ms],
408        )
409    }
410
411    pub(crate) fn ids_for_ticket(
412        transaction: &Transaction<'_>,
413        ticket_id: &str,
414    ) -> rusqlite::Result<Vec<String>> {
415        let mut statement = transaction.prepare("SELECT id FROM runs WHERE ticket_id = ?1")?;
416        statement
417            .query_map(params![ticket_id], |row| row.get(0))?
418            .collect::<Result<Vec<_>, _>>()
419    }
420
421    pub(crate) fn ids_for_trigger(
422        transaction: &Transaction<'_>,
423        trigger_id: &str,
424    ) -> rusqlite::Result<Vec<String>> {
425        let mut statement = transaction.prepare("SELECT id FROM runs WHERE trigger_id = ?1")?;
426        statement
427            .query_map(params![trigger_id], |row| row.get(0))?
428            .collect::<Result<Vec<_>, _>>()
429    }
430
431    pub(crate) fn delete(transaction: &Transaction<'_>, run_id: &str) -> rusqlite::Result<usize> {
432        transaction.execute("DELETE FROM runs WHERE id = ?1", params![run_id])
433    }
434
435    pub(crate) fn mark_ticket_runs_cleanup_eligible(
436        transaction: &Transaction<'_>,
437        ticket_id: &str,
438        state: RunState,
439        now_ms: i64,
440    ) -> rusqlite::Result<usize> {
441        transaction.execute(
442            "UPDATE runs SET cleanup_eligible_at_ms = ?3
443             WHERE ticket_id = ?1 AND state = ?2
444               AND cleanup_eligible_at_ms IS NULL AND cleaned_at_ms IS NULL",
445            params![ticket_id, state.as_str(), now_ms],
446        )
447    }
448
449    pub(crate) fn mark_failed_or_review_runs_cleanup_eligible(
450        transaction: &Transaction<'_>,
451        ticket_id: &str,
452        now_ms: i64,
453    ) -> rusqlite::Result<usize> {
454        transaction.execute(
455            "UPDATE runs SET cleanup_eligible_at_ms = ?2
456             WHERE ticket_id = ?1 AND state IN ('failed', 'needs_review')
457               AND cleanup_eligible_at_ms IS NULL AND cleaned_at_ms IS NULL",
458            params![ticket_id, now_ms],
459        )
460    }
461
462    pub(crate) fn claim_agent_exit(
463        transaction: &Transaction<'_>,
464        run_id: &str,
465        exit_code: Option<i32>,
466        now_ms: i64,
467    ) -> rusqlite::Result<usize> {
468        transaction.execute(
469            "UPDATE runs
470             SET state = ?4, exit_code = ?2, updated_at_ms = ?3
471             WHERE id = ?1 AND state = ?5 AND exited_at_ms IS NULL",
472            params![
473                run_id,
474                exit_code,
475                now_ms,
476                RunState::Driving.as_str(),
477                RunState::Running.as_str(),
478            ],
479        )
480    }
481
482    pub(crate) fn release_agent(
483        transaction: &Transaction<'_>,
484        run_id: &str,
485        now_ms: i64,
486    ) -> rusqlite::Result<usize> {
487        transaction.execute(
488            "UPDATE runs
489             SET state = ?3, updated_at_ms = ?2
490             WHERE id = ?1 AND state = ?4 AND exited_at_ms IS NULL",
491            params![
492                run_id,
493                now_ms,
494                RunState::Driving.as_str(),
495                RunState::Running.as_str(),
496            ],
497        )
498    }
499
500    pub(crate) fn next_attempt(
501        transaction: &Transaction<'_>,
502        ticket_id: &str,
503    ) -> rusqlite::Result<i64> {
504        transaction.query_row(
505            "SELECT COALESCE(MAX(attempt), 0) + 1 FROM runs WHERE ticket_id = ?1",
506            params![ticket_id],
507            |row| row.get(0),
508        )
509    }
510
511    #[allow(clippy::too_many_arguments)]
512    pub(crate) fn insert_claimed(
513        transaction: &Transaction<'_>,
514        run_id: &str,
515        trigger_id: &str,
516        ticket_id: &str,
517        attempt: i64,
518        flow_json: &str,
519        ticket_json: &str,
520        now_ms: i64,
521    ) -> rusqlite::Result<usize> {
522        transaction.execute(
523            "INSERT INTO runs
524                 (id, trigger_id, ticket_id, state, attempt, flow_json, ticket_json,
525                  created_at_ms, updated_at_ms)
526             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
527            params![
528                run_id,
529                trigger_id,
530                ticket_id,
531                RunState::Claimed.as_str(),
532                attempt,
533                flow_json,
534                ticket_json,
535                now_ms,
536            ],
537        )
538    }
539
540    /// Appends one activity-feed row inside the transaction performing the
541    /// transition, so the event commits or rolls back with it.
542    pub(crate) fn record_event(
543        transaction: &Transaction<'_>,
544        now_ms: i64,
545        kind: &str,
546        run_id: Option<&str>,
547        ticket_id: Option<&str>,
548        data_json: &str,
549    ) -> rusqlite::Result<()> {
550        transaction.execute(
551            "INSERT INTO events (occurred_at_ms, kind, run_id, ticket_id, data_json)
552             VALUES (?1, ?2, ?3, ?4, ?5)",
553            params![now_ms, kind, run_id, ticket_id, data_json],
554        )?;
555        Ok(())
556    }
557
558    pub(crate) fn insert_note(
559        transaction: &Transaction<'_>,
560        id: &str,
561        run_id: &str,
562        text: &str,
563        now_ms: i64,
564    ) -> rusqlite::Result<()> {
565        transaction.execute(
566            "INSERT INTO notes (id, run_id, text, recorded_at_ms)
567             VALUES (?1, ?2, ?3, ?4)",
568            params![id, run_id, text, now_ms],
569        )?;
570        Ok(())
571    }
572
573    pub(crate) fn delete_notes_for_run(
574        transaction: &Transaction<'_>,
575        run_id: &str,
576    ) -> rusqlite::Result<usize> {
577        transaction.execute("DELETE FROM notes WHERE run_id = ?1", params![run_id])
578    }
579
580    pub(crate) fn trim_events(transaction: &Transaction<'_>, keep: i64) -> rusqlite::Result<()> {
581        transaction.execute(
582            "DELETE FROM events
583             WHERE sequence <= (SELECT COALESCE(MAX(sequence), 0) FROM events) - ?1",
584            params![keep],
585        )?;
586        Ok(())
587    }
588
589    pub(crate) fn mark_worktree_cleaned(
590        transaction: &Transaction<'_>,
591        candidate: &WorktreeCleanupCandidate,
592        now_ms: i64,
593    ) -> rusqlite::Result<bool> {
594        let changed = transaction.execute(
595            "UPDATE runs SET cleaned_at_ms = ?2, updated_at_ms = ?2
596             WHERE id = ?1 AND cleanup_eligible_at_ms IS NOT NULL
597               AND cleaned_at_ms IS NULL
598               AND NOT EXISTS (SELECT 1 FROM leases l WHERE l.run_id = runs.id)",
599            params![candidate.run_id, now_ms],
600        )?;
601        if changed == 1 {
602            record_event(
603                transaction,
604                now_ms,
605                "run_worktree_cleaned",
606                Some(&candidate.run_id),
607                Some(&candidate.ticket_id),
608                &serde_json::json!({
609                    "branch": candidate.branch,
610                    "worktree": candidate.worktree_path,
611                })
612                .to_string(),
613            )?;
614        }
615        Ok(changed == 1)
616    }
617}
618
619pub(crate) fn notes_for_run(
620    connection: &Connection,
621    run_id: &str,
622) -> rusqlite::Result<Vec<String>> {
623    let mut statement = connection
624        .prepare("SELECT text FROM notes WHERE run_id = ?1 ORDER BY recorded_at_ms, id")?;
625    statement
626        .query_map(params![run_id], |row| row.get(0))?
627        .collect::<Result<Vec<_>, _>>()
628}
629
630pub(crate) fn notes_for_project(
631    connection: &Connection,
632    project_id: &str,
633) -> rusqlite::Result<Vec<ProjectNote>> {
634    let mut statement = connection.prepare(
635        "SELECT n.id, n.run_id, r.ticket_id, n.text, n.recorded_at_ms
636         FROM notes n
637         JOIN runs r ON r.id = n.run_id
638         JOIN tickets t ON t.id = r.ticket_id
639         WHERE t.project_id = ?1
640         ORDER BY r.ticket_id, n.recorded_at_ms, n.id",
641    )?;
642    statement
643        .query_map(params![project_id], |row| {
644            Ok(ProjectNote {
645                id: row.get(0)?,
646                run_id: row.get(1)?,
647                ticket_id: row.get(2)?,
648                text: row.get(3)?,
649                recorded_at_ms: row.get(4)?,
650            })
651        })?
652        .collect::<Result<Vec<_>, _>>()
653}
654
655pub(crate) fn events_after(
656    connection: &Connection,
657    after: i64,
658    limit: usize,
659) -> rusqlite::Result<Vec<EventRecord>> {
660    let mut statement = connection.prepare(
661        "SELECT sequence, occurred_at_ms, kind, run_id, ticket_id, data_json
662         FROM events WHERE sequence > ?1 ORDER BY sequence LIMIT ?2",
663    )?;
664    statement
665        .query_map(params![after, limit as i64], |row| {
666            Ok(EventRecord {
667                sequence: row.get(0)?,
668                occurred_at_ms: row.get(1)?,
669                kind: row.get(2)?,
670                run_id: row.get(3)?,
671                ticket_id: row.get(4)?,
672                data_json: row.get(5)?,
673            })
674        })?
675        .collect::<Result<Vec<_>, _>>()
676}
677
678pub(crate) fn run_timelines(
679    connection: &Connection,
680    run_ids: &[&str],
681) -> rusqlite::Result<HashMap<String, RunTimeline>> {
682    if run_ids.is_empty() {
683        return Ok(HashMap::new());
684    }
685    let placeholders = std::iter::repeat_n("?", run_ids.len())
686        .collect::<Vec<_>>()
687        .join(", ");
688    let mut statement = connection.prepare(&format!(
689        "SELECT run_id,
690                MIN(CASE WHEN kind = 'run_claimed' THEN occurred_at_ms END),
691                MIN(CASE WHEN kind = 'run_started' THEN occurred_at_ms END),
692                MAX(CASE WHEN kind IN ('run_finished', 'run_aborted')
693                         THEN occurred_at_ms END)
694         FROM events
695         WHERE run_id IN ({placeholders})
696         GROUP BY run_id"
697    ))?;
698    statement
699        .query_map(rusqlite::params_from_iter(run_ids), |row| {
700            Ok((
701                row.get::<_, String>(0)?,
702                RunTimeline {
703                    claimed_at_ms: row.get(1)?,
704                    started_at_ms: row.get(2)?,
705                    finished_at_ms: row.get(3)?,
706                },
707            ))
708        })?
709        .collect::<Result<HashMap<_, _>, _>>()
710}
711
712pub(crate) fn latest_event_sequence(connection: &Connection) -> rusqlite::Result<i64> {
713    connection.query_row("SELECT COALESCE(MAX(sequence), 0) FROM events", [], |row| {
714        row.get(0)
715    })
716}
717
718pub(crate) fn ticket_is_referenced(
719    connection: &Connection,
720    ticket_id: &str,
721) -> rusqlite::Result<bool> {
722    connection.query_row(
723        "SELECT EXISTS (SELECT 1 FROM runs WHERE ticket_id = ?1)",
724        params![ticket_id],
725        |row| row.get(0),
726    )
727}
728
729pub(crate) fn run(connection: &Connection, id: &str) -> rusqlite::Result<Option<RunRecord>> {
730    connection
731        .query_row(
732            &format!("{RUN_RECORD_SELECT} WHERE id = ?1"),
733            params![id],
734            run_record,
735        )
736        .optional()
737}
738
739pub(crate) fn run_for_ticket_attempt(
740    connection: &Connection,
741    ticket_id: &str,
742    attempt: i64,
743) -> rusqlite::Result<Option<RunRecord>> {
744    connection
745        .query_row(
746            &format!(
747                "{RUN_RECORD_SELECT} WHERE ticket_id = ?1 AND attempt = ?2
748                 ORDER BY created_at_ms DESC LIMIT 1"
749            ),
750            params![ticket_id, attempt],
751            run_record,
752        )
753        .optional()
754}
755
756pub(crate) fn runs_for_ticket(
757    connection: &Connection,
758    ticket_id: &str,
759) -> rusqlite::Result<Vec<RunRecord>> {
760    let mut statement = connection.prepare(&format!(
761        "{RUN_RECORD_SELECT} WHERE ticket_id = ?1 ORDER BY attempt DESC, created_at_ms DESC"
762    ))?;
763    statement
764        .query_map(params![ticket_id], run_record)?
765        .collect::<Result<Vec<_>, _>>()
766}
767
768pub(crate) fn runs_with_id_prefix(
769    connection: &Connection,
770    prefix: &str,
771) -> rusqlite::Result<Vec<RunRecord>> {
772    let mut statement = connection.prepare(&format!(
773        "{RUN_RECORD_SELECT} WHERE SUBSTR(id, 1, ?2) = ?1 ORDER BY created_at_ms, id"
774    ))?;
775    statement
776        .query_map(params![prefix, prefix.len() as i64], run_record)?
777        .collect::<Result<Vec<_>, _>>()
778}
779
780pub(crate) fn needs_review_branches(
781    connection: &Connection,
782) -> rusqlite::Result<Vec<NeedsReviewBranch>> {
783    let mut statement = connection.prepare(
784        "SELECT t.id, r.id, r.branch
785         FROM tickets t
786         JOIN runs r ON r.id = (
787             SELECT r2.id FROM runs r2
788             WHERE r2.ticket_id = t.id
789               AND r2.state = 'needs_review'
790               AND r2.branch IS NOT NULL
791             ORDER BY r2.created_at_ms DESC, r2.id DESC
792             LIMIT 1
793         )
794         WHERE t.state = 'needs_review'",
795    )?;
796    statement
797        .query_map([], |row| {
798            Ok(NeedsReviewBranch {
799                ticket_id: row.get(0)?,
800                run_id: row.get(1)?,
801                branch: row.get(2)?,
802            })
803        })?
804        .collect::<Result<Vec<_>, _>>()
805}
806
807pub(crate) fn worktree_cleanup_candidates(
808    connection: &Connection,
809) -> rusqlite::Result<Vec<WorktreeCleanupCandidate>> {
810    let mut statement = connection.prepare(
811        "SELECT r.id, r.ticket_id, r.branch, r.worktree_path, r.cleanup_eligible_at_ms
812         FROM runs r
813         WHERE r.cleanup_eligible_at_ms IS NOT NULL
814           AND r.cleaned_at_ms IS NULL
815           AND r.branch IS NOT NULL
816           AND r.worktree_path IS NOT NULL
817           AND NOT EXISTS (SELECT 1 FROM leases l WHERE l.run_id = r.id)
818         ORDER BY r.cleanup_eligible_at_ms, r.id",
819    )?;
820    statement
821        .query_map([], |row| {
822            Ok(WorktreeCleanupCandidate {
823                run_id: row.get(0)?,
824                ticket_id: row.get(1)?,
825                branch: row.get(2)?,
826                worktree_path: row.get(3)?,
827                cleanup_eligible_at_ms: row.get(4)?,
828            })
829        })?
830        .collect::<Result<Vec<_>, _>>()
831}
832
833pub(crate) fn next_worktree_cleanup_at_ms(
834    connection: &Connection,
835    retention_ms: i64,
836    now_ms: i64,
837) -> rusqlite::Result<Option<i64>> {
838    let eligible_at: Option<i64> = connection.query_row(
839        "SELECT MIN(r.cleanup_eligible_at_ms)
840         FROM runs r
841         WHERE r.cleanup_eligible_at_ms IS NOT NULL
842           AND r.cleaned_at_ms IS NULL
843           AND r.branch IS NOT NULL
844           AND r.worktree_path IS NOT NULL
845           AND NOT EXISTS (SELECT 1 FROM leases l WHERE l.run_id = r.id)",
846        [],
847        |row| row.get(0),
848    )?;
849    Ok(eligible_at.and_then(|value| {
850        let deadline = value.saturating_add(retention_ms);
851        (deadline > now_ms).then_some(deadline)
852    }))
853}
854
855pub(crate) fn active_run_for_ticket(
856    connection: &Connection,
857    ticket_id: &str,
858) -> rusqlite::Result<Option<(String, i64)>> {
859    connection
860        .query_row(
861            "SELECT r.id, r.attempt FROM runs r
862             JOIN leases l ON l.run_id = r.id
863             WHERE r.ticket_id = ?1
864               AND r.state IN (?2, ?3, ?4)
865               AND r.exited_at_ms IS NULL
866             ORDER BY r.created_at_ms DESC, r.id DESC LIMIT 1",
867            params![
868                ticket_id,
869                NONTERMINAL_RUN_STATES[0].as_str(),
870                NONTERMINAL_RUN_STATES[1].as_str(),
871                NONTERMINAL_RUN_STATES[2].as_str(),
872            ],
873            |row| Ok((row.get(0)?, row.get(1)?)),
874        )
875        .optional()
876}
877
878pub(crate) fn active_runs(connection: &Connection) -> rusqlite::Result<Vec<ActiveRun>> {
879    let mut statement = connection.prepare(
880        "SELECT r.id, r.ticket_id, r.attempt, t.name, t.project_id, r.state FROM runs r
881         JOIN leases l ON l.run_id = r.id
882         JOIN tickets t ON t.id = r.ticket_id
883         WHERE r.exited_at_ms IS NULL
884           AND r.state IN (?1, ?2, ?3)
885         ORDER BY r.created_at_ms, r.id",
886    )?;
887    statement
888        .query_map(nonterminal_state_params(), |row| {
889            Ok(ActiveRun {
890                id: row.get(0)?,
891                ticket_id: row.get(1)?,
892                attempt: row.get(2)?,
893                ticket_name: row.get(3)?,
894                project_id: row.get(4)?,
895                state: row.get(5)?,
896            })
897        })?
898        .collect::<Result<Vec<_>, _>>()
899}
900
901pub(crate) fn recoverable_runs(connection: &Connection) -> rusqlite::Result<Vec<RecoverableRun>> {
902    let mut statement = connection.prepare(
903        "SELECT r.id, r.ticket_id, t.target, r.state, r.branch, r.worktree_path,
904                r.pid, r.pid_start_time, r.process_group_id, r.worker_token,
905                r.worker_socket_path, r.exit_code, l.expires_at_ms, r.flow_json,
906                r.ticket_json
907         FROM runs r
908         JOIN leases l ON l.run_id = r.id
909         JOIN tickets t ON t.id = r.ticket_id
910         WHERE r.exited_at_ms IS NULL
911           AND r.state IN (?1, ?2, ?3)
912         ORDER BY r.created_at_ms, r.id",
913    )?;
914    statement
915        .query_map(nonterminal_state_params(), |row| {
916            Ok(RecoverableRun {
917                id: row.get(0)?,
918                ticket_id: row.get(1)?,
919                target: row.get(2)?,
920                state: row.get(3)?,
921                branch: row.get(4)?,
922                worktree_path: row.get(5)?,
923                pid: row.get(6)?,
924                pid_start_time: row.get(7)?,
925                process_group_id: row.get(8)?,
926                worker_token: row.get(9)?,
927                worker_socket_path: row.get(10)?,
928                exit_code: row.get(11)?,
929                lease_expires_at_ms: row.get(12)?,
930                flow_json: row.get(13)?,
931                ticket_json: row.get(14)?,
932            })
933        })?
934        .collect::<Result<Vec<_>, _>>()
935}
936
937impl RunStore {
938    pub(crate) fn ticket_is_referenced(&self, id: &str) -> Result<bool, StoreError> {
939        ticket_is_referenced(&self.db.lock(), id).map_err(StoreError::from)
940    }
941
942    pub fn insert_claimed_run(
943        &self,
944        claim: &RunAdmission<'_>,
945        now_ms: i64,
946    ) -> Result<AdmittedRun, StoreError> {
947        self.write(TransactionBehavior::Immediate, |transaction| {
948            let attempt = tx::next_attempt(transaction, claim.ticket_id)?;
949            tx::insert_claimed(
950                transaction,
951                claim.run_id,
952                claim.trigger_id,
953                claim.ticket_id,
954                attempt,
955                claim.flow_json,
956                claim.ticket_json,
957                now_ms,
958            )?;
959            tx::record_event(
960                transaction,
961                now_ms,
962                "run_claimed",
963                Some(claim.run_id),
964                Some(claim.ticket_id),
965                &serde_json::json!({"attempt": attempt}).to_string(),
966            )?;
967            Ok(AdmittedRun {
968                run_id: claim.run_id.into(),
969                attempt,
970            })
971        })
972    }
973
974    pub(crate) fn insert_note(
975        &self,
976        id: &str,
977        run_id: &str,
978        text: &str,
979        now_ms: i64,
980    ) -> Result<(), StoreError> {
981        self.write(TransactionBehavior::Deferred, |transaction| {
982            tx::insert_note(transaction, id, run_id, text, now_ms)
983        })
984    }
985
986    pub fn notes_for_run(&self, run_id: &str) -> Result<Vec<String>, StoreError> {
987        notes_for_run(&self.db.lock(), run_id).map_err(StoreError::from)
988    }
989
990    pub(crate) fn notes_for_project(
991        &self,
992        project_id: &str,
993    ) -> Result<Vec<ProjectNote>, StoreError> {
994        notes_for_project(&self.db.lock(), project_id).map_err(StoreError::from)
995    }
996
997    pub(crate) fn events_after(
998        &self,
999        after: i64,
1000        limit: usize,
1001    ) -> Result<Vec<EventRecord>, StoreError> {
1002        events_after(&self.db.lock(), after, limit).map_err(StoreError::from)
1003    }
1004
1005    pub(crate) fn run_timelines(
1006        &self,
1007        run_ids: &[&str],
1008    ) -> Result<HashMap<String, RunTimeline>, StoreError> {
1009        if run_ids.is_empty() {
1010            return Ok(HashMap::new());
1011        }
1012        run_timelines(&self.db.lock(), run_ids).map_err(StoreError::from)
1013    }
1014
1015    pub(crate) fn latest_event_sequence(&self) -> Result<i64, StoreError> {
1016        latest_event_sequence(&self.db.lock()).map_err(StoreError::from)
1017    }
1018
1019    pub(crate) fn trim_events(&self, keep: i64) -> Result<(), StoreError> {
1020        self.write(TransactionBehavior::Deferred, |transaction| {
1021            tx::trim_events(transaction, keep)
1022        })
1023    }
1024
1025    pub(crate) fn run(&self, id: &str) -> Result<Option<RunRecord>, StoreError> {
1026        run(&self.db.lock(), id).map_err(StoreError::from)
1027    }
1028
1029    pub(crate) fn run_for_ticket_attempt(
1030        &self,
1031        ticket_id: &str,
1032        attempt: i64,
1033    ) -> Result<Option<RunRecord>, StoreError> {
1034        run_for_ticket_attempt(&self.db.lock(), ticket_id, attempt).map_err(StoreError::from)
1035    }
1036
1037    pub(crate) fn runs_for_ticket(&self, ticket_id: &str) -> Result<Vec<RunRecord>, StoreError> {
1038        runs_for_ticket(&self.db.lock(), ticket_id).map_err(StoreError::from)
1039    }
1040
1041    pub(crate) fn runs_with_id_prefix(&self, prefix: &str) -> Result<Vec<RunRecord>, StoreError> {
1042        runs_with_id_prefix(&self.db.lock(), prefix).map_err(StoreError::from)
1043    }
1044
1045    pub(crate) fn needs_review_branches(&self) -> Result<Vec<NeedsReviewBranch>, StoreError> {
1046        needs_review_branches(&self.db.lock()).map_err(StoreError::from)
1047    }
1048
1049    pub(crate) fn worktree_cleanup_candidates(
1050        &self,
1051    ) -> Result<Vec<WorktreeCleanupCandidate>, StoreError> {
1052        worktree_cleanup_candidates(&self.db.lock()).map_err(StoreError::from)
1053    }
1054
1055    pub(crate) fn next_worktree_cleanup_at_ms(
1056        &self,
1057        retention_ms: i64,
1058        now_ms: i64,
1059    ) -> Result<Option<i64>, StoreError> {
1060        next_worktree_cleanup_at_ms(&self.db.lock(), retention_ms, now_ms).map_err(StoreError::from)
1061    }
1062
1063    pub(crate) fn mark_run_worktree_cleaned(
1064        &self,
1065        candidate: &WorktreeCleanupCandidate,
1066        now_ms: i64,
1067    ) -> Result<bool, StoreError> {
1068        self.write(TransactionBehavior::Immediate, |transaction| {
1069            tx::mark_worktree_cleaned(transaction, candidate, now_ms)
1070        })
1071    }
1072
1073    pub(crate) fn active_run_for_ticket(
1074        &self,
1075        ticket_id: &str,
1076    ) -> Result<Option<(String, i64)>, StoreError> {
1077        active_run_for_ticket(&self.db.lock(), ticket_id).map_err(StoreError::from)
1078    }
1079
1080    pub(crate) fn active_runs(&self) -> Result<Vec<ActiveRun>, StoreError> {
1081        active_runs(&self.db.lock()).map_err(StoreError::from)
1082    }
1083
1084    pub(crate) fn recoverable_runs(&self) -> Result<Vec<RecoverableRun>, StoreError> {
1085        recoverable_runs(&self.db.lock()).map_err(StoreError::from)
1086    }
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091    use rusqlite::Connection;
1092    use tempfile::tempdir;
1093
1094    use super::{RunState, RunStore};
1095    use crate::db::{Db, LEGACY_STAGE_TABLE, REVERT_TRIGGER_RENAME, SCHEMA_VERSION, StoreError};
1096    use crate::domain::ticket::TicketSnapshot;
1097    use crate::flow::{Actor, Builtin, Check, FailAction, Flow, Stage};
1098    use crate::outcome::Outcome;
1099    use crate::run_store::test_support::{abort_run, claim_run, open_seeded, settle_run};
1100    use crate::run_store::{RunAdmission, RunStart, Start, StartDenial};
1101
1102    fn open(path: &std::path::Path, now_ms: i64) -> Result<RunStore, StoreError> {
1103        Db::open(path, now_ms)
1104            .map(RunStore::from_db)
1105            .map_err(StoreError::from)
1106    }
1107
1108    fn claim_t1(run_id: &str) -> RunAdmission<'_> {
1109        RunAdmission {
1110            ticket_id: "T1",
1111            run_id,
1112            trigger_id: "TR1",
1113            flow_json: "{}",
1114            ticket_json: "{}",
1115        }
1116    }
1117
1118    fn admit(store: &RunStore, claim: &RunAdmission<'_>, now_ms: i64) {
1119        claim_run(
1120            store,
1121            claim.run_id,
1122            claim.flow_json,
1123            claim.ticket_json,
1124            now_ms,
1125        );
1126    }
1127
1128    fn start_run(store: &RunStore, start: &RunStart<'_>, now_ms: i64) {
1129        assert_eq!(
1130            store
1131                .begin(start.run_id, start.branch, start.worktree_path, now_ms)
1132                .unwrap(),
1133            Start::Granted
1134        );
1135        assert_eq!(store.start(start, now_ms).unwrap(), Start::Granted);
1136    }
1137
1138    fn running_r1(store: &RunStore) {
1139        admit(store, &claim_t1("R1"), 2_000);
1140        start_run(
1141            store,
1142            &RunStart {
1143                run_id: "R1",
1144                branch: "branch",
1145                worktree_path: "/worktree",
1146                pid: 123,
1147                pid_start_time: Some(456),
1148                process_group_id: 123,
1149                worker_token: "token",
1150                worker_socket_path: "/runtime/R1.sock",
1151            },
1152            2_100,
1153        );
1154    }
1155
1156    #[test]
1157    fn every_run_state_round_trips_through_its_stored_string() {
1158        let states = [
1159            RunState::Claimed,
1160            RunState::Running,
1161            RunState::Driving,
1162            RunState::Aborted,
1163            RunState::Merged,
1164            RunState::Failed,
1165            RunState::NeedsReview,
1166            RunState::Cancelled,
1167            RunState::RateLimited,
1168            RunState::Orphaned,
1169        ];
1170        for state in states {
1171            assert_eq!(RunState::parse(state.as_str()).unwrap(), state);
1172        }
1173        for outcome in [
1174            Outcome::Merged,
1175            Outcome::Failed,
1176            Outcome::NeedsReview,
1177            Outcome::Cancelled,
1178            Outcome::RateLimited,
1179            Outcome::Orphaned,
1180        ] {
1181            assert_eq!(RunState::from(outcome).as_str(), outcome.as_str());
1182            assert!(RunState::from(outcome).is_terminal());
1183        }
1184        for state in [RunState::Claimed, RunState::Running, RunState::Driving] {
1185            assert!(!state.is_terminal());
1186        }
1187        assert!(RunState::Aborted.is_terminal());
1188    }
1189
1190    #[test]
1191    fn an_unknown_stored_run_state_is_an_error_not_a_fallback() {
1192        let error = RunState::parse("half_running").unwrap_err();
1193        assert!(matches!(error, StoreError::UnknownRunState { state } if state == "half_running"));
1194    }
1195
1196    #[test]
1197    fn claims_persist_flow_and_ticket_snapshots() {
1198        let directory = tempdir().unwrap();
1199        let store = open_seeded(&directory.path().join("sloop.db"));
1200        let flow = Flow {
1201            name: "default".into(),
1202            stages: vec![
1203                Stage {
1204                    name: "build".into(),
1205                    action: Actor::Agent,
1206                    result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
1207                    fail_action: FailAction::Halt,
1208                    ff_only: false,
1209                },
1210                Stage {
1211                    name: "check".into(),
1212                    action: Actor::Exec {
1213                        cmd: vec!["cargo".into(), "test".into()],
1214                    },
1215                    result_check: Check::None,
1216                    fail_action: FailAction::Halt,
1217                    ff_only: false,
1218                },
1219            ],
1220        };
1221        let ticket = TicketSnapshot {
1222            id: "T1".into(),
1223            name: "Ticket one".into(),
1224            blocked_by: vec![],
1225            worktree: Some("sloop/T1".into()),
1226            target: Some("claude".into()),
1227            model: Some("sonnet".into()),
1228            effort: Some("medium".into()),
1229            body: "# Original body\n".into(),
1230        };
1231        let flow_json = serde_json::to_string(&flow).unwrap();
1232        let ticket_json = serde_json::to_string(&ticket).unwrap();
1233
1234        admit(
1235            &store,
1236            &RunAdmission {
1237                flow_json: &flow_json,
1238                ticket_json: &ticket_json,
1239                ..claim_t1("R1")
1240            },
1241            2_000,
1242        );
1243
1244        let run = store.run("R1").unwrap().unwrap();
1245        assert_eq!(
1246            serde_json::from_str::<Flow>(run.flow_json.as_deref().unwrap()).unwrap(),
1247            flow
1248        );
1249        assert_eq!(
1250            serde_json::from_str::<TicketSnapshot>(run.ticket_json.as_deref().unwrap()).unwrap(),
1251            ticket
1252        );
1253    }
1254
1255    #[test]
1256    fn active_run_for_ticket_tracks_claimed_and_running_runs_only() {
1257        let directory = tempdir().unwrap();
1258        let store = open_seeded(&directory.path().join("sloop.db"));
1259        assert_eq!(store.active_run_for_ticket("T1").unwrap(), None);
1260
1261        admit(&store, &claim_t1("R1"), 2_000);
1262        assert_eq!(
1263            store.active_run_for_ticket("T1").unwrap(),
1264            Some(("R1".into(), 1))
1265        );
1266        start_run(
1267            &store,
1268            &RunStart {
1269                run_id: "R1",
1270                branch: "branch",
1271                worktree_path: "/tmp/worktree",
1272                pid: 1,
1273                pid_start_time: Some(1),
1274                process_group_id: 1,
1275                worker_token: "token",
1276                worker_socket_path: "/runtime/R1.sock",
1277            },
1278            2_100,
1279        );
1280        assert_eq!(
1281            store.active_run_for_ticket("T1").unwrap(),
1282            Some(("R1".into(), 1))
1283        );
1284
1285        settle_run(&store, "R1", Some(1), Outcome::Failed, &[], None, 2_200);
1286        assert_eq!(store.active_run_for_ticket("T1").unwrap(), None);
1287    }
1288
1289    #[test]
1290    fn starting_a_run_that_left_claimed_is_denied() {
1291        let directory = tempdir().unwrap();
1292        let store = open_seeded(&directory.path().join("sloop.db"));
1293        admit(&store, &claim_t1("R1"), 2_000);
1294        abort_run(&store, "R1", 2_050);
1295
1296        let result = store
1297            .start(
1298                &RunStart {
1299                    run_id: "R1",
1300                    branch: "branch",
1301                    worktree_path: "/tmp/worktree",
1302                    pid: 1,
1303                    pid_start_time: Some(1),
1304                    process_group_id: 1,
1305                    worker_token: "token",
1306                    worker_socket_path: "/runtime/R1.sock",
1307                },
1308                2_100,
1309            )
1310            .unwrap();
1311        assert_eq!(
1312            result,
1313            Start::Denied(StartDenial::NotClaimed {
1314                state: Some("aborted".into()),
1315            })
1316        );
1317    }
1318
1319    #[test]
1320    fn recoverable_runs_round_trip_process_identity_and_lease() {
1321        let directory = tempdir().unwrap();
1322        let path = directory.path().join("sloop.db");
1323        let store = open_seeded(&path);
1324        admit(&store, &claim_t1("R1"), 2_000);
1325        start_run(
1326            &store,
1327            &RunStart {
1328                run_id: "R1",
1329                branch: "sloop/T1-a1-R1",
1330                worktree_path: "/worktrees/R1",
1331                pid: 123,
1332                pid_start_time: Some(456),
1333                process_group_id: 123,
1334                worker_token: "worker-token",
1335                worker_socket_path: "/runtime/R1.sock",
1336            },
1337            2_100,
1338        );
1339        drop(store);
1340
1341        let store = open(&path, 3_000).unwrap();
1342        let runs = store.recoverable_runs().unwrap();
1343        assert_eq!(runs.len(), 1);
1344        assert_eq!(runs[0].id, "R1");
1345        assert_eq!(runs[0].ticket_id, "T1");
1346        assert_eq!(runs[0].pid, Some(123));
1347        assert_eq!(runs[0].pid_start_time, Some(456));
1348        assert_eq!(runs[0].process_group_id, Some(123));
1349        assert_eq!(runs[0].worker_token.as_deref(), Some("worker-token"));
1350        assert_eq!(
1351            runs[0].worker_socket_path.as_deref(),
1352            Some("/runtime/R1.sock")
1353        );
1354        assert_eq!(runs[0].exit_code, None);
1355        assert_eq!(runs[0].lease_expires_at_ms, 62_000);
1356    }
1357
1358    #[test]
1359    fn writable_probe_commits_without_changing_pause_state() {
1360        let directory = tempdir().unwrap();
1361        let store = open_seeded(&directory.path().join("sloop.db"));
1362
1363        store.probe_writable(2_000).unwrap();
1364
1365        assert!(!store.paused().unwrap());
1366        let updated_at_ms: i64 = store
1367            .db()
1368            .lock()
1369            .query_row(
1370                "SELECT updated_at_ms FROM scheduler_state WHERE singleton = 1",
1371                [],
1372                |row| row.get(0),
1373            )
1374            .unwrap();
1375        assert_eq!(updated_at_ms, 2_000);
1376    }
1377
1378    #[test]
1379    fn lifecycle_transitions_append_ordered_events() {
1380        let directory = tempdir().unwrap();
1381        let store = open_seeded(&directory.path().join("sloop.db"));
1382        running_r1(&store);
1383        settle_run(&store, "R1", Some(0), Outcome::Merged, &[], None, 2_300);
1384
1385        let events = store.events_after(0, 10).unwrap();
1386        let kinds: Vec<&str> = events.iter().map(|event| event.kind.as_str()).collect();
1387        assert_eq!(kinds, ["run_claimed", "run_started", "run_finished"]);
1388        assert!(events.iter().all(|event| {
1389            event.run_id.as_deref() == Some("R1") && event.ticket_id.as_deref() == Some("T1")
1390        }));
1391        let finished: serde_json::Value = serde_json::from_str(&events[2].data_json).unwrap();
1392        assert_eq!(finished["outcome"], "merged");
1393        assert_eq!(finished["ticket_state"], "merged");
1394
1395        settle_run(&store, "R1", Some(1), Outcome::Failed, &[], None, 2_400);
1396        assert_eq!(store.latest_event_sequence().unwrap(), events[2].sequence);
1397
1398        let rest = store.events_after(events[0].sequence, 10).unwrap();
1399        assert_eq!(rest.len(), 2);
1400        assert_eq!(rest[0].kind, "run_started");
1401
1402        store.trim_events(1).unwrap();
1403        let kept = store.events_after(0, 10).unwrap();
1404        assert_eq!(kept.len(), 1);
1405        assert_eq!(kept[0].sequence, events[2].sequence);
1406    }
1407
1408    #[test]
1409    fn abandoned_claims_append_an_abort_event() {
1410        let directory = tempdir().unwrap();
1411        let store = open_seeded(&directory.path().join("sloop.db"));
1412        admit(&store, &claim_t1("R1"), 2_000);
1413        abort_run(&store, "R1", 2_100);
1414
1415        let kinds: Vec<String> = store
1416            .events_after(0, 10)
1417            .unwrap()
1418            .into_iter()
1419            .map(|event| event.kind)
1420            .collect();
1421        assert_eq!(kinds, ["run_claimed", "run_aborted"]);
1422    }
1423
1424    #[test]
1425    fn notes_round_trip_in_arrival_order() {
1426        let directory = tempdir().unwrap();
1427        let store = open_seeded(&directory.path().join("sloop.db"));
1428        admit(&store, &claim_t1("R1"), 2_000);
1429
1430        assert_eq!(store.next_note_ordinal().unwrap(), 1);
1431        store.insert_note("N1", "R1", "first", 3_000).unwrap();
1432        store.insert_note("N2", "R1", "second", 3_000).unwrap();
1433        assert_eq!(store.next_note_ordinal().unwrap(), 3);
1434
1435        assert_eq!(
1436            store.notes_for_run("R1").unwrap(),
1437            vec!["first".to_owned(), "second".to_owned()]
1438        );
1439        assert!(store.notes_for_run("R2").unwrap().is_empty());
1440    }
1441
1442    #[test]
1443    fn version_three_migrates_ticket_metadata_and_newer_schemas_are_rejected() {
1444        let directory = tempdir().unwrap();
1445        let path = directory.path().join("sloop.db");
1446        drop(open(&path, 1_000).unwrap());
1447
1448        let connection = Connection::open(&path).unwrap();
1449        connection
1450            .execute_batch(
1451                "DROP TABLE ticket_blockers;
1452                 ALTER TABLE tickets DROP COLUMN name;
1453                 ALTER TABLE tickets DROP COLUMN worktree;
1454                 ALTER TABLE tickets DROP COLUMN flow;
1455                 ALTER TABLE tickets DROP COLUMN body;
1456                 ALTER TABLE tickets DROP COLUMN held_reason;
1457                 ALTER TABLE tickets DROP COLUMN missing_at_ms;
1458                 ALTER TABLE scheduler_state DROP COLUMN draining;
1459                 ALTER TABLE runs DROP COLUMN worker_socket_path;
1460                 ALTER TABLE runs DROP COLUMN flow_json;
1461                 ALTER TABLE runs DROP COLUMN ticket_json;
1462                 ALTER TABLE runs DROP COLUMN cleanup_eligible_at_ms;
1463                 ALTER TABLE runs DROP COLUMN cleaned_at_ms;",
1464            )
1465            .unwrap();
1466        connection
1467            .execute_batch(&format!(
1468                "ALTER TABLE stage_runs RENAME TO {LEGACY_STAGE_TABLE};"
1469            ))
1470            .unwrap();
1471        connection.execute_batch(REVERT_TRIGGER_RENAME).unwrap();
1472        connection.pragma_update(None, "user_version", 3).unwrap();
1473        drop(connection);
1474
1475        let store = open(&path, 2_000).unwrap();
1476        assert!(!store.paused().unwrap());
1477        store
1478            .db()
1479            .lock()
1480            .execute_batch(
1481                "INSERT INTO projects
1482                     (id, file_path, source, title, created_at_ms, updated_at_ms)
1483                 VALUES ('default', 'projects/default.md', 'local', 'Default', 2000, 2000);
1484                 INSERT INTO tickets
1485                     (id, project_id, file_path, source, state, name, worktree, target, flow,
1486                      created_at_ms, updated_at_ms)
1487                 VALUES ('T1', 'default', 'tickets/t1.md', 'local', 'ready', 'Ticket one',
1488                         'sloop/T1', 'codex', 'default', 2000, 2000);",
1489            )
1490            .unwrap();
1491        let target: String = store
1492            .db()
1493            .lock()
1494            .query_row("SELECT target FROM tickets WHERE id = 'T1'", [], |row| {
1495                row.get(0)
1496            })
1497            .unwrap();
1498        assert_eq!(target, "codex");
1499        drop(store);
1500
1501        let connection = Connection::open(&path).unwrap();
1502        connection.pragma_update(None, "user_version", 99).unwrap();
1503        drop(connection);
1504
1505        assert!(matches!(
1506            open(&path, 3_000),
1507            Err(StoreError::UnsupportedSchemaVersion(99))
1508        ));
1509    }
1510
1511    #[test]
1512    fn version_eight_migrates_existing_runs_with_null_snapshots() {
1513        let directory = tempdir().unwrap();
1514        let path = directory.path().join("sloop.db");
1515        let store = open_seeded(&path);
1516        admit(&store, &claim_t1("R1"), 2_000);
1517        drop(store);
1518
1519        let connection = Connection::open(&path).unwrap();
1520        connection
1521            .execute_batch(
1522                "ALTER TABLE runs DROP COLUMN flow_json;
1523                 ALTER TABLE runs DROP COLUMN ticket_json;
1524                 ALTER TABLE tickets DROP COLUMN body;
1525                 ALTER TABLE tickets DROP COLUMN held_reason;
1526                 ALTER TABLE scheduler_state DROP COLUMN draining;
1527                 ALTER TABLE runs DROP COLUMN cleanup_eligible_at_ms;
1528                 ALTER TABLE runs DROP COLUMN cleaned_at_ms;",
1529            )
1530            .unwrap();
1531        connection
1532            .execute_batch(&format!(
1533                "ALTER TABLE stage_runs RENAME TO {LEGACY_STAGE_TABLE};"
1534            ))
1535            .unwrap();
1536        connection.execute_batch(REVERT_TRIGGER_RENAME).unwrap();
1537        connection.pragma_update(None, "user_version", 8).unwrap();
1538        drop(connection);
1539
1540        let store = open(&path, 3_000).unwrap();
1541        let run = store.run("R1").unwrap().unwrap();
1542        assert_eq!(run.flow_json, None);
1543        assert_eq!(run.ticket_json, None);
1544    }
1545
1546    #[test]
1547    fn version_ten_adds_source_metadata_without_disturbing_ticket_state() {
1548        let directory = tempdir().unwrap();
1549        let path = directory.path().join("sloop.db");
1550        let store = open_seeded(&path);
1551        store
1552            .db()
1553            .lock()
1554            .execute(
1555                "UPDATE tickets SET state = 'held', attempts = 3 WHERE id = 'T1'",
1556                [],
1557            )
1558            .unwrap();
1559        drop(store);
1560
1561        let connection = Connection::open(&path).unwrap();
1562        connection
1563            .execute_batch(
1564                "ALTER TABLE tickets DROP COLUMN body;
1565                 ALTER TABLE tickets DROP COLUMN held_reason;
1566                 ALTER TABLE scheduler_state DROP COLUMN draining;
1567                 ALTER TABLE runs DROP COLUMN cleanup_eligible_at_ms;
1568                 ALTER TABLE runs DROP COLUMN cleaned_at_ms;",
1569            )
1570            .unwrap();
1571        connection
1572            .execute_batch(&format!(
1573                "ALTER TABLE stage_runs RENAME TO {LEGACY_STAGE_TABLE};"
1574            ))
1575            .unwrap();
1576        connection.execute_batch(REVERT_TRIGGER_RENAME).unwrap();
1577        connection.pragma_update(None, "user_version", 10).unwrap();
1578        drop(connection);
1579
1580        let store = open(&path, 3_000).unwrap();
1581        let ticket = store
1582            .db()
1583            .lock()
1584            .query_row(
1585                "SELECT state, attempts, body, held_reason FROM tickets WHERE id = 'T1'",
1586                [],
1587                |row| {
1588                    Ok((
1589                        row.get::<_, String>(0)?,
1590                        row.get::<_, i64>(1)?,
1591                        row.get::<_, Option<String>>(2)?,
1592                        row.get::<_, Option<String>>(3)?,
1593                    ))
1594                },
1595            )
1596            .unwrap();
1597        assert_eq!(ticket, ("held".into(), 3, None, None));
1598    }
1599
1600    #[test]
1601    fn paused_state_persists() {
1602        let directory = tempdir().unwrap();
1603        let path = directory.path().join("sloop.db");
1604
1605        let store = open(&path, 1_000).unwrap();
1606        store.set_paused(true, 2_000).unwrap();
1607        drop(store);
1608
1609        assert!(open(&path, 3_000).unwrap().paused().unwrap());
1610    }
1611
1612    #[test]
1613    fn restart_draining_is_durable_idempotent_and_cancelled_by_resume() {
1614        let directory = tempdir().unwrap();
1615        let path = directory.path().join("sloop.db");
1616        let store = open(&path, 1_000).unwrap();
1617
1618        assert!(store.begin_restart_draining(2, 2_000).unwrap());
1619        assert!(!store.begin_restart_draining(2, 2_100).unwrap());
1620        assert!(store.restart_draining().unwrap());
1621        assert_eq!(
1622            store
1623                .events_after(0, 10)
1624                .unwrap()
1625                .iter()
1626                .filter(|event| event.kind == "daemon_restart_requested")
1627                .count(),
1628            1
1629        );
1630        drop(store);
1631
1632        let reopened = open(&path, 3_000).unwrap();
1633        assert!(reopened.restart_draining().unwrap());
1634        assert!(reopened.resume_scheduler(4_000).unwrap());
1635        assert!(!reopened.restart_draining().unwrap());
1636    }
1637
1638    #[test]
1639    fn version_eleven_adds_restart_draining_state() {
1640        let directory = tempdir().unwrap();
1641        let path = directory.path().join("sloop.db");
1642        drop(open(&path, 1_000).unwrap());
1643        let connection = Connection::open(&path).unwrap();
1644        connection
1645            .execute_batch(&format!(
1646                "ALTER TABLE scheduler_state DROP COLUMN draining;
1647                 ALTER TABLE runs DROP COLUMN cleanup_eligible_at_ms;
1648                 ALTER TABLE runs DROP COLUMN cleaned_at_ms;
1649                 ALTER TABLE stage_runs RENAME TO {LEGACY_STAGE_TABLE};
1650                 PRAGMA user_version = 11;"
1651            ))
1652            .unwrap();
1653        connection.execute_batch(REVERT_TRIGGER_RENAME).unwrap();
1654        drop(connection);
1655
1656        let store = open(&path, 2_000).unwrap();
1657        assert!(!store.restart_draining().unwrap());
1658        assert_eq!(
1659            store
1660                .db()
1661                .lock()
1662                .query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0))
1663                .unwrap(),
1664            SCHEMA_VERSION
1665        );
1666    }
1667}