Skip to main content

hivemind/
server.rs

1use crate::cli::output::CliResponse;
2use crate::core::error::{HivemindError, Result};
3use crate::core::events::{CorrelationIds, Event, EventPayload};
4use crate::core::flow::RetryMode;
5use crate::core::registry::Registry;
6use crate::core::state::{MergeState, Project, Task};
7use crate::core::verification::CheckConfig;
8use crate::core::{flow::TaskFlow, graph::TaskGraph};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use std::collections::HashMap;
13
14#[derive(Debug, Clone)]
15pub struct ServeConfig {
16    pub host: String,
17    pub port: u16,
18    pub events_limit: usize,
19}
20
21impl Default for ServeConfig {
22    fn default() -> Self {
23        Self {
24            host: "127.0.0.1".to_string(),
25            port: 8787,
26            events_limit: 200,
27        }
28    }
29}
30
31pub fn handle_api_request(
32    method: ApiMethod,
33    url: &str,
34    default_events_limit: usize,
35    body: Option<&[u8]>,
36) -> Result<ApiResponse> {
37    let registry = Registry::open()?;
38    handle_api_request_inner(method, url, default_events_limit, body, &registry)
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ApiMethod {
43    Get,
44    Post,
45    Options,
46}
47
48impl ApiMethod {
49    fn from_http(method: &tiny_http::Method) -> Option<Self> {
50        match method {
51            tiny_http::Method::Get => Some(Self::Get),
52            tiny_http::Method::Post => Some(Self::Post),
53            tiny_http::Method::Options => Some(Self::Options),
54            _ => None,
55        }
56    }
57}
58
59#[derive(Debug, Clone)]
60pub struct ApiResponse {
61    pub status_code: u16,
62    pub content_type: &'static str,
63    pub body: Vec<u8>,
64    pub extra_headers: Vec<tiny_http::Header>,
65}
66
67impl ApiResponse {
68    fn json<T: Serialize>(status_code: u16, value: &T) -> Result<Self> {
69        let body = serde_json::to_vec_pretty(value).map_err(|e| {
70            HivemindError::system("json_serialize_failed", e.to_string(), "server:json")
71        })?;
72        Ok(Self {
73            status_code,
74            content_type: "application/json",
75            body,
76            extra_headers: Vec::new(),
77        })
78    }
79
80    fn text(status_code: u16, content_type: &'static str, body: impl Into<Vec<u8>>) -> Self {
81        Self {
82            status_code,
83            content_type,
84            body: body.into(),
85            extra_headers: Vec::new(),
86        }
87    }
88}
89
90#[derive(Debug, Serialize)]
91pub struct UiState {
92    pub projects: Vec<Project>,
93    pub tasks: Vec<Task>,
94    pub graphs: Vec<TaskGraph>,
95    pub flows: Vec<TaskFlow>,
96    pub merge_states: Vec<MergeState>,
97    pub events: Vec<UiEvent>,
98}
99
100#[derive(Debug, Serialize)]
101pub struct UiEvent {
102    pub id: String,
103    pub r#type: String,
104    pub category: String,
105    pub timestamp: DateTime<Utc>,
106    pub sequence: Option<u64>,
107    pub correlation: CorrelationIds,
108    pub payload: HashMap<String, Value>,
109}
110
111fn payload_pascal_type(payload: &EventPayload) -> &'static str {
112    match payload {
113        EventPayload::ErrorOccurred { .. } => "ErrorOccurred",
114        EventPayload::ProjectCreated { .. } => "ProjectCreated",
115        EventPayload::ProjectUpdated { .. } => "ProjectUpdated",
116        EventPayload::ProjectRuntimeConfigured { .. } => "ProjectRuntimeConfigured",
117        EventPayload::RepositoryAttached { .. } => "RepositoryAttached",
118        EventPayload::RepositoryDetached { .. } => "RepositoryDetached",
119        EventPayload::TaskCreated { .. } => "TaskCreated",
120        EventPayload::TaskUpdated { .. } => "TaskUpdated",
121        EventPayload::TaskRuntimeConfigured { .. } => "TaskRuntimeConfigured",
122        EventPayload::TaskRuntimeCleared { .. } => "TaskRuntimeCleared",
123        EventPayload::TaskClosed { .. } => "TaskClosed",
124        EventPayload::TaskGraphCreated { .. } => "TaskGraphCreated",
125        EventPayload::TaskAddedToGraph { .. } => "TaskAddedToGraph",
126        EventPayload::DependencyAdded { .. } => "DependencyAdded",
127        EventPayload::GraphTaskCheckAdded { .. } => "GraphTaskCheckAdded",
128        EventPayload::ScopeAssigned { .. } => "ScopeAssigned",
129        EventPayload::TaskGraphValidated { .. } => "TaskGraphValidated",
130        EventPayload::TaskGraphLocked { .. } => "TaskGraphLocked",
131        EventPayload::TaskFlowCreated { .. } => "TaskFlowCreated",
132        EventPayload::TaskFlowStarted { .. } => "TaskFlowStarted",
133        EventPayload::TaskFlowPaused { .. } => "TaskFlowPaused",
134        EventPayload::TaskFlowResumed { .. } => "TaskFlowResumed",
135        EventPayload::TaskFlowCompleted { .. } => "TaskFlowCompleted",
136        EventPayload::TaskFlowAborted { .. } => "TaskFlowAborted",
137        EventPayload::TaskReady { .. } => "TaskReady",
138        EventPayload::TaskBlocked { .. } => "TaskBlocked",
139        EventPayload::ScopeConflictDetected { .. } => "ScopeConflictDetected",
140        EventPayload::TaskSchedulingDeferred { .. } => "TaskSchedulingDeferred",
141        EventPayload::TaskExecutionStateChanged { .. } => "TaskExecutionStateChanged",
142        EventPayload::TaskExecutionStarted { .. } => "TaskExecutionStarted",
143        EventPayload::TaskExecutionSucceeded { .. } => "TaskExecutionSucceeded",
144        EventPayload::TaskExecutionFailed { .. } => "TaskExecutionFailed",
145        EventPayload::AttemptStarted { .. } => "AttemptStarted",
146        EventPayload::BaselineCaptured { .. } => "BaselineCaptured",
147        EventPayload::FileModified { .. } => "FileModified",
148        EventPayload::DiffComputed { .. } => "DiffComputed",
149        EventPayload::CheckStarted { .. } => "CheckStarted",
150        EventPayload::CheckCompleted { .. } => "CheckCompleted",
151        EventPayload::MergeCheckStarted { .. } => "MergeCheckStarted",
152        EventPayload::MergeCheckCompleted { .. } => "MergeCheckCompleted",
153        EventPayload::CheckpointDeclared { .. } => "CheckpointDeclared",
154        EventPayload::CheckpointActivated { .. } => "CheckpointActivated",
155        EventPayload::CheckpointCompleted { .. } => "CheckpointCompleted",
156        EventPayload::AllCheckpointsCompleted { .. } => "AllCheckpointsCompleted",
157        EventPayload::CheckpointCommitCreated { .. } => "CheckpointCommitCreated",
158        EventPayload::ScopeValidated { .. } => "ScopeValidated",
159        EventPayload::ScopeViolationDetected { .. } => "ScopeViolationDetected",
160        EventPayload::RetryContextAssembled { .. } => "RetryContextAssembled",
161        EventPayload::TaskRetryRequested { .. } => "TaskRetryRequested",
162        EventPayload::TaskAborted { .. } => "TaskAborted",
163        EventPayload::HumanOverride { .. } => "HumanOverride",
164        EventPayload::MergePrepared { .. } => "MergePrepared",
165        EventPayload::TaskExecutionFrozen { .. } => "TaskExecutionFrozen",
166        EventPayload::TaskIntegratedIntoFlow { .. } => "TaskIntegratedIntoFlow",
167        EventPayload::MergeConflictDetected { .. } => "MergeConflictDetected",
168        EventPayload::FlowFrozenForMerge { .. } => "FlowFrozenForMerge",
169        EventPayload::FlowIntegrationLockAcquired { .. } => "FlowIntegrationLockAcquired",
170        EventPayload::MergeApproved { .. } => "MergeApproved",
171        EventPayload::MergeCompleted { .. } => "MergeCompleted",
172        EventPayload::RuntimeStarted { .. } => "RuntimeStarted",
173        EventPayload::RuntimeOutputChunk { .. } => "RuntimeOutputChunk",
174        EventPayload::RuntimeInputProvided { .. } => "RuntimeInputProvided",
175        EventPayload::RuntimeInterrupted { .. } => "RuntimeInterrupted",
176        EventPayload::RuntimeExited { .. } => "RuntimeExited",
177        EventPayload::RuntimeTerminated { .. } => "RuntimeTerminated",
178        EventPayload::RuntimeFilesystemObserved { .. } => "RuntimeFilesystemObserved",
179        EventPayload::RuntimeCommandObserved { .. } => "RuntimeCommandObserved",
180        EventPayload::RuntimeToolCallObserved { .. } => "RuntimeToolCallObserved",
181        EventPayload::RuntimeTodoSnapshotUpdated { .. } => "RuntimeTodoSnapshotUpdated",
182        EventPayload::RuntimeNarrativeOutputObserved { .. } => "RuntimeNarrativeOutputObserved",
183        EventPayload::Unknown => "Unknown",
184    }
185}
186
187fn payload_category(payload: &EventPayload) -> &'static str {
188    match payload {
189        EventPayload::ErrorOccurred { .. } => "error",
190
191        EventPayload::ProjectCreated { .. }
192        | EventPayload::ProjectUpdated { .. }
193        | EventPayload::ProjectRuntimeConfigured { .. }
194        | EventPayload::RepositoryAttached { .. }
195        | EventPayload::RepositoryDetached { .. } => "project",
196
197        EventPayload::TaskCreated { .. }
198        | EventPayload::TaskUpdated { .. }
199        | EventPayload::TaskRuntimeConfigured { .. }
200        | EventPayload::TaskRuntimeCleared { .. }
201        | EventPayload::TaskClosed { .. } => "task",
202
203        EventPayload::TaskGraphCreated { .. }
204        | EventPayload::TaskAddedToGraph { .. }
205        | EventPayload::DependencyAdded { .. }
206        | EventPayload::GraphTaskCheckAdded { .. }
207        | EventPayload::ScopeAssigned { .. }
208        | EventPayload::TaskGraphValidated { .. }
209        | EventPayload::TaskGraphLocked { .. } => "graph",
210
211        EventPayload::TaskFlowCreated { .. }
212        | EventPayload::TaskFlowStarted { .. }
213        | EventPayload::TaskFlowPaused { .. }
214        | EventPayload::TaskFlowResumed { .. }
215        | EventPayload::TaskFlowCompleted { .. }
216        | EventPayload::TaskFlowAborted { .. } => "flow",
217
218        EventPayload::TaskReady { .. }
219        | EventPayload::TaskBlocked { .. }
220        | EventPayload::ScopeConflictDetected { .. }
221        | EventPayload::TaskSchedulingDeferred { .. }
222        | EventPayload::TaskExecutionStateChanged { .. }
223        | EventPayload::TaskExecutionStarted { .. }
224        | EventPayload::TaskExecutionSucceeded { .. }
225        | EventPayload::TaskExecutionFailed { .. }
226        | EventPayload::AttemptStarted { .. }
227        | EventPayload::CheckpointDeclared { .. }
228        | EventPayload::CheckpointActivated { .. }
229        | EventPayload::CheckpointCompleted { .. }
230        | EventPayload::AllCheckpointsCompleted { .. }
231        | EventPayload::RetryContextAssembled { .. }
232        | EventPayload::TaskRetryRequested { .. }
233        | EventPayload::TaskAborted { .. } => "execution",
234
235        EventPayload::CheckStarted { .. }
236        | EventPayload::CheckCompleted { .. }
237        | EventPayload::HumanOverride { .. } => "verification",
238
239        EventPayload::ScopeValidated { .. } | EventPayload::ScopeViolationDetected { .. } => {
240            "scope"
241        }
242
243        EventPayload::MergePrepared { .. }
244        | EventPayload::MergeCheckStarted { .. }
245        | EventPayload::MergeCheckCompleted { .. }
246        | EventPayload::TaskExecutionFrozen { .. }
247        | EventPayload::TaskIntegratedIntoFlow { .. }
248        | EventPayload::MergeConflictDetected { .. }
249        | EventPayload::FlowFrozenForMerge { .. }
250        | EventPayload::FlowIntegrationLockAcquired { .. }
251        | EventPayload::MergeApproved { .. }
252        | EventPayload::MergeCompleted { .. } => "merge",
253
254        EventPayload::RuntimeStarted { .. }
255        | EventPayload::RuntimeOutputChunk { .. }
256        | EventPayload::RuntimeInputProvided { .. }
257        | EventPayload::RuntimeInterrupted { .. }
258        | EventPayload::RuntimeExited { .. }
259        | EventPayload::RuntimeTerminated { .. }
260        | EventPayload::RuntimeFilesystemObserved { .. }
261        | EventPayload::RuntimeCommandObserved { .. }
262        | EventPayload::RuntimeToolCallObserved { .. }
263        | EventPayload::RuntimeTodoSnapshotUpdated { .. }
264        | EventPayload::RuntimeNarrativeOutputObserved { .. }
265        | EventPayload::Unknown => "runtime",
266
267        EventPayload::FileModified { .. }
268        | EventPayload::DiffComputed { .. }
269        | EventPayload::CheckpointCommitCreated { .. }
270        | EventPayload::BaselineCaptured { .. } => "filesystem",
271    }
272}
273
274fn payload_map(payload: &EventPayload) -> Result<HashMap<String, Value>> {
275    let mut v = serde_json::to_value(payload).map_err(|e| {
276        HivemindError::system(
277            "payload_serialize_failed",
278            e.to_string(),
279            "server:payload_map",
280        )
281    })?;
282
283    match &mut v {
284        Value::Object(map) => {
285            map.remove("type");
286            Ok(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
287        }
288        _ => Ok(HashMap::new()),
289    }
290}
291
292fn ui_event(event: &Event) -> Result<UiEvent> {
293    Ok(UiEvent {
294        id: event.metadata.id.to_string(),
295        r#type: payload_pascal_type(&event.payload).to_string(),
296        category: payload_category(&event.payload).to_string(),
297        timestamp: event.metadata.timestamp,
298        sequence: event.metadata.sequence,
299        correlation: event.metadata.correlation.clone(),
300        payload: payload_map(&event.payload)?,
301    })
302}
303
304fn build_ui_state(registry: &Registry, events_limit: usize) -> Result<UiState> {
305    let state = registry.state()?;
306
307    let mut projects: Vec<Project> = state.projects.into_values().collect();
308    projects.sort_by(|a, b| a.name.cmp(&b.name));
309
310    let mut tasks: Vec<Task> = state.tasks.into_values().collect();
311    tasks.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
312    tasks.reverse();
313
314    let mut graphs: Vec<TaskGraph> = state.graphs.into_values().collect();
315    graphs.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
316    graphs.reverse();
317
318    let mut flows: Vec<TaskFlow> = state.flows.into_values().collect();
319    flows.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
320    flows.reverse();
321
322    let mut merge_states: Vec<MergeState> = state.merge_states.into_values().collect();
323    merge_states.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
324    merge_states.reverse();
325
326    let events = registry.list_events(None, events_limit)?;
327    let mut ui_events: Vec<UiEvent> = events.iter().map(ui_event).collect::<Result<_>>()?;
328    ui_events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
329    ui_events.reverse();
330
331    Ok(UiState {
332        projects,
333        tasks,
334        graphs,
335        flows,
336        merge_states,
337        events: ui_events,
338    })
339}
340
341fn parse_query(url: &str) -> HashMap<String, String> {
342    let mut out = HashMap::new();
343    let Some((_path, qs)) = url.split_once('?') else {
344        return out;
345    };
346
347    for part in qs.split('&') {
348        if part.trim().is_empty() {
349            continue;
350        }
351
352        let (k, v) = part.split_once('=').unwrap_or((part, ""));
353        out.insert(k.to_string(), v.to_string());
354    }
355
356    out
357}
358
359fn cors_headers() -> Vec<tiny_http::Header> {
360    vec![
361        tiny_http::Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..])
362            .expect("static header"),
363        tiny_http::Header::from_bytes(
364            &b"Access-Control-Allow-Methods"[..],
365            &b"GET, POST, OPTIONS"[..],
366        )
367        .expect("static header"),
368        tiny_http::Header::from_bytes(&b"Access-Control-Allow-Headers"[..], &b"Content-Type"[..])
369            .expect("static header"),
370    ]
371}
372
373fn parse_json_body<T: for<'de> Deserialize<'de>>(body: Option<&[u8]>, origin: &str) -> Result<T> {
374    let raw = body.ok_or_else(|| {
375        HivemindError::user("request_body_required", "Request body is required", origin)
376    })?;
377
378    serde_json::from_slice(raw).map_err(|e| {
379        HivemindError::user(
380            "invalid_json_body",
381            format!("Invalid JSON body: {e}"),
382            origin,
383        )
384    })
385}
386
387fn method_not_allowed(path: &str, method: ApiMethod, allowed: &'static str) -> Result<ApiResponse> {
388    let err = HivemindError::user(
389        "method_not_allowed",
390        format!("Method '{method:?}' is not allowed for '{path}'"),
391        "server:handle_api_request",
392    )
393    .with_hint(format!("Allowed method(s): {allowed}"));
394    let wrapped = CliResponse::<()>::error(&err);
395    let mut resp = ApiResponse::json(405, &wrapped)?;
396    resp.extra_headers.extend(cors_headers());
397    Ok(resp)
398}
399
400#[derive(Debug, Deserialize)]
401struct ProjectCreateRequest {
402    name: String,
403    description: Option<String>,
404}
405
406#[derive(Debug, Deserialize)]
407struct ProjectUpdateRequest {
408    project: String,
409    name: Option<String>,
410    description: Option<String>,
411}
412
413#[derive(Debug, Deserialize)]
414struct ProjectRuntimeRequest {
415    project: String,
416    adapter: Option<String>,
417    binary_path: Option<String>,
418    model: Option<String>,
419    args: Option<Vec<String>>,
420    env: Option<HashMap<String, String>>,
421    timeout_ms: Option<u64>,
422    max_parallel_tasks: Option<u16>,
423}
424
425#[derive(Debug, Deserialize)]
426struct ProjectAttachRepoRequest {
427    project: String,
428    path: String,
429    name: Option<String>,
430    access: Option<String>,
431}
432
433#[derive(Debug, Deserialize)]
434struct ProjectDetachRepoRequest {
435    project: String,
436    repo_name: String,
437}
438
439#[derive(Debug, Deserialize)]
440struct TaskCreateRequest {
441    project: String,
442    title: String,
443    description: Option<String>,
444    scope: Option<crate::core::scope::Scope>,
445}
446
447#[derive(Debug, Deserialize)]
448struct TaskUpdateRequest {
449    task_id: String,
450    title: Option<String>,
451    description: Option<String>,
452}
453
454#[derive(Debug, Deserialize)]
455struct TaskCloseRequest {
456    task_id: String,
457    reason: Option<String>,
458}
459
460#[derive(Debug, Deserialize)]
461struct TaskIdRequest {
462    task_id: String,
463}
464
465#[derive(Debug, Deserialize)]
466struct TaskRetryRequest {
467    task_id: String,
468    reset_count: Option<bool>,
469    mode: Option<String>,
470}
471
472#[derive(Debug, Deserialize)]
473struct TaskAbortRequest {
474    task_id: String,
475    reason: Option<String>,
476}
477
478#[derive(Debug, Deserialize)]
479struct GraphCreateRequest {
480    project: String,
481    name: String,
482    from_tasks: Vec<String>,
483}
484
485#[derive(Debug, Deserialize)]
486struct GraphDependencyRequest {
487    graph_id: String,
488    from_task: String,
489    to_task: String,
490}
491
492#[derive(Debug, Deserialize)]
493struct GraphAddCheckRequest {
494    graph_id: String,
495    task_id: String,
496    name: String,
497    command: String,
498    required: Option<bool>,
499    timeout_ms: Option<u64>,
500}
501
502#[derive(Debug, Deserialize)]
503struct GraphValidateRequest {
504    graph_id: String,
505}
506
507#[derive(Debug, Deserialize)]
508struct FlowCreateRequest {
509    graph_id: String,
510    name: Option<String>,
511}
512
513#[derive(Debug, Deserialize)]
514struct FlowIdRequest {
515    flow_id: String,
516}
517
518#[derive(Debug, Deserialize)]
519struct FlowTickRequest {
520    flow_id: String,
521    interactive: Option<bool>,
522    max_parallel: Option<u16>,
523}
524
525#[derive(Debug, Deserialize)]
526struct FlowAbortRequest {
527    flow_id: String,
528    reason: Option<String>,
529    force: Option<bool>,
530}
531
532#[derive(Debug, Deserialize)]
533struct VerifyOverrideRequest {
534    task_id: String,
535    decision: String,
536    reason: String,
537}
538
539#[derive(Debug, Deserialize)]
540struct VerifyRunRequest {
541    task_id: String,
542}
543
544#[derive(Debug, Deserialize)]
545struct MergePrepareRequest {
546    flow_id: String,
547    target: Option<String>,
548}
549
550#[derive(Debug, Deserialize)]
551struct MergeApproveRequest {
552    flow_id: String,
553}
554
555#[derive(Debug, Deserialize)]
556struct MergeExecuteRequest {
557    flow_id: String,
558}
559
560#[derive(Debug, Deserialize)]
561struct CheckpointCompleteRequest {
562    attempt_id: String,
563    checkpoint_id: String,
564    summary: Option<String>,
565}
566
567#[derive(Debug, Deserialize)]
568struct WorktreeCleanupRequest {
569    flow_id: String,
570}
571
572#[derive(Debug, Serialize)]
573struct VerifyResultsView {
574    attempt_id: String,
575    task_id: String,
576    flow_id: String,
577    attempt_number: u32,
578    check_results: Vec<Value>,
579}
580
581#[derive(Debug, Serialize)]
582struct AttemptInspectView {
583    attempt_id: String,
584    task_id: String,
585    flow_id: String,
586    attempt_number: u32,
587    started_at: DateTime<Utc>,
588    baseline_id: Option<String>,
589    diff_id: Option<String>,
590    diff: Option<String>,
591}
592
593#[derive(Debug, Serialize)]
594struct ApiCatalog {
595    read_endpoints: Vec<&'static str>,
596    write_endpoints: Vec<&'static str>,
597}
598
599fn api_catalog() -> ApiCatalog {
600    ApiCatalog {
601        read_endpoints: vec![
602            "/api/version",
603            "/api/state",
604            "/api/projects",
605            "/api/tasks",
606            "/api/graphs",
607            "/api/flows",
608            "/api/merges",
609            "/api/events",
610            "/api/events/inspect?event_id=<id>",
611            "/api/verify/results?attempt_id=<id>&output=true|false",
612            "/api/attempts/inspect?attempt_id=<id>&diff=true|false",
613            "/api/attempts/diff?attempt_id=<id>",
614            "/api/flows/replay?flow_id=<id>",
615            "/api/worktrees?flow_id=<id>",
616            "/api/worktrees/inspect?task_id=<id>",
617        ],
618        write_endpoints: vec![
619            "/api/projects/create",
620            "/api/projects/update",
621            "/api/projects/runtime",
622            "/api/projects/repos/attach",
623            "/api/projects/repos/detach",
624            "/api/tasks/create",
625            "/api/tasks/update",
626            "/api/tasks/close",
627            "/api/tasks/start",
628            "/api/tasks/complete",
629            "/api/tasks/retry",
630            "/api/tasks/abort",
631            "/api/graphs/create",
632            "/api/graphs/dependencies/add",
633            "/api/graphs/checks/add",
634            "/api/graphs/validate",
635            "/api/flows/create",
636            "/api/flows/start",
637            "/api/flows/tick",
638            "/api/flows/pause",
639            "/api/flows/resume",
640            "/api/flows/abort",
641            "/api/verify/override",
642            "/api/verify/run",
643            "/api/merge/prepare",
644            "/api/merge/approve",
645            "/api/merge/execute",
646            "/api/checkpoints/complete",
647            "/api/worktrees/cleanup",
648        ],
649    }
650}
651
652fn list_tasks(registry: &Registry) -> Result<Vec<Task>> {
653    let state = registry.state()?;
654    let mut tasks: Vec<Task> = state.tasks.into_values().collect();
655    tasks.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
656    tasks.reverse();
657    Ok(tasks)
658}
659
660fn list_graphs(registry: &Registry) -> Result<Vec<TaskGraph>> {
661    let state = registry.state()?;
662    let mut graphs: Vec<TaskGraph> = state.graphs.into_values().collect();
663    graphs.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
664    graphs.reverse();
665    Ok(graphs)
666}
667
668fn list_flows(registry: &Registry) -> Result<Vec<TaskFlow>> {
669    let state = registry.state()?;
670    let mut flows: Vec<TaskFlow> = state.flows.into_values().collect();
671    flows.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
672    flows.reverse();
673    Ok(flows)
674}
675
676fn list_merge_states(registry: &Registry) -> Result<Vec<MergeState>> {
677    let state = registry.state()?;
678    let mut merges: Vec<MergeState> = state.merge_states.into_values().collect();
679    merges.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
680    merges.reverse();
681    Ok(merges)
682}
683
684fn list_ui_events(registry: &Registry, limit: usize) -> Result<Vec<UiEvent>> {
685    let events = registry.list_events(None, limit)?;
686    let mut ui_events: Vec<UiEvent> = events.iter().map(ui_event).collect::<Result<_>>()?;
687    ui_events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
688    ui_events.reverse();
689    Ok(ui_events)
690}
691
692#[allow(clippy::too_many_lines)]
693fn handle_api_request_inner(
694    method: ApiMethod,
695    url: &str,
696    default_events_limit: usize,
697    body: Option<&[u8]>,
698    registry: &Registry,
699) -> Result<ApiResponse> {
700    if method == ApiMethod::Options {
701        let mut resp = ApiResponse::text(204, "text/plain", "");
702        resp.extra_headers.extend(cors_headers());
703        return Ok(resp);
704    }
705
706    let (path, _qs) = url.split_once('?').unwrap_or((url, ""));
707
708    match path {
709        "/health" if method == ApiMethod::Get => {
710            let mut resp = ApiResponse::text(200, "text/plain", "ok\n");
711            resp.extra_headers.extend(cors_headers());
712            Ok(resp)
713        }
714        "/api/version" if method == ApiMethod::Get => {
715            let value = serde_json::json!({"version": env!("CARGO_PKG_VERSION")});
716            let wrapped = CliResponse::success(value);
717            let mut resp = ApiResponse::json(200, &wrapped)?;
718            resp.extra_headers.extend(cors_headers());
719            Ok(resp)
720        }
721        "/api/catalog" if method == ApiMethod::Get => {
722            let wrapped = CliResponse::success(api_catalog());
723            let mut resp = ApiResponse::json(200, &wrapped)?;
724            resp.extra_headers.extend(cors_headers());
725            Ok(resp)
726        }
727        "/api/state" if method == ApiMethod::Get => {
728            let query = parse_query(url);
729            let events_limit = query
730                .get("events_limit")
731                .and_then(|v| v.parse::<usize>().ok())
732                .unwrap_or(default_events_limit);
733
734            let state = build_ui_state(registry, events_limit)?;
735            let wrapped = CliResponse::success(state);
736            let mut resp = ApiResponse::json(200, &wrapped)?;
737            resp.extra_headers.extend(cors_headers());
738            Ok(resp)
739        }
740        "/api/projects" if method == ApiMethod::Get => {
741            let wrapped = CliResponse::success(registry.list_projects()?);
742            let mut resp = ApiResponse::json(200, &wrapped)?;
743            resp.extra_headers.extend(cors_headers());
744            Ok(resp)
745        }
746        "/api/tasks" if method == ApiMethod::Get => {
747            let wrapped = CliResponse::success(list_tasks(registry)?);
748            let mut resp = ApiResponse::json(200, &wrapped)?;
749            resp.extra_headers.extend(cors_headers());
750            Ok(resp)
751        }
752        "/api/graphs" if method == ApiMethod::Get => {
753            let wrapped = CliResponse::success(list_graphs(registry)?);
754            let mut resp = ApiResponse::json(200, &wrapped)?;
755            resp.extra_headers.extend(cors_headers());
756            Ok(resp)
757        }
758        "/api/flows" if method == ApiMethod::Get => {
759            let wrapped = CliResponse::success(list_flows(registry)?);
760            let mut resp = ApiResponse::json(200, &wrapped)?;
761            resp.extra_headers.extend(cors_headers());
762            Ok(resp)
763        }
764        "/api/merges" if method == ApiMethod::Get => {
765            let wrapped = CliResponse::success(list_merge_states(registry)?);
766            let mut resp = ApiResponse::json(200, &wrapped)?;
767            resp.extra_headers.extend(cors_headers());
768            Ok(resp)
769        }
770        "/api/events" if method == ApiMethod::Get => {
771            let query = parse_query(url);
772            let limit = query
773                .get("limit")
774                .and_then(|v| v.parse::<usize>().ok())
775                .unwrap_or(default_events_limit);
776            let wrapped = CliResponse::success(list_ui_events(registry, limit)?);
777            let mut resp = ApiResponse::json(200, &wrapped)?;
778            resp.extra_headers.extend(cors_headers());
779            Ok(resp)
780        }
781        "/api/events/inspect" if method == ApiMethod::Get => {
782            let query = parse_query(url);
783            let event_id = query.get("event_id").ok_or_else(|| {
784                HivemindError::user(
785                    "missing_event_id",
786                    "Query parameter 'event_id' is required",
787                    "server:events:inspect",
788                )
789            })?;
790            let wrapped = CliResponse::success(registry.get_event(event_id)?);
791            let mut resp = ApiResponse::json(200, &wrapped)?;
792            resp.extra_headers.extend(cors_headers());
793            Ok(resp)
794        }
795        "/api/verify/results" if method == ApiMethod::Get => {
796            let query = parse_query(url);
797            let attempt_id = query.get("attempt_id").ok_or_else(|| {
798                HivemindError::user(
799                    "missing_attempt_id",
800                    "Query parameter 'attempt_id' is required",
801                    "server:verify:results",
802                )
803            })?;
804            let output = query.get("output").is_some_and(|v| v == "true");
805            let attempt = registry.get_attempt(attempt_id)?;
806            let check_results = attempt
807                .check_results
808                .iter()
809                .map(|r| {
810                    if output {
811                        serde_json::json!(r)
812                    } else {
813                        serde_json::json!({
814                            "name": r.name,
815                            "passed": r.passed,
816                            "exit_code": r.exit_code,
817                            "duration_ms": r.duration_ms,
818                            "required": r.required,
819                        })
820                    }
821                })
822                .collect::<Vec<_>>();
823            let view = VerifyResultsView {
824                attempt_id: attempt.id.to_string(),
825                task_id: attempt.task_id.to_string(),
826                flow_id: attempt.flow_id.to_string(),
827                attempt_number: attempt.attempt_number,
828                check_results,
829            };
830            let wrapped = CliResponse::success(view);
831            let mut resp = ApiResponse::json(200, &wrapped)?;
832            resp.extra_headers.extend(cors_headers());
833            Ok(resp)
834        }
835        "/api/attempts/inspect" if method == ApiMethod::Get => {
836            let query = parse_query(url);
837            let attempt_id = query.get("attempt_id").ok_or_else(|| {
838                HivemindError::user(
839                    "missing_attempt_id",
840                    "Query parameter 'attempt_id' is required",
841                    "server:attempts:inspect",
842                )
843            })?;
844            let include_diff = query.get("diff").is_some_and(|v| v == "true");
845            let attempt = registry.get_attempt(attempt_id)?;
846            let diff = if include_diff {
847                registry.get_attempt_diff(attempt_id)?
848            } else {
849                None
850            };
851            let view = AttemptInspectView {
852                attempt_id: attempt.id.to_string(),
853                task_id: attempt.task_id.to_string(),
854                flow_id: attempt.flow_id.to_string(),
855                attempt_number: attempt.attempt_number,
856                started_at: attempt.started_at,
857                baseline_id: attempt.baseline_id.map(|v| v.to_string()),
858                diff_id: attempt.diff_id.map(|v| v.to_string()),
859                diff,
860            };
861            let wrapped = CliResponse::success(view);
862            let mut resp = ApiResponse::json(200, &wrapped)?;
863            resp.extra_headers.extend(cors_headers());
864            Ok(resp)
865        }
866        "/api/attempts/diff" if method == ApiMethod::Get => {
867            let query = parse_query(url);
868            let attempt_id = query.get("attempt_id").ok_or_else(|| {
869                HivemindError::user(
870                    "missing_attempt_id",
871                    "Query parameter 'attempt_id' is required",
872                    "server:attempts:diff",
873                )
874            })?;
875            let wrapped = CliResponse::success(serde_json::json!({
876                "attempt_id": attempt_id,
877                "diff": registry.get_attempt_diff(attempt_id)?,
878            }));
879            let mut resp = ApiResponse::json(200, &wrapped)?;
880            resp.extra_headers.extend(cors_headers());
881            Ok(resp)
882        }
883        "/api/flows/replay" if method == ApiMethod::Get => {
884            let query = parse_query(url);
885            let flow_id = query.get("flow_id").ok_or_else(|| {
886                HivemindError::user(
887                    "missing_flow_id",
888                    "Query parameter 'flow_id' is required",
889                    "server:flows:replay",
890                )
891            })?;
892            let wrapped = CliResponse::success(registry.replay_flow(flow_id)?);
893            let mut resp = ApiResponse::json(200, &wrapped)?;
894            resp.extra_headers.extend(cors_headers());
895            Ok(resp)
896        }
897        "/api/worktrees" if method == ApiMethod::Get => {
898            let query = parse_query(url);
899            let flow_id = query.get("flow_id").ok_or_else(|| {
900                HivemindError::user(
901                    "missing_flow_id",
902                    "Query parameter 'flow_id' is required",
903                    "server:worktrees:list",
904                )
905            })?;
906            let wrapped = CliResponse::success(registry.worktree_list(flow_id)?);
907            let mut resp = ApiResponse::json(200, &wrapped)?;
908            resp.extra_headers.extend(cors_headers());
909            Ok(resp)
910        }
911        "/api/worktrees/inspect" if method == ApiMethod::Get => {
912            let query = parse_query(url);
913            let task_id = query.get("task_id").ok_or_else(|| {
914                HivemindError::user(
915                    "missing_task_id",
916                    "Query parameter 'task_id' is required",
917                    "server:worktrees:inspect",
918                )
919            })?;
920            let wrapped = CliResponse::success(registry.worktree_inspect(task_id)?);
921            let mut resp = ApiResponse::json(200, &wrapped)?;
922            resp.extra_headers.extend(cors_headers());
923            Ok(resp)
924        }
925        "/api/projects/create" if method == ApiMethod::Post => {
926            let req: ProjectCreateRequest = parse_json_body(body, "server:projects:create")?;
927            let wrapped = CliResponse::success(
928                registry.create_project(&req.name, req.description.as_deref())?,
929            );
930            let mut resp = ApiResponse::json(200, &wrapped)?;
931            resp.extra_headers.extend(cors_headers());
932            Ok(resp)
933        }
934        "/api/projects/update" if method == ApiMethod::Post => {
935            let req: ProjectUpdateRequest = parse_json_body(body, "server:projects:update")?;
936            let wrapped = CliResponse::success(registry.update_project(
937                &req.project,
938                req.name.as_deref(),
939                req.description.as_deref(),
940            )?);
941            let mut resp = ApiResponse::json(200, &wrapped)?;
942            resp.extra_headers.extend(cors_headers());
943            Ok(resp)
944        }
945        "/api/projects/runtime" if method == ApiMethod::Post => {
946            let req: ProjectRuntimeRequest = parse_json_body(body, "server:projects:runtime")?;
947            let mut env_pairs = Vec::new();
948            if let Some(env) = req.env {
949                for (k, v) in env {
950                    env_pairs.push(format!("{k}={v}"));
951                }
952            }
953            let wrapped = CliResponse::success(registry.project_runtime_set(
954                &req.project,
955                req.adapter.as_deref().unwrap_or("opencode"),
956                req.binary_path.as_deref().unwrap_or("opencode"),
957                req.model,
958                &req.args.unwrap_or_default(),
959                &env_pairs,
960                req.timeout_ms.unwrap_or(600_000),
961                req.max_parallel_tasks.unwrap_or(1),
962            )?);
963            let mut resp = ApiResponse::json(200, &wrapped)?;
964            resp.extra_headers.extend(cors_headers());
965            Ok(resp)
966        }
967        "/api/projects/repos/attach" if method == ApiMethod::Post => {
968            let req: ProjectAttachRepoRequest =
969                parse_json_body(body, "server:projects:repos:attach")?;
970            let access_mode = match req
971                .access
972                .as_deref()
973                .unwrap_or("rw")
974                .to_lowercase()
975                .as_str()
976            {
977                "ro" | "readonly" => crate::core::scope::RepoAccessMode::ReadOnly,
978                "rw" | "readwrite" => crate::core::scope::RepoAccessMode::ReadWrite,
979                other => {
980                    return Err(HivemindError::user(
981                        "invalid_access_mode",
982                        format!("Invalid access mode '{other}'"),
983                        "server:projects:repos:attach",
984                    ));
985                }
986            };
987            let wrapped = CliResponse::success(registry.attach_repo(
988                &req.project,
989                &req.path,
990                req.name.as_deref(),
991                access_mode,
992            )?);
993            let mut resp = ApiResponse::json(200, &wrapped)?;
994            resp.extra_headers.extend(cors_headers());
995            Ok(resp)
996        }
997        "/api/projects/repos/detach" if method == ApiMethod::Post => {
998            let req: ProjectDetachRepoRequest =
999                parse_json_body(body, "server:projects:repos:detach")?;
1000            let wrapped = CliResponse::success(registry.detach_repo(&req.project, &req.repo_name)?);
1001            let mut resp = ApiResponse::json(200, &wrapped)?;
1002            resp.extra_headers.extend(cors_headers());
1003            Ok(resp)
1004        }
1005        "/api/tasks/create" if method == ApiMethod::Post => {
1006            let req: TaskCreateRequest = parse_json_body(body, "server:tasks:create")?;
1007            let wrapped = CliResponse::success(registry.create_task(
1008                &req.project,
1009                &req.title,
1010                req.description.as_deref(),
1011                req.scope,
1012            )?);
1013            let mut resp = ApiResponse::json(200, &wrapped)?;
1014            resp.extra_headers.extend(cors_headers());
1015            Ok(resp)
1016        }
1017        "/api/tasks/update" if method == ApiMethod::Post => {
1018            let req: TaskUpdateRequest = parse_json_body(body, "server:tasks:update")?;
1019            let wrapped = CliResponse::success(registry.update_task(
1020                &req.task_id,
1021                req.title.as_deref(),
1022                req.description.as_deref(),
1023            )?);
1024            let mut resp = ApiResponse::json(200, &wrapped)?;
1025            resp.extra_headers.extend(cors_headers());
1026            Ok(resp)
1027        }
1028        "/api/tasks/close" if method == ApiMethod::Post => {
1029            let req: TaskCloseRequest = parse_json_body(body, "server:tasks:close")?;
1030            let wrapped =
1031                CliResponse::success(registry.close_task(&req.task_id, req.reason.as_deref())?);
1032            let mut resp = ApiResponse::json(200, &wrapped)?;
1033            resp.extra_headers.extend(cors_headers());
1034            Ok(resp)
1035        }
1036        "/api/tasks/start" if method == ApiMethod::Post => {
1037            let req: TaskIdRequest = parse_json_body(body, "server:tasks:start")?;
1038            let wrapped = CliResponse::success(serde_json::json!({
1039                "attempt_id": registry.start_task_execution(&req.task_id)?,
1040            }));
1041            let mut resp = ApiResponse::json(200, &wrapped)?;
1042            resp.extra_headers.extend(cors_headers());
1043            Ok(resp)
1044        }
1045        "/api/tasks/complete" if method == ApiMethod::Post => {
1046            let req: TaskIdRequest = parse_json_body(body, "server:tasks:complete")?;
1047            let wrapped = CliResponse::success(registry.complete_task_execution(&req.task_id)?);
1048            let mut resp = ApiResponse::json(200, &wrapped)?;
1049            resp.extra_headers.extend(cors_headers());
1050            Ok(resp)
1051        }
1052        "/api/tasks/retry" if method == ApiMethod::Post => {
1053            let req: TaskRetryRequest = parse_json_body(body, "server:tasks:retry")?;
1054            let mode = match req
1055                .mode
1056                .as_deref()
1057                .unwrap_or("clean")
1058                .to_lowercase()
1059                .as_str()
1060            {
1061                "clean" => RetryMode::Clean,
1062                "continue" => RetryMode::Continue,
1063                other => {
1064                    return Err(HivemindError::user(
1065                        "invalid_retry_mode",
1066                        format!("Invalid retry mode '{other}'"),
1067                        "server:tasks:retry",
1068                    ));
1069                }
1070            };
1071            let wrapped = CliResponse::success(registry.retry_task(
1072                &req.task_id,
1073                req.reset_count.unwrap_or(false),
1074                mode,
1075            )?);
1076            let mut resp = ApiResponse::json(200, &wrapped)?;
1077            resp.extra_headers.extend(cors_headers());
1078            Ok(resp)
1079        }
1080        "/api/tasks/abort" if method == ApiMethod::Post => {
1081            let req: TaskAbortRequest = parse_json_body(body, "server:tasks:abort")?;
1082            let wrapped =
1083                CliResponse::success(registry.abort_task(&req.task_id, req.reason.as_deref())?);
1084            let mut resp = ApiResponse::json(200, &wrapped)?;
1085            resp.extra_headers.extend(cors_headers());
1086            Ok(resp)
1087        }
1088        "/api/graphs/create" if method == ApiMethod::Post => {
1089            let req: GraphCreateRequest = parse_json_body(body, "server:graphs:create")?;
1090            let wrapped = CliResponse::success(
1091                registry.create_graph(
1092                    &req.project,
1093                    &req.name,
1094                    &req.from_tasks
1095                        .iter()
1096                        .map(|s| {
1097                            uuid::Uuid::parse_str(s).map_err(|_| {
1098                                HivemindError::user(
1099                                    "invalid_task_id",
1100                                    format!("'{s}' is not a valid task ID"),
1101                                    "server:graphs:create",
1102                                )
1103                            })
1104                        })
1105                        .collect::<Result<Vec<_>>>()?,
1106                )?,
1107            );
1108            let mut resp = ApiResponse::json(200, &wrapped)?;
1109            resp.extra_headers.extend(cors_headers());
1110            Ok(resp)
1111        }
1112        "/api/graphs/dependencies/add" if method == ApiMethod::Post => {
1113            let req: GraphDependencyRequest =
1114                parse_json_body(body, "server:graphs:dependencies:add")?;
1115            let wrapped = CliResponse::success(registry.add_graph_dependency(
1116                &req.graph_id,
1117                &req.from_task,
1118                &req.to_task,
1119            )?);
1120            let mut resp = ApiResponse::json(200, &wrapped)?;
1121            resp.extra_headers.extend(cors_headers());
1122            Ok(resp)
1123        }
1124        "/api/graphs/checks/add" if method == ApiMethod::Post => {
1125            let req: GraphAddCheckRequest = parse_json_body(body, "server:graphs:checks:add")?;
1126            let mut check = CheckConfig::new(req.name, req.command);
1127            check.required = req.required.unwrap_or(true);
1128            check.timeout_ms = req.timeout_ms;
1129            let wrapped = CliResponse::success(registry.add_graph_task_check(
1130                &req.graph_id,
1131                &req.task_id,
1132                check,
1133            )?);
1134            let mut resp = ApiResponse::json(200, &wrapped)?;
1135            resp.extra_headers.extend(cors_headers());
1136            Ok(resp)
1137        }
1138        "/api/graphs/validate" if method == ApiMethod::Post => {
1139            let req: GraphValidateRequest = parse_json_body(body, "server:graphs:validate")?;
1140            let wrapped = CliResponse::success(registry.validate_graph(&req.graph_id)?);
1141            let mut resp = ApiResponse::json(200, &wrapped)?;
1142            resp.extra_headers.extend(cors_headers());
1143            Ok(resp)
1144        }
1145        "/api/flows/create" if method == ApiMethod::Post => {
1146            let req: FlowCreateRequest = parse_json_body(body, "server:flows:create")?;
1147            let wrapped =
1148                CliResponse::success(registry.create_flow(&req.graph_id, req.name.as_deref())?);
1149            let mut resp = ApiResponse::json(200, &wrapped)?;
1150            resp.extra_headers.extend(cors_headers());
1151            Ok(resp)
1152        }
1153        "/api/flows/start" if method == ApiMethod::Post => {
1154            let req: FlowIdRequest = parse_json_body(body, "server:flows:start")?;
1155            let wrapped = CliResponse::success(registry.start_flow(&req.flow_id)?);
1156            let mut resp = ApiResponse::json(200, &wrapped)?;
1157            resp.extra_headers.extend(cors_headers());
1158            Ok(resp)
1159        }
1160        "/api/flows/tick" if method == ApiMethod::Post => {
1161            let req: FlowTickRequest = parse_json_body(body, "server:flows:tick")?;
1162            let wrapped = CliResponse::success(registry.tick_flow(
1163                &req.flow_id,
1164                req.interactive.unwrap_or(false),
1165                req.max_parallel,
1166            )?);
1167            let mut resp = ApiResponse::json(200, &wrapped)?;
1168            resp.extra_headers.extend(cors_headers());
1169            Ok(resp)
1170        }
1171        "/api/flows/pause" if method == ApiMethod::Post => {
1172            let req: FlowIdRequest = parse_json_body(body, "server:flows:pause")?;
1173            let wrapped = CliResponse::success(registry.pause_flow(&req.flow_id)?);
1174            let mut resp = ApiResponse::json(200, &wrapped)?;
1175            resp.extra_headers.extend(cors_headers());
1176            Ok(resp)
1177        }
1178        "/api/flows/resume" if method == ApiMethod::Post => {
1179            let req: FlowIdRequest = parse_json_body(body, "server:flows:resume")?;
1180            let wrapped = CliResponse::success(registry.resume_flow(&req.flow_id)?);
1181            let mut resp = ApiResponse::json(200, &wrapped)?;
1182            resp.extra_headers.extend(cors_headers());
1183            Ok(resp)
1184        }
1185        "/api/flows/abort" if method == ApiMethod::Post => {
1186            let req: FlowAbortRequest = parse_json_body(body, "server:flows:abort")?;
1187            let wrapped = CliResponse::success(registry.abort_flow(
1188                &req.flow_id,
1189                req.reason.as_deref(),
1190                req.force.unwrap_or(false),
1191            )?);
1192            let mut resp = ApiResponse::json(200, &wrapped)?;
1193            resp.extra_headers.extend(cors_headers());
1194            Ok(resp)
1195        }
1196        "/api/verify/override" if method == ApiMethod::Post => {
1197            let req: VerifyOverrideRequest = parse_json_body(body, "server:verify:override")?;
1198            let wrapped = CliResponse::success(registry.verify_override(
1199                &req.task_id,
1200                &req.decision,
1201                &req.reason,
1202            )?);
1203            let mut resp = ApiResponse::json(200, &wrapped)?;
1204            resp.extra_headers.extend(cors_headers());
1205            Ok(resp)
1206        }
1207        "/api/verify/run" if method == ApiMethod::Post => {
1208            let req: VerifyRunRequest = parse_json_body(body, "server:verify:run")?;
1209            let wrapped = CliResponse::success(registry.verify_run(&req.task_id)?);
1210            let mut resp = ApiResponse::json(200, &wrapped)?;
1211            resp.extra_headers.extend(cors_headers());
1212            Ok(resp)
1213        }
1214        "/api/merge/prepare" if method == ApiMethod::Post => {
1215            let req: MergePrepareRequest = parse_json_body(body, "server:merge:prepare")?;
1216            let wrapped =
1217                CliResponse::success(registry.merge_prepare(&req.flow_id, req.target.as_deref())?);
1218            let mut resp = ApiResponse::json(200, &wrapped)?;
1219            resp.extra_headers.extend(cors_headers());
1220            Ok(resp)
1221        }
1222        "/api/merge/approve" if method == ApiMethod::Post => {
1223            let req: MergeApproveRequest = parse_json_body(body, "server:merge:approve")?;
1224            let wrapped = CliResponse::success(registry.merge_approve(&req.flow_id)?);
1225            let mut resp = ApiResponse::json(200, &wrapped)?;
1226            resp.extra_headers.extend(cors_headers());
1227            Ok(resp)
1228        }
1229        "/api/merge/execute" if method == ApiMethod::Post => {
1230            let req: MergeExecuteRequest = parse_json_body(body, "server:merge:execute")?;
1231            let wrapped = CliResponse::success(registry.merge_execute(&req.flow_id)?);
1232            let mut resp = ApiResponse::json(200, &wrapped)?;
1233            resp.extra_headers.extend(cors_headers());
1234            Ok(resp)
1235        }
1236        "/api/checkpoints/complete" if method == ApiMethod::Post => {
1237            let req: CheckpointCompleteRequest =
1238                parse_json_body(body, "server:checkpoints:complete")?;
1239            let wrapped = CliResponse::success(registry.checkpoint_complete(
1240                &req.attempt_id,
1241                &req.checkpoint_id,
1242                req.summary.as_deref(),
1243            )?);
1244            let mut resp = ApiResponse::json(200, &wrapped)?;
1245            resp.extra_headers.extend(cors_headers());
1246            Ok(resp)
1247        }
1248        "/api/worktrees/cleanup" if method == ApiMethod::Post => {
1249            let req: WorktreeCleanupRequest = parse_json_body(body, "server:worktrees:cleanup")?;
1250            registry.worktree_cleanup(&req.flow_id)?;
1251            let wrapped = CliResponse::success(serde_json::json!({ "ok": true }));
1252            let mut resp = ApiResponse::json(200, &wrapped)?;
1253            resp.extra_headers.extend(cors_headers());
1254            Ok(resp)
1255        }
1256        "/health"
1257        | "/api/version"
1258        | "/api/catalog"
1259        | "/api/state"
1260        | "/api/projects"
1261        | "/api/tasks"
1262        | "/api/graphs"
1263        | "/api/flows"
1264        | "/api/merges"
1265        | "/api/events"
1266        | "/api/events/inspect"
1267        | "/api/verify/results"
1268        | "/api/attempts/inspect"
1269        | "/api/attempts/diff"
1270        | "/api/flows/replay"
1271        | "/api/worktrees"
1272        | "/api/worktrees/inspect" => method_not_allowed(path, method, "GET"),
1273        "/api/projects/create"
1274        | "/api/projects/update"
1275        | "/api/projects/runtime"
1276        | "/api/projects/repos/attach"
1277        | "/api/projects/repos/detach"
1278        | "/api/tasks/create"
1279        | "/api/tasks/update"
1280        | "/api/tasks/close"
1281        | "/api/tasks/start"
1282        | "/api/tasks/complete"
1283        | "/api/tasks/retry"
1284        | "/api/tasks/abort"
1285        | "/api/graphs/create"
1286        | "/api/graphs/dependencies/add"
1287        | "/api/graphs/checks/add"
1288        | "/api/graphs/validate"
1289        | "/api/flows/create"
1290        | "/api/flows/start"
1291        | "/api/flows/tick"
1292        | "/api/flows/pause"
1293        | "/api/flows/resume"
1294        | "/api/flows/abort"
1295        | "/api/verify/override"
1296        | "/api/verify/run"
1297        | "/api/merge/prepare"
1298        | "/api/merge/approve"
1299        | "/api/merge/execute"
1300        | "/api/checkpoints/complete"
1301        | "/api/worktrees/cleanup" => method_not_allowed(path, method, "POST"),
1302        _ => {
1303            let err = HivemindError::user(
1304                "endpoint_not_found",
1305                format!("Unknown endpoint '{path}'"),
1306                "server:handle_api_request",
1307            );
1308            let wrapped = CliResponse::<()>::error(&err);
1309            let mut resp = ApiResponse::json(404, &wrapped)?;
1310            resp.extra_headers.extend(cors_headers());
1311            Ok(resp)
1312        }
1313    }
1314}
1315
1316pub fn serve(config: &ServeConfig) -> Result<()> {
1317    let addr = format!("{}:{}", config.host, config.port);
1318    let server = tiny_http::Server::http(&addr)
1319        .map_err(|e| HivemindError::system("server_bind_failed", e.to_string(), "server:serve"))?;
1320
1321    eprintln!("hivemind serve listening on http://{addr}");
1322
1323    for mut req in server.incoming_requests() {
1324        let Some(method) = ApiMethod::from_http(req.method()) else {
1325            let _ = req.respond(tiny_http::Response::empty(405));
1326            continue;
1327        };
1328
1329        let mut request_body = Vec::new();
1330        if method == ApiMethod::Post {
1331            let _ = req.as_reader().read_to_end(&mut request_body);
1332        }
1333
1334        let response = match handle_api_request(
1335            method,
1336            req.url(),
1337            config.events_limit,
1338            if request_body.is_empty() {
1339                None
1340            } else {
1341                Some(request_body.as_slice())
1342            },
1343        ) {
1344            Ok(r) => r,
1345            Err(e) => {
1346                let wrapped = CliResponse::<()>::error(&e);
1347                match ApiResponse::json(500, &wrapped) {
1348                    Ok(mut r) => {
1349                        r.extra_headers.extend(cors_headers());
1350                        r
1351                    }
1352                    Err(_) => ApiResponse::text(500, "text/plain", "internal error\n"),
1353                }
1354            }
1355        };
1356
1357        let mut tiny = tiny_http::Response::from_data(response.body)
1358            .with_status_code(response.status_code)
1359            .with_header(
1360                tiny_http::Header::from_bytes(
1361                    &b"Content-Type"[..],
1362                    response.content_type.as_bytes(),
1363                )
1364                .expect("content-type header"),
1365            );
1366
1367        for h in response.extra_headers {
1368            tiny = tiny.with_header(h);
1369        }
1370
1371        let _ = req.respond(tiny);
1372    }
1373
1374    Ok(())
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379    use super::*;
1380    use crate::core::registry::{Registry, RegistryConfig};
1381    use std::mem;
1382
1383    fn test_registry() -> Registry {
1384        let tmp = tempfile::tempdir().expect("tempdir");
1385        let data_dir = tmp.path().to_path_buf();
1386        mem::forget(tmp);
1387        let config = RegistryConfig::with_dir(data_dir);
1388        Registry::open_with_config(config).expect("registry")
1389    }
1390
1391    fn json_value(body: &[u8]) -> Value {
1392        serde_json::from_slice(body).expect("json")
1393    }
1394
1395    #[test]
1396    fn api_version_ok() {
1397        let reg = test_registry();
1398        let resp =
1399            handle_api_request_inner(ApiMethod::Get, "/api/version", 10, None, &reg).unwrap();
1400        assert_eq!(resp.status_code, 200);
1401        let v = json_value(&resp.body);
1402        assert_eq!(v["success"], true);
1403        assert!(v["data"]["version"].is_string());
1404    }
1405
1406    #[test]
1407    fn api_state_ok_empty() {
1408        let reg = test_registry();
1409        let resp = handle_api_request_inner(ApiMethod::Get, "/api/state", 10, None, &reg).unwrap();
1410        assert_eq!(resp.status_code, 200);
1411        let v = json_value(&resp.body);
1412        assert_eq!(v["success"], true);
1413        assert!(v["data"]["projects"].is_array());
1414    }
1415
1416    #[test]
1417    fn api_unknown_endpoint_404() {
1418        let reg = test_registry();
1419        let resp = handle_api_request_inner(ApiMethod::Get, "/api/nope", 10, None, &reg).unwrap();
1420        assert_eq!(resp.status_code, 404);
1421        let v = json_value(&resp.body);
1422        assert_eq!(v["success"], false);
1423        assert_eq!(v["error"]["code"], "endpoint_not_found");
1424    }
1425
1426    #[test]
1427    fn api_post_project_create_ok() {
1428        let reg = test_registry();
1429        let body = serde_json::json!({
1430            "name": "proj-a",
1431            "description": "project from api"
1432        });
1433        let body = serde_json::to_vec(&body).expect("json body");
1434        let resp = handle_api_request_inner(
1435            ApiMethod::Post,
1436            "/api/projects/create",
1437            10,
1438            Some(&body),
1439            &reg,
1440        )
1441        .unwrap();
1442        assert_eq!(resp.status_code, 200);
1443        let v = json_value(&resp.body);
1444        assert_eq!(v["success"], true);
1445        assert_eq!(v["data"]["name"], "proj-a");
1446    }
1447}