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
215/// Increment a counter in the task store.
216///
217/// Uses `store.increment()` for an atomic SQL `field + 1`.
218/// Returns `Ok(new_value)` on success or `Err(anyhow::Error)` when the
219/// store is unavailable, the task cannot be resolved, or the underlying
220/// SQL increment failed. Callers must handle errors explicitly — this
221/// helper no longer silently returns 0 on error.
222pub async fn store_increment_by_id(
223    store: &Option<Arc<TaskStore>>,
224    store_id: i64,
225    field: &str,
226) -> anyhow::Result<u64> {
227    let s = store
228        .as_ref()
229        .ok_or_else(|| anyhow!("task store unavailable"))?;
230    match s.increment(store_id, field).await {
231        Ok(new_val) => Ok(new_val as u64),
232        Err(e) => Err(anyhow!("store.increment failed: {}", e)),
233    }
234}
235
236/// Increment a counter in the task store.
237///
238/// Uses `store.increment()` for an atomic SQL `field + 1`.
239/// Returns `Ok(new_value)` on success or `Err(anyhow::Error)` when the
240/// store is unavailable, the task cannot be resolved, or the underlying
241/// SQL increment failed. Callers must handle errors explicitly — this
242/// helper no longer silently returns 0 on error.
243pub async fn store_increment(
244    store: &Option<Arc<TaskStore>>,
245    repo: &str,
246    task_id: &str,
247    field: &str,
248) -> anyhow::Result<u64> {
249    let s = store
250        .as_ref()
251        .ok_or_else(|| anyhow!("task store unavailable"))?;
252    match s.resolve_task_id(repo, task_id).await {
253        Ok(Some(store_id)) => store_increment_by_id(store, store_id, field).await,
254        Ok(None) => Err(anyhow!("task not present in store: {}/{}", repo, task_id)),
255        Err(e) => {
256            tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
257            Err(anyhow!("resolve_task_id failed: {}", e))
258        }
259    }
260}
261
262/// Read a specific string field from a task record.
263pub async fn get_task_field(
264    store: &Option<Arc<TaskStore>>,
265    repo: &str,
266    task_id: &str,
267    field: &str,
268) -> Option<String> {
269    let task = opt_store_get_task(store, repo, task_id).await?;
270    match field {
271        "no_code_last_agent" => Some(task.no_code_last_agent),
272        _ => {
273            tracing::warn!(task_id, field, "get_task_field: unknown field");
274            None
275        }
276    }
277}
278
279/// Read a specific string field from a task record (store always available).
280pub async fn get_task_field_direct(
281    store: &Arc<TaskStore>,
282    repo: &str,
283    task_id: &str,
284    field: &str,
285) -> Option<String> {
286    let store_opt: &Option<Arc<TaskStore>> = &Some(store.clone());
287    get_task_field(store_opt, repo, task_id, field).await
288}
289
290/// Reset all task counters in the task store.
291pub async fn store_reset_counters(store: &Option<Arc<TaskStore>>, repo: &str, task_id: &str) {
292    if let Some(ref store) = store {
293        match store.resolve_task_id(repo, task_id).await {
294            Ok(Some(store_id)) => {
295                if let Err(e) = store.reset_counters(store_id).await {
296                    tracing::warn!(task_id, err = %e, "store reset_counters failed");
297                }
298            }
299            Ok(None) => {} // task not in store — no-op is expected
300            Err(e) => {
301                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
302            }
303        }
304    }
305}
306
307/// Reset transient failure/retry counters, preserving `review_cycles`.
308///
309/// Use this after a `RequestChanges` review decision so that per-attempt
310/// noise is cleared without undoing the cycle count set by `handle_review_changes`.
311pub async fn store_reset_failure_counters(
312    store: &Option<Arc<TaskStore>>,
313    repo: &str,
314    task_id: &str,
315) {
316    if let Some(ref store) = store {
317        match store.resolve_task_id(repo, task_id).await {
318            Ok(Some(store_id)) => {
319                if let Err(e) = store.reset_failure_counters(store_id).await {
320                    tracing::warn!(task_id, err = %e, "failed to reset failure counters after review dispatch");
321                }
322            }
323            Ok(None) => {} // task not in store — no-op is expected
324            Err(e) => {
325                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
326            }
327        }
328    }
329}
330
331/// Get token usage from the store, surfacing DB read errors.
332pub async fn get_token_usage_result(
333    store: &Option<Arc<TaskStore>>,
334    repo: &str,
335    task_id: &str,
336) -> StoreResult<TokenUsage> {
337    let s = store
338        .as_ref()
339        .ok_or_else(|| anyhow!("task store unavailable"))?;
340    let store_id = s
341        .resolve_task_id(repo, task_id)
342        .await?
343        .ok_or_else(|| anyhow!("task {}/{} not found in store", repo, task_id))?;
344    let task = s.get(store_id).await?;
345    Ok(Some(TokenUsage {
346        input_tokens: token_i64_to_u64_non_negative(task.input_tokens),
347        output_tokens: token_i64_to_u64_non_negative(task.output_tokens),
348    }))
349}
350
351/// Get token usage from the store.
352///
353/// Returns `TokenUsage::default()` when the store is unavailable or the task is not found.
354/// DB read errors are logged as warnings and return the default.
355pub async fn get_token_usage(
356    store: &Option<Arc<TaskStore>>,
357    repo: &str,
358    task_id: &str,
359) -> TokenUsage {
360    match get_token_usage_result(store, repo, task_id).await {
361        Ok(Some(usage)) => usage,
362        Ok(None) => TokenUsage::default(),
363        Err(e) => {
364            tracing::warn!(task_id, error = %e, "get_token_usage failed — returning zero usage");
365            TokenUsage::default()
366        }
367    }
368}
369
370/// Get cost estimate from the store, surfacing DB read errors.
371pub async fn get_cost_estimate_result(
372    store: &Option<Arc<TaskStore>>,
373    repo: &str,
374    task_id: &str,
375) -> StoreResult<CostEstimate> {
376    let s = store
377        .as_ref()
378        .ok_or_else(|| anyhow!("task store unavailable"))?;
379    let store_id = s
380        .resolve_task_id(repo, task_id)
381        .await?
382        .ok_or_else(|| anyhow!("task {}/{} not found in store", repo, task_id))?;
383    let task = s.get(store_id).await?;
384    Ok(Some(CostEstimate {
385        input_cost_usd: task.input_cost_usd,
386        output_cost_usd: task.output_cost_usd,
387        total_cost_usd: task.total_cost_usd,
388    }))
389}
390
391/// Get cost estimate from the store.
392///
393/// Returns `CostEstimate::default()` when the store is unavailable or the task is not found.
394/// DB read errors are logged as warnings and return the default.
395pub async fn get_cost_estimate(
396    store: &Option<Arc<TaskStore>>,
397    repo: &str,
398    task_id: &str,
399) -> CostEstimate {
400    match get_cost_estimate_result(store, repo, task_id).await {
401        Ok(Some(cost)) => cost,
402        Ok(None) => CostEstimate::default(),
403        Err(e) => {
404            tracing::warn!(task_id, error = %e, "get_cost_estimate failed — returning zero cost");
405            CostEstimate::default()
406        }
407    }
408}
409
410/// Get recent memory entries for a task, surfacing DB read errors.
411pub async fn get_recent_memory_result(
412    store: &Option<Arc<TaskStore>>,
413    repo: &str,
414    task_id: &str,
415    max: usize,
416) -> StoreResult<Vec<MemoryEntry>> {
417    let s = store
418        .as_ref()
419        .ok_or_else(|| anyhow!("task store unavailable"))?;
420    let store_id = s
421        .resolve_task_id(repo, task_id)
422        .await?
423        .ok_or_else(|| anyhow!("task {}/{} not found in store", repo, task_id))?;
424    let entries = s.recent_memory(store_id, max).await?;
425    Ok(Some(entries))
426}
427
428/// Get recent memory entries for a task.
429///
430/// Returns an empty vec when the store is unavailable or the task is not found.
431/// DB read errors are logged as warnings and return an empty vec.
432pub async fn get_recent_memory(
433    store: &Option<Arc<TaskStore>>,
434    repo: &str,
435    task_id: &str,
436    max: usize,
437) -> Vec<MemoryEntry> {
438    match get_recent_memory_result(store, repo, task_id, max).await {
439        Ok(Some(entries)) => entries,
440        Ok(None) => Vec::new(),
441        Err(e) => {
442            tracing::warn!(task_id, error = %e, "get_recent_memory failed — returning empty memory");
443            Vec::new()
444        }
445    }
446}