1use std::path::Path;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::sync::Arc;
4
5use axum::body::Bytes;
6use axum::extract::{Path as AxumPath, Query, State};
7use axum::http::{StatusCode, Uri};
8use axum::middleware::{from_fn_with_state, Next};
9use axum::response::{IntoResponse, Response};
10use axum::routing::{get, patch, post, put};
11use axum::{extract::Request, response::Response as AxumResponse};
12use axum::{Json, Router};
13use serde::{Deserialize, Serialize};
14use tower_http::services::{ServeDir, ServeFile};
15use track_core::backend_config::RemoteAgentConfigService;
16use track_core::config::{
17 RemoteAgentConfigFile, RemoteAgentReviewFollowUpConfigFile, DEFAULT_REMOTE_AGENT_PORT,
18 DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT, DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH,
19};
20use track_core::dispatch_repository::DispatchRepository;
21use track_core::errors::{ErrorCode, TrackError};
22use track_core::migration::{MigrationImportSummary, MigrationStatus};
23use track_core::migration_service::MigrationService;
24use track_core::project_repository::{
25 ProjectMetadataUpdateInput, ProjectRecord, ProjectRepository, ProjectUpsertInput,
26};
27use track_core::remote_agent::{RemoteDispatchService, RemoteReviewService};
28use track_core::review_dispatch_repository::ReviewDispatchRepository;
29use track_core::review_repository::ReviewRepository;
30use track_core::task_repository::FileTaskRepository;
31use track_core::task_sort::sort_tasks;
32use track_core::time_utils::now_utc;
33use track_core::types::{
34 CreateReviewInput, RemoteAgentPreferredTool, RemoteCleanupSummary, RemoteResetSummary,
35 ReviewRecord, ReviewRunRecord, Task, TaskCreateInput, TaskDispatchRecord, TaskSource,
36 TaskUpdateInput,
37};
38
39#[derive(Clone)]
40pub struct AppState {
41 pub config_service: Arc<RemoteAgentConfigService>,
42 pub dispatch_repository: Arc<DispatchRepository>,
43 pub migration_service: Arc<MigrationService>,
44 pub project_repository: Arc<ProjectRepository>,
45 pub review_dispatch_repository: Arc<ReviewDispatchRepository>,
46 pub review_repository: Arc<ReviewRepository>,
47 pub task_repository: Arc<FileTaskRepository>,
48 pub task_change_version: Arc<AtomicU64>,
49}
50
51#[derive(Debug, Serialize)]
52struct ApiErrorBody {
53 error: ApiErrorPayload,
54}
55
56#[derive(Debug, Serialize)]
57struct ApiErrorPayload {
58 code: String,
59 message: String,
60}
61
62#[derive(Debug)]
63pub struct ApiError {
64 status: StatusCode,
65 code: String,
66 message: String,
67}
68
69impl ApiError {
70 pub fn from_track_error(error: TrackError) -> Self {
71 let status = match error.code {
72 ErrorCode::TaskNotFound => StatusCode::NOT_FOUND,
73 ErrorCode::InvalidJson
74 | ErrorCode::InvalidPathComponent
75 | ErrorCode::InvalidProjectMetadata
76 | ErrorCode::InvalidRemoteAgentConfig
77 | ErrorCode::InvalidTaskUpdate
78 | ErrorCode::MigrationRequired
79 | ErrorCode::ConfigNotFound
80 | ErrorCode::InvalidConfig
81 | ErrorCode::InvalidConfigInput
82 | ErrorCode::NoProjectRoots
83 | ErrorCode::NoProjectsDiscovered
84 | ErrorCode::InvalidProjectSelection
85 | ErrorCode::AiParseFailed
86 | ErrorCode::EmptyInput
87 | ErrorCode::InteractiveRequired
88 | ErrorCode::DispatchWriteFailed
89 | ErrorCode::RemoteAgentNotConfigured
90 | ErrorCode::ProjectWriteFailed
91 | ErrorCode::TaskWriteFailed => StatusCode::BAD_REQUEST,
92 ErrorCode::MigrationFailed => StatusCode::INTERNAL_SERVER_ERROR,
93 ErrorCode::ProjectNotFound | ErrorCode::DispatchNotFound => StatusCode::NOT_FOUND,
94 ErrorCode::RemoteDispatchFailed => StatusCode::BAD_GATEWAY,
95 };
96
97 Self {
98 status,
99 code: error.code.to_string(),
100 message: error.to_string(),
101 }
102 }
103
104 pub fn invalid_json(message: &str) -> Self {
105 Self {
106 status: StatusCode::BAD_REQUEST,
107 code: ErrorCode::InvalidJson.to_string(),
108 message: message.to_owned(),
109 }
110 }
111
112 pub fn internal(message: impl Into<String>) -> Self {
113 Self {
114 status: StatusCode::INTERNAL_SERVER_ERROR,
115 code: "INTERNAL_ERROR".to_owned(),
116 message: message.into(),
117 }
118 }
119}
120
121impl IntoResponse for ApiError {
122 fn into_response(self) -> Response {
123 (
124 self.status,
125 Json(ApiErrorBody {
126 error: ApiErrorPayload {
127 code: self.code,
128 message: self.message,
129 },
130 }),
131 )
132 .into_response()
133 }
134}
135
136#[derive(Debug, Serialize)]
137struct HealthResponse {
138 ok: bool,
139}
140
141#[derive(Debug, Serialize)]
142struct ProjectsResponse {
143 projects: Vec<ProjectRecord>,
144}
145
146#[derive(Debug, Serialize)]
147struct TasksResponse {
148 tasks: Vec<Task>,
149}
150
151#[derive(Debug, Serialize)]
152struct DeleteTaskResponse {
153 ok: bool,
154}
155
156#[derive(Debug, Serialize)]
157struct DispatchesResponse {
158 dispatches: Vec<TaskDispatchRecord>,
159}
160
161#[derive(Debug, Serialize)]
162struct RunRecordResponse {
163 task: Task,
164 dispatch: TaskDispatchRecord,
165}
166
167#[derive(Debug, Serialize)]
168struct RunsResponse {
169 runs: Vec<RunRecordResponse>,
170}
171
172#[derive(Debug, Serialize)]
173struct ReviewSummaryResponse {
174 review: ReviewRecord,
175 #[serde(rename = "latestRun", skip_serializing_if = "Option::is_none")]
176 latest_run: Option<ReviewRunRecord>,
177}
178
179#[derive(Debug, Serialize)]
180struct ReviewsResponse {
181 reviews: Vec<ReviewSummaryResponse>,
182}
183
184#[derive(Debug, Serialize)]
185struct ReviewRunsResponse {
186 runs: Vec<ReviewRunRecord>,
187}
188
189#[derive(Debug, Serialize)]
190struct CreateReviewResponse {
191 review: ReviewRecord,
192 run: ReviewRunRecord,
193}
194
195#[derive(Debug, Serialize)]
196struct RemoteAgentSettingsResponse {
197 configured: bool,
198 #[serde(rename = "preferredTool")]
199 preferred_tool: RemoteAgentPreferredTool,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 host: Option<String>,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 user: Option<String>,
204 #[serde(skip_serializing_if = "Option::is_none")]
205 port: Option<u16>,
206 #[serde(rename = "shellPrelude", skip_serializing_if = "Option::is_none")]
207 shell_prelude: Option<String>,
208 #[serde(rename = "reviewFollowUp", skip_serializing_if = "Option::is_none")]
209 review_follow_up: Option<RemoteAgentReviewFollowUpSettingsResponse>,
210}
211
212#[derive(Debug, Serialize, Deserialize)]
213struct RemoteAgentReviewFollowUpSettingsResponse {
214 enabled: bool,
215 #[serde(rename = "mainUser", skip_serializing_if = "Option::is_none")]
216 main_user: Option<String>,
217 #[serde(
218 rename = "defaultReviewPrompt",
219 skip_serializing_if = "Option::is_none"
220 )]
221 default_review_prompt: Option<String>,
222}
223
224#[derive(Debug, Serialize)]
225struct RemoteCleanupResponse {
226 summary: RemoteCleanupSummary,
227}
228
229#[derive(Debug, Serialize)]
230struct RemoteResetResponse {
231 summary: RemoteResetSummary,
232}
233
234#[derive(Debug, Serialize)]
235struct TaskChangeVersionResponse {
236 version: u64,
237}
238
239#[derive(Debug, Serialize)]
240struct MigrationStatusResponse {
241 migration: MigrationStatus,
242}
243
244#[derive(Debug, Serialize)]
245struct MigrationImportResponse {
246 summary: MigrationImportSummary,
247}
248
249#[derive(Debug, Deserialize)]
250struct TaskListQuery {
251 #[serde(rename = "includeClosed")]
252 include_closed: Option<bool>,
253 project: Option<String>,
254}
255
256#[derive(Debug, Deserialize)]
257struct RunsQuery {
258 limit: Option<usize>,
259}
260
261#[derive(Debug, Deserialize)]
262struct UpdateRemoteAgentSettingsInput {
263 #[serde(rename = "preferredTool", default)]
264 preferred_tool: Option<RemoteAgentPreferredTool>,
265 #[serde(rename = "shellPrelude")]
266 shell_prelude: String,
267 #[serde(rename = "reviewFollowUp")]
268 review_follow_up: Option<RemoteAgentReviewFollowUpSettingsResponse>,
269}
270
271#[derive(Debug, Deserialize)]
272struct PutRemoteAgentInput {
273 host: String,
274 user: String,
275 #[serde(default = "default_remote_agent_port")]
276 port: u16,
277 #[serde(
278 rename = "workspaceRoot",
279 default = "default_remote_agent_workspace_root"
280 )]
281 workspace_root: String,
282 #[serde(
283 rename = "projectsRegistryPath",
284 default = "default_remote_projects_registry_path"
285 )]
286 projects_registry_path: String,
287 #[serde(rename = "preferredTool", default)]
288 preferred_tool: RemoteAgentPreferredTool,
289 #[serde(rename = "shellPrelude")]
290 shell_prelude: Option<String>,
291 #[serde(rename = "reviewFollowUp")]
292 review_follow_up: Option<RemoteAgentReviewFollowUpSettingsResponse>,
293 #[serde(rename = "sshPrivateKey")]
294 ssh_private_key: String,
295 #[serde(rename = "knownHosts")]
296 known_hosts: Option<String>,
297}
298
299#[derive(Debug, Deserialize)]
300struct PutProjectInput {
301 #[serde(default)]
302 aliases: Vec<String>,
303 #[serde(flatten)]
304 metadata: ProjectMetadataUpdateInput,
305}
306
307#[derive(Debug, Deserialize)]
308struct FollowUpRequestInput {
309 request: String,
310}
311
312#[derive(Debug, Default, Deserialize)]
313struct DispatchTaskInput {
314 #[serde(rename = "preferredTool", default)]
315 preferred_tool: Option<RemoteAgentPreferredTool>,
316}
317
318fn remote_agent_settings_response(
319 remote_agent: Option<RemoteAgentConfigFile>,
320) -> RemoteAgentSettingsResponse {
321 match remote_agent {
322 Some(remote_agent) => RemoteAgentSettingsResponse {
323 configured: true,
324 preferred_tool: remote_agent.preferred_tool,
325 host: Some(remote_agent.host),
326 user: Some(remote_agent.user),
327 port: Some(remote_agent.port),
328 shell_prelude: remote_agent.shell_prelude,
329 review_follow_up: Some(
330 remote_agent
331 .review_follow_up
332 .map(
333 |review_follow_up| RemoteAgentReviewFollowUpSettingsResponse {
334 enabled: review_follow_up.enabled,
335 main_user: review_follow_up.main_user,
336 default_review_prompt: review_follow_up.default_review_prompt,
337 },
338 )
339 .unwrap_or(RemoteAgentReviewFollowUpSettingsResponse {
340 enabled: false,
341 main_user: None,
342 default_review_prompt: None,
343 }),
344 ),
345 },
346 None => RemoteAgentSettingsResponse {
347 configured: false,
348 preferred_tool: RemoteAgentPreferredTool::Codex,
349 host: None,
350 user: None,
351 port: None,
352 shell_prelude: None,
353 review_follow_up: None,
354 },
355 }
356}
357
358fn default_remote_agent_port() -> u16 {
359 DEFAULT_REMOTE_AGENT_PORT
360}
361
362fn default_remote_agent_workspace_root() -> String {
363 DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT.to_owned()
364}
365
366fn default_remote_projects_registry_path() -> String {
367 DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH.to_owned()
368}
369
370async fn health() -> Json<HealthResponse> {
371 Json(HealthResponse { ok: true })
372}
373
374async fn list_projects(State(state): State<AppState>) -> Result<Json<ProjectsResponse>, ApiError> {
375 let projects = state
376 .project_repository
377 .list_projects()
378 .map_err(ApiError::from_track_error)?;
379
380 Ok(Json(ProjectsResponse { projects }))
381}
382
383async fn migration_status(
384 State(state): State<AppState>,
385) -> Result<Json<MigrationStatusResponse>, ApiError> {
386 let migration = state
387 .migration_service
388 .status()
389 .map_err(ApiError::from_track_error)?;
390
391 Ok(Json(MigrationStatusResponse { migration }))
392}
393
394async fn import_legacy_data(
395 State(state): State<AppState>,
396) -> Result<Json<MigrationImportResponse>, ApiError> {
397 let summary = state
398 .migration_service
399 .import_legacy()
400 .map_err(ApiError::from_track_error)?;
401 bump_task_change_version(&state);
402
403 Ok(Json(MigrationImportResponse { summary }))
404}
405
406async fn get_remote_agent_settings(
407 State(state): State<AppState>,
408) -> Result<Json<RemoteAgentSettingsResponse>, ApiError> {
409 let remote_agent = state
410 .config_service
411 .load_remote_agent_config()
412 .map_err(ApiError::from_track_error)?;
413
414 Ok(Json(remote_agent_settings_response(remote_agent)))
415}
416
417async fn patch_remote_agent_settings(
418 State(state): State<AppState>,
419 body: Bytes,
420) -> Result<Json<RemoteAgentSettingsResponse>, ApiError> {
421 let input = serde_json::from_slice::<UpdateRemoteAgentSettingsInput>(&body)
422 .map_err(|_| ApiError::invalid_json("Request body is not valid JSON."))?;
423 let existing_remote_agent = state
424 .config_service
425 .load_remote_agent_config()
426 .map_err(ApiError::from_track_error)?
427 .ok_or_else(|| ApiError::from_track_error(TrackError::new(
428 ErrorCode::RemoteAgentNotConfigured,
429 "Remote dispatch is not configured yet. Run `track remote-agent configure ...` locally to register the remote host and SSH key first.",
430 )))?;
431
432 let remote_agent = state
433 .config_service
434 .save_remote_agent_settings(
435 input
436 .preferred_tool
437 .unwrap_or(existing_remote_agent.preferred_tool),
438 Some(input.shell_prelude),
439 input
440 .review_follow_up
441 .map(|review_follow_up| RemoteAgentReviewFollowUpConfigFile {
442 enabled: review_follow_up.enabled,
443 main_user: review_follow_up.main_user,
444 default_review_prompt: review_follow_up.default_review_prompt,
445 })
446 .or(existing_remote_agent.review_follow_up),
447 )
448 .map_err(ApiError::from_track_error)?;
449
450 Ok(Json(remote_agent_settings_response(Some(remote_agent))))
451}
452
453async fn put_remote_agent_settings(
454 State(state): State<AppState>,
455 body: Bytes,
456) -> Result<Json<RemoteAgentSettingsResponse>, ApiError> {
457 let input = serde_json::from_slice::<PutRemoteAgentInput>(&body)
458 .map_err(|_| ApiError::invalid_json("Request body is not valid JSON."))?;
459
460 let remote_agent = state
461 .config_service
462 .replace_remote_agent_config(
463 RemoteAgentConfigFile {
464 host: input.host,
465 user: input.user,
466 port: input.port,
467 workspace_root: input.workspace_root,
468 projects_registry_path: input.projects_registry_path,
469 preferred_tool: input.preferred_tool,
470 shell_prelude: input.shell_prelude,
471 review_follow_up: input.review_follow_up.map(|review_follow_up| {
472 RemoteAgentReviewFollowUpConfigFile {
473 enabled: review_follow_up.enabled,
474 main_user: review_follow_up.main_user,
475 default_review_prompt: review_follow_up.default_review_prompt,
476 }
477 }),
478 },
479 &input.ssh_private_key,
480 input.known_hosts.as_deref(),
481 )
482 .map_err(ApiError::from_track_error)?;
483
484 Ok(Json(remote_agent_settings_response(Some(remote_agent))))
485}
486
487async fn cleanup_remote_agent_artifacts(
488 State(state): State<AppState>,
489) -> Result<Json<RemoteCleanupResponse>, ApiError> {
490 let cleanup_state = state.clone();
491 let summary = tokio::task::spawn_blocking(move || {
492 let dispatch_service = RemoteDispatchService {
493 config_service: &cleanup_state.config_service,
494 dispatch_repository: &cleanup_state.dispatch_repository,
495 project_repository: &cleanup_state.project_repository,
496 task_repository: &cleanup_state.task_repository,
497 review_repository: &cleanup_state.review_repository,
498 review_dispatch_repository: &cleanup_state.review_dispatch_repository,
499 };
500
501 dispatch_service.cleanup_unused_remote_artifacts()
502 })
503 .await
504 .map_err(|error| ApiError::internal(format!("Remote cleanup task failed to join: {error}")))?
505 .map_err(ApiError::from_track_error)?;
506
507 Ok(Json(RemoteCleanupResponse { summary }))
508}
509
510async fn reset_remote_agent_workspace(
511 State(state): State<AppState>,
512) -> Result<Json<RemoteResetResponse>, ApiError> {
513 let reset_state = state.clone();
514 let summary = tokio::task::spawn_blocking(move || {
515 let dispatch_service = RemoteDispatchService {
516 config_service: &reset_state.config_service,
517 dispatch_repository: &reset_state.dispatch_repository,
518 project_repository: &reset_state.project_repository,
519 task_repository: &reset_state.task_repository,
520 review_repository: &reset_state.review_repository,
521 review_dispatch_repository: &reset_state.review_dispatch_repository,
522 };
523
524 dispatch_service.reset_remote_workspace()
525 })
526 .await
527 .map_err(|error| ApiError::internal(format!("Remote reset task failed to join: {error}")))?
528 .map_err(ApiError::from_track_error)?;
529
530 Ok(Json(RemoteResetResponse { summary }))
531}
532
533async fn patch_project(
534 State(state): State<AppState>,
535 AxumPath(canonical_name): AxumPath<String>,
536 body: Bytes,
537) -> Result<Json<ProjectRecord>, ApiError> {
538 let input = serde_json::from_slice::<ProjectMetadataUpdateInput>(&body)
539 .map_err(|_| ApiError::invalid_json("Request body is not valid JSON."))?;
540
541 let project = state
542 .project_repository
543 .update_project_by_name(
544 &canonical_name,
545 input.validate().map_err(ApiError::from_track_error)?,
546 )
547 .map_err(ApiError::from_track_error)?;
548
549 Ok(Json(project))
550}
551
552async fn put_project(
553 State(state): State<AppState>,
554 AxumPath(canonical_name): AxumPath<String>,
555 body: Bytes,
556) -> Result<Json<ProjectRecord>, ApiError> {
557 let input = serde_json::from_slice::<PutProjectInput>(&body)
558 .map_err(|_| ApiError::invalid_json("Request body is not valid JSON."))?;
559
560 let project = state
561 .project_repository
562 .upsert_project(ProjectUpsertInput {
563 canonical_name,
564 aliases: input.aliases,
565 metadata: input.metadata,
566 })
567 .map_err(ApiError::from_track_error)?;
568
569 Ok(Json(project))
570}
571
572async fn list_tasks(
573 State(state): State<AppState>,
574 Query(query): Query<TaskListQuery>,
575) -> Result<Json<TasksResponse>, ApiError> {
576 let tasks = state
577 .task_repository
578 .list_tasks(
579 query.include_closed.unwrap_or(false),
580 query.project.as_deref(),
581 )
582 .map_err(ApiError::from_track_error)?;
583
584 Ok(Json(TasksResponse {
585 tasks: sort_tasks(&tasks),
586 }))
587}
588
589async fn list_dispatches(
590 State(state): State<AppState>,
591 uri: Uri,
592) -> Result<Json<DispatchesResponse>, ApiError> {
593 let state = state.clone();
594 let task_ids = parse_dispatch_task_ids(uri.query());
595 let dispatches = tokio::task::spawn_blocking(move || {
596 let dispatch_service = RemoteDispatchService {
597 config_service: &state.config_service,
598 dispatch_repository: &state.dispatch_repository,
599 project_repository: &state.project_repository,
600 task_repository: &state.task_repository,
601 review_repository: &state.review_repository,
602 review_dispatch_repository: &state.review_dispatch_repository,
603 };
604
605 dispatch_service.latest_dispatches_for_tasks(&task_ids)
606 })
607 .await
608 .map_err(|error| ApiError::internal(format!("Dispatch refresh task failed to join: {error}")))?
609 .map_err(ApiError::from_track_error)?;
610
611 Ok(Json(DispatchesResponse { dispatches }))
612}
613
614async fn list_runs(
615 State(state): State<AppState>,
616 Query(query): Query<RunsQuery>,
617) -> Result<Json<RunsResponse>, ApiError> {
618 let state = state.clone();
619 let limit = query.limit;
620 let runs = tokio::task::spawn_blocking(move || {
621 let dispatch_service = RemoteDispatchService {
622 config_service: &state.config_service,
623 dispatch_repository: &state.dispatch_repository,
624 project_repository: &state.project_repository,
625 task_repository: &state.task_repository,
626 review_repository: &state.review_repository,
627 review_dispatch_repository: &state.review_dispatch_repository,
628 };
629
630 let dispatches = dispatch_service.list_dispatches(limit)?;
631 let mut runs = Vec::new();
632 for dispatch in dispatches {
633 let task = match state.task_repository.get_task(&dispatch.task_id) {
634 Ok(task) => task,
635 Err(error) if error.code == ErrorCode::TaskNotFound => continue,
639 Err(error) => return Err(error),
640 };
641 runs.push(RunRecordResponse { task, dispatch });
642 }
643
644 Ok::<Vec<RunRecordResponse>, TrackError>(runs)
645 })
646 .await
647 .map_err(|error| ApiError::internal(format!("Runs refresh task failed to join: {error}")))?
648 .map_err(ApiError::from_track_error)?;
649
650 Ok(Json(RunsResponse { runs }))
651}
652
653async fn list_task_runs(
654 State(state): State<AppState>,
655 AxumPath(id): AxumPath<String>,
656) -> Result<Json<RunsResponse>, ApiError> {
657 let state = state.clone();
658 let task_id = id.clone();
659 let runs = tokio::task::spawn_blocking(move || {
660 let dispatch_service = RemoteDispatchService {
661 config_service: &state.config_service,
662 dispatch_repository: &state.dispatch_repository,
663 project_repository: &state.project_repository,
664 task_repository: &state.task_repository,
665 review_repository: &state.review_repository,
666 review_dispatch_repository: &state.review_dispatch_repository,
667 };
668
669 let task = state.task_repository.get_task(&task_id)?;
670 let dispatches = dispatch_service.dispatch_history_for_task(&task_id)?;
671
672 Ok::<Vec<RunRecordResponse>, TrackError>(
673 dispatches
674 .into_iter()
675 .map(|dispatch| RunRecordResponse {
676 task: task.clone(),
677 dispatch,
678 })
679 .collect(),
680 )
681 })
682 .await
683 .map_err(|error| ApiError::internal(format!("Task runs refresh failed to join: {error}")))?
684 .map_err(ApiError::from_track_error)?;
685
686 Ok(Json(RunsResponse { runs }))
687}
688
689async fn list_reviews(State(state): State<AppState>) -> Result<Json<ReviewsResponse>, ApiError> {
690 let state = state.clone();
691 let reviews = tokio::task::spawn_blocking(move || {
692 let review_service = RemoteReviewService {
693 config_service: &state.config_service,
694 project_repository: &state.project_repository,
695 review_repository: &state.review_repository,
696 review_dispatch_repository: &state.review_dispatch_repository,
697 };
698
699 let reviews = state.review_repository.list_reviews()?;
700 let review_ids = reviews
701 .iter()
702 .map(|review| review.id.clone())
703 .collect::<Vec<_>>();
704 let latest_runs = review_service.latest_dispatches_for_reviews(&review_ids)?;
705 let latest_runs_by_review_id = latest_runs
706 .into_iter()
707 .map(|run| (run.review_id.clone(), run))
708 .collect::<std::collections::BTreeMap<_, _>>();
709
710 Ok::<Vec<ReviewSummaryResponse>, TrackError>(
711 reviews
712 .into_iter()
713 .map(|review| ReviewSummaryResponse {
714 latest_run: latest_runs_by_review_id.get(&review.id).cloned(),
715 review,
716 })
717 .collect(),
718 )
719 })
720 .await
721 .map_err(|error| ApiError::internal(format!("Review list refresh failed to join: {error}")))?
722 .map_err(ApiError::from_track_error)?;
723
724 Ok(Json(ReviewsResponse { reviews }))
725}
726
727async fn list_review_runs(
728 State(state): State<AppState>,
729 AxumPath(id): AxumPath<String>,
730) -> Result<Json<ReviewRunsResponse>, ApiError> {
731 let state = state.clone();
732 let review_id = id.clone();
733 let runs = tokio::task::spawn_blocking(move || {
734 let review_service = RemoteReviewService {
735 config_service: &state.config_service,
736 project_repository: &state.project_repository,
737 review_repository: &state.review_repository,
738 review_dispatch_repository: &state.review_dispatch_repository,
739 };
740
741 review_service.dispatch_history_for_review(&review_id)
742 })
743 .await
744 .map_err(|error| ApiError::internal(format!("Review runs refresh failed to join: {error}")))?
745 .map_err(ApiError::from_track_error)?;
746
747 Ok(Json(ReviewRunsResponse { runs }))
748}
749
750fn parse_dispatch_task_ids(raw_query: Option<&str>) -> Vec<String> {
764 let Some(raw_query) = raw_query else {
765 return Vec::new();
766 };
767
768 raw_query
769 .split('&')
770 .filter_map(|pair| {
771 let (key, value) = pair.split_once('=')?;
772 if key != "taskId" || value.is_empty() {
773 return None;
774 }
775
776 Some(value.to_owned())
777 })
778 .collect()
779}
780
781fn bump_task_change_version(state: &AppState) -> u64 {
782 state.task_change_version.fetch_add(1, Ordering::SeqCst) + 1
783}
784
785async fn get_task_change_version(State(state): State<AppState>) -> Json<TaskChangeVersionResponse> {
786 Json(TaskChangeVersionResponse {
787 version: state.task_change_version.load(Ordering::SeqCst),
788 })
789}
790
791async fn notify_task_change(State(state): State<AppState>) -> Json<TaskChangeVersionResponse> {
792 Json(TaskChangeVersionResponse {
793 version: bump_task_change_version(&state),
794 })
795}
796
797async fn patch_task(
798 State(state): State<AppState>,
799 AxumPath(id): AxumPath<String>,
800 body: Bytes,
801) -> Result<Json<Task>, ApiError> {
802 let input = serde_json::from_slice::<TaskUpdateInput>(&body)
803 .map_err(|_| ApiError::invalid_json("Request body is not valid JSON."))?;
804 let validated_input = input.validate().map_err(ApiError::from_track_error)?;
805
806 let patch_state = state.clone();
807 let task_id = id.clone();
808 let updated_task = tokio::task::spawn_blocking(move || {
809 let dispatch_service = RemoteDispatchService {
810 config_service: &patch_state.config_service,
811 dispatch_repository: &patch_state.dispatch_repository,
812 project_repository: &patch_state.project_repository,
813 task_repository: &patch_state.task_repository,
814 review_repository: &patch_state.review_repository,
815 review_dispatch_repository: &patch_state.review_dispatch_repository,
816 };
817
818 dispatch_service.update_task(&task_id, validated_input)
819 })
820 .await
821 .map_err(|error| ApiError::internal(format!("Patch task failed to join: {error}")))?
822 .map_err(ApiError::from_track_error)?;
823 bump_task_change_version(&state);
824
825 Ok(Json(updated_task))
826}
827
828async fn create_task(State(state): State<AppState>, body: Bytes) -> Result<Json<Task>, ApiError> {
829 let input = serde_json::from_slice::<TaskCreateInput>(&body)
830 .map_err(|_| ApiError::invalid_json("Request body is not valid JSON."))?;
831 let TaskCreateInput {
832 project,
833 priority,
834 description,
835 source,
836 } = input;
837 let validated_input = TaskCreateInput {
838 project,
839 priority,
840 description,
841 source: source.or(Some(TaskSource::Web)),
842 }
843 .validate()
844 .map_err(ApiError::from_track_error)?;
845
846 let created_task = state
847 .task_repository
848 .create_task(validated_input)
849 .map_err(ApiError::from_track_error)?;
850 bump_task_change_version(&state);
851
852 Ok(Json(created_task.task))
853}
854
855async fn delete_task(
856 State(state): State<AppState>,
857 AxumPath(id): AxumPath<String>,
858) -> Result<Json<DeleteTaskResponse>, ApiError> {
859 let delete_state = state.clone();
860 let task_id = id.clone();
861 tokio::task::spawn_blocking(move || {
862 let dispatch_service = RemoteDispatchService {
863 config_service: &delete_state.config_service,
864 dispatch_repository: &delete_state.dispatch_repository,
865 project_repository: &delete_state.project_repository,
866 task_repository: &delete_state.task_repository,
867 review_repository: &delete_state.review_repository,
868 review_dispatch_repository: &delete_state.review_dispatch_repository,
869 };
870
871 dispatch_service.delete_task(&task_id)
872 })
873 .await
874 .map_err(|error| ApiError::internal(format!("Delete task failed to join: {error}")))?
875 .map_err(ApiError::from_track_error)?;
876 bump_task_change_version(&state);
877
878 Ok(Json(DeleteTaskResponse { ok: true }))
879}
880
881async fn create_review(
882 State(state): State<AppState>,
883 body: Bytes,
884) -> Result<Json<CreateReviewResponse>, ApiError> {
885 let input = serde_json::from_slice::<CreateReviewInput>(&body)
886 .map_err(|_| ApiError::invalid_json("Request body is not valid JSON."))?;
887
888 let queue_state = state.clone();
889 let (review, run) = tokio::task::spawn_blocking(move || {
890 let review_service = RemoteReviewService {
891 config_service: &queue_state.config_service,
892 project_repository: &queue_state.project_repository,
893 review_repository: &queue_state.review_repository,
894 review_dispatch_repository: &queue_state.review_dispatch_repository,
895 };
896
897 review_service.create_review(input)
898 })
899 .await
900 .map_err(|error| ApiError::internal(format!("Create review failed to join: {error}")))?
901 .map_err(ApiError::from_track_error)?;
902 bump_task_change_version(&state);
903
904 spawn_review_launch(state.clone(), run.clone());
905
906 Ok(Json(CreateReviewResponse { review, run }))
907}
908
909async fn follow_up_review(
910 State(state): State<AppState>,
911 AxumPath(id): AxumPath<String>,
912 body: Bytes,
913) -> Result<Json<ReviewRunRecord>, ApiError> {
914 let input = serde_json::from_slice::<FollowUpRequestInput>(&body)
915 .map_err(|_| ApiError::invalid_json("Request body is not valid JSON."))?;
916
917 let queue_state = state.clone();
918 let review_id = id.clone();
919 let run = tokio::task::spawn_blocking(move || {
920 let review_service = RemoteReviewService {
921 config_service: &queue_state.config_service,
922 project_repository: &queue_state.project_repository,
923 review_repository: &queue_state.review_repository,
924 review_dispatch_repository: &queue_state.review_dispatch_repository,
925 };
926
927 review_service.queue_follow_up_review_dispatch(&review_id, &input.request)
928 })
929 .await
930 .map_err(|error| ApiError::internal(format!("Follow-up review failed to join: {error}")))?
931 .map_err(ApiError::from_track_error)?;
932 bump_task_change_version(&state);
933
934 spawn_review_launch(state.clone(), run.clone());
935
936 Ok(Json(run))
937}
938
939async fn delete_review(
940 State(state): State<AppState>,
941 AxumPath(id): AxumPath<String>,
942) -> Result<Json<DeleteTaskResponse>, ApiError> {
943 let delete_state = state.clone();
944 let review_id = id.clone();
945 tokio::task::spawn_blocking(move || {
946 let review_service = RemoteReviewService {
947 config_service: &delete_state.config_service,
948 project_repository: &delete_state.project_repository,
949 review_repository: &delete_state.review_repository,
950 review_dispatch_repository: &delete_state.review_dispatch_repository,
951 };
952
953 review_service.delete_review(&review_id)
954 })
955 .await
956 .map_err(|error| ApiError::internal(format!("Delete review failed to join: {error}")))?
957 .map_err(ApiError::from_track_error)?;
958 bump_task_change_version(&state);
959
960 Ok(Json(DeleteTaskResponse { ok: true }))
961}
962
963fn spawn_dispatch_launch(state: AppState, queued_dispatch: TaskDispatchRecord) {
964 tokio::spawn(async move {
965 let launch_state = state.clone();
966 let launch_dispatch = queued_dispatch.clone();
967 let join_result = tokio::task::spawn_blocking(move || {
968 let dispatch_service = RemoteDispatchService {
969 config_service: &launch_state.config_service,
970 dispatch_repository: &launch_state.dispatch_repository,
971 project_repository: &launch_state.project_repository,
972 task_repository: &launch_state.task_repository,
973 review_repository: &launch_state.review_repository,
974 review_dispatch_repository: &launch_state.review_dispatch_repository,
975 };
976
977 dispatch_service.launch_prepared_dispatch(launch_dispatch)
978 })
979 .await;
980
981 if let Err(join_error) = join_result {
982 if let Some(mut saved_dispatch) = state
983 .dispatch_repository
984 .get_dispatch(&queued_dispatch.task_id, &queued_dispatch.dispatch_id)
985 .ok()
986 .flatten()
987 {
988 if saved_dispatch.status.is_active() {
989 saved_dispatch.status = track_core::types::DispatchStatus::Failed;
990 saved_dispatch.updated_at = now_utc();
991 saved_dispatch.finished_at = Some(saved_dispatch.updated_at);
992 saved_dispatch.error_message = Some(format!(
993 "Background dispatch task stopped unexpectedly: {join_error}"
994 ));
995 let _ = state.dispatch_repository.save_dispatch(&saved_dispatch);
996 }
997 }
998 }
999 });
1000}
1001
1002fn spawn_review_launch(state: AppState, queued_dispatch: ReviewRunRecord) {
1003 tokio::spawn(async move {
1004 let launch_state = state.clone();
1005 let launch_dispatch = queued_dispatch.clone();
1006 let join_result = tokio::task::spawn_blocking(move || {
1007 let review_service = RemoteReviewService {
1008 config_service: &launch_state.config_service,
1009 project_repository: &launch_state.project_repository,
1010 review_repository: &launch_state.review_repository,
1011 review_dispatch_repository: &launch_state.review_dispatch_repository,
1012 };
1013
1014 review_service.launch_prepared_review(launch_dispatch)
1015 })
1016 .await;
1017
1018 if let Err(join_error) = join_result {
1019 if let Some(mut saved_dispatch) = state
1020 .review_dispatch_repository
1021 .get_dispatch(&queued_dispatch.review_id, &queued_dispatch.dispatch_id)
1022 .ok()
1023 .flatten()
1024 {
1025 if saved_dispatch.status.is_active() {
1026 saved_dispatch.status = track_core::types::DispatchStatus::Failed;
1027 saved_dispatch.updated_at = now_utc();
1028 saved_dispatch.finished_at = Some(saved_dispatch.updated_at);
1029 saved_dispatch.error_message = Some(format!(
1030 "Background review task stopped unexpectedly: {join_error}"
1031 ));
1032 let _ = state
1033 .review_dispatch_repository
1034 .save_dispatch(&saved_dispatch);
1035 }
1036 }
1037 }
1038 });
1039}
1040
1041pub fn spawn_remote_review_follow_up_reconciler(state: AppState) {
1042 tokio::spawn(async move {
1043 let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
1044 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1045
1046 loop {
1047 interval.tick().await;
1048 let reconciliation_run_id =
1049 format!("review-follow-up-{}", now_utc().unix_timestamp_nanos());
1050
1051 let reconcile_state = state.clone();
1052 let join_result = tokio::task::spawn_blocking(move || {
1053 let dispatch_service = RemoteDispatchService {
1054 config_service: &reconcile_state.config_service,
1055 dispatch_repository: &reconcile_state.dispatch_repository,
1056 project_repository: &reconcile_state.project_repository,
1057 task_repository: &reconcile_state.task_repository,
1058 review_repository: &reconcile_state.review_repository,
1059 review_dispatch_repository: &reconcile_state.review_dispatch_repository,
1060 };
1061
1062 dispatch_service.reconcile_review_follow_up()
1063 })
1064 .await;
1065
1066 let reconciliation = match join_result {
1067 Ok(Ok(reconciliation)) => reconciliation,
1068 Ok(Err(error)) => {
1069 tracing::warn!(
1070 reconciliation_run_id = %reconciliation_run_id,
1071 "Review follow-up reconciliation failed: {error}"
1072 );
1073 continue;
1074 }
1075 Err(join_error) => {
1076 tracing::warn!(
1077 reconciliation_run_id = %reconciliation_run_id,
1078 "Review follow-up reconciliation task failed to join: {join_error}"
1079 );
1080 continue;
1081 }
1082 };
1083
1084 for event in &reconciliation.events {
1085 let branch_name = event.branch_name.as_deref().unwrap_or("");
1086 let pull_request_url = event.pull_request_url.as_deref().unwrap_or("");
1087 let pr_head_oid = event.pr_head_oid.as_deref().unwrap_or("");
1088 let latest_review_state = event.latest_review_state.as_deref().unwrap_or("");
1089 let latest_review_submitted_at =
1090 event.latest_review_submitted_at.as_deref().unwrap_or("");
1091
1092 let task_event = tracing::info_span!(
1093 "review_follow_up_task_event",
1094 reconciliation_run_id = %reconciliation_run_id,
1095 outcome = %event.outcome,
1096 task_id = %event.task_id,
1097 dispatch_id = %event.dispatch_id,
1098 dispatch_status = %event.dispatch_status,
1099 remote_host = %event.remote_host,
1100 branch_name = %branch_name,
1101 pull_request_url = %pull_request_url,
1102 reviewer = %event.reviewer,
1103 pr_is_open = ?event.pr_is_open,
1104 pr_head_oid = %pr_head_oid,
1105 latest_review_state = %latest_review_state,
1106 latest_review_submitted_at = %latest_review_submitted_at,
1107 );
1108 let _task_event_guard = task_event.enter();
1109
1110 if event.outcome.ends_with("_failed") {
1111 tracing::warn!("{}", event.detail);
1112 } else {
1113 tracing::info!("{}", event.detail);
1114 }
1115 }
1116
1117 if reconciliation.review_notifications_updated > 0
1118 || !reconciliation.queued_dispatches.is_empty()
1119 || reconciliation.failures > 0
1120 {
1121 tracing::info!(
1122 reconciliation_run_id = %reconciliation_run_id,
1123 review_notifications_updated = reconciliation.review_notifications_updated,
1124 queued_dispatches = reconciliation.queued_dispatches.len(),
1125 failures = reconciliation.failures,
1126 evaluated_events = reconciliation.events.len(),
1127 "Review follow-up reconciliation applied updates"
1128 );
1129 }
1130
1131 if !reconciliation.queued_dispatches.is_empty() {
1132 bump_task_change_version(&state);
1133 }
1134
1135 for queued_dispatch in reconciliation.queued_dispatches {
1136 spawn_dispatch_launch(state.clone(), queued_dispatch);
1137 }
1138 }
1139 });
1140}
1141
1142async fn dispatch_task(
1143 State(state): State<AppState>,
1144 AxumPath(id): AxumPath<String>,
1145 body: Bytes,
1146) -> Result<Json<TaskDispatchRecord>, ApiError> {
1147 let input = if body.is_empty() {
1148 DispatchTaskInput::default()
1149 } else {
1150 serde_json::from_slice::<DispatchTaskInput>(&body)
1151 .map_err(|_| ApiError::invalid_json("Request body is not valid JSON."))?
1152 };
1153
1154 let queue_state = state.clone();
1155 let task_id = id.clone();
1156 let dispatch = tokio::task::spawn_blocking(move || {
1157 let dispatch_service = RemoteDispatchService {
1158 config_service: &queue_state.config_service,
1159 dispatch_repository: &queue_state.dispatch_repository,
1160 project_repository: &queue_state.project_repository,
1161 task_repository: &queue_state.task_repository,
1162 review_repository: &queue_state.review_repository,
1163 review_dispatch_repository: &queue_state.review_dispatch_repository,
1164 };
1165
1166 dispatch_service.queue_dispatch(&task_id, input.preferred_tool)
1167 })
1168 .await
1169 .map_err(|error| ApiError::internal(format!("Dispatch task failed to join: {error}")))?
1170 .map_err(ApiError::from_track_error)?;
1171
1172 spawn_dispatch_launch(state.clone(), dispatch.clone());
1173
1174 Ok(Json(dispatch))
1175}
1176
1177async fn follow_up_task(
1178 State(state): State<AppState>,
1179 AxumPath(id): AxumPath<String>,
1180 body: Bytes,
1181) -> Result<Json<TaskDispatchRecord>, ApiError> {
1182 let input = serde_json::from_slice::<FollowUpRequestInput>(&body)
1183 .map_err(|_| ApiError::invalid_json("Request body is not valid JSON."))?;
1184
1185 let queue_state = state.clone();
1186 let task_id = id.clone();
1187 let dispatch = tokio::task::spawn_blocking(move || {
1188 let dispatch_service = RemoteDispatchService {
1189 config_service: &queue_state.config_service,
1190 dispatch_repository: &queue_state.dispatch_repository,
1191 project_repository: &queue_state.project_repository,
1192 task_repository: &queue_state.task_repository,
1193 review_repository: &queue_state.review_repository,
1194 review_dispatch_repository: &queue_state.review_dispatch_repository,
1195 };
1196
1197 dispatch_service.queue_follow_up_dispatch(&task_id, &input.request)
1198 })
1199 .await
1200 .map_err(|error| ApiError::internal(format!("Follow-up task failed to join: {error}")))?
1201 .map_err(ApiError::from_track_error)?;
1202 bump_task_change_version(&state);
1203
1204 spawn_dispatch_launch(state.clone(), dispatch.clone());
1205
1206 Ok(Json(dispatch))
1207}
1208
1209async fn cancel_task_dispatch(
1210 State(state): State<AppState>,
1211 AxumPath(id): AxumPath<String>,
1212) -> Result<Json<TaskDispatchRecord>, ApiError> {
1213 let state = state.clone();
1214 let canceled_dispatch = tokio::task::spawn_blocking(move || {
1215 let dispatch_service = RemoteDispatchService {
1216 config_service: &state.config_service,
1217 dispatch_repository: &state.dispatch_repository,
1218 project_repository: &state.project_repository,
1219 task_repository: &state.task_repository,
1220 review_repository: &state.review_repository,
1221 review_dispatch_repository: &state.review_dispatch_repository,
1222 };
1223
1224 dispatch_service.cancel_dispatch(&id)
1225 })
1226 .await
1227 .map_err(|error| ApiError::internal(format!("Cancel dispatch task failed to join: {error}")))?
1228 .map_err(ApiError::from_track_error)?;
1229
1230 Ok(Json(canceled_dispatch))
1231}
1232
1233async fn cancel_review_dispatch(
1234 State(state): State<AppState>,
1235 AxumPath(id): AxumPath<String>,
1236) -> Result<Json<ReviewRunRecord>, ApiError> {
1237 let state = state.clone();
1238 let canceled_dispatch = tokio::task::spawn_blocking(move || {
1239 let review_service = RemoteReviewService {
1240 config_service: &state.config_service,
1241 project_repository: &state.project_repository,
1242 review_repository: &state.review_repository,
1243 review_dispatch_repository: &state.review_dispatch_repository,
1244 };
1245
1246 review_service.cancel_dispatch(&id)
1247 })
1248 .await
1249 .map_err(|error| ApiError::internal(format!("Cancel review task failed to join: {error}")))?
1250 .map_err(ApiError::from_track_error)?;
1251
1252 Ok(Json(canceled_dispatch))
1253}
1254
1255async fn discard_task_dispatch(
1256 State(state): State<AppState>,
1257 AxumPath(id): AxumPath<String>,
1258) -> Result<Json<DeleteTaskResponse>, ApiError> {
1259 let state = state.clone();
1260 tokio::task::spawn_blocking(move || {
1261 let dispatch_service = RemoteDispatchService {
1262 config_service: &state.config_service,
1263 dispatch_repository: &state.dispatch_repository,
1264 project_repository: &state.project_repository,
1265 task_repository: &state.task_repository,
1266 review_repository: &state.review_repository,
1267 review_dispatch_repository: &state.review_dispatch_repository,
1268 };
1269
1270 dispatch_service.discard_dispatch_history(&id)
1271 })
1272 .await
1273 .map_err(|error| ApiError::internal(format!("Discard dispatch task failed to join: {error}")))?
1274 .map_err(ApiError::from_track_error)?;
1275
1276 Ok(Json(DeleteTaskResponse { ok: true }))
1277}
1278
1279async fn api_not_found() -> ApiError {
1280 ApiError {
1281 status: StatusCode::NOT_FOUND,
1282 code: "ROUTE_NOT_FOUND".to_owned(),
1283 message: "Route was not found.".to_owned(),
1284 }
1285}
1286
1287async fn enforce_migration_gate(
1288 State(state): State<AppState>,
1289 request: Request,
1290 next: Next,
1291) -> Result<AxumResponse, ApiError> {
1292 let migration = state
1293 .migration_service
1294 .status()
1295 .map_err(ApiError::from_track_error)?;
1296 if migration.requires_migration {
1297 return Err(ApiError::from_track_error(TrackError::new(
1298 ErrorCode::MigrationRequired,
1299 "Legacy track data must be imported before the backend can serve normal API routes.",
1300 )));
1301 }
1302
1303 Ok(next.run(request).await)
1304}
1305
1306pub fn build_app(state: AppState, static_root: impl AsRef<Path>) -> Router {
1307 let static_root = static_root.as_ref().to_path_buf();
1310 let migration_router = Router::new()
1311 .route("/migration/status", get(migration_status))
1312 .route("/migration/import", post(import_legacy_data));
1313
1314 let application_router = Router::new()
1319 .route("/projects", get(list_projects))
1320 .route(
1321 "/projects/{canonical_name}",
1322 put(put_project).patch(patch_project),
1323 )
1324 .route(
1325 "/remote-agent",
1326 get(get_remote_agent_settings)
1327 .put(put_remote_agent_settings)
1328 .patch(patch_remote_agent_settings),
1329 )
1330 .route(
1331 "/remote-agent/cleanup",
1332 post(cleanup_remote_agent_artifacts),
1333 )
1334 .route("/remote-agent/reset", post(reset_remote_agent_workspace))
1335 .route("/dispatches", get(list_dispatches))
1336 .route("/reviews", get(list_reviews).post(create_review))
1337 .route("/reviews/{id}", axum::routing::delete(delete_review))
1338 .route("/reviews/{id}/runs", get(list_review_runs))
1339 .route("/reviews/{id}/follow-up", post(follow_up_review))
1340 .route("/reviews/{id}/cancel", post(cancel_review_dispatch))
1341 .route("/runs", get(list_runs))
1342 .route("/tasks", get(list_tasks).post(create_task))
1343 .route("/tasks/{id}/runs", get(list_task_runs))
1344 .route("/tasks/{id}", patch(patch_task).delete(delete_task))
1345 .route(
1346 "/tasks/{id}/dispatch",
1347 post(dispatch_task).delete(discard_task_dispatch),
1348 )
1349 .route("/tasks/{id}/follow-up", post(follow_up_task))
1350 .route("/tasks/{id}/dispatch/cancel", post(cancel_task_dispatch))
1351 .route("/events/version", get(get_task_change_version))
1352 .route(
1353 "/events/tasks-changed",
1354 axum::routing::post(notify_task_change),
1355 )
1356 .fallback(api_not_found)
1357 .route_layer(from_fn_with_state(state.clone(), enforce_migration_gate));
1358
1359 let api_router = migration_router.merge(application_router);
1360
1361 Router::new()
1362 .route("/health", get(health))
1363 .nest("/api", api_router)
1364 .fallback_service(
1365 axum::routing::get_service(
1366 ServeDir::new(static_root.clone())
1367 .not_found_service(ServeFile::new(static_root.join("index.html"))),
1368 )
1369 .handle_error(|error| async move {
1370 ApiError::internal(format!("Static assets are not available yet: {error}"))
1371 }),
1372 )
1373 .with_state(state)
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378 use std::ffi::OsString;
1379 use std::fs;
1380 use std::path::PathBuf;
1381 use std::sync::atomic::AtomicU64;
1382 use std::sync::{Arc, Mutex, OnceLock};
1383
1384 use axum::body::Body;
1385 use axum::http::{Request, StatusCode};
1386 use tempfile::TempDir;
1387 use tower::ServiceExt;
1388 use track_core::backend_config::{BackendConfigRepository, RemoteAgentConfigService};
1389 use track_core::config::{
1390 RemoteAgentConfigFile, DEFAULT_REMOTE_AGENT_PORT, DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT,
1391 DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH,
1392 };
1393 use track_core::database::DatabaseContext;
1394 use track_core::dispatch_repository::DispatchRepository;
1395 use track_core::migration_service::MigrationService;
1396 use track_core::paths::{
1397 get_backend_managed_remote_agent_key_path,
1398 get_backend_managed_remote_agent_known_hosts_path,
1399 };
1400 use track_core::project_catalog::ProjectInfo;
1401 use track_core::project_repository::{ProjectMetadata, ProjectRepository};
1402 use track_core::review_dispatch_repository::ReviewDispatchRepository;
1403 use track_core::review_repository::ReviewRepository;
1404 use track_core::settings_repository::SettingsRepository;
1405 use track_core::task_repository::FileTaskRepository;
1406 use track_core::time_utils::now_utc;
1407 use track_core::types::{
1408 DispatchStatus, Priority, RemoteAgentPreferredTool, ReviewRecord, ReviewRunRecord,
1409 TaskCreateInput, TaskSource,
1410 };
1411
1412 use super::{build_app, AppState};
1413
1414 fn static_root(directory: &TempDir) -> std::path::PathBuf {
1415 let root = directory.path().join("static");
1416 fs::create_dir_all(&root).expect("static root should exist");
1417 fs::write(root.join("index.html"), "<html><body>track</body></html>")
1418 .expect("static index should be written");
1419 root
1420 }
1421
1422 fn database_path(directory: &TempDir) -> PathBuf {
1423 directory.path().join("backend").join("track.sqlite")
1424 }
1425
1426 fn test_env_lock() -> &'static Mutex<()> {
1427 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1428 LOCK.get_or_init(|| Mutex::new(()))
1429 }
1430
1431 struct ScopedEnvVar {
1432 key: &'static str,
1433 previous_value: Option<OsString>,
1434 }
1435
1436 impl ScopedEnvVar {
1437 fn set_path(key: &'static str, value: PathBuf) -> Self {
1438 let previous_value = std::env::var_os(key);
1439 std::env::set_var(key, value);
1440
1441 Self {
1442 key,
1443 previous_value,
1444 }
1445 }
1446 }
1447
1448 impl Drop for ScopedEnvVar {
1449 fn drop(&mut self) {
1450 match self.previous_value.take() {
1451 Some(previous_value) => std::env::set_var(self.key, previous_value),
1452 None => std::env::remove_var(self.key),
1453 }
1454 }
1455 }
1456
1457 struct TestEnvironment {
1458 _env_lock: std::sync::MutexGuard<'static, ()>,
1459 _track_state_dir_guard: ScopedEnvVar,
1460 _track_legacy_root_guard: ScopedEnvVar,
1461 _track_legacy_config_guard: ScopedEnvVar,
1462 }
1463
1464 impl TestEnvironment {
1465 fn new(directory: &TempDir) -> Self {
1466 let env_lock = test_env_lock()
1467 .lock()
1468 .unwrap_or_else(|poisoned| poisoned.into_inner());
1469 let backend_state_dir = directory.path().join("backend");
1470 let legacy_root = directory.path().join("legacy-root");
1471 let legacy_config_path = directory.path().join("legacy-config/config.json");
1472
1473 Self {
1474 _env_lock: env_lock,
1475 _track_state_dir_guard: ScopedEnvVar::set_path(
1476 "TRACK_STATE_DIR",
1477 backend_state_dir,
1478 ),
1479 _track_legacy_root_guard: ScopedEnvVar::set_path("TRACK_LEGACY_ROOT", legacy_root),
1480 _track_legacy_config_guard: ScopedEnvVar::set_path(
1481 "TRACK_LEGACY_CONFIG_PATH",
1482 legacy_config_path,
1483 ),
1484 }
1485 }
1486 }
1487
1488 fn config_service(directory: &TempDir) -> Arc<RemoteAgentConfigService> {
1489 let database = DatabaseContext::new(Some(database_path(directory)))
1490 .expect("database context should resolve");
1491 let settings =
1492 SettingsRepository::new(Some(database)).expect("settings repository should resolve");
1493 let repository = BackendConfigRepository::new(Some(settings))
1494 .expect("backend config repository should resolve");
1495
1496 Arc::new(
1497 RemoteAgentConfigService::new(Some(repository))
1498 .expect("remote-agent config service should resolve"),
1499 )
1500 }
1501
1502 fn dispatch_repository(directory: &TempDir) -> Arc<DispatchRepository> {
1503 Arc::new(
1504 DispatchRepository::new(Some(database_path(directory)))
1505 .expect("dispatch repository should resolve"),
1506 )
1507 }
1508
1509 fn project_repository(directory: &TempDir) -> Arc<ProjectRepository> {
1510 Arc::new(
1511 ProjectRepository::new(Some(database_path(directory)))
1512 .expect("project repository should resolve"),
1513 )
1514 }
1515
1516 fn review_repository(directory: &TempDir) -> Arc<ReviewRepository> {
1517 Arc::new(
1518 ReviewRepository::new(Some(database_path(directory)))
1519 .expect("review repository should resolve"),
1520 )
1521 }
1522
1523 fn review_dispatch_repository(directory: &TempDir) -> Arc<ReviewDispatchRepository> {
1524 Arc::new(
1525 ReviewDispatchRepository::new(Some(database_path(directory)))
1526 .expect("review dispatch repository should resolve"),
1527 )
1528 }
1529
1530 fn task_repository(directory: &TempDir) -> Arc<FileTaskRepository> {
1531 Arc::new(
1532 FileTaskRepository::new(Some(database_path(directory)))
1533 .expect("task repository should resolve"),
1534 )
1535 }
1536
1537 fn migration_service(
1538 config_service: &Arc<RemoteAgentConfigService>,
1539 dispatch_repository: &Arc<DispatchRepository>,
1540 project_repository: &Arc<ProjectRepository>,
1541 review_dispatch_repository: &Arc<ReviewDispatchRepository>,
1542 review_repository: &Arc<ReviewRepository>,
1543 task_repository: &Arc<FileTaskRepository>,
1544 ) -> Arc<MigrationService> {
1545 Arc::new(
1546 MigrationService::new(
1547 (**config_service).clone(),
1548 (**project_repository).clone(),
1549 (**task_repository).clone(),
1550 (**dispatch_repository).clone(),
1551 (**review_repository).clone(),
1552 (**review_dispatch_repository).clone(),
1553 )
1554 .expect("migration service should resolve"),
1555 )
1556 }
1557
1558 fn app_state(
1559 config_service: Arc<RemoteAgentConfigService>,
1560 dispatch_repository: Arc<DispatchRepository>,
1561 project_repository: Arc<ProjectRepository>,
1562 review_dispatch_repository: Arc<ReviewDispatchRepository>,
1563 review_repository: Arc<ReviewRepository>,
1564 task_repository: Arc<FileTaskRepository>,
1565 ) -> AppState {
1566 let migration_service = migration_service(
1567 &config_service,
1568 &dispatch_repository,
1569 &project_repository,
1570 &review_dispatch_repository,
1571 &review_repository,
1572 &task_repository,
1573 );
1574
1575 AppState {
1576 config_service,
1577 dispatch_repository,
1578 migration_service,
1579 project_repository,
1580 review_dispatch_repository,
1581 review_repository,
1582 task_repository,
1583 task_change_version: Arc::new(AtomicU64::new(0)),
1584 }
1585 }
1586
1587 fn register_project(project_repository: &ProjectRepository, canonical_name: &str) {
1588 project_repository
1589 .upsert_project_by_name(
1590 canonical_name,
1591 ProjectMetadata {
1592 repo_url: format!("https://example.com/{canonical_name}"),
1593 git_url: format!("git@example.com:{canonical_name}.git"),
1594 base_branch: "main".to_owned(),
1595 description: None,
1596 },
1597 Vec::new(),
1598 )
1599 .expect("project should save");
1600 }
1601
1602 fn configured_remote_agent_config_service(
1603 directory: &TempDir,
1604 ) -> Arc<RemoteAgentConfigService> {
1605 let service = config_service(directory);
1606 service
1607 .save_remote_agent_config(Some(&RemoteAgentConfigFile {
1608 host: "192.0.2.25".to_owned(),
1609 user: "builder".to_owned(),
1610 port: DEFAULT_REMOTE_AGENT_PORT,
1611 workspace_root: DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT.to_owned(),
1612 projects_registry_path: DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH.to_owned(),
1613 preferred_tool: RemoteAgentPreferredTool::Codex,
1614 shell_prelude: Some(". \"$HOME/.cargo/env\"".to_owned()),
1615 review_follow_up: None,
1616 }))
1617 .expect("remote-agent config should save");
1618 service
1619 }
1620
1621 #[tokio::test]
1622 async fn lists_tasks_with_backend_sorting() {
1623 let directory = TempDir::new().expect("tempdir should be created");
1624 let _environment = TestEnvironment::new(&directory);
1625 let static_root = static_root(&directory);
1626 let project_repository = project_repository(&directory);
1627 register_project(&project_repository, "project-a");
1628 let repository = task_repository(&directory);
1629 repository
1630 .create_task(TaskCreateInput {
1631 project: "project-a".to_owned(),
1632 priority: Priority::Medium,
1633 description: "Middle priority task".to_owned(),
1634 source: Some(TaskSource::Cli),
1635 })
1636 .expect("first task should be created");
1637 repository
1638 .create_task(TaskCreateInput {
1639 project: "project-a".to_owned(),
1640 priority: Priority::High,
1641 description: "Top priority task".to_owned(),
1642 source: Some(TaskSource::Cli),
1643 })
1644 .expect("second task should be created");
1645
1646 let app = build_app(
1647 app_state(
1648 config_service(&directory),
1649 dispatch_repository(&directory),
1650 project_repository,
1651 review_dispatch_repository(&directory),
1652 review_repository(&directory),
1653 repository,
1654 ),
1655 &static_root,
1656 );
1657
1658 let response = app
1659 .oneshot(
1660 Request::builder()
1661 .uri("/api/tasks")
1662 .body(Body::empty())
1663 .expect("request should build"),
1664 )
1665 .await
1666 .expect("request should succeed");
1667
1668 assert_eq!(response.status(), StatusCode::OK);
1669 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1670 .await
1671 .expect("response body should be readable");
1672 let json: serde_json::Value =
1673 serde_json::from_slice(&body).expect("response should be valid json");
1674 assert_eq!(json["tasks"][0]["priority"], "high");
1675 }
1676
1677 #[tokio::test]
1678 async fn creates_tasks_from_the_web_api() {
1679 let directory = TempDir::new().expect("tempdir should be created");
1680 let _environment = TestEnvironment::new(&directory);
1681 let static_root = static_root(&directory);
1682 let project_repository = project_repository(&directory);
1683 register_project(&project_repository, "project-a");
1684 let repository = task_repository(&directory);
1685
1686 let app = build_app(
1687 app_state(
1688 config_service(&directory),
1689 dispatch_repository(&directory),
1690 project_repository,
1691 review_dispatch_repository(&directory),
1692 review_repository(&directory),
1693 repository.clone(),
1694 ),
1695 &static_root,
1696 );
1697
1698 let response = app
1699 .clone()
1700 .oneshot(
1701 Request::builder()
1702 .method("POST")
1703 .uri("/api/tasks")
1704 .header("content-type", "application/json")
1705 .body(Body::from(
1706 r#"{"project":"project-a","priority":"high","description":"Create a task from the web UI"}"#,
1707 ))
1708 .expect("request should build"),
1709 )
1710 .await
1711 .expect("request should succeed");
1712
1713 assert_eq!(response.status(), StatusCode::OK);
1714 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1715 .await
1716 .expect("response body should be readable");
1717 let json: serde_json::Value =
1718 serde_json::from_slice(&body).expect("response should be valid json");
1719 assert_eq!(json["project"], "project-a");
1720 assert_eq!(json["priority"], "high");
1721 assert_eq!(json["source"], "web");
1722
1723 let stored = repository
1724 .list_tasks(false, Some("project-a"))
1725 .expect("stored tasks should load");
1726 assert_eq!(stored.len(), 1);
1727 assert_eq!(stored[0].source, Some(TaskSource::Web));
1728 }
1729
1730 #[tokio::test]
1731 async fn preserves_cli_source_when_task_is_created_through_the_api() {
1732 let directory = TempDir::new().expect("tempdir should be created");
1733 let _environment = TestEnvironment::new(&directory);
1734 let static_root = static_root(&directory);
1735 let project_repository = project_repository(&directory);
1736 register_project(&project_repository, "project-a");
1737 let repository = task_repository(&directory);
1738
1739 let app = build_app(
1740 app_state(
1741 config_service(&directory),
1742 dispatch_repository(&directory),
1743 project_repository,
1744 review_dispatch_repository(&directory),
1745 review_repository(&directory),
1746 repository.clone(),
1747 ),
1748 &static_root,
1749 );
1750
1751 let response = app
1752 .oneshot(
1753 Request::builder()
1754 .method("POST")
1755 .uri("/api/tasks")
1756 .header("content-type", "application/json")
1757 .body(Body::from(
1758 r#"{"project":"project-a","priority":"high","description":"Create a task from the CLI","source":"cli"}"#,
1759 ))
1760 .expect("request should build"),
1761 )
1762 .await
1763 .expect("request should succeed");
1764
1765 assert_eq!(response.status(), StatusCode::OK);
1766 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1767 .await
1768 .expect("response body should be readable");
1769 let json: serde_json::Value =
1770 serde_json::from_slice(&body).expect("response should be valid json");
1771 assert_eq!(json["source"], "cli");
1772
1773 let stored = repository
1774 .list_tasks(false, Some("project-a"))
1775 .expect("stored tasks should load");
1776 assert_eq!(stored.len(), 1);
1777 assert_eq!(stored[0].source, Some(TaskSource::Cli));
1778 }
1779
1780 #[tokio::test]
1781 async fn lists_dispatches_for_single_and_repeated_task_ids() {
1782 let directory = TempDir::new().expect("tempdir should be created");
1783 let _environment = TestEnvironment::new(&directory);
1784 let static_root = static_root(&directory);
1785 let project_repository = project_repository(&directory);
1786 register_project(&project_repository, "project-a");
1787 register_project(&project_repository, "project-b");
1788 let task_repository = task_repository(&directory);
1789 let dispatch_repository = dispatch_repository(&directory);
1790
1791 let first_task = task_repository
1792 .create_task(TaskCreateInput {
1793 project: "project-a".to_owned(),
1794 priority: Priority::High,
1795 description: "First dispatched task".to_owned(),
1796 source: Some(TaskSource::Cli),
1797 })
1798 .expect("first task should be created")
1799 .task;
1800 let second_task = task_repository
1801 .create_task(TaskCreateInput {
1802 project: "project-b".to_owned(),
1803 priority: Priority::Medium,
1804 description: "Second dispatched task".to_owned(),
1805 source: Some(TaskSource::Cli),
1806 })
1807 .expect("second task should be created")
1808 .task;
1809
1810 dispatch_repository
1811 .create_dispatch(&first_task, "192.0.2.25", RemoteAgentPreferredTool::Codex)
1812 .expect("first dispatch should be created");
1813 dispatch_repository
1814 .create_dispatch(&second_task, "192.0.2.25", RemoteAgentPreferredTool::Codex)
1815 .expect("second dispatch should be created");
1816
1817 let app = build_app(
1818 app_state(
1819 config_service(&directory),
1820 dispatch_repository,
1821 project_repository,
1822 review_dispatch_repository(&directory),
1823 review_repository(&directory),
1824 task_repository,
1825 ),
1826 &static_root,
1827 );
1828
1829 let single_response = app
1830 .clone()
1831 .oneshot(
1832 Request::builder()
1833 .uri(format!("/api/dispatches?taskId={}", first_task.id))
1834 .body(Body::empty())
1835 .expect("single-dispatch request should build"),
1836 )
1837 .await
1838 .expect("single-dispatch request should succeed");
1839 assert_eq!(single_response.status(), StatusCode::OK);
1840 let single_body = axum::body::to_bytes(single_response.into_body(), usize::MAX)
1841 .await
1842 .expect("single-dispatch body should be readable");
1843 let single_json: serde_json::Value =
1844 serde_json::from_slice(&single_body).expect("single-dispatch response should be json");
1845 assert_eq!(single_json["dispatches"].as_array().map(Vec::len), Some(1));
1846 assert_eq!(single_json["dispatches"][0]["taskId"], first_task.id);
1847
1848 let repeated_response = app
1849 .oneshot(
1850 Request::builder()
1851 .uri(format!(
1852 "/api/dispatches?taskId={}&taskId={}",
1853 first_task.id, second_task.id
1854 ))
1855 .body(Body::empty())
1856 .expect("repeated-dispatch request should build"),
1857 )
1858 .await
1859 .expect("repeated-dispatch request should succeed");
1860 assert_eq!(repeated_response.status(), StatusCode::OK);
1861 let repeated_body = axum::body::to_bytes(repeated_response.into_body(), usize::MAX)
1862 .await
1863 .expect("repeated-dispatch body should be readable");
1864 let repeated_json: serde_json::Value = serde_json::from_slice(&repeated_body)
1865 .expect("repeated-dispatch response should be json");
1866 assert_eq!(
1867 repeated_json["dispatches"].as_array().map(Vec::len),
1868 Some(2)
1869 );
1870 }
1871
1872 #[tokio::test]
1873 async fn lists_runs_with_task_context() {
1874 let directory = TempDir::new().expect("tempdir should be created");
1875 let _environment = TestEnvironment::new(&directory);
1876 let static_root = static_root(&directory);
1877 let project_repository = project_repository(&directory);
1878 register_project(&project_repository, "project-a");
1879 let task_repository = task_repository(&directory);
1880 let dispatch_repository = dispatch_repository(&directory);
1881
1882 let task = task_repository
1883 .create_task(TaskCreateInput {
1884 project: "project-a".to_owned(),
1885 priority: Priority::High,
1886 description: "Investigate an agent run".to_owned(),
1887 source: Some(TaskSource::Cli),
1888 })
1889 .expect("task should be created")
1890 .task;
1891 let dispatch = dispatch_repository
1892 .create_dispatch(&task, "192.0.2.25", RemoteAgentPreferredTool::Codex)
1893 .expect("dispatch should be created");
1894
1895 let app = build_app(
1896 app_state(
1897 config_service(&directory),
1898 dispatch_repository,
1899 project_repository,
1900 review_dispatch_repository(&directory),
1901 review_repository(&directory),
1902 task_repository,
1903 ),
1904 &static_root,
1905 );
1906
1907 let response = app
1908 .oneshot(
1909 Request::builder()
1910 .uri("/api/runs?limit=10")
1911 .body(Body::empty())
1912 .expect("runs request should build"),
1913 )
1914 .await
1915 .expect("runs request should succeed");
1916 assert_eq!(response.status(), StatusCode::OK);
1917
1918 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1919 .await
1920 .expect("runs response body should be readable");
1921 let json: serde_json::Value =
1922 serde_json::from_slice(&body).expect("runs response should be valid json");
1923
1924 assert_eq!(json["runs"].as_array().map(Vec::len), Some(1));
1925 assert_eq!(json["runs"][0]["task"]["id"], task.id);
1926 assert_eq!(
1927 json["runs"][0]["dispatch"]["dispatchId"],
1928 dispatch.dispatch_id
1929 );
1930 }
1931
1932 #[tokio::test]
1933 async fn lists_task_scoped_runs_without_global_limit_truncation() {
1934 let directory = TempDir::new().expect("tempdir should be created");
1935 let _environment = TestEnvironment::new(&directory);
1936 let static_root = static_root(&directory);
1937 let project_repository = project_repository(&directory);
1938 register_project(&project_repository, "project-a");
1939 let task_repository = task_repository(&directory);
1940 let dispatch_repository = dispatch_repository(&directory);
1941
1942 let task = task_repository
1943 .create_task(TaskCreateInput {
1944 project: "project-a".to_owned(),
1945 priority: Priority::High,
1946 description: "Inspect task-scoped run history".to_owned(),
1947 source: Some(TaskSource::Cli),
1948 })
1949 .expect("task should be created")
1950 .task;
1951
1952 dispatch_repository
1953 .create_dispatch(&task, "192.0.2.25", RemoteAgentPreferredTool::Codex)
1954 .expect("first dispatch should be created");
1955 dispatch_repository
1956 .create_dispatch(&task, "192.0.2.25", RemoteAgentPreferredTool::Codex)
1957 .expect("second dispatch should be created");
1958
1959 let app = build_app(
1960 app_state(
1961 config_service(&directory),
1962 dispatch_repository,
1963 project_repository,
1964 review_dispatch_repository(&directory),
1965 review_repository(&directory),
1966 task_repository,
1967 ),
1968 &static_root,
1969 );
1970
1971 let response = app
1972 .oneshot(
1973 Request::builder()
1974 .uri(format!("/api/tasks/{}/runs", task.id))
1975 .body(Body::empty())
1976 .expect("task-runs request should build"),
1977 )
1978 .await
1979 .expect("task-runs request should succeed");
1980 assert_eq!(response.status(), StatusCode::OK);
1981
1982 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1983 .await
1984 .expect("task-runs response body should be readable");
1985 let json: serde_json::Value =
1986 serde_json::from_slice(&body).expect("task-runs response should be valid json");
1987
1988 assert_eq!(json["runs"].as_array().map(Vec::len), Some(2));
1989 assert!(json["runs"]
1990 .as_array()
1991 .expect("runs should be an array")
1992 .iter()
1993 .all(|run| run["task"]["id"] == task.id));
1994 }
1995
1996 #[tokio::test]
1997 async fn lists_reviews_with_latest_run_and_review_history() {
1998 let directory = TempDir::new().expect("tempdir should be created");
1999 let _environment = TestEnvironment::new(&directory);
2000 let static_root = static_root(&directory);
2001 let review_repository = review_repository(&directory);
2002 let review_dispatch_repository = review_dispatch_repository(&directory);
2003 let created_at = now_utc();
2004 let review = ReviewRecord {
2005 id: "20260326-120000-review-pr-42".to_owned(),
2006 pull_request_url: "https://github.com/acme/project-a/pull/42".to_owned(),
2007 pull_request_number: 42,
2008 pull_request_title: "Fix queue layout".to_owned(),
2009 repository_full_name: "acme/project-a".to_owned(),
2010 repo_url: "https://github.com/acme/project-a".to_owned(),
2011 git_url: "git@github.com:acme/project-a.git".to_owned(),
2012 base_branch: "main".to_owned(),
2013 workspace_key: "project-a".to_owned(),
2014 preferred_tool: RemoteAgentPreferredTool::Codex,
2015 project: Some("project-a".to_owned()),
2016 main_user: "octocat".to_owned(),
2017 default_review_prompt: Some("Focus on regressions.".to_owned()),
2018 extra_instructions: Some("Pay attention to queue layout.".to_owned()),
2019 created_at,
2020 updated_at: created_at,
2021 };
2022 review_repository
2023 .save_review(&review)
2024 .expect("review should save");
2025 review_dispatch_repository
2026 .save_dispatch(&ReviewRunRecord {
2027 dispatch_id: "review-dispatch-1".to_owned(),
2028 review_id: review.id.clone(),
2029 pull_request_url: review.pull_request_url.clone(),
2030 repository_full_name: review.repository_full_name.clone(),
2031 workspace_key: review.workspace_key.clone(),
2032 preferred_tool: RemoteAgentPreferredTool::Codex,
2033 status: DispatchStatus::Succeeded,
2034 created_at,
2035 updated_at: created_at,
2036 finished_at: Some(created_at),
2037 remote_host: "192.0.2.25".to_owned(),
2038 branch_name: Some("track-review/review-dispatch-1".to_owned()),
2039 worktree_path: Some("/tmp/review-worktree".to_owned()),
2040 follow_up_request: None,
2041 target_head_oid: Some("abc123def456".to_owned()),
2042 summary: Some("Submitted a GitHub review with two inline comments.".to_owned()),
2043 review_submitted: true,
2044 github_review_id: Some("1001".to_owned()),
2045 github_review_url: Some(
2046 "https://github.com/acme/project-a/pull/42#pullrequestreview-1001".to_owned(),
2047 ),
2048 notes: None,
2049 error_message: None,
2050 })
2051 .expect("review run should save");
2052
2053 let app = build_app(
2054 app_state(
2055 config_service(&directory),
2056 dispatch_repository(&directory),
2057 project_repository(&directory),
2058 review_dispatch_repository,
2059 review_repository,
2060 task_repository(&directory),
2061 ),
2062 &static_root,
2063 );
2064
2065 let list_response = app
2066 .clone()
2067 .oneshot(
2068 Request::builder()
2069 .uri("/api/reviews")
2070 .body(Body::empty())
2071 .expect("review list request should build"),
2072 )
2073 .await
2074 .expect("review list request should succeed");
2075 assert_eq!(list_response.status(), StatusCode::OK);
2076 let list_body = axum::body::to_bytes(list_response.into_body(), usize::MAX)
2077 .await
2078 .expect("review list response body should be readable");
2079 let list_json: serde_json::Value =
2080 serde_json::from_slice(&list_body).expect("review list response should be valid json");
2081 assert_eq!(list_json["reviews"].as_array().map(Vec::len), Some(1));
2082 assert_eq!(
2083 list_json["reviews"][0]["latestRun"]["reviewSubmitted"],
2084 true
2085 );
2086
2087 let runs_response = app
2088 .oneshot(
2089 Request::builder()
2090 .uri(format!("/api/reviews/{}/runs", review.id))
2091 .body(Body::empty())
2092 .expect("review runs request should build"),
2093 )
2094 .await
2095 .expect("review runs request should succeed");
2096 assert_eq!(runs_response.status(), StatusCode::OK);
2097 let runs_body = axum::body::to_bytes(runs_response.into_body(), usize::MAX)
2098 .await
2099 .expect("review runs response body should be readable");
2100 let runs_json: serde_json::Value =
2101 serde_json::from_slice(&runs_body).expect("review runs response should be valid json");
2102 assert_eq!(runs_json["runs"].as_array().map(Vec::len), Some(1));
2103 assert_eq!(runs_json["runs"][0]["reviewSubmitted"], true);
2104 assert_eq!(
2105 runs_json["runs"][0]["summary"],
2106 "Submitted a GitHub review with two inline comments."
2107 );
2108 assert_eq!(
2109 runs_json["runs"][0]["githubReviewUrl"],
2110 "https://github.com/acme/project-a/pull/42#pullrequestreview-1001"
2111 );
2112 }
2113
2114 #[tokio::test]
2115 async fn discards_dispatch_history_for_a_task() {
2116 let directory = TempDir::new().expect("tempdir should be created");
2117 let _environment = TestEnvironment::new(&directory);
2118 let static_root = static_root(&directory);
2119 let project_repository = project_repository(&directory);
2120 register_project(&project_repository, "project-a");
2121 let task_repository = task_repository(&directory);
2122 let dispatch_repository = dispatch_repository(&directory);
2123
2124 let task = task_repository
2125 .create_task(TaskCreateInput {
2126 project: "project-a".to_owned(),
2127 priority: Priority::High,
2128 description: "Discardable dispatch".to_owned(),
2129 source: Some(TaskSource::Cli),
2130 })
2131 .expect("task should be created")
2132 .task;
2133
2134 let mut dispatch = dispatch_repository
2135 .create_dispatch(&task, "192.0.2.25", RemoteAgentPreferredTool::Codex)
2136 .expect("dispatch should be created");
2137 dispatch.status = DispatchStatus::Failed;
2138 dispatch.finished_at = Some(dispatch.updated_at);
2139 dispatch_repository
2140 .save_dispatch(&dispatch)
2141 .expect("terminal dispatch should save");
2142
2143 let app = build_app(
2144 app_state(
2145 config_service(&directory),
2146 dispatch_repository.clone(),
2147 project_repository,
2148 review_dispatch_repository(&directory),
2149 review_repository(&directory),
2150 task_repository,
2151 ),
2152 &static_root,
2153 );
2154
2155 let response = app
2156 .clone()
2157 .oneshot(
2158 Request::builder()
2159 .method("DELETE")
2160 .uri(format!("/api/tasks/{}/dispatch", task.id))
2161 .body(Body::empty())
2162 .expect("discard request should build"),
2163 )
2164 .await
2165 .expect("discard request should succeed");
2166 assert_eq!(response.status(), StatusCode::OK);
2167
2168 assert!(dispatch_repository
2169 .latest_dispatch_for_task(&task.id)
2170 .expect("latest dispatch lookup should succeed")
2171 .is_none());
2172
2173 let list_response = app
2174 .oneshot(
2175 Request::builder()
2176 .uri(format!("/api/dispatches?taskId={}", task.id))
2177 .body(Body::empty())
2178 .expect("list request should build"),
2179 )
2180 .await
2181 .expect("list request should succeed");
2182 assert_eq!(list_response.status(), StatusCode::OK);
2183 let list_body = axum::body::to_bytes(list_response.into_body(), usize::MAX)
2184 .await
2185 .expect("list response body should be readable");
2186 let list_json: serde_json::Value =
2187 serde_json::from_slice(&list_body).expect("list response should be valid json");
2188 assert_eq!(list_json["dispatches"].as_array().map(Vec::len), Some(0));
2189 }
2190
2191 #[tokio::test]
2192 async fn patches_and_deletes_tasks() {
2193 let directory = TempDir::new().expect("tempdir should be created");
2194 let _environment = TestEnvironment::new(&directory);
2195 let static_root = static_root(&directory);
2196 let project_repository = project_repository(&directory);
2197 register_project(&project_repository, "project-a");
2198 let repository = task_repository(&directory);
2199 let created = repository
2200 .create_task(TaskCreateInput {
2201 project: "project-a".to_owned(),
2202 priority: Priority::Medium,
2203 description: "Update the onboarding guide".to_owned(),
2204 source: Some(TaskSource::Web),
2205 })
2206 .expect("task should be created");
2207
2208 let app = build_app(
2209 app_state(
2210 config_service(&directory),
2211 dispatch_repository(&directory),
2212 project_repository,
2213 review_dispatch_repository(&directory),
2214 review_repository(&directory),
2215 repository,
2216 ),
2217 &static_root,
2218 );
2219
2220 let patch_response = app
2221 .clone()
2222 .oneshot(
2223 Request::builder()
2224 .method("PATCH")
2225 .uri(format!("/api/tasks/{}", created.task.id))
2226 .header("content-type", "application/json")
2227 .body(Body::from(
2228 r#"{"description":"Update the onboarding guide for Linux users","priority":"high","status":"closed"}"#,
2229 ))
2230 .expect("patch request should build"),
2231 )
2232 .await
2233 .expect("patch request should succeed");
2234 assert_eq!(patch_response.status(), StatusCode::OK);
2235
2236 let delete_response = app
2237 .oneshot(
2238 Request::builder()
2239 .method("DELETE")
2240 .uri(format!("/api/tasks/{}", created.task.id))
2241 .body(Body::empty())
2242 .expect("delete request should build"),
2243 )
2244 .await
2245 .expect("delete request should succeed");
2246 assert_eq!(delete_response.status(), StatusCode::OK);
2247 }
2248
2249 #[tokio::test]
2250 async fn bumps_task_change_version_for_notify_and_mutations() {
2251 let directory = TempDir::new().expect("tempdir should be created");
2252 let _environment = TestEnvironment::new(&directory);
2253 let static_root = static_root(&directory);
2254 let project_repository = project_repository(&directory);
2255 register_project(&project_repository, "project-a");
2256 let repository = task_repository(&directory);
2257 let created = repository
2258 .create_task(TaskCreateInput {
2259 project: "project-a".to_owned(),
2260 priority: Priority::Medium,
2261 description: "Versioned task".to_owned(),
2262 source: Some(TaskSource::Cli),
2263 })
2264 .expect("task should be created");
2265
2266 let app = build_app(
2267 app_state(
2268 config_service(&directory),
2269 dispatch_repository(&directory),
2270 project_repository,
2271 review_dispatch_repository(&directory),
2272 review_repository(&directory),
2273 repository,
2274 ),
2275 &static_root,
2276 );
2277
2278 let notify_response = app
2279 .clone()
2280 .oneshot(
2281 Request::builder()
2282 .method("POST")
2283 .uri("/api/events/tasks-changed")
2284 .body(Body::empty())
2285 .expect("notify request should build"),
2286 )
2287 .await
2288 .expect("notify request should succeed");
2289 assert_eq!(notify_response.status(), StatusCode::OK);
2290
2291 let patch_response = app
2292 .clone()
2293 .oneshot(
2294 Request::builder()
2295 .method("PATCH")
2296 .uri(format!("/api/tasks/{}", created.task.id))
2297 .header("content-type", "application/json")
2298 .body(Body::from(r#"{"status":"closed"}"#))
2299 .expect("patch request should build"),
2300 )
2301 .await
2302 .expect("patch request should succeed");
2303 assert_eq!(patch_response.status(), StatusCode::OK);
2304
2305 let version_response = app
2306 .oneshot(
2307 Request::builder()
2308 .uri("/api/events/version")
2309 .body(Body::empty())
2310 .expect("version request should build"),
2311 )
2312 .await
2313 .expect("version request should succeed");
2314 assert_eq!(version_response.status(), StatusCode::OK);
2315 let version_body = axum::body::to_bytes(version_response.into_body(), usize::MAX)
2316 .await
2317 .expect("version response body should be readable");
2318 let version_json: serde_json::Value =
2319 serde_json::from_slice(&version_body).expect("version response should be valid json");
2320 assert_eq!(version_json["version"], 2);
2321 }
2322
2323 #[tokio::test]
2324 async fn lists_and_updates_project_metadata() {
2325 let directory = TempDir::new().expect("tempdir should be created");
2326 let _environment = TestEnvironment::new(&directory);
2327 let static_root = static_root(&directory);
2328 let project_path = directory.path().join("workspace/project-a");
2329 fs::create_dir_all(project_path.join(".git")).expect("git directory should exist");
2330 fs::write(
2331 project_path.join(".git/config"),
2332 "[remote \"origin\"]\n\turl = git@github.com:acme/project-a.git\n",
2333 )
2334 .expect("git config should be written");
2335 let project_repository = project_repository(&directory);
2336 project_repository
2337 .ensure_project(&ProjectInfo {
2338 canonical_name: "project-a".to_owned(),
2339 path: project_path,
2340 aliases: vec![],
2341 })
2342 .expect("project should initialize");
2343
2344 let app = build_app(
2345 app_state(
2346 config_service(&directory),
2347 dispatch_repository(&directory),
2348 project_repository,
2349 review_dispatch_repository(&directory),
2350 review_repository(&directory),
2351 task_repository(&directory),
2352 ),
2353 &static_root,
2354 );
2355
2356 let list_response = app
2357 .clone()
2358 .oneshot(
2359 Request::builder()
2360 .uri("/api/projects")
2361 .body(Body::empty())
2362 .expect("list request should build"),
2363 )
2364 .await
2365 .expect("list request should succeed");
2366 assert_eq!(list_response.status(), StatusCode::OK);
2367 let list_body = axum::body::to_bytes(list_response.into_body(), usize::MAX)
2368 .await
2369 .expect("list response body should be readable");
2370 let list_json: serde_json::Value =
2371 serde_json::from_slice(&list_body).expect("list response should be valid json");
2372 assert_eq!(
2373 list_json["projects"][0]["metadata"]["repoUrl"],
2374 "https://github.com/acme/project-a"
2375 );
2376 assert_eq!(list_json["projects"][0]["metadata"]["baseBranch"], "main");
2377
2378 let patch_response = app
2379 .oneshot(
2380 Request::builder()
2381 .method("PATCH")
2382 .uri("/api/projects/project-a")
2383 .header("content-type", "application/json")
2384 .body(Body::from(
2385 r#"{"repoUrl":"https://github.com/acme/project-a","gitUrl":"git@github.com:acme/project-a.git","baseBranch":"release","description":"Release coordination repository."}"#,
2386 ))
2387 .expect("patch request should build"),
2388 )
2389 .await
2390 .expect("patch request should succeed");
2391 assert_eq!(patch_response.status(), StatusCode::OK);
2392 let patch_body = axum::body::to_bytes(patch_response.into_body(), usize::MAX)
2393 .await
2394 .expect("patch response body should be readable");
2395 let patch_json: serde_json::Value =
2396 serde_json::from_slice(&patch_body).expect("patch response should be valid json");
2397 assert_eq!(patch_json["metadata"]["baseBranch"], "release");
2398 assert_eq!(
2399 patch_json["metadata"]["description"],
2400 "Release coordination repository."
2401 );
2402 }
2403
2404 #[tokio::test]
2405 async fn rejects_task_creation_for_unknown_projects() {
2406 let directory = TempDir::new().expect("tempdir should be created");
2407 let _environment = TestEnvironment::new(&directory);
2408 let static_root = static_root(&directory);
2409 let project_repository = project_repository(&directory);
2410 let task_repository = task_repository(&directory);
2411
2412 let app = build_app(
2413 app_state(
2414 config_service(&directory),
2415 dispatch_repository(&directory),
2416 project_repository,
2417 review_dispatch_repository(&directory),
2418 review_repository(&directory),
2419 task_repository,
2420 ),
2421 &static_root,
2422 );
2423
2424 let response = app
2425 .oneshot(
2426 Request::builder()
2427 .method("POST")
2428 .uri("/api/tasks")
2429 .header("content-type", "application/json")
2430 .body(Body::from(
2431 r#"{"project":"project-a","priority":"medium","description":"This project does not exist yet"}"#,
2432 ))
2433 .expect("request should build"),
2434 )
2435 .await
2436 .expect("request should succeed");
2437 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2438 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2439 .await
2440 .expect("response body should be readable");
2441 let json: serde_json::Value =
2442 serde_json::from_slice(&body).expect("response should be valid json");
2443 assert_eq!(json["error"]["code"], "PROJECT_NOT_FOUND");
2444 }
2445
2446 #[tokio::test]
2447 async fn gets_and_updates_remote_agent_shell_prelude() {
2448 let directory = TempDir::new().expect("tempdir should be created");
2449 let _environment = TestEnvironment::new(&directory);
2450 let static_root = static_root(&directory);
2451 let config_service = configured_remote_agent_config_service(&directory);
2452 let project_repository = project_repository(&directory);
2453
2454 let app = build_app(
2455 app_state(
2456 config_service,
2457 dispatch_repository(&directory),
2458 project_repository,
2459 review_dispatch_repository(&directory),
2460 review_repository(&directory),
2461 task_repository(&directory),
2462 ),
2463 &static_root,
2464 );
2465
2466 let get_response = app
2467 .clone()
2468 .oneshot(
2469 Request::builder()
2470 .uri("/api/remote-agent")
2471 .body(Body::empty())
2472 .expect("get request should build"),
2473 )
2474 .await
2475 .expect("get request should succeed");
2476 assert_eq!(get_response.status(), StatusCode::OK);
2477 let get_body = axum::body::to_bytes(get_response.into_body(), usize::MAX)
2478 .await
2479 .expect("get response body should be readable");
2480 let get_json: serde_json::Value =
2481 serde_json::from_slice(&get_body).expect("get response should be valid json");
2482 assert_eq!(get_json["configured"], true);
2483 assert_eq!(get_json["preferredTool"], "codex");
2484 assert_eq!(get_json["shellPrelude"], ". \"$HOME/.cargo/env\"");
2485 assert_eq!(get_json["reviewFollowUp"]["enabled"], false);
2486
2487 let patch_response = app
2488 .oneshot(
2489 Request::builder()
2490 .method("PATCH")
2491 .uri("/api/remote-agent")
2492 .header("content-type", "application/json")
2493 .body(Body::from(
2494 r#"{"preferredTool":"claude","shellPrelude":"export NVM_DIR=\"$HOME/.nvm\"\n. \"$HOME/.cargo/env\"","reviewFollowUp":{"enabled":true,"mainUser":"octocat","defaultReviewPrompt":"Focus on regressions and missing tests."}}"#,
2495 ))
2496 .expect("patch request should build"),
2497 )
2498 .await
2499 .expect("patch request should succeed");
2500 assert_eq!(patch_response.status(), StatusCode::OK);
2501 let patch_body = axum::body::to_bytes(patch_response.into_body(), usize::MAX)
2502 .await
2503 .expect("patch response body should be readable");
2504 let patch_json: serde_json::Value =
2505 serde_json::from_slice(&patch_body).expect("patch response should be valid json");
2506 assert_eq!(patch_json["preferredTool"], "claude");
2507 assert_eq!(
2508 patch_json["shellPrelude"],
2509 "export NVM_DIR=\"$HOME/.nvm\"\n. \"$HOME/.cargo/env\""
2510 );
2511 assert_eq!(patch_json["reviewFollowUp"]["enabled"], true);
2512 assert_eq!(patch_json["reviewFollowUp"]["mainUser"], "octocat");
2513 assert_eq!(
2514 patch_json["reviewFollowUp"]["defaultReviewPrompt"],
2515 "Focus on regressions and missing tests."
2516 );
2517 }
2518
2519 #[tokio::test]
2520 async fn puts_remote_agent_config_for_a_fresh_install() {
2521 let directory = TempDir::new().expect("tempdir should be created");
2522 let _environment = TestEnvironment::new(&directory);
2523 let static_root = static_root(&directory);
2524 let config_service = config_service(&directory);
2525
2526 let app = build_app(
2527 app_state(
2528 config_service,
2529 dispatch_repository(&directory),
2530 project_repository(&directory),
2531 review_dispatch_repository(&directory),
2532 review_repository(&directory),
2533 task_repository(&directory),
2534 ),
2535 &static_root,
2536 );
2537
2538 let response = app
2539 .oneshot(
2540 Request::builder()
2541 .method("PUT")
2542 .uri("/api/remote-agent")
2543 .header("content-type", "application/json")
2544 .body(Body::from(
2545 r#"{"host":"192.0.2.25","user":"builder","port":22,"workspaceRoot":"~/workspace","projectsRegistryPath":"~/track-projects.json","preferredTool":"claude","shellPrelude":"export PATH=\"$HOME/.cargo/bin:$PATH\"","reviewFollowUp":{"enabled":false,"mainUser":"octocat","defaultReviewPrompt":"Focus on regressions."},"sshPrivateKey":"-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----\n","knownHosts":"github.com ssh-ed25519 AAAA"}"#,
2546 ))
2547 .expect("put request should build"),
2548 )
2549 .await
2550 .expect("put request should succeed");
2551 assert_eq!(response.status(), StatusCode::OK);
2552 let response_body = axum::body::to_bytes(response.into_body(), usize::MAX)
2553 .await
2554 .expect("put response body should be readable");
2555 let response_json: serde_json::Value =
2556 serde_json::from_slice(&response_body).expect("put response should be valid json");
2557 assert_eq!(response_json["preferredTool"], "claude");
2558
2559 let key_path =
2560 get_backend_managed_remote_agent_key_path().expect("managed key path should resolve");
2561 let known_hosts_path = get_backend_managed_remote_agent_known_hosts_path()
2562 .expect("managed known_hosts path should resolve");
2563 assert_eq!(
2564 fs::read_to_string(&key_path).expect("managed key should be readable"),
2565 "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----\n"
2566 );
2567 assert_eq!(
2568 fs::read_to_string(&known_hosts_path).expect("known_hosts should be readable"),
2569 "github.com ssh-ed25519 AAAA"
2570 );
2571 }
2572}