@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/neatd.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", (req2, reply, done) => {
59
- const path39 = (req2.url.split("?")[0] ?? "").replace(/\/+$/, "");
60
+ const path40 = (req2.url.split("?")[0] ?? "").replace(/\/+$/, "");
60
61
  for (const suffix of suffixes) {
61
- if (path39 === suffix || path39.endsWith(suffix)) {
62
+ if (path40 === suffix || path40.endsWith(suffix)) {
62
63
  done();
63
64
  return;
64
65
  }
65
66
  }
67
+ if (publicRead && PUBLIC_READ_METHODS.has(req2.method)) {
68
+ done();
69
+ return;
70
+ }
66
71
  const header = req2.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,
@@ -465,7 +491,7 @@ async function buildOtelReceiver(opts) {
465
491
  };
466
492
  return decorated;
467
493
  }
468
- var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
494
+ var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT, ENV_FALLBACK, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
469
495
  var init_otel = __esm({
470
496
  "src/otel.ts"() {
471
497
  "use strict";
@@ -475,6 +501,9 @@ var init_otel = __esm({
475
501
  import_fastify2 = __toESM(require("fastify"), 1);
476
502
  import_protobufjs = __toESM(require("protobufjs"), 1);
477
503
  init_auth();
504
+ ENV_ATTR_CANONICAL = "deployment.environment.name";
505
+ ENV_ATTR_COMPAT = "deployment.environment";
506
+ ENV_FALLBACK = "unknown";
478
507
  exportTraceServiceRequestType = null;
479
508
  exportTraceServiceResponseType = null;
480
509
  cachedProtobufResponseBody = null;
@@ -483,14 +512,14 @@ var init_otel = __esm({
483
512
 
484
513
  // src/neatd.ts
485
514
  init_cjs_shims();
486
- var import_node_fs22 = require("fs");
487
- var import_node_path38 = __toESM(require("path"), 1);
515
+ var import_node_fs23 = require("fs");
516
+ var import_node_path39 = __toESM(require("path"), 1);
488
517
  var import_node_module = require("module");
489
518
 
490
519
  // src/daemon.ts
491
520
  init_cjs_shims();
492
- var import_node_fs21 = require("fs");
493
- var import_node_path36 = __toESM(require("path"), 1);
521
+ var import_node_fs22 = require("fs");
522
+ var import_node_path37 = __toESM(require("path"), 1);
494
523
 
495
524
  // src/graph.ts
496
525
  init_cjs_shims();
@@ -950,19 +979,19 @@ function confidenceFromMix(edges, now = Date.now()) {
950
979
  function longestIncomingWalk(graph, start, maxDepth) {
951
980
  let best = { path: [start], edges: [] };
952
981
  const visited = /* @__PURE__ */ new Set([start]);
953
- function step(node, path39, edges) {
954
- if (path39.length > best.path.length) {
955
- best = { path: [...path39], edges: [...edges] };
982
+ function step(node, path40, edges) {
983
+ if (path40.length > best.path.length) {
984
+ best = { path: [...path40], edges: [...edges] };
956
985
  }
957
- if (path39.length - 1 >= maxDepth) return;
986
+ if (path40.length - 1 >= maxDepth) return;
958
987
  const incoming = bestEdgeBySource(graph, graph.inboundEdges(node));
959
988
  for (const [srcId, edge] of incoming) {
960
989
  if (visited.has(srcId)) continue;
961
990
  visited.add(srcId);
962
- path39.push(srcId);
991
+ path40.push(srcId);
963
992
  edges.push(edge);
964
- step(srcId, path39, edges);
965
- path39.pop();
993
+ step(srcId, path40, edges);
994
+ path40.pop();
966
995
  edges.pop();
967
996
  visited.delete(srcId);
968
997
  }
@@ -1513,52 +1542,64 @@ function cacheSpanService(span, now) {
1513
1542
  if (!span.traceId || !span.spanId) return;
1514
1543
  const key = parentSpanKey(span.traceId, span.spanId);
1515
1544
  parentSpanCache.delete(key);
1516
- parentSpanCache.set(key, { service: span.service, expiresAt: now + PARENT_SPAN_CACHE_TTL_MS });
1545
+ parentSpanCache.set(key, {
1546
+ service: span.service,
1547
+ env: span.env ?? "unknown",
1548
+ expiresAt: now + PARENT_SPAN_CACHE_TTL_MS
1549
+ });
1517
1550
  while (parentSpanCache.size > PARENT_SPAN_CACHE_SIZE) {
1518
1551
  const oldest = parentSpanCache.keys().next().value;
1519
1552
  if (!oldest) break;
1520
1553
  parentSpanCache.delete(oldest);
1521
1554
  }
1522
1555
  }
1523
- function lookupParentSpanService(traceId, parentSpanId, now) {
1556
+ function lookupParentSpan(traceId, parentSpanId, now) {
1524
1557
  const entry2 = parentSpanCache.get(parentSpanKey(traceId, parentSpanId));
1525
1558
  if (!entry2) return null;
1526
1559
  if (entry2.expiresAt <= now) {
1527
1560
  parentSpanCache.delete(parentSpanKey(traceId, parentSpanId));
1528
1561
  return null;
1529
1562
  }
1530
- return entry2.service;
1563
+ return { service: entry2.service, env: entry2.env };
1531
1564
  }
1532
- function resolveServiceId(graph, host) {
1533
- const direct = (0, import_types3.serviceId)(host);
1534
- if (graph.hasNode(direct)) return direct;
1535
- let found = null;
1565
+ function resolveServiceId(graph, host, env) {
1566
+ const envTagged = (0, import_types3.serviceId)(host, env);
1567
+ if (graph.hasNode(envTagged)) return envTagged;
1568
+ const envLess = (0, import_types3.serviceId)(host);
1569
+ if (envLess !== envTagged && graph.hasNode(envLess)) return envLess;
1570
+ let sameEnv = null;
1571
+ let envLessMatch = null;
1572
+ let anyMatch = null;
1536
1573
  graph.forEachNode((id, attrs) => {
1537
- if (found) return;
1574
+ if (sameEnv) return;
1538
1575
  const a = attrs;
1539
1576
  if (a.type !== import_types3.NodeType.ServiceNode) return;
1540
- if (a.name === host) {
1541
- found = id;
1577
+ const matchesByName = a.name === host;
1578
+ const matchesByAlias = a.aliases ? a.aliases.includes(host) : false;
1579
+ if (!matchesByName && !matchesByAlias) return;
1580
+ const nodeEnv = a.env ?? "unknown";
1581
+ if (nodeEnv === env) {
1582
+ sameEnv = id;
1542
1583
  return;
1543
1584
  }
1544
- if (a.aliases && a.aliases.includes(host)) {
1545
- found = id;
1546
- }
1585
+ if (nodeEnv === "unknown" && !envLessMatch) envLessMatch = id;
1586
+ else if (!anyMatch) anyMatch = id;
1547
1587
  });
1548
- return found;
1588
+ return sameEnv ?? envLessMatch ?? anyMatch;
1549
1589
  }
1550
1590
  function frontierIdFor(host) {
1551
1591
  return (0, import_types3.frontierId)(host);
1552
1592
  }
1553
- function ensureServiceNode(graph, serviceName) {
1554
- const id = (0, import_types3.serviceId)(serviceName);
1593
+ function ensureServiceNode(graph, serviceName, env) {
1594
+ const id = (0, import_types3.serviceId)(serviceName, env);
1555
1595
  if (graph.hasNode(id)) return id;
1556
1596
  const node = {
1557
1597
  id,
1558
1598
  type: import_types3.NodeType.ServiceNode,
1559
1599
  name: serviceName,
1560
1600
  language: "unknown",
1561
- discoveredVia: "otel"
1601
+ discoveredVia: "otel",
1602
+ ...env !== "unknown" ? { env } : {}
1562
1603
  };
1563
1604
  graph.addNode(id, node);
1564
1605
  return id;
@@ -1704,7 +1745,7 @@ function buildErrorEventForReceiver(span) {
1704
1745
  ...span.exception?.type ? { exceptionType: span.exception.type } : {},
1705
1746
  ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
1706
1747
  ...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
1707
- affectedNode: (0, import_types3.serviceId)(span.service)
1748
+ affectedNode: (0, import_types3.serviceId)(span.service, span.env)
1708
1749
  };
1709
1750
  }
1710
1751
  function makeErrorSpanWriter(errorsPath) {
@@ -1718,7 +1759,8 @@ function makeErrorSpanWriter(errorsPath) {
1718
1759
  async function handleSpan(ctx, span) {
1719
1760
  const ts = span.startTimeIso ?? nowIso(ctx);
1720
1761
  const nowMs = ctx.now ? ctx.now() : Date.now();
1721
- const sourceId = ensureServiceNode(ctx.graph, span.service);
1762
+ const env = span.env ?? "unknown";
1763
+ const sourceId = ensureServiceNode(ctx.graph, span.service, env);
1722
1764
  const isError = span.statusCode === 2;
1723
1765
  cacheSpanService(span, nowMs);
1724
1766
  let affectedNode = sourceId;
@@ -1741,7 +1783,7 @@ async function handleSpan(ctx, span) {
1741
1783
  const host = pickAddress(span);
1742
1784
  let resolvedViaAddress = false;
1743
1785
  if (host && host !== span.service) {
1744
- const targetId = resolveServiceId(ctx.graph, host);
1786
+ const targetId = resolveServiceId(ctx.graph, host, env);
1745
1787
  if (targetId && targetId !== sourceId) {
1746
1788
  upsertObservedEdge(
1747
1789
  ctx.graph,
@@ -1768,9 +1810,9 @@ async function handleSpan(ctx, span) {
1768
1810
  }
1769
1811
  }
1770
1812
  if (!resolvedViaAddress && span.parentSpanId) {
1771
- const parentService = lookupParentSpanService(span.traceId, span.parentSpanId, nowMs);
1772
- if (parentService && parentService !== span.service) {
1773
- const parentId = ensureServiceNode(ctx.graph, parentService);
1813
+ const parent = lookupParentSpan(span.traceId, span.parentSpanId, nowMs);
1814
+ if (parent && parent.service !== span.service) {
1815
+ const parentId = ensureServiceNode(ctx.graph, parent.service, parent.env);
1774
1816
  upsertObservedEdge(
1775
1817
  ctx.graph,
1776
1818
  import_types3.EdgeType.CALLS,
@@ -1893,6 +1935,28 @@ async function readErrorEvents(errorsPath) {
1893
1935
  throw err;
1894
1936
  }
1895
1937
  }
1938
+ function mergeSnapshot(graph, snapshot) {
1939
+ const exported = snapshot.graph;
1940
+ let nodesAdded = 0;
1941
+ let edgesAdded = 0;
1942
+ for (const node of exported.nodes ?? []) {
1943
+ if (graph.hasNode(node.key)) continue;
1944
+ if (!node.attributes) continue;
1945
+ graph.addNode(node.key, node.attributes);
1946
+ nodesAdded++;
1947
+ }
1948
+ for (const edge of exported.edges ?? []) {
1949
+ const attrs = edge.attributes;
1950
+ if (!attrs) continue;
1951
+ const id = edge.key ?? attrs.id;
1952
+ if (!id) continue;
1953
+ if (graph.hasEdge(id)) continue;
1954
+ if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
1955
+ graph.addEdgeWithKey(id, edge.source, edge.target, attrs);
1956
+ edgesAdded++;
1957
+ }
1958
+ return { nodesAdded, edgesAdded };
1959
+ }
1896
1960
 
1897
1961
  // src/extract/services.ts
1898
1962
  init_cjs_shims();
@@ -1916,8 +1980,36 @@ var IGNORED_DIRS = /* @__PURE__ */ new Set([
1916
1980
  ".turbo",
1917
1981
  "dist",
1918
1982
  "build",
1919
- ".next"
1983
+ ".next",
1984
+ // Python virtualenv shapes (issue #344). Walking into a venv pulls in the
1985
+ // entire CPython stdlib + every installed package as if it were first-party
1986
+ // service code — 20k+ files, none of which the user wrote. The shape names
1987
+ // cover the three common venv tools (`venv` / `python -m venv`,
1988
+ // `virtualenv`) and the tox + PEP 582 conventions.
1989
+ ".venv",
1990
+ "venv",
1991
+ "__pypackages__",
1992
+ ".tox",
1993
+ // `site-packages` shows up nested inside venvs that don't carry one of the
1994
+ // outer names (system Python on macOS, in-place pyenv layouts). Listing it
1995
+ // here means we stop the walk at the boundary even when the wrapper dir
1996
+ // wasn't recognisable.
1997
+ "site-packages"
1920
1998
  ]);
1999
+ var PYVENV_MARKER_CACHE = /* @__PURE__ */ new Map();
2000
+ async function isPythonVenvDir(dir) {
2001
+ const cached = PYVENV_MARKER_CACHE.get(dir);
2002
+ if (cached !== void 0) return cached;
2003
+ try {
2004
+ const stat = await import_node_fs4.promises.stat(import_node_path4.default.join(dir, "pyvenv.cfg"));
2005
+ const ok = stat.isFile();
2006
+ PYVENV_MARKER_CACHE.set(dir, ok);
2007
+ return ok;
2008
+ } catch {
2009
+ PYVENV_MARKER_CACHE.set(dir, false);
2010
+ return false;
2011
+ }
2012
+ }
1921
2013
  function isConfigFile(name) {
1922
2014
  const ext = import_node_path4.default.extname(name);
1923
2015
  if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
@@ -2254,6 +2346,7 @@ async function walkDirs(start, scanPath, options, visit) {
2254
2346
  const rel = import_node_path8.default.relative(scanPath, child).split(import_node_path8.default.sep).join("/");
2255
2347
  if (rel && options.ig.ignores(rel + "/")) continue;
2256
2348
  }
2349
+ if (await isPythonVenvDir(child)) continue;
2257
2350
  await visit(child);
2258
2351
  await recurse(child, depth + 1);
2259
2352
  }
@@ -2289,6 +2382,18 @@ async function expandWorkspaceGlobs(scanPath, globs) {
2289
2382
  }
2290
2383
  return [...found];
2291
2384
  }
2385
+ function detectJsFramework(pkg) {
2386
+ const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
2387
+ if (deps["next"] !== void 0) return "next";
2388
+ if (deps["remix"] !== void 0) return "remix";
2389
+ for (const k of Object.keys(deps)) {
2390
+ if (k.startsWith("@remix-run/")) return "remix";
2391
+ }
2392
+ if (deps["@sveltejs/kit"] !== void 0) return "sveltekit";
2393
+ if (deps["nuxt"] !== void 0) return "nuxt";
2394
+ if (deps["astro"] !== void 0) return "astro";
2395
+ return void 0;
2396
+ }
2292
2397
  async function discoverNodeService(scanPath, dir) {
2293
2398
  const pkgPath = import_node_path8.default.join(dir, "package.json");
2294
2399
  if (!await exists(pkgPath)) return null;
@@ -2300,6 +2405,7 @@ async function discoverNodeService(scanPath, dir) {
2300
2405
  return null;
2301
2406
  }
2302
2407
  if (!pkg.name) return null;
2408
+ const framework = detectJsFramework(pkg);
2303
2409
  const node = {
2304
2410
  id: (0, import_types5.serviceId)(pkg.name),
2305
2411
  type: import_types5.NodeType.ServiceNode,
@@ -2308,7 +2414,8 @@ async function discoverNodeService(scanPath, dir) {
2308
2414
  version: pkg.version,
2309
2415
  dependencies: pkg.dependencies ?? {},
2310
2416
  repoPath: import_node_path8.default.relative(scanPath, dir),
2311
- ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {}
2417
+ ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {},
2418
+ ...framework ? { framework } : {}
2312
2419
  };
2313
2420
  return { pkg, dir, node };
2314
2421
  }
@@ -2518,7 +2625,9 @@ async function walkYamlFiles(start, depth = 0, max = 5) {
2518
2625
  for (const entry2 of entries) {
2519
2626
  if (entry2.isDirectory()) {
2520
2627
  if (IGNORED_DIRS.has(entry2.name)) continue;
2521
- out.push(...await walkYamlFiles(import_node_path9.default.join(start, entry2.name), depth + 1, max));
2628
+ const child = import_node_path9.default.join(start, entry2.name);
2629
+ if (await isPythonVenvDir(child)) continue;
2630
+ out.push(...await walkYamlFiles(child, depth + 1, max));
2522
2631
  } else if (entry2.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path9.default.extname(entry2.name))) {
2523
2632
  out.push(import_node_path9.default.join(start, entry2.name));
2524
2633
  }
@@ -3200,7 +3309,9 @@ async function walkConfigFiles(dir) {
3200
3309
  for (const entry2 of entries) {
3201
3310
  const full = import_node_path18.default.join(current, entry2.name);
3202
3311
  if (entry2.isDirectory()) {
3203
- if (!IGNORED_DIRS.has(entry2.name)) await walk(full);
3312
+ if (IGNORED_DIRS.has(entry2.name)) continue;
3313
+ if (await isPythonVenvDir(full)) continue;
3314
+ await walk(full);
3204
3315
  } else if (entry2.isFile() && isConfigFile(entry2.name).match) {
3205
3316
  out.push(full);
3206
3317
  }
@@ -3268,7 +3379,9 @@ async function walkSourceFiles(dir) {
3268
3379
  for (const entry2 of entries) {
3269
3380
  const full = import_node_path19.default.join(current, entry2.name);
3270
3381
  if (entry2.isDirectory()) {
3271
- if (!IGNORED_DIRS.has(entry2.name)) await walk(full);
3382
+ if (IGNORED_DIRS.has(entry2.name)) continue;
3383
+ if (await isPythonVenvDir(full)) continue;
3384
+ await walk(full);
3272
3385
  } else if (entry2.isFile() && SERVICE_FILE_EXTENSIONS.has(import_node_path19.default.extname(entry2.name))) {
3273
3386
  out.push(full);
3274
3387
  }
@@ -3904,7 +4017,9 @@ async function walkTfFiles(start, depth = 0, max = 5) {
3904
4017
  for (const entry2 of entries) {
3905
4018
  if (entry2.isDirectory()) {
3906
4019
  if (IGNORED_DIRS.has(entry2.name) || entry2.name === ".terraform") continue;
3907
- out.push(...await walkTfFiles(import_node_path27.default.join(start, entry2.name), depth + 1, max));
4020
+ const child = import_node_path27.default.join(start, entry2.name);
4021
+ if (await isPythonVenvDir(child)) continue;
4022
+ out.push(...await walkTfFiles(child, depth + 1, max));
3908
4023
  } else if (entry2.isFile() && entry2.name.endsWith(".tf")) {
3909
4024
  out.push(import_node_path27.default.join(start, entry2.name));
3910
4025
  }
@@ -3952,7 +4067,9 @@ async function walkYamlFiles2(start, depth = 0, max = 5) {
3952
4067
  for (const entry2 of entries) {
3953
4068
  if (entry2.isDirectory()) {
3954
4069
  if (IGNORED_DIRS.has(entry2.name)) continue;
3955
- out.push(...await walkYamlFiles2(import_node_path28.default.join(start, entry2.name), depth + 1, max));
4070
+ const child = import_node_path28.default.join(start, entry2.name);
4071
+ if (await isPythonVenvDir(child)) continue;
4072
+ out.push(...await walkYamlFiles2(child, depth + 1, max));
3956
4073
  } else if (entry2.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path28.default.extname(entry2.name))) {
3957
4074
  out.push(import_node_path28.default.join(start, entry2.name));
3958
4075
  }
@@ -4091,7 +4208,7 @@ init_cjs_shims();
4091
4208
  var import_node_fs18 = require("fs");
4092
4209
  var import_node_path31 = __toESM(require("path"), 1);
4093
4210
  var import_types19 = require("@neat.is/types");
4094
- var SCHEMA_VERSION = 3;
4211
+ var SCHEMA_VERSION = 4;
4095
4212
  function migrateV1ToV2(payload) {
4096
4213
  const nodes = payload.graph.nodes;
4097
4214
  if (Array.isArray(nodes)) {
@@ -4103,6 +4220,9 @@ function migrateV1ToV2(payload) {
4103
4220
  }
4104
4221
  return { ...payload, schemaVersion: 2 };
4105
4222
  }
4223
+ function migrateV3ToV4(payload) {
4224
+ return { ...payload, schemaVersion: 4 };
4225
+ }
4106
4226
  function migrateV2ToV3(payload) {
4107
4227
  const edges = payload.graph.edges;
4108
4228
  if (Array.isArray(edges)) {
@@ -4151,6 +4271,9 @@ async function loadGraphFromDisk(graph, outPath) {
4151
4271
  if (payload.schemaVersion === 2) {
4152
4272
  payload = migrateV2ToV3(payload);
4153
4273
  }
4274
+ if (payload.schemaVersion === 3) {
4275
+ payload = migrateV3ToV4(payload);
4276
+ }
4154
4277
  if (payload.schemaVersion !== SCHEMA_VERSION) {
4155
4278
  throw new Error(
4156
4279
  `persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
@@ -4747,10 +4870,19 @@ function projectFromReq(req2) {
4747
4870
  const params = req2.params;
4748
4871
  return params.project ?? DEFAULT_PROJECT;
4749
4872
  }
4750
- function resolveProject(registry, req2, reply) {
4873
+ function resolveProject(registry, req2, reply, bootstrap) {
4751
4874
  const name = projectFromReq(req2);
4752
4875
  const ctx = registry.get(name);
4753
4876
  if (!ctx) {
4877
+ const phase = bootstrap?.status(name);
4878
+ if (phase === "bootstrapping") {
4879
+ void reply.code(503).send({ ready: false, project: name, status: "bootstrapping" });
4880
+ return null;
4881
+ }
4882
+ if (phase === "broken") {
4883
+ void reply.code(503).send({ ready: false, project: name, status: "broken" });
4884
+ return null;
4885
+ }
4754
4886
  void reply.code(404).send({ error: "project not found", project: name });
4755
4887
  return null;
4756
4888
  }
@@ -4780,36 +4912,38 @@ function buildLegacyRegistry(opts) {
4780
4912
  function registerRoutes(scope, ctx) {
4781
4913
  const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
4782
4914
  scope.get("/events", (req2, reply) => {
4783
- const proj = resolveProject(registry, req2, reply);
4915
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4784
4916
  if (!proj) return;
4785
4917
  handleSse(req2, reply, { project: proj.name });
4786
4918
  });
4787
- scope.get("/health", async (req2, reply) => {
4788
- const proj = resolveProject(registry, req2, reply);
4789
- if (!proj) return;
4790
- const uptimeMs = Date.now() - startedAt;
4791
- return {
4792
- ok: true,
4793
- project: proj.name,
4794
- uptimeMs,
4795
- // Legacy fields kept additively. The web shell's StatusBar reads
4796
- // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
4797
- // the canonical triple and lets the extras pass through.
4798
- uptime: Math.floor(uptimeMs / 1e3),
4799
- nodeCount: proj.graph.order,
4800
- edgeCount: proj.graph.size,
4801
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
4802
- };
4803
- });
4919
+ if (ctx.scope === "project") {
4920
+ scope.get("/health", async (req2, reply) => {
4921
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4922
+ if (!proj) return;
4923
+ const uptimeMs = Date.now() - startedAt;
4924
+ return {
4925
+ ok: true,
4926
+ project: proj.name,
4927
+ uptimeMs,
4928
+ // Legacy fields kept additively. The web shell's StatusBar reads
4929
+ // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
4930
+ // the canonical triple and lets the extras pass through.
4931
+ uptime: Math.floor(uptimeMs / 1e3),
4932
+ nodeCount: proj.graph.order,
4933
+ edgeCount: proj.graph.size,
4934
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
4935
+ };
4936
+ });
4937
+ }
4804
4938
  scope.get("/graph", async (req2, reply) => {
4805
- const proj = resolveProject(registry, req2, reply);
4939
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4806
4940
  if (!proj) return;
4807
4941
  return serializeGraph(proj.graph);
4808
4942
  });
4809
4943
  scope.get(
4810
4944
  "/graph/node/:id",
4811
4945
  async (req2, reply) => {
4812
- const proj = resolveProject(registry, req2, reply);
4946
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4813
4947
  if (!proj) return;
4814
4948
  const { id } = req2.params;
4815
4949
  if (!proj.graph.hasNode(id)) {
@@ -4821,7 +4955,7 @@ function registerRoutes(scope, ctx) {
4821
4955
  scope.get(
4822
4956
  "/graph/edges/:id",
4823
4957
  async (req2, reply) => {
4824
- const proj = resolveProject(registry, req2, reply);
4958
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4825
4959
  if (!proj) return;
4826
4960
  const { id } = req2.params;
4827
4961
  if (!proj.graph.hasNode(id)) {
@@ -4833,7 +4967,7 @@ function registerRoutes(scope, ctx) {
4833
4967
  }
4834
4968
  );
4835
4969
  scope.get("/graph/dependencies/:nodeId", async (req2, reply) => {
4836
- const proj = resolveProject(registry, req2, reply);
4970
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4837
4971
  if (!proj) return;
4838
4972
  const { nodeId } = req2.params;
4839
4973
  if (!proj.graph.hasNode(nodeId)) {
@@ -4848,7 +4982,7 @@ function registerRoutes(scope, ctx) {
4848
4982
  return getTransitiveDependencies(proj.graph, nodeId, depth);
4849
4983
  });
4850
4984
  scope.get("/graph/divergences", async (req2, reply) => {
4851
- const proj = resolveProject(registry, req2, reply);
4985
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4852
4986
  if (!proj) return;
4853
4987
  let typeFilter;
4854
4988
  if (req2.query.type) {
@@ -4883,7 +5017,7 @@ function registerRoutes(scope, ctx) {
4883
5017
  });
4884
5018
  });
4885
5019
  scope.get("/incidents", async (req2, reply) => {
4886
- const proj = resolveProject(registry, req2, reply);
5020
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4887
5021
  if (!proj) return;
4888
5022
  const epath = errorsPathFor(proj);
4889
5023
  if (!epath) return { count: 0, total: 0, events: [] };
@@ -4895,7 +5029,7 @@ function registerRoutes(scope, ctx) {
4895
5029
  return { count: sliced.length, total, events: sliced };
4896
5030
  });
4897
5031
  scope.get("/stale-events", async (req2, reply) => {
4898
- const proj = resolveProject(registry, req2, reply);
5032
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4899
5033
  if (!proj) return;
4900
5034
  const spath = staleEventsPathFor(proj);
4901
5035
  if (!spath) return { count: 0, total: 0, events: [] };
@@ -4910,7 +5044,7 @@ function registerRoutes(scope, ctx) {
4910
5044
  scope.get(
4911
5045
  "/incidents/:nodeId",
4912
5046
  async (req2, reply) => {
4913
- const proj = resolveProject(registry, req2, reply);
5047
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4914
5048
  if (!proj) return;
4915
5049
  const { nodeId } = req2.params;
4916
5050
  if (!proj.graph.hasNode(nodeId)) {
@@ -4926,7 +5060,7 @@ function registerRoutes(scope, ctx) {
4926
5060
  }
4927
5061
  );
4928
5062
  scope.get("/graph/root-cause/:nodeId", async (req2, reply) => {
4929
- const proj = resolveProject(registry, req2, reply);
5063
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4930
5064
  if (!proj) return;
4931
5065
  const { nodeId } = req2.params;
4932
5066
  if (!proj.graph.hasNode(nodeId)) {
@@ -4946,7 +5080,7 @@ function registerRoutes(scope, ctx) {
4946
5080
  return result;
4947
5081
  });
4948
5082
  scope.get("/graph/blast-radius/:nodeId", async (req2, reply) => {
4949
- const proj = resolveProject(registry, req2, reply);
5083
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4950
5084
  if (!proj) return;
4951
5085
  const { nodeId } = req2.params;
4952
5086
  if (!proj.graph.hasNode(nodeId)) {
@@ -4959,7 +5093,7 @@ function registerRoutes(scope, ctx) {
4959
5093
  return getBlastRadius(proj.graph, nodeId, depth);
4960
5094
  });
4961
5095
  scope.get("/search", async (req2, reply) => {
4962
- const proj = resolveProject(registry, req2, reply);
5096
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4963
5097
  if (!proj) return;
4964
5098
  const raw = (req2.query.q ?? "").trim();
4965
5099
  if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
@@ -4990,7 +5124,7 @@ function registerRoutes(scope, ctx) {
4990
5124
  scope.get(
4991
5125
  "/graph/diff",
4992
5126
  async (req2, reply) => {
4993
- const proj = resolveProject(registry, req2, reply);
5127
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4994
5128
  if (!proj) return;
4995
5129
  const against = req2.query.against;
4996
5130
  if (!against) {
@@ -5004,8 +5138,37 @@ function registerRoutes(scope, ctx) {
5004
5138
  }
5005
5139
  }
5006
5140
  );
5141
+ scope.post("/snapshot", async (req2, reply) => {
5142
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5143
+ if (!proj) return;
5144
+ const body = req2.body;
5145
+ if (!body || typeof body !== "object" || !body.snapshot) {
5146
+ return reply.code(400).send({ error: "request body must be { snapshot: <persisted-graph> }" });
5147
+ }
5148
+ const snap = body.snapshot;
5149
+ if (typeof snap.schemaVersion !== "number" || snap.schemaVersion !== SCHEMA_VERSION) {
5150
+ return reply.code(400).send({
5151
+ error: `unsupported snapshot schemaVersion ${snap.schemaVersion} (expected ${SCHEMA_VERSION})`
5152
+ });
5153
+ }
5154
+ try {
5155
+ const result = mergeSnapshot(proj.graph, snap);
5156
+ return {
5157
+ project: proj.name,
5158
+ nodesAdded: result.nodesAdded,
5159
+ edgesAdded: result.edgesAdded,
5160
+ nodeCount: proj.graph.order,
5161
+ edgeCount: proj.graph.size
5162
+ };
5163
+ } catch (err) {
5164
+ return reply.code(400).send({
5165
+ error: "snapshot merge failed",
5166
+ details: err.message
5167
+ });
5168
+ }
5169
+ });
5007
5170
  scope.post("/graph/scan", async (req2, reply) => {
5008
- const proj = resolveProject(registry, req2, reply);
5171
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5009
5172
  if (!proj) return;
5010
5173
  if (!proj.scanPath) {
5011
5174
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -5021,7 +5184,7 @@ function registerRoutes(scope, ctx) {
5021
5184
  };
5022
5185
  });
5023
5186
  scope.get("/policies", async (req2, reply) => {
5024
- const proj = resolveProject(registry, req2, reply);
5187
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5025
5188
  if (!proj) return;
5026
5189
  const policyPath = ctx.policyFilePathFor(proj);
5027
5190
  if (!policyPath) {
@@ -5038,7 +5201,7 @@ function registerRoutes(scope, ctx) {
5038
5201
  }
5039
5202
  });
5040
5203
  scope.get("/policies/violations", async (req2, reply) => {
5041
- const proj = resolveProject(registry, req2, reply);
5204
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5042
5205
  if (!proj) return;
5043
5206
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
5044
5207
  let violations = await log.readAll();
@@ -5058,7 +5221,7 @@ function registerRoutes(scope, ctx) {
5058
5221
  return { violations };
5059
5222
  });
5060
5223
  scope.post("/policies/check", async (req2, reply) => {
5061
- const proj = resolveProject(registry, req2, reply);
5224
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5062
5225
  if (!proj) return;
5063
5226
  const parsed = import_types22.PoliciesCheckBodySchema.safeParse(req2.body ?? {});
5064
5227
  if (!parsed.success) {
@@ -5097,7 +5260,15 @@ function registerRoutes(scope, ctx) {
5097
5260
  async function buildApi(opts) {
5098
5261
  const app = (0, import_fastify.default)({ logger: false });
5099
5262
  await app.register(import_cors.default, { origin: true });
5100
- mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy });
5263
+ mountBearerAuth(app, {
5264
+ token: opts.authToken,
5265
+ trustProxy: opts.trustProxy,
5266
+ publicRead: opts.publicRead
5267
+ });
5268
+ app.get("/api/config", async () => ({
5269
+ publicRead: opts.publicRead === true,
5270
+ authProxy: opts.trustProxy === true
5271
+ }));
5101
5272
  const startedAt = opts.startedAt ?? Date.now();
5102
5273
  const registry = buildLegacyRegistry(opts);
5103
5274
  const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
@@ -5121,10 +5292,36 @@ async function buildApi(opts) {
5121
5292
  const routeCtx = {
5122
5293
  registry,
5123
5294
  startedAt,
5295
+ scope: "root",
5124
5296
  errorsPathFor,
5125
5297
  staleEventsPathFor,
5126
- policyFilePathFor
5298
+ policyFilePathFor,
5299
+ bootstrap: opts.bootstrap
5127
5300
  };
5301
+ app.get("/health", async () => {
5302
+ const uptimeMs = Date.now() - startedAt;
5303
+ const bootstrapList = opts.bootstrap?.list() ?? [];
5304
+ const byName = new Map(bootstrapList.map((p) => [p.name, p]));
5305
+ const names = /* @__PURE__ */ new Set([
5306
+ ...registry.list(),
5307
+ ...bootstrapList.map((p) => p.name)
5308
+ ]);
5309
+ const projects = [...names].sort().map((name) => {
5310
+ const proj = registry.get(name);
5311
+ const tracked = byName.get(name);
5312
+ return {
5313
+ name,
5314
+ nodeCount: proj?.graph.order ?? 0,
5315
+ edgeCount: proj?.graph.size ?? 0,
5316
+ ...tracked ? { status: tracked.status, elapsedMs: tracked.elapsedMs } : {}
5317
+ };
5318
+ });
5319
+ return {
5320
+ ok: true,
5321
+ uptimeMs,
5322
+ projects
5323
+ };
5324
+ });
5128
5325
  app.get("/projects", async (_req, reply) => {
5129
5326
  try {
5130
5327
  return await listProjects();
@@ -5152,7 +5349,7 @@ async function buildApi(opts) {
5152
5349
  registerRoutes(app, routeCtx);
5153
5350
  await app.register(
5154
5351
  async (scope) => {
5155
- registerRoutes(scope, routeCtx);
5352
+ registerRoutes(scope, { ...routeCtx, scope: "project" });
5156
5353
  },
5157
5354
  { prefix: "/projects/:project" }
5158
5355
  );
@@ -5162,12 +5359,35 @@ async function buildApi(opts) {
5162
5359
  // src/daemon.ts
5163
5360
  init_otel();
5164
5361
  init_auth();
5362
+
5363
+ // src/unrouted.ts
5364
+ init_cjs_shims();
5365
+ var import_node_fs21 = require("fs");
5366
+ var import_node_path36 = __toESM(require("path"), 1);
5367
+ function buildUnroutedSpanRecord(serviceName, traceId, now = /* @__PURE__ */ new Date()) {
5368
+ return {
5369
+ timestamp: now.toISOString(),
5370
+ reason: "no-project-match",
5371
+ service_name: serviceName ?? null,
5372
+ traceId: traceId ?? null
5373
+ };
5374
+ }
5375
+ async function appendUnroutedSpan(neatHome3, record) {
5376
+ const target = import_node_path36.default.join(neatHome3, "errors.ndjson");
5377
+ await import_node_fs21.promises.mkdir(neatHome3, { recursive: true });
5378
+ await import_node_fs21.promises.appendFile(target, JSON.stringify(record) + "\n", "utf8");
5379
+ }
5380
+ function unroutedErrorsPath(neatHome3) {
5381
+ return import_node_path36.default.join(neatHome3, "errors.ndjson");
5382
+ }
5383
+
5384
+ // src/daemon.ts
5165
5385
  function neatHomeFor(opts) {
5166
- if (opts.neatHome && opts.neatHome.length > 0) return import_node_path36.default.resolve(opts.neatHome);
5386
+ if (opts.neatHome && opts.neatHome.length > 0) return import_node_path37.default.resolve(opts.neatHome);
5167
5387
  const env = process.env.NEAT_HOME;
5168
- if (env && env.length > 0) return import_node_path36.default.resolve(env);
5388
+ if (env && env.length > 0) return import_node_path37.default.resolve(env);
5169
5389
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
5170
- return import_node_path36.default.join(home, ".neat");
5390
+ return import_node_path37.default.join(home, ".neat");
5171
5391
  }
5172
5392
  function routeSpanToProject(serviceName, projects) {
5173
5393
  if (!serviceName) return DEFAULT_PROJECT;
@@ -5202,9 +5422,9 @@ function isTokenContained(needle, haystack) {
5202
5422
  return tokens.includes(needle);
5203
5423
  }
5204
5424
  async function bootstrapProject(entry2) {
5205
- const paths = pathsForProject(entry2.name, import_node_path36.default.join(entry2.path, "neat-out"));
5425
+ const paths = pathsForProject(entry2.name, import_node_path37.default.join(entry2.path, "neat-out"));
5206
5426
  try {
5207
- const stat = await import_node_fs21.promises.stat(entry2.path);
5427
+ const stat = await import_node_fs22.promises.stat(entry2.path);
5208
5428
  if (!stat.isDirectory()) {
5209
5429
  throw new Error(`registered path ${entry2.path} is not a directory`);
5210
5430
  }
@@ -5259,27 +5479,30 @@ function resolveOtlpPort(opts) {
5259
5479
  }
5260
5480
  return 4318;
5261
5481
  }
5262
- function resolveHost(opts) {
5482
+ function resolveHost(opts, authTokenSet) {
5263
5483
  if (opts.host && opts.host.length > 0) return opts.host;
5264
5484
  const env = process.env.HOST;
5265
5485
  if (env && env.length > 0) return env;
5486
+ if (!authTokenSet) return "127.0.0.1";
5266
5487
  return "0.0.0.0";
5267
5488
  }
5268
5489
  async function startDaemon(opts = {}) {
5269
5490
  const home = neatHomeFor(opts);
5270
5491
  const regPath = registryPath();
5271
5492
  try {
5272
- await import_node_fs21.promises.access(regPath);
5493
+ await import_node_fs22.promises.access(regPath);
5273
5494
  } catch {
5274
5495
  throw new Error(
5275
5496
  `neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
5276
5497
  );
5277
5498
  }
5278
- const pidPath = import_node_path36.default.join(home, "neatd.pid");
5499
+ const pidPath = import_node_path37.default.join(home, "neatd.pid");
5279
5500
  await writeAtomically(pidPath, `${process.pid}
5280
5501
  `);
5281
5502
  const slots = /* @__PURE__ */ new Map();
5282
5503
  const registry = new Projects();
5504
+ const bootstrapStatus = /* @__PURE__ */ new Map();
5505
+ const bootstrapStartedAt = /* @__PURE__ */ new Map();
5283
5506
  const DROP_WARN_INTERVAL_MS = 6e4;
5284
5507
  const lastDropWarnAt = /* @__PURE__ */ new Map();
5285
5508
  function warnDroppedSpan(project, reason) {
@@ -5291,6 +5514,22 @@ async function startDaemon(opts = {}) {
5291
5514
  `[neatd] dropping span for project "${project}" \u2014 project status: broken (${reason}). Run \`neatd reload\` to retry bootstrap.`
5292
5515
  );
5293
5516
  }
5517
+ const unroutedPath = unroutedErrorsPath(home);
5518
+ const lastUnroutedWarnAt = /* @__PURE__ */ new Map();
5519
+ async function recordUnroutedSpan(serviceName, traceId) {
5520
+ const key = serviceName ?? "<missing>";
5521
+ const now = Date.now();
5522
+ try {
5523
+ await appendUnroutedSpan(home, buildUnroutedSpanRecord(serviceName, traceId, new Date(now)));
5524
+ } catch {
5525
+ }
5526
+ const prev = lastUnroutedWarnAt.get(key) ?? 0;
5527
+ if (now - prev < DROP_WARN_INTERVAL_MS) return;
5528
+ lastUnroutedWarnAt.set(key, now);
5529
+ console.warn(
5530
+ `[neatd] dropping span \u2014 service.name "${key}" matches no registered project and no \`default\` project exists. See ${unroutedPath}.`
5531
+ );
5532
+ }
5294
5533
  function upsertRegistryFromSlot(slot) {
5295
5534
  if (slot.status !== "active") return;
5296
5535
  registry.set(slot.entry.name, {
@@ -5319,34 +5558,43 @@ async function startDaemon(opts = {}) {
5319
5558
  return slots.get(entry2.name);
5320
5559
  }
5321
5560
  }
5561
+ async function bootstrapOne(entry2) {
5562
+ bootstrapStatus.set(entry2.name, "bootstrapping");
5563
+ bootstrapStartedAt.set(entry2.name, Date.now());
5564
+ try {
5565
+ const slot = await bootstrapProject(entry2);
5566
+ slots.set(entry2.name, slot);
5567
+ upsertRegistryFromSlot(slot);
5568
+ bootstrapStatus.set(entry2.name, slot.status === "broken" ? "broken" : "active");
5569
+ if (slot.status === "broken") {
5570
+ console.warn(`neatd: project "${entry2.name}" broken \u2014 ${slot.errorReason}`);
5571
+ } else {
5572
+ console.log(`neatd: project "${entry2.name}" active (${entry2.path})`);
5573
+ }
5574
+ } catch (err) {
5575
+ bootstrapStatus.set(entry2.name, "broken");
5576
+ console.warn(
5577
+ `neatd: project "${entry2.name}" failed to bootstrap \u2014 ${err.message}`
5578
+ );
5579
+ await setStatus(entry2.name, "broken").catch(() => {
5580
+ });
5581
+ }
5582
+ }
5322
5583
  async function loadAll() {
5323
5584
  const projects = await listProjects();
5324
5585
  const seen = /* @__PURE__ */ new Set();
5586
+ const pending = [];
5325
5587
  for (const entry2 of projects) {
5326
5588
  seen.add(entry2.name);
5327
5589
  const existing = slots.get(entry2.name);
5328
5590
  if (existing) {
5329
5591
  if (existing.status === "broken") {
5330
- await tryRecoverSlot(entry2);
5592
+ pending.push(tryRecoverSlot(entry2).then(() => {
5593
+ }));
5331
5594
  }
5332
5595
  continue;
5333
5596
  }
5334
- try {
5335
- const slot = await bootstrapProject(entry2);
5336
- slots.set(entry2.name, slot);
5337
- upsertRegistryFromSlot(slot);
5338
- if (slot.status === "broken") {
5339
- console.warn(`neatd: project "${entry2.name}" broken \u2014 ${slot.errorReason}`);
5340
- } else {
5341
- console.log(`neatd: project "${entry2.name}" active (${entry2.path})`);
5342
- }
5343
- } catch (err) {
5344
- console.warn(
5345
- `neatd: project "${entry2.name}" failed to bootstrap \u2014 ${err.message}`
5346
- );
5347
- await setStatus(entry2.name, "broken").catch(() => {
5348
- });
5349
- }
5597
+ pending.push(bootstrapOne(entry2));
5350
5598
  }
5351
5599
  for (const [name, slot] of [...slots.entries()]) {
5352
5600
  if (seen.has(name)) continue;
@@ -5355,26 +5603,45 @@ async function startDaemon(opts = {}) {
5355
5603
  } catch {
5356
5604
  }
5357
5605
  slots.delete(name);
5606
+ bootstrapStatus.delete(name);
5607
+ bootstrapStartedAt.delete(name);
5358
5608
  console.log(`neatd: project "${name}" removed from registry \u2014 stopped`);
5359
5609
  }
5610
+ await Promise.allSettled(pending);
5611
+ }
5612
+ const initialEntries = await listProjects().catch(() => []);
5613
+ for (const entry2 of initialEntries) {
5614
+ bootstrapStatus.set(entry2.name, "bootstrapping");
5615
+ bootstrapStartedAt.set(entry2.name, Date.now());
5360
5616
  }
5361
- await loadAll();
5362
5617
  const bind = opts.bindListeners !== false;
5363
5618
  let restApp = null;
5364
5619
  let otlpApp = null;
5365
5620
  let restAddress = "";
5366
5621
  let otlpAddress = "";
5367
5622
  if (bind) {
5368
- const host = resolveHost(opts);
5623
+ const auth = readAuthEnv();
5624
+ const host = resolveHost(opts, Boolean(auth.authToken));
5369
5625
  const restPort = resolveRestPort(opts);
5370
5626
  const otlpPort = resolveOtlpPort(opts);
5371
- const auth = readAuthEnv();
5372
5627
  assertBindAuthority(host, auth.authToken);
5373
5628
  try {
5374
5629
  restApp = await buildApi({
5375
5630
  projects: registry,
5376
5631
  authToken: auth.authToken,
5377
- trustProxy: auth.trustProxy
5632
+ trustProxy: auth.trustProxy,
5633
+ publicRead: auth.publicRead,
5634
+ bootstrap: {
5635
+ status: (name) => bootstrapStatus.get(name),
5636
+ list: () => {
5637
+ const now = Date.now();
5638
+ return [...bootstrapStatus.entries()].map(([name, status2]) => ({
5639
+ name,
5640
+ status: status2,
5641
+ elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
5642
+ }));
5643
+ }
5644
+ }
5378
5645
  });
5379
5646
  restAddress = await restApp.listen({ port: restPort, host });
5380
5647
  console.log(`neatd: REST listening on ${restAddress}`);
@@ -5387,17 +5654,20 @@ async function startDaemon(opts = {}) {
5387
5654
  }
5388
5655
  if (restApp) await restApp.close().catch(() => {
5389
5656
  });
5390
- await import_node_fs21.promises.unlink(pidPath).catch(() => {
5657
+ await import_node_fs22.promises.unlink(pidPath).catch(() => {
5391
5658
  });
5392
5659
  throw new Error(
5393
5660
  `neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
5394
5661
  );
5395
5662
  }
5396
- async function resolveTargetSlot(serviceName) {
5663
+ async function resolveTargetSlot(serviceName, traceId) {
5397
5664
  const liveEntries = await listProjects().catch(() => []);
5398
5665
  const target = routeSpanToProject(serviceName, liveEntries);
5399
5666
  let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5400
- if (!slot) return null;
5667
+ if (!slot) {
5668
+ await recordUnroutedSpan(serviceName, traceId);
5669
+ return null;
5670
+ }
5401
5671
  if (slot.status === "broken") {
5402
5672
  const entry2 = liveEntries.find((e) => e.name === slot.entry.name);
5403
5673
  if (entry2) {
@@ -5415,7 +5685,7 @@ async function startDaemon(opts = {}) {
5415
5685
  authToken: auth.otelToken,
5416
5686
  trustProxy: auth.trustProxy,
5417
5687
  onSpan: async (span) => {
5418
- const slot = await resolveTargetSlot(span.service);
5688
+ const slot = await resolveTargetSlot(span.service, span.traceId);
5419
5689
  if (!slot) return;
5420
5690
  await handleSpan(
5421
5691
  {
@@ -5429,7 +5699,7 @@ async function startDaemon(opts = {}) {
5429
5699
  );
5430
5700
  },
5431
5701
  onErrorSpanSync: async (span) => {
5432
- const slot = await resolveTargetSlot(span.service);
5702
+ const slot = await resolveTargetSlot(span.service, span.traceId);
5433
5703
  if (!slot) return;
5434
5704
  await makeErrorSpanWriter(slot.paths.errorsPath)(span);
5435
5705
  }
@@ -5447,14 +5717,17 @@ async function startDaemon(opts = {}) {
5447
5717
  });
5448
5718
  if (otlpApp) await otlpApp.close().catch(() => {
5449
5719
  });
5450
- await import_node_fs21.promises.unlink(pidPath).catch(() => {
5720
+ await import_node_fs22.promises.unlink(pidPath).catch(() => {
5451
5721
  });
5452
5722
  throw new Error(
5453
5723
  `neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
5454
5724
  );
5455
5725
  }
5456
5726
  }
5457
- let reloading = null;
5727
+ const initialBootstrap = loadAll().catch((err) => {
5728
+ console.warn(`neatd: initial bootstrap pass failed \u2014 ${err.message}`);
5729
+ });
5730
+ let reloading = initialBootstrap;
5458
5731
  const reload = async () => {
5459
5732
  if (reloading) return reloading;
5460
5733
  reloading = (async () => {
@@ -5466,6 +5739,20 @@ async function startDaemon(opts = {}) {
5466
5739
  })();
5467
5740
  return reloading;
5468
5741
  };
5742
+ void initialBootstrap.finally(() => {
5743
+ if (reloading === initialBootstrap) reloading = null;
5744
+ });
5745
+ const tracker = {
5746
+ status: (name) => bootstrapStatus.get(name),
5747
+ list: () => {
5748
+ const now = Date.now();
5749
+ return [...bootstrapStatus.entries()].map(([name, status2]) => ({
5750
+ name,
5751
+ status: status2,
5752
+ elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
5753
+ }));
5754
+ }
5755
+ };
5469
5756
  const sighupHandler = () => {
5470
5757
  void reload().catch((err) => {
5471
5758
  console.warn(`neatd: SIGHUP reload failed \u2014 ${err.message}`);
@@ -5487,10 +5774,19 @@ async function startDaemon(opts = {}) {
5487
5774
  } catch {
5488
5775
  }
5489
5776
  }
5490
- await import_node_fs21.promises.unlink(pidPath).catch(() => {
5777
+ await import_node_fs22.promises.unlink(pidPath).catch(() => {
5491
5778
  });
5492
5779
  };
5493
- return { slots, reload, stop, pidPath, restAddress, otlpAddress };
5780
+ return {
5781
+ slots,
5782
+ reload,
5783
+ stop,
5784
+ pidPath,
5785
+ restAddress,
5786
+ otlpAddress,
5787
+ bootstrap: tracker,
5788
+ initialBootstrap
5789
+ };
5494
5790
  }
5495
5791
 
5496
5792
  // src/neatd.ts
@@ -5500,7 +5796,7 @@ init_auth();
5500
5796
  init_cjs_shims();
5501
5797
  var import_node_child_process = require("child_process");
5502
5798
  var import_node_net = __toESM(require("net"), 1);
5503
- var import_node_path37 = __toESM(require("path"), 1);
5799
+ var import_node_path38 = __toESM(require("path"), 1);
5504
5800
  var DEFAULT_WEB_PORT = 6328;
5505
5801
  async function assertPortFree(port) {
5506
5802
  await new Promise((resolve, reject) => {
@@ -5528,10 +5824,10 @@ function resolveWebPackageDir() {
5528
5824
  eval("require")
5529
5825
  );
5530
5826
  const pkgJsonPath = req.resolve("@neat.is/web/package.json");
5531
- return import_node_path37.default.dirname(pkgJsonPath);
5827
+ return import_node_path38.default.dirname(pkgJsonPath);
5532
5828
  }
5533
5829
  function resolveStandaloneServerEntry(webDir) {
5534
- return import_node_path37.default.join(webDir, ".next/standalone/packages/web/server.js");
5830
+ return import_node_path38.default.join(webDir, ".next/standalone/packages/web/server.js");
5535
5831
  }
5536
5832
  async function spawnWebUI(restPort) {
5537
5833
  const portRaw = process.env.NEAT_WEB_PORT;
@@ -5556,7 +5852,7 @@ async function spawnWebUI(restPort) {
5556
5852
  NEAT_API_URL: process.env.NEAT_API_URL ?? `http://localhost:${restPort}`
5557
5853
  };
5558
5854
  const child = (0, import_node_child_process.spawn)(process.execPath, [serverEntry], {
5559
- cwd: import_node_path37.default.dirname(serverEntry),
5855
+ cwd: import_node_path38.default.dirname(serverEntry),
5560
5856
  env,
5561
5857
  stdio: ["ignore", "inherit", "inherit"],
5562
5858
  detached: false
@@ -5669,14 +5965,14 @@ function localVersion() {
5669
5965
  }
5670
5966
  function neatHome2() {
5671
5967
  if (process.env.NEAT_HOME && process.env.NEAT_HOME.length > 0) {
5672
- return import_node_path38.default.resolve(process.env.NEAT_HOME);
5968
+ return import_node_path39.default.resolve(process.env.NEAT_HOME);
5673
5969
  }
5674
5970
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
5675
- return import_node_path38.default.join(home, ".neat");
5971
+ return import_node_path39.default.join(home, ".neat");
5676
5972
  }
5677
5973
  async function readPid() {
5678
5974
  try {
5679
- const raw = await import_node_fs22.promises.readFile(import_node_path38.default.join(neatHome2(), "neatd.pid"), "utf8");
5975
+ const raw = await import_node_fs23.promises.readFile(import_node_path39.default.join(neatHome2(), "neatd.pid"), "utf8");
5680
5976
  const n = Number.parseInt(raw.trim(), 10);
5681
5977
  return Number.isFinite(n) ? n : null;
5682
5978
  } catch {