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:
45 Arc<std::sync::Mutex<Option<(String, String, speccoder_runtime::github::auth::Pending)>>>,
46}
47
48const GRACE: std::time::Duration = std::time::Duration::from_secs(2);
50
51async 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 Err(_) => std::panic::resume_unwind(Box::new("a request's work stopped unexpectedly")),
63 }
64}
65
66async 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
119async 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 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 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 #[serde(rename = "ref")]
174 reference: Option<String>,
175}
176
177fn 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
246async 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 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
379async 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
388async 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
405async 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
413async 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
427async 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 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
442async 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
462async 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
496async 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
514async 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
552async 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
579async 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
617async 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
634async 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
654async 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
716async 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 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
742pub 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
780pub type Events = tokio::sync::broadcast::Receiver<String>;
782pub use tokio::sync::broadcast::error::RecvError;
784
785#[derive(Debug)]
787pub struct Started {
788 pub url: String,
790 pub port: u16,
792 pub events: Option<Events>,
795}
796
797pub 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 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}