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