trusty-mpm-daemon 0.2.2

Long-running trusty-mpm daemon: session control, hook interception, artifact serving
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
//! Daemon HTTP API.
//!
//! Why: the CLI, TUI, and Telegram bot are separate processes; they need a
//! transport to the daemon. HTTP/JSON over a loopback port is simple, debuggable
//! with `curl`, and lets the universal hook relay receive events from a tiny
//! forwarder shim with no client library.
//! What: builds the axum [`Router`] — health, session listing, the hook-event
//! relay endpoint, the live event feed, and the per-agent breaker view. State
//! is injected as `Arc<DaemonState>` via axum's `State` extractor.
//! Test: `cargo test -p trusty-mpm-daemon` drives the handlers directly with an
//! in-memory state (no socket bind needed).

use std::path::PathBuf;
use std::sync::Arc;

use axum::{
    Json, Router,
    extract::{Path, Query, State},
    routing::{get, post},
};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;

use trusty_mpm_core::compress::CompressionLevel;
use trusty_mpm_core::hook::HookEvent;
use trusty_mpm_core::project::ProjectInfo;
use trusty_mpm_core::session::{ControlModel, Session, SessionId, SessionStatus};

use crate::error::DaemonError;
use crate::services::{HookDecision, HookService, PairingService, SessionService, TmuxService};
use crate::state::DaemonState;

/// The Claude Code configuration analyzer routes (`/claude-config/*`).
///
/// Why: that endpoint cluster is cohesive and large; keeping it in its own
/// module keeps `api.rs` focused on the core session / hook / tmux surface.
/// The handlers are re-exported below so `router` and `openapi.rs` can keep
/// referring to them as `crate::api::<handler>`.
pub mod claude_config_routes;
pub use claude_config_routes::*;

/// Typed HTTP response bodies for every endpoint.
///
/// Why: keeping the response structs in their own module keeps `api.rs`
/// focused on routing and handler logic. Re-exported so handlers and tests
/// refer to them as `crate::api::<Type>`.
pub mod types;
pub use types::*;

/// Build the daemon's HTTP router with shared state injected.
///
/// Why: one place wires every route so `main` stays a thin bootstrap.
/// What: returns an axum `Router` already carrying `Arc<DaemonState>`.
/// Test: `health_endpoint_responds` and the hook-relay tests call handlers via
/// this router's logic.
pub fn router(state: Arc<DaemonState>) -> Router {
    Router::new()
        .route("/health", get(health))
        .route("/sessions", get(list_sessions).post(register_session))
        .route("/sessions/dead", axum::routing::delete(reap_sessions))
        .route("/sessions/discover", post(discover_sessions))
        .route("/sessions/{id}", axum::routing::delete(remove_session))
        .route("/sessions/{id}/events", get(session_events))
        .route("/sessions/{id}/pause", post(pause_session))
        .route("/sessions/{id}/resume", post(resume_session))
        .route("/sessions/{id}/command", post(send_command))
        .route("/sessions/{id}/output", get(get_output))
        .route("/sessions/{id}/pid", axum::routing::patch(set_session_pid))
        .route("/projects", get(list_projects).post(register_project))
        .route("/projects/current", get(current_project))
        .route("/projects/discover", get(discover_projects))
        .route("/events", get(recent_events))
        .route("/hooks", post(ingest_hook))
        .route("/breakers", get(breakers))
        .route("/optimizer", get(get_optimizer))
        .route("/overseer", get(get_overseer))
        .route("/llm/chat", post(llm_chat))
        .route("/tmux/sessions", get(list_tmux_sessions))
        .route("/tmux/sessions/{name}/snapshot", get(tmux_snapshot))
        .route("/tmux/adopt", post(adopt_tmux_session))
        .route("/claude-config", get(get_claude_config))
        .route("/claude-config/apply", post(apply_claude_config))
        .route("/claude-config/restart", post(restart_claude_code))
        .route(
            "/claude-config/checkpoints",
            get(list_checkpoints).post(create_checkpoint),
        )
        .route(
            "/claude-config/checkpoints/{id}",
            axum::routing::delete(delete_checkpoint),
        )
        .route("/claude-config/restore", post(restore_checkpoint))
        .route("/claude-config/profiles", get(list_profiles))
        .route("/claude-config/deploy", post(deploy_profile))
        .route("/pair/request", post(pair_request))
        .route("/pair/confirm", post(pair_confirm))
        .route("/pair/status", get(pair_status))
        .route("/pair/reset", post(pair_reset))
        .merge(
            SwaggerUi::new("/api-docs")
                .url("/api-docs/openapi.json", crate::openapi::ApiDoc::openapi()),
        )
        .with_state(state)
}

/// Liveness probe — always returns `ok` while the daemon is up.
#[utoipa::path(
    get,
    path = "/health",
    tag = "config",
    responses((status = 200, description = "Daemon is alive", body = String))
)]
pub async fn health() -> &'static str {
    "ok"
}

/// Query parameters for `GET /sessions`.
///
/// Why: `trusty-mpm session list` scopes the listing to one project; an
/// optional `?project=<path>` filter keeps the endpoint usable both ways.
/// What: an optional project path; when absent, all sessions are returned.
/// Test: `list_sessions_filters_by_project`.
#[derive(serde::Deserialize, Default)]
pub struct SessionQuery {
    /// Optional project path to filter sessions by.
    pub project: Option<PathBuf>,
}

/// `GET /sessions` — snapshot of managed sessions, optionally project-scoped.
#[utoipa::path(
    get,
    path = "/sessions",
    tag = "sessions",
    params(("project" = Option<String>, Query, description = "Filter by project path")),
    responses((status = 200, description = "Array of managed sessions", body = [Session]))
)]
pub async fn list_sessions(
    State(state): State<Arc<DaemonState>>,
    Query(query): Query<SessionQuery>,
) -> Json<SessionsResponse> {
    let sessions = match query.project {
        Some(path) => state.list_sessions_for_project(&path),
        None => state.list_sessions(),
    };
    Json(SessionsResponse { sessions })
}

/// `GET /events` — recent hook events across all sessions (dashboard feed).
#[utoipa::path(
    get,
    path = "/events",
    tag = "events",
    responses((status = 200, description = "Recent hook events across all sessions"))
)]
pub async fn recent_events(State(state): State<Arc<DaemonState>>) -> Json<EventsResponse> {
    Json(EventsResponse {
        events: state.recent_hook_events(),
    })
}

/// JSON body for registering a session via `POST /sessions`.
///
/// Why: a session created by an external launcher (or the CLI) must announce
/// itself so the dashboard and MCP tools can see it.
/// What: the working directory the session runs in, plus an optional project
/// and an optional caller-supplied tmux session name.
/// Test: `register_and_remove_session`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct RegisterSession {
    /// Working directory the session was launched in.
    pub workdir: String,
    /// Optional project this session belongs to. When present, the session is
    /// associated with that registered project so `session list` can scope to
    /// it.
    #[serde(default)]
    #[schema(value_type = Option<String>)]
    pub project_path: Option<PathBuf>,
    /// Optional caller-supplied tmux session name.
    ///
    /// Why: the CLI computes a `tmpm-<folder>` name from the project directory
    /// and creates the tmux session under that name; passing it here keeps the
    /// daemon registry's `tmux_name` consistent with the live tmux session.
    /// What: when present and non-empty it is used as the session's
    /// `tmux_name`; when absent the daemon derives one itself.
    #[serde(default)]
    pub name: Option<String>,
}

/// `POST /sessions` — register a new managed session, returning its id.
///
/// Why: registering a session is pure bookkeeping — it records that a session
/// exists so the dashboard and MCP tools can see it. It does NOT create a tmux
/// window; spawning a tmux host here caused session proliferation (every call
/// orphaned a new window). Sessions are instead *discovered* from existing
/// tmux panes, *auto-registered* on a `SessionStart` hook, or launched by the
/// `tm session start` CLI which owns the actual `claude` launch.
/// What: builds the `Session` record and registers it in state.
/// Test: `register_and_remove_session` covers the bookkeeping path.
#[utoipa::path(
    post,
    path = "/sessions",
    tag = "sessions",
    request_body = RegisterSession,
    responses((status = 201, description = "Session registered; returns its id and name"))
)]
pub async fn register_session(
    State(state): State<Arc<DaemonState>>,
    Json(body): Json<RegisterSession>,
) -> Json<RegisterSessionResponse> {
    // Derive the tmux name from the project directory (`tmpm-<folder>`) so the
    // registry name matches the folder-based session the CLI creates. A
    // caller-supplied `name` always wins; otherwise fall back to the UUID name.
    let project_dir = body.project_path.as_deref();
    let mut session = Session::new(
        SessionId::new(),
        body.workdir.clone(),
        ControlModel::Tmux,
        project_dir,
    );
    session.project_path = body.project_path.clone();
    if let Some(name) = body.name.as_deref().filter(|n| !n.is_empty()) {
        session.tmux_name = name.to_string();
    }
    let id = session.id;
    let tmux_name = session.tmux_name.clone();
    state.register_session(session);

    // Discover the `claude` PID inside the registered tmux pane in the
    // background so the reaper can monitor process liveness. This is the
    // daemon-side counterpart of the CLI's post-launch PID capture; it does not
    // block the response, and a failure is logged, never fatal.
    crate::services::session_service::spawn_pid_capture(Arc::clone(&state), id, tmux_name.clone());

    Json(RegisterSessionResponse {
        id,
        name: tmux_name,
    })
}

/// `DELETE /sessions/:id` — deregister a session.
#[utoipa::path(
    delete,
    path = "/sessions/{id}",
    tag = "sessions",
    params(("id" = String, Path, description = "Session UUID")),
    responses(
        (status = 200, description = "Session removed"),
        (status = 404, description = "No session with that id"),
    )
)]
pub async fn remove_session(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
) -> Result<Json<RemoveSessionResponse>, DaemonError> {
    let session = parse_id(&id)?;
    match state.remove_session(session) {
        Some(_) => Ok(Json(RemoveSessionResponse { removed: id })),
        None => Err(DaemonError::SessionNotFound { id }),
    }
}

/// `DELETE /sessions/dead` — reap registry entries with no live tmux session.
///
/// Why: dead sessions accumulate forever otherwise; an operator (or a periodic
/// task) needs a way to prune the registry down to what tmux actually hosts.
/// What: discovers tmux, calls [`DaemonState::reap_dead_sessions`], and returns
/// `{ "removed": <count> }`. If tmux is unavailable nothing is reaped (returns
/// `0`) — reaping against an empty list would wrongly delete every session.
/// Test: `reap_dead_sessions` in `state.rs` covers the core logic.
#[utoipa::path(
    delete,
    path = "/sessions/dead",
    tag = "sessions",
    responses((status = 200, description = "Dead sessions reaped; returns the removed count"))
)]
pub async fn reap_sessions(State(state): State<Arc<DaemonState>>) -> Json<ReapResponse> {
    let result = SessionService::new(&state).reap();
    Json(ReapResponse {
        removed: result.reaped,
        stopped: result.stopped,
    })
}

/// JSON body for `PATCH /sessions/{id}/pid`.
///
/// Why: after launching `claude` inside a tmux pane the CLI discovers the real
/// process PID and reports it back so the daemon can monitor process liveness.
/// What: the OS-level `claude` process id.
/// Test: `set_session_pid_records_pid`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct SetPidRequest {
    /// OS-level `claude` process id discovered inside the session's tmux pane.
    pub pid: u32,
}

/// `PATCH /sessions/{id}/pid` — record the OS-level `claude` process PID.
///
/// Why: a tmux session can outlive the `claude` process inside it; tracking the
/// real PID lets the reaper detect a stopped session. The CLI (and the daemon's
/// own launch path) discover the PID a few seconds after `send-keys` and report
/// it here.
/// What: resolves the session by UUID, sets `session.pid`, and echoes the id
/// and PID. An unknown id is `404`.
/// Test: `set_session_pid_records_pid`, `set_session_pid_unknown_is_404`.
#[utoipa::path(
    patch,
    path = "/sessions/{id}/pid",
    tag = "sessions",
    params(("id" = String, Path, description = "Session UUID")),
    request_body = SetPidRequest,
    responses(
        (status = 200, description = "PID recorded for the session"),
        (status = 404, description = "No session with that id"),
    )
)]
pub async fn set_session_pid(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
    Json(body): Json<SetPidRequest>,
) -> Result<Json<SetPidResponse>, DaemonError> {
    let session = parse_id(&id)?;
    if state.set_session_pid(session, body.pid) {
        Ok(Json(SetPidResponse {
            session_id: id,
            pid: body.pid,
        }))
    } else {
        Err(DaemonError::SessionNotFound { id })
    }
}

/// `POST /sessions/discover` — auto-discover Claude Code sessions.
///
/// Why: `GET /sessions` only reports daemon-managed sessions; operators run
/// `claude` / `claude-code` / `claude-mpm` / `tm` in tmux panes — and, more
/// commonly, in native Terminal.app windows — that the daemon never created.
/// This endpoint scans both and registers the ones running Claude Code so they
/// appear in the dashboard and the Telegram bot.
/// What: runs [`crate::discovery::discover_all`] (tmux panes plus native `ps`
/// processes) and returns `{ "discovered": <count>, "sessions": [name, ...] }`.
/// A missing tmux or `ps` yields a zero count rather than an error.
/// Test: `discover_sessions_returns_count` in `api_tests.rs`.
#[utoipa::path(
    post,
    path = "/sessions/discover",
    tag = "sessions",
    responses((status = 200, description = "tmux sessions running Claude Code, newly registered"))
)]
pub async fn discover_sessions(State(state): State<Arc<DaemonState>>) -> Json<DiscoverResponse> {
    let result = crate::discovery::discover_all(&state);
    Json(DiscoverResponse {
        discovered: result.adopted,
        sessions: result.sessions,
    })
}

/// `GET /sessions/:id/events` — recent hook events for one session.
#[utoipa::path(
    get,
    path = "/sessions/{id}/events",
    tag = "events",
    params(("id" = String, Path, description = "Session UUID")),
    responses(
        (status = 200, description = "Recent hook events for the session"),
        (status = 404, description = "No session with that id"),
    )
)]
pub async fn session_events(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
) -> Result<Json<EventsResponse>, DaemonError> {
    let session = parse_id(&id)?;
    Ok(Json(EventsResponse {
        events: state.hook_events_for(session),
    }))
}

/// Result of applying an optional compression level to captured output.
///
/// Why: the command and output endpoints share the same compress-then-return
/// shape; bundling the text and stats lets one helper produce both.
/// What: the (possibly compressed) text, the byte stats, and the level as a
/// lowercase wire string (`None` when no compression was applied).
/// Test: `apply_compression_off_is_passthrough`, `apply_compression_summarise`.
struct CompressedOutput {
    /// The output text after compression (or unchanged when off).
    text: String,
    /// Byte counts before and after compression.
    stats: trusty_mpm_core::compress::CompressionStats,
    /// Lowercase wire name of the level applied, or `None` when uncompressed.
    level_label: Option<String>,
}

/// Apply an optional compression level to captured pane output.
///
/// Why: `POST .../command` and `GET .../output` both accept an optional
/// `?compress=` query param; doing the compress-or-passthrough decision once
/// keeps the two handlers identical.
/// What: when `level` is `Some`, runs [`compress_output`] and records the
/// level's lowercase label; when `None`, returns the raw text with empty stats
/// and no label.
/// Test: `apply_compression_off_is_passthrough`, `apply_compression_summarise`.
fn apply_compression(level: Option<CompressionLevel>, raw: &str) -> CompressedOutput {
    match level {
        Some(level) => {
            let (text, stats) = trusty_mpm_core::compress::compress_output(raw, level);
            CompressedOutput {
                text,
                stats,
                level_label: Some(compression_level_label(level)),
            }
        }
        None => CompressedOutput {
            text: raw.to_string(),
            stats: trusty_mpm_core::compress::CompressionStats::default(),
            level_label: None,
        },
    }
}

/// Lowercase wire name for a [`CompressionLevel`].
///
/// Why: API responses report the applied level as a stable lowercase string,
/// matching the `snake_case` serde representation of the enum.
/// What: maps each variant to its `serde` wire name.
/// Test: `compress_level_label_matches_serde`.
fn compression_level_label(level: CompressionLevel) -> String {
    match level {
        CompressionLevel::Off => "off",
        CompressionLevel::Trim => "trim",
        CompressionLevel::Summarise => "summarise",
        CompressionLevel::Caveman => "caveman",
    }
    .to_string()
}

/// JSON body for `POST /sessions/{id}/pause`.
///
/// Why: a pause may carry an optional operator note describing where the
/// session was left off; when absent the daemon derives one from pane output.
/// What: an optional free-form summary string.
/// Test: `pause_then_resume_round_trips`.
#[derive(serde::Deserialize, utoipa::ToSchema, Default)]
pub struct PauseRequest {
    /// Optional note about where the session was left off.
    #[serde(default)]
    pub summary: Option<String>,
}

/// `POST /sessions/{id}/pause` — pause a session, saving its state for resume.
///
/// Why: an operator stepping away needs the session frozen with a "where I left
/// off" note that survives a daemon restart.
/// What: resolves the session by UUID or friendly name, captures the last 50
/// pane lines, sets `status = Paused` / `paused_at = now` / `pause_summary`
/// (the request note, or the first 500 chars of the `Summarise`-compressed
/// captured output), and mirrors the pause record to disk via
/// `session_store::save_pause`.
/// Test: `pause_then_resume_round_trips`, `pause_unknown_session_is_404`.
#[utoipa::path(
    post,
    path = "/sessions/{id}/pause",
    tag = "sessions",
    params(("id" = String, Path, description = "Session UUID or friendly name")),
    request_body = PauseRequest,
    responses(
        (status = 200, description = "Session paused; returns the pause summary"),
        (status = 404, description = "No session with that id or name"),
    )
)]
pub async fn pause_session(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
    Json(body): Json<PauseRequest>,
) -> Result<Json<PauseResponse>, DaemonError> {
    let result = SessionService::new(&state).pause(&id, body.summary)?;
    Ok(Json(PauseResponse {
        paused: true,
        session_id: result.session_id,
        summary: result.summary,
    }))
}

/// `POST /sessions/{id}/resume` — resume a previously-paused session.
///
/// Why: the counterpart to pause; clears the frozen state and the on-disk
/// pause record so the session is active again.
/// What: resolves the session, requires `status == Paused` (else `409`), sets
/// `status = Active` / `paused_at = None` / `pause_summary = None`, and removes
/// the pause file via `session_store::clear_pause`.
/// Test: `pause_then_resume_round_trips`, `resume_unpaused_session_is_409`.
#[utoipa::path(
    post,
    path = "/sessions/{id}/resume",
    tag = "sessions",
    params(("id" = String, Path, description = "Session UUID or friendly name")),
    responses(
        (status = 200, description = "Session resumed"),
        (status = 404, description = "No session with that id or name"),
        (status = 409, description = "Session is not paused"),
    )
)]
pub async fn resume_session(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
) -> Result<Json<ResumeResponse>, DaemonError> {
    SessionService::new(&state).resume(&id)?;
    Ok(Json(ResumeResponse { resumed: true }))
}

/// JSON body for `POST /sessions/{id}/command`.
///
/// Why: feeding a command into a session's tmux pane is how the operator (and
/// the Telegram bot) drives Claude Code remotely.
/// What: the command line to type into the pane.
/// Test: `send_command_returns_output_shape`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct CommandRequest {
    /// The command line to send to the session's tmux pane.
    pub command: String,
}

/// Query parameters for `POST /sessions/{id}/command`.
///
/// Why: the caller may want the captured output summarised before it returns,
/// completing the "summarize output" step of the full user cycle.
/// What: an optional compression level (`off`, `trim`, `summarise`,
/// `caveman`); when absent the raw pane capture is returned unchanged.
/// Test: `send_command_compress_query_defaults_off`.
#[derive(serde::Deserialize, Default)]
pub struct CommandQuery {
    /// Compression level to apply to the captured output before returning.
    /// Values: off, trim, summarise, caveman. Defaults to none (raw output).
    #[serde(default)]
    pub compress: Option<CompressionLevel>,
}

/// `POST /sessions/{id}/command` — send a command to a session's tmux pane.
///
/// Why: remote control of a running session — type a line, let it run, read
/// back what happened.
/// What: resolves the session (`404` if missing, `409` if `Stopped`), sends the
/// command via `TmuxDriver::send_line`, waits 500ms for output to settle, then
/// captures the last 100 pane lines. When `?compress=` is supplied the capture
/// is compressed at that level before returning. tmux errors are logged, not
/// fatal — the endpoint still returns `200` with whatever output was captured.
/// Test: `send_command_returns_output_shape`, `command_to_stopped_session_is_409`.
#[utoipa::path(
    post,
    path = "/sessions/{id}/command",
    tag = "sessions",
    params(
        ("id" = String, Path, description = "Session UUID or friendly name"),
        ("compress" = Option<String>, Query, description = "Compression level: off, trim, summarise, caveman"),
    ),
    request_body = CommandRequest,
    responses(
        (status = 200, description = "Command sent; returns captured pane output"),
        (status = 404, description = "No session with that id or name"),
        (status = 409, description = "Session is stopped"),
    )
)]
pub async fn send_command(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
    Query(query): Query<CommandQuery>,
    Json(body): Json<CommandRequest>,
) -> Result<Json<CommandResponse>, DaemonError> {
    let session = SessionService::new(&state).command_target(&id)?;
    TmuxService::send_command(&session, &body.command);

    // Give the pane a moment to render the command's output before capturing.
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    let raw = TmuxService::capture(&session, 100);
    let compressed = apply_compression(query.compress, &raw);

    Ok(Json(CommandResponse {
        sent: true,
        output: compressed.text,
        original_bytes: compressed.stats.original_bytes,
        compressed_bytes: compressed.stats.compressed_bytes,
        compress_level: compressed.level_label,
    }))
}

/// Query parameters for `GET /sessions/{id}/output`.
///
/// Why: the caller chooses how much scrollback to capture and whether to
/// summarise it; defaults keep the endpoint usable with no query string.
/// What: an optional line count (defaulting to 50 when absent) and an optional
/// compression level applied to the capture before returning.
/// Test: `get_output_returns_output_shape`, `output_query_defaults`.
#[derive(serde::Deserialize, Default)]
pub struct OutputQuery {
    /// Number of trailing pane lines to capture (default 50 when absent).
    #[serde(default)]
    pub lines: Option<u32>,
    /// Compression level to apply to the captured output before returning.
    /// Values: off, trim, summarise, caveman. Defaults to none (raw output).
    #[serde(default)]
    pub compress: Option<CompressionLevel>,
}

/// Default trailing-line count for `GET /sessions/{id}/output`.
fn default_output_lines() -> u32 {
    50
}

/// `GET /sessions/{id}/output` — capture the current tmux pane output.
///
/// Why: the dashboard and the Telegram bot show a session's recent output
/// without sending it a command.
/// What: resolves the session (`404` if missing), captures the last `?lines=N`
/// pane lines (default 50), optionally compresses it at `?compress=`, and
/// returns `{ output, lines, original_bytes, compressed_bytes, compress_level }`.
/// tmux being unavailable yields an empty `output` rather than an error.
/// Test: `get_output_returns_output_shape`, `output_unknown_session_is_404`.
#[utoipa::path(
    get,
    path = "/sessions/{id}/output",
    tag = "sessions",
    params(
        ("id" = String, Path, description = "Session UUID or friendly name"),
        ("lines" = Option<u32>, Query, description = "Trailing lines to capture (default 50)"),
        ("compress" = Option<String>, Query, description = "Compression level: off, trim, summarise, caveman"),
    ),
    responses(
        (status = 200, description = "Captured pane output"),
        (status = 404, description = "No session with that id or name"),
    )
)]
pub async fn get_output(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
    Query(query): Query<OutputQuery>,
) -> Result<Json<OutputResponse>, DaemonError> {
    let session = SessionService::new(&state).resolve(&id)?;
    let lines = query.lines.unwrap_or_else(default_output_lines);
    let raw = TmuxService::capture(&session, lines);
    let compressed = apply_compression(query.compress, &raw);
    Ok(Json(OutputResponse {
        output: compressed.text,
        lines,
        original_bytes: compressed.stats.original_bytes,
        compressed_bytes: compressed.stats.compressed_bytes,
        compress_level: compressed.level_label,
    }))
}

/// `GET /breakers` — every agent's circuit-breaker state.
#[utoipa::path(
    get,
    path = "/breakers",
    tag = "config",
    responses((status = 200, description = "Array of per-agent circuit-breaker states"))
)]
pub async fn breakers(State(state): State<Arc<DaemonState>>) -> Json<BreakersResponse> {
    let breakers = state
        .all_breakers()
        .into_iter()
        .map(|(agent, breaker)| BreakerEntry { agent, breaker })
        .collect();
    Json(BreakersResponse { breakers })
}

/// JSON body for the universal hook relay endpoint.
///
/// Why: the forwarder shim posts raw Claude Code hook events here; a typed
/// body documents the contract.
/// What: session id, the Claude Code event name, and the opaque payload.
/// Test: `hook_relay_ingests_known_event`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct HookPost {
    /// Session the event came from (UUID string).
    pub session_id: String,
    /// Claude Code event, e.g. `PreToolUse`. Deserialization rejects any name
    /// that is not a known [`HookEvent`] variant, so an unknown event is a
    /// `400` before the handler runs.
    #[schema(value_type = String)]
    pub event: HookEvent,
    /// Raw event payload (shape varies per event).
    #[serde(default)]
    #[schema(value_type = Object)]
    pub payload: serde_json::Value,
}

/// `POST /hooks` — universal hook relay; ingests one Claude Code hook event.
///
/// Why: this is how the daemon achieves full observability — a forwarder shim
/// configured for *all* 32 hook events posts each one here. It is also the
/// enforcement point for the optional session overseer.
/// What: parses the session id and event name. On a `SessionStart` event for
/// an unknown session it auto-registers that session (this is how a claude
/// session announces itself to the daemon — connection-driven registration,
/// not `POST /sessions`). It then runs the overseer on tool-use events
/// (auditing every decision; a `Block` returns `403` early), compresses
/// `PostToolUse` output, then appends a `HookEventRecord` to the ring buffer.
/// Rejects malformed ids with `400`.
/// Test: `hook_relay_ingests_known_event`, `hook_relay_rejects_unknown_event`,
/// `overseer_blocks_pre_tool_use`, `session_start_auto_registers_session`.
#[utoipa::path(
    post,
    path = "/hooks",
    tag = "internal",
    request_body = HookPost,
    responses(
        (status = 200, description = "Hook event accepted"),
        (status = 400, description = "Unknown event name or malformed session id"),
        (status = 403, description = "Overseer blocked the event"),
    )
)]
pub async fn ingest_hook(
    State(state): State<Arc<DaemonState>>,
    Json(post): Json<HookPost>,
) -> Result<Json<HookAcceptedResponse>, DaemonError> {
    let session = parse_id(&post.session_id)?;

    // Auto-register on SessionStart if not already known. This is how a claude
    // session connects itself to the daemon: its first hook event registers it
    // using the incoming UUID, so discovery and `POST /sessions` are not the
    // only ways a session enters state. The workdir is left empty here and
    // enriched later by a snapshot or subsequent events.
    if post.event == HookEvent::SessionStart && state.session(session).is_none() {
        let mut new_session = Session::new(session, String::new(), ControlModel::Tmux, None);
        new_session.status = SessionStatus::Active;
        state.register_session(new_session);
        tracing::info!("auto-registered session on SessionStart: {session:?}");
    }

    match HookService::new(&state).process(session, post.event, post.payload) {
        HookDecision::Block { reason } => Err(DaemonError::OverseerBlocked { reason }),
        _ => Ok(Json(HookAcceptedResponse {
            accepted: post.event,
        })),
    }
}

/// `GET /overseer` — current session-overseer configuration and status.
///
/// Why: the CLI and dashboard surface whether oversight is active and which
/// strategy is in force.
/// What: returns `{ "overseer": { "enabled": <bool>, "handler": <str> } }`,
/// where `handler` is the active strategy name reported by the overseer.
/// Test: `get_overseer_returns_status`.
#[utoipa::path(
    get,
    path = "/overseer",
    tag = "config",
    responses((status = 200, description = "Overseer enabled flag and handler type"))
)]
pub async fn get_overseer(State(state): State<Arc<DaemonState>>) -> Json<OverseerResponse> {
    Json(OverseerResponse {
        overseer: OverseerStatus {
            enabled: state.overseer().is_enabled(),
            handler: state.overseer_handler().to_string(),
        },
    })
}

/// `POST /llm/chat` — send a message to the LLM chat assistant.
///
/// Why: the Telegram bot routes free-text (non-command) messages here, and the
/// TUI's `/chat` command does the same; both want a conversational endpoint
/// that reuses the overseer's already-resolved OpenRouter credentials.
/// What: requires a configured LLM overseer (else `503`), runs
/// [`LlmOverseer::chat`] over the client-supplied history, and returns the
/// assistant reply plus the updated history. The daemon stays stateless about
/// chat sessions — the caller owns the history.
/// Test: `llm_chat_without_overseer_is_503`.
#[utoipa::path(
    post,
    path = "/llm/chat",
    tag = "config",
    request_body = LlmChatRequest,
    responses(
        (status = 200, description = "Assistant reply and updated history"),
        (status = 503, description = "LLM chat is not configured on this daemon"),
    )
)]
pub async fn llm_chat(
    State(state): State<Arc<DaemonState>>,
    Json(body): Json<LlmChatRequest>,
) -> Result<Json<LlmChatResponse>, DaemonError> {
    let overseer = state.llm_overseer().ok_or_else(|| {
        DaemonError::ServiceUnavailable(
            "LLM chat is not configured (no OpenRouter API key)".to_string(),
        )
    })?;
    let mut history = body.history;
    let reply = overseer
        .chat(&mut history, &body.message)
        .await
        .map_err(|e| DaemonError::Internal(e.to_string()))?;
    Ok(Json(LlmChatResponse { reply, history }))
}

/// `GET /optimizer` — current token-use optimizer configuration.
///
/// Why: the CLI and dashboard surface the active compression tuning. The
/// config is now framework-managed on disk (`optimizer.toml`); this endpoint
/// is read-only introspection of the daemon's in-memory copy of it.
/// What: returns `{ "optimizer": <OptimizerConfig> }`.
/// Test: `get_optimizer_returns_default`.
#[utoipa::path(
    get,
    path = "/optimizer",
    tag = "config",
    responses((status = 200, description = "Current token-use optimizer configuration"))
)]
pub async fn get_optimizer(State(state): State<Arc<DaemonState>>) -> Json<OptimizerResponse> {
    Json(OptimizerResponse {
        optimizer: state.optimizer_config(),
    })
}

/// JSON body for registering a project via `POST /projects`.
///
/// Why: `trusty-mpm project init` announces a working directory to the daemon
/// so sessions started there can be associated with it.
/// What: the absolute path of the project's working directory.
/// Test: `register_and_list_projects`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct RegisterProject {
    /// Absolute path to the project's working directory.
    #[schema(value_type = String)]
    pub path: PathBuf,
}

/// `POST /projects` — register a project, returning its `ProjectInfo`.
///
/// Why: the daemon owns the project registry; `project init` posts the
/// resolved directory here.
/// What: delegates to [`DaemonState::register_project`] and returns the
/// stored info as JSON.
/// Test: `register_and_list_projects`.
#[utoipa::path(
    post,
    path = "/projects",
    tag = "projects",
    request_body = RegisterProject,
    responses((status = 201, description = "Project registered", body = ProjectInfo))
)]
pub async fn register_project(
    State(state): State<Arc<DaemonState>>,
    Json(body): Json<RegisterProject>,
) -> Json<ProjectInfo> {
    Json(state.register_project(body.path))
}

/// `GET /projects` — snapshot of every registered project.
#[utoipa::path(
    get,
    path = "/projects",
    tag = "projects",
    responses((status = 200, description = "Array of registered projects", body = [ProjectInfo]))
)]
pub async fn list_projects(State(state): State<Arc<DaemonState>>) -> Json<ProjectsResponse> {
    Json(ProjectsResponse {
        projects: state.list_projects(),
    })
}

/// Query parameters for `GET /projects/current`.
///
/// Why: the daemon cannot see the caller's cwd; the CLI passes the resolved
/// path so the daemon can look the project up.
/// What: the path to resolve a project for.
/// Test: `current_project_found_and_missing`.
#[derive(serde::Deserialize)]
pub struct CurrentProjectQuery {
    /// Path whose registered project should be returned.
    pub path: PathBuf,
}

/// `GET /projects/current?path=<dir>` — the project registered for `path`.
///
/// Why: `trusty-mpm project info` shows the current directory's project; the
/// daemon resolves the path against its registry.
/// What: returns the matching `ProjectInfo`, or `404` when `path` is not a
/// registered project.
/// Test: `current_project_found_and_missing`.
#[utoipa::path(
    get,
    path = "/projects/current",
    tag = "projects",
    params(("path" = String, Query, description = "Directory whose project to resolve")),
    responses(
        (status = 200, description = "The project registered for the path", body = ProjectInfo),
        (status = 404, description = "Path is not a registered project"),
    )
)]
pub async fn current_project(
    State(state): State<Arc<DaemonState>>,
    Query(query): Query<CurrentProjectQuery>,
) -> Result<Json<ProjectInfo>, DaemonError> {
    match state.project(&query.path) {
        Some(info) => Ok(Json(info)),
        None => Err(DaemonError::SessionNotFound {
            id: query.path.display().to_string(),
        }),
    }
}

/// `GET /projects/discover` — projects mined from `~/.claude/projects/`.
///
/// Why: rather than register every repo by hand, the operator wants trusty-mpm
/// to enumerate the projects Claude Code already knows about and offer them for
/// one-tap registration (the Telegram `/projects` command consumes this).
/// What: runs [`ProjectDiscovery::discover`], maps each row to a
/// [`DiscoveredProjectInfo`] (path as a string, last-session time as an
/// ISO-8601 string), and returns them newest-session-first. The discovery
/// itself never fails — an absent directory yields an empty list.
/// Test: `discover_projects_returns_array`.
#[utoipa::path(
    get,
    path = "/projects/discover",
    tag = "projects",
    responses((status = 200, description = "Projects discovered from Claude Code config"))
)]
pub async fn discover_projects(
    State(_state): State<Arc<DaemonState>>,
) -> Json<DiscoverProjectsResponse> {
    let projects = trusty_mpm_core::project_discovery::ProjectDiscovery::discover()
        .into_iter()
        .map(|p| DiscoveredProjectInfo {
            path: p.path.display().to_string(),
            session_count: p.session_count,
            last_session: p.last_session.map(system_time_to_iso8601),
        })
        .collect();
    Json(DiscoverProjectsResponse { projects })
}

/// Render a `SystemTime` as an ISO-8601 / RFC3339 UTC string.
///
/// Why: the discovery endpoint reports session times as human- and
/// machine-readable strings; `SystemTime` has no wire-stable serde form.
/// What: converts via `chrono::DateTime<Utc>`, falling back to the Unix epoch
/// for the (unreachable in practice) pre-1970 case.
/// Test: covered by `discover_projects_returns_array`.
fn system_time_to_iso8601(time: std::time::SystemTime) -> String {
    let datetime: chrono::DateTime<chrono::Utc> = time.into();
    datetime.to_rfc3339()
}

// ---- universal tmux session management ---------------------------------

/// `GET /tmux/sessions` — every tmux session on the host, origin-tagged.
///
/// Why: trusty-mpm manages *all* tmux sessions, not just the ones it created;
/// the dashboard needs the full list with an origin label so it can offer to
/// adopt external sessions.
/// What: runs `TmuxDriver::list_all_sessions` and returns
/// `{ "sessions": [ExternalSession, ...] }`. tmux being unavailable yields an
/// empty array rather than an error.
/// Test: `list_tmux_sessions_returns_array`.
#[utoipa::path(
    get,
    path = "/tmux/sessions",
    tag = "tmux",
    responses((status = 200, description = "All tmux sessions with origin labels"))
)]
pub async fn list_tmux_sessions(
    State(_state): State<Arc<DaemonState>>,
) -> Json<TmuxSessionsResponse> {
    Json(TmuxSessionsResponse {
        sessions: TmuxService::list_all(),
    })
}

/// `GET /tmux/sessions/{name}/snapshot` — capture any session's current state.
///
/// Why: the dashboard inspects any session (internal or external) without
/// attaching to it.
/// What: runs `TmuxDriver::monitor_session` for the last 100 pane lines and
/// returns the [`SessionSnapshot`]. A missing session or absent tmux is `404`.
/// Test: `tmux_snapshot_unknown_session_is_404` (covers the no-tmux path).
#[utoipa::path(
    get,
    path = "/tmux/sessions/{name}/snapshot",
    tag = "tmux",
    params(("name" = String, Path, description = "tmux session name")),
    responses(
        (status = 200, description = "Session snapshot"),
        (status = 404, description = "Session not found or tmux unavailable"),
    )
)]
pub async fn tmux_snapshot(
    State(_state): State<Arc<DaemonState>>,
    Path(name): Path<String>,
) -> Result<Json<TmuxSnapshotResponse>, DaemonError> {
    let snapshot = TmuxService::snapshot(&name, 100)?;
    Ok(Json(TmuxSnapshotResponse { snapshot }))
}

/// JSON body for `POST /tmux/adopt`.
///
/// Why: adopting an external session needs only its name.
/// What: the tmux session name to bring under oversight.
/// Test: `adopt_tmux_session_handles_missing`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct AdoptRequest {
    /// tmux session name to adopt.
    pub session: String,
}

/// `POST /tmux/adopt` — register an external tmux session for oversight.
///
/// Why: trusty-mpm should watch sessions it did not create; adoption is the
/// explicit, non-destructive opt-in for that.
/// What: runs `TmuxDriver::adopt_session` (which captures the session's shape
/// without modifying it) and returns the [`AdoptedSession`]. A missing session
/// or absent tmux is `404`.
/// Test: `adopt_tmux_session_handles_missing`.
#[utoipa::path(
    post,
    path = "/tmux/adopt",
    tag = "tmux",
    request_body = AdoptRequest,
    responses(
        (status = 200, description = "Session adopted; returns its captured state"),
        (status = 404, description = "Session not found or tmux unavailable"),
    )
)]
pub async fn adopt_tmux_session(
    State(_state): State<Arc<DaemonState>>,
    Json(body): Json<AdoptRequest>,
) -> Result<Json<AdoptResponse>, DaemonError> {
    let adopted = TmuxService::adopt(&body.session)?;
    Ok(Json(AdoptResponse { adopted }))
}

// ---- bot pairing --------------------------------------------------------

/// `POST /pair/request` — generate a one-time Telegram-bot pairing code.
///
/// Why: pairing the Telegram bot to this daemon needs an out-of-band shared
/// secret; `tm pair` calls this on the local daemon to obtain a short code the
/// operator then types into the bot.
/// What: generates a six-character code (stored with a five-minute TTL) and
/// returns `{ "code", "expires_in_seconds" }`.
/// Test: `pair_request_returns_code`.
#[utoipa::path(
    post,
    path = "/pair/request",
    tag = "config",
    responses((status = 200, description = "A one-time pairing code and its TTL"))
)]
pub async fn pair_request(
    State(state): State<Arc<DaemonState>>,
) -> Json<crate::services::PairCode> {
    Json(PairingService::new(&state).request_code())
}

/// JSON body for `POST /pair/confirm`.
///
/// Why: confirming a pairing needs the operator's code and the Telegram chat id
/// to bind.
/// What: the six-character code and the chat id.
/// Test: `pair_confirm_validates_code`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct PairConfirmRequest {
    /// The one-time pairing code issued by `POST /pair/request`.
    pub code: String,
    /// The Telegram chat id to pair with this daemon.
    pub chat_id: i64,
}

/// `POST /pair/confirm` — confirm a pairing code and register the chat.
///
/// Why: the Telegram bot's `/pair <code>` flow validates the operator's code so
/// push alerts have an authenticated destination.
/// What: validates `code` against the outstanding code within its TTL; on
/// success stores `chat_id` and returns `{ "success": true, "chat_id" }`,
/// otherwise `{ "success": false, "error": "invalid or expired code" }`.
/// Test: `pair_confirm_validates_code`, `pair_confirm_rejects_bad_code`.
#[utoipa::path(
    post,
    path = "/pair/confirm",
    tag = "config",
    request_body = PairConfirmRequest,
    responses((status = 200, description = "Pairing result (success flag and chat id or error)"))
)]
pub async fn pair_confirm(
    State(state): State<Arc<DaemonState>>,
    Json(body): Json<PairConfirmRequest>,
) -> Json<PairConfirmResponse> {
    match PairingService::new(&state).confirm(&body.code, body.chat_id) {
        Ok(()) => Json(PairConfirmResponse {
            success: true,
            chat_id: Some(body.chat_id),
            error: None,
        }),
        Err(_) => Json(PairConfirmResponse {
            success: false,
            chat_id: None,
            error: Some("invalid or expired code".to_string()),
        }),
    }
}

/// `GET /pair/status` — report whether a Telegram chat is paired.
///
/// Why: the bot's `/start` command branches on whether the daemon is already
/// paired so it shows either a welcome-and-pair prompt or a ready message.
/// What: returns `{ "paired": <bool>, "chat_id": <i64 or null> }`.
/// Test: `pair_status_reports_unpaired`.
#[utoipa::path(
    get,
    path = "/pair/status",
    tag = "config",
    responses((status = 200, description = "Pairing status (paired flag and chat id)"))
)]
pub async fn pair_status(
    State(state): State<Arc<DaemonState>>,
) -> Json<crate::services::PairStatus> {
    Json(PairingService::new(&state).status())
}

/// `POST /pair/reset` — clear the Telegram pairing.
///
/// Why: an operator unpairing the bot must drop the binding both in memory and
/// on disk so a daemon restart does not restore it from `pairing.json`.
/// What: delegates to [`PairingService::reset`] and returns `{ "reset": true }`.
/// Test: `pair_reset_clears_pairing` in `api_tests.rs`.
#[utoipa::path(
    post,
    path = "/pair/reset",
    tag = "config",
    responses((status = 200, description = "Pairing cleared"))
)]
pub async fn pair_reset(State(state): State<Arc<DaemonState>>) -> Json<PairResetResponse> {
    PairingService::new(&state).reset();
    Json(PairResetResponse { reset: true })
}

/// Parse a UUID string into a `SessionId`, mapping failure to a `400`-mapped
/// [`DaemonError::InvalidRequest`].
fn parse_id(raw: &str) -> Result<SessionId, DaemonError> {
    uuid::Uuid::parse_str(raw)
        .map(SessionId)
        .map_err(|_| DaemonError::InvalidRequest(format!("malformed session id: {raw}")))
}

/// Handler unit tests.
///
/// Why: the suite is large enough that keeping it in `api.rs` pushed the file
/// past a maintainable size; a `#[path]`-linked sibling keeps the handler
/// surface readable while the tests still see the private helpers via
/// `super::*`.
/// Test: this *is* the test module.
#[cfg(test)]
#[path = "api_tests.rs"]
mod tests;