@neat.is/core 0.3.8 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  mountBearerAuth
3
- } from "./chunk-7TYESDAI.js";
3
+ } from "./chunk-KYRIQIPG.js";
4
4
 
5
5
  // src/graph.ts
6
6
  import GraphDefault from "graphology";
@@ -1104,52 +1104,64 @@ function cacheSpanService(span, now) {
1104
1104
  if (!span.traceId || !span.spanId) return;
1105
1105
  const key = parentSpanKey(span.traceId, span.spanId);
1106
1106
  parentSpanCache.delete(key);
1107
- parentSpanCache.set(key, { service: span.service, expiresAt: now + PARENT_SPAN_CACHE_TTL_MS });
1107
+ parentSpanCache.set(key, {
1108
+ service: span.service,
1109
+ env: span.env ?? "unknown",
1110
+ expiresAt: now + PARENT_SPAN_CACHE_TTL_MS
1111
+ });
1108
1112
  while (parentSpanCache.size > PARENT_SPAN_CACHE_SIZE) {
1109
1113
  const oldest = parentSpanCache.keys().next().value;
1110
1114
  if (!oldest) break;
1111
1115
  parentSpanCache.delete(oldest);
1112
1116
  }
1113
1117
  }
1114
- function lookupParentSpanService(traceId, parentSpanId, now) {
1118
+ function lookupParentSpan(traceId, parentSpanId, now) {
1115
1119
  const entry = parentSpanCache.get(parentSpanKey(traceId, parentSpanId));
1116
1120
  if (!entry) return null;
1117
1121
  if (entry.expiresAt <= now) {
1118
1122
  parentSpanCache.delete(parentSpanKey(traceId, parentSpanId));
1119
1123
  return null;
1120
1124
  }
1121
- return entry.service;
1125
+ return { service: entry.service, env: entry.env };
1122
1126
  }
1123
- function resolveServiceId(graph, host) {
1124
- const direct = serviceId(host);
1125
- if (graph.hasNode(direct)) return direct;
1126
- let found = null;
1127
+ function resolveServiceId(graph, host, env) {
1128
+ const envTagged = serviceId(host, env);
1129
+ if (graph.hasNode(envTagged)) return envTagged;
1130
+ const envLess = serviceId(host);
1131
+ if (envLess !== envTagged && graph.hasNode(envLess)) return envLess;
1132
+ let sameEnv = null;
1133
+ let envLessMatch = null;
1134
+ let anyMatch = null;
1127
1135
  graph.forEachNode((id, attrs) => {
1128
- if (found) return;
1136
+ if (sameEnv) return;
1129
1137
  const a = attrs;
1130
1138
  if (a.type !== NodeType3.ServiceNode) return;
1131
- if (a.name === host) {
1132
- found = id;
1139
+ const matchesByName = a.name === host;
1140
+ const matchesByAlias = a.aliases ? a.aliases.includes(host) : false;
1141
+ if (!matchesByName && !matchesByAlias) return;
1142
+ const nodeEnv = a.env ?? "unknown";
1143
+ if (nodeEnv === env) {
1144
+ sameEnv = id;
1133
1145
  return;
1134
1146
  }
1135
- if (a.aliases && a.aliases.includes(host)) {
1136
- found = id;
1137
- }
1147
+ if (nodeEnv === "unknown" && !envLessMatch) envLessMatch = id;
1148
+ else if (!anyMatch) anyMatch = id;
1138
1149
  });
1139
- return found;
1150
+ return sameEnv ?? envLessMatch ?? anyMatch;
1140
1151
  }
1141
1152
  function frontierIdFor(host) {
1142
1153
  return frontierId(host);
1143
1154
  }
1144
- function ensureServiceNode(graph, serviceName) {
1145
- const id = serviceId(serviceName);
1155
+ function ensureServiceNode(graph, serviceName, env) {
1156
+ const id = serviceId(serviceName, env);
1146
1157
  if (graph.hasNode(id)) return id;
1147
1158
  const node = {
1148
1159
  id,
1149
1160
  type: NodeType3.ServiceNode,
1150
1161
  name: serviceName,
1151
1162
  language: "unknown",
1152
- discoveredVia: "otel"
1163
+ discoveredVia: "otel",
1164
+ ...env !== "unknown" ? { env } : {}
1153
1165
  };
1154
1166
  graph.addNode(id, node);
1155
1167
  return id;
@@ -1295,7 +1307,7 @@ function buildErrorEventForReceiver(span) {
1295
1307
  ...span.exception?.type ? { exceptionType: span.exception.type } : {},
1296
1308
  ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
1297
1309
  ...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
1298
- affectedNode: serviceId(span.service)
1310
+ affectedNode: serviceId(span.service, span.env)
1299
1311
  };
1300
1312
  }
1301
1313
  function makeErrorSpanWriter(errorsPath) {
@@ -1309,7 +1321,8 @@ function makeErrorSpanWriter(errorsPath) {
1309
1321
  async function handleSpan(ctx, span) {
1310
1322
  const ts = span.startTimeIso ?? nowIso(ctx);
1311
1323
  const nowMs = ctx.now ? ctx.now() : Date.now();
1312
- const sourceId = ensureServiceNode(ctx.graph, span.service);
1324
+ const env = span.env ?? "unknown";
1325
+ const sourceId = ensureServiceNode(ctx.graph, span.service, env);
1313
1326
  const isError = span.statusCode === 2;
1314
1327
  cacheSpanService(span, nowMs);
1315
1328
  let affectedNode = sourceId;
@@ -1332,7 +1345,7 @@ async function handleSpan(ctx, span) {
1332
1345
  const host = pickAddress(span);
1333
1346
  let resolvedViaAddress = false;
1334
1347
  if (host && host !== span.service) {
1335
- const targetId = resolveServiceId(ctx.graph, host);
1348
+ const targetId = resolveServiceId(ctx.graph, host, env);
1336
1349
  if (targetId && targetId !== sourceId) {
1337
1350
  upsertObservedEdge(
1338
1351
  ctx.graph,
@@ -1359,9 +1372,9 @@ async function handleSpan(ctx, span) {
1359
1372
  }
1360
1373
  }
1361
1374
  if (!resolvedViaAddress && span.parentSpanId) {
1362
- const parentService = lookupParentSpanService(span.traceId, span.parentSpanId, nowMs);
1363
- if (parentService && parentService !== span.service) {
1364
- const parentId = ensureServiceNode(ctx.graph, parentService);
1375
+ const parent = lookupParentSpan(span.traceId, span.parentSpanId, nowMs);
1376
+ if (parent && parent.service !== span.service) {
1377
+ const parentId = ensureServiceNode(ctx.graph, parent.service, parent.env);
1365
1378
  upsertObservedEdge(
1366
1379
  ctx.graph,
1367
1380
  EdgeType2.CALLS,
@@ -1557,6 +1570,28 @@ async function readErrorEvents(errorsPath) {
1557
1570
  throw err;
1558
1571
  }
1559
1572
  }
1573
+ function mergeSnapshot(graph, snapshot) {
1574
+ const exported = snapshot.graph;
1575
+ let nodesAdded = 0;
1576
+ let edgesAdded = 0;
1577
+ for (const node of exported.nodes ?? []) {
1578
+ if (graph.hasNode(node.key)) continue;
1579
+ if (!node.attributes) continue;
1580
+ graph.addNode(node.key, node.attributes);
1581
+ nodesAdded++;
1582
+ }
1583
+ for (const edge of exported.edges ?? []) {
1584
+ const attrs = edge.attributes;
1585
+ if (!attrs) continue;
1586
+ const id = edge.key ?? attrs.id;
1587
+ if (!id) continue;
1588
+ if (graph.hasEdge(id)) continue;
1589
+ if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
1590
+ graph.addEdgeWithKey(id, edge.source, edge.target, attrs);
1591
+ edgesAdded++;
1592
+ }
1593
+ return { nodesAdded, edgesAdded };
1594
+ }
1560
1595
 
1561
1596
  // src/extract/errors.ts
1562
1597
  import { promises as fs4 } from "fs";
@@ -1633,8 +1668,36 @@ var IGNORED_DIRS = /* @__PURE__ */ new Set([
1633
1668
  ".turbo",
1634
1669
  "dist",
1635
1670
  "build",
1636
- ".next"
1671
+ ".next",
1672
+ // Python virtualenv shapes (issue #344). Walking into a venv pulls in the
1673
+ // entire CPython stdlib + every installed package as if it were first-party
1674
+ // service code — 20k+ files, none of which the user wrote. The shape names
1675
+ // cover the three common venv tools (`venv` / `python -m venv`,
1676
+ // `virtualenv`) and the tox + PEP 582 conventions.
1677
+ ".venv",
1678
+ "venv",
1679
+ "__pypackages__",
1680
+ ".tox",
1681
+ // `site-packages` shows up nested inside venvs that don't carry one of the
1682
+ // outer names (system Python on macOS, in-place pyenv layouts). Listing it
1683
+ // here means we stop the walk at the boundary even when the wrapper dir
1684
+ // wasn't recognisable.
1685
+ "site-packages"
1637
1686
  ]);
1687
+ var PYVENV_MARKER_CACHE = /* @__PURE__ */ new Map();
1688
+ async function isPythonVenvDir(dir) {
1689
+ const cached = PYVENV_MARKER_CACHE.get(dir);
1690
+ if (cached !== void 0) return cached;
1691
+ try {
1692
+ const stat = await fs5.stat(path5.join(dir, "pyvenv.cfg"));
1693
+ const ok = stat.isFile();
1694
+ PYVENV_MARKER_CACHE.set(dir, ok);
1695
+ return ok;
1696
+ } catch {
1697
+ PYVENV_MARKER_CACHE.set(dir, false);
1698
+ return false;
1699
+ }
1700
+ }
1638
1701
  function isConfigFile(name) {
1639
1702
  const ext = path5.extname(name);
1640
1703
  if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
@@ -1925,6 +1988,7 @@ async function walkDirs(start, scanPath, options, visit) {
1925
1988
  const rel = path8.relative(scanPath, child).split(path8.sep).join("/");
1926
1989
  if (rel && options.ig.ignores(rel + "/")) continue;
1927
1990
  }
1991
+ if (await isPythonVenvDir(child)) continue;
1928
1992
  await visit(child);
1929
1993
  await recurse(child, depth + 1);
1930
1994
  }
@@ -1960,6 +2024,18 @@ async function expandWorkspaceGlobs(scanPath, globs) {
1960
2024
  }
1961
2025
  return [...found];
1962
2026
  }
2027
+ function detectJsFramework(pkg) {
2028
+ const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
2029
+ if (deps["next"] !== void 0) return "next";
2030
+ if (deps["remix"] !== void 0) return "remix";
2031
+ for (const k of Object.keys(deps)) {
2032
+ if (k.startsWith("@remix-run/")) return "remix";
2033
+ }
2034
+ if (deps["@sveltejs/kit"] !== void 0) return "sveltekit";
2035
+ if (deps["nuxt"] !== void 0) return "nuxt";
2036
+ if (deps["astro"] !== void 0) return "astro";
2037
+ return void 0;
2038
+ }
1963
2039
  async function discoverNodeService(scanPath, dir) {
1964
2040
  const pkgPath = path8.join(dir, "package.json");
1965
2041
  if (!await exists(pkgPath)) return null;
@@ -1971,6 +2047,7 @@ async function discoverNodeService(scanPath, dir) {
1971
2047
  return null;
1972
2048
  }
1973
2049
  if (!pkg.name) return null;
2050
+ const framework = detectJsFramework(pkg);
1974
2051
  const node = {
1975
2052
  id: serviceId2(pkg.name),
1976
2053
  type: NodeType4.ServiceNode,
@@ -1979,7 +2056,8 @@ async function discoverNodeService(scanPath, dir) {
1979
2056
  version: pkg.version,
1980
2057
  dependencies: pkg.dependencies ?? {},
1981
2058
  repoPath: path8.relative(scanPath, dir),
1982
- ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {}
2059
+ ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {},
2060
+ ...framework ? { framework } : {}
1983
2061
  };
1984
2062
  return { pkg, dir, node };
1985
2063
  }
@@ -2188,7 +2266,9 @@ async function walkYamlFiles(start, depth = 0, max = 5) {
2188
2266
  for (const entry of entries) {
2189
2267
  if (entry.isDirectory()) {
2190
2268
  if (IGNORED_DIRS.has(entry.name)) continue;
2191
- out.push(...await walkYamlFiles(path9.join(start, entry.name), depth + 1, max));
2269
+ const child = path9.join(start, entry.name);
2270
+ if (await isPythonVenvDir(child)) continue;
2271
+ out.push(...await walkYamlFiles(child, depth + 1, max));
2192
2272
  } else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(path9.extname(entry.name))) {
2193
2273
  out.push(path9.join(start, entry.name));
2194
2274
  }
@@ -2870,7 +2950,9 @@ async function walkConfigFiles(dir) {
2870
2950
  for (const entry of entries) {
2871
2951
  const full = path18.join(current, entry.name);
2872
2952
  if (entry.isDirectory()) {
2873
- if (!IGNORED_DIRS.has(entry.name)) await walk(full);
2953
+ if (IGNORED_DIRS.has(entry.name)) continue;
2954
+ if (await isPythonVenvDir(full)) continue;
2955
+ await walk(full);
2874
2956
  } else if (entry.isFile() && isConfigFile(entry.name).match) {
2875
2957
  out.push(full);
2876
2958
  }
@@ -2946,7 +3028,9 @@ async function walkSourceFiles(dir) {
2946
3028
  for (const entry of entries) {
2947
3029
  const full = path19.join(current, entry.name);
2948
3030
  if (entry.isDirectory()) {
2949
- if (!IGNORED_DIRS.has(entry.name)) await walk(full);
3031
+ if (IGNORED_DIRS.has(entry.name)) continue;
3032
+ if (await isPythonVenvDir(full)) continue;
3033
+ await walk(full);
2950
3034
  } else if (entry.isFile() && SERVICE_FILE_EXTENSIONS.has(path19.extname(entry.name))) {
2951
3035
  out.push(full);
2952
3036
  }
@@ -3571,7 +3655,9 @@ async function walkTfFiles(start, depth = 0, max = 5) {
3571
3655
  for (const entry of entries) {
3572
3656
  if (entry.isDirectory()) {
3573
3657
  if (IGNORED_DIRS.has(entry.name) || entry.name === ".terraform") continue;
3574
- out.push(...await walkTfFiles(path27.join(start, entry.name), depth + 1, max));
3658
+ const child = path27.join(start, entry.name);
3659
+ if (await isPythonVenvDir(child)) continue;
3660
+ out.push(...await walkTfFiles(child, depth + 1, max));
3575
3661
  } else if (entry.isFile() && entry.name.endsWith(".tf")) {
3576
3662
  out.push(path27.join(start, entry.name));
3577
3663
  }
@@ -3618,7 +3704,9 @@ async function walkYamlFiles2(start, depth = 0, max = 5) {
3618
3704
  for (const entry of entries) {
3619
3705
  if (entry.isDirectory()) {
3620
3706
  if (IGNORED_DIRS.has(entry.name)) continue;
3621
- out.push(...await walkYamlFiles2(path28.join(start, entry.name), depth + 1, max));
3707
+ const child = path28.join(start, entry.name);
3708
+ if (await isPythonVenvDir(child)) continue;
3709
+ out.push(...await walkYamlFiles2(child, depth + 1, max));
3622
3710
  } else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(path28.extname(entry.name))) {
3623
3711
  out.push(path28.join(start, entry.name));
3624
3712
  }
@@ -4009,7 +4097,7 @@ function computeDivergences(graph, opts = {}) {
4009
4097
  import { promises as fs17 } from "fs";
4010
4098
  import path31 from "path";
4011
4099
  import { Provenance as Provenance10, observedEdgeId as observedEdgeId2 } from "@neat.is/types";
4012
- var SCHEMA_VERSION = 3;
4100
+ var SCHEMA_VERSION = 4;
4013
4101
  function migrateV1ToV2(payload) {
4014
4102
  const nodes = payload.graph.nodes;
4015
4103
  if (Array.isArray(nodes)) {
@@ -4021,6 +4109,9 @@ function migrateV1ToV2(payload) {
4021
4109
  }
4022
4110
  return { ...payload, schemaVersion: 2 };
4023
4111
  }
4112
+ function migrateV3ToV4(payload) {
4113
+ return { ...payload, schemaVersion: 4 };
4114
+ }
4024
4115
  function migrateV2ToV3(payload) {
4025
4116
  const edges = payload.graph.edges;
4026
4117
  if (Array.isArray(edges)) {
@@ -4069,6 +4160,9 @@ async function loadGraphFromDisk(graph, outPath) {
4069
4160
  if (payload.schemaVersion === 2) {
4070
4161
  payload = migrateV2ToV3(payload);
4071
4162
  }
4163
+ if (payload.schemaVersion === 3) {
4164
+ payload = migrateV3ToV4(payload);
4165
+ }
4072
4166
  if (payload.schemaVersion !== SCHEMA_VERSION) {
4073
4167
  throw new Error(
4074
4168
  `persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
@@ -4486,10 +4580,19 @@ function projectFromReq(req) {
4486
4580
  const params = req.params;
4487
4581
  return params.project ?? DEFAULT_PROJECT;
4488
4582
  }
4489
- function resolveProject(registry, req, reply) {
4583
+ function resolveProject(registry, req, reply, bootstrap) {
4490
4584
  const name = projectFromReq(req);
4491
4585
  const ctx = registry.get(name);
4492
4586
  if (!ctx) {
4587
+ const phase = bootstrap?.status(name);
4588
+ if (phase === "bootstrapping") {
4589
+ void reply.code(503).send({ ready: false, project: name, status: "bootstrapping" });
4590
+ return null;
4591
+ }
4592
+ if (phase === "broken") {
4593
+ void reply.code(503).send({ ready: false, project: name, status: "broken" });
4594
+ return null;
4595
+ }
4493
4596
  void reply.code(404).send({ error: "project not found", project: name });
4494
4597
  return null;
4495
4598
  }
@@ -4519,36 +4622,38 @@ function buildLegacyRegistry(opts) {
4519
4622
  function registerRoutes(scope, ctx) {
4520
4623
  const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
4521
4624
  scope.get("/events", (req, reply) => {
4522
- const proj = resolveProject(registry, req, reply);
4625
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4523
4626
  if (!proj) return;
4524
4627
  handleSse(req, reply, { project: proj.name });
4525
4628
  });
4526
- scope.get("/health", async (req, reply) => {
4527
- const proj = resolveProject(registry, req, reply);
4528
- if (!proj) return;
4529
- const uptimeMs = Date.now() - startedAt;
4530
- return {
4531
- ok: true,
4532
- project: proj.name,
4533
- uptimeMs,
4534
- // Legacy fields kept additively. The web shell's StatusBar reads
4535
- // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
4536
- // the canonical triple and lets the extras pass through.
4537
- uptime: Math.floor(uptimeMs / 1e3),
4538
- nodeCount: proj.graph.order,
4539
- edgeCount: proj.graph.size,
4540
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
4541
- };
4542
- });
4629
+ if (ctx.scope === "project") {
4630
+ scope.get("/health", async (req, reply) => {
4631
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4632
+ if (!proj) return;
4633
+ const uptimeMs = Date.now() - startedAt;
4634
+ return {
4635
+ ok: true,
4636
+ project: proj.name,
4637
+ uptimeMs,
4638
+ // Legacy fields kept additively. The web shell's StatusBar reads
4639
+ // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
4640
+ // the canonical triple and lets the extras pass through.
4641
+ uptime: Math.floor(uptimeMs / 1e3),
4642
+ nodeCount: proj.graph.order,
4643
+ edgeCount: proj.graph.size,
4644
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
4645
+ };
4646
+ });
4647
+ }
4543
4648
  scope.get("/graph", async (req, reply) => {
4544
- const proj = resolveProject(registry, req, reply);
4649
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4545
4650
  if (!proj) return;
4546
4651
  return serializeGraph(proj.graph);
4547
4652
  });
4548
4653
  scope.get(
4549
4654
  "/graph/node/:id",
4550
4655
  async (req, reply) => {
4551
- const proj = resolveProject(registry, req, reply);
4656
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4552
4657
  if (!proj) return;
4553
4658
  const { id } = req.params;
4554
4659
  if (!proj.graph.hasNode(id)) {
@@ -4560,7 +4665,7 @@ function registerRoutes(scope, ctx) {
4560
4665
  scope.get(
4561
4666
  "/graph/edges/:id",
4562
4667
  async (req, reply) => {
4563
- const proj = resolveProject(registry, req, reply);
4668
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4564
4669
  if (!proj) return;
4565
4670
  const { id } = req.params;
4566
4671
  if (!proj.graph.hasNode(id)) {
@@ -4572,7 +4677,7 @@ function registerRoutes(scope, ctx) {
4572
4677
  }
4573
4678
  );
4574
4679
  scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
4575
- const proj = resolveProject(registry, req, reply);
4680
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4576
4681
  if (!proj) return;
4577
4682
  const { nodeId } = req.params;
4578
4683
  if (!proj.graph.hasNode(nodeId)) {
@@ -4587,7 +4692,7 @@ function registerRoutes(scope, ctx) {
4587
4692
  return getTransitiveDependencies(proj.graph, nodeId, depth);
4588
4693
  });
4589
4694
  scope.get("/graph/divergences", async (req, reply) => {
4590
- const proj = resolveProject(registry, req, reply);
4695
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4591
4696
  if (!proj) return;
4592
4697
  let typeFilter;
4593
4698
  if (req.query.type) {
@@ -4622,7 +4727,7 @@ function registerRoutes(scope, ctx) {
4622
4727
  });
4623
4728
  });
4624
4729
  scope.get("/incidents", async (req, reply) => {
4625
- const proj = resolveProject(registry, req, reply);
4730
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4626
4731
  if (!proj) return;
4627
4732
  const epath = errorsPathFor(proj);
4628
4733
  if (!epath) return { count: 0, total: 0, events: [] };
@@ -4634,7 +4739,7 @@ function registerRoutes(scope, ctx) {
4634
4739
  return { count: sliced.length, total, events: sliced };
4635
4740
  });
4636
4741
  scope.get("/stale-events", async (req, reply) => {
4637
- const proj = resolveProject(registry, req, reply);
4742
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4638
4743
  if (!proj) return;
4639
4744
  const spath = staleEventsPathFor(proj);
4640
4745
  if (!spath) return { count: 0, total: 0, events: [] };
@@ -4649,7 +4754,7 @@ function registerRoutes(scope, ctx) {
4649
4754
  scope.get(
4650
4755
  "/incidents/:nodeId",
4651
4756
  async (req, reply) => {
4652
- const proj = resolveProject(registry, req, reply);
4757
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4653
4758
  if (!proj) return;
4654
4759
  const { nodeId } = req.params;
4655
4760
  if (!proj.graph.hasNode(nodeId)) {
@@ -4665,7 +4770,7 @@ function registerRoutes(scope, ctx) {
4665
4770
  }
4666
4771
  );
4667
4772
  scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
4668
- const proj = resolveProject(registry, req, reply);
4773
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4669
4774
  if (!proj) return;
4670
4775
  const { nodeId } = req.params;
4671
4776
  if (!proj.graph.hasNode(nodeId)) {
@@ -4685,7 +4790,7 @@ function registerRoutes(scope, ctx) {
4685
4790
  return result;
4686
4791
  });
4687
4792
  scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
4688
- const proj = resolveProject(registry, req, reply);
4793
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4689
4794
  if (!proj) return;
4690
4795
  const { nodeId } = req.params;
4691
4796
  if (!proj.graph.hasNode(nodeId)) {
@@ -4698,7 +4803,7 @@ function registerRoutes(scope, ctx) {
4698
4803
  return getBlastRadius(proj.graph, nodeId, depth);
4699
4804
  });
4700
4805
  scope.get("/search", async (req, reply) => {
4701
- const proj = resolveProject(registry, req, reply);
4806
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4702
4807
  if (!proj) return;
4703
4808
  const raw = (req.query.q ?? "").trim();
4704
4809
  if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
@@ -4729,7 +4834,7 @@ function registerRoutes(scope, ctx) {
4729
4834
  scope.get(
4730
4835
  "/graph/diff",
4731
4836
  async (req, reply) => {
4732
- const proj = resolveProject(registry, req, reply);
4837
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4733
4838
  if (!proj) return;
4734
4839
  const against = req.query.against;
4735
4840
  if (!against) {
@@ -4743,8 +4848,37 @@ function registerRoutes(scope, ctx) {
4743
4848
  }
4744
4849
  }
4745
4850
  );
4851
+ scope.post("/snapshot", async (req, reply) => {
4852
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4853
+ if (!proj) return;
4854
+ const body = req.body;
4855
+ if (!body || typeof body !== "object" || !body.snapshot) {
4856
+ return reply.code(400).send({ error: "request body must be { snapshot: <persisted-graph> }" });
4857
+ }
4858
+ const snap = body.snapshot;
4859
+ if (typeof snap.schemaVersion !== "number" || snap.schemaVersion !== SCHEMA_VERSION) {
4860
+ return reply.code(400).send({
4861
+ error: `unsupported snapshot schemaVersion ${snap.schemaVersion} (expected ${SCHEMA_VERSION})`
4862
+ });
4863
+ }
4864
+ try {
4865
+ const result = mergeSnapshot(proj.graph, snap);
4866
+ return {
4867
+ project: proj.name,
4868
+ nodesAdded: result.nodesAdded,
4869
+ edgesAdded: result.edgesAdded,
4870
+ nodeCount: proj.graph.order,
4871
+ edgeCount: proj.graph.size
4872
+ };
4873
+ } catch (err) {
4874
+ return reply.code(400).send({
4875
+ error: "snapshot merge failed",
4876
+ details: err.message
4877
+ });
4878
+ }
4879
+ });
4746
4880
  scope.post("/graph/scan", async (req, reply) => {
4747
- const proj = resolveProject(registry, req, reply);
4881
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4748
4882
  if (!proj) return;
4749
4883
  if (!proj.scanPath) {
4750
4884
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -4760,7 +4894,7 @@ function registerRoutes(scope, ctx) {
4760
4894
  };
4761
4895
  });
4762
4896
  scope.get("/policies", async (req, reply) => {
4763
- const proj = resolveProject(registry, req, reply);
4897
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4764
4898
  if (!proj) return;
4765
4899
  const policyPath = ctx.policyFilePathFor(proj);
4766
4900
  if (!policyPath) {
@@ -4777,7 +4911,7 @@ function registerRoutes(scope, ctx) {
4777
4911
  }
4778
4912
  });
4779
4913
  scope.get("/policies/violations", async (req, reply) => {
4780
- const proj = resolveProject(registry, req, reply);
4914
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4781
4915
  if (!proj) return;
4782
4916
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
4783
4917
  let violations = await log.readAll();
@@ -4797,7 +4931,7 @@ function registerRoutes(scope, ctx) {
4797
4931
  return { violations };
4798
4932
  });
4799
4933
  scope.post("/policies/check", async (req, reply) => {
4800
- const proj = resolveProject(registry, req, reply);
4934
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4801
4935
  if (!proj) return;
4802
4936
  const parsed = PoliciesCheckBodySchema.safeParse(req.body ?? {});
4803
4937
  if (!parsed.success) {
@@ -4836,7 +4970,15 @@ function registerRoutes(scope, ctx) {
4836
4970
  async function buildApi(opts) {
4837
4971
  const app = Fastify({ logger: false });
4838
4972
  await app.register(cors, { origin: true });
4839
- mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy });
4973
+ mountBearerAuth(app, {
4974
+ token: opts.authToken,
4975
+ trustProxy: opts.trustProxy,
4976
+ publicRead: opts.publicRead
4977
+ });
4978
+ app.get("/api/config", async () => ({
4979
+ publicRead: opts.publicRead === true,
4980
+ authProxy: opts.trustProxy === true
4981
+ }));
4840
4982
  const startedAt = opts.startedAt ?? Date.now();
4841
4983
  const registry = buildLegacyRegistry(opts);
4842
4984
  const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
@@ -4860,10 +5002,36 @@ async function buildApi(opts) {
4860
5002
  const routeCtx = {
4861
5003
  registry,
4862
5004
  startedAt,
5005
+ scope: "root",
4863
5006
  errorsPathFor,
4864
5007
  staleEventsPathFor,
4865
- policyFilePathFor
5008
+ policyFilePathFor,
5009
+ bootstrap: opts.bootstrap
4866
5010
  };
5011
+ app.get("/health", async () => {
5012
+ const uptimeMs = Date.now() - startedAt;
5013
+ const bootstrapList = opts.bootstrap?.list() ?? [];
5014
+ const byName = new Map(bootstrapList.map((p) => [p.name, p]));
5015
+ const names = /* @__PURE__ */ new Set([
5016
+ ...registry.list(),
5017
+ ...bootstrapList.map((p) => p.name)
5018
+ ]);
5019
+ const projects = [...names].sort().map((name) => {
5020
+ const proj = registry.get(name);
5021
+ const tracked = byName.get(name);
5022
+ return {
5023
+ name,
5024
+ nodeCount: proj?.graph.order ?? 0,
5025
+ edgeCount: proj?.graph.size ?? 0,
5026
+ ...tracked ? { status: tracked.status, elapsedMs: tracked.elapsedMs } : {}
5027
+ };
5028
+ });
5029
+ return {
5030
+ ok: true,
5031
+ uptimeMs,
5032
+ projects
5033
+ };
5034
+ });
4867
5035
  app.get("/projects", async (_req, reply) => {
4868
5036
  try {
4869
5037
  return await listProjects();
@@ -4891,7 +5059,7 @@ async function buildApi(opts) {
4891
5059
  registerRoutes(app, routeCtx);
4892
5060
  await app.register(
4893
5061
  async (scope) => {
4894
- registerRoutes(scope, routeCtx);
5062
+ registerRoutes(scope, { ...routeCtx, scope: "project" });
4895
5063
  },
4896
5064
  { prefix: "/projects/:project" }
4897
5065
  );
@@ -4958,4 +5126,4 @@ export {
4958
5126
  removeProject,
4959
5127
  buildApi
4960
5128
  };
4961
- //# sourceMappingURL=chunk-CZ3T6TE2.js.map
5129
+ //# sourceMappingURL=chunk-NTQHMXWE.js.map