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