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 jobs;
8pub mod security;
9
10use std::net::SocketAddr;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use axum::extract::{Query, Request, State};
15use axum::http::{HeaderValue, StatusCode, header};
16use axum::middleware::Next;
17use axum::response::{IntoResponse, Response};
18use axum::routing::{get, post};
19use axum::{Json, Router};
20use serde::Deserialize;
21use serde_json::{Value, json};
22use speccoder_core::actions::Runnable;
23use speccoder_core::{CommandData, CommandResult};
24
25mod bundle {
26    include!(concat!(env!("OUT_DIR"), "/bundle.rs"));
27}
28
29/// The host's state.
30#[derive(Debug, Clone)]
31pub struct Shared {
32    /// The project.
33    pub root: PathBuf,
34    /// The per-start token.
35    pub token: String,
36    /// The port it listens on.
37    pub port: u16,
38    /// Jobs and events.
39    pub hub: Arc<jobs::Hub>,
40    /// Turns true when the host stops: open event streams end with it.
41    pub closing: tokio::sync::watch::Receiver<bool>,
42    /// A GitHub sign-in waiting for the person to enter its code:
43    /// the host, the app and the pending device code — never sent to the page.
44    pub device: Arc<
45        std::sync::Mutex<
46            Option<(
47                String,
48                String,
49                speccoder_runtime::service::github::auth::Pending,
50            )>,
51        >,
52    >,
53}
54
55/// How long open requests get to finish after ctrl-c.
56const GRACE: std::time::Duration = std::time::Duration::from_secs(2);
57
58/// Runs `f` on a thread of its own: the runtime's calls block and start
59/// runtimes of their own, which the host's workers must not.
60async fn off<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
61    let (tx, rx) = tokio::sync::oneshot::channel();
62    std::thread::spawn(move || {
63        let _ = tx.send(f());
64    });
65    match rx.await {
66        Ok(v) => v,
67        // The work panicked: that request fails (its connection drops), the
68        // host and a running job go on. Never abort the process for one.
69        Err(_) => std::panic::resume_unwind(Box::new("a request's work stopped unexpectedly")),
70    }
71}
72
73/// Refuses what does not come from this host's page or a token holder.
74async fn guard(State(s): State<Shared>, req: Request, next: Next) -> Response {
75    let headers = req.headers();
76    if !security::host_ok(s.port, headers) {
77        return (
78            StatusCode::MISDIRECTED_REQUEST,
79            "this host answers on the loopback address only",
80        )
81            .into_response();
82    }
83    if req.uri().path().starts_with("/api/") {
84        if !security::authorised(&s.token, headers) {
85            return (
86                StatusCode::UNAUTHORIZED,
87                Json(json!({ "error": "unauthorised" })),
88            )
89                .into_response();
90        }
91        if !security::origin_ok(s.port, req.method(), headers) {
92            return (
93                StatusCode::FORBIDDEN,
94                Json(json!({ "error": "cross-origin request refused" })),
95            )
96                .into_response();
97        }
98    }
99    let mut res = next.run(req).await;
100    let h = res.headers_mut();
101    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
102    h.insert(
103        header::REFERRER_POLICY,
104        HeaderValue::from_static("no-referrer"),
105    );
106    h.insert(
107        header::X_CONTENT_TYPE_OPTIONS,
108        HeaderValue::from_static("nosniff"),
109    );
110    h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
111    res
112}
113
114fn content_type(path: &str) -> &'static str {
115    match path.rsplit('.').next() {
116        Some("html") => "text/html; charset=utf-8",
117        Some("js") => "text/javascript; charset=utf-8",
118        Some("css") => "text/css; charset=utf-8",
119        Some("svg") => "image/svg+xml",
120        Some("json") => "application/json",
121        Some("woff2") => "font/woff2",
122        _ => "application/octet-stream",
123    }
124}
125
126/// The interface: the embedded bundle (`SPECCODER_UI` names a `dist/` to
127/// serve instead, for working on the interface).
128async fn asset(uri: axum::http::Uri) -> Response {
129    let want = match uri.path().trim_start_matches('/') {
130        "" => "index.html",
131        p => p,
132    };
133    let csp = [(
134        header::CONTENT_SECURITY_POLICY,
135        security::CONTENT_SECURITY_POLICY,
136    )];
137    if let Some(dir) = std::env::var_os("SPECCODER_UI")
138        && !want.contains("..")
139        && let Ok(bytes) = std::fs::read(Path::new(&dir).join(want))
140    {
141        return (csp, [(header::CONTENT_TYPE, content_type(want))], bytes).into_response();
142    }
143    if bundle::BUNDLE.is_empty() {
144        return (
145            StatusCode::SERVICE_UNAVAILABLE,
146            [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
147            "<!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.",
148        )
149            .into_response();
150    }
151    // Vite emits flat names: a path matches an embedded file or nothing.
152    let name = want.trim_start_matches("assets/");
153    match bundle::BUNDLE
154        .iter()
155        .find(|(p, _)| *p == want || *p == name)
156    {
157        Some((p, bytes)) => {
158            (csp, [(header::CONTENT_TYPE, content_type(p))], *bytes).into_response()
159        }
160        // The interface routes in the page: an unknown path is the page.
161        None => match bundle::BUNDLE.iter().find(|(p, _)| *p == "index.html") {
162            Some((_, bytes)) => (
163                csp,
164                [(header::CONTENT_TYPE, content_type("index.html"))],
165                *bytes,
166            )
167                .into_response(),
168            None => (StatusCode::NOT_FOUND, "no such file").into_response(),
169        },
170    }
171}
172
173#[derive(Debug, Deserialize)]
174struct FeatureQuery {
175    feature: Option<String>,
176    task: Option<String>,
177    commit: Option<String>,
178    /// An attempt ref (`refs/speccoder/attempts/…`): a failed or held
179    /// attempt's code, kept for a person to read.
180    #[serde(rename = "ref")]
181    reference: Option<String>,
182}
183
184/// A commit id, or a ref in speccoder's attempt namespace — nothing else
185/// reaches `git show` (no options, no other refs, no `..`).
186fn revision(q: &FeatureQuery) -> Option<String> {
187    if let Some(c) = q.commit.as_deref() {
188        return (c.len() >= 7 && c.chars().all(|ch| ch.is_ascii_hexdigit())).then(|| c.to_owned());
189    }
190    let r = q.reference.as_deref()?;
191    let rest = r.strip_prefix("refs/speccoder/attempts/")?;
192    let ok = !rest.is_empty()
193        && !r.contains("..")
194        && rest
195            .chars()
196            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '-' | '_' | '.'));
197    ok.then(|| r.to_owned())
198}
199
200/// A project-relative path the file view may show: nothing absolute,
201/// nothing up. Where it leads on disk is checked where it is read — a link
202/// is never followed, the file must be inside the worktree.
203fn file_path_ok(path: &str) -> bool {
204    let p = Path::new(path);
205    !path.is_empty()
206        && !p.is_absolute()
207        && p.components()
208            .all(|c| matches!(c, std::path::Component::Normal(_)))
209}
210
211fn envelope(r: &CommandResult) -> Json<Value> {
212    Json(serde_json::to_value(r).unwrap_or(Value::Null))
213}
214
215async fn project(State(s): State<Shared>) -> Json<Value> {
216    let root = s.root.clone();
217    Json(
218        off(move || {
219            let words = speccoder_runtime::service::format_words(&root);
220            let features = speccoder_runtime::service::current::list(&root, None);
221            let name = root
222                .file_name()
223                .map(|n| n.to_string_lossy().into_owned())
224                .unwrap_or_default();
225            json!({
226                "name": name,
227                "root": root.display().to_string(),
228                "format": words.name,
229                "unit": words.unit,
230                "features": features.data().and_then(|d| match d {
231                    CommandData::Features(f) => serde_json::to_value(f).ok(),
232                    _ => None,
233                }),
234                "version": env!("CARGO_PKG_VERSION"),
235            })
236        })
237        .await,
238    )
239}
240
241async fn report(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
242    let root = s.root.clone();
243    let r = off(move || {
244        speccoder_runtime::service::next::attach(
245            speccoder_runtime::service::status::report(&root, q.feature.as_deref()),
246            q.feature.as_deref(),
247        )
248    })
249    .await;
250    envelope(&r)
251}
252
253async fn review(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
254    let root = s.root.clone();
255    let r =
256        off(move || speccoder_runtime::service::review::report(&root, q.feature.as_deref())).await;
257    envelope(&r)
258}
259
260/// The comment tray and the last revision turn.
261async fn review_comments(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
262    let root = s.root.clone();
263    let r = off(move || {
264        speccoder_runtime::service::review::comments(&root, q.feature.as_deref(), None)
265    })
266    .await;
267    envelope(&r)
268}
269
270#[derive(Debug, Deserialize)]
271struct RunQuery {
272    run: String,
273}
274
275/// The model's account of a run, turn by turn: its reasoning and its words
276/// (a claim).
277async fn account(State(s): State<Shared>, Query(q): Query<RunQuery>) -> Json<Value> {
278    let root = s.root.clone();
279    Json(
280        off(
281            move || match speccoder_runtime::service::evidence::account_of(&root, &q.run) {
282                Ok(turns) => json!({ "turns": turns }),
283                Err(e) => json!({ "error": e }),
284            },
285        )
286        .await,
287    )
288}
289
290async fn inbox(State(s): State<Shared>) -> Json<Value> {
291    let root = s.root.clone();
292    let r = off(move || speccoder_runtime::service::inbox::inbox(&root, None)).await;
293    envelope(&r)
294}
295
296/// A bug by `<unit>/<task>` (`task`): its triad, reproductions and stage.
297async fn bug(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
298    let root = s.root.clone();
299    let Some(task) = q.task else {
300        return Json(json!({ "error": "a bug is needed: task=<unit>/<task>" }));
301    };
302    let r = off(move || speccoder_runtime::service::bug::show(&root, &task)).await;
303    envelope(&r)
304}
305
306async fn actions(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
307    let root = s.root.clone();
308    Json(
309        off(move || {
310            json!(speccoder_runtime::service::actions::available(
311                &root,
312                q.feature.as_deref(),
313                q.task.as_deref()
314            ))
315        })
316        .await,
317    )
318}
319
320async fn approvals(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
321    let root = s.root.clone();
322    let r =
323        off(move || speccoder_runtime::service::approvals::list(&root, q.feature.as_deref())).await;
324    envelope(&r)
325}
326
327async fn evidence(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
328    let root = s.root.clone();
329    Json(
330        off(move || {
331            let report = speccoder_runtime::service::status::report(&root, q.feature.as_deref());
332            let Some(CommandData::Report(r)) = report.data() else {
333                return json!({ "error": report.message() });
334            };
335            let Some(t) = r.tasks.iter().find(|t| Some(&t.id) == q.task.as_ref()) else {
336                return json!({ "error": "no such task" });
337            };
338            let statements = t
339                .commit
340                .as_deref()
341                .map(|c| speccoder_runtime::service::evidence::statements(&root, c))
342                .unwrap_or_default();
343            let (mark, words) = speccoder_runtime::service::evidence::banner(&t.state);
344            json!({
345                "task": t,
346                "banner": { "mark": mark, "words": words },
347                "rows": speccoder_runtime::service::evidence::rows(t, &statements, None),
348            })
349        })
350        .await,
351    )
352}
353
354/// The task's evidence tour (payload `evidence_tour`, the same as
355/// `speccoder-verify tour --json`), read as recorded: the app shows what
356/// the signed statements and the commit say; a maintainer's verifier
357/// checks the signatures.
358async fn tour(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
359    let root = s.root.clone();
360    Json(
361        off(move || {
362            let Some(task) = q.task else {
363                return json!({ "error": "a task is needed" });
364            };
365            let result =
366                speccoder_runtime::service::tour::of_task(&root, q.feature.as_deref(), &task);
367            serde_json::to_value(&result).unwrap_or_else(|e| json!({ "error": e.to_string() }))
368        })
369        .await,
370    )
371}
372
373async fn diff(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Response {
374    let root = s.root.clone();
375    let Some(commit) = revision(&q) else {
376        return (
377            StatusCode::BAD_REQUEST,
378            Json(json!({ "error": "a commit id or an attempt ref is needed" })),
379        )
380            .into_response();
381    };
382    let patch = off(move || speccoder_runtime::service::commit_patch(&root, &commit)).await;
383    Json(json!({ "patch": patch })).into_response()
384}
385
386async fn documents(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
387    let root = s.root.clone();
388    Json(
389        off(move || {
390            json!(speccoder_runtime::service::documents::of_feature(
391                &root,
392                q.feature.as_deref()
393            ))
394        })
395        .await,
396    )
397}
398
399async fn doctor(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
400    let root = s.root.clone();
401    let r = off(move || speccoder_runtime::service::doctor::run(&root, q.feature.as_deref(), None))
402        .await;
403    envelope(&r)
404}
405
406async fn config(State(s): State<Shared>) -> Json<Value> {
407    let root = s.root.clone();
408    let r = off(move || speccoder_runtime::service::settings::show(&root)).await;
409    envelope(&r)
410}
411
412#[derive(Debug, Deserialize)]
413struct ConfigBody {
414    changes: Vec<speccoder_core::ConfigChange>,
415}
416
417/// Saves changes to `speccoder.toml`, all or nothing, after every parser
418/// accepts the result. Safe while a job runs: runs read the committed file,
419/// so a saved change is in force only once committed.
420async fn config_save(State(s): State<Shared>, Json(b): Json<ConfigBody>) -> Json<Value> {
421    let root = s.root.clone();
422    let r = off(move || speccoder_runtime::service::settings::apply(&root, &b.changes)).await;
423    envelope(&r)
424}
425
426#[derive(Debug, Deserialize)]
427struct CoverageQuery {
428    /// `commands.allow_prefix` or `sandbox.network_allow_prefix`.
429    key: String,
430    /// The prefix as a JSON array of strings.
431    prefix: String,
432}
433
434/// What a prefix entry would newly allow, from the project's journal: the
435/// preview a prefix is saved from (`WidenPolicy` through `/api/run`).
436async fn policy_coverage(State(s): State<Shared>, Query(q): Query<CoverageQuery>) -> Json<Value> {
437    let Some(list) = speccoder_core::actions::PolicyList::of_key(&q.key) else {
438        return Json(
439            json!({ "status": "usage_error", "message": format!("`{}` is not a prefix list", q.key) }),
440        );
441    };
442    let Ok(prefix) = serde_json::from_str::<Vec<String>>(&q.prefix) else {
443        return Json(
444            json!({ "status": "usage_error", "message": "the prefix is a JSON list of strings" }),
445        );
446    };
447    let root = s.root.clone();
448    let r = off(move || {
449        let state = speccoder_runtime::service::worktree::state_dir();
450        speccoder_runtime::service::settings::coverage(&root, state.as_deref(), list, &prefix)
451    })
452    .await;
453    envelope(&r)
454}
455
456/// Commits `speccoder.toml` alone — not while a job runs (an in-place run
457/// commits in the same checkout).
458async fn config_commit(State(s): State<Shared>) -> Json<Value> {
459    let (root, hub) = (s.root.clone(), Arc::clone(&s.hub));
460    let r =
461        off(move || hub.exclusive(|| speccoder_runtime::service::settings::commit(&root))).await;
462    match r {
463        Ok(result) => envelope(&result),
464        Err(e) => Json(json!({ "status": "error", "message": e })),
465    }
466}
467
468async fn cost(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
469    let root = s.root.clone();
470    let r =
471        off(move || speccoder_runtime::service::controls::cost(&root, q.feature.as_deref())).await;
472    envelope(&r)
473}
474
475/// What the feature's branch changed: commits, files, the patch.
476async fn changes(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
477    let root = s.root.clone();
478    Json(json!(
479        off(move || speccoder_runtime::service::code::changes(&root, q.feature.as_deref())).await
480    ))
481}
482
483/// The files at the feature's revision.
484async fn tree(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
485    let root = s.root.clone();
486    let ((rev, files), worktree) = off(move || {
487        (
488            speccoder_runtime::service::code::tree(&root, q.feature.as_deref()),
489            speccoder_runtime::service::code::worktree_view(&root, q.feature.as_deref()),
490        )
491    })
492    .await;
493    Json(json!({ "rev": rev, "files": files, "worktree": worktree }))
494}
495
496#[derive(Debug, Deserialize)]
497struct FileQuery {
498    feature: Option<String>,
499    path: String,
500}
501
502/// One file at the feature's revision, when the tree lists it.
503async fn file(State(s): State<Shared>, Query(q): Query<FileQuery>) -> Json<Value> {
504    let root = s.root.clone();
505    // Project paths only: nothing absolute, nothing up, nothing of the
506    // state directory (where drafts, frozen and held-out sets live).
507    if !file_path_ok(&q.path) {
508        return Json(json!({ "error": format!("{} is not a project file", q.path) }));
509    }
510    let markdown = q.path.to_ascii_lowercase().ends_with(".md");
511    // Committed at the revision, else untracked in the worktree.
512    let r = off(move || {
513        speccoder_runtime::service::code::file(&root, q.feature.as_deref(), &q.path).or_else(|e| {
514            speccoder_runtime::service::code::worktree_file(&root, q.feature.as_deref(), &q.path)
515                .map_err(|_| e)
516        })
517    })
518    .await;
519    Json(match r {
520        // Markdown also rendered (raw HTML shown as text), for the preview.
521        Ok(text) if markdown => {
522            json!({ "text": text, "html": speccoder_runtime::service::documents::render(&text) })
523        }
524        Ok(text) => json!({ "text": text }),
525        Err(e) => json!({ "error": e }),
526    })
527}
528
529/// The setup assistant's view: what `doctor` sees, and what `init` would
530/// write.
531async fn setup(State(s): State<Shared>) -> Json<Value> {
532    let root = s.root.clone();
533    let (doctor, preview) = off(move || {
534        (
535            speccoder_runtime::service::doctor::run(&root, None, None),
536            speccoder_runtime::service::init::preview(&root),
537        )
538    })
539    .await;
540    Json(json!({ "doctor": doctor, "preview": preview }))
541}
542
543#[derive(Debug, Deserialize)]
544struct SetupBody {
545    provider: Option<String>,
546    model: Option<String>,
547}
548
549/// Writes `speccoder.toml` and runs its checks on the untouched code; the
550/// commit is the person's next click.
551async fn setup_init(State(s): State<Shared>, Json(b): Json<SetupBody>) -> Json<Value> {
552    if s.hub.job().is_some_and(|j| j.running) {
553        return Json(json!({ "status": "error", "message": "a job is running" }));
554    }
555    let root = s.root.clone();
556    let r = off(move || {
557        speccoder_runtime::service::init::prepare(
558            &root,
559            b.provider.as_deref(),
560            b.model.as_deref(),
561            false,
562            false,
563        )
564    })
565    .await;
566    match r {
567        Err(refused) => envelope(&refused),
568        Ok(p) => {
569            let (to_commit, failed) = (p.to_commit.clone(), p.failed.clone());
570            let Json(mut v) = envelope(&speccoder_runtime::service::init::finish(p));
571            v["to_commit"] = json!(to_commit);
572            v["failed"] = json!(failed);
573            Json(v)
574        }
575    }
576}
577
578#[derive(Debug, Deserialize)]
579struct SetupCommit {
580    paths: Vec<String>,
581}
582
583/// Commits what `init` wrote — only files `init` commits.
584async fn setup_commit(State(s): State<Shared>, Json(b): Json<SetupCommit>) -> Json<Value> {
585    let (root, hub) = (s.root.clone(), Arc::clone(&s.hub));
586    let r = off(move || {
587        hub.exclusive(|| {
588            speccoder_runtime::service::init::commit_written(
589                &speccoder_runtime::service::init::root_of(&root),
590                &b.paths,
591            )
592        })
593    })
594    .await;
595    Json(match r.and_then(|r| r) {
596        Ok(id) => json!({ "status": "ok", "message": format!("committed as {id}") }),
597        Err(e) => json!({ "status": "error", "message": e }),
598    })
599}
600
601/// GitHub as the person's token sees it: whether it is ready, the open
602/// issues (each with the feature that names it), and the feature's pull
603/// request.
604async fn github(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
605    let root = s.root.clone();
606    Json(
607        off(move || {
608            use speccoder_runtime::service::github;
609            let ctx = github::Ctx::from_env();
610            let status = github::status(&ctx, &root);
611            if !status.ready {
612                return json!({ "status": status });
613            }
614            let issues = github::issues(&ctx, &root, &github::started(&root));
615            let branch = match speccoder_runtime::service::status::report(
616                &root,
617                q.feature.as_deref(),
618            )
619            .data()
620            {
621                Some(speccoder_core::CommandData::Report(r)) => r.branch.clone(),
622                _ => None,
623            };
624            let pull = branch.as_deref().map(|b| github::pr_for(&ctx, &root, b));
625            json!({
626                "status": status,
627                "issues": issues.as_ref().ok(),
628                "issues_error": issues.as_ref().err(),
629                "branch": branch,
630                "pull": pull.as_ref().and_then(|p| p.as_ref().ok()).cloned().flatten(),
631                "pull_error": pull.as_ref().and_then(|p| p.as_ref().err()),
632            })
633        })
634        .await,
635    )
636}
637
638#[derive(Debug, Deserialize)]
639struct IssueQuery {
640    number: String,
641}
642
643/// Starts GitHub's device sign-in for the repository's host: the code for
644/// the person to enter, and where.
645async fn github_device(State(s): State<Shared>) -> Json<Value> {
646    let root = s.root.clone();
647    let slot = Arc::clone(&s.device);
648    Json(
649        off(move || {
650            use speccoder_runtime::service::github;
651            let host = github::repo(&root, None).map_or_else(|_| "github.com".to_owned(), |r| r.host);
652            let Some(client) = github::auth::client_id(&host) else {
653                return json!({ "error": format!("{host} has no speccoder app: sign in with a token instead") });
654            };
655            match github::auth::start(&github::http::oauth(&host), &client) {
656                Ok(p) => {
657                    let shown = json!({ "user_code": p.user_code, "verification_uri": p.verification_uri, "host": host });
658                    if let Ok(mut d) = slot.lock() {
659                        *d = Some((host, client, p));
660                    }
661                    shown
662                }
663                Err(e) => json!({ "error": e }),
664            }
665        })
666        .await,
667    )
668}
669
670/// Waits for the person to enter the code, then keeps the token in the
671/// keychain; says whether the app can reach this repository.
672async fn github_device_wait(State(s): State<Shared>) -> Json<Value> {
673    let root = s.root.clone();
674    let slot = Arc::clone(&s.device);
675    let r = off(move || {
676        use speccoder_runtime::service::github;
677        let Some((host, client, pending)) = slot.lock().ok().and_then(|mut d| d.take()) else {
678            return speccoder_core::CommandResult::new(
679                "auth",
680                speccoder_core::CommandStatus::UsageError,
681                "no sign-in is waiting: start it again",
682            );
683        };
684        let ctx = github::Ctx::from_env();
685        match github::auth::wait(&github::http::oauth(&host), &client, &pending) {
686            Ok(token) => {
687                let signed = github::login_with_token(&ctx, &host, token.expose());
688                match (signed.status(), github::reach(&ctx, &root)) {
689                    (speccoder_core::CommandStatus::Ok, Some(todo)) => {
690                        let message = format!("{}; {todo}", signed.message());
691                        signed.reworded(speccoder_core::CommandStatus::Ok, &message)
692                    }
693                    _ => signed,
694                }
695            }
696            Err(e) => github::refused("auth", &e),
697        }
698    })
699    .await;
700    envelope(&r)
701}
702
703#[derive(Debug, Deserialize)]
704struct TokenBody {
705    token: String,
706}
707
708/// Signs in to the repository's GitHub host with a token the person pasted
709/// (kept in the credential store, never echoed or logged).
710async fn github_login(State(s): State<Shared>, Json(b): Json<TokenBody>) -> Json<Value> {
711    let root = s.root.clone();
712    let r = off(move || {
713        let host = speccoder_runtime::service::github::repo(&root, None)
714            .map_or_else(|_| "github.com".to_owned(), |r| r.host);
715        speccoder_runtime::service::github::login_with_token(
716            &speccoder_runtime::service::github::Ctx::from_env(),
717            &host,
718            &b.token,
719        )
720    })
721    .await;
722    envelope(&r)
723}
724
725/// One issue, the description a feature started from it gets, and the
726/// skill that starts it.
727async fn github_issue(State(s): State<Shared>, Query(q): Query<IssueQuery>) -> Json<Value> {
728    let root = s.root.clone();
729    Json(
730        off(move || {
731            match speccoder_runtime::service::github::issue(
732                &speccoder_runtime::service::github::Ctx::from_env(),
733                &root,
734                &q.number,
735            ) {
736                Ok(issue) => {
737                    json!({
738                        "description": speccoder_runtime::service::github::description(&issue),
739                        "skill": speccoder_runtime::service::create_skill(&root),
740                        "issue": issue,
741                    })
742                }
743                Err(e) => json!({ "error": e }),
744            }
745        })
746        .await,
747    )
748}
749
750/// What `pr` would do — pushed where, the title, the body — without doing it.
751async fn pr_preview(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
752    let root = s.root.clone();
753    let r = off(move || {
754        speccoder_runtime::service::handoff::pr(
755            &root,
756            q.feature.as_deref(),
757            &speccoder_runtime::service::handoff::PrOptions {
758                remote: "origin",
759                base: None,
760                open: false,
761                force: false,
762                github: &speccoder_runtime::service::github::Ctx::from_env(),
763            },
764        )
765    })
766    .await;
767    envelope(&r)
768}
769
770/// The project's current (or last) run, whoever started it: this host's
771/// job, or `speccoder run` in a terminal. `running`: its process lives and
772/// it has not finished; `own`: it is this host's.
773async fn live(State(s): State<Shared>) -> Json<Value> {
774    let root = s.root.clone();
775    Json(
776        off(move || {
777            let Some(state) = speccoder_runtime::service::worktree::state_dir() else {
778                return json!({ "live": null });
779            };
780            match speccoder_runtime::service::progress::read_live(&root, &state) {
781                Some((live, running)) => json!({
782                    "live": live,
783                    "running": running,
784                    "own": live.pid == std::process::id(),
785                    "interrupted": live.finished.is_none() && !running,
786                    "now": std::time::SystemTime::now()
787                        .duration_since(std::time::UNIX_EPOCH)
788                        .map_or(0, |d| d.as_secs()),
789                }),
790                None => json!({ "live": null }),
791            }
792        })
793        .await,
794    )
795}
796
797async fn job(State(s): State<Shared>) -> Json<Value> {
798    Json(json!({ "job": s.hub.job(), "waiting": s.hub.waiting() }))
799}
800
801#[derive(Debug, Deserialize)]
802struct Start {
803    run: Runnable,
804    input: Option<String>,
805    feature: Option<String>,
806    command: String,
807}
808
809async fn start(State(s): State<Shared>, Json(b): Json<Start>) -> Response {
810    match s.hub.start(b.run, b.input, b.feature, b.command) {
811        Ok(id) => Json(json!({ "id": id })).into_response(),
812        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
813    }
814}
815
816#[derive(Debug, Deserialize)]
817struct AnswerBody {
818    id: u64,
819    answer: Option<speccoder_runtime::service::approvals::Answer>,
820    text: Option<String>,
821}
822
823async fn answer(State(s): State<Shared>, Json(b): Json<AnswerBody>) -> Response {
824    let reply = match b.answer {
825        Some(a) => jobs::Reply::Answer(a),
826        None => jobs::Reply::Text(b.text.filter(|t| !t.trim().is_empty())),
827    };
828    match s.hub.answer(b.id, reply) {
829        Ok(()) => Json(json!({ "ok": true })).into_response(),
830        Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
831    }
832}
833
834async fn stop(State(s): State<Shared>) -> Json<Value> {
835    s.hub.stop();
836    Json(json!({ "ok": true }))
837}
838
839/// Server-sent events: what the job does, what it asks. Read with `fetch`
840/// (the token is a header, never in the address).
841async fn events(State(s): State<Shared>) -> Response {
842    use futures_util::StreamExt;
843    let rx = s.hub.subscribe();
844    let hello = futures_util::stream::once(async {
845        Ok::<_, std::convert::Infallible>(
846            axum::response::sse::Event::default().data(json!({ "type": "hello" }).to_string()),
847        )
848    });
849    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|m| async move {
850        m.ok().map(|text| {
851            Ok::<_, std::convert::Infallible>(axum::response::sse::Event::default().data(text))
852        })
853    });
854    // An event stream never ends on its own: it ends when the host stops,
855    // or a graceful shutdown would wait for it forever.
856    let mut closing = s.closing.clone();
857    let stream = stream.take_until(async move {
858        let _ = closing.wait_for(|c| *c).await;
859    });
860    axum::response::sse::Sse::new(hello.chain(stream))
861        .keep_alive(axum::response::sse::KeepAlive::default())
862        .into_response()
863}
864
865/// The routes.
866pub fn router(s: Shared) -> Router {
867    Router::new()
868        .route("/api/project", get(project))
869        .route("/api/report", get(report))
870        .route("/api/actions", get(actions))
871        .route("/api/inbox", get(inbox))
872        .route("/api/bug", get(bug))
873        .route("/api/account", get(account))
874        .route("/api/review", get(review))
875        .route("/api/review/comments", get(review_comments))
876        .route("/api/approvals", get(approvals))
877        .route("/api/evidence", get(evidence))
878        .route("/api/tour", get(tour))
879        .route("/api/diff", get(diff))
880        .route("/api/documents", get(documents))
881        .route("/api/doctor", get(doctor))
882        .route("/api/job", get(job))
883        .route("/api/live", get(live))
884        .route("/api/config", get(config).post(config_save))
885        .route("/api/config/commit", post(config_commit))
886        .route("/api/policy/coverage", get(policy_coverage))
887        .route("/api/cost", get(cost))
888        .route("/api/changes", get(changes))
889        .route("/api/tree", get(tree))
890        .route("/api/file", get(file))
891        .route("/api/setup", get(setup).post(setup_init))
892        .route("/api/github", get(github))
893        .route("/api/github/issue", get(github_issue))
894        .route("/api/github/login", post(github_login))
895        .route("/api/github/device", post(github_device))
896        .route("/api/github/device/wait", post(github_device_wait))
897        .route("/api/pr/preview", get(pr_preview))
898        .route("/api/setup/commit", post(setup_commit))
899        .route("/api/events", get(events))
900        .route("/api/run", post(start))
901        .route("/api/answer", post(answer))
902        .route("/api/stop", post(stop))
903        .fallback(get(asset))
904        .layer(axum::middleware::from_fn_with_state(s.clone(), guard))
905        .with_state(s)
906}
907
908/// The host's events as a shell in the same process reads them.
909pub type Events = tokio::sync::broadcast::Receiver<String>;
910/// Why reading [`Events`] failed: fell behind (`Lagged`) or the host ended.
911pub use tokio::sync::broadcast::error::RecvError;
912
913/// A started host.
914#[derive(Debug)]
915pub struct Started {
916    /// The address to open: the page, with the token in its fragment.
917    pub url: String,
918    /// The port.
919    pub port: u16,
920    /// What the host's jobs do, as the page's events (JSON lines): for a
921    /// shell in the same process (the window) to follow without a bridge.
922    pub events: Option<Events>,
923}
924
925/// Serves the project at `root` on `port` (0: any free one) until the
926/// process ends; `ready` is told the address once it listens.
927pub fn serve(root: &Path, port: u16, ready: impl FnOnce(Started) + Send) -> Result<(), String> {
928    let rt = tokio::runtime::Builder::new_multi_thread()
929        .worker_threads(2)
930        .enable_all()
931        .build()
932        .map_err(|e| e.to_string())?;
933    rt.block_on(async move {
934        let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port)))
935            .await
936            .map_err(|e| format!("127.0.0.1:{port}: {e}"))?;
937        let port = listener.local_addr().map_err(|e| e.to_string())?.port();
938        let (close, closed) = tokio::sync::watch::channel(false);
939        let token = security::token();
940        if token.is_empty() {
941            return Err("no random source for the token".to_owned());
942        }
943        let shared = Shared {
944            root: root.to_path_buf(),
945            token: token.clone(),
946            port,
947            hub: jobs::Hub::new(root),
948            closing: closed,
949            device: Arc::default(),
950        };
951        ready(Started {
952            url: format!("http://127.0.0.1:{port}/#token={token}"),
953            port,
954            events: Some(shared.hub.subscribe()),
955        });
956        // ctrl-c: end the event streams, give open requests a moment, then
957        // stop whatever still holds on.
958        let (stop, stopping) = tokio::sync::oneshot::channel::<()>();
959        let server = axum::serve(listener, router(shared)).with_graceful_shutdown(async move {
960            let _ = stopping.await;
961        });
962        let signal = async move {
963            let _ = tokio::signal::ctrl_c().await;
964            let _ = close.send(true);
965            let _ = stop.send(());
966            tokio::time::sleep(GRACE).await;
967        };
968        tokio::select! {
969            served = server => served.map_err(|e| e.to_string()),
970            () = signal => Ok(()),
971        }
972    })
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978
979    fn q(commit: Option<&str>, reference: Option<&str>) -> FeatureQuery {
980        FeatureQuery {
981            feature: None,
982            task: None,
983            commit: commit.map(str::to_owned),
984            reference: reference.map(str::to_owned),
985        }
986    }
987
988    #[test]
989    fn only_commits_and_attempt_refs_reach_git_show() {
990        assert!(revision(&q(Some("e23d4197bb"), None)).is_some());
991        assert!(revision(&q(Some("e23d"), None)).is_none());
992        assert!(revision(&q(Some("--output=/tmp/x"), None)).is_none());
993        let attempt = "refs/speccoder/attempts/001-greet/T001/run_01M5S5VJ";
994        assert_eq!(revision(&q(None, Some(attempt))).as_deref(), Some(attempt));
995        for bad in [
996            "refs/heads/main",
997            "refs/speccoder/attempts/",
998            "refs/speccoder/attempts/../../heads/main",
999            "refs/speccoder/attempts/a b",
1000            "--all",
1001            "HEAD",
1002        ] {
1003            assert!(revision(&q(None, Some(bad))).is_none(), "{bad}");
1004        }
1005    }
1006}