Skip to main content

wipe_daemon/
api.rs

1//! HTTP + WebSocket API handlers over `wipe-core`.
2
3use std::path::PathBuf;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::Arc;
6
7use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
8use axum::extract::{Multipart, Path, Query, State};
9use axum::http::{header, StatusCode};
10use axum::response::{IntoResponse, Response};
11use axum::Json;
12use chrono::Utc;
13use serde::Deserialize;
14use serde_json::{json, Value};
15use tokio::sync::broadcast;
16
17use wipe_core::model::{Board, IdentityKind, Ticket};
18use wipe_core::ops::{self, NewTicket, TicketPatch};
19use wipe_core::{git, Store};
20
21/// Shared server state.
22#[derive(Clone)]
23pub struct AppState {
24    /// The project the daemon was started in (default target).
25    pub current: PathBuf,
26    /// Broadcast channel for live-update notifications.
27    pub tx: broadcast::Sender<String>,
28    /// Number of live UI WebSocket clients. Drives idle-shutdown: when this is 0
29    /// for long enough, an auto-served daemon exits.
30    pub clients: Arc<AtomicUsize>,
31}
32
33/// An error that renders as a JSON `{ok:false,error}` body.
34pub struct ApiError(anyhow::Error);
35
36impl<E: Into<anyhow::Error>> From<E> for ApiError {
37    fn from(e: E) -> Self {
38        ApiError(e.into())
39    }
40}
41
42impl IntoResponse for ApiError {
43    fn into_response(self) -> Response {
44        let body = Json(json!({ "ok": false, "error": self.0.to_string() }));
45        (StatusCode::BAD_REQUEST, body).into_response()
46    }
47}
48
49type ApiResult = Result<Json<Value>, ApiError>;
50
51/// Query string carrying an optional project root.
52#[derive(Debug, Deserialize)]
53pub struct ProjectQuery {
54    project: Option<String>,
55}
56
57fn store_for(state: &AppState, project: Option<String>) -> Result<Store, ApiError> {
58    let root = project
59        .map(PathBuf::from)
60        .unwrap_or_else(|| state.current.clone());
61    Ok(Store::open(root)?)
62}
63
64fn notify(state: &AppState) {
65    let _ = state.tx.send("changed".to_string());
66}
67
68/// Who to attribute a UI-driven mutation to for the activity timeline: an explicit
69/// `actor` from the request if given, else the repo's configured git identity, else
70/// a generic fallback.
71fn resolve_actor(store: &Store, provided: Option<String>) -> String {
72    provided
73        .filter(|s| !s.trim().is_empty())
74        .or_else(|| git::config_identity(store.root()))
75        .unwrap_or_else(|| "someone".to_string())
76}
77
78fn board_json(board: &Board, view: &[(String, Vec<Ticket>)]) -> Value {
79    let lists: Vec<Value> = view
80        .iter()
81        .map(|(list_id, tickets)| {
82            let name = board
83                .list(list_id)
84                .map(|l| l.name.clone())
85                .unwrap_or_else(|| list_id.clone());
86            json!({ "list": list_id, "name": name, "tickets": tickets })
87        })
88        .collect();
89    json!({ "board": board.name, "lists": lists })
90}
91
92// --- read endpoints --------------------------------------------------------
93
94/// `GET /api/health`
95pub async fn health() -> Json<Value> {
96    Json(json!({ "ok": true, "service": "wipe-daemon", "version": env!("CARGO_PKG_VERSION") }))
97}
98
99/// `GET /api/config` - user-global UI preferences (accent/theme) so the board can
100/// honor the styling chosen via `wipe config --global`.
101pub async fn app_config() -> Json<Value> {
102    let g = wipe_core::GlobalConfig::load();
103    Json(json!({ "accent": g.ui_accent, "theme": g.ui_theme }))
104}
105
106/// `GET /api/projects`
107pub async fn projects(State(state): State<AppState>) -> ApiResult {
108    crate::registry::register(&state.current);
109    Ok(Json(json!({ "projects": crate::registry::list() })))
110}
111
112/// `GET /api/board`
113pub async fn board(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
114    let store = store_for(&state, q.project)?;
115    let (board, view) = ops::board_view(&store)?;
116    Ok(Json(board_json(&board, &view)))
117}
118
119/// `GET /api/history` - commits touching `.wipe/`, most recent first.
120pub async fn history(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
121    let store = store_for(&state, q.project)?;
122    let commits = git::log(store.root(), Some(".wipe"), Some(200))?;
123    Ok(Json(json!({ "commits": commits })))
124}
125
126/// Query for a historical board snapshot.
127#[derive(Debug, Deserialize)]
128pub struct AtQuery {
129    project: Option<String>,
130    commit: String,
131}
132
133/// `GET /api/board/at` - reconstruct the board as of a commit (the rewind feature).
134pub async fn board_at(State(state): State<AppState>, Query(q): Query<AtQuery>) -> ApiResult {
135    let store = store_for(&state, q.project)?;
136    let root = store.root();
137    let board_src = git::file_at_commit(root, &q.commit, ".wipe/board.json")?
138        .ok_or_else(|| ApiError(anyhow::anyhow!("no board at commit {}", q.commit)))?;
139    let board: Board = serde_json::from_str(&board_src)?;
140
141    let mut lists = Vec::with_capacity(board.lists.len());
142    for list in &board.lists {
143        let mut tickets = Vec::with_capacity(list.cards.len());
144        for id in &list.cards {
145            let rel = format!(".wipe/tickets/{id}.json");
146            if let Some(src) = git::file_at_commit(root, &q.commit, &rel)? {
147                if let Ok(t) = serde_json::from_str::<Ticket>(&src) {
148                    tickets.push(t);
149                }
150            }
151        }
152        lists.push(json!({ "list": list.id, "name": list.name, "tickets": tickets }));
153    }
154    Ok(Json(
155        json!({ "board": board.name, "commit": q.commit, "lists": lists }),
156    ))
157}
158
159// --- write endpoints -------------------------------------------------------
160
161/// Body for creating a ticket.
162#[derive(Debug, Deserialize)]
163pub struct CreateTicketBody {
164    project: Option<String>,
165    title: String,
166    #[serde(default)]
167    body: Option<String>,
168    #[serde(default)]
169    priority: Option<String>,
170    #[serde(default)]
171    list: Option<String>,
172    #[serde(default)]
173    labels: Vec<String>,
174    #[serde(default)]
175    assignees: Vec<String>,
176    #[serde(default)]
177    actor: Option<String>,
178}
179
180/// `POST /api/tickets`
181pub async fn create_ticket(
182    State(state): State<AppState>,
183    Json(b): Json<CreateTicketBody>,
184) -> ApiResult {
185    let store = store_for(&state, b.project)?;
186    let actor = resolve_actor(&store, b.actor);
187    let spec = NewTicket {
188        title: b.title,
189        body: b.body,
190        priority: b.priority,
191        list: b.list,
192        labels: b.labels,
193        assignees: b.assignees,
194    };
195    let ticket = ops::create_ticket(&store, spec, &actor, Utc::now())?;
196    notify(&state);
197    Ok(Json(serde_json::to_value(ticket)?))
198}
199
200/// Body for moving a ticket.
201#[derive(Debug, Deserialize)]
202pub struct MoveBody {
203    project: Option<String>,
204    to: String,
205    #[serde(default)]
206    pos: Option<usize>,
207    #[serde(default)]
208    actor: Option<String>,
209}
210
211/// `POST /api/tickets/{id}/move`
212pub async fn move_ticket(
213    State(state): State<AppState>,
214    Path(id): Path<String>,
215    Json(b): Json<MoveBody>,
216) -> ApiResult {
217    let store = store_for(&state, b.project)?;
218    let actor = resolve_actor(&store, b.actor);
219    ops::move_ticket(&store, &id, &b.to, b.pos, &actor, Utc::now())?;
220    notify(&state);
221    Ok(Json(json!({ "ok": true, "id": id, "list": b.to })))
222}
223
224/// Body for adding a comment.
225#[derive(Debug, Deserialize)]
226pub struct CommentBody {
227    project: Option<String>,
228    #[serde(default)]
229    author: Option<String>,
230    body: String,
231}
232
233/// `POST /api/tickets/{id}/comments`
234pub async fn add_comment(
235    State(state): State<AppState>,
236    Path(id): Path<String>,
237    Json(b): Json<CommentBody>,
238) -> ApiResult {
239    let store = store_for(&state, b.project)?;
240    let author = b.author.unwrap_or_else(|| "ui".to_string());
241    let cid = ops::add_comment(&store, &id, &author, &b.body, Utc::now())?;
242    notify(&state);
243    Ok(Json(json!({ "ok": true, "ticket": id, "comment": cid })))
244}
245
246/// `GET /api/definitions` - labels + priorities.
247pub async fn definitions(
248    State(state): State<AppState>,
249    Query(q): Query<ProjectQuery>,
250) -> ApiResult {
251    let store = store_for(&state, q.project)?;
252    Ok(Json(serde_json::to_value(store.load_definitions()?)?))
253}
254
255/// Body for creating a label definition. `color` is optional (auto-assigned).
256#[derive(Debug, Deserialize)]
257pub struct LabelBody {
258    project: Option<String>,
259    name: String,
260    #[serde(default)]
261    color: Option<String>,
262    #[serde(default)]
263    description: Option<String>,
264}
265
266/// `POST /api/labels` - define a new label (auto-colored if no color given).
267pub async fn create_label(State(state): State<AppState>, Json(b): Json<LabelBody>) -> ApiResult {
268    let store = store_for(&state, b.project)?;
269    let label = ops::create_label(&store, &b.name, b.color, b.description)?;
270    notify(&state);
271    Ok(Json(serde_json::to_value(label)?))
272}
273
274/// Body for updating a label's color.
275#[derive(Debug, Deserialize)]
276pub struct LabelColorBody {
277    project: Option<String>,
278    color: String,
279}
280
281/// `PATCH /api/labels/{name}` - change a label's color.
282pub async fn recolor_label(
283    State(state): State<AppState>,
284    Path(name): Path<String>,
285    Json(b): Json<LabelColorBody>,
286) -> ApiResult {
287    let store = store_for(&state, b.project)?;
288    let label = ops::set_label_color(&store, &name, &b.color)?;
289    notify(&state);
290    Ok(Json(serde_json::to_value(label)?))
291}
292
293/// `DELETE /api/labels/{name}` - delete a label and strip it from all tickets.
294pub async fn delete_label(
295    State(state): State<AppState>,
296    Path(name): Path<String>,
297    Query(q): Query<ProjectQuery>,
298) -> ApiResult {
299    let store = store_for(&state, q.project)?;
300    ops::delete_label(&store, &name, Utc::now())?;
301    notify(&state);
302    Ok(Json(json!({ "ok": true })))
303}
304
305/// Body for patching a ticket. Absent fields are left unchanged; an explicit
306/// `null` for `priority` clears it.
307#[derive(Debug, Deserialize)]
308pub struct PatchBody {
309    project: Option<String>,
310    #[serde(default)]
311    title: Option<String>,
312    #[serde(default)]
313    body: Option<String>,
314    #[serde(default)]
315    priority: Option<Option<String>>,
316    #[serde(default)]
317    labels: Option<Vec<String>>,
318    #[serde(default)]
319    assignees: Option<Vec<String>>,
320    #[serde(default)]
321    actor: Option<String>,
322}
323
324/// `PATCH /api/tickets/{id}` - update ticket fields.
325pub async fn patch_ticket(
326    State(state): State<AppState>,
327    Path(id): Path<String>,
328    Json(b): Json<PatchBody>,
329) -> ApiResult {
330    let store = store_for(&state, b.project)?;
331    let actor = resolve_actor(&store, b.actor);
332    let patch = TicketPatch {
333        title: b.title,
334        body: b.body,
335        priority: b.priority,
336        labels: b.labels,
337        assignees: b.assignees,
338    };
339    let ticket = ops::update_ticket(&store, &id, patch, &actor, Utc::now())?;
340    notify(&state);
341    Ok(Json(serde_json::to_value(ticket)?))
342}
343
344/// `GET /api/identities` - humans (from git) + agents (registry).
345pub async fn identities(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
346    let store = store_for(&state, q.project)?;
347    Ok(Json(json!({ "identities": ops::list_identities(&store)? })))
348}
349
350/// Body for creating/updating an identity.
351#[derive(Debug, Deserialize)]
352pub struct IdentityBody {
353    project: Option<String>,
354    display_name: String,
355    #[serde(default)]
356    kind: Option<String>,
357}
358
359/// `PUT /api/identities/{id}` - set an identity's display name / kind.
360pub async fn put_identity(
361    State(state): State<AppState>,
362    Path(id): Path<String>,
363    Json(b): Json<IdentityBody>,
364) -> ApiResult {
365    let store = store_for(&state, b.project)?;
366    let kind = match b.kind.as_deref() {
367        Some("agent") => Some(IdentityKind::Agent),
368        Some("human") => Some(IdentityKind::Human),
369        _ => None,
370    };
371    let ident = ops::upsert_identity(&store, &id, &b.display_name, kind)?;
372    notify(&state);
373    Ok(Json(serde_json::to_value(ident)?))
374}
375
376/// `DELETE /api/identities/{id}` - remove an identity from the registry (agents /
377/// manual overrides; git-discovered humans reappear from history).
378pub async fn delete_identity(
379    State(state): State<AppState>,
380    Path(id): Path<String>,
381    Query(q): Query<ProjectQuery>,
382) -> ApiResult {
383    let store = store_for(&state, q.project)?;
384    ops::delete_identity(&store, &id)?;
385    notify(&state);
386    Ok(Json(json!({ "ok": true, "id": id })))
387}
388
389/// `POST /api/tickets/{id}/attachments` - multipart file upload (field `file`).
390pub async fn upload_attachment(
391    State(state): State<AppState>,
392    Path(id): Path<String>,
393    Query(q): Query<ProjectQuery>,
394    mut multipart: Multipart,
395) -> ApiResult {
396    let store = store_for(&state, q.project)?;
397    let actor = resolve_actor(&store, None);
398    let max = store.load_settings()?.max_attachment_mb * 1024 * 1024;
399
400    while let Some(field) = multipart
401        .next_field()
402        .await
403        .map_err(|e| ApiError(anyhow::anyhow!("bad upload: {e}")))?
404    {
405        if field.name() != Some("file") {
406            continue;
407        }
408        let name = field.file_name().unwrap_or("file").to_string();
409        let mime = field
410            .content_type()
411            .map(|s| s.to_string())
412            .unwrap_or_else(|| "application/octet-stream".to_string());
413        let bytes = field
414            .bytes()
415            .await
416            .map_err(|e| ApiError(anyhow::anyhow!("read upload: {e}")))?;
417        if bytes.len() as u64 > max {
418            return Err(ApiError(anyhow::anyhow!(
419                "attachment is {:.1} MB, over the {} MB limit",
420                bytes.len() as f64 / 1_048_576.0,
421                max / 1024 / 1024
422            )));
423        }
424        let att = ops::add_attachment(&store, &id, &name, &bytes, &mime, &actor, Utc::now())?;
425        notify(&state);
426        return Ok(Json(serde_json::to_value(att)?));
427    }
428    Err(ApiError(anyhow::anyhow!("no `file` field in upload")))
429}
430
431/// Body for detaching an attachment.
432#[derive(Debug, Deserialize)]
433pub struct DetachBody {
434    project: Option<String>,
435    path: String,
436    #[serde(default)]
437    actor: Option<String>,
438}
439
440/// `DELETE /api/tickets/{id}/attachments` - detach by repo-relative path.
441pub async fn delete_attachment(
442    State(state): State<AppState>,
443    Path(id): Path<String>,
444    Json(b): Json<DetachBody>,
445) -> ApiResult {
446    let store = store_for(&state, b.project)?;
447    let actor = resolve_actor(&store, b.actor);
448    ops::remove_attachment(&store, &id, &b.path, &actor, Utc::now())?;
449    notify(&state);
450    Ok(Json(json!({ "ok": true })))
451}
452
453/// `GET /api/media/{*path}` - serve an attachment for preview/download.
454pub async fn serve_media(
455    State(state): State<AppState>,
456    Query(q): Query<ProjectQuery>,
457    Path(path): Path<String>,
458) -> Response {
459    let store = match store_for(&state, q.project) {
460        Ok(s) => s,
461        Err(e) => return e.into_response(),
462    };
463    let rel = path.replace('\\', "/");
464    if rel.contains("..") {
465        return (StatusCode::BAD_REQUEST, "invalid path").into_response();
466    }
467    let full = store.root().join(&rel);
468    // Confine reads to within the project root.
469    let within = std::fs::canonicalize(store.root())
470        .ok()
471        .zip(std::fs::canonicalize(&full).ok())
472        .map(|(root, target)| target.starts_with(root))
473        .unwrap_or(false);
474    if !within {
475        return (StatusCode::NOT_FOUND, "not found").into_response();
476    }
477    match std::fs::read(&full) {
478        Ok(bytes) => {
479            let mime = mime_guess::from_path(&full).first_or_octet_stream();
480            ([(header::CONTENT_TYPE, mime.as_ref())], bytes).into_response()
481        }
482        Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(),
483    }
484}
485
486/// `GET /api/graph` - the commit graph (all branches) with board checkpoints.
487pub async fn graph(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
488    let store = store_for(&state, q.project)?;
489    let commits = git::graph(store.root(), Some(300))?;
490    Ok(Json(json!({ "commits": commits })))
491}
492
493/// Body for adding a list.
494#[derive(Debug, Deserialize)]
495pub struct AddListBody {
496    project: Option<String>,
497    name: String,
498}
499
500/// `POST /api/lists` - add a list to the board.
501pub async fn add_list(State(state): State<AppState>, Json(b): Json<AddListBody>) -> ApiResult {
502    let store = store_for(&state, b.project)?;
503    let id = ops::add_list(&store, &b.name, Utc::now())?;
504    notify(&state);
505    Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
506}
507
508/// Body for renaming a list.
509#[derive(Debug, Deserialize)]
510pub struct RenameListBody {
511    project: Option<String>,
512    name: String,
513}
514
515/// `PATCH /api/lists/{id}` - rename a list.
516pub async fn rename_list(
517    State(state): State<AppState>,
518    Path(id): Path<String>,
519    Json(b): Json<RenameListBody>,
520) -> ApiResult {
521    let store = store_for(&state, b.project)?;
522    ops::rename_list(&store, &id, &b.name, Utc::now())?;
523    notify(&state);
524    Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
525}
526
527/// Body for reordering a list.
528#[derive(Debug, Deserialize)]
529pub struct MoveListBody {
530    project: Option<String>,
531    index: usize,
532}
533
534/// `POST /api/lists/{id}/move` - reorder a list to a new index.
535pub async fn move_list(
536    State(state): State<AppState>,
537    Path(id): Path<String>,
538    Json(b): Json<MoveListBody>,
539) -> ApiResult {
540    let store = store_for(&state, b.project)?;
541    ops::move_list(&store, &id, b.index, Utc::now())?;
542    notify(&state);
543    Ok(Json(json!({ "ok": true, "id": id, "index": b.index })))
544}
545
546/// Query for removing a list.
547#[derive(Debug, Deserialize)]
548pub struct RemoveListQuery {
549    project: Option<String>,
550    #[serde(default)]
551    force: bool,
552}
553
554/// `DELETE /api/lists/{id}` - remove a list (use `?force=true` to delete its cards).
555pub async fn remove_list(
556    State(state): State<AppState>,
557    Path(id): Path<String>,
558    Query(q): Query<RemoveListQuery>,
559) -> ApiResult {
560    let store = store_for(&state, q.project)?;
561    ops::remove_list(&store, &id, q.force, Utc::now())?;
562    notify(&state);
563    Ok(Json(json!({ "ok": true, "id": id })))
564}
565
566// --- websocket -------------------------------------------------------------
567
568/// `GET /ws` - upgrade to a WebSocket that streams change notifications.
569pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
570    ws.on_upgrade(move |socket| ws_loop(socket, state))
571}
572
573/// Decrements the live-client counter when a WebSocket handler ends.
574struct ClientGuard(Arc<AtomicUsize>);
575impl Drop for ClientGuard {
576    fn drop(&mut self) {
577        self.0.fetch_sub(1, Ordering::SeqCst);
578    }
579}
580
581async fn ws_loop(mut socket: WebSocket, state: AppState) {
582    let mut rx = state.tx.subscribe();
583    // Count this client for the lifetime of the socket so idle-shutdown knows the
584    // board is actively being viewed; `_guard` decrements on drop (any exit path).
585    state.clients.fetch_add(1, Ordering::SeqCst);
586    let _guard = ClientGuard(state.clients.clone());
587    let _ = socket.send(Message::Text("connected".into())).await;
588    loop {
589        tokio::select! {
590            msg = rx.recv() => match msg {
591                Ok(m) => {
592                    if socket.send(Message::Text(m.into())).await.is_err() {
593                        break;
594                    }
595                }
596                Err(broadcast::error::RecvError::Closed) => break,
597                Err(broadcast::error::RecvError::Lagged(_)) => {}
598            },
599            incoming = socket.recv() => match incoming {
600                Some(Ok(_)) => {}
601                _ => break,
602            },
603        }
604    }
605}