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