use scc_store::Store;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
fn require_remote_opt_in(addr: &str) -> crate::Result<()> {
let loopback = match addr.parse::<std::net::SocketAddr>() {
Ok(sa) => sa.ip().is_loopback(),
Err(_) => {
let host = addr.strip_prefix('[').and_then(|s| s.split(']').next());
let host = host.unwrap_or_else(|| addr.split(':').next().unwrap_or(addr));
host == "localhost" || host == "127.0.0.1" || host == "::1"
}
};
if loopback {
return Ok(());
}
if std::env::var("SCC_ALLOW_REMOTE_LISTEN").as_deref() == Ok("1") {
eprintln!("warning: unauthenticated SCC daemon on non-loopback {addr} (explicit opt-in)");
return Ok(());
}
Err(crate::CliError::Other(format!(
"refusing non-loopback bind {addr}: the SCC daemon has no authentication; bind config.security.listen to 127.0.0.1 or set SCC_ALLOW_REMOTE_LISTEN=1 to opt in explicitly"
)))
}
pub fn serve(root: &Path) -> crate::Result<()> {
let config = crate::load_config(root)?;
let addr = config.security.listen.clone();
let watch_root = root.to_path_buf();
let _watcher_handle = if config.index.watch {
Some(std::thread::spawn(move || {
let _ = watch_loop_inner(&watch_root, true);
}))
} else {
None
};
require_remote_opt_in(&addr)?;
let server = tiny_http::Server::http(&addr)
.map_err(|e| crate::CliError::Other(format!("cannot bind {addr}: {e}")))?;
println!("scc daemon listening on http://{addr} (root {})", root.display());
for request in server.incoming_requests() {
let root = root.to_path_buf();
let addr = addr.clone();
let _ = handle_request(root, request, &addr);
}
Ok(())
}
fn handle_request(
root: PathBuf,
mut request: tiny_http::Request,
addr: &str,
) -> crate::Result<()> {
let url = request.url().to_string();
let method = request.method().clone();
let mut body = String::new();
if method == tiny_http::Method::Post {
let mut buf = Vec::new();
request.as_reader().take(8 * 1024 * 1024).read_to_end(&mut buf)?;
body = String::from_utf8_lossy(&buf).to_string();
}
let (status, ctype, payload) = route(&root, &method.to_string(), &url, &body, addr)?;
let response = tiny_http::Response::from_string(payload)
.with_status_code(status)
.with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], ctype.as_bytes()).unwrap(),
);
let _ = request.respond(response);
Ok(())
}
fn route(
root: &Path,
method: &str,
url: &str,
body: &str,
addr: &str,
) -> crate::Result<(u16, String, String)> {
let path = url.split('?').next().unwrap_or(url);
let json_err = |code: u16, msg: String| -> crate::Result<(u16, String, String)> {
Ok((
code,
"application/json".to_string(),
serde_json::to_string(&serde_json::json!({"error": msg}))?,
))
};
let html_ok = |body: String| -> crate::Result<(u16, String, String)> {
Ok((200, "text/html; charset=utf-8".to_string(), body))
};
match (method, path) {
("GET", "/v1/system") => {
let store = crate::open_store(root)?;
if store.snapshot_status()?.is_none() {
return json_err(409, "not indexed; POST /v1/index first".into());
}
let output = scc_engine::invoke(root, "context.overview", serde_json::json!({}))
.map_err(|e| crate::CliError::Other(e.to_string()))?;
Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
}
("POST", "/v1/context/task") => {
let req: serde_json::Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(_) => return json_err(400, "invalid JSON body".to_string()),
};
let goal = req.get("goal").and_then(|g| g.as_str()).unwrap_or("");
if goal.is_empty() {
return json_err(400, "missing required field: goal".into());
}
let store = crate::open_store(root)?;
if store.snapshot_status()?.is_none() {
return json_err(409, "not indexed".into());
}
let files = json_arr(&req, "files");
let symbols = json_arr(&req, "symbols");
let budget = req.get("token_budget").and_then(|b| b.as_u64()).map(|b| b as usize);
let output = scc_engine::invoke(root, "context.task", serde_json::json!({
"goal": goal, "files": files, "symbols": symbols,
"budget": budget, "hook": false,
}))
.map_err(|e| crate::CliError::Other(e.to_string()))?;
Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
}
("POST", "/v1/context/startup") => {
let input: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::json!({}));
let budget = input.get("token_budget").and_then(|b| b.as_u64()).map(|b| b as usize);
let store = crate::open_store(root)?;
if store.snapshot_status()?.is_none() {
return json_err(409, "not indexed; POST /v1/index first".into());
}
let output = scc_engine::invoke(root, "context.startup", serde_json::json!({"budget": budget}))
.map_err(|e| crate::CliError::Other(e.to_string()))?;
Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
}
("GET", "/v1/atlas") => {
let store = crate::open_store(root)?;
if store.snapshot_status()?.is_none() {
return json_err(409, "not indexed".into());
}
let output = scc_engine::invoke(root, "context.atlas", serde_json::json!({}))
.map_err(|e| crate::CliError::Other(e.to_string()))?;
Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
}
("GET", p) if p.starts_with("/v1/components/") => {
let id = p.trim_start_matches("/v1/components/");
let store = crate::open_store(root)?;
if store.snapshot_status()?.is_none() {
return json_err(409, "not indexed".into());
}
let output = scc_engine::invoke(root, "context.component", serde_json::json!({"id": id}))
.map_err(|e| crate::CliError::Other(e.to_string()))?;
Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
}
("GET", p) if p.starts_with("/v1/flows/") => {
let id = p.trim_start_matches("/v1/flows/");
let store = crate::open_store(root)?;
if store.snapshot_status()?.is_none() {
return json_err(409, "not indexed".into());
}
let output = scc_engine::invoke(root, "context.flow", serde_json::json!({"id": id}))
.map_err(|e| crate::CliError::Other(e.to_string()))?;
Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
}
("POST", "/v1/impact") => {
let req: serde_json::Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(_) => return json_err(400, "invalid JSON body".to_string()),
};
let store = crate::open_store(root)?;
if store.snapshot_status()?.is_none() {
return json_err(409, "not indexed".into());
}
let output = scc_engine::invoke(root, "context.impact", serde_json::json!({
"files": json_arr(&req, "files"), "symbols": json_arr(&req, "symbols"),
"diff": req.get("diff").and_then(|d| d.as_str()),
}))
.map_err(|e| crate::CliError::Other(e.to_string()))?;
Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
}
("POST", "/v1/verify") => {
let store = crate::open_store(root)?;
if store.snapshot_status()?.is_none() {
return json_err(409, "not indexed".into());
}
let output = scc_engine::invoke(root, "context.verify", serde_json::json!({}))
.map_err(|e| crate::CliError::Other(e.to_string()))?;
Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
}
("POST", "/v1/index") => {
scc_engine::invoke(root, "index.full", serde_json::json!({})).map_err(|e| crate::CliError::Other(e.to_string()))?;
let store = crate::open_store(root)?;
let status = store.snapshot_status()?;
Ok((
202,
"application/json".to_string(),
serde_json::to_string(&serde_json::json!({
"status": "ok",
"revision": status.map(|(s, _)| s.revision).unwrap_or_default(),
}))?,
))
}
("GET", "/v1/index/status") => {
let store = crate::open_store(root)?;
match store.snapshot_status()? {
Some((snap, files)) => Ok((
200,
"application/json".to_string(),
serde_json::to_string(&serde_json::json!({
"indexed": true,
"revision": snap.revision,
"branch": snap.branch,
"indexed_at": snap.indexed_at,
"files": files,
}))?,
)),
None => Ok((
200,
"application/json".to_string(),
serde_json::to_string(&serde_json::json!({"indexed": false}))?,
)),
}
}
("POST", "/v1/runtime/traces") => {
let input: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::json!({}));
let payload = input.get("body").and_then(|b| b.as_str()).unwrap_or(body);
scc_engine::invoke(root, "runtime.ingest", serde_json::json!({"body": payload})).map_err(|e| crate::CliError::Other(e.to_string()))?;
Ok((202, "application/json".to_string(), serde_json::to_string(&serde_json::json!({"status": "accepted"}))?))
}
("GET", "/v1/operations") => {
let ids: Vec<serde_json::Value> = scc_engine::ops::OPERATIONS
.iter()
.map(|d| serde_json::json!({
"id": d.id,
"description": d.description,
"mutation": format!("{:?}", d.mutation),
"streaming": d.streaming,
"stability": format!("{:?}", d.stability),
}))
.collect();
Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
"api_version": scc_api::API_VERSION,
"scc_version": env!("CARGO_PKG_VERSION"),
"operations": ids,
}))?))
}
("GET", p) if p.starts_with("/v1/operations/") => {
let id = p.trim_start_matches("/v1/operations/");
match scc_engine::ops::describe(id) {
None => json_err(404, format!("unknown operation '{id}' (see GET /v1/operations)")),
Some(d) => {
let schema = scc_engine::ops::input_schema(id);
Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
"api_version": scc_api::API_VERSION,
"scc_version": env!("CARGO_PKG_VERSION"),
"operation": serde_json::to_value(d)?,
"input_schema": schema,
}))?))
}
}
}
("POST", p) if p.starts_with("/v1/operations/") => {
let id = p.trim_start_matches("/v1/operations/");
if scc_engine::ops::describe(id).is_none() {
return json_err(404, format!("unknown operation '{id}' (see GET /v1/operations)"));
}
let input: serde_json::Value = if body.trim().is_empty() {
serde_json::json!({})
} else {
match serde_json::from_str(body) {
Ok(v) => v,
Err(_) => return json_err(400, "invalid JSON body".to_string()),
}
};
match scc_engine::invoke(root, id, input) {
Ok(output) => Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
"operation": id,
"api_version": scc_api::API_VERSION,
"scc_version": env!("CARGO_PKG_VERSION"),
"output": output,
}))?)),
Err(e) => json_err(500, e.to_string()),
}
}
("GET", "/healthz") => Ok((200, "text/plain".to_string(), "ok".into())),
("GET", "/") | ("GET", "/components") | ("GET", "/flows") | ("GET", "/diagram")
| ("GET", "/search") => {
let store = crate::open_store(root)?;
if store.snapshot_status()?.is_none() {
return json_err(409, "not indexed".into());
}
let (vstatus, body) = crate::viewer::serve_viewer(&store, url);
if vstatus == 200 {
return html_ok(body);
}
Ok((vstatus, "text/html; charset=utf-8".to_string(), body))
}
("GET", p) if crate::viewer::is_viewer_path(p) => {
let store = crate::open_store(root)?;
if store.snapshot_status()?.is_none() {
return json_err(409, "not indexed".into());
}
let (vstatus, body) = crate::viewer::serve_viewer(&store, url);
Ok((vstatus, "text/html; charset=utf-8".to_string(), body))
}
_ => {
let _ = addr;
json_err(404, format!("no route for {method} {path}"))
}
}
}
fn json_arr(v: &serde_json::Value, key: &str) -> Vec<String> {
v.get(key)
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(|x| x.to_string()))
.collect()
})
.unwrap_or_default()
}
pub fn ingest_runtime(store: &Store, body: &str) -> crate::Result<()> {
if body.contains("resourceSpans") {
scc_indexer::runtime::ingest_otlp_json(store, body)
.map_err(|e| crate::CliError::Other(e.to_string()))?;
return Ok(());
}
scc_indexer::runtime::ingest_simple_edges(store, body)
.map_err(|e| crate::CliError::Other(e.to_string()))?;
Ok(())
}
pub fn watch_loop(root: &Path) -> crate::Result<()> {
watch_loop_inner(root, false)
}
pub fn refresh_stale_by_hash(root: &Path) -> crate::Result<Vec<String>> {
let store = crate::open_store(root)?;
let mut paths = crate::stale_paths(&store)?;
drop(store);
paths.sort();
paths.dedup();
if !paths.is_empty() {
crate::commands::cmd_index_paths(root, &paths, true)?;
}
Ok(paths)
}
fn watch_loop_inner(root: &Path, quiet: bool) -> crate::Result<()> {
let (tx, rx) = mpsc::channel::<notify::Event>();
let mut watcher = match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(ev) = res {
let _ = tx.send(ev);
}
}) {
Ok(w) => w,
Err(e) => {
if !quiet {
eprintln!("watcher unavailable ({e}); falling back to content-hash sweep");
}
return hash_sweep_loop(root, quiet);
}
};
if let Err(e) = notify::Watcher::watch(&mut watcher, root, notify::RecursiveMode::Recursive) {
if !quiet {
eprintln!("watch {root:?} failed ({e}); falling back to content-hash sweep");
}
drop(watcher);
return hash_sweep_loop(root, quiet);
}
if !quiet {
println!("watching {} (ctrl-c to stop)", root.display());
}
let mut pending: std::collections::BTreeSet<String> = Default::default();
let mut last: std::time::Instant = std::time::Instant::now();
loop {
match rx.recv_timeout(Duration::from_millis(250)) {
Ok(ev) => {
for p in ev.paths {
if let Some(rel) = crate::relative_of(root, &p) {
pending.insert(rel);
}
}
last = std::time::Instant::now();
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if pending.is_empty() {
continue;
}
if last.elapsed() < Duration::from_millis(400) {
continue; }
let paths: Vec<String> = std::mem::take(&mut pending).into_iter().collect();
let res = crate::commands::cmd_index_paths(root, &paths, true);
match res {
Ok(()) => {}
Err(e) => eprintln!("reindex error: {e}"),
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
Ok(())
}
fn hash_sweep_loop(root: &Path, quiet: bool) -> crate::Result<()> {
if !quiet {
println!("hash-sweep watching {} (ctrl-c to stop)", root.display());
}
loop {
if let Err(e) = refresh_stale_by_hash(root) {
eprintln!("hash sweep error: {e}");
}
std::thread::sleep(Duration::from_secs(2));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::benchctx::{copy_fixture, locate_fixtures_dir};
#[test]
fn remote_listen_fails_closed_without_opt_in() {
assert!(require_remote_opt_in("127.0.0.1:7777").is_ok());
assert!(require_remote_opt_in("localhost:7777").is_ok());
assert!(require_remote_opt_in("[::1]:7777").is_ok());
assert!(require_remote_opt_in("0.0.0.0:7777").is_err());
std::env::remove_var("SCC_ALLOW_REMOTE_LISTEN");
assert!(require_remote_opt_in("192.168.1.10:7777").is_err());
}
#[test]
fn hash_sweep_refreshes_edited_file() {
let fixtures = locate_fixtures_dir().expect("fixtures");
let src = fixtures.join("behavior-native");
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("repo");
copy_fixture(&src, &root);
crate::commands::cmd_index(&root, true).unwrap();
let store = crate::open_store(&root).unwrap();
assert!(crate::stale_paths(&store).unwrap().is_empty());
drop(store);
let app = root.join("app.py");
let mut text = std::fs::read_to_string(&app).unwrap();
text.push_str("\n# hash-sweep probe\n");
std::fs::write(&app, text).unwrap();
let stale = refresh_stale_by_hash(&root).unwrap();
assert!(
stale.iter().any(|p| p == "app.py" || p.ends_with("/app.py")),
"edited file must be in the hash sweep: {stale:?}"
);
let store = crate::open_store(&root).unwrap();
assert!(
crate::stale_paths(&store).unwrap().is_empty(),
"after sweep the snapshot must match disk"
);
}
#[test]
fn hash_sweep_indexes_newly_created_file() {
let fixtures = locate_fixtures_dir().expect("fixtures");
let src = fixtures.join("behavior-native");
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("repo");
copy_fixture(&src, &root);
crate::commands::cmd_index(&root, true).unwrap();
std::fs::write(root.join("fresh.py"), "def fresh():\n return 1\n").unwrap();
let stale = refresh_stale_by_hash(&root).unwrap();
assert!(
stale.iter().any(|p| p == "fresh.py" || p.ends_with("/fresh.py")),
"new file must be in the hash sweep: {stale:?}"
);
let store = crate::open_store(&root).unwrap();
let files: Vec<_> = store
.all_files()
.unwrap()
.into_iter()
.map(|(p, _, _, _, _)| p)
.collect();
assert!(
files.iter().any(|p| p == "fresh.py" || p.ends_with("/fresh.py")),
"new file must be indexed: {files:?}"
);
}
}