1use super::*;
2use crate::security;
3use serde::{Deserialize, Serialize};
4use sqlx::Row;
5
6pub(crate) const TASK_COLS: &str = "id, external_id, repo, origin, title, body, status, \
14 source, source_id, author, url, labels, agent, model, complexity, estimate, \
15 route_reason, agent_profile, selected_skills, route_attempts, attempts, \
16 branch, worktree, worktree_cleaned, summary, last_error, parent_id, \
17 block_reason, pr_number, pr_review_context, last_review_ts, review_ts_map, \
18 last_comment_review_ts, merge_conflict_retries, ci_merge_failures, \
19 pr_create_failures, review_agent_failures, review_cycles, input_tokens, \
20 output_tokens, input_cost_usd, output_cost_usd, total_cost_usd, \
21 model_reroute_chain, limit_reroute_chain, \
22 memory, delegations, created_at, updated_at, review_session_expected, \
23 review_invocations, needs_review_refires, push_failures, auto_unblock_count, auto_unblock_last_at, \
24 ci_recovery_count, auto_unblock_last_reason, no_code_reroutes, network_retries, no_code_last_agent";
25
26pub(crate) const TASK_COLS_COUNT: usize = 60;
28
29const TASK_RUN_COLS: &str =
31 "id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, \
32 exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, \
33 total_cost_usd, duration_secs, started_at, completed_at";
34
35const TASK_ACTIVITY_COLS: &str =
37 "id, task_id, timestamp, event_type, from_status, to_status, agent, model, details";
38
39const ALLOWED_FIELDS: &[&str] = &[
41 "agent",
42 "model",
43 "complexity",
44 "route_reason",
45 "agent_profile",
46 "selected_skills",
47 "route_attempts",
48 "attempts",
49 "branch",
50 "worktree",
51 "worktree_cleaned",
52 "summary",
53 "last_error",
54 "parent_id",
55 "block_reason",
56 "pr_number",
57 "pr_review_context",
58 "last_review_ts",
59 "review_ts_map",
60 "last_comment_review_ts",
61 "merge_conflict_retries",
62 "ci_merge_failures",
63 "pr_create_failures",
64 "push_failures",
65 "review_agent_failures",
66 "review_cycles",
67 "review_invocations",
68 "needs_review_refires",
69 "review_session_expected",
70 "input_tokens",
71 "output_tokens",
72 "input_cost_usd",
73 "output_cost_usd",
74 "total_cost_usd",
75 "model_reroute_chain",
76 "limit_reroute_chain",
77 "memory",
78 "delegations",
79 "source",
80 "source_id",
81 "labels",
82 "auto_unblock_count",
83 "auto_unblock_last_at",
84 "auto_unblock_last_reason",
85 "ci_recovery_count",
86 "no_code_reroutes",
87 "network_retries",
88 "no_code_last_agent",
89];
90
91const INCREMENTABLE_FIELDS: &[&str] = &[
93 "attempts",
94 "route_attempts",
95 "merge_conflict_retries",
96 "ci_merge_failures",
97 "pr_create_failures",
98 "push_failures",
99 "review_agent_failures",
100 "review_cycles",
101 "review_invocations",
102 "needs_review_refires",
103 "auto_unblock_count",
104 "ci_recovery_count",
105 "no_code_reroutes",
106 "network_retries",
107];
108
109fn sanitize_activity_details(value: &serde_json::Value) -> serde_json::Value {
110 match value {
111 serde_json::Value::String(text) => serde_json::Value::String(security::redact(text)),
112 serde_json::Value::Array(items) => serde_json::Value::Array(
113 items
114 .iter()
115 .map(sanitize_activity_details)
116 .collect::<Vec<_>>(),
117 ),
118 serde_json::Value::Object(map) => serde_json::Value::Object(
119 map.iter()
120 .map(|(key, value)| (key.clone(), sanitize_activity_details(value)))
121 .collect(),
122 ),
123 other => other.clone(),
124 }
125}
126
127fn sanitize_task_field_value(col: &str, value: &serde_json::Value) -> serde_json::Value {
128 match (col, value) {
129 ("last_error" | "block_reason", serde_json::Value::String(text)) => {
130 serde_json::Value::String(security::redact(text))
131 }
132 _ => value.clone(),
133 }
134}
135
136fn sql_value_for_task_field(
137 col: &str,
138 value: &serde_json::Value,
139) -> anyhow::Result<Option<String>> {
140 let sanitized = sanitize_task_field_value(col, value);
141 match sanitized {
142 serde_json::Value::String(text) => Ok(Some(text)),
143 serde_json::Value::Number(number) => Ok(Some(number.to_string())),
144 serde_json::Value::Bool(boolean) => Ok(Some(if boolean { "1" } else { "0" }.to_string())),
145 serde_json::Value::Null => Ok(None),
146 other => Ok(Some(serde_json::to_string(&other)?)),
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum TaskStatus {
153 New,
154 Routed,
155 InProgress,
156 Done,
157 Blocked,
158 InReview,
159 NeedsReview,
160 Waiting,
161}
162
163impl TaskStatus {
164 pub fn as_str(&self) -> &'static str {
165 match self {
166 Self::New => "new",
167 Self::Routed => "routed",
168 Self::InProgress => "in_progress",
169 Self::Done => "done",
170 Self::Blocked => "blocked",
171 Self::InReview => "in_review",
172 Self::NeedsReview => "needs_review",
173 Self::Waiting => "waiting",
174 }
175 }
176
177 pub fn from_str(s: &str) -> Option<Self> {
178 match s {
179 "new" => Some(Self::New),
180 "routed" => Some(Self::Routed),
181 "in_progress" => Some(Self::InProgress),
182 "done" => Some(Self::Done),
183 "blocked" => Some(Self::Blocked),
184 "in_review" => Some(Self::InReview),
185 "needs_review" => Some(Self::NeedsReview),
186 "waiting" => Some(Self::Waiting),
187 _ => None,
188 }
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct Task {
195 pub id: i64,
196 pub external_id: Option<String>,
197 pub repo: String,
198 pub origin: String,
199
200 pub title: String,
201 pub body: String,
202 pub status: TaskStatus,
203 pub source: String,
204 pub source_id: String,
205 pub author: String,
206 pub url: String,
207 pub labels: Vec<String>,
208
209 pub agent: Option<String>,
211 pub model: Option<String>,
212 pub complexity: String,
213 pub estimate: u8,
215 pub route_reason: String,
216 pub agent_profile: String,
217 pub selected_skills: String,
218 pub route_attempts: i32,
219
220 pub attempts: i32,
222 pub branch: String,
223 pub worktree: String,
224 pub worktree_cleaned: bool,
225 pub summary: String,
226 pub last_error: String,
227 pub parent_id: Option<i64>,
228 pub block_reason: Option<String>,
229
230 pub pr_number: Option<i32>,
232 pub pr_review_context: String,
233 pub last_review_ts: String,
234 pub review_ts_map: String,
235 pub last_comment_review_ts: String,
236 pub merge_conflict_retries: i32,
237 pub ci_merge_failures: i32,
238 pub pr_create_failures: i32,
239 pub push_failures: i32,
240 pub network_retries: i32,
241 pub review_agent_failures: i32,
242 pub review_cycles: i32,
243 pub review_invocations: i32,
244 pub review_session_expected: bool,
245 pub needs_review_refires: i32,
247
248 pub input_tokens: i64,
250 pub output_tokens: i64,
251 pub input_cost_usd: f64,
252 pub output_cost_usd: f64,
253 pub total_cost_usd: f64,
254
255 pub model_reroute_chain: String,
257 pub limit_reroute_chain: String,
258
259 pub memory: Vec<MemoryEntry>,
261 pub delegations: Vec<serde_json::Value>,
262
263 pub auto_unblock_count: i32,
265 pub auto_unblock_last_at: String,
266 pub auto_unblock_last_reason: String,
267
268 pub ci_recovery_count: i32,
270
271 pub no_code_reroutes: i32,
273 pub no_code_last_agent: String,
275
276 pub created_at: String,
278 pub updated_at: String,
279}
280
281#[derive(Debug, Clone, Default)]
283pub struct NewTask {
284 pub external_id: Option<String>,
285 pub repo: String,
286 pub origin: String,
287 pub title: String,
288 pub body: String,
289 pub source: String,
290 pub source_id: String,
291 pub author: String,
292 pub url: String,
293 pub labels: Vec<String>,
294 pub parent_id: Option<i64>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct TaskRun {
301 pub id: i64,
302 pub task_id: i64,
303 pub attempt: i32,
304 pub run_type: String,
305 pub agent: String,
306 pub model: String,
307 pub command: String,
308 pub prompt: String,
309 pub env_vars: String,
310 pub exit_code: Option<i32>,
311 pub stdout: String,
312 pub stderr: String,
313 pub parsed_response: String,
314 pub outcome: String,
315 pub error: String,
316 pub input_tokens: i64,
317 pub output_tokens: i64,
318 pub total_cost_usd: f64,
319 pub duration_secs: f64,
320 pub started_at: String,
321 pub completed_at: Option<String>,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct TaskActivity {
327 pub id: i64,
328 pub task_id: i64,
329 pub timestamp: String,
330 pub event_type: String,
331 pub from_status: Option<String>,
332 pub to_status: Option<String>,
333 pub agent: Option<String>,
334 pub model: Option<String>,
335 pub details: serde_json::Value,
336}
337
338#[derive(Debug, Clone, Copy, Default)]
340pub struct RunTokenUsage {
341 pub input_tokens: u64,
342 pub output_tokens: u64,
343 pub total_cost_usd: f64,
344 pub duration_secs: f64,
345}
346
347#[derive(Debug, Clone)]
349pub struct StartRun<'a> {
350 pub task_id: i64,
351 pub attempt: i32,
352 pub run_type: &'a str,
353 pub agent: &'a str,
354 pub model: &'a str,
355 pub command: &'a str,
356 pub prompt: &'a str,
357}
358
359#[derive(Debug, Clone)]
361pub struct CompleteRun<'a> {
362 pub run_id: i64,
363 pub exit_code: Option<i32>,
364 pub stdout: &'a str,
365 pub stderr: &'a str,
366 pub parsed: &'a str,
367 pub outcome: &'a str,
368 pub error: &'a str,
369 pub tokens: RunTokenUsage,
370}
371
372#[derive(Debug, Clone)]
374pub struct StoreRoute<'a> {
375 pub id: i64,
376 pub agent: &'a str,
377 pub model: Option<&'a str>,
378 pub complexity: &'a str,
379 pub estimate: u8,
381 pub reason: &'a str,
382 pub profile: &'a str,
383 pub skills: &'a str,
384}
385
386#[derive(Debug, Clone)]
388pub struct UpsertExternal<'a> {
389 pub repo: &'a str,
390 pub ext_id: &'a str,
391 pub title: &'a str,
392 pub body: &'a str,
393 pub author: &'a str,
394 pub url: &'a str,
395 pub labels: &'a [String],
396 pub origin: &'a str,
397}
398
399fn parent_external_id_from_labels(labels: &[String]) -> Option<&str> {
400 labels
401 .iter()
402 .find_map(|label| label.strip_prefix("parent:"))
403 .filter(|parent| !parent.is_empty())
404}
405
406impl TaskStore {
407 #[allow(clippy::too_many_arguments)]
409 pub async fn append_activity(
410 &self,
411 task_id: i64,
412 event_type: &str,
413 from_status: Option<&str>,
414 to_status: Option<&str>,
415 agent: Option<&str>,
416 model: Option<&str>,
417 details: Option<&serde_json::Value>,
418 ) -> anyhow::Result<()> {
419 let details_json = match details {
420 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
421 None => "{}".to_string(),
422 };
423 sqlx::query(
424 "INSERT INTO task_activity
425 (task_id, event_type, from_status, to_status, agent, model, details)
426 VALUES (?, ?, ?, ?, ?, ?, ?)",
427 )
428 .bind(task_id)
429 .bind(event_type)
430 .bind(from_status)
431 .bind(to_status)
432 .bind(agent)
433 .bind(model)
434 .bind(details_json)
435 .execute(&self.pool)
436 .await?;
437 Ok(())
438 }
439
440 #[doc(hidden)]
442 #[allow(clippy::too_many_arguments)]
443 pub async fn append_activity_tx(
444 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
445 task_id: i64,
446 event_type: &str,
447 from_status: Option<&str>,
448 to_status: Option<&str>,
449 agent: Option<&str>,
450 model: Option<&str>,
451 details: Option<&serde_json::Value>,
452 ) -> anyhow::Result<()> {
453 let details_json = match details {
454 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
455 None => "{}".to_string(),
456 };
457 sqlx::query(
458 "INSERT INTO task_activity
459 (task_id, event_type, from_status, to_status, agent, model, details)
460 VALUES (?, ?, ?, ?, ?, ?, ?)",
461 )
462 .bind(task_id)
463 .bind(event_type)
464 .bind(from_status)
465 .bind(to_status)
466 .bind(agent)
467 .bind(model)
468 .bind(details_json)
469 .execute(&mut **tx)
470 .await?;
471 Ok(())
472 }
473
474 pub async fn get_activity(
476 &self,
477 task_id: i64,
478 limit: Option<usize>,
479 ) -> anyhow::Result<Vec<TaskActivity>> {
480 let rows = if let Some(limit) = limit {
481 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
482 sqlx::query(&format!(
483 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC LIMIT ?"
484 ))
485 .bind(task_id)
486 .bind(limit_i64)
487 .fetch_all(&self.pool)
488 .await?
489 } else {
490 sqlx::query(&format!(
491 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC"
492 ))
493 .bind(task_id)
494 .fetch_all(&self.pool)
495 .await?
496 };
497
498 rows.iter().map(Self::row_to_activity).collect()
499 }
500
501 pub async fn create(&self, new: &NewTask) -> anyhow::Result<i64> {
502 let labels_json = serde_json::to_string(&new.labels)?;
503 let row = sqlx::query(
504 "INSERT INTO tasks (external_id, repo, origin, title, body, source, source_id, author, url, labels, parent_id)
505 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
506 RETURNING id",
507 )
508 .bind(&new.external_id)
509 .bind(&new.repo)
510 .bind(&new.origin)
511 .bind(&new.title)
512 .bind(&new.body)
513 .bind(&new.source)
514 .bind(&new.source_id)
515 .bind(&new.author)
516 .bind(&new.url)
517 .bind(&labels_json)
518 .bind(new.parent_id)
519 .fetch_one(&self.pool)
520 .await?;
521
522 row.try_get("id")
523 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
524 }
525
526 pub async fn create_internal(
531 &self,
532 repo: &str,
533 title: &str,
534 body: &str,
535 source: &str,
536 source_id: &str,
537 parent_id: Option<i64>,
538 ) -> anyhow::Result<i64> {
539 let id = self
540 .create(&NewTask {
541 external_id: None,
542 repo: repo.to_string(),
543 origin: "internal".to_string(),
544 title: title.to_string(),
545 body: body.to_string(),
546 source: source.to_string(),
547 source_id: source_id.to_string(),
548 author: String::new(),
549 url: String::new(),
550 labels: vec![],
551 parent_id,
552 })
553 .await?;
554 sqlx::query("UPDATE tasks SET external_id = ? WHERE id = ?")
556 .bind(format!("internal:{id}"))
557 .bind(id)
558 .execute(&self.pool)
559 .await?;
560 Ok(id)
561 }
562
563 pub async fn get(&self, id: i64) -> anyhow::Result<Task> {
565 let row = sqlx::query(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?"))
566 .bind(id)
567 .fetch_optional(&self.pool)
568 .await?
569 .with_context(|| format!("task {id} not found"))?;
570
571 Self::row_to_task(&row)
572 }
573
574 pub async fn get_by_external_id(
576 &self,
577 repo: &str,
578 ext_id: &str,
579 ) -> anyhow::Result<Option<Task>> {
580 let row = sqlx::query(&format!(
581 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND external_id = ?"
582 ))
583 .bind(repo)
584 .bind(ext_id)
585 .fetch_optional(&self.pool)
586 .await?;
587
588 match row {
589 Some(r) => Ok(Some(Self::row_to_task(&r)?)),
590 None => Ok(None),
591 }
592 }
593
594 pub async fn resolve_id_by_external(
600 &self,
601 repo: &str,
602 ext_id: &str,
603 ) -> anyhow::Result<Option<i64>> {
604 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND external_id = ?")
605 .bind(repo)
606 .bind(ext_id)
607 .fetch_optional(&self.pool)
608 .await?;
609 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
610 Ok(id)
611 }
612
613 pub async fn resolve_id_by_pr_number(
619 &self,
620 repo: &str,
621 pr_number: i32,
622 ) -> anyhow::Result<Option<i64>> {
623 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND pr_number = ? LIMIT 1")
624 .bind(repo)
625 .bind(pr_number)
626 .fetch_optional(&self.pool)
627 .await?;
628 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
629 Ok(id)
630 }
631
632 pub async fn get_agent_by_external_id(
635 &self,
636 repo: &str,
637 ext_id: &str,
638 ) -> anyhow::Result<Option<String>> {
639 let row = sqlx::query("SELECT agent FROM tasks WHERE repo = ? AND external_id = ?")
640 .bind(repo)
641 .bind(ext_id)
642 .fetch_optional(&self.pool)
643 .await?;
644 let agent = row
645 .map(|r| r.try_get::<Option<String>, _>("agent"))
646 .transpose()?
647 .flatten();
648 Ok(agent)
649 }
650
651 pub async fn upsert_external(&self, ext: &UpsertExternal<'_>) -> anyhow::Result<i64> {
653 let labels_json = serde_json::to_string(ext.labels)?;
654 let parent_id = match parent_external_id_from_labels(ext.labels) {
655 Some(parent_external_id) => self.resolve_task_id(ext.repo, parent_external_id).await?,
656 None => None,
657 };
658 let row = sqlx::query(
659 "INSERT INTO tasks (external_id, repo, origin, title, body, author, url, labels, parent_id)
660 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
661 ON CONFLICT(repo, external_id) DO UPDATE SET
662 title = excluded.title,
663 body = excluded.body,
664 labels = excluded.labels,
665 url = excluded.url,
666 parent_id = COALESCE(excluded.parent_id, tasks.parent_id)
667 RETURNING id",
668 )
669 .bind(ext.ext_id)
670 .bind(ext.repo)
671 .bind(ext.origin)
672 .bind(ext.title)
673 .bind(ext.body)
674 .bind(ext.author)
675 .bind(ext.url)
676 .bind(&labels_json)
677 .bind(parent_id)
678 .fetch_one(&self.pool)
679 .await?;
680
681 row.try_get("id")
682 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
683 }
684
685 pub async fn update_status_and_fields(
690 &self,
691 id: i64,
692 status: TaskStatus,
693 updates: &[(&str, serde_json::Value)],
694 ) -> anyhow::Result<()> {
695 for (col, _) in updates {
696 anyhow::ensure!(
697 ALLOWED_FIELDS.contains(col),
698 "column {col} is not in the update allowlist"
699 );
700 }
701
702 let mut tx = self.pool.begin().await?;
703 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
704 .bind(id)
705 .fetch_one(&mut *tx)
706 .await?;
707 let from_status: String = previous
708 .try_get("status")
709 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
710 let agent: Option<String> = previous
711 .try_get("agent")
712 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
713 let model: Option<String> = previous
714 .try_get("model")
715 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
716
717 let mut set_parts = Vec::new();
718 let mut values: Vec<Option<String>> = Vec::new();
719
720 set_parts.push("status = ?".to_string());
721 values.push(Some(status.as_str().to_string()));
722
723 let mut block_reason_in_updates = false;
724 for (col, val) in updates {
725 if *col == "block_reason" {
726 block_reason_in_updates = true;
727 }
728 set_parts.push(format!("{col} = ?"));
729 match val {
730 serde_json::Value::String(s) => values.push(Some(s.clone())),
731 serde_json::Value::Number(n) => values.push(Some(n.to_string())),
732 serde_json::Value::Bool(b) => {
733 values.push(Some(if *b { "1" } else { "0" }.to_string()));
734 }
735 serde_json::Value::Null => values.push(None),
736 other => values.push(Some(serde_json::to_string(other)?)),
737 }
738 }
739
740 if !block_reason_in_updates && status != TaskStatus::Blocked {
741 set_parts.push("block_reason = NULL".to_string());
742 }
743
744 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
745 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
746
747 let mut query = sqlx::query(&sql);
748 for v in &values {
749 query = query.bind(v.as_deref());
750 }
751 query = query.bind(id);
752 query.execute(&mut *tx).await?;
753
754 Self::append_activity_tx(
755 &mut tx,
756 id,
757 "status_change",
758 Some(from_status.as_str()),
759 Some(status.as_str()),
760 agent.as_deref(),
761 model.as_deref(),
762 None,
763 )
764 .await?;
765 tx.commit().await?;
766 Ok(())
767 }
768
769 pub async fn update_status(&self, id: i64, status: TaskStatus) -> anyhow::Result<()> {
771 let mut tx = self.pool.begin().await?;
772 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
773 .bind(id)
774 .fetch_one(&mut *tx)
775 .await?;
776 let from_status: String = previous
777 .try_get("status")
778 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
779 let agent: Option<String> = previous
780 .try_get("agent")
781 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
782 let model: Option<String> = previous
783 .try_get("model")
784 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
785 let sql = if status == TaskStatus::Blocked {
786 "UPDATE tasks SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
787 } else {
788 "UPDATE tasks SET status = ?, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
789 };
790 sqlx::query(sql)
791 .bind(status.as_str())
792 .bind(id)
793 .execute(&mut *tx)
794 .await?;
795
796 Self::append_activity_tx(
797 &mut tx,
798 id,
799 "status_change",
800 Some(from_status.as_str()),
801 Some(status.as_str()),
802 agent.as_deref(),
803 model.as_deref(),
804 None,
805 )
806 .await?;
807 tx.commit().await?;
808 Ok(())
809 }
810
811 pub async fn update_external_id(&self, id: i64, external_id: &str) -> anyhow::Result<()> {
813 sqlx::query(
814 "UPDATE tasks SET external_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
815 )
816 .bind(external_id)
817 .bind(id)
818 .execute(&self.pool)
819 .await?;
820 Ok(())
821 }
822
823 pub async fn update_status_if(
828 &self,
829 id: i64,
830 status: TaskStatus,
831 expected: TaskStatus,
832 ) -> anyhow::Result<bool> {
833 let mut tx = self.pool.begin().await?;
834 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
835 .bind(id)
836 .fetch_one(&mut *tx)
837 .await?;
838 let from_status: String = previous
839 .try_get("status")
840 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
841 let agent: Option<String> = previous
842 .try_get("agent")
843 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
844 let model: Option<String> = previous
845 .try_get("model")
846 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
847 let sql = if status == TaskStatus::Blocked {
848 "UPDATE tasks SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND status = ?"
849 } else {
850 "UPDATE tasks SET status = ?, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND status = ?"
851 };
852 let result = sqlx::query(sql)
853 .bind(status.as_str())
854 .bind(id)
855 .bind(expected.as_str())
856 .execute(&mut *tx)
857 .await?;
858 if result.rows_affected() == 0 {
859 return Ok(false);
860 }
861 Self::append_activity_tx(
862 &mut tx,
863 id,
864 "status_change",
865 Some(from_status.as_str()),
866 Some(status.as_str()),
867 agent.as_deref(),
868 model.as_deref(),
869 None,
870 )
871 .await?;
872 tx.commit().await?;
873 Ok(true)
874 }
875
876 pub async fn touch_updated_at(&self, id: i64) -> anyhow::Result<()> {
884 sqlx::query(
885 "UPDATE tasks SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
886 )
887 .bind(id)
888 .execute(&self.pool)
889 .await?;
890 Ok(())
891 }
892
893 pub async fn reset_to_new(&self, id: i64) -> anyhow::Result<()> {
895 let mut tx = self.pool.begin().await?;
896 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
897 .bind(id)
898 .fetch_one(&mut *tx)
899 .await?;
900 let from_status: String = previous
901 .try_get("status")
902 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
903 let agent: Option<String> = previous
904 .try_get("agent")
905 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
906 let model: Option<String> = previous
907 .try_get("model")
908 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
909 sqlx::query(
910 "UPDATE tasks SET status = 'new', branch = '', worktree = '', worktree_cleaned = 0, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
911 )
912 .bind(id)
913 .execute(&mut *tx)
914 .await?;
915 let details = serde_json::json!({ "op": "reset_to_new" });
916 Self::append_activity_tx(
917 &mut tx,
918 id,
919 "status_change",
920 Some(from_status.as_str()),
921 Some(TaskStatus::New.as_str()),
922 agent.as_deref(),
923 model.as_deref(),
924 Some(&details),
925 )
926 .await?;
927 tx.commit().await?;
928 Ok(())
929 }
930
931 pub async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()> {
933 let value = reason
934 .map(|r| serde_json::Value::String(r.to_string()))
935 .unwrap_or(serde_json::Value::Null);
936 self.set_fields(id, &[("block_reason", value)]).await
937 }
938
939 pub async fn list_by_status(
941 &self,
942 repo: &str,
943 status: TaskStatus,
944 ) -> anyhow::Result<Vec<Task>> {
945 let rows = sqlx::query(&format!(
946 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status = ? ORDER BY created_at DESC"
947 ))
948 .bind(repo)
949 .bind(status.as_str())
950 .fetch_all(&self.pool)
951 .await?;
952
953 rows.iter().map(Self::row_to_task).collect()
954 }
955
956 pub async fn list_children(&self, repo: &str, parent_id: i64) -> anyhow::Result<Vec<Task>> {
958 let rows = sqlx::query(&format!(
959 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND parent_id = ? ORDER BY created_at ASC"
960 ))
961 .bind(repo)
962 .bind(parent_id)
963 .fetch_all(&self.pool)
964 .await?;
965
966 rows.iter().map(Self::row_to_task).collect()
967 }
968
969 pub async fn list_external_by_status(
971 &self,
972 repo: &str,
973 status: TaskStatus,
974 ) -> anyhow::Result<Vec<Task>> {
975 let rows = sqlx::query(
976 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' AND status = ? ORDER BY created_at DESC"),
977 )
978 .bind(repo)
979 .bind(status.as_str())
980 .fetch_all(&self.pool)
981 .await?;
982
983 rows.iter().map(Self::row_to_task).collect()
984 }
985
986 pub async fn list_routable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
988 self.list_by_status(repo, TaskStatus::New).await
989 }
990
991 pub async fn list_internal_by_status(
993 &self,
994 repo: &str,
995 status: TaskStatus,
996 ) -> anyhow::Result<Vec<Task>> {
997 let rows = sqlx::query(
998 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND status = ? ORDER BY created_at DESC"),
999 )
1000 .bind(repo)
1001 .bind(status.as_str())
1002 .fetch_all(&self.pool)
1003 .await?;
1004
1005 rows.iter().map(Self::row_to_task).collect()
1006 }
1007
1008 pub async fn list_all_internal(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1010 let rows = sqlx::query(
1011 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' ORDER BY created_at DESC"),
1012 )
1013 .bind(repo)
1014 .fetch_all(&self.pool)
1015 .await?;
1016
1017 rows.iter().map(Self::row_to_task).collect()
1018 }
1019
1020 pub async fn list_internal_by_source(
1023 &self,
1024 repo: &str,
1025 source: &str,
1026 ) -> anyhow::Result<Vec<Task>> {
1027 let rows = sqlx::query(
1028 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? ORDER BY created_at DESC"),
1029 )
1030 .bind(repo)
1031 .bind(source)
1032 .fetch_all(&self.pool)
1033 .await?;
1034
1035 rows.iter().map(Self::row_to_task).collect()
1036 }
1037
1038 pub async fn list_source_ids_by_source(
1043 &self,
1044 repo: &str,
1045 source: &str,
1046 since_days: i64,
1047 ) -> anyhow::Result<Vec<String>> {
1048 let cutoff = format!("-{since_days} days");
1049 let rows = sqlx::query(
1050 "SELECT source_id FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? AND created_at >= datetime('now', ?)",
1051 )
1052 .bind(repo)
1053 .bind(source)
1054 .bind(&cutoff)
1055 .fetch_all(&self.pool)
1056 .await?;
1057
1058 let mut ids = Vec::with_capacity(rows.len());
1059 for row in &rows {
1060 ids.push(row.try_get::<String, _>("source_id").map_err(|e| {
1061 anyhow::anyhow!(
1062 "list_source_ids_by_source: failed to decode source_id for repo={repo}, source={source}: {e}"
1063 )
1064 })?);
1065 }
1066 Ok(ids)
1067 }
1068
1069 pub async fn list_active(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1071 let rows = sqlx::query(
1072 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status != 'done' ORDER BY created_at DESC"),
1073 )
1074 .bind(repo)
1075 .fetch_all(&self.pool)
1076 .await?;
1077
1078 rows.iter().map(Self::row_to_task).collect()
1079 }
1080
1081 pub async fn list_all_external(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1083 let rows = sqlx::query(
1084 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' ORDER BY created_at DESC"),
1085 )
1086 .bind(repo)
1087 .fetch_all(&self.pool)
1088 .await?;
1089
1090 rows.iter().map(Self::row_to_task).collect()
1091 }
1092
1093 pub async fn has_external_tasks(&self, repo: &str) -> anyhow::Result<bool> {
1095 let has_external = sqlx::query_scalar::<_, i32>(
1096 "SELECT 1 FROM tasks WHERE repo = ? AND origin != 'internal' LIMIT 1",
1097 )
1098 .bind(repo)
1099 .fetch_optional(&self.pool)
1100 .await?
1101 .is_some();
1102 Ok(has_external)
1103 }
1104
1105 pub async fn existing_external_ids(
1109 &self,
1110 repo: &str,
1111 ) -> anyhow::Result<std::collections::HashSet<String>> {
1112 let rows: Vec<(String,)> = sqlx::query_as(
1113 "SELECT external_id FROM tasks WHERE repo = ? AND origin != 'internal' AND external_id IS NOT NULL",
1114 )
1115 .bind(repo)
1116 .fetch_all(&self.pool)
1117 .await?;
1118
1119 Ok(rows
1120 .into_iter()
1121 .filter_map(|(id,)| if id.is_empty() { None } else { Some(id) })
1122 .collect())
1123 }
1124
1125 pub async fn list_for_doctor(&self, repo: &str, cutoff_str: &str) -> anyhow::Result<Vec<Task>> {
1128 let rows = sqlx::query(
1129 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND (status != 'done' OR updated_at >= ?) ORDER BY created_at DESC"),
1130 )
1131 .bind(repo)
1132 .bind(cutoff_str)
1133 .fetch_all(&self.pool)
1134 .await?;
1135 rows.iter().map(Self::row_to_task).collect()
1136 }
1137
1138 pub async fn list_all(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1140 let rows = sqlx::query(&format!(
1141 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? ORDER BY created_at DESC"
1142 ))
1143 .bind(repo)
1144 .fetch_all(&self.pool)
1145 .await?;
1146 rows.iter().map(Self::row_to_task).collect()
1147 }
1148
1149 pub async fn distinct_repos(&self) -> anyhow::Result<Vec<String>> {
1151 let rows =
1152 sqlx::query("SELECT DISTINCT repo FROM tasks WHERE repo IS NOT NULL AND repo != ''")
1153 .fetch_all(&self.pool)
1154 .await?;
1155 let mut repos = Vec::new();
1156 for row in &rows {
1157 repos.push(row.try_get::<String, _>("repo").map_err(|e| {
1158 anyhow::anyhow!("distinct_repos: failed to decode 'repo' column: {e}")
1159 })?);
1160 }
1161 Ok(repos)
1162 }
1163
1164 pub async fn list_all_active_global(&self) -> anyhow::Result<Vec<Task>> {
1169 let rows = sqlx::query(&format!(
1170 "SELECT {TASK_COLS} FROM tasks WHERE status != 'done' ORDER BY created_at DESC"
1171 ))
1172 .fetch_all(&self.pool)
1173 .await?;
1174 rows.iter().map(Self::row_to_task).collect()
1175 }
1176
1177 pub async fn list_all_global(&self) -> anyhow::Result<Vec<Task>> {
1181 let rows = sqlx::query(&format!(
1182 "SELECT {TASK_COLS} FROM tasks ORDER BY created_at DESC"
1183 ))
1184 .fetch_all(&self.pool)
1185 .await?;
1186 rows.iter().map(Self::row_to_task).collect()
1187 }
1188
1189 pub async fn list_all_by_status_global(&self, status: TaskStatus) -> anyhow::Result<Vec<Task>> {
1193 let rows = sqlx::query(&format!(
1194 "SELECT {TASK_COLS} FROM tasks WHERE status = ? ORDER BY created_at DESC"
1195 ))
1196 .bind(status.as_str())
1197 .fetch_all(&self.pool)
1198 .await?;
1199 rows.iter().map(Self::row_to_task).collect()
1200 }
1201
1202 pub async fn set_fields(
1205 &self,
1206 id: i64,
1207 updates: &[(&str, serde_json::Value)],
1208 ) -> anyhow::Result<()> {
1209 if updates.is_empty() {
1210 return Ok(());
1211 }
1212
1213 for (col, _) in updates {
1214 anyhow::ensure!(
1215 ALLOWED_FIELDS.contains(col),
1216 "column {col} is not in the update allowlist"
1217 );
1218 }
1219
1220 let mut set_parts = Vec::new();
1222 let mut values: Vec<Option<String>> = Vec::new();
1223
1224 for (col, val) in updates {
1225 set_parts.push(format!("{col} = ?"));
1226 values.push(sql_value_for_task_field(col, val)?);
1227 }
1228
1229 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1230 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1231
1232 let mut query = sqlx::query(&sql);
1233 for v in &values {
1234 query = query.bind(v.as_deref());
1235 }
1236 query = query.bind(id);
1237 query.execute(&self.pool).await?;
1238
1239 Ok(())
1240 }
1241
1242 pub async fn set_fields_silent(
1247 &self,
1248 id: i64,
1249 updates: &[(&str, serde_json::Value)],
1250 ) -> anyhow::Result<()> {
1251 if updates.is_empty() {
1252 return Ok(());
1253 }
1254
1255 for (col, _) in updates {
1256 anyhow::ensure!(
1257 ALLOWED_FIELDS.contains(col),
1258 "column {col} is not in the update allowlist"
1259 );
1260 }
1261
1262 let mut set_parts = Vec::new();
1263 let mut values: Vec<Option<String>> = Vec::new();
1264
1265 for (col, val) in updates {
1266 set_parts.push(format!("{col} = ?"));
1267 values.push(sql_value_for_task_field(col, val)?);
1268 }
1269
1270 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1271
1272 let mut query = sqlx::query(&sql);
1273 for v in &values {
1274 query = query.bind(v.as_deref());
1275 }
1276 query = query.bind(id);
1277 query.execute(&self.pool).await?;
1278
1279 Ok(())
1280 }
1281
1282 pub async fn increment(&self, id: i64, field: &str) -> anyhow::Result<i32> {
1284 anyhow::ensure!(
1285 INCREMENTABLE_FIELDS.contains(&field),
1286 "field {field} is not incrementable"
1287 );
1288
1289 let sql = format!(
1290 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? RETURNING {field} AS val"
1291 );
1292
1293 let row = sqlx::query(&sql).bind(id).fetch_one(&self.pool).await?;
1294
1295 Ok(row.try_get("val")?)
1296 }
1297
1298 pub async fn reset_counters(&self, id: i64) -> anyhow::Result<()> {
1300 sqlx::query(
1301 "UPDATE tasks SET
1302 attempts = 0,
1303 route_attempts = 0,
1304 review_agent_failures = 0,
1305 merge_conflict_retries = 0,
1306 pr_create_failures = 0,
1307 push_failures = 0,
1308 network_retries = 0,
1309 ci_merge_failures = 0,
1310 review_cycles = 0,
1311 review_invocations = 0,
1312 review_session_expected = 0,
1313 auto_unblock_count = 0,
1314 auto_unblock_last_at = '',
1315 auto_unblock_last_reason = '',
1316 ci_recovery_count = 0,
1317 no_code_reroutes = 0,
1318 no_code_last_agent = '',
1319 needs_review_refires = 0,
1320 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1321 WHERE id = ?",
1322 )
1323 .bind(id)
1324 .execute(&self.pool)
1325 .await?;
1326 Ok(())
1327 }
1328
1329 pub async fn reset_failure_counters(&self, id: i64) -> anyhow::Result<()> {
1344 sqlx::query(
1345 "UPDATE tasks SET
1346 route_attempts = 0,
1347 review_agent_failures = 0,
1348 pr_create_failures = 0,
1349 push_failures = 0,
1350 network_retries = 0,
1351 review_session_expected = 0,
1352 auto_unblock_count = 0,
1353 auto_unblock_last_at = '',
1354 auto_unblock_last_reason = '',
1355 ci_recovery_count = 0,
1356 no_code_reroutes = 0,
1357 no_code_last_agent = '',
1358 needs_review_refires = 0,
1359 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1360 WHERE id = ?",
1361 )
1362 .bind(id)
1363 .execute(&self.pool)
1364 .await?;
1365 Ok(())
1366 }
1367
1368 pub async fn store_route(&self, route: &StoreRoute<'_>) -> anyhow::Result<()> {
1374 sqlx::query(
1375 "UPDATE tasks SET
1376 agent = ?, model = ?, complexity = ?, estimate = ?, route_reason = ?,
1377 agent_profile = ?, selected_skills = ?,
1378 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1379 WHERE id = ?",
1380 )
1381 .bind(route.agent)
1382 .bind(route.model)
1383 .bind(route.complexity)
1384 .bind(route.estimate as i64)
1385 .bind(route.reason)
1386 .bind(route.profile)
1387 .bind(route.skills)
1388 .bind(route.id)
1389 .execute(&self.pool)
1390 .await?;
1391 let details = serde_json::json!({
1392 "complexity": route.complexity,
1393 "estimate": route.estimate,
1394 "reason": route.reason,
1395 "skills": route.skills,
1396 });
1397 self.append_activity(
1398 route.id,
1399 "dispatch",
1400 None,
1401 None,
1402 Some(route.agent),
1403 route.model,
1404 Some(&details),
1405 )
1406 .await?;
1407 Ok(())
1408 }
1409
1410 pub async fn list_external_tasks_with_estimates(&self) -> anyhow::Result<Vec<(String, u8)>> {
1418 let rows = sqlx::query(
1419 "SELECT external_id, estimate FROM tasks \
1420 WHERE estimate > 0 AND external_id NOT LIKE 'internal:%' \
1421 AND status NOT IN ('done', 'blocked')",
1422 )
1423 .fetch_all(&self.pool)
1424 .await?;
1425
1426 let mut result = Vec::new();
1427 for row in &rows {
1428 let external_id: String = row.try_get("external_id")?;
1429 let estimate_raw: i64 = row.try_get("estimate")?;
1430 if (1..=255).contains(&estimate_raw) {
1431 result.push((external_id, estimate_raw as u8));
1432 }
1433 }
1434 Ok(result)
1435 }
1436
1437 pub async fn set_estimate_if_zero(&self, id: i64, estimate: u8) -> anyhow::Result<bool> {
1443 let result = sqlx::query("UPDATE tasks SET estimate = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND estimate = 0")
1444 .bind(estimate as i64)
1445 .bind(id)
1446 .execute(&self.pool)
1447 .await?;
1448
1449 Ok(result.rows_affected() > 0)
1450 }
1451
1452 pub async fn append_memory(&self, id: i64, entry: &MemoryEntry) -> anyhow::Result<()> {
1461 let mut tx = self.pool.begin().await?;
1462
1463 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1464 .bind(id)
1465 .fetch_one(&mut *tx)
1466 .await?;
1467
1468 let memory_opt: Option<String> = row
1473 .try_get::<Option<String>, _>("memory")
1474 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1475 let memory_str = memory_opt.unwrap_or_default();
1476
1477 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1478 Vec::new()
1479 } else {
1480 serde_json::from_str(&memory_str)
1481 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1482 };
1483 memory.push(entry.clone());
1484 let new_json = serde_json::to_string(&memory)?;
1485
1486 sqlx::query(
1487 "UPDATE tasks SET memory = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1488 )
1489 .bind(&new_json)
1490 .bind(id)
1491 .execute(&mut *tx)
1492 .await?;
1493
1494 tx.commit().await?;
1495 Ok(())
1496 }
1497
1498 pub async fn recent_memory(&self, id: i64, max: usize) -> anyhow::Result<Vec<MemoryEntry>> {
1500 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1501 .bind(id)
1502 .fetch_one(&self.pool)
1503 .await?;
1504
1505 let memory_opt: Option<String> = row
1506 .try_get::<Option<String>, _>("memory")
1507 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1508 let memory_str = memory_opt.unwrap_or_default();
1509
1510 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1511 Vec::new()
1512 } else {
1513 serde_json::from_str(&memory_str)
1514 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1515 };
1516
1517 memory.sort_by_key(|m| m.attempt);
1518 if memory.len() > max {
1519 memory = memory.split_off(memory.len() - max);
1520 }
1521 Ok(memory)
1522 }
1523
1524 pub async fn store_tokens(
1530 &self,
1531 id: i64,
1532 input: u64,
1533 output: u64,
1534 model: &str,
1535 ) -> anyhow::Result<()> {
1536 let pricing = pricing_for_model(model);
1537 let usage = TokenUsage {
1538 input_tokens: input,
1539 output_tokens: output,
1540 };
1541 let cost = pricing.estimate_cost_usd(usage);
1542
1543 sqlx::query(
1544 "UPDATE tasks SET
1545 input_tokens = COALESCE(input_tokens, 0) + ?,
1546 output_tokens = COALESCE(output_tokens, 0) + ?,
1547 input_cost_usd = COALESCE(input_cost_usd, 0.0) + ?,
1548 output_cost_usd = COALESCE(output_cost_usd, 0.0) + ?,
1549 total_cost_usd = COALESCE(total_cost_usd, 0.0) + ?,
1550 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1551 WHERE id = ?",
1552 )
1553 .bind(i64::try_from(input).unwrap_or_else(|_| {
1554 tracing::warn!(input, "input_tokens overflows i64, clamping to MAX");
1555 i64::MAX
1556 }))
1557 .bind(i64::try_from(output).unwrap_or_else(|_| {
1558 tracing::warn!(output, "output_tokens overflows i64, clamping to MAX");
1559 i64::MAX
1560 }))
1561 .bind(cost.input_cost_usd)
1562 .bind(cost.output_cost_usd)
1563 .bind(cost.total_cost_usd)
1564 .bind(id)
1565 .execute(&self.pool)
1566 .await?;
1567
1568 Ok(())
1569 }
1570
1571 pub async fn mark_cleaned(&self, id: i64) -> anyhow::Result<()> {
1577 sqlx::query(
1578 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1579 )
1580 .bind(id)
1581 .execute(&self.pool)
1582 .await?;
1583 self.append_activity(
1584 id,
1585 "branch_delete",
1586 None,
1587 None,
1588 None,
1589 None,
1590 Some(&serde_json::json!({ "worktree_cleaned": true })),
1591 )
1592 .await?;
1593 Ok(())
1594 }
1595
1596 #[cfg(test)]
1602 pub async fn batch_set_fields(
1603 &self,
1604 updates: &[(i64, &[(&str, serde_json::Value)])],
1605 ) -> anyhow::Result<()> {
1606 if updates.is_empty() {
1607 return Ok(());
1608 }
1609
1610 for (_, entry_updates) in updates {
1611 for (col, _) in *entry_updates {
1612 anyhow::ensure!(
1613 ALLOWED_FIELDS.contains(col),
1614 "column {col} is not in the update allowlist"
1615 );
1616 }
1617 }
1618
1619 let mut tx = self.pool.begin().await?;
1620 for (id, entry_updates) in updates {
1621 if entry_updates.is_empty() {
1622 continue;
1623 }
1624 let mut set_parts = Vec::new();
1625 let mut values: Vec<Option<String>> = Vec::new();
1626 for (col, val) in *entry_updates {
1627 set_parts.push(format!("{col} = ?"));
1628 values.push(sql_value_for_task_field(col, val)?);
1629 }
1630 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1631 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1632 let mut query = sqlx::query(&sql);
1633 for v in &values {
1634 query = query.bind(v.as_deref());
1635 }
1636 query = query.bind(*id);
1637 query.execute(&mut *tx).await?;
1638 }
1639 tx.commit().await?;
1640 Ok(())
1641 }
1642
1643 #[cfg(test)]
1648 pub async fn batch_increment(&self, entries: &[(i64, &str)]) -> anyhow::Result<()> {
1649 if entries.is_empty() {
1650 return Ok(());
1651 }
1652
1653 for (_, field) in entries {
1654 anyhow::ensure!(
1655 INCREMENTABLE_FIELDS.contains(field),
1656 "field {field} is not incrementable"
1657 );
1658 }
1659
1660 let mut tx = self.pool.begin().await?;
1661 for (id, field) in entries {
1662 let sql = format!(
1663 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
1664 );
1665 sqlx::query(&sql).bind(*id).execute(&mut *tx).await?;
1666 }
1667 tx.commit().await?;
1668 Ok(())
1669 }
1670
1671 #[cfg(test)]
1678 pub async fn batch_reset_failure_counters(&self, ids: &[i64]) -> anyhow::Result<()> {
1679 if ids.is_empty() {
1680 return Ok(());
1681 }
1682 const CHUNK_SIZE: usize = 500;
1685
1686 let mut tx = self.pool.begin().await?;
1687
1688 for chunk in ids.chunks(CHUNK_SIZE) {
1689 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1691 let sql = format!(
1692 "UPDATE tasks SET
1693 route_attempts = 0,
1694 review_agent_failures = 0,
1695 review_invocations = 0,
1696 pr_create_failures = 0,
1697 push_failures = 0,
1698 network_retries = 0,
1699 review_session_expected = 0,
1700 auto_unblock_count = 0,
1701 auto_unblock_last_at = '',
1702 auto_unblock_last_reason = '',
1703 ci_recovery_count = 0,
1704 no_code_reroutes = 0,
1705 needs_review_refires = 0,
1706 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1707 WHERE id IN ({placeholders})",
1708 );
1709 let mut query = sqlx::query(&sql);
1710 for id in chunk {
1711 query = query.bind(*id);
1712 }
1713 query.execute(&mut *tx).await?;
1714 }
1715
1716 tx.commit().await?;
1717 Ok(())
1718 }
1719
1720 #[cfg(test)]
1725 pub async fn batch_mark_cleaned(&self, ids: &[i64]) -> anyhow::Result<()> {
1726 if ids.is_empty() {
1727 return Ok(());
1728 }
1729
1730 const CHUNK_SIZE: usize = 500;
1732
1733 let mut tx = self.pool.begin().await?;
1734
1735 for chunk in ids.chunks(CHUNK_SIZE) {
1736 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1737 let sql = format!(
1738 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id IN ({placeholders})",
1739 );
1740 let mut query = sqlx::query(&sql);
1741 for id in chunk {
1742 query = query.bind(*id);
1743 }
1744 query.execute(&mut *tx).await?;
1745 }
1746
1747 let details = serde_json::json!({ "worktree_cleaned": true });
1751 let details_json = serde_json::to_string(&details)?;
1752 for id in ids {
1753 sqlx::query(
1754 "INSERT INTO task_activity (task_id, event_type, from_status, to_status, agent, model, details) VALUES (?, ?, ?, ?, ?, ?, ?)",
1755 )
1756 .bind(*id)
1757 .bind("branch_delete")
1758 .bind(None::<&str>)
1759 .bind(None::<&str>)
1760 .bind(None::<&str>)
1761 .bind(None::<&str>)
1762 .bind(details_json.as_str())
1763 .execute(&mut *tx)
1764 .await?;
1765 }
1766
1767 tx.commit().await?;
1768 Ok(())
1769 }
1770
1771 #[cfg(test)]
1774 pub async fn list_cleanable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1775 let rows = sqlx::query(&format!(
1776 "SELECT {TASK_COLS} FROM tasks
1777 WHERE repo = ?
1778 AND worktree != ''
1779 AND worktree_cleaned = 0
1780 AND status IN ('done', 'blocked')
1781 ORDER BY updated_at ASC"
1782 ))
1783 .bind(repo)
1784 .fetch_all(&self.pool)
1785 .await?;
1786
1787 rows.iter().map(Self::row_to_task).collect()
1788 }
1789
1790 pub async fn cleaned_external_ids(
1795 &self,
1796 repo: &str,
1797 ) -> anyhow::Result<std::collections::HashSet<String>> {
1798 let rows: Vec<(String,)> =
1799 sqlx::query_as("SELECT external_id FROM tasks WHERE repo = ? AND worktree_cleaned = 1 AND external_id IS NOT NULL")
1800 .bind(repo)
1801 .fetch_all(&self.pool)
1802 .await?;
1803
1804 Ok(rows.into_iter().map(|(id,)| id).collect())
1805 }
1806
1807 pub async fn start_run(&self, run: &StartRun<'_>) -> anyhow::Result<i64> {
1813 let orphaned: Vec<(i64,)> = sqlx::query_as(
1825 "SELECT id FROM task_runs
1826 WHERE task_id = ? AND run_type = ? AND attempt != ?
1827 AND outcome IS NULL AND completed_at IS NULL",
1828 )
1829 .bind(run.task_id)
1830 .bind(run.run_type)
1831 .bind(run.attempt)
1832 .fetch_all(&self.pool)
1833 .await?;
1834
1835 for (run_id,) in orphaned {
1836 self.complete_run(&CompleteRun {
1837 run_id,
1838 exit_code: Some(-1),
1839 stdout: "",
1840 stderr: "",
1841 parsed: "",
1842 outcome: "aborted",
1843 error: "superseded by a new run start before this one completed",
1844 tokens: RunTokenUsage::default(),
1845 })
1846 .await?;
1847 }
1848
1849 let row = sqlx::query(
1850 "INSERT INTO task_runs (task_id, attempt, run_type, agent, model, command, prompt, outcome)
1851 VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
1852 ON CONFLICT(task_id, attempt, run_type) DO UPDATE SET
1853 agent = excluded.agent, model = excluded.model,
1854 command = excluded.command, prompt = excluded.prompt,
1855 outcome = NULL,
1856 started_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
1857 completed_at = NULL, error = NULL, exit_code = NULL,
1858 stdout = NULL, stderr = NULL, parsed_response = NULL,
1859 duration_secs = NULL, input_tokens = NULL, output_tokens = NULL,
1860 total_cost_usd = NULL
1861 RETURNING id",
1862 )
1863 .bind(run.task_id)
1864 .bind(run.attempt)
1865 .bind(run.run_type)
1866 .bind(run.agent)
1867 .bind(run.model)
1868 .bind(run.command)
1869 .bind(run.prompt)
1870 .fetch_one(&self.pool)
1871 .await?;
1872
1873 let run_id: i64 = row
1874 .try_get("id")
1875 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
1876 let event_type = match run.run_type {
1877 "review" => "review_start",
1878 _ => "dispatch",
1879 };
1880 let details = serde_json::json!({
1881 "run_type": run.run_type,
1882 "attempt": run.attempt,
1883 "command": run.command,
1884 });
1885 self.append_activity(
1886 run.task_id,
1887 event_type,
1888 None,
1889 None,
1890 Some(run.agent),
1891 Some(run.model),
1892 Some(&details),
1893 )
1894 .await?;
1895
1896 Ok(run_id)
1897 }
1898
1899 pub async fn complete_run(&self, run: &CompleteRun<'_>) -> anyhow::Result<()> {
1901 let run_ctx =
1902 sqlx::query("SELECT task_id, run_type, agent, model FROM task_runs WHERE id = ?")
1903 .bind(run.run_id)
1904 .fetch_optional(&self.pool)
1905 .await?;
1906 sqlx::query(
1907 "UPDATE task_runs SET
1908 exit_code = ?, stdout = ?, stderr = ?, parsed_response = ?,
1909 outcome = ?, error = NULLIF(?, ''),
1910 input_tokens = ?, output_tokens = ?, total_cost_usd = ?,
1911 duration_secs = ?,
1912 completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1913 WHERE id = ?",
1914 )
1915 .bind(run.exit_code)
1916 .bind(run.stdout)
1917 .bind(security::redact(run.stderr))
1918 .bind(run.parsed)
1919 .bind(run.outcome)
1920 .bind(security::redact(run.error))
1921 .bind(i64::try_from(run.tokens.input_tokens).unwrap_or_else(|_| {
1922 tracing::warn!(
1923 run.tokens.input_tokens,
1924 "input_tokens overflows i64, clamping to MAX"
1925 );
1926 i64::MAX
1927 }))
1928 .bind(i64::try_from(run.tokens.output_tokens).unwrap_or_else(|_| {
1929 tracing::warn!(
1930 run.tokens.output_tokens,
1931 "output_tokens overflows i64, clamping to MAX"
1932 );
1933 i64::MAX
1934 }))
1935 .bind(run.tokens.total_cost_usd)
1936 .bind(run.tokens.duration_secs)
1937 .bind(run.run_id)
1938 .execute(&self.pool)
1939 .await?;
1940 if let Some(run_ctx) = run_ctx {
1941 let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
1942 anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
1943 })?;
1944 let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
1945 anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
1946 })?;
1947 let agent: String = run_ctx.try_get("agent").map_err(|e| {
1948 anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
1949 })?;
1950 let model: String = run_ctx.try_get("model").map_err(|e| {
1951 anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
1952 })?;
1953 if run.outcome == "timeout" {
1954 self.append_activity(
1955 task_id,
1956 "timeout",
1957 None,
1958 None,
1959 Some(&agent),
1960 Some(&model),
1961 Some(&serde_json::json!({
1962 "run_type": run_type,
1963 "error": run.error,
1964 "stderr": run.stderr,
1965 })),
1966 )
1967 .await?;
1968 } else if run.outcome != "success" {
1969 self.append_activity(
1970 task_id,
1971 "error",
1972 None,
1973 None,
1974 Some(&agent),
1975 Some(&model),
1976 Some(&serde_json::json!({
1977 "run_type": run_type,
1978 "outcome": run.outcome,
1979 "error": run.error,
1980 })),
1981 )
1982 .await?;
1983 }
1984 } else if run.outcome != "success" {
1985 tracing::warn!(
1986 run_id = run.run_id,
1987 outcome = run.outcome,
1988 "run_id not found in task_runs; skipping timeout/error activity log"
1989 );
1990 }
1991 Ok(())
1992 }
1993
1994 pub async fn finalize_incomplete_runs(
1999 &self,
2000 task_id: i64,
2001 outcome: &str,
2002 error: &str,
2003 ) -> anyhow::Result<u64> {
2004 let rows: Vec<(i64,)> = sqlx::query_as(
2005 "SELECT id FROM task_runs
2006 WHERE task_id = ?
2007 AND outcome IS NULL
2008 AND completed_at IS NULL
2009 ORDER BY id ASC",
2010 )
2011 .bind(task_id)
2012 .fetch_all(&self.pool)
2013 .await?;
2014
2015 for (run_id,) in &rows {
2016 self.complete_run(&CompleteRun {
2017 run_id: *run_id,
2018 exit_code: Some(-1),
2019 stdout: "",
2020 stderr: "",
2021 parsed: "",
2022 outcome,
2023 error,
2024 tokens: RunTokenUsage::default(),
2025 })
2026 .await?;
2027 }
2028
2029 Ok(rows.len() as u64)
2030 }
2031
2032 pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
2034 let rows = sqlx::query(&format!(
2035 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
2036 ))
2037 .bind(task_id)
2038 .fetch_all(&self.pool)
2039 .await?;
2040
2041 rows.iter().map(Self::row_to_run).collect()
2042 }
2043
2044 pub async fn get_runs_batch(
2047 &self,
2048 task_ids: &[i64],
2049 ) -> anyhow::Result<std::collections::HashMap<i64, Vec<TaskRun>>> {
2050 if task_ids.is_empty() {
2051 return Ok(std::collections::HashMap::new());
2052 }
2053 const CHUNK_SIZE: usize = 500;
2056
2057 let mut result: std::collections::HashMap<i64, Vec<TaskRun>> =
2058 std::collections::HashMap::new();
2059
2060 for chunk in task_ids.chunks(CHUNK_SIZE) {
2061 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2062 let sql = format!(
2063 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
2064 );
2065 let mut query = sqlx::query(&sql);
2066 for id in chunk {
2067 query = query.bind(*id);
2068 }
2069 let rows = query.fetch_all(&self.pool).await?;
2070 for row in &rows {
2071 let run = Self::row_to_run(row)?;
2072 result.entry(run.task_id).or_default().push(run);
2073 }
2074 }
2075 Ok(result)
2076 }
2077
2078 pub async fn get_batch(
2081 &self,
2082 ids: &[i64],
2083 ) -> anyhow::Result<std::collections::HashMap<i64, Task>> {
2084 if ids.is_empty() {
2085 return Ok(std::collections::HashMap::new());
2086 }
2087 const CHUNK_SIZE: usize = 500;
2090
2091 let mut result = std::collections::HashMap::new();
2092
2093 for chunk in ids.chunks(CHUNK_SIZE) {
2094 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2095 let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
2096 let mut query = sqlx::query(&sql);
2097 for id in chunk {
2098 query = query.bind(*id);
2099 }
2100 let rows = query.fetch_all(&self.pool).await?;
2101 for row in &rows {
2102 let task = Self::row_to_task(row)?;
2103 result.insert(task.id, task);
2104 }
2105 }
2106 Ok(result)
2107 }
2108
2109 pub async fn get_statuses_by_external_ids(
2114 &self,
2115 repo: &str,
2116 ext_ids: &[&str],
2117 ) -> anyhow::Result<std::collections::HashMap<String, TaskStatus>> {
2118 let mut result = std::collections::HashMap::new();
2119 if ext_ids.is_empty() {
2120 return Ok(result);
2121 }
2122
2123 const CHUNK_SIZE: usize = 500;
2124 for chunk in ext_ids.chunks(CHUNK_SIZE) {
2125 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2126 let sql = format!(
2127 "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2128 );
2129 let mut query = sqlx::query(&sql);
2130 query = query.bind(repo);
2131 for id in chunk {
2132 query = query.bind(id);
2133 }
2134 let rows = query.fetch_all(&self.pool).await?;
2135 for row in &rows {
2136 let external_id: Result<String, _> = row.try_get("external_id");
2137 let status_str: Result<String, _> = row.try_get("status");
2138 match (external_id, status_str) {
2139 (Ok(ext_id), Ok(sts)) => {
2140 if ext_id.is_empty() {
2141 tracing::warn!(status = %sts, "skipping task row with empty external_id");
2142 continue;
2143 }
2144 if let Some(status) = TaskStatus::from_str(&sts) {
2145 result.insert(ext_id, status);
2146 } else {
2147 tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2148 }
2149 }
2150 (Err(e), _) => {
2151 tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2152 }
2153 (_, Err(e)) => {
2154 tracing::warn!(error = %e, "skipping task row with decode error for status");
2155 }
2156 }
2157 }
2158 }
2159
2160 Ok(result)
2161 }
2162
2163 #[cfg(test)]
2166 pub async fn get_last_run(
2167 &self,
2168 task_id: i64,
2169 run_type: &str,
2170 ) -> anyhow::Result<Option<TaskRun>> {
2171 let row = sqlx::query(&format!(
2172 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2173 ))
2174 .bind(task_id)
2175 .bind(run_type)
2176 .fetch_optional(&self.pool)
2177 .await?;
2178
2179 match row {
2180 Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2181 None => Ok(None),
2182 }
2183 }
2184
2185 pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2189 let rows: Vec<(String,)> = sqlx::query_as(
2190 "SELECT DISTINCT agent FROM task_runs \
2191 WHERE task_id = ? AND run_type = 'review' \
2192 ORDER BY agent",
2193 )
2194 .bind(task_id)
2195 .fetch_all(&self.pool)
2196 .await?;
2197 Ok(rows.into_iter().map(|(a,)| a).collect())
2198 }
2199
2200 #[cfg(test)]
2203 pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2204 let result = sqlx::query(
2205 "DELETE FROM task_runs WHERE task_id IN (
2206 SELECT id FROM tasks
2207 WHERE status IN ('done', 'blocked')
2208 AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2209 )",
2210 )
2211 .bind(format!("-{days}"))
2212 .execute(&self.pool)
2213 .await?;
2214
2215 Ok(result.rows_affected())
2216 }
2217
2218 fn row_to_task(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Task> {
2223 let col_count = row.len();
2226 if col_count != TASK_COLS_COUNT {
2227 tracing::warn!(
2228 expected = TASK_COLS_COUNT,
2229 actual = col_count,
2230 "TASK_COLS column count mismatch - schema may have changed"
2231 );
2232 }
2233
2234 let status_str: String = row
2235 .try_get("status")
2236 .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2237 let labels_str: String = row.try_get("labels").unwrap_or_default();
2238 let memory_str: String = row.try_get("memory").unwrap_or_default();
2239 let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2240
2241 Ok(Task {
2242 id: row
2243 .try_get("id")
2244 .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2245 external_id: row.try_get("external_id").unwrap_or(None),
2246 repo: row
2247 .try_get("repo")
2248 .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2249 origin: row
2250 .try_get("origin")
2251 .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2252 title: row
2253 .try_get("title")
2254 .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2255 body: row
2256 .try_get("body")
2257 .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2258 status: TaskStatus::from_str(&status_str)
2259 .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2260 source: row
2261 .try_get("source")
2262 .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2263 source_id: row
2264 .try_get("source_id")
2265 .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2266 author: row
2267 .try_get("author")
2268 .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2269 url: row
2270 .try_get("url")
2271 .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2272 labels: serde_json::from_str(&labels_str)
2273 .inspect_err(
2274 |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2275 )
2276 .unwrap_or_default(),
2277 agent: row.try_get("agent").unwrap_or(None),
2278 model: row.try_get("model").unwrap_or(None),
2279 complexity: row.try_get("complexity").unwrap_or_default(),
2280 estimate: {
2281 let raw: i32 = row.try_get("estimate").unwrap_or(0);
2282 u8::try_from(raw).unwrap_or_else(|_| {
2283 tracing::warn!(
2284 raw_estimate = raw,
2285 "estimate value out of u8 range; defaulting to 0"
2286 );
2287 0
2288 })
2289 },
2290 route_reason: row.try_get("route_reason").unwrap_or_default(),
2291 agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2292 selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2293 route_attempts: row.try_get("route_attempts").unwrap_or(0),
2294 attempts: row.try_get("attempts").unwrap_or(0),
2295 branch: row.try_get("branch").unwrap_or_default(),
2296 worktree: row.try_get("worktree").unwrap_or_default(),
2297 worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2298 summary: row.try_get("summary").unwrap_or_default(),
2299 last_error: row.try_get("last_error").unwrap_or_default(),
2300 parent_id: row.try_get("parent_id").unwrap_or(None),
2301 block_reason: row.try_get("block_reason").unwrap_or(None),
2302 pr_number: row.try_get("pr_number").unwrap_or(None),
2303 pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2304 last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2305 review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2306 last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2307 merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2308 ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2309 pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2310 push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2311 network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2312 review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2313 review_cycles: row.try_get("review_cycles").unwrap_or(0),
2314 review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2315 review_session_expected: row
2316 .try_get::<i32, _>("review_session_expected")
2317 .unwrap_or(0)
2318 != 0,
2319 needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2320 input_tokens: row.try_get("input_tokens").unwrap_or(0),
2321 output_tokens: row.try_get("output_tokens").unwrap_or(0),
2322 input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2323 output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2324 total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2325 model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2326 limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2327 memory: serde_json::from_str(&memory_str)
2328 .inspect_err(
2329 |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2330 )
2331 .unwrap_or_default(),
2332 delegations: serde_json::from_str(&delegations_str)
2333 .inspect_err(
2334 |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2335 )
2336 .unwrap_or_default(),
2337 auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2342 auto_unblock_last_at: row
2343 .try_get::<String, _>("auto_unblock_last_at")
2344 .unwrap_or_default(),
2345 auto_unblock_last_reason: row
2346 .try_get::<String, _>("auto_unblock_last_reason")
2347 .unwrap_or_default(),
2348 ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2349 no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2350 no_code_last_agent: row
2351 .try_get::<String, _>("no_code_last_agent")
2352 .unwrap_or_default(),
2353 created_at: row
2354 .try_get::<String, _>("created_at")
2355 .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2356 updated_at: row
2357 .try_get::<String, _>("updated_at")
2358 .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2359 })
2360 }
2361 pub async fn ensure_external_task(
2362 &self,
2363 repo: &str,
2364 ext: &crate::backends::ExternalTask,
2365 ) -> anyhow::Result<i64> {
2366 self.upsert_external(&UpsertExternal {
2367 repo,
2368 ext_id: &ext.id.0,
2369 title: &ext.title,
2370 body: &ext.body,
2371 author: &ext.author,
2372 url: &ext.url,
2373 labels: &ext.labels,
2374 origin: "github",
2375 })
2376 .await
2377 }
2378
2379 pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2382 if let Some(suffix) = task_id.strip_prefix("internal:") {
2383 if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2385 return Ok(Some(id));
2386 }
2387 if let Ok(id) = suffix.parse::<i64>() {
2391 let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2392 .bind(id)
2393 .bind(repo)
2394 .fetch_optional(&self.pool)
2395 .await?;
2396 if exists.is_some() {
2397 return Ok(Some(id));
2398 }
2399 }
2400 return Ok(None);
2401 }
2402 self.resolve_id_by_external(repo, task_id).await
2405 }
2406
2407 pub async fn resolve_task_id_globally(
2413 &self,
2414 task_id: &str,
2415 ) -> anyhow::Result<Option<(i64, String)>> {
2416 if let Some(suffix) = task_id.strip_prefix("internal:") {
2417 let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2419 .bind(task_id)
2420 .fetch_optional(&self.pool)
2421 .await?;
2422 if let Some(r) = row {
2423 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2424 }
2425 if let Ok(id) = suffix.parse::<i64>() {
2426 let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2427 .bind(id)
2428 .fetch_optional(&self.pool)
2429 .await?;
2430 if let Some(r) = row {
2431 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2432 }
2433 }
2434 return Ok(None);
2435 }
2436 let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2438 .bind(task_id)
2439 .fetch_all(&self.pool)
2440 .await?;
2441 match rows.len() {
2442 0 => Ok(None),
2443 1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2444 _ => {
2445 let repos: Vec<String> = rows
2446 .iter()
2447 .filter_map(|r| r.try_get::<String, _>("repo").ok())
2448 .collect();
2449 anyhow::bail!(
2450 "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2451 Run from the repo worktree so the current-repo lookup resolves it.",
2452 repos.join(", ")
2453 )
2454 }
2455 }
2456 }
2457
2458 fn row_to_run(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskRun> {
2459 Ok(TaskRun {
2460 id: row
2461 .try_get("id")
2462 .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2463 task_id: row
2464 .try_get("task_id")
2465 .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2466 attempt: row
2467 .try_get("attempt")
2468 .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2469 run_type: row
2470 .try_get("run_type")
2471 .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2472 agent: row
2473 .try_get("agent")
2474 .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2475 model: row
2476 .try_get("model")
2477 .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2478 command: row
2479 .try_get("command")
2480 .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2481 prompt: row
2482 .try_get("prompt")
2483 .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2484 env_vars: row
2485 .try_get("env_vars")
2486 .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2487 exit_code: row
2488 .try_get("exit_code")
2489 .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2490 stdout: row
2491 .try_get("stdout")
2492 .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2493 stderr: row
2494 .try_get("stderr")
2495 .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2496 parsed_response: row
2497 .try_get("parsed_response")
2498 .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2499 outcome: row
2500 .try_get("outcome")
2501 .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2502 error: row
2503 .try_get("error")
2504 .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2505 input_tokens: row
2506 .try_get("input_tokens")
2507 .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2508 output_tokens: row
2509 .try_get("output_tokens")
2510 .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2511 total_cost_usd: row
2512 .try_get("total_cost_usd")
2513 .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2514 duration_secs: row
2515 .try_get("duration_secs")
2516 .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2517 started_at: row
2518 .try_get("started_at")
2519 .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2520 completed_at: row.try_get("completed_at").unwrap_or(None),
2521 })
2522 }
2523
2524 fn row_to_activity(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskActivity> {
2525 let details_str: String = row
2526 .try_get("details")
2527 .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2528 let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2529 |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2530 )?;
2531 Ok(TaskActivity {
2532 id: row
2533 .try_get("id")
2534 .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2535 task_id: row
2536 .try_get("task_id")
2537 .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2538 timestamp: row
2539 .try_get("timestamp")
2540 .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2541 event_type: row
2542 .try_get("event_type")
2543 .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2544 from_status: row
2545 .try_get("from_status")
2546 .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2547 to_status: row
2548 .try_get("to_status")
2549 .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2550 agent: row
2551 .try_get("agent")
2552 .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2553 model: row
2554 .try_get("model")
2555 .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2556 details,
2557 })
2558 }
2559}
2560
2561#[cfg(test)]
2562mod row_to_task_tests {
2563 use super::*;
2564
2565 async fn store_with_task() -> (crate::store::TaskStore, i64) {
2566 let store = crate::store::TaskStore::open_memory().await.unwrap();
2567 let id = store
2568 .create(&NewTask {
2569 external_id: None,
2570 repo: "owner/repo".to_string(),
2571 origin: "internal".to_string(),
2572 title: "Test task".to_string(),
2573 body: "Test body".to_string(),
2574 source: "cron".to_string(),
2575 source_id: "test-1".to_string(),
2576 author: "tester".to_string(),
2577 url: "https://example.com/1".to_string(),
2578 labels: vec![],
2579 parent_id: None,
2580 })
2581 .await
2582 .unwrap();
2583 (store, id)
2584 }
2585
2586 macro_rules! missing_column_test {
2589 ($name:ident, $col:literal, $select:literal) => {
2590 #[tokio::test]
2591 async fn $name() {
2592 let (store, id) = store_with_task().await;
2593 let sql = format!("{} WHERE id = {id}", $select);
2594 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2595 let err = TaskStore::row_to_task(&row).unwrap_err();
2596 assert!(
2597 err.to_string().contains($col),
2598 "expected '{}' in error message, got: {err}",
2599 $col
2600 );
2601 }
2602 };
2603 }
2604
2605 missing_column_test!(
2606 row_to_task_fails_on_missing_repo,
2607 "repo",
2608 "SELECT id, status FROM tasks"
2609 );
2610 missing_column_test!(
2611 row_to_task_fails_on_missing_origin,
2612 "origin",
2613 "SELECT id, repo, status FROM tasks"
2614 );
2615 missing_column_test!(
2616 row_to_task_fails_on_missing_title,
2617 "title",
2618 "SELECT id, repo, origin, status FROM tasks"
2619 );
2620 missing_column_test!(
2621 row_to_task_fails_on_missing_body,
2622 "body",
2623 "SELECT id, repo, origin, title, status FROM tasks"
2624 );
2625 missing_column_test!(
2626 row_to_task_fails_on_missing_source,
2627 "source",
2628 "SELECT id, repo, origin, title, body, status FROM tasks"
2629 );
2630 missing_column_test!(
2631 row_to_task_fails_on_missing_source_id,
2632 "source_id",
2633 "SELECT id, repo, origin, title, body, source, status FROM tasks"
2634 );
2635 missing_column_test!(
2636 row_to_task_fails_on_missing_author,
2637 "author",
2638 "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
2639 );
2640 missing_column_test!(
2641 row_to_task_fails_on_missing_url,
2642 "url",
2643 "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
2644 );
2645
2646 #[tokio::test]
2653 async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
2654 let (store, id) = store_with_task().await;
2655 let sql = format!(
2658 "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
2659 source, source_id, author, url FROM tasks WHERE id = {id}"
2660 );
2661 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2662 let err = TaskStore::row_to_task(&row).unwrap_err();
2663 assert!(
2664 err.to_string().contains("repo"),
2665 "expected 'repo' in error message, got: {err}"
2666 );
2667 }
2668
2669 #[tokio::test]
2672 async fn distinct_repos_fails_on_decode_error() {
2673 let store = crate::store::TaskStore::open_memory().await.unwrap();
2674 let id = store
2675 .create(&NewTask {
2676 external_id: None,
2677 repo: "owner/repo".to_string(),
2678 origin: "internal".to_string(),
2679 title: "Test task".to_string(),
2680 body: "Test body".to_string(),
2681 source: "cron".to_string(),
2682 source_id: "test-1".to_string(),
2683 author: "tester".to_string(),
2684 url: "https://example.com/1".to_string(),
2685 labels: vec![],
2686 parent_id: None,
2687 })
2688 .await
2689 .unwrap();
2690
2691 let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
2693 sqlx::query(&sql).execute(store.pool()).await.unwrap();
2694
2695 let err = store.distinct_repos().await.unwrap_err();
2696 assert!(
2697 err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
2698 "expected 'distinct_repos' and 'repo' in error message, got: {err}"
2699 );
2700 }
2701
2702 async fn store_with_run() -> (crate::store::TaskStore, i64, i64) {
2705 let store = crate::store::TaskStore::open_memory().await.unwrap();
2706 let task_id = store
2707 .create(&NewTask {
2708 external_id: None,
2709 repo: "owner/repo".to_string(),
2710 origin: "internal".to_string(),
2711 title: "Test task".to_string(),
2712 body: "Test body".to_string(),
2713 source: "cron".to_string(),
2714 source_id: "test-1".to_string(),
2715 author: "tester".to_string(),
2716 url: "https://example.com/1".to_string(),
2717 labels: vec![],
2718 parent_id: None,
2719 })
2720 .await
2721 .unwrap();
2722 let run_id = store
2723 .start_run(&crate::store::StartRun {
2724 task_id,
2725 attempt: 1,
2726 run_type: "agent",
2727 agent: "claude",
2728 model: "sonnet",
2729 command: "claude -p ...",
2730 prompt: "system prompt",
2731 })
2732 .await
2733 .unwrap();
2734 (store, task_id, run_id)
2735 }
2736
2737 #[tokio::test]
2738 async fn start_run_upsert_clears_stale_completion_fields() {
2739 let (store, task_id, run_id) = store_with_run().await;
2740 store
2741 .complete_run(&CompleteRun {
2742 run_id,
2743 exit_code: Some(0),
2744 stdout: "",
2745 stderr: "",
2746 parsed: "",
2747 outcome: "success",
2748 error: "boom",
2749 tokens: RunTokenUsage::default(),
2750 })
2751 .await
2752 .unwrap();
2753
2754 let same_run_id = store
2756 .start_run(&crate::store::StartRun {
2757 task_id,
2758 attempt: 1,
2759 run_type: "agent",
2760 agent: "claude",
2761 model: "sonnet",
2762 command: "claude -p ...",
2763 prompt: "system prompt",
2764 })
2765 .await
2766 .unwrap();
2767 assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
2768
2769 let row = sqlx::query(
2770 "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
2771 )
2772 .bind(run_id)
2773 .fetch_one(store.pool())
2774 .await
2775 .unwrap();
2776 let outcome: Option<String> = row.try_get("outcome").unwrap();
2777 let completed_at: Option<String> = row.try_get("completed_at").unwrap();
2778 let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
2779 let error: Option<String> = row.try_get("error").unwrap();
2780 assert_eq!(outcome, None);
2781 assert_eq!(
2782 completed_at, None,
2783 "stale completion timestamp must be cleared"
2784 );
2785 assert_eq!(exit_code, None);
2786 assert_eq!(error, None);
2787 }
2788
2789 macro_rules! run_blob_decode_test {
2796 ($name:ident, $col:literal, $select:literal) => {
2797 #[tokio::test]
2798 async fn $name() {
2799 let (store, _task_id, run_id) = store_with_run().await;
2800 let sql = format!($select, run_id = run_id);
2801 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2802 let err = TaskStore::row_to_run(&row).unwrap_err();
2803 assert!(
2804 err.to_string().contains($col),
2805 "expected '{0}' in error message, got: {err}",
2806 $col
2807 );
2808 }
2809 };
2810 }
2811
2812 run_blob_decode_test!(
2813 row_to_run_fails_on_decode_error_invalid_utf8_id,
2814 "id",
2815 "SELECT X'DEADBEEF' as id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2816 );
2817 run_blob_decode_test!(
2818 row_to_run_fails_on_decode_error_invalid_utf8_task_id,
2819 "task_id",
2820 "SELECT id, X'DEADBEEF' as task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2821 );
2822 run_blob_decode_test!(
2823 row_to_run_fails_on_decode_error_invalid_utf8_started_at,
2824 "started_at",
2825 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, X'DEADBEEF' as started_at, completed_at FROM task_runs WHERE id = {run_id}"
2826 );
2827
2828 async fn store_with_activity() -> (crate::store::TaskStore, i64, i64) {
2831 let store = crate::store::TaskStore::open_memory().await.unwrap();
2832 let task_id = store
2833 .create(&NewTask {
2834 external_id: None,
2835 repo: "owner/repo".to_string(),
2836 origin: "internal".to_string(),
2837 title: "Test task".to_string(),
2838 body: "Test body".to_string(),
2839 source: "cron".to_string(),
2840 source_id: "test-1".to_string(),
2841 author: "tester".to_string(),
2842 url: "https://example.com/1".to_string(),
2843 labels: vec![],
2844 parent_id: None,
2845 })
2846 .await
2847 .unwrap();
2848 store
2849 .append_activity(task_id, "created", None, Some("new"), None, None, None)
2850 .await
2851 .unwrap();
2852 (store, task_id, 1)
2853 }
2854
2855 #[tokio::test]
2858 async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
2859 let (store, _task_id, activity_id) = store_with_activity().await;
2860 let sql = format!(
2861 "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2862 );
2863 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2864 let err = TaskStore::row_to_activity(&row).unwrap_err();
2865 assert!(
2866 err.to_string().contains("id"),
2867 "expected 'id' in error message, got: {err}"
2868 );
2869 }
2870
2871 #[tokio::test]
2872 async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
2873 let (store, _task_id, activity_id) = store_with_activity().await;
2874 let sql = format!(
2875 "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2876 );
2877 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2878 let err = TaskStore::row_to_activity(&row).unwrap_err();
2879 assert!(
2880 err.to_string().contains("task_id"),
2881 "expected 'task_id' in error message, got: {err}"
2882 );
2883 }
2884
2885 #[tokio::test]
2886 async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
2887 let (store, _task_id, activity_id) = store_with_activity().await;
2888 let sql = format!(
2889 "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2890 );
2891 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2892 let err = TaskStore::row_to_activity(&row).unwrap_err();
2893 assert!(
2894 err.to_string().contains("timestamp"),
2895 "expected 'timestamp' in error message, got: {err}"
2896 );
2897 }
2898
2899 #[tokio::test]
2900 async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
2901 let (store, _task_id, activity_id) = store_with_activity().await;
2902 let sql = format!(
2903 "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
2904 );
2905 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2906 let err = TaskStore::row_to_activity(&row).unwrap_err();
2907 assert!(
2908 err.to_string().contains("event_type"),
2909 "expected 'event_type' in error message, got: {err}"
2910 );
2911 }
2912
2913 #[tokio::test]
2914 async fn row_to_activity_fails_on_corrupt_details_json() {
2915 let (store, task_id, _activity_id) = store_with_activity().await;
2916 sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
2918 .bind(task_id)
2919 .bind("test")
2920 .bind("not valid json {{{")
2921 .execute(store.pool())
2922 .await
2923 .unwrap();
2924 let row = sqlx::query(&format!(
2925 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
2926 ))
2927 .fetch_one(store.pool())
2928 .await
2929 .unwrap();
2930 let err = TaskStore::row_to_activity(&row).unwrap_err();
2931 assert!(
2932 err.to_string().contains("details")
2933 || err.to_string().contains("json")
2934 || err.to_string().contains("expected"),
2935 "expected 'details', 'json', or 'expected' in error message, got: {err}"
2936 );
2937 }
2938
2939 run_blob_decode_test!(
2942 row_to_run_fails_on_decode_error_invalid_utf8_model,
2943 "model",
2944 "SELECT id, task_id, attempt, run_type, agent, X'DeadbEEF' as model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2945 );
2946 run_blob_decode_test!(
2947 row_to_run_fails_on_decode_error_invalid_utf8_command,
2948 "command",
2949 "SELECT id, task_id, attempt, run_type, agent, model, X'DeadbEEF' as command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2950 );
2951 run_blob_decode_test!(
2952 row_to_run_fails_on_decode_error_invalid_utf8_prompt,
2953 "prompt",
2954 "SELECT id, task_id, attempt, run_type, agent, model, command, X'DeadbEEF' as prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2955 );
2956 run_blob_decode_test!(
2957 row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
2958 "env_vars",
2959 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, X'DeadbEEF' as env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2960 );
2961 run_blob_decode_test!(
2962 row_to_run_fails_on_decode_error_invalid_utf8_stdout,
2963 "stdout",
2964 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, X'DeadbEEF' as stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2965 );
2966 run_blob_decode_test!(
2967 row_to_run_fails_on_decode_error_invalid_utf8_stderr,
2968 "stderr",
2969 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, X'DeadbEEF' as stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2970 );
2971 run_blob_decode_test!(
2972 row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
2973 "parsed_response",
2974 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, X'DeadbEEF' as parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2975 );
2976 run_blob_decode_test!(
2977 row_to_run_fails_on_decode_error_invalid_utf8_outcome,
2978 "outcome",
2979 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, X'DeadbEEF' as outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2980 );
2981 run_blob_decode_test!(
2982 row_to_run_fails_on_decode_error_invalid_utf8_error,
2983 "error",
2984 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, X'DeadbEEF' as error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2985 );
2986 run_blob_decode_test!(
2987 row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
2988 "input_tokens",
2989 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, X'DeadbEEF' as input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2990 );
2991 run_blob_decode_test!(
2992 row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
2993 "output_tokens",
2994 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, X'DeadbEEF' as output_tokens, total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
2995 );
2996 run_blob_decode_test!(
2997 row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
2998 "total_cost_usd",
2999 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, X'DeadbEEF' as total_cost_usd, duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
3000 );
3001 run_blob_decode_test!(
3002 row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3003 "duration_secs",
3004 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, X'DeadbEEF' as duration_secs, started_at, completed_at FROM task_runs WHERE id = {run_id}"
3005 );
3006 run_blob_decode_test!(
3007 row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3008 "exit_code",
3009 "SELECT id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, total_cost_usd, duration_secs, started_at, X'DeadbEEF' as exit_code, completed_at FROM task_runs WHERE id = {run_id}"
3010 );
3011
3012 #[tokio::test]
3019 async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3020 let (store, _task_id, activity_id) = store_with_activity().await;
3021 let sql = format!(
3022 "SELECT id, task_id, timestamp, event_type, \
3023 X'DeadbEEF' as from_status, NULL as to_status, \
3024 NULL as agent, NULL as model, details \
3025 FROM task_activity WHERE id = {activity_id}"
3026 );
3027 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3028 let err = TaskStore::row_to_activity(&row).unwrap_err();
3029 assert!(
3030 err.to_string().contains("from_status"),
3031 "expected 'from_status' in error message, got: {err}"
3032 );
3033 }
3034
3035 #[tokio::test]
3036 async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3037 let (store, _task_id, activity_id) = store_with_activity().await;
3038 let sql = format!(
3039 "SELECT id, task_id, timestamp, event_type, \
3040 NULL as from_status, X'DeadbEEF' as to_status, \
3041 NULL as agent, NULL as model, details \
3042 FROM task_activity WHERE id = {activity_id}"
3043 );
3044 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3045 let err = TaskStore::row_to_activity(&row).unwrap_err();
3046 assert!(
3047 err.to_string().contains("to_status"),
3048 "expected 'to_status' in error message, got: {err}"
3049 );
3050 }
3051
3052 #[tokio::test]
3053 async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3054 let (store, _task_id, activity_id) = store_with_activity().await;
3055 let sql = format!(
3056 "SELECT id, task_id, timestamp, event_type, \
3057 NULL as from_status, NULL as to_status, \
3058 X'DeadbEEF' as agent, NULL as model, details \
3059 FROM task_activity WHERE id = {activity_id}"
3060 );
3061 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3062 let err = TaskStore::row_to_activity(&row).unwrap_err();
3063 assert!(
3064 err.to_string().contains("agent"),
3065 "expected 'agent' in error message, got: {err}"
3066 );
3067 }
3068
3069 #[tokio::test]
3070 async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3071 let (store, _task_id, activity_id) = store_with_activity().await;
3072 let sql = format!(
3073 "SELECT id, task_id, timestamp, event_type, \
3074 NULL as from_status, NULL as to_status, \
3075 NULL as agent, X'DeadbEEF' as model, details \
3076 FROM task_activity WHERE id = {activity_id}"
3077 );
3078 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3079 let err = TaskStore::row_to_activity(&row).unwrap_err();
3080 assert!(
3081 err.to_string().contains("model"),
3082 "expected 'model' in error message, got: {err}"
3083 );
3084 }
3085
3086 #[tokio::test]
3089 async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3090 let store = crate::store::TaskStore::open_memory().await.unwrap();
3095 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3096 .execute(store.pool())
3097 .await
3098 .unwrap();
3099 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3101 .execute(store.pool())
3102 .await
3103 .unwrap();
3104
3105 let result = store
3106 .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3107 .await
3108 .unwrap();
3109 assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3110 assert_eq!(
3111 result.get("valid-id"),
3112 Some(&crate::store::TaskStatus::Done)
3113 );
3114 }
3115}