Skip to main content

speccoder_web/
lib.rs

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