Skip to main content

ag_store/
operation.rs

1//! Session-operation persistence adapters and query helpers.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use sqlx::SqlitePool;
7
8use super::status;
9use crate::timestamp::TimestampSource;
10use crate::{DbError, DbResultExt};
11
12/// Persisted operation lifecycle state for one session command.
13pub struct SessionOperationRow {
14    /// Whether the owning workflow requested cancellation.
15    pub cancel_requested: bool,
16    /// Completion timestamp in Unix seconds, when finished.
17    pub finished_at: Option<i64>,
18    /// Most recent liveness timestamp in Unix seconds.
19    pub heartbeat_at: Option<i64>,
20    /// Stable operation identifier.
21    pub id: String,
22    /// Persisted operation-kind discriminator.
23    pub kind: String,
24    /// Most recent failure or cancellation reason.
25    pub last_error: Option<String>,
26    /// Queue-entry timestamp in Unix seconds.
27    pub queued_at: i64,
28    /// Session that owns the operation.
29    pub session_id: String,
30    /// Start timestamp in Unix seconds, when running.
31    pub started_at: Option<i64>,
32    /// Persisted operation-lifecycle status.
33    pub status: String,
34}
35
36/// Session-operation persistence boundary used by app orchestration and tests.
37#[cfg_attr(test, mockall::automock)]
38#[async_trait]
39pub trait OperationRepository: Send + Sync {
40    /// Marks unfinished operations as failed after process restart.
41    async fn fail_unfinished_session_operations(&self, reason: &str) -> Result<(), DbError>;
42
43    /// Returns whether cancellation is requested for a specific operation.
44    async fn is_cancel_requested_for_operation(&self, operation_id: &str) -> Result<bool, DbError>;
45
46    /// Returns whether an operation is still unfinished.
47    async fn is_session_operation_unfinished(&self, operation_id: &str) -> Result<bool, DbError>;
48
49    /// Loads operations still waiting in queue or currently running.
50    async fn load_unfinished_session_operations(&self)
51    -> Result<Vec<SessionOperationRow>, DbError>;
52
53    /// Marks an operation as canceled.
54    async fn mark_session_operation_canceled(
55        &self,
56        operation_id: &str,
57        reason: &str,
58    ) -> Result<(), DbError>;
59
60    /// Marks an operation as completed successfully.
61    async fn mark_session_operation_done(&self, operation_id: &str) -> Result<(), DbError>;
62
63    /// Marks an operation as failed with an error message.
64    async fn mark_session_operation_failed(
65        &self,
66        operation_id: &str,
67        error: &str,
68    ) -> Result<(), DbError>;
69
70    /// Marks an operation as running and refreshes its heartbeat timestamp.
71    async fn mark_session_operation_running(&self, operation_id: &str) -> Result<(), DbError>;
72
73    /// Claims an idempotent queued operation.
74    ///
75    /// Returns `true` when the caller must enqueue the command. Existing
76    /// queued, running, or completed operations return `false`; failed or
77    /// canceled attempts are reset and reclaimed for restart recovery.
78    async fn claim_session_operation(
79        &self,
80        operation_id: &str,
81        session_id: &str,
82        kind: &str,
83    ) -> Result<bool, DbError>;
84
85    /// Inserts a queued operation row for a session.
86    async fn insert_session_operation(
87        &self,
88        operation_id: &str,
89        session_id: &str,
90        kind: &str,
91    ) -> Result<(), DbError>;
92
93    /// Requests cancellation for unfinished operations of a session.
94    async fn request_cancel_for_session_operations(&self, session_id: &str) -> Result<(), DbError>;
95}
96
97/// `SQLite` implementation of [`OperationRepository`].
98#[derive(Clone)]
99pub(crate) struct SqliteOperationRepository {
100    pool: SqlitePool,
101    timestamp_source: Arc<dyn TimestampSource>,
102}
103
104impl SqliteOperationRepository {
105    /// Creates an operation repository backed by the provided pool.
106    pub(crate) fn new(pool: SqlitePool, timestamp_source: Arc<dyn TimestampSource>) -> Self {
107        Self {
108            pool,
109            timestamp_source,
110        }
111    }
112
113    /// Returns the shared persistence timestamp in Unix seconds.
114    fn now(&self) -> i64 {
115        self.timestamp_source.now_timestamp_seconds()
116    }
117}
118
119/// Row returned when loading one non-null boolean scalar value.
120struct RequiredBoolValueRow {
121    value: bool,
122}
123
124#[async_trait]
125impl OperationRepository for SqliteOperationRepository {
126    async fn fail_unfinished_session_operations(&self, reason: &str) -> Result<(), DbError> {
127        let now = self.now();
128
129        sqlx::query!(
130            r"
131UPDATE session_operation
132SET status = 'failed',
133    finished_at = ?,
134    heartbeat_at = ?,
135    last_error = ?,
136    cancel_requested = 1
137WHERE status IN ('queued', 'running')
138",
139            now,
140            now,
141            reason
142        )
143        .execute(&self.pool)
144        .await
145        .db_context("fail unfinished session operations")?;
146
147        Ok(())
148    }
149
150    async fn is_cancel_requested_for_operation(&self, operation_id: &str) -> Result<bool, DbError> {
151        let row = sqlx::query_as!(
152            RequiredBoolValueRow,
153            r#"
154SELECT EXISTS(
155    SELECT 1
156    FROM session_operation
157    WHERE id = ?
158      AND cancel_requested = 1
159      AND status IN ('queued', 'running')
160) AS "value!: _"
161"#,
162            operation_id
163        )
164        .fetch_one(&self.pool)
165        .await?;
166
167        Ok(row.value)
168    }
169
170    async fn is_session_operation_unfinished(&self, operation_id: &str) -> Result<bool, DbError> {
171        let row = sqlx::query_as!(
172            RequiredBoolValueRow,
173            r#"
174SELECT EXISTS(
175    SELECT 1
176    FROM session_operation
177    WHERE id = ?
178      AND status IN ('queued', 'running')
179) AS "value!: _"
180"#,
181            operation_id
182        )
183        .fetch_one(&self.pool)
184        .await?;
185
186        Ok(row.value)
187    }
188
189    async fn load_unfinished_session_operations(
190        &self,
191    ) -> Result<Vec<SessionOperationRow>, DbError> {
192        let rows = sqlx::query_as!(
193            SessionOperationRow,
194            r#"
195SELECT id AS "id!", session_id AS "session_id!", kind AS "kind!", status AS "status!",
196       queued_at, started_at, finished_at,
197       heartbeat_at, last_error,
198       cancel_requested AS "cancel_requested: _"
199FROM session_operation
200WHERE status IN ('queued', 'running')
201ORDER BY queued_at ASC, id ASC
202            "#
203        )
204        .fetch_all(&self.pool)
205        .await?;
206        for row in &rows {
207            status::validate_operation(&row.status)?;
208        }
209
210        Ok(rows)
211    }
212
213    async fn mark_session_operation_canceled(
214        &self,
215        operation_id: &str,
216        reason: &str,
217    ) -> Result<(), DbError> {
218        let now = self.now();
219
220        sqlx::query!(
221            r"
222UPDATE session_operation
223SET status = 'canceled',
224    finished_at = ?,
225    heartbeat_at = ?,
226    last_error = ?
227WHERE id = ?
228",
229            now,
230            now,
231            reason,
232            operation_id
233        )
234        .execute(&self.pool)
235        .await?;
236
237        Ok(())
238    }
239
240    async fn mark_session_operation_done(&self, operation_id: &str) -> Result<(), DbError> {
241        let now = self.now();
242
243        sqlx::query!(
244            r"
245UPDATE session_operation
246SET status = 'done',
247    finished_at = ?,
248    heartbeat_at = ?,
249    last_error = NULL
250WHERE id = ?
251",
252            now,
253            now,
254            operation_id
255        )
256        .execute(&self.pool)
257        .await?;
258
259        Ok(())
260    }
261
262    async fn mark_session_operation_failed(
263        &self,
264        operation_id: &str,
265        error: &str,
266    ) -> Result<(), DbError> {
267        let now = self.now();
268
269        sqlx::query!(
270            r"
271UPDATE session_operation
272SET status = 'failed',
273    finished_at = ?,
274    heartbeat_at = ?,
275    last_error = ?
276WHERE id = ?
277",
278            now,
279            now,
280            error,
281            operation_id
282        )
283        .execute(&self.pool)
284        .await?;
285
286        Ok(())
287    }
288
289    async fn mark_session_operation_running(&self, operation_id: &str) -> Result<(), DbError> {
290        let now = self.now();
291
292        sqlx::query!(
293            r"
294UPDATE session_operation
295SET status = 'running',
296    started_at = COALESCE(started_at, ?),
297    heartbeat_at = ?,
298    last_error = NULL
299WHERE id = ?
300",
301            now,
302            now,
303            operation_id
304        )
305        .execute(&self.pool)
306        .await?;
307
308        Ok(())
309    }
310
311    async fn claim_session_operation(
312        &self,
313        operation_id: &str,
314        session_id: &str,
315        kind: &str,
316    ) -> Result<bool, DbError> {
317        let queued_at = self.now();
318
319        let claimed = sqlx::query!(
320            r#"
321INSERT INTO session_operation (id, session_id, kind, status, queued_at)
322VALUES (?, ?, ?, 'queued', ?)
323ON CONFLICT(id) DO UPDATE SET
324    session_id = excluded.session_id,
325    kind = excluded.kind,
326    status = 'queued',
327    queued_at = excluded.queued_at,
328    started_at = NULL,
329    finished_at = NULL,
330    heartbeat_at = NULL,
331    last_error = NULL,
332    cancel_requested = 0
333WHERE session_operation.status IN ('failed', 'canceled')
334RETURNING id AS "id!: String"
335"#,
336            operation_id,
337            session_id,
338            kind,
339            queued_at
340        )
341        .fetch_optional(&self.pool)
342        .await
343        .db_context("claim session operation")?;
344
345        Ok(claimed.is_some())
346    }
347
348    async fn insert_session_operation(
349        &self,
350        operation_id: &str,
351        session_id: &str,
352        kind: &str,
353    ) -> Result<(), DbError> {
354        let queued_at = self.now();
355
356        sqlx::query!(
357            r"
358INSERT INTO session_operation (id, session_id, kind, status, queued_at)
359VALUES (?, ?, ?, 'queued', ?)
360",
361            operation_id,
362            session_id,
363            kind,
364            queued_at
365        )
366        .execute(&self.pool)
367        .await?;
368
369        Ok(())
370    }
371
372    async fn request_cancel_for_session_operations(&self, session_id: &str) -> Result<(), DbError> {
373        sqlx::query!(
374            r"
375UPDATE session_operation
376SET cancel_requested = 1
377WHERE session_id = ?
378  AND status IN ('queued', 'running')
379",
380            session_id
381        )
382        .execute(&self.pool)
383        .await?;
384
385        Ok(())
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use crate::{AppRepositories, DbError};
392
393    #[tokio::test]
394    /// Claims new and failed idempotent operations while leaving accepted
395    /// operations untouched.
396    async fn test_claim_session_operation_recovers_only_terminal_failures() {
397        // Arrange
398        let database = AppRepositories::in_memory().await.expect("db should open");
399        let project_id = database
400            .projects()
401            .upsert_project("/tmp/operation-project", Some("main".to_string()))
402            .await
403            .expect("failed to insert project");
404        database
405            .sessions()
406            .insert_session("session-a", "gpt-5.6-sol", "main", "Review", project_id)
407            .await
408            .expect("failed to insert session");
409
410        // Act
411        let first_claim = database
412            .operations()
413            .claim_session_operation("rollup-1", "session-a", "reply")
414            .await
415            .expect("failed to claim new operation");
416        let queued_claim = database
417            .operations()
418            .claim_session_operation("rollup-1", "session-a", "reply")
419            .await
420            .expect("failed to inspect queued operation");
421        database
422            .operations()
423            .mark_session_operation_failed("rollup-1", "restart")
424            .await
425            .expect("failed to mark operation failed");
426        let recovered_claim = database
427            .operations()
428            .claim_session_operation("rollup-1", "session-a", "reply")
429            .await
430            .expect("failed to reclaim failed operation");
431        database
432            .operations()
433            .mark_session_operation_done("rollup-1")
434            .await
435            .expect("failed to mark operation done");
436        let done_claim = database
437            .operations()
438            .claim_session_operation("rollup-1", "session-a", "reply")
439            .await
440            .expect("failed to inspect completed operation");
441
442        // Assert
443        assert!(first_claim);
444        assert!(!queued_claim);
445        assert!(recovered_claim);
446        assert!(!done_claim);
447    }
448
449    #[tokio::test]
450    async fn recovery_failure_reports_semantic_operation_context() {
451        // Arrange
452        let (database, pool) = AppRepositories::in_memory_with_pool()
453            .await
454            .expect("db should open");
455        sqlx::query("DROP TABLE session_operation")
456            .execute(&pool)
457            .await
458            .expect("failed to drop operation table");
459
460        // Act
461        let error = database
462            .operations()
463            .fail_unfinished_session_operations("restart")
464            .await
465            .expect_err("recovery should fail without its table");
466
467        // Assert
468        assert!(matches!(
469            error,
470            DbError::QueryContext {
471                operation: "fail unfinished session operations",
472                ..
473            }
474        ));
475    }
476}