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, ®istry)
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, ®).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, ®).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, ®).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 ®,
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}