Skip to main content

track_api/
app.rs

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                // Runs are persisted separately from task files. If a task was
636                // deleted later, we prefer to hide that orphaned run from the
637                // normal UI instead of turning the whole page into an error.
638                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
750// =============================================================================
751// Dispatch Query Parsing
752// =============================================================================
753//
754// The frontend sends `/api/dispatches?taskId=...&taskId=...` so the browser can
755// ask for many task rows in one request. Axum's serde-based query extractor is
756// strict here and rejects a plain repeated scalar as "expected a sequence", so
757// we parse the raw query ourselves instead of making the UI change shape.
758//
759// Task ids are filesystem-derived slugs, so we intentionally keep this parser
760// narrow and only extract repeated `taskId=` entries. A full percent-decoding
761// query parser would add complexity without buying us anything for this domain.
762// TODO: Expand this helper if dispatch lookups ever need arbitrary free-form ids.
763fn 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    // The deployed app still serves both API routes and the frontend from one
1308    // process so Docker can expose a single local port.
1309    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    // The migration release has two distinct backend modes. Migration routes
1315    // stay available at all times so the UI and CLI can recover gracefully,
1316    // while the rest of the API refuses normal work until the legacy import
1317    // finishes.
1318    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}