1use super::*;
2use crate::cluster::Command;
3use crate::security;
4use serde::{Deserialize, Serialize};
5use sqlx::Row;
6
7pub(crate) const TASK_COLS: &str = "id, external_id, repo, origin, title, body, status, \
15 source, source_id, author, url, labels, agent, model, complexity, estimate, \
16 route_reason, agent_profile, selected_skills, route_attempts, attempts, \
17 branch, worktree, worktree_cleaned, summary, last_error, parent_id, \
18 block_reason, pr_number, pr_review_context, last_review_ts, review_ts_map, \
19 last_comment_review_ts, merge_conflict_retries, ci_merge_failures, \
20 pr_create_failures, review_agent_failures, review_resets, review_cycles, input_tokens, \
21 output_tokens, input_cost_usd, output_cost_usd, total_cost_usd, \
22 model_reroute_chain, limit_reroute_chain, \
23 memory, delegations, created_at, updated_at, \
24 review_invocations, needs_review_refires, push_failures, auto_unblock_count, auto_unblock_last_at, \
25 ci_recovery_count, auto_unblock_last_reason, no_code_reroutes, network_retries, no_code_last_agent, \
26 owner_node";
27
28pub(crate) const TASK_COLS_COUNT: usize = 61;
30
31const TASK_RUN_COLS: &str =
33 "id, task_id, attempt, run_type, agent, model, command, prompt, env_vars, \
34 exit_code, stdout, stderr, parsed_response, outcome, error, input_tokens, output_tokens, \
35 total_cost_usd, duration_secs, started_at, completed_at";
36
37const TASK_ACTIVITY_COLS: &str =
39 "id, task_id, timestamp, event_type, from_status, to_status, agent, model, details";
40
41const ALLOWED_FIELDS: &[&str] = &[
43 "agent",
44 "model",
45 "complexity",
46 "route_reason",
47 "agent_profile",
48 "selected_skills",
49 "route_attempts",
50 "attempts",
51 "branch",
52 "worktree",
53 "worktree_cleaned",
54 "owner_node",
55 "summary",
56 "last_error",
57 "parent_id",
58 "block_reason",
59 "pr_number",
60 "pr_review_context",
61 "last_review_ts",
62 "review_ts_map",
63 "last_comment_review_ts",
64 "merge_conflict_retries",
65 "ci_merge_failures",
66 "pr_create_failures",
67 "push_failures",
68 "review_agent_failures",
69 "review_resets",
70 "review_cycles",
71 "review_invocations",
72 "needs_review_refires",
73 "input_tokens",
74 "output_tokens",
75 "input_cost_usd",
76 "output_cost_usd",
77 "total_cost_usd",
78 "model_reroute_chain",
79 "limit_reroute_chain",
80 "memory",
81 "delegations",
82 "source",
83 "source_id",
84 "labels",
85 "auto_unblock_count",
86 "auto_unblock_last_at",
87 "auto_unblock_last_reason",
88 "ci_recovery_count",
89 "no_code_reroutes",
90 "network_retries",
91 "no_code_last_agent",
92];
93
94const INCREMENTABLE_FIELDS: &[&str] = &[
96 "attempts",
97 "route_attempts",
98 "merge_conflict_retries",
99 "ci_merge_failures",
100 "pr_create_failures",
101 "push_failures",
102 "review_agent_failures",
103 "review_resets",
104 "review_cycles",
105 "review_invocations",
106 "needs_review_refires",
107 "auto_unblock_count",
108 "ci_recovery_count",
109 "no_code_reroutes",
110 "network_retries",
111];
112
113fn sanitize_activity_details(value: &serde_json::Value) -> serde_json::Value {
114 match value {
115 serde_json::Value::String(text) => serde_json::Value::String(security::redact(text)),
116 serde_json::Value::Array(items) => serde_json::Value::Array(
117 items
118 .iter()
119 .map(sanitize_activity_details)
120 .collect::<Vec<_>>(),
121 ),
122 serde_json::Value::Object(map) => serde_json::Value::Object(
123 map.iter()
124 .map(|(key, value)| (key.clone(), sanitize_activity_details(value)))
125 .collect(),
126 ),
127 other => other.clone(),
128 }
129}
130
131fn sanitize_task_field_value(col: &str, value: &serde_json::Value) -> serde_json::Value {
132 match (col, value) {
133 ("last_error" | "block_reason", serde_json::Value::String(text)) => {
134 serde_json::Value::String(security::redact(text))
135 }
136 _ => value.clone(),
137 }
138}
139
140fn sql_value_for_task_field(
141 col: &str,
142 value: &serde_json::Value,
143) -> anyhow::Result<Option<String>> {
144 let sanitized = sanitize_task_field_value(col, value);
145 match sanitized {
146 serde_json::Value::String(text) => Ok(Some(text)),
147 serde_json::Value::Number(number) => Ok(Some(number.to_string())),
148 serde_json::Value::Bool(boolean) => Ok(Some(if boolean { "1" } else { "0" }.to_string())),
149 serde_json::Value::Null => Ok(None),
150 other => Ok(Some(serde_json::to_string(&other)?)),
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum TaskStatus {
157 New,
158 Routed,
159 InProgress,
160 Done,
161 Blocked,
162 InReview,
163 Approved,
164 NeedsReview,
165 Waiting,
166}
167
168impl TaskStatus {
169 pub fn as_str(&self) -> &'static str {
170 match self {
171 Self::New => "new",
172 Self::Routed => "routed",
173 Self::InProgress => "in_progress",
174 Self::Done => "done",
175 Self::Blocked => "blocked",
176 Self::InReview => "in_review",
177 Self::Approved => "approved",
178 Self::NeedsReview => "needs_review",
179 Self::Waiting => "waiting",
180 }
181 }
182
183 pub fn from_str(s: &str) -> Option<Self> {
184 match s {
185 "new" => Some(Self::New),
186 "routed" => Some(Self::Routed),
187 "in_progress" => Some(Self::InProgress),
188 "done" => Some(Self::Done),
189 "blocked" => Some(Self::Blocked),
190 "in_review" => Some(Self::InReview),
191 "approved" => Some(Self::Approved),
192 "needs_review" => Some(Self::NeedsReview),
193 "waiting" => Some(Self::Waiting),
194 _ => None,
195 }
196 }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct Task {
202 pub id: i64,
203 pub external_id: Option<String>,
204 pub repo: String,
205 pub origin: String,
206
207 pub title: String,
208 pub body: String,
209 pub status: TaskStatus,
210 pub source: String,
211 pub source_id: String,
212 pub author: String,
213 pub url: String,
214 pub labels: Vec<String>,
215
216 pub agent: Option<String>,
218 pub model: Option<String>,
219 pub complexity: String,
220 pub estimate: u8,
222 pub route_reason: String,
223 pub agent_profile: String,
224 pub selected_skills: String,
225 pub route_attempts: i32,
226
227 pub attempts: i32,
229 pub branch: String,
230 pub worktree: String,
231 pub worktree_cleaned: bool,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub owner_node: Option<String>,
235 pub summary: String,
236 pub last_error: String,
237 pub parent_id: Option<i64>,
238 pub block_reason: Option<String>,
239
240 pub pr_number: Option<i32>,
242 pub pr_review_context: String,
243 pub last_review_ts: String,
244 pub review_ts_map: String,
245 pub last_comment_review_ts: String,
246 pub merge_conflict_retries: i32,
247 pub ci_merge_failures: i32,
248 pub pr_create_failures: i32,
249 pub push_failures: i32,
250 pub network_retries: i32,
251 pub review_agent_failures: i32,
252 pub review_resets: i32,
254 pub review_cycles: i32,
255 pub review_invocations: i32,
256 pub needs_review_refires: i32,
258
259 pub input_tokens: i64,
261 pub output_tokens: i64,
262 pub input_cost_usd: f64,
263 pub output_cost_usd: f64,
264 pub total_cost_usd: f64,
265
266 pub model_reroute_chain: String,
268 pub limit_reroute_chain: String,
269
270 pub memory: Vec<MemoryEntry>,
272 pub delegations: Vec<serde_json::Value>,
273
274 pub auto_unblock_count: i32,
276 pub auto_unblock_last_at: String,
277 pub auto_unblock_last_reason: String,
278
279 pub ci_recovery_count: i32,
281
282 pub no_code_reroutes: i32,
284 pub no_code_last_agent: String,
286
287 pub created_at: String,
289 pub updated_at: String,
290}
291
292#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
294pub struct NewTask {
295 pub external_id: Option<String>,
296 pub repo: String,
297 pub origin: String,
298 pub title: String,
299 pub body: String,
300 pub source: String,
301 pub source_id: String,
302 pub author: String,
303 pub url: String,
304 pub labels: Vec<String>,
305 pub parent_id: Option<i64>,
307}
308
309#[derive(Debug, Clone, PartialEq, Eq)]
311pub enum CliTaskSpec {
312 Internal(i64),
314 Issue { repo: String, number: i64 },
316 Bare(i64),
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct CliTaskRef {
323 pub store_id: Option<i64>,
324 pub repo: String,
325 pub origin: String,
326 pub title: String,
327 pub external_id: String,
328 pub status: String,
329}
330
331impl CliTaskRef {
332 pub fn is_internal(&self) -> bool {
333 self.origin == "internal"
334 }
335
336 pub fn unsynced_issue(repo: String, number: i64) -> Self {
337 Self {
338 store_id: None,
339 repo,
340 origin: "github".to_string(),
341 title: String::new(),
342 external_id: number.to_string(),
343 status: String::new(),
344 }
345 }
346
347 pub fn display_spec(&self) -> String {
348 if self.is_internal() {
349 match self.store_id {
350 Some(id) => format!("internal:{id}"),
351 None => self.external_id.clone(),
352 }
353 } else {
354 format!("{}#{}", self.repo, self.external_id)
355 }
356 }
357
358 fn from_row(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Self> {
359 let id: i64 = row.try_get("id")?;
360 let origin: String = row.try_get("origin")?;
361 let external_id: Option<String> = row.try_get("external_id")?;
362 let external_id = external_id.unwrap_or_else(|| {
363 if origin == "internal" {
364 format!("internal:{id}")
365 } else {
366 String::new()
367 }
368 });
369 Ok(Self {
370 store_id: Some(id),
371 repo: row.try_get("repo")?,
372 origin,
373 title: row.try_get("title")?,
374 external_id,
375 status: row.try_get("status")?,
376 })
377 }
378
379 fn format_match(&self) -> String {
380 format!(
381 " {} [{}] {}",
382 self.display_spec(),
383 if self.status.is_empty() {
384 "unknown"
385 } else {
386 &self.status
387 },
388 if self.title.is_empty() {
389 "-"
390 } else {
391 &self.title
392 }
393 )
394 }
395}
396
397pub fn parse_cli_task_spec(raw: &str) -> anyhow::Result<CliTaskSpec> {
399 let spec = raw.trim();
400 if spec.is_empty() {
401 anyhow::bail!("empty task id");
402 }
403 if let Some(suffix) = spec.strip_prefix("internal:") {
404 let number: i64 = suffix
405 .parse()
406 .map_err(|_| anyhow::anyhow!("internal task id '{suffix}' is not numeric"))?;
407 if number <= 0 {
408 anyhow::bail!("internal task id must be a positive number");
409 }
410 return Ok(CliTaskSpec::Internal(number));
411 }
412 if let Some((repo, number)) = parse_repo_issue(spec) {
413 return Ok(CliTaskSpec::Issue { repo, number });
414 }
415 let bare = spec.strip_prefix('#').unwrap_or(spec);
416 if let Ok(number) = bare.parse::<i64>() {
417 if number > 0 {
418 return Ok(CliTaskSpec::Bare(number));
419 }
420 }
421 anyhow::bail!("invalid task id '{spec}': use a number, owner/repo#N, or internal:N")
422}
423
424fn parse_repo_issue(spec: &str) -> Option<(String, i64)> {
425 let (repo, number) = spec.rsplit_once('#')?;
426 if repo.is_empty() {
427 return None;
428 }
429 let number: i64 = number.parse().ok()?;
430 if number <= 0 {
431 return None;
432 }
433 let mut parts = repo.split('/');
434 let owner = parts.next()?;
435 let name = parts.next()?;
436 if parts.next().is_some() || owner.is_empty() || name.is_empty() {
437 return None;
438 }
439 let ok = |part: &str| {
440 part.chars()
441 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
442 };
443 if !ok(owner) || !ok(name) {
444 return None;
445 }
446 Some((repo.to_string(), number))
447}
448
449fn ambiguous_cli_id_error(spec: &str, matches: &[CliTaskRef]) -> anyhow::Error {
450 let mut lines = vec![format!("task ID {spec} matches more than one task:")];
451 for task in matches {
452 lines.push(task.format_match());
453 }
454 lines.push(ambiguous_cli_id_hint(matches));
455 anyhow::anyhow!(lines.join("\n"))
456}
457
458fn ambiguous_cli_id_hint(matches: &[CliTaskRef]) -> String {
459 let github = matches
460 .iter()
461 .find(|task| !task.is_internal())
462 .map(CliTaskRef::display_spec);
463 let internal = matches
464 .iter()
465 .find(|task| task.is_internal())
466 .map(CliTaskRef::display_spec);
467 match (github, internal) {
468 (Some(github), Some(internal)) => {
469 format!("Use {github} for the GitHub task or {internal} for the internal one")
470 }
471 _ => "Use owner/repo#N for an issue, or internal:N for an internal task.".to_string(),
472 }
473}
474
475fn dedup_cli_refs(rows: Vec<sqlx::sqlite::SqliteRow>) -> anyhow::Result<Vec<CliTaskRef>> {
476 let mut seen = Vec::new();
477 let mut out = Vec::new();
478 for row in rows {
479 let task = CliTaskRef::from_row(&row)?;
480 if let Some(id) = task.store_id {
481 if seen.contains(&id) {
482 continue;
483 }
484 seen.push(id);
485 }
486 out.push(task);
487 }
488 Ok(out)
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct TaskRun {
494 pub id: i64,
495 pub task_id: i64,
496 pub attempt: i32,
497 pub run_type: String,
498 pub agent: String,
499 pub model: String,
500 pub command: String,
501 pub prompt: String,
502 pub env_vars: String,
503 pub exit_code: Option<i32>,
504 pub stdout: String,
505 pub stderr: String,
506 pub parsed_response: String,
507 pub outcome: String,
508 pub error: String,
509 pub input_tokens: i64,
510 pub output_tokens: i64,
511 pub total_cost_usd: f64,
512 pub duration_secs: f64,
513 pub started_at: String,
514 pub completed_at: Option<String>,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct TaskActivity {
520 pub id: i64,
521 pub task_id: i64,
522 pub timestamp: String,
523 pub event_type: String,
524 pub from_status: Option<String>,
525 pub to_status: Option<String>,
526 pub agent: Option<String>,
527 pub model: Option<String>,
528 pub details: serde_json::Value,
529}
530
531#[derive(Debug, Clone, Copy, Default)]
533pub struct RunTokenUsage {
534 pub input_tokens: u64,
535 pub output_tokens: u64,
536 pub total_cost_usd: f64,
537 pub duration_secs: f64,
538}
539
540#[derive(Debug, Clone)]
542pub struct StartRun<'a> {
543 pub task_id: i64,
544 pub attempt: i32,
545 pub run_type: &'a str,
546 pub agent: &'a str,
547 pub model: &'a str,
548 pub command: &'a str,
549 pub prompt: &'a str,
550}
551
552#[derive(Debug, Clone)]
554pub struct CompleteRun<'a> {
555 pub run_id: i64,
556 pub exit_code: Option<i32>,
557 pub stdout: &'a str,
558 pub stderr: &'a str,
559 pub parsed: &'a str,
560 pub outcome: &'a str,
561 pub error: &'a str,
562 pub tokens: RunTokenUsage,
563}
564
565#[derive(Debug, Clone)]
567pub struct StoreRoute<'a> {
568 pub id: i64,
569 pub agent: &'a str,
570 pub model: Option<&'a str>,
571 pub complexity: &'a str,
572 pub estimate: u8,
574 pub reason: &'a str,
575 pub profile: &'a str,
576 pub skills: &'a str,
577}
578
579#[derive(Debug, Clone)]
581pub struct UpsertExternal<'a> {
582 pub repo: &'a str,
583 pub ext_id: &'a str,
584 pub title: &'a str,
585 pub body: &'a str,
586 pub author: &'a str,
587 pub url: &'a str,
588 pub labels: &'a [String],
589 pub origin: &'a str,
590}
591
592fn parent_external_id_from_labels(labels: &[String]) -> Option<&str> {
593 labels
594 .iter()
595 .find_map(|label| label.strip_prefix("parent:"))
596 .filter(|parent| !parent.is_empty())
597}
598
599fn owned_updates(
600 updates: &[(&str, serde_json::Value)],
601) -> anyhow::Result<Vec<(String, serde_json::Value)>> {
602 updates
603 .iter()
604 .map(|(col, val)| {
605 anyhow::ensure!(
606 ALLOWED_FIELDS.contains(col),
607 "column {col} is not in the update allowlist"
608 );
609 Ok((col.to_string(), val.clone()))
610 })
611 .collect()
612}
613
614impl TaskStore {
615 #[allow(clippy::too_many_arguments)]
617 pub async fn append_activity(
618 &self,
619 task_id: i64,
620 event_type: &str,
621 from_status: Option<&str>,
622 to_status: Option<&str>,
623 agent: Option<&str>,
624 model: Option<&str>,
625 details: Option<&serde_json::Value>,
626 ) -> anyhow::Result<()> {
627 let details_json = match details {
628 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
629 None => "{}".to_string(),
630 };
631 sqlx::query(
632 "INSERT INTO task_activity
633 (task_id, event_type, from_status, to_status, agent, model, details)
634 VALUES (?, ?, ?, ?, ?, ?, ?)",
635 )
636 .bind(task_id)
637 .bind(event_type)
638 .bind(from_status)
639 .bind(to_status)
640 .bind(agent)
641 .bind(model)
642 .bind(details_json)
643 .execute(&self.pool)
644 .await?;
645 Ok(())
646 }
647
648 #[doc(hidden)]
650 #[allow(clippy::too_many_arguments)]
651 pub async fn append_activity_tx(
652 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
653 task_id: i64,
654 event_type: &str,
655 from_status: Option<&str>,
656 to_status: Option<&str>,
657 agent: Option<&str>,
658 model: Option<&str>,
659 details: Option<&serde_json::Value>,
660 ) -> anyhow::Result<()> {
661 let details_json = match details {
662 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
663 None => "{}".to_string(),
664 };
665 sqlx::query(
666 "INSERT INTO task_activity
667 (task_id, event_type, from_status, to_status, agent, model, details)
668 VALUES (?, ?, ?, ?, ?, ?, ?)",
669 )
670 .bind(task_id)
671 .bind(event_type)
672 .bind(from_status)
673 .bind(to_status)
674 .bind(agent)
675 .bind(model)
676 .bind(details_json)
677 .execute(&mut **tx)
678 .await?;
679 Ok(())
680 }
681
682 pub async fn in_review_entry(&self, task_id: i64) -> anyhow::Result<Option<(String, bool)>> {
684 let row: Option<(String, bool)> = sqlx::query_as(
685 "SELECT a.timestamp, EXISTS (
686 SELECT 1 FROM task_activity d
687 WHERE d.task_id = a.task_id AND d.event_type = 'review_decision' AND d.id > a.id
688 )
689 FROM task_activity a
690 WHERE a.task_id = ? AND a.event_type = 'status_change' AND a.to_status = 'in_review'
691 ORDER BY a.id DESC LIMIT 1",
692 )
693 .bind(task_id)
694 .fetch_optional(&self.pool)
695 .await?;
696 Ok(row)
697 }
698
699 pub async fn get_activity(
701 &self,
702 task_id: i64,
703 limit: Option<usize>,
704 ) -> anyhow::Result<Vec<TaskActivity>> {
705 let rows = if let Some(limit) = limit {
706 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
707 sqlx::query(sqlx::AssertSqlSafe(format!(
708 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC LIMIT ?"
709 )))
710 .bind(task_id)
711 .bind(limit_i64)
712 .fetch_all(&self.pool)
713 .await?
714 } else {
715 sqlx::query(sqlx::AssertSqlSafe(format!(
716 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = ? ORDER BY timestamp ASC, id ASC"
717 )))
718 .bind(task_id)
719 .fetch_all(&self.pool)
720 .await?
721 };
722
723 rows.iter().map(Self::row_to_activity).collect()
724 }
725
726 pub async fn create(&self, new: &NewTask) -> anyhow::Result<i64> {
727 self.write_int(Command::TaskCreate {
728 new: new.clone(),
729 internal: false,
730 })
731 .await
732 }
733
734 pub async fn create_internal(
739 &self,
740 repo: &str,
741 title: &str,
742 body: &str,
743 source: &str,
744 source_id: &str,
745 parent_id: Option<i64>,
746 ) -> anyhow::Result<i64> {
747 let new = NewTask {
748 external_id: None,
749 repo: repo.to_string(),
750 origin: "internal".to_string(),
751 title: title.to_string(),
752 body: body.to_string(),
753 source: source.to_string(),
754 source_id: source_id.to_string(),
755 author: String::new(),
756 url: String::new(),
757 labels: vec![],
758 parent_id,
759 };
760 self.write_int(Command::TaskCreate {
761 new,
762 internal: true,
763 })
764 .await
765 }
766
767 pub async fn get(&self, id: i64) -> anyhow::Result<Task> {
769 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
770 "SELECT {TASK_COLS} FROM tasks WHERE id = ?"
771 )))
772 .bind(id)
773 .fetch_optional(&self.pool)
774 .await?
775 .with_context(|| format!("task {id} not found"))?;
776
777 Self::row_to_task(&row)
778 }
779
780 pub async fn get_by_external_id(
782 &self,
783 repo: &str,
784 ext_id: &str,
785 ) -> anyhow::Result<Option<Task>> {
786 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
787 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND external_id = ?"
788 )))
789 .bind(repo)
790 .bind(ext_id)
791 .fetch_optional(&self.pool)
792 .await?;
793
794 match row {
795 Some(r) => Ok(Some(Self::row_to_task(&r)?)),
796 None => Ok(None),
797 }
798 }
799
800 pub async fn resolve_id_by_external(
806 &self,
807 repo: &str,
808 ext_id: &str,
809 ) -> anyhow::Result<Option<i64>> {
810 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND external_id = ?")
811 .bind(repo)
812 .bind(ext_id)
813 .fetch_optional(&self.pool)
814 .await?;
815 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
816 Ok(id)
817 }
818
819 pub async fn resolve_id_by_worktree(&self, path: &str) -> anyhow::Result<Option<i64>> {
821 if path.is_empty() {
822 return Ok(None);
823 }
824
825 let row = sqlx::query("SELECT id FROM tasks WHERE worktree = ? LIMIT 1")
826 .bind(path)
827 .fetch_optional(&self.pool)
828 .await?;
829 Ok(row.map(|r| r.try_get::<i64, _>("id")).transpose()?)
830 }
831
832 pub async fn resolve_id_by_pr_number(
838 &self,
839 repo: &str,
840 pr_number: i32,
841 ) -> anyhow::Result<Option<i64>> {
842 let row = sqlx::query("SELECT id FROM tasks WHERE repo = ? AND pr_number = ? LIMIT 1")
843 .bind(repo)
844 .bind(pr_number)
845 .fetch_optional(&self.pool)
846 .await?;
847 let id = row.map(|r| r.try_get::<i64, _>("id")).transpose()?;
848 Ok(id)
849 }
850
851 pub async fn get_agent_by_external_id(
854 &self,
855 repo: &str,
856 ext_id: &str,
857 ) -> anyhow::Result<Option<String>> {
858 let row = sqlx::query("SELECT agent FROM tasks WHERE repo = ? AND external_id = ?")
859 .bind(repo)
860 .bind(ext_id)
861 .fetch_optional(&self.pool)
862 .await?;
863 let agent = row
864 .map(|r| r.try_get::<Option<String>, _>("agent"))
865 .transpose()?
866 .flatten();
867 Ok(agent)
868 }
869
870 pub async fn upsert_external(&self, ext: &UpsertExternal<'_>) -> anyhow::Result<i64> {
872 let parent_id = match parent_external_id_from_labels(ext.labels) {
873 Some(parent_external_id) => self.resolve_task_id(ext.repo, parent_external_id).await?,
874 None => None,
875 };
876 self.write_int(Command::TaskUpsertExternal(UpsertExternalCmd {
877 repo: ext.repo.to_string(),
878 ext_id: ext.ext_id.to_string(),
879 title: ext.title.to_string(),
880 body: ext.body.to_string(),
881 author: ext.author.to_string(),
882 url: ext.url.to_string(),
883 labels: ext.labels.to_vec(),
884 origin: ext.origin.to_string(),
885 parent_id,
886 }))
887 .await
888 }
889
890 pub async fn update_status_and_fields(
895 &self,
896 id: i64,
897 status: TaskStatus,
898 updates: &[(&str, serde_json::Value)],
899 ) -> anyhow::Result<()> {
900 let mut updates = owned_updates(updates)?;
901 if self.replicates() {
903 let local: Vec<_> = updates
904 .iter()
905 .filter(|(col, _)| is_local_column(col))
906 .cloned()
907 .collect();
908 updates.retain(|(col, _)| !is_local_column(col));
909
910 if !local.is_empty() {
911 let mut tx = self.begin_write().await?;
912 apply::set_fields(&mut tx, &crate::cluster::now_stamp(), id, &local, true).await?;
913 tx.commit().await?;
914 }
915 }
916
917 self.write(Command::TaskUpdateStatusAndFields {
918 id,
919 status,
920 updates,
921 })
922 .await
923 .map(|_| ())
924 }
925
926 pub async fn update_status(&self, id: i64, status: TaskStatus) -> anyhow::Result<()> {
928 self.write_bool(Command::TaskUpdateStatus {
929 id,
930 status,
931 expected: None,
932 })
933 .await
934 .map(|_| ())
935 }
936
937 pub async fn update_external_id(&self, id: i64, external_id: &str) -> anyhow::Result<()> {
939 self.write(Command::TaskUpdateExternalId {
940 id,
941 external_id: external_id.to_string(),
942 })
943 .await
944 .map(|_| ())
945 }
946
947 pub async fn update_status_if(
952 &self,
953 id: i64,
954 status: TaskStatus,
955 expected: TaskStatus,
956 ) -> anyhow::Result<bool> {
957 self.write_bool(Command::TaskUpdateStatus {
958 id,
959 status,
960 expected: Some(expected),
961 })
962 .await
963 }
964
965 pub async fn touch_updated_at(&self, id: i64) -> anyhow::Result<()> {
973 self.write(Command::TaskTouch { id }).await.map(|_| ())
974 }
975
976 pub async fn reset_to_new(&self, id: i64) -> anyhow::Result<()> {
978 self.write(Command::TaskResetToNew { id }).await.map(|_| ())
979 }
980
981 pub async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()> {
983 let value = reason
984 .map(|r| serde_json::Value::String(r.to_string()))
985 .unwrap_or(serde_json::Value::Null);
986 self.set_fields(id, &[("block_reason", value)]).await
987 }
988
989 pub async fn list_by_status(
991 &self,
992 repo: &str,
993 status: TaskStatus,
994 ) -> anyhow::Result<Vec<Task>> {
995 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
996 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status = ? ORDER BY created_at DESC"
997 )))
998 .bind(repo)
999 .bind(status.as_str())
1000 .fetch_all(&self.pool)
1001 .await?;
1002
1003 rows.iter().map(Self::row_to_task).collect()
1004 }
1005
1006 pub async fn list_children(&self, repo: &str, parent_id: i64) -> anyhow::Result<Vec<Task>> {
1008 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1009 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND parent_id = ? ORDER BY created_at ASC"
1010 )))
1011 .bind(repo)
1012 .bind(parent_id)
1013 .fetch_all(&self.pool)
1014 .await?;
1015
1016 rows.iter().map(Self::row_to_task).collect()
1017 }
1018
1019 pub async fn list_external_by_status(
1021 &self,
1022 repo: &str,
1023 status: TaskStatus,
1024 ) -> anyhow::Result<Vec<Task>> {
1025 let rows = sqlx::query(
1026 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' AND status = ? ORDER BY created_at DESC")),
1027 )
1028 .bind(repo)
1029 .bind(status.as_str())
1030 .fetch_all(&self.pool)
1031 .await?;
1032
1033 rows.iter().map(Self::row_to_task).collect()
1034 }
1035
1036 pub async fn list_routable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1038 self.list_by_status(repo, TaskStatus::New).await
1039 }
1040
1041 pub async fn list_internal_by_status(
1043 &self,
1044 repo: &str,
1045 status: TaskStatus,
1046 ) -> anyhow::Result<Vec<Task>> {
1047 let rows = sqlx::query(
1048 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND status = ? ORDER BY created_at DESC")),
1049 )
1050 .bind(repo)
1051 .bind(status.as_str())
1052 .fetch_all(&self.pool)
1053 .await?;
1054
1055 rows.iter().map(Self::row_to_task).collect()
1056 }
1057
1058 pub async fn list_all_internal(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1060 let rows = sqlx::query(
1061 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' ORDER BY created_at DESC")),
1062 )
1063 .bind(repo)
1064 .fetch_all(&self.pool)
1065 .await?;
1066
1067 rows.iter().map(Self::row_to_task).collect()
1068 }
1069
1070 pub async fn list_internal_by_source(
1073 &self,
1074 repo: &str,
1075 source: &str,
1076 ) -> anyhow::Result<Vec<Task>> {
1077 let rows = sqlx::query(
1078 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? ORDER BY created_at DESC")),
1079 )
1080 .bind(repo)
1081 .bind(source)
1082 .fetch_all(&self.pool)
1083 .await?;
1084
1085 rows.iter().map(Self::row_to_task).collect()
1086 }
1087
1088 pub async fn list_source_ids_by_source(
1093 &self,
1094 repo: &str,
1095 source: &str,
1096 since_days: i64,
1097 ) -> anyhow::Result<Vec<String>> {
1098 let cutoff = format!("-{since_days} days");
1099 let rows = sqlx::query(
1100 "SELECT source_id FROM tasks WHERE repo = ? AND origin = 'internal' AND source = ? AND created_at >= datetime('now', ?)",
1101 )
1102 .bind(repo)
1103 .bind(source)
1104 .bind(&cutoff)
1105 .fetch_all(&self.pool)
1106 .await?;
1107
1108 let mut ids = Vec::with_capacity(rows.len());
1109 for row in &rows {
1110 ids.push(row.try_get::<String, _>("source_id").map_err(|e| {
1111 anyhow::anyhow!(
1112 "list_source_ids_by_source: failed to decode source_id for repo={repo}, source={source}: {e}"
1113 )
1114 })?);
1115 }
1116 Ok(ids)
1117 }
1118
1119 pub async fn list_active(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1121 let rows = sqlx::query(
1122 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND status != 'done' ORDER BY created_at DESC")),
1123 )
1124 .bind(repo)
1125 .fetch_all(&self.pool)
1126 .await?;
1127
1128 rows.iter().map(Self::row_to_task).collect()
1129 }
1130
1131 pub async fn list_all_external(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1133 let rows = sqlx::query(
1134 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND origin != 'internal' ORDER BY created_at DESC")),
1135 )
1136 .bind(repo)
1137 .fetch_all(&self.pool)
1138 .await?;
1139
1140 rows.iter().map(Self::row_to_task).collect()
1141 }
1142
1143 pub async fn has_external_tasks(&self, repo: &str) -> anyhow::Result<bool> {
1145 let has_external = sqlx::query_scalar::<_, i32>(
1146 "SELECT 1 FROM tasks WHERE repo = ? AND origin != 'internal' LIMIT 1",
1147 )
1148 .bind(repo)
1149 .fetch_optional(&self.pool)
1150 .await?
1151 .is_some();
1152 Ok(has_external)
1153 }
1154
1155 pub async fn existing_external_ids(
1159 &self,
1160 repo: &str,
1161 ) -> anyhow::Result<std::collections::HashSet<String>> {
1162 let rows: Vec<(String,)> = sqlx::query_as(
1163 "SELECT external_id FROM tasks WHERE repo = ? AND origin != 'internal' AND external_id IS NOT NULL",
1164 )
1165 .bind(repo)
1166 .fetch_all(&self.pool)
1167 .await?;
1168
1169 Ok(rows
1170 .into_iter()
1171 .filter_map(|(id,)| if id.is_empty() { None } else { Some(id) })
1172 .collect())
1173 }
1174
1175 pub async fn list_for_doctor(&self, repo: &str, cutoff_str: &str) -> anyhow::Result<Vec<Task>> {
1178 let rows = sqlx::query(
1179 sqlx::AssertSqlSafe(format!("SELECT {TASK_COLS} FROM tasks WHERE repo = ? AND (status != 'done' OR updated_at >= ?) ORDER BY created_at DESC")),
1180 )
1181 .bind(repo)
1182 .bind(cutoff_str)
1183 .fetch_all(&self.pool)
1184 .await?;
1185 rows.iter().map(Self::row_to_task).collect()
1186 }
1187
1188 pub async fn list_all(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1190 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1191 "SELECT {TASK_COLS} FROM tasks WHERE repo = ? ORDER BY created_at DESC"
1192 )))
1193 .bind(repo)
1194 .fetch_all(&self.pool)
1195 .await?;
1196 rows.iter().map(Self::row_to_task).collect()
1197 }
1198
1199 pub async fn distinct_repos(&self) -> anyhow::Result<Vec<String>> {
1201 let rows =
1202 sqlx::query("SELECT DISTINCT repo FROM tasks WHERE repo IS NOT NULL AND repo != ''")
1203 .fetch_all(&self.pool)
1204 .await?;
1205 let mut repos = Vec::new();
1206 for row in &rows {
1207 repos.push(row.try_get::<String, _>("repo").map_err(|e| {
1208 anyhow::anyhow!("distinct_repos: failed to decode 'repo' column: {e}")
1209 })?);
1210 }
1211 Ok(repos)
1212 }
1213
1214 pub async fn list_all_active_global(&self) -> anyhow::Result<Vec<Task>> {
1219 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1220 "SELECT {TASK_COLS} FROM tasks WHERE status != 'done' ORDER BY created_at DESC"
1221 )))
1222 .fetch_all(&self.pool)
1223 .await?;
1224 rows.iter().map(Self::row_to_task).collect()
1225 }
1226
1227 pub async fn list_all_global(&self) -> anyhow::Result<Vec<Task>> {
1231 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1232 "SELECT {TASK_COLS} FROM tasks ORDER BY created_at DESC"
1233 )))
1234 .fetch_all(&self.pool)
1235 .await?;
1236 rows.iter().map(Self::row_to_task).collect()
1237 }
1238
1239 pub async fn list_all_by_status_global(&self, status: TaskStatus) -> anyhow::Result<Vec<Task>> {
1243 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1244 "SELECT {TASK_COLS} FROM tasks WHERE status = ? ORDER BY created_at DESC"
1245 )))
1246 .bind(status.as_str())
1247 .fetch_all(&self.pool)
1248 .await?;
1249 rows.iter().map(Self::row_to_task).collect()
1250 }
1251
1252 pub async fn set_fields(
1255 &self,
1256 id: i64,
1257 updates: &[(&str, serde_json::Value)],
1258 ) -> anyhow::Result<()> {
1259 self.set_fields_split(id, updates, false).await
1260 }
1261
1262 pub async fn set_fields_silent(
1267 &self,
1268 id: i64,
1269 updates: &[(&str, serde_json::Value)],
1270 ) -> anyhow::Result<()> {
1271 self.set_fields_split(id, updates, true).await
1272 }
1273
1274 async fn set_fields_split(
1276 &self,
1277 id: i64,
1278 updates: &[(&str, serde_json::Value)],
1279 silent: bool,
1280 ) -> anyhow::Result<()> {
1281 let all = owned_updates(updates)?;
1282 if !self.replicates() {
1284 let mut tx = self.begin_write().await?;
1285 apply::set_fields(&mut tx, &crate::cluster::now_stamp(), id, &all, silent).await?;
1286 tx.commit().await?;
1287 return Ok(());
1288 }
1289
1290 let (local, replicated): (Vec<_>, Vec<_>) =
1291 all.into_iter().partition(|(col, _)| is_local_column(col));
1292 if !local.is_empty() {
1293 let mut tx = self.begin_write().await?;
1294 apply::set_fields(&mut tx, &crate::cluster::now_stamp(), id, &local, silent).await?;
1295 tx.commit().await?;
1296 }
1297
1298 if replicated.is_empty() {
1299 return Ok(());
1300 }
1301
1302 self.write(Command::TaskSetFields {
1303 id,
1304 updates: replicated,
1305 silent,
1306 })
1307 .await
1308 .map(|_| ())
1309 }
1310
1311 pub async fn increment(&self, id: i64, field: &str) -> anyhow::Result<i32> {
1313 anyhow::ensure!(
1314 INCREMENTABLE_FIELDS.contains(&field),
1315 "field {field} is not incrementable"
1316 );
1317 let n = self
1318 .write_int(Command::TaskIncrement {
1319 id,
1320 field: field.to_string(),
1321 })
1322 .await?;
1323 Ok(i32::try_from(n)?)
1324 }
1325
1326 pub async fn reset_counters(&self, id: i64) -> anyhow::Result<()> {
1328 self.write(Command::TaskResetCounters {
1329 id,
1330 failure_only: false,
1331 })
1332 .await
1333 .map(|_| ())
1334 }
1335
1336 pub async fn reset_failure_counters(&self, id: i64) -> anyhow::Result<()> {
1351 self.write(Command::TaskResetCounters {
1352 id,
1353 failure_only: true,
1354 })
1355 .await
1356 .map(|_| ())
1357 }
1358
1359 pub async fn store_route(&self, route: &StoreRoute<'_>) -> anyhow::Result<()> {
1365 self.write(Command::TaskStoreRoute(StoreRouteCmd {
1366 id: route.id,
1367 agent: route.agent.to_string(),
1368 model: route.model.map(str::to_string),
1369 complexity: route.complexity.to_string(),
1370 estimate: route.estimate,
1371 reason: route.reason.to_string(),
1372 profile: route.profile.to_string(),
1373 skills: route.skills.to_string(),
1374 }))
1375 .await
1376 .map(|_| ())
1377 }
1378
1379 pub async fn list_external_tasks_with_estimates(&self) -> anyhow::Result<Vec<(String, u8)>> {
1387 let rows = sqlx::query(
1388 "SELECT external_id, estimate FROM tasks \
1389 WHERE estimate > 0 AND external_id NOT LIKE 'internal:%' \
1390 AND status NOT IN ('done', 'blocked')",
1391 )
1392 .fetch_all(&self.pool)
1393 .await?;
1394
1395 let mut result = Vec::new();
1396 for row in &rows {
1397 let external_id: String = row.try_get("external_id")?;
1398 let estimate_raw: i64 = row.try_get("estimate")?;
1399 if (1..=255).contains(&estimate_raw) {
1400 result.push((external_id, estimate_raw as u8));
1401 }
1402 }
1403 Ok(result)
1404 }
1405
1406 pub async fn set_estimate_if_zero(&self, id: i64, estimate: u8) -> anyhow::Result<bool> {
1412 self.write_bool(Command::TaskSetEstimateIfZero { id, estimate })
1413 .await
1414 }
1415
1416 pub async fn append_memory(&self, id: i64, entry: &MemoryEntry) -> anyhow::Result<()> {
1425 self.write(Command::TaskAppendMemory {
1426 id,
1427 entry: entry.clone(),
1428 })
1429 .await
1430 .map(|_| ())
1431 }
1432
1433 pub async fn recent_memory(&self, id: i64, max: usize) -> anyhow::Result<Vec<MemoryEntry>> {
1435 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
1436 .bind(id)
1437 .fetch_one(&self.pool)
1438 .await?;
1439
1440 let memory_opt: Option<String> = row
1441 .try_get::<Option<String>, _>("memory")
1442 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
1443 let memory_str = memory_opt.unwrap_or_default();
1444
1445 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
1446 Vec::new()
1447 } else {
1448 serde_json::from_str(&memory_str)
1449 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
1450 };
1451
1452 memory.sort_by_key(|m| m.attempt);
1453 if memory.len() > max {
1454 memory = memory.split_off(memory.len() - max);
1455 }
1456 Ok(memory)
1457 }
1458
1459 pub async fn store_tokens(
1465 &self,
1466 id: i64,
1467 input: u64,
1468 output: u64,
1469 model: &str,
1470 ) -> anyhow::Result<()> {
1471 let pricing = pricing_for_model(model);
1472 let usage = TokenUsage {
1473 input_tokens: input,
1474 output_tokens: output,
1475 };
1476 let cost = pricing.estimate_cost_usd(usage);
1477
1478 sqlx::query(
1479 "UPDATE tasks SET
1480 input_tokens = COALESCE(input_tokens, 0) + ?,
1481 output_tokens = COALESCE(output_tokens, 0) + ?,
1482 input_cost_usd = COALESCE(input_cost_usd, 0.0) + ?,
1483 output_cost_usd = COALESCE(output_cost_usd, 0.0) + ?,
1484 total_cost_usd = COALESCE(total_cost_usd, 0.0) + ?,
1485 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1486 WHERE id = ?",
1487 )
1488 .bind(i64::try_from(input).unwrap_or_else(|_| {
1489 tracing::warn!(input, "input_tokens overflows i64, clamping to MAX");
1490 i64::MAX
1491 }))
1492 .bind(i64::try_from(output).unwrap_or_else(|_| {
1493 tracing::warn!(output, "output_tokens overflows i64, clamping to MAX");
1494 i64::MAX
1495 }))
1496 .bind(cost.input_cost_usd)
1497 .bind(cost.output_cost_usd)
1498 .bind(cost.total_cost_usd)
1499 .bind(id)
1500 .execute(&self.pool)
1501 .await?;
1502
1503 Ok(())
1504 }
1505
1506 pub async fn mark_cleaned(&self, id: i64) -> anyhow::Result<()> {
1512 self.write(Command::TaskMarkCleaned { id })
1513 .await
1514 .map(|_| ())
1515 }
1516
1517 #[cfg(test)]
1523 pub async fn batch_set_fields(
1524 &self,
1525 updates: &[(i64, &[(&str, serde_json::Value)])],
1526 ) -> anyhow::Result<()> {
1527 if updates.is_empty() {
1528 return Ok(());
1529 }
1530
1531 for (_, entry_updates) in updates {
1532 for (col, _) in *entry_updates {
1533 anyhow::ensure!(
1534 ALLOWED_FIELDS.contains(col),
1535 "column {col} is not in the update allowlist"
1536 );
1537 }
1538 }
1539
1540 let mut tx = self.begin_write().await?;
1541 for (id, entry_updates) in updates {
1542 if entry_updates.is_empty() {
1543 continue;
1544 }
1545 let mut set_parts = Vec::new();
1546 let mut values: Vec<Option<String>> = Vec::new();
1547 for (col, val) in *entry_updates {
1548 set_parts.push(format!("{col} = ?"));
1549 values.push(sql_value_for_task_field(col, val)?);
1550 }
1551 set_parts.push("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')".to_string());
1552 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
1553 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1554 for v in &values {
1555 query = query.bind(v.as_deref());
1556 }
1557 query = query.bind(*id);
1558 query.execute(&mut *tx).await?;
1559 }
1560 tx.commit().await?;
1561 Ok(())
1562 }
1563
1564 #[cfg(test)]
1569 pub async fn batch_increment(&self, entries: &[(i64, &str)]) -> anyhow::Result<()> {
1570 if entries.is_empty() {
1571 return Ok(());
1572 }
1573
1574 for (_, field) in entries {
1575 anyhow::ensure!(
1576 INCREMENTABLE_FIELDS.contains(field),
1577 "field {field} is not incrementable"
1578 );
1579 }
1580
1581 let mut tx = self.begin_write().await?;
1582 for (id, field) in entries {
1583 let sql = format!(
1584 "UPDATE tasks SET {field} = {field} + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
1585 );
1586 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
1587 .bind(*id)
1588 .execute(&mut *tx)
1589 .await?;
1590 }
1591 tx.commit().await?;
1592 Ok(())
1593 }
1594
1595 #[cfg(test)]
1602 pub async fn batch_reset_failure_counters(&self, ids: &[i64]) -> anyhow::Result<()> {
1603 if ids.is_empty() {
1604 return Ok(());
1605 }
1606 const CHUNK_SIZE: usize = 500;
1609
1610 let mut tx = self.begin_write().await?;
1611
1612 for chunk in ids.chunks(CHUNK_SIZE) {
1613 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1615 let sql = format!(
1616 "UPDATE tasks SET
1617 route_attempts = 0,
1618 review_agent_failures = 0,
1619 review_resets = 0,
1620 review_invocations = 0,
1621 pr_create_failures = 0,
1622 push_failures = 0,
1623 network_retries = 0,
1624 auto_unblock_count = 0,
1625 auto_unblock_last_at = '',
1626 auto_unblock_last_reason = '',
1627 ci_recovery_count = 0,
1628 no_code_reroutes = 0,
1629 needs_review_refires = 0,
1630 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1631 WHERE id IN ({placeholders})",
1632 );
1633 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1634 for id in chunk {
1635 query = query.bind(*id);
1636 }
1637 query.execute(&mut *tx).await?;
1638 }
1639
1640 tx.commit().await?;
1641 Ok(())
1642 }
1643
1644 #[cfg(test)]
1649 pub async fn batch_mark_cleaned(&self, ids: &[i64]) -> anyhow::Result<()> {
1650 if ids.is_empty() {
1651 return Ok(());
1652 }
1653
1654 const CHUNK_SIZE: usize = 500;
1656
1657 let mut tx = self.begin_write().await?;
1658
1659 for chunk in ids.chunks(CHUNK_SIZE) {
1660 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1661 let sql = format!(
1662 "UPDATE tasks SET worktree_cleaned = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id IN ({placeholders})",
1663 );
1664 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1665 for id in chunk {
1666 query = query.bind(*id);
1667 }
1668 query.execute(&mut *tx).await?;
1669 }
1670
1671 let details = serde_json::json!({ "worktree_cleaned": true });
1675 let details_json = serde_json::to_string(&details)?;
1676 for id in ids {
1677 sqlx::query(
1678 "INSERT INTO task_activity (task_id, event_type, from_status, to_status, agent, model, details) VALUES (?, ?, ?, ?, ?, ?, ?)",
1679 )
1680 .bind(*id)
1681 .bind("branch_delete")
1682 .bind(None::<&str>)
1683 .bind(None::<&str>)
1684 .bind(None::<&str>)
1685 .bind(None::<&str>)
1686 .bind(details_json.as_str())
1687 .execute(&mut *tx)
1688 .await?;
1689 }
1690
1691 tx.commit().await?;
1692 Ok(())
1693 }
1694
1695 #[cfg(test)]
1698 pub async fn list_cleanable(&self, repo: &str) -> anyhow::Result<Vec<Task>> {
1699 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1700 "SELECT {TASK_COLS} FROM tasks
1701 WHERE repo = ?
1702 AND worktree != ''
1703 AND worktree_cleaned = 0
1704 AND status IN ('done', 'blocked')
1705 ORDER BY updated_at ASC"
1706 )))
1707 .bind(repo)
1708 .fetch_all(&self.pool)
1709 .await?;
1710
1711 rows.iter().map(Self::row_to_task).collect()
1712 }
1713
1714 pub async fn cleaned_external_ids(
1719 &self,
1720 repo: &str,
1721 ) -> anyhow::Result<std::collections::HashSet<String>> {
1722 let rows: Vec<(String,)> =
1723 sqlx::query_as("SELECT external_id FROM tasks WHERE repo = ? AND worktree_cleaned = 1 AND external_id IS NOT NULL")
1724 .bind(repo)
1725 .fetch_all(&self.pool)
1726 .await?;
1727
1728 Ok(rows.into_iter().map(|(id,)| id).collect())
1729 }
1730
1731 pub async fn start_run(&self, run: &StartRun<'_>) -> anyhow::Result<i64> {
1737 let orphaned: Vec<(i64,)> = sqlx::query_as(
1749 "SELECT id FROM task_runs
1750 WHERE task_id = ? AND run_type = ? AND attempt != ?
1751 AND outcome IS NULL AND completed_at IS NULL",
1752 )
1753 .bind(run.task_id)
1754 .bind(run.run_type)
1755 .bind(run.attempt)
1756 .fetch_all(&self.pool)
1757 .await?;
1758
1759 for (run_id,) in orphaned {
1760 self.complete_run(&CompleteRun {
1761 run_id,
1762 exit_code: Some(-1),
1763 stdout: "",
1764 stderr: "",
1765 parsed: "",
1766 outcome: "aborted",
1767 error: "superseded by a new run start before this one completed",
1768 tokens: RunTokenUsage::default(),
1769 })
1770 .await?;
1771 }
1772
1773 let row = sqlx::query(
1774 "INSERT INTO task_runs (task_id, attempt, run_type, agent, model, command, prompt, outcome)
1775 VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
1776 ON CONFLICT(task_id, attempt, run_type) DO UPDATE SET
1777 agent = excluded.agent, model = excluded.model,
1778 command = excluded.command, prompt = excluded.prompt,
1779 outcome = NULL,
1780 started_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
1781 completed_at = NULL, error = NULL, exit_code = NULL,
1782 stdout = NULL, stderr = NULL, parsed_response = NULL,
1783 duration_secs = NULL, input_tokens = NULL, output_tokens = NULL,
1784 total_cost_usd = NULL
1785 RETURNING id",
1786 )
1787 .bind(run.task_id)
1788 .bind(run.attempt)
1789 .bind(run.run_type)
1790 .bind(run.agent)
1791 .bind(run.model)
1792 .bind(run.command)
1793 .bind(run.prompt)
1794 .fetch_one(&self.pool)
1795 .await?;
1796
1797 let run_id: i64 = row
1798 .try_get("id")
1799 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
1800 let event_type = match run.run_type {
1801 "review" => "review_start",
1802 _ => "dispatch",
1803 };
1804 let details = serde_json::json!({
1805 "run_type": run.run_type,
1806 "attempt": run.attempt,
1807 "command": run.command,
1808 });
1809 self.append_activity(
1810 run.task_id,
1811 event_type,
1812 None,
1813 None,
1814 Some(run.agent),
1815 Some(run.model),
1816 Some(&details),
1817 )
1818 .await?;
1819
1820 Ok(run_id)
1821 }
1822
1823 pub async fn complete_run(&self, run: &CompleteRun<'_>) -> anyhow::Result<()> {
1825 let run_ctx =
1826 sqlx::query("SELECT task_id, run_type, agent, model FROM task_runs WHERE id = ?")
1827 .bind(run.run_id)
1828 .fetch_optional(&self.pool)
1829 .await?;
1830 sqlx::query(
1831 "UPDATE task_runs SET
1832 exit_code = ?, stdout = ?, stderr = ?, parsed_response = ?,
1833 outcome = ?, error = NULLIF(?, ''),
1834 input_tokens = ?, output_tokens = ?, total_cost_usd = ?,
1835 duration_secs = CASE WHEN ? > 0 THEN ?
1836 ELSE MAX(0, (julianday('now') - julianday(started_at)) * 86400.0) END,
1837 completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1838 WHERE id = ?",
1839 )
1840 .bind(run.exit_code)
1841 .bind(run.stdout)
1842 .bind(security::redact(run.stderr))
1843 .bind(run.parsed)
1844 .bind(run.outcome)
1845 .bind(security::redact(run.error))
1846 .bind(i64::try_from(run.tokens.input_tokens).unwrap_or_else(|_| {
1847 tracing::warn!(
1848 run.tokens.input_tokens,
1849 "input_tokens overflows i64, clamping to MAX"
1850 );
1851 i64::MAX
1852 }))
1853 .bind(i64::try_from(run.tokens.output_tokens).unwrap_or_else(|_| {
1854 tracing::warn!(
1855 run.tokens.output_tokens,
1856 "output_tokens overflows i64, clamping to MAX"
1857 );
1858 i64::MAX
1859 }))
1860 .bind(run.tokens.total_cost_usd)
1861 .bind(run.tokens.duration_secs)
1862 .bind(run.tokens.duration_secs)
1863 .bind(run.run_id)
1864 .execute(&self.pool)
1865 .await?;
1866 if let Some(run_ctx) = run_ctx {
1867 let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
1868 anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
1869 })?;
1870 let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
1871 anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
1872 })?;
1873 let agent: String = run_ctx.try_get("agent").map_err(|e| {
1874 anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
1875 })?;
1876 let model: String = run_ctx.try_get("model").map_err(|e| {
1877 anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
1878 })?;
1879 if run.outcome == "timeout" {
1880 self.append_activity(
1881 task_id,
1882 "timeout",
1883 None,
1884 None,
1885 Some(&agent),
1886 Some(&model),
1887 Some(&serde_json::json!({
1888 "run_type": run_type,
1889 "error": run.error,
1890 "stderr": run.stderr,
1891 })),
1892 )
1893 .await?;
1894 } else if run.outcome != "success" {
1895 self.append_activity(
1896 task_id,
1897 "error",
1898 None,
1899 None,
1900 Some(&agent),
1901 Some(&model),
1902 Some(&serde_json::json!({
1903 "run_type": run_type,
1904 "outcome": run.outcome,
1905 "error": run.error,
1906 })),
1907 )
1908 .await?;
1909 }
1910 } else if run.outcome != "success" {
1911 tracing::warn!(
1912 run_id = run.run_id,
1913 outcome = run.outcome,
1914 "run_id not found in task_runs; skipping timeout/error activity log"
1915 );
1916 }
1917 Ok(())
1918 }
1919
1920 pub async fn finalize_incomplete_runs(
1925 &self,
1926 task_id: i64,
1927 outcome: &str,
1928 error: &str,
1929 ) -> anyhow::Result<u64> {
1930 let rows: Vec<(i64,)> = sqlx::query_as(
1931 "SELECT id FROM task_runs
1932 WHERE task_id = ?
1933 AND outcome IS NULL
1934 AND completed_at IS NULL
1935 ORDER BY id ASC",
1936 )
1937 .bind(task_id)
1938 .fetch_all(&self.pool)
1939 .await?;
1940
1941 for (run_id,) in &rows {
1942 self.complete_run(&CompleteRun {
1943 run_id: *run_id,
1944 exit_code: Some(-1),
1945 stdout: "",
1946 stderr: "",
1947 parsed: "",
1948 outcome,
1949 error,
1950 tokens: RunTokenUsage::default(),
1951 })
1952 .await?;
1953 }
1954
1955 Ok(rows.len() as u64)
1956 }
1957
1958 pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
1960 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1961 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
1962 )))
1963 .bind(task_id)
1964 .fetch_all(&self.pool)
1965 .await?;
1966
1967 rows.iter().map(Self::row_to_run).collect()
1968 }
1969
1970 pub async fn get_runs_batch(
1973 &self,
1974 task_ids: &[i64],
1975 ) -> anyhow::Result<std::collections::HashMap<i64, Vec<TaskRun>>> {
1976 if task_ids.is_empty() {
1977 return Ok(std::collections::HashMap::new());
1978 }
1979 const CHUNK_SIZE: usize = 500;
1982
1983 let mut result: std::collections::HashMap<i64, Vec<TaskRun>> =
1984 std::collections::HashMap::new();
1985
1986 for chunk in task_ids.chunks(CHUNK_SIZE) {
1987 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1988 let sql = format!(
1989 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
1990 );
1991 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1992 for id in chunk {
1993 query = query.bind(*id);
1994 }
1995 let rows = query.fetch_all(&self.pool).await?;
1996 for row in &rows {
1997 let run = Self::row_to_run(row)?;
1998 result.entry(run.task_id).or_default().push(run);
1999 }
2000 }
2001 Ok(result)
2002 }
2003
2004 pub async fn get_batch(
2007 &self,
2008 ids: &[i64],
2009 ) -> anyhow::Result<std::collections::HashMap<i64, Task>> {
2010 if ids.is_empty() {
2011 return Ok(std::collections::HashMap::new());
2012 }
2013 const CHUNK_SIZE: usize = 500;
2016
2017 let mut result = std::collections::HashMap::new();
2018
2019 for chunk in ids.chunks(CHUNK_SIZE) {
2020 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2021 let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
2022 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2023 for id in chunk {
2024 query = query.bind(*id);
2025 }
2026 let rows = query.fetch_all(&self.pool).await?;
2027 for row in &rows {
2028 let task = Self::row_to_task(row)?;
2029 result.insert(task.id, task);
2030 }
2031 }
2032 Ok(result)
2033 }
2034
2035 pub async fn get_statuses_by_external_ids(
2040 &self,
2041 repo: &str,
2042 ext_ids: &[&str],
2043 ) -> anyhow::Result<std::collections::HashMap<String, TaskStatus>> {
2044 let mut result = std::collections::HashMap::new();
2045 if ext_ids.is_empty() {
2046 return Ok(result);
2047 }
2048
2049 const CHUNK_SIZE: usize = 500;
2050 for chunk in ext_ids.chunks(CHUNK_SIZE) {
2051 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2052 let sql = format!(
2053 "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2054 );
2055 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2056 query = query.bind(repo);
2057 for id in chunk {
2058 query = query.bind(id);
2059 }
2060 let rows = query.fetch_all(&self.pool).await?;
2061 for row in &rows {
2062 let external_id: Result<String, _> = row.try_get("external_id");
2063 let status_str: Result<String, _> = row.try_get("status");
2064 match (external_id, status_str) {
2065 (Ok(ext_id), Ok(sts)) => {
2066 if ext_id.is_empty() {
2067 tracing::warn!(status = %sts, "skipping task row with empty external_id");
2068 continue;
2069 }
2070 if let Some(status) = TaskStatus::from_str(&sts) {
2071 result.insert(ext_id, status);
2072 } else {
2073 tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2074 }
2075 }
2076 (Err(e), _) => {
2077 tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2078 }
2079 (_, Err(e)) => {
2080 tracing::warn!(error = %e, "skipping task row with decode error for status");
2081 }
2082 }
2083 }
2084 }
2085
2086 Ok(result)
2087 }
2088
2089 #[cfg(test)]
2092 pub async fn get_last_run(
2093 &self,
2094 task_id: i64,
2095 run_type: &str,
2096 ) -> anyhow::Result<Option<TaskRun>> {
2097 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
2098 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2099 )))
2100 .bind(task_id)
2101 .bind(run_type)
2102 .fetch_optional(&self.pool)
2103 .await?;
2104
2105 match row {
2106 Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2107 None => Ok(None),
2108 }
2109 }
2110
2111 pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2115 let rows: Vec<(String,)> = sqlx::query_as(
2116 "SELECT DISTINCT agent FROM task_runs \
2117 WHERE task_id = ? AND run_type = 'review' \
2118 ORDER BY agent",
2119 )
2120 .bind(task_id)
2121 .fetch_all(&self.pool)
2122 .await?;
2123 Ok(rows.into_iter().map(|(a,)| a).collect())
2124 }
2125
2126 #[cfg(test)]
2129 pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2130 let result = sqlx::query(
2131 "DELETE FROM task_runs WHERE task_id IN (
2132 SELECT id FROM tasks
2133 WHERE status IN ('done', 'blocked')
2134 AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2135 )",
2136 )
2137 .bind(format!("-{days}"))
2138 .execute(&self.pool)
2139 .await?;
2140
2141 Ok(result.rows_affected())
2142 }
2143
2144 fn row_to_task(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Task> {
2149 let col_count = row.len();
2152 if col_count != TASK_COLS_COUNT {
2153 tracing::warn!(
2154 expected = TASK_COLS_COUNT,
2155 actual = col_count,
2156 "TASK_COLS column count mismatch - schema may have changed"
2157 );
2158 }
2159
2160 let status_str: String = row
2161 .try_get("status")
2162 .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2163 let labels_str: String = row.try_get("labels").unwrap_or_default();
2164 let memory_str: String = row.try_get("memory").unwrap_or_default();
2165 let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2166
2167 Ok(Task {
2168 id: row
2169 .try_get("id")
2170 .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2171 external_id: row.try_get("external_id").unwrap_or(None),
2172 repo: row
2173 .try_get("repo")
2174 .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2175 origin: row
2176 .try_get("origin")
2177 .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2178 title: row
2179 .try_get("title")
2180 .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2181 body: row
2182 .try_get("body")
2183 .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2184 status: TaskStatus::from_str(&status_str)
2185 .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2186 source: row
2187 .try_get("source")
2188 .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2189 source_id: row
2190 .try_get("source_id")
2191 .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2192 author: row
2193 .try_get("author")
2194 .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2195 url: row
2196 .try_get("url")
2197 .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2198 labels: serde_json::from_str(&labels_str)
2199 .inspect_err(
2200 |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2201 )
2202 .unwrap_or_default(),
2203 agent: row.try_get("agent").unwrap_or(None),
2204 model: row.try_get("model").unwrap_or(None),
2205 complexity: row.try_get("complexity").unwrap_or_default(),
2206 estimate: {
2207 let raw: i32 = row.try_get("estimate").unwrap_or(0);
2208 u8::try_from(raw).unwrap_or_else(|_| {
2209 tracing::warn!(
2210 raw_estimate = raw,
2211 "estimate value out of u8 range; defaulting to 0"
2212 );
2213 0
2214 })
2215 },
2216 route_reason: row.try_get("route_reason").unwrap_or_default(),
2217 agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2218 selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2219 route_attempts: row.try_get("route_attempts").unwrap_or(0),
2220 attempts: row.try_get("attempts").unwrap_or(0),
2221 branch: row.try_get("branch").unwrap_or_default(),
2222 worktree: row.try_get("worktree").unwrap_or_default(),
2223 worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2224 owner_node: row.try_get("owner_node").unwrap_or(None),
2225 summary: row.try_get("summary").unwrap_or_default(),
2226 last_error: row.try_get("last_error").unwrap_or_default(),
2227 parent_id: row.try_get("parent_id").unwrap_or(None),
2228 block_reason: row.try_get("block_reason").unwrap_or(None),
2229 pr_number: row.try_get("pr_number").unwrap_or(None),
2230 pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2231 last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2232 review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2233 last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2234 merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2235 ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2236 pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2237 push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2238 network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2239 review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2240 review_resets: row.try_get::<i32, _>("review_resets").unwrap_or(0),
2241 review_cycles: row.try_get("review_cycles").unwrap_or(0),
2242 review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2243 needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2244 input_tokens: row.try_get("input_tokens").unwrap_or(0),
2245 output_tokens: row.try_get("output_tokens").unwrap_or(0),
2246 input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2247 output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2248 total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2249 model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2250 limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2251 memory: serde_json::from_str(&memory_str)
2252 .inspect_err(
2253 |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2254 )
2255 .unwrap_or_default(),
2256 delegations: serde_json::from_str(&delegations_str)
2257 .inspect_err(
2258 |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2259 )
2260 .unwrap_or_default(),
2261 auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2266 auto_unblock_last_at: row
2267 .try_get::<String, _>("auto_unblock_last_at")
2268 .unwrap_or_default(),
2269 auto_unblock_last_reason: row
2270 .try_get::<String, _>("auto_unblock_last_reason")
2271 .unwrap_or_default(),
2272 ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2273 no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2274 no_code_last_agent: row
2275 .try_get::<String, _>("no_code_last_agent")
2276 .unwrap_or_default(),
2277 created_at: row
2278 .try_get::<String, _>("created_at")
2279 .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2280 updated_at: row
2281 .try_get::<String, _>("updated_at")
2282 .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2283 })
2284 }
2285 pub async fn ensure_external_task(
2286 &self,
2287 repo: &str,
2288 ext: &crate::backends::ExternalTask,
2289 ) -> anyhow::Result<i64> {
2290 self.upsert_external(&UpsertExternal {
2291 repo,
2292 ext_id: &ext.id.0,
2293 title: &ext.title,
2294 body: &ext.body,
2295 author: &ext.author,
2296 url: &ext.url,
2297 labels: &ext.labels,
2298 origin: "github",
2299 })
2300 .await
2301 }
2302
2303 pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2306 if let Some(suffix) = task_id.strip_prefix("internal:") {
2307 if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2309 return Ok(Some(id));
2310 }
2311 if let Ok(id) = suffix.parse::<i64>() {
2315 let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2316 .bind(id)
2317 .bind(repo)
2318 .fetch_optional(&self.pool)
2319 .await?;
2320 if exists.is_some() {
2321 return Ok(Some(id));
2322 }
2323 }
2324 return Ok(None);
2325 }
2326 self.resolve_id_by_external(repo, task_id).await
2329 }
2330
2331 pub async fn resolve_task_id_globally(
2337 &self,
2338 task_id: &str,
2339 ) -> anyhow::Result<Option<(i64, String)>> {
2340 if let Some(suffix) = task_id.strip_prefix("internal:") {
2341 let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2343 .bind(task_id)
2344 .fetch_optional(&self.pool)
2345 .await?;
2346 if let Some(r) = row {
2347 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2348 }
2349 if let Ok(id) = suffix.parse::<i64>() {
2350 let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2351 .bind(id)
2352 .fetch_optional(&self.pool)
2353 .await?;
2354 if let Some(r) = row {
2355 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2356 }
2357 }
2358 return Ok(None);
2359 }
2360 let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2362 .bind(task_id)
2363 .fetch_all(&self.pool)
2364 .await?;
2365 match rows.len() {
2366 0 => Ok(None),
2367 1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2368 _ => {
2369 let repos: Vec<String> = rows
2370 .iter()
2371 .filter_map(|r| r.try_get::<String, _>("repo").ok())
2372 .collect();
2373 anyhow::bail!(
2374 "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2375 Run from the repo worktree so the current-repo lookup resolves it.",
2376 repos.join(", ")
2377 )
2378 }
2379 }
2380 }
2381
2382 pub async fn resolve_cli_id(
2384 &self,
2385 spec: &str,
2386 current_repo: Option<&str>,
2387 ) -> anyhow::Result<Option<CliTaskRef>> {
2388 let current_repo = current_repo.filter(|repo| !repo.is_empty());
2389 match parse_cli_task_spec(spec)? {
2390 CliTaskSpec::Internal(number) => self.lookup_internal_cli_id(number).await,
2391 CliTaskSpec::Issue { repo, number } => self.lookup_issue_cli_id(&repo, number).await,
2392 CliTaskSpec::Bare(number) => {
2393 if let Some(repo) = current_repo {
2394 if let Some(task) = self.lookup_issue_cli_id(repo, number).await? {
2395 return Ok(Some(task));
2396 }
2397 if let Some(task) = self.lookup_internal_in_repo(number, repo).await? {
2398 return Ok(Some(task));
2399 }
2400 return Ok(None);
2401 }
2402 let mut matches = self.lookup_bare_cli_id_matches(number).await?;
2403 match matches.len() {
2404 0 => Ok(None),
2405 1 => Ok(matches.pop()),
2406 _ => Err(ambiguous_cli_id_error(spec, &matches)),
2407 }
2408 }
2409 }
2410 }
2411
2412 async fn lookup_internal_cli_id(&self, number: i64) -> anyhow::Result<Option<CliTaskRef>> {
2413 let ext = format!("internal:{number}");
2414 let rows = sqlx::query(
2415 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2416 WHERE origin = 'internal' AND (id = ? OR external_id = ?) \
2417 ORDER BY id",
2418 )
2419 .bind(number)
2420 .bind(&ext)
2421 .fetch_all(&self.pool)
2422 .await?;
2423 let mut matches = dedup_cli_refs(rows)?;
2424 match matches.len() {
2425 0 => Ok(None),
2426 1 => Ok(matches.pop()),
2427 _ => Err(ambiguous_cli_id_error(&ext, &matches)),
2428 }
2429 }
2430
2431 async fn lookup_internal_in_repo(
2432 &self,
2433 number: i64,
2434 repo: &str,
2435 ) -> anyhow::Result<Option<CliTaskRef>> {
2436 let row = sqlx::query(
2437 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2438 WHERE origin = 'internal' AND id = ? AND repo = ?",
2439 )
2440 .bind(number)
2441 .bind(repo)
2442 .fetch_optional(&self.pool)
2443 .await?;
2444 match row {
2445 Some(row) => Ok(Some(CliTaskRef::from_row(&row)?)),
2446 None => Ok(None),
2447 }
2448 }
2449
2450 async fn lookup_issue_cli_id(
2451 &self,
2452 repo: &str,
2453 number: i64,
2454 ) -> anyhow::Result<Option<CliTaskRef>> {
2455 let ext = number.to_string();
2456 let rows = sqlx::query(
2457 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2458 WHERE repo = ? AND origin != 'internal' AND external_id = ? \
2459 ORDER BY id",
2460 )
2461 .bind(repo)
2462 .bind(&ext)
2463 .fetch_all(&self.pool)
2464 .await?;
2465 let mut matches = dedup_cli_refs(rows)?;
2466 match matches.len() {
2467 0 => Ok(None),
2468 1 => Ok(matches.pop()),
2469 _ => Err(ambiguous_cli_id_error(
2470 &format!("{repo}#{number}"),
2471 &matches,
2472 )),
2473 }
2474 }
2475
2476 async fn lookup_bare_cli_id_matches(&self, number: i64) -> anyhow::Result<Vec<CliTaskRef>> {
2477 let ext = number.to_string();
2478 let rows = sqlx::query(
2479 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2480 WHERE (origin = 'internal' AND id = ?) \
2481 OR (origin != 'internal' AND external_id = ?) \
2482 ORDER BY repo, id",
2483 )
2484 .bind(number)
2485 .bind(&ext)
2486 .fetch_all(&self.pool)
2487 .await?;
2488 dedup_cli_refs(rows)
2489 }
2490
2491 fn row_to_run(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskRun> {
2492 Ok(TaskRun {
2493 id: row
2494 .try_get("id")
2495 .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2496 task_id: row
2497 .try_get("task_id")
2498 .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2499 attempt: row
2500 .try_get("attempt")
2501 .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2502 run_type: row
2503 .try_get("run_type")
2504 .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2505 agent: row
2506 .try_get("agent")
2507 .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2508 model: row
2509 .try_get("model")
2510 .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2511 command: row
2512 .try_get("command")
2513 .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2514 prompt: row
2515 .try_get("prompt")
2516 .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2517 env_vars: row
2518 .try_get("env_vars")
2519 .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2520 exit_code: row
2521 .try_get("exit_code")
2522 .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2523 stdout: row
2524 .try_get("stdout")
2525 .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2526 stderr: row
2527 .try_get("stderr")
2528 .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2529 parsed_response: row
2530 .try_get("parsed_response")
2531 .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2532 outcome: row
2533 .try_get("outcome")
2534 .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2535 error: row
2536 .try_get("error")
2537 .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2538 input_tokens: row
2539 .try_get("input_tokens")
2540 .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2541 output_tokens: row
2542 .try_get("output_tokens")
2543 .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2544 total_cost_usd: row
2545 .try_get("total_cost_usd")
2546 .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2547 duration_secs: row
2548 .try_get("duration_secs")
2549 .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2550 started_at: row
2551 .try_get("started_at")
2552 .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2553 completed_at: row.try_get("completed_at").unwrap_or(None),
2554 })
2555 }
2556
2557 fn row_to_activity(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskActivity> {
2558 let details_str: String = row
2559 .try_get("details")
2560 .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2561 let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2562 |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2563 )?;
2564 Ok(TaskActivity {
2565 id: row
2566 .try_get("id")
2567 .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2568 task_id: row
2569 .try_get("task_id")
2570 .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2571 timestamp: row
2572 .try_get("timestamp")
2573 .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2574 event_type: row
2575 .try_get("event_type")
2576 .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2577 from_status: row
2578 .try_get("from_status")
2579 .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2580 to_status: row
2581 .try_get("to_status")
2582 .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2583 agent: row
2584 .try_get("agent")
2585 .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2586 model: row
2587 .try_get("model")
2588 .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2589 details,
2590 })
2591 }
2592}
2593
2594const LOCAL_TASK_COLUMNS: &[&str] = &[
2596 "worktree",
2597 "input_tokens",
2598 "output_tokens",
2599 "input_cost_usd",
2600 "output_cost_usd",
2601 "total_cost_usd",
2602];
2603
2604pub(crate) fn is_local_column(col: &str) -> bool {
2605 LOCAL_TASK_COLUMNS.contains(&col)
2606}
2607
2608#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2610pub struct UpsertExternalCmd {
2611 pub repo: String,
2612 pub ext_id: String,
2613 pub title: String,
2614 pub body: String,
2615 pub author: String,
2616 pub url: String,
2617 pub labels: Vec<String>,
2618 pub origin: String,
2619 pub parent_id: Option<i64>,
2620}
2621
2622#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2624pub struct StoreRouteCmd {
2625 pub id: i64,
2626 pub agent: String,
2627 pub model: Option<String>,
2628 pub complexity: String,
2629 pub estimate: u8,
2630 pub reason: String,
2631 pub profile: String,
2632 pub skills: String,
2633}
2634
2635const TOMBSTONE_COLUMNS: &[&str] = &[
2637 "id",
2638 "external_id",
2639 "repo",
2640 "origin",
2641 "title",
2642 "status",
2643 "pr_number",
2644 "parent_id",
2645 "created_at",
2646 "updated_at",
2647];
2648
2649async fn replicated_columns(conn: &mut sqlx::SqliteConnection) -> anyhow::Result<Vec<String>> {
2650 let cols: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info('tasks')")
2651 .fetch_all(conn)
2652 .await?;
2653 Ok(cols
2654 .into_iter()
2655 .map(|(c,)| c)
2656 .filter(|c| !is_local_column(c))
2657 .collect())
2658}
2659
2660pub(crate) async fn replicated_rows(pool: &SqlitePool) -> anyhow::Result<Vec<serde_json::Value>> {
2662 let mut conn = pool.acquire().await?;
2663 let cols = replicated_columns(&mut conn).await?;
2664 let full: Vec<&String> = cols.iter().collect();
2665 let tomb: Vec<&String> = cols
2666 .iter()
2667 .filter(|c| TOMBSTONE_COLUMNS.contains(&c.as_str()))
2668 .collect();
2669 let sql = format!(
2670 "SELECT CASE WHEN status = 'done' THEN {} ELSE {} END FROM tasks ORDER BY id",
2671 super::replica::json_row_sql(&tomb),
2672 super::replica::json_row_sql(&full)
2673 );
2674 let rows: Vec<(String,)> = sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str()))
2675 .fetch_all(&mut *conn)
2676 .await?;
2677 rows.into_iter()
2678 .map(|(r,)| Ok(serde_json::from_str(&r)?))
2679 .collect()
2680}
2681
2682pub(crate) async fn replace_replicated(
2685 conn: &mut sqlx::SqliteConnection,
2686 rows: &[serde_json::Value],
2687) -> anyhow::Result<()> {
2688 let allowed = replicated_columns(conn).await?;
2689 sqlx::query("PRAGMA defer_foreign_keys = ON")
2690 .execute(&mut *conn)
2691 .await?;
2692 sqlx::query("CREATE TEMP TABLE IF NOT EXISTS snapshot_ids (id INTEGER PRIMARY KEY)")
2693 .execute(&mut *conn)
2694 .await?;
2695 sqlx::query("DELETE FROM snapshot_ids")
2696 .execute(&mut *conn)
2697 .await?;
2698 for row in rows {
2699 let id = row["id"]
2700 .as_i64()
2701 .context("snapshot task row without an id")?;
2702 sqlx::query("INSERT INTO snapshot_ids (id) VALUES (?)")
2703 .bind(id)
2704 .execute(&mut *conn)
2705 .await?;
2706 }
2707
2708 for table in ["task_runs", "task_activity"] {
2709 sqlx::query(sqlx::AssertSqlSafe(format!(
2710 "DELETE FROM {table} WHERE task_id NOT IN (SELECT id FROM snapshot_ids)"
2711 )))
2712 .execute(&mut *conn)
2713 .await?;
2714 }
2715
2716 sqlx::query("DELETE FROM tasks WHERE id NOT IN (SELECT id FROM snapshot_ids)")
2717 .execute(&mut *conn)
2718 .await?;
2719
2720 for row in rows {
2721 let obj = row
2722 .as_object()
2723 .context("snapshot task row is not an object")?;
2724 let cols: Vec<&String> = obj.keys().collect();
2725 for c in &cols {
2726 anyhow::ensure!(
2727 allowed.contains(c),
2728 "snapshot names unknown task column {c}"
2729 );
2730 }
2731
2732 let values: Vec<String> = cols
2733 .iter()
2734 .map(|c| format!("json_extract(?1, '$.\"{c}\"')"))
2735 .collect();
2736 let updates: Vec<String> = cols.iter().map(|c| format!("{c} = excluded.{c}")).collect();
2737 let names: Vec<&str> = cols.iter().map(|c| c.as_str()).collect();
2738 let sql = format!(
2739 "INSERT INTO tasks ({}) VALUES ({}) ON CONFLICT(id) DO UPDATE SET {}",
2740 names.join(", "),
2741 values.join(", "),
2742 updates.join(", ")
2743 );
2744 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2745 .bind(serde_json::to_string(row)?)
2746 .execute(&mut *conn)
2747 .await?;
2748 }
2749
2750 sqlx::query("DROP TABLE snapshot_ids")
2751 .execute(&mut *conn)
2752 .await?;
2753 Ok(())
2754}
2755
2756pub(crate) mod apply {
2758 use super::*;
2759 use sqlx::SqliteConnection;
2760
2761 async fn previous(
2762 conn: &mut SqliteConnection,
2763 id: i64,
2764 ) -> anyhow::Result<(String, Option<String>, Option<String>)> {
2765 let row = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
2766 .bind(id)
2767 .fetch_one(&mut *conn)
2768 .await?;
2769 let status: String = row
2770 .try_get("status")
2771 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
2772 let agent: Option<String> = row
2773 .try_get("agent")
2774 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
2775 let model: Option<String> = row
2776 .try_get("model")
2777 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
2778 Ok((status, agent, model))
2779 }
2780
2781 #[allow(clippy::too_many_arguments)]
2782 pub(crate) async fn activity(
2783 conn: &mut SqliteConnection,
2784 task_id: i64,
2785 event_type: &str,
2786 from_status: Option<&str>,
2787 to_status: Option<&str>,
2788 agent: Option<&str>,
2789 model: Option<&str>,
2790 details: Option<&serde_json::Value>,
2791 ) -> anyhow::Result<()> {
2792 let details_json = match details {
2793 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
2794 None => "{}".to_string(),
2795 };
2796 sqlx::query(
2797 "INSERT INTO task_activity
2798 (task_id, event_type, from_status, to_status, agent, model, details)
2799 VALUES (?, ?, ?, ?, ?, ?, ?)",
2800 )
2801 .bind(task_id)
2802 .bind(event_type)
2803 .bind(from_status)
2804 .bind(to_status)
2805 .bind(agent)
2806 .bind(model)
2807 .bind(details_json)
2808 .execute(conn)
2809 .await?;
2810 Ok(())
2811 }
2812
2813 async fn status_change(
2814 conn: &mut SqliteConnection,
2815 id: i64,
2816 previous: &(String, Option<String>, Option<String>),
2817 to: TaskStatus,
2818 details: Option<&serde_json::Value>,
2819 ) -> anyhow::Result<()> {
2820 activity(
2821 conn,
2822 id,
2823 "status_change",
2824 Some(previous.0.as_str()),
2825 Some(to.as_str()),
2826 previous.1.as_deref(),
2827 previous.2.as_deref(),
2828 details,
2829 )
2830 .await
2831 }
2832
2833 pub(crate) async fn create(
2836 conn: &mut SqliteConnection,
2837 now: &str,
2838 new: &NewTask,
2839 internal: bool,
2840 replicated: bool,
2841 ) -> anyhow::Result<i64> {
2842 let labels_json = serde_json::to_string(&new.labels)?;
2843 let row = sqlx::query(
2844 "INSERT INTO tasks (id, external_id, repo, origin, title, body, source, source_id, author, url, labels, parent_id, created_at, updated_at)
2845 VALUES ((SELECT CASE WHEN ? THEN COALESCE(MAX(id), 0) + 1 END FROM tasks), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2846 RETURNING id",
2847 )
2848 .bind(replicated)
2849 .bind(&new.external_id)
2850 .bind(&new.repo)
2851 .bind(&new.origin)
2852 .bind(&new.title)
2853 .bind(&new.body)
2854 .bind(&new.source)
2855 .bind(&new.source_id)
2856 .bind(&new.author)
2857 .bind(&new.url)
2858 .bind(&labels_json)
2859 .bind(new.parent_id)
2860 .bind(now)
2861 .bind(now)
2862 .fetch_one(&mut *conn)
2863 .await?;
2864 let id: i64 = row
2865 .try_get("id")
2866 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
2867 if internal {
2868 sqlx::query("UPDATE tasks SET external_id = ? WHERE id = ?")
2870 .bind(format!("internal:{id}"))
2871 .bind(id)
2872 .execute(conn)
2873 .await?;
2874 }
2875
2876 Ok(id)
2877 }
2878
2879 pub(crate) async fn upsert_external(
2880 conn: &mut SqliteConnection,
2881 now: &str,
2882 ext: &UpsertExternalCmd,
2883 replicated: bool,
2884 ) -> anyhow::Result<i64> {
2885 let labels_json = serde_json::to_string(&ext.labels)?;
2886 let row = sqlx::query(
2887 "INSERT INTO tasks (id, external_id, repo, origin, title, body, author, url, labels, parent_id, created_at, updated_at)
2888 VALUES ((SELECT CASE WHEN ? THEN COALESCE(MAX(id), 0) + 1 END FROM tasks), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2889 ON CONFLICT(repo, external_id) DO UPDATE SET
2890 title = excluded.title,
2891 body = excluded.body,
2892 labels = excluded.labels,
2893 url = excluded.url,
2894 parent_id = COALESCE(excluded.parent_id, tasks.parent_id)
2895 RETURNING id",
2896 )
2897 .bind(replicated)
2898 .bind(&ext.ext_id)
2899 .bind(&ext.repo)
2900 .bind(&ext.origin)
2901 .bind(&ext.title)
2902 .bind(&ext.body)
2903 .bind(&ext.author)
2904 .bind(&ext.url)
2905 .bind(&labels_json)
2906 .bind(ext.parent_id)
2907 .bind(now)
2908 .bind(now)
2909 .fetch_one(conn)
2910 .await?;
2911 row.try_get("id")
2912 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
2913 }
2914
2915 fn ensure_allowed(updates: &[(String, serde_json::Value)]) -> anyhow::Result<()> {
2916 for (col, _) in updates {
2917 anyhow::ensure!(
2918 ALLOWED_FIELDS.contains(&col.as_str()),
2919 "column {col} is not in the update allowlist"
2920 );
2921 }
2922
2923 Ok(())
2924 }
2925
2926 pub(crate) async fn update_status_and_fields(
2927 conn: &mut SqliteConnection,
2928 now: &str,
2929 id: i64,
2930 status: TaskStatus,
2931 updates: &[(String, serde_json::Value)],
2932 ) -> anyhow::Result<()> {
2933 ensure_allowed(updates)?;
2934 let prev = previous(conn, id).await?;
2935
2936 let mut set_parts = vec!["status = ?".to_string()];
2937 let mut values: Vec<Option<String>> = vec![Some(status.as_str().to_string())];
2938 let mut block_reason_in_updates = false;
2939 for (col, val) in updates {
2940 if col == "block_reason" {
2941 block_reason_in_updates = true;
2942 }
2943
2944 set_parts.push(format!("{col} = ?"));
2945 match val {
2946 serde_json::Value::String(s) => values.push(Some(s.clone())),
2947 serde_json::Value::Number(n) => values.push(Some(n.to_string())),
2948 serde_json::Value::Bool(b) => {
2949 values.push(Some(if *b { "1" } else { "0" }.to_string()));
2950 }
2951 serde_json::Value::Null => values.push(None),
2952 other => values.push(Some(serde_json::to_string(other)?)),
2953 }
2954 }
2955
2956 if !block_reason_in_updates && status != TaskStatus::Blocked {
2957 set_parts.push("block_reason = NULL".to_string());
2958 }
2959
2960 set_parts.push("updated_at = ?".to_string());
2961 values.push(Some(now.to_string()));
2962 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
2963 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2964 for v in &values {
2965 query = query.bind(v.as_deref());
2966 }
2967 query.bind(id).execute(&mut *conn).await?;
2968 status_change(conn, id, &prev, status, None).await
2969 }
2970
2971 pub(crate) async fn update_status(
2973 conn: &mut SqliteConnection,
2974 now: &str,
2975 id: i64,
2976 status: TaskStatus,
2977 expected: Option<TaskStatus>,
2978 ) -> anyhow::Result<bool> {
2979 let prev = previous(conn, id).await?;
2980 let clear_block = if status == TaskStatus::Blocked {
2981 ""
2982 } else {
2983 ", block_reason = NULL"
2984 };
2985 let guard = if expected.is_some() {
2986 " AND status = ?"
2987 } else {
2988 ""
2989 };
2990 let sql =
2991 format!("UPDATE tasks SET status = ?{clear_block}, updated_at = ? WHERE id = ?{guard}");
2992 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2993 .bind(status.as_str())
2994 .bind(now)
2995 .bind(id);
2996 if let Some(expected) = expected {
2997 query = query.bind(expected.as_str());
2998 }
2999
3000 if query.execute(&mut *conn).await?.rows_affected() == 0 {
3001 return Ok(false);
3002 }
3003
3004 status_change(conn, id, &prev, status, None).await?;
3005 Ok(true)
3006 }
3007
3008 pub(crate) async fn update_external_id(
3009 conn: &mut SqliteConnection,
3010 now: &str,
3011 id: i64,
3012 external_id: &str,
3013 ) -> anyhow::Result<()> {
3014 sqlx::query("UPDATE tasks SET external_id = ?, updated_at = ? WHERE id = ?")
3015 .bind(external_id)
3016 .bind(now)
3017 .bind(id)
3018 .execute(conn)
3019 .await?;
3020 Ok(())
3021 }
3022
3023 pub(crate) async fn touch(
3024 conn: &mut SqliteConnection,
3025 now: &str,
3026 id: i64,
3027 ) -> anyhow::Result<()> {
3028 sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
3029 .bind(now)
3030 .bind(id)
3031 .execute(conn)
3032 .await?;
3033 Ok(())
3034 }
3035
3036 pub(crate) async fn reset_to_new(
3037 conn: &mut SqliteConnection,
3038 now: &str,
3039 id: i64,
3040 ) -> anyhow::Result<()> {
3041 let prev = previous(conn, id).await?;
3042 sqlx::query(
3043 "UPDATE tasks SET status = 'new', branch = '', worktree = '', worktree_cleaned = 0, block_reason = NULL, updated_at = ? WHERE id = ?",
3044 )
3045 .bind(now)
3046 .bind(id)
3047 .execute(&mut *conn)
3048 .await?;
3049 let details = serde_json::json!({ "op": "reset_to_new" });
3050 status_change(conn, id, &prev, TaskStatus::New, Some(&details)).await
3051 }
3052
3053 pub(crate) async fn set_fields(
3055 conn: &mut SqliteConnection,
3056 now: &str,
3057 id: i64,
3058 updates: &[(String, serde_json::Value)],
3059 silent: bool,
3060 ) -> anyhow::Result<()> {
3061 if updates.is_empty() {
3062 return Ok(());
3063 }
3064
3065 ensure_allowed(updates)?;
3066 let mut set_parts = Vec::new();
3067 let mut values: Vec<Option<String>> = Vec::new();
3068 for (col, val) in updates {
3069 set_parts.push(format!("{col} = ?"));
3070 values.push(sql_value_for_task_field(col, val)?);
3071 }
3072
3073 if !silent {
3074 set_parts.push("updated_at = ?".to_string());
3075 values.push(Some(now.to_string()));
3076 }
3077
3078 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
3079 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
3080 for v in &values {
3081 query = query.bind(v.as_deref());
3082 }
3083 query.bind(id).execute(conn).await?;
3084 Ok(())
3085 }
3086
3087 pub(crate) async fn increment(
3088 conn: &mut SqliteConnection,
3089 now: &str,
3090 id: i64,
3091 field: &str,
3092 ) -> anyhow::Result<i64> {
3093 anyhow::ensure!(
3094 INCREMENTABLE_FIELDS.contains(&field),
3095 "field {field} is not incrementable"
3096 );
3097 let sql = format!(
3098 "UPDATE tasks SET {field} = {field} + 1, updated_at = ? WHERE id = ? RETURNING {field} AS val"
3099 );
3100 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3101 .bind(now)
3102 .bind(id)
3103 .fetch_one(conn)
3104 .await?;
3105 let val: i32 = row.try_get("val")?;
3106 Ok(val.into())
3107 }
3108
3109 pub(crate) async fn reset_counters(
3112 conn: &mut SqliteConnection,
3113 now: &str,
3114 id: i64,
3115 failure_only: bool,
3116 ) -> anyhow::Result<()> {
3117 let all = if failure_only {
3118 ""
3119 } else {
3120 "attempts = 0, merge_conflict_retries = 0, ci_merge_failures = 0, review_cycles = 0, review_invocations = 0,"
3121 };
3122 let sql = format!(
3123 "UPDATE tasks SET {all}
3124 route_attempts = 0,
3125 review_agent_failures = 0,
3126 review_resets = 0,
3127 pr_create_failures = 0,
3128 push_failures = 0,
3129 network_retries = 0,
3130 auto_unblock_count = 0,
3131 auto_unblock_last_at = '',
3132 auto_unblock_last_reason = '',
3133 ci_recovery_count = 0,
3134 no_code_reroutes = 0,
3135 no_code_last_agent = '',
3136 needs_review_refires = 0,
3137 updated_at = ?
3138 WHERE id = ?"
3139 );
3140 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3141 .bind(now)
3142 .bind(id)
3143 .execute(conn)
3144 .await?;
3145 Ok(())
3146 }
3147
3148 pub(crate) async fn store_route(
3149 conn: &mut SqliteConnection,
3150 now: &str,
3151 route: &StoreRouteCmd,
3152 ) -> anyhow::Result<()> {
3153 sqlx::query(
3154 "UPDATE tasks SET
3155 agent = ?, model = ?, complexity = ?, estimate = ?, route_reason = ?,
3156 agent_profile = ?, selected_skills = ?,
3157 updated_at = ?
3158 WHERE id = ?",
3159 )
3160 .bind(&route.agent)
3161 .bind(&route.model)
3162 .bind(&route.complexity)
3163 .bind(route.estimate as i64)
3164 .bind(&route.reason)
3165 .bind(&route.profile)
3166 .bind(&route.skills)
3167 .bind(now)
3168 .bind(route.id)
3169 .execute(&mut *conn)
3170 .await?;
3171 let details = serde_json::json!({
3172 "complexity": route.complexity,
3173 "estimate": route.estimate,
3174 "reason": route.reason,
3175 "skills": route.skills,
3176 });
3177 activity(
3178 conn,
3179 route.id,
3180 "dispatch",
3181 None,
3182 None,
3183 Some(&route.agent),
3184 route.model.as_deref(),
3185 Some(&details),
3186 )
3187 .await
3188 }
3189
3190 pub(crate) async fn set_estimate_if_zero(
3191 conn: &mut SqliteConnection,
3192 now: &str,
3193 id: i64,
3194 estimate: u8,
3195 ) -> anyhow::Result<bool> {
3196 let result = sqlx::query(
3197 "UPDATE tasks SET estimate = ?, updated_at = ? WHERE id = ? AND estimate = 0",
3198 )
3199 .bind(estimate as i64)
3200 .bind(now)
3201 .bind(id)
3202 .execute(conn)
3203 .await?;
3204 Ok(result.rows_affected() > 0)
3205 }
3206
3207 pub(crate) async fn append_memory(
3208 conn: &mut SqliteConnection,
3209 now: &str,
3210 id: i64,
3211 entry: &MemoryEntry,
3212 ) -> anyhow::Result<()> {
3213 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
3214 .bind(id)
3215 .fetch_one(&mut *conn)
3216 .await?;
3217 let memory_opt: Option<String> = row
3219 .try_get::<Option<String>, _>("memory")
3220 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
3221 let memory_str = memory_opt.unwrap_or_default();
3222 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
3223 Vec::new()
3224 } else {
3225 serde_json::from_str(&memory_str)
3226 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
3227 };
3228 memory.push(entry.clone());
3229 sqlx::query("UPDATE tasks SET memory = ?, updated_at = ? WHERE id = ?")
3230 .bind(serde_json::to_string(&memory)?)
3231 .bind(now)
3232 .bind(id)
3233 .execute(conn)
3234 .await?;
3235 Ok(())
3236 }
3237
3238 pub(crate) async fn mark_cleaned(
3239 conn: &mut SqliteConnection,
3240 now: &str,
3241 id: i64,
3242 ) -> anyhow::Result<()> {
3243 sqlx::query("UPDATE tasks SET worktree_cleaned = 1, updated_at = ? WHERE id = ?")
3244 .bind(now)
3245 .bind(id)
3246 .execute(&mut *conn)
3247 .await?;
3248 activity(
3249 conn,
3250 id,
3251 "branch_delete",
3252 None,
3253 None,
3254 None,
3255 None,
3256 Some(&serde_json::json!({ "worktree_cleaned": true })),
3257 )
3258 .await
3259 }
3260}
3261
3262#[cfg(test)]
3263mod row_to_task_tests {
3264 use super::*;
3265
3266 async fn store_with_task() -> (crate::store::TaskStore, i64) {
3267 let store = crate::store::TaskStore::open_memory().await.unwrap();
3268 let id = store
3269 .create(&NewTask {
3270 external_id: None,
3271 repo: "owner/repo".to_string(),
3272 origin: "internal".to_string(),
3273 title: "Test task".to_string(),
3274 body: "Test body".to_string(),
3275 source: "cron".to_string(),
3276 source_id: "test-1".to_string(),
3277 author: "tester".to_string(),
3278 url: "https://example.com/1".to_string(),
3279 labels: vec![],
3280 parent_id: None,
3281 })
3282 .await
3283 .unwrap();
3284 (store, id)
3285 }
3286
3287 macro_rules! missing_column_test {
3290 ($name:ident, $col:literal, $select:literal) => {
3291 #[tokio::test]
3292 async fn $name() {
3293 let (store, id) = store_with_task().await;
3294 let sql = format!("{} WHERE id = {id}", $select);
3295 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3296 .fetch_one(store.pool())
3297 .await
3298 .unwrap();
3299 let err = TaskStore::row_to_task(&row).unwrap_err();
3300 assert!(
3301 err.to_string().contains($col),
3302 "expected '{}' in error message, got: {err}",
3303 $col
3304 );
3305 }
3306 };
3307 }
3308
3309 missing_column_test!(
3310 row_to_task_fails_on_missing_repo,
3311 "repo",
3312 "SELECT id, status FROM tasks"
3313 );
3314 missing_column_test!(
3315 row_to_task_fails_on_missing_origin,
3316 "origin",
3317 "SELECT id, repo, status FROM tasks"
3318 );
3319 missing_column_test!(
3320 row_to_task_fails_on_missing_title,
3321 "title",
3322 "SELECT id, repo, origin, status FROM tasks"
3323 );
3324 missing_column_test!(
3325 row_to_task_fails_on_missing_body,
3326 "body",
3327 "SELECT id, repo, origin, title, status FROM tasks"
3328 );
3329 missing_column_test!(
3330 row_to_task_fails_on_missing_source,
3331 "source",
3332 "SELECT id, repo, origin, title, body, status FROM tasks"
3333 );
3334 missing_column_test!(
3335 row_to_task_fails_on_missing_source_id,
3336 "source_id",
3337 "SELECT id, repo, origin, title, body, source, status FROM tasks"
3338 );
3339 missing_column_test!(
3340 row_to_task_fails_on_missing_author,
3341 "author",
3342 "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
3343 );
3344 missing_column_test!(
3345 row_to_task_fails_on_missing_url,
3346 "url",
3347 "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
3348 );
3349
3350 #[tokio::test]
3357 async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
3358 let (store, id) = store_with_task().await;
3359 let sql = format!(
3362 "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
3363 source, source_id, author, url FROM tasks WHERE id = {id}"
3364 );
3365 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3366 .fetch_one(store.pool())
3367 .await
3368 .unwrap();
3369 let err = TaskStore::row_to_task(&row).unwrap_err();
3370 assert!(
3371 err.to_string().contains("repo"),
3372 "expected 'repo' in error message, got: {err}"
3373 );
3374 }
3375
3376 #[tokio::test]
3379 async fn distinct_repos_fails_on_decode_error() {
3380 let store = crate::store::TaskStore::open_memory().await.unwrap();
3381 let id = store
3382 .create(&NewTask {
3383 external_id: None,
3384 repo: "owner/repo".to_string(),
3385 origin: "internal".to_string(),
3386 title: "Test task".to_string(),
3387 body: "Test body".to_string(),
3388 source: "cron".to_string(),
3389 source_id: "test-1".to_string(),
3390 author: "tester".to_string(),
3391 url: "https://example.com/1".to_string(),
3392 labels: vec![],
3393 parent_id: None,
3394 })
3395 .await
3396 .unwrap();
3397
3398 let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
3400 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3401 .execute(store.pool())
3402 .await
3403 .unwrap();
3404
3405 let err = store.distinct_repos().await.unwrap_err();
3406 assert!(
3407 err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
3408 "expected 'distinct_repos' and 'repo' in error message, got: {err}"
3409 );
3410 }
3411
3412 async fn store_with_run() -> (crate::store::TaskStore, i64, i64) {
3415 let store = crate::store::TaskStore::open_memory().await.unwrap();
3416 let task_id = store
3417 .create(&NewTask {
3418 external_id: None,
3419 repo: "owner/repo".to_string(),
3420 origin: "internal".to_string(),
3421 title: "Test task".to_string(),
3422 body: "Test body".to_string(),
3423 source: "cron".to_string(),
3424 source_id: "test-1".to_string(),
3425 author: "tester".to_string(),
3426 url: "https://example.com/1".to_string(),
3427 labels: vec![],
3428 parent_id: None,
3429 })
3430 .await
3431 .unwrap();
3432 let run_id = store
3433 .start_run(&crate::store::StartRun {
3434 task_id,
3435 attempt: 1,
3436 run_type: "agent",
3437 agent: "claude",
3438 model: "sonnet",
3439 command: "claude -p ...",
3440 prompt: "system prompt",
3441 })
3442 .await
3443 .unwrap();
3444 (store, task_id, run_id)
3445 }
3446
3447 #[tokio::test]
3448 async fn start_run_upsert_clears_stale_completion_fields() {
3449 let (store, task_id, run_id) = store_with_run().await;
3450 store
3451 .complete_run(&CompleteRun {
3452 run_id,
3453 exit_code: Some(0),
3454 stdout: "",
3455 stderr: "",
3456 parsed: "",
3457 outcome: "success",
3458 error: "boom",
3459 tokens: RunTokenUsage::default(),
3460 })
3461 .await
3462 .unwrap();
3463
3464 let same_run_id = store
3466 .start_run(&crate::store::StartRun {
3467 task_id,
3468 attempt: 1,
3469 run_type: "agent",
3470 agent: "claude",
3471 model: "sonnet",
3472 command: "claude -p ...",
3473 prompt: "system prompt",
3474 })
3475 .await
3476 .unwrap();
3477 assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
3478
3479 let row = sqlx::query(
3480 "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
3481 )
3482 .bind(run_id)
3483 .fetch_one(store.pool())
3484 .await
3485 .unwrap();
3486 let outcome: Option<String> = row.try_get("outcome").unwrap();
3487 let completed_at: Option<String> = row.try_get("completed_at").unwrap();
3488 let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
3489 let error: Option<String> = row.try_get("error").unwrap();
3490 assert_eq!(outcome, None);
3491 assert_eq!(
3492 completed_at, None,
3493 "stale completion timestamp must be cleared"
3494 );
3495 assert_eq!(exit_code, None);
3496 assert_eq!(error, None);
3497 }
3498
3499 macro_rules! run_blob_decode_test {
3506 ($name:ident, $col:literal, $select:literal) => {
3507 #[tokio::test]
3508 async fn $name() {
3509 let (store, _task_id, run_id) = store_with_run().await;
3510 let sql = format!($select, run_id = run_id);
3511 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3512 .fetch_one(store.pool())
3513 .await
3514 .unwrap();
3515 let err = TaskStore::row_to_run(&row).unwrap_err();
3516 assert!(
3517 err.to_string().contains($col),
3518 "expected '{0}' in error message, got: {err}",
3519 $col
3520 );
3521 }
3522 };
3523 }
3524
3525 run_blob_decode_test!(
3526 row_to_run_fails_on_decode_error_invalid_utf8_id,
3527 "id",
3528 "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}"
3529 );
3530 run_blob_decode_test!(
3531 row_to_run_fails_on_decode_error_invalid_utf8_task_id,
3532 "task_id",
3533 "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}"
3534 );
3535 run_blob_decode_test!(
3536 row_to_run_fails_on_decode_error_invalid_utf8_started_at,
3537 "started_at",
3538 "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}"
3539 );
3540
3541 async fn store_with_activity() -> (crate::store::TaskStore, i64, i64) {
3544 let store = crate::store::TaskStore::open_memory().await.unwrap();
3545 let task_id = store
3546 .create(&NewTask {
3547 external_id: None,
3548 repo: "owner/repo".to_string(),
3549 origin: "internal".to_string(),
3550 title: "Test task".to_string(),
3551 body: "Test body".to_string(),
3552 source: "cron".to_string(),
3553 source_id: "test-1".to_string(),
3554 author: "tester".to_string(),
3555 url: "https://example.com/1".to_string(),
3556 labels: vec![],
3557 parent_id: None,
3558 })
3559 .await
3560 .unwrap();
3561 store
3562 .append_activity(task_id, "created", None, Some("new"), None, None, None)
3563 .await
3564 .unwrap();
3565 (store, task_id, 1)
3566 }
3567
3568 #[tokio::test]
3571 async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
3572 let (store, _task_id, activity_id) = store_with_activity().await;
3573 let sql = format!(
3574 "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3575 );
3576 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3577 .fetch_one(store.pool())
3578 .await
3579 .unwrap();
3580 let err = TaskStore::row_to_activity(&row).unwrap_err();
3581 assert!(
3582 err.to_string().contains("id"),
3583 "expected 'id' in error message, got: {err}"
3584 );
3585 }
3586
3587 #[tokio::test]
3588 async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
3589 let (store, _task_id, activity_id) = store_with_activity().await;
3590 let sql = format!(
3591 "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3592 );
3593 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3594 .fetch_one(store.pool())
3595 .await
3596 .unwrap();
3597 let err = TaskStore::row_to_activity(&row).unwrap_err();
3598 assert!(
3599 err.to_string().contains("task_id"),
3600 "expected 'task_id' in error message, got: {err}"
3601 );
3602 }
3603
3604 #[tokio::test]
3605 async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
3606 let (store, _task_id, activity_id) = store_with_activity().await;
3607 let sql = format!(
3608 "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3609 );
3610 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3611 .fetch_one(store.pool())
3612 .await
3613 .unwrap();
3614 let err = TaskStore::row_to_activity(&row).unwrap_err();
3615 assert!(
3616 err.to_string().contains("timestamp"),
3617 "expected 'timestamp' in error message, got: {err}"
3618 );
3619 }
3620
3621 #[tokio::test]
3622 async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
3623 let (store, _task_id, activity_id) = store_with_activity().await;
3624 let sql = format!(
3625 "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3626 );
3627 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3628 .fetch_one(store.pool())
3629 .await
3630 .unwrap();
3631 let err = TaskStore::row_to_activity(&row).unwrap_err();
3632 assert!(
3633 err.to_string().contains("event_type"),
3634 "expected 'event_type' in error message, got: {err}"
3635 );
3636 }
3637
3638 #[tokio::test]
3639 async fn row_to_activity_fails_on_corrupt_details_json() {
3640 let (store, task_id, _activity_id) = store_with_activity().await;
3641 sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
3643 .bind(task_id)
3644 .bind("test")
3645 .bind("not valid json {{{")
3646 .execute(store.pool())
3647 .await
3648 .unwrap();
3649 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
3650 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
3651 )))
3652 .fetch_one(store.pool())
3653 .await
3654 .unwrap();
3655 let err = TaskStore::row_to_activity(&row).unwrap_err();
3656 assert!(
3657 err.to_string().contains("details")
3658 || err.to_string().contains("json")
3659 || err.to_string().contains("expected"),
3660 "expected 'details', 'json', or 'expected' in error message, got: {err}"
3661 );
3662 }
3663
3664 run_blob_decode_test!(
3667 row_to_run_fails_on_decode_error_invalid_utf8_model,
3668 "model",
3669 "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}"
3670 );
3671 run_blob_decode_test!(
3672 row_to_run_fails_on_decode_error_invalid_utf8_command,
3673 "command",
3674 "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}"
3675 );
3676 run_blob_decode_test!(
3677 row_to_run_fails_on_decode_error_invalid_utf8_prompt,
3678 "prompt",
3679 "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}"
3680 );
3681 run_blob_decode_test!(
3682 row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
3683 "env_vars",
3684 "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}"
3685 );
3686 run_blob_decode_test!(
3687 row_to_run_fails_on_decode_error_invalid_utf8_stdout,
3688 "stdout",
3689 "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}"
3690 );
3691 run_blob_decode_test!(
3692 row_to_run_fails_on_decode_error_invalid_utf8_stderr,
3693 "stderr",
3694 "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}"
3695 );
3696 run_blob_decode_test!(
3697 row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
3698 "parsed_response",
3699 "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}"
3700 );
3701 run_blob_decode_test!(
3702 row_to_run_fails_on_decode_error_invalid_utf8_outcome,
3703 "outcome",
3704 "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}"
3705 );
3706 run_blob_decode_test!(
3707 row_to_run_fails_on_decode_error_invalid_utf8_error,
3708 "error",
3709 "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}"
3710 );
3711 run_blob_decode_test!(
3712 row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
3713 "input_tokens",
3714 "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}"
3715 );
3716 run_blob_decode_test!(
3717 row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
3718 "output_tokens",
3719 "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}"
3720 );
3721 run_blob_decode_test!(
3722 row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
3723 "total_cost_usd",
3724 "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}"
3725 );
3726 run_blob_decode_test!(
3727 row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3728 "duration_secs",
3729 "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}"
3730 );
3731 run_blob_decode_test!(
3732 row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3733 "exit_code",
3734 "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}"
3735 );
3736
3737 #[tokio::test]
3744 async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3745 let (store, _task_id, activity_id) = store_with_activity().await;
3746 let sql = format!(
3747 "SELECT id, task_id, timestamp, event_type, \
3748 X'DeadbEEF' as from_status, NULL as to_status, \
3749 NULL as agent, NULL as model, details \
3750 FROM task_activity WHERE id = {activity_id}"
3751 );
3752 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3753 .fetch_one(store.pool())
3754 .await
3755 .unwrap();
3756 let err = TaskStore::row_to_activity(&row).unwrap_err();
3757 assert!(
3758 err.to_string().contains("from_status"),
3759 "expected 'from_status' in error message, got: {err}"
3760 );
3761 }
3762
3763 #[tokio::test]
3764 async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3765 let (store, _task_id, activity_id) = store_with_activity().await;
3766 let sql = format!(
3767 "SELECT id, task_id, timestamp, event_type, \
3768 NULL as from_status, X'DeadbEEF' as to_status, \
3769 NULL as agent, NULL as model, details \
3770 FROM task_activity WHERE id = {activity_id}"
3771 );
3772 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3773 .fetch_one(store.pool())
3774 .await
3775 .unwrap();
3776 let err = TaskStore::row_to_activity(&row).unwrap_err();
3777 assert!(
3778 err.to_string().contains("to_status"),
3779 "expected 'to_status' in error message, got: {err}"
3780 );
3781 }
3782
3783 #[tokio::test]
3784 async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3785 let (store, _task_id, activity_id) = store_with_activity().await;
3786 let sql = format!(
3787 "SELECT id, task_id, timestamp, event_type, \
3788 NULL as from_status, NULL as to_status, \
3789 X'DeadbEEF' as agent, NULL as model, details \
3790 FROM task_activity WHERE id = {activity_id}"
3791 );
3792 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3793 .fetch_one(store.pool())
3794 .await
3795 .unwrap();
3796 let err = TaskStore::row_to_activity(&row).unwrap_err();
3797 assert!(
3798 err.to_string().contains("agent"),
3799 "expected 'agent' in error message, got: {err}"
3800 );
3801 }
3802
3803 #[tokio::test]
3804 async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3805 let (store, _task_id, activity_id) = store_with_activity().await;
3806 let sql = format!(
3807 "SELECT id, task_id, timestamp, event_type, \
3808 NULL as from_status, NULL as to_status, \
3809 NULL as agent, X'DeadbEEF' as model, details \
3810 FROM task_activity WHERE id = {activity_id}"
3811 );
3812 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3813 .fetch_one(store.pool())
3814 .await
3815 .unwrap();
3816 let err = TaskStore::row_to_activity(&row).unwrap_err();
3817 assert!(
3818 err.to_string().contains("model"),
3819 "expected 'model' in error message, got: {err}"
3820 );
3821 }
3822
3823 #[tokio::test]
3826 async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3827 let store = crate::store::TaskStore::open_memory().await.unwrap();
3832 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3833 .execute(store.pool())
3834 .await
3835 .unwrap();
3836 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3838 .execute(store.pool())
3839 .await
3840 .unwrap();
3841
3842 let result = store
3843 .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3844 .await
3845 .unwrap();
3846 assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3847 assert_eq!(
3848 result.get("valid-id"),
3849 Some(&crate::store::TaskStatus::Done)
3850 );
3851 }
3852}