Skip to main content

orch/store/
helpers.rs

1//! Store helper functions used by the engine/CLI.
2//!
3//! These helpers operate on the unified SQLite task store (`TaskStore`) but accept
4//! `Option<Arc<TaskStore>>` because parts of the engine can run before the store is
5//! initialized.
6
7use crate::store::{CostEstimate, MemoryEntry, Task, TaskStore, TokenUsage};
8use anyhow::anyhow;
9use std::sync::Arc;
10
11/// Result type for helpers that distinguish "not found" from "read error".
12pub type StoreResult<T> = anyhow::Result<Option<T>>;
13
14fn token_i64_to_u64_non_negative(tokens: i64) -> u64 {
15    u64::try_from(tokens.max(0)).unwrap_or(0)
16}
17
18/// Resolve a task's numeric store ID from its external identifier.
19///
20/// Returns `None` if the store is unavailable, the task is not found, or the
21/// lookup fails (with a warning logged in that case).
22pub async fn resolve_store_id(
23    store: &Option<Arc<TaskStore>>,
24    repo: &str,
25    task_id: &str,
26) -> Option<i64> {
27    let s = store.as_ref()?;
28    match s.resolve_task_id(repo, task_id).await {
29        Ok(Some(id)) => Some(id),
30        Ok(None) => None,
31        Err(e) => {
32            tracing::warn!(task_id, error = %e, "failed to resolve task id for store operations");
33            None
34        }
35    }
36}
37
38/// Load the full `Task` record from the store.
39///
40/// Returns `None` if the store is unavailable, the task cannot be resolved,
41/// or the task row cannot be loaded.
42pub async fn opt_store_get_task_by_id(
43    store: &Option<Arc<TaskStore>>,
44    store_id: i64,
45) -> Option<Task> {
46    let s = store.as_ref()?;
47    match s.get(store_id).await {
48        Ok(task) => Some(task),
49        Err(e) => {
50            tracing::warn!(store_id, error = %e, "failed to fetch task from store");
51            None
52        }
53    }
54}
55
56/// Load the full `Task` record from the store.
57///
58/// Returns `None` if the store is unavailable, the task cannot be resolved,
59/// or the task row cannot be loaded.
60pub async fn opt_store_get_task(
61    store: &Option<Arc<TaskStore>>,
62    repo: &str,
63    task_id: &str,
64) -> Option<Task> {
65    let s = store.as_ref()?;
66    let store_id = match s.resolve_task_id(repo, task_id).await {
67        Ok(Some(id)) => id,
68        Ok(None) => return None,
69        Err(e) => {
70            tracing::warn!(task_id, error = %e, "failed to resolve task id in store");
71            return None;
72        }
73    };
74    opt_store_get_task_by_id(store, store_id).await
75}
76
77/// Write fields to the task store.
78///
79/// `store` may be None if the store isn't initialized yet.
80pub async fn store_set_by_id(
81    store: &Option<&Arc<TaskStore>>,
82    store_id: i64,
83    store_fields: &[(&str, serde_json::Value)],
84) -> anyhow::Result<()> {
85    if let Some(store) = store {
86        store.set_fields(store_id, store_fields).await?;
87    }
88    Ok(())
89}
90
91/// Write fields to the task store.
92///
93/// `store` may be None if the store isn't initialized yet.
94pub async fn store_set(
95    store: &Option<Arc<TaskStore>>,
96    repo: &str,
97    task_id: &str,
98    store_fields: &[(&str, serde_json::Value)],
99) {
100    if let Some(ref store) = store {
101        match store.resolve_task_id(repo, task_id).await {
102            Ok(Some(store_id)) => {
103                if let Err(e) = store.set_fields(store_id, store_fields).await {
104                    tracing::warn!(task_id, error = %e, "store set_fields failed");
105                }
106            }
107            Ok(None) => {} // task not in store — no-op is expected
108            Err(e) => {
109                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
110            }
111        }
112    }
113}
114
115/// Write fields to the task store, returning an error if the write fails.
116///
117/// Unlike `store_set`, this variant propagates failures so callers can
118/// abort further processing when a critical write (e.g. a watermark) fails.
119/// Returns `Ok(())` when `store` is `None` (store not yet initialized).
120pub async fn store_set_result_by_id(
121    store: &Option<Arc<TaskStore>>,
122    store_id: i64,
123    store_fields: &[(&str, serde_json::Value)],
124) -> anyhow::Result<()> {
125    if let Some(ref store) = store {
126        store.set_fields(store_id, store_fields).await?;
127    }
128    Ok(())
129}
130
131/// Write fields to the task store, returning an error if the write fails.
132///
133/// Unlike `store_set`, this variant propagates failures so callers can
134/// abort further processing when a critical write (e.g. a watermark) fails.
135/// Returns `Ok(())` when `store` is `None` (store not yet initialized).
136pub async fn store_set_result(
137    store: &Option<Arc<TaskStore>>,
138    repo: &str,
139    task_id: &str,
140    store_fields: &[(&str, serde_json::Value)],
141) -> anyhow::Result<()> {
142    if let Some(ref store) = store {
143        let store_id = store
144            .resolve_task_id(repo, task_id)
145            .await?
146            .ok_or_else(|| anyhow::anyhow!("task {} not found in store", task_id))?;
147        store.set_fields(store_id, store_fields).await?;
148    }
149    Ok(())
150}
151
152/// Touch `updated_at` to now so the stuck-task timer is reset after a session exits.
153///
154/// No-op when the store is unavailable or the task cannot be resolved.
155pub async fn store_touch_updated_at(store: &Option<Arc<TaskStore>>, repo: &str, task_id: &str) {
156    if let Some(ref s) = store {
157        match s.resolve_task_id(repo, task_id).await {
158            Ok(Some(store_id)) => {
159                if let Err(e) = s.touch_updated_at(store_id).await {
160                    tracing::warn!(task_id, error = %e, "store touch_updated_at failed");
161                } else {
162                    // Logged on success (not just failure) so a stuck-task reclaim race
163                    // can be diagnosed from logs by checking whether this touch actually
164                    // landed before the reclaim's own age check ran.
165                    tracing::debug!(task_id, store_id, "store touch_updated_at succeeded");
166                }
167            }
168            Ok(None) => {} // task not in store — no-op is expected
169            Err(e) => {
170                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
171            }
172        }
173    }
174}
175
176/// Append a lifecycle activity event to a task timeline.
177#[allow(clippy::too_many_arguments)]
178pub async fn store_log_activity(
179    store: &Option<Arc<TaskStore>>,
180    repo: &str,
181    task_id: &str,
182    event_type: &str,
183    from_status: Option<&str>,
184    to_status: Option<&str>,
185    agent: Option<&str>,
186    model: Option<&str>,
187    details: Option<&serde_json::Value>,
188) {
189    if let Some(ref s) = store {
190        match s.resolve_task_id(repo, task_id).await {
191            Ok(Some(store_id)) => {
192                if let Err(e) = s
193                    .append_activity(
194                        store_id,
195                        event_type,
196                        from_status,
197                        to_status,
198                        agent,
199                        model,
200                        details,
201                    )
202                    .await
203                {
204                    tracing::warn!(task_id, event_type, error = %e, "store append_activity failed");
205                }
206            }
207            Ok(None) => {} // task not in store — no-op is expected
208            Err(e) => {
209                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
210            }
211        }
212    }
213}
214
215pub async fn review_session_expected(store: &Arc<TaskStore>, repo: &str, task_id: &str) -> bool {
216    match store.resolve_task_id(repo, task_id).await {
217        Ok(Some(store_id)) => store
218            .get(store_id)
219            .await
220            .map(|task| task.review_session_expected)
221            .unwrap_or(false),
222        Ok(None) | Err(_) => false,
223    }
224}
225
226pub async fn set_review_session_expected(
227    store: &Arc<TaskStore>,
228    repo: &str,
229    task_id: &str,
230    expected: bool,
231) {
232    store_set(
233        &Some(Arc::clone(store)),
234        repo,
235        task_id,
236        &[("review_session_expected", serde_json::json!(expected))],
237    )
238    .await;
239}
240
241/// Increment a counter in the task store.
242///
243/// Uses `store.increment()` for an atomic SQL `field + 1`.
244/// Returns `Ok(new_value)` on success or `Err(anyhow::Error)` when the
245/// store is unavailable, the task cannot be resolved, or the underlying
246/// SQL increment failed. Callers must handle errors explicitly — this
247/// helper no longer silently returns 0 on error.
248pub async fn store_increment_by_id(
249    store: &Option<Arc<TaskStore>>,
250    store_id: i64,
251    field: &str,
252) -> anyhow::Result<u64> {
253    let s = store
254        .as_ref()
255        .ok_or_else(|| anyhow!("task store unavailable"))?;
256    match s.increment(store_id, field).await {
257        Ok(new_val) => Ok(new_val as u64),
258        Err(e) => Err(anyhow!("store.increment failed: {}", e)),
259    }
260}
261
262/// Increment a counter in the task store.
263///
264/// Uses `store.increment()` for an atomic SQL `field + 1`.
265/// Returns `Ok(new_value)` on success or `Err(anyhow::Error)` when the
266/// store is unavailable, the task cannot be resolved, or the underlying
267/// SQL increment failed. Callers must handle errors explicitly — this
268/// helper no longer silently returns 0 on error.
269pub async fn store_increment(
270    store: &Option<Arc<TaskStore>>,
271    repo: &str,
272    task_id: &str,
273    field: &str,
274) -> anyhow::Result<u64> {
275    let s = store
276        .as_ref()
277        .ok_or_else(|| anyhow!("task store unavailable"))?;
278    match s.resolve_task_id(repo, task_id).await {
279        Ok(Some(store_id)) => store_increment_by_id(store, store_id, field).await,
280        Ok(None) => Err(anyhow!("task not present in store: {}/{}", repo, task_id)),
281        Err(e) => {
282            tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
283            Err(anyhow!("resolve_task_id failed: {}", e))
284        }
285    }
286}
287
288/// Read a specific string field from a task record.
289pub async fn get_task_field(
290    store: &Option<Arc<TaskStore>>,
291    repo: &str,
292    task_id: &str,
293    field: &str,
294) -> Option<String> {
295    let task = opt_store_get_task(store, repo, task_id).await?;
296    match field {
297        "no_code_last_agent" => Some(task.no_code_last_agent),
298        _ => {
299            tracing::warn!(task_id, field, "get_task_field: unknown field");
300            None
301        }
302    }
303}
304
305/// Read a specific string field from a task record (store always available).
306pub async fn get_task_field_direct(
307    store: &Arc<TaskStore>,
308    repo: &str,
309    task_id: &str,
310    field: &str,
311) -> Option<String> {
312    let store_opt: &Option<Arc<TaskStore>> = &Some(store.clone());
313    get_task_field(store_opt, repo, task_id, field).await
314}
315
316/// Reset all task counters in the task store.
317pub async fn store_reset_counters(store: &Option<Arc<TaskStore>>, repo: &str, task_id: &str) {
318    if let Some(ref store) = store {
319        match store.resolve_task_id(repo, task_id).await {
320            Ok(Some(store_id)) => {
321                if let Err(e) = store.reset_counters(store_id).await {
322                    tracing::warn!(task_id, err = %e, "store reset_counters failed");
323                }
324            }
325            Ok(None) => {} // task not in store — no-op is expected
326            Err(e) => {
327                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
328            }
329        }
330    }
331}
332
333/// Reset transient failure/retry counters, preserving `review_cycles`.
334///
335/// Use this after a `RequestChanges` review decision so that per-attempt
336/// noise is cleared without undoing the cycle count set by `handle_review_changes`.
337pub async fn store_reset_failure_counters(
338    store: &Option<Arc<TaskStore>>,
339    repo: &str,
340    task_id: &str,
341) {
342    if let Some(ref store) = store {
343        match store.resolve_task_id(repo, task_id).await {
344            Ok(Some(store_id)) => {
345                if let Err(e) = store.reset_failure_counters(store_id).await {
346                    tracing::warn!(task_id, err = %e, "failed to reset failure counters after review dispatch");
347                }
348            }
349            Ok(None) => {} // task not in store — no-op is expected
350            Err(e) => {
351                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
352            }
353        }
354    }
355}
356
357/// Get token usage from the store, surfacing DB read errors.
358pub async fn get_token_usage_result(
359    store: &Option<Arc<TaskStore>>,
360    repo: &str,
361    task_id: &str,
362) -> StoreResult<TokenUsage> {
363    let s = store
364        .as_ref()
365        .ok_or_else(|| anyhow!("task store unavailable"))?;
366    let store_id = s
367        .resolve_task_id(repo, task_id)
368        .await?
369        .ok_or_else(|| anyhow!("task {}/{} not found in store", repo, task_id))?;
370    let task = s.get(store_id).await?;
371    Ok(Some(TokenUsage {
372        input_tokens: token_i64_to_u64_non_negative(task.input_tokens),
373        output_tokens: token_i64_to_u64_non_negative(task.output_tokens),
374    }))
375}
376
377/// Get token usage from the store.
378///
379/// Returns `TokenUsage::default()` when the store is unavailable or the task is not found.
380/// DB read errors are logged as warnings and return the default.
381pub async fn get_token_usage(
382    store: &Option<Arc<TaskStore>>,
383    repo: &str,
384    task_id: &str,
385) -> TokenUsage {
386    match get_token_usage_result(store, repo, task_id).await {
387        Ok(Some(usage)) => usage,
388        Ok(None) => TokenUsage::default(),
389        Err(e) => {
390            tracing::warn!(task_id, error = %e, "get_token_usage failed — returning zero usage");
391            TokenUsage::default()
392        }
393    }
394}
395
396/// Get cost estimate from the store, surfacing DB read errors.
397pub async fn get_cost_estimate_result(
398    store: &Option<Arc<TaskStore>>,
399    repo: &str,
400    task_id: &str,
401) -> StoreResult<CostEstimate> {
402    let s = store
403        .as_ref()
404        .ok_or_else(|| anyhow!("task store unavailable"))?;
405    let store_id = s
406        .resolve_task_id(repo, task_id)
407        .await?
408        .ok_or_else(|| anyhow!("task {}/{} not found in store", repo, task_id))?;
409    let task = s.get(store_id).await?;
410    Ok(Some(CostEstimate {
411        input_cost_usd: task.input_cost_usd,
412        output_cost_usd: task.output_cost_usd,
413        total_cost_usd: task.total_cost_usd,
414    }))
415}
416
417/// Get cost estimate from the store.
418///
419/// Returns `CostEstimate::default()` when the store is unavailable or the task is not found.
420/// DB read errors are logged as warnings and return the default.
421pub async fn get_cost_estimate(
422    store: &Option<Arc<TaskStore>>,
423    repo: &str,
424    task_id: &str,
425) -> CostEstimate {
426    match get_cost_estimate_result(store, repo, task_id).await {
427        Ok(Some(cost)) => cost,
428        Ok(None) => CostEstimate::default(),
429        Err(e) => {
430            tracing::warn!(task_id, error = %e, "get_cost_estimate failed — returning zero cost");
431            CostEstimate::default()
432        }
433    }
434}
435
436/// Get recent memory entries for a task, surfacing DB read errors.
437pub async fn get_recent_memory_result(
438    store: &Option<Arc<TaskStore>>,
439    repo: &str,
440    task_id: &str,
441    max: usize,
442) -> StoreResult<Vec<MemoryEntry>> {
443    let s = store
444        .as_ref()
445        .ok_or_else(|| anyhow!("task store unavailable"))?;
446    let store_id = s
447        .resolve_task_id(repo, task_id)
448        .await?
449        .ok_or_else(|| anyhow!("task {}/{} not found in store", repo, task_id))?;
450    let entries = s.recent_memory(store_id, max).await?;
451    Ok(Some(entries))
452}
453
454/// Get recent memory entries for a task.
455///
456/// Returns an empty vec when the store is unavailable or the task is not found.
457/// DB read errors are logged as warnings and return an empty vec.
458pub async fn get_recent_memory(
459    store: &Option<Arc<TaskStore>>,
460    repo: &str,
461    task_id: &str,
462    max: usize,
463) -> Vec<MemoryEntry> {
464    match get_recent_memory_result(store, repo, task_id, max).await {
465        Ok(Some(entries)) => entries,
466        Ok(None) => Vec::new(),
467        Err(e) => {
468            tracing::warn!(task_id, error = %e, "get_recent_memory failed — returning empty memory");
469            Vec::new()
470        }
471    }
472}