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 self.release_dependents_if_done(task_status, &self.repo, &id.0)
465 .await;
466
467 let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
471
472 if is_internal_id(&id.0) {
473 let store = self
475 .store
476 .as_ref()
477 .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
478 let store_id = snapshot_store_id
479 .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
480 if task_status != TaskStatus::Blocked {
481 store.set_block_reason(store_id, None).await?;
482 }
483 store.update_status(store_id, task_status).await?;
484 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
486 return Ok(());
487 }
488
489 if let Some(ref store) = self.store {
492 if let Some(store_id) = snapshot_store_id {
493 if task_status != TaskStatus::Blocked {
494 store.set_block_reason(store_id, None).await?;
495 }
496 store.update_status(store_id, task_status).await?;
497 }
498 }
499
500 if let Err(e) = self.backend.update_status(id, status).await {
503 tracing::warn!(
504 task_id = id.0,
505 ?status,
506 err = %e,
507 "failed to mirror status to backend — store is authoritative"
508 );
509 }
510
511 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
513
514 Ok(())
515 }
516
517 pub async fn update_task_status_and_result(
518 &self,
519 id: &ExternalId,
520 status: Status,
521 updates: &[(&str, serde_json::Value)],
522 ) -> anyhow::Result<()> {
523 let task_status = status_to_task_status(status);
524 self.release_dependents_if_done(task_status, &self.repo, &id.0)
525 .await;
526
527 let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
529
530 if is_internal_id(&id.0) {
531 let store = self
533 .store
534 .as_ref()
535 .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
536 let store_id = snapshot_store_id
537 .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
538
539 store
540 .update_status_and_fields(store_id, task_status, updates)
541 .await?;
542 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
544 return Ok(());
545 }
546
547 if let Some(ref store) = self.store {
549 if let Some(store_id) = snapshot_store_id {
550 store
551 .update_status_and_fields(store_id, task_status, updates)
552 .await?;
553 }
554 }
555
556 if let Err(e) = self.backend.update_status(id, status).await {
558 tracing::warn!(
559 task_id = id.0,
560 ?status,
561 err = %e,
562 "failed to mirror status to backend — store is authoritative"
563 );
564 }
565
566 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
568
569 Ok(())
570 }
571
572 pub async fn update_task_status_if(
577 &self,
578 id: &ExternalId,
579 status: Status,
580 expected_status: Status,
581 ) -> anyhow::Result<bool> {
582 let task_status = status_to_task_status(status);
583 self.release_dependents_if_done(task_status, &self.repo, &id.0)
584 .await;
585 let expected_task_status = status_to_task_status(expected_status);
586
587 let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
588
589 if is_internal_id(&id.0) {
590 let store = self
591 .store
592 .as_ref()
593 .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
594 let store_id = snapshot_store_id
595 .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
596 let updated = store
597 .update_status_if(store_id, task_status, expected_task_status)
598 .await?;
599 if updated {
600 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
601 }
602 return Ok(updated);
603 }
604
605 if let Some(ref store) = self.store {
612 if let Some(store_id) = snapshot_store_id {
613 let updated = store
614 .update_status_if(store_id, task_status, expected_task_status)
615 .await?;
616 if !updated {
617 return Ok(false);
618 }
619 } else {
620 tracing::warn!(
621 task_id = id.0,
622 ?expected_task_status,
623 "update_task_status_if: external task has no store record — cannot perform conditional update"
624 );
625 return Ok(false);
626 }
627 } else {
628 tracing::warn!(
629 task_id = id.0,
630 ?expected_task_status,
631 "update_task_status_if: no store available — cannot perform conditional update for external task"
632 );
633 return Ok(false);
634 }
635
636 if let Err(e) = self.backend.update_status(id, status).await {
637 tracing::warn!(
638 task_id = id.0,
639 ?status,
640 err = %e,
641 "failed to mirror status to backend — store is authoritative"
642 );
643 }
644
645 self.publish_event(id, status, &pre_snapshot, &self.repo, None);
646 Ok(true)
647 }
648
649 pub async fn update_task_status_with_duration(
654 &self,
655 id: &ExternalId,
656 status: Status,
657 duration_seconds: Option<f64>,
658 ) -> anyhow::Result<()> {
659 let task_status = status_to_task_status(status);
660 self.release_dependents_if_done(task_status, &self.repo, &id.0)
661 .await;
662 let (pre_snapshot, snapshot_store_id) = self.read_task_snapshot(&id.0).await;
663
664 if is_internal_id(&id.0) {
665 let store = self
666 .store
667 .as_ref()
668 .ok_or_else(|| anyhow::anyhow!("store required for internal task status update"))?;
669 let store_id = snapshot_store_id
670 .ok_or_else(|| anyhow::anyhow!("internal task {} not found in store", id.0))?;
671 if task_status != TaskStatus::Blocked {
672 store.set_block_reason(store_id, None).await?;
673 }
674 store.update_status(store_id, task_status).await?;
675 self.publish_event(id, status, &pre_snapshot, &self.repo, duration_seconds);
676 return Ok(());
677 }
678
679 if let Some(ref store) = self.store {
680 if let Some(store_id) = snapshot_store_id {
681 if task_status != TaskStatus::Blocked {
682 store.set_block_reason(store_id, None).await?;
683 }
684 store.update_status(store_id, task_status).await?;
685 }
686 }
687
688 if let Err(e) = self.backend.update_status(id, status).await {
689 tracing::warn!(
690 task_id = id.0,
691 ?status,
692 err = %e,
693 "failed to mirror status to backend — store is authoritative"
694 );
695 }
696
697 self.publish_event(id, status, &pre_snapshot, &self.repo, duration_seconds);
698
699 Ok(())
700 }
701
702 async fn read_task_snapshot(&self, task_id: &str) -> (TaskSnapshot, Option<i64>) {
706 let Some(ref store) = self.store else {
707 return (TaskSnapshot::default(), None);
708 };
709 let store_id = match store.resolve_task_id(&self.repo, task_id).await {
710 Ok(Some(id)) => id,
711 _ => return (TaskSnapshot::default(), None),
712 };
713 let snapshot = Self::snapshot_for_store_id(store, store_id, task_id).await;
714 (snapshot, Some(store_id))
715 }
716
717 async fn snapshot_for_store_id(
721 store: &TaskStore,
722 store_id: i64,
723 metric_task_id: &str,
724 ) -> TaskSnapshot {
725 match store.get(store_id).await {
726 Ok(task) => {
727 let duration_seconds = store.latest_task_metric_duration(metric_task_id).await;
728 TaskSnapshot {
729 old_status: Some(task.status.as_str().to_string()),
730 agent: task.agent.clone(),
731 model: task.model.clone(),
732 branch: if task.branch.is_empty() {
733 None
734 } else {
735 Some(task.branch.clone())
736 },
737 pr_number: task.pr_number.map(|n| n.to_string()),
738 error: if task.last_error.is_empty() {
739 None
740 } else {
741 Some(task.last_error.clone())
742 },
743 title: if task.title.is_empty() {
744 None
745 } else {
746 Some(task.title.clone())
747 },
748 summary: if task.summary.is_empty() {
749 None
750 } else {
751 Some(task.summary.clone())
752 },
753 duration_seconds,
754 }
755 }
756 Err(e) => {
757 tracing::warn!(
758 task_id = store_id,
759 err = %e,
760 "failed to read task snapshot for event enrichment — event context will be empty"
761 );
762 TaskSnapshot::default()
763 }
764 }
765 }
766
767 async fn release_dependents_if_done(&self, status: TaskStatus, repo: &str, ext_id: &str) {
774 if status != TaskStatus::Done {
775 return;
776 }
777 if let Some(ref store) = self.store {
778 crate::engine::dependencies::clear_blocker_read_cache(store, repo, ext_id).await;
779 }
780 }
781
782 fn publish_event(
784 &self,
785 id: &ExternalId,
786 status: Status,
787 snapshot: &TaskSnapshot,
788 repo: &str,
789 duration_seconds: Option<f64>,
790 ) {
791 if let Some(ref tx) = self.event_tx {
792 let event = crate::engine::events::TaskEvent {
793 task_id: id.0.clone(),
794 repo: repo.to_string(),
795 old_status: snapshot.old_status.clone().unwrap_or_default(),
796 new_status: status.as_label().trim_start_matches("status:").to_string(),
797 agent: snapshot.agent.clone(),
798 model: snapshot.model.clone(),
799 pr_number: snapshot.pr_number.clone(),
800 branch: snapshot.branch.clone(),
801 review_context: None,
802 error: snapshot.error.clone(),
803 timestamp: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
804 title: snapshot.title.clone(),
805 summary: snapshot.summary.clone(),
806 duration_seconds: duration_seconds.or(snapshot.duration_seconds),
807 };
808 let _ = tx.send(event);
809 }
810 }
811
812 pub async fn mark_task_done_by_store_id(
818 &self,
819 store_id: i64,
820 repo: &str,
821 ext_id: &ExternalId,
822 ) -> anyhow::Result<()> {
823 self.update_task_status_by_store_id(store_id, repo, ext_id, Status::Done)
824 .await
825 }
826
827 pub async fn update_task_status_by_store_id(
835 &self,
836 store_id: i64,
837 repo: &str,
838 ext_id: &ExternalId,
839 status: Status,
840 ) -> anyhow::Result<()> {
841 let store = self
842 .store
843 .as_ref()
844 .ok_or_else(|| anyhow::anyhow!("store required for status update"))?;
845 let task_status = status_to_task_status(status);
846 self.release_dependents_if_done(task_status, repo, &ext_id.0)
847 .await;
848
849 let snapshot = Self::snapshot_for_store_id(store, store_id, &ext_id.0).await;
850 if task_status != TaskStatus::Blocked {
851 store.set_block_reason(store_id, None).await?;
852 }
853 store.update_status(store_id, task_status).await?;
854 self.publish_event(ext_id, status, &snapshot, repo, None);
855 Ok(())
856 }
857
858 pub async fn update_task_status_and_result_by_store_id(
861 &self,
862 store_id: i64,
863 repo: &str,
864 ext_id: &ExternalId,
865 status: Status,
866 updates: &[(&str, serde_json::Value)],
867 ) -> anyhow::Result<()> {
868 let store = self
869 .store
870 .as_ref()
871 .ok_or_else(|| anyhow::anyhow!("store required for status update"))?;
872 let task_status = status_to_task_status(status);
873 self.release_dependents_if_done(task_status, repo, &ext_id.0)
874 .await;
875
876 let snapshot = Self::snapshot_for_store_id(store, store_id, &ext_id.0).await;
877 store
878 .update_status_and_fields(store_id, task_status, updates)
879 .await?;
880 self.publish_event(ext_id, status, &snapshot, repo, None);
881 Ok(())
882 }
883
884 pub async fn list_internal_by_status(
887 &self,
888 status: TaskStatus,
889 ) -> anyhow::Result<Vec<ExternalTask>> {
890 let store = self
891 .store
892 .as_ref()
893 .ok_or_else(|| anyhow::anyhow!("store required"))?;
894 let tasks = store.list_internal_by_status(&self.repo, status).await?;
895 Ok(tasks
896 .into_iter()
897 .map(|t| store_task_to_external(&t))
898 .collect())
899 }
900
901 pub async fn list_all_internal(&self) -> anyhow::Result<Vec<store::Task>> {
903 let store = self
904 .store
905 .as_ref()
906 .ok_or_else(|| anyhow::anyhow!("store required"))?;
907 store.list_all_internal(&self.repo).await
908 }
909
910 pub async fn publish_task(&self, id: i64, labels: &[String]) -> anyhow::Result<ExternalId> {
911 let store = self
912 .store
913 .as_ref()
914 .ok_or_else(|| anyhow::anyhow!("store required for publish_task"))?;
915 let task = store.get(id).await?;
916 let ext_id = self
917 .backend
918 .create_task(&task.title, &task.body, labels)
919 .await?;
920 store.update_external_id(id, &ext_id.0).await?;
921 store.update_status(id, TaskStatus::New).await?;
922 Ok(ext_id)
923 }
924}
925
926pub fn store_task_to_external(t: &store::Task) -> ExternalTask {
929 let ext_id = t
930 .external_id
931 .clone()
932 .unwrap_or_else(|| format!("internal:{}", t.id));
933 ExternalTask {
934 id: ExternalId(ext_id),
935 title: t.title.clone(),
936 body: t.body.clone(),
937 state: "open".to_string(),
938 labels: {
939 let mut labels: Vec<String> = t
940 .labels
941 .iter()
942 .filter(|l| !l.starts_with("status:"))
943 .cloned()
944 .collect();
945 labels.push(format!("status:{}", t.status.as_str()));
946 labels
947 },
948 author: t.source.clone(),
949 created_at: t.created_at.clone(),
950 updated_at: t.updated_at.clone(),
951 url: t.url.clone(),
952 }
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958 use crate::backends::{ExternalId, ExternalTask, Mention};
959 use crate::store::{NewTask, TaskStore};
960 use async_trait::async_trait;
961 use std::sync::{Arc, Mutex};
962
963 type StatusTaskMap = Arc<Mutex<Vec<(Status, Vec<ExternalTask>)>>>;
964
965 struct MockBackend {
967 status_updates: Arc<Mutex<Vec<(String, Status)>>>,
968 status_tasks: StatusTaskMap,
969 routable_tasks: Arc<Mutex<Vec<ExternalTask>>>,
970 }
971
972 impl MockBackend {
973 fn new() -> Self {
974 Self {
975 status_updates: Arc::new(Mutex::new(vec![])),
976 status_tasks: Arc::new(Mutex::new(vec![])),
977 routable_tasks: Arc::new(Mutex::new(vec![])),
978 }
979 }
980
981 fn with_status_tasks(self, status: Status, tasks: Vec<ExternalTask>) -> Self {
982 self.status_tasks.lock().unwrap().push((status, tasks));
983 self
984 }
985
986 fn with_routable_tasks(self, tasks: Vec<ExternalTask>) -> Self {
987 *self.routable_tasks.lock().unwrap() = tasks;
988 self
989 }
990 }
991
992 #[async_trait]
993 impl ExternalBackend for MockBackend {
994 fn name(&self) -> &str {
995 "mock"
996 }
997 async fn create_task(
998 &self,
999 _t: &str,
1000 _b: &str,
1001 _l: &[String],
1002 ) -> anyhow::Result<ExternalId> {
1003 Ok(ExternalId("new".to_string()))
1004 }
1005 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
1006 Ok(ExternalTask {
1007 id: id.clone(),
1008 title: "Mock".to_string(),
1009 body: "".to_string(),
1010 state: "open".to_string(),
1011 labels: vec![],
1012 author: "bot".to_string(),
1013 created_at: "2026-01-01T00:00:00Z".to_string(),
1014 updated_at: "2026-01-01T00:00:00Z".to_string(),
1015 url: "".to_string(),
1016 })
1017 }
1018 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
1019 Ok(self
1020 .status_tasks
1021 .lock()
1022 .unwrap()
1023 .iter()
1024 .find(|(s, _)| *s == status)
1025 .map(|(_, tasks)| tasks.clone())
1026 .unwrap_or_default())
1027 }
1028 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1029 Ok(self.routable_tasks.lock().unwrap().clone())
1030 }
1031 async fn post_comment(&self, _id: &ExternalId, _b: &str) -> anyhow::Result<()> {
1032 Ok(())
1033 }
1034 async fn set_labels(&self, _id: &ExternalId, _l: &[String]) -> anyhow::Result<()> {
1035 Ok(())
1036 }
1037 async fn remove_label(&self, _id: &ExternalId, _l: &str) -> anyhow::Result<()> {
1038 Ok(())
1039 }
1040 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
1041 Ok(vec![])
1042 }
1043 async fn create_sub_task(
1044 &self,
1045 _p: &ExternalId,
1046 _t: &str,
1047 _b: &str,
1048 _l: &[String],
1049 ) -> anyhow::Result<ExternalId> {
1050 Ok(ExternalId("child".to_string()))
1051 }
1052 async fn ensure_status_label(&self, _l: &str) -> anyhow::Result<()> {
1053 Ok(())
1054 }
1055 async fn has_open_issue_with_title(&self, _t: &str, _l: &str) -> anyhow::Result<bool> {
1056 Ok(false)
1057 }
1058 async fn health_check(&self) -> anyhow::Result<()> {
1059 Ok(())
1060 }
1061 async fn is_pr_merged(&self, _b: &str) -> anyhow::Result<bool> {
1062 Ok(false)
1063 }
1064 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
1065 Ok(Some("testbot".to_string()))
1066 }
1067 async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
1068 Ok(vec![])
1069 }
1070 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
1071 self.status_updates
1072 .lock()
1073 .unwrap()
1074 .push((id.0.clone(), status));
1075 Ok(())
1076 }
1077 }
1078
1079 #[tokio::test]
1080 async fn with_store_constructor_enables_dual_write() {
1081 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1082 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1083
1084 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1085
1086 assert!(tm.store.is_some(), "store should be set via with_store");
1087 assert_eq!(tm.repo, "owner/repo");
1088 }
1089
1090 #[tokio::test]
1091 async fn update_status_dual_writes_to_store_for_external_task() {
1092 let mock = MockBackend::new();
1093 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
1094 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1095
1096 store
1098 .create(&NewTask {
1099 external_id: Some("42".to_string()),
1100 repo: "owner/repo".to_string(),
1101 origin: "github".to_string(),
1102 title: "Test".to_string(),
1103 ..Default::default()
1104 })
1105 .await
1106 .unwrap();
1107
1108 let tm = TaskManager::with_store(backend.clone(), store.clone(), "owner/repo".to_string());
1109
1110 tm.update_task_status(&ExternalId("42".to_string()), Status::InProgress)
1112 .await
1113 .unwrap();
1114
1115 let task = store.get(1).await.unwrap();
1117 assert_eq!(
1118 task.status,
1119 TaskStatus::InProgress,
1120 "store should have updated status"
1121 );
1122 }
1123
1124 #[tokio::test]
1125 async fn update_status_skips_store_when_task_not_in_store() {
1126 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1127 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1128
1129 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1130
1131 let result = tm
1133 .update_task_status(&ExternalId("999".to_string()), Status::Done)
1134 .await;
1135 assert!(result.is_ok(), "should succeed even when task not in store");
1136 }
1137
1138 #[tokio::test]
1139 async fn update_status_works_without_store() {
1140 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1141
1142 let tm = TaskManager::new(backend);
1144
1145 let result = tm
1146 .update_task_status(&ExternalId("42".to_string()), Status::Routed)
1147 .await;
1148 assert!(
1149 result.is_ok(),
1150 "should work without store (backward compat)"
1151 );
1152 }
1153
1154 #[tokio::test]
1155 async fn update_status_writes_to_store_for_internal_task() {
1156 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1157 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1158
1159 let internal_id = store
1161 .create_internal("owner/repo", "Internal task", "body", "cron", "job:1", None)
1162 .await
1163 .unwrap();
1164
1165 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1166
1167 let id_str = format!("internal:{}", internal_id);
1169 tm.update_task_status(&ExternalId(id_str), Status::Routed)
1170 .await
1171 .unwrap();
1172
1173 let task = store.get(internal_id).await.unwrap();
1175 assert_eq!(task.status, TaskStatus::Routed);
1176 }
1177
1178 #[tokio::test]
1179 async fn status_to_task_status_maps_all_variants() {
1180 assert_eq!(status_to_task_status(Status::New), TaskStatus::New);
1181 assert_eq!(status_to_task_status(Status::Routed), TaskStatus::Routed);
1182 assert_eq!(
1183 status_to_task_status(Status::InProgress),
1184 TaskStatus::InProgress
1185 );
1186 assert_eq!(status_to_task_status(Status::Done), TaskStatus::Done);
1187 assert_eq!(status_to_task_status(Status::Blocked), TaskStatus::Blocked);
1188 assert_eq!(
1189 status_to_task_status(Status::InReview),
1190 TaskStatus::InReview
1191 );
1192 assert_eq!(
1193 status_to_task_status(Status::NeedsReview),
1194 TaskStatus::NeedsReview
1195 );
1196 }
1197
1198 #[test]
1199 fn is_internal_id_detects_prefix() {
1200 assert!(is_internal_id("internal:5"));
1201 assert!(is_internal_id("internal:0"));
1202 assert!(!is_internal_id("42"));
1203 assert!(!is_internal_id(""));
1204 assert!(!is_internal_id("internal"));
1205 }
1206
1207 #[test]
1208 fn parse_internal_id_extracts_number() {
1209 assert_eq!(parse_internal_id("internal:5"), Some(5));
1210 assert_eq!(parse_internal_id("internal:0"), Some(0));
1211 assert_eq!(parse_internal_id("internal:abc"), None);
1212 assert_eq!(parse_internal_id("42"), None);
1213 assert_eq!(parse_internal_id(""), None);
1214 }
1215
1216 #[tokio::test]
1217 async fn create_internal_task_via_task_manager() {
1218 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1219 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1220 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1221
1222 let task = tm
1223 .create_task(CreateTaskRequest {
1224 title: "Internal task".to_string(),
1225 body: "Do something".to_string(),
1226 task_type: TaskType::Internal,
1227 labels: vec![],
1228 source: "cron".to_string(),
1229 source_id: "job:daily".to_string(),
1230 })
1231 .await
1232 .unwrap();
1233
1234 match task {
1235 Task::Internal(t) => {
1236 assert_eq!(t.title, "Internal task");
1237 assert_eq!(t.origin, "internal");
1238 assert!(t
1239 .external_id
1240 .as_deref()
1241 .map(|s| s.starts_with("internal:"))
1242 .unwrap_or(false));
1243 }
1244 Task::External(_) => panic!("expected Internal variant"),
1245 }
1246 }
1247
1248 #[tokio::test]
1249 async fn create_internal_task_fails_without_store() {
1250 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1251 let tm = TaskManager::new(backend);
1252
1253 let result = tm
1254 .create_task(CreateTaskRequest {
1255 title: "No store".to_string(),
1256 body: "".to_string(),
1257 task_type: TaskType::Internal,
1258 labels: vec![],
1259 source: "manual".to_string(),
1260 source_id: "".to_string(),
1261 })
1262 .await;
1263
1264 assert!(result.is_err(), "should fail without store");
1265 }
1266
1267 #[tokio::test]
1270 async fn update_status_internal_task_not_found_returns_err_and_no_event() {
1271 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1272 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1273
1274 let (event_tx, mut event_rx) =
1275 tokio::sync::broadcast::channel::<crate::engine::events::TaskEvent>(16);
1276
1277 let tm =
1278 TaskManager::with_events(backend, store.clone(), "owner/repo".to_string(), event_tx);
1279
1280 let result = tm
1282 .update_task_status(&ExternalId("internal:999".to_string()), Status::Done)
1283 .await;
1284
1285 assert!(
1286 result.is_err(),
1287 "should return Err when internal task is not found in store"
1288 );
1289
1290 assert!(
1292 event_rx.try_recv().is_err(),
1293 "no event should be published for a not-found internal task"
1294 );
1295 }
1296
1297 #[tokio::test]
1303 async fn mark_task_done_by_store_id_succeeds_across_repos() {
1304 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1305 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1306
1307 let store_id = store
1308 .create_internal(
1309 "other/repo",
1310 "Cross-repo task",
1311 "body",
1312 "cron",
1313 "job:1",
1314 None,
1315 )
1316 .await
1317 .unwrap();
1318 let ext_id = ExternalId(format!("internal:{store_id}"));
1319
1320 let tm = TaskManager::with_store(backend, store.clone(), "active/repo".to_string());
1322
1323 let resolve_result = tm.update_task_status(&ext_id, Status::Done).await;
1325 assert!(
1326 resolve_result.is_err(),
1327 "repo-scoped update_task_status must fail for a cross-repo task"
1328 );
1329
1330 tm.mark_task_done_by_store_id(store_id, "other/repo", &ext_id)
1332 .await
1333 .unwrap();
1334
1335 let task = store.get(store_id).await.unwrap();
1336 assert_eq!(task.status, TaskStatus::Done);
1337 }
1338
1339 #[tokio::test]
1340 async fn get_task_returns_internal_from_store() {
1341 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1342 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1343 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1344
1345 let id = store
1346 .create_internal("owner/repo", "Fetch me", "body", "cron", "job:1", None)
1347 .await
1348 .unwrap();
1349
1350 let task = tm.get_task(id).await.unwrap();
1351 match task {
1352 Task::Internal(t) => assert_eq!(t.title, "Fetch me"),
1353 Task::External(_) => panic!("expected Internal variant"),
1354 }
1355 }
1356
1357 #[tokio::test]
1358 async fn list_routable_includes_internal_new_tasks() {
1359 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1360 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1361 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1362
1363 store
1365 .create_internal("owner/repo", "Route me", "", "cron", "job:2", None)
1366 .await
1367 .unwrap();
1368
1369 let routable = tm.list_routable().await.unwrap();
1370 assert_eq!(routable.len(), 1);
1371 assert!(routable[0].id.0.starts_with("internal:"));
1372 assert_eq!(routable[0].title, "Route me");
1373 }
1374
1375 #[tokio::test]
1376 async fn list_routable_excludes_non_new_internal_tasks() {
1377 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1378 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1379 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1380
1381 let id = store
1382 .create_internal("owner/repo", "Already routed", "", "cron", "job:3", None)
1383 .await
1384 .unwrap();
1385 store
1386 .update_status(id, crate::store::TaskStatus::InProgress)
1387 .await
1388 .unwrap();
1389
1390 let routable = tm.list_routable().await.unwrap();
1391 assert!(
1392 routable.is_empty(),
1393 "in_progress tasks should not be routable"
1394 );
1395 }
1396
1397 #[tokio::test]
1398 async fn list_internal_by_status_returns_external_tasks() {
1399 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1400 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1401 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1402
1403 store
1404 .create_internal("owner/repo", "Task A", "", "cron", "a", None)
1405 .await
1406 .unwrap();
1407 let id_b = store
1408 .create_internal("owner/repo", "Task B", "", "cron", "b", None)
1409 .await
1410 .unwrap();
1411 store
1412 .update_status(id_b, crate::store::TaskStatus::Done)
1413 .await
1414 .unwrap();
1415
1416 let new_tasks = tm
1417 .list_internal_by_status(crate::store::TaskStatus::New)
1418 .await
1419 .unwrap();
1420 assert_eq!(new_tasks.len(), 1);
1421 assert_eq!(new_tasks[0].title, "Task A");
1422 assert!(new_tasks[0].labels.contains(&"status:new".to_string()));
1424 }
1425
1426 #[tokio::test]
1427 async fn store_task_to_external_maps_fields_correctly() {
1428 let store = TaskStore::open_memory().await.unwrap();
1429 let id = store
1430 .create_internal(
1431 "owner/repo",
1432 "Convert me",
1433 "body text",
1434 "manual",
1435 "m:1",
1436 None,
1437 )
1438 .await
1439 .unwrap();
1440 let task = store.get(id).await.unwrap();
1441 let ext = store_task_to_external(&task);
1442
1443 assert!(ext.id.0.starts_with("internal:"));
1444 assert_eq!(ext.title, "Convert me");
1445 assert_eq!(ext.body, "body text");
1446 assert_eq!(ext.state, "open");
1447 assert!(ext.labels.contains(&"status:new".to_string()));
1448 assert_eq!(ext.author, "manual"); }
1450
1451 #[tokio::test]
1452 async fn list_all_internal_returns_store_tasks() {
1453 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1454 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1455 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1456
1457 store
1458 .create_internal("owner/repo", "T1", "", "cron", "1", None)
1459 .await
1460 .unwrap();
1461 store
1462 .create_internal("owner/repo", "T2", "", "cron", "2", None)
1463 .await
1464 .unwrap();
1465 store
1467 .create_internal("other/repo", "T3", "", "cron", "3", None)
1468 .await
1469 .unwrap();
1470
1471 let tasks = tm.list_all_internal().await.unwrap();
1472 assert_eq!(tasks.len(), 2);
1473 }
1474
1475 #[tokio::test]
1478 async fn list_external_by_status_reads_from_store() {
1479 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1480 let backend = Arc::new(MockBackend::new());
1481 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1482
1483 let id = store
1485 .upsert_external(&crate::store::UpsertExternal {
1486 repo: "owner/repo",
1487 ext_id: "42",
1488 title: "Store task",
1489 body: "",
1490 author: "",
1491 url: "",
1492 labels: &[],
1493 origin: "github",
1494 })
1495 .await
1496 .unwrap();
1497 store.update_status(id, TaskStatus::Routed).await.unwrap();
1498
1499 let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1500 assert_eq!(routed.len(), 1);
1501 assert_eq!(routed[0].id.0, "42");
1502 assert_eq!(routed[0].title, "Store task");
1503 }
1504
1505 #[tokio::test]
1506 async fn list_external_by_status_excludes_internal_tasks() {
1507 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1508 let backend = Arc::new(MockBackend::new());
1509 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1510
1511 let id = store
1513 .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1514 .await
1515 .unwrap();
1516 store.update_status(id, TaskStatus::Routed).await.unwrap();
1517
1518 store
1520 .upsert_external(&crate::store::UpsertExternal {
1521 repo: "owner/repo",
1522 ext_id: "99",
1523 title: "External",
1524 body: "",
1525 author: "",
1526 url: "",
1527 labels: &[],
1528 origin: "github",
1529 })
1530 .await
1531 .unwrap();
1532
1533 let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1534 assert_eq!(routed.len(), 0);
1536 }
1537
1538 #[tokio::test]
1539 async fn list_external_by_status_falls_back_when_store_has_only_internal_tasks() {
1540 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1541 let backend = Arc::new(MockBackend::new().with_status_tasks(
1542 Status::Routed,
1543 vec![ExternalTask {
1544 id: ExternalId("123".to_string()),
1545 title: "Backend routed".to_string(),
1546 body: "".to_string(),
1547 state: "open".to_string(),
1548 labels: vec!["status:routed".to_string()],
1549 author: "bot".to_string(),
1550 created_at: "2026-01-01T00:00:00Z".to_string(),
1551 updated_at: "2026-01-01T00:00:00Z".to_string(),
1552 url: "".to_string(),
1553 }],
1554 ));
1555 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1556
1557 let id = store
1558 .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1559 .await
1560 .unwrap();
1561 store.update_status(id, TaskStatus::Routed).await.unwrap();
1562
1563 let routed = tm.list_external_by_status(Status::Routed).await.unwrap();
1564 assert_eq!(routed.len(), 1);
1565 assert_eq!(routed[0].id.0, "123");
1566 }
1567
1568 #[tokio::test]
1569 async fn list_routable_reads_from_store_when_populated() {
1570 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1571 let backend = Arc::new(MockBackend::new());
1572 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1573
1574 store
1576 .upsert_external(&crate::store::UpsertExternal {
1577 repo: "owner/repo",
1578 ext_id: "10",
1579 title: "New external",
1580 body: "",
1581 author: "",
1582 url: "",
1583 labels: &[],
1584 origin: "github",
1585 })
1586 .await
1587 .unwrap();
1588 store
1590 .create_internal("owner/repo", "New internal", "", "cron", "1", None)
1591 .await
1592 .unwrap();
1593
1594 let routable = tm.list_routable().await.unwrap();
1595 assert_eq!(routable.len(), 2);
1597 }
1598
1599 #[tokio::test]
1600 async fn list_routable_falls_back_when_store_has_only_internal_tasks() {
1601 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1602 let backend = Arc::new(MockBackend::new().with_routable_tasks(vec![ExternalTask {
1603 id: ExternalId("55".to_string()),
1604 title: "Backend new".to_string(),
1605 body: "".to_string(),
1606 state: "open".to_string(),
1607 labels: vec!["status:new".to_string()],
1608 author: "bot".to_string(),
1609 created_at: "2026-01-01T00:00:00Z".to_string(),
1610 updated_at: "2026-01-01T00:00:00Z".to_string(),
1611 url: "".to_string(),
1612 }]));
1613 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1614
1615 store
1616 .create_internal("owner/repo", "Internal new", "", "cron", "1", None)
1617 .await
1618 .unwrap();
1619
1620 let routable = tm.list_routable().await.unwrap();
1621 assert_eq!(routable.len(), 2);
1622 assert!(routable.iter().any(|task| task.id.0 == "55"));
1623 assert!(routable
1624 .iter()
1625 .any(|task| task.id.0.starts_with("internal:")));
1626 }
1627
1628 #[tokio::test]
1629 async fn list_all_external_tasks_reads_from_store() {
1630 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1631 let backend = Arc::new(MockBackend::new());
1632 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1633
1634 let id1 = store
1636 .upsert_external(&crate::store::UpsertExternal {
1637 repo: "owner/repo",
1638 ext_id: "1",
1639 title: "Task 1",
1640 body: "",
1641 author: "",
1642 url: "",
1643 labels: &[],
1644 origin: "github",
1645 })
1646 .await
1647 .unwrap();
1648 let id2 = store
1649 .upsert_external(&crate::store::UpsertExternal {
1650 repo: "owner/repo",
1651 ext_id: "2",
1652 title: "Task 2",
1653 body: "",
1654 author: "",
1655 url: "",
1656 labels: &[],
1657 origin: "github",
1658 })
1659 .await
1660 .unwrap();
1661 store.update_status(id1, TaskStatus::Done).await.unwrap();
1662 store
1663 .update_status(id2, TaskStatus::InProgress)
1664 .await
1665 .unwrap();
1666
1667 store
1669 .create_internal("owner/repo", "Internal", "", "cron", "1", None)
1670 .await
1671 .unwrap();
1672
1673 let all = tm.list_all_external_tasks().await.unwrap();
1674 assert_eq!(all.len(), 2);
1675 }
1676
1677 #[tokio::test]
1678 async fn list_all_external_tasks_falls_back_when_store_has_only_internal_tasks() {
1679 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1680 let backend = Arc::new(MockBackend::new().with_status_tasks(
1681 Status::Done,
1682 vec![ExternalTask {
1683 id: ExternalId("200".to_string()),
1684 title: "Backend done".to_string(),
1685 body: "".to_string(),
1686 state: "closed".to_string(),
1687 labels: vec!["status:done".to_string()],
1688 author: "bot".to_string(),
1689 created_at: "2026-01-01T00:00:00Z".to_string(),
1690 updated_at: "2026-01-01T00:00:00Z".to_string(),
1691 url: "".to_string(),
1692 }],
1693 ));
1694 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1695
1696 store
1697 .create_internal("owner/repo", "Internal", "", "cron", "1", None)
1698 .await
1699 .unwrap();
1700
1701 let all = tm.list_all_external_tasks().await.unwrap();
1702 assert_eq!(all.len(), 1);
1703 assert_eq!(all[0].id.0, "200");
1704 }
1705
1706 #[tokio::test]
1707 async fn list_tasks_without_status_merges_backend_external_when_store_only_has_internal() {
1708 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1709 let backend = Arc::new(MockBackend::new().with_status_tasks(
1710 Status::New,
1711 vec![ExternalTask {
1712 id: ExternalId("301".to_string()),
1713 title: "Backend new".to_string(),
1714 body: "".to_string(),
1715 state: "open".to_string(),
1716 labels: vec!["status:new".to_string()],
1717 author: "bot".to_string(),
1718 created_at: "2026-01-01T00:00:00Z".to_string(),
1719 updated_at: "2026-01-01T00:00:00Z".to_string(),
1720 url: "".to_string(),
1721 }],
1722 ));
1723 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1724
1725 let internal_id = store
1726 .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1727 .await
1728 .unwrap();
1729
1730 let listed = tm.list_tasks(TaskFilter::default()).await.unwrap();
1731 assert_eq!(listed.len(), 2);
1732 assert!(listed.iter().any(|task| matches!(
1733 task,
1734 Task::External(external) if external.id.0 == "301"
1735 )));
1736 assert!(listed.iter().any(|task| matches!(
1737 task,
1738 Task::Internal(internal) if internal.id == internal_id
1739 )));
1740 }
1741
1742 #[tokio::test]
1743 async fn list_tasks_status_all_merges_backend_external_when_store_only_has_internal() {
1744 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1745 let backend = Arc::new(MockBackend::new().with_status_tasks(
1746 Status::Done,
1747 vec![ExternalTask {
1748 id: ExternalId("302".to_string()),
1749 title: "Backend done".to_string(),
1750 body: "".to_string(),
1751 state: "closed".to_string(),
1752 labels: vec!["status:done".to_string()],
1753 author: "bot".to_string(),
1754 created_at: "2026-01-01T00:00:00Z".to_string(),
1755 updated_at: "2026-01-01T00:00:00Z".to_string(),
1756 url: "".to_string(),
1757 }],
1758 ));
1759 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1760
1761 let internal_id = store
1762 .create_internal("owner/repo", "Internal task", "", "cron", "1", None)
1763 .await
1764 .unwrap();
1765
1766 let listed = tm
1767 .list_tasks(TaskFilter {
1768 status: Some("all".to_string()),
1769 source: None,
1770 })
1771 .await
1772 .unwrap();
1773 assert_eq!(listed.len(), 2);
1774 assert!(listed.iter().any(|task| matches!(
1775 task,
1776 Task::External(external) if external.id.0 == "302"
1777 )));
1778 assert!(listed.iter().any(|task| matches!(
1779 task,
1780 Task::Internal(internal) if internal.id == internal_id
1781 )));
1782 }
1783
1784 #[tokio::test]
1787 async fn update_task_status_writes_store_first_for_external() {
1788 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1789 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1790 let tm = TaskManager::with_store(backend.clone(), store.clone(), "owner/repo".to_string());
1791
1792 let store_id = store
1794 .upsert_external(&crate::store::UpsertExternal {
1795 repo: "owner/repo",
1796 ext_id: "42",
1797 title: "Test",
1798 body: "",
1799 author: "user",
1800 url: "",
1801 labels: &[],
1802 origin: "github",
1803 })
1804 .await
1805 .unwrap();
1806 assert_eq!(store.get(store_id).await.unwrap().status, TaskStatus::New);
1807
1808 let ext_id = ExternalId("42".to_string());
1810 tm.update_task_status(&ext_id, Status::Routed)
1811 .await
1812 .unwrap();
1813
1814 let task = store.get(store_id).await.unwrap();
1816 assert_eq!(task.status, TaskStatus::Routed);
1817 }
1818
1819 #[tokio::test]
1820 async fn update_task_status_handles_internal_tasks() {
1821 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1822 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1823 let tm = TaskManager::with_store(backend.clone(), store.clone(), "owner/repo".to_string());
1824
1825 let store_id = store
1827 .create_internal("owner/repo", "task", "body", "manual", "", None)
1828 .await
1829 .unwrap();
1830
1831 let internal_id = ExternalId(format!("internal:{}", store_id));
1832 tm.update_task_status(&internal_id, Status::InProgress)
1833 .await
1834 .unwrap();
1835
1836 let task = store.get(store_id).await.unwrap();
1837 assert_eq!(task.status, TaskStatus::InProgress);
1838 }
1839
1840 #[tokio::test]
1841 async fn update_task_status_publishes_event() {
1842 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1843 let (tx, mut rx) = tokio::sync::broadcast::channel::<crate::engine::events::TaskEvent>(16);
1844 let tm = TaskManager {
1845 backend,
1846 store: None,
1847 repo: "owner/repo".to_string(),
1848 event_tx: Some(tx),
1849 };
1850 let id = ExternalId("42".to_string());
1852 tm.update_task_status(&id, Status::Routed).await.unwrap();
1853 let event = rx.try_recv().unwrap();
1854 assert_eq!(event.task_id, "42");
1855 assert_eq!(event.new_status, "routed");
1856 assert_eq!(event.repo, "owner/repo");
1857 }
1858
1859 #[tokio::test]
1860 async fn event_includes_old_status_and_context() {
1861 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1862 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1863 let (tx, mut rx) = tokio::sync::broadcast::channel::<crate::engine::events::TaskEvent>(16);
1864
1865 let store_id = store
1867 .upsert_external(&crate::store::UpsertExternal {
1868 repo: "owner/repo",
1869 ext_id: "77",
1870 title: "Context test",
1871 body: "",
1872 author: "user",
1873 url: "",
1874 labels: &[],
1875 origin: "github",
1876 })
1877 .await
1878 .unwrap();
1879
1880 store
1882 .set_fields(
1883 store_id,
1884 &[
1885 ("agent", serde_json::Value::String("claude".to_string())),
1886 ("model", serde_json::Value::String("sonnet".to_string())),
1887 ("branch", serde_json::Value::String("feat-77".to_string())),
1888 ("pr_number", serde_json::Value::Number(42.into())),
1889 (
1890 "last_error",
1891 serde_json::Value::String("timeout".to_string()),
1892 ),
1893 ],
1894 )
1895 .await
1896 .unwrap();
1897
1898 let tm = TaskManager::with_events(backend, store.clone(), "owner/repo".to_string(), tx);
1899
1900 let ext_id = ExternalId("77".to_string());
1902 tm.update_task_status(&ext_id, Status::Routed)
1903 .await
1904 .unwrap();
1905
1906 let event = rx.try_recv().unwrap();
1907 assert_eq!(event.task_id, "77");
1908 assert_eq!(event.old_status, "new");
1909 assert_eq!(event.new_status, "routed");
1910 assert_eq!(event.agent.as_deref(), Some("claude"));
1911 assert_eq!(event.model.as_deref(), Some("sonnet"));
1912 assert_eq!(event.branch.as_deref(), Some("feat-77"));
1913 assert_eq!(event.pr_number.as_deref(), Some("42"));
1914 assert_eq!(event.error.as_deref(), Some("timeout"));
1915 }
1916
1917 #[tokio::test]
1918 async fn event_includes_old_status_for_internal_task() {
1919 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1920 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1921 let (tx, mut rx) = tokio::sync::broadcast::channel::<crate::engine::events::TaskEvent>(16);
1922
1923 let store_id = store
1925 .create_internal(
1926 "owner/repo",
1927 "internal ctx test",
1928 "body",
1929 "manual",
1930 "",
1931 None,
1932 )
1933 .await
1934 .unwrap();
1935
1936 let tm = TaskManager::with_events(backend, store.clone(), "owner/repo".to_string(), tx);
1937
1938 let internal_id = ExternalId(format!("internal:{}", store_id));
1940 tm.update_task_status(&internal_id, Status::Routed)
1941 .await
1942 .unwrap();
1943
1944 let event = rx.try_recv().unwrap();
1945 assert_eq!(event.old_status, "new");
1946 assert_eq!(event.new_status, "routed");
1947
1948 tm.update_task_status(&internal_id, Status::InProgress)
1950 .await
1951 .unwrap();
1952
1953 let event2 = rx.try_recv().unwrap();
1954 assert_eq!(event2.old_status, "routed");
1955 assert_eq!(event2.new_status, "in_progress");
1956 }
1957
1958 #[tokio::test]
1961 async fn update_task_status_if_applies_when_status_matches() {
1962 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1963 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
1964 let tm = TaskManager::with_store(backend, store.clone(), "owner/repo".to_string());
1965
1966 let store_id = store
1967 .upsert_external(&crate::store::UpsertExternal {
1968 repo: "owner/repo",
1969 ext_id: "10",
1970 title: "T",
1971 body: "",
1972 author: "u",
1973 url: "",
1974 labels: &[],
1975 origin: "github",
1976 })
1977 .await
1978 .unwrap();
1979 store
1981 .update_status(store_id, crate::store::TaskStatus::InReview)
1982 .await
1983 .unwrap();
1984
1985 let id = ExternalId("10".to_string());
1986 let updated = tm
1987 .update_task_status_if(&id, Status::NeedsReview, Status::InReview)
1988 .await
1989 .unwrap();
1990 assert!(updated, "should update when expected status matches");
1991
1992 let task = store.get(store_id).await.unwrap();
1993 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
1994 }
1995
1996 #[tokio::test]
1997 async fn update_task_status_if_is_noop_when_status_mismatches() {
1998 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1999 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
2000 let (tx, mut rx) = tokio::sync::broadcast::channel::<crate::engine::events::TaskEvent>(16);
2001 let tm = TaskManager::with_events(backend, store.clone(), "owner/repo".to_string(), tx);
2002
2003 let store_id = store
2004 .upsert_external(&crate::store::UpsertExternal {
2005 repo: "owner/repo",
2006 ext_id: "11",
2007 title: "T",
2008 body: "",
2009 author: "u",
2010 url: "",
2011 labels: &[],
2012 origin: "github",
2013 })
2014 .await
2015 .unwrap();
2016 store
2018 .update_status(store_id, crate::store::TaskStatus::Done)
2019 .await
2020 .unwrap();
2021
2022 let id = ExternalId("11".to_string());
2023 let updated = tm
2025 .update_task_status_if(&id, Status::NeedsReview, Status::InReview)
2026 .await
2027 .unwrap();
2028 assert!(
2029 !updated,
2030 "should not update when status has already changed"
2031 );
2032
2033 let task = store.get(store_id).await.unwrap();
2035 assert_eq!(task.status, crate::store::TaskStatus::Done);
2036
2037 assert!(
2039 rx.try_recv().is_err(),
2040 "no event should be published on a no-op update"
2041 );
2042 }
2043}