@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.
package/dist/index.cjs CHANGED
@@ -55,14 +55,19 @@ function mountBearerAuth(app, opts) {
55
55
  if (opts.trustProxy) return;
56
56
  const expected = Buffer.from(opts.token, "utf8");
57
57
  const suffixes = [...DEFAULT_UNAUTH_SUFFIXES, ...opts.extraUnauthenticatedSuffixes ?? []];
58
+ const publicRead = opts.publicRead === true;
58
59
  app.addHook("preHandler", (req, reply, done) => {
59
- const path37 = (req.url.split("?")[0] ?? "").replace(/\/+$/, "");
60
+ const path38 = (req.url.split("?")[0] ?? "").replace(/\/+$/, "");
60
61
  for (const suffix of suffixes) {
61
- if (path37 === suffix || path37.endsWith(suffix)) {
62
+ if (path38 === suffix || path38.endsWith(suffix)) {
62
63
  done();
63
64
  return;
64
65
  }
65
66
  }
67
+ if (publicRead && PUBLIC_READ_METHODS.has(req.method)) {
68
+ done();
69
+ return;
70
+ }
66
71
  const header = req.headers.authorization;
67
72
  if (typeof header !== "string" || !header.startsWith("Bearer ")) {
68
73
  void reply.code(401).send({ error: "unauthorized" });
@@ -76,16 +81,21 @@ function mountBearerAuth(app, opts) {
76
81
  done();
77
82
  });
78
83
  }
84
+ function parseBoolEnv(v) {
85
+ if (!v) return false;
86
+ return v === "true" || v === "1";
87
+ }
79
88
  function readAuthEnv(env = process.env) {
80
89
  const t = env.NEAT_AUTH_TOKEN;
81
90
  const ot = env.NEAT_OTEL_TOKEN;
82
91
  return {
83
92
  authToken: t && t.length > 0 ? t : void 0,
84
93
  otelToken: ot && ot.length > 0 ? ot : t && t.length > 0 ? t : void 0,
85
- trustProxy: env.NEAT_AUTH_PROXY === "true"
94
+ trustProxy: env.NEAT_AUTH_PROXY === "true",
95
+ publicRead: parseBoolEnv(env.NEAT_PUBLIC_READ)
86
96
  };
87
97
  }
88
- var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, DEFAULT_UNAUTH_SUFFIXES;
98
+ var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, PUBLIC_READ_METHODS, DEFAULT_UNAUTH_SUFFIXES;
89
99
  var init_auth = __esm({
90
100
  "src/auth.ts"() {
91
101
  "use strict";
@@ -105,7 +115,13 @@ var init_auth = __esm({
105
115
  this.name = "BindAuthorityError";
106
116
  }
107
117
  };
108
- DEFAULT_UNAUTH_SUFFIXES = ["/health", "/healthz", "/readyz"];
118
+ PUBLIC_READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
119
+ DEFAULT_UNAUTH_SUFFIXES = [
120
+ "/health",
121
+ "/healthz",
122
+ "/readyz",
123
+ "/api/config"
124
+ ];
109
125
  }
110
126
  });
111
127
 
@@ -308,6 +324,15 @@ function isoFromUnixNano(nanos) {
308
324
  return void 0;
309
325
  }
310
326
  }
327
+ function pickEnv(spanAttrs, resourceAttrs) {
328
+ for (const attrs of [spanAttrs, resourceAttrs]) {
329
+ for (const key of [ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT]) {
330
+ const v = attrs[key];
331
+ if (typeof v === "string" && v.length > 0) return v;
332
+ }
333
+ }
334
+ return ENV_FALLBACK;
335
+ }
311
336
  function parseOtlpRequest(body) {
312
337
  const out = [];
313
338
  for (const rs of body.resourceSpans ?? []) {
@@ -327,6 +352,7 @@ function parseOtlpRequest(body) {
327
352
  endTimeUnixNano: span.endTimeUnixNano ?? "0",
328
353
  startTimeIso: isoFromUnixNano(span.startTimeUnixNano),
329
354
  durationNanos: durationNanos(span.startTimeUnixNano, span.endTimeUnixNano),
355
+ env: pickEnv(attrs, resourceAttrs),
330
356
  attributes: attrs,
331
357
  dbSystem: typeof attrs["db.system"] === "string" ? attrs["db.system"] : void 0,
332
358
  dbName: typeof attrs["db.name"] === "string" ? attrs["db.name"] : void 0,
@@ -473,7 +499,7 @@ function logSpanHandler(span) {
473
499
  `otel: ${span.service} ${span.name} parent=${parent} status=${status2}${db}`
474
500
  );
475
501
  }
476
- var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
502
+ var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT, ENV_FALLBACK, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
477
503
  var init_otel = __esm({
478
504
  "src/otel.ts"() {
479
505
  "use strict";
@@ -483,6 +509,9 @@ var init_otel = __esm({
483
509
  import_fastify2 = __toESM(require("fastify"), 1);
484
510
  import_protobufjs = __toESM(require("protobufjs"), 1);
485
511
  init_auth();
512
+ ENV_ATTR_CANONICAL = "deployment.environment.name";
513
+ ENV_ATTR_COMPAT = "deployment.environment";
514
+ ENV_FALLBACK = "unknown";
486
515
  exportTraceServiceRequestType = null;
487
516
  exportTraceServiceResponseType = null;
488
517
  cachedProtobufResponseBody = null;
@@ -994,19 +1023,19 @@ function confidenceFromMix(edges, now = Date.now()) {
994
1023
  function longestIncomingWalk(graph, start, maxDepth) {
995
1024
  let best = { path: [start], edges: [] };
996
1025
  const visited = /* @__PURE__ */ new Set([start]);
997
- function step(node, path37, edges) {
998
- if (path37.length > best.path.length) {
999
- best = { path: [...path37], edges: [...edges] };
1026
+ function step(node, path38, edges) {
1027
+ if (path38.length > best.path.length) {
1028
+ best = { path: [...path38], edges: [...edges] };
1000
1029
  }
1001
- if (path37.length - 1 >= maxDepth) return;
1030
+ if (path38.length - 1 >= maxDepth) return;
1002
1031
  const incoming = bestEdgeBySource(graph, graph.inboundEdges(node));
1003
1032
  for (const [srcId, edge] of incoming) {
1004
1033
  if (visited.has(srcId)) continue;
1005
1034
  visited.add(srcId);
1006
- path37.push(srcId);
1035
+ path38.push(srcId);
1007
1036
  edges.push(edge);
1008
- step(srcId, path37, edges);
1009
- path37.pop();
1037
+ step(srcId, path38, edges);
1038
+ path38.pop();
1010
1039
  edges.pop();
1011
1040
  visited.delete(srcId);
1012
1041
  }
@@ -1579,52 +1608,64 @@ function cacheSpanService(span, now) {
1579
1608
  if (!span.traceId || !span.spanId) return;
1580
1609
  const key = parentSpanKey(span.traceId, span.spanId);
1581
1610
  parentSpanCache.delete(key);
1582
- parentSpanCache.set(key, { service: span.service, expiresAt: now + PARENT_SPAN_CACHE_TTL_MS });
1611
+ parentSpanCache.set(key, {
1612
+ service: span.service,
1613
+ env: span.env ?? "unknown",
1614
+ expiresAt: now + PARENT_SPAN_CACHE_TTL_MS
1615
+ });
1583
1616
  while (parentSpanCache.size > PARENT_SPAN_CACHE_SIZE) {
1584
1617
  const oldest = parentSpanCache.keys().next().value;
1585
1618
  if (!oldest) break;
1586
1619
  parentSpanCache.delete(oldest);
1587
1620
  }
1588
1621
  }
1589
- function lookupParentSpanService(traceId, parentSpanId, now) {
1622
+ function lookupParentSpan(traceId, parentSpanId, now) {
1590
1623
  const entry = parentSpanCache.get(parentSpanKey(traceId, parentSpanId));
1591
1624
  if (!entry) return null;
1592
1625
  if (entry.expiresAt <= now) {
1593
1626
  parentSpanCache.delete(parentSpanKey(traceId, parentSpanId));
1594
1627
  return null;
1595
1628
  }
1596
- return entry.service;
1629
+ return { service: entry.service, env: entry.env };
1597
1630
  }
1598
- function resolveServiceId(graph, host) {
1599
- const direct = (0, import_types3.serviceId)(host);
1600
- if (graph.hasNode(direct)) return direct;
1601
- let found = null;
1631
+ function resolveServiceId(graph, host, env) {
1632
+ const envTagged = (0, import_types3.serviceId)(host, env);
1633
+ if (graph.hasNode(envTagged)) return envTagged;
1634
+ const envLess = (0, import_types3.serviceId)(host);
1635
+ if (envLess !== envTagged && graph.hasNode(envLess)) return envLess;
1636
+ let sameEnv = null;
1637
+ let envLessMatch = null;
1638
+ let anyMatch = null;
1602
1639
  graph.forEachNode((id, attrs) => {
1603
- if (found) return;
1640
+ if (sameEnv) return;
1604
1641
  const a = attrs;
1605
1642
  if (a.type !== import_types3.NodeType.ServiceNode) return;
1606
- if (a.name === host) {
1607
- found = id;
1643
+ const matchesByName = a.name === host;
1644
+ const matchesByAlias = a.aliases ? a.aliases.includes(host) : false;
1645
+ if (!matchesByName && !matchesByAlias) return;
1646
+ const nodeEnv = a.env ?? "unknown";
1647
+ if (nodeEnv === env) {
1648
+ sameEnv = id;
1608
1649
  return;
1609
1650
  }
1610
- if (a.aliases && a.aliases.includes(host)) {
1611
- found = id;
1612
- }
1651
+ if (nodeEnv === "unknown" && !envLessMatch) envLessMatch = id;
1652
+ else if (!anyMatch) anyMatch = id;
1613
1653
  });
1614
- return found;
1654
+ return sameEnv ?? envLessMatch ?? anyMatch;
1615
1655
  }
1616
1656
  function frontierIdFor(host) {
1617
1657
  return (0, import_types3.frontierId)(host);
1618
1658
  }
1619
- function ensureServiceNode(graph, serviceName) {
1620
- const id = (0, import_types3.serviceId)(serviceName);
1659
+ function ensureServiceNode(graph, serviceName, env) {
1660
+ const id = (0, import_types3.serviceId)(serviceName, env);
1621
1661
  if (graph.hasNode(id)) return id;
1622
1662
  const node = {
1623
1663
  id,
1624
1664
  type: import_types3.NodeType.ServiceNode,
1625
1665
  name: serviceName,
1626
1666
  language: "unknown",
1627
- discoveredVia: "otel"
1667
+ discoveredVia: "otel",
1668
+ ...env !== "unknown" ? { env } : {}
1628
1669
  };
1629
1670
  graph.addNode(id, node);
1630
1671
  return id;
@@ -1770,7 +1811,7 @@ function buildErrorEventForReceiver(span) {
1770
1811
  ...span.exception?.type ? { exceptionType: span.exception.type } : {},
1771
1812
  ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
1772
1813
  ...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
1773
- affectedNode: (0, import_types3.serviceId)(span.service)
1814
+ affectedNode: (0, import_types3.serviceId)(span.service, span.env)
1774
1815
  };
1775
1816
  }
1776
1817
  function makeErrorSpanWriter(errorsPath) {
@@ -1784,7 +1825,8 @@ function makeErrorSpanWriter(errorsPath) {
1784
1825
  async function handleSpan(ctx, span) {
1785
1826
  const ts = span.startTimeIso ?? nowIso(ctx);
1786
1827
  const nowMs = ctx.now ? ctx.now() : Date.now();
1787
- const sourceId = ensureServiceNode(ctx.graph, span.service);
1828
+ const env = span.env ?? "unknown";
1829
+ const sourceId = ensureServiceNode(ctx.graph, span.service, env);
1788
1830
  const isError = span.statusCode === 2;
1789
1831
  cacheSpanService(span, nowMs);
1790
1832
  let affectedNode = sourceId;
@@ -1807,7 +1849,7 @@ async function handleSpan(ctx, span) {
1807
1849
  const host = pickAddress(span);
1808
1850
  let resolvedViaAddress = false;
1809
1851
  if (host && host !== span.service) {
1810
- const targetId = resolveServiceId(ctx.graph, host);
1852
+ const targetId = resolveServiceId(ctx.graph, host, env);
1811
1853
  if (targetId && targetId !== sourceId) {
1812
1854
  upsertObservedEdge(
1813
1855
  ctx.graph,
@@ -1834,9 +1876,9 @@ async function handleSpan(ctx, span) {
1834
1876
  }
1835
1877
  }
1836
1878
  if (!resolvedViaAddress && span.parentSpanId) {
1837
- const parentService = lookupParentSpanService(span.traceId, span.parentSpanId, nowMs);
1838
- if (parentService && parentService !== span.service) {
1839
- const parentId = ensureServiceNode(ctx.graph, parentService);
1879
+ const parent = lookupParentSpan(span.traceId, span.parentSpanId, nowMs);
1880
+ if (parent && parent.service !== span.service) {
1881
+ const parentId = ensureServiceNode(ctx.graph, parent.service, parent.env);
1840
1882
  upsertObservedEdge(
1841
1883
  ctx.graph,
1842
1884
  import_types3.EdgeType.CALLS,
@@ -2032,6 +2074,28 @@ async function readErrorEvents(errorsPath) {
2032
2074
  throw err;
2033
2075
  }
2034
2076
  }
2077
+ function mergeSnapshot(graph, snapshot) {
2078
+ const exported = snapshot.graph;
2079
+ let nodesAdded = 0;
2080
+ let edgesAdded = 0;
2081
+ for (const node of exported.nodes ?? []) {
2082
+ if (graph.hasNode(node.key)) continue;
2083
+ if (!node.attributes) continue;
2084
+ graph.addNode(node.key, node.attributes);
2085
+ nodesAdded++;
2086
+ }
2087
+ for (const edge of exported.edges ?? []) {
2088
+ const attrs = edge.attributes;
2089
+ if (!attrs) continue;
2090
+ const id = edge.key ?? attrs.id;
2091
+ if (!id) continue;
2092
+ if (graph.hasEdge(id)) continue;
2093
+ if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
2094
+ graph.addEdgeWithKey(id, edge.source, edge.target, attrs);
2095
+ edgesAdded++;
2096
+ }
2097
+ return { nodesAdded, edgesAdded };
2098
+ }
2035
2099
 
2036
2100
  // src/extract/services.ts
2037
2101
  init_cjs_shims();
@@ -2055,8 +2119,36 @@ var IGNORED_DIRS = /* @__PURE__ */ new Set([
2055
2119
  ".turbo",
2056
2120
  "dist",
2057
2121
  "build",
2058
- ".next"
2122
+ ".next",
2123
+ // Python virtualenv shapes (issue #344). Walking into a venv pulls in the
2124
+ // entire CPython stdlib + every installed package as if it were first-party
2125
+ // service code — 20k+ files, none of which the user wrote. The shape names
2126
+ // cover the three common venv tools (`venv` / `python -m venv`,
2127
+ // `virtualenv`) and the tox + PEP 582 conventions.
2128
+ ".venv",
2129
+ "venv",
2130
+ "__pypackages__",
2131
+ ".tox",
2132
+ // `site-packages` shows up nested inside venvs that don't carry one of the
2133
+ // outer names (system Python on macOS, in-place pyenv layouts). Listing it
2134
+ // here means we stop the walk at the boundary even when the wrapper dir
2135
+ // wasn't recognisable.
2136
+ "site-packages"
2059
2137
  ]);
2138
+ var PYVENV_MARKER_CACHE = /* @__PURE__ */ new Map();
2139
+ async function isPythonVenvDir(dir) {
2140
+ const cached = PYVENV_MARKER_CACHE.get(dir);
2141
+ if (cached !== void 0) return cached;
2142
+ try {
2143
+ const stat = await import_node_fs4.promises.stat(import_node_path4.default.join(dir, "pyvenv.cfg"));
2144
+ const ok = stat.isFile();
2145
+ PYVENV_MARKER_CACHE.set(dir, ok);
2146
+ return ok;
2147
+ } catch {
2148
+ PYVENV_MARKER_CACHE.set(dir, false);
2149
+ return false;
2150
+ }
2151
+ }
2060
2152
  function isConfigFile(name) {
2061
2153
  const ext = import_node_path4.default.extname(name);
2062
2154
  if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
@@ -2393,6 +2485,7 @@ async function walkDirs(start, scanPath, options, visit) {
2393
2485
  const rel = import_node_path8.default.relative(scanPath, child).split(import_node_path8.default.sep).join("/");
2394
2486
  if (rel && options.ig.ignores(rel + "/")) continue;
2395
2487
  }
2488
+ if (await isPythonVenvDir(child)) continue;
2396
2489
  await visit(child);
2397
2490
  await recurse(child, depth + 1);
2398
2491
  }
@@ -2428,6 +2521,18 @@ async function expandWorkspaceGlobs(scanPath, globs) {
2428
2521
  }
2429
2522
  return [...found];
2430
2523
  }
2524
+ function detectJsFramework(pkg) {
2525
+ const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
2526
+ if (deps["next"] !== void 0) return "next";
2527
+ if (deps["remix"] !== void 0) return "remix";
2528
+ for (const k of Object.keys(deps)) {
2529
+ if (k.startsWith("@remix-run/")) return "remix";
2530
+ }
2531
+ if (deps["@sveltejs/kit"] !== void 0) return "sveltekit";
2532
+ if (deps["nuxt"] !== void 0) return "nuxt";
2533
+ if (deps["astro"] !== void 0) return "astro";
2534
+ return void 0;
2535
+ }
2431
2536
  async function discoverNodeService(scanPath, dir) {
2432
2537
  const pkgPath = import_node_path8.default.join(dir, "package.json");
2433
2538
  if (!await exists(pkgPath)) return null;
@@ -2439,6 +2544,7 @@ async function discoverNodeService(scanPath, dir) {
2439
2544
  return null;
2440
2545
  }
2441
2546
  if (!pkg.name) return null;
2547
+ const framework = detectJsFramework(pkg);
2442
2548
  const node = {
2443
2549
  id: (0, import_types5.serviceId)(pkg.name),
2444
2550
  type: import_types5.NodeType.ServiceNode,
@@ -2447,7 +2553,8 @@ async function discoverNodeService(scanPath, dir) {
2447
2553
  version: pkg.version,
2448
2554
  dependencies: pkg.dependencies ?? {},
2449
2555
  repoPath: import_node_path8.default.relative(scanPath, dir),
2450
- ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {}
2556
+ ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {},
2557
+ ...framework ? { framework } : {}
2451
2558
  };
2452
2559
  return { pkg, dir, node };
2453
2560
  }
@@ -2657,7 +2764,9 @@ async function walkYamlFiles(start, depth = 0, max = 5) {
2657
2764
  for (const entry of entries) {
2658
2765
  if (entry.isDirectory()) {
2659
2766
  if (IGNORED_DIRS.has(entry.name)) continue;
2660
- out.push(...await walkYamlFiles(import_node_path9.default.join(start, entry.name), depth + 1, max));
2767
+ const child = import_node_path9.default.join(start, entry.name);
2768
+ if (await isPythonVenvDir(child)) continue;
2769
+ out.push(...await walkYamlFiles(child, depth + 1, max));
2661
2770
  } else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path9.default.extname(entry.name))) {
2662
2771
  out.push(import_node_path9.default.join(start, entry.name));
2663
2772
  }
@@ -3339,7 +3448,9 @@ async function walkConfigFiles(dir) {
3339
3448
  for (const entry of entries) {
3340
3449
  const full = import_node_path18.default.join(current, entry.name);
3341
3450
  if (entry.isDirectory()) {
3342
- if (!IGNORED_DIRS.has(entry.name)) await walk(full);
3451
+ if (IGNORED_DIRS.has(entry.name)) continue;
3452
+ if (await isPythonVenvDir(full)) continue;
3453
+ await walk(full);
3343
3454
  } else if (entry.isFile() && isConfigFile(entry.name).match) {
3344
3455
  out.push(full);
3345
3456
  }
@@ -3407,7 +3518,9 @@ async function walkSourceFiles(dir) {
3407
3518
  for (const entry of entries) {
3408
3519
  const full = import_node_path19.default.join(current, entry.name);
3409
3520
  if (entry.isDirectory()) {
3410
- if (!IGNORED_DIRS.has(entry.name)) await walk(full);
3521
+ if (IGNORED_DIRS.has(entry.name)) continue;
3522
+ if (await isPythonVenvDir(full)) continue;
3523
+ await walk(full);
3411
3524
  } else if (entry.isFile() && SERVICE_FILE_EXTENSIONS.has(import_node_path19.default.extname(entry.name))) {
3412
3525
  out.push(full);
3413
3526
  }
@@ -4043,7 +4156,9 @@ async function walkTfFiles(start, depth = 0, max = 5) {
4043
4156
  for (const entry of entries) {
4044
4157
  if (entry.isDirectory()) {
4045
4158
  if (IGNORED_DIRS.has(entry.name) || entry.name === ".terraform") continue;
4046
- out.push(...await walkTfFiles(import_node_path27.default.join(start, entry.name), depth + 1, max));
4159
+ const child = import_node_path27.default.join(start, entry.name);
4160
+ if (await isPythonVenvDir(child)) continue;
4161
+ out.push(...await walkTfFiles(child, depth + 1, max));
4047
4162
  } else if (entry.isFile() && entry.name.endsWith(".tf")) {
4048
4163
  out.push(import_node_path27.default.join(start, entry.name));
4049
4164
  }
@@ -4091,7 +4206,9 @@ async function walkYamlFiles2(start, depth = 0, max = 5) {
4091
4206
  for (const entry of entries) {
4092
4207
  if (entry.isDirectory()) {
4093
4208
  if (IGNORED_DIRS.has(entry.name)) continue;
4094
- out.push(...await walkYamlFiles2(import_node_path28.default.join(start, entry.name), depth + 1, max));
4209
+ const child = import_node_path28.default.join(start, entry.name);
4210
+ if (await isPythonVenvDir(child)) continue;
4211
+ out.push(...await walkYamlFiles2(child, depth + 1, max));
4095
4212
  } else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path28.default.extname(entry.name))) {
4096
4213
  out.push(import_node_path28.default.join(start, entry.name));
4097
4214
  }
@@ -4230,7 +4347,7 @@ init_cjs_shims();
4230
4347
  var import_node_fs18 = require("fs");
4231
4348
  var import_node_path31 = __toESM(require("path"), 1);
4232
4349
  var import_types19 = require("@neat.is/types");
4233
- var SCHEMA_VERSION = 3;
4350
+ var SCHEMA_VERSION = 4;
4234
4351
  function migrateV1ToV2(payload) {
4235
4352
  const nodes = payload.graph.nodes;
4236
4353
  if (Array.isArray(nodes)) {
@@ -4242,6 +4359,9 @@ function migrateV1ToV2(payload) {
4242
4359
  }
4243
4360
  return { ...payload, schemaVersion: 2 };
4244
4361
  }
4362
+ function migrateV3ToV4(payload) {
4363
+ return { ...payload, schemaVersion: 4 };
4364
+ }
4245
4365
  function migrateV2ToV3(payload) {
4246
4366
  const edges = payload.graph.edges;
4247
4367
  if (Array.isArray(edges)) {
@@ -4290,6 +4410,9 @@ async function loadGraphFromDisk(graph, outPath) {
4290
4410
  if (payload.schemaVersion === 2) {
4291
4411
  payload = migrateV2ToV3(payload);
4292
4412
  }
4413
+ if (payload.schemaVersion === 3) {
4414
+ payload = migrateV3ToV4(payload);
4415
+ }
4293
4416
  if (payload.schemaVersion !== SCHEMA_VERSION) {
4294
4417
  throw new Error(
4295
4418
  `persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
@@ -4944,10 +5067,19 @@ function projectFromReq(req) {
4944
5067
  const params = req.params;
4945
5068
  return params.project ?? DEFAULT_PROJECT;
4946
5069
  }
4947
- function resolveProject(registry, req, reply) {
5070
+ function resolveProject(registry, req, reply, bootstrap) {
4948
5071
  const name = projectFromReq(req);
4949
5072
  const ctx = registry.get(name);
4950
5073
  if (!ctx) {
5074
+ const phase = bootstrap?.status(name);
5075
+ if (phase === "bootstrapping") {
5076
+ void reply.code(503).send({ ready: false, project: name, status: "bootstrapping" });
5077
+ return null;
5078
+ }
5079
+ if (phase === "broken") {
5080
+ void reply.code(503).send({ ready: false, project: name, status: "broken" });
5081
+ return null;
5082
+ }
4951
5083
  void reply.code(404).send({ error: "project not found", project: name });
4952
5084
  return null;
4953
5085
  }
@@ -4977,36 +5109,38 @@ function buildLegacyRegistry(opts) {
4977
5109
  function registerRoutes(scope, ctx) {
4978
5110
  const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
4979
5111
  scope.get("/events", (req, reply) => {
4980
- const proj = resolveProject(registry, req, reply);
5112
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4981
5113
  if (!proj) return;
4982
5114
  handleSse(req, reply, { project: proj.name });
4983
5115
  });
4984
- scope.get("/health", async (req, reply) => {
4985
- const proj = resolveProject(registry, req, reply);
4986
- if (!proj) return;
4987
- const uptimeMs = Date.now() - startedAt;
4988
- return {
4989
- ok: true,
4990
- project: proj.name,
4991
- uptimeMs,
4992
- // Legacy fields kept additively. The web shell's StatusBar reads
4993
- // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
4994
- // the canonical triple and lets the extras pass through.
4995
- uptime: Math.floor(uptimeMs / 1e3),
4996
- nodeCount: proj.graph.order,
4997
- edgeCount: proj.graph.size,
4998
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
4999
- };
5000
- });
5116
+ if (ctx.scope === "project") {
5117
+ scope.get("/health", async (req, reply) => {
5118
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5119
+ if (!proj) return;
5120
+ const uptimeMs = Date.now() - startedAt;
5121
+ return {
5122
+ ok: true,
5123
+ project: proj.name,
5124
+ uptimeMs,
5125
+ // Legacy fields kept additively. The web shell's StatusBar reads
5126
+ // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
5127
+ // the canonical triple and lets the extras pass through.
5128
+ uptime: Math.floor(uptimeMs / 1e3),
5129
+ nodeCount: proj.graph.order,
5130
+ edgeCount: proj.graph.size,
5131
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
5132
+ };
5133
+ });
5134
+ }
5001
5135
  scope.get("/graph", async (req, reply) => {
5002
- const proj = resolveProject(registry, req, reply);
5136
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5003
5137
  if (!proj) return;
5004
5138
  return serializeGraph(proj.graph);
5005
5139
  });
5006
5140
  scope.get(
5007
5141
  "/graph/node/:id",
5008
5142
  async (req, reply) => {
5009
- const proj = resolveProject(registry, req, reply);
5143
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5010
5144
  if (!proj) return;
5011
5145
  const { id } = req.params;
5012
5146
  if (!proj.graph.hasNode(id)) {
@@ -5018,7 +5152,7 @@ function registerRoutes(scope, ctx) {
5018
5152
  scope.get(
5019
5153
  "/graph/edges/:id",
5020
5154
  async (req, reply) => {
5021
- const proj = resolveProject(registry, req, reply);
5155
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5022
5156
  if (!proj) return;
5023
5157
  const { id } = req.params;
5024
5158
  if (!proj.graph.hasNode(id)) {
@@ -5030,7 +5164,7 @@ function registerRoutes(scope, ctx) {
5030
5164
  }
5031
5165
  );
5032
5166
  scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
5033
- const proj = resolveProject(registry, req, reply);
5167
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5034
5168
  if (!proj) return;
5035
5169
  const { nodeId } = req.params;
5036
5170
  if (!proj.graph.hasNode(nodeId)) {
@@ -5045,7 +5179,7 @@ function registerRoutes(scope, ctx) {
5045
5179
  return getTransitiveDependencies(proj.graph, nodeId, depth);
5046
5180
  });
5047
5181
  scope.get("/graph/divergences", async (req, reply) => {
5048
- const proj = resolveProject(registry, req, reply);
5182
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5049
5183
  if (!proj) return;
5050
5184
  let typeFilter;
5051
5185
  if (req.query.type) {
@@ -5080,7 +5214,7 @@ function registerRoutes(scope, ctx) {
5080
5214
  });
5081
5215
  });
5082
5216
  scope.get("/incidents", async (req, reply) => {
5083
- const proj = resolveProject(registry, req, reply);
5217
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5084
5218
  if (!proj) return;
5085
5219
  const epath = errorsPathFor(proj);
5086
5220
  if (!epath) return { count: 0, total: 0, events: [] };
@@ -5092,7 +5226,7 @@ function registerRoutes(scope, ctx) {
5092
5226
  return { count: sliced.length, total, events: sliced };
5093
5227
  });
5094
5228
  scope.get("/stale-events", async (req, reply) => {
5095
- const proj = resolveProject(registry, req, reply);
5229
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5096
5230
  if (!proj) return;
5097
5231
  const spath = staleEventsPathFor(proj);
5098
5232
  if (!spath) return { count: 0, total: 0, events: [] };
@@ -5107,7 +5241,7 @@ function registerRoutes(scope, ctx) {
5107
5241
  scope.get(
5108
5242
  "/incidents/:nodeId",
5109
5243
  async (req, reply) => {
5110
- const proj = resolveProject(registry, req, reply);
5244
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5111
5245
  if (!proj) return;
5112
5246
  const { nodeId } = req.params;
5113
5247
  if (!proj.graph.hasNode(nodeId)) {
@@ -5123,7 +5257,7 @@ function registerRoutes(scope, ctx) {
5123
5257
  }
5124
5258
  );
5125
5259
  scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
5126
- const proj = resolveProject(registry, req, reply);
5260
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5127
5261
  if (!proj) return;
5128
5262
  const { nodeId } = req.params;
5129
5263
  if (!proj.graph.hasNode(nodeId)) {
@@ -5143,7 +5277,7 @@ function registerRoutes(scope, ctx) {
5143
5277
  return result;
5144
5278
  });
5145
5279
  scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
5146
- const proj = resolveProject(registry, req, reply);
5280
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5147
5281
  if (!proj) return;
5148
5282
  const { nodeId } = req.params;
5149
5283
  if (!proj.graph.hasNode(nodeId)) {
@@ -5156,7 +5290,7 @@ function registerRoutes(scope, ctx) {
5156
5290
  return getBlastRadius(proj.graph, nodeId, depth);
5157
5291
  });
5158
5292
  scope.get("/search", async (req, reply) => {
5159
- const proj = resolveProject(registry, req, reply);
5293
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5160
5294
  if (!proj) return;
5161
5295
  const raw = (req.query.q ?? "").trim();
5162
5296
  if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
@@ -5187,7 +5321,7 @@ function registerRoutes(scope, ctx) {
5187
5321
  scope.get(
5188
5322
  "/graph/diff",
5189
5323
  async (req, reply) => {
5190
- const proj = resolveProject(registry, req, reply);
5324
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5191
5325
  if (!proj) return;
5192
5326
  const against = req.query.against;
5193
5327
  if (!against) {
@@ -5201,8 +5335,37 @@ function registerRoutes(scope, ctx) {
5201
5335
  }
5202
5336
  }
5203
5337
  );
5338
+ scope.post("/snapshot", async (req, reply) => {
5339
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5340
+ if (!proj) return;
5341
+ const body = req.body;
5342
+ if (!body || typeof body !== "object" || !body.snapshot) {
5343
+ return reply.code(400).send({ error: "request body must be { snapshot: <persisted-graph> }" });
5344
+ }
5345
+ const snap = body.snapshot;
5346
+ if (typeof snap.schemaVersion !== "number" || snap.schemaVersion !== SCHEMA_VERSION) {
5347
+ return reply.code(400).send({
5348
+ error: `unsupported snapshot schemaVersion ${snap.schemaVersion} (expected ${SCHEMA_VERSION})`
5349
+ });
5350
+ }
5351
+ try {
5352
+ const result = mergeSnapshot(proj.graph, snap);
5353
+ return {
5354
+ project: proj.name,
5355
+ nodesAdded: result.nodesAdded,
5356
+ edgesAdded: result.edgesAdded,
5357
+ nodeCount: proj.graph.order,
5358
+ edgeCount: proj.graph.size
5359
+ };
5360
+ } catch (err) {
5361
+ return reply.code(400).send({
5362
+ error: "snapshot merge failed",
5363
+ details: err.message
5364
+ });
5365
+ }
5366
+ });
5204
5367
  scope.post("/graph/scan", async (req, reply) => {
5205
- const proj = resolveProject(registry, req, reply);
5368
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5206
5369
  if (!proj) return;
5207
5370
  if (!proj.scanPath) {
5208
5371
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -5218,7 +5381,7 @@ function registerRoutes(scope, ctx) {
5218
5381
  };
5219
5382
  });
5220
5383
  scope.get("/policies", async (req, reply) => {
5221
- const proj = resolveProject(registry, req, reply);
5384
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5222
5385
  if (!proj) return;
5223
5386
  const policyPath = ctx.policyFilePathFor(proj);
5224
5387
  if (!policyPath) {
@@ -5235,7 +5398,7 @@ function registerRoutes(scope, ctx) {
5235
5398
  }
5236
5399
  });
5237
5400
  scope.get("/policies/violations", async (req, reply) => {
5238
- const proj = resolveProject(registry, req, reply);
5401
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5239
5402
  if (!proj) return;
5240
5403
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
5241
5404
  let violations = await log.readAll();
@@ -5255,7 +5418,7 @@ function registerRoutes(scope, ctx) {
5255
5418
  return { violations };
5256
5419
  });
5257
5420
  scope.post("/policies/check", async (req, reply) => {
5258
- const proj = resolveProject(registry, req, reply);
5421
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5259
5422
  if (!proj) return;
5260
5423
  const parsed = import_types22.PoliciesCheckBodySchema.safeParse(req.body ?? {});
5261
5424
  if (!parsed.success) {
@@ -5294,7 +5457,15 @@ function registerRoutes(scope, ctx) {
5294
5457
  async function buildApi(opts) {
5295
5458
  const app = (0, import_fastify.default)({ logger: false });
5296
5459
  await app.register(import_cors.default, { origin: true });
5297
- mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy });
5460
+ mountBearerAuth(app, {
5461
+ token: opts.authToken,
5462
+ trustProxy: opts.trustProxy,
5463
+ publicRead: opts.publicRead
5464
+ });
5465
+ app.get("/api/config", async () => ({
5466
+ publicRead: opts.publicRead === true,
5467
+ authProxy: opts.trustProxy === true
5468
+ }));
5298
5469
  const startedAt = opts.startedAt ?? Date.now();
5299
5470
  const registry = buildLegacyRegistry(opts);
5300
5471
  const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
@@ -5318,10 +5489,36 @@ async function buildApi(opts) {
5318
5489
  const routeCtx = {
5319
5490
  registry,
5320
5491
  startedAt,
5492
+ scope: "root",
5321
5493
  errorsPathFor,
5322
5494
  staleEventsPathFor,
5323
- policyFilePathFor
5495
+ policyFilePathFor,
5496
+ bootstrap: opts.bootstrap
5324
5497
  };
5498
+ app.get("/health", async () => {
5499
+ const uptimeMs = Date.now() - startedAt;
5500
+ const bootstrapList = opts.bootstrap?.list() ?? [];
5501
+ const byName = new Map(bootstrapList.map((p) => [p.name, p]));
5502
+ const names = /* @__PURE__ */ new Set([
5503
+ ...registry.list(),
5504
+ ...bootstrapList.map((p) => p.name)
5505
+ ]);
5506
+ const projects = [...names].sort().map((name) => {
5507
+ const proj = registry.get(name);
5508
+ const tracked = byName.get(name);
5509
+ return {
5510
+ name,
5511
+ nodeCount: proj?.graph.order ?? 0,
5512
+ edgeCount: proj?.graph.size ?? 0,
5513
+ ...tracked ? { status: tracked.status, elapsedMs: tracked.elapsedMs } : {}
5514
+ };
5515
+ });
5516
+ return {
5517
+ ok: true,
5518
+ uptimeMs,
5519
+ projects
5520
+ };
5521
+ });
5325
5522
  app.get("/projects", async (_req, reply) => {
5326
5523
  try {
5327
5524
  return await listProjects();
@@ -5349,7 +5546,7 @@ async function buildApi(opts) {
5349
5546
  registerRoutes(app, routeCtx);
5350
5547
  await app.register(
5351
5548
  async (scope) => {
5352
- registerRoutes(scope, routeCtx);
5549
+ registerRoutes(scope, { ...routeCtx, scope: "project" });
5353
5550
  },
5354
5551
  { prefix: "/projects/:project" }
5355
5552
  );
@@ -5362,16 +5559,39 @@ init_otel_grpc();
5362
5559
 
5363
5560
  // src/daemon.ts
5364
5561
  init_cjs_shims();
5365
- var import_node_fs21 = require("fs");
5366
- var import_node_path36 = __toESM(require("path"), 1);
5562
+ var import_node_fs22 = require("fs");
5563
+ var import_node_path37 = __toESM(require("path"), 1);
5367
5564
  init_otel();
5368
5565
  init_auth();
5566
+
5567
+ // src/unrouted.ts
5568
+ init_cjs_shims();
5569
+ var import_node_fs21 = require("fs");
5570
+ var import_node_path36 = __toESM(require("path"), 1);
5571
+ function buildUnroutedSpanRecord(serviceName, traceId, now = /* @__PURE__ */ new Date()) {
5572
+ return {
5573
+ timestamp: now.toISOString(),
5574
+ reason: "no-project-match",
5575
+ service_name: serviceName ?? null,
5576
+ traceId: traceId ?? null
5577
+ };
5578
+ }
5579
+ async function appendUnroutedSpan(neatHome2, record) {
5580
+ const target = import_node_path36.default.join(neatHome2, "errors.ndjson");
5581
+ await import_node_fs21.promises.mkdir(neatHome2, { recursive: true });
5582
+ await import_node_fs21.promises.appendFile(target, JSON.stringify(record) + "\n", "utf8");
5583
+ }
5584
+ function unroutedErrorsPath(neatHome2) {
5585
+ return import_node_path36.default.join(neatHome2, "errors.ndjson");
5586
+ }
5587
+
5588
+ // src/daemon.ts
5369
5589
  function neatHomeFor(opts) {
5370
- if (opts.neatHome && opts.neatHome.length > 0) return import_node_path36.default.resolve(opts.neatHome);
5590
+ if (opts.neatHome && opts.neatHome.length > 0) return import_node_path37.default.resolve(opts.neatHome);
5371
5591
  const env = process.env.NEAT_HOME;
5372
- if (env && env.length > 0) return import_node_path36.default.resolve(env);
5592
+ if (env && env.length > 0) return import_node_path37.default.resolve(env);
5373
5593
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
5374
- return import_node_path36.default.join(home, ".neat");
5594
+ return import_node_path37.default.join(home, ".neat");
5375
5595
  }
5376
5596
  function routeSpanToProject(serviceName, projects) {
5377
5597
  if (!serviceName) return DEFAULT_PROJECT;
@@ -5406,9 +5626,9 @@ function isTokenContained(needle, haystack) {
5406
5626
  return tokens.includes(needle);
5407
5627
  }
5408
5628
  async function bootstrapProject(entry) {
5409
- const paths = pathsForProject(entry.name, import_node_path36.default.join(entry.path, "neat-out"));
5629
+ const paths = pathsForProject(entry.name, import_node_path37.default.join(entry.path, "neat-out"));
5410
5630
  try {
5411
- const stat = await import_node_fs21.promises.stat(entry.path);
5631
+ const stat = await import_node_fs22.promises.stat(entry.path);
5412
5632
  if (!stat.isDirectory()) {
5413
5633
  throw new Error(`registered path ${entry.path} is not a directory`);
5414
5634
  }
@@ -5463,27 +5683,30 @@ function resolveOtlpPort(opts) {
5463
5683
  }
5464
5684
  return 4318;
5465
5685
  }
5466
- function resolveHost(opts) {
5686
+ function resolveHost(opts, authTokenSet) {
5467
5687
  if (opts.host && opts.host.length > 0) return opts.host;
5468
5688
  const env = process.env.HOST;
5469
5689
  if (env && env.length > 0) return env;
5690
+ if (!authTokenSet) return "127.0.0.1";
5470
5691
  return "0.0.0.0";
5471
5692
  }
5472
5693
  async function startDaemon(opts = {}) {
5473
5694
  const home = neatHomeFor(opts);
5474
5695
  const regPath = registryPath();
5475
5696
  try {
5476
- await import_node_fs21.promises.access(regPath);
5697
+ await import_node_fs22.promises.access(regPath);
5477
5698
  } catch {
5478
5699
  throw new Error(
5479
5700
  `neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
5480
5701
  );
5481
5702
  }
5482
- const pidPath = import_node_path36.default.join(home, "neatd.pid");
5703
+ const pidPath = import_node_path37.default.join(home, "neatd.pid");
5483
5704
  await writeAtomically(pidPath, `${process.pid}
5484
5705
  `);
5485
5706
  const slots = /* @__PURE__ */ new Map();
5486
5707
  const registry = new Projects();
5708
+ const bootstrapStatus = /* @__PURE__ */ new Map();
5709
+ const bootstrapStartedAt = /* @__PURE__ */ new Map();
5487
5710
  const DROP_WARN_INTERVAL_MS = 6e4;
5488
5711
  const lastDropWarnAt = /* @__PURE__ */ new Map();
5489
5712
  function warnDroppedSpan(project, reason) {
@@ -5495,6 +5718,22 @@ async function startDaemon(opts = {}) {
5495
5718
  `[neatd] dropping span for project "${project}" \u2014 project status: broken (${reason}). Run \`neatd reload\` to retry bootstrap.`
5496
5719
  );
5497
5720
  }
5721
+ const unroutedPath = unroutedErrorsPath(home);
5722
+ const lastUnroutedWarnAt = /* @__PURE__ */ new Map();
5723
+ async function recordUnroutedSpan(serviceName, traceId) {
5724
+ const key = serviceName ?? "<missing>";
5725
+ const now = Date.now();
5726
+ try {
5727
+ await appendUnroutedSpan(home, buildUnroutedSpanRecord(serviceName, traceId, new Date(now)));
5728
+ } catch {
5729
+ }
5730
+ const prev = lastUnroutedWarnAt.get(key) ?? 0;
5731
+ if (now - prev < DROP_WARN_INTERVAL_MS) return;
5732
+ lastUnroutedWarnAt.set(key, now);
5733
+ console.warn(
5734
+ `[neatd] dropping span \u2014 service.name "${key}" matches no registered project and no \`default\` project exists. See ${unroutedPath}.`
5735
+ );
5736
+ }
5498
5737
  function upsertRegistryFromSlot(slot) {
5499
5738
  if (slot.status !== "active") return;
5500
5739
  registry.set(slot.entry.name, {
@@ -5523,34 +5762,43 @@ async function startDaemon(opts = {}) {
5523
5762
  return slots.get(entry.name);
5524
5763
  }
5525
5764
  }
5765
+ async function bootstrapOne(entry) {
5766
+ bootstrapStatus.set(entry.name, "bootstrapping");
5767
+ bootstrapStartedAt.set(entry.name, Date.now());
5768
+ try {
5769
+ const slot = await bootstrapProject(entry);
5770
+ slots.set(entry.name, slot);
5771
+ upsertRegistryFromSlot(slot);
5772
+ bootstrapStatus.set(entry.name, slot.status === "broken" ? "broken" : "active");
5773
+ if (slot.status === "broken") {
5774
+ console.warn(`neatd: project "${entry.name}" broken \u2014 ${slot.errorReason}`);
5775
+ } else {
5776
+ console.log(`neatd: project "${entry.name}" active (${entry.path})`);
5777
+ }
5778
+ } catch (err) {
5779
+ bootstrapStatus.set(entry.name, "broken");
5780
+ console.warn(
5781
+ `neatd: project "${entry.name}" failed to bootstrap \u2014 ${err.message}`
5782
+ );
5783
+ await setStatus(entry.name, "broken").catch(() => {
5784
+ });
5785
+ }
5786
+ }
5526
5787
  async function loadAll() {
5527
5788
  const projects = await listProjects();
5528
5789
  const seen = /* @__PURE__ */ new Set();
5790
+ const pending = [];
5529
5791
  for (const entry of projects) {
5530
5792
  seen.add(entry.name);
5531
5793
  const existing = slots.get(entry.name);
5532
5794
  if (existing) {
5533
5795
  if (existing.status === "broken") {
5534
- await tryRecoverSlot(entry);
5796
+ pending.push(tryRecoverSlot(entry).then(() => {
5797
+ }));
5535
5798
  }
5536
5799
  continue;
5537
5800
  }
5538
- try {
5539
- const slot = await bootstrapProject(entry);
5540
- slots.set(entry.name, slot);
5541
- upsertRegistryFromSlot(slot);
5542
- if (slot.status === "broken") {
5543
- console.warn(`neatd: project "${entry.name}" broken \u2014 ${slot.errorReason}`);
5544
- } else {
5545
- console.log(`neatd: project "${entry.name}" active (${entry.path})`);
5546
- }
5547
- } catch (err) {
5548
- console.warn(
5549
- `neatd: project "${entry.name}" failed to bootstrap \u2014 ${err.message}`
5550
- );
5551
- await setStatus(entry.name, "broken").catch(() => {
5552
- });
5553
- }
5801
+ pending.push(bootstrapOne(entry));
5554
5802
  }
5555
5803
  for (const [name, slot] of [...slots.entries()]) {
5556
5804
  if (seen.has(name)) continue;
@@ -5559,26 +5807,45 @@ async function startDaemon(opts = {}) {
5559
5807
  } catch {
5560
5808
  }
5561
5809
  slots.delete(name);
5810
+ bootstrapStatus.delete(name);
5811
+ bootstrapStartedAt.delete(name);
5562
5812
  console.log(`neatd: project "${name}" removed from registry \u2014 stopped`);
5563
5813
  }
5814
+ await Promise.allSettled(pending);
5815
+ }
5816
+ const initialEntries = await listProjects().catch(() => []);
5817
+ for (const entry of initialEntries) {
5818
+ bootstrapStatus.set(entry.name, "bootstrapping");
5819
+ bootstrapStartedAt.set(entry.name, Date.now());
5564
5820
  }
5565
- await loadAll();
5566
5821
  const bind = opts.bindListeners !== false;
5567
5822
  let restApp = null;
5568
5823
  let otlpApp = null;
5569
5824
  let restAddress = "";
5570
5825
  let otlpAddress = "";
5571
5826
  if (bind) {
5572
- const host = resolveHost(opts);
5827
+ const auth = readAuthEnv();
5828
+ const host = resolveHost(opts, Boolean(auth.authToken));
5573
5829
  const restPort = resolveRestPort(opts);
5574
5830
  const otlpPort = resolveOtlpPort(opts);
5575
- const auth = readAuthEnv();
5576
5831
  assertBindAuthority(host, auth.authToken);
5577
5832
  try {
5578
5833
  restApp = await buildApi({
5579
5834
  projects: registry,
5580
5835
  authToken: auth.authToken,
5581
- trustProxy: auth.trustProxy
5836
+ trustProxy: auth.trustProxy,
5837
+ publicRead: auth.publicRead,
5838
+ bootstrap: {
5839
+ status: (name) => bootstrapStatus.get(name),
5840
+ list: () => {
5841
+ const now = Date.now();
5842
+ return [...bootstrapStatus.entries()].map(([name, status2]) => ({
5843
+ name,
5844
+ status: status2,
5845
+ elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
5846
+ }));
5847
+ }
5848
+ }
5582
5849
  });
5583
5850
  restAddress = await restApp.listen({ port: restPort, host });
5584
5851
  console.log(`neatd: REST listening on ${restAddress}`);
@@ -5591,17 +5858,20 @@ async function startDaemon(opts = {}) {
5591
5858
  }
5592
5859
  if (restApp) await restApp.close().catch(() => {
5593
5860
  });
5594
- await import_node_fs21.promises.unlink(pidPath).catch(() => {
5861
+ await import_node_fs22.promises.unlink(pidPath).catch(() => {
5595
5862
  });
5596
5863
  throw new Error(
5597
5864
  `neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
5598
5865
  );
5599
5866
  }
5600
- async function resolveTargetSlot(serviceName) {
5867
+ async function resolveTargetSlot(serviceName, traceId) {
5601
5868
  const liveEntries = await listProjects().catch(() => []);
5602
5869
  const target = routeSpanToProject(serviceName, liveEntries);
5603
5870
  let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5604
- if (!slot) return null;
5871
+ if (!slot) {
5872
+ await recordUnroutedSpan(serviceName, traceId);
5873
+ return null;
5874
+ }
5605
5875
  if (slot.status === "broken") {
5606
5876
  const entry = liveEntries.find((e) => e.name === slot.entry.name);
5607
5877
  if (entry) {
@@ -5619,7 +5889,7 @@ async function startDaemon(opts = {}) {
5619
5889
  authToken: auth.otelToken,
5620
5890
  trustProxy: auth.trustProxy,
5621
5891
  onSpan: async (span) => {
5622
- const slot = await resolveTargetSlot(span.service);
5892
+ const slot = await resolveTargetSlot(span.service, span.traceId);
5623
5893
  if (!slot) return;
5624
5894
  await handleSpan(
5625
5895
  {
@@ -5633,7 +5903,7 @@ async function startDaemon(opts = {}) {
5633
5903
  );
5634
5904
  },
5635
5905
  onErrorSpanSync: async (span) => {
5636
- const slot = await resolveTargetSlot(span.service);
5906
+ const slot = await resolveTargetSlot(span.service, span.traceId);
5637
5907
  if (!slot) return;
5638
5908
  await makeErrorSpanWriter(slot.paths.errorsPath)(span);
5639
5909
  }
@@ -5651,14 +5921,17 @@ async function startDaemon(opts = {}) {
5651
5921
  });
5652
5922
  if (otlpApp) await otlpApp.close().catch(() => {
5653
5923
  });
5654
- await import_node_fs21.promises.unlink(pidPath).catch(() => {
5924
+ await import_node_fs22.promises.unlink(pidPath).catch(() => {
5655
5925
  });
5656
5926
  throw new Error(
5657
5927
  `neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
5658
5928
  );
5659
5929
  }
5660
5930
  }
5661
- let reloading = null;
5931
+ const initialBootstrap = loadAll().catch((err) => {
5932
+ console.warn(`neatd: initial bootstrap pass failed \u2014 ${err.message}`);
5933
+ });
5934
+ let reloading = initialBootstrap;
5662
5935
  const reload = async () => {
5663
5936
  if (reloading) return reloading;
5664
5937
  reloading = (async () => {
@@ -5670,6 +5943,20 @@ async function startDaemon(opts = {}) {
5670
5943
  })();
5671
5944
  return reloading;
5672
5945
  };
5946
+ void initialBootstrap.finally(() => {
5947
+ if (reloading === initialBootstrap) reloading = null;
5948
+ });
5949
+ const tracker = {
5950
+ status: (name) => bootstrapStatus.get(name),
5951
+ list: () => {
5952
+ const now = Date.now();
5953
+ return [...bootstrapStatus.entries()].map(([name, status2]) => ({
5954
+ name,
5955
+ status: status2,
5956
+ elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
5957
+ }));
5958
+ }
5959
+ };
5673
5960
  const sighupHandler = () => {
5674
5961
  void reload().catch((err) => {
5675
5962
  console.warn(`neatd: SIGHUP reload failed \u2014 ${err.message}`);
@@ -5691,10 +5978,19 @@ async function startDaemon(opts = {}) {
5691
5978
  } catch {
5692
5979
  }
5693
5980
  }
5694
- await import_node_fs21.promises.unlink(pidPath).catch(() => {
5981
+ await import_node_fs22.promises.unlink(pidPath).catch(() => {
5695
5982
  });
5696
5983
  };
5697
- return { slots, reload, stop, pidPath, restAddress, otlpAddress };
5984
+ return {
5985
+ slots,
5986
+ reload,
5987
+ stop,
5988
+ pidPath,
5989
+ restAddress,
5990
+ otlpAddress,
5991
+ bootstrap: tracker,
5992
+ initialBootstrap
5993
+ };
5698
5994
  }
5699
5995
  // Annotate the CommonJS export names for ESM import in node:
5700
5996
  0 && (module.exports = {