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 hooks(State(s): State<Shared>) -> Json<Value> {
493    let root = s.root.get();
494    let r = off(move || speccoder_runtime::service::hooks::list(&root)).await;
495    envelope(&s, &r)
496}
497
498async fn doctor(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
499    let root = s.root.get();
500    let r = off(move || speccoder_runtime::service::doctor::run(&root, q.feature.as_deref(), None))
501        .await;
502    envelope(&s, &r)
503}
504
505#[derive(Debug, Deserialize)]
506struct AgentsQuery {
507    /// Ask each installed agent for a session (never a prompt); `false`:
508    /// what is known without starting one.
509    #[serde(default = "yes")]
510    probe: bool,
511}
512
513fn yes() -> bool {
514    true
515}
516
517/// `doctor --agents`: every agent's card.
518async fn agents(State(s): State<Shared>, Query(q): Query<AgentsQuery>) -> Json<Value> {
519    let root = s.root.get();
520    let r = off(move || speccoder_runtime::service::doctor::agents(&root, None, q.probe)).await;
521    envelope(&s, &r)
522}
523
524async fn config(State(s): State<Shared>) -> Json<Value> {
525    let root = s.root.get();
526    let r = off(move || speccoder_runtime::service::settings::show(&root)).await;
527    envelope(&s, &r)
528}
529
530#[derive(Debug, Deserialize)]
531struct ConfigBody {
532    changes: Vec<speccoder_core::ConfigChange>,
533}
534
535/// Saves changes to `speccoder.toml`, all or nothing, after every parser
536/// accepts the result. Safe while a job runs: runs read the committed file,
537/// so a saved change is in force only once committed.
538async fn config_save(State(s): State<Shared>, Json(b): Json<ConfigBody>) -> Json<Value> {
539    let root = s.root.get();
540    let r = off(move || speccoder_runtime::service::settings::apply(&root, &b.changes)).await;
541    envelope(&s, &r)
542}
543
544/// One Ask turn: read-only, streamed as `ask_step` / `ask_text` /
545/// `ask_done`; it writes only the Ask plane's journal and thread.
546async fn ask(
547    State(s): State<Shared>,
548    Json(b): Json<speccoder_runtime::service::ask::Request>,
549) -> Json<Value> {
550    let root = s.root.get();
551    let sink = s.hub.progress();
552    let r = off(move || speccoder_runtime::service::ask::turn(&root, &b, None, Some(sink))).await;
553    envelope(&s, &r)
554}
555
556#[derive(Debug, Deserialize)]
557struct StopBody {
558    conversation: Option<String>,
559}
560
561/// Stops the turn in flight: it ends after the model call it is in,
562/// keeping its words; the conversation goes on with the next question.
563async fn ask_stop(Json(b): Json<StopBody>) -> Json<Value> {
564    Json(json!({ "stopped": speccoder_runtime::service::ask::stop(b.conversation.as_deref()) }))
565}
566
567#[derive(Debug, Deserialize)]
568struct AskQuery {
569    id: Option<String>,
570    feature: Option<String>,
571}
572
573/// The project's explorations, newest first.
574async fn explore_list(State(s): State<Shared>) -> Json<Value> {
575    let root = s.root.get();
576    let r = off(move || speccoder_runtime::service::explore::list(&root, None)).await;
577    envelope(&s, &r)
578}
579
580/// One exploration: its approaches, the choice and the brief.
581async fn explore_session(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
582    let root = s.root.get();
583    let r = off(move || {
584        speccoder_runtime::service::explore::session(
585            &root,
586            None,
587            q.id.as_deref().unwrap_or_default(),
588        )
589    })
590    .await;
591    envelope(&s, &r)
592}
593
594async fn ask_conversations(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
595    let root = s.root.get();
596    let r = off(move || {
597        speccoder_runtime::service::ask::conversations(&root, None, q.feature.as_deref())
598    })
599    .await;
600    envelope(&s, &r)
601}
602
603async fn ask_conversation(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
604    let root = s.root.get();
605    let r = off(move || {
606        speccoder_runtime::service::ask::thread(&root, None, q.id.as_deref().unwrap_or_default())
607    })
608    .await;
609    envelope(&s, &r)
610}
611
612#[derive(Debug, Deserialize)]
613struct ChangedQuery {
614    path: String,
615    start: Option<u32>,
616    end: Option<u32>,
617    sha: String,
618}
619
620/// Whether the lines a citation names still say what Ask read
621/// (`changed`: null when the file cannot be read now).
622async fn ask_changed(State(s): State<Shared>, Query(q): Query<ChangedQuery>) -> Json<Value> {
623    let root = s.root.get();
624    Json(json!({
625        "changed": off(move || {
626            speccoder_runtime::service::ask::changed_since(&root, &q.path, q.start, q.end, &q.sha)
627        })
628        .await
629    }))
630}
631
632/// A chip resolved now: its label, digest and words; records nothing.
633async fn ask_chip(
634    State(s): State<Shared>,
635    Json(b): Json<speccoder_runtime::service::ask::ChipRef>,
636) -> Json<Value> {
637    let root = s.root.get();
638    Json(
639        off(
640            move || match speccoder_runtime::service::ask::chip(&root, "c0", &b) {
641                Ok((chip, text)) => {
642                    json!({ "chip": chip, "preview": text.chars().take(400).collect::<String>() })
643                }
644                Err(e) => json!({ "error": e }),
645            },
646        )
647        .await,
648    )
649}
650
651#[derive(Debug, Deserialize)]
652struct ProposalBody {
653    proposal: speccoder_core::ask::Proposal,
654    feature: Option<String>,
655}
656
657/// A proposal as the person edited it, checked again now.
658async fn ask_proposal(State(s): State<Shared>, Json(b): Json<ProposalBody>) -> Json<Value> {
659    let root = s.root.get();
660    Json(
661        off(move || json!({ "proposal": speccoder_runtime::service::ask::proposal(&root, b.feature.as_deref(), &b.proposal) }))
662            .await,
663    )
664}
665
666#[derive(Debug, Deserialize)]
667struct ShapeBody {
668    field: String,
669    text: String,
670}
671
672/// Shape the person's words for a field: one model call, judged by code;
673/// nothing is written.
674async fn shape(State(s): State<Shared>, Json(b): Json<ShapeBody>) -> Json<Value> {
675    let root = s.root.get();
676    let r =
677        off(move || speccoder_runtime::service::shape::shape(&root, &b.field, &b.text, None)).await;
678    envelope(&s, &r)
679}
680
681/// The writing check of what the person is typing (no model).
682async fn shape_check(Json(b): Json<ShapeBody>) -> Json<Value> {
683    Json(off(move || json!({ "findings": speccoder_runtime::service::shape::findings(&b.field, &b.text) })).await)
684}
685
686#[derive(Debug, Deserialize)]
687struct JudgeBody {
688    field: String,
689    original: String,
690    proposal: String,
691    #[serde(default)]
692    assumptions: Vec<speccoder_core::shape::Assumption>,
693}
694
695/// The proposal again, as the person changed it in the sheet.
696async fn shape_judge(Json(b): Json<JudgeBody>) -> Json<Value> {
697    Json(
698        off(move || {
699            speccoder_runtime::service::shape::judge(
700                &b.field,
701                &b.original,
702                &b.proposal,
703                &b.assumptions,
704            )
705        })
706        .await,
707    )
708}
709
710#[derive(Debug, Deserialize)]
711struct CoverageQuery {
712    /// `commands.allow_prefix` or `sandbox.network_allow_prefix`.
713    key: String,
714    /// The prefix as a JSON array of strings.
715    prefix: String,
716}
717
718/// What a prefix entry would newly allow, from the project's journal: the
719/// preview a prefix is saved from (`WidenPolicy` through `/api/run`).
720async fn policy_coverage(State(s): State<Shared>, Query(q): Query<CoverageQuery>) -> Json<Value> {
721    let Some(list) = speccoder_core::actions::PolicyList::of_key(&q.key) else {
722        return Json(
723            json!({ "status": "usage_error", "message": format!("`{}` is not a prefix list", q.key) }),
724        );
725    };
726    let Ok(prefix) = serde_json::from_str::<Vec<String>>(&q.prefix) else {
727        return Json(
728            json!({ "status": "usage_error", "message": "the prefix is a JSON list of strings" }),
729        );
730    };
731    let root = s.root.get();
732    let r = off(move || {
733        let state = speccoder_runtime::service::worktree::state_dir();
734        speccoder_runtime::service::settings::coverage(&root, state.as_deref(), list, &prefix)
735    })
736    .await;
737    envelope(&s, &r)
738}
739
740/// Commits `speccoder.toml` alone — not while a job runs (an in-place run
741/// commits in the same checkout).
742async fn config_commit(State(s): State<Shared>) -> Json<Value> {
743    let (root, hub) = (s.root.get(), Arc::clone(&s.hub));
744    let r =
745        off(move || hub.exclusive(|| speccoder_runtime::service::settings::commit(&root))).await;
746    match r {
747        Ok(result) => envelope(&s, &result),
748        Err(e) => Json(json!({ "status": "error", "message": e })),
749    }
750}
751
752async fn cost(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
753    let root = s.root.get();
754    let r =
755        off(move || speccoder_runtime::service::controls::cost(&root, q.feature.as_deref())).await;
756    envelope(&s, &r)
757}
758
759/// What the feature's branch changed: commits, files, the patch.
760async fn changes(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
761    let root = s.root.get();
762    Json(json!(
763        off(move || speccoder_runtime::service::code::changes(&root, q.feature.as_deref())).await
764    ))
765}
766
767/// The files at the feature's revision.
768async fn tree(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
769    let root = s.root.get();
770    let ((rev, files), worktree) = off(move || {
771        (
772            speccoder_runtime::service::code::tree(&root, q.feature.as_deref()),
773            speccoder_runtime::service::code::worktree_view(&root, q.feature.as_deref()),
774        )
775    })
776    .await;
777    Json(json!({ "rev": rev, "files": files, "worktree": worktree }))
778}
779
780#[derive(Debug, Deserialize)]
781struct FileQuery {
782    feature: Option<String>,
783    path: String,
784}
785
786/// One file at the feature's revision, when the tree lists it.
787async fn file(State(s): State<Shared>, Query(q): Query<FileQuery>) -> Json<Value> {
788    let root = s.root.get();
789    // Project paths only: nothing absolute, nothing up, nothing of the
790    // state directory (where drafts, frozen and held-out sets live).
791    if !file_path_ok(&q.path) {
792        return Json(json!({ "error": format!("{} is not a project file", q.path) }));
793    }
794    let markdown = q.path.to_ascii_lowercase().ends_with(".md");
795    // Committed at the revision, else untracked in the worktree.
796    let r = off(move || {
797        speccoder_runtime::service::code::file(&root, q.feature.as_deref(), &q.path).or_else(|e| {
798            speccoder_runtime::service::code::worktree_file(&root, q.feature.as_deref(), &q.path)
799                .map_err(|_| e)
800        })
801    })
802    .await;
803    Json(match r {
804        // Markdown also rendered (raw HTML shown as text), for the preview.
805        Ok(text) if markdown => {
806            json!({ "text": text, "html": speccoder_runtime::service::documents::render(&text) })
807        }
808        Ok(text) => json!({ "text": text }),
809        Err(e) => json!({ "error": e }),
810    })
811}
812
813/// The setup assistant's view: what `doctor` sees, and what `init` would
814/// write.
815async fn setup(State(s): State<Shared>) -> Json<Value> {
816    let root = s.root.get();
817    let (doctor, preview) = off(move || {
818        (
819            speccoder_runtime::service::doctor::run(&root, None, None),
820            speccoder_runtime::service::init::preview(&root),
821        )
822    })
823    .await;
824    Json(json!({ "doctor": doctor, "preview": preview }))
825}
826
827#[derive(Debug, Deserialize)]
828struct SetupBody {
829    provider: Option<String>,
830    model: Option<String>,
831    /// Bedrock's region.
832    region: Option<String>,
833}
834
835/// Writes `speccoder.toml` and runs its checks on the untouched code; the
836/// commit is the person's next click.
837async fn setup_init(State(s): State<Shared>, Json(b): Json<SetupBody>) -> Json<Value> {
838    if s.hub.job().is_some_and(|j| j.running) {
839        return Json(json!({ "status": "error", "message": "a job is running" }));
840    }
841    let root = s.root.get();
842    let r = off(move || {
843        speccoder_runtime::service::init::prepare(
844            &root,
845            b.provider.as_deref(),
846            b.model.as_deref(),
847            b.region.as_deref(),
848            false,
849            false,
850        )
851    })
852    .await;
853    match r {
854        Err(refused) => envelope(&s, &refused),
855        Ok(p) => {
856            let (to_commit, failed) = (p.to_commit.clone(), p.failed.clone());
857            let Json(mut v) = envelope(&s, &speccoder_runtime::service::init::finish(p));
858            v["to_commit"] = json!(to_commit);
859            v["failed"] = json!(failed);
860            Json(v)
861        }
862    }
863}
864
865#[derive(Debug, Deserialize)]
866struct SetupCommit {
867    paths: Vec<String>,
868}
869
870/// Commits what `init` wrote — only files `init` commits.
871async fn setup_commit(State(s): State<Shared>, Json(b): Json<SetupCommit>) -> Json<Value> {
872    let (root, hub) = (s.root.get(), Arc::clone(&s.hub));
873    let r = off(move || {
874        hub.exclusive(|| {
875            speccoder_runtime::service::init::commit_written(
876                &speccoder_runtime::service::init::root_of(&root),
877                &b.paths,
878            )
879        })
880    })
881    .await;
882    Json(match r.and_then(|r| r) {
883        Ok(id) => json!({ "status": "ok", "message": format!("committed as {id}") }),
884        Err(e) => json!({ "status": "error", "message": e }),
885    })
886}
887
888/// GitHub as the person's token sees it: whether it is ready, the open
889/// issues (each with the feature that names it), and the feature's pull
890/// request.
891async fn github(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
892    let root = s.root.get();
893    Json(
894        off(move || {
895            use speccoder_runtime::service::github;
896            let ctx = github::Ctx::from_env();
897            let status = github::status(&ctx, &root);
898            if !status.ready {
899                return json!({ "status": status });
900            }
901            let issues = github::issues(&ctx, &root, &github::started(&root));
902            let branch = match speccoder_runtime::service::status::report(
903                &root,
904                q.feature.as_deref(),
905            )
906            .data()
907            {
908                Some(speccoder_core::CommandData::Report(r)) => r.branch.clone(),
909                _ => None,
910            };
911            let pull = branch.as_deref().map(|b| github::pr_for(&ctx, &root, b));
912            json!({
913                "status": status,
914                "issues": issues.as_ref().ok(),
915                "issues_error": issues.as_ref().err(),
916                "branch": branch,
917                "pull": pull.as_ref().and_then(|p| p.as_ref().ok()).cloned().flatten(),
918                "pull_error": pull.as_ref().and_then(|p| p.as_ref().err()),
919            })
920        })
921        .await,
922    )
923}
924
925#[derive(Debug, Deserialize)]
926struct IssueQuery {
927    number: String,
928}
929
930/// Starts GitHub's device sign-in for the repository's host: the code for
931/// the person to enter, and where.
932async fn github_device(State(s): State<Shared>) -> Json<Value> {
933    let root = s.root.get();
934    let slot = Arc::clone(&s.device);
935    Json(
936        off(move || {
937            use speccoder_runtime::service::github;
938            let host = github::repo(&root, None).map_or_else(|_| "github.com".to_owned(), |r| r.host);
939            let Some(client) = github::auth::client_id(&host) else {
940                return json!({ "error": format!("{host} has no speccoder app: sign in with a token instead") });
941            };
942            match github::auth::start(&github::http::oauth(&host), &client) {
943                Ok(p) => {
944                    let shown = json!({ "user_code": p.user_code, "verification_uri": p.verification_uri, "host": host });
945                    if let Ok(mut d) = slot.lock() {
946                        *d = Some((host, client, p));
947                    }
948                    shown
949                }
950                Err(e) => json!({ "error": e }),
951            }
952        })
953        .await,
954    )
955}
956
957/// Waits for the person to enter the code, then keeps the token in the
958/// keychain; says whether the app can reach this repository.
959async fn github_device_wait(State(s): State<Shared>) -> Json<Value> {
960    let root = s.root.get();
961    let slot = Arc::clone(&s.device);
962    let r = off(move || {
963        use speccoder_runtime::service::github;
964        let Some((host, client, pending)) = slot.lock().ok().and_then(|mut d| d.take()) else {
965            return speccoder_core::CommandResult::new(
966                "auth",
967                speccoder_core::CommandStatus::UsageError,
968                "no sign-in is waiting: start it again",
969            );
970        };
971        let ctx = github::Ctx::from_env();
972        match github::auth::wait(&github::http::oauth(&host), &client, &pending) {
973            Ok(token) => {
974                let signed = github::login_with_token(&ctx, &host, token.expose());
975                match (signed.status(), github::reach(&ctx, &root)) {
976                    (speccoder_core::CommandStatus::Ok, Some(todo)) => {
977                        let message = format!("{}; {todo}", signed.message());
978                        signed.reworded(speccoder_core::CommandStatus::Ok, &message)
979                    }
980                    _ => signed,
981                }
982            }
983            Err(e) => github::refused("auth", &e),
984        }
985    })
986    .await;
987    envelope(&s, &r)
988}
989
990#[derive(Debug, Deserialize)]
991struct TokenBody {
992    token: String,
993}
994
995/// Signs in to the repository's GitHub host with a token the person pasted
996/// (kept in the credential store, never echoed or logged).
997async fn github_login(State(s): State<Shared>, Json(b): Json<TokenBody>) -> Json<Value> {
998    let root = s.root.get();
999    let r = off(move || {
1000        let host = speccoder_runtime::service::github::repo(&root, None)
1001            .map_or_else(|_| "github.com".to_owned(), |r| r.host);
1002        speccoder_runtime::service::github::login_with_token(
1003            &speccoder_runtime::service::github::Ctx::from_env(),
1004            &host,
1005            &b.token,
1006        )
1007    })
1008    .await;
1009    envelope(&s, &r)
1010}
1011
1012/// One issue, the description a feature started from it gets, and the
1013/// skill that starts it.
1014async fn github_issue(State(s): State<Shared>, Query(q): Query<IssueQuery>) -> Json<Value> {
1015    let root = s.root.get();
1016    Json(
1017        off(move || {
1018            match speccoder_runtime::service::github::issue(
1019                &speccoder_runtime::service::github::Ctx::from_env(),
1020                &root,
1021                &q.number,
1022            ) {
1023                Ok(issue) => {
1024                    json!({
1025                        "description": speccoder_runtime::service::github::description(&issue),
1026                        "skill": speccoder_runtime::service::create_skill(&root),
1027                        "issue": issue,
1028                    })
1029                }
1030                Err(e) => json!({ "error": e }),
1031            }
1032        })
1033        .await,
1034    )
1035}
1036
1037/// What `pr` would do — pushed where, the title, the body — without doing it.
1038async fn pr_preview(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
1039    let root = s.root.get();
1040    let r = off(move || {
1041        speccoder_runtime::service::handoff::pr(
1042            &root,
1043            q.feature.as_deref(),
1044            &speccoder_runtime::service::handoff::PrOptions {
1045                remote: "origin",
1046                base: None,
1047                open: false,
1048                force: false,
1049                github: &speccoder_runtime::service::github::Ctx::from_env(),
1050            },
1051        )
1052    })
1053    .await;
1054    envelope(&s, &r)
1055}
1056
1057/// The project's current (or last) run, whoever started it: this host's
1058/// job, or `speccoder run` in a terminal. `running`: its process lives and
1059/// it has not finished; `own`: it is this host's.
1060async fn live(State(s): State<Shared>) -> Json<Value> {
1061    let root = s.root.get();
1062    Json(
1063        off(move || {
1064            let Some(state) = speccoder_runtime::service::worktree::state_dir() else {
1065                return json!({ "live": null });
1066            };
1067            match speccoder_runtime::service::progress::read_live(&root, &state) {
1068                Some((live, running)) => json!({
1069                    "live": live,
1070                    "running": running,
1071                    "own": live.pid == std::process::id(),
1072                    "interrupted": live.finished.is_none() && !running,
1073                    "now": std::time::SystemTime::now()
1074                        .duration_since(std::time::UNIX_EPOCH)
1075                        .map_or(0, |d| d.as_secs()),
1076                }),
1077                None => json!({ "live": null }),
1078            }
1079        })
1080        .await,
1081    )
1082}
1083
1084async fn job(State(s): State<Shared>) -> Json<Value> {
1085    Json(json!({ "job": s.hub.job(), "waiting": s.hub.waiting() }))
1086}
1087
1088#[derive(Debug, Deserialize)]
1089struct Start {
1090    run: Runnable,
1091    input: Option<String>,
1092    feature: Option<String>,
1093    command: String,
1094    /// Before the host is bound: the folder an Undo is for (a recent project).
1095    #[serde(default)]
1096    root: Option<String>,
1097}
1098
1099async fn start(State(s): State<Shared>, Json(b): Json<Start>) -> Response {
1100    let root = match welcome::job_root(&s.root, &b.run, b.root.as_deref()) {
1101        Ok(r) => r,
1102        Err(e) => return welcome::no_project_yet(&e),
1103    };
1104    match s.hub.start(b.run, b.input, b.feature, b.command, root) {
1105        Ok(id) => Json(json!({ "id": id })).into_response(),
1106        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1107    }
1108}
1109
1110#[derive(Debug, Deserialize)]
1111struct AnswerBody {
1112    id: u64,
1113    answer: Option<speccoder_runtime::service::approvals::Answer>,
1114    text: Option<String>,
1115}
1116
1117async fn answer(State(s): State<Shared>, Json(b): Json<AnswerBody>) -> Response {
1118    let reply = match b.answer {
1119        Some(a) => jobs::Reply::Answer(a),
1120        None => jobs::Reply::Text(b.text.filter(|t| !t.trim().is_empty())),
1121    };
1122    match s.hub.answer(b.id, reply) {
1123        Ok(()) => Json(json!({ "ok": true })).into_response(),
1124        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1125    }
1126}
1127
1128async fn stop(State(s): State<Shared>) -> Json<Value> {
1129    s.hub.stop();
1130    Json(json!({ "ok": true }))
1131}
1132
1133/// Server-sent events: what the job does, what it asks. Read with `fetch`
1134/// (the token is a header, never in the address).
1135async fn events(State(s): State<Shared>) -> Response {
1136    use futures_util::StreamExt;
1137    let rx = s.hub.subscribe();
1138    let hello = futures_util::stream::once(async {
1139        Ok::<_, std::convert::Infallible>(
1140            axum::response::sse::Event::default().data(json!({ "type": "hello" }).to_string()),
1141        )
1142    });
1143    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|m| async move {
1144        m.ok().map(|text| {
1145            Ok::<_, std::convert::Infallible>(axum::response::sse::Event::default().data(text))
1146        })
1147    });
1148    // An event stream never ends on its own: it ends when the host stops,
1149    // or a graceful shutdown would wait for it forever.
1150    let mut closing = s.closing.clone();
1151    let stream = stream.take_until(async move {
1152        let _ = closing.wait_for(|c| *c).await;
1153    });
1154    axum::response::sse::Sse::new(hello.chain(stream))
1155        .keep_alive(axum::response::sse::KeepAlive::default())
1156        .into_response()
1157}
1158
1159#[derive(Debug, Deserialize)]
1160struct FixBody {
1161    argvs: Vec<Vec<String>>,
1162    token: String,
1163}
1164
1165/// The person's own command — argvs this host issued, exactly — run as
1166/// them in the project folder, as a job (`output` events, then `done`).
1167/// Anything else is refused: argvs the page composed, edited or took from
1168/// elsewhere, and a command that reads the keyboard.
1169async fn fix_run(State(s): State<Shared>, Json(b): Json<FixBody>) -> Response {
1170    let root = s.root.get();
1171    // Signed as a step that does not read the keyboard, or not at all.
1172    if !s.hub.issuer.issued(&root, &b.argvs, false, &b.token) {
1173        return (
1174            StatusCode::FORBIDDEN,
1175            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" })),
1176        )
1177            .into_response();
1178    }
1179    if b.argvs.iter().any(|a| speccoder_core::step::interactive(a)) {
1180        return (
1181            StatusCode::BAD_REQUEST,
1182            Json(json!({ "error": "it asks you questions as it runs: open it in a terminal" })),
1183        )
1184            .into_response();
1185    }
1186    // speccoder's own command is a job of speccoder's, never the
1187    // person's command runner.
1188    let started = match b.argvs.as_slice() {
1189        [one] if one.first().map(String::as_str) == Some("speccoder") => {
1190            let args = one[1..].to_vec();
1191            // A slot a constant line leaves free names a unit of this project.
1192            if let [spec, archive, change, ..] = args.as_slice()
1193                && spec == "spec"
1194                && archive == "archive"
1195                && !change.starts_with('-')
1196            {
1197                let (root2, change2) = (root.clone(), change.clone());
1198                let known = off(move || unit_exists(&root2, &change2)).await;
1199                if !known {
1200                    return (
1201                        StatusCode::FORBIDDEN,
1202                        Json(json!({ "error": format!("`{change}` is no unit of this project") })),
1203                    )
1204                        .into_response();
1205                }
1206            }
1207            s.hub.start_speccoder(args, root)
1208        }
1209        argvs
1210            if argvs
1211                .iter()
1212                .any(|a| a.first().map(String::as_str) == Some("speccoder")) =>
1213        {
1214            return (
1215                StatusCode::FORBIDDEN,
1216                Json(json!({ "error": "speccoder runs on its own, never in a chain" })),
1217            )
1218                .into_response();
1219        }
1220        _ => s.hub.start_fix(b.argvs, root),
1221    };
1222    match started {
1223        Ok(id) => Json(json!({ "id": id })).into_response(),
1224        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1225    }
1226}
1227
1228/// Whether `name` is a unit (a feature, a change) of the project at `root`.
1229fn unit_exists(root: &Path, name: &str) -> bool {
1230    match speccoder_runtime::service::current::list(root, None).data() {
1231        Some(CommandData::Features(f)) => f.features.iter().any(|e| e.name == name),
1232        _ => false,
1233    }
1234}
1235
1236/// A terminal of the person's own at the project folder, for a command
1237/// this host issued that reads the keyboard (the page puts it on the
1238/// clipboard). The desktop window only: a tab copies.
1239async fn terminal(State(s): State<Shared>, Json(b): Json<FixBody>) -> Response {
1240    let root = s.root.get();
1241    if !s.window {
1242        return (
1243            StatusCode::BAD_REQUEST,
1244            Json(json!({ "error": "a browser tab opens no terminal: copy the command" })),
1245        )
1246            .into_response();
1247    }
1248    if !s.hub.issuer.issued(&root, &b.argvs, true, &b.token) {
1249        return (
1250            StatusCode::FORBIDDEN,
1251            Json(json!({ "error": "speccoder did not issue this command here for a terminal" })),
1252        )
1253            .into_response();
1254    }
1255    match off(move || speccoder_runtime::service::person::open_terminal(&root)).await {
1256        Ok(()) => Json(json!({ "ok": true })).into_response(),
1257        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1258    }
1259}
1260
1261/// `doctor --bug-report`: the setup in Markdown, secrets taken out.
1262async fn bug_report(State(s): State<Shared>) -> Json<Value> {
1263    let root = s.root.get();
1264    Json(
1265        off(move || {
1266            let doctor = speccoder_runtime::service::doctor::run(&root, None, None);
1267            json!({ "text": speccoder_runtime::service::copy::bug_report(&doctor, &root) })
1268        })
1269        .await,
1270    )
1271}
1272
1273#[derive(Debug, Deserialize)]
1274struct CodeQuery {
1275    code: String,
1276}
1277
1278/// `explain CODE`: what a code means and how to resolve it.
1279async fn explain(Query(q): Query<CodeQuery>) -> Json<Value> {
1280    use speccoder_core::{CodeInfo, CommandData, ExplainReport};
1281    let r = match speccoder_core::codes::lookup(&q.code.to_ascii_uppercase()) {
1282        Some(e) => CommandResult::ok("explain", e.name).with_data(CommandData::Explain(Box::new(
1283            ExplainReport {
1284                codes: vec![CodeInfo::of(e)],
1285            },
1286        ))),
1287        None => CommandResult::new(
1288            "explain",
1289            speccoder_core::CommandStatus::UsageError,
1290            &format!("no code `{}`", q.code),
1291        )
1292        .with_code("USAGE-UNKNOWN-CODE"),
1293    };
1294    Json(serde_json::to_value(&r).unwrap_or(Value::Null))
1295}
1296
1297/// The routes.
1298pub fn router(s: Shared) -> Router {
1299    Router::new()
1300        .route("/api/project", get(project))
1301        .route("/api/report", get(report))
1302        .route("/api/actions", get(actions))
1303        .route("/api/inbox", get(inbox))
1304        .route("/api/ask", post(ask))
1305        .route("/api/ask/conversations", get(ask_conversations))
1306        .route("/api/ask/conversation", get(ask_conversation))
1307        .route("/api/ask/stop", post(ask_stop))
1308        .route("/api/ask/changed", get(ask_changed))
1309        .route("/api/explore", get(explore_list))
1310        .route("/api/explore/session", get(explore_session))
1311        .route("/api/ask/chip", post(ask_chip))
1312        .route("/api/ask/proposal", post(ask_proposal))
1313        .route("/api/shape", post(shape))
1314        .route("/api/shape/check", post(shape_check))
1315        .route("/api/shape/judge", post(shape_judge))
1316        .route("/api/bug", get(bug))
1317        .route("/api/account", get(account))
1318        .route("/api/review", get(review))
1319        .route("/api/review/comments", get(review_comments))
1320        .route("/api/approvals", get(approvals))
1321        .route("/api/evidence", get(evidence))
1322        .route("/api/tour", get(tour))
1323        .route("/api/diff", get(diff))
1324        .route("/api/documents", get(documents))
1325        .route("/api/doctor", get(doctor))
1326        .route("/api/hooks", get(hooks))
1327        .route("/api/agents", get(agents))
1328        .route("/api/job", get(job))
1329        .route("/api/live", get(live))
1330        .route("/api/config", get(config).post(config_save))
1331        .route("/api/config/commit", post(config_commit))
1332        .route("/api/policy/coverage", get(policy_coverage))
1333        .route("/api/cost", get(cost))
1334        .route("/api/changes", get(changes))
1335        .route("/api/tree", get(tree))
1336        .route("/api/file", get(file))
1337        .route("/api/setup", get(setup).post(setup_init))
1338        .route("/api/github", get(github))
1339        .route("/api/github/issue", get(github_issue))
1340        .route("/api/github/login", post(github_login))
1341        .route("/api/github/device", post(github_device))
1342        .route("/api/github/device/wait", post(github_device_wait))
1343        .route("/api/pr/preview", get(pr_preview))
1344        .route("/api/setup/commit", post(setup_commit))
1345        .route("/api/events", get(events))
1346        .route("/api/run", post(start))
1347        .route("/api/answer", post(answer))
1348        .route("/api/stop", post(stop))
1349        .route("/api/fix/run", post(fix_run))
1350        .route("/api/terminal", post(terminal))
1351        .route("/api/bug-report", get(bug_report))
1352        .route("/api/explain", get(explain))
1353        .route("/api/welcome", get(welcome::view))
1354        .route("/api/open", post(welcome::open))
1355        .route("/api/recent/forget", post(welcome::forget))
1356        .route("/api/folders", get(welcome::folders))
1357        .route("/api/new/plan", get(welcome::plan))
1358        .route("/api/new/first-feature", post(welcome::first_feature))
1359        .route("/api/similar", get(welcome::similar))
1360        .route("/api/suggest", get(welcome::suggest))
1361        .route("/api/github/repos", get(welcome::repositories))
1362        .route("/api/skill-cost", get(welcome::skill_cost))
1363        .fallback(get(asset))
1364        .layer(axum::middleware::from_fn_with_state(
1365            s.clone(),
1366            welcome::projectless,
1367        ))
1368        .layer(axum::middleware::from_fn_with_state(s.clone(), guard))
1369        .with_state(s)
1370}
1371
1372/// The host's events as a shell in the same process reads them.
1373pub type Events = tokio::sync::broadcast::Receiver<String>;
1374/// Why reading [`Events`] failed: fell behind (`Lagged`) or the host ended.
1375pub use tokio::sync::broadcast::error::RecvError;
1376
1377/// A started host.
1378#[derive(Debug)]
1379pub struct Started {
1380    /// The address to open: the page, with the token in its fragment.
1381    pub url: String,
1382    /// The port.
1383    pub port: u16,
1384    /// What the host's jobs do, as the page's events (JSON lines): for a
1385    /// shell in the same process (the window) to follow without a bridge.
1386    pub events: Option<Events>,
1387    /// The host's project: the window binds it in-process to the folder
1388    /// the person picked (the path never transits the page).
1389    pub root: Root,
1390}
1391
1392/// Serves the project at `root` on `port` (0: any free one) until the
1393/// process ends; `ready` is told the address once it listens. Outside a
1394/// project the host serves the welcome until it is bound to one.
1395pub fn serve(
1396    root: &Path,
1397    port: u16,
1398    window: bool,
1399    ready: impl FnOnce(Started) + Send,
1400) -> Result<(), String> {
1401    let rt = tokio::runtime::Builder::new_multi_thread()
1402        .worker_threads(2)
1403        .enable_all()
1404        .build()
1405        .map_err(|e| e.to_string())?;
1406    rt.block_on(async move {
1407        let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port)))
1408            .await
1409            .map_err(|e| format!("127.0.0.1:{port}: {e}"))?;
1410        let port = listener.local_addr().map_err(|e| e.to_string())?.port();
1411        let (close, closed) = tokio::sync::watch::channel(false);
1412        let token = security::token();
1413        if token.is_empty() {
1414            return Err("no random source for the token".to_owned());
1415        }
1416        let Some(issuer) = fixes::Issuer::new() else {
1417            return Err("no random source for the token".to_owned());
1418        };
1419        let root = Root::new(root, welcome::is_project(root));
1420        let shared = Shared {
1421            root: root.clone(),
1422            token: token.clone(),
1423            port,
1424            hub: jobs::Hub::new(root.clone(), Arc::new(issuer)),
1425            window,
1426            closing: closed,
1427            device: Arc::default(),
1428        };
1429        ready(Started {
1430            url: format!("http://127.0.0.1:{port}/#token={token}"),
1431            port,
1432            events: Some(shared.hub.subscribe()),
1433            root,
1434        });
1435        // ctrl-c: end the event streams, give open requests a moment, then
1436        // stop whatever still holds on.
1437        let (stop, stopping) = tokio::sync::oneshot::channel::<()>();
1438        let server = axum::serve(listener, router(shared)).with_graceful_shutdown(async move {
1439            let _ = stopping.await;
1440        });
1441        let signal = async move {
1442            let _ = tokio::signal::ctrl_c().await;
1443            let _ = close.send(true);
1444            let _ = stop.send(());
1445            tokio::time::sleep(GRACE).await;
1446        };
1447        tokio::select! {
1448            served = server => served.map_err(|e| e.to_string()),
1449            () = signal => Ok(()),
1450        }
1451    })
1452}
1453
1454#[cfg(test)]
1455mod tests {
1456    use super::*;
1457
1458    /// A free slot of a signed speccoder line (`spec archive {change}`)
1459    /// takes only a unit the project has.
1460    #[test]
1461    fn an_archive_names_a_unit_the_project_has() {
1462        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1463        let r = dir.path();
1464        std::fs::create_dir_all(r.join(".specify/memory")).unwrap_or_else(|e| panic!("{e}"));
1465        std::fs::create_dir_all(r.join("specs/001-x")).unwrap_or_else(|e| panic!("{e}"));
1466        std::fs::write(r.join("specs/001-x/spec.md"), "# Spec\n").unwrap_or_else(|e| panic!("{e}"));
1467        assert!(unit_exists(r, "001-x"));
1468        assert!(!unit_exists(r, "002-other"));
1469        assert!(!unit_exists(r, "--yes"));
1470    }
1471
1472    fn q(commit: Option<&str>, reference: Option<&str>) -> FeatureQuery {
1473        FeatureQuery {
1474            feature: None,
1475            task: None,
1476            commit: commit.map(str::to_owned),
1477            reference: reference.map(str::to_owned),
1478        }
1479    }
1480
1481    #[test]
1482    fn only_commits_and_attempt_refs_reach_git_show() {
1483        assert!(revision(&q(Some("e23d4197bb"), None)).is_some());
1484        assert!(revision(&q(Some("e23d"), None)).is_none());
1485        assert!(revision(&q(Some("--output=/tmp/x"), None)).is_none());
1486        let attempt = "refs/speccoder/attempts/001-greet/T001/run_01M5S5VJ";
1487        assert_eq!(revision(&q(None, Some(attempt))).as_deref(), Some(attempt));
1488        for bad in [
1489            "refs/heads/main",
1490            "refs/speccoder/attempts/",
1491            "refs/speccoder/attempts/../../heads/main",
1492            "refs/speccoder/attempts/a b",
1493            "--all",
1494            "HEAD",
1495        ] {
1496            assert!(revision(&q(None, Some(bad))).is_none(), "{bad}");
1497        }
1498    }
1499}