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