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 = ?,
1836 completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
1837 WHERE id = ?",
1838 )
1839 .bind(run.exit_code)
1840 .bind(run.stdout)
1841 .bind(security::redact(run.stderr))
1842 .bind(run.parsed)
1843 .bind(run.outcome)
1844 .bind(security::redact(run.error))
1845 .bind(i64::try_from(run.tokens.input_tokens).unwrap_or_else(|_| {
1846 tracing::warn!(
1847 run.tokens.input_tokens,
1848 "input_tokens overflows i64, clamping to MAX"
1849 );
1850 i64::MAX
1851 }))
1852 .bind(i64::try_from(run.tokens.output_tokens).unwrap_or_else(|_| {
1853 tracing::warn!(
1854 run.tokens.output_tokens,
1855 "output_tokens overflows i64, clamping to MAX"
1856 );
1857 i64::MAX
1858 }))
1859 .bind(run.tokens.total_cost_usd)
1860 .bind(run.tokens.duration_secs)
1861 .bind(run.run_id)
1862 .execute(&self.pool)
1863 .await?;
1864 if let Some(run_ctx) = run_ctx {
1865 let task_id: i64 = run_ctx.try_get("task_id").map_err(|e| {
1866 anyhow::anyhow!("failed to decode task_id from run_id {}: {e}", run.run_id)
1867 })?;
1868 let run_type: String = run_ctx.try_get("run_type").map_err(|e| {
1869 anyhow::anyhow!("failed to decode run_type from run_id {}: {e}", run.run_id)
1870 })?;
1871 let agent: String = run_ctx.try_get("agent").map_err(|e| {
1872 anyhow::anyhow!("failed to decode agent from run_id {}: {e}", run.run_id)
1873 })?;
1874 let model: String = run_ctx.try_get("model").map_err(|e| {
1875 anyhow::anyhow!("failed to decode model from run_id {}: {e}", run.run_id)
1876 })?;
1877 if run.outcome == "timeout" {
1878 self.append_activity(
1879 task_id,
1880 "timeout",
1881 None,
1882 None,
1883 Some(&agent),
1884 Some(&model),
1885 Some(&serde_json::json!({
1886 "run_type": run_type,
1887 "error": run.error,
1888 "stderr": run.stderr,
1889 })),
1890 )
1891 .await?;
1892 } else if run.outcome != "success" {
1893 self.append_activity(
1894 task_id,
1895 "error",
1896 None,
1897 None,
1898 Some(&agent),
1899 Some(&model),
1900 Some(&serde_json::json!({
1901 "run_type": run_type,
1902 "outcome": run.outcome,
1903 "error": run.error,
1904 })),
1905 )
1906 .await?;
1907 }
1908 } else if run.outcome != "success" {
1909 tracing::warn!(
1910 run_id = run.run_id,
1911 outcome = run.outcome,
1912 "run_id not found in task_runs; skipping timeout/error activity log"
1913 );
1914 }
1915 Ok(())
1916 }
1917
1918 pub async fn finalize_incomplete_runs(
1923 &self,
1924 task_id: i64,
1925 outcome: &str,
1926 error: &str,
1927 ) -> anyhow::Result<u64> {
1928 let rows: Vec<(i64,)> = sqlx::query_as(
1929 "SELECT id FROM task_runs
1930 WHERE task_id = ?
1931 AND outcome IS NULL
1932 AND completed_at IS NULL
1933 ORDER BY id ASC",
1934 )
1935 .bind(task_id)
1936 .fetch_all(&self.pool)
1937 .await?;
1938
1939 for (run_id,) in &rows {
1940 self.complete_run(&CompleteRun {
1941 run_id: *run_id,
1942 exit_code: Some(-1),
1943 stdout: "",
1944 stderr: "",
1945 parsed: "",
1946 outcome,
1947 error,
1948 tokens: RunTokenUsage::default(),
1949 })
1950 .await?;
1951 }
1952
1953 Ok(rows.len() as u64)
1954 }
1955
1956 pub async fn get_runs(&self, task_id: i64) -> anyhow::Result<Vec<TaskRun>> {
1958 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1959 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? ORDER BY attempt ASC, run_type ASC"
1960 )))
1961 .bind(task_id)
1962 .fetch_all(&self.pool)
1963 .await?;
1964
1965 rows.iter().map(Self::row_to_run).collect()
1966 }
1967
1968 pub async fn get_runs_batch(
1971 &self,
1972 task_ids: &[i64],
1973 ) -> anyhow::Result<std::collections::HashMap<i64, Vec<TaskRun>>> {
1974 if task_ids.is_empty() {
1975 return Ok(std::collections::HashMap::new());
1976 }
1977 const CHUNK_SIZE: usize = 500;
1980
1981 let mut result: std::collections::HashMap<i64, Vec<TaskRun>> =
1982 std::collections::HashMap::new();
1983
1984 for chunk in task_ids.chunks(CHUNK_SIZE) {
1985 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1986 let sql = format!(
1987 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY task_id ASC, attempt ASC, run_type ASC"
1988 );
1989 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
1990 for id in chunk {
1991 query = query.bind(*id);
1992 }
1993 let rows = query.fetch_all(&self.pool).await?;
1994 for row in &rows {
1995 let run = Self::row_to_run(row)?;
1996 result.entry(run.task_id).or_default().push(run);
1997 }
1998 }
1999 Ok(result)
2000 }
2001
2002 pub async fn get_batch(
2005 &self,
2006 ids: &[i64],
2007 ) -> anyhow::Result<std::collections::HashMap<i64, Task>> {
2008 if ids.is_empty() {
2009 return Ok(std::collections::HashMap::new());
2010 }
2011 const CHUNK_SIZE: usize = 500;
2014
2015 let mut result = std::collections::HashMap::new();
2016
2017 for chunk in ids.chunks(CHUNK_SIZE) {
2018 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2019 let sql = format!("SELECT {TASK_COLS} FROM tasks WHERE id IN ({placeholders})");
2020 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2021 for id in chunk {
2022 query = query.bind(*id);
2023 }
2024 let rows = query.fetch_all(&self.pool).await?;
2025 for row in &rows {
2026 let task = Self::row_to_task(row)?;
2027 result.insert(task.id, task);
2028 }
2029 }
2030 Ok(result)
2031 }
2032
2033 pub async fn get_statuses_by_external_ids(
2038 &self,
2039 repo: &str,
2040 ext_ids: &[&str],
2041 ) -> anyhow::Result<std::collections::HashMap<String, TaskStatus>> {
2042 let mut result = std::collections::HashMap::new();
2043 if ext_ids.is_empty() {
2044 return Ok(result);
2045 }
2046
2047 const CHUNK_SIZE: usize = 500;
2048 for chunk in ext_ids.chunks(CHUNK_SIZE) {
2049 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2050 let sql = format!(
2051 "SELECT external_id, status FROM tasks WHERE repo = ? AND external_id IN ({placeholders})"
2052 );
2053 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2054 query = query.bind(repo);
2055 for id in chunk {
2056 query = query.bind(id);
2057 }
2058 let rows = query.fetch_all(&self.pool).await?;
2059 for row in &rows {
2060 let external_id: Result<String, _> = row.try_get("external_id");
2061 let status_str: Result<String, _> = row.try_get("status");
2062 match (external_id, status_str) {
2063 (Ok(ext_id), Ok(sts)) => {
2064 if ext_id.is_empty() {
2065 tracing::warn!(status = %sts, "skipping task row with empty external_id");
2066 continue;
2067 }
2068 if let Some(status) = TaskStatus::from_str(&sts) {
2069 result.insert(ext_id, status);
2070 } else {
2071 tracing::warn!(external_id = %ext_id, status = %sts, "unknown status value in tasks table");
2072 }
2073 }
2074 (Err(e), _) => {
2075 tracing::warn!(error = %e, "skipping task row with decode error for external_id");
2076 }
2077 (_, Err(e)) => {
2078 tracing::warn!(error = %e, "skipping task row with decode error for status");
2079 }
2080 }
2081 }
2082 }
2083
2084 Ok(result)
2085 }
2086
2087 #[cfg(test)]
2090 pub async fn get_last_run(
2091 &self,
2092 task_id: i64,
2093 run_type: &str,
2094 ) -> anyhow::Result<Option<TaskRun>> {
2095 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
2096 "SELECT {TASK_RUN_COLS} FROM task_runs WHERE task_id = ? AND run_type = ? ORDER BY attempt DESC LIMIT 1"
2097 )))
2098 .bind(task_id)
2099 .bind(run_type)
2100 .fetch_optional(&self.pool)
2101 .await?;
2102
2103 match row {
2104 Some(r) => Ok(Some(Self::row_to_run(&r)?)),
2105 None => Ok(None),
2106 }
2107 }
2108
2109 pub async fn previous_review_agents(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
2113 let rows: Vec<(String,)> = sqlx::query_as(
2114 "SELECT DISTINCT agent FROM task_runs \
2115 WHERE task_id = ? AND run_type = 'review' \
2116 ORDER BY agent",
2117 )
2118 .bind(task_id)
2119 .fetch_all(&self.pool)
2120 .await?;
2121 Ok(rows.into_iter().map(|(a,)| a).collect())
2122 }
2123
2124 #[cfg(test)]
2127 pub async fn prune_old_runs(&self, days: i32) -> anyhow::Result<u64> {
2128 let result = sqlx::query(
2129 "DELETE FROM task_runs WHERE task_id IN (
2130 SELECT id FROM tasks
2131 WHERE status IN ('done', 'blocked')
2132 AND updated_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ? || ' days')
2133 )",
2134 )
2135 .bind(format!("-{days}"))
2136 .execute(&self.pool)
2137 .await?;
2138
2139 Ok(result.rows_affected())
2140 }
2141
2142 fn row_to_task(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<Task> {
2147 let col_count = row.len();
2150 if col_count != TASK_COLS_COUNT {
2151 tracing::warn!(
2152 expected = TASK_COLS_COUNT,
2153 actual = col_count,
2154 "TASK_COLS column count mismatch - schema may have changed"
2155 );
2156 }
2157
2158 let status_str: String = row
2159 .try_get("status")
2160 .map_err(|e| anyhow::anyhow!("task row missing status column: {e}"))?;
2161 let labels_str: String = row.try_get("labels").unwrap_or_default();
2162 let memory_str: String = row.try_get("memory").unwrap_or_default();
2163 let delegations_str: String = row.try_get("delegations").unwrap_or_default();
2164
2165 Ok(Task {
2166 id: row
2167 .try_get("id")
2168 .map_err(|e| anyhow::anyhow!("task row missing id: {e}"))?,
2169 external_id: row.try_get("external_id").unwrap_or(None),
2170 repo: row
2171 .try_get("repo")
2172 .map_err(|e| anyhow::anyhow!("task row missing or invalid repo: {e}"))?,
2173 origin: row
2174 .try_get("origin")
2175 .map_err(|e| anyhow::anyhow!("task row missing or invalid origin: {e}"))?,
2176 title: row
2177 .try_get("title")
2178 .map_err(|e| anyhow::anyhow!("task row missing or invalid title: {e}"))?,
2179 body: row
2180 .try_get("body")
2181 .map_err(|e| anyhow::anyhow!("task row missing or invalid body: {e}"))?,
2182 status: TaskStatus::from_str(&status_str)
2183 .ok_or_else(|| anyhow::anyhow!("unknown task status: '{status_str}'"))?,
2184 source: row
2185 .try_get("source")
2186 .map_err(|e| anyhow::anyhow!("task row missing or invalid source: {e}"))?,
2187 source_id: row
2188 .try_get("source_id")
2189 .map_err(|e| anyhow::anyhow!("task row missing or invalid source_id: {e}"))?,
2190 author: row
2191 .try_get("author")
2192 .map_err(|e| anyhow::anyhow!("task row missing or invalid author: {e}"))?,
2193 url: row
2194 .try_get("url")
2195 .map_err(|e| anyhow::anyhow!("task row missing or invalid url: {e}"))?,
2196 labels: serde_json::from_str(&labels_str)
2197 .inspect_err(
2198 |e| tracing::warn!(error = %e, "corrupt labels JSON, defaulting to empty"),
2199 )
2200 .unwrap_or_default(),
2201 agent: row.try_get("agent").unwrap_or(None),
2202 model: row.try_get("model").unwrap_or(None),
2203 complexity: row.try_get("complexity").unwrap_or_default(),
2204 estimate: {
2205 let raw: i32 = row.try_get("estimate").unwrap_or(0);
2206 u8::try_from(raw).unwrap_or_else(|_| {
2207 tracing::warn!(
2208 raw_estimate = raw,
2209 "estimate value out of u8 range; defaulting to 0"
2210 );
2211 0
2212 })
2213 },
2214 route_reason: row.try_get("route_reason").unwrap_or_default(),
2215 agent_profile: row.try_get("agent_profile").unwrap_or_default(),
2216 selected_skills: row.try_get("selected_skills").unwrap_or_default(),
2217 route_attempts: row.try_get("route_attempts").unwrap_or(0),
2218 attempts: row.try_get("attempts").unwrap_or(0),
2219 branch: row.try_get("branch").unwrap_or_default(),
2220 worktree: row.try_get("worktree").unwrap_or_default(),
2221 worktree_cleaned: row.try_get::<i32, _>("worktree_cleaned").unwrap_or(0) != 0,
2222 owner_node: row.try_get("owner_node").unwrap_or(None),
2223 summary: row.try_get("summary").unwrap_or_default(),
2224 last_error: row.try_get("last_error").unwrap_or_default(),
2225 parent_id: row.try_get("parent_id").unwrap_or(None),
2226 block_reason: row.try_get("block_reason").unwrap_or(None),
2227 pr_number: row.try_get("pr_number").unwrap_or(None),
2228 pr_review_context: row.try_get("pr_review_context").unwrap_or_default(),
2229 last_review_ts: row.try_get("last_review_ts").unwrap_or_default(),
2230 review_ts_map: row.try_get("review_ts_map").unwrap_or_default(),
2231 last_comment_review_ts: row.try_get("last_comment_review_ts").unwrap_or_default(),
2232 merge_conflict_retries: row.try_get("merge_conflict_retries").unwrap_or(0),
2233 ci_merge_failures: row.try_get("ci_merge_failures").unwrap_or(0),
2234 pr_create_failures: row.try_get("pr_create_failures").unwrap_or(0),
2235 push_failures: row.try_get::<i32, _>("push_failures").unwrap_or(0),
2236 network_retries: row.try_get::<i32, _>("network_retries").unwrap_or(0),
2237 review_agent_failures: row.try_get("review_agent_failures").unwrap_or(0),
2238 review_resets: row.try_get::<i32, _>("review_resets").unwrap_or(0),
2239 review_cycles: row.try_get("review_cycles").unwrap_or(0),
2240 review_invocations: row.try_get::<i32, _>("review_invocations").unwrap_or(0),
2241 needs_review_refires: row.try_get::<i32, _>("needs_review_refires").unwrap_or(0),
2242 input_tokens: row.try_get("input_tokens").unwrap_or(0),
2243 output_tokens: row.try_get("output_tokens").unwrap_or(0),
2244 input_cost_usd: row.try_get("input_cost_usd").unwrap_or(0.0),
2245 output_cost_usd: row.try_get("output_cost_usd").unwrap_or(0.0),
2246 total_cost_usd: row.try_get("total_cost_usd").unwrap_or(0.0),
2247 model_reroute_chain: row.try_get("model_reroute_chain").unwrap_or_default(),
2248 limit_reroute_chain: row.try_get("limit_reroute_chain").unwrap_or_default(),
2249 memory: serde_json::from_str(&memory_str)
2250 .inspect_err(
2251 |e| tracing::warn!(error = %e, "corrupt memory JSON, defaulting to empty"),
2252 )
2253 .unwrap_or_default(),
2254 delegations: serde_json::from_str(&delegations_str)
2255 .inspect_err(
2256 |e| tracing::warn!(error = %e, "corrupt delegations JSON, defaulting to empty"),
2257 )
2258 .unwrap_or_default(),
2259 auto_unblock_count: row.try_get::<i32, _>("auto_unblock_count").unwrap_or(0),
2264 auto_unblock_last_at: row
2265 .try_get::<String, _>("auto_unblock_last_at")
2266 .unwrap_or_default(),
2267 auto_unblock_last_reason: row
2268 .try_get::<String, _>("auto_unblock_last_reason")
2269 .unwrap_or_default(),
2270 ci_recovery_count: row.try_get::<i32, _>("ci_recovery_count").unwrap_or(0),
2271 no_code_reroutes: row.try_get::<i32, _>("no_code_reroutes").unwrap_or(0),
2272 no_code_last_agent: row
2273 .try_get::<String, _>("no_code_last_agent")
2274 .unwrap_or_default(),
2275 created_at: row
2276 .try_get::<String, _>("created_at")
2277 .map_err(|e| anyhow::anyhow!("task row missing created_at: {e}"))?,
2278 updated_at: row
2279 .try_get::<String, _>("updated_at")
2280 .map_err(|e| anyhow::anyhow!("task row missing updated_at: {e}"))?,
2281 })
2282 }
2283 pub async fn ensure_external_task(
2284 &self,
2285 repo: &str,
2286 ext: &crate::backends::ExternalTask,
2287 ) -> anyhow::Result<i64> {
2288 self.upsert_external(&UpsertExternal {
2289 repo,
2290 ext_id: &ext.id.0,
2291 title: &ext.title,
2292 body: &ext.body,
2293 author: &ext.author,
2294 url: &ext.url,
2295 labels: &ext.labels,
2296 origin: "github",
2297 })
2298 .await
2299 }
2300
2301 pub async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
2304 if let Some(suffix) = task_id.strip_prefix("internal:") {
2305 if let Some(id) = self.resolve_id_by_external(repo, task_id).await? {
2307 return Ok(Some(id));
2308 }
2309 if let Ok(id) = suffix.parse::<i64>() {
2313 let exists = sqlx::query("SELECT id FROM tasks WHERE id = ? AND repo = ?")
2314 .bind(id)
2315 .bind(repo)
2316 .fetch_optional(&self.pool)
2317 .await?;
2318 if exists.is_some() {
2319 return Ok(Some(id));
2320 }
2321 }
2322 return Ok(None);
2323 }
2324 self.resolve_id_by_external(repo, task_id).await
2327 }
2328
2329 pub async fn resolve_task_id_globally(
2335 &self,
2336 task_id: &str,
2337 ) -> anyhow::Result<Option<(i64, String)>> {
2338 if let Some(suffix) = task_id.strip_prefix("internal:") {
2339 let row = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2341 .bind(task_id)
2342 .fetch_optional(&self.pool)
2343 .await?;
2344 if let Some(r) = row {
2345 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2346 }
2347 if let Ok(id) = suffix.parse::<i64>() {
2348 let row = sqlx::query("SELECT id, repo FROM tasks WHERE id = ?")
2349 .bind(id)
2350 .fetch_optional(&self.pool)
2351 .await?;
2352 if let Some(r) = row {
2353 return Ok(Some((r.try_get("id")?, r.try_get("repo")?)));
2354 }
2355 }
2356 return Ok(None);
2357 }
2358 let rows = sqlx::query("SELECT id, repo FROM tasks WHERE external_id = ?")
2360 .bind(task_id)
2361 .fetch_all(&self.pool)
2362 .await?;
2363 match rows.len() {
2364 0 => Ok(None),
2365 1 => Ok(Some((rows[0].try_get("id")?, rows[0].try_get("repo")?))),
2366 _ => {
2367 let repos: Vec<String> = rows
2368 .iter()
2369 .filter_map(|r| r.try_get::<String, _>("repo").ok())
2370 .collect();
2371 anyhow::bail!(
2372 "task ID {task_id:?} is ambiguous — found in multiple repos: {}. \
2373 Run from the repo worktree so the current-repo lookup resolves it.",
2374 repos.join(", ")
2375 )
2376 }
2377 }
2378 }
2379
2380 pub async fn resolve_cli_id(
2382 &self,
2383 spec: &str,
2384 current_repo: Option<&str>,
2385 ) -> anyhow::Result<Option<CliTaskRef>> {
2386 let current_repo = current_repo.filter(|repo| !repo.is_empty());
2387 match parse_cli_task_spec(spec)? {
2388 CliTaskSpec::Internal(number) => self.lookup_internal_cli_id(number).await,
2389 CliTaskSpec::Issue { repo, number } => self.lookup_issue_cli_id(&repo, number).await,
2390 CliTaskSpec::Bare(number) => {
2391 if let Some(repo) = current_repo {
2392 if let Some(task) = self.lookup_issue_cli_id(repo, number).await? {
2393 return Ok(Some(task));
2394 }
2395 if let Some(task) = self.lookup_internal_in_repo(number, repo).await? {
2396 return Ok(Some(task));
2397 }
2398 return Ok(None);
2399 }
2400 let mut matches = self.lookup_bare_cli_id_matches(number).await?;
2401 match matches.len() {
2402 0 => Ok(None),
2403 1 => Ok(matches.pop()),
2404 _ => Err(ambiguous_cli_id_error(spec, &matches)),
2405 }
2406 }
2407 }
2408 }
2409
2410 async fn lookup_internal_cli_id(&self, number: i64) -> anyhow::Result<Option<CliTaskRef>> {
2411 let ext = format!("internal:{number}");
2412 let rows = sqlx::query(
2413 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2414 WHERE origin = 'internal' AND (id = ? OR external_id = ?) \
2415 ORDER BY id",
2416 )
2417 .bind(number)
2418 .bind(&ext)
2419 .fetch_all(&self.pool)
2420 .await?;
2421 let mut matches = dedup_cli_refs(rows)?;
2422 match matches.len() {
2423 0 => Ok(None),
2424 1 => Ok(matches.pop()),
2425 _ => Err(ambiguous_cli_id_error(&ext, &matches)),
2426 }
2427 }
2428
2429 async fn lookup_internal_in_repo(
2430 &self,
2431 number: i64,
2432 repo: &str,
2433 ) -> anyhow::Result<Option<CliTaskRef>> {
2434 let row = sqlx::query(
2435 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2436 WHERE origin = 'internal' AND id = ? AND repo = ?",
2437 )
2438 .bind(number)
2439 .bind(repo)
2440 .fetch_optional(&self.pool)
2441 .await?;
2442 match row {
2443 Some(row) => Ok(Some(CliTaskRef::from_row(&row)?)),
2444 None => Ok(None),
2445 }
2446 }
2447
2448 async fn lookup_issue_cli_id(
2449 &self,
2450 repo: &str,
2451 number: i64,
2452 ) -> anyhow::Result<Option<CliTaskRef>> {
2453 let ext = number.to_string();
2454 let rows = sqlx::query(
2455 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2456 WHERE repo = ? AND origin != 'internal' AND external_id = ? \
2457 ORDER BY id",
2458 )
2459 .bind(repo)
2460 .bind(&ext)
2461 .fetch_all(&self.pool)
2462 .await?;
2463 let mut matches = dedup_cli_refs(rows)?;
2464 match matches.len() {
2465 0 => Ok(None),
2466 1 => Ok(matches.pop()),
2467 _ => Err(ambiguous_cli_id_error(
2468 &format!("{repo}#{number}"),
2469 &matches,
2470 )),
2471 }
2472 }
2473
2474 async fn lookup_bare_cli_id_matches(&self, number: i64) -> anyhow::Result<Vec<CliTaskRef>> {
2475 let ext = number.to_string();
2476 let rows = sqlx::query(
2477 "SELECT id, repo, origin, title, external_id, status FROM tasks \
2478 WHERE (origin = 'internal' AND id = ?) \
2479 OR (origin != 'internal' AND external_id = ?) \
2480 ORDER BY repo, id",
2481 )
2482 .bind(number)
2483 .bind(&ext)
2484 .fetch_all(&self.pool)
2485 .await?;
2486 dedup_cli_refs(rows)
2487 }
2488
2489 fn row_to_run(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskRun> {
2490 Ok(TaskRun {
2491 id: row
2492 .try_get("id")
2493 .map_err(|e| anyhow::anyhow!("run row decode error for id: {e}"))?,
2494 task_id: row
2495 .try_get("task_id")
2496 .map_err(|e| anyhow::anyhow!("run row decode error for task_id: {e}"))?,
2497 attempt: row
2498 .try_get("attempt")
2499 .map_err(|e| anyhow::anyhow!("run row decode error for attempt: {e}"))?,
2500 run_type: row
2501 .try_get("run_type")
2502 .map_err(|e| anyhow::anyhow!("run row decode error for run_type: {e}"))?,
2503 agent: row
2504 .try_get("agent")
2505 .map_err(|e| anyhow::anyhow!("run row decode error for agent: {e}"))?,
2506 model: row
2507 .try_get("model")
2508 .map_err(|e| anyhow::anyhow!("run row decode error for model: {e}"))?,
2509 command: row
2510 .try_get("command")
2511 .map_err(|e| anyhow::anyhow!("run row decode error for command: {e}"))?,
2512 prompt: row
2513 .try_get("prompt")
2514 .map_err(|e| anyhow::anyhow!("run row decode error for prompt: {e}"))?,
2515 env_vars: row
2516 .try_get("env_vars")
2517 .map_err(|e| anyhow::anyhow!("run row decode error for env_vars: {e}"))?,
2518 exit_code: row
2519 .try_get("exit_code")
2520 .map_err(|e| anyhow::anyhow!("run row decode error for exit_code: {e}"))?,
2521 stdout: row
2522 .try_get("stdout")
2523 .map_err(|e| anyhow::anyhow!("run row decode error for stdout: {e}"))?,
2524 stderr: row
2525 .try_get("stderr")
2526 .map_err(|e| anyhow::anyhow!("run row decode error for stderr: {e}"))?,
2527 parsed_response: row
2528 .try_get("parsed_response")
2529 .map_err(|e| anyhow::anyhow!("run row decode error for parsed_response: {e}"))?,
2530 outcome: row
2531 .try_get("outcome")
2532 .map_err(|e| anyhow::anyhow!("run row decode error for outcome: {e}"))?,
2533 error: row
2534 .try_get("error")
2535 .map_err(|e| anyhow::anyhow!("run row decode error for error: {e}"))?,
2536 input_tokens: row
2537 .try_get("input_tokens")
2538 .map_err(|e| anyhow::anyhow!("run row decode error for input_tokens: {e}"))?,
2539 output_tokens: row
2540 .try_get("output_tokens")
2541 .map_err(|e| anyhow::anyhow!("run row decode error for output_tokens: {e}"))?,
2542 total_cost_usd: row
2543 .try_get("total_cost_usd")
2544 .map_err(|e| anyhow::anyhow!("run row decode error for total_cost_usd: {e}"))?,
2545 duration_secs: row
2546 .try_get("duration_secs")
2547 .map_err(|e| anyhow::anyhow!("run row decode error for duration_secs: {e}"))?,
2548 started_at: row
2549 .try_get("started_at")
2550 .map_err(|e| anyhow::anyhow!("run row decode error for started_at: {e}"))?,
2551 completed_at: row.try_get("completed_at").unwrap_or(None),
2552 })
2553 }
2554
2555 fn row_to_activity(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<TaskActivity> {
2556 let details_str: String = row
2557 .try_get("details")
2558 .map_err(|e| anyhow::anyhow!("activity row decode error for details: {e}"))?;
2559 let details = serde_json::from_str::<serde_json::Value>(&details_str).inspect_err(
2560 |e| tracing::warn!(error = %e, "corrupt activity details JSON, skipping field"),
2561 )?;
2562 Ok(TaskActivity {
2563 id: row
2564 .try_get("id")
2565 .map_err(|e| anyhow::anyhow!("activity row decode error for id: {e}"))?,
2566 task_id: row
2567 .try_get("task_id")
2568 .map_err(|e| anyhow::anyhow!("activity row decode error for task_id: {e}"))?,
2569 timestamp: row
2570 .try_get("timestamp")
2571 .map_err(|e| anyhow::anyhow!("activity row decode error for timestamp: {e}"))?,
2572 event_type: row
2573 .try_get("event_type")
2574 .map_err(|e| anyhow::anyhow!("activity row decode error for event_type: {e}"))?,
2575 from_status: row
2576 .try_get("from_status")
2577 .map_err(|e| anyhow::anyhow!("activity row decode error for from_status: {e}"))?,
2578 to_status: row
2579 .try_get("to_status")
2580 .map_err(|e| anyhow::anyhow!("activity row decode error for to_status: {e}"))?,
2581 agent: row
2582 .try_get("agent")
2583 .map_err(|e| anyhow::anyhow!("activity row decode error for agent: {e}"))?,
2584 model: row
2585 .try_get("model")
2586 .map_err(|e| anyhow::anyhow!("activity row decode error for model: {e}"))?,
2587 details,
2588 })
2589 }
2590}
2591
2592const LOCAL_TASK_COLUMNS: &[&str] = &[
2594 "worktree",
2595 "input_tokens",
2596 "output_tokens",
2597 "input_cost_usd",
2598 "output_cost_usd",
2599 "total_cost_usd",
2600];
2601
2602pub(crate) fn is_local_column(col: &str) -> bool {
2603 LOCAL_TASK_COLUMNS.contains(&col)
2604}
2605
2606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2608pub struct UpsertExternalCmd {
2609 pub repo: String,
2610 pub ext_id: String,
2611 pub title: String,
2612 pub body: String,
2613 pub author: String,
2614 pub url: String,
2615 pub labels: Vec<String>,
2616 pub origin: String,
2617 pub parent_id: Option<i64>,
2618}
2619
2620#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2622pub struct StoreRouteCmd {
2623 pub id: i64,
2624 pub agent: String,
2625 pub model: Option<String>,
2626 pub complexity: String,
2627 pub estimate: u8,
2628 pub reason: String,
2629 pub profile: String,
2630 pub skills: String,
2631}
2632
2633const TOMBSTONE_COLUMNS: &[&str] = &[
2635 "id",
2636 "external_id",
2637 "repo",
2638 "origin",
2639 "title",
2640 "status",
2641 "pr_number",
2642 "parent_id",
2643 "created_at",
2644 "updated_at",
2645];
2646
2647async fn replicated_columns(conn: &mut sqlx::SqliteConnection) -> anyhow::Result<Vec<String>> {
2648 let cols: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info('tasks')")
2649 .fetch_all(conn)
2650 .await?;
2651 Ok(cols
2652 .into_iter()
2653 .map(|(c,)| c)
2654 .filter(|c| !is_local_column(c))
2655 .collect())
2656}
2657
2658pub(crate) async fn replicated_rows(pool: &SqlitePool) -> anyhow::Result<Vec<serde_json::Value>> {
2660 let mut conn = pool.acquire().await?;
2661 let cols = replicated_columns(&mut conn).await?;
2662 let full: Vec<&String> = cols.iter().collect();
2663 let tomb: Vec<&String> = cols
2664 .iter()
2665 .filter(|c| TOMBSTONE_COLUMNS.contains(&c.as_str()))
2666 .collect();
2667 let sql = format!(
2668 "SELECT CASE WHEN status = 'done' THEN {} ELSE {} END FROM tasks ORDER BY id",
2669 super::replica::json_row_sql(&tomb),
2670 super::replica::json_row_sql(&full)
2671 );
2672 let rows: Vec<(String,)> = sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str()))
2673 .fetch_all(&mut *conn)
2674 .await?;
2675 rows.into_iter()
2676 .map(|(r,)| Ok(serde_json::from_str(&r)?))
2677 .collect()
2678}
2679
2680pub(crate) async fn replace_replicated(
2683 conn: &mut sqlx::SqliteConnection,
2684 rows: &[serde_json::Value],
2685) -> anyhow::Result<()> {
2686 let allowed = replicated_columns(conn).await?;
2687 sqlx::query("PRAGMA defer_foreign_keys = ON")
2688 .execute(&mut *conn)
2689 .await?;
2690 sqlx::query("CREATE TEMP TABLE IF NOT EXISTS snapshot_ids (id INTEGER PRIMARY KEY)")
2691 .execute(&mut *conn)
2692 .await?;
2693 sqlx::query("DELETE FROM snapshot_ids")
2694 .execute(&mut *conn)
2695 .await?;
2696 for row in rows {
2697 let id = row["id"]
2698 .as_i64()
2699 .context("snapshot task row without an id")?;
2700 sqlx::query("INSERT INTO snapshot_ids (id) VALUES (?)")
2701 .bind(id)
2702 .execute(&mut *conn)
2703 .await?;
2704 }
2705
2706 for table in ["task_runs", "task_activity"] {
2707 sqlx::query(sqlx::AssertSqlSafe(format!(
2708 "DELETE FROM {table} WHERE task_id NOT IN (SELECT id FROM snapshot_ids)"
2709 )))
2710 .execute(&mut *conn)
2711 .await?;
2712 }
2713
2714 sqlx::query("DELETE FROM tasks WHERE id NOT IN (SELECT id FROM snapshot_ids)")
2715 .execute(&mut *conn)
2716 .await?;
2717
2718 for row in rows {
2719 let obj = row
2720 .as_object()
2721 .context("snapshot task row is not an object")?;
2722 let cols: Vec<&String> = obj.keys().collect();
2723 for c in &cols {
2724 anyhow::ensure!(
2725 allowed.contains(c),
2726 "snapshot names unknown task column {c}"
2727 );
2728 }
2729
2730 let values: Vec<String> = cols
2731 .iter()
2732 .map(|c| format!("json_extract(?1, '$.\"{c}\"')"))
2733 .collect();
2734 let updates: Vec<String> = cols.iter().map(|c| format!("{c} = excluded.{c}")).collect();
2735 let names: Vec<&str> = cols.iter().map(|c| c.as_str()).collect();
2736 let sql = format!(
2737 "INSERT INTO tasks ({}) VALUES ({}) ON CONFLICT(id) DO UPDATE SET {}",
2738 names.join(", "),
2739 values.join(", "),
2740 updates.join(", ")
2741 );
2742 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2743 .bind(serde_json::to_string(row)?)
2744 .execute(&mut *conn)
2745 .await?;
2746 }
2747
2748 sqlx::query("DROP TABLE snapshot_ids")
2749 .execute(&mut *conn)
2750 .await?;
2751 Ok(())
2752}
2753
2754pub(crate) mod apply {
2756 use super::*;
2757 use sqlx::SqliteConnection;
2758
2759 async fn previous(
2760 conn: &mut SqliteConnection,
2761 id: i64,
2762 ) -> anyhow::Result<(String, Option<String>, Option<String>)> {
2763 let row = sqlx::query("SELECT status, agent, model FROM tasks WHERE id = ?")
2764 .bind(id)
2765 .fetch_one(&mut *conn)
2766 .await?;
2767 let status: String = row
2768 .try_get("status")
2769 .map_err(|e| anyhow::anyhow!("failed to read status column before update: {e}"))?;
2770 let agent: Option<String> = row
2771 .try_get("agent")
2772 .map_err(|e| anyhow::anyhow!("failed to read agent column before update: {e}"))?;
2773 let model: Option<String> = row
2774 .try_get("model")
2775 .map_err(|e| anyhow::anyhow!("failed to read model column before update: {e}"))?;
2776 Ok((status, agent, model))
2777 }
2778
2779 #[allow(clippy::too_many_arguments)]
2780 pub(crate) async fn activity(
2781 conn: &mut SqliteConnection,
2782 task_id: i64,
2783 event_type: &str,
2784 from_status: Option<&str>,
2785 to_status: Option<&str>,
2786 agent: Option<&str>,
2787 model: Option<&str>,
2788 details: Option<&serde_json::Value>,
2789 ) -> anyhow::Result<()> {
2790 let details_json = match details {
2791 Some(v) => serde_json::to_string(&sanitize_activity_details(v))?,
2792 None => "{}".to_string(),
2793 };
2794 sqlx::query(
2795 "INSERT INTO task_activity
2796 (task_id, event_type, from_status, to_status, agent, model, details)
2797 VALUES (?, ?, ?, ?, ?, ?, ?)",
2798 )
2799 .bind(task_id)
2800 .bind(event_type)
2801 .bind(from_status)
2802 .bind(to_status)
2803 .bind(agent)
2804 .bind(model)
2805 .bind(details_json)
2806 .execute(conn)
2807 .await?;
2808 Ok(())
2809 }
2810
2811 async fn status_change(
2812 conn: &mut SqliteConnection,
2813 id: i64,
2814 previous: &(String, Option<String>, Option<String>),
2815 to: TaskStatus,
2816 details: Option<&serde_json::Value>,
2817 ) -> anyhow::Result<()> {
2818 activity(
2819 conn,
2820 id,
2821 "status_change",
2822 Some(previous.0.as_str()),
2823 Some(to.as_str()),
2824 previous.1.as_deref(),
2825 previous.2.as_deref(),
2826 details,
2827 )
2828 .await
2829 }
2830
2831 pub(crate) async fn create(
2834 conn: &mut SqliteConnection,
2835 now: &str,
2836 new: &NewTask,
2837 internal: bool,
2838 replicated: bool,
2839 ) -> anyhow::Result<i64> {
2840 let labels_json = serde_json::to_string(&new.labels)?;
2841 let row = sqlx::query(
2842 "INSERT INTO tasks (id, external_id, repo, origin, title, body, source, source_id, author, url, labels, parent_id, created_at, updated_at)
2843 VALUES ((SELECT CASE WHEN ? THEN COALESCE(MAX(id), 0) + 1 END FROM tasks), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2844 RETURNING id",
2845 )
2846 .bind(replicated)
2847 .bind(&new.external_id)
2848 .bind(&new.repo)
2849 .bind(&new.origin)
2850 .bind(&new.title)
2851 .bind(&new.body)
2852 .bind(&new.source)
2853 .bind(&new.source_id)
2854 .bind(&new.author)
2855 .bind(&new.url)
2856 .bind(&labels_json)
2857 .bind(new.parent_id)
2858 .bind(now)
2859 .bind(now)
2860 .fetch_one(&mut *conn)
2861 .await?;
2862 let id: i64 = row
2863 .try_get("id")
2864 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))?;
2865 if internal {
2866 sqlx::query("UPDATE tasks SET external_id = ? WHERE id = ?")
2868 .bind(format!("internal:{id}"))
2869 .bind(id)
2870 .execute(conn)
2871 .await?;
2872 }
2873
2874 Ok(id)
2875 }
2876
2877 pub(crate) async fn upsert_external(
2878 conn: &mut SqliteConnection,
2879 now: &str,
2880 ext: &UpsertExternalCmd,
2881 replicated: bool,
2882 ) -> anyhow::Result<i64> {
2883 let labels_json = serde_json::to_string(&ext.labels)?;
2884 let row = sqlx::query(
2885 "INSERT INTO tasks (id, external_id, repo, origin, title, body, author, url, labels, parent_id, created_at, updated_at)
2886 VALUES ((SELECT CASE WHEN ? THEN COALESCE(MAX(id), 0) + 1 END FROM tasks), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2887 ON CONFLICT(repo, external_id) DO UPDATE SET
2888 title = excluded.title,
2889 body = excluded.body,
2890 labels = excluded.labels,
2891 url = excluded.url,
2892 parent_id = COALESCE(excluded.parent_id, tasks.parent_id)
2893 RETURNING id",
2894 )
2895 .bind(replicated)
2896 .bind(&ext.ext_id)
2897 .bind(&ext.repo)
2898 .bind(&ext.origin)
2899 .bind(&ext.title)
2900 .bind(&ext.body)
2901 .bind(&ext.author)
2902 .bind(&ext.url)
2903 .bind(&labels_json)
2904 .bind(ext.parent_id)
2905 .bind(now)
2906 .bind(now)
2907 .fetch_one(conn)
2908 .await?;
2909 row.try_get("id")
2910 .map_err(|e| anyhow::anyhow!("INSERT RETURNING id returned NULL or invalid: {e}"))
2911 }
2912
2913 fn ensure_allowed(updates: &[(String, serde_json::Value)]) -> anyhow::Result<()> {
2914 for (col, _) in updates {
2915 anyhow::ensure!(
2916 ALLOWED_FIELDS.contains(&col.as_str()),
2917 "column {col} is not in the update allowlist"
2918 );
2919 }
2920
2921 Ok(())
2922 }
2923
2924 pub(crate) async fn update_status_and_fields(
2925 conn: &mut SqliteConnection,
2926 now: &str,
2927 id: i64,
2928 status: TaskStatus,
2929 updates: &[(String, serde_json::Value)],
2930 ) -> anyhow::Result<()> {
2931 ensure_allowed(updates)?;
2932 let prev = previous(conn, id).await?;
2933
2934 let mut set_parts = vec!["status = ?".to_string()];
2935 let mut values: Vec<Option<String>> = vec![Some(status.as_str().to_string())];
2936 let mut block_reason_in_updates = false;
2937 for (col, val) in updates {
2938 if col == "block_reason" {
2939 block_reason_in_updates = true;
2940 }
2941
2942 set_parts.push(format!("{col} = ?"));
2943 match val {
2944 serde_json::Value::String(s) => values.push(Some(s.clone())),
2945 serde_json::Value::Number(n) => values.push(Some(n.to_string())),
2946 serde_json::Value::Bool(b) => {
2947 values.push(Some(if *b { "1" } else { "0" }.to_string()));
2948 }
2949 serde_json::Value::Null => values.push(None),
2950 other => values.push(Some(serde_json::to_string(other)?)),
2951 }
2952 }
2953
2954 if !block_reason_in_updates && status != TaskStatus::Blocked {
2955 set_parts.push("block_reason = NULL".to_string());
2956 }
2957
2958 set_parts.push("updated_at = ?".to_string());
2959 values.push(Some(now.to_string()));
2960 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
2961 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
2962 for v in &values {
2963 query = query.bind(v.as_deref());
2964 }
2965 query.bind(id).execute(&mut *conn).await?;
2966 status_change(conn, id, &prev, status, None).await
2967 }
2968
2969 pub(crate) async fn update_status(
2971 conn: &mut SqliteConnection,
2972 now: &str,
2973 id: i64,
2974 status: TaskStatus,
2975 expected: Option<TaskStatus>,
2976 ) -> anyhow::Result<bool> {
2977 let prev = previous(conn, id).await?;
2978 let clear_block = if status == TaskStatus::Blocked {
2979 ""
2980 } else {
2981 ", block_reason = NULL"
2982 };
2983 let guard = if expected.is_some() {
2984 " AND status = ?"
2985 } else {
2986 ""
2987 };
2988 let sql =
2989 format!("UPDATE tasks SET status = ?{clear_block}, updated_at = ? WHERE id = ?{guard}");
2990 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
2991 .bind(status.as_str())
2992 .bind(now)
2993 .bind(id);
2994 if let Some(expected) = expected {
2995 query = query.bind(expected.as_str());
2996 }
2997
2998 if query.execute(&mut *conn).await?.rows_affected() == 0 {
2999 return Ok(false);
3000 }
3001
3002 status_change(conn, id, &prev, status, None).await?;
3003 Ok(true)
3004 }
3005
3006 pub(crate) async fn update_external_id(
3007 conn: &mut SqliteConnection,
3008 now: &str,
3009 id: i64,
3010 external_id: &str,
3011 ) -> anyhow::Result<()> {
3012 sqlx::query("UPDATE tasks SET external_id = ?, updated_at = ? WHERE id = ?")
3013 .bind(external_id)
3014 .bind(now)
3015 .bind(id)
3016 .execute(conn)
3017 .await?;
3018 Ok(())
3019 }
3020
3021 pub(crate) async fn touch(
3022 conn: &mut SqliteConnection,
3023 now: &str,
3024 id: i64,
3025 ) -> anyhow::Result<()> {
3026 sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
3027 .bind(now)
3028 .bind(id)
3029 .execute(conn)
3030 .await?;
3031 Ok(())
3032 }
3033
3034 pub(crate) async fn reset_to_new(
3035 conn: &mut SqliteConnection,
3036 now: &str,
3037 id: i64,
3038 ) -> anyhow::Result<()> {
3039 let prev = previous(conn, id).await?;
3040 sqlx::query(
3041 "UPDATE tasks SET status = 'new', branch = '', worktree = '', worktree_cleaned = 0, block_reason = NULL, updated_at = ? WHERE id = ?",
3042 )
3043 .bind(now)
3044 .bind(id)
3045 .execute(&mut *conn)
3046 .await?;
3047 let details = serde_json::json!({ "op": "reset_to_new" });
3048 status_change(conn, id, &prev, TaskStatus::New, Some(&details)).await
3049 }
3050
3051 pub(crate) async fn set_fields(
3053 conn: &mut SqliteConnection,
3054 now: &str,
3055 id: i64,
3056 updates: &[(String, serde_json::Value)],
3057 silent: bool,
3058 ) -> anyhow::Result<()> {
3059 if updates.is_empty() {
3060 return Ok(());
3061 }
3062
3063 ensure_allowed(updates)?;
3064 let mut set_parts = Vec::new();
3065 let mut values: Vec<Option<String>> = Vec::new();
3066 for (col, val) in updates {
3067 set_parts.push(format!("{col} = ?"));
3068 values.push(sql_value_for_task_field(col, val)?);
3069 }
3070
3071 if !silent {
3072 set_parts.push("updated_at = ?".to_string());
3073 values.push(Some(now.to_string()));
3074 }
3075
3076 let sql = format!("UPDATE tasks SET {} WHERE id = ?", set_parts.join(", "));
3077 let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
3078 for v in &values {
3079 query = query.bind(v.as_deref());
3080 }
3081 query.bind(id).execute(conn).await?;
3082 Ok(())
3083 }
3084
3085 pub(crate) async fn increment(
3086 conn: &mut SqliteConnection,
3087 now: &str,
3088 id: i64,
3089 field: &str,
3090 ) -> anyhow::Result<i64> {
3091 anyhow::ensure!(
3092 INCREMENTABLE_FIELDS.contains(&field),
3093 "field {field} is not incrementable"
3094 );
3095 let sql = format!(
3096 "UPDATE tasks SET {field} = {field} + 1, updated_at = ? WHERE id = ? RETURNING {field} AS val"
3097 );
3098 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3099 .bind(now)
3100 .bind(id)
3101 .fetch_one(conn)
3102 .await?;
3103 let val: i32 = row.try_get("val")?;
3104 Ok(val.into())
3105 }
3106
3107 pub(crate) async fn reset_counters(
3110 conn: &mut SqliteConnection,
3111 now: &str,
3112 id: i64,
3113 failure_only: bool,
3114 ) -> anyhow::Result<()> {
3115 let all = if failure_only {
3116 ""
3117 } else {
3118 "attempts = 0, merge_conflict_retries = 0, ci_merge_failures = 0, review_cycles = 0, review_invocations = 0,"
3119 };
3120 let sql = format!(
3121 "UPDATE tasks SET {all}
3122 route_attempts = 0,
3123 review_agent_failures = 0,
3124 review_resets = 0,
3125 pr_create_failures = 0,
3126 push_failures = 0,
3127 network_retries = 0,
3128 auto_unblock_count = 0,
3129 auto_unblock_last_at = '',
3130 auto_unblock_last_reason = '',
3131 ci_recovery_count = 0,
3132 no_code_reroutes = 0,
3133 no_code_last_agent = '',
3134 needs_review_refires = 0,
3135 updated_at = ?
3136 WHERE id = ?"
3137 );
3138 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3139 .bind(now)
3140 .bind(id)
3141 .execute(conn)
3142 .await?;
3143 Ok(())
3144 }
3145
3146 pub(crate) async fn store_route(
3147 conn: &mut SqliteConnection,
3148 now: &str,
3149 route: &StoreRouteCmd,
3150 ) -> anyhow::Result<()> {
3151 sqlx::query(
3152 "UPDATE tasks SET
3153 agent = ?, model = ?, complexity = ?, estimate = ?, route_reason = ?,
3154 agent_profile = ?, selected_skills = ?,
3155 updated_at = ?
3156 WHERE id = ?",
3157 )
3158 .bind(&route.agent)
3159 .bind(&route.model)
3160 .bind(&route.complexity)
3161 .bind(route.estimate as i64)
3162 .bind(&route.reason)
3163 .bind(&route.profile)
3164 .bind(&route.skills)
3165 .bind(now)
3166 .bind(route.id)
3167 .execute(&mut *conn)
3168 .await?;
3169 let details = serde_json::json!({
3170 "complexity": route.complexity,
3171 "estimate": route.estimate,
3172 "reason": route.reason,
3173 "skills": route.skills,
3174 });
3175 activity(
3176 conn,
3177 route.id,
3178 "dispatch",
3179 None,
3180 None,
3181 Some(&route.agent),
3182 route.model.as_deref(),
3183 Some(&details),
3184 )
3185 .await
3186 }
3187
3188 pub(crate) async fn set_estimate_if_zero(
3189 conn: &mut SqliteConnection,
3190 now: &str,
3191 id: i64,
3192 estimate: u8,
3193 ) -> anyhow::Result<bool> {
3194 let result = sqlx::query(
3195 "UPDATE tasks SET estimate = ?, updated_at = ? WHERE id = ? AND estimate = 0",
3196 )
3197 .bind(estimate as i64)
3198 .bind(now)
3199 .bind(id)
3200 .execute(conn)
3201 .await?;
3202 Ok(result.rows_affected() > 0)
3203 }
3204
3205 pub(crate) async fn append_memory(
3206 conn: &mut SqliteConnection,
3207 now: &str,
3208 id: i64,
3209 entry: &MemoryEntry,
3210 ) -> anyhow::Result<()> {
3211 let row = sqlx::query("SELECT memory FROM tasks WHERE id = ?")
3212 .bind(id)
3213 .fetch_one(&mut *conn)
3214 .await?;
3215 let memory_opt: Option<String> = row
3217 .try_get::<Option<String>, _>("memory")
3218 .map_err(|e| anyhow::anyhow!("failed to read memory column for task {id}: {e}"))?;
3219 let memory_str = memory_opt.unwrap_or_default();
3220 let mut memory: Vec<MemoryEntry> = if memory_str.trim().is_empty() {
3221 Vec::new()
3222 } else {
3223 serde_json::from_str(&memory_str)
3224 .map_err(|e| anyhow::anyhow!("failed to parse memory JSON for task {id}: {e}"))?
3225 };
3226 memory.push(entry.clone());
3227 sqlx::query("UPDATE tasks SET memory = ?, updated_at = ? WHERE id = ?")
3228 .bind(serde_json::to_string(&memory)?)
3229 .bind(now)
3230 .bind(id)
3231 .execute(conn)
3232 .await?;
3233 Ok(())
3234 }
3235
3236 pub(crate) async fn mark_cleaned(
3237 conn: &mut SqliteConnection,
3238 now: &str,
3239 id: i64,
3240 ) -> anyhow::Result<()> {
3241 sqlx::query("UPDATE tasks SET worktree_cleaned = 1, updated_at = ? WHERE id = ?")
3242 .bind(now)
3243 .bind(id)
3244 .execute(&mut *conn)
3245 .await?;
3246 activity(
3247 conn,
3248 id,
3249 "branch_delete",
3250 None,
3251 None,
3252 None,
3253 None,
3254 Some(&serde_json::json!({ "worktree_cleaned": true })),
3255 )
3256 .await
3257 }
3258}
3259
3260#[cfg(test)]
3261mod row_to_task_tests {
3262 use super::*;
3263
3264 async fn store_with_task() -> (crate::store::TaskStore, i64) {
3265 let store = crate::store::TaskStore::open_memory().await.unwrap();
3266 let id = store
3267 .create(&NewTask {
3268 external_id: None,
3269 repo: "owner/repo".to_string(),
3270 origin: "internal".to_string(),
3271 title: "Test task".to_string(),
3272 body: "Test body".to_string(),
3273 source: "cron".to_string(),
3274 source_id: "test-1".to_string(),
3275 author: "tester".to_string(),
3276 url: "https://example.com/1".to_string(),
3277 labels: vec![],
3278 parent_id: None,
3279 })
3280 .await
3281 .unwrap();
3282 (store, id)
3283 }
3284
3285 macro_rules! missing_column_test {
3288 ($name:ident, $col:literal, $select:literal) => {
3289 #[tokio::test]
3290 async fn $name() {
3291 let (store, id) = store_with_task().await;
3292 let sql = format!("{} WHERE id = {id}", $select);
3293 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3294 .fetch_one(store.pool())
3295 .await
3296 .unwrap();
3297 let err = TaskStore::row_to_task(&row).unwrap_err();
3298 assert!(
3299 err.to_string().contains($col),
3300 "expected '{}' in error message, got: {err}",
3301 $col
3302 );
3303 }
3304 };
3305 }
3306
3307 missing_column_test!(
3308 row_to_task_fails_on_missing_repo,
3309 "repo",
3310 "SELECT id, status FROM tasks"
3311 );
3312 missing_column_test!(
3313 row_to_task_fails_on_missing_origin,
3314 "origin",
3315 "SELECT id, repo, status FROM tasks"
3316 );
3317 missing_column_test!(
3318 row_to_task_fails_on_missing_title,
3319 "title",
3320 "SELECT id, repo, origin, status FROM tasks"
3321 );
3322 missing_column_test!(
3323 row_to_task_fails_on_missing_body,
3324 "body",
3325 "SELECT id, repo, origin, title, status FROM tasks"
3326 );
3327 missing_column_test!(
3328 row_to_task_fails_on_missing_source,
3329 "source",
3330 "SELECT id, repo, origin, title, body, status FROM tasks"
3331 );
3332 missing_column_test!(
3333 row_to_task_fails_on_missing_source_id,
3334 "source_id",
3335 "SELECT id, repo, origin, title, body, source, status FROM tasks"
3336 );
3337 missing_column_test!(
3338 row_to_task_fails_on_missing_author,
3339 "author",
3340 "SELECT id, repo, origin, title, body, source, source_id, status FROM tasks"
3341 );
3342 missing_column_test!(
3343 row_to_task_fails_on_missing_url,
3344 "url",
3345 "SELECT id, repo, origin, title, body, source, source_id, author, status FROM tasks"
3346 );
3347
3348 #[tokio::test]
3355 async fn row_to_task_fails_on_decode_error_invalid_utf8_repo() {
3356 let (store, id) = store_with_task().await;
3357 let sql = format!(
3360 "SELECT id, X'DEADBEEF' as repo, origin, title, body, status, \
3361 source, source_id, author, url FROM tasks WHERE id = {id}"
3362 );
3363 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3364 .fetch_one(store.pool())
3365 .await
3366 .unwrap();
3367 let err = TaskStore::row_to_task(&row).unwrap_err();
3368 assert!(
3369 err.to_string().contains("repo"),
3370 "expected 'repo' in error message, got: {err}"
3371 );
3372 }
3373
3374 #[tokio::test]
3377 async fn distinct_repos_fails_on_decode_error() {
3378 let store = crate::store::TaskStore::open_memory().await.unwrap();
3379 let id = store
3380 .create(&NewTask {
3381 external_id: None,
3382 repo: "owner/repo".to_string(),
3383 origin: "internal".to_string(),
3384 title: "Test task".to_string(),
3385 body: "Test body".to_string(),
3386 source: "cron".to_string(),
3387 source_id: "test-1".to_string(),
3388 author: "tester".to_string(),
3389 url: "https://example.com/1".to_string(),
3390 labels: vec![],
3391 parent_id: None,
3392 })
3393 .await
3394 .unwrap();
3395
3396 let sql = format!("UPDATE tasks SET repo = X'DEADBEEF' WHERE id = {id}");
3398 sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3399 .execute(store.pool())
3400 .await
3401 .unwrap();
3402
3403 let err = store.distinct_repos().await.unwrap_err();
3404 assert!(
3405 err.to_string().contains("distinct_repos") && err.to_string().contains("repo"),
3406 "expected 'distinct_repos' and 'repo' in error message, got: {err}"
3407 );
3408 }
3409
3410 async fn store_with_run() -> (crate::store::TaskStore, i64, i64) {
3413 let store = crate::store::TaskStore::open_memory().await.unwrap();
3414 let task_id = store
3415 .create(&NewTask {
3416 external_id: None,
3417 repo: "owner/repo".to_string(),
3418 origin: "internal".to_string(),
3419 title: "Test task".to_string(),
3420 body: "Test body".to_string(),
3421 source: "cron".to_string(),
3422 source_id: "test-1".to_string(),
3423 author: "tester".to_string(),
3424 url: "https://example.com/1".to_string(),
3425 labels: vec![],
3426 parent_id: None,
3427 })
3428 .await
3429 .unwrap();
3430 let run_id = store
3431 .start_run(&crate::store::StartRun {
3432 task_id,
3433 attempt: 1,
3434 run_type: "agent",
3435 agent: "claude",
3436 model: "sonnet",
3437 command: "claude -p ...",
3438 prompt: "system prompt",
3439 })
3440 .await
3441 .unwrap();
3442 (store, task_id, run_id)
3443 }
3444
3445 #[tokio::test]
3446 async fn start_run_upsert_clears_stale_completion_fields() {
3447 let (store, task_id, run_id) = store_with_run().await;
3448 store
3449 .complete_run(&CompleteRun {
3450 run_id,
3451 exit_code: Some(0),
3452 stdout: "",
3453 stderr: "",
3454 parsed: "",
3455 outcome: "success",
3456 error: "boom",
3457 tokens: RunTokenUsage::default(),
3458 })
3459 .await
3460 .unwrap();
3461
3462 let same_run_id = store
3464 .start_run(&crate::store::StartRun {
3465 task_id,
3466 attempt: 1,
3467 run_type: "agent",
3468 agent: "claude",
3469 model: "sonnet",
3470 command: "claude -p ...",
3471 prompt: "system prompt",
3472 })
3473 .await
3474 .unwrap();
3475 assert_eq!(run_id, same_run_id, "upsert must reuse the same row");
3476
3477 let row = sqlx::query(
3478 "SELECT outcome, completed_at, exit_code, error FROM task_runs WHERE id = ?",
3479 )
3480 .bind(run_id)
3481 .fetch_one(store.pool())
3482 .await
3483 .unwrap();
3484 let outcome: Option<String> = row.try_get("outcome").unwrap();
3485 let completed_at: Option<String> = row.try_get("completed_at").unwrap();
3486 let exit_code: Option<i64> = row.try_get("exit_code").unwrap();
3487 let error: Option<String> = row.try_get("error").unwrap();
3488 assert_eq!(outcome, None);
3489 assert_eq!(
3490 completed_at, None,
3491 "stale completion timestamp must be cleared"
3492 );
3493 assert_eq!(exit_code, None);
3494 assert_eq!(error, None);
3495 }
3496
3497 macro_rules! run_blob_decode_test {
3504 ($name:ident, $col:literal, $select:literal) => {
3505 #[tokio::test]
3506 async fn $name() {
3507 let (store, _task_id, run_id) = store_with_run().await;
3508 let sql = format!($select, run_id = run_id);
3509 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3510 .fetch_one(store.pool())
3511 .await
3512 .unwrap();
3513 let err = TaskStore::row_to_run(&row).unwrap_err();
3514 assert!(
3515 err.to_string().contains($col),
3516 "expected '{0}' in error message, got: {err}",
3517 $col
3518 );
3519 }
3520 };
3521 }
3522
3523 run_blob_decode_test!(
3524 row_to_run_fails_on_decode_error_invalid_utf8_id,
3525 "id",
3526 "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}"
3527 );
3528 run_blob_decode_test!(
3529 row_to_run_fails_on_decode_error_invalid_utf8_task_id,
3530 "task_id",
3531 "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}"
3532 );
3533 run_blob_decode_test!(
3534 row_to_run_fails_on_decode_error_invalid_utf8_started_at,
3535 "started_at",
3536 "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}"
3537 );
3538
3539 async fn store_with_activity() -> (crate::store::TaskStore, i64, i64) {
3542 let store = crate::store::TaskStore::open_memory().await.unwrap();
3543 let task_id = store
3544 .create(&NewTask {
3545 external_id: None,
3546 repo: "owner/repo".to_string(),
3547 origin: "internal".to_string(),
3548 title: "Test task".to_string(),
3549 body: "Test body".to_string(),
3550 source: "cron".to_string(),
3551 source_id: "test-1".to_string(),
3552 author: "tester".to_string(),
3553 url: "https://example.com/1".to_string(),
3554 labels: vec![],
3555 parent_id: None,
3556 })
3557 .await
3558 .unwrap();
3559 store
3560 .append_activity(task_id, "created", None, Some("new"), None, None, None)
3561 .await
3562 .unwrap();
3563 (store, task_id, 1)
3564 }
3565
3566 #[tokio::test]
3569 async fn row_to_activity_fails_on_decode_error_invalid_utf8_id() {
3570 let (store, _task_id, activity_id) = store_with_activity().await;
3571 let sql = format!(
3572 "SELECT X'DEADBEEF' as id, task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3573 );
3574 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3575 .fetch_one(store.pool())
3576 .await
3577 .unwrap();
3578 let err = TaskStore::row_to_activity(&row).unwrap_err();
3579 assert!(
3580 err.to_string().contains("id"),
3581 "expected 'id' in error message, got: {err}"
3582 );
3583 }
3584
3585 #[tokio::test]
3586 async fn row_to_activity_fails_on_decode_error_invalid_utf8_task_id() {
3587 let (store, _task_id, activity_id) = store_with_activity().await;
3588 let sql = format!(
3589 "SELECT id, X'DEADBEEF' as task_id, timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3590 );
3591 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3592 .fetch_one(store.pool())
3593 .await
3594 .unwrap();
3595 let err = TaskStore::row_to_activity(&row).unwrap_err();
3596 assert!(
3597 err.to_string().contains("task_id"),
3598 "expected 'task_id' in error message, got: {err}"
3599 );
3600 }
3601
3602 #[tokio::test]
3603 async fn row_to_activity_fails_on_decode_error_invalid_utf8_timestamp() {
3604 let (store, _task_id, activity_id) = store_with_activity().await;
3605 let sql = format!(
3606 "SELECT id, task_id, X'DEADBEEF' as timestamp, event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3607 );
3608 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3609 .fetch_one(store.pool())
3610 .await
3611 .unwrap();
3612 let err = TaskStore::row_to_activity(&row).unwrap_err();
3613 assert!(
3614 err.to_string().contains("timestamp"),
3615 "expected 'timestamp' in error message, got: {err}"
3616 );
3617 }
3618
3619 #[tokio::test]
3620 async fn row_to_activity_fails_on_decode_error_invalid_utf8_event_type() {
3621 let (store, _task_id, activity_id) = store_with_activity().await;
3622 let sql = format!(
3623 "SELECT id, task_id, timestamp, X'DEADBEEF' as event_type, from_status, to_status, agent, model, details FROM task_activity WHERE id = {activity_id}"
3624 );
3625 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3626 .fetch_one(store.pool())
3627 .await
3628 .unwrap();
3629 let err = TaskStore::row_to_activity(&row).unwrap_err();
3630 assert!(
3631 err.to_string().contains("event_type"),
3632 "expected 'event_type' in error message, got: {err}"
3633 );
3634 }
3635
3636 #[tokio::test]
3637 async fn row_to_activity_fails_on_corrupt_details_json() {
3638 let (store, task_id, _activity_id) = store_with_activity().await;
3639 sqlx::query("INSERT INTO task_activity (task_id, event_type, details) VALUES (?, ?, ?)")
3641 .bind(task_id)
3642 .bind("test")
3643 .bind("not valid json {{{")
3644 .execute(store.pool())
3645 .await
3646 .unwrap();
3647 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
3648 "SELECT {TASK_ACTIVITY_COLS} FROM task_activity WHERE task_id = {task_id} ORDER BY id DESC LIMIT 1"
3649 )))
3650 .fetch_one(store.pool())
3651 .await
3652 .unwrap();
3653 let err = TaskStore::row_to_activity(&row).unwrap_err();
3654 assert!(
3655 err.to_string().contains("details")
3656 || err.to_string().contains("json")
3657 || err.to_string().contains("expected"),
3658 "expected 'details', 'json', or 'expected' in error message, got: {err}"
3659 );
3660 }
3661
3662 run_blob_decode_test!(
3665 row_to_run_fails_on_decode_error_invalid_utf8_model,
3666 "model",
3667 "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}"
3668 );
3669 run_blob_decode_test!(
3670 row_to_run_fails_on_decode_error_invalid_utf8_command,
3671 "command",
3672 "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}"
3673 );
3674 run_blob_decode_test!(
3675 row_to_run_fails_on_decode_error_invalid_utf8_prompt,
3676 "prompt",
3677 "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}"
3678 );
3679 run_blob_decode_test!(
3680 row_to_run_fails_on_decode_error_invalid_utf8_env_vars,
3681 "env_vars",
3682 "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}"
3683 );
3684 run_blob_decode_test!(
3685 row_to_run_fails_on_decode_error_invalid_utf8_stdout,
3686 "stdout",
3687 "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}"
3688 );
3689 run_blob_decode_test!(
3690 row_to_run_fails_on_decode_error_invalid_utf8_stderr,
3691 "stderr",
3692 "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}"
3693 );
3694 run_blob_decode_test!(
3695 row_to_run_fails_on_decode_error_invalid_utf8_parsed_response,
3696 "parsed_response",
3697 "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}"
3698 );
3699 run_blob_decode_test!(
3700 row_to_run_fails_on_decode_error_invalid_utf8_outcome,
3701 "outcome",
3702 "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}"
3703 );
3704 run_blob_decode_test!(
3705 row_to_run_fails_on_decode_error_invalid_utf8_error,
3706 "error",
3707 "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}"
3708 );
3709 run_blob_decode_test!(
3710 row_to_run_fails_on_decode_error_invalid_utf8_input_tokens,
3711 "input_tokens",
3712 "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}"
3713 );
3714 run_blob_decode_test!(
3715 row_to_run_fails_on_decode_error_invalid_utf8_output_tokens,
3716 "output_tokens",
3717 "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}"
3718 );
3719 run_blob_decode_test!(
3720 row_to_run_fails_on_decode_error_invalid_utf8_total_cost_usd,
3721 "total_cost_usd",
3722 "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}"
3723 );
3724 run_blob_decode_test!(
3725 row_to_run_fails_on_decode_error_invalid_utf8_duration_secs,
3726 "duration_secs",
3727 "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}"
3728 );
3729 run_blob_decode_test!(
3730 row_to_run_fails_on_decode_error_invalid_utf8_exit_code,
3731 "exit_code",
3732 "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}"
3733 );
3734
3735 #[tokio::test]
3742 async fn row_to_activity_fails_on_decode_error_invalid_utf8_from_status() {
3743 let (store, _task_id, activity_id) = store_with_activity().await;
3744 let sql = format!(
3745 "SELECT id, task_id, timestamp, event_type, \
3746 X'DeadbEEF' as from_status, NULL as to_status, \
3747 NULL as agent, NULL as model, details \
3748 FROM task_activity WHERE id = {activity_id}"
3749 );
3750 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3751 .fetch_one(store.pool())
3752 .await
3753 .unwrap();
3754 let err = TaskStore::row_to_activity(&row).unwrap_err();
3755 assert!(
3756 err.to_string().contains("from_status"),
3757 "expected 'from_status' in error message, got: {err}"
3758 );
3759 }
3760
3761 #[tokio::test]
3762 async fn row_to_activity_fails_on_decode_error_invalid_utf8_to_status() {
3763 let (store, _task_id, activity_id) = store_with_activity().await;
3764 let sql = format!(
3765 "SELECT id, task_id, timestamp, event_type, \
3766 NULL as from_status, X'DeadbEEF' as to_status, \
3767 NULL as agent, NULL as model, details \
3768 FROM task_activity WHERE id = {activity_id}"
3769 );
3770 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3771 .fetch_one(store.pool())
3772 .await
3773 .unwrap();
3774 let err = TaskStore::row_to_activity(&row).unwrap_err();
3775 assert!(
3776 err.to_string().contains("to_status"),
3777 "expected 'to_status' in error message, got: {err}"
3778 );
3779 }
3780
3781 #[tokio::test]
3782 async fn row_to_activity_fails_on_decode_error_invalid_utf8_agent() {
3783 let (store, _task_id, activity_id) = store_with_activity().await;
3784 let sql = format!(
3785 "SELECT id, task_id, timestamp, event_type, \
3786 NULL as from_status, NULL as to_status, \
3787 X'DeadbEEF' as agent, NULL as model, details \
3788 FROM task_activity WHERE id = {activity_id}"
3789 );
3790 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3791 .fetch_one(store.pool())
3792 .await
3793 .unwrap();
3794 let err = TaskStore::row_to_activity(&row).unwrap_err();
3795 assert!(
3796 err.to_string().contains("agent"),
3797 "expected 'agent' in error message, got: {err}"
3798 );
3799 }
3800
3801 #[tokio::test]
3802 async fn row_to_activity_fails_on_decode_error_invalid_utf8_model() {
3803 let (store, _task_id, activity_id) = store_with_activity().await;
3804 let sql = format!(
3805 "SELECT id, task_id, timestamp, event_type, \
3806 NULL as from_status, NULL as to_status, \
3807 NULL as agent, X'DeadbEEF' as model, details \
3808 FROM task_activity WHERE id = {activity_id}"
3809 );
3810 let row = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
3811 .fetch_one(store.pool())
3812 .await
3813 .unwrap();
3814 let err = TaskStore::row_to_activity(&row).unwrap_err();
3815 assert!(
3816 err.to_string().contains("model"),
3817 "expected 'model' in error message, got: {err}"
3818 );
3819 }
3820
3821 #[tokio::test]
3824 async fn get_statuses_by_external_ids_skips_rows_with_decode_error() {
3825 let store = crate::store::TaskStore::open_memory().await.unwrap();
3830 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', X'DeadbEEF', 'new', '', 'internal')")
3831 .execute(store.pool())
3832 .await
3833 .unwrap();
3834 sqlx::query("INSERT INTO tasks (repo, external_id, status, title, origin) VALUES ('owner/repo', 'valid-id', 'done', '', 'internal')")
3836 .execute(store.pool())
3837 .await
3838 .unwrap();
3839
3840 let result = store
3841 .get_statuses_by_external_ids("owner/repo", &["valid-id"])
3842 .await
3843 .unwrap();
3844 assert_eq!(result.len(), 1, "expected exactly one result for valid-id");
3845 assert_eq!(
3846 result.get("valid-id"),
3847 Some(&crate::store::TaskStatus::Done)
3848 );
3849 }
3850}