pub mod fixes;
pub mod jobs;
pub mod security;
mod welcome;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::extract::{Query, Request, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{Value, json};
use speccoder_core::actions::Runnable;
use speccoder_core::{CommandData, CommandResult};
mod bundle {
include!(concat!(env!("OUT_DIR"), "/bundle.rs"));
}
#[derive(Debug, Clone)]
pub struct Root(Arc<RootCell>);
#[derive(Debug)]
struct RootCell {
start: PathBuf,
bound: std::sync::OnceLock<PathBuf>,
}
impl Root {
#[must_use]
pub fn new(start: &Path, bound: bool) -> Self {
let cell = RootCell {
start: start.to_path_buf(),
bound: std::sync::OnceLock::new(),
};
if bound {
let _ = cell.bound.set(start.to_path_buf());
}
Self(Arc::new(cell))
}
#[must_use]
pub fn get(&self) -> PathBuf {
self.0
.bound
.get()
.cloned()
.unwrap_or_else(|| self.0.start.clone())
}
#[must_use]
pub fn bound(&self) -> Option<PathBuf> {
self.0.bound.get().cloned()
}
#[must_use]
pub fn start(&self) -> PathBuf {
self.0.start.clone()
}
pub fn bind(&self, project: &Path) -> Result<(), String> {
self.0.bound.set(project.to_path_buf()).map_err(|_| {
format!(
"this host serves {} already: open another project with `speccoder ui -C {}`",
self.get().display(),
project.display()
)
})
}
}
#[derive(Debug, Clone)]
pub struct Shared {
pub root: Root,
pub token: String,
pub port: u16,
pub hub: Arc<jobs::Hub>,
pub window: bool,
pub closing: tokio::sync::watch::Receiver<bool>,
pub device: Arc<
std::sync::Mutex<
Option<(
String,
String,
speccoder_runtime::service::github::auth::Pending,
)>,
>,
>,
}
const GRACE: std::time::Duration = std::time::Duration::from_secs(2);
async fn off<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
match rx.await {
Ok(v) => v,
Err(_) => std::panic::resume_unwind(Box::new("a request's work stopped unexpectedly")),
}
}
async fn guard(State(s): State<Shared>, req: Request, next: Next) -> Response {
let headers = req.headers();
if !security::host_ok(s.port, headers) {
return (
StatusCode::MISDIRECTED_REQUEST,
"this host answers on the loopback address only",
)
.into_response();
}
if req.uri().path().starts_with("/api/") {
if !security::authorised(&s.token, headers) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "unauthorised" })),
)
.into_response();
}
if !security::origin_ok(s.port, req.method(), headers) {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "cross-origin request refused" })),
)
.into_response();
}
}
let mut res = next.run(req).await;
let h = res.headers_mut();
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
h.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
h.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
res
}
fn content_type(path: &str) -> &'static str {
match path.rsplit('.').next() {
Some("html") => "text/html; charset=utf-8",
Some("js") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("json") => "application/json",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
}
}
async fn asset(uri: axum::http::Uri) -> Response {
let want = match uri.path().trim_start_matches('/') {
"" => "index.html",
p => p,
};
let csp = [(
header::CONTENT_SECURITY_POLICY,
security::CONTENT_SECURITY_POLICY,
)];
if let Some(dir) = std::env::var_os("SPECCODER_UI")
&& !want.contains("..")
&& let Ok(bytes) = std::fs::read(Path::new(&dir).join(want))
{
return (csp, [(header::CONTENT_TYPE, content_type(want))], bytes).into_response();
}
if bundle::BUNDLE.is_empty() {
return (
StatusCode::SERVICE_UNAVAILABLE,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
"<!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.",
)
.into_response();
}
let name = want.trim_start_matches("assets/");
match bundle::BUNDLE
.iter()
.find(|(p, _)| *p == want || *p == name)
{
Some((p, bytes)) => {
(csp, [(header::CONTENT_TYPE, content_type(p))], *bytes).into_response()
}
None => match bundle::BUNDLE.iter().find(|(p, _)| *p == "index.html") {
Some((_, bytes)) => (
csp,
[(header::CONTENT_TYPE, content_type("index.html"))],
*bytes,
)
.into_response(),
None => (StatusCode::NOT_FOUND, "no such file").into_response(),
},
}
}
#[derive(Debug, Deserialize)]
struct FeatureQuery {
feature: Option<String>,
task: Option<String>,
commit: Option<String>,
#[serde(rename = "ref")]
reference: Option<String>,
}
fn revision(q: &FeatureQuery) -> Option<String> {
if let Some(c) = q.commit.as_deref() {
return (c.len() >= 7 && c.chars().all(|ch| ch.is_ascii_hexdigit())).then(|| c.to_owned());
}
let r = q.reference.as_deref()?;
let rest = r.strip_prefix("refs/speccoder/attempts/")?;
let ok = !rest.is_empty()
&& !r.contains("..")
&& rest
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '-' | '_' | '.'));
ok.then(|| r.to_owned())
}
fn file_path_ok(path: &str) -> bool {
let p = Path::new(path);
!path.is_empty()
&& !p.is_absolute()
&& p.components()
.all(|c| matches!(c, std::path::Component::Normal(_)))
}
fn envelope(s: &Shared, r: &CommandResult) -> Json<Value> {
Json(s.hub.issuer.signing(s.root.get(), || {
serde_json::to_value(r).unwrap_or(Value::Null)
}))
}
async fn project(State(s): State<Shared>) -> Json<Value> {
let root = s.root.get();
let window = s.window;
Json(
off(move || {
let words = speccoder_runtime::service::format_words(&root);
let features = speccoder_runtime::service::current::list(&root, None);
let name = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
json!({
"name": name,
"root": root.display().to_string(),
"format": words.name,
"unit": words.unit,
"features": features.data().and_then(|d| match d {
CommandData::Features(f) => serde_json::to_value(f).ok(),
_ => None,
}),
"version": env!("CARGO_PKG_VERSION"),
"os": format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
"window": window,
"sandbox": speccoder_runtime::service::person::sandboxed(),
"planning": speccoder_runtime::service::planning_words(&root),
"sample": welcome::sample_of(&root),
})
})
.await,
)
}
async fn report(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
let r = off(move || {
speccoder_runtime::service::next::attach(
speccoder_runtime::service::status::report(&root, q.feature.as_deref()),
q.feature.as_deref(),
)
})
.await;
envelope(&s, &r)
}
async fn review(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
let r =
off(move || speccoder_runtime::service::review::report(&root, q.feature.as_deref())).await;
envelope(&s, &r)
}
async fn review_comments(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
let r = off(move || {
speccoder_runtime::service::review::comments(&root, q.feature.as_deref(), None)
})
.await;
envelope(&s, &r)
}
#[derive(Debug, Deserialize)]
struct RunQuery {
run: String,
}
async fn account(State(s): State<Shared>, Query(q): Query<RunQuery>) -> Json<Value> {
let root = s.root.get();
Json(
off(
move || match speccoder_runtime::service::evidence::account_of(&root, &q.run) {
Ok(turns) => json!({ "turns": turns }),
Err(e) => json!({ "error": e }),
},
)
.await,
)
}
async fn inbox(State(s): State<Shared>) -> Json<Value> {
let root = s.root.get();
let issuer = Arc::clone(&s.hub.issuer);
Json(
off(move || {
issuer.signing(root.clone(), || {
serde_json::to_value(speccoder_runtime::service::inbox::inbox(&root, None))
.unwrap_or(Value::Null)
})
})
.await,
)
}
async fn bug(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
let Some(task) = q.task else {
return Json(json!({ "error": "a bug is needed: task=<unit>/<task>" }));
};
let r = off(move || speccoder_runtime::service::bug::show(&root, &task)).await;
envelope(&s, &r)
}
async fn actions(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
let issuer = Arc::clone(&s.hub.issuer);
Json(
off(move || {
issuer.signing(root.clone(), || {
json!(speccoder_runtime::service::actions::available(
&root,
q.feature.as_deref(),
q.task.as_deref()
))
})
})
.await,
)
}
async fn approvals(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
let r =
off(move || speccoder_runtime::service::approvals::list(&root, q.feature.as_deref())).await;
envelope(&s, &r)
}
async fn evidence(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
Json(
off(move || {
let report = speccoder_runtime::service::status::report(&root, q.feature.as_deref());
let Some(CommandData::Report(r)) = report.data() else {
return json!({ "error": report.message() });
};
let Some(t) = r.tasks.iter().find(|t| Some(&t.id) == q.task.as_ref()) else {
return json!({ "error": "no such task" });
};
let statements = t
.commit
.as_deref()
.map(|c| speccoder_runtime::service::evidence::statements(&root, c))
.unwrap_or_default();
let (mark, words) = speccoder_runtime::service::evidence::banner(&t.state);
json!({
"task": t,
"banner": { "mark": mark, "words": words },
"rows": speccoder_runtime::service::evidence::rows(t, &statements, None),
})
})
.await,
)
}
async fn tour(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
Json(
off(move || {
let Some(task) = q.task else {
return json!({ "error": "a task is needed" });
};
let result =
speccoder_runtime::service::tour::of_task(&root, q.feature.as_deref(), &task);
serde_json::to_value(&result).unwrap_or_else(|e| json!({ "error": e.to_string() }))
})
.await,
)
}
async fn diff(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Response {
let root = s.root.get();
let Some(commit) = revision(&q) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "a commit id or an attempt ref is needed" })),
)
.into_response();
};
let patch = off(move || speccoder_runtime::service::commit_patch(&root, &commit)).await;
Json(json!({ "patch": patch })).into_response()
}
async fn documents(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
Json(
off(move || {
json!(speccoder_runtime::service::documents::of_feature(
&root,
q.feature.as_deref()
))
})
.await,
)
}
async fn hooks(State(s): State<Shared>) -> Json<Value> {
let root = s.root.get();
let r = off(move || speccoder_runtime::service::hooks::list(&root)).await;
envelope(&s, &r)
}
async fn doctor(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
let r = off(move || speccoder_runtime::service::doctor::run(&root, q.feature.as_deref(), None))
.await;
envelope(&s, &r)
}
#[derive(Debug, Deserialize)]
struct AgentsQuery {
#[serde(default = "yes")]
probe: bool,
}
fn yes() -> bool {
true
}
async fn agents(State(s): State<Shared>, Query(q): Query<AgentsQuery>) -> Json<Value> {
let root = s.root.get();
let r = off(move || speccoder_runtime::service::doctor::agents(&root, None, q.probe)).await;
envelope(&s, &r)
}
async fn config(State(s): State<Shared>) -> Json<Value> {
let root = s.root.get();
let r = off(move || speccoder_runtime::service::settings::show(&root)).await;
envelope(&s, &r)
}
#[derive(Debug, Deserialize)]
struct ConfigBody {
changes: Vec<speccoder_core::ConfigChange>,
}
async fn config_save(State(s): State<Shared>, Json(b): Json<ConfigBody>) -> Json<Value> {
let root = s.root.get();
let r = off(move || speccoder_runtime::service::settings::apply(&root, &b.changes)).await;
envelope(&s, &r)
}
async fn ask(
State(s): State<Shared>,
Json(b): Json<speccoder_runtime::service::ask::Request>,
) -> Json<Value> {
let root = s.root.get();
let sink = s.hub.progress();
let r = off(move || speccoder_runtime::service::ask::turn(&root, &b, None, Some(sink))).await;
envelope(&s, &r)
}
#[derive(Debug, Deserialize)]
struct StopBody {
conversation: Option<String>,
}
async fn ask_stop(Json(b): Json<StopBody>) -> Json<Value> {
Json(json!({ "stopped": speccoder_runtime::service::ask::stop(b.conversation.as_deref()) }))
}
#[derive(Debug, Deserialize)]
struct AskQuery {
id: Option<String>,
feature: Option<String>,
}
async fn explore_list(State(s): State<Shared>) -> Json<Value> {
let root = s.root.get();
let r = off(move || speccoder_runtime::service::explore::list(&root, None)).await;
envelope(&s, &r)
}
async fn explore_session(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
let root = s.root.get();
let r = off(move || {
speccoder_runtime::service::explore::session(
&root,
None,
q.id.as_deref().unwrap_or_default(),
)
})
.await;
envelope(&s, &r)
}
async fn ask_conversations(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
let root = s.root.get();
let r = off(move || {
speccoder_runtime::service::ask::conversations(&root, None, q.feature.as_deref())
})
.await;
envelope(&s, &r)
}
async fn ask_conversation(State(s): State<Shared>, Query(q): Query<AskQuery>) -> Json<Value> {
let root = s.root.get();
let r = off(move || {
speccoder_runtime::service::ask::thread(&root, None, q.id.as_deref().unwrap_or_default())
})
.await;
envelope(&s, &r)
}
#[derive(Debug, Deserialize)]
struct ChangedQuery {
path: String,
start: Option<u32>,
end: Option<u32>,
sha: String,
}
async fn ask_changed(State(s): State<Shared>, Query(q): Query<ChangedQuery>) -> Json<Value> {
let root = s.root.get();
Json(json!({
"changed": off(move || {
speccoder_runtime::service::ask::changed_since(&root, &q.path, q.start, q.end, &q.sha)
})
.await
}))
}
async fn ask_chip(
State(s): State<Shared>,
Json(b): Json<speccoder_runtime::service::ask::ChipRef>,
) -> Json<Value> {
let root = s.root.get();
Json(
off(
move || match speccoder_runtime::service::ask::chip(&root, "c0", &b) {
Ok((chip, text)) => {
json!({ "chip": chip, "preview": text.chars().take(400).collect::<String>() })
}
Err(e) => json!({ "error": e }),
},
)
.await,
)
}
#[derive(Debug, Deserialize)]
struct ProposalBody {
proposal: speccoder_core::ask::Proposal,
feature: Option<String>,
}
async fn ask_proposal(State(s): State<Shared>, Json(b): Json<ProposalBody>) -> Json<Value> {
let root = s.root.get();
Json(
off(move || json!({ "proposal": speccoder_runtime::service::ask::proposal(&root, b.feature.as_deref(), &b.proposal) }))
.await,
)
}
#[derive(Debug, Deserialize)]
struct ShapeBody {
field: String,
text: String,
}
async fn shape(State(s): State<Shared>, Json(b): Json<ShapeBody>) -> Json<Value> {
let root = s.root.get();
let r =
off(move || speccoder_runtime::service::shape::shape(&root, &b.field, &b.text, None)).await;
envelope(&s, &r)
}
async fn shape_check(Json(b): Json<ShapeBody>) -> Json<Value> {
Json(off(move || json!({ "findings": speccoder_runtime::service::shape::findings(&b.field, &b.text) })).await)
}
#[derive(Debug, Deserialize)]
struct JudgeBody {
field: String,
original: String,
proposal: String,
#[serde(default)]
assumptions: Vec<speccoder_core::shape::Assumption>,
}
async fn shape_judge(Json(b): Json<JudgeBody>) -> Json<Value> {
Json(
off(move || {
speccoder_runtime::service::shape::judge(
&b.field,
&b.original,
&b.proposal,
&b.assumptions,
)
})
.await,
)
}
#[derive(Debug, Deserialize)]
struct CoverageQuery {
key: String,
prefix: String,
}
async fn policy_coverage(State(s): State<Shared>, Query(q): Query<CoverageQuery>) -> Json<Value> {
let Some(list) = speccoder_core::actions::PolicyList::of_key(&q.key) else {
return Json(
json!({ "status": "usage_error", "message": format!("`{}` is not a prefix list", q.key) }),
);
};
let Ok(prefix) = serde_json::from_str::<Vec<String>>(&q.prefix) else {
return Json(
json!({ "status": "usage_error", "message": "the prefix is a JSON list of strings" }),
);
};
let root = s.root.get();
let r = off(move || {
let state = speccoder_runtime::service::worktree::state_dir();
speccoder_runtime::service::settings::coverage(&root, state.as_deref(), list, &prefix)
})
.await;
envelope(&s, &r)
}
async fn config_commit(State(s): State<Shared>) -> Json<Value> {
let (root, hub) = (s.root.get(), Arc::clone(&s.hub));
let r =
off(move || hub.exclusive(|| speccoder_runtime::service::settings::commit(&root))).await;
match r {
Ok(result) => envelope(&s, &result),
Err(e) => Json(json!({ "status": "error", "message": e })),
}
}
async fn cost(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
let r =
off(move || speccoder_runtime::service::controls::cost(&root, q.feature.as_deref())).await;
envelope(&s, &r)
}
async fn changes(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
Json(json!(
off(move || speccoder_runtime::service::code::changes(&root, q.feature.as_deref())).await
))
}
async fn tree(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
let ((rev, files), worktree) = off(move || {
(
speccoder_runtime::service::code::tree(&root, q.feature.as_deref()),
speccoder_runtime::service::code::worktree_view(&root, q.feature.as_deref()),
)
})
.await;
Json(json!({ "rev": rev, "files": files, "worktree": worktree }))
}
#[derive(Debug, Deserialize)]
struct FileQuery {
feature: Option<String>,
path: String,
}
async fn file(State(s): State<Shared>, Query(q): Query<FileQuery>) -> Json<Value> {
let root = s.root.get();
if !file_path_ok(&q.path) {
return Json(json!({ "error": format!("{} is not a project file", q.path) }));
}
let markdown = q.path.to_ascii_lowercase().ends_with(".md");
let r = off(move || {
speccoder_runtime::service::code::file(&root, q.feature.as_deref(), &q.path).or_else(|e| {
speccoder_runtime::service::code::worktree_file(&root, q.feature.as_deref(), &q.path)
.map_err(|_| e)
})
})
.await;
Json(match r {
Ok(text) if markdown => {
json!({ "text": text, "html": speccoder_runtime::service::documents::render(&text) })
}
Ok(text) => json!({ "text": text }),
Err(e) => json!({ "error": e }),
})
}
async fn setup(State(s): State<Shared>) -> Json<Value> {
let root = s.root.get();
let (doctor, preview) = off(move || {
(
speccoder_runtime::service::doctor::run(&root, None, None),
speccoder_runtime::service::init::preview(&root),
)
})
.await;
Json(json!({ "doctor": doctor, "preview": preview }))
}
#[derive(Debug, Deserialize)]
struct SetupBody {
provider: Option<String>,
model: Option<String>,
region: Option<String>,
}
async fn setup_init(State(s): State<Shared>, Json(b): Json<SetupBody>) -> Json<Value> {
if s.hub.job().is_some_and(|j| j.running) {
return Json(json!({ "status": "error", "message": "a job is running" }));
}
let root = s.root.get();
let r = off(move || {
speccoder_runtime::service::init::prepare(
&root,
b.provider.as_deref(),
b.model.as_deref(),
b.region.as_deref(),
false,
false,
)
})
.await;
match r {
Err(refused) => envelope(&s, &refused),
Ok(p) => {
let (to_commit, failed) = (p.to_commit.clone(), p.failed.clone());
let Json(mut v) = envelope(&s, &speccoder_runtime::service::init::finish(p));
v["to_commit"] = json!(to_commit);
v["failed"] = json!(failed);
Json(v)
}
}
}
#[derive(Debug, Deserialize)]
struct SetupCommit {
paths: Vec<String>,
}
async fn setup_commit(State(s): State<Shared>, Json(b): Json<SetupCommit>) -> Json<Value> {
let (root, hub) = (s.root.get(), Arc::clone(&s.hub));
let r = off(move || {
hub.exclusive(|| {
speccoder_runtime::service::init::commit_written(
&speccoder_runtime::service::init::root_of(&root),
&b.paths,
)
})
})
.await;
Json(match r.and_then(|r| r) {
Ok(id) => json!({ "status": "ok", "message": format!("committed as {id}") }),
Err(e) => json!({ "status": "error", "message": e }),
})
}
async fn github(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
Json(
off(move || {
use speccoder_runtime::service::github;
let ctx = github::Ctx::from_env();
let status = github::status(&ctx, &root);
if !status.ready {
return json!({ "status": status });
}
let issues = github::issues(&ctx, &root, &github::started(&root));
let branch = match speccoder_runtime::service::status::report(
&root,
q.feature.as_deref(),
)
.data()
{
Some(speccoder_core::CommandData::Report(r)) => r.branch.clone(),
_ => None,
};
let pull = branch.as_deref().map(|b| github::pr_for(&ctx, &root, b));
json!({
"status": status,
"issues": issues.as_ref().ok(),
"issues_error": issues.as_ref().err(),
"branch": branch,
"pull": pull.as_ref().and_then(|p| p.as_ref().ok()).cloned().flatten(),
"pull_error": pull.as_ref().and_then(|p| p.as_ref().err()),
})
})
.await,
)
}
#[derive(Debug, Deserialize)]
struct IssueQuery {
number: String,
}
async fn github_device(State(s): State<Shared>) -> Json<Value> {
let root = s.root.get();
let slot = Arc::clone(&s.device);
Json(
off(move || {
use speccoder_runtime::service::github;
let host = github::repo(&root, None).map_or_else(|_| "github.com".to_owned(), |r| r.host);
let Some(client) = github::auth::client_id(&host) else {
return json!({ "error": format!("{host} has no speccoder app: sign in with a token instead") });
};
match github::auth::start(&github::http::oauth(&host), &client) {
Ok(p) => {
let shown = json!({ "user_code": p.user_code, "verification_uri": p.verification_uri, "host": host });
if let Ok(mut d) = slot.lock() {
*d = Some((host, client, p));
}
shown
}
Err(e) => json!({ "error": e }),
}
})
.await,
)
}
async fn github_device_wait(State(s): State<Shared>) -> Json<Value> {
let root = s.root.get();
let slot = Arc::clone(&s.device);
let r = off(move || {
use speccoder_runtime::service::github;
let Some((host, client, pending)) = slot.lock().ok().and_then(|mut d| d.take()) else {
return speccoder_core::CommandResult::new(
"auth",
speccoder_core::CommandStatus::UsageError,
"no sign-in is waiting: start it again",
);
};
let ctx = github::Ctx::from_env();
match github::auth::wait(&github::http::oauth(&host), &client, &pending) {
Ok(token) => {
let signed = github::login_with_token(&ctx, &host, token.expose());
match (signed.status(), github::reach(&ctx, &root)) {
(speccoder_core::CommandStatus::Ok, Some(todo)) => {
let message = format!("{}; {todo}", signed.message());
signed.reworded(speccoder_core::CommandStatus::Ok, &message)
}
_ => signed,
}
}
Err(e) => github::refused("auth", &e),
}
})
.await;
envelope(&s, &r)
}
#[derive(Debug, Deserialize)]
struct TokenBody {
token: String,
}
async fn github_login(State(s): State<Shared>, Json(b): Json<TokenBody>) -> Json<Value> {
let root = s.root.get();
let r = off(move || {
let host = speccoder_runtime::service::github::repo(&root, None)
.map_or_else(|_| "github.com".to_owned(), |r| r.host);
speccoder_runtime::service::github::login_with_token(
&speccoder_runtime::service::github::Ctx::from_env(),
&host,
&b.token,
)
})
.await;
envelope(&s, &r)
}
async fn github_issue(State(s): State<Shared>, Query(q): Query<IssueQuery>) -> Json<Value> {
let root = s.root.get();
Json(
off(move || {
match speccoder_runtime::service::github::issue(
&speccoder_runtime::service::github::Ctx::from_env(),
&root,
&q.number,
) {
Ok(issue) => {
json!({
"description": speccoder_runtime::service::github::description(&issue),
"skill": speccoder_runtime::service::create_skill(&root),
"issue": issue,
})
}
Err(e) => json!({ "error": e }),
}
})
.await,
)
}
async fn pr_preview(State(s): State<Shared>, Query(q): Query<FeatureQuery>) -> Json<Value> {
let root = s.root.get();
let r = off(move || {
speccoder_runtime::service::handoff::pr(
&root,
q.feature.as_deref(),
&speccoder_runtime::service::handoff::PrOptions {
remote: "origin",
base: None,
open: false,
force: false,
github: &speccoder_runtime::service::github::Ctx::from_env(),
},
)
})
.await;
envelope(&s, &r)
}
async fn live(State(s): State<Shared>) -> Json<Value> {
let root = s.root.get();
Json(
off(move || {
let Some(state) = speccoder_runtime::service::worktree::state_dir() else {
return json!({ "live": null });
};
match speccoder_runtime::service::progress::read_live(&root, &state) {
Some((live, running)) => json!({
"live": live,
"running": running,
"own": live.pid == std::process::id(),
"interrupted": live.finished.is_none() && !running,
"now": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()),
}),
None => json!({ "live": null }),
}
})
.await,
)
}
async fn job(State(s): State<Shared>) -> Json<Value> {
Json(json!({ "job": s.hub.job(), "waiting": s.hub.waiting() }))
}
#[derive(Debug, Deserialize)]
struct Start {
run: Runnable,
input: Option<String>,
feature: Option<String>,
command: String,
#[serde(default)]
root: Option<String>,
}
async fn start(State(s): State<Shared>, Json(b): Json<Start>) -> Response {
let root = match welcome::job_root(&s.root, &b.run, b.root.as_deref()) {
Ok(r) => r,
Err(e) => return welcome::no_project_yet(&e),
};
match s.hub.start(b.run, b.input, b.feature, b.command, root) {
Ok(id) => Json(json!({ "id": id })).into_response(),
Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
}
}
#[derive(Debug, Deserialize)]
struct AnswerBody {
id: u64,
answer: Option<speccoder_runtime::service::approvals::Answer>,
text: Option<String>,
}
async fn answer(State(s): State<Shared>, Json(b): Json<AnswerBody>) -> Response {
let reply = match b.answer {
Some(a) => jobs::Reply::Answer(a),
None => jobs::Reply::Text(b.text.filter(|t| !t.trim().is_empty())),
};
match s.hub.answer(b.id, reply) {
Ok(()) => Json(json!({ "ok": true })).into_response(),
Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
}
}
async fn stop(State(s): State<Shared>) -> Json<Value> {
s.hub.stop();
Json(json!({ "ok": true }))
}
async fn events(State(s): State<Shared>) -> Response {
use futures_util::StreamExt;
let rx = s.hub.subscribe();
let hello = futures_util::stream::once(async {
Ok::<_, std::convert::Infallible>(
axum::response::sse::Event::default().data(json!({ "type": "hello" }).to_string()),
)
});
let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|m| async move {
m.ok().map(|text| {
Ok::<_, std::convert::Infallible>(axum::response::sse::Event::default().data(text))
})
});
let mut closing = s.closing.clone();
let stream = stream.take_until(async move {
let _ = closing.wait_for(|c| *c).await;
});
axum::response::sse::Sse::new(hello.chain(stream))
.keep_alive(axum::response::sse::KeepAlive::default())
.into_response()
}
#[derive(Debug, Deserialize)]
struct FixBody {
argvs: Vec<Vec<String>>,
token: String,
}
async fn fix_run(State(s): State<Shared>, Json(b): Json<FixBody>) -> Response {
let root = s.root.get();
if !s.hub.issuer.issued(&root, &b.argvs, false, &b.token) {
return (
StatusCode::FORBIDDEN,
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" })),
)
.into_response();
}
if b.argvs.iter().any(|a| speccoder_core::step::interactive(a)) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "it asks you questions as it runs: open it in a terminal" })),
)
.into_response();
}
let started = match b.argvs.as_slice() {
[one] if one.first().map(String::as_str) == Some("speccoder") => {
let args = one[1..].to_vec();
if let [spec, archive, change, ..] = args.as_slice()
&& spec == "spec"
&& archive == "archive"
&& !change.starts_with('-')
{
let (root2, change2) = (root.clone(), change.clone());
let known = off(move || unit_exists(&root2, &change2)).await;
if !known {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": format!("`{change}` is no unit of this project") })),
)
.into_response();
}
}
s.hub.start_speccoder(args, root)
}
argvs
if argvs
.iter()
.any(|a| a.first().map(String::as_str) == Some("speccoder")) =>
{
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "speccoder runs on its own, never in a chain" })),
)
.into_response();
}
_ => s.hub.start_fix(b.argvs, root),
};
match started {
Ok(id) => Json(json!({ "id": id })).into_response(),
Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
}
}
fn unit_exists(root: &Path, name: &str) -> bool {
match speccoder_runtime::service::current::list(root, None).data() {
Some(CommandData::Features(f)) => f.features.iter().any(|e| e.name == name),
_ => false,
}
}
async fn terminal(State(s): State<Shared>, Json(b): Json<FixBody>) -> Response {
let root = s.root.get();
if !s.window {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "a browser tab opens no terminal: copy the command" })),
)
.into_response();
}
if !s.hub.issuer.issued(&root, &b.argvs, true, &b.token) {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "speccoder did not issue this command here for a terminal" })),
)
.into_response();
}
match off(move || speccoder_runtime::service::person::open_terminal(&root)).await {
Ok(()) => Json(json!({ "ok": true })).into_response(),
Err(e) => (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response(),
}
}
async fn bug_report(State(s): State<Shared>) -> Json<Value> {
let root = s.root.get();
Json(
off(move || {
let doctor = speccoder_runtime::service::doctor::run(&root, None, None);
json!({ "text": speccoder_runtime::service::copy::bug_report(&doctor, &root) })
})
.await,
)
}
#[derive(Debug, Deserialize)]
struct CodeQuery {
code: String,
}
async fn explain(Query(q): Query<CodeQuery>) -> Json<Value> {
use speccoder_core::{CodeInfo, CommandData, ExplainReport};
let r = match speccoder_core::codes::lookup(&q.code.to_ascii_uppercase()) {
Some(e) => CommandResult::ok("explain", e.name).with_data(CommandData::Explain(Box::new(
ExplainReport {
codes: vec![CodeInfo::of(e)],
},
))),
None => CommandResult::new(
"explain",
speccoder_core::CommandStatus::UsageError,
&format!("no code `{}`", q.code),
)
.with_code("USAGE-UNKNOWN-CODE"),
};
Json(serde_json::to_value(&r).unwrap_or(Value::Null))
}
pub fn router(s: Shared) -> Router {
Router::new()
.route("/api/project", get(project))
.route("/api/report", get(report))
.route("/api/actions", get(actions))
.route("/api/inbox", get(inbox))
.route("/api/ask", post(ask))
.route("/api/ask/conversations", get(ask_conversations))
.route("/api/ask/conversation", get(ask_conversation))
.route("/api/ask/stop", post(ask_stop))
.route("/api/ask/changed", get(ask_changed))
.route("/api/explore", get(explore_list))
.route("/api/explore/session", get(explore_session))
.route("/api/ask/chip", post(ask_chip))
.route("/api/ask/proposal", post(ask_proposal))
.route("/api/shape", post(shape))
.route("/api/shape/check", post(shape_check))
.route("/api/shape/judge", post(shape_judge))
.route("/api/bug", get(bug))
.route("/api/account", get(account))
.route("/api/review", get(review))
.route("/api/review/comments", get(review_comments))
.route("/api/approvals", get(approvals))
.route("/api/evidence", get(evidence))
.route("/api/tour", get(tour))
.route("/api/diff", get(diff))
.route("/api/documents", get(documents))
.route("/api/doctor", get(doctor))
.route("/api/hooks", get(hooks))
.route("/api/agents", get(agents))
.route("/api/job", get(job))
.route("/api/live", get(live))
.route("/api/config", get(config).post(config_save))
.route("/api/config/commit", post(config_commit))
.route("/api/policy/coverage", get(policy_coverage))
.route("/api/cost", get(cost))
.route("/api/changes", get(changes))
.route("/api/tree", get(tree))
.route("/api/file", get(file))
.route("/api/setup", get(setup).post(setup_init))
.route("/api/github", get(github))
.route("/api/github/issue", get(github_issue))
.route("/api/github/login", post(github_login))
.route("/api/github/device", post(github_device))
.route("/api/github/device/wait", post(github_device_wait))
.route("/api/pr/preview", get(pr_preview))
.route("/api/setup/commit", post(setup_commit))
.route("/api/events", get(events))
.route("/api/run", post(start))
.route("/api/answer", post(answer))
.route("/api/stop", post(stop))
.route("/api/fix/run", post(fix_run))
.route("/api/terminal", post(terminal))
.route("/api/bug-report", get(bug_report))
.route("/api/explain", get(explain))
.route("/api/welcome", get(welcome::view))
.route("/api/open", post(welcome::open))
.route("/api/recent/forget", post(welcome::forget))
.route("/api/folders", get(welcome::folders))
.route("/api/new/plan", get(welcome::plan))
.route("/api/new/first-feature", post(welcome::first_feature))
.route("/api/similar", get(welcome::similar))
.route("/api/suggest", get(welcome::suggest))
.route("/api/github/repos", get(welcome::repositories))
.route("/api/skill-cost", get(welcome::skill_cost))
.fallback(get(asset))
.layer(axum::middleware::from_fn_with_state(
s.clone(),
welcome::projectless,
))
.layer(axum::middleware::from_fn_with_state(s.clone(), guard))
.with_state(s)
}
pub type Events = tokio::sync::broadcast::Receiver<String>;
pub use tokio::sync::broadcast::error::RecvError;
#[derive(Debug)]
pub struct Started {
pub url: String,
pub port: u16,
pub events: Option<Events>,
pub root: Root,
}
pub fn serve(
root: &Path,
port: u16,
window: bool,
ready: impl FnOnce(Started) + Send,
) -> Result<(), String> {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async move {
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port)))
.await
.map_err(|e| format!("127.0.0.1:{port}: {e}"))?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let (close, closed) = tokio::sync::watch::channel(false);
let token = security::token();
if token.is_empty() {
return Err("no random source for the token".to_owned());
}
let Some(issuer) = fixes::Issuer::new() else {
return Err("no random source for the token".to_owned());
};
let root = Root::new(root, welcome::is_project(root));
let shared = Shared {
root: root.clone(),
token: token.clone(),
port,
hub: jobs::Hub::new(root.clone(), Arc::new(issuer)),
window,
closing: closed,
device: Arc::default(),
};
ready(Started {
url: format!("http://127.0.0.1:{port}/#token={token}"),
port,
events: Some(shared.hub.subscribe()),
root,
});
let (stop, stopping) = tokio::sync::oneshot::channel::<()>();
let server = axum::serve(listener, router(shared)).with_graceful_shutdown(async move {
let _ = stopping.await;
});
let signal = async move {
let _ = tokio::signal::ctrl_c().await;
let _ = close.send(true);
let _ = stop.send(());
tokio::time::sleep(GRACE).await;
};
tokio::select! {
served = server => served.map_err(|e| e.to_string()),
() = signal => Ok(()),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_archive_names_a_unit_the_project_has() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let r = dir.path();
std::fs::create_dir_all(r.join(".specify/memory")).unwrap_or_else(|e| panic!("{e}"));
std::fs::create_dir_all(r.join("specs/001-x")).unwrap_or_else(|e| panic!("{e}"));
std::fs::write(r.join("specs/001-x/spec.md"), "# Spec\n").unwrap_or_else(|e| panic!("{e}"));
assert!(unit_exists(r, "001-x"));
assert!(!unit_exists(r, "002-other"));
assert!(!unit_exists(r, "--yes"));
}
fn q(commit: Option<&str>, reference: Option<&str>) -> FeatureQuery {
FeatureQuery {
feature: None,
task: None,
commit: commit.map(str::to_owned),
reference: reference.map(str::to_owned),
}
}
#[test]
fn only_commits_and_attempt_refs_reach_git_show() {
assert!(revision(&q(Some("e23d4197bb"), None)).is_some());
assert!(revision(&q(Some("e23d"), None)).is_none());
assert!(revision(&q(Some("--output=/tmp/x"), None)).is_none());
let attempt = "refs/speccoder/attempts/001-greet/T001/run_01M5S5VJ";
assert_eq!(revision(&q(None, Some(attempt))).as_deref(), Some(attempt));
for bad in [
"refs/heads/main",
"refs/speccoder/attempts/",
"refs/speccoder/attempts/../../heads/main",
"refs/speccoder/attempts/a b",
"--all",
"HEAD",
] {
assert!(revision(&q(None, Some(bad))).is_none(), "{bad}");
}
}
}