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