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