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