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 "stability": format!("{:?}", d.stability),
272 }))
273 .collect();
274 Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
275 "api_version": scc_api::API_VERSION,
276 "scc_version": env!("CARGO_PKG_VERSION"),
277 "operations": ids,
278 }))?))
279 }
280 ("GET", p) if p.starts_with("/v1/operations/") => {
281 let id = p.trim_start_matches("/v1/operations/");
282 match scc_engine::ops::describe(id) {
283 None => json_err(404, format!("unknown operation '{id}' (see GET /v1/operations)")),
284 Some(d) => {
285 let schema = scc_engine::ops::input_schema(id);
286 Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
287 "api_version": scc_api::API_VERSION,
288 "scc_version": env!("CARGO_PKG_VERSION"),
289 "operation": serde_json::to_value(d)?,
290 "input_schema": schema,
291 }))?))
292 }
293 }
294 }
295 ("POST", p) if p.starts_with("/v1/operations/") => {
296 let id = p.trim_start_matches("/v1/operations/");
297 if scc_engine::ops::describe(id).is_none() {
298 return json_err(404, format!("unknown operation '{id}' (see GET /v1/operations)"));
299 }
300 let input: serde_json::Value = if body.trim().is_empty() {
301 serde_json::json!({})
302 } else {
303 match serde_json::from_str(body) {
304 Ok(v) => v,
305 Err(_) => return json_err(400, "invalid JSON body".to_string()),
306 }
307 };
308 match scc_engine::invoke(root, id, input) {
309 Ok(output) => Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
310 "operation": id,
311 "api_version": scc_api::API_VERSION,
312 "scc_version": env!("CARGO_PKG_VERSION"),
313 "output": output,
314 }))?)),
315 Err(e) => json_err(500, e.to_string()),
316 }
317 }
318 ("GET", "/healthz") => Ok((200, "text/plain".to_string(), "ok".into())),
319 ("GET", "/") | ("GET", "/components") | ("GET", "/flows") | ("GET", "/diagram")
320 | ("GET", "/search") => {
321 let store = crate::open_store(root)?;
322 if store.snapshot_status()?.is_none() {
323 return json_err(409, "not indexed".into());
324 }
325 let (vstatus, body) = crate::viewer::serve_viewer(&store, url);
326 if vstatus == 200 {
327 return html_ok(body);
328 }
329 Ok((vstatus, "text/html; charset=utf-8".to_string(), body))
330 }
331 ("GET", p) if crate::viewer::is_viewer_path(p) => {
332 let store = crate::open_store(root)?;
333 if store.snapshot_status()?.is_none() {
334 return json_err(409, "not indexed".into());
335 }
336 let (vstatus, body) = crate::viewer::serve_viewer(&store, url);
337 Ok((vstatus, "text/html; charset=utf-8".to_string(), body))
338 }
339 _ => {
340 let _ = addr;
341 json_err(404, format!("no route for {method} {path}"))
342 }
343 }
344}
345
346fn json_arr(v: &serde_json::Value, key: &str) -> Vec<String> {
348 v.get(key)
349 .and_then(|x| x.as_array())
350 .map(|a| {
351 a.iter()
352 .filter_map(|s| s.as_str().map(|x| x.to_string()))
353 .collect()
354 })
355 .unwrap_or_default()
356}
357
358pub fn ingest_runtime(store: &Store, body: &str) -> crate::Result<()> {
363 if body.contains("resourceSpans") {
364 scc_indexer::runtime::ingest_otlp_json(store, body)
365 .map_err(|e| crate::CliError::Other(e.to_string()))?;
366 return Ok(());
367 }
368 scc_indexer::runtime::ingest_simple_edges(store, body)
369 .map_err(|e| crate::CliError::Other(e.to_string()))?;
370 Ok(())
371}
372
373
374pub fn watch_loop(root: &Path) -> crate::Result<()> {
381 watch_loop_inner(root, false)
382}
383
384pub fn refresh_stale_by_hash(root: &Path) -> crate::Result<Vec<String>> {
388 let store = crate::open_store(root)?;
389 let mut paths = crate::stale_paths(&store)?;
392 drop(store);
393 paths.sort();
394 paths.dedup();
395 if !paths.is_empty() {
396 crate::commands::cmd_index_paths(root, &paths, true)?;
397 }
398 Ok(paths)
399}
400
401fn watch_loop_inner(root: &Path, quiet: bool) -> crate::Result<()> {
403 let (tx, rx) = mpsc::channel::<notify::Event>();
404 let mut watcher = match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
405 if let Ok(ev) = res {
406 let _ = tx.send(ev);
407 }
408 }) {
409 Ok(w) => w,
410 Err(e) => {
411 if !quiet {
412 eprintln!("watcher unavailable ({e}); falling back to content-hash sweep");
413 }
414 return hash_sweep_loop(root, quiet);
415 }
416 };
417 if let Err(e) = notify::Watcher::watch(&mut watcher, root, notify::RecursiveMode::Recursive) {
418 if !quiet {
419 eprintln!("watch {root:?} failed ({e}); falling back to content-hash sweep");
420 }
421 drop(watcher);
422 return hash_sweep_loop(root, quiet);
423 }
424
425 if !quiet {
426 println!("watching {} (ctrl-c to stop)", root.display());
427 }
428 let mut pending: std::collections::BTreeSet<String> = Default::default();
429 let mut last: std::time::Instant = std::time::Instant::now();
430 loop {
431 match rx.recv_timeout(Duration::from_millis(250)) {
432 Ok(ev) => {
433 for p in ev.paths {
434 if let Some(rel) = crate::relative_of(root, &p) {
435 pending.insert(rel);
436 }
437 }
438 last = std::time::Instant::now();
439 }
440 Err(mpsc::RecvTimeoutError::Timeout) => {
441 if pending.is_empty() {
442 continue;
443 }
444 if last.elapsed() < Duration::from_millis(400) {
445 continue; }
447 let paths: Vec<String> = std::mem::take(&mut pending).into_iter().collect();
448 let res = crate::commands::cmd_index_paths(root, &paths, true);
449 match res {
450 Ok(()) => {}
451 Err(e) => eprintln!("reindex error: {e}"),
452 }
453 }
454 Err(mpsc::RecvTimeoutError::Disconnected) => break,
455 }
456 }
457 Ok(())
458}
459
460fn hash_sweep_loop(root: &Path, quiet: bool) -> crate::Result<()> {
462 if !quiet {
463 println!("hash-sweep watching {} (ctrl-c to stop)", root.display());
464 }
465 loop {
466 if let Err(e) = refresh_stale_by_hash(root) {
467 eprintln!("hash sweep error: {e}");
468 }
469 std::thread::sleep(Duration::from_secs(2));
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use crate::benchctx::{copy_fixture, locate_fixtures_dir};
477
478 #[test]
479 fn remote_listen_fails_closed_without_opt_in() {
481 assert!(require_remote_opt_in("127.0.0.1:7777").is_ok());
482 assert!(require_remote_opt_in("localhost:7777").is_ok());
483 assert!(require_remote_opt_in("[::1]:7777").is_ok());
484 assert!(require_remote_opt_in("0.0.0.0:7777").is_err());
486 std::env::remove_var("SCC_ALLOW_REMOTE_LISTEN");
487 assert!(require_remote_opt_in("192.168.1.10:7777").is_err());
488 }
489
490 #[test]
491 fn hash_sweep_refreshes_edited_file() {
493 let fixtures = locate_fixtures_dir().expect("fixtures");
494 let src = fixtures.join("behavior-native");
495 let tmp = tempfile::TempDir::new().unwrap();
496 let root = tmp.path().join("repo");
497 copy_fixture(&src, &root);
498 crate::commands::cmd_index(&root, true).unwrap();
499 let store = crate::open_store(&root).unwrap();
500 assert!(crate::stale_paths(&store).unwrap().is_empty());
501 drop(store);
502 let app = root.join("app.py");
503 let mut text = std::fs::read_to_string(&app).unwrap();
504 text.push_str("\n# hash-sweep probe\n");
505 std::fs::write(&app, text).unwrap();
506 let stale = refresh_stale_by_hash(&root).unwrap();
507 assert!(
508 stale.iter().any(|p| p == "app.py" || p.ends_with("/app.py")),
509 "edited file must be in the hash sweep: {stale:?}"
510 );
511 let store = crate::open_store(&root).unwrap();
512 assert!(
513 crate::stale_paths(&store).unwrap().is_empty(),
514 "after sweep the snapshot must match disk"
515 );
516 }
517
518 #[test]
519 fn hash_sweep_indexes_newly_created_file() {
521 let fixtures = locate_fixtures_dir().expect("fixtures");
522 let src = fixtures.join("behavior-native");
523 let tmp = tempfile::TempDir::new().unwrap();
524 let root = tmp.path().join("repo");
525 copy_fixture(&src, &root);
526 crate::commands::cmd_index(&root, true).unwrap();
527 std::fs::write(root.join("fresh.py"), "def fresh():\n return 1\n").unwrap();
528 let stale = refresh_stale_by_hash(&root).unwrap();
529 assert!(
530 stale.iter().any(|p| p == "fresh.py" || p.ends_with("/fresh.py")),
531 "new file must be in the hash sweep: {stale:?}"
532 );
533 let store = crate::open_store(&root).unwrap();
534 let files: Vec<_> = store
535 .all_files()
536 .unwrap()
537 .into_iter()
538 .map(|(p, _, _, _, _)| p)
539 .collect();
540 assert!(
541 files.iter().any(|p| p == "fresh.py" || p.ends_with("/fresh.py")),
542 "new file must be indexed: {files:?}"
543 );
544 }
545}