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 get_activity(
641 &self,
642 task_id: i64,
643 limit: Option<usize>,
644 ) -> anyhow::Result<Vec<TaskActivity>> {
645 let rows = if let Some(limit) = limit {
646 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
647 sqlx::query(&format!(
648 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC LIMIT ?"
649 ))
650 .bind(task_id)
651 .bind(limit_i64)
652 .fetch_all(&self.pool)
653 .await?
654 } else {
655 sqlx::query(&format!(
656 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC"
657 ))
658 .bind(task_id)
659 .fetch_all(&self.pool)
660 .await?
661 };
662
663 rows.iter().map(Self::row_to_activity).collect()
664 }
665
666 pub async fn create(&self, new: &NewTask) -> anyhow::Result<i64> {
667 let labels_json = serde_json::to_string(&new.labels)?;
668 let row = sqlx::query(
669 "INSERT INTO tasks (external_id, repo, origin, title, body, source, source_id, author, url, labels, parent_id)
670 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
671 RETURNING id",
672 )
673 .bind(&new.external_id)
674 .bind(&new.repo)
675 .bind(&new.origin)
676 .bind(&new.title)
677 .bind(&new.body)
678 .bind(&new.source)
679 .bind(&new.source_id)
680 .bind(&new.author)
681 .bind(&new.url)
682 .bind(&labels_json)
683 .bind(new.parent_id)
684 .fetch_one(&self.pool)
685 .await?;
686
687 row.try_get("id")
688 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
689 }
690
691 pub async fn create_internal(
696 &self,
697 repo: &str,
698 title: &str,
699 body: &str,
700 source: &str,
701 source_id: &str,
702 parent_id: Option<i64>,
703 ) -> anyhow::Result<i64> {
704 let id = self
705 .create(&NewTask {
706 external_id: None,
707 repo: repo.to_string(),
708 origin: "internal".to_string(),
709 title: title.to_string(),
710 body: body.to_string(),
711 source: source.to_string(),
712 source_id: source_id.to_string(),
713 author: String::new(),
714 url: String::new(),
715 labels: vec![],
716 parent_id,
717 })
718 .await?;
719 sqlx::query("UPDATE tasks SET external_id = ? WHERE id = ?")
721 .bind(format!("internal:{id}"))
722 .bind(id)
723 .execute(&self.pool)
724 .await?;
725 Ok(id)
726 }
727
728 pub async fn get(&self, id: i64) -> anyhow::Result<Task> {
730 let row = sqlx::query(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?"))
731 .bind(id)
732 .fetch_optional(&self.pool)
733 .await?
734 .with_context(|| format!("task {id} not found"))?;
735
736 Self::row_to_task(&row)
737 }
738
739 pub async fn get_by_external_id(
741 &self,
742 repo: &str,
743 ext_id: &str,
744 ) -> anyhow::Result<Option<Task>> {
745 let row = sqlx::query(&format!(
746 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND external_id = ?"
747 ))
748 .bind(repo)
749 .bind(ext_id)
750 .fetch_optional(&self.pool)
751 .await?;
752
753 match row {
754 Some(r) => Ok(Some(Self::row_to_task(&r)?)),
755 None => Ok(None),
756 }
757 }
758
759 pub async fn resolve_id_by_external(
765 &self,
766 repo: &str,
767 ext_id: &str,
768 ) -> anyhow::Result<Option<i64>> {
769 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND external_id = ?")
770 .bind(repo)
771 .bind(ext_id)
772 .fetch_optional(&self.pool)
773 .await?;
774 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
775 Ok(id)
776 }
777
778 pub async fn resolve_id_by_pr_number(
784 &self,
785 repo: &str,
786 pr_number: i32,
787 ) -> anyhow::Result<Option<i64>> {
788 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND pr_number = ? LIMIT 1")
789 .bind(repo)
790 .bind(pr_number)
791 .fetch_optional(&self.pool)
792 .await?;
793 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
794 Ok(id)
795 }
796
797 pub async fn get_agent_by_external_id(
800 &self,
801 repo: &str,
802 ext_id: &str,
803 ) -> anyhow::Result<Option<String>> {
804 let row = sqlx::query("SELECT agent FROM tasks WHERE repo = ? AND external_id = ?")
805 .bind(repo)
806 .bind(ext_id)
807 .fetch_optional(&self.pool)
808 .await?;
809 let agent = row
810 .map(|r| r.try_get::<Option<String>, _>("agent"))
811 .transpose()?
812 .flatten();
813 Ok(agent)
814 }
815
816 pub async fn upsert_external(&self, ext: &UpsertExternal<'_>) -> anyhow::Result<i64> {
818 let labels_json = serde_json::to_string(ext.labels)?;
819 let parent_id = match parent_external_id_from_labels(ext.labels) {
820 Some(parent_external_id) => self.resolve_task_id(ext.repo, parent_external_id).await?,
821 None => None,
822 };
823 let row = sqlx::query(
824 "INSERT INTO tasks (external_id, repo, origin, title, body, author, url, labels, parent_id)
825 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
826 ON CONFLICT(repo, external_id) DO UPDATE SET
827 title = excluded.title,
828 body = excluded.body,
829 labels = excluded.labels,
830 url = excluded.url,
831 parent_id = COALESCE(excluded.parent_id, tasks.parent_id)
832 RETURNING id",
833 )
834 .bind(ext.ext_id)
835 .bind(ext.repo)
836 .bind(ext.origin)
837 .bind(ext.title)
838 .bind(ext.body)
839 .bind(ext.author)
840 .bind(ext.url)
841 .bind(&labels_json)
842 .bind(parent_id)
843 .fetch_one(&self.pool)
844 .await?;
845
846 row.try_get("id")
847 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
848 }
849
850 pub async fn update_status_and_fields(
855 &self,
856 id: i64,
857 status: TaskStatus,
858 updates: &[(&str, serde_json::Value)],
859 ) -> anyhow::Result<()> {
860 for (col, _) in updates {
861 anyhow::ensure!(
862 ALLOWED_FIELDS.contains(col),
863 "column {col} is not in the update allowlist"
864 );
865 }
866
867 let mut tx = self.pool.begin().await?;
868 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
869 .bind(id)
870 .fetch_one(&mut *tx)
871 .await?;
872 let from_status: String = previous
873 .try_get("status")
874 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
875 let agent: Option<String> = previous
876 .try_get("agent")
877 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
878 let model: Option<String> = previous
879 .try_get("model")
880 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
881
882 let mut set_parts = Vec::new();
883 let mut values: Vec<Option<String>> = Vec::new();
884
885 set_parts.push("status = ?".to_string());
886 values.push(Some(status.as_str().to_string()));
887
888 let mut block_reason_in_updates = false;
889 for (col, val) in updates {
890 if *col == "block_reason" {
891 block_reason_in_updates = true;
892 }
893 set_parts.push(format!("{col} = ?"));
894 match val {
895 serde_json::Value::String(s) => values.push(Some(s.clone())),
896 serde_json::Value::Number(n) => values.push(Some(n.to_string())),
897 serde_json::Value::Bool(b) => {
898 values.push(Some(if *b { "1" } else { "0" }.to_string()));
899 }
900 serde_json::Value::Null => values.push(None),
901 other => values.push(Some(serde_json::to_string(other)?)),
902 }
903 }
904
905 if !block_reason_in_updates && status != TaskStatus::Blocked {
906 set_parts.push("block_reason = NULL".to_string());
907 }
908
909 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
910 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
911
912 let mut query = sqlx::query(&sql);
913 for v in &values {
914 query = query.bind(v.as_deref());
915 }
916 query = query.bind(id);
917 query.execute(&mut *tx).await?;
918
919 Self::append_activity_tx(
920 &mut tx,
921 id,
922 "status_change",
923 Some(from_status.as_str()),
924 Some(status.as_str()),
925 agent.as_deref(),
926 model.as_deref(),
927 None,
928 )
929 .await?;
930 tx.commit().await?;
931 Ok(())
932 }
933
934 pub async fn update_status(&self, id: i64, status: TaskStatus) -> anyhow::Result<()> {
936 let mut tx = self.pool.begin().await?;
937 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
938 .bind(id)
939 .fetch_one(&mut *tx)
940 .await?;
941 let from_status: String = previous
942 .try_get("status")
943 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
944 let agent: Option<String> = previous
945 .try_get("agent")
946 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
947 let model: Option<String> = previous
948 .try_get("model")
949 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
950 let sql = if status == TaskStatus::Blocked {
951 "UPDATE tasks SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
952 } else {
953 "UPDATE tasks SET status = ?, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
954 };
955 sqlx::query(sql)
956 .bind(status.as_str())
957 .bind(id)
958 .execute(&mut *tx)
959 .await?;
960
961 Self::append_activity_tx(
962 &mut tx,
963 id,
964 "status_change",
965 Some(from_status.as_str()),
966 Some(status.as_str()),
967 agent.as_deref(),
968 model.as_deref(),
969 None,
970 )
971 .await?;
972 tx.commit().await?;
973 Ok(())
974 }
975
976 pub async fn update_external_id(&self, id: i64, external_id: &str) -> anyhow::Result<()> {
978 sqlx::query(
979 "UPDATE tasks SET external_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
980 )
981 .bind(external_id)
982 .bind(id)
983 .execute(&self.pool)
984 .await?;
985 Ok(())
986 }
987
988 pub async fn update_status_if(
993 &self,
994 id: i64,
995 status: TaskStatus,
996 expected: TaskStatus,
997 ) -> anyhow::Result<bool> {
998 let mut tx = self.pool.begin().await?;
999 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
1000 .bind(id)
1001 .fetch_one(&mut *tx)
1002 .await?;
1003 let from_status: String = previous
1004 .try_get("status")
1005 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
1006 let agent: Option<String> = previous
1007 .try_get("agent")
1008 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
1009 let model: Option<String> = previous
1010 .try_get("model")
1011 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
1012 let sql = if status == TaskStatus::Blocked {
1013 "UPDATE tasks SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND status = ?"
1014 } else {
1015 "UPDATE tasks SET status = ?, block_reason = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND status = ?"
1016 };
1017 let result = sqlx::query(sql)
1018 .bind(status.as_str())
1019 .bind(id)
1020 .bind(expected.as_str())
1021 .execute(&mut *tx)
1022 .await?;
1023 if result.rows_affected() == 0 {
1024 return Ok(false);
1025 }
1026 Self::append_activity_tx(
1027 &mut tx,
1028 id,
1029 "status_change",
1030 Some(from_status.as_str()),
1031 Some(status.as_str()),
1032 agent.as_deref(),
1033 model.as_deref(),
1034 None,
1035 )
1036 .await?;
1037 tx.commit().await?;
1038 Ok(true)
1039 }
1040
1041 pub async fn touch_updated_at(&self, id: i64) -> anyhow::Result<()> {
1049 sqlx::query(
1050 "UPDATE tasks SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1051 )
1052 .bind(id)
1053 .execute(&self.pool)
1054 .await?;
1055 Ok(())
1056 }
1057
1058 pub async fn reset_to_new(&self, id: i64) -> anyhow::Result<()> {
1060 let mut tx = self.pool.begin().await?;
1061 let previous = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
1062 .bind(id)
1063 .fetch_one(&mut *tx)
1064 .await?;
1065 let from_status: String = previous
1066 .try_get("status")
1067 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
1068 let agent: Option<String> = previous
1069 .try_get("agent")
1070 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
1071 let model: Option<String> = previous
1072 .try_get("model")
1073 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
1074 sqlx::query(
1075 "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 = ?",
1076 )
1077 .bind(id)
1078 .execute(&mut *tx)
1079 .await?;
1080 let details = serde_json::json!({ "op": "reset_to_new" });
1081 Self::append_activity_tx(
1082 &mut tx,
1083 id,
1084 "status_change",
1085 Some(from_status.as_str()),
1086 Some(TaskStatus::New.as_str()),
1087 agent.as_deref(),
1088 model.as_deref(),
1089 Some(&details),
1090 )
1091 .await?;
1092 tx.commit().await?;
1093 Ok(())
1094 }
1095
1096 pub async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()> {
1098 let value = reason
1099 .map(|r| serde_json::Value::String(r.to_string()))
1100 .unwrap_or(serde_json::Value::Null);
1101 self.set_fields(id, &[("block_reason", value)]).await
1102 }
1103
1104 pub async fn list_by_status(
1106 &self,
1107 repo: &str,
1108 status: TaskStatus,
1109 ) -> anyhow::Result<Vec<Task>> {
1110 let rows = sqlx::query(&format!(
1111 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status = ? ORDER BY created_at DESC"
1112 ))
1113 .bind(repo)
1114 .bind(status.as_str())
1115 .fetch_all(&self.pool)
1116 .await?;
1117
1118 rows.iter().map(Self::row_to_task).collect()
1119 }
1120
1121 pub async fn list_children(&self, repo: &str, parent_id: i64) -> anyhow::Result<Vec<Task>> {
1123 let rows = sqlx::query(&format!(
1124 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND parent_id = ? ORDER BY created_at ASC"
1125 ))
1126 .bind(repo)
1127 .bind(parent_id)
1128 .fetch_all(&self.pool)
1129 .await?;
1130
1131 rows.iter().map(Self::row_to_task).collect()
1132 }
1133
1134 pub async fn list_external_by_status(
1136 &self,
1137 repo: &str,
1138 status: TaskStatus,
1139 ) -> anyhow::Result<Vec<Task>> {
1140 let rows = sqlx::query(
1141 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' AND status = ? ORDER BY created_at DESC"),
1142 )
1143 .bind(repo)
1144 .bind(status.as_str())
1145 .fetch_all(&self.pool)
1146 .await?;
1147
1148 rows.iter().map(Self::row_to_task).collect()
1149 }
1150
1151 pub async fn list_routable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1153 self.list_by_status(repo, TaskStatus::New).await
1154 }
1155
1156 pub async fn list_internal_by_status(
1158 &self,
1159 repo: &str,
1160 status: TaskStatus,
1161 ) -> anyhow::Result<Vec<Task>> {
1162 let rows = sqlx::query(
1163 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND status = ? ORDER BY created_at DESC"),
1164 )
1165 .bind(repo)
1166 .bind(status.as_str())
1167 .fetch_all(&self.pool)
1168 .await?;
1169
1170 rows.iter().map(Self::row_to_task).collect()
1171 }
1172
1173 pub async fn list_all_internal(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1175 let rows = sqlx::query(
1176 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' ORDER BY created_at DESC"),
1177 )
1178 .bind(repo)
1179 .fetch_all(&self.pool)
1180 .await?;
1181
1182 rows.iter().map(Self::row_to_task).collect()
1183 }
1184
1185 pub async fn list_internal_by_source(
1188 &self,
1189 repo: &str,
1190 source: &str,
1191 ) -> anyhow::Result<Vec<Task>> {
1192 let rows = sqlx::query(
1193 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? ORDER BY created_at DESC"),
1194 )
1195 .bind(repo)
1196 .bind(source)
1197 .fetch_all(&self.pool)
1198 .await?;
1199
1200 rows.iter().map(Self::row_to_task).collect()
1201 }
1202
1203 pub async fn list_source_ids_by_source(
1208 &self,
1209 repo: &str,
1210 source: &str,
1211 since_days: i64,
1212 ) -> anyhow::Result<Vec<String>> {
1213 let cutoff = format!("-{since_days} days");
1214 let rows = sqlx::query(
1215 "SELECT source_id FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? AND created_at >= datetime('now', ?)",
1216 )
1217 .bind(repo)
1218 .bind(source)
1219 .bind(&cutoff)
1220 .fetch_all(&self.pool)
1221 .await?;
1222
1223 let mut ids = Vec::with_capacity(rows.len());
1224 for row in &rows {
1225 ids.push(row.try_get::<String, _>("source_id").map_err(|e| {
1226 anyhow::anyhow!(
1227 "list_source_ids_by_source: failed to decode source_id for repo={repo}, source={source}: {e}"
1228 )
1229 })?);
1230 }
1231 Ok(ids)
1232 }
1233
1234 pub async fn list_active(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1236 let rows = sqlx::query(
1237 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status != 'done' ORDER BY created_at DESC"),
1238 )
1239 .bind(repo)
1240 .fetch_all(&self.pool)
1241 .await?;
1242
1243 rows.iter().map(Self::row_to_task).collect()
1244 }
1245
1246 pub async fn list_all_external(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1248 let rows = sqlx::query(
1249 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' ORDER BY created_at DESC"),
1250 )
1251 .bind(repo)
1252 .fetch_all(&self.pool)
1253 .await?;
1254
1255 rows.iter().map(Self::row_to_task).collect()
1256 }
1257
1258 pub async fn has_external_tasks(&self, repo: &str) -> anyhow::Result<bool> {
1260 let has_external = sqlx::query_scalar::<_, i32>(
1261 "SELECT 1 FROM tasks WHERE repo = ? AND origin != 'internal' LIMIT 1",
1262 )
1263 .bind(repo)
1264 .fetch_optional(&self.pool)
1265 .await?
1266 .is_some();
1267 Ok(has_external)
1268 }
1269
1270 pub async fn existing_external_ids(
1274 &self,
1275 repo: &str,
1276 ) -> anyhow::Result<std::collections::HashSet<String>> {
1277 let rows: Vec<(String,)> = sqlx::query_as(
1278 "SELECT external_id FROM tasks WHERE repo = ? AND origin != 'internal' AND external_id IS NOT NULL",
1279 )
1280 .bind(repo)
1281 .fetch_all(&self.pool)
1282 .await?;
1283
1284 Ok(rows
1285 .into_iter()
1286 .filter_map(|(id,)| if id.is_empty() { None } else { Some(id) })
1287 .collect())
1288 }
1289
1290 pub async fn list_for_doctor(&self, repo: &str, cutoff_str: &str) -> anyhow::Result<Vec<Task>> {
1293 let rows = sqlx::query(
1294 &format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND (status != 'done' OR updated_at >= ?) ORDER BY created_at DESC"),
1295 )
1296 .bind(repo)
1297 .bind(cutoff_str)
1298 .fetch_all(&self.pool)
1299 .await?;
1300 rows.iter().map(Self::row_to_task).collect()
1301 }
1302
1303 pub async fn list_all(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1305 let rows = sqlx::query(&format!(
1306 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? ORDER BY created_at DESC"
1307 ))
1308 .bind(repo)
1309 .fetch_all(&self.pool)
1310 .await?;
1311 rows.iter().map(Self::row_to_task).collect()
1312 }
1313
1314 pub async fn distinct_repos(&self) -> anyhow::Result<Vec<String>> {
1316 let rows =
1317 sqlx::query("SELECT DISTINCT repo FROM tasks WHERE repo IS NOT NULL AND repo != ''")
1318 .fetch_all(&self.pool)
1319 .await?;
1320 let mut repos = Vec::new();
1321 for row in &rows {
1322 repos.push(row.try_get::<String, _>("repo").map_err(|e| {
1323 anyhow::anyhow!("distinct_repos: failed to decode 'repo' column: {e}")
1324 })?);
1325 }
1326 Ok(repos)
1327 }
1328
1329 pub async fn list_all_active_global(&self) -> anyhow::Result<Vec<Task>> {
1334 let rows = sqlx::query(&format!(
1335 "SELECT {TASK_COLS} FROM tasks WHERE status != 'done' ORDER BY created_at DESC"
1336 ))
1337 .fetch_all(&self.pool)
1338 .await?;
1339 rows.iter().map(Self::row_to_task).collect()
1340 }
1341
1342 pub async fn list_all_global(&self) -> anyhow::Result<Vec<Task>> {
1346 let rows = sqlx::query(&format!(
1347 "SELECT {TASK_COLS} FROM tasks ORDER BY created_at DESC"
1348 ))
1349 .fetch_all(&self.pool)
1350 .await?;
1351 rows.iter().map(Self::row_to_task).collect()
1352 }
1353
1354 pub async fn list_all_by_status_global(&self, status: TaskStatus) -> anyhow::Result<Vec<Task>> {
1358 let rows = sqlx::query(&format!(
1359 "SELECT {TASK_COLS} FROM tasks WHERE status = ? ORDER BY created_at DESC"
1360 ))
1361 .bind(status.as_str())
1362 .fetch_all(&self.pool)
1363 .await?;
1364 rows.iter().map(Self::row_to_task).collect()
1365 }
1366
1367 pub async fn set_fields(
1370 &self,
1371 id: i64,
1372 updates: &[(&str, serde_json::Value)],
1373 ) -> anyhow::Result<()> {
1374 if updates.is_empty() {
1375 return Ok(());
1376 }
1377
1378 for (col, _) in updates {
1379 anyhow::ensure!(
1380 ALLOWED_FIELDS.contains(col),
1381 "column {col} is not in the update allowlist"
1382 );
1383 }
1384
1385 let mut set_parts = Vec::new();
1387 let mut values: Vec<Option<String>> = Vec::new();
1388
1389 for (col, val) in updates {
1390 set_parts.push(format!("{col} = ?"));
1391 values.push(sql_value_for_task_field(col, val)?);
1392 }
1393
1394 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1395 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1396
1397 let mut query = sqlx::query(&sql);
1398 for v in &values {
1399 query = query.bind(v.as_deref());
1400 }
1401 query = query.bind(id);
1402 query.execute(&self.pool).await?;
1403
1404 Ok(())
1405 }
1406
1407 pub async fn set_fields_silent(
1412 &self,
1413 id: i64,
1414 updates: &[(&str, serde_json::Value)],
1415 ) -> anyhow::Result<()> {
1416 if updates.is_empty() {
1417 return Ok(());
1418 }
1419
1420 for (col, _) in updates {
1421 anyhow::ensure!(
1422 ALLOWED_FIELDS.contains(col),
1423 "column {col} is not in the update allowlist"
1424 );
1425 }
1426
1427 let mut set_parts = Vec::new();
1428 let mut values: Vec<Option<String>> = Vec::new();
1429
1430 for (col, val) in updates {
1431 set_parts.push(format!("{col} = ?"));
1432 values.push(sql_value_for_task_field(col, val)?);
1433 }
1434
1435 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1436
1437 let mut query = sqlx::query(&sql);
1438 for v in &values {
1439 query = query.bind(v.as_deref());
1440 }
1441 query = query.bind(id);
1442 query.execute(&self.pool).await?;
1443
1444 Ok(())
1445 }
1446
1447 pub async fn increment(&self, id: i64, field: &str) -> anyhow::Result<i32> {
1449 anyhow::ensure!(
1450 INCREMENTABLE_FIELDS.contains(&field),
1451 "field {field} is not incrementable"
1452 );
1453
1454 let sql = format!(
1455 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? RETURNING {field} AS val"
1456 );
1457
1458 let row = sqlx::query(&sql).bind(id).fetch_one(&self.pool).await?;
1459
1460 Ok(row.try_get("val")?)
1461 }
1462
1463 pub async fn reset_counters(&self, id: i64) -> anyhow::Result<()> {
1465 sqlx::query(
1466 "UPDATE tasks SET
1467 attempts = 0,
1468 route_attempts = 0,
1469 review_agent_failures = 0,
1470 merge_conflict_retries = 0,
1471 pr_create_failures = 0,
1472 push_failures = 0,
1473 network_retries = 0,
1474 ci_merge_failures = 0,
1475 review_cycles = 0,
1476 review_invocations = 0,
1477 review_session_expected = 0,
1478 auto_unblock_count = 0,
1479 auto_unblock_last_at = '',
1480 auto_unblock_last_reason = '',
1481 ci_recovery_count = 0,
1482 no_code_reroutes = 0,
1483 no_code_last_agent = '',
1484 needs_review_refires = 0,
1485 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1486 WHERE id = ?",
1487 )
1488 .bind(id)
1489 .execute(&self.pool)
1490 .await?;
1491 Ok(())
1492 }
1493
1494 pub async fn reset_failure_counters(&self, id: i64) -> anyhow::Result<()> {
1509 sqlx::query(
1510 "UPDATE tasks SET
1511 route_attempts = 0,
1512 review_agent_failures = 0,
1513 pr_create_failures = 0,
1514 push_failures = 0,
1515 network_retries = 0,
1516 review_session_expected = 0,
1517 auto_unblock_count = 0,
1518 auto_unblock_last_at = '',
1519 auto_unblock_last_reason = '',
1520 ci_recovery_count = 0,
1521 no_code_reroutes = 0,
1522 no_code_last_agent = '',
1523 needs_review_refires = 0,
1524 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1525 WHERE id = ?",
1526 )
1527 .bind(id)
1528 .execute(&self.pool)
1529 .await?;
1530 Ok(())
1531 }
1532
1533 pub async fn store_route(&self, route: &StoreRoute<'_>) -> anyhow::Result<()> {
1539 sqlx::query(
1540 "UPDATE tasks SET
1541 agent = ?, model = ?, complexity = ?, estimate = ?, route_reason = ?,
1542 agent_profile = ?, selected_skills = ?,
1543 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1544 WHERE id = ?",
1545 )
1546 .bind(route.agent)
1547 .bind(route.model)
1548 .bind(route.complexity)
1549 .bind(route.estimate as i64)
1550 .bind(route.reason)
1551 .bind(route.profile)
1552 .bind(route.skills)
1553 .bind(route.id)
1554 .execute(&self.pool)
1555 .await?;
1556 let details = serde_json::json!({
1557 "complexity": route.complexity,
1558 "estimate": route.estimate,
1559 "reason": route.reason,
1560 "skills": route.skills,
1561 });
1562 self.append_activity(
1563 route.id,
1564 "dispatch",
1565 None,
1566 None,
1567 Some(route.agent),
1568 route.model,
1569 Some(&details),
1570 )
1571 .await?;
1572 Ok(())
1573 }
1574
1575 pub async fn list_external_tasks_with_estimates(&self) -> anyhow::Result<Vec<(String, u8)>> {
1583 let rows = sqlx::query(
1584 "SELECT external_id, estimate FROM tasks \
1585 WHERE estimate > 0 AND external_id NOT LIKE 'internal:%' \
1586 AND status NOT IN ('done', 'blocked')",
1587 )
1588 .fetch_all(&self.pool)
1589 .await?;
1590
1591 let mut result = Vec::new();
1592 for row in &rows {
1593 let external_id: String = row.try_get("external_id")?;
1594 let estimate_raw: i64 = row.try_get("estimate")?;
1595 if (1..=255).contains(&estimate_raw) {
1596 result.push((external_id, estimate_raw as u8));
1597 }
1598 }
1599 Ok(result)
1600 }
1601
1602 pub async fn set_estimate_if_zero(&self, id: i64, estimate: u8) -> anyhow::Result<bool> {
1608 let result = sqlx::query("UPDATE tasks SET estimate = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND estimate = 0")
1609 .bind(estimate as i64)
1610 .bind(id)
1611 .execute(&self.pool)
1612 .await?;
1613
1614 Ok(result.rows_affected() > 0)
1615 }
1616
1617 pub async fn append_memory(&self, id: i64, entry: &MemoryEntry) -> anyhow::Result<()> {
1626 let mut tx = self.pool.begin().await?;
1627
1628 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1629 .bind(id)
1630 .fetch_one(&mut *tx)
1631 .await?;
1632
1633 let memory_opt: Option<String> = row
1638 .try_get::<Option<String>, _>("memory")
1639 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1640 let memory_str = memory_opt.unwrap_or_default();
1641
1642 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1643 Vec::new()
1644 } else {
1645 serde_json::from_str(&memory_str)
1646 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1647 };
1648 memory.push(entry.clone());
1649 let new_json = serde_json::to_string(&memory)?;
1650
1651 sqlx::query(
1652 "UPDATE tasks SET memory = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1653 )
1654 .bind(&new_json)
1655 .bind(id)
1656 .execute(&mut *tx)
1657 .await?;
1658
1659 tx.commit().await?;
1660 Ok(())
1661 }
1662
1663 pub async fn recent_memory(&self, id: i64, max: usize) -> anyhow::Result<Vec<MemoryEntry>> {
1665 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1666 .bind(id)
1667 .fetch_one(&self.pool)
1668 .await?;
1669
1670 let memory_opt: Option<String> = row
1671 .try_get::<Option<String>, _>("memory")
1672 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1673 let memory_str = memory_opt.unwrap_or_default();
1674
1675 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1676 Vec::new()
1677 } else {
1678 serde_json::from_str(&memory_str)
1679 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1680 };
1681
1682 memory.sort_by_key(|m| m.attempt);
1683 if memory.len() > max {
1684 memory = memory.split_off(memory.len() - max);
1685 }
1686 Ok(memory)
1687 }
1688
1689 pub async fn store_tokens(
1695 &self,
1696 id: i64,
1697 input: u64,
1698 output: u64,
1699 model: &str,
1700 ) -> anyhow::Result<()> {
1701 let pricing = pricing_for_model(model);
1702 let usage = TokenUsage {
1703 input_tokens: input,
1704 output_tokens: output,
1705 };
1706 let cost = pricing.estimate_cost_usd(usage);
1707
1708 sqlx::query(
1709 "UPDATE tasks SET
1710 input_tokens = COALESCE(input_tokens, 0) + ?,
1711 output_tokens = COALESCE(output_tokens, 0) + ?,
1712 input_cost_usd = COALESCE(input_cost_usd, 0.0) + ?,
1713 output_cost_usd = COALESCE(output_cost_usd, 0.0) + ?,
1714 total_cost_usd = COALESCE(total_cost_usd, 0.0) + ?,
1715 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1716 WHERE id = ?",
1717 )
1718 .bind(i64::try_from(input).unwrap_or_else(|_| {
1719 tracing::warn!(input, "input_tokens overflows i64, clamping to MAX");
1720 i64::MAX
1721 }))
1722 .bind(i64::try_from(output).unwrap_or_else(|_| {
1723 tracing::warn!(output, "output_tokens overflows i64, clamping to MAX");
1724 i64::MAX
1725 }))
1726 .bind(cost.input_cost_usd)
1727 .bind(cost.output_cost_usd)
1728 .bind(cost.total_cost_usd)
1729 .bind(id)
1730 .execute(&self.pool)
1731 .await?;
1732
1733 Ok(())
1734 }
1735
1736 pub async fn mark_cleaned(&self, id: i64) -> anyhow::Result<()> {
1742 sqlx::query(
1743 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
1744 )
1745 .bind(id)
1746 .execute(&self.pool)
1747 .await?;
1748 self.append_activity(
1749 id,
1750 "branch_delete",
1751 None,
1752 None,
1753 None,
1754 None,
1755 Some(&serde_json::json!({ "worktree_cleaned": true })),
1756 )
1757 .await?;
1758 Ok(())
1759 }
1760
1761 #[cfg(test)]
1767 pub async fn batch_set_fields(
1768 &self,
1769 updates: &[(i64, &[(&str, serde_json::Value)])],
1770 ) -> anyhow::Result<()> {
1771 if updates.is_empty() {
1772 return Ok(());
1773 }
1774
1775 for (_, entry_updates) in updates {
1776 for (col, _) in *entry_updates {
1777 anyhow::ensure!(
1778 ALLOWED_FIELDS.contains(col),
1779 "column {col} is not in the update allowlist"
1780 );
1781 }
1782 }
1783
1784 let mut tx = self.pool.begin().await?;
1785 for (id, entry_updates) in updates {
1786 if entry_updates.is_empty() {
1787 continue;
1788 }
1789 let mut set_parts = Vec::new();
1790 let mut values: Vec<Option<String>> = Vec::new();
1791 for (col, val) in *entry_updates {
1792 set_parts.push(format!("{col} = ?"));
1793 values.push(sql_value_for_task_field(col, val)?);
1794 }
1795 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1796 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1797 let mut query = sqlx::query(&sql);
1798 for v in &values {
1799 query = query.bind(v.as_deref());
1800 }
1801 query = query.bind(*id);
1802 query.execute(&mut *tx).await?;
1803 }
1804 tx.commit().await?;
1805 Ok(())
1806 }
1807
1808 #[cfg(test)]
1813 pub async fn batch_increment(&self, entries: &[(i64, &str)]) -> anyhow::Result<()> {
1814 if entries.is_empty() {
1815 return Ok(());
1816 }
1817
1818 for (_, field) in entries {
1819 anyhow::ensure!(
1820 INCREMENTABLE_FIELDS.contains(field),
1821 "field {field} is not incrementable"
1822 );
1823 }
1824
1825 let mut tx = self.pool.begin().await?;
1826 for (id, field) in entries {
1827 let sql = format!(
1828 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
1829 );
1830 sqlx::query(&sql).bind(*id).execute(&mut *tx).await?;
1831 }
1832 tx.commit().await?;
1833 Ok(())
1834 }
1835
1836 #[cfg(test)]
1843 pub async fn batch_reset_failure_counters(&self, ids: &[i64]) -> anyhow::Result<()> {
1844 if ids.is_empty() {
1845 return Ok(());
1846 }
1847 const CHUNK_SIZE: usize = 500;
1850
1851 let mut tx = self.pool.begin().await?;
1852
1853 for chunk in ids.chunks(CHUNK_SIZE) {
1854 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1856 let sql = format!(
1857 "UPDATE tasks SET
1858 route_attempts = 0,
1859 review_agent_failures = 0,
1860 review_invocations = 0,
1861 pr_create_failures = 0,
1862 push_failures = 0,
1863 network_retries = 0,
1864 review_session_expected = 0,
1865 auto_unblock_count = 0,
1866 auto_unblock_last_at = '',
1867 auto_unblock_last_reason = '',
1868 ci_recovery_count = 0,
1869 no_code_reroutes = 0,
1870 needs_review_refires = 0,
1871 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1872 WHERE id IN ({placeholders})",
1873 );
1874 let mut query = sqlx::query(&sql);
1875 for id in chunk {
1876 query = query.bind(*id);
1877 }
1878 query.execute(&mut *tx).await?;
1879 }
1880
1881 tx.commit().await?;
1882 Ok(())
1883 }
1884
1885 #[cfg(test)]
1890 pub async fn batch_mark_cleaned(&self, ids: &[i64]) -> anyhow::Result<()> {
1891 if ids.is_empty() {
1892 return Ok(());
1893 }
1894
1895 const CHUNK_SIZE: usize = 500;
1897
1898 let mut tx = self.pool.begin().await?;
1899
1900 for chunk in ids.chunks(CHUNK_SIZE) {
1901 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1902 let sql = format!(
1903 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id IN ({placeholders})",
1904 );
1905 let mut query = sqlx::query(&sql);
1906 for id in chunk {
1907 query = query.bind(*id);
1908 }
1909 query.execute(&mut *tx).await?;
1910 }
1911
1912 let details = serde_json::json!({ "worktree_cleaned": true });
1916 let details_json = serde_json::to_string(&details)?;
1917 for id in ids {
1918 sqlx::query(
1919 "INSERT INTO task_activity (task_id, event_type, from_status, to_status, agent, model, details) VALUES (?, ?, ?, ?, ?, ?, ?)",
1920 )
1921 .bind(*id)
1922 .bind("branch_delete")
1923 .bind(None::<&str>)
1924 .bind(None::<&str>)
1925 .bind(None::<&str>)
1926 .bind(None::<&str>)
1927 .bind(details_json.as_str())
1928 .execute(&mut *tx)
1929 .await?;
1930 }
1931
1932 tx.commit().await?;
1933 Ok(())
1934 }
1935
1936 #[cfg(test)]
1939 pub async fn list_cleanable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1940 let rows = sqlx::query(&format!(
1941 "SELECT {TASK_COLS} FROM tasks
1942 WHERE repo = ?
1943 AND worktree != ''
1944 AND worktree_cleaned = 0
1945 AND status IN ('done', 'blocked')
1946 ORDER BY updated_at ASC"
1947 ))
1948 .bind(repo)
1949 .fetch_all(&self.pool)
1950 .await?;
1951
1952 rows.iter().map(Self::row_to_task).collect()
1953 }
1954
1955 pub async fn cleaned_external_ids(
1960 &self,
1961 repo: &str,
1962 ) -> anyhow::Result<std::collections::HashSet<String>> {
1963 let rows: Vec<(String,)> =
1964 sqlx::query_as("SELECT external_id FROM tasks WHERE repo = ? AND worktree_cleaned = 1 AND external_id IS NOT NULL")
1965 .bind(repo)
1966 .fetch_all(&self.pool)
1967 .await?;
1968
1969 Ok(rows.into_iter().map(|(id,)| id).collect())
1970 }
1971
1972 pub async fn start_run(&self, run: &StartRun<'_>) -> anyhow::Result<i64> {
1978 let orphaned: Vec<(i64,)> = sqlx::query_as(
1990 "SELECT id FROM task_runs
1991 WHERE task_id = ? AND run_type = ? AND attempt != ?
1992 AND outcome IS NULL AND completed_at IS NULL",
1993 )
1994 .bind(run.task_id)
1995 .bind(run.run_type)
1996 .bind(run.attempt)
1997 .fetch_all(&self.pool)
1998 .await?;
1999
2000 for (run_id,) in orphaned {
2001 self.complete_run(&CompleteRun {
2002 run_id,
2003 exit_code: Some(-1),
2004 stdout: "",
2005 stderr: "",
2006 parsed: "",
2007 outcome: "aborted",
2008 error: "superseded by a new run start before this one completed",
2009 tokens: RunTokenUsage::default(),
2010 })
2011 .await?;
2012 }
2013
2014 let row = sqlx::query(
2015 "INSERT INTO task_runs (task_id, attempt, run_type, agent, model, command, prompt, outcome)
2016 VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
2017 ON CONFLICT(task_id, attempt, run_type) DO UPDATE SET
2018 agent = excluded.agent, model = excluded.model,
2019 command = excluded.command, prompt = excluded.prompt,
2020 outcome = NULL,
2021 started_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
2022 completed_at = NULL, error = NULL, exit_code = NULL,
2023 stdout = NULL, stderr = NULL, parsed_response = NULL,
2024 duration_secs = NULL, input_tokens = NULL, output_tokens = NULL,
2025 total_cost_usd = NULL
2026 RETURNING id",
2027 )
2028 .bind(run.task_id)
2029 .bind(run.attempt)
2030 .bind(run.run_type)
2031 .bind(run.agent)
2032 .bind(run.model)
2033 .bind(run.command)
2034 .bind(run.prompt)
2035 .fetch_one(&self.pool)
2036 .await?;
2037
2038 let run_id: i64 = row
2039 .try_get("id")
2040 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
2041 let event_type = match run.run_type {
2042 "review" => "review_start",
2043 _ => "dispatch",
2044 };
2045 let details = serde_json::json!({
2046 "run_type": run.run_type,
2047 "attempt": run.attempt,
2048 "command": run.command,
2049 });
2050 self.append_activity(
2051 run.task_id,
2052 event_type,
2053 None,
2054 None,
2055 Some(run.agent),
2056 Some(run.model),
2057 Some(&details),
2058 )
2059 .await?;
2060
2061 Ok(run_id)
2062 }
2063
2064 pub async fn complete_run(&self, run: &CompleteRun<'_>) -> anyhow::Result<()> {
2066 let run_ctx =
2067 sqlx::query("SELECT task_id, run_type, agent, model FROM task_runs WHERE id = ?")
2068 .bind(run.run_id)
2069 .fetch_optional(&self.pool)
2070 .await?;
2071 sqlx::query(
2072 "UPDATE task_runs SET
2073 exit_code = ?, stdout = ?, stderr = ?, parsed_response = ?,
2074 outcome = ?, error = NULLIF(?, ''),
2075 input_tokens = ?, output_tokens = ?, total_cost_usd = ?,
2076 duration_secs = ?,
2077 completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
2078 WHERE id = ?",
2079 )
2080 .bind(run.exit_code)
2081 .bind(run.stdout)
2082 .bind(security::redact(run.stderr))
2083 .bind(run.parsed)
2084 .bind(run.outcome)
2085 .bind(security::redact(run.error))
2086 .bind(i64::try_from(run.tokens.input_tokens).unwrap_or_else(|_| {
2087 tracing::warn!(
2088 run.tokens.input_tokens,
2089 "input_tokens overflows i64, clamping to MAX"
2090 );
2091 i64::MAX
2092 }))
2093 .bind(i64::try_from(run.tokens.output_tokens).unwrap_or_else(|_| {
2094 tracing::warn!(
2095 run.tokens.output_tokens,
2096 "output_tokens overflows i64, clamping to MAX"
2097 );
2098 i64::MAX
2099 }))
2100 .bind(run.tokens.total_cost_usd)
2101 .bind(run.tokens.duration_secs)
2102 .bind(run.run_id)
2103 .execute(&self.pool)
2104 .await?;
2105 if let Some(run_ctx) = run_ctx {
2106 let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
2107 anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
2108 })?;
2109 let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
2110 anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
2111 })?;
2112 let agent: String = run_ctx.try_get("agent").map_err(|e| {
2113 anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
2114 })?;
2115 let model: String = run_ctx.try_get("model").map_err(|e| {
2116 anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
2117 })?;
2118 if run.outcome == "timeout" {
2119 self.append_activity(
2120 task_id,
2121 "timeout",
2122 None,
2123 None,
2124 Some(&agent),
2125 Some(&model),
2126 Some(&serde_json::json!({
2127 "run_type": run_type,
2128 "error": run.error,
2129 "stderr": run.stderr,
2130 })),
2131 )
2132 .await?;
2133 } else if run.outcome != "success" {
2134 self.append_activity(
2135 task_id,
2136 "error",
2137 None,
2138 None,
2139 Some(&agent),
2140 Some(&model),
2141 Some(&serde_json::json!({
2142 "run_type": run_type,
2143 "outcome": run.outcome,
2144 "error": run.error,
2145 })),
2146 )
2147 .await?;
2148 }
2149 } else if run.outcome != "success" {
2150 tracing::warn!(
2151 run_id = run.run_id,
2152 outcome = run.outcome,
2153 "run_id not found in task_runs; skipping timeout/error activity log"
2154 );
2155 }
2156 Ok(())
2157 }
2158
2159 pub async fn finalize_incomplete_runs(
2164 &self,
2165 task_id: i64,
2166 outcome: &str,
2167 error: &str,
2168 ) -> anyhow::Result<u64> {
2169 let rows: Vec<(i64,)> = sqlx::query_as(
2170 "SELECT id FROM task_runs
2171 WHERE task_id = ?
2172 AND outcome IS NULL
2173 AND completed_at IS NULL
2174 ORDER BY id ASC",
2175 )
2176 .bind(task_id)
2177 .fetch_all(&self.pool)
2178 .await?;
2179
2180 for (run_id,) in &rows {
2181 self.complete_run(&CompleteRun {
2182 run_id: *run_id,
2183 exit_code: Some(-1),
2184 stdout: "",
2185 stderr: "",
2186 parsed: "",
2187 outcome,
2188 error,
2189 tokens: RunTokenUsage::default(),
2190 })
2191 .await?;
2192 }
2193
2194 Ok(rows.len() as u64)
2195 }
2196
2197 pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
2199 let rows = sqlx::query(&format!(
2200 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
2201 ))
2202 .bind(task_id)
2203 .fetch_all(&self.pool)
2204 .await?;
2205
2206 rows.iter().map(Self::row_to_run).collect()
2207 }
2208
2209 pub async fn get_runs_batch(
2212 &self,
2213 task_ids: &[i64],
2214 ) -> anyhow::Result<std::collections::HashMap<i64, Vec<TaskRun>>> {
2215 if task_ids.is_empty() {
2216 return Ok(std::collections::HashMap::new());
2217 }
2218 const CHUNK_SIZE: usize = 500;
2221
2222 let mut result: std::collections::HashMap<i64, Vec<TaskRun>> =
2223 std::collections::HashMap::new();
2224
2225 for chunk in task_ids.chunks(CHUNK_SIZE) {
2226 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2227 let sql = format!(
2228 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
2229 );
2230 let mut query = sqlx::query(&sql);
2231 for id in chunk {
2232 query = query.bind(*id);
2233 }
2234 let rows = query.fetch_all(&self.pool).await?;
2235 for row in &rows {
2236 let run = Self::row_to_run(row)?;
2237 result.entry(run.task_id).or_default().push(run);
2238 }
2239 }
2240 Ok(result)
2241 }
2242
2243 pub async fn get_batch(
2246 &self,
2247 ids: &[i64],
2248 ) -> anyhow::Result<std::collections::HashMap<i64, Task>> {
2249 if ids.is_empty() {
2250 return Ok(std::collections::HashMap::new());
2251 }
2252 const CHUNK_SIZE: usize = 500;
2255
2256 let mut result = std::collections::HashMap::new();
2257
2258 for chunk in ids.chunks(CHUNK_SIZE) {
2259 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2260 let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
2261 let mut query = sqlx::query(&sql);
2262 for id in chunk {
2263 query = query.bind(*id);
2264 }
2265 let rows = query.fetch_all(&self.pool).await?;
2266 for row in &rows {
2267 let task = Self::row_to_task(row)?;
2268 result.insert(task.id, task);
2269 }
2270 }
2271 Ok(result)
2272 }
2273
2274 pub async fn get_statuses_by_external_ids(
2279 &self,
2280 repo: &str,
2281 ext_ids: &[&str],
2282 ) -> anyhow::Result<std::collections::HashMap<String, TaskStatus>> {
2283 let mut result = std::collections::HashMap::new();
2284 if ext_ids.is_empty() {
2285 return Ok(result);
2286 }
2287
2288 const CHUNK_SIZE: usize = 500;
2289 for chunk in ext_ids.chunks(CHUNK_SIZE) {
2290 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2291 let sql = format!(
2292 "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2293 );
2294 let mut query = sqlx::query(&sql);
2295 query = query.bind(repo);
2296 for id in chunk {
2297 query = query.bind(id);
2298 }
2299 let rows = query.fetch_all(&self.pool).await?;
2300 for row in &rows {
2301 let external_id: Result<String, _> = row.try_get("external_id");
2302 let status_str: Result<String, _> = row.try_get("status");
2303 match (external_id, status_str) {
2304 (Ok(ext_id), Ok(sts)) => {
2305 if ext_id.is_empty() {
2306 tracing::warn!(status = %sts, "skipping task row with empty external_id");
2307 continue;
2308 }
2309 if let Some(status) = TaskStatus::from_str(&sts) {
2310 result.insert(ext_id, status);
2311 } else {
2312 tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2313 }
2314 }
2315 (Err(e), _) => {
2316 tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2317 }
2318 (_, Err(e)) => {
2319 tracing::warn!(error = %e, "skipping task row with decode error for status");
2320 }
2321 }
2322 }
2323 }
2324
2325 Ok(result)
2326 }
2327
2328 #[cfg(test)]
2331 pub async fn get_last_run(
2332 &self,
2333 task_id: i64,
2334 run_type: &str,
2335 ) -> anyhow::Result<Option<TaskRun>> {
2336 let row = sqlx::query(&format!(
2337 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2338 ))
2339 .bind(task_id)
2340 .bind(run_type)
2341 .fetch_optional(&self.pool)
2342 .await?;
2343
2344 match row {
2345 Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2346 None => Ok(None),
2347 }
2348 }
2349
2350 pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2354 let rows: Vec<(String,)> = sqlx::query_as(
2355 "SELECT DISTINCT agent FROM task_runs \
2356 WHERE task_id = ? AND run_type = 'review' \
2357 ORDER BY agent",
2358 )
2359 .bind(task_id)
2360 .fetch_all(&self.pool)
2361 .await?;
2362 Ok(rows.into_iter().map(|(a,)| a).collect())
2363 }
2364
2365 #[cfg(test)]
2368 pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2369 let result = sqlx::query(
2370 "DELETE FROM task_runs WHERE task_id IN (
2371 SELECT id FROM tasks
2372 WHERE status IN ('done', 'blocked')
2373 AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2374 )",
2375 )
2376 .bind(format!("-{days}"))
2377 .execute(&self.pool)
2378 .await?;
2379
2380 Ok(result.rows_affected())
2381 }
2382
2383 fn row_to_task(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Task> {
2388 let col_count = row.len();
2391 if col_count != TASK_COLS_COUNT {
2392 tracing::warn!(
2393 expected = TASK_COLS_COUNT,
2394 actual = col_count,
2395 "TASK_COLS column count mismatch - schema may have changed"
2396 );
2397 }
2398
2399 let status_str: String = row
2400 .try_get("status")
2401 .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2402 let labels_str: String = row.try_get("labels").unwrap_or_default();
2403 let memory_str: String = row.try_get("memory").unwrap_or_default();
2404 let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2405
2406 Ok(Task {
2407 id: row
2408 .try_get("id")
2409 .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2410 external_id: row.try_get("external_id").unwrap_or(None),
2411 repo: row
2412 .try_get("repo")
2413 .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2414 origin: row
2415 .try_get("origin")
2416 .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2417 title: row
2418 .try_get("title")
2419 .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2420 body: row
2421 .try_get("body")
2422 .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2423 status: TaskStatus::from_str(&status_str)
2424 .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2425 source: row
2426 .try_get("source")
2427 .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2428 source_id: row
2429 .try_get("source_id")
2430 .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2431 author: row
2432 .try_get("author")
2433 .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2434 url: row
2435 .try_get("url")
2436 .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2437 labels: serde_json::from_str(&labels_str)
2438 .inspect_err(
2439 |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2440 )
2441 .unwrap_or_default(),
2442 agent: row.try_get("agent").unwrap_or(None),
2443 model: row.try_get("model").unwrap_or(None),
2444 complexity: row.try_get("complexity").unwrap_or_default(),
2445 estimate: {
2446 let raw: i32 = row.try_get("estimate").unwrap_or(0);
2447 u8::try_from(raw).unwrap_or_else(|_| {
2448 tracing::warn!(
2449 raw_estimate = raw,
2450 "estimate value out of u8 range; defaulting to 0"
2451 );
2452 0
2453 })
2454 },
2455 route_reason: row.try_get("route_reason").unwrap_or_default(),
2456 agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2457 selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2458 route_attempts: row.try_get("route_attempts").unwrap_or(0),
2459 attempts: row.try_get("attempts").unwrap_or(0),
2460 branch: row.try_get("branch").unwrap_or_default(),
2461 worktree: row.try_get("worktree").unwrap_or_default(),
2462 worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2463 summary: row.try_get("summary").unwrap_or_default(),
2464 last_error: row.try_get("last_error").unwrap_or_default(),
2465 parent_id: row.try_get("parent_id").unwrap_or(None),
2466 block_reason: row.try_get("block_reason").unwrap_or(None),
2467 pr_number: row.try_get("pr_number").unwrap_or(None),
2468 pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2469 last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2470 review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2471 last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2472 merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2473 ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2474 pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2475 push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2476 network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2477 review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2478 review_cycles: row.try_get("review_cycles").unwrap_or(0),
2479 review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2480 review_session_expected: row
2481 .try_get::<i32, _>("review_session_expected")
2482 .unwrap_or(0)
2483 != 0,
2484 needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2485 input_tokens: row.try_get("input_tokens").unwrap_or(0),
2486 output_tokens: row.try_get("output_tokens").unwrap_or(0),
2487 input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2488 output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2489 total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2490 model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2491 limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2492 memory: serde_json::from_str(&memory_str)
2493 .inspect_err(
2494 |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2495 )
2496 .unwrap_or_default(),
2497 delegations: serde_json::from_str(&delegations_str)
2498 .inspect_err(
2499 |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2500 )
2501 .unwrap_or_default(),
2502 auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2507 auto_unblock_last_at: row
2508 .try_get::<String, _>("auto_unblock_last_at")
2509 .unwrap_or_default(),
2510 auto_unblock_last_reason: row
2511 .try_get::<String, _>("auto_unblock_last_reason")
2512 .unwrap_or_default(),
2513 ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2514 no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2515 no_code_last_agent: row
2516 .try_get::<String, _>("no_code_last_agent")
2517 .unwrap_or_default(),
2518 created_at: row
2519 .try_get::<String, _>("created_at")
2520 .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2521 updated_at: row
2522 .try_get::<String, _>("updated_at")
2523 .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2524 })
2525 }
2526 pub async fn ensure_external_task(
2527 &self,
2528 repo: &str,
2529 ext: &crate::backends::ExternalTask,
2530 ) -> anyhow::Result<i64> {
2531 self.upsert_external(&UpsertExternal {
2532 repo,
2533 ext_id: &ext.id.0,
2534 title: &ext.title,
2535 body: &ext.body,
2536 author: &ext.author,
2537 url: &ext.url,
2538 labels: &ext.labels,
2539 origin: "github",
2540 })
2541 .await
2542 }
2543
2544 pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2547 if let Some(suffix) = task_id.strip_prefix("internal:") {
2548 if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2550 return Ok(Some(id));
2551 }
2552 if let Ok(id) = suffix.parse::<i64>() {
2556 let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2557 .bind(id)
2558 .bind(repo)
2559 .fetch_optional(&self.pool)
2560 .await?;
2561 if exists.is_some() {
2562 return Ok(Some(id));
2563 }
2564 }
2565 return Ok(None);
2566 }
2567 self.resolve_id_by_external(repo, task_id).await
2570 }
2571
2572 pub async fn resolve_task_id_globally(
2578 &self,
2579 task_id: &str,
2580 ) -> anyhow::Result<Option<(i64, String)>> {
2581 if let Some(suffix) = task_id.strip_prefix("internal:") {
2582 let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2584 .bind(task_id)
2585 .fetch_optional(&self.pool)
2586 .await?;
2587 if let Some(r) = row {
2588 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2589 }
2590 if let Ok(id) = suffix.parse::<i64>() {
2591 let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2592 .bind(id)
2593 .fetch_optional(&self.pool)
2594 .await?;
2595 if let Some(r) = row {
2596 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2597 }
2598 }
2599 return Ok(None);
2600 }
2601 let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2603 .bind(task_id)
2604 .fetch_all(&self.pool)
2605 .await?;
2606 match rows.len() {
2607 0 => Ok(None),
2608 1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2609 _ => {
2610 let repos: Vec<String> = rows
2611 .iter()
2612 .filter_map(|r| r.try_get::<String, _>("repo").ok())
2613 .collect();
2614 anyhow::bail!(
2615 "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2616 Run from the repo worktree so the current-repo lookup resolves it.",
2617 repos.join(", ")
2618 )
2619 }
2620 }
2621 }
2622
2623 pub async fn resolve_cli_id(
2625 &self,
2626 spec: &str,
2627 current_repo: Option<&str>,
2628 ) -> anyhow::Result<Option<CliTaskRef>> {
2629 let current_repo = current_repo.filter(|repo| !repo.is_empty());
2630 match parse_cli_task_spec(spec)? {
2631 CliTaskSpec::Internal(number) => self.lookup_internal_cli_id(number).await,
2632 CliTaskSpec::Issue { repo, number } => self.lookup_issue_cli_id(&repo, number).await,
2633 CliTaskSpec::Bare(number) => {
2634 if let Some(repo) = current_repo {
2635 if let Some(task) = self.lookup_issue_cli_id(repo, number).await? {
2636 return Ok(Some(task));
2637 }
2638 if let Some(task) = self.lookup_internal_in_repo(number, repo).await? {
2639 return Ok(Some(task));
2640 }
2641 return Ok(None);
2642 }
2643 let mut matches = self.lookup_bare_cli_id_matches(number).await?;
2644 match matches.len() {
2645 0 => Ok(None),
2646 1 => Ok(matches.pop()),
2647 _ => Err(ambiguous_cli_id_error(spec, &matches)),
2648 }
2649 }
2650 }
2651 }
2652
2653 async fn lookup_internal_cli_id(&self, number: i64) -> anyhow::Result<Option<CliTaskRef>> {
2654 let ext = format!("internal:{number}");
2655 let rows = sqlx::query(
2656 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2657 WHERE origin = 'internal' AND (id = ? OR external_id = ?) \
2658 ORDER BY id",
2659 )
2660 .bind(number)
2661 .bind(&ext)
2662 .fetch_all(&self.pool)
2663 .await?;
2664 let mut matches = dedup_cli_refs(rows)?;
2665 match matches.len() {
2666 0 => Ok(None),
2667 1 => Ok(matches.pop()),
2668 _ => Err(ambiguous_cli_id_error(&ext, &matches)),
2669 }
2670 }
2671
2672 async fn lookup_internal_in_repo(
2673 &self,
2674 number: i64,
2675 repo: &str,
2676 ) -> anyhow::Result<Option<CliTaskRef>> {
2677 let row = sqlx::query(
2678 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2679 WHERE origin = 'internal' AND id = ? AND repo = ?",
2680 )
2681 .bind(number)
2682 .bind(repo)
2683 .fetch_optional(&self.pool)
2684 .await?;
2685 match row {
2686 Some(row) => Ok(Some(CliTaskRef::from_row(&row)?)),
2687 None => Ok(None),
2688 }
2689 }
2690
2691 async fn lookup_issue_cli_id(
2692 &self,
2693 repo: &str,
2694 number: i64,
2695 ) -> anyhow::Result<Option<CliTaskRef>> {
2696 let ext = number.to_string();
2697 let rows = sqlx::query(
2698 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2699 WHERE repo = ? AND origin != 'internal' AND external_id = ? \
2700 ORDER BY id",
2701 )
2702 .bind(repo)
2703 .bind(&ext)
2704 .fetch_all(&self.pool)
2705 .await?;
2706 let mut matches = dedup_cli_refs(rows)?;
2707 match matches.len() {
2708 0 => Ok(None),
2709 1 => Ok(matches.pop()),
2710 _ => Err(ambiguous_cli_id_error(
2711 &format!("{repo}#{number}"),
2712 &matches,
2713 )),
2714 }
2715 }
2716
2717 async fn lookup_bare_cli_id_matches(&self, number: i64) -> anyhow::Result<Vec<CliTaskRef>> {
2718 let ext = number.to_string();
2719 let rows = sqlx::query(
2720 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2721 WHERE (origin = 'internal' AND id = ?) \
2722 OR (origin != 'internal' AND external_id = ?) \
2723 ORDER BY repo, id",
2724 )
2725 .bind(number)
2726 .bind(&ext)
2727 .fetch_all(&self.pool)
2728 .await?;
2729 dedup_cli_refs(rows)
2730 }
2731
2732 fn row_to_run(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskRun> {
2733 Ok(TaskRun {
2734 id: row
2735 .try_get("id")
2736 .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2737 task_id: row
2738 .try_get("task_id")
2739 .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2740 attempt: row
2741 .try_get("attempt")
2742 .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2743 run_type: row
2744 .try_get("run_type")
2745 .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2746 agent: row
2747 .try_get("agent")
2748 .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2749 model: row
2750 .try_get("model")
2751 .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2752 command: row
2753 .try_get("command")
2754 .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2755 prompt: row
2756 .try_get("prompt")
2757 .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2758 env_vars: row
2759 .try_get("env_vars")
2760 .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2761 exit_code: row
2762 .try_get("exit_code")
2763 .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2764 stdout: row
2765 .try_get("stdout")
2766 .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2767 stderr: row
2768 .try_get("stderr")
2769 .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2770 parsed_response: row
2771 .try_get("parsed_response")
2772 .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2773 outcome: row
2774 .try_get("outcome")
2775 .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2776 error: row
2777 .try_get("error")
2778 .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2779 input_tokens: row
2780 .try_get("input_tokens")
2781 .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2782 output_tokens: row
2783 .try_get("output_tokens")
2784 .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2785 total_cost_usd: row
2786 .try_get("total_cost_usd")
2787 .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2788 duration_secs: row
2789 .try_get("duration_secs")
2790 .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2791 started_at: row
2792 .try_get("started_at")
2793 .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2794 completed_at: row.try_get("completed_at").unwrap_or(None),
2795 })
2796 }
2797
2798 fn row_to_activity(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskActivity> {
2799 let details_str: String = row
2800 .try_get("details")
2801 .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2802 let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2803 |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2804 )?;
2805 Ok(TaskActivity {
2806 id: row
2807 .try_get("id")
2808 .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2809 task_id: row
2810 .try_get("task_id")
2811 .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2812 timestamp: row
2813 .try_get("timestamp")
2814 .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2815 event_type: row
2816 .try_get("event_type")
2817 .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2818 from_status: row
2819 .try_get("from_status")
2820 .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2821 to_status: row
2822 .try_get("to_status")
2823 .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2824 agent: row
2825 .try_get("agent")
2826 .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2827 model: row
2828 .try_get("model")
2829 .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2830 details,
2831 })
2832 }
2833}
2834
2835#[cfg(test)]
2836mod row_to_task_tests {
2837 use super::*;
2838
2839 async fn store_with_task() -> (crate::store::TaskStore, i64) {
2840 let store = crate::store::TaskStore::open_memory().await.unwrap();
2841 let id = store
2842 .create(&NewTask {
2843 external_id: None,
2844 repo: "owner/repo".to_string(),
2845 origin: "internal".to_string(),
2846 title: "Test task".to_string(),
2847 body: "Test body".to_string(),
2848 source: "cron".to_string(),
2849 source_id: "test-1".to_string(),
2850 author: "tester".to_string(),
2851 url: "https://example.com/1".to_string(),
2852 labels: vec![],
2853 parent_id: None,
2854 })
2855 .await
2856 .unwrap();
2857 (store, id)
2858 }
2859
2860 macro_rules! missing_column_test {
2863 ($name:ident, $col:literal, $select:literal) => {
2864 #[tokio::test]
2865 async fn $name() {
2866 let (store, id) = store_with_task().await;
2867 let sql = format!("{} WHERE id = {id}", $select);
2868 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2869 let err = TaskStore::row_to_task(&row).unwrap_err();
2870 assert!(
2871 err.to_string().contains($col),
2872 "expected '{}' in error message, got: {err}",
2873 $col
2874 );
2875 }
2876 };
2877 }
2878
2879 missing_column_test!(
2880 row_to_task_fails_on_missing_repo,
2881 "repo",
2882 "SELECT id, status FROM tasks"
2883 );
2884 missing_column_test!(
2885 row_to_task_fails_on_missing_origin,
2886 "origin",
2887 "SELECT id, repo, status FROM tasks"
2888 );
2889 missing_column_test!(
2890 row_to_task_fails_on_missing_title,
2891 "title",
2892 "SELECT id, repo, origin, status FROM tasks"
2893 );
2894 missing_column_test!(
2895 row_to_task_fails_on_missing_body,
2896 "body",
2897 "SELECT id, repo, origin, title, status FROM tasks"
2898 );
2899 missing_column_test!(
2900 row_to_task_fails_on_missing_source,
2901 "source",
2902 "SELECT id, repo, origin, title, body, status FROM tasks"
2903 );
2904 missing_column_test!(
2905 row_to_task_fails_on_missing_source_id,
2906 "source_id",
2907 "SELECT id, repo, origin, title, body, source, status FROM tasks"
2908 );
2909 missing_column_test!(
2910 row_to_task_fails_on_missing_author,
2911 "author",
2912 "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
2913 );
2914 missing_column_test!(
2915 row_to_task_fails_on_missing_url,
2916 "url",
2917 "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
2918 );
2919
2920 #[tokio::test]
2927 async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
2928 let (store, id) = store_with_task().await;
2929 let sql = format!(
2932 "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
2933 source, source_id, author, url FROM tasks WHERE id = {id}"
2934 );
2935 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
2936 let err = TaskStore::row_to_task(&row).unwrap_err();
2937 assert!(
2938 err.to_string().contains("repo"),
2939 "expected 'repo' in error message, got: {err}"
2940 );
2941 }
2942
2943 #[tokio::test]
2946 async fn distinct_repos_fails_on_decode_error() {
2947 let store = crate::store::TaskStore::open_memory().await.unwrap();
2948 let id = store
2949 .create(&NewTask {
2950 external_id: None,
2951 repo: "owner/repo".to_string(),
2952 origin: "internal".to_string(),
2953 title: "Test task".to_string(),
2954 body: "Test body".to_string(),
2955 source: "cron".to_string(),
2956 source_id: "test-1".to_string(),
2957 author: "tester".to_string(),
2958 url: "https://example.com/1".to_string(),
2959 labels: vec![],
2960 parent_id: None,
2961 })
2962 .await
2963 .unwrap();
2964
2965 let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
2967 sqlx::query(&sql).execute(store.pool()).await.unwrap();
2968
2969 let err = store.distinct_repos().await.unwrap_err();
2970 assert!(
2971 err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
2972 "expected 'distinct_repos' and 'repo' in error message, got: {err}"
2973 );
2974 }
2975
2976 async fn store_with_run() -> (crate::store::TaskStore, i64, i64) {
2979 let store = crate::store::TaskStore::open_memory().await.unwrap();
2980 let task_id = store
2981 .create(&NewTask {
2982 external_id: None,
2983 repo: "owner/repo".to_string(),
2984 origin: "internal".to_string(),
2985 title: "Test task".to_string(),
2986 body: "Test body".to_string(),
2987 source: "cron".to_string(),
2988 source_id: "test-1".to_string(),
2989 author: "tester".to_string(),
2990 url: "https://example.com/1".to_string(),
2991 labels: vec![],
2992 parent_id: None,
2993 })
2994 .await
2995 .unwrap();
2996 let run_id = store
2997 .start_run(&crate::store::StartRun {
2998 task_id,
2999 attempt: 1,
3000 run_type: "agent",
3001 agent: "claude",
3002 model: "sonnet",
3003 command: "claude -p ...",
3004 prompt: "system prompt",
3005 })
3006 .await
3007 .unwrap();
3008 (store, task_id, run_id)
3009 }
3010
3011 #[tokio::test]
3012 async fn start_run_upsert_clears_stale_completion_fields() {
3013 let (store, task_id, run_id) = store_with_run().await;
3014 store
3015 .complete_run(&CompleteRun {
3016 run_id,
3017 exit_code: Some(0),
3018 stdout: "",
3019 stderr: "",
3020 parsed: "",
3021 outcome: "success",
3022 error: "boom",
3023 tokens: RunTokenUsage::default(),
3024 })
3025 .await
3026 .unwrap();
3027
3028 let same_run_id = store
3030 .start_run(&crate::store::StartRun {
3031 task_id,
3032 attempt: 1,
3033 run_type: "agent",
3034 agent: "claude",
3035 model: "sonnet",
3036 command: "claude -p ...",
3037 prompt: "system prompt",
3038 })
3039 .await
3040 .unwrap();
3041 assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
3042
3043 let row = sqlx::query(
3044 "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
3045 )
3046 .bind(run_id)
3047 .fetch_one(store.pool())
3048 .await
3049 .unwrap();
3050 let outcome: Option<String> = row.try_get("outcome").unwrap();
3051 let completed_at: Option<String> = row.try_get("completed_at").unwrap();
3052 let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
3053 let error: Option<String> = row.try_get("error").unwrap();
3054 assert_eq!(outcome, None);
3055 assert_eq!(
3056 completed_at, None,
3057 "stale completion timestamp must be cleared"
3058 );
3059 assert_eq!(exit_code, None);
3060 assert_eq!(error, None);
3061 }
3062
3063 macro_rules! run_blob_decode_test {
3070 ($name:ident, $col:literal, $select:literal) => {
3071 #[tokio::test]
3072 async fn $name() {
3073 let (store, _task_id, run_id) = store_with_run().await;
3074 let sql = format!($select, run_id = run_id);
3075 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3076 let err = TaskStore::row_to_run(&row).unwrap_err();
3077 assert!(
3078 err.to_string().contains($col),
3079 "expected '{0}' in error message, got: {err}",
3080 $col
3081 );
3082 }
3083 };
3084 }
3085
3086 run_blob_decode_test!(
3087 row_to_run_fails_on_decode_error_invalid_utf8_id,
3088 "id",
3089 "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}"
3090 );
3091 run_blob_decode_test!(
3092 row_to_run_fails_on_decode_error_invalid_utf8_task_id,
3093 "task_id",
3094 "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}"
3095 );
3096 run_blob_decode_test!(
3097 row_to_run_fails_on_decode_error_invalid_utf8_started_at,
3098 "started_at",
3099 "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}"
3100 );
3101
3102 async fn store_with_activity() -> (crate::store::TaskStore, i64, i64) {
3105 let store = crate::store::TaskStore::open_memory().await.unwrap();
3106 let task_id = store
3107 .create(&NewTask {
3108 external_id: None,
3109 repo: "owner/repo".to_string(),
3110 origin: "internal".to_string(),
3111 title: "Test task".to_string(),
3112 body: "Test body".to_string(),
3113 source: "cron".to_string(),
3114 source_id: "test-1".to_string(),
3115 author: "tester".to_string(),
3116 url: "https://example.com/1".to_string(),
3117 labels: vec![],
3118 parent_id: None,
3119 })
3120 .await
3121 .unwrap();
3122 store
3123 .append_activity(task_id, "created", None, Some("new"), None, None, None)
3124 .await
3125 .unwrap();
3126 (store, task_id, 1)
3127 }
3128
3129 #[tokio::test]
3132 async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
3133 let (store, _task_id, activity_id) = store_with_activity().await;
3134 let sql = format!(
3135 "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3136 );
3137 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3138 let err = TaskStore::row_to_activity(&row).unwrap_err();
3139 assert!(
3140 err.to_string().contains("id"),
3141 "expected 'id' in error message, got: {err}"
3142 );
3143 }
3144
3145 #[tokio::test]
3146 async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
3147 let (store, _task_id, activity_id) = store_with_activity().await;
3148 let sql = format!(
3149 "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3150 );
3151 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3152 let err = TaskStore::row_to_activity(&row).unwrap_err();
3153 assert!(
3154 err.to_string().contains("task_id"),
3155 "expected 'task_id' in error message, got: {err}"
3156 );
3157 }
3158
3159 #[tokio::test]
3160 async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
3161 let (store, _task_id, activity_id) = store_with_activity().await;
3162 let sql = format!(
3163 "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3164 );
3165 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3166 let err = TaskStore::row_to_activity(&row).unwrap_err();
3167 assert!(
3168 err.to_string().contains("timestamp"),
3169 "expected 'timestamp' in error message, got: {err}"
3170 );
3171 }
3172
3173 #[tokio::test]
3174 async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
3175 let (store, _task_id, activity_id) = store_with_activity().await;
3176 let sql = format!(
3177 "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3178 );
3179 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3180 let err = TaskStore::row_to_activity(&row).unwrap_err();
3181 assert!(
3182 err.to_string().contains("event_type"),
3183 "expected 'event_type' in error message, got: {err}"
3184 );
3185 }
3186
3187 #[tokio::test]
3188 async fn row_to_activity_fails_on_corrupt_details_json() {
3189 let (store, task_id, _activity_id) = store_with_activity().await;
3190 sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
3192 .bind(task_id)
3193 .bind("test")
3194 .bind("not valid json {{{")
3195 .execute(store.pool())
3196 .await
3197 .unwrap();
3198 let row = sqlx::query(&format!(
3199 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
3200 ))
3201 .fetch_one(store.pool())
3202 .await
3203 .unwrap();
3204 let err = TaskStore::row_to_activity(&row).unwrap_err();
3205 assert!(
3206 err.to_string().contains("details")
3207 || err.to_string().contains("json")
3208 || err.to_string().contains("expected"),
3209 "expected 'details', 'json', or 'expected' in error message, got: {err}"
3210 );
3211 }
3212
3213 run_blob_decode_test!(
3216 row_to_run_fails_on_decode_error_invalid_utf8_model,
3217 "model",
3218 "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}"
3219 );
3220 run_blob_decode_test!(
3221 row_to_run_fails_on_decode_error_invalid_utf8_command,
3222 "command",
3223 "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}"
3224 );
3225 run_blob_decode_test!(
3226 row_to_run_fails_on_decode_error_invalid_utf8_prompt,
3227 "prompt",
3228 "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}"
3229 );
3230 run_blob_decode_test!(
3231 row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
3232 "env_vars",
3233 "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}"
3234 );
3235 run_blob_decode_test!(
3236 row_to_run_fails_on_decode_error_invalid_utf8_stdout,
3237 "stdout",
3238 "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}"
3239 );
3240 run_blob_decode_test!(
3241 row_to_run_fails_on_decode_error_invalid_utf8_stderr,
3242 "stderr",
3243 "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}"
3244 );
3245 run_blob_decode_test!(
3246 row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
3247 "parsed_response",
3248 "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}"
3249 );
3250 run_blob_decode_test!(
3251 row_to_run_fails_on_decode_error_invalid_utf8_outcome,
3252 "outcome",
3253 "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}"
3254 );
3255 run_blob_decode_test!(
3256 row_to_run_fails_on_decode_error_invalid_utf8_error,
3257 "error",
3258 "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}"
3259 );
3260 run_blob_decode_test!(
3261 row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
3262 "input_tokens",
3263 "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}"
3264 );
3265 run_blob_decode_test!(
3266 row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
3267 "output_tokens",
3268 "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}"
3269 );
3270 run_blob_decode_test!(
3271 row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
3272 "total_cost_usd",
3273 "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}"
3274 );
3275 run_blob_decode_test!(
3276 row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3277 "duration_secs",
3278 "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}"
3279 );
3280 run_blob_decode_test!(
3281 row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3282 "exit_code",
3283 "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}"
3284 );
3285
3286 #[tokio::test]
3293 async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3294 let (store, _task_id, activity_id) = store_with_activity().await;
3295 let sql = format!(
3296 "SELECT id, task_id, timestamp, event_type, \
3297 X'DeadbEEF' as from_status, NULL as to_status, \
3298 NULL as agent, NULL as model, details \
3299 FROM task_activity WHERE id = {activity_id}"
3300 );
3301 let row = sqlx::query(&sql).fetch_one(store.pool()).await.unwrap();
3302 let err = TaskStore::row_to_activity(&row).unwrap_err();
3303 assert!(
3304 err.to_string().contains("from_status"),
3305 "expected 'from_status' in error message, got: {err}"
3306 );
3307 }
3308
3309 #[tokio::test]
3310 async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_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 NULL as from_status, X'DeadbEEF' 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("to_status"),
3322 "expected 'to_status' in error message, got: {err}"
3323 );
3324 }
3325
3326 #[tokio::test]
3327 async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
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, NULL as to_status, \
3332 X'DeadbEEF' 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("agent"),
3339 "expected 'agent' in error message, got: {err}"
3340 );
3341 }
3342
3343 #[tokio::test]
3344 async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
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 NULL as agent, X'DeadbEEF' 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("model"),
3356 "expected 'model' in error message, got: {err}"
3357 );
3358 }
3359
3360 #[tokio::test]
3363 async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3364 let store = crate::store::TaskStore::open_memory().await.unwrap();
3369 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3370 .execute(store.pool())
3371 .await
3372 .unwrap();
3373 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3375 .execute(store.pool())
3376 .await
3377 .unwrap();
3378
3379 let result = store
3380 .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3381 .await
3382 .unwrap();
3383 assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3384 assert_eq!(
3385 result.get("valid-id"),
3386 Some(&crate::store::TaskStatus::Done)
3387 );
3388 }
3389}