1use scc_store::Store;
10use std::io::Read;
11use std::path::{Path, PathBuf};
12use std::sync::mpsc;
13use std::time::Duration;
14
15
16fn require_remote_opt_in(addr: &str) -> crate::Result<()> {
21 let loopback = match addr.parse::<std::net::SocketAddr>() {
22 Ok(sa) => sa.ip().is_loopback(),
23 Err(_) => {
24 let host = addr.strip_prefix('[').and_then(|s| s.split(']').next());
25 let host = host.unwrap_or_else(|| addr.split(':').next().unwrap_or(addr));
26 host == "localhost" || host == "127.0.0.1" || host == "::1"
27 }
28 };
29 if loopback {
30 return Ok(());
31 }
32 if std::env::var("SCC_ALLOW_REMOTE_LISTEN").as_deref() == Ok("1") {
33 eprintln!("warning: unauthenticated SCC daemon on non-loopback {addr} (explicit opt-in)");
34 return Ok(());
35 }
36 Err(crate::CliError::Other(format!(
37 "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"
38 )))
39}
40
41pub fn serve(root: &Path) -> crate::Result<()> {
43 let config = crate::load_config(root)?;
44 let addr = config.security.listen.clone();
45
46 let watch_root = root.to_path_buf();
48 let _watcher_handle = if config.index.watch {
49 Some(std::thread::spawn(move || {
50 let _ = watch_loop_inner(&watch_root, true);
51 }))
52 } else {
53 None
54 };
55
56 require_remote_opt_in(&addr)?;
60
61 let server = tiny_http::Server::http(&addr)
62 .map_err(|e| crate::CliError::Other(format!("cannot bind {addr}: {e}")))?;
63 println!("scc daemon listening on http://{addr} (root {})", root.display());
64 for request in server.incoming_requests() {
65 let root = root.to_path_buf();
66 let addr = addr.clone();
67 let _ = handle_request(root, request, &addr);
68 }
69 Ok(())
70}
71
72fn handle_request(
74 root: PathBuf,
75 mut request: tiny_http::Request,
76 addr: &str,
77) -> crate::Result<()> {
78 let url = request.url().to_string();
79 let method = request.method().clone();
80 let mut body = String::new();
81 if method == tiny_http::Method::Post {
82 let mut buf = Vec::new();
83 request.as_reader().take(8 * 1024 * 1024).read_to_end(&mut buf)?;
84 body = String::from_utf8_lossy(&buf).to_string();
85 }
86
87 let (status, ctype, payload) = route(&root, &method.to_string(), &url, &body, addr)?;
88 let response = tiny_http::Response::from_string(payload)
89 .with_status_code(status)
90 .with_header(
91 tiny_http::Header::from_bytes(&b"Content-Type"[..], ctype.as_bytes()).unwrap(),
92 );
93 let _ = request.respond(response);
94 Ok(())
95}
96fn route(
100 root: &Path,
101 method: &str,
102 url: &str,
103 body: &str,
104 addr: &str,
105) -> crate::Result<(u16, String, String)> {
106 let path = url.split('?').next().unwrap_or(url);
107 let json_err = |code: u16, msg: String| -> crate::Result<(u16, String, String)> {
108 Ok((
109 code,
110 "application/json".to_string(),
111 serde_json::to_string(&serde_json::json!({"error": msg}))?,
112 ))
113 };
114 let html_ok = |body: String| -> crate::Result<(u16, String, String)> {
116 Ok((200, "text/html; charset=utf-8".to_string(), body))
117 };
118
119 match (method, path) {
120 ("GET", "/v1/system") => {
121 let store = crate::open_store(root)?;
122 if store.snapshot_status()?.is_none() {
123 return json_err(409, "not indexed; POST /v1/index first".into());
124 }
125 let output = scc_engine::invoke(root, "context.overview", serde_json::json!({}))
126 .map_err(|e| crate::CliError::Other(e.to_string()))?;
127 Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
128 }
129 ("POST", "/v1/context/task") => {
130 let req: serde_json::Value = match serde_json::from_str(body) {
131 Ok(v) => v,
132 Err(_) => return json_err(400, "invalid JSON body".to_string()),
133 };
134 let goal = req.get("goal").and_then(|g| g.as_str()).unwrap_or("");
135 if goal.is_empty() {
136 return json_err(400, "missing required field: goal".into());
137 }
138 let store = crate::open_store(root)?;
139 if store.snapshot_status()?.is_none() {
140 return json_err(409, "not indexed".into());
141 }
142 let files = json_arr(&req, "files");
143 let symbols = json_arr(&req, "symbols");
144 let budget = req.get("token_budget").and_then(|b| b.as_u64()).map(|b| b as usize);
145 let output = scc_engine::invoke(root, "context.task", serde_json::json!({
149 "goal": goal, "files": files, "symbols": symbols,
150 "budget": budget, "hook": false,
151 }))
152 .map_err(|e| crate::CliError::Other(e.to_string()))?;
153 Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
154 }
155 ("POST", "/v1/context/startup") => {
156 let input: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::json!({}));
157 let budget = input.get("token_budget").and_then(|b| b.as_u64()).map(|b| b as usize);
158 let store = crate::open_store(root)?;
159 if store.snapshot_status()?.is_none() {
160 return json_err(409, "not indexed; POST /v1/index first".into());
161 }
162 let output = scc_engine::invoke(root, "context.startup", serde_json::json!({"budget": budget}))
165 .map_err(|e| crate::CliError::Other(e.to_string()))?;
166 Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
167 }
168 ("GET", "/v1/atlas") => {
169 let store = crate::open_store(root)?;
170 if store.snapshot_status()?.is_none() {
171 return json_err(409, "not indexed".into());
172 }
173 let output = scc_engine::invoke(root, "context.atlas", serde_json::json!({}))
174 .map_err(|e| crate::CliError::Other(e.to_string()))?;
175 Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
176 }
177 ("GET", p) if p.starts_with("/v1/components/") => {
178 let id = p.trim_start_matches("/v1/components/");
179 let store = crate::open_store(root)?;
180 if store.snapshot_status()?.is_none() {
181 return json_err(409, "not indexed".into());
182 }
183 let output = scc_engine::invoke(root, "context.component", serde_json::json!({"id": id}))
184 .map_err(|e| crate::CliError::Other(e.to_string()))?;
185 Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
186 }
187 ("GET", p) if p.starts_with("/v1/flows/") => {
188 let id = p.trim_start_matches("/v1/flows/");
189 let store = crate::open_store(root)?;
190 if store.snapshot_status()?.is_none() {
191 return json_err(409, "not indexed".into());
192 }
193 let output = scc_engine::invoke(root, "context.flow", serde_json::json!({"id": id}))
194 .map_err(|e| crate::CliError::Other(e.to_string()))?;
195 Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
196 }
197 ("POST", "/v1/impact") => {
198 let req: serde_json::Value = match serde_json::from_str(body) {
199 Ok(v) => v,
200 Err(_) => return json_err(400, "invalid JSON body".to_string()),
201 };
202 let store = crate::open_store(root)?;
203 if store.snapshot_status()?.is_none() {
204 return json_err(409, "not indexed".into());
205 }
206 let output = scc_engine::invoke(root, "context.impact", serde_json::json!({
207 "files": json_arr(&req, "files"), "symbols": json_arr(&req, "symbols"),
208 "diff": req.get("diff").and_then(|d| d.as_str()),
209 }))
210 .map_err(|e| crate::CliError::Other(e.to_string()))?;
211 Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
212 }
213 ("POST", "/v1/verify") => {
214 let store = crate::open_store(root)?;
215 if store.snapshot_status()?.is_none() {
216 return json_err(409, "not indexed".into());
217 }
218 let output = scc_engine::invoke(root, "context.verify", serde_json::json!({}))
219 .map_err(|e| crate::CliError::Other(e.to_string()))?;
220 Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
221 }
222 ("POST", "/v1/index") => {
223 scc_engine::invoke(root, "index.full", serde_json::json!({})).map_err(|e| crate::CliError::Other(e.to_string()))?;
224 let store = crate::open_store(root)?;
225 let status = store.snapshot_status()?;
226 Ok((
227 202,
228 "application/json".to_string(),
229 serde_json::to_string(&serde_json::json!({
230 "status": "ok",
231 "revision": status.map(|(s, _)| s.revision).unwrap_or_default(),
232 }))?,
233 ))
234 }
235 ("GET", "/v1/index/status") => {
236 let store = crate::open_store(root)?;
237 match store.snapshot_status()? {
238 Some((snap, files)) => Ok((
239 200,
240 "application/json".to_string(),
241 serde_json::to_string(&serde_json::json!({
242 "indexed": true,
243 "revision": snap.revision,
244 "branch": snap.branch,
245 "indexed_at": snap.indexed_at,
246 "files": files,
247 }))?,
248 )),
249 None => Ok((
250 200,
251 "application/json".to_string(),
252 serde_json::to_string(&serde_json::json!({"indexed": false}))?,
253 )),
254 }
255 }
256 ("POST", "/v1/runtime/traces") => {
257 let input: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::json!({}));
258 let payload = input.get("body").and_then(|b| b.as_str()).unwrap_or(body);
260 scc_engine::invoke(root, "runtime.ingest", serde_json::json!({"body": payload})).map_err(|e| crate::CliError::Other(e.to_string()))?;
261 Ok((202, "application/json".to_string(), serde_json::to_string(&serde_json::json!({"status": "accepted"}))?))
262 }
263 ("GET", "/v1/operations") => {
264 let ids: Vec<serde_json::Value> = scc_engine::ops::OPERATIONS
265 .iter()
266 .map(|d| serde_json::json!({
267 "id": d.id,
268 "description": d.description,
269 "mutation": format!("{:?}", d.mutation),
270 "streaming": d.streaming,
271 }))
272 .collect();
273 Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
274 "api_version": scc_api::API_VERSION,
275 "scc_version": env!("CARGO_PKG_VERSION"),
276 "operations": ids,
277 }))?))
278 }
279 ("POST", p) if p.starts_with("/v1/operations/") => {
280 let id = p.trim_start_matches("/v1/operations/");
281 if scc_engine::ops::describe(id).is_none() {
282 return json_err(404, format!("unknown operation '{id}' (see GET /v1/operations)"));
283 }
284 let input: serde_json::Value = if body.trim().is_empty() {
285 serde_json::json!({})
286 } else {
287 match serde_json::from_str(body) {
288 Ok(v) => v,
289 Err(_) => return json_err(400, "invalid JSON body".to_string()),
290 }
291 };
292 match scc_engine::invoke(root, id, input) {
293 Ok(output) => Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
294 "operation": id,
295 "api_version": scc_api::API_VERSION,
296 "scc_version": env!("CARGO_PKG_VERSION"),
297 "output": output,
298 }))?)),
299 Err(e) => json_err(500, e.to_string()),
300 }
301 }
302 ("GET", "/healthz") => Ok((200, "text/plain".to_string(), "ok".into())),
303 ("GET", "/") | ("GET", "/components") | ("GET", "/flows") | ("GET", "/diagram")
304 | ("GET", "/search") => {
305 let store = crate::open_store(root)?;
306 if store.snapshot_status()?.is_none() {
307 return json_err(409, "not indexed".into());
308 }
309 let (vstatus, body) = crate::viewer::serve_viewer(&store, url);
310 if vstatus == 200 {
311 return html_ok(body);
312 }
313 Ok((vstatus, "text/html; charset=utf-8".to_string(), body))
314 }
315 ("GET", p) if crate::viewer::is_viewer_path(p) => {
316 let store = crate::open_store(root)?;
317 if store.snapshot_status()?.is_none() {
318 return json_err(409, "not indexed".into());
319 }
320 let (vstatus, body) = crate::viewer::serve_viewer(&store, url);
321 Ok((vstatus, "text/html; charset=utf-8".to_string(), body))
322 }
323 _ => {
324 let _ = addr;
325 json_err(404, format!("no route for {method} {path}"))
326 }
327 }
328}
329
330fn json_arr(v: &serde_json::Value, key: &str) -> Vec<String> {
332 v.get(key)
333 .and_then(|x| x.as_array())
334 .map(|a| {
335 a.iter()
336 .filter_map(|s| s.as_str().map(|x| x.to_string()))
337 .collect()
338 })
339 .unwrap_or_default()
340}
341
342pub fn ingest_runtime(store: &Store, body: &str) -> crate::Result<()> {
347 if body.contains("resourceSpans") {
348 scc_indexer::runtime::ingest_otlp_json(store, body)
349 .map_err(|e| crate::CliError::Other(e.to_string()))?;
350 return Ok(());
351 }
352 scc_indexer::runtime::ingest_simple_edges(store, body)
353 .map_err(|e| crate::CliError::Other(e.to_string()))?;
354 Ok(())
355}
356
357
358pub fn watch_loop(root: &Path) -> crate::Result<()> {
365 watch_loop_inner(root, false)
366}
367
368pub fn refresh_stale_by_hash(root: &Path) -> crate::Result<Vec<String>> {
372 let store = crate::open_store(root)?;
373 let mut paths = crate::stale_paths(&store)?;
376 drop(store);
377 paths.sort();
378 paths.dedup();
379 if !paths.is_empty() {
380 crate::commands::cmd_index_paths(root, &paths, true)?;
381 }
382 Ok(paths)
383}
384
385fn watch_loop_inner(root: &Path, quiet: bool) -> crate::Result<()> {
387 let (tx, rx) = mpsc::channel::<notify::Event>();
388 let mut watcher = match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
389 if let Ok(ev) = res {
390 let _ = tx.send(ev);
391 }
392 }) {
393 Ok(w) => w,
394 Err(e) => {
395 if !quiet {
396 eprintln!("watcher unavailable ({e}); falling back to content-hash sweep");
397 }
398 return hash_sweep_loop(root, quiet);
399 }
400 };
401 if let Err(e) = notify::Watcher::watch(&mut watcher, root, notify::RecursiveMode::Recursive) {
402 if !quiet {
403 eprintln!("watch {root:?} failed ({e}); falling back to content-hash sweep");
404 }
405 drop(watcher);
406 return hash_sweep_loop(root, quiet);
407 }
408
409 if !quiet {
410 println!("watching {} (ctrl-c to stop)", root.display());
411 }
412 let mut pending: std::collections::BTreeSet<String> = Default::default();
413 let mut last: std::time::Instant = std::time::Instant::now();
414 loop {
415 match rx.recv_timeout(Duration::from_millis(250)) {
416 Ok(ev) => {
417 for p in ev.paths {
418 if let Some(rel) = crate::relative_of(root, &p) {
419 pending.insert(rel);
420 }
421 }
422 last = std::time::Instant::now();
423 }
424 Err(mpsc::RecvTimeoutError::Timeout) => {
425 if pending.is_empty() {
426 continue;
427 }
428 if last.elapsed() < Duration::from_millis(400) {
429 continue; }
431 let paths: Vec<String> = std::mem::take(&mut pending).into_iter().collect();
432 let res = crate::commands::cmd_index_paths(root, &paths, true);
433 match res {
434 Ok(()) => {}
435 Err(e) => eprintln!("reindex error: {e}"),
436 }
437 }
438 Err(mpsc::RecvTimeoutError::Disconnected) => break,
439 }
440 }
441 Ok(())
442}
443
444fn hash_sweep_loop(root: &Path, quiet: bool) -> crate::Result<()> {
446 if !quiet {
447 println!("hash-sweep watching {} (ctrl-c to stop)", root.display());
448 }
449 loop {
450 if let Err(e) = refresh_stale_by_hash(root) {
451 eprintln!("hash sweep error: {e}");
452 }
453 std::thread::sleep(Duration::from_secs(2));
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::benchctx::{copy_fixture, locate_fixtures_dir};
461
462 #[test]
463 fn remote_listen_fails_closed_without_opt_in() {
465 assert!(require_remote_opt_in("127.0.0.1:7777").is_ok());
466 assert!(require_remote_opt_in("localhost:7777").is_ok());
467 assert!(require_remote_opt_in("[::1]:7777").is_ok());
468 assert!(require_remote_opt_in("0.0.0.0:7777").is_err());
470 std::env::remove_var("SCC_ALLOW_REMOTE_LISTEN");
471 assert!(require_remote_opt_in("192.168.1.10:7777").is_err());
472 }
473
474 #[test]
475 fn hash_sweep_refreshes_edited_file() {
477 let fixtures = locate_fixtures_dir().expect("fixtures");
478 let src = fixtures.join("behavior-native");
479 let tmp = tempfile::TempDir::new().unwrap();
480 let root = tmp.path().join("repo");
481 copy_fixture(&src, &root);
482 crate::commands::cmd_index(&root, true).unwrap();
483 let store = crate::open_store(&root).unwrap();
484 assert!(crate::stale_paths(&store).unwrap().is_empty());
485 drop(store);
486 let app = root.join("app.py");
487 let mut text = std::fs::read_to_string(&app).unwrap();
488 text.push_str("\n# hash-sweep probe\n");
489 std::fs::write(&app, text).unwrap();
490 let stale = refresh_stale_by_hash(&root).unwrap();
491 assert!(
492 stale.iter().any(|p| p == "app.py" || p.ends_with("/app.py")),
493 "edited file must be in the hash sweep: {stale:?}"
494 );
495 let store = crate::open_store(&root).unwrap();
496 assert!(
497 crate::stale_paths(&store).unwrap().is_empty(),
498 "after sweep the snapshot must match disk"
499 );
500 }
501
502 #[test]
503 fn hash_sweep_indexes_newly_created_file() {
505 let fixtures = locate_fixtures_dir().expect("fixtures");
506 let src = fixtures.join("behavior-native");
507 let tmp = tempfile::TempDir::new().unwrap();
508 let root = tmp.path().join("repo");
509 copy_fixture(&src, &root);
510 crate::commands::cmd_index(&root, true).unwrap();
511 std::fs::write(root.join("fresh.py"), "def fresh():\n return 1\n").unwrap();
512 let stale = refresh_stale_by_hash(&root).unwrap();
513 assert!(
514 stale.iter().any(|p| p == "fresh.py" || p.ends_with("/fresh.py")),
515 "new file must be in the hash sweep: {stale:?}"
516 );
517 let store = crate::open_store(&root).unwrap();
518 let files: Vec<_> = store
519 .all_files()
520 .unwrap()
521 .into_iter()
522 .map(|(p, _, _, _, _)| p)
523 .collect();
524 assert!(
525 files.iter().any(|p| p == "fresh.py" || p.ends_with("/fresh.py")),
526 "new file must be indexed: {files:?}"
527 );
528 }
529}