wsb-daemon 0.258.6441

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
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
use wsb::prelude::*;
pub mod github;
pub mod handlers;

pub mod fs_scope;
pub mod notes;
pub mod log_janitor;
pub mod machine_web;
pub mod op_log;
pub mod ops;
pub mod parser;
#[cfg(test)]
#[path = "proxy_tests.rs"]
mod proxy_tests;
pub mod search_sources;
pub mod state;
pub mod sync;

use anyhow::{Context, Result};
use axum::{
    body::{boxed, Full},
    http::{header, StatusCode, Uri},
    response::{IntoResponse, Response},
    routing::{delete, get, patch, post, put},
    Router,
};
use rust_embed::RustEmbed;
use wsb::fleet::config::gitmodules::validate_gitmodules_structure;
use std::env;
use std::net::{SocketAddr, TcpListener};
use tower_http::cors::CorsLayer;

use state::WebState;

#[derive(RustEmbed)]
// The workbench's built assets, in the build script's OUT_DIR — the same path
// build.rs writes.
#[folder = "$OUT_DIR/workbench/dist"]
struct FrontendAssets;

/// Why the workbench's port could not be bound, in words that are the same on
/// every platform.
///
/// A port somebody else holds is told apart from an address this machine
/// cannot bind, by the error's kind rather than its text: the text is the
/// operating system's, and it differs. Linux and macOS say "Address already in
/// use"; Windows says "Only one usage of each socket address … is normally
/// permitted (os error 10048)". Anything that matched the first wording —
/// the test fixtures' retry on a lost port race among them — missed the
/// second, and so failed on Windows over a race it would have survived.
pub(crate) fn bind_failure(addr: SocketAddr, error: std::io::Error) -> anyhow::Error {
    if error.kind() == std::io::ErrorKind::AddrInUse {
        anyhow::anyhow!(
            "cannot serve the workbench on {addr}: the port is already in use. Stop \
             whatever holds it (another wsb daemon?), or choose another with --port."
        )
    } else {
        anyhow::anyhow!(
            "cannot serve the workbench on {addr}: this machine cannot bind that address \
             ({error}). Choose another with --listen/--port."
        )
    }
}

/// Serve the workbench and the control API on one port.
///
/// Both planes are one server. The daemon already binds a port for the page;
/// a second listener for the command line would be a second thing to bind,
/// record in the rendezvous file, and explain to whoever is reading a firewall
/// rule.
///
/// The listener is bound and handed back through `bound` BEFORE the server
/// starts running, so the caller can publish its rendezvous file knowing the
/// port is claimed — a file published earlier would send a command line at a
/// port nothing is listening on.
pub async fn start_server(
    machine: Arc<crate::machine::MachineState>,
    parent_pid: Option<u32>,
    bound: tokio::sync::oneshot::Sender<SocketAddr>,
) -> Result<()> {
    let listen = machine.listen;
    let port = machine.port;

    // Claim the port before doing any expensive setup. If another daemon is
    // already on it, bail out cleanly rather than spawning background workers
    // that will be thrown away.
    let addr = SocketAddr::new(listen, port);
    let listener = TcpListener::bind(addr).map_err(|error| bind_failure(addr, error))?;

    // Said once, loudly, at the moment it becomes true.
    //
    // This server has NO authentication. It reads and edits every worktree
    // it has open and runs git on them. On loopback that is the operator
    // talking to their own machine; on any other address it is everyone who
    // can route to it.
    if !listen.is_loopback() {
        wsb::errln!(
            "[wsb] WARNING: the workbench is reachable at {addr}, not only from this \
             machine.\n       It has no authentication: anyone who can reach this \
             address can read every workspace on it and start operations on this \
             host.\n\x20      Put it behind a tunnel (Tailscale, SSH, a TLS reverse \
             proxy), or drop --listen to serve only 127.0.0.1."
        );
    }
    listener.set_nonblocking(true)?;

    // The port is claimed. Whoever is waiting to publish a rendezvous file may
    // do it now: from here on, a command line that connects gets an answer.
    // The address the kernel actually gave, not the one that was asked for.
    // They differ whenever the request was port 0, and the rendezvous file is
    // how everything else finds this daemon — a file naming the requested port
    // sends every caller to a port nothing is listening on.
    let actual = listener
        .local_addr()
        .context("the workbench listener has no address")?;
    let _ = bound.send(actual);

    // The machine's GitHub mirror is kept current once, for every worktree.
    // Without one — PRJ unset — there is nothing to keep, and every GitHub
    // route says why.
    match &machine.github {
        Ok(github) => github::spawn(github.clone()),
        Err(reason) => wsb::errln!("[wsb] GitHub is unavailable: {reason}"),
    }
    // So are the machine's agent sessions.
    machine.web.spawn_services();
    // And the list of its projects.
    handlers::prj::spawn_listing(machine.clone());
    handlers::prj::spawn_watcher(machine.clone());

    let app = machine_router(machine.clone());
    wsb::errln!("[wsb] listening on http://{}", addr);

    // If a parent PID was given, monitor it and exit as soon as it is gone, so
    // an embedded daemon does not outlive the application that started it.
    if let Some(pid) = parent_pid {
        tokio::spawn(async move {
            loop {
                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
                if !wsb::ops::process::is_alive(pid) {
                    wsb::errln!("[wsb] parent process {} exited — shutting down", pid);
                    crate::remove_rendezvous();
                    std::process::exit(0);
                }
            }
        });
    }

    axum::Server::from_tcp(listener)?
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

/// Start a worktree's services once, however many times it is resolved.
///
/// Every command resolves its worktree, and a daemon that spawned a watcher
/// set per command would have a hundred of them by lunchtime.
pub fn spawn_worktree_services_once(runtime: &Arc<crate::machine::WorktreeRuntime>) {
    static STARTED: std::sync::OnceLock<std::sync::Mutex<std::collections::BTreeSet<String>>> =
        std::sync::OnceLock::new();
    let started = STARTED.get_or_init(|| std::sync::Mutex::new(Default::default()));
    let Ok(mut started) = started.lock() else {
        return;
    };
    if !started.insert(runtime.registered.id.to_string()) {
        return;
    }
    spawn_worktree_services(runtime.web.clone());
}

/// Everything a worktree needs once it is open: its watchers, and the
/// reconciliation of its operation list.
///
/// Called when a worktree is first opened rather than at startup, because a
/// worktree can be opened at any time — the first command run in it is
/// enough. The machine's own services — the session sync and its transcript
/// watcher — are not here: they are the machine's, and start once.
pub fn spawn_worktree_services(state: WebState) {
    // The DAEMON reconciles its own operation list, whether or not anybody is
    // looking. An adopted run resolves when its process reports an exit, and a
    // process that was killed never does — so without this a row says
    // "running" until the daemon restarts. Doing it when a page asks would
    // make the truth a property of somebody having the panel open; the
    // workbench consumes this state, it does not maintain it.
    let reconcile = state.ops.clone();
    tokio::spawn(async move {
        // Faster than anyone's patience for a stale row, and cheap: one file
        // read per unresolved adopted op, of which there are single digits.
        let mut every = tokio::time::interval(std::time::Duration::from_secs(5));
        loop {
            every.tick().await;
            reconcile.resolve_finished_adopted().await;
        }
    });

    handlers::fleet::spawn_git_watcher(state.clone());
    handlers::fleet::spawn_git_fs_watcher(state.clone());
    spawn_workspace_file_watcher(state);
}

/// The machine's own router.
///
/// Three kinds of route, and the separation is the architecture:
///
/// * `/api/v1/machine`, `/api/v1/projects`, `/api/v1/peers/*`, the sessions
///   and the host — the machine's own state, which belongs to no worktree.
/// * `/api/v1/worktrees/{id}/*` — one worktree's routes, served by that
///   worktree's own router with its own state. The worktree is a path
///   segment rather than an assumption about the process.
/// * everything else — the workbench's static assets.
fn machine_router(machine: Arc<crate::machine::MachineState>) -> Router {
    Router::new()
        .merge(handlers::machine::routes())
        .merge(handlers::prj::routes())
        .merge(crate::peer::routes())
        .merge(machine_web::routes(machine.web.clone()))
        .route(
            "/api/v1/worktrees/:worktree/*rest",
            axum::routing::any(worktree_proxy),
        )
        .fallback(spa_handler)
        .layer(
            tower_http::cors::CorsLayer::new()
                .allow_origin(tower_http::cors::Any)
                .allow_methods(tower_http::cors::Any)
                .allow_headers(tower_http::cors::Any),
        )
        // Merged AFTER the layer, and therefore outside it: these are the
        // daemon's own control plane — opening worktrees, shutdown — and
        // a permissive CORS header on them would let any page a browser
        // happens to be showing drive this machine's daemon over localhost.
        // The clients that use them are processes, not pages.
        .merge(crate::control::routes())
        .with_state(machine)
}

/// Hand a request to the worktree it names, opening it if this daemon has
/// not.
///
/// The worktree's router was built with the worktree's own state, so the
/// handlers underneath it are exactly the ones that existed when a daemon
/// served one checkout — none of them had to learn about the machine.
async fn worktree_proxy(
    axum::extract::State(machine): axum::extract::State<Arc<crate::machine::MachineState>>,
    axum::extract::Path((worktree, rest)): axum::extract::Path<(String, String)>,
    request: axum::http::Request<axum::body::Body>,
) -> axum::response::Response {
    use axum::response::IntoResponse;
    use tower::ServiceExt;

    let refuse = |status: axum::http::StatusCode, error: String| {
        (status, axum::Json(serde_json::json!({ "error": error }))).into_response()
    };
    let id = match wsb::daemon::WorktreeId::parse(&worktree) {
        Ok(id) => id,
        Err(error) => return refuse(axum::http::StatusCode::BAD_REQUEST, error),
    };
    // A worktree that is not open yet is opened, not refused: the page names
    // worktrees from the machine's list, and one nobody has run a command in
    // since the daemon started is no less a worktree.
    let runtime = match machine.open(&id).await {
        Ok(runtime) => runtime,
        Err(error) => return refuse(axum::http::StatusCode::NOT_FOUND, error),
    };
    spawn_worktree_services_once(&runtime);

    let request = match inner_request(&rest, request) {
        Ok(request) => request,
        Err(error) => return refuse(axum::http::StatusCode::BAD_REQUEST, error),
    };
    match runtime.router().oneshot(request).await {
        Ok(response) => response,
        // `Router`'s error type is `Infallible`, so this arm is unreachable —
        // stated rather than unwrapped so a future change to that type is a
        // compile error rather than a panic in a request.
        Err(error) => match error {},
    }
}

/// The request a worktree's own router is handed for one reached at
/// `/api/v1/worktrees/<id>/<rest>`.
///
/// The worktree's router knows nothing about the prefix it was reached
/// through, so the path is rewritten to what it expects, the query kept.
///
/// A FRESH request, rather than the outer one with its URI swapped. Routing
/// leaves its captures in the request's extensions, and the inner router's
/// `Path` extractor reads them from there. Handing it the outer request meant
/// every worktree handler taking more than one path segment got the OUTER
/// captures instead of its own: `/issues/:owner/:repo` resolved to owner =
/// the worktree id and repo = "issues/machinefabric/…". It answered,
/// plausibly, about a repository that does not exist — the worst kind of
/// wrong. Nothing from the outer routing belongs in here.
pub(crate) fn inner_request(
    rest: &str,
    request: axum::http::Request<axum::body::Body>,
) -> Result<axum::http::Request<axum::body::Body>, String> {
    let query = request
        .uri()
        .query()
        .map(|q| format!("?{q}"))
        .unwrap_or_default();
    let uri: axum::http::Uri = format!("/api/v1/{}{}", rest.trim_start_matches('/'), query)
        .parse()
        .map_err(|error| format!("{error}"))?;
    let (parts, body) = request.into_parts();
    let mut inner = axum::http::Request::builder()
        .method(parts.method)
        .uri(uri)
        .version(parts.version)
        .body(body)
        .map_err(|error| format!("{error}"))?;
    *inner.headers_mut() = parts.headers;
    Ok(inner)
}

pub fn validate_gitmodules_if_present(workspace_root: &Path) -> Result<()> {
    let path = workspace_root.join(".gitmodules");
    if !path.exists() {
        return Ok(());
    }
    let bytes =
        std::fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?;
    validate_gitmodules_structure(&bytes, &path).map_err(|e| anyhow::anyhow!("{}", e))
}

/// Spawn a lightweight file watcher over the workspace root that emits
/// `FileChangedEvent` on `state.file_changed_tx` whenever a non-hidden file
/// is created, modified, or deleted. The git watcher already handles `.git/`
/// changes; this watcher covers ordinary workspace files so open editors can
/// detect on-disk conflicts.
///
/// The watch is SCOPED, not recursive-and-filtered: on Linux a recursive
/// watch costs one inotify watch per directory, and this workspace's ignored
/// artifact trees (node_modules/, target/, build/) have enough directories to
/// exhaust `fs.inotify.max_user_watches` on their own. Git-ignored
/// directories hold no files an editor opens for conflict detection, so they
/// are outside the scope entirely — see `web::fs_scope`.
fn spawn_workspace_file_watcher(state: WebState) {
    use std::time::Duration;

    let workspace_root = state.workspace_root.clone();
    let tx = state.file_changed_tx.clone();
    let root_clone = workspace_root.clone();

    let outcome = fs_scope::spawn_scoped_watcher(
        "workspace-files".to_string(),
        vec![workspace_root.clone()],
        // Cross submodule boundaries: an editor opens files in every repo,
        // so this one watcher covers the whole workspace.
        true,
        Duration::from_millis(300),
        move |events| {
            for event in events {
                let path = &event.path;

                // Hidden files and .git-adjacent churn stay out even when a
                // hidden FILE sits inside a watched directory.
                let rel = match path.strip_prefix(&root_clone) {
                    Ok(r) => r,
                    Err(_) => continue,
                };
                let rel_str = rel.to_string_lossy();
                if rel_str.split('/').any(|seg| seg.starts_with('.')) {
                    continue;
                }

                let mtime_secs = std::fs::metadata(path)
                    .ok()
                    .and_then(|m| m.modified().ok())
                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                    .map(|d| d.as_secs());

                let _ = tx.send(crate::web::state::FileChangedEvent {
                    path: rel_str.into_owned(),
                    mtime_secs,
                });
            }
        },
    );
    if let Err(e) = outcome {
        wsb::errln!("[wsb] workspace file watcher not running: {}", e);
    }
}

pub(crate) fn home_dir() -> Result<PathBuf> {
    if let Some(home) = env::var_os("HOME") {
        let path = PathBuf::from(home);
        if !path.as_os_str().is_empty() {
            return Ok(path);
        }
    }

    std::panic::catch_unwind(dirs::home_dir)
        .ok()
        .flatten()
        .context("cannot determine home directory")
}

pub fn create_router(state: WebState) -> Router {
    let api = Router::new()
        // At a glance, read now.
        .route(
            "/overview/stats",
            get(handlers::overview::get_overview_stats),
        )
        // Search this worktree's files. The machine's sessions are searched
        // at the machine's `/search`.
        .route("/search", get(handlers::search::file_search))
        // Path notes (markdown files across workspace, linked via ](path) targets)
        .route("/path-notes", get(handlers::path_notes::list_path_notes))
        .route("/path-notes", post(handlers::path_notes::create_path_note))
        // The test catalogue: every test in the workspace, as a set.
        .route("/catalogue/tests", get(handlers::catalogue::list_tests))
        .route("/catalogue/survey", get(handlers::catalogue::survey))
        .route("/catalogue/parity", get(handlers::catalogue::parity))
        .route("/catalogue/free", get(handlers::catalogue::free_numbers))
        .route("/catalogue/scan", post(handlers::catalogue::scan))
        // Settings
        .route("/settings", get(handlers::settings::get_settings))
        .route("/settings", put(handlers::settings::update_settings))
        // Workspace
        .route("/workspace/trees", post(handlers::workspace::list_trees))
        // Every workspace-wide push rides one connection; see handlers::events.
        .route("/events", get(handlers::events::events_stream))
        .route("/workspace/file", get(handlers::workspace::read_file))
        .route("/workspace/file", post(handlers::workspace::save_file))
        .route("/workspace/update", post(handlers::workspace::run_update))
        .route(
            "/workspace/create-file",
            post(handlers::workspace::create_file),
        )
        .route(
            "/workspace/create-directory",
            post(handlers::workspace::create_directory),
        )
        .route("/workspace/delete", post(handlers::workspace::delete_entry))
        .route("/workspace/rename", post(handlers::workspace::rename_entry))
        .route("/workspace/copy", post(handlers::workspace::copy_entry))
        .route(
            "/workspace/workbench-model",
            get(handlers::extensions::get_workbench_model),
        )
        .route(
            "/workspace/extensions/execute",
            post(handlers::extensions::execute_extension_command),
        )
        .route(
            "/workspace/extensions/:extension_id/assets",
            get(handlers::extensions::get_extension_asset),
        )
        .route(
            "/workspace/extensions/:extension_id/readme",
            get(handlers::extensions::get_extension_readme),
        )
        // Marketplace
        .route(
            "/marketplace/search",
            get(handlers::marketplace::search_marketplace),
        )
        .route(
            "/marketplace/readme",
            get(handlers::marketplace::get_marketplace_readme),
        )
        .route(
            "/marketplace/install",
            post(handlers::marketplace::install_extension),
        )
        .route(
            "/marketplace/inspect",
            post(handlers::marketplace::inspect_marketplace_extension),
        )
        .route(
            "/marketplace/uninstall",
            post(handlers::marketplace::uninstall_extension),
        )
        .route(
            "/marketplace/disable",
            post(handlers::marketplace::disable_extension),
        )
        .route(
            "/marketplace/enable",
            post(handlers::marketplace::enable_extension),
        )
        // Workbench state
        .route(
            "/workbench/state",
            get(handlers::workbench_state::get_workbench_state),
        )
        .route(
            "/workbench/state",
            put(handlers::workbench_state::put_workbench_state),
        )
        // Version
        .route("/version", get(handlers::version::get_version))
        // Issues
        .route("/issues", get(handlers::issues::list_issues))
        // The live stream is gone: it fetched from GitHub while a page
        // watched, which is the behaviour the mirror replaces. Refreshing is
        // an action; learning that something changed is the `github` event.
        .route("/issues/refresh", post(handlers::issues::refresh_issues))
        .route(
            "/issues/:owner/:repo",
            get(handlers::issues::list_repo_issues),
        )
        .route("/issues/:owner/:repo", post(handlers::issues::create_issue))
        .route(
            "/issues/:owner/:repo/:number",
            patch(handlers::issues::update_issue),
        )
        .route(
            "/issues/:owner/:repo/:number/comments",
            get(handlers::issues::list_comments),
        )
        .route(
            "/issues/:owner/:repo/:number/comments",
            post(handlers::issues::add_comment),
        )
        // The GitHub mirror: everything read locally, refreshed by the daemon.
        .route("/github/overview", get(handlers::github::overview))
        .route("/github/refresh", post(handlers::github::refresh))
        .route(
            "/github/notifications",
            get(handlers::github::notifications),
        )
        .route("/github/rate-limit", get(handlers::github::rate_limit))
        .route("/github/:owner/:repo", get(handlers::github::repository))
        .route(
            "/github/:owner/:repo/pulls",
            get(handlers::github::list_pulls),
        )
        .route(
            "/github/:owner/:repo/releases",
            get(handlers::github::list_releases),
        )
        .route(
            "/github/:owner/:repo/tags",
            get(handlers::github::list_tags),
        )
        .route(
            "/github/:owner/:repo/milestones",
            get(handlers::github::list_milestones),
        )
        .route(
            "/github/:owner/:repo/labels",
            get(handlers::github::list_labels),
        )
        .route("/github/:owner/:repo/checks", get(handlers::github::checks))
        .route(
            "/github/:owner/:repo/traffic",
            get(handlers::github::traffic),
        )
        // Projects v2. Under `/github` because `/projects` is already this
        // workspace's own projects — two different things with one word, and
        // the one that belongs to GitHub says so.
        .route("/github/projects", get(handlers::projects::list_projects))
        .route("/github/projects/refresh", post(handlers::projects::refresh_projects))
        .route(
            "/github/projects/:project/columns",
            post(handlers::projects::add_column),
        )
        .route(
            "/github/projects/:project/columns/:option",
            axum::routing::delete(handlers::projects::remove_column),
        )
        .route(
            "/github/projects/:project/items",
            get(handlers::projects::project_items).post(handlers::projects::add_item),
        )
        .route(
            "/github/projects/:project/items/:item/field",
            post(handlers::projects::set_field),
        )
        .route(
            "/github/projects/:project/items/:item/position",
            post(handlers::projects::set_position),
        )
        .route(
            "/github/projects/:project/items/:item/archived",
            post(handlers::projects::set_archived),
        )
        .route(
            "/github/projects/:project/items/:item/draft",
            post(handlers::projects::edit_draft),
        )
        .route(
            "/github/projects/:project/items/:item",
            axum::routing::delete(handlers::projects::remove_item),
        )
        // Gists
        .route("/gists", get(handlers::gists::list_gists))
        .route("/gists", post(handlers::gists::create_gist))
        .route("/gists/refresh", post(handlers::gists::refresh_gists))
        .route("/gists/:id", get(handlers::gists::get_gist))
        .route("/gists/:id", patch(handlers::gists::update_gist))
        // Fleet — read endpoints
        .route("/fleet/status", get(handlers::fleet::fleet_status))
        .route("/fleet/repos", get(handlers::fleet::fleet_repos))
        .route(
            "/fleet/detailed-status",
            get(handlers::fleet::fleet_detailed_status),
        )
        .route("/fleet/branches", get(handlers::fleet::fleet_branches))
        .route("/fleet/diff", get(handlers::fleet::fleet_diff))
        .route("/fleet/watch", get(handlers::fleet::fleet_watch))
        // Fleet — single-repo mutations
        .route("/fleet/focus", post(handlers::fleet::fleet_focus))
        .route("/fleet/stage", post(handlers::fleet::fleet_stage))
        .route("/fleet/commit", post(handlers::fleet::fleet_commit))
        .route("/fleet/push", post(handlers::fleet::fleet_push))
        .route("/fleet/pull", post(handlers::fleet::fleet_pull))
        .route("/fleet/checkout", post(handlers::fleet::fleet_checkout))
        .route("/fleet/fetch", post(handlers::fleet::fleet_fetch))
        .route("/fleet/discard", post(handlers::fleet::fleet_discard))
        .route(
            "/fleet/branch/delete",
            post(handlers::fleet::fleet_delete_branch),
        )
        .route(
            "/fleet/branch/create",
            post(handlers::fleet::fleet_create_branch),
        )
        // Fleet — fleet-wide mutations
        .route("/fleet/stage-all", post(handlers::fleet::fleet_stage_all))
        .route("/fleet/commit-all", post(handlers::fleet::fleet_commit_all))
        .route("/fleet/push-all", post(handlers::fleet::fleet_push_all))
        .route("/fleet/pull-all", post(handlers::fleet::fleet_pull_all))
        .route(
            "/fleet/checkout-all",
            post(handlers::fleet::fleet_checkout_all),
        )
        .route("/fleet/merge-back", post(handlers::fleet::fleet_merge_back))
        // A run's bulk output lives in per-suite files, not in the op log.
        // This follows one of them while it is still being written, so a step
        // in progress can show what it is doing rather than only that it began.
        .route("/logs/tail", get(handlers::log_tail::tail_stream))
        // A refactor is reviewed before it is carried out: the plan is
        // computed and held, read a page at a time, then applied on the
        // reader's word — or refused if the tree moved in between.
        .route("/refactor/preview", post(handlers::refactor::preview))
        .route("/refactor/preview/:id", get(handlers::refactor::changes))
        .route("/refactor/preview/:id", delete(handlers::refactor::discard))
        .route("/refactor/preview/:id/apply", post(handlers::refactor::apply))
        .route("/refactor/preview/:id/diff", get(handlers::refactor::diff))
        // Background ops — snapshot, live stream, per-op output, cancellation
        .route("/ops", get(ops::ops_snapshot))
        .route("/ops/:id/output", get(ops::op_output_stream))
        // Which ops still have output to show, for a workbench restoring tabs.
        .route("/ops/live", get(ops::op_live_ids))
        // The children an operation is running, and stopping them. A pid from
        // a browser is checked against this operation's own log before
        // anything is signalled.
        .route("/ops/:id/processes", get(handlers::op_processes::list))
        .route("/ops/:id/processes/kill", post(handlers::op_processes::kill))
        // A command line reporting itself, so a run in a terminal appears in
        // the same panel as one started here.
        .route("/ops/adopt", post(ops::op_adopt))
        .route("/ops/:id/lines", post(ops::op_adopted_lines))
        .route("/ops/:id/finished", post(ops::op_adopted_finished));

    Router::new()
        .nest("/api/v1", api)
        .layer(CorsLayer::permissive())
        .with_state(state)
}

/// Serve embedded frontend assets with SPA fallback to index.html
async fn spa_handler(uri: Uri) -> impl IntoResponse {
    let path = uri.path().trim_start_matches('/');

    // An unknown API path is a mistake, and it says so. Falling through to the
    // page meant a request for a route this build does not have came back as
    // 200 text/html — which the client then tried to parse as JSON, so a stale
    // server reported itself as "JSON.parse: unexpected character at line 1".
    if path.starts_with("api/") {
        return Response::builder()
            .status(StatusCode::NOT_FOUND)
            .header(header::CONTENT_TYPE, "application/json")
            .body(boxed(Full::from(
                serde_json::json!({
                    "error": format!("no such endpoint: /{path}"),
                })
                .to_string(),
            )))
            .unwrap();
    }

    // Try exact file match first
    if let Some(content) = FrontendAssets::get(path) {
        let mime = mime_guess::from_path(path).first_or_octet_stream();
        let cache_control = if path == "index.html" {
            "no-store"
        } else {
            "public, max-age=31536000, immutable"
        };
        return Response::builder()
            .header(header::CONTENT_TYPE, mime.as_ref())
            .header(header::CACHE_CONTROL, cache_control)
            .body(boxed(Full::from(content.data)))
            .unwrap();
    }

    // SPA fallback: serve index.html for all non-file routes
    match FrontendAssets::get("index.html") {
        Some(content) => Response::builder()
            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
            .header(header::CACHE_CONTROL, "no-store")
            .body(boxed(Full::from(content.data)))
            .unwrap(),
        None => Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(boxed(Full::from("frontend not found")))
            .unwrap(),
    }
}