1use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
2use crate::engine::dependencies;
3use crate::engine::events::TaskEvent;
4use crate::store::{self, TaskStatus, TaskStore};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::fmt::Debug;
8use std::fmt::Formatter;
9use std::sync::Arc;
10use tokio::sync::broadcast::Sender;
11
12pub fn is_internal_id(id: &str) -> bool {
14 id.starts_with("internal:")
15}
16
17pub fn parse_internal_id(id: &str) -> Option<i64> {
19 id.strip_prefix("internal:")?.parse().ok()
20}
21
22pub fn status_to_task_status(status: Status) -> TaskStatus {
24 match status {
25 Status::New => TaskStatus::New,
26 Status::Routed => TaskStatus::Routed,
27 Status::InProgress => TaskStatus::InProgress,
28 Status::Done => TaskStatus::Done,
29 Status::Blocked => TaskStatus::Blocked,
30 Status::InReview => TaskStatus::InReview,
31 Status::Approved => TaskStatus::Approved,
32 Status::NeedsReview => TaskStatus::NeedsReview,
33 Status::Waiting => TaskStatus::Waiting,
34 }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum TaskType {
40 External,
41 Internal,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CreateTaskRequest {
46 pub title: String,
47 pub body: String,
48 pub task_type: TaskType,
49 pub labels: Vec<String>,
50 pub source: String,
51 pub source_id: String,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct TaskFilter {
56 pub status: Option<String>,
57 pub source: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(untagged)]
62#[expect(
63 clippy::large_enum_variant,
64 reason = "tasks are few and boxing would touch every match"
65)]
66pub enum Task {
67 External(ExternalTask),
68 Internal(store::Task),
69}
70
71#[derive(Debug, Clone, Default)]
74struct TaskSnapshot {
75 old_status: Option<String>,
76 agent: Option<String>,
77 model: Option<String>,
78 branch: Option<String>,
79 pr_number: Option<String>,
80 error: Option<String>,
81 title: Option<String>,
82 summary: Option<String>,
83 duration_seconds: Option<f64>,
84}
85
86pub struct TaskManager {
87 backend: Arc<dyn ExternalBackend>,
88 store: Option<Arc<TaskStore>>,
90 repo: String,
92 event_tx: Option<Sender<TaskEvent>>,
94}
95
96impl Debug for TaskManager {
97 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
98 f.debug_struct("TaskManager")
99 .field("repo", &self.repo)
100 .finish_non_exhaustive()
101 }
102}
103
104impl Clone for TaskManager {
105 fn clone(&self) -> Self {
106 Self {
107 backend: Arc::clone(&self.backend),
108 store: self.store.clone(),
109 repo: self.repo.clone(),
110 event_tx: self.event_tx.clone(),
111 }
112 }
113}
114
115impl TaskManager {
116 #[cfg(test)]
117 pub fn new(backend: Arc<dyn ExternalBackend>) -> Self {
118 Self {
119 backend,
120 store: None,
121 repo: String::new(),
122 event_tx: None,
123 }
124 }
125
126 pub fn with_store(
128 backend: Arc<dyn ExternalBackend>,
129 store: Arc<TaskStore>,
130 repo: String,
131 ) -> Self {
132 Self {
133 backend,
134 store: Some(store),
135 repo,
136 event_tx: None,
137 }
138 }
139
140 pub fn with_events(
142 backend: Arc<dyn ExternalBackend>,
143 store: Arc<TaskStore>,
144 repo: String,
145 event_tx: Sender<TaskEvent>,
146 ) -> Self {
147 Self {
148 backend,
149 store: Some(store),
150 repo,
151 event_tx: Some(event_tx),
152 }
153 }
154
155 pub async fn create_task(&self, req: CreateTaskRequest) -> anyhow::Result<Task> {
156 match req.task_type {
157 TaskType::Internal => {
158 let store = self
159 .store
160 .as_ref()
161 .ok_or_else(|| anyhow::anyhow!("store required for internal tasks"))?;
162 let id = store
163 .create_internal(
164 &self.repo,
165 &req.title,
166 &req.body,
167 &req.source,
168 &req.source_id,
169 None,
170 )
171 .await?;
172 let task = store.get(id).await?;
173 Ok(Task::Internal(task))
174 }
175 TaskType::External => {
176 let ext_id = self
177 .backend
178 .create_task(&req.title, &req.body, &req.labels)
179 .await?;
180 let task = self.backend.get_task(&ext_id).await?;
181 Ok(Task::External(task))
182 }
183 }
184 }
185
186 pub async fn get_task(&self, id: i64) -> anyhow::Result<Task> {
187 if let Some(ref store) = self.store
189 && let Ok(task) = store.get(id).await
190 && task.origin == "internal"
191 {
192 return Ok(Task::Internal(task));
193 }
194 let ext_id = ExternalId(id.to_string());
198 match self.backend.get_task(&ext_id).await {
199 Ok(external) => Ok(Task::External(external)),
200 Err(external_err) => Err(anyhow::anyhow!(
201 "task {id} not found in store or externally (external: {external_err})"
202 )),
203 }
204 }
205
206 pub async fn list_tasks(&self, filter: TaskFilter) -> anyhow::Result<Vec<Task>> {
209 let mut tasks = Vec::new();
210
211 let is_all = filter.status.as_deref() == Some("all");
212
213 if let Some(status_str) = &filter.status {
214 if !is_all {
215 let task_status = TaskStatus::from_str(status_str)
216 .ok_or_else(|| anyhow::anyhow!(
217 "invalid status: '{status_str}'. allowed: new, routed, in_progress, needs_review, in_review, approved, blocked, waiting, done, all"
218 ))?;
219 let backend_status = match status_str.as_str() {
220 "new" => Status::New,
221 "routed" => Status::Routed,
222 "in_progress" => Status::InProgress,
223 "done" => Status::Done,
224 "blocked" => Status::Blocked,
225 "in_review" => Status::InReview,
226 "approved" => Status::Approved,
227 "needs_review" => Status::NeedsReview,
228 "waiting" => Status::Waiting,
229 _ => unreachable!(),
230 };
231
232 if let Some(ref store) = self.store {
234 let internal_tasks = store
235 .list_internal_by_status(&self.repo, task_status)
236 .await?;
237 for t in internal_tasks {
238 if let Some(ref source) = filter.source
239 && t.source != *source
240 {
241 continue;
242 }
243 tasks.push(Task::Internal(t));
244 }
245 }
246
247 let external_tasks = self.list_external_by_status(backend_status).await?;
249 for t in external_tasks {
250 tasks.push(Task::External(t));
251 }
252 }
253 } else if let Some(source) = &filter.source {
254 if let Some(ref store) = self.store {
256 let by_source = store.list_internal_by_source(&self.repo, source).await?;
257 for t in by_source {
258 tasks.push(Task::Internal(t));
259 }
260 }
261 }
262
263 if filter.status.is_none() || is_all {
265 let mut has_external_in_store = false;
266 let store_tasks = if let Some(ref store) = self.store {
267 if is_all {
268 let tasks = store.list_all(&self.repo).await?;
269 has_external_in_store = match store.has_external_tasks(&self.repo).await {
270 Ok(has_external) => has_external,
271 Err(e) => {
272 tracing::warn!(
273 repo = %self.repo,
274 error = %e,
275 "failed to check external task sentinel; using store-only list path"
276 );
277 true
278 }
279 };
280 tasks
281 } else {
282 let tasks = store.list_active(&self.repo).await?;
283 has_external_in_store = match store.has_external_tasks(&self.repo).await {
284 Ok(has_external) => has_external,
285 Err(e) => {
286 tracing::warn!(
287 repo = %self.repo,
288 error = %e,
289 "failed to check external task sentinel; using store-only list path"
290 );
291 true
292 }
293 };
294 tasks
295 }
296 } else {
297 vec![]
298 };
299
300 for t in store_tasks {
301 if t.origin == "internal" {
302 tasks.push(Task::Internal(t));
303 } else {
304 tasks.push(Task::External(store_task_to_external(&t)));
305 }
306 }
307
308 if !has_external_in_store {
309 let external_tasks = self.backend.list_all_tasks().await?;
310 for t in external_tasks {
311 tasks.push(Task::External(t));
312 }
313 }
314 }
315
316 Ok(tasks)
317 }
318
319 pub async fn list_all_external_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
322 if let Some(ref store) = self.store {
323 let tasks = store.list_all_external(&self.repo).await?;
324 if !tasks.is_empty() {
325 return Ok(tasks.iter().map(store_task_to_external).collect());
326 }
327 }
328 self.backend.list_all_tasks().await
329 }
330
331 pub async fn list_external_by_status(
335 &self,
336 status: Status,
337 ) -> anyhow::Result<Vec<ExternalTask>> {
338 if let Some(ref store) = self.store {
339 let db_status = status_to_task_status(status);
340 let external = store.list_external_by_status(&self.repo, db_status).await?;
341 let has_external_in_store = if external.is_empty() {
344 match store.has_external_tasks(&self.repo).await {
345 Ok(has_external) => has_external,
346 Err(e) => {
347 tracing::warn!(
348 repo = %self.repo,
349 status = ?status,
350 error = %e,
351 "failed to check external task sentinel; skipping backend fallback"
352 );
353 true
354 }
355 }
356 } else {
357 true
358 };
359 if has_external_in_store {
360 let external: Vec<ExternalTask> =
361 external.iter().map(store_task_to_external).collect();
362 return Ok(external);
363 }
364 }
365 self.backend.list_by_status(status).await
366 }
367
368 pub async fn list_all_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
373 let mut tasks = Vec::new();
374
375 if let Some(ref store) = self.store {
377 let db_status = status_to_task_status(status);
378 let external = store.list_external_by_status(&self.repo, db_status).await?;
379 let has_external_in_store = if external.is_empty() {
382 match store.has_external_tasks(&self.repo).await {
383 Ok(has_external) => has_external,
384 Err(e) => {
385 tracing::warn!(
386 repo = %self.repo,
387 status = ?status,
388 error = %e,
389 "failed to check external task sentinel; skipping backend fallback"
390 );
391 true
392 }
393 }
394 } else {
395 true
396 };
397 if has_external_in_store {
398 tasks.extend(external.into_iter().map(|t| store_task_to_external(&t)));
399 } else {
400 tasks.extend(self.backend.list_by_status(status).await?);
402 }
403 } else {
404 tasks.extend(self.backend.list_by_status(status).await?);
406 }
407
408 if self.store.is_some()
410 && let Ok(internal) = self
411 .list_internal_by_status(status_to_task_status(status))
412 .await
413 {
414 tasks.extend(internal);
415 }
416
417 Ok(tasks)
418 }
419
420 pub async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
424 if let Some(ref store) = self.store {
425 let all_new = store.list_routable(&self.repo).await?;
426 let has_external_new = all_new.iter().any(|t| t.origin != "internal");
429 let tasks: Vec<ExternalTask> = all_new.iter().map(store_task_to_external).collect();
430 let has_external_in_store = if has_external_new {
431 true
432 } else {
433 match store.has_external_tasks(&self.repo).await {
434 Ok(has_external) => has_external,
435 Err(e) => {
436 tracing::warn!(
437 repo = %self.repo,
438 error = %e,
439 "failed to check external task sentinel; skipping backend fallback"
440 );
441 true
442 }
443 }
444 };
445 if has_external_in_store {
446 return Ok(tasks);
447 }
448 }
449
450 let mut tasks = self.backend.list_routable().await?;
452 if let Some(ref store) = self.store {
453 let internal_new = store
454 .list_internal_by_status(&self.repo, TaskStatus::New)
455 .await?;
456 for t in internal_new {
457 tasks.push(store_task_to_external(&t));
458 }
459 }
460 Ok(tasks)
461 }
462
463 pub async fn update_task_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
467 let task_status = status_to_task_status(status);
468 self.release_dependents_if_done(task_status, &self.repo, &id.0)
469 .await;
470
471 let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
475
476 if is_internal_id(&id.0) {
477 let store = self
479 .store
480 .as_ref()
481 .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
482 let store_id = snapshot_store_id
483 .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
484 if task_status != TaskStatus::Blocked {
485 store.set_block_reason(store_id, None).await?;
486 }
487 store.update_status(store_id, task_status).await?;
488 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
490 return Ok(());
491 }
492
493 if let Some(ref store) = self.store
496 && let Some(store_id) = snapshot_store_id
497 {
498 if task_status != TaskStatus::Blocked {
499 store.set_block_reason(store_id, None).await?;
500 }
501 store.update_status(store_id, task_status).await?;
502 }
503
504 if let Err(e) = self.backend.update_status(id, status).await {
507 tracing::warn!(
508 task_id = id.0,
509 ?status,
510 err = %e,
511 "failed to mirror status to backend — store is authoritative"
512 );
513 }
514
515 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
517
518 Ok(())
519 }
520
521 pub async fn update_task_status_and_result(
522 &self,
523 id: &ExternalId,
524 status: Status,
525 updates: &[(&str, serde_json::Value)],
526 ) -> anyhow::Result<()> {
527 let task_status = status_to_task_status(status);
528 self.release_dependents_if_done(task_status, &self.repo, &id.0)
529 .await;
530
531 let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
533
534 if is_internal_id(&id.0) {
535 let store = self
537 .store
538 .as_ref()
539 .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
540 let store_id = snapshot_store_id
541 .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
542
543 store
544 .update_status_and_fields(store_id, task_status, updates)
545 .await?;
546 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
548 return Ok(());
549 }
550
551 if let Some(ref store) = self.store
553 && let Some(store_id) = snapshot_store_id
554 {
555 store
556 .update_status_and_fields(store_id, task_status, updates)
557 .await?;
558 }
559
560 if let Err(e) = self.backend.update_status(id, status).await {
562 tracing::warn!(
563 task_id = id.0,
564 ?status,
565 err = %e,
566 "failed to mirror status to backend — store is authoritative"
567 );
568 }
569
570 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
572
573 Ok(())
574 }
575
576 pub async fn update_task_status_if(
581 &self,
582 id: &ExternalId,
583 status: Status,
584 expected_status: Status,
585 ) -> anyhow::Result<bool> {
586 let task_status = status_to_task_status(status);
587 self.release_dependents_if_done(task_status, &self.repo, &id.0)
588 .await;
589 let expected_task_status = status_to_task_status(expected_status);
590
591 let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
592
593 if is_internal_id(&id.0) {
594 let store = self
595 .store
596 .as_ref()
597 .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
598 let store_id = snapshot_store_id
599 .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
600 let updated = store
601 .update_status_if(store_id, task_status, expected_task_status)
602 .await?;
603 if updated {
604 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
605 }
606 return Ok(updated);
607 }
608
609 if let Some(ref store) = self.store {
616 if let Some(store_id) = snapshot_store_id {
617 let updated = store
618 .update_status_if(store_id, task_status, expected_task_status)
619 .await?;
620 if !updated {
621 return Ok(false);
622 }
623 } else {
624 tracing::warn!(
625 task_id = id.0,
626 ?expected_task_status,
627 "update_task_status_if: external task has no store record — cannot perform conditional update"
628 );
629 return Ok(false);
630 }
631 } else {
632 tracing::warn!(
633 task_id = id.0,
634 ?expected_task_status,
635 "update_task_status_if: no store available — cannot perform conditional update for external task"
636 );
637 return Ok(false);
638 }
639
640 if let Err(e) = self.backend.update_status(id, status).await {
641 tracing::warn!(
642 task_id = id.0,
643 ?status,
644 err = %e,
645 "failed to mirror status to backend — store is authoritative"
646 );
647 }
648
649 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
650 Ok(true)
651 }
652
653 pub async fn update_task_status_with_duration(
658 &self,
659 id: &ExternalId,
660 status: Status,
661 duration_seconds: Option<f64>,
662 ) -> anyhow::Result<()> {
663 let task_status = status_to_task_status(status);
664 self.release_dependents_if_done(task_status, &self.repo, &id.0)
665 .await;
666 let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
667
668 if is_internal_id(&id.0) {
669 let store = self
670 .store
671 .as_ref()
672 .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
673 let store_id = snapshot_store_id
674 .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
675 if task_status != TaskStatus::Blocked {
676 store.set_block_reason(store_id, None).await?;
677 }
678 store.update_status(store_id, task_status).await?;
679 self.publish_event(id, status, &pre_snapshot, &self.repo, duration_seconds);
680 return Ok(());
681 }
682
683 if let Some(ref store) = self.store
684 && let Some(store_id) = snapshot_store_id
685 {
686 if task_status != TaskStatus::Blocked {
687 store.set_block_reason(store_id, None).await?;
688 }
689 store.update_status(store_id, task_status).await?;
690 }
691
692 if let Err(e) = self.backend.update_status(id, status).await {
693 tracing::warn!(
694 task_id = id.0,
695 ?status,
696 err = %e,
697 "failed to mirror status to backend — store is authoritative"
698 );
699 }
700
701 self.publish_event(id, status, &pre_snapshot, &self.repo, duration_seconds);
702
703 Ok(())
704 }
705
706 async fn read_task_snapshot(&self, task_id: &str) -> (TaskSnapshot, Option<i64>) {
710 let Some(ref store) = self.store else {
711 return (TaskSnapshot::default(), None);
712 };
713 let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, task_id).await else {
714 return (TaskSnapshot::default(), None);
715 };
716 let snapshot = Self::snapshot_for_store_id(store, store_id, task_id).await;
717 (snapshot, Some(store_id))
718 }
719
720 async fn snapshot_for_store_id(
724 store: &TaskStore,
725 store_id: i64,
726 metric_task_id: &str,
727 ) -> TaskSnapshot {
728 match store.get(store_id).await {
729 Ok(task) => {
730 let duration_seconds = store.latest_task_metric_duration(metric_task_id).await;
731 TaskSnapshot {
732 old_status: Some(task.status.as_str().to_string()),
733 agent: task.agent.clone(),
734 model: task.model.clone(),
735 branch: if task.branch.is_empty() {
736 None
737 } else {
738 Some(task.branch.clone())
739 },
740 pr_number: task.pr_number.map(|n| n.to_string()),
741 error: if task.last_error.is_empty() {
742 None
743 } else {
744 Some(task.last_error.clone())
745 },
746 title: if task.title.is_empty() {
747 None
748 } else {
749 Some(task.title.clone())
750 },
751 summary: if task.summary.is_empty() {
752 None
753 } else {
754 Some(task.summary.clone())
755 },
756 duration_seconds,
757 }
758 }
759 Err(e) => {
760 tracing::warn!(
761 task_id = store_id,
762 err = %e,
763 "failed to read task snapshot for event enrichment — event context will be empty"
764 );
765 TaskSnapshot::default()
766 }
767 }
768 }
769
770 async fn release_dependents_if_done(&self, status: TaskStatus, repo: &str, ext_id: &str) {
777 if status != TaskStatus::Done {
778 return;
779 }
780 if let Some(ref store) = self.store {
781 dependencies::clear_blocker_read_cache(store, repo, ext_id).await;
782 }
783 }
784
785 fn publish_event(
787 &self,
788 id: &ExternalId,
789 status: Status,
790 snapshot: &TaskSnapshot,
791 repo: &str,
792 duration_seconds: Option<f64>,
793 ) {
794 if let Some(ref tx) = self.event_tx {
795 let event = TaskEvent {
796 task_id: id.0.clone(),
797 repo: repo.to_string(),
798 old_status: snapshot.old_status.clone().unwrap_or_default(),
799 new_status: status.as_label().trim_start_matches("status:").to_string(),
800 agent: snapshot.agent.clone(),
801 model: snapshot.model.clone(),
802 pr_number: snapshot.pr_number.clone(),
803 branch: snapshot.branch.clone(),
804 review_context: None,
805 error: snapshot.error.clone(),
806 timestamp: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
807 title: snapshot.title.clone(),
808 summary: snapshot.summary.clone(),
809 duration_seconds: duration_seconds.or(snapshot.duration_seconds),
810 };
811 let _ = tx.send(event);
812 }
813 }
814
815 pub async fn mark_task_done_by_store_id(
821 &self,
822 store_id: i64,
823 repo: &str,
824 ext_id: &ExternalId,
825 ) -> anyhow::Result<()> {
826 self.update_task_status_by_store_id(store_id, repo, ext_id, Status::Done)
827 .await
828 }
829
830 pub async fn update_task_status_by_store_id(
838 &self,
839 store_id: i64,
840 repo: &str,
841 ext_id: &ExternalId,
842 status: Status,
843 ) -> anyhow::Result<()> {
844 let store = self
845 .store
846 .as_ref()
847 .ok_or_else(|| anyhow::anyhow!("store required for status update"))?;
848 let task_status = status_to_task_status(status);
849 self.release_dependents_if_done(task_status, repo, &ext_id.0)
850 .await;
851
852 let snapshot = Self::snapshot_for_store_id(store, store_id, &ext_id.0).await;
853 if task_status != TaskStatus::Blocked {
854 store.set_block_reason(store_id, None).await?;
855 }
856 store.update_status(store_id, task_status).await?;
857 self.publish_event(ext_id, status, &snapshot, repo, None);
858 Ok(())
859 }
860
861 pub async fn update_task_status_and_result_by_store_id(
864 &self,
865 store_id: i64,
866 repo: &str,
867 ext_id: &ExternalId,
868 status: Status,
869 updates: &[(&str, serde_json::Value)],
870 ) -> anyhow::Result<()> {
871 let store = self
872 .store
873 .as_ref()
874 .ok_or_else(|| anyhow::anyhow!("store required for status update"))?;
875 let task_status = status_to_task_status(status);
876 self.release_dependents_if_done(task_status, repo, &ext_id.0)
877 .await;
878
879 let snapshot = Self::snapshot_for_store_id(store, store_id, &ext_id.0).await;
880 store
881 .update_status_and_fields(store_id, task_status, updates)
882 .await?;
883 self.publish_event(ext_id, status, &snapshot, repo, None);
884 Ok(())
885 }
886
887 pub async fn list_internal_by_status(
890 &self,
891 status: TaskStatus,
892 ) -> anyhow::Result<Vec<ExternalTask>> {
893 let store = self
894 .store
895 .as_ref()
896 .ok_or_else(|| anyhow::anyhow!("store required"))?;
897 let tasks = store.list_internal_by_status(&self.repo, status).await?;
898 Ok(tasks
899 .into_iter()
900 .map(|t| store_task_to_external(&t))
901 .collect())
902 }
903
904 pub async fn list_all_internal(&self) -> anyhow::Result<Vec<store::Task>> {
906 let store = self
907 .store
908 .as_ref()
909 .ok_or_else(|| anyhow::anyhow!("store required"))?;
910 store.list_all_internal(&self.repo).await
911 }
912
913 pub async fn publish_task(&self, id: i64, labels: &[String]) -> anyhow::Result<ExternalId> {
914 let store = self
915 .store
916 .as_ref()
917 .ok_or_else(|| anyhow::anyhow!("store required for publish_task"))?;
918 let task = store.get(id).await?;
919 let ext_id = self
920 .backend
921 .create_task(&task.title, &task.body, labels)
922 .await?;
923 store.update_external_id(id, &ext_id.0).await?;
924 store.update_status(id, TaskStatus::New).await?;
925 Ok(ext_id)
926 }
927}
928
929pub fn store_task_to_external(t: &store::Task) -> ExternalTask {
932 let ext_id = t
933 .external_id
934 .clone()
935 .unwrap_or_else(|| format!("internal:{}", t.id));
936 ExternalTask {
937 id: ExternalId(ext_id),
938 title: t.title.clone(),
939 body: t.body.clone(),
940 state: "open".to_string(),
941 labels: {
942 let mut labels: Vec<String> = t
943 .labels
944 .iter()
945 .filter(|l| !l.starts_with("status:"))
946 .cloned()
947 .collect();
948 labels.push(format!("status:{}", t.status.as_str()));
949 labels
950 },
951 author: t.source.clone(),
952 created_at: t.created_at.clone(),
953 updated_at: t.updated_at.clone(),
954 url: t.url.clone(),
955 }
956}
957
958#[cfg(test)]
959mod tests {
960 use super::*;
961 use crate::backends::{ExternalId, ExternalTask, Mention};
962 use crate::store::{NewTask, TaskStore};
963 use async_trait::async_trait;
964 use std::sync::{Arc, Mutex};
965 use tokio::sync::broadcast;
966
967 type StatusTaskMap = Arc<Mutex<Vec<(Status, Vec<ExternalTask>)>>>;
968
969 struct MockBackend {
971 status_updates: Arc<Mutex<Vec<(String, Status)>>>,
972 status_tasks: StatusTaskMap,
973 routable_tasks: Arc<Mutex<Vec<ExternalTask>>>,
974 }
975
976 impl MockBackend {
977 fn new() -> Self {
978 Self {
979 status_updates: Arc::new(Mutex::new(vec![])),
980 status_tasks: Arc::new(Mutex::new(vec![])),
981 routable_tasks: Arc::new(Mutex::new(vec![])),
982 }
983 }
984
985 fn with_status_tasks(self, status: Status, tasks: Vec<ExternalTask>) -> Self {
986 self.status_tasks.lock().unwrap().push((status, tasks));
987 self
988 }
989
990 fn with_routable_tasks(self, tasks: Vec<ExternalTask>) -> Self {
991 *self.routable_tasks.lock().unwrap() = tasks;
992 self
993 }
994 }
995
996 #[async_trait]
997 impl ExternalBackend for MockBackend {
998 fn name(&self) -> &'static str {
999 "mock"
1000 }
1001 async fn create_task(
1002 &self,
1003 _t: &str,
1004 _b: &str,
1005 _l: &[String],
1006 ) -> anyhow::Result<ExternalId> {
1007 Ok(ExternalId("new".to_string()))
1008 }
1009 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
1010 Ok(ExternalTask {
1011 id: id.clone(),
1012 title: "Mock".to_string(),
1013 body: String::new(),
1014 state: "open".to_string(),
1015 labels: vec![],
1016 author: "bot".to_string(),
1017 created_at: "2026-01-01T00:00:00Z".to_string(),
1018 updated_at: "2026-01-01T00:00:00Z".to_string(),
1019 url: String::new(),
1020 })
1021 }
1022 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
1023 Ok(self
1024 .status_tasks
1025 .lock()
1026 .unwrap()
1027 .iter()
1028 .find(|(s, _)| *s == status)
1029 .map(|(_, tasks)| tasks.clone())
1030 .unwrap_or_default())
1031 }
1032 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1033 Ok(self.routable_tasks.lock().unwrap().clone())
1034 }
1035 async fn post_comment(&self, _id: &ExternalId, _b: &str) -> anyhow::Result<()> {
1036 Ok(())
1037 }
1038 async fn set_labels(&self, _id: &ExternalId, _l: &[String]) -> anyhow::Result<()> {
1039 Ok(())
1040 }
1041 async fn remove_label(&self, _id: &ExternalId, _l: &str) -> anyhow::Result<()> {
1042 Ok(())
1043 }
1044 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
1045 Ok(vec![])
1046 }
1047 async fn create_sub_task(
1048 &self,
1049 _p: &ExternalId,
1050 _t: &str,
1051 _b: &str,
1052 _l: &[String],
1053 ) -> anyhow::Result<ExternalId> {
1054 Ok(ExternalId("child".to_string()))
1055 }
1056 async fn ensure_status_label(&self, _l: &str) -> anyhow::Result<()> {
1057 Ok(())
1058 }
1059 async fn has_open_issue_with_title(&self, _t: &str, _l: &str) -> anyhow::Result<bool> {
1060 Ok(false)
1061 }
1062 async fn health_check(&self) -> anyhow::Result<()> {
1063 Ok(())
1064 }
1065 async fn is_pr_merged(&self, _b: &str) -> anyhow::Result<bool> {
1066 Ok(false)
1067 }
1068 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
1069 Ok(Some("testbot".to_string()))
1070 }
1071 async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
1072 Ok(vec![])
1073 }
1074 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
1075 self.status_updates
1076 .lock()
1077 .unwrap()
1078 .push((id.0.clone(), status));
1079 Ok(())
1080 }
1081 }
1082
1083 #[tokio::test]
1084 async fn with_store_constructor_enables_dual_write() {
1085 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1086 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1087
1088 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1089
1090 assert!(tm.store.is_some(), "store should be set via with_store");
1091 assert_eq!(tm.repo, "owner/repo");
1092 }
1093
1094 #[tokio::test]
1095 async fn update_status_dual_writes_to_store_for_external_task() {
1096 let mock = MockBackend::new();
1097 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
1098 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1099
1100 store
1102 .create(&NewTask {
1103 external_id: Some("42".to_string()),
1104 repo: "owner/repo".to_string(),
1105 origin: "github".to_string(),
1106 title: "Test".to_string(),
1107 ..Default::default()
1108 })
1109 .await
1110 .unwrap();
1111
1112 let tm = TaskManager::with_store(
1113 Arc::clone(&backend),
1114 Arc::clone(&store),
1115 "owner/repo".to_string(),
1116 );
1117
1118 tm.update_task_status(&ExternalId("42".to_string()), Status::InProgress)
1120 .await
1121 .unwrap();
1122
1123 let task = store.get(1).await.unwrap();
1125 assert_eq!(
1126 task.status,
1127 TaskStatus::InProgress,
1128 "store should have updated status"
1129 );
1130 }
1131
1132 #[tokio::test]
1133 async fn update_status_skips_store_when_task_not_in_store() {
1134 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1135 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1136
1137 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1138
1139 let result = tm
1141 .update_task_status(&ExternalId("999".to_string()), Status::Done)
1142 .await;
1143 assert!(result.is_ok(), "should succeed even when task not in store");
1144 }
1145
1146 #[tokio::test]
1147 async fn update_status_works_without_store() {
1148 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1149
1150 let tm = TaskManager::new(backend);
1152
1153 let result = tm
1154 .update_task_status(&ExternalId("42".to_string()), Status::Routed)
1155 .await;
1156 assert!(
1157 result.is_ok(),
1158 "should work without store (backward compat)"
1159 );
1160 }
1161
1162 #[tokio::test]
1163 async fn update_status_writes_to_store_for_internal_task() {
1164 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1165 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1166
1167 let internal_id = store
1169 .create_internal("owner/repo", "Internal task", "body", "cron", "job:1", None)
1170 .await
1171 .unwrap();
1172
1173 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1174
1175 let id_str = format!("internal:{internal_id}");
1177 tm.update_task_status(&ExternalId(id_str), Status::Routed)
1178 .await
1179 .unwrap();
1180
1181 let task = store.get(internal_id).await.unwrap();
1183 assert_eq!(task.status, TaskStatus::Routed);
1184 }
1185
1186 #[tokio::test]
1187 async fn status_to_task_status_maps_all_variants() {
1188 assert_eq!(status_to_task_status(Status::New), TaskStatus::New);
1189 assert_eq!(status_to_task_status(Status::Routed), TaskStatus::Routed);
1190 assert_eq!(
1191 status_to_task_status(Status::InProgress),
1192 TaskStatus::InProgress
1193 );
1194 assert_eq!(status_to_task_status(Status::Done), TaskStatus::Done);
1195 assert_eq!(status_to_task_status(Status::Blocked), TaskStatus::Blocked);
1196 assert_eq!(
1197 status_to_task_status(Status::InReview),
1198 TaskStatus::InReview
1199 );
1200 assert_eq!(
1201 status_to_task_status(Status::NeedsReview),
1202 TaskStatus::NeedsReview
1203 );
1204 }
1205
1206 #[test]
1207 fn is_internal_id_detects_prefix() {
1208 assert!(is_internal_id("internal:5"));
1209 assert!(is_internal_id("internal:0"));
1210 assert!(!is_internal_id("42"));
1211 assert!(!is_internal_id(""));
1212 assert!(!is_internal_id("internal"));
1213 }
1214
1215 #[test]
1216 fn parse_internal_id_extracts_number() {
1217 assert_eq!(parse_internal_id("internal:5"), Some(5));
1218 assert_eq!(parse_internal_id("internal:0"), Some(0));
1219 assert_eq!(parse_internal_id("internal:abc"), None);
1220 assert_eq!(parse_internal_id("42"), None);
1221 assert_eq!(parse_internal_id(""), None);
1222 }
1223
1224 #[tokio::test]
1225 async fn create_internal_task_via_task_manager() {
1226 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1227 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1228 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1229
1230 let task = tm
1231 .create_task(CreateTaskRequest {
1232 title: "Internal task".to_string(),
1233 body: "Do something".to_string(),
1234 task_type: TaskType::Internal,
1235 labels: vec![],
1236 source: "cron".to_string(),
1237 source_id: "job:daily".to_string(),
1238 })
1239 .await
1240 .unwrap();
1241
1242 match task {
1243 Task::Internal(t) => {
1244 assert_eq!(t.title, "Internal task");
1245 assert_eq!(t.origin, "internal");
1246 assert!(
1247 t.external_id
1248 .as_deref()
1249 .is_some_and(|s| s.starts_with("internal:"))
1250 );
1251 }
1252 Task::External(_) => panic!("expected Internal variant"),
1253 }
1254 }
1255
1256 #[tokio::test]
1257 async fn create_internal_task_fails_without_store() {
1258 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1259 let tm = TaskManager::new(backend);
1260
1261 let result = tm
1262 .create_task(CreateTaskRequest {
1263 title: "No store".to_string(),
1264 body: String::new(),
1265 task_type: TaskType::Internal,
1266 labels: vec![],
1267 source: "manual".to_string(),
1268 source_id: String::new(),
1269 })
1270 .await;
1271
1272 assert!(result.is_err(), "should fail without store");
1273 }
1274
1275 #[tokio::test]
1278 async fn update_status_internal_task_not_found_returns_err_and_no_event() {
1279 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1280 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1281
1282 let (event_tx, mut event_rx) = broadcast::channel::<TaskEvent>(16);
1283
1284 let tm = TaskManager::with_events(
1285 backend,
1286 Arc::clone(&store),
1287 "owner/repo".to_string(),
1288 event_tx,
1289 );
1290
1291 let result = tm
1293 .update_task_status(&ExternalId("internal:999".to_string()), Status::Done)
1294 .await;
1295
1296 assert!(
1297 result.is_err(),
1298 "should return Err when internal task is not found in store"
1299 );
1300
1301 assert!(
1303 event_rx.try_recv().is_err(),
1304 "no event should be published for a not-found internal task"
1305 );
1306 }
1307
1308 #[tokio::test]
1314 async fn mark_task_done_by_store_id_succeeds_across_repos() {
1315 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1316 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1317
1318 let store_id = store
1319 .create_internal(
1320 "other/repo",
1321 "Cross-repo task",
1322 "body",
1323 "cron",
1324 "job:1",
1325 None,
1326 )
1327 .await
1328 .unwrap();
1329 let ext_id = ExternalId(format!("internal:{store_id}"));
1330
1331 let tm = TaskManager::with_store(backend, Arc::clone(&store), "active/repo".to_string());
1333
1334 let resolve_result = tm.update_task_status(&ext_id, Status::Done).await;
1336 assert!(
1337 resolve_result.is_err(),
1338 "repo-scoped update_task_status must fail for a cross-repo task"
1339 );
1340
1341 tm.mark_task_done_by_store_id(store_id, "other/repo", &ext_id)
1343 .await
1344 .unwrap();
1345
1346 let task = store.get(store_id).await.unwrap();
1347 assert_eq!(task.status, TaskStatus::Done);
1348 }
1349
1350 #[tokio::test]
1351 async fn get_task_returns_internal_from_store() {
1352 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1353 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1354 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1355
1356 let id = store
1357 .create_internal("owner/repo", "Fetch me", "body", "cron", "job:1", None)
1358 .await
1359 .unwrap();
1360
1361 let task = tm.get_task(id).await.unwrap();
1362 match task {
1363 Task::Internal(t) => assert_eq!(t.title, "Fetch me"),
1364 Task::External(_) => panic!("expected Internal variant"),
1365 }
1366 }
1367
1368 #[tokio::test]
1369 async fn list_routable_includes_internal_new_tasks() {
1370 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1371 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1372 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1373
1374 store
1376 .create_internal("owner/repo", "Route me", "", "cron", "job:2", None)
1377 .await
1378 .unwrap();
1379
1380 let routable = tm.list_routable().await.unwrap();
1381 assert_eq!(routable.len(), 1);
1382 assert!(routable[0].id.0.starts_with("internal:"));
1383 assert_eq!(routable[0].title, "Route me");
1384 }
1385
1386 #[tokio::test]
1387 async fn list_routable_excludes_non_new_internal_tasks() {
1388 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1389 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1390 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1391
1392 let id = store
1393 .create_internal("owner/repo", "Already routed", "", "cron", "job:3", None)
1394 .await
1395 .unwrap();
1396 store
1397 .update_status(id, TaskStatus::InProgress)
1398 .await
1399 .unwrap();
1400
1401 let routable = tm.list_routable().await.unwrap();
1402 assert!(
1403 routable.is_empty(),
1404 "in_progress tasks should not be routable"
1405 );
1406 }
1407
1408 #[tokio::test]
1409 async fn list_internal_by_status_returns_external_tasks() {
1410 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1411 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1412 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1413
1414 store
1415 .create_internal("owner/repo", "Task A", "", "cron", "a", None)
1416 .await
1417 .unwrap();
1418 let id_b = store
1419 .create_internal("owner/repo", "Task B", "", "cron", "b", None)
1420 .await
1421 .unwrap();
1422 store.update_status(id_b, TaskStatus::Done).await.unwrap();
1423
1424 let new_tasks = tm.list_internal_by_status(TaskStatus::New).await.unwrap();
1425 assert_eq!(new_tasks.len(), 1);
1426 assert_eq!(new_tasks[0].title, "Task A");
1427 assert!(new_tasks[0].labels.contains(&"status:new".to_string()));
1429 }
1430
1431 #[tokio::test]
1432 async fn store_task_to_external_maps_fields_correctly() {
1433 let store = TaskStore::open_memory().await.unwrap();
1434 let id = store
1435 .create_internal(
1436 "owner/repo",
1437 "Convert me",
1438 "body text",
1439 "manual",
1440 "m:1",
1441 None,
1442 )
1443 .await
1444 .unwrap();
1445 let task = store.get(id).await.unwrap();
1446 let ext = store_task_to_external(&task);
1447
1448 assert!(ext.id.0.starts_with("internal:"));
1449 assert_eq!(ext.title, "Convert me");
1450 assert_eq!(ext.body, "body text");
1451 assert_eq!(ext.state, "open");
1452 assert!(ext.labels.contains(&"status:new".to_string()));
1453 assert_eq!(ext.author, "manual"); }
1455
1456 #[tokio::test]
1457 async fn list_all_internal_returns_store_tasks() {
1458 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1459 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1460 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1461
1462 store
1463 .create_internal("owner/repo", "T1", "", "cron", "1", None)
1464 .await
1465 .unwrap();
1466 store
1467 .create_internal("owner/repo", "T2", "", "cron", "2", None)
1468 .await
1469 .unwrap();
1470 store
1472 .create_internal("other/repo", "T3", "", "cron", "3", None)
1473 .await
1474 .unwrap();
1475
1476 let tasks = tm.list_all_internal().await.unwrap();
1477 assert_eq!(tasks.len(), 2);
1478 }
1479
1480 #[tokio::test]
1483 async fn list_external_by_status_reads_from_store() {
1484 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1485 let backend = Arc::new(MockBackend::new());
1486 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1487
1488 let id = store
1490 .upsert_external(&crate::store::UpsertExternal {
1491 repo: "owner/repo",
1492 ext_id: "42",
1493 title: "Store task",
1494 body: "",
1495 author: "",
1496 url: "",
1497 labels: &[],
1498 origin: "github",
1499 })
1500 .await
1501 .unwrap();
1502 store.update_status(id, TaskStatus::Routed).await.unwrap();
1503
1504 let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1505 assert_eq!(routed.len(), 1);
1506 assert_eq!(routed[0].id.0, "42");
1507 assert_eq!(routed[0].title, "Store task");
1508 }
1509
1510 #[tokio::test]
1511 async fn list_external_by_status_excludes_internal_tasks() {
1512 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1513 let backend = Arc::new(MockBackend::new());
1514 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1515
1516 let id = store
1518 .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1519 .await
1520 .unwrap();
1521 store.update_status(id, TaskStatus::Routed).await.unwrap();
1522
1523 store
1525 .upsert_external(&crate::store::UpsertExternal {
1526 repo: "owner/repo",
1527 ext_id: "99",
1528 title: "External",
1529 body: "",
1530 author: "",
1531 url: "",
1532 labels: &[],
1533 origin: "github",
1534 })
1535 .await
1536 .unwrap();
1537
1538 let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1539 assert_eq!(routed.len(), 0);
1541 }
1542
1543 #[tokio::test]
1544 async fn list_external_by_status_falls_back_when_store_has_only_internal_tasks() {
1545 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1546 let backend = Arc::new(MockBackend::new().with_status_tasks(
1547 Status::Routed,
1548 vec![ExternalTask {
1549 id: ExternalId("123".to_string()),
1550 title: "Backend routed".to_string(),
1551 body: String::new(),
1552 state: "open".to_string(),
1553 labels: vec!["status:routed".to_string()],
1554 author: "bot".to_string(),
1555 created_at: "2026-01-01T00:00:00Z".to_string(),
1556 updated_at: "2026-01-01T00:00:00Z".to_string(),
1557 url: String::new(),
1558 }],
1559 ));
1560 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1561
1562 let id = store
1563 .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1564 .await
1565 .unwrap();
1566 store.update_status(id, TaskStatus::Routed).await.unwrap();
1567
1568 let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1569 assert_eq!(routed.len(), 1);
1570 assert_eq!(routed[0].id.0, "123");
1571 }
1572
1573 #[tokio::test]
1574 async fn list_routable_reads_from_store_when_populated() {
1575 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1576 let backend = Arc::new(MockBackend::new());
1577 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1578
1579 store
1581 .upsert_external(&crate::store::UpsertExternal {
1582 repo: "owner/repo",
1583 ext_id: "10",
1584 title: "New external",
1585 body: "",
1586 author: "",
1587 url: "",
1588 labels: &[],
1589 origin: "github",
1590 })
1591 .await
1592 .unwrap();
1593 store
1595 .create_internal("owner/repo", "New internal", "", "cron", "1", None)
1596 .await
1597 .unwrap();
1598
1599 let routable = tm.list_routable().await.unwrap();
1600 assert_eq!(routable.len(), 2);
1602 }
1603
1604 #[tokio::test]
1605 async fn list_routable_falls_back_when_store_has_only_internal_tasks() {
1606 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1607 let backend = Arc::new(MockBackend::new().with_routable_tasks(vec![ExternalTask {
1608 id: ExternalId("55".to_string()),
1609 title: "Backend new".to_string(),
1610 body: String::new(),
1611 state: "open".to_string(),
1612 labels: vec!["status:new".to_string()],
1613 author: "bot".to_string(),
1614 created_at: "2026-01-01T00:00:00Z".to_string(),
1615 updated_at: "2026-01-01T00:00:00Z".to_string(),
1616 url: String::new(),
1617 }]));
1618 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1619
1620 store
1621 .create_internal("owner/repo", "Internal new", "", "cron", "1", None)
1622 .await
1623 .unwrap();
1624
1625 let routable = tm.list_routable().await.unwrap();
1626 assert_eq!(routable.len(), 2);
1627 assert!(routable.iter().any(|task| task.id.0 == "55"));
1628 assert!(
1629 routable
1630 .iter()
1631 .any(|task| task.id.0.starts_with("internal:"))
1632 );
1633 }
1634
1635 #[tokio::test]
1636 async fn list_all_external_tasks_reads_from_store() {
1637 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1638 let backend = Arc::new(MockBackend::new());
1639 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1640
1641 let id1 = store
1643 .upsert_external(&crate::store::UpsertExternal {
1644 repo: "owner/repo",
1645 ext_id: "1",
1646 title: "Task 1",
1647 body: "",
1648 author: "",
1649 url: "",
1650 labels: &[],
1651 origin: "github",
1652 })
1653 .await
1654 .unwrap();
1655 let id2 = store
1656 .upsert_external(&crate::store::UpsertExternal {
1657 repo: "owner/repo",
1658 ext_id: "2",
1659 title: "Task 2",
1660 body: "",
1661 author: "",
1662 url: "",
1663 labels: &[],
1664 origin: "github",
1665 })
1666 .await
1667 .unwrap();
1668 store.update_status(id1, TaskStatus::Done).await.unwrap();
1669 store
1670 .update_status(id2, TaskStatus::InProgress)
1671 .await
1672 .unwrap();
1673
1674 store
1676 .create_internal("owner/repo", "Internal", "", "cron", "1", None)
1677 .await
1678 .unwrap();
1679
1680 let all = tm.list_all_external_tasks().await.unwrap();
1681 assert_eq!(all.len(), 2);
1682 }
1683
1684 #[tokio::test]
1685 async fn list_all_external_tasks_falls_back_when_store_has_only_internal_tasks() {
1686 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1687 let backend = Arc::new(MockBackend::new().with_status_tasks(
1688 Status::Done,
1689 vec![ExternalTask {
1690 id: ExternalId("200".to_string()),
1691 title: "Backend done".to_string(),
1692 body: String::new(),
1693 state: "closed".to_string(),
1694 labels: vec!["status:done".to_string()],
1695 author: "bot".to_string(),
1696 created_at: "2026-01-01T00:00:00Z".to_string(),
1697 updated_at: "2026-01-01T00:00:00Z".to_string(),
1698 url: String::new(),
1699 }],
1700 ));
1701 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1702
1703 store
1704 .create_internal("owner/repo", "Internal", "", "cron", "1", None)
1705 .await
1706 .unwrap();
1707
1708 let all = tm.list_all_external_tasks().await.unwrap();
1709 assert_eq!(all.len(), 1);
1710 assert_eq!(all[0].id.0, "200");
1711 }
1712
1713 #[tokio::test]
1714 async fn list_tasks_without_status_merges_backend_external_when_store_only_has_internal() {
1715 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1716 let backend = Arc::new(MockBackend::new().with_status_tasks(
1717 Status::New,
1718 vec![ExternalTask {
1719 id: ExternalId("301".to_string()),
1720 title: "Backend new".to_string(),
1721 body: String::new(),
1722 state: "open".to_string(),
1723 labels: vec!["status:new".to_string()],
1724 author: "bot".to_string(),
1725 created_at: "2026-01-01T00:00:00Z".to_string(),
1726 updated_at: "2026-01-01T00:00:00Z".to_string(),
1727 url: String::new(),
1728 }],
1729 ));
1730 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1731
1732 let internal_id = store
1733 .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1734 .await
1735 .unwrap();
1736
1737 let listed = tm.list_tasks(TaskFilter::default()).await.unwrap();
1738 assert_eq!(listed.len(), 2);
1739 assert!(listed.iter().any(|task| matches!(
1740 task,
1741 Task::External(external) if external.id.0 == "301"
1742 )));
1743 assert!(listed.iter().any(|task| matches!(
1744 task,
1745 Task::Internal(internal) if internal.id == internal_id
1746 )));
1747 }
1748
1749 #[tokio::test]
1750 async fn list_tasks_status_all_merges_backend_external_when_store_only_has_internal() {
1751 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1752 let backend = Arc::new(MockBackend::new().with_status_tasks(
1753 Status::Done,
1754 vec![ExternalTask {
1755 id: ExternalId("302".to_string()),
1756 title: "Backend done".to_string(),
1757 body: String::new(),
1758 state: "closed".to_string(),
1759 labels: vec!["status:done".to_string()],
1760 author: "bot".to_string(),
1761 created_at: "2026-01-01T00:00:00Z".to_string(),
1762 updated_at: "2026-01-01T00:00:00Z".to_string(),
1763 url: String::new(),
1764 }],
1765 ));
1766 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1767
1768 let internal_id = store
1769 .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1770 .await
1771 .unwrap();
1772
1773 let listed = tm
1774 .list_tasks(TaskFilter {
1775 status: Some("all".to_string()),
1776 source: None,
1777 })
1778 .await
1779 .unwrap();
1780 assert_eq!(listed.len(), 2);
1781 assert!(listed.iter().any(|task| matches!(
1782 task,
1783 Task::External(external) if external.id.0 == "302"
1784 )));
1785 assert!(listed.iter().any(|task| matches!(
1786 task,
1787 Task::Internal(internal) if internal.id == internal_id
1788 )));
1789 }
1790
1791 #[tokio::test]
1794 async fn update_task_status_writes_store_first_for_external() {
1795 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1796 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1797 let tm = TaskManager::with_store(
1798 Arc::clone(&backend),
1799 Arc::clone(&store),
1800 "owner/repo".to_string(),
1801 );
1802
1803 let store_id = store
1805 .upsert_external(&crate::store::UpsertExternal {
1806 repo: "owner/repo",
1807 ext_id: "42",
1808 title: "Test",
1809 body: "",
1810 author: "user",
1811 url: "",
1812 labels: &[],
1813 origin: "github",
1814 })
1815 .await
1816 .unwrap();
1817 assert_eq!(store.get(store_id).await.unwrap().status, TaskStatus::New);
1818
1819 let ext_id = ExternalId("42".to_string());
1821 tm.update_task_status(&ext_id, Status::Routed)
1822 .await
1823 .unwrap();
1824
1825 let task = store.get(store_id).await.unwrap();
1827 assert_eq!(task.status, TaskStatus::Routed);
1828 }
1829
1830 #[tokio::test]
1831 async fn update_task_status_handles_internal_tasks() {
1832 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1833 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1834 let tm = TaskManager::with_store(
1835 Arc::clone(&backend),
1836 Arc::clone(&store),
1837 "owner/repo".to_string(),
1838 );
1839
1840 let store_id = store
1842 .create_internal("owner/repo", "task", "body", "manual", "", None)
1843 .await
1844 .unwrap();
1845
1846 let internal_id = ExternalId(format!("internal:{store_id}"));
1847 tm.update_task_status(&internal_id, Status::InProgress)
1848 .await
1849 .unwrap();
1850
1851 let task = store.get(store_id).await.unwrap();
1852 assert_eq!(task.status, TaskStatus::InProgress);
1853 }
1854
1855 #[tokio::test]
1856 async fn update_task_status_publishes_event() {
1857 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1858 let (tx, mut rx) = broadcast::channel::<TaskEvent>(16);
1859 let tm = TaskManager {
1860 backend,
1861 store: None,
1862 repo: "owner/repo".to_string(),
1863 event_tx: Some(tx),
1864 };
1865 let id = ExternalId("42".to_string());
1867 tm.update_task_status(&id, Status::Routed).await.unwrap();
1868 let event = rx.try_recv().unwrap();
1869 assert_eq!(event.task_id, "42");
1870 assert_eq!(event.new_status, "routed");
1871 assert_eq!(event.repo, "owner/repo");
1872 }
1873
1874 #[tokio::test]
1875 async fn event_includes_old_status_and_context() {
1876 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1877 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1878 let (tx, mut rx) = broadcast::channel::<TaskEvent>(16);
1879
1880 let store_id = store
1882 .upsert_external(&crate::store::UpsertExternal {
1883 repo: "owner/repo",
1884 ext_id: "77",
1885 title: "Context test",
1886 body: "",
1887 author: "user",
1888 url: "",
1889 labels: &[],
1890 origin: "github",
1891 })
1892 .await
1893 .unwrap();
1894
1895 store
1897 .set_fields(
1898 store_id,
1899 &[
1900 ("agent", serde_json::Value::String("claude".to_string())),
1901 ("model", serde_json::Value::String("sonnet".to_string())),
1902 ("branch", serde_json::Value::String("feat-77".to_string())),
1903 ("pr_number", serde_json::Value::Number(42.into())),
1904 (
1905 "last_error",
1906 serde_json::Value::String("timeout".to_string()),
1907 ),
1908 ],
1909 )
1910 .await
1911 .unwrap();
1912
1913 let tm =
1914 TaskManager::with_events(backend, Arc::clone(&store), "owner/repo".to_string(), tx);
1915
1916 let ext_id = ExternalId("77".to_string());
1918 tm.update_task_status(&ext_id, Status::Routed)
1919 .await
1920 .unwrap();
1921
1922 let event = rx.try_recv().unwrap();
1923 assert_eq!(event.task_id, "77");
1924 assert_eq!(event.old_status, "new");
1925 assert_eq!(event.new_status, "routed");
1926 assert_eq!(event.agent.as_deref(), Some("claude"));
1927 assert_eq!(event.model.as_deref(), Some("sonnet"));
1928 assert_eq!(event.branch.as_deref(), Some("feat-77"));
1929 assert_eq!(event.pr_number.as_deref(), Some("42"));
1930 assert_eq!(event.error.as_deref(), Some("timeout"));
1931 }
1932
1933 #[tokio::test]
1934 async fn event_includes_old_status_for_internal_task() {
1935 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1936 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1937 let (tx, mut rx) = broadcast::channel::<TaskEvent>(16);
1938
1939 let store_id = store
1941 .create_internal(
1942 "owner/repo",
1943 "internal ctx test",
1944 "body",
1945 "manual",
1946 "",
1947 None,
1948 )
1949 .await
1950 .unwrap();
1951
1952 let tm =
1953 TaskManager::with_events(backend, Arc::clone(&store), "owner/repo".to_string(), tx);
1954
1955 let internal_id = ExternalId(format!("internal:{store_id}"));
1957 tm.update_task_status(&internal_id, Status::Routed)
1958 .await
1959 .unwrap();
1960
1961 let event = rx.try_recv().unwrap();
1962 assert_eq!(event.old_status, "new");
1963 assert_eq!(event.new_status, "routed");
1964
1965 tm.update_task_status(&internal_id, Status::InProgress)
1967 .await
1968 .unwrap();
1969
1970 let event2 = rx.try_recv().unwrap();
1971 assert_eq!(event2.old_status, "routed");
1972 assert_eq!(event2.new_status, "in_progress");
1973 }
1974
1975 #[tokio::test]
1978 async fn update_task_status_if_applies_when_status_matches() {
1979 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1980 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1981 let tm = TaskManager::with_store(backend, Arc::clone(&store), "owner/repo".to_string());
1982
1983 let store_id = store
1984 .upsert_external(&crate::store::UpsertExternal {
1985 repo: "owner/repo",
1986 ext_id: "10",
1987 title: "T",
1988 body: "",
1989 author: "u",
1990 url: "",
1991 labels: &[],
1992 origin: "github",
1993 })
1994 .await
1995 .unwrap();
1996 store
1998 .update_status(store_id, TaskStatus::InReview)
1999 .await
2000 .unwrap();
2001
2002 let id = ExternalId("10".to_string());
2003 let updated = tm
2004 .update_task_status_if(&id, Status::NeedsReview, Status::InReview)
2005 .await
2006 .unwrap();
2007 assert!(updated, "should update when expected status matches");
2008
2009 let task = store.get(store_id).await.unwrap();
2010 assert_eq!(task.status, TaskStatus::NeedsReview);
2011 }
2012
2013 #[tokio::test]
2014 async fn update_task_status_if_is_noop_when_status_mismatches() {
2015 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2016 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
2017 let (tx, mut rx) = broadcast::channel::<TaskEvent>(16);
2018 let tm =
2019 TaskManager::with_events(backend, Arc::clone(&store), "owner/repo".to_string(), tx);
2020
2021 let store_id = store
2022 .upsert_external(&crate::store::UpsertExternal {
2023 repo: "owner/repo",
2024 ext_id: "11",
2025 title: "T",
2026 body: "",
2027 author: "u",
2028 url: "",
2029 labels: &[],
2030 origin: "github",
2031 })
2032 .await
2033 .unwrap();
2034 store
2036 .update_status(store_id, TaskStatus::Done)
2037 .await
2038 .unwrap();
2039
2040 let id = ExternalId("11".to_string());
2041 let updated = tm
2043 .update_task_status_if(&id, Status::NeedsReview, Status::InReview)
2044 .await
2045 .unwrap();
2046 assert!(
2047 !updated,
2048 "should not update when status has already changed"
2049 );
2050
2051 let task = store.get(store_id).await.unwrap();
2053 assert_eq!(task.status, TaskStatus::Done);
2054
2055 assert!(
2057 rx.try_recv().is_err(),
2058 "no event should be published on a no-op update"
2059 );
2060 }
2061}