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