Skip to main content

speccoder_web/
lib.rs

1//! speccoder's web interface: a host on 127.0.0.1 that
2//! serves the interface — embedded in the binary — and an API over
3//! speccoder's own payloads. The page, a browser tab or the desktop window,
4//! holds no logic of its own: what it shows and offers comes from the
5//! runtime, as for the CLI.
6
7pub mod fixes;
8pub mod jobs;
9pub mod security;
10mod welcome;
11
12use std::net::SocketAddr;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use axum::extract::{Query, Request, State};
17use axum::http::{HeaderValue, StatusCode, header};
18use axum::middleware::Next;
19use axum::response::{IntoResponse, Response};
20use axum::routing::{get, post};
21use axum::{Json, Router};
22use serde::Deserialize;
23use serde_json::{Value, json};
24use speccoder_core::actions::Runnable;
25use speccoder_core::{CommandData, CommandResult};
26
27mod bundle {
28    include!(concat!(env!("OUT_DIR"), "/bundle.rs"));
29}
30
31/// The project a host serves: bound at most once (one host per
32/// project). A host started outside a project serves the welcome until
33/// the person creates, opens, clones or tries one.
34#[derive(Debug, Clone)]
35pub struct Root(Arc<RootCell>);
36
37#[derive(Debug)]
38struct RootCell {
39    start: PathBuf,
40    bound: std::sync::OnceLock<PathBuf>,
41}
42
43impl Root {
44    /// A host's root: `start`, bound now when it is a project.
45    #[must_use]
46    pub fn new(start: &Path, bound: bool) -> Self {
47        let cell = RootCell {
48            start: start.to_path_buf(),
49            bound: std::sync::OnceLock::new(),
50        };
51        if bound {
52            let _ = cell.bound.set(start.to_path_buf());
53        }
54        Self(Arc::new(cell))
55    }
56
57    /// The project (the start folder before a bind).
58    #[must_use]
59    pub fn get(&self) -> PathBuf {
60        self.0
61            .bound
62            .get()
63            .cloned()
64            .unwrap_or_else(|| self.0.start.clone())
65    }
66
67    /// The project, once bound.
68    #[must_use]
69    pub fn bound(&self) -> Option<PathBuf> {
70        self.0.bound.get().cloned()
71    }
72
73    /// Where the host was started.
74    #[must_use]
75    pub fn start(&self) -> PathBuf {
76        self.0.start.clone()
77    }
78
79    /// Binds the host to `project`, once.
80    ///
81    /// # Errors
82    ///
83    /// The host serves a project already.
84    pub fn bind(&self, project: &Path) -> Result<(), String> {
85        self.0.bound.set(project.to_path_buf()).map_err(|_| {
86            format!(
87                "this host serves {} already: open another project with `speccoder ui -C {}`",
88                self.get().display(),
89                project.display()
90            )
91        })
92    }
93}
94
95/// The host's state.
96#[derive(Debug, Clone)]
97pub struct Shared {
98    /// The project, bound at most once.
99    pub root: Root,
100    /// The per-start token.
101    pub token: String,
102    /// The port it listens on.
103    pub port: u16,
104    /// Jobs and events.
105    pub hub: Arc<jobs::Hub>,
106    /// It serves the desktop window (in this process), not a browser tab:
107    /// it may open a terminal for the person.
108    pub window: bool,
109    /// Turns true when the host stops: open event streams end with it.
110    pub closing: tokio::sync::watch::Receiver<bool>,
111    /// A GitHub sign-in waiting for the person to enter its code:
112    /// the host, the app and the pending device code — never sent to the page.
113    pub device: Arc<
114        std::sync::Mutex<
115            Option<(
116                String,
117                String,
118                speccoder_runtime::service::github::auth::Pending,
119            )>,
120        >,
121    >,
122}
123
124/// How long open requests get to finish after ctrl-c.
125const GRACE: std::time::Duration = std::time::Duration::from_secs(2);
126
127/// Runs `f` on a thread of its own: the runtime's calls block and start
128/// runtimes of their own, which the host's workers must not.
129async fn off<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
130    let (tx, rx) = tokio::sync::oneshot::channel();
131    std::thread::spawn(move || {
132        let _ = tx.send(f());
133    });
134    match rx.await {
135        Ok(v) => v,
136        // The work panicked: that request fails (its connection drops), the
137        // host and a running job go on. Never abort the process for one.
138        Err(_) => std::panic::resume_unwind(Box::new("a request's work stopped unexpectedly")),
139    }
140}
141
142/// Refuses what does not come from this host's page or a token holder.
143async fn guard(State(s): State<Shared>, req: Request, next: Next) -> Response {
144    let headers = req.headers();
145    if !security::host_ok(s.port, headers) {
146        return (
147            StatusCode::MISDIRECTED_REQUEST,
148            "this host answers on the loopback address only",
149        )
150            .into_response();
151    }
152    if req.uri().path().starts_with("/api/") {
153        if !security::authorised(&s.token, headers) {
154            return (
155                StatusCode::UNAUTHORIZED,
156                Json(json!({ "error": "unauthorised" })),
157            )
158                .into_response();
159        }
160        if !security::origin_ok(s.port, req.method(), headers) {
161            return (
162                StatusCode::FORBIDDEN,
163                Json(json!({ "error": "cross-origin request refused" })),
164            )
165                .into_response();
166        }
167    }
168    let mut res = next.run(req).await;
169    let h = res.headers_mut();
170    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
171    h.insert(
172        header::REFERRER_POLICY,
173        HeaderValue::from_static("no-referrer"),
174    );
175    h.insert(
176        header::X_CONTENT_TYPE_OPTIONS,
177        HeaderValue::from_static("nosniff"),
178    );
179    h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
180    res
181}
182
183fn content_type(path: &str) -> &'static str {
184    match path.rsplit('.').next() {
185        Some("html") => "text/html; charset=utf-8",
186        Some("js") => "text/javascript; charset=utf-8",
187        Some("css") => "text/css; charset=utf-8",
188        Some("svg") => "image/svg+xml",
189        Some("json") => "application/json",
190        Some("woff2") => "font/woff2",
191        _ => "application/octet-stream",
192    }
193}
194
195/// The interface: the embedded bundle (`SPECCODER_UI` names a `dist/` to
196/// serve instead, for working on the interface).
197async fn asset(uri: axum::http::Uri) -> Response {
198    let want = match uri.path().trim_start_matches('/') {
199        "" => "index.html",
200        p => p,
201    };
202    let csp = [(
203        header::CONTENT_SECURITY_POLICY,
204        security::CONTENT_SECURITY_POLICY,
205    )];
206    if let Some(dir) = std::env::var_os("SPECCODER_UI")
207        && !want.contains("..")
208        && let Ok(bytes) = std::fs::read(Path::new(&dir).join(want))
209    {
210        return (csp, [(header::CONTENT_TYPE, content_type(want))], bytes).into_response();
211    }
212    if bundle::BUNDLE.is_empty() {
213        return (
214            StatusCode::SERVICE_UNAVAILABLE,
215            [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
216            "<!doctype html><meta charset=utf-8><title>speccoder</title><p style=\"font:16px system-ui;max-width:60ch;margin:4rem auto\">This binary was built without its interface (<code>npm run build</code> in <code>crates/speccoder-web/ui</code>). The API and the command line work.",
217        )
218            .into_response();
219    }
220    // Vite emits flat names: a path matches an embedded file or nothing.
221    let name = want.trim_start_matches("assets/");
222    match bundle::BUNDLE
223        .iter()
224        .find(|(p, _)| *p == want || *p == name)
225    {
226        Some((p, bytes)) => {
227            (csp, [(header::CONTENT_TYPE, content_type(p))], *bytes).into_response()
228        }
229        // The interface routes in the page: an unknown path is the page.
230        None => match bundle::BUNDLE.iter().find(|(p, _)| *p == "index.html") {
231            Some((_, bytes)) => (
232                csp,
233                [(header::CONTENT_TYPE, content_type("index.html"))],
234                *bytes,
235            )
236                .into_response(),
237            None => (StatusCode::NOT_FOUND, "no such file").into_response(),
238        },
239    }
240}
241
242#[derive(Debug, Deserialize)]
243struct FeatureQuery {
244    feature: Option<String>,
245    task: Option<String>,
246    commit: Option<String>,
247    /// An attempt ref (`refs/speccoder/attempts/…`): a failed or held
248    /// attempt's code, kept for a person to read.
249    #[serde(rename = "ref")]
250    reference: Option<String>,
251}
252
253/// A commit id, or a ref in speccoder's attempt namespace — nothing else
254/// reaches `git show` (no options, no other refs, no `..`).
255fn revision(q: &FeatureQuery) -> Option<String> {
256    if let Some(c) = q.commit.as_deref() {
257        return (c.len() >= 7 && c.chars().all(|ch| ch.is_ascii_hexdigit())).then(|| c.to_owned());
258    }
259    let r = q.reference.as_deref()?;
260    let rest = r.strip_prefix("refs/speccoder/attempts/")?;
261    let ok = !rest.is_empty()
262        && !r.contains("..")
263        && rest
264            .chars()
265            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '-' | '_' | '.'));
266    ok.then(|| r.to_owned())
267}
268
269/// A project-relative path the file view may show: nothing absolute,
270/// nothing up. Where it leads on disk is checked where it is read — a link
271/// is never followed, the file must be inside the worktree.
272fn file_path_ok(path: &str) -> bool {
273    let p = Path::new(path);
274    !path.is_empty()
275        && !p.is_absolute()
276        && p.components()
277            .all(|c| matches!(c, std::path::Component::Normal(_)))
278}
279
280/// A result as the page gets it: the person's own commands in it signed
281/// for this host's project ([`fixes::Issuer`]).
282fn envelope(s: &Shared, r: &CommandResult) -> Json<Value> {
283    Json(s.hub.issuer.signing(s.root.get(), || {
284        serde_json::to_value(r).unwrap_or(Value::Null)
285    }))
286}
287
288async fn project(State(s): State<Shared>) -> Json<Value> {
289    let root = s.root.get();
290    let window = s.window;
291    Json(
292        off(move || {
293            let words = speccoder_runtime::service::format_words(&root);
294            let features = speccoder_runtime::service::current::list(&root, None);
295            let name = root
296                .file_name()
297                .map(|n| n.to_string_lossy().into_owned())
298                .unwrap_or_default();
299            json!({
300                "name": name,
301                "root": root.display().to_string(),
302                "format": words.name,
303                "unit": words.unit,
304                "features": features.data().and_then(|d| match d {
305                    CommandData::Features(f) => serde_json::to_value(f).ok(),
306                    _ => None,
307                }),
308                "version": env!("CARGO_PKG_VERSION"),
309                "os": format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
310                "window": window,
311                "sandbox": speccoder_runtime::service::person::sandboxed(),
312                "planning": speccoder_runtime::service::planning_words(&root),
313                "sample": welcome::sample_of(&root),
314            })
315        })
316        .await,
317    )
318}
319
320async fn report(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
321    let root = s.root.get();
322    let r = off(move || {
323        speccoder_runtime::service::next::attach(
324            speccoder_runtime::service::status::report(&root, q.feature.as_deref()),
325            q.feature.as_deref(),
326        )
327    })
328    .await;
329    envelope(&s, &r)
330}
331
332async fn review(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
333    let root = s.root.get();
334    let r =
335        off(move || speccoder_runtime::service::review::report(&root, q.feature.as_deref())).await;
336    envelope(&s, &r)
337}
338
339/// The comment tray and the last revision turn.
340async fn review_comments(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
341    let root = s.root.get();
342    let r = off(move || {
343        speccoder_runtime::service::review::comments(&root, q.feature.as_deref(), None)
344    })
345    .await;
346    envelope(&s, &r)
347}
348
349#[derive(Debug, Deserialize)]
350struct RunQuery {
351    run: String,
352}
353
354/// The model's account of a run, turn by turn: its reasoning and its words
355/// (a claim).
356async fn account(State(s): State<Shared>, Query(q): Query<RunQuery>) -> Json<Value> {
357    let root = s.root.get();
358    Json(
359        off(
360            move || match speccoder_runtime::service::evidence::account_of(&root, &q.run) {
361                Ok(turns) => json!({ "turns": turns }),
362                Err(e) => json!({ "error": e }),
363            },
364        )
365        .await,
366    )
367}
368
369async fn inbox(State(s): State<Shared>) -> Json<Value> {
370    let root = s.root.get();
371    let issuer = Arc::clone(&s.hub.issuer);
372    // Its actions' steps are built with them: signed as built.
373    Json(
374        off(move || {
375            issuer.signing(root.clone(), || {
376                serde_json::to_value(speccoder_runtime::service::inbox::inbox(&root, None))
377                    .unwrap_or(Value::Null)
378            })
379        })
380        .await,
381    )
382}
383
384/// A bug by `<unit>/<task>` (`task`): its triad, reproductions and stage.
385async fn bug(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
386    let root = s.root.get();
387    let Some(task) = q.task else {
388        return Json(json!({ "error": "a bug is needed: task=<unit>/<task>" }));
389    };
390    let r = off(move || speccoder_runtime::service::bug::show(&root, &task)).await;
391    envelope(&s, &r)
392}
393
394async fn actions(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
395    let root = s.root.get();
396    let issuer = Arc::clone(&s.hub.issuer);
397    Json(
398        off(move || {
399            // An action's step is built with the action: signed as built.
400            issuer.signing(root.clone(), || {
401                json!(speccoder_runtime::service::actions::available(
402                    &root,
403                    q.feature.as_deref(),
404                    q.task.as_deref()
405                ))
406            })
407        })
408        .await,
409    )
410}
411
412async fn approvals(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
413    let root = s.root.get();
414    let r =
415        off(move || speccoder_runtime::service::approvals::list(&root, q.feature.as_deref())).await;
416    envelope(&s, &r)
417}
418
419async fn evidence(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
420    let root = s.root.get();
421    Json(
422        off(move || {
423            let report = speccoder_runtime::service::status::report(&root, q.feature.as_deref());
424            let Some(CommandData::Report(r)) = report.data() else {
425                return json!({ "error": report.message() });
426            };
427            let Some(t) = r.tasks.iter().find(|t| Some(&t.id) == q.task.as_ref()) else {
428                return json!({ "error": "no such task" });
429            };
430            let statements = t
431                .commit
432                .as_deref()
433                .map(|c| speccoder_runtime::service::evidence::statements(&root, c))
434                .unwrap_or_default();
435            let (mark, words) = speccoder_runtime::service::evidence::banner(&t.state);
436            json!({
437                "task": t,
438                "banner": { "mark": mark, "words": words },
439                "rows": speccoder_runtime::service::evidence::rows(t, &statements, None),
440            })
441        })
442        .await,
443    )
444}
445
446/// The task's evidence tour (payload `evidence_tour`, the same as
447/// `speccoder attest tour --json`), read as recorded: the app shows what
448/// the signed statements and the commit say; a maintainer's verifier
449/// checks the signatures.
450async fn tour(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
451    let root = s.root.get();
452    Json(
453        off(move || {
454            let Some(task) = q.task else {
455                return json!({ "error": "a task is needed" });
456            };
457            let result =
458                speccoder_runtime::service::tour::of_task(&root, q.feature.as_deref(), &task);
459            serde_json::to_value(&result).unwrap_or_else(|e| json!({ "error": e.to_string() }))
460        })
461        .await,
462    )
463}
464
465async fn diff(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Response {
466    let root = s.root.get();
467    let Some(commit) = revision(&q) else {
468        return (
469            StatusCode::BAD_REQUEST,
470            Json(json!({ "error": "a commit id or an attempt ref is needed" })),
471        )
472            .into_response();
473    };
474    let patch = off(move || speccoder_runtime::service::commit_patch(&root, &commit)).await;
475    Json(json!({ "patch": patch })).into_response()
476}
477
478async fn documents(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
479    let root = s.root.get();
480    Json(
481        off(move || {
482            json!(speccoder_runtime::service::documents::of_feature(
483                &root,
484                q.feature.as_deref()
485            ))
486        })
487        .await,
488    )
489}
490
491/// `hooks`: every hook, its trust and its recent runs.
492async fn extensions(State(s): State<Shared>) -> Json<Value> {
493    let root = s.root.get();
494    let r = off(move || speccoder_runtime::service::extensions::list(&root)).await;
495    envelope(&s, &r)
496}
497
498/// The views extensions place on a feature (`?feature=`) or the project.
499async fn extension_views(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
500    let root = s.root.get();
501    let r = off(move || speccoder_runtime::service::extensions::views(&root, q.feature.as_deref()))
502        .await;
503    envelope(&s, &r)
504}
505
506async fn hooks(State(s): State<Shared>) -> Json<Value> {
507    let root = s.root.get();
508    let r = off(move || speccoder_runtime::service::hooks::list(&root)).await;
509    envelope(&s, &r)
510}
511
512async fn doctor(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
513    let root = s.root.get();
514    let r = off(move || speccoder_runtime::service::doctor::run(&root, q.feature.as_deref(), None))
515        .await;
516    envelope(&s, &r)
517}
518
519#[derive(Debug, Deserialize)]
520struct AgentsQuery {
521    /// Ask each installed agent for a session (never a prompt); `false`:
522    /// what is known without starting one.
523    #[serde(default = "yes")]
524    probe: bool,
525}
526
527fn yes() -> bool {
528    true
529}
530
531/// `doctor --agents`: every agent's card.
532async fn agents(State(s): State<Shared>, Query(q): Query<AgentsQuery>) -> Json<Value> {
533    let root = s.root.get();
534    let r = off(move || speccoder_runtime::service::doctor::agents(&root, None, q.probe)).await;
535    envelope(&s, &r)
536}
537
538async fn config(State(s): State<Shared>) -> Json<Value> {
539    let root = s.root.get();
540    let r = off(move || speccoder_runtime::service::settings::show(&root)).await;
541    envelope(&s, &r)
542}
543
544#[derive(Debug, Deserialize)]
545struct ConfigBody {
546    changes: Vec<speccoder_core::ConfigChange>,
547}
548
549/// Saves changes to `speccoder.toml`, all or nothing, after every parser
550/// accepts the result. Safe while a job runs: runs read the committed file,
551/// so a saved change is in force only once committed.
552async fn config_save(State(s): State<Shared>, Json(b): Json<ConfigBody>) -> Json<Value> {
553    let root = s.root.get();
554    let r = off(move || speccoder_runtime::service::settings::apply(&root, &b.changes)).await;
555    envelope(&s, &r)
556}
557
558/// One Ask turn: read-only, streamed as `ask_step` / `ask_text` /
559/// `ask_done`; it writes only the Ask plane's journal and thread.
560async fn ask(
561    State(s): State<Shared>,
562    Json(b): Json<speccoder_runtime::service::ask::Request>,
563) -> Json<Value> {
564    let root = s.root.get();
565    let sink = s.hub.progress();
566    let r = off(move || speccoder_runtime::service::ask::turn(&root, &b, None, Some(sink))).await;
567    envelope(&s, &r)
568}
569
570#[derive(Debug, Deserialize)]
571struct StopBody {
572    conversation: Option<String>,
573}
574
575/// Stops the turn in flight: it ends after the model call it is in,
576/// keeping its words; the conversation goes on with the next question.
577async fn ask_stop(Json(b): Json<StopBody>) -> Json<Value> {
578    Json(json!({ "stopped": speccoder_runtime::service::ask::stop(b.conversation.as_deref()) }))
579}
580
581#[derive(Debug, Deserialize)]
582struct AskQuery {
583    id: Option<String>,
584    feature: Option<String>,
585}
586
587/// The project's explorations, newest first.
588async fn explore_list(State(s): State<Shared>) -> Json<Value> {
589    let root = s.root.get();
590    let r = off(move || speccoder_runtime::service::explore::list(&root, None)).await;
591    envelope(&s, &r)
592}
593
594/// One exploration: its approaches, the choice and the brief.
595async fn explore_session(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
596    let root = s.root.get();
597    let r = off(move || {
598        speccoder_runtime::service::explore::session(
599            &root,
600            None,
601            q.id.as_deref().unwrap_or_default(),
602        )
603    })
604    .await;
605    envelope(&s, &r)
606}
607
608async fn ask_conversations(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
609    let root = s.root.get();
610    let r = off(move || {
611        speccoder_runtime::service::ask::conversations(&root, None, q.feature.as_deref())
612    })
613    .await;
614    envelope(&s, &r)
615}
616
617async fn ask_conversation(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
618    let root = s.root.get();
619    let r = off(move || {
620        speccoder_runtime::service::ask::thread(&root, None, q.id.as_deref().unwrap_or_default())
621    })
622    .await;
623    envelope(&s, &r)
624}
625
626#[derive(Debug, Deserialize)]
627struct ChangedQuery {
628    path: String,
629    start: Option<u32>,
630    end: Option<u32>,
631    sha: String,
632}
633
634/// Whether the lines a citation names still say what Ask read
635/// (`changed`: null when the file cannot be read now).
636async fn ask_changed(State(s): State<Shared>, Query(q): Query<ChangedQuery>) -> Json<Value> {
637    let root = s.root.get();
638    Json(json!({
639        "changed": off(move || {
640            speccoder_runtime::service::ask::changed_since(&root, &q.path, q.start, q.end, &q.sha)
641        })
642        .await
643    }))
644}
645
646/// A chip resolved now: its label, digest and words; records nothing.
647async fn ask_chip(
648    State(s): State<Shared>,
649    Json(b): Json<speccoder_runtime::service::ask::ChipRef>,
650) -> Json<Value> {
651    let root = s.root.get();
652    Json(
653        off(
654            move || match speccoder_runtime::service::ask::chip(&root, "c0", &b) {
655                Ok((chip, text)) => {
656                    json!({ "chip": chip, "preview": text.chars().take(400).collect::<String>() })
657                }
658                Err(e) => json!({ "error": e }),
659            },
660        )
661        .await,
662    )
663}
664
665#[derive(Debug, Deserialize)]
666struct ProposalBody {
667    proposal: speccoder_core::ask::Proposal,
668    feature: Option<String>,
669}
670
671/// A proposal as the person edited it, checked again now.
672async fn ask_proposal(State(s): State<Shared>, Json(b): Json<ProposalBody>) -> Json<Value> {
673    let root = s.root.get();
674    Json(
675        off(move || json!({ "proposal": speccoder_runtime::service::ask::proposal(&root, b.feature.as_deref(), &b.proposal) }))
676            .await,
677    )
678}
679
680#[derive(Debug, Deserialize)]
681struct ShapeBody {
682    field: String,
683    text: String,
684}
685
686/// Shape the person's words for a field: one model call, judged by code;
687/// nothing is written.
688async fn shape(State(s): State<Shared>, Json(b): Json<ShapeBody>) -> Json<Value> {
689    let root = s.root.get();
690    let r =
691        off(move || speccoder_runtime::service::shape::shape(&root, &b.field, &b.text, None)).await;
692    envelope(&s, &r)
693}
694
695/// The writing check of what the person is typing (no model).
696async fn shape_check(Json(b): Json<ShapeBody>) -> Json<Value> {
697    Json(off(move || json!({ "findings": speccoder_runtime::service::shape::findings(&b.field, &b.text) })).await)
698}
699
700#[derive(Debug, Deserialize)]
701struct JudgeBody {
702    field: String,
703    original: String,
704    proposal: String,
705    #[serde(default)]
706    assumptions: Vec<speccoder_core::shape::Assumption>,
707}
708
709/// The proposal again, as the person changed it in the sheet.
710async fn shape_judge(Json(b): Json<JudgeBody>) -> Json<Value> {
711    Json(
712        off(move || {
713            speccoder_runtime::service::shape::judge(
714                &b.field,
715                &b.original,
716                &b.proposal,
717                &b.assumptions,
718            )
719        })
720        .await,
721    )
722}
723
724#[derive(Debug, Deserialize)]
725struct CoverageQuery {
726    /// `commands.allow_prefix` or `sandbox.network_allow_prefix`.
727    key: String,
728    /// The prefix as a JSON array of strings.
729    prefix: String,
730}
731
732/// What a prefix entry would newly allow, from the project's journal: the
733/// preview a prefix is saved from (`WidenPolicy` through `/api/run`).
734async fn policy_coverage(State(s): State<Shared>, Query(q): Query<CoverageQuery>) -> Json<Value> {
735    let Some(list) = speccoder_core::actions::PolicyList::of_key(&q.key) else {
736        return Json(
737            json!({ "status": "usage_error", "message": format!("`{}` is not a prefix list", q.key) }),
738        );
739    };
740    let Ok(prefix) = serde_json::from_str::<Vec<String>>(&q.prefix) else {
741        return Json(
742            json!({ "status": "usage_error", "message": "the prefix is a JSON list of strings" }),
743        );
744    };
745    let root = s.root.get();
746    let r = off(move || {
747        let state = speccoder_runtime::service::worktree::state_dir();
748        speccoder_runtime::service::settings::coverage(&root, state.as_deref(), list, &prefix)
749    })
750    .await;
751    envelope(&s, &r)
752}
753
754/// Commits `speccoder.toml` alone — not while a job runs (an in-place run
755/// commits in the same checkout).
756async fn config_commit(State(s): State<Shared>) -> Json<Value> {
757    let (root, hub) = (s.root.get(), Arc::clone(&s.hub));
758    let r =
759        off(move || hub.exclusive(|| speccoder_runtime::service::settings::commit(&root))).await;
760    match r {
761        Ok(result) => envelope(&s, &result),
762        Err(e) => Json(json!({ "status": "error", "message": e })),
763    }
764}
765
766async fn cost(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
767    let root = s.root.get();
768    let r =
769        off(move || speccoder_runtime::service::controls::cost(&root, q.feature.as_deref())).await;
770    envelope(&s, &r)
771}
772
773/// What the feature's branch changed: commits, files, the patch.
774async fn changes(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
775    let root = s.root.get();
776    Json(json!(
777        off(move || speccoder_runtime::service::code::changes(&root, q.feature.as_deref())).await
778    ))
779}
780
781/// The files at the feature's revision.
782async fn tree(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
783    let root = s.root.get();
784    let ((rev, files), worktree) = off(move || {
785        (
786            speccoder_runtime::service::code::tree(&root, q.feature.as_deref()),
787            speccoder_runtime::service::code::worktree_view(&root, q.feature.as_deref()),
788        )
789    })
790    .await;
791    Json(json!({ "rev": rev, "files": files, "worktree": worktree }))
792}
793
794#[derive(Debug, Deserialize)]
795struct FileQuery {
796    feature: Option<String>,
797    path: String,
798}
799
800/// One file at the feature's revision, when the tree lists it.
801async fn file(State(s): State<Shared>, Query(q): Query<FileQuery>) -> Json<Value> {
802    let root = s.root.get();
803    // Project paths only: nothing absolute, nothing up, nothing of the
804    // state directory (where drafts, frozen and held-out sets live).
805    if !file_path_ok(&q.path) {
806        return Json(json!({ "error": format!("{} is not a project file", q.path) }));
807    }
808    let markdown = q.path.to_ascii_lowercase().ends_with(".md");
809    // Committed at the revision, else untracked in the worktree.
810    let r = off(move || {
811        speccoder_runtime::service::code::file(&root, q.feature.as_deref(), &q.path).or_else(|e| {
812            speccoder_runtime::service::code::worktree_file(&root, q.feature.as_deref(), &q.path)
813                .map_err(|_| e)
814        })
815    })
816    .await;
817    Json(match r {
818        // Markdown also rendered (raw HTML shown as text), for the preview.
819        Ok(text) if markdown => {
820            json!({ "text": text, "html": speccoder_runtime::service::documents::render(&text) })
821        }
822        Ok(text) => json!({ "text": text }),
823        Err(e) => json!({ "error": e }),
824    })
825}
826
827/// The setup assistant's view: what `doctor` sees, and what `init` would
828/// write.
829async fn setup(State(s): State<Shared>) -> Json<Value> {
830    let root = s.root.get();
831    let (doctor, preview) = off(move || {
832        (
833            speccoder_runtime::service::doctor::run(&root, None, None),
834            speccoder_runtime::service::init::preview(&root),
835        )
836    })
837    .await;
838    Json(json!({ "doctor": doctor, "preview": preview }))
839}
840
841#[derive(Debug, Deserialize)]
842struct SetupBody {
843    provider: Option<String>,
844    model: Option<String>,
845    /// Bedrock's region.
846    region: Option<String>,
847}
848
849/// Writes `speccoder.toml` and runs its checks on the untouched code; the
850/// commit is the person's next click.
851async fn setup_init(State(s): State<Shared>, Json(b): Json<SetupBody>) -> Json<Value> {
852    if s.hub.job().is_some_and(|j| j.running) {
853        return Json(json!({ "status": "error", "message": "a job is running" }));
854    }
855    let root = s.root.get();
856    let r = off(move || {
857        speccoder_runtime::service::init::prepare(
858            &root,
859            b.provider.as_deref(),
860            b.model.as_deref(),
861            b.region.as_deref(),
862            false,
863            false,
864        )
865    })
866    .await;
867    match r {
868        Err(refused) => envelope(&s, &refused),
869        Ok(p) => {
870            let (to_commit, failed) = (p.to_commit.clone(), p.failed.clone());
871            let Json(mut v) = envelope(&s, &speccoder_runtime::service::init::finish(p));
872            v["to_commit"] = json!(to_commit);
873            v["failed"] = json!(failed);
874            Json(v)
875        }
876    }
877}
878
879#[derive(Debug, Deserialize)]
880struct SetupCommit {
881    paths: Vec<String>,
882}
883
884/// Commits what `init` wrote — only files `init` commits.
885async fn setup_commit(State(s): State<Shared>, Json(b): Json<SetupCommit>) -> Json<Value> {
886    let (root, hub) = (s.root.get(), Arc::clone(&s.hub));
887    let r = off(move || {
888        hub.exclusive(|| {
889            speccoder_runtime::service::init::commit_written(
890                &speccoder_runtime::service::init::root_of(&root),
891                &b.paths,
892            )
893        })
894    })
895    .await;
896    Json(match r.and_then(|r| r) {
897        Ok(id) => json!({ "status": "ok", "message": format!("committed as {id}") }),
898        Err(e) => json!({ "status": "error", "message": e }),
899    })
900}
901
902/// GitHub as the person's token sees it: whether it is ready, the open
903/// issues (each with the feature that names it), and the feature's pull
904/// request.
905async fn github(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
906    let root = s.root.get();
907    Json(
908        off(move || {
909            use speccoder_runtime::service::github;
910            let ctx = github::Ctx::from_env();
911            let status = github::status(&ctx, &root);
912            if !status.ready {
913                return json!({ "status": status });
914            }
915            let issues = github::issues(&ctx, &root, &github::started(&root));
916            let branch = match speccoder_runtime::service::status::report(
917                &root,
918                q.feature.as_deref(),
919            )
920            .data()
921            {
922                Some(speccoder_core::CommandData::Report(r)) => r.branch.clone(),
923                _ => None,
924            };
925            let pull = branch.as_deref().map(|b| github::pr_for(&ctx, &root, b));
926            json!({
927                "status": status,
928                "issues": issues.as_ref().ok(),
929                "issues_error": issues.as_ref().err(),
930                "branch": branch,
931                "pull": pull.as_ref().and_then(|p| p.as_ref().ok()).cloned().flatten(),
932                "pull_error": pull.as_ref().and_then(|p| p.as_ref().err()),
933            })
934        })
935        .await,
936    )
937}
938
939#[derive(Debug, Deserialize)]
940struct IssueQuery {
941    number: String,
942}
943
944/// Starts GitHub's device sign-in for the repository's host: the code for
945/// the person to enter, and where.
946async fn github_device(State(s): State<Shared>) -> Json<Value> {
947    let root = s.root.get();
948    let slot = Arc::clone(&s.device);
949    Json(
950        off(move || {
951            use speccoder_runtime::service::github;
952            let host = github::repo(&root, None).map_or_else(|_| "github.com".to_owned(), |r| r.host);
953            let Some(client) = github::auth::client_id(&host) else {
954                return json!({ "error": format!("{host} has no speccoder app: sign in with a token instead") });
955            };
956            match github::auth::start(&github::http::oauth(&host), &client) {
957                Ok(p) => {
958                    let shown = json!({ "user_code": p.user_code, "verification_uri": p.verification_uri, "host": host });
959                    if let Ok(mut d) = slot.lock() {
960                        *d = Some((host, client, p));
961                    }
962                    shown
963                }
964                Err(e) => json!({ "error": e }),
965            }
966        })
967        .await,
968    )
969}
970
971/// Waits for the person to enter the code, then keeps the token in the
972/// keychain; says whether the app can reach this repository.
973async fn github_device_wait(State(s): State<Shared>) -> Json<Value> {
974    let root = s.root.get();
975    let slot = Arc::clone(&s.device);
976    let r = off(move || {
977        use speccoder_runtime::service::github;
978        let Some((host, client, pending)) = slot.lock().ok().and_then(|mut d| d.take()) else {
979            return speccoder_core::CommandResult::new(
980                "auth",
981                speccoder_core::CommandStatus::UsageError,
982                "no sign-in is waiting: start it again",
983            );
984        };
985        let ctx = github::Ctx::from_env();
986        match github::auth::wait(&github::http::oauth(&host), &client, &pending) {
987            Ok(token) => {
988                let signed = github::login_with_token(&ctx, &host, token.expose());
989                match (signed.status(), github::reach(&ctx, &root)) {
990                    (speccoder_core::CommandStatus::Ok, Some(todo)) => {
991                        let message = format!("{}; {todo}", signed.message());
992                        signed.reworded(speccoder_core::CommandStatus::Ok, &message)
993                    }
994                    _ => signed,
995                }
996            }
997            Err(e) => github::refused("auth", &e),
998        }
999    })
1000    .await;
1001    envelope(&s, &r)
1002}
1003
1004#[derive(Debug, Deserialize)]
1005struct TokenBody {
1006    token: String,
1007}
1008
1009/// Signs in to the repository's GitHub host with a token the person pasted
1010/// (kept in the credential store, never echoed or logged).
1011async fn github_login(State(s): State<Shared>, Json(b): Json<TokenBody>) -> Json<Value> {
1012    let root = s.root.get();
1013    let r = off(move || {
1014        let host = speccoder_runtime::service::github::repo(&root, None)
1015            .map_or_else(|_| "github.com".to_owned(), |r| r.host);
1016        speccoder_runtime::service::github::login_with_token(
1017            &speccoder_runtime::service::github::Ctx::from_env(),
1018            &host,
1019            &b.token,
1020        )
1021    })
1022    .await;
1023    envelope(&s, &r)
1024}
1025
1026/// One issue, the description a feature started from it gets, and the
1027/// skill that starts it.
1028async fn github_issue(State(s): State<Shared>, Query(q): Query<IssueQuery>) -> Json<Value> {
1029    let root = s.root.get();
1030    Json(
1031        off(move || {
1032            match speccoder_runtime::service::github::issue(
1033                &speccoder_runtime::service::github::Ctx::from_env(),
1034                &root,
1035                &q.number,
1036            ) {
1037                Ok(issue) => {
1038                    json!({
1039                        "description": speccoder_runtime::service::github::description(&issue),
1040                        "skill": speccoder_runtime::service::create_skill(&root),
1041                        "issue": issue,
1042                    })
1043                }
1044                Err(e) => json!({ "error": e }),
1045            }
1046        })
1047        .await,
1048    )
1049}
1050
1051/// What `pr` would do — pushed where, the title, the body — without doing it.
1052async fn pr_preview(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
1053    let root = s.root.get();
1054    let r = off(move || {
1055        speccoder_runtime::service::handoff::pr(
1056            &root,
1057            q.feature.as_deref(),
1058            &speccoder_runtime::service::handoff::PrOptions {
1059                remote: "origin",
1060                base: None,
1061                open: false,
1062                force: false,
1063                github: &speccoder_runtime::service::github::Ctx::from_env(),
1064            },
1065        )
1066    })
1067    .await;
1068    envelope(&s, &r)
1069}
1070
1071/// The project's current (or last) run, whoever started it: this host's
1072/// job, or `speccoder run` in a terminal. `running`: its process lives and
1073/// it has not finished; `own`: it is this host's.
1074async fn live(State(s): State<Shared>) -> Json<Value> {
1075    let root = s.root.get();
1076    Json(
1077        off(move || {
1078            let Some(state) = speccoder_runtime::service::worktree::state_dir() else {
1079                return json!({ "live": null });
1080            };
1081            match speccoder_runtime::service::progress::read_live(&root, &state) {
1082                Some((live, running)) => json!({
1083                    "live": live,
1084                    "running": running,
1085                    "own": live.pid == std::process::id(),
1086                    "interrupted": live.finished.is_none() && !running,
1087                    "now": std::time::SystemTime::now()
1088                        .duration_since(std::time::UNIX_EPOCH)
1089                        .map_or(0, |d| d.as_secs()),
1090                }),
1091                None => json!({ "live": null }),
1092            }
1093        })
1094        .await,
1095    )
1096}
1097
1098async fn job(State(s): State<Shared>) -> Json<Value> {
1099    Json(json!({ "job": s.hub.job(), "waiting": s.hub.waiting() }))
1100}
1101
1102#[derive(Debug, Deserialize)]
1103struct Start {
1104    run: Runnable,
1105    input: Option<String>,
1106    feature: Option<String>,
1107    command: String,
1108    /// Before the host is bound: the folder an Undo is for (a recent project).
1109    #[serde(default)]
1110    root: Option<String>,
1111}
1112
1113async fn start(State(s): State<Shared>, Json(b): Json<Start>) -> Response {
1114    let root = match welcome::job_root(&s.root, &b.run, b.root.as_deref()) {
1115        Ok(r) => r,
1116        Err(e) => return welcome::no_project_yet(&e),
1117    };
1118    match s.hub.start(b.run, b.input, b.feature, b.command, root) {
1119        Ok(id) => Json(json!({ "id": id })).into_response(),
1120        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1121    }
1122}
1123
1124#[derive(Debug, Deserialize)]
1125struct AnswerBody {
1126    id: u64,
1127    answer: Option<speccoder_runtime::service::approvals::Answer>,
1128    text: Option<String>,
1129}
1130
1131async fn answer(State(s): State<Shared>, Json(b): Json<AnswerBody>) -> Response {
1132    let reply = match b.answer {
1133        Some(a) => jobs::Reply::Answer(a),
1134        None => jobs::Reply::Text(b.text.filter(|t| !t.trim().is_empty())),
1135    };
1136    match s.hub.answer(b.id, reply) {
1137        Ok(()) => Json(json!({ "ok": true })).into_response(),
1138        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1139    }
1140}
1141
1142async fn stop(State(s): State<Shared>) -> Json<Value> {
1143    s.hub.stop();
1144    Json(json!({ "ok": true }))
1145}
1146
1147/// Server-sent events: what the job does, what it asks. Read with `fetch`
1148/// (the token is a header, never in the address).
1149async fn events(State(s): State<Shared>) -> Response {
1150    use futures_util::StreamExt;
1151    let rx = s.hub.subscribe();
1152    let hello = futures_util::stream::once(async {
1153        Ok::<_, std::convert::Infallible>(
1154            axum::response::sse::Event::default().data(json!({ "type": "hello" }).to_string()),
1155        )
1156    });
1157    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|m| async move {
1158        m.ok().map(|text| {
1159            Ok::<_, std::convert::Infallible>(axum::response::sse::Event::default().data(text))
1160        })
1161    });
1162    // An event stream never ends on its own: it ends when the host stops,
1163    // or a graceful shutdown would wait for it forever.
1164    let mut closing = s.closing.clone();
1165    let stream = stream.take_until(async move {
1166        let _ = closing.wait_for(|c| *c).await;
1167    });
1168    axum::response::sse::Sse::new(hello.chain(stream))
1169        .keep_alive(axum::response::sse::KeepAlive::default())
1170        .into_response()
1171}
1172
1173#[derive(Debug, Deserialize)]
1174struct FixBody {
1175    argvs: Vec<Vec<String>>,
1176    token: String,
1177}
1178
1179/// The person's own command — argvs this host issued, exactly — run as
1180/// them in the project folder, as a job (`output` events, then `done`).
1181/// Anything else is refused: argvs the page composed, edited or took from
1182/// elsewhere, and a command that reads the keyboard.
1183async fn fix_run(State(s): State<Shared>, Json(b): Json<FixBody>) -> Response {
1184    let root = s.root.get();
1185    // Signed as a step that does not read the keyboard, or not at all.
1186    if !s.hub.issuer.issued(&root, &b.argvs, false, &b.token) {
1187        return (
1188            StatusCode::FORBIDDEN,
1189            Json(json!({ "error": "speccoder did not issue this command here to run: only a fix it printed runs from the app, and one that asks questions runs in a terminal" })),
1190        )
1191            .into_response();
1192    }
1193    if b.argvs.iter().any(|a| speccoder_core::step::interactive(a)) {
1194        return (
1195            StatusCode::BAD_REQUEST,
1196            Json(json!({ "error": "it asks you questions as it runs: open it in a terminal" })),
1197        )
1198            .into_response();
1199    }
1200    // speccoder's own command is a job of speccoder's, never the
1201    // person's command runner.
1202    let started = match b.argvs.as_slice() {
1203        [one] if one.first().map(String::as_str) == Some("speccoder") => {
1204            let args = one[1..].to_vec();
1205            // A slot a constant line leaves free names a unit of this project.
1206            if let [spec, archive, change, ..] = args.as_slice()
1207                && spec == "spec"
1208                && archive == "archive"
1209                && !change.starts_with('-')
1210            {
1211                let (root2, change2) = (root.clone(), change.clone());
1212                let known = off(move || unit_exists(&root2, &change2)).await;
1213                if !known {
1214                    return (
1215                        StatusCode::FORBIDDEN,
1216                        Json(json!({ "error": format!("`{change}` is no unit of this project") })),
1217                    )
1218                        .into_response();
1219                }
1220            }
1221            s.hub.start_speccoder(args, root)
1222        }
1223        argvs
1224            if argvs
1225                .iter()
1226                .any(|a| a.first().map(String::as_str) == Some("speccoder")) =>
1227        {
1228            return (
1229                StatusCode::FORBIDDEN,
1230                Json(json!({ "error": "speccoder runs on its own, never in a chain" })),
1231            )
1232                .into_response();
1233        }
1234        _ => s.hub.start_fix(b.argvs, root),
1235    };
1236    match started {
1237        Ok(id) => Json(json!({ "id": id })).into_response(),
1238        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1239    }
1240}
1241
1242/// Whether `name` is a unit (a feature, a change) of the project at `root`.
1243fn unit_exists(root: &Path, name: &str) -> bool {
1244    match speccoder_runtime::service::current::list(root, None).data() {
1245        Some(CommandData::Features(f)) => f.features.iter().any(|e| e.name == name),
1246        _ => false,
1247    }
1248}
1249
1250/// A terminal of the person's own at the project folder, for a command
1251/// this host issued that reads the keyboard (the page puts it on the
1252/// clipboard). The desktop window only: a tab copies.
1253async fn terminal(State(s): State<Shared>, Json(b): Json<FixBody>) -> Response {
1254    let root = s.root.get();
1255    if !s.window {
1256        return (
1257            StatusCode::BAD_REQUEST,
1258            Json(json!({ "error": "a browser tab opens no terminal: copy the command" })),
1259        )
1260            .into_response();
1261    }
1262    if !s.hub.issuer.issued(&root, &b.argvs, true, &b.token) {
1263        return (
1264            StatusCode::FORBIDDEN,
1265            Json(json!({ "error": "speccoder did not issue this command here for a terminal" })),
1266        )
1267            .into_response();
1268    }
1269    match off(move || speccoder_runtime::service::person::open_terminal(&root)).await {
1270        Ok(()) => Json(json!({ "ok": true })).into_response(),
1271        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1272    }
1273}
1274
1275/// `doctor --bug-report`: the setup in Markdown, secrets taken out.
1276async fn bug_report(State(s): State<Shared>) -> Json<Value> {
1277    let root = s.root.get();
1278    Json(
1279        off(move || {
1280            let doctor = speccoder_runtime::service::doctor::run(&root, None, None);
1281            json!({ "text": speccoder_runtime::service::copy::bug_report(&doctor, &root) })
1282        })
1283        .await,
1284    )
1285}
1286
1287#[derive(Debug, Deserialize)]
1288struct CodeQuery {
1289    code: String,
1290}
1291
1292/// `explain CODE`: what a code means and how to resolve it.
1293async fn explain(Query(q): Query<CodeQuery>) -> Json<Value> {
1294    use speccoder_core::{CodeInfo, CommandData, ExplainReport};
1295    let r = match speccoder_core::codes::lookup(&q.code.to_ascii_uppercase()) {
1296        Some(e) => CommandResult::ok("explain", e.name).with_data(CommandData::Explain(Box::new(
1297            ExplainReport {
1298                codes: vec![CodeInfo::of(e)],
1299            },
1300        ))),
1301        None => CommandResult::new(
1302            "explain",
1303            speccoder_core::CommandStatus::UsageError,
1304            &format!("no code `{}`", q.code),
1305        )
1306        .with_code("USAGE-UNKNOWN-CODE"),
1307    };
1308    Json(serde_json::to_value(&r).unwrap_or(Value::Null))
1309}
1310
1311/// The routes.
1312pub fn router(s: Shared) -> Router {
1313    Router::new()
1314        .route("/api/project", get(project))
1315        .route("/api/report", get(report))
1316        .route("/api/actions", get(actions))
1317        .route("/api/inbox", get(inbox))
1318        .route("/api/ask", post(ask))
1319        .route("/api/ask/conversations", get(ask_conversations))
1320        .route("/api/ask/conversation", get(ask_conversation))
1321        .route("/api/ask/stop", post(ask_stop))
1322        .route("/api/ask/changed", get(ask_changed))
1323        .route("/api/explore", get(explore_list))
1324        .route("/api/explore/session", get(explore_session))
1325        .route("/api/ask/chip", post(ask_chip))
1326        .route("/api/ask/proposal", post(ask_proposal))
1327        .route("/api/shape", post(shape))
1328        .route("/api/shape/check", post(shape_check))
1329        .route("/api/shape/judge", post(shape_judge))
1330        .route("/api/bug", get(bug))
1331        .route("/api/account", get(account))
1332        .route("/api/review", get(review))
1333        .route("/api/review/comments", get(review_comments))
1334        .route("/api/approvals", get(approvals))
1335        .route("/api/evidence", get(evidence))
1336        .route("/api/tour", get(tour))
1337        .route("/api/diff", get(diff))
1338        .route("/api/documents", get(documents))
1339        .route("/api/doctor", get(doctor))
1340        .route("/api/hooks", get(hooks))
1341        .route("/api/extensions", get(extensions))
1342        .route("/api/extensions/views", get(extension_views))
1343        .route("/api/agents", get(agents))
1344        .route("/api/job", get(job))
1345        .route("/api/live", get(live))
1346        .route("/api/config", get(config).post(config_save))
1347        .route("/api/config/commit", post(config_commit))
1348        .route("/api/policy/coverage", get(policy_coverage))
1349        .route("/api/cost", get(cost))
1350        .route("/api/changes", get(changes))
1351        .route("/api/tree", get(tree))
1352        .route("/api/file", get(file))
1353        .route("/api/setup", get(setup).post(setup_init))
1354        .route("/api/github", get(github))
1355        .route("/api/github/issue", get(github_issue))
1356        .route("/api/github/login", post(github_login))
1357        .route("/api/github/device", post(github_device))
1358        .route("/api/github/device/wait", post(github_device_wait))
1359        .route("/api/pr/preview", get(pr_preview))
1360        .route("/api/setup/commit", post(setup_commit))
1361        .route("/api/events", get(events))
1362        .route("/api/run", post(start))
1363        .route("/api/answer", post(answer))
1364        .route("/api/stop", post(stop))
1365        .route("/api/fix/run", post(fix_run))
1366        .route("/api/terminal", post(terminal))
1367        .route("/api/bug-report", get(bug_report))
1368        .route("/api/explain", get(explain))
1369        .route("/api/welcome", get(welcome::view))
1370        .route("/api/open", post(welcome::open))
1371        .route("/api/recent/forget", post(welcome::forget))
1372        .route("/api/folders", get(welcome::folders))
1373        .route("/api/new/plan", get(welcome::plan))
1374        .route("/api/new/first-feature", post(welcome::first_feature))
1375        .route("/api/similar", get(welcome::similar))
1376        .route("/api/suggest", get(welcome::suggest))
1377        .route("/api/github/repos", get(welcome::repositories))
1378        .route("/api/skill-cost", get(welcome::skill_cost))
1379        .fallback(get(asset))
1380        .layer(axum::middleware::from_fn_with_state(
1381            s.clone(),
1382            welcome::projectless,
1383        ))
1384        .layer(axum::middleware::from_fn_with_state(s.clone(), guard))
1385        .with_state(s)
1386}
1387
1388/// The host's events as a shell in the same process reads them.
1389pub type Events = tokio::sync::broadcast::Receiver<String>;
1390/// Why reading [`Events`] failed: fell behind (`Lagged`) or the host ended.
1391pub use tokio::sync::broadcast::error::RecvError;
1392
1393/// A started host.
1394#[derive(Debug)]
1395pub struct Started {
1396    /// The address to open: the page, with the token in its fragment.
1397    pub url: String,
1398    /// The port.
1399    pub port: u16,
1400    /// What the host's jobs do, as the page's events (JSON lines): for a
1401    /// shell in the same process (the window) to follow without a bridge.
1402    pub events: Option<Events>,
1403    /// The host's project: the window binds it in-process to the folder
1404    /// the person picked (the path never transits the page).
1405    pub root: Root,
1406}
1407
1408/// Serves the project at `root` on `port` (0: any free one) until the
1409/// process ends; `ready` is told the address once it listens. Outside a
1410/// project the host serves the welcome until it is bound to one.
1411pub fn serve(
1412    root: &Path,
1413    port: u16,
1414    window: bool,
1415    ready: impl FnOnce(Started) + Send,
1416) -> Result<(), String> {
1417    let rt = tokio::runtime::Builder::new_multi_thread()
1418        .worker_threads(2)
1419        .enable_all()
1420        .build()
1421        .map_err(|e| e.to_string())?;
1422    rt.block_on(async move {
1423        let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port)))
1424            .await
1425            .map_err(|e| format!("127.0.0.1:{port}: {e}"))?;
1426        let port = listener.local_addr().map_err(|e| e.to_string())?.port();
1427        let (close, closed) = tokio::sync::watch::channel(false);
1428        let token = security::token();
1429        if token.is_empty() {
1430            return Err("no random source for the token".to_owned());
1431        }
1432        let Some(issuer) = fixes::Issuer::new() else {
1433            return Err("no random source for the token".to_owned());
1434        };
1435        let root = Root::new(root, welcome::is_project(root));
1436        let shared = Shared {
1437            root: root.clone(),
1438            token: token.clone(),
1439            port,
1440            hub: jobs::Hub::new(root.clone(), Arc::new(issuer)),
1441            window,
1442            closing: closed,
1443            device: Arc::default(),
1444        };
1445        ready(Started {
1446            url: format!("http://127.0.0.1:{port}/#token={token}"),
1447            port,
1448            events: Some(shared.hub.subscribe()),
1449            root,
1450        });
1451        // ctrl-c: end the event streams, give open requests a moment, then
1452        // stop whatever still holds on.
1453        let (stop, stopping) = tokio::sync::oneshot::channel::<()>();
1454        let server = axum::serve(listener, router(shared)).with_graceful_shutdown(async move {
1455            let _ = stopping.await;
1456        });
1457        let signal = async move {
1458            let _ = tokio::signal::ctrl_c().await;
1459            let _ = close.send(true);
1460            let _ = stop.send(());
1461            tokio::time::sleep(GRACE).await;
1462        };
1463        tokio::select! {
1464            served = server => served.map_err(|e| e.to_string()),
1465            () = signal => Ok(()),
1466        }
1467    })
1468}
1469
1470#[cfg(test)]
1471mod tests {
1472    use super::*;
1473
1474    /// A free slot of a signed speccoder line (`spec archive {change}`)
1475    /// takes only a unit the project has.
1476    #[test]
1477    fn an_archive_names_a_unit_the_project_has() {
1478        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1479        let r = dir.path();
1480        std::fs::create_dir_all(r.join(".specify/memory")).unwrap_or_else(|e| panic!("{e}"));
1481        std::fs::create_dir_all(r.join("specs/001-x")).unwrap_or_else(|e| panic!("{e}"));
1482        std::fs::write(r.join("specs/001-x/spec.md"), "# Spec\n").unwrap_or_else(|e| panic!("{e}"));
1483        assert!(unit_exists(r, "001-x"));
1484        assert!(!unit_exists(r, "002-other"));
1485        assert!(!unit_exists(r, "--yes"));
1486    }
1487
1488    fn q(commit: Option<&str>, reference: Option<&str>) -> FeatureQuery {
1489        FeatureQuery {
1490            feature: None,
1491            task: None,
1492            commit: commit.map(str::to_owned),
1493            reference: reference.map(str::to_owned),
1494        }
1495    }
1496
1497    #[test]
1498    fn only_commits_and_attempt_refs_reach_git_show() {
1499        assert!(revision(&q(Some("e23d4197bb"), None)).is_some());
1500        assert!(revision(&q(Some("e23d"), None)).is_none());
1501        assert!(revision(&q(Some("--output=/tmp/x"), None)).is_none());
1502        let attempt = "refs/speccoder/attempts/001-greet/T001/run_01M5S5VJ";
1503        assert_eq!(revision(&q(None, Some(attempt))).as_deref(), Some(attempt));
1504        for bad in [
1505            "refs/heads/main",
1506            "refs/speccoder/attempts/",
1507            "refs/speccoder/attempts/../../heads/main",
1508            "refs/speccoder/attempts/a b",
1509            "--all",
1510            "HEAD",
1511        ] {
1512            assert!(revision(&q(None, Some(bad))).is_none(), "{bad}");
1513        }
1514    }
1515}