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