1pub mod fixes;
8pub mod jobs;
9pub mod security;
10mod welcome;
11
12use std::net::SocketAddr;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use axum::extract::{Query, Request, State};
17use axum::http::{HeaderValue, StatusCode, header};
18use axum::middleware::Next;
19use axum::response::{IntoResponse, Response};
20use axum::routing::{get, post};
21use axum::{Json, Router};
22use serde::Deserialize;
23use serde_json::{Value, json};
24use speccoder_core::actions::Runnable;
25use speccoder_core::{CommandData, CommandResult};
26
27mod bundle {
28 include!(concat!(env!("OUT_DIR"), "/bundle.rs"));
29}
30
31#[derive(Debug, Clone)]
35pub struct Root(Arc<RootCell>);
36
37#[derive(Debug)]
38struct RootCell {
39 start: PathBuf,
40 bound: std::sync::OnceLock<PathBuf>,
41}
42
43impl Root {
44 #[must_use]
46 pub fn new(start: &Path, bound: bool) -> Self {
47 let cell = RootCell {
48 start: start.to_path_buf(),
49 bound: std::sync::OnceLock::new(),
50 };
51 if bound {
52 let _ = cell.bound.set(start.to_path_buf());
53 }
54 Self(Arc::new(cell))
55 }
56
57 #[must_use]
59 pub fn get(&self) -> PathBuf {
60 self.0
61 .bound
62 .get()
63 .cloned()
64 .unwrap_or_else(|| self.0.start.clone())
65 }
66
67 #[must_use]
69 pub fn bound(&self) -> Option<PathBuf> {
70 self.0.bound.get().cloned()
71 }
72
73 #[must_use]
75 pub fn start(&self) -> PathBuf {
76 self.0.start.clone()
77 }
78
79 pub fn bind(&self, project: &Path) -> Result<(), String> {
85 self.0.bound.set(project.to_path_buf()).map_err(|_| {
86 format!(
87 "this host serves {} already: open another project with `speccoder ui -C {}`",
88 self.get().display(),
89 project.display()
90 )
91 })
92 }
93}
94
95#[derive(Debug, Clone)]
97pub struct Shared {
98 pub root: Root,
100 pub token: String,
102 pub port: u16,
104 pub hub: Arc<jobs::Hub>,
106 pub window: bool,
109 pub closing: tokio::sync::watch::Receiver<bool>,
111 pub device: Arc<
114 std::sync::Mutex<
115 Option<(
116 String,
117 String,
118 speccoder_runtime::service::github::auth::Pending,
119 )>,
120 >,
121 >,
122}
123
124const GRACE: std::time::Duration = std::time::Duration::from_secs(2);
126
127async fn off<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
130 let (tx, rx) = tokio::sync::oneshot::channel();
131 std::thread::spawn(move || {
132 let _ = tx.send(f());
133 });
134 match rx.await {
135 Ok(v) => v,
136 Err(_) => std::panic::resume_unwind(Box::new("a request's work stopped unexpectedly")),
139 }
140}
141
142async fn guard(State(s): State<Shared>, req: Request, next: Next) -> Response {
144 let headers = req.headers();
145 if !security::host_ok(s.port, headers) {
146 return (
147 StatusCode::MISDIRECTED_REQUEST,
148 "this host answers on the loopback address only",
149 )
150 .into_response();
151 }
152 if req.uri().path().starts_with("/api/") {
153 if !security::authorised(&s.token, headers) {
154 return (
155 StatusCode::UNAUTHORIZED,
156 Json(json!({ "error": "unauthorised" })),
157 )
158 .into_response();
159 }
160 if !security::origin_ok(s.port, req.method(), headers) {
161 return (
162 StatusCode::FORBIDDEN,
163 Json(json!({ "error": "cross-origin request refused" })),
164 )
165 .into_response();
166 }
167 }
168 let mut res = next.run(req).await;
169 let h = res.headers_mut();
170 h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
171 h.insert(
172 header::REFERRER_POLICY,
173 HeaderValue::from_static("no-referrer"),
174 );
175 h.insert(
176 header::X_CONTENT_TYPE_OPTIONS,
177 HeaderValue::from_static("nosniff"),
178 );
179 h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
180 res
181}
182
183fn content_type(path: &str) -> &'static str {
184 match path.rsplit('.').next() {
185 Some("html") => "text/html; charset=utf-8",
186 Some("js") => "text/javascript; charset=utf-8",
187 Some("css") => "text/css; charset=utf-8",
188 Some("svg") => "image/svg+xml",
189 Some("json") => "application/json",
190 Some("woff2") => "font/woff2",
191 _ => "application/octet-stream",
192 }
193}
194
195async fn asset(uri: axum::http::Uri) -> Response {
198 let want = match uri.path().trim_start_matches('/') {
199 "" => "index.html",
200 p => p,
201 };
202 let csp = [(
203 header::CONTENT_SECURITY_POLICY,
204 security::CONTENT_SECURITY_POLICY,
205 )];
206 if let Some(dir) = std::env::var_os("SPECCODER_UI")
207 && !want.contains("..")
208 && let Ok(bytes) = std::fs::read(Path::new(&dir).join(want))
209 {
210 return (csp, [(header::CONTENT_TYPE, content_type(want))], bytes).into_response();
211 }
212 if bundle::BUNDLE.is_empty() {
213 return (
214 StatusCode::SERVICE_UNAVAILABLE,
215 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
216 "<!doctype html><meta charset=utf-8><title>speccoder</title><p style=\"font:16px system-ui;max-width:60ch;margin:4rem auto\">This binary was built without its interface (<code>npm run build</code> in <code>crates/speccoder-web/ui</code>). The API and the command line work.",
217 )
218 .into_response();
219 }
220 let name = want.trim_start_matches("assets/");
222 match bundle::BUNDLE
223 .iter()
224 .find(|(p, _)| *p == want || *p == name)
225 {
226 Some((p, bytes)) => {
227 (csp, [(header::CONTENT_TYPE, content_type(p))], *bytes).into_response()
228 }
229 None => match bundle::BUNDLE.iter().find(|(p, _)| *p == "index.html") {
231 Some((_, bytes)) => (
232 csp,
233 [(header::CONTENT_TYPE, content_type("index.html"))],
234 *bytes,
235 )
236 .into_response(),
237 None => (StatusCode::NOT_FOUND, "no such file").into_response(),
238 },
239 }
240}
241
242#[derive(Debug, Deserialize)]
243struct FeatureQuery {
244 feature: Option<String>,
245 task: Option<String>,
246 commit: Option<String>,
247 #[serde(rename = "ref")]
250 reference: Option<String>,
251}
252
253fn revision(q: &FeatureQuery) -> Option<String> {
256 if let Some(c) = q.commit.as_deref() {
257 return (c.len() >= 7 && c.chars().all(|ch| ch.is_ascii_hexdigit())).then(|| c.to_owned());
258 }
259 let r = q.reference.as_deref()?;
260 let rest = r.strip_prefix("refs/speccoder/attempts/")?;
261 let ok = !rest.is_empty()
262 && !r.contains("..")
263 && rest
264 .chars()
265 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '-' | '_' | '.'));
266 ok.then(|| r.to_owned())
267}
268
269fn file_path_ok(path: &str) -> bool {
273 let p = Path::new(path);
274 !path.is_empty()
275 && !p.is_absolute()
276 && p.components()
277 .all(|c| matches!(c, std::path::Component::Normal(_)))
278}
279
280fn envelope(s: &Shared, r: &CommandResult) -> Json<Value> {
283 Json(s.hub.issuer.signing(s.root.get(), || {
284 serde_json::to_value(r).unwrap_or(Value::Null)
285 }))
286}
287
288async fn project(State(s): State<Shared>) -> Json<Value> {
289 let root = s.root.get();
290 let window = s.window;
291 Json(
292 off(move || {
293 let words = speccoder_runtime::service::format_words(&root);
294 let features = speccoder_runtime::service::current::list(&root, None);
295 let name = root
296 .file_name()
297 .map(|n| n.to_string_lossy().into_owned())
298 .unwrap_or_default();
299 json!({
300 "name": name,
301 "root": root.display().to_string(),
302 "format": words.name,
303 "unit": words.unit,
304 "features": features.data().and_then(|d| match d {
305 CommandData::Features(f) => serde_json::to_value(f).ok(),
306 _ => None,
307 }),
308 "version": env!("CARGO_PKG_VERSION"),
309 "os": format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
310 "window": window,
311 "sandbox": speccoder_runtime::service::person::sandboxed(),
312 "planning": speccoder_runtime::service::planning_words(&root),
313 "sample": welcome::sample_of(&root),
314 })
315 })
316 .await,
317 )
318}
319
320async fn report(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
321 let root = s.root.get();
322 let r = off(move || {
323 speccoder_runtime::service::next::attach(
324 speccoder_runtime::service::status::report(&root, q.feature.as_deref()),
325 q.feature.as_deref(),
326 )
327 })
328 .await;
329 envelope(&s, &r)
330}
331
332async fn review(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
333 let root = s.root.get();
334 let r =
335 off(move || speccoder_runtime::service::review::report(&root, q.feature.as_deref())).await;
336 envelope(&s, &r)
337}
338
339async fn review_comments(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
341 let root = s.root.get();
342 let r = off(move || {
343 speccoder_runtime::service::review::comments(&root, q.feature.as_deref(), None)
344 })
345 .await;
346 envelope(&s, &r)
347}
348
349#[derive(Debug, Deserialize)]
350struct RunQuery {
351 run: String,
352}
353
354async fn account(State(s): State<Shared>, Query(q): Query<RunQuery>) -> Json<Value> {
357 let root = s.root.get();
358 Json(
359 off(
360 move || match speccoder_runtime::service::evidence::account_of(&root, &q.run) {
361 Ok(turns) => json!({ "turns": turns }),
362 Err(e) => json!({ "error": e }),
363 },
364 )
365 .await,
366 )
367}
368
369async fn inbox(State(s): State<Shared>) -> Json<Value> {
370 let root = s.root.get();
371 let issuer = Arc::clone(&s.hub.issuer);
372 Json(
374 off(move || {
375 issuer.signing(root.clone(), || {
376 serde_json::to_value(speccoder_runtime::service::inbox::inbox(&root, None))
377 .unwrap_or(Value::Null)
378 })
379 })
380 .await,
381 )
382}
383
384async fn bug(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
386 let root = s.root.get();
387 let Some(task) = q.task else {
388 return Json(json!({ "error": "a bug is needed: task=<unit>/<task>" }));
389 };
390 let r = off(move || speccoder_runtime::service::bug::show(&root, &task)).await;
391 envelope(&s, &r)
392}
393
394async fn actions(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
395 let root = s.root.get();
396 let issuer = Arc::clone(&s.hub.issuer);
397 Json(
398 off(move || {
399 issuer.signing(root.clone(), || {
401 json!(speccoder_runtime::service::actions::available(
402 &root,
403 q.feature.as_deref(),
404 q.task.as_deref()
405 ))
406 })
407 })
408 .await,
409 )
410}
411
412async fn approvals(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
413 let root = s.root.get();
414 let r =
415 off(move || speccoder_runtime::service::approvals::list(&root, q.feature.as_deref())).await;
416 envelope(&s, &r)
417}
418
419async fn evidence(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
420 let root = s.root.get();
421 Json(
422 off(move || {
423 let report = speccoder_runtime::service::status::report(&root, q.feature.as_deref());
424 let Some(CommandData::Report(r)) = report.data() else {
425 return json!({ "error": report.message() });
426 };
427 let Some(t) = r.tasks.iter().find(|t| Some(&t.id) == q.task.as_ref()) else {
428 return json!({ "error": "no such task" });
429 };
430 let statements = t
431 .commit
432 .as_deref()
433 .map(|c| speccoder_runtime::service::evidence::statements(&root, c))
434 .unwrap_or_default();
435 let (mark, words) = speccoder_runtime::service::evidence::banner(&t.state);
436 json!({
437 "task": t,
438 "banner": { "mark": mark, "words": words },
439 "rows": speccoder_runtime::service::evidence::rows(t, &statements, None),
440 })
441 })
442 .await,
443 )
444}
445
446async fn tour(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
451 let root = s.root.get();
452 Json(
453 off(move || {
454 let Some(task) = q.task else {
455 return json!({ "error": "a task is needed" });
456 };
457 let result =
458 speccoder_runtime::service::tour::of_task(&root, q.feature.as_deref(), &task);
459 serde_json::to_value(&result).unwrap_or_else(|e| json!({ "error": e.to_string() }))
460 })
461 .await,
462 )
463}
464
465async fn diff(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Response {
466 let root = s.root.get();
467 let Some(commit) = revision(&q) else {
468 return (
469 StatusCode::BAD_REQUEST,
470 Json(json!({ "error": "a commit id or an attempt ref is needed" })),
471 )
472 .into_response();
473 };
474 let patch = off(move || speccoder_runtime::service::commit_patch(&root, &commit)).await;
475 Json(json!({ "patch": patch })).into_response()
476}
477
478async fn documents(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
479 let root = s.root.get();
480 Json(
481 off(move || {
482 json!(speccoder_runtime::service::documents::of_feature(
483 &root,
484 q.feature.as_deref()
485 ))
486 })
487 .await,
488 )
489}
490
491async fn extensions(State(s): State<Shared>) -> Json<Value> {
493 let root = s.root.get();
494 let r = off(move || speccoder_runtime::service::extensions::list(&root)).await;
495 envelope(&s, &r)
496}
497
498async fn extension_views(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
500 let root = s.root.get();
501 let r = off(move || speccoder_runtime::service::extensions::views(&root, q.feature.as_deref()))
502 .await;
503 envelope(&s, &r)
504}
505
506async fn hooks(State(s): State<Shared>) -> Json<Value> {
507 let root = s.root.get();
508 let r = off(move || speccoder_runtime::service::hooks::list(&root)).await;
509 envelope(&s, &r)
510}
511
512async fn doctor(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
513 let root = s.root.get();
514 let r = off(move || speccoder_runtime::service::doctor::run(&root, q.feature.as_deref(), None))
515 .await;
516 envelope(&s, &r)
517}
518
519#[derive(Debug, Deserialize)]
520struct AgentsQuery {
521 #[serde(default = "yes")]
524 probe: bool,
525}
526
527fn yes() -> bool {
528 true
529}
530
531async fn agents(State(s): State<Shared>, Query(q): Query<AgentsQuery>) -> Json<Value> {
533 let root = s.root.get();
534 let r = off(move || speccoder_runtime::service::doctor::agents(&root, None, q.probe)).await;
535 envelope(&s, &r)
536}
537
538async fn config(State(s): State<Shared>) -> Json<Value> {
539 let root = s.root.get();
540 let r = off(move || speccoder_runtime::service::settings::show(&root)).await;
541 envelope(&s, &r)
542}
543
544#[derive(Debug, Deserialize)]
545struct ConfigBody {
546 changes: Vec<speccoder_core::ConfigChange>,
547}
548
549async fn config_save(State(s): State<Shared>, Json(b): Json<ConfigBody>) -> Json<Value> {
553 let root = s.root.get();
554 let r = off(move || speccoder_runtime::service::settings::apply(&root, &b.changes)).await;
555 envelope(&s, &r)
556}
557
558async fn ask(
561 State(s): State<Shared>,
562 Json(b): Json<speccoder_runtime::service::ask::Request>,
563) -> Json<Value> {
564 let root = s.root.get();
565 let sink = s.hub.progress();
566 let r = off(move || speccoder_runtime::service::ask::turn(&root, &b, None, Some(sink))).await;
567 envelope(&s, &r)
568}
569
570#[derive(Debug, Deserialize)]
571struct StopBody {
572 conversation: Option<String>,
573}
574
575async fn ask_stop(Json(b): Json<StopBody>) -> Json<Value> {
578 Json(json!({ "stopped": speccoder_runtime::service::ask::stop(b.conversation.as_deref()) }))
579}
580
581#[derive(Debug, Deserialize)]
582struct AskQuery {
583 id: Option<String>,
584 feature: Option<String>,
585}
586
587async fn explore_list(State(s): State<Shared>) -> Json<Value> {
589 let root = s.root.get();
590 let r = off(move || speccoder_runtime::service::explore::list(&root, None)).await;
591 envelope(&s, &r)
592}
593
594async fn explore_session(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
596 let root = s.root.get();
597 let r = off(move || {
598 speccoder_runtime::service::explore::session(
599 &root,
600 None,
601 q.id.as_deref().unwrap_or_default(),
602 )
603 })
604 .await;
605 envelope(&s, &r)
606}
607
608async fn ask_conversations(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
609 let root = s.root.get();
610 let r = off(move || {
611 speccoder_runtime::service::ask::conversations(&root, None, q.feature.as_deref())
612 })
613 .await;
614 envelope(&s, &r)
615}
616
617async fn ask_conversation(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
618 let root = s.root.get();
619 let r = off(move || {
620 speccoder_runtime::service::ask::thread(&root, None, q.id.as_deref().unwrap_or_default())
621 })
622 .await;
623 envelope(&s, &r)
624}
625
626#[derive(Debug, Deserialize)]
627struct ChangedQuery {
628 path: String,
629 start: Option<u32>,
630 end: Option<u32>,
631 sha: String,
632}
633
634async fn ask_changed(State(s): State<Shared>, Query(q): Query<ChangedQuery>) -> Json<Value> {
637 let root = s.root.get();
638 Json(json!({
639 "changed": off(move || {
640 speccoder_runtime::service::ask::changed_since(&root, &q.path, q.start, q.end, &q.sha)
641 })
642 .await
643 }))
644}
645
646async fn ask_chip(
648 State(s): State<Shared>,
649 Json(b): Json<speccoder_runtime::service::ask::ChipRef>,
650) -> Json<Value> {
651 let root = s.root.get();
652 Json(
653 off(
654 move || match speccoder_runtime::service::ask::chip(&root, "c0", &b) {
655 Ok((chip, text)) => {
656 json!({ "chip": chip, "preview": text.chars().take(400).collect::<String>() })
657 }
658 Err(e) => json!({ "error": e }),
659 },
660 )
661 .await,
662 )
663}
664
665#[derive(Debug, Deserialize)]
666struct ProposalBody {
667 proposal: speccoder_core::ask::Proposal,
668 feature: Option<String>,
669}
670
671async fn ask_proposal(State(s): State<Shared>, Json(b): Json<ProposalBody>) -> Json<Value> {
673 let root = s.root.get();
674 Json(
675 off(move || json!({ "proposal": speccoder_runtime::service::ask::proposal(&root, b.feature.as_deref(), &b.proposal) }))
676 .await,
677 )
678}
679
680#[derive(Debug, Deserialize)]
681struct ShapeBody {
682 field: String,
683 text: String,
684}
685
686async fn shape(State(s): State<Shared>, Json(b): Json<ShapeBody>) -> Json<Value> {
689 let root = s.root.get();
690 let r =
691 off(move || speccoder_runtime::service::shape::shape(&root, &b.field, &b.text, None)).await;
692 envelope(&s, &r)
693}
694
695async fn shape_check(Json(b): Json<ShapeBody>) -> Json<Value> {
697 Json(off(move || json!({ "findings": speccoder_runtime::service::shape::findings(&b.field, &b.text) })).await)
698}
699
700#[derive(Debug, Deserialize)]
701struct JudgeBody {
702 field: String,
703 original: String,
704 proposal: String,
705 #[serde(default)]
706 assumptions: Vec<speccoder_core::shape::Assumption>,
707}
708
709async fn shape_judge(Json(b): Json<JudgeBody>) -> Json<Value> {
711 Json(
712 off(move || {
713 speccoder_runtime::service::shape::judge(
714 &b.field,
715 &b.original,
716 &b.proposal,
717 &b.assumptions,
718 )
719 })
720 .await,
721 )
722}
723
724#[derive(Debug, Deserialize)]
725struct CoverageQuery {
726 key: String,
728 prefix: String,
730}
731
732async fn policy_coverage(State(s): State<Shared>, Query(q): Query<CoverageQuery>) -> Json<Value> {
735 let Some(list) = speccoder_core::actions::PolicyList::of_key(&q.key) else {
736 return Json(
737 json!({ "status": "usage_error", "message": format!("`{}` is not a prefix list", q.key) }),
738 );
739 };
740 let Ok(prefix) = serde_json::from_str::<Vec<String>>(&q.prefix) else {
741 return Json(
742 json!({ "status": "usage_error", "message": "the prefix is a JSON list of strings" }),
743 );
744 };
745 let root = s.root.get();
746 let r = off(move || {
747 let state = speccoder_runtime::service::worktree::state_dir();
748 speccoder_runtime::service::settings::coverage(&root, state.as_deref(), list, &prefix)
749 })
750 .await;
751 envelope(&s, &r)
752}
753
754async fn config_commit(State(s): State<Shared>) -> Json<Value> {
757 let (root, hub) = (s.root.get(), Arc::clone(&s.hub));
758 let r =
759 off(move || hub.exclusive(|| speccoder_runtime::service::settings::commit(&root))).await;
760 match r {
761 Ok(result) => envelope(&s, &result),
762 Err(e) => Json(json!({ "status": "error", "message": e })),
763 }
764}
765
766async fn cost(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
767 let root = s.root.get();
768 let r =
769 off(move || speccoder_runtime::service::controls::cost(&root, q.feature.as_deref())).await;
770 envelope(&s, &r)
771}
772
773async fn changes(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
775 let root = s.root.get();
776 Json(json!(
777 off(move || speccoder_runtime::service::code::changes(&root, q.feature.as_deref())).await
778 ))
779}
780
781async fn tree(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
783 let root = s.root.get();
784 let ((rev, files), worktree) = off(move || {
785 (
786 speccoder_runtime::service::code::tree(&root, q.feature.as_deref()),
787 speccoder_runtime::service::code::worktree_view(&root, q.feature.as_deref()),
788 )
789 })
790 .await;
791 Json(json!({ "rev": rev, "files": files, "worktree": worktree }))
792}
793
794#[derive(Debug, Deserialize)]
795struct FileQuery {
796 feature: Option<String>,
797 path: String,
798}
799
800async fn file(State(s): State<Shared>, Query(q): Query<FileQuery>) -> Json<Value> {
802 let root = s.root.get();
803 if !file_path_ok(&q.path) {
806 return Json(json!({ "error": format!("{} is not a project file", q.path) }));
807 }
808 let markdown = q.path.to_ascii_lowercase().ends_with(".md");
809 let r = off(move || {
811 speccoder_runtime::service::code::file(&root, q.feature.as_deref(), &q.path).or_else(|e| {
812 speccoder_runtime::service::code::worktree_file(&root, q.feature.as_deref(), &q.path)
813 .map_err(|_| e)
814 })
815 })
816 .await;
817 Json(match r {
818 Ok(text) if markdown => {
820 json!({ "text": text, "html": speccoder_runtime::service::documents::render(&text) })
821 }
822 Ok(text) => json!({ "text": text }),
823 Err(e) => json!({ "error": e }),
824 })
825}
826
827async fn setup(State(s): State<Shared>) -> Json<Value> {
830 let root = s.root.get();
831 let (doctor, preview) = off(move || {
832 (
833 speccoder_runtime::service::doctor::run(&root, None, None),
834 speccoder_runtime::service::init::preview(&root),
835 )
836 })
837 .await;
838 Json(json!({ "doctor": doctor, "preview": preview }))
839}
840
841#[derive(Debug, Deserialize)]
842struct SetupBody {
843 provider: Option<String>,
844 model: Option<String>,
845 region: Option<String>,
847}
848
849async fn setup_init(State(s): State<Shared>, Json(b): Json<SetupBody>) -> Json<Value> {
852 if s.hub.job().is_some_and(|j| j.running) {
853 return Json(json!({ "status": "error", "message": "a job is running" }));
854 }
855 let root = s.root.get();
856 let r = off(move || {
857 speccoder_runtime::service::init::prepare(
858 &root,
859 b.provider.as_deref(),
860 b.model.as_deref(),
861 b.region.as_deref(),
862 false,
863 false,
864 )
865 })
866 .await;
867 match r {
868 Err(refused) => envelope(&s, &refused),
869 Ok(p) => {
870 let (to_commit, failed) = (p.to_commit.clone(), p.failed.clone());
871 let Json(mut v) = envelope(&s, &speccoder_runtime::service::init::finish(p));
872 v["to_commit"] = json!(to_commit);
873 v["failed"] = json!(failed);
874 Json(v)
875 }
876 }
877}
878
879#[derive(Debug, Deserialize)]
880struct SetupCommit {
881 paths: Vec<String>,
882}
883
884async fn setup_commit(State(s): State<Shared>, Json(b): Json<SetupCommit>) -> Json<Value> {
886 let (root, hub) = (s.root.get(), Arc::clone(&s.hub));
887 let r = off(move || {
888 hub.exclusive(|| {
889 speccoder_runtime::service::init::commit_written(
890 &speccoder_runtime::service::init::root_of(&root),
891 &b.paths,
892 )
893 })
894 })
895 .await;
896 Json(match r.and_then(|r| r) {
897 Ok(id) => json!({ "status": "ok", "message": format!("committed as {id}") }),
898 Err(e) => json!({ "status": "error", "message": e }),
899 })
900}
901
902async fn github(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
906 let root = s.root.get();
907 Json(
908 off(move || {
909 use speccoder_runtime::service::github;
910 let ctx = github::Ctx::from_env();
911 let status = github::status(&ctx, &root);
912 if !status.ready {
913 return json!({ "status": status });
914 }
915 let issues = github::issues(&ctx, &root, &github::started(&root));
916 let branch = match speccoder_runtime::service::status::report(
917 &root,
918 q.feature.as_deref(),
919 )
920 .data()
921 {
922 Some(speccoder_core::CommandData::Report(r)) => r.branch.clone(),
923 _ => None,
924 };
925 let pull = branch.as_deref().map(|b| github::pr_for(&ctx, &root, b));
926 json!({
927 "status": status,
928 "issues": issues.as_ref().ok(),
929 "issues_error": issues.as_ref().err(),
930 "branch": branch,
931 "pull": pull.as_ref().and_then(|p| p.as_ref().ok()).cloned().flatten(),
932 "pull_error": pull.as_ref().and_then(|p| p.as_ref().err()),
933 })
934 })
935 .await,
936 )
937}
938
939#[derive(Debug, Deserialize)]
940struct IssueQuery {
941 number: String,
942}
943
944async fn github_device(State(s): State<Shared>) -> Json<Value> {
947 let root = s.root.get();
948 let slot = Arc::clone(&s.device);
949 Json(
950 off(move || {
951 use speccoder_runtime::service::github;
952 let host = github::repo(&root, None).map_or_else(|_| "github.com".to_owned(), |r| r.host);
953 let Some(client) = github::auth::client_id(&host) else {
954 return json!({ "error": format!("{host} has no speccoder app: sign in with a token instead") });
955 };
956 match github::auth::start(&github::http::oauth(&host), &client) {
957 Ok(p) => {
958 let shown = json!({ "user_code": p.user_code, "verification_uri": p.verification_uri, "host": host });
959 if let Ok(mut d) = slot.lock() {
960 *d = Some((host, client, p));
961 }
962 shown
963 }
964 Err(e) => json!({ "error": e }),
965 }
966 })
967 .await,
968 )
969}
970
971async fn github_device_wait(State(s): State<Shared>) -> Json<Value> {
974 let root = s.root.get();
975 let slot = Arc::clone(&s.device);
976 let r = off(move || {
977 use speccoder_runtime::service::github;
978 let Some((host, client, pending)) = slot.lock().ok().and_then(|mut d| d.take()) else {
979 return speccoder_core::CommandResult::new(
980 "auth",
981 speccoder_core::CommandStatus::UsageError,
982 "no sign-in is waiting: start it again",
983 );
984 };
985 let ctx = github::Ctx::from_env();
986 match github::auth::wait(&github::http::oauth(&host), &client, &pending) {
987 Ok(token) => {
988 let signed = github::login_with_token(&ctx, &host, token.expose());
989 match (signed.status(), github::reach(&ctx, &root)) {
990 (speccoder_core::CommandStatus::Ok, Some(todo)) => {
991 let message = format!("{}; {todo}", signed.message());
992 signed.reworded(speccoder_core::CommandStatus::Ok, &message)
993 }
994 _ => signed,
995 }
996 }
997 Err(e) => github::refused("auth", &e),
998 }
999 })
1000 .await;
1001 envelope(&s, &r)
1002}
1003
1004#[derive(Debug, Deserialize)]
1005struct TokenBody {
1006 token: String,
1007}
1008
1009async fn github_login(State(s): State<Shared>, Json(b): Json<TokenBody>) -> Json<Value> {
1012 let root = s.root.get();
1013 let r = off(move || {
1014 let host = speccoder_runtime::service::github::repo(&root, None)
1015 .map_or_else(|_| "github.com".to_owned(), |r| r.host);
1016 speccoder_runtime::service::github::login_with_token(
1017 &speccoder_runtime::service::github::Ctx::from_env(),
1018 &host,
1019 &b.token,
1020 )
1021 })
1022 .await;
1023 envelope(&s, &r)
1024}
1025
1026async fn github_issue(State(s): State<Shared>, Query(q): Query<IssueQuery>) -> Json<Value> {
1029 let root = s.root.get();
1030 Json(
1031 off(move || {
1032 match speccoder_runtime::service::github::issue(
1033 &speccoder_runtime::service::github::Ctx::from_env(),
1034 &root,
1035 &q.number,
1036 ) {
1037 Ok(issue) => {
1038 json!({
1039 "description": speccoder_runtime::service::github::description(&issue),
1040 "skill": speccoder_runtime::service::create_skill(&root),
1041 "issue": issue,
1042 })
1043 }
1044 Err(e) => json!({ "error": e }),
1045 }
1046 })
1047 .await,
1048 )
1049}
1050
1051async fn pr_preview(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
1053 let root = s.root.get();
1054 let r = off(move || {
1055 speccoder_runtime::service::handoff::pr(
1056 &root,
1057 q.feature.as_deref(),
1058 &speccoder_runtime::service::handoff::PrOptions {
1059 remote: "origin",
1060 base: None,
1061 open: false,
1062 force: false,
1063 github: &speccoder_runtime::service::github::Ctx::from_env(),
1064 },
1065 )
1066 })
1067 .await;
1068 envelope(&s, &r)
1069}
1070
1071async fn live(State(s): State<Shared>) -> Json<Value> {
1075 let root = s.root.get();
1076 Json(
1077 off(move || {
1078 let Some(state) = speccoder_runtime::service::worktree::state_dir() else {
1079 return json!({ "live": null });
1080 };
1081 match speccoder_runtime::service::progress::read_live(&root, &state) {
1082 Some((live, running)) => json!({
1083 "live": live,
1084 "running": running,
1085 "own": live.pid == std::process::id(),
1086 "interrupted": live.finished.is_none() && !running,
1087 "now": std::time::SystemTime::now()
1088 .duration_since(std::time::UNIX_EPOCH)
1089 .map_or(0, |d| d.as_secs()),
1090 }),
1091 None => json!({ "live": null }),
1092 }
1093 })
1094 .await,
1095 )
1096}
1097
1098async fn job(State(s): State<Shared>) -> Json<Value> {
1099 Json(json!({ "job": s.hub.job(), "waiting": s.hub.waiting() }))
1100}
1101
1102#[derive(Debug, Deserialize)]
1103struct Start {
1104 run: Runnable,
1105 input: Option<String>,
1106 feature: Option<String>,
1107 command: String,
1108 #[serde(default)]
1110 root: Option<String>,
1111}
1112
1113async fn start(State(s): State<Shared>, Json(b): Json<Start>) -> Response {
1114 let root = match welcome::job_root(&s.root, &b.run, b.root.as_deref()) {
1115 Ok(r) => r,
1116 Err(e) => return welcome::no_project_yet(&e),
1117 };
1118 match s.hub.start(b.run, b.input, b.feature, b.command, root) {
1119 Ok(id) => Json(json!({ "id": id })).into_response(),
1120 Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1121 }
1122}
1123
1124#[derive(Debug, Deserialize)]
1125struct AnswerBody {
1126 id: u64,
1127 answer: Option<speccoder_runtime::service::approvals::Answer>,
1128 text: Option<String>,
1129}
1130
1131async fn answer(State(s): State<Shared>, Json(b): Json<AnswerBody>) -> Response {
1132 let reply = match b.answer {
1133 Some(a) => jobs::Reply::Answer(a),
1134 None => jobs::Reply::Text(b.text.filter(|t| !t.trim().is_empty())),
1135 };
1136 match s.hub.answer(b.id, reply) {
1137 Ok(()) => Json(json!({ "ok": true })).into_response(),
1138 Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1139 }
1140}
1141
1142async fn stop(State(s): State<Shared>) -> Json<Value> {
1143 s.hub.stop();
1144 Json(json!({ "ok": true }))
1145}
1146
1147async fn events(State(s): State<Shared>) -> Response {
1150 use futures_util::StreamExt;
1151 let rx = s.hub.subscribe();
1152 let hello = futures_util::stream::once(async {
1153 Ok::<_, std::convert::Infallible>(
1154 axum::response::sse::Event::default().data(json!({ "type": "hello" }).to_string()),
1155 )
1156 });
1157 let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|m| async move {
1158 m.ok().map(|text| {
1159 Ok::<_, std::convert::Infallible>(axum::response::sse::Event::default().data(text))
1160 })
1161 });
1162 let mut closing = s.closing.clone();
1165 let stream = stream.take_until(async move {
1166 let _ = closing.wait_for(|c| *c).await;
1167 });
1168 axum::response::sse::Sse::new(hello.chain(stream))
1169 .keep_alive(axum::response::sse::KeepAlive::default())
1170 .into_response()
1171}
1172
1173#[derive(Debug, Deserialize)]
1174struct FixBody {
1175 argvs: Vec<Vec<String>>,
1176 token: String,
1177}
1178
1179async fn fix_run(State(s): State<Shared>, Json(b): Json<FixBody>) -> Response {
1184 let root = s.root.get();
1185 if !s.hub.issuer.issued(&root, &b.argvs, false, &b.token) {
1187 return (
1188 StatusCode::FORBIDDEN,
1189 Json(json!({ "error": "speccoder did not issue this command here to run: only a fix it printed runs from the app, and one that asks questions runs in a terminal" })),
1190 )
1191 .into_response();
1192 }
1193 if b.argvs.iter().any(|a| speccoder_core::step::interactive(a)) {
1194 return (
1195 StatusCode::BAD_REQUEST,
1196 Json(json!({ "error": "it asks you questions as it runs: open it in a terminal" })),
1197 )
1198 .into_response();
1199 }
1200 let started = match b.argvs.as_slice() {
1203 [one] if one.first().map(String::as_str) == Some("speccoder") => {
1204 let args = one[1..].to_vec();
1205 if let [spec, archive, change, ..] = args.as_slice()
1207 && spec == "spec"
1208 && archive == "archive"
1209 && !change.starts_with('-')
1210 {
1211 let (root2, change2) = (root.clone(), change.clone());
1212 let known = off(move || unit_exists(&root2, &change2)).await;
1213 if !known {
1214 return (
1215 StatusCode::FORBIDDEN,
1216 Json(json!({ "error": format!("`{change}` is no unit of this project") })),
1217 )
1218 .into_response();
1219 }
1220 }
1221 s.hub.start_speccoder(args, root)
1222 }
1223 argvs
1224 if argvs
1225 .iter()
1226 .any(|a| a.first().map(String::as_str) == Some("speccoder")) =>
1227 {
1228 return (
1229 StatusCode::FORBIDDEN,
1230 Json(json!({ "error": "speccoder runs on its own, never in a chain" })),
1231 )
1232 .into_response();
1233 }
1234 _ => s.hub.start_fix(b.argvs, root),
1235 };
1236 match started {
1237 Ok(id) => Json(json!({ "id": id })).into_response(),
1238 Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1239 }
1240}
1241
1242fn unit_exists(root: &Path, name: &str) -> bool {
1244 match speccoder_runtime::service::current::list(root, None).data() {
1245 Some(CommandData::Features(f)) => f.features.iter().any(|e| e.name == name),
1246 _ => false,
1247 }
1248}
1249
1250async fn terminal(State(s): State<Shared>, Json(b): Json<FixBody>) -> Response {
1254 let root = s.root.get();
1255 if !s.window {
1256 return (
1257 StatusCode::BAD_REQUEST,
1258 Json(json!({ "error": "a browser tab opens no terminal: copy the command" })),
1259 )
1260 .into_response();
1261 }
1262 if !s.hub.issuer.issued(&root, &b.argvs, true, &b.token) {
1263 return (
1264 StatusCode::FORBIDDEN,
1265 Json(json!({ "error": "speccoder did not issue this command here for a terminal" })),
1266 )
1267 .into_response();
1268 }
1269 match off(move || speccoder_runtime::service::person::open_terminal(&root)).await {
1270 Ok(()) => Json(json!({ "ok": true })).into_response(),
1271 Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
1272 }
1273}
1274
1275async fn bug_report(State(s): State<Shared>) -> Json<Value> {
1277 let root = s.root.get();
1278 Json(
1279 off(move || {
1280 let doctor = speccoder_runtime::service::doctor::run(&root, None, None);
1281 json!({ "text": speccoder_runtime::service::copy::bug_report(&doctor, &root) })
1282 })
1283 .await,
1284 )
1285}
1286
1287#[derive(Debug, Deserialize)]
1288struct CodeQuery {
1289 code: String,
1290}
1291
1292async fn explain(Query(q): Query<CodeQuery>) -> Json<Value> {
1294 use speccoder_core::{CodeInfo, CommandData, ExplainReport};
1295 let r = match speccoder_core::codes::lookup(&q.code.to_ascii_uppercase()) {
1296 Some(e) => CommandResult::ok("explain", e.name).with_data(CommandData::Explain(Box::new(
1297 ExplainReport {
1298 codes: vec![CodeInfo::of(e)],
1299 },
1300 ))),
1301 None => CommandResult::new(
1302 "explain",
1303 speccoder_core::CommandStatus::UsageError,
1304 &format!("no code `{}`", q.code),
1305 )
1306 .with_code("USAGE-UNKNOWN-CODE"),
1307 };
1308 Json(serde_json::to_value(&r).unwrap_or(Value::Null))
1309}
1310
1311pub fn router(s: Shared) -> Router {
1313 Router::new()
1314 .route("/api/project", get(project))
1315 .route("/api/report", get(report))
1316 .route("/api/actions", get(actions))
1317 .route("/api/inbox", get(inbox))
1318 .route("/api/ask", post(ask))
1319 .route("/api/ask/conversations", get(ask_conversations))
1320 .route("/api/ask/conversation", get(ask_conversation))
1321 .route("/api/ask/stop", post(ask_stop))
1322 .route("/api/ask/changed", get(ask_changed))
1323 .route("/api/explore", get(explore_list))
1324 .route("/api/explore/session", get(explore_session))
1325 .route("/api/ask/chip", post(ask_chip))
1326 .route("/api/ask/proposal", post(ask_proposal))
1327 .route("/api/shape", post(shape))
1328 .route("/api/shape/check", post(shape_check))
1329 .route("/api/shape/judge", post(shape_judge))
1330 .route("/api/bug", get(bug))
1331 .route("/api/account", get(account))
1332 .route("/api/review", get(review))
1333 .route("/api/review/comments", get(review_comments))
1334 .route("/api/approvals", get(approvals))
1335 .route("/api/evidence", get(evidence))
1336 .route("/api/tour", get(tour))
1337 .route("/api/diff", get(diff))
1338 .route("/api/documents", get(documents))
1339 .route("/api/doctor", get(doctor))
1340 .route("/api/hooks", get(hooks))
1341 .route("/api/extensions", get(extensions))
1342 .route("/api/extensions/views", get(extension_views))
1343 .route("/api/agents", get(agents))
1344 .route("/api/job", get(job))
1345 .route("/api/live", get(live))
1346 .route("/api/config", get(config).post(config_save))
1347 .route("/api/config/commit", post(config_commit))
1348 .route("/api/policy/coverage", get(policy_coverage))
1349 .route("/api/cost", get(cost))
1350 .route("/api/changes", get(changes))
1351 .route("/api/tree", get(tree))
1352 .route("/api/file", get(file))
1353 .route("/api/setup", get(setup).post(setup_init))
1354 .route("/api/github", get(github))
1355 .route("/api/github/issue", get(github_issue))
1356 .route("/api/github/login", post(github_login))
1357 .route("/api/github/device", post(github_device))
1358 .route("/api/github/device/wait", post(github_device_wait))
1359 .route("/api/pr/preview", get(pr_preview))
1360 .route("/api/setup/commit", post(setup_commit))
1361 .route("/api/events", get(events))
1362 .route("/api/run", post(start))
1363 .route("/api/answer", post(answer))
1364 .route("/api/stop", post(stop))
1365 .route("/api/fix/run", post(fix_run))
1366 .route("/api/terminal", post(terminal))
1367 .route("/api/bug-report", get(bug_report))
1368 .route("/api/explain", get(explain))
1369 .route("/api/welcome", get(welcome::view))
1370 .route("/api/open", post(welcome::open))
1371 .route("/api/recent/forget", post(welcome::forget))
1372 .route("/api/folders", get(welcome::folders))
1373 .route("/api/new/plan", get(welcome::plan))
1374 .route("/api/new/first-feature", post(welcome::first_feature))
1375 .route("/api/similar", get(welcome::similar))
1376 .route("/api/suggest", get(welcome::suggest))
1377 .route("/api/github/repos", get(welcome::repositories))
1378 .route("/api/skill-cost", get(welcome::skill_cost))
1379 .fallback(get(asset))
1380 .layer(axum::middleware::from_fn_with_state(
1381 s.clone(),
1382 welcome::projectless,
1383 ))
1384 .layer(axum::middleware::from_fn_with_state(s.clone(), guard))
1385 .with_state(s)
1386}
1387
1388pub type Events = tokio::sync::broadcast::Receiver<String>;
1390pub use tokio::sync::broadcast::error::RecvError;
1392
1393#[derive(Debug)]
1395pub struct Started {
1396 pub url: String,
1398 pub port: u16,
1400 pub events: Option<Events>,
1403 pub root: Root,
1406}
1407
1408pub fn serve(
1412 root: &Path,
1413 port: u16,
1414 window: bool,
1415 ready: impl FnOnce(Started) + Send,
1416) -> Result<(), String> {
1417 let rt = tokio::runtime::Builder::new_multi_thread()
1418 .worker_threads(2)
1419 .enable_all()
1420 .build()
1421 .map_err(|e| e.to_string())?;
1422 rt.block_on(async move {
1423 let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port)))
1424 .await
1425 .map_err(|e| format!("127.0.0.1:{port}: {e}"))?;
1426 let port = listener.local_addr().map_err(|e| e.to_string())?.port();
1427 let (close, closed) = tokio::sync::watch::channel(false);
1428 let token = security::token();
1429 if token.is_empty() {
1430 return Err("no random source for the token".to_owned());
1431 }
1432 let Some(issuer) = fixes::Issuer::new() else {
1433 return Err("no random source for the token".to_owned());
1434 };
1435 let root = Root::new(root, welcome::is_project(root));
1436 let shared = Shared {
1437 root: root.clone(),
1438 token: token.clone(),
1439 port,
1440 hub: jobs::Hub::new(root.clone(), Arc::new(issuer)),
1441 window,
1442 closing: closed,
1443 device: Arc::default(),
1444 };
1445 ready(Started {
1446 url: format!("http://127.0.0.1:{port}/#token={token}"),
1447 port,
1448 events: Some(shared.hub.subscribe()),
1449 root,
1450 });
1451 let (stop, stopping) = tokio::sync::oneshot::channel::<()>();
1454 let server = axum::serve(listener, router(shared)).with_graceful_shutdown(async move {
1455 let _ = stopping.await;
1456 });
1457 let signal = async move {
1458 let _ = tokio::signal::ctrl_c().await;
1459 let _ = close.send(true);
1460 let _ = stop.send(());
1461 tokio::time::sleep(GRACE).await;
1462 };
1463 tokio::select! {
1464 served = server => served.map_err(|e| e.to_string()),
1465 () = signal => Ok(()),
1466 }
1467 })
1468}
1469
1470#[cfg(test)]
1471mod tests {
1472 use super::*;
1473
1474 #[test]
1477 fn an_archive_names_a_unit_the_project_has() {
1478 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1479 let r = dir.path();
1480 std::fs::create_dir_all(r.join(".specify/memory")).unwrap_or_else(|e| panic!("{e}"));
1481 std::fs::create_dir_all(r.join("specs/001-x")).unwrap_or_else(|e| panic!("{e}"));
1482 std::fs::write(r.join("specs/001-x/spec.md"), "# Spec\n").unwrap_or_else(|e| panic!("{e}"));
1483 assert!(unit_exists(r, "001-x"));
1484 assert!(!unit_exists(r, "002-other"));
1485 assert!(!unit_exists(r, "--yes"));
1486 }
1487
1488 fn q(commit: Option<&str>, reference: Option<&str>) -> FeatureQuery {
1489 FeatureQuery {
1490 feature: None,
1491 task: None,
1492 commit: commit.map(str::to_owned),
1493 reference: reference.map(str::to_owned),
1494 }
1495 }
1496
1497 #[test]
1498 fn only_commits_and_attempt_refs_reach_git_show() {
1499 assert!(revision(&q(Some("e23d4197bb"), None)).is_some());
1500 assert!(revision(&q(Some("e23d"), None)).is_none());
1501 assert!(revision(&q(Some("--output=/tmp/x"), None)).is_none());
1502 let attempt = "refs/speccoder/attempts/001-greet/T001/run_01M5S5VJ";
1503 assert_eq!(revision(&q(None, Some(attempt))).as_deref(), Some(attempt));
1504 for bad in [
1505 "refs/heads/main",
1506 "refs/speccoder/attempts/",
1507 "refs/speccoder/attempts/../../heads/main",
1508 "refs/speccoder/attempts/a b",
1509 "--all",
1510 "HEAD",
1511 ] {
1512 assert!(revision(&q(None, Some(bad))).is_none(), "{bad}");
1513 }
1514 }
1515}