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(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(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(store) = store {
143        let store_id = store
144            .resolve_task_id(repo, task_id)
145            .await?
146            .ok_or_else(|| anyhow::anyhow!("task {task_id} not found in store"))?;
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(s) = store {
157        match s.resolve_task_id(repo, task_id).await {
158            Ok(Some(store_id)) => {
159                match s.touch_updated_at(store_id).await {
160                    Err(e) => {
161                        tracing::warn!(task_id, error = %e, "store touch_updated_at failed");
162                    }
163                    _ => {
164                        // Logged on success (not just failure) so a stuck-task reclaim race
165                        // can be diagnosed from logs by checking whether this touch actually
166                        // landed before the reclaim's own age check ran.
167                        tracing::debug!(task_id, store_id, "store touch_updated_at succeeded");
168                    }
169                }
170            }
171            Ok(None) => {} // task not in store — no-op is expected
172            Err(e) => {
173                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
174            }
175        }
176    }
177}
178
179/// Append a lifecycle activity event to a task timeline.
180#[expect(
181    clippy::too_many_arguments,
182    reason = "the arguments are the activity row columns"
183)]
184pub async fn store_log_activity(
185    store: &Option<Arc<TaskStore>>,
186    repo: &str,
187    task_id: &str,
188    event_type: &str,
189    from_status: Option<&str>,
190    to_status: Option<&str>,
191    agent: Option<&str>,
192    model: Option<&str>,
193    details: Option<&serde_json::Value>,
194) {
195    if let Some(s) = store {
196        match s.resolve_task_id(repo, task_id).await {
197            Ok(Some(store_id)) => {
198                if let Err(e) = s
199                    .append_activity(
200                        store_id,
201                        event_type,
202                        from_status,
203                        to_status,
204                        agent,
205                        model,
206                        details,
207                    )
208                    .await
209                {
210                    tracing::warn!(task_id, event_type, error = %e, "store append_activity failed");
211                }
212            }
213            Ok(None) => {} // task not in store — no-op is expected
214            Err(e) => {
215                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
216            }
217        }
218    }
219}
220
221/// Increment a counter in the task store.
222///
223/// Uses `store.increment()` for an atomic SQL `field + 1`.
224/// Returns `Ok(new_value)` on success or `Err(anyhow::Error)` when the
225/// store is unavailable, the task cannot be resolved, or the underlying
226/// SQL increment failed. Callers must handle errors explicitly — this
227/// helper no longer silently returns 0 on error.
228pub async fn store_increment_by_id(
229    store: &Option<Arc<TaskStore>>,
230    store_id: i64,
231    field: &str,
232) -> anyhow::Result<u64> {
233    let s = store
234        .as_ref()
235        .ok_or_else(|| anyhow!("task store unavailable"))?;
236    match s.increment(store_id, field).await {
237        Ok(new_val) => Ok(u64::try_from(new_val).unwrap_or(0)),
238        Err(e) => Err(anyhow!("store.increment failed: {e}")),
239    }
240}
241
242/// Increment a counter in the task store.
243///
244/// Uses `store.increment()` for an atomic SQL `field + 1`.
245/// Returns `Ok(new_value)` on success or `Err(anyhow::Error)` when the
246/// store is unavailable, the task cannot be resolved, or the underlying
247/// SQL increment failed. Callers must handle errors explicitly — this
248/// helper no longer silently returns 0 on error.
249pub async fn store_increment(
250    store: &Option<Arc<TaskStore>>,
251    repo: &str,
252    task_id: &str,
253    field: &str,
254) -> anyhow::Result<u64> {
255    let s = store
256        .as_ref()
257        .ok_or_else(|| anyhow!("task store unavailable"))?;
258    match s.resolve_task_id(repo, task_id).await {
259        Ok(Some(store_id)) => store_increment_by_id(store, store_id, field).await,
260        Ok(None) => Err(anyhow!("task not present in store: {repo}/{task_id}")),
261        Err(e) => {
262            tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
263            Err(anyhow!("resolve_task_id failed: {e}"))
264        }
265    }
266}
267
268/// Read a specific string field from a task record.
269pub async fn get_task_field(
270    store: &Option<Arc<TaskStore>>,
271    repo: &str,
272    task_id: &str,
273    field: &str,
274) -> Option<String> {
275    let task = opt_store_get_task(store, repo, task_id).await?;
276    if field == "no_code_last_agent" {
277        Some(task.no_code_last_agent)
278    } else {
279        tracing::warn!(task_id, field, "get_task_field: unknown field");
280        None
281    }
282}
283
284/// Read a specific string field from a task record (store always available).
285pub async fn get_task_field_direct(
286    store: &Arc<TaskStore>,
287    repo: &str,
288    task_id: &str,
289    field: &str,
290) -> Option<String> {
291    let store_opt: &Option<Arc<TaskStore>> = &Some(std::sync::Arc::clone(store));
292    get_task_field(store_opt, repo, task_id, field).await
293}
294
295/// Reset all task counters in the task store.
296pub async fn store_reset_counters(store: &Option<Arc<TaskStore>>, repo: &str, task_id: &str) {
297    if let Some(store) = store {
298        match store.resolve_task_id(repo, task_id).await {
299            Ok(Some(store_id)) => {
300                if let Err(e) = store.reset_counters(store_id).await {
301                    tracing::warn!(task_id, err = %e, "store reset_counters failed");
302                }
303            }
304            Ok(None) => {} // task not in store — no-op is expected
305            Err(e) => {
306                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
307            }
308        }
309    }
310}
311
312/// Reset transient failure/retry counters, preserving `review_cycles`.
313///
314/// Use this after a `RequestChanges` review decision so that per-attempt
315/// noise is cleared without undoing the cycle count set by `handle_review_changes`.
316pub async fn store_reset_failure_counters(
317    store: &Option<Arc<TaskStore>>,
318    repo: &str,
319    task_id: &str,
320) {
321    if let Some(store) = store {
322        match store.resolve_task_id(repo, task_id).await {
323            Ok(Some(store_id)) => {
324                if let Err(e) = store.reset_failure_counters(store_id).await {
325                    tracing::warn!(task_id, err = %e, "failed to reset failure counters after review dispatch");
326                }
327            }
328            Ok(None) => {} // task not in store — no-op is expected
329            Err(e) => {
330                tracing::warn!(task_id, error = %e, "resolve_task_id failed in store write helper");
331            }
332        }
333    }
334}
335
336/// Get token usage from the store, surfacing DB read errors.
337pub async fn get_token_usage_result(
338    store: &Option<Arc<TaskStore>>,
339    repo: &str,
340    task_id: &str,
341) -> StoreResult<TokenUsage> {
342    let s = store
343        .as_ref()
344        .ok_or_else(|| anyhow!("task store unavailable"))?;
345    let store_id = s
346        .resolve_task_id(repo, task_id)
347        .await?
348        .ok_or_else(|| anyhow!("task {repo}/{task_id} not found in store"))?;
349    let task = s.get(store_id).await?;
350    Ok(Some(TokenUsage {
351        input_tokens: token_i64_to_u64_non_negative(task.input_tokens),
352        output_tokens: token_i64_to_u64_non_negative(task.output_tokens),
353    }))
354}
355
356/// Get token usage from the store.
357///
358/// Returns `TokenUsage::default()` when the store is unavailable or the task is not found.
359/// DB read errors are logged as warnings and return the default.
360pub async fn get_token_usage(
361    store: &Option<Arc<TaskStore>>,
362    repo: &str,
363    task_id: &str,
364) -> TokenUsage {
365    match get_token_usage_result(store, repo, task_id).await {
366        Ok(Some(usage)) => usage,
367        Ok(None) => TokenUsage::default(),
368        Err(e) => {
369            tracing::warn!(task_id, error = %e, "get_token_usage failed — returning zero usage");
370            TokenUsage::default()
371        }
372    }
373}
374
375/// Get cost estimate from the store, surfacing DB read errors.
376pub async fn get_cost_estimate_result(
377    store: &Option<Arc<TaskStore>>,
378    repo: &str,
379    task_id: &str,
380) -> StoreResult<CostEstimate> {
381    let s = store
382        .as_ref()
383        .ok_or_else(|| anyhow!("task store unavailable"))?;
384    let store_id = s
385        .resolve_task_id(repo, task_id)
386        .await?
387        .ok_or_else(|| anyhow!("task {repo}/{task_id} not found in store"))?;
388    let task = s.get(store_id).await?;
389    Ok(Some(CostEstimate {
390        input_cost_usd: task.input_cost_usd,
391        output_cost_usd: task.output_cost_usd,
392        total_cost_usd: task.total_cost_usd,
393    }))
394}
395
396/// Get cost estimate from the store.
397///
398/// Returns `CostEstimate::default()` when the store is unavailable or the task is not found.
399/// DB read errors are logged as warnings and return the default.
400pub async fn get_cost_estimate(
401    store: &Option<Arc<TaskStore>>,
402    repo: &str,
403    task_id: &str,
404) -> CostEstimate {
405    match get_cost_estimate_result(store, repo, task_id).await {
406        Ok(Some(cost)) => cost,
407        Ok(None) => CostEstimate::default(),
408        Err(e) => {
409            tracing::warn!(task_id, error = %e, "get_cost_estimate failed — returning zero cost");
410            CostEstimate::default()
411        }
412    }
413}
414
415/// Get recent memory entries for a task, surfacing DB read errors.
416pub async fn get_recent_memory_result(
417    store: &Option<Arc<TaskStore>>,
418    repo: &str,
419    task_id: &str,
420    max: usize,
421) -> StoreResult<Vec<MemoryEntry>> {
422    let s = store
423        .as_ref()
424        .ok_or_else(|| anyhow!("task store unavailable"))?;
425    let store_id = s
426        .resolve_task_id(repo, task_id)
427        .await?
428        .ok_or_else(|| anyhow!("task {repo}/{task_id} not found in store"))?;
429    let entries = s.recent_memory(store_id, max).await?;
430    Ok(Some(entries))
431}
432
433/// Get recent memory entries for a task.
434///
435/// Returns an empty vec when the store is unavailable or the task is not found.
436/// DB read errors are logged as warnings and return an empty vec.
437pub async fn get_recent_memory(
438    store: &Option<Arc<TaskStore>>,
439    repo: &str,
440    task_id: &str,
441    max: usize,
442) -> Vec<MemoryEntry> {
443    match get_recent_memory_result(store, repo, task_id, max).await {
444        Ok(Some(entries)) => entries,
445        Ok(None) => Vec::new(),
446        Err(e) => {
447            tracing::warn!(task_id, error = %e, "get_recent_memory failed — returning empty memory");
448            Vec::new()
449        }
450    }
451}