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, PartialEq, Eq)]
300pub enum CliTaskSpec {
301 Internal(i64),
303 Issue { repo: String, number: i64 },
305 Bare(i64),
307}
308
309#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct CliTaskRef {
312 pub store_id: Option<i64>,
313 pub repo: String,
314 pub origin: String,
315 pub title: String,
316 pub external_id: String,
317 pub status: String,
318}
319
320impl CliTaskRef {
321 pub fn is_internal(&self) -> bool {
322 self.origin == "internal"
323 }
324
325 pub fn unsynced_issue(repo: String, number: i64) -> Self {
326 Self {
327 store_id: None,
328 repo,
329 origin: "github".to_string(),
330 title: String::new(),
331 external_id: number.to_string(),
332 status: String::new(),
333 }
334 }
335
336 pub fn display_spec(&self) -> String {
337 if self.is_internal() {
338 match self.store_id {
339 Some(id) => format!("internal:{id}"),
340 None => self.external_id.clone(),
341 }
342 } else {
343 format!("{}#{}", self.repo, self.external_id)
344 }
345 }
346
347 fn from_row(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Self> {
348 let id: i64 = row.try_get("id")?;
349 let origin: String = row.try_get("origin")?;
350 let external_id: Option<String> = row.try_get("external_id")?;
351 let external_id = external_id.unwrap_or_else(|| {
352 if origin == "internal" {
353 format!("internal:{id}")
354 } else {
355 String::new()
356 }
357 });
358 Ok(Self {
359 store_id: Some(id),
360 repo: row.try_get("repo")?,
361 origin,
362 title: row.try_get("title")?,
363 external_id,
364 status: row.try_get("status")?,
365 })
366 }
367
368 fn format_match(&self) -> String {
369 format!(
370 " {} [{}] {}",
371 self.display_spec(),
372 if self.status.is_empty() {
373 "unknown"
374 } else {
375 &self.status
376 },
377 if self.title.is_empty() {
378 "-"
379 } else {
380 &self.title
381 }
382 )
383 }
384}
385
386pub fn parse_cli_task_spec(raw: &str) -> anyhow::Result<CliTaskSpec> {
388 let spec = raw.trim();
389 if spec.is_empty() {
390 anyhow::bail!("empty task id");
391 }
392 if let Some(suffix) = spec.strip_prefix("internal:") {
393 let number: i64 = suffix
394 .parse()
395 .map_err(|_| anyhow::anyhow!("internal task id '{suffix}' is not numeric"))?;
396 if number <= 0 {
397 anyhow::bail!("internal task id must be a positive number");
398 }
399 return Ok(CliTaskSpec::Internal(number));
400 }
401 if let Some((repo, number)) = parse_repo_issue(spec) {
402 return Ok(CliTaskSpec::Issue { repo, number });
403 }
404 let bare = spec.strip_prefix('#').unwrap_or(spec);
405 if let Ok(number) = bare.parse::<i64>() {
406 if number > 0 {
407 return Ok(CliTaskSpec::Bare(number));
408 }
409 }
410 anyhow::bail!("invalid task id '{spec}': use a number, owner/repo#N, or internal:N")
411}
412
413fn parse_repo_issue(spec: &str) -> Option<(String, i64)> {
414 let (repo, number) = spec.rsplit_once('#')?;
415 if repo.is_empty() {
416 return None;
417 }
418 let number: i64 = number.parse().ok()?;
419 if number <= 0 {
420 return None;
421 }
422 let mut parts = repo.split('/');
423 let owner = parts.next()?;
424 let name = parts.next()?;
425 if parts.next().is_some() || owner.is_empty() || name.is_empty() {
426 return None;
427 }
428 let ok = |part: &str| {
429 part.chars()
430 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
431 };
432 if !ok(owner) || !ok(name) {
433 return None;
434 }
435 Some((repo.to_string(), number))
436}
437
438fn ambiguous_cli_id_error(spec: &str, matches: &[CliTaskRef]) -> anyhow::Error {
439 let mut lines = vec![format!("task ID {spec} matches more than one task:")];
440 for task in matches {
441 lines.push(task.format_match());
442 }
443 lines.push("Use owner/repo#N for an issue, or internal:N for an internal task.".to_string());
444 anyhow::anyhow!(lines.join("\n"))
445}
446
447fn dedup_cli_refs(rows: Vec<sqlx::sqlite::SqliteRow>) -> anyhow::Result<Vec<CliTaskRef>> {
448 let mut seen = Vec::new();
449 let mut out = Vec::new();
450 for row in rows {
451 let task = CliTaskRef::from_row(&row)?;
452 if let Some(id) = task.store_id {
453 if seen.contains(&id) {
454 continue;
455 }
456 seen.push(id);
457 }
458 out.push(task);
459 }
460 Ok(out)
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct TaskRun {
466 pub id: i64,
467 pub task_id: i64,
468 pub attempt: i32,
469 pub run_type: String,
470 pub agent: String,
471 pub model: String,
472 pub command: String,
473 pub prompt: String,
474 pub env_vars: String,
475 pub exit_code: Option<i32>,
476 pub stdout: String,
477 pub stderr: String,
478 pub parsed_response: String,
479 pub outcome: String,
480 pub error: String,
481 pub input_tokens: i64,
482 pub output_tokens: i64,
483 pub total_cost_usd: f64,
484 pub duration_secs: f64,
485 pub started_at: String,
486 pub completed_at: Option<String>,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct TaskActivity {
492 pub id: i64,
493 pub task_id: i64,
494 pub timestamp: String,
495 pub event_type: String,
496 pub from_status: Option<String>,
497 pub to_status: Option<String>,
498 pub agent: Option<String>,
499 pub model: Option<String>,
500 pub details: serde_json::Value,
501}
502
503#[derive(Debug, Clone, Copy, Default)]
505pub struct RunTokenUsage {
506 pub input_tokens: u64,
507 pub output_tokens: u64,
508 pub total_cost_usd: f64,
509 pub duration_secs: f64,
510}
511
512#[derive(Debug, Clone)]
514pub struct StartRun<'a> {
515 pub task_id: i64,
516 pub attempt: i32,
517 pub run_type: &'a str,
518 pub agent: &'a str,
519 pub model: &'a str,
520 pub command: &'a str,
521 pub prompt: &'a str,
522}
523
524#[derive(Debug, Clone)]
526pub struct CompleteRun<'a> {
527 pub run_id: i64,
528 pub exit_code: Option<i32>,
529 pub stdout: &'a str,
530 pub stderr: &'a str,
531 pub parsed: &'a str,
532 pub outcome: &'a str,
533 pub error: &'a str,
534 pub tokens: RunTokenUsage,
535}
536
537#[derive(Debug, Clone)]
539pub struct StoreRoute<'a> {
540 pub id: i64,
541 pub agent: &'a str,
542 pub model: Option<&'a str>,
543 pub complexity: &'a str,
544 pub estimate: u8,
546 pub reason: &'a str,
547 pub profile: &'a str,
548 pub skills: &'a str,
549}
550
551#[derive(Debug, Clone)]
553pub struct UpsertExternal<'a> {
554 pub repo: &'a str,
555 pub ext_id: &'a str,
556 pub title: &'a str,
557 pub body: &'a str,
558 pub author: &'a str,
559 pub url: &'a str,
560 pub labels: &'a [String],
561 pub origin: &'a str,
562}
563
564fn parent_external_id_from_labels(labels: &[String]) -> Option<&str> {
565 labels
566 .iter()
567 .find_map(|label| label.strip_prefix("parent:"))
568 .filter(|parent| !parent.is_empty())
569}
570
571impl TaskStore {
572 #[allow(clippy::too_many_arguments)]
574 pub async fn append_activity(
575 &self,
576 task_id: i64,
577 event_type: &str,
578 from_status: Option<&str>,
579 to_status: Option<&str>,
580 agent: Option<&str>,
581 model: Option<&str>,
582 details: Option<&serde_json::Value>,
583 ) -> anyhow::Result<()> {
584 let details_json = match details {
585 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
586 None => "{}".to_string(),
587 };
588 sqlx::query(
589 "INSERT INTO task_activity
590 (task_id, event_type, from_status, to_status, agent, model, details)
591 VALUES (?, ?, ?, ?, ?, ?, ?)",
592 )
593 .bind(task_id)
594 .bind(event_type)
595 .bind(from_status)
596 .bind(to_status)
597 .bind(agent)
598 .bind(model)
599 .bind(details_json)
600 .execute(&self.pool)
601 .await?;
602 Ok(())
603 }
604
605 #[doc(hidden)]
607 #[allow(clippy::too_many_arguments)]
608 pub async fn append_activity_tx(
609 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
610 task_id: i64,
611 event_type: &str,
612 from_status: Option<&str>,
613 to_status: Option<&str>,
614 agent: Option<&str>,
615 model: Option<&str>,
616 details: Option<&serde_json::Value>,
617 ) -> anyhow::Result<()> {
618 let details_json = match details {
619 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
620 None => "{}".to_string(),
621 };
622 sqlx::query(
623 "INSERT INTO task_activity
624 (task_id, event_type, from_status, to_status, agent, model, details)
625 VALUES (?, ?, ?, ?, ?, ?, ?)",
626 )
627 .bind(task_id)
628 .bind(event_type)
629 .bind(from_status)
630 .bind(to_status)
631 .bind(agent)
632 .bind(model)
633 .bind(details_json)
634 .execute(&mut **tx)
635 .await?;
636 Ok(())
637 }
638
639 pub async fn in_review_entry(&self, task_id: i64) -> anyhow::Result<Option<(String, bool)>> {
641 let row: Option<(String, bool)> = sqlx::query_as(
642 "SELECT a.timestamp, EXISTS (
643 SELECT 1 FROM task_activity d
644 WHERE d.task_id = a.task_id AND d.event_type = 'review_decision' AND d.id > a.id
645 )
646 FROM task_activity a
647 WHERE a.task_id = ? AND a.event_type = 'status_change' AND a.to_status = 'in_review'
648 ORDER BY a.id DESC LIMIT 1",
649 )
650 .bind(task_id)
651 .fetch_optional(&self.pool)
652 .await?;
653 Ok(row)
654 }
655
656 pub async fn get_activity(
658 &self,
659 task_id: i64,
660 limit: Option<usize>,
661 ) -> anyhow::Result<Vec<TaskActivity>> {
662 let rows = if let Some(limit) = limit {
663 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
664 sqlx::query(&format!(
665 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC LIMIT ?"
666 ))
667 .bind(task_id)
668 .bind(limit_i64)
669 .fetch_all(&self.pool)
670 .await?
671 } else {
672 sqlx::query(&format!(
673 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC"
674 ))
675 .bind(task_id)
676 .fetch_all(&self.pool)
677 .await?
678 };
679
680 rows.iter().map(Self::row_to_activity).collect()
681 }
682
683 pub async fn create(&self, new: &NewTask) -> anyhow::Result<i64> {
684 let labels_json = serde_json::to_string(&new.labels)?;
685 let row = sqlx::query(
686 "INSERT INTO tasks (external_id, repo, origin, title, body, source, source_id, author, url, labels, parent_id)
687 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
688 RETURNING id",
689 )
690 .bind(&new.external_id)
691 .bind(&new.repo)
692 .bind(&new.origin)
693 .bind(&new.title)
694 .bind(&new.body)
695 .bind(&new.source)
696 .bind(&new.source_id)
697 .bind(&new.author)
698 .bind(&new.url)
699 .bind(&labels_json)
700 .bind(new.parent_id)
701 .fetch_one(&self.pool)
702 .await?;
703
704 row.try_get("id")
705 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
706 }
707
708 pub async fn create_internal(
713 &self,
714 repo: &str,
715 title: &str,
716 body: &str,
717 source: &str,
718 source_id: &str,
719 parent_id: Option<i64>,
720 ) -> anyhow::Result<i64> {
721 let id = self
722 .create(&NewTask {
723 external_id: None,
724 repo: repo.to_string(),
725 origin: "internal".to_string(),
726 title: title.to_string(),
727 body: body.to_string(),
728 source: source.to_string(),
729 source_id: source_id.to_string(),
730 author: String::new(),
731 url: String::new(),
732 labels: vec![],
733 parent_id,
734 })
735 .await?;
736 sqlx::query("UPDATE tasks SET external_id = ? WHERE id = ?")
738 .bind(format!("internal:{id}"))
739 .bind(id)
740 .execute(&self.pool)
741 .await?;
742 Ok(id)
743 }
744
745 pub async fn get(&self, id: i64) -> anyhow::Result<Task> {
747 let row = sqlx::query(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?"))
748 .bind(id)
749 .fetch_optional(&self.pool)
750 .await?
751 .with_context(|| format!("task {id} not found"))?;
752
753 Self::row_to_task(&row)
754 }
755
756 pub async fn get_by_external_id(
758 &self,
759 repo: &str,
760 ext_id: &str,
761 ) -> anyhow::Result<Option<Task>> {
762 let row = sqlx::query(&format!(
763 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND external_id = ?"
764 ))
765 .bind(repo)
766 .bind(ext_id)
767 .fetch_optional(&self.pool)
768 .await?;
769
770 match row {
771 Some(r) => Ok(Some(Self::row_to_task(&r)?)),
772 None => Ok(None),
773 }
774 }
775
776 pub async fn resolve_id_by_external(
782 &self,
783 repo: &str,
784 ext_id: &str,
785 ) -> anyhow::Result<Option<i64>> {
786 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND external_id = ?")
787 .bind(repo)
788 .bind(ext_id)
789 .fetch_optional(&self.pool)
790 .await?;
791 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
792 Ok(id)
793 }
794
795 pub async fn resolve_id_by_pr_number(
801 &self,
802 repo: &str,
803 pr_number: i32,
804 ) -> anyhow::Result<Option<i64>> {
805 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND pr_number = ? LIMIT 1")
806 .bind(repo)
807 .bind(pr_number)
808 .fetch_optional(&self.pool)
809 .await?;
810 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
811 Ok(id)
812 }
813
814 pub async fn get_agent_by_external_id(
817 &self,
818 repo: &str,
819 ext_id: &str,
820 ) -> anyhow::Result<Option<String>> {
821 let row = sqlx::query("SELECT agent FROM tasks WHERE repo = ? AND external_id = ?")
822 .bind(repo)
823 .bind(ext_id)
824 .fetch_optional(&self.pool)
825 .await?;
826 let agent = row
827 .map(|r| r.try_get::<Option<String>, _>("agent"))
828 .transpose()?
829 .flatten();
830 Ok(agent)
831 }
832
833 pub async fn upsert_external(&self, ext: &UpsertExternal<'_>) -> anyhow::Result<i64> {
835 let labels_json = serde_json::to_string(ext.labels)?;
836 let parent_id = match parent_external_id_from_labels(ext.labels) {
837 Some(parent_external_id) => self.resolve_task_id(ext.repo, parent_external_id).await?,
838 None => None,
839 };
840 let row = sqlx::query(
841 "INSERT INTO tasks (external_id, repo, origin, title, body, author, url, labels, parent_id)
842 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
843 ON CONFLICT(repo, external_id) DO UPDATE SET
844 title = excluded.title,
845 body = excluded.body,
846 labels = excluded.labels,
847 url = excluded.url,
848 parent_id = COALESCE(excluded.parent_id, tasks.parent_id)
849 RETURNING id",
850 )
851 .bind(ext.ext_id)
852 .bind(ext.repo)
853 .bind(ext.origin)
854 .bind(ext.title)
855 .bind(ext.body)
856 .bind(ext.author)
857 .bind(ext.url)
858 .bind(&labels_json)
859 .bind(parent_id)
860 .fetch_one(&self.pool)
861 .await?;
862
863 row.try_get("id")
864 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
865 }
866
867 pub async fn update_status_and_fields(
872 &self,
873 id: i64,
874 status: TaskStatus,
875 updates: &[(&str, serde_json::Value)],
876 ) -> anyhow::Result<()> {
877 for (col, _) in updates {
878 anyhow::ensure!(
879 ALLOWED_FIELDS.contains(col),
880 "column {col} is not in the update allowlist"
881 );
882 }
883
884 let mut tx = self.pool.begin().await?;
885 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
886 .bind(id)
887 .fetch_one(&mut *tx)
888 .await?;
889 let from_status: String = previous
890 .try_get("status")
891 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
892 let agent: Option<String> = previous
893 .try_get("agent")
894 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
895 let model: Option<String> = previous
896 .try_get("model")
897 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
898
899 let mut set_parts = Vec::new();
900 let mut values: Vec<Option<String>> = Vec::new();
901
902 set_parts.push("status = ?".to_string());
903 values.push(Some(status.as_str().to_string()));
904
905 let mut block_reason_in_updates = false;
906 for (col, val) in updates {
907 if *col == "block_reason" {
908 block_reason_in_updates = true;
909 }
910 set_parts.push(format!("{col} = ?"));
911 match val {
912 serde_json::Value::String(s) => values.push(Some(s.clone())),
913 serde_json::Value::Number(n) => values.push(Some(n.to_string())),
914 serde_json::Value::Bool(b) => {
915 values.push(Some(if *b { "1" } else { "0" }.to_string()));
916 }
917 serde_json::Value::Null => values.push(None),
918 other => values.push(Some(serde_json::to_string(other)?)),
919 }
920 }
921
922 if !block_reason_in_updates && status != TaskStatus::Blocked {
923 set_parts.push("block_reason = NULL".to_string());
924 }
925
926 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
927 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
928
929 let mut query = sqlx::query(&sql);
930 for v in &values {
931 query = query.bind(v.as_deref());
932 }
933 query = query.bind(id);
934 query.execute(&mut *tx).await?;
935
936 Self::append_activity_tx(
937 &mut tx,
938 id,
939 "status_change",
940 Some(from_status.as_str()),
941 Some(status.as_str()),
942 agent.as_deref(),
943 model.as_deref(),
944 None,
945 )
946 .await?;
947 tx.commit().await?;
948 Ok(())
949 }
950
951 pub async fn update_status(&self, id: i64, status: TaskStatus) -> anyhow::Result<()> {
953 let mut tx = self.pool.begin().await?;
954 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
955 .bind(id)
956 .fetch_one(&mut *tx)
957 .await?;
958 let from_status: String = previous
959 .try_get("status")
960 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
961 let agent: Option<String> = previous
962 .try_get("agent")
963 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
964 let model: Option<String> = previous
965 .try_get("model")
966 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
967 let sql = if status == TaskStatus::Blocked {
968 "UPDATE tasks SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
969 } else {
970 "UPDATE tasks SET status = ?, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
971 };
972 sqlx::query(sql)
973 .bind(status.as_str())
974 .bind(id)
975 .execute(&mut *tx)
976 .await?;
977
978 Self::append_activity_tx(
979 &mut tx,
980 id,
981 "status_change",
982 Some(from_status.as_str()),
983 Some(status.as_str()),
984 agent.as_deref(),
985 model.as_deref(),
986 None,
987 )
988 .await?;
989 tx.commit().await?;
990 Ok(())
991 }
992
993 pub async fn update_external_id(&self, id: i64, external_id: &str) -> anyhow::Result<()> {
995 sqlx::query(
996 "UPDATE tasks SET external_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
997 )
998 .bind(external_id)
999 .bind(id)
1000 .execute(&self.pool)
1001 .await?;
1002 Ok(())
1003 }
1004
1005 pub async fn update_status_if(
1010 &self,
1011 id: i64,
1012 status: TaskStatus,
1013 expected: TaskStatus,
1014 ) -> anyhow::Result<bool> {
1015 let mut tx = self.pool.begin().await?;
1016 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
1017 .bind(id)
1018 .fetch_one(&mut *tx)
1019 .await?;
1020 let from_status: String = previous
1021 .try_get("status")
1022 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
1023 let agent: Option<String> = previous
1024 .try_get("agent")
1025 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
1026 let model: Option<String> = previous
1027 .try_get("model")
1028 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
1029 let sql = if status == TaskStatus::Blocked {
1030 "UPDATE tasks SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND status = ?"
1031 } else {
1032 "UPDATE tasks SET status = ?, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND status = ?"
1033 };
1034 let result = sqlx::query(sql)
1035 .bind(status.as_str())
1036 .bind(id)
1037 .bind(expected.as_str())
1038 .execute(&mut *tx)
1039 .await?;
1040 if result.rows_affected() == 0 {
1041 return Ok(false);
1042 }
1043 Self::append_activity_tx(
1044 &mut tx,
1045 id,
1046 "status_change",
1047 Some(from_status.as_str()),
1048 Some(status.as_str()),
1049 agent.as_deref(),
1050 model.as_deref(),
1051 None,
1052 )
1053 .await?;
1054 tx.commit().await?;
1055 Ok(true)
1056 }
1057
1058 pub async fn touch_updated_at(&self, id: i64) -> anyhow::Result<()> {
1066 sqlx::query(
1067 "UPDATE tasks SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1068 )
1069 .bind(id)
1070 .execute(&self.pool)
1071 .await?;
1072 Ok(())
1073 }
1074
1075 pub async fn reset_to_new(&self, id: i64) -> anyhow::Result<()> {
1077 let mut tx = self.pool.begin().await?;
1078 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
1079 .bind(id)
1080 .fetch_one(&mut *tx)
1081 .await?;
1082 let from_status: String = previous
1083 .try_get("status")
1084 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
1085 let agent: Option<String> = previous
1086 .try_get("agent")
1087 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
1088 let model: Option<String> = previous
1089 .try_get("model")
1090 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
1091 sqlx::query(
1092 "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 = ?",
1093 )
1094 .bind(id)
1095 .execute(&mut *tx)
1096 .await?;
1097 let details = serde_json::json!({ "op": "reset_to_new" });
1098 Self::append_activity_tx(
1099 &mut tx,
1100 id,
1101 "status_change",
1102 Some(from_status.as_str()),
1103 Some(TaskStatus::New.as_str()),
1104 agent.as_deref(),
1105 model.as_deref(),
1106 Some(&details),
1107 )
1108 .await?;
1109 tx.commit().await?;
1110 Ok(())
1111 }
1112
1113 pub async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()> {
1115 let value = reason
1116 .map(|r| serde_json::Value::String(r.to_string()))
1117 .unwrap_or(serde_json::Value::Null);
1118 self.set_fields(id, &[("block_reason", value)]).await
1119 }
1120
1121 pub async fn list_by_status(
1123 &self,
1124 repo: &str,
1125 status: TaskStatus,
1126 ) -> anyhow::Result<Vec<Task>> {
1127 let rows = sqlx::query(&format!(
1128 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status = ? ORDER BY created_at DESC"
1129 ))
1130 .bind(repo)
1131 .bind(status.as_str())
1132 .fetch_all(&self.pool)
1133 .await?;
1134
1135 rows.iter().map(Self::row_to_task).collect()
1136 }
1137
1138 pub async fn list_children(&self, repo: &str, parent_id: i64) -> anyhow::Result<Vec<Task>> {
1140 let rows = sqlx::query(&format!(
1141 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND parent_id = ? ORDER BY created_at ASC"
1142 ))
1143 .bind(repo)
1144 .bind(parent_id)
1145 .fetch_all(&self.pool)
1146 .await?;
1147
1148 rows.iter().map(Self::row_to_task).collect()
1149 }
1150
1151 pub async fn list_external_by_status(
1153 &self,
1154 repo: &str,
1155 status: TaskStatus,
1156 ) -> anyhow::Result<Vec<Task>> {
1157 let rows = sqlx::query(
1158 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' AND status = ? ORDER BY created_at DESC"),
1159 )
1160 .bind(repo)
1161 .bind(status.as_str())
1162 .fetch_all(&self.pool)
1163 .await?;
1164
1165 rows.iter().map(Self::row_to_task).collect()
1166 }
1167
1168 pub async fn list_routable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1170 self.list_by_status(repo, TaskStatus::New).await
1171 }
1172
1173 pub async fn list_internal_by_status(
1175 &self,
1176 repo: &str,
1177 status: TaskStatus,
1178 ) -> anyhow::Result<Vec<Task>> {
1179 let rows = sqlx::query(
1180 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND status = ? ORDER BY created_at DESC"),
1181 )
1182 .bind(repo)
1183 .bind(status.as_str())
1184 .fetch_all(&self.pool)
1185 .await?;
1186
1187 rows.iter().map(Self::row_to_task).collect()
1188 }
1189
1190 pub async fn list_all_internal(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1192 let rows = sqlx::query(
1193 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' ORDER BY created_at DESC"),
1194 )
1195 .bind(repo)
1196 .fetch_all(&self.pool)
1197 .await?;
1198
1199 rows.iter().map(Self::row_to_task).collect()
1200 }
1201
1202 pub async fn list_internal_by_source(
1205 &self,
1206 repo: &str,
1207 source: &str,
1208 ) -> anyhow::Result<Vec<Task>> {
1209 let rows = sqlx::query(
1210 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? ORDER BY created_at DESC"),
1211 )
1212 .bind(repo)
1213 .bind(source)
1214 .fetch_all(&self.pool)
1215 .await?;
1216
1217 rows.iter().map(Self::row_to_task).collect()
1218 }
1219
1220 pub async fn list_source_ids_by_source(
1225 &self,
1226 repo: &str,
1227 source: &str,
1228 since_days: i64,
1229 ) -> anyhow::Result<Vec<String>> {
1230 let cutoff = format!("-{since_days} days");
1231 let rows = sqlx::query(
1232 "SELECT source_id FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? AND created_at >= datetime('now', ?)",
1233 )
1234 .bind(repo)
1235 .bind(source)
1236 .bind(&cutoff)
1237 .fetch_all(&self.pool)
1238 .await?;
1239
1240 let mut ids = Vec::with_capacity(rows.len());
1241 for row in &rows {
1242 ids.push(row.try_get::<String, _>("source_id").map_err(|e| {
1243 anyhow::anyhow!(
1244 "list_source_ids_by_source: failed to decode source_id for repo={repo}, source={source}: {e}"
1245 )
1246 })?);
1247 }
1248 Ok(ids)
1249 }
1250
1251 pub async fn list_active(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1253 let rows = sqlx::query(
1254 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status != 'done' ORDER BY created_at DESC"),
1255 )
1256 .bind(repo)
1257 .fetch_all(&self.pool)
1258 .await?;
1259
1260 rows.iter().map(Self::row_to_task).collect()
1261 }
1262
1263 pub async fn list_all_external(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1265 let rows = sqlx::query(
1266 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' ORDER BY created_at DESC"),
1267 )
1268 .bind(repo)
1269 .fetch_all(&self.pool)
1270 .await?;
1271
1272 rows.iter().map(Self::row_to_task).collect()
1273 }
1274
1275 pub async fn has_external_tasks(&self, repo: &str) -> anyhow::Result<bool> {
1277 let has_external = sqlx::query_scalar::<_, i32>(
1278 "SELECT 1 FROM tasks WHERE repo = ? AND origin != 'internal' LIMIT 1",
1279 )
1280 .bind(repo)
1281 .fetch_optional(&self.pool)
1282 .await?
1283 .is_some();
1284 Ok(has_external)
1285 }
1286
1287 pub async fn existing_external_ids(
1291 &self,
1292 repo: &str,
1293 ) -> anyhow::Result<std::collections::HashSet<String>> {
1294 let rows: Vec<(String,)> = sqlx::query_as(
1295 "SELECT external_id FROM tasks WHERE repo = ? AND origin != 'internal' AND external_id IS NOT NULL",
1296 )
1297 .bind(repo)
1298 .fetch_all(&self.pool)
1299 .await?;
1300
1301 Ok(rows
1302 .into_iter()
1303 .filter_map(|(id,)| if id.is_empty() { None } else { Some(id) })
1304 .collect())
1305 }
1306
1307 pub async fn list_for_doctor(&self, repo: &str, cutoff_str: &str) -> anyhow::Result<Vec<Task>> {
1310 let rows = sqlx::query(
1311 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND (status != 'done' OR updated_at >= ?) ORDER BY created_at DESC"),
1312 )
1313 .bind(repo)
1314 .bind(cutoff_str)
1315 .fetch_all(&self.pool)
1316 .await?;
1317 rows.iter().map(Self::row_to_task).collect()
1318 }
1319
1320 pub async fn list_all(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1322 let rows = sqlx::query(&format!(
1323 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? ORDER BY created_at DESC"
1324 ))
1325 .bind(repo)
1326 .fetch_all(&self.pool)
1327 .await?;
1328 rows.iter().map(Self::row_to_task).collect()
1329 }
1330
1331 pub async fn distinct_repos(&self) -> anyhow::Result<Vec<String>> {
1333 let rows =
1334 sqlx::query("SELECT DISTINCT repo FROM tasks WHERE repo IS NOT NULL AND repo != ''")
1335 .fetch_all(&self.pool)
1336 .await?;
1337 let mut repos = Vec::new();
1338 for row in &rows {
1339 repos.push(row.try_get::<String, _>("repo").map_err(|e| {
1340 anyhow::anyhow!("distinct_repos: failed to decode 'repo' column: {e}")
1341 })?);
1342 }
1343 Ok(repos)
1344 }
1345
1346 pub async fn list_all_active_global(&self) -> anyhow::Result<Vec<Task>> {
1351 let rows = sqlx::query(&format!(
1352 "SELECT {TASK_COLS} FROM tasks WHERE status != 'done' ORDER BY created_at DESC"
1353 ))
1354 .fetch_all(&self.pool)
1355 .await?;
1356 rows.iter().map(Self::row_to_task).collect()
1357 }
1358
1359 pub async fn list_all_global(&self) -> anyhow::Result<Vec<Task>> {
1363 let rows = sqlx::query(&format!(
1364 "SELECT {TASK_COLS} FROM tasks ORDER BY created_at DESC"
1365 ))
1366 .fetch_all(&self.pool)
1367 .await?;
1368 rows.iter().map(Self::row_to_task).collect()
1369 }
1370
1371 pub async fn list_all_by_status_global(&self, status: TaskStatus) -> anyhow::Result<Vec<Task>> {
1375 let rows = sqlx::query(&format!(
1376 "SELECT {TASK_COLS} FROM tasks WHERE status = ? ORDER BY created_at DESC"
1377 ))
1378 .bind(status.as_str())
1379 .fetch_all(&self.pool)
1380 .await?;
1381 rows.iter().map(Self::row_to_task).collect()
1382 }
1383
1384 pub async fn set_fields(
1387 &self,
1388 id: i64,
1389 updates: &[(&str, serde_json::Value)],
1390 ) -> anyhow::Result<()> {
1391 if updates.is_empty() {
1392 return Ok(());
1393 }
1394
1395 for (col, _) in updates {
1396 anyhow::ensure!(
1397 ALLOWED_FIELDS.contains(col),
1398 "column {col} is not in the update allowlist"
1399 );
1400 }
1401
1402 let mut set_parts = Vec::new();
1404 let mut values: Vec<Option<String>> = Vec::new();
1405
1406 for (col, val) in updates {
1407 set_parts.push(format!("{col} = ?"));
1408 values.push(sql_value_for_task_field(col, val)?);
1409 }
1410
1411 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1412 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1413
1414 let mut query = sqlx::query(&sql);
1415 for v in &values {
1416 query = query.bind(v.as_deref());
1417 }
1418 query = query.bind(id);
1419 query.execute(&self.pool).await?;
1420
1421 Ok(())
1422 }
1423
1424 pub async fn set_fields_silent(
1429 &self,
1430 id: i64,
1431 updates: &[(&str, serde_json::Value)],
1432 ) -> anyhow::Result<()> {
1433 if updates.is_empty() {
1434 return Ok(());
1435 }
1436
1437 for (col, _) in updates {
1438 anyhow::ensure!(
1439 ALLOWED_FIELDS.contains(col),
1440 "column {col} is not in the update allowlist"
1441 );
1442 }
1443
1444 let mut set_parts = Vec::new();
1445 let mut values: Vec<Option<String>> = Vec::new();
1446
1447 for (col, val) in updates {
1448 set_parts.push(format!("{col} = ?"));
1449 values.push(sql_value_for_task_field(col, val)?);
1450 }
1451
1452 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1453
1454 let mut query = sqlx::query(&sql);
1455 for v in &values {
1456 query = query.bind(v.as_deref());
1457 }
1458 query = query.bind(id);
1459 query.execute(&self.pool).await?;
1460
1461 Ok(())
1462 }
1463
1464 pub async fn increment(&self, id: i64, field: &str) -> anyhow::Result<i32> {
1466 anyhow::ensure!(
1467 INCREMENTABLE_FIELDS.contains(&field),
1468 "field {field} is not incrementable"
1469 );
1470
1471 let sql = format!(
1472 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? RETURNING {field} AS val"
1473 );
1474
1475 let row = sqlx::query(&sql).bind(id).fetch_one(&self.pool).await?;
1476
1477 Ok(row.try_get("val")?)
1478 }
1479
1480 pub async fn reset_counters(&self, id: i64) -> anyhow::Result<()> {
1482 sqlx::query(
1483 "UPDATE tasks SET
1484 attempts = 0,
1485 route_attempts = 0,
1486 review_agent_failures = 0,
1487 merge_conflict_retries = 0,
1488 pr_create_failures = 0,
1489 push_failures = 0,
1490 network_retries = 0,
1491 ci_merge_failures = 0,
1492 review_cycles = 0,
1493 review_invocations = 0,
1494 review_session_expected = 0,
1495 auto_unblock_count = 0,
1496 auto_unblock_last_at = '',
1497 auto_unblock_last_reason = '',
1498 ci_recovery_count = 0,
1499 no_code_reroutes = 0,
1500 no_code_last_agent = '',
1501 needs_review_refires = 0,
1502 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1503 WHERE id = ?",
1504 )
1505 .bind(id)
1506 .execute(&self.pool)
1507 .await?;
1508 Ok(())
1509 }
1510
1511 pub async fn reset_failure_counters(&self, id: i64) -> anyhow::Result<()> {
1526 sqlx::query(
1527 "UPDATE tasks SET
1528 route_attempts = 0,
1529 review_agent_failures = 0,
1530 pr_create_failures = 0,
1531 push_failures = 0,
1532 network_retries = 0,
1533 review_session_expected = 0,
1534 auto_unblock_count = 0,
1535 auto_unblock_last_at = '',
1536 auto_unblock_last_reason = '',
1537 ci_recovery_count = 0,
1538 no_code_reroutes = 0,
1539 no_code_last_agent = '',
1540 needs_review_refires = 0,
1541 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1542 WHERE id = ?",
1543 )
1544 .bind(id)
1545 .execute(&self.pool)
1546 .await?;
1547 Ok(())
1548 }
1549
1550 pub async fn store_route(&self, route: &StoreRoute<'_>) -> anyhow::Result<()> {
1556 sqlx::query(
1557 "UPDATE tasks SET
1558 agent = ?, model = ?, complexity = ?, estimate = ?, route_reason = ?,
1559 agent_profile = ?, selected_skills = ?,
1560 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1561 WHERE id = ?",
1562 )
1563 .bind(route.agent)
1564 .bind(route.model)
1565 .bind(route.complexity)
1566 .bind(route.estimate as i64)
1567 .bind(route.reason)
1568 .bind(route.profile)
1569 .bind(route.skills)
1570 .bind(route.id)
1571 .execute(&self.pool)
1572 .await?;
1573 let details = serde_json::json!({
1574 "complexity": route.complexity,
1575 "estimate": route.estimate,
1576 "reason": route.reason,
1577 "skills": route.skills,
1578 });
1579 self.append_activity(
1580 route.id,
1581 "dispatch",
1582 None,
1583 None,
1584 Some(route.agent),
1585 route.model,
1586 Some(&details),
1587 )
1588 .await?;
1589 Ok(())
1590 }
1591
1592 pub async fn list_external_tasks_with_estimates(&self) -> anyhow::Result<Vec<(String, u8)>> {
1600 let rows = sqlx::query(
1601 "SELECT external_id, estimate FROM tasks \
1602 WHERE estimate > 0 AND external_id NOT LIKE 'internal:%' \
1603 AND status NOT IN ('done', 'blocked')",
1604 )
1605 .fetch_all(&self.pool)
1606 .await?;
1607
1608 let mut result = Vec::new();
1609 for row in &rows {
1610 let external_id: String = row.try_get("external_id")?;
1611 let estimate_raw: i64 = row.try_get("estimate")?;
1612 if (1..=255).contains(&estimate_raw) {
1613 result.push((external_id, estimate_raw as u8));
1614 }
1615 }
1616 Ok(result)
1617 }
1618
1619 pub async fn set_estimate_if_zero(&self, id: i64, estimate: u8) -> anyhow::Result<bool> {
1625 let result = sqlx::query("UPDATE tasks SET estimate = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND estimate = 0")
1626 .bind(estimate as i64)
1627 .bind(id)
1628 .execute(&self.pool)
1629 .await?;
1630
1631 Ok(result.rows_affected() > 0)
1632 }
1633
1634 pub async fn append_memory(&self, id: i64, entry: &MemoryEntry) -> anyhow::Result<()> {
1643 let mut tx = self.pool.begin().await?;
1644
1645 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1646 .bind(id)
1647 .fetch_one(&mut *tx)
1648 .await?;
1649
1650 let memory_opt: Option<String> = row
1655 .try_get::<Option<String>, _>("memory")
1656 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1657 let memory_str = memory_opt.unwrap_or_default();
1658
1659 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1660 Vec::new()
1661 } else {
1662 serde_json::from_str(&memory_str)
1663 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1664 };
1665 memory.push(entry.clone());
1666 let new_json = serde_json::to_string(&memory)?;
1667
1668 sqlx::query(
1669 "UPDATE tasks SET memory = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1670 )
1671 .bind(&new_json)
1672 .bind(id)
1673 .execute(&mut *tx)
1674 .await?;
1675
1676 tx.commit().await?;
1677 Ok(())
1678 }
1679
1680 pub async fn recent_memory(&self, id: i64, max: usize) -> anyhow::Result<Vec<MemoryEntry>> {
1682 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1683 .bind(id)
1684 .fetch_one(&self.pool)
1685 .await?;
1686
1687 let memory_opt: Option<String> = row
1688 .try_get::<Option<String>, _>("memory")
1689 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1690 let memory_str = memory_opt.unwrap_or_default();
1691
1692 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1693 Vec::new()
1694 } else {
1695 serde_json::from_str(&memory_str)
1696 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1697 };
1698
1699 memory.sort_by_key(|m| m.attempt);
1700 if memory.len() > max {
1701 memory = memory.split_off(memory.len() - max);
1702 }
1703 Ok(memory)
1704 }
1705
1706 pub async fn store_tokens(
1712 &self,
1713 id: i64,
1714 input: u64,
1715 output: u64,
1716 model: &str,
1717 ) -> anyhow::Result<()> {
1718 let pricing = pricing_for_model(model);
1719 let usage = TokenUsage {
1720 input_tokens: input,
1721 output_tokens: output,
1722 };
1723 let cost = pricing.estimate_cost_usd(usage);
1724
1725 sqlx::query(
1726 "UPDATE tasks SET
1727 input_tokens = COALESCE(input_tokens, 0) + ?,
1728 output_tokens = COALESCE(output_tokens, 0) + ?,
1729 input_cost_usd = COALESCE(input_cost_usd, 0.0) + ?,
1730 output_cost_usd = COALESCE(output_cost_usd, 0.0) + ?,
1731 total_cost_usd = COALESCE(total_cost_usd, 0.0) + ?,
1732 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1733 WHERE id = ?",
1734 )
1735 .bind(i64::try_from(input).unwrap_or_else(|_| {
1736 tracing::warn!(input, "input_tokens overflows i64, clamping to MAX");
1737 i64::MAX
1738 }))
1739 .bind(i64::try_from(output).unwrap_or_else(|_| {
1740 tracing::warn!(output, "output_tokens overflows i64, clamping to MAX");
1741 i64::MAX
1742 }))
1743 .bind(cost.input_cost_usd)
1744 .bind(cost.output_cost_usd)
1745 .bind(cost.total_cost_usd)
1746 .bind(id)
1747 .execute(&self.pool)
1748 .await?;
1749
1750 Ok(())
1751 }
1752
1753 pub async fn mark_cleaned(&self, id: i64) -> anyhow::Result<()> {
1759 sqlx::query(
1760 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1761 )
1762 .bind(id)
1763 .execute(&self.pool)
1764 .await?;
1765 self.append_activity(
1766 id,
1767 "branch_delete",
1768 None,
1769 None,
1770 None,
1771 None,
1772 Some(&serde_json::json!({ "worktree_cleaned": true })),
1773 )
1774 .await?;
1775 Ok(())
1776 }
1777
1778 #[cfg(test)]
1784 pub async fn batch_set_fields(
1785 &self,
1786 updates: &[(i64, &[(&str, serde_json::Value)])],
1787 ) -> anyhow::Result<()> {
1788 if updates.is_empty() {
1789 return Ok(());
1790 }
1791
1792 for (_, entry_updates) in updates {
1793 for (col, _) in *entry_updates {
1794 anyhow::ensure!(
1795 ALLOWED_FIELDS.contains(col),
1796 "column {col} is not in the update allowlist"
1797 );
1798 }
1799 }
1800
1801 let mut tx = self.pool.begin().await?;
1802 for (id, entry_updates) in updates {
1803 if entry_updates.is_empty() {
1804 continue;
1805 }
1806 let mut set_parts = Vec::new();
1807 let mut values: Vec<Option<String>> = Vec::new();
1808 for (col, val) in *entry_updates {
1809 set_parts.push(format!("{col} = ?"));
1810 values.push(sql_value_for_task_field(col, val)?);
1811 }
1812 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1813 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1814 let mut query = sqlx::query(&sql);
1815 for v in &values {
1816 query = query.bind(v.as_deref());
1817 }
1818 query = query.bind(*id);
1819 query.execute(&mut *tx).await?;
1820 }
1821 tx.commit().await?;
1822 Ok(())
1823 }
1824
1825 #[cfg(test)]
1830 pub async fn batch_increment(&self, entries: &[(i64, &str)]) -> anyhow::Result<()> {
1831 if entries.is_empty() {
1832 return Ok(());
1833 }
1834
1835 for (_, field) in entries {
1836 anyhow::ensure!(
1837 INCREMENTABLE_FIELDS.contains(field),
1838 "field {field} is not incrementable"
1839 );
1840 }
1841
1842 let mut tx = self.pool.begin().await?;
1843 for (id, field) in entries {
1844 let sql = format!(
1845 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
1846 );
1847 sqlx::query(&sql).bind(*id).execute(&mut *tx).await?;
1848 }
1849 tx.commit().await?;
1850 Ok(())
1851 }
1852
1853 #[cfg(test)]
1860 pub async fn batch_reset_failure_counters(&self, ids: &[i64]) -> anyhow::Result<()> {
1861 if ids.is_empty() {
1862 return Ok(());
1863 }
1864 const CHUNK_SIZE: usize = 500;
1867
1868 let mut tx = self.pool.begin().await?;
1869
1870 for chunk in ids.chunks(CHUNK_SIZE) {
1871 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1873 let sql = format!(
1874 "UPDATE tasks SET
1875 route_attempts = 0,
1876 review_agent_failures = 0,
1877 review_invocations = 0,
1878 pr_create_failures = 0,
1879 push_failures = 0,
1880 network_retries = 0,
1881 review_session_expected = 0,
1882 auto_unblock_count = 0,
1883 auto_unblock_last_at = '',
1884 auto_unblock_last_reason = '',
1885 ci_recovery_count = 0,
1886 no_code_reroutes = 0,
1887 needs_review_refires = 0,
1888 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1889 WHERE id IN ({placeholders})",
1890 );
1891 let mut query = sqlx::query(&sql);
1892 for id in chunk {
1893 query = query.bind(*id);
1894 }
1895 query.execute(&mut *tx).await?;
1896 }
1897
1898 tx.commit().await?;
1899 Ok(())
1900 }
1901
1902 #[cfg(test)]
1907 pub async fn batch_mark_cleaned(&self, ids: &[i64]) -> anyhow::Result<()> {
1908 if ids.is_empty() {
1909 return Ok(());
1910 }
1911
1912 const CHUNK_SIZE: usize = 500;
1914
1915 let mut tx = self.pool.begin().await?;
1916
1917 for chunk in ids.chunks(CHUNK_SIZE) {
1918 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1919 let sql = format!(
1920 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id IN ({placeholders})",
1921 );
1922 let mut query = sqlx::query(&sql);
1923 for id in chunk {
1924 query = query.bind(*id);
1925 }
1926 query.execute(&mut *tx).await?;
1927 }
1928
1929 let details = serde_json::json!({ "worktree_cleaned": true });
1933 let details_json = serde_json::to_string(&details)?;
1934 for id in ids {
1935 sqlx::query(
1936 "INSERT INTO task_activity (task_id, event_type, from_status, to_status, agent, model, details) VALUES (?, ?, ?, ?, ?, ?, ?)",
1937 )
1938 .bind(*id)
1939 .bind("branch_delete")
1940 .bind(None::<&str>)
1941 .bind(None::<&str>)
1942 .bind(None::<&str>)
1943 .bind(None::<&str>)
1944 .bind(details_json.as_str())
1945 .execute(&mut *tx)
1946 .await?;
1947 }
1948
1949 tx.commit().await?;
1950 Ok(())
1951 }
1952
1953 #[cfg(test)]
1956 pub async fn list_cleanable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1957 let rows = sqlx::query(&format!(
1958 "SELECT {TASK_COLS} FROM tasks
1959 WHERE repo = ?
1960 AND worktree != ''
1961 AND worktree_cleaned = 0
1962 AND status IN ('done', 'blocked')
1963 ORDER BY updated_at ASC"
1964 ))
1965 .bind(repo)
1966 .fetch_all(&self.pool)
1967 .await?;
1968
1969 rows.iter().map(Self::row_to_task).collect()
1970 }
1971
1972 pub async fn cleaned_external_ids(
1977 &self,
1978 repo: &str,
1979 ) -> anyhow::Result<std::collections::HashSet<String>> {
1980 let rows: Vec<(String,)> =
1981 sqlx::query_as("SELECT external_id FROM tasks WHERE repo = ? AND worktree_cleaned = 1 AND external_id IS NOT NULL")
1982 .bind(repo)
1983 .fetch_all(&self.pool)
1984 .await?;
1985
1986 Ok(rows.into_iter().map(|(id,)| id).collect())
1987 }
1988
1989 pub async fn start_run(&self, run: &StartRun<'_>) -> anyhow::Result<i64> {
1995 let orphaned: Vec<(i64,)> = sqlx::query_as(
2007 "SELECT id FROM task_runs
2008 WHERE task_id = ? AND run_type = ? AND attempt != ?
2009 AND outcome IS NULL AND completed_at IS NULL",
2010 )
2011 .bind(run.task_id)
2012 .bind(run.run_type)
2013 .bind(run.attempt)
2014 .fetch_all(&self.pool)
2015 .await?;
2016
2017 for (run_id,) in orphaned {
2018 self.complete_run(&CompleteRun {
2019 run_id,
2020 exit_code: Some(-1),
2021 stdout: "",
2022 stderr: "",
2023 parsed: "",
2024 outcome: "aborted",
2025 error: "superseded by a new run start before this one completed",
2026 tokens: RunTokenUsage::default(),
2027 })
2028 .await?;
2029 }
2030
2031 let row = sqlx::query(
2032 "INSERT INTO task_runs (task_id, attempt, run_type, agent, model, command, prompt, outcome)
2033 VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
2034 ON CONFLICT(task_id, attempt, run_type) DO UPDATE SET
2035 agent = excluded.agent, model = excluded.model,
2036 command = excluded.command, prompt = excluded.prompt,
2037 outcome = NULL,
2038 started_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
2039 completed_at = NULL, error = NULL, exit_code = NULL,
2040 stdout = NULL, stderr = NULL, parsed_response = NULL,
2041 duration_secs = NULL, input_tokens = NULL, output_tokens = NULL,
2042 total_cost_usd = NULL
2043 RETURNING id",
2044 )
2045 .bind(run.task_id)
2046 .bind(run.attempt)
2047 .bind(run.run_type)
2048 .bind(run.agent)
2049 .bind(run.model)
2050 .bind(run.command)
2051 .bind(run.prompt)
2052 .fetch_one(&self.pool)
2053 .await?;
2054
2055 let run_id: i64 = row
2056 .try_get("id")
2057 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
2058 let event_type = match run.run_type {
2059 "review" => "review_start",
2060 _ => "dispatch",
2061 };
2062 let details = serde_json::json!({
2063 "run_type": run.run_type,
2064 "attempt": run.attempt,
2065 "command": run.command,
2066 });
2067 self.append_activity(
2068 run.task_id,
2069 event_type,
2070 None,
2071 None,
2072 Some(run.agent),
2073 Some(run.model),
2074 Some(&details),
2075 )
2076 .await?;
2077
2078 Ok(run_id)
2079 }
2080
2081 pub async fn complete_run(&self, run: &CompleteRun<'_>) -> anyhow::Result<()> {
2083 let run_ctx =
2084 sqlx::query("SELECT task_id, run_type, agent, model FROM task_runs WHERE id = ?")
2085 .bind(run.run_id)
2086 .fetch_optional(&self.pool)
2087 .await?;
2088 sqlx::query(
2089 "UPDATE task_runs SET
2090 exit_code = ?, stdout = ?, stderr = ?, parsed_response = ?,
2091 outcome = ?, error = NULLIF(?, ''),
2092 input_tokens = ?, output_tokens = ?, total_cost_usd = ?,
2093 duration_secs = ?,
2094 completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
2095 WHERE id = ?",
2096 )
2097 .bind(run.exit_code)
2098 .bind(run.stdout)
2099 .bind(security::redact(run.stderr))
2100 .bind(run.parsed)
2101 .bind(run.outcome)
2102 .bind(security::redact(run.error))
2103 .bind(i64::try_from(run.tokens.input_tokens).unwrap_or_else(|_| {
2104 tracing::warn!(
2105 run.tokens.input_tokens,
2106 "input_tokens overflows i64, clamping to MAX"
2107 );
2108 i64::MAX
2109 }))
2110 .bind(i64::try_from(run.tokens.output_tokens).unwrap_or_else(|_| {
2111 tracing::warn!(
2112 run.tokens.output_tokens,
2113 "output_tokens overflows i64, clamping to MAX"
2114 );
2115 i64::MAX
2116 }))
2117 .bind(run.tokens.total_cost_usd)
2118 .bind(run.tokens.duration_secs)
2119 .bind(run.run_id)
2120 .execute(&self.pool)
2121 .await?;
2122 if let Some(run_ctx) = run_ctx {
2123 let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
2124 anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
2125 })?;
2126 let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
2127 anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
2128 })?;
2129 let agent: String = run_ctx.try_get("agent").map_err(|e| {
2130 anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
2131 })?;
2132 let model: String = run_ctx.try_get("model").map_err(|e| {
2133 anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
2134 })?;
2135 if run.outcome == "timeout" {
2136 self.append_activity(
2137 task_id,
2138 "timeout",
2139 None,
2140 None,
2141 Some(&agent),
2142 Some(&model),
2143 Some(&serde_json::json!({
2144 "run_type": run_type,
2145 "error": run.error,
2146 "stderr": run.stderr,
2147 })),
2148 )
2149 .await?;
2150 } else if run.outcome != "success" {
2151 self.append_activity(
2152 task_id,
2153 "error",
2154 None,
2155 None,
2156 Some(&agent),
2157 Some(&model),
2158 Some(&serde_json::json!({
2159 "run_type": run_type,
2160 "outcome": run.outcome,
2161 "error": run.error,
2162 })),
2163 )
2164 .await?;
2165 }
2166 } else if run.outcome != "success" {
2167 tracing::warn!(
2168 run_id = run.run_id,
2169 outcome = run.outcome,
2170 "run_id not found in task_runs; skipping timeout/error activity log"
2171 );
2172 }
2173 Ok(())
2174 }
2175
2176 pub async fn finalize_incomplete_runs(
2181 &self,
2182 task_id: i64,
2183 outcome: &str,
2184 error: &str,
2185 ) -> anyhow::Result<u64> {
2186 let rows: Vec<(i64,)> = sqlx::query_as(
2187 "SELECT id FROM task_runs
2188 WHERE task_id = ?
2189 AND outcome IS NULL
2190 AND completed_at IS NULL
2191 ORDER BY id ASC",
2192 )
2193 .bind(task_id)
2194 .fetch_all(&self.pool)
2195 .await?;
2196
2197 for (run_id,) in &rows {
2198 self.complete_run(&CompleteRun {
2199 run_id: *run_id,
2200 exit_code: Some(-1),
2201 stdout: "",
2202 stderr: "",
2203 parsed: "",
2204 outcome,
2205 error,
2206 tokens: RunTokenUsage::default(),
2207 })
2208 .await?;
2209 }
2210
2211 Ok(rows.len() as u64)
2212 }
2213
2214 pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
2216 let rows = sqlx::query(&format!(
2217 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
2218 ))
2219 .bind(task_id)
2220 .fetch_all(&self.pool)
2221 .await?;
2222
2223 rows.iter().map(Self::row_to_run).collect()
2224 }
2225
2226 pub async fn get_runs_batch(
2229 &self,
2230 task_ids: &[i64],
2231 ) -> anyhow::Result<std::collections::HashMap<i64, Vec<TaskRun>>> {
2232 if task_ids.is_empty() {
2233 return Ok(std::collections::HashMap::new());
2234 }
2235 const CHUNK_SIZE: usize = 500;
2238
2239 let mut result: std::collections::HashMap<i64, Vec<TaskRun>> =
2240 std::collections::HashMap::new();
2241
2242 for chunk in task_ids.chunks(CHUNK_SIZE) {
2243 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2244 let sql = format!(
2245 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
2246 );
2247 let mut query = sqlx::query(&sql);
2248 for id in chunk {
2249 query = query.bind(*id);
2250 }
2251 let rows = query.fetch_all(&self.pool).await?;
2252 for row in &rows {
2253 let run = Self::row_to_run(row)?;
2254 result.entry(run.task_id).or_default().push(run);
2255 }
2256 }
2257 Ok(result)
2258 }
2259
2260 pub async fn get_batch(
2263 &self,
2264 ids: &[i64],
2265 ) -> anyhow::Result<std::collections::HashMap<i64, Task>> {
2266 if ids.is_empty() {
2267 return Ok(std::collections::HashMap::new());
2268 }
2269 const CHUNK_SIZE: usize = 500;
2272
2273 let mut result = std::collections::HashMap::new();
2274
2275 for chunk in ids.chunks(CHUNK_SIZE) {
2276 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2277 let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
2278 let mut query = sqlx::query(&sql);
2279 for id in chunk {
2280 query = query.bind(*id);
2281 }
2282 let rows = query.fetch_all(&self.pool).await?;
2283 for row in &rows {
2284 let task = Self::row_to_task(row)?;
2285 result.insert(task.id, task);
2286 }
2287 }
2288 Ok(result)
2289 }
2290
2291 pub async fn get_statuses_by_external_ids(
2296 &self,
2297 repo: &str,
2298 ext_ids: &[&str],
2299 ) -> anyhow::Result<std::collections::HashMap<String, TaskStatus>> {
2300 let mut result = std::collections::HashMap::new();
2301 if ext_ids.is_empty() {
2302 return Ok(result);
2303 }
2304
2305 const CHUNK_SIZE: usize = 500;
2306 for chunk in ext_ids.chunks(CHUNK_SIZE) {
2307 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2308 let sql = format!(
2309 "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2310 );
2311 let mut query = sqlx::query(&sql);
2312 query = query.bind(repo);
2313 for id in chunk {
2314 query = query.bind(id);
2315 }
2316 let rows = query.fetch_all(&self.pool).await?;
2317 for row in &rows {
2318 let external_id: Result<String, _> = row.try_get("external_id");
2319 let status_str: Result<String, _> = row.try_get("status");
2320 match (external_id, status_str) {
2321 (Ok(ext_id), Ok(sts)) => {
2322 if ext_id.is_empty() {
2323 tracing::warn!(status = %sts, "skipping task row with empty external_id");
2324 continue;
2325 }
2326 if let Some(status) = TaskStatus::from_str(&sts) {
2327 result.insert(ext_id, status);
2328 } else {
2329 tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2330 }
2331 }
2332 (Err(e), _) => {
2333 tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2334 }
2335 (_, Err(e)) => {
2336 tracing::warn!(error = %e, "skipping task row with decode error for status");
2337 }
2338 }
2339 }
2340 }
2341
2342 Ok(result)
2343 }
2344
2345 #[cfg(test)]
2348 pub async fn get_last_run(
2349 &self,
2350 task_id: i64,
2351 run_type: &str,
2352 ) -> anyhow::Result<Option<TaskRun>> {
2353 let row = sqlx::query(&format!(
2354 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2355 ))
2356 .bind(task_id)
2357 .bind(run_type)
2358 .fetch_optional(&self.pool)
2359 .await?;
2360
2361 match row {
2362 Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2363 None => Ok(None),
2364 }
2365 }
2366
2367 pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2371 let rows: Vec<(String,)> = sqlx::query_as(
2372 "SELECT DISTINCT agent FROM task_runs \
2373 WHERE task_id = ? AND run_type = 'review' \
2374 ORDER BY agent",
2375 )
2376 .bind(task_id)
2377 .fetch_all(&self.pool)
2378 .await?;
2379 Ok(rows.into_iter().map(|(a,)| a).collect())
2380 }
2381
2382 #[cfg(test)]
2385 pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2386 let result = sqlx::query(
2387 "DELETE FROM task_runs WHERE task_id IN (
2388 SELECT id FROM tasks
2389 WHERE status IN ('done', 'blocked')
2390 AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2391 )",
2392 )
2393 .bind(format!("-{days}"))
2394 .execute(&self.pool)
2395 .await?;
2396
2397 Ok(result.rows_affected())
2398 }
2399
2400 fn row_to_task(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Task> {
2405 let col_count = row.len();
2408 if col_count != TASK_COLS_COUNT {
2409 tracing::warn!(
2410 expected = TASK_COLS_COUNT,
2411 actual = col_count,
2412 "TASK_COLS column count mismatch - schema may have changed"
2413 );
2414 }
2415
2416 let status_str: String = row
2417 .try_get("status")
2418 .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2419 let labels_str: String = row.try_get("labels").unwrap_or_default();
2420 let memory_str: String = row.try_get("memory").unwrap_or_default();
2421 let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2422
2423 Ok(Task {
2424 id: row
2425 .try_get("id")
2426 .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2427 external_id: row.try_get("external_id").unwrap_or(None),
2428 repo: row
2429 .try_get("repo")
2430 .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2431 origin: row
2432 .try_get("origin")
2433 .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2434 title: row
2435 .try_get("title")
2436 .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2437 body: row
2438 .try_get("body")
2439 .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2440 status: TaskStatus::from_str(&status_str)
2441 .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2442 source: row
2443 .try_get("source")
2444 .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2445 source_id: row
2446 .try_get("source_id")
2447 .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2448 author: row
2449 .try_get("author")
2450 .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2451 url: row
2452 .try_get("url")
2453 .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2454 labels: serde_json::from_str(&labels_str)
2455 .inspect_err(
2456 |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2457 )
2458 .unwrap_or_default(),
2459 agent: row.try_get("agent").unwrap_or(None),
2460 model: row.try_get("model").unwrap_or(None),
2461 complexity: row.try_get("complexity").unwrap_or_default(),
2462 estimate: {
2463 let raw: i32 = row.try_get("estimate").unwrap_or(0);
2464 u8::try_from(raw).unwrap_or_else(|_| {
2465 tracing::warn!(
2466 raw_estimate = raw,
2467 "estimate value out of u8 range; defaulting to 0"
2468 );
2469 0
2470 })
2471 },
2472 route_reason: row.try_get("route_reason").unwrap_or_default(),
2473 agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2474 selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2475 route_attempts: row.try_get("route_attempts").unwrap_or(0),
2476 attempts: row.try_get("attempts").unwrap_or(0),
2477 branch: row.try_get("branch").unwrap_or_default(),
2478 worktree: row.try_get("worktree").unwrap_or_default(),
2479 worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2480 summary: row.try_get("summary").unwrap_or_default(),
2481 last_error: row.try_get("last_error").unwrap_or_default(),
2482 parent_id: row.try_get("parent_id").unwrap_or(None),
2483 block_reason: row.try_get("block_reason").unwrap_or(None),
2484 pr_number: row.try_get("pr_number").unwrap_or(None),
2485 pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2486 last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2487 review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2488 last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2489 merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2490 ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2491 pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2492 push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2493 network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2494 review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2495 review_cycles: row.try_get("review_cycles").unwrap_or(0),
2496 review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2497 review_session_expected: row
2498 .try_get::<i32, _>("review_session_expected")
2499 .unwrap_or(0)
2500 != 0,
2501 needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2502 input_tokens: row.try_get("input_tokens").unwrap_or(0),
2503 output_tokens: row.try_get("output_tokens").unwrap_or(0),
2504 input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2505 output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2506 total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2507 model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2508 limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2509 memory: serde_json::from_str(&memory_str)
2510 .inspect_err(
2511 |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2512 )
2513 .unwrap_or_default(),
2514 delegations: serde_json::from_str(&delegations_str)
2515 .inspect_err(
2516 |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2517 )
2518 .unwrap_or_default(),
2519 auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2524 auto_unblock_last_at: row
2525 .try_get::<String, _>("auto_unblock_last_at")
2526 .unwrap_or_default(),
2527 auto_unblock_last_reason: row
2528 .try_get::<String, _>("auto_unblock_last_reason")
2529 .unwrap_or_default(),
2530 ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2531 no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2532 no_code_last_agent: row
2533 .try_get::<String, _>("no_code_last_agent")
2534 .unwrap_or_default(),
2535 created_at: row
2536 .try_get::<String, _>("created_at")
2537 .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2538 updated_at: row
2539 .try_get::<String, _>("updated_at")
2540 .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2541 })
2542 }
2543 pub async fn ensure_external_task(
2544 &self,
2545 repo: &str,
2546 ext: &crate::backends::ExternalTask,
2547 ) -> anyhow::Result<i64> {
2548 self.upsert_external(&UpsertExternal {
2549 repo,
2550 ext_id: &ext.id.0,
2551 title: &ext.title,
2552 body: &ext.body,
2553 author: &ext.author,
2554 url: &ext.url,
2555 labels: &ext.labels,
2556 origin: "github",
2557 })
2558 .await
2559 }
2560
2561 pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2564 if let Some(suffix) = task_id.strip_prefix("internal:") {
2565 if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2567 return Ok(Some(id));
2568 }
2569 if let Ok(id) = suffix.parse::<i64>() {
2573 let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2574 .bind(id)
2575 .bind(repo)
2576 .fetch_optional(&self.pool)
2577 .await?;
2578 if exists.is_some() {
2579 return Ok(Some(id));
2580 }
2581 }
2582 return Ok(None);
2583 }
2584 self.resolve_id_by_external(repo, task_id).await
2587 }
2588
2589 pub async fn resolve_task_id_globally(
2595 &self,
2596 task_id: &str,
2597 ) -> anyhow::Result<Option<(i64, String)>> {
2598 if let Some(suffix) = task_id.strip_prefix("internal:") {
2599 let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2601 .bind(task_id)
2602 .fetch_optional(&self.pool)
2603 .await?;
2604 if let Some(r) = row {
2605 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2606 }
2607 if let Ok(id) = suffix.parse::<i64>() {
2608 let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2609 .bind(id)
2610 .fetch_optional(&self.pool)
2611 .await?;
2612 if let Some(r) = row {
2613 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2614 }
2615 }
2616 return Ok(None);
2617 }
2618 let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2620 .bind(task_id)
2621 .fetch_all(&self.pool)
2622 .await?;
2623 match rows.len() {
2624 0 => Ok(None),
2625 1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2626 _ => {
2627 let repos: Vec<String> = rows
2628 .iter()
2629 .filter_map(|r| r.try_get::<String, _>("repo").ok())
2630 .collect();
2631 anyhow::bail!(
2632 "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2633 Run from the repo worktree so the current-repo lookup resolves it.",
2634 repos.join(", ")
2635 )
2636 }
2637 }
2638 }
2639
2640 pub async fn resolve_cli_id(
2642 &self,
2643 spec: &str,
2644 current_repo: Option<&str>,
2645 ) -> anyhow::Result<Option<CliTaskRef>> {
2646 let current_repo = current_repo.filter(|repo| !repo.is_empty());
2647 match parse_cli_task_spec(spec)? {
2648 CliTaskSpec::Internal(number) => self.lookup_internal_cli_id(number).await,
2649 CliTaskSpec::Issue { repo, number } => self.lookup_issue_cli_id(&repo, number).await,
2650 CliTaskSpec::Bare(number) => {
2651 if let Some(repo) = current_repo {
2652 if let Some(task) = self.lookup_issue_cli_id(repo, number).await? {
2653 return Ok(Some(task));
2654 }
2655 if let Some(task) = self.lookup_internal_in_repo(number, repo).await? {
2656 return Ok(Some(task));
2657 }
2658 return Ok(None);
2659 }
2660 let mut matches = self.lookup_bare_cli_id_matches(number).await?;
2661 match matches.len() {
2662 0 => Ok(None),
2663 1 => Ok(matches.pop()),
2664 _ => Err(ambiguous_cli_id_error(spec, &matches)),
2665 }
2666 }
2667 }
2668 }
2669
2670 async fn lookup_internal_cli_id(&self, number: i64) -> anyhow::Result<Option<CliTaskRef>> {
2671 let ext = format!("internal:{number}");
2672 let rows = sqlx::query(
2673 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2674 WHERE origin = 'internal' AND (id = ? OR external_id = ?) \
2675 ORDER BY id",
2676 )
2677 .bind(number)
2678 .bind(&ext)
2679 .fetch_all(&self.pool)
2680 .await?;
2681 let mut matches = dedup_cli_refs(rows)?;
2682 match matches.len() {
2683 0 => Ok(None),
2684 1 => Ok(matches.pop()),
2685 _ => Err(ambiguous_cli_id_error(&ext, &matches)),
2686 }
2687 }
2688
2689 async fn lookup_internal_in_repo(
2690 &self,
2691 number: i64,
2692 repo: &str,
2693 ) -> anyhow::Result<Option<CliTaskRef>> {
2694 let row = sqlx::query(
2695 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2696 WHERE origin = 'internal' AND id = ? AND repo = ?",
2697 )
2698 .bind(number)
2699 .bind(repo)
2700 .fetch_optional(&self.pool)
2701 .await?;
2702 match row {
2703 Some(row) => Ok(Some(CliTaskRef::from_row(&row)?)),
2704 None => Ok(None),
2705 }
2706 }
2707
2708 async fn lookup_issue_cli_id(
2709 &self,
2710 repo: &str,
2711 number: i64,
2712 ) -> anyhow::Result<Option<CliTaskRef>> {
2713 let ext = number.to_string();
2714 let rows = sqlx::query(
2715 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2716 WHERE repo = ? AND origin != 'internal' AND external_id = ? \
2717 ORDER BY id",
2718 )
2719 .bind(repo)
2720 .bind(&ext)
2721 .fetch_all(&self.pool)
2722 .await?;
2723 let mut matches = dedup_cli_refs(rows)?;
2724 match matches.len() {
2725 0 => Ok(None),
2726 1 => Ok(matches.pop()),
2727 _ => Err(ambiguous_cli_id_error(
2728 &format!("{repo}#{number}"),
2729 &matches,
2730 )),
2731 }
2732 }
2733
2734 async fn lookup_bare_cli_id_matches(&self, number: i64) -> anyhow::Result<Vec<CliTaskRef>> {
2735 let ext = number.to_string();
2736 let rows = sqlx::query(
2737 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2738 WHERE (origin = 'internal' AND id = ?) \
2739 OR (origin != 'internal' AND external_id = ?) \
2740 ORDER BY repo, id",
2741 )
2742 .bind(number)
2743 .bind(&ext)
2744 .fetch_all(&self.pool)
2745 .await?;
2746 dedup_cli_refs(rows)
2747 }
2748
2749 fn row_to_run(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskRun> {
2750 Ok(TaskRun {
2751 id: row
2752 .try_get("id")
2753 .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2754 task_id: row
2755 .try_get("task_id")
2756 .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2757 attempt: row
2758 .try_get("attempt")
2759 .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2760 run_type: row
2761 .try_get("run_type")
2762 .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2763 agent: row
2764 .try_get("agent")
2765 .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2766 model: row
2767 .try_get("model")
2768 .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2769 command: row
2770 .try_get("command")
2771 .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2772 prompt: row
2773 .try_get("prompt")
2774 .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2775 env_vars: row
2776 .try_get("env_vars")
2777 .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2778 exit_code: row
2779 .try_get("exit_code")
2780 .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2781 stdout: row
2782 .try_get("stdout")
2783 .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2784 stderr: row
2785 .try_get("stderr")
2786 .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2787 parsed_response: row
2788 .try_get("parsed_response")
2789 .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2790 outcome: row
2791 .try_get("outcome")
2792 .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2793 error: row
2794 .try_get("error")
2795 .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2796 input_tokens: row
2797 .try_get("input_tokens")
2798 .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2799 output_tokens: row
2800 .try_get("output_tokens")
2801 .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2802 total_cost_usd: row
2803 .try_get("total_cost_usd")
2804 .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2805 duration_secs: row
2806 .try_get("duration_secs")
2807 .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2808 started_at: row
2809 .try_get("started_at")
2810 .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2811 completed_at: row.try_get("completed_at").unwrap_or(None),
2812 })
2813 }
2814
2815 fn row_to_activity(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskActivity> {
2816 let details_str: String = row
2817 .try_get("details")
2818 .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2819 let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2820 |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2821 )?;
2822 Ok(TaskActivity {
2823 id: row
2824 .try_get("id")
2825 .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2826 task_id: row
2827 .try_get("task_id")
2828 .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2829 timestamp: row
2830 .try_get("timestamp")
2831 .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2832 event_type: row
2833 .try_get("event_type")
2834 .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2835 from_status: row
2836 .try_get("from_status")
2837 .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2838 to_status: row
2839 .try_get("to_status")
2840 .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2841 agent: row
2842 .try_get("agent")
2843 .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2844 model: row
2845 .try_get("model")
2846 .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2847 details,
2848 })
2849 }
2850}
2851
2852#[cfg(test)]
2853mod row_to_task_tests {
2854 use super::*;
2855
2856 async fn store_with_task() -> (crate::store::TaskStore, i64) {
2857 let store = crate::store::TaskStore::open_memory().await.unwrap();
2858 let id = store
2859 .create(&NewTask {
2860 external_id: None,
2861 repo: "owner/repo".to_string(),
2862 origin: "internal".to_string(),
2863 title: "Test task".to_string(),
2864 body: "Test body".to_string(),
2865 source: "cron".to_string(),
2866 source_id: "test-1".to_string(),
2867 author: "tester".to_string(),
2868 url: "https://example.com/1".to_string(),
2869 labels: vec![],
2870 parent_id: None,
2871 })
2872 .await
2873 .unwrap();
2874 (store, id)
2875 }
2876
2877 macro_rules! missing_column_test {
2880 ($name:ident, $col:literal, $select:literal) => {
2881 #[tokio::test]
2882 async fn $name() {
2883 let (store, id) = store_with_task().await;
2884 let sql = format!("{} WHERE id = {id}", $select);
2885 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2886 let err = TaskStore::row_to_task(&row).unwrap_err();
2887 assert!(
2888 err.to_string().contains($col),
2889 "expected '{}' in error message, got: {err}",
2890 $col
2891 );
2892 }
2893 };
2894 }
2895
2896 missing_column_test!(
2897 row_to_task_fails_on_missing_repo,
2898 "repo",
2899 "SELECT id, status FROM tasks"
2900 );
2901 missing_column_test!(
2902 row_to_task_fails_on_missing_origin,
2903 "origin",
2904 "SELECT id, repo, status FROM tasks"
2905 );
2906 missing_column_test!(
2907 row_to_task_fails_on_missing_title,
2908 "title",
2909 "SELECT id, repo, origin, status FROM tasks"
2910 );
2911 missing_column_test!(
2912 row_to_task_fails_on_missing_body,
2913 "body",
2914 "SELECT id, repo, origin, title, status FROM tasks"
2915 );
2916 missing_column_test!(
2917 row_to_task_fails_on_missing_source,
2918 "source",
2919 "SELECT id, repo, origin, title, body, status FROM tasks"
2920 );
2921 missing_column_test!(
2922 row_to_task_fails_on_missing_source_id,
2923 "source_id",
2924 "SELECT id, repo, origin, title, body, source, status FROM tasks"
2925 );
2926 missing_column_test!(
2927 row_to_task_fails_on_missing_author,
2928 "author",
2929 "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
2930 );
2931 missing_column_test!(
2932 row_to_task_fails_on_missing_url,
2933 "url",
2934 "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
2935 );
2936
2937 #[tokio::test]
2944 async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
2945 let (store, id) = store_with_task().await;
2946 let sql = format!(
2949 "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
2950 source, source_id, author, url FROM tasks WHERE id = {id}"
2951 );
2952 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2953 let err = TaskStore::row_to_task(&row).unwrap_err();
2954 assert!(
2955 err.to_string().contains("repo"),
2956 "expected 'repo' in error message, got: {err}"
2957 );
2958 }
2959
2960 #[tokio::test]
2963 async fn distinct_repos_fails_on_decode_error() {
2964 let store = crate::store::TaskStore::open_memory().await.unwrap();
2965 let id = store
2966 .create(&NewTask {
2967 external_id: None,
2968 repo: "owner/repo".to_string(),
2969 origin: "internal".to_string(),
2970 title: "Test task".to_string(),
2971 body: "Test body".to_string(),
2972 source: "cron".to_string(),
2973 source_id: "test-1".to_string(),
2974 author: "tester".to_string(),
2975 url: "https://example.com/1".to_string(),
2976 labels: vec![],
2977 parent_id: None,
2978 })
2979 .await
2980 .unwrap();
2981
2982 let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
2984 sqlx::query(&sql).execute(store.pool()).await.unwrap();
2985
2986 let err = store.distinct_repos().await.unwrap_err();
2987 assert!(
2988 err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
2989 "expected 'distinct_repos' and 'repo' in error message, got: {err}"
2990 );
2991 }
2992
2993 async fn store_with_run() -> (crate::store::TaskStore, i64, i64) {
2996 let store = crate::store::TaskStore::open_memory().await.unwrap();
2997 let task_id = store
2998 .create(&NewTask {
2999 external_id: None,
3000 repo: "owner/repo".to_string(),
3001 origin: "internal".to_string(),
3002 title: "Test task".to_string(),
3003 body: "Test body".to_string(),
3004 source: "cron".to_string(),
3005 source_id: "test-1".to_string(),
3006 author: "tester".to_string(),
3007 url: "https://example.com/1".to_string(),
3008 labels: vec![],
3009 parent_id: None,
3010 })
3011 .await
3012 .unwrap();
3013 let run_id = store
3014 .start_run(&crate::store::StartRun {
3015 task_id,
3016 attempt: 1,
3017 run_type: "agent",
3018 agent: "claude",
3019 model: "sonnet",
3020 command: "claude -p ...",
3021 prompt: "system prompt",
3022 })
3023 .await
3024 .unwrap();
3025 (store, task_id, run_id)
3026 }
3027
3028 #[tokio::test]
3029 async fn start_run_upsert_clears_stale_completion_fields() {
3030 let (store, task_id, run_id) = store_with_run().await;
3031 store
3032 .complete_run(&CompleteRun {
3033 run_id,
3034 exit_code: Some(0),
3035 stdout: "",
3036 stderr: "",
3037 parsed: "",
3038 outcome: "success",
3039 error: "boom",
3040 tokens: RunTokenUsage::default(),
3041 })
3042 .await
3043 .unwrap();
3044
3045 let same_run_id = store
3047 .start_run(&crate::store::StartRun {
3048 task_id,
3049 attempt: 1,
3050 run_type: "agent",
3051 agent: "claude",
3052 model: "sonnet",
3053 command: "claude -p ...",
3054 prompt: "system prompt",
3055 })
3056 .await
3057 .unwrap();
3058 assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
3059
3060 let row = sqlx::query(
3061 "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
3062 )
3063 .bind(run_id)
3064 .fetch_one(store.pool())
3065 .await
3066 .unwrap();
3067 let outcome: Option<String> = row.try_get("outcome").unwrap();
3068 let completed_at: Option<String> = row.try_get("completed_at").unwrap();
3069 let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
3070 let error: Option<String> = row.try_get("error").unwrap();
3071 assert_eq!(outcome, None);
3072 assert_eq!(
3073 completed_at, None,
3074 "stale completion timestamp must be cleared"
3075 );
3076 assert_eq!(exit_code, None);
3077 assert_eq!(error, None);
3078 }
3079
3080 macro_rules! run_blob_decode_test {
3087 ($name:ident, $col:literal, $select:literal) => {
3088 #[tokio::test]
3089 async fn $name() {
3090 let (store, _task_id, run_id) = store_with_run().await;
3091 let sql = format!($select, run_id = run_id);
3092 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3093 let err = TaskStore::row_to_run(&row).unwrap_err();
3094 assert!(
3095 err.to_string().contains($col),
3096 "expected '{0}' in error message, got: {err}",
3097 $col
3098 );
3099 }
3100 };
3101 }
3102
3103 run_blob_decode_test!(
3104 row_to_run_fails_on_decode_error_invalid_utf8_id,
3105 "id",
3106 "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}"
3107 );
3108 run_blob_decode_test!(
3109 row_to_run_fails_on_decode_error_invalid_utf8_task_id,
3110 "task_id",
3111 "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}"
3112 );
3113 run_blob_decode_test!(
3114 row_to_run_fails_on_decode_error_invalid_utf8_started_at,
3115 "started_at",
3116 "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}"
3117 );
3118
3119 async fn store_with_activity() -> (crate::store::TaskStore, i64, i64) {
3122 let store = crate::store::TaskStore::open_memory().await.unwrap();
3123 let task_id = store
3124 .create(&NewTask {
3125 external_id: None,
3126 repo: "owner/repo".to_string(),
3127 origin: "internal".to_string(),
3128 title: "Test task".to_string(),
3129 body: "Test body".to_string(),
3130 source: "cron".to_string(),
3131 source_id: "test-1".to_string(),
3132 author: "tester".to_string(),
3133 url: "https://example.com/1".to_string(),
3134 labels: vec![],
3135 parent_id: None,
3136 })
3137 .await
3138 .unwrap();
3139 store
3140 .append_activity(task_id, "created", None, Some("new"), None, None, None)
3141 .await
3142 .unwrap();
3143 (store, task_id, 1)
3144 }
3145
3146 #[tokio::test]
3149 async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
3150 let (store, _task_id, activity_id) = store_with_activity().await;
3151 let sql = format!(
3152 "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3153 );
3154 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3155 let err = TaskStore::row_to_activity(&row).unwrap_err();
3156 assert!(
3157 err.to_string().contains("id"),
3158 "expected 'id' in error message, got: {err}"
3159 );
3160 }
3161
3162 #[tokio::test]
3163 async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
3164 let (store, _task_id, activity_id) = store_with_activity().await;
3165 let sql = format!(
3166 "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3167 );
3168 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3169 let err = TaskStore::row_to_activity(&row).unwrap_err();
3170 assert!(
3171 err.to_string().contains("task_id"),
3172 "expected 'task_id' in error message, got: {err}"
3173 );
3174 }
3175
3176 #[tokio::test]
3177 async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
3178 let (store, _task_id, activity_id) = store_with_activity().await;
3179 let sql = format!(
3180 "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3181 );
3182 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3183 let err = TaskStore::row_to_activity(&row).unwrap_err();
3184 assert!(
3185 err.to_string().contains("timestamp"),
3186 "expected 'timestamp' in error message, got: {err}"
3187 );
3188 }
3189
3190 #[tokio::test]
3191 async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
3192 let (store, _task_id, activity_id) = store_with_activity().await;
3193 let sql = format!(
3194 "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3195 );
3196 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3197 let err = TaskStore::row_to_activity(&row).unwrap_err();
3198 assert!(
3199 err.to_string().contains("event_type"),
3200 "expected 'event_type' in error message, got: {err}"
3201 );
3202 }
3203
3204 #[tokio::test]
3205 async fn row_to_activity_fails_on_corrupt_details_json() {
3206 let (store, task_id, _activity_id) = store_with_activity().await;
3207 sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
3209 .bind(task_id)
3210 .bind("test")
3211 .bind("not valid json {{{")
3212 .execute(store.pool())
3213 .await
3214 .unwrap();
3215 let row = sqlx::query(&format!(
3216 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
3217 ))
3218 .fetch_one(store.pool())
3219 .await
3220 .unwrap();
3221 let err = TaskStore::row_to_activity(&row).unwrap_err();
3222 assert!(
3223 err.to_string().contains("details")
3224 || err.to_string().contains("json")
3225 || err.to_string().contains("expected"),
3226 "expected 'details', 'json', or 'expected' in error message, got: {err}"
3227 );
3228 }
3229
3230 run_blob_decode_test!(
3233 row_to_run_fails_on_decode_error_invalid_utf8_model,
3234 "model",
3235 "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}"
3236 );
3237 run_blob_decode_test!(
3238 row_to_run_fails_on_decode_error_invalid_utf8_command,
3239 "command",
3240 "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}"
3241 );
3242 run_blob_decode_test!(
3243 row_to_run_fails_on_decode_error_invalid_utf8_prompt,
3244 "prompt",
3245 "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}"
3246 );
3247 run_blob_decode_test!(
3248 row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
3249 "env_vars",
3250 "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}"
3251 );
3252 run_blob_decode_test!(
3253 row_to_run_fails_on_decode_error_invalid_utf8_stdout,
3254 "stdout",
3255 "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}"
3256 );
3257 run_blob_decode_test!(
3258 row_to_run_fails_on_decode_error_invalid_utf8_stderr,
3259 "stderr",
3260 "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}"
3261 );
3262 run_blob_decode_test!(
3263 row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
3264 "parsed_response",
3265 "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}"
3266 );
3267 run_blob_decode_test!(
3268 row_to_run_fails_on_decode_error_invalid_utf8_outcome,
3269 "outcome",
3270 "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}"
3271 );
3272 run_blob_decode_test!(
3273 row_to_run_fails_on_decode_error_invalid_utf8_error,
3274 "error",
3275 "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}"
3276 );
3277 run_blob_decode_test!(
3278 row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
3279 "input_tokens",
3280 "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}"
3281 );
3282 run_blob_decode_test!(
3283 row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
3284 "output_tokens",
3285 "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}"
3286 );
3287 run_blob_decode_test!(
3288 row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
3289 "total_cost_usd",
3290 "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}"
3291 );
3292 run_blob_decode_test!(
3293 row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3294 "duration_secs",
3295 "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}"
3296 );
3297 run_blob_decode_test!(
3298 row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3299 "exit_code",
3300 "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}"
3301 );
3302
3303 #[tokio::test]
3310 async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3311 let (store, _task_id, activity_id) = store_with_activity().await;
3312 let sql = format!(
3313 "SELECT id, task_id, timestamp, event_type, \
3314 X'DeadbEEF' as from_status, NULL as to_status, \
3315 NULL as agent, NULL as model, details \
3316 FROM task_activity WHERE id = {activity_id}"
3317 );
3318 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3319 let err = TaskStore::row_to_activity(&row).unwrap_err();
3320 assert!(
3321 err.to_string().contains("from_status"),
3322 "expected 'from_status' in error message, got: {err}"
3323 );
3324 }
3325
3326 #[tokio::test]
3327 async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3328 let (store, _task_id, activity_id) = store_with_activity().await;
3329 let sql = format!(
3330 "SELECT id, task_id, timestamp, event_type, \
3331 NULL as from_status, X'DeadbEEF' as to_status, \
3332 NULL as agent, NULL as model, details \
3333 FROM task_activity WHERE id = {activity_id}"
3334 );
3335 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3336 let err = TaskStore::row_to_activity(&row).unwrap_err();
3337 assert!(
3338 err.to_string().contains("to_status"),
3339 "expected 'to_status' in error message, got: {err}"
3340 );
3341 }
3342
3343 #[tokio::test]
3344 async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3345 let (store, _task_id, activity_id) = store_with_activity().await;
3346 let sql = format!(
3347 "SELECT id, task_id, timestamp, event_type, \
3348 NULL as from_status, NULL as to_status, \
3349 X'DeadbEEF' as agent, NULL as model, details \
3350 FROM task_activity WHERE id = {activity_id}"
3351 );
3352 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3353 let err = TaskStore::row_to_activity(&row).unwrap_err();
3354 assert!(
3355 err.to_string().contains("agent"),
3356 "expected 'agent' in error message, got: {err}"
3357 );
3358 }
3359
3360 #[tokio::test]
3361 async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3362 let (store, _task_id, activity_id) = store_with_activity().await;
3363 let sql = format!(
3364 "SELECT id, task_id, timestamp, event_type, \
3365 NULL as from_status, NULL as to_status, \
3366 NULL as agent, X'DeadbEEF' as model, details \
3367 FROM task_activity WHERE id = {activity_id}"
3368 );
3369 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3370 let err = TaskStore::row_to_activity(&row).unwrap_err();
3371 assert!(
3372 err.to_string().contains("model"),
3373 "expected 'model' in error message, got: {err}"
3374 );
3375 }
3376
3377 #[tokio::test]
3380 async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3381 let store = crate::store::TaskStore::open_memory().await.unwrap();
3386 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3387 .execute(store.pool())
3388 .await
3389 .unwrap();
3390 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3392 .execute(store.pool())
3393 .await
3394 .unwrap();
3395
3396 let result = store
3397 .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3398 .await
3399 .unwrap();
3400 assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3401 assert_eq!(
3402 result.get("valid-id"),
3403 Some(&crate::store::TaskStatus::Done)
3404 );
3405 }
3406}