@neat.is/core 0.4.0 → 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
@@ -57,9 +57,9 @@ function mountBearerAuth(app, opts) {
57
57
  const suffixes = [...DEFAULT_UNAUTH_SUFFIXES, ...opts.extraUnauthenticatedSuffixes ?? []];
58
58
  const publicRead = opts.publicRead === true;
59
59
  app.addHook("preHandler", (req2, reply, done) => {
60
- const path39 = (req2.url.split("?")[0] ?? "").replace(/\/+$/, "");
60
+ const path40 = (req2.url.split("?")[0] ?? "").replace(/\/+$/, "");
61
61
  for (const suffix of suffixes) {
62
- if (path39 === suffix || path39.endsWith(suffix)) {
62
+ if (path40 === suffix || path40.endsWith(suffix)) {
63
63
  done();
64
64
  return;
65
65
  }
@@ -512,14 +512,14 @@ var init_otel = __esm({
512
512
 
513
513
  // src/neatd.ts
514
514
  init_cjs_shims();
515
- var import_node_fs22 = require("fs");
516
- var import_node_path38 = __toESM(require("path"), 1);
515
+ var import_node_fs23 = require("fs");
516
+ var import_node_path39 = __toESM(require("path"), 1);
517
517
  var import_node_module = require("module");
518
518
 
519
519
  // src/daemon.ts
520
520
  init_cjs_shims();
521
- var import_node_fs21 = require("fs");
522
- var import_node_path36 = __toESM(require("path"), 1);
521
+ var import_node_fs22 = require("fs");
522
+ var import_node_path37 = __toESM(require("path"), 1);
523
523
 
524
524
  // src/graph.ts
525
525
  init_cjs_shims();
@@ -979,19 +979,19 @@ function confidenceFromMix(edges, now = Date.now()) {
979
979
  function longestIncomingWalk(graph, start, maxDepth) {
980
980
  let best = { path: [start], edges: [] };
981
981
  const visited = /* @__PURE__ */ new Set([start]);
982
- function step(node, path39, edges) {
983
- if (path39.length > best.path.length) {
984
- best = { path: [...path39], edges: [...edges] };
982
+ function step(node, path40, edges) {
983
+ if (path40.length > best.path.length) {
984
+ best = { path: [...path40], edges: [...edges] };
985
985
  }
986
- if (path39.length - 1 >= maxDepth) return;
986
+ if (path40.length - 1 >= maxDepth) return;
987
987
  const incoming = bestEdgeBySource(graph, graph.inboundEdges(node));
988
988
  for (const [srcId, edge] of incoming) {
989
989
  if (visited.has(srcId)) continue;
990
990
  visited.add(srcId);
991
- path39.push(srcId);
991
+ path40.push(srcId);
992
992
  edges.push(edge);
993
- step(srcId, path39, edges);
994
- path39.pop();
993
+ step(srcId, path40, edges);
994
+ path40.pop();
995
995
  edges.pop();
996
996
  visited.delete(srcId);
997
997
  }
@@ -1980,8 +1980,36 @@ var IGNORED_DIRS = /* @__PURE__ */ new Set([
1980
1980
  ".turbo",
1981
1981
  "dist",
1982
1982
  "build",
1983
- ".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"
1984
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
+ }
1985
2013
  function isConfigFile(name) {
1986
2014
  const ext = import_node_path4.default.extname(name);
1987
2015
  if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
@@ -2318,6 +2346,7 @@ async function walkDirs(start, scanPath, options, visit) {
2318
2346
  const rel = import_node_path8.default.relative(scanPath, child).split(import_node_path8.default.sep).join("/");
2319
2347
  if (rel && options.ig.ignores(rel + "/")) continue;
2320
2348
  }
2349
+ if (await isPythonVenvDir(child)) continue;
2321
2350
  await visit(child);
2322
2351
  await recurse(child, depth + 1);
2323
2352
  }
@@ -2596,7 +2625,9 @@ async function walkYamlFiles(start, depth = 0, max = 5) {
2596
2625
  for (const entry2 of entries) {
2597
2626
  if (entry2.isDirectory()) {
2598
2627
  if (IGNORED_DIRS.has(entry2.name)) continue;
2599
- 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));
2600
2631
  } else if (entry2.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path9.default.extname(entry2.name))) {
2601
2632
  out.push(import_node_path9.default.join(start, entry2.name));
2602
2633
  }
@@ -3278,7 +3309,9 @@ async function walkConfigFiles(dir) {
3278
3309
  for (const entry2 of entries) {
3279
3310
  const full = import_node_path18.default.join(current, entry2.name);
3280
3311
  if (entry2.isDirectory()) {
3281
- 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);
3282
3315
  } else if (entry2.isFile() && isConfigFile(entry2.name).match) {
3283
3316
  out.push(full);
3284
3317
  }
@@ -3346,7 +3379,9 @@ async function walkSourceFiles(dir) {
3346
3379
  for (const entry2 of entries) {
3347
3380
  const full = import_node_path19.default.join(current, entry2.name);
3348
3381
  if (entry2.isDirectory()) {
3349
- 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);
3350
3385
  } else if (entry2.isFile() && SERVICE_FILE_EXTENSIONS.has(import_node_path19.default.extname(entry2.name))) {
3351
3386
  out.push(full);
3352
3387
  }
@@ -3982,7 +4017,9 @@ async function walkTfFiles(start, depth = 0, max = 5) {
3982
4017
  for (const entry2 of entries) {
3983
4018
  if (entry2.isDirectory()) {
3984
4019
  if (IGNORED_DIRS.has(entry2.name) || entry2.name === ".terraform") continue;
3985
- 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));
3986
4023
  } else if (entry2.isFile() && entry2.name.endsWith(".tf")) {
3987
4024
  out.push(import_node_path27.default.join(start, entry2.name));
3988
4025
  }
@@ -4030,7 +4067,9 @@ async function walkYamlFiles2(start, depth = 0, max = 5) {
4030
4067
  for (const entry2 of entries) {
4031
4068
  if (entry2.isDirectory()) {
4032
4069
  if (IGNORED_DIRS.has(entry2.name)) continue;
4033
- 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));
4034
4073
  } else if (entry2.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path28.default.extname(entry2.name))) {
4035
4074
  out.push(import_node_path28.default.join(start, entry2.name));
4036
4075
  }
@@ -4831,10 +4870,19 @@ function projectFromReq(req2) {
4831
4870
  const params = req2.params;
4832
4871
  return params.project ?? DEFAULT_PROJECT;
4833
4872
  }
4834
- function resolveProject(registry, req2, reply) {
4873
+ function resolveProject(registry, req2, reply, bootstrap) {
4835
4874
  const name = projectFromReq(req2);
4836
4875
  const ctx = registry.get(name);
4837
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
+ }
4838
4886
  void reply.code(404).send({ error: "project not found", project: name });
4839
4887
  return null;
4840
4888
  }
@@ -4864,36 +4912,38 @@ function buildLegacyRegistry(opts) {
4864
4912
  function registerRoutes(scope, ctx) {
4865
4913
  const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
4866
4914
  scope.get("/events", (req2, reply) => {
4867
- const proj = resolveProject(registry, req2, reply);
4915
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4868
4916
  if (!proj) return;
4869
4917
  handleSse(req2, reply, { project: proj.name });
4870
4918
  });
4871
- scope.get("/health", async (req2, reply) => {
4872
- const proj = resolveProject(registry, req2, reply);
4873
- if (!proj) return;
4874
- const uptimeMs = Date.now() - startedAt;
4875
- return {
4876
- ok: true,
4877
- project: proj.name,
4878
- uptimeMs,
4879
- // Legacy fields kept additively. The web shell's StatusBar reads
4880
- // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
4881
- // the canonical triple and lets the extras pass through.
4882
- uptime: Math.floor(uptimeMs / 1e3),
4883
- nodeCount: proj.graph.order,
4884
- edgeCount: proj.graph.size,
4885
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
4886
- };
4887
- });
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
+ }
4888
4938
  scope.get("/graph", async (req2, reply) => {
4889
- const proj = resolveProject(registry, req2, reply);
4939
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4890
4940
  if (!proj) return;
4891
4941
  return serializeGraph(proj.graph);
4892
4942
  });
4893
4943
  scope.get(
4894
4944
  "/graph/node/:id",
4895
4945
  async (req2, reply) => {
4896
- const proj = resolveProject(registry, req2, reply);
4946
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4897
4947
  if (!proj) return;
4898
4948
  const { id } = req2.params;
4899
4949
  if (!proj.graph.hasNode(id)) {
@@ -4905,7 +4955,7 @@ function registerRoutes(scope, ctx) {
4905
4955
  scope.get(
4906
4956
  "/graph/edges/:id",
4907
4957
  async (req2, reply) => {
4908
- const proj = resolveProject(registry, req2, reply);
4958
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4909
4959
  if (!proj) return;
4910
4960
  const { id } = req2.params;
4911
4961
  if (!proj.graph.hasNode(id)) {
@@ -4917,7 +4967,7 @@ function registerRoutes(scope, ctx) {
4917
4967
  }
4918
4968
  );
4919
4969
  scope.get("/graph/dependencies/:nodeId", async (req2, reply) => {
4920
- const proj = resolveProject(registry, req2, reply);
4970
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4921
4971
  if (!proj) return;
4922
4972
  const { nodeId } = req2.params;
4923
4973
  if (!proj.graph.hasNode(nodeId)) {
@@ -4932,7 +4982,7 @@ function registerRoutes(scope, ctx) {
4932
4982
  return getTransitiveDependencies(proj.graph, nodeId, depth);
4933
4983
  });
4934
4984
  scope.get("/graph/divergences", async (req2, reply) => {
4935
- const proj = resolveProject(registry, req2, reply);
4985
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4936
4986
  if (!proj) return;
4937
4987
  let typeFilter;
4938
4988
  if (req2.query.type) {
@@ -4967,7 +5017,7 @@ function registerRoutes(scope, ctx) {
4967
5017
  });
4968
5018
  });
4969
5019
  scope.get("/incidents", async (req2, reply) => {
4970
- const proj = resolveProject(registry, req2, reply);
5020
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4971
5021
  if (!proj) return;
4972
5022
  const epath = errorsPathFor(proj);
4973
5023
  if (!epath) return { count: 0, total: 0, events: [] };
@@ -4979,7 +5029,7 @@ function registerRoutes(scope, ctx) {
4979
5029
  return { count: sliced.length, total, events: sliced };
4980
5030
  });
4981
5031
  scope.get("/stale-events", async (req2, reply) => {
4982
- const proj = resolveProject(registry, req2, reply);
5032
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4983
5033
  if (!proj) return;
4984
5034
  const spath = staleEventsPathFor(proj);
4985
5035
  if (!spath) return { count: 0, total: 0, events: [] };
@@ -4994,7 +5044,7 @@ function registerRoutes(scope, ctx) {
4994
5044
  scope.get(
4995
5045
  "/incidents/:nodeId",
4996
5046
  async (req2, reply) => {
4997
- const proj = resolveProject(registry, req2, reply);
5047
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
4998
5048
  if (!proj) return;
4999
5049
  const { nodeId } = req2.params;
5000
5050
  if (!proj.graph.hasNode(nodeId)) {
@@ -5010,7 +5060,7 @@ function registerRoutes(scope, ctx) {
5010
5060
  }
5011
5061
  );
5012
5062
  scope.get("/graph/root-cause/:nodeId", async (req2, reply) => {
5013
- const proj = resolveProject(registry, req2, reply);
5063
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5014
5064
  if (!proj) return;
5015
5065
  const { nodeId } = req2.params;
5016
5066
  if (!proj.graph.hasNode(nodeId)) {
@@ -5030,7 +5080,7 @@ function registerRoutes(scope, ctx) {
5030
5080
  return result;
5031
5081
  });
5032
5082
  scope.get("/graph/blast-radius/:nodeId", async (req2, reply) => {
5033
- const proj = resolveProject(registry, req2, reply);
5083
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5034
5084
  if (!proj) return;
5035
5085
  const { nodeId } = req2.params;
5036
5086
  if (!proj.graph.hasNode(nodeId)) {
@@ -5043,7 +5093,7 @@ function registerRoutes(scope, ctx) {
5043
5093
  return getBlastRadius(proj.graph, nodeId, depth);
5044
5094
  });
5045
5095
  scope.get("/search", async (req2, reply) => {
5046
- const proj = resolveProject(registry, req2, reply);
5096
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5047
5097
  if (!proj) return;
5048
5098
  const raw = (req2.query.q ?? "").trim();
5049
5099
  if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
@@ -5074,7 +5124,7 @@ function registerRoutes(scope, ctx) {
5074
5124
  scope.get(
5075
5125
  "/graph/diff",
5076
5126
  async (req2, reply) => {
5077
- const proj = resolveProject(registry, req2, reply);
5127
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5078
5128
  if (!proj) return;
5079
5129
  const against = req2.query.against;
5080
5130
  if (!against) {
@@ -5089,7 +5139,7 @@ function registerRoutes(scope, ctx) {
5089
5139
  }
5090
5140
  );
5091
5141
  scope.post("/snapshot", async (req2, reply) => {
5092
- const proj = resolveProject(registry, req2, reply);
5142
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5093
5143
  if (!proj) return;
5094
5144
  const body = req2.body;
5095
5145
  if (!body || typeof body !== "object" || !body.snapshot) {
@@ -5118,7 +5168,7 @@ function registerRoutes(scope, ctx) {
5118
5168
  }
5119
5169
  });
5120
5170
  scope.post("/graph/scan", async (req2, reply) => {
5121
- const proj = resolveProject(registry, req2, reply);
5171
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5122
5172
  if (!proj) return;
5123
5173
  if (!proj.scanPath) {
5124
5174
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -5134,7 +5184,7 @@ function registerRoutes(scope, ctx) {
5134
5184
  };
5135
5185
  });
5136
5186
  scope.get("/policies", async (req2, reply) => {
5137
- const proj = resolveProject(registry, req2, reply);
5187
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5138
5188
  if (!proj) return;
5139
5189
  const policyPath = ctx.policyFilePathFor(proj);
5140
5190
  if (!policyPath) {
@@ -5151,7 +5201,7 @@ function registerRoutes(scope, ctx) {
5151
5201
  }
5152
5202
  });
5153
5203
  scope.get("/policies/violations", async (req2, reply) => {
5154
- const proj = resolveProject(registry, req2, reply);
5204
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5155
5205
  if (!proj) return;
5156
5206
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
5157
5207
  let violations = await log.readAll();
@@ -5171,7 +5221,7 @@ function registerRoutes(scope, ctx) {
5171
5221
  return { violations };
5172
5222
  });
5173
5223
  scope.post("/policies/check", async (req2, reply) => {
5174
- const proj = resolveProject(registry, req2, reply);
5224
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
5175
5225
  if (!proj) return;
5176
5226
  const parsed = import_types22.PoliciesCheckBodySchema.safeParse(req2.body ?? {});
5177
5227
  if (!parsed.success) {
@@ -5242,10 +5292,36 @@ async function buildApi(opts) {
5242
5292
  const routeCtx = {
5243
5293
  registry,
5244
5294
  startedAt,
5295
+ scope: "root",
5245
5296
  errorsPathFor,
5246
5297
  staleEventsPathFor,
5247
- policyFilePathFor
5298
+ policyFilePathFor,
5299
+ bootstrap: opts.bootstrap
5248
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
+ });
5249
5325
  app.get("/projects", async (_req, reply) => {
5250
5326
  try {
5251
5327
  return await listProjects();
@@ -5273,7 +5349,7 @@ async function buildApi(opts) {
5273
5349
  registerRoutes(app, routeCtx);
5274
5350
  await app.register(
5275
5351
  async (scope) => {
5276
- registerRoutes(scope, routeCtx);
5352
+ registerRoutes(scope, { ...routeCtx, scope: "project" });
5277
5353
  },
5278
5354
  { prefix: "/projects/:project" }
5279
5355
  );
@@ -5283,12 +5359,35 @@ async function buildApi(opts) {
5283
5359
  // src/daemon.ts
5284
5360
  init_otel();
5285
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
5286
5385
  function neatHomeFor(opts) {
5287
- 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);
5288
5387
  const env = process.env.NEAT_HOME;
5289
- 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);
5290
5389
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
5291
- return import_node_path36.default.join(home, ".neat");
5390
+ return import_node_path37.default.join(home, ".neat");
5292
5391
  }
5293
5392
  function routeSpanToProject(serviceName, projects) {
5294
5393
  if (!serviceName) return DEFAULT_PROJECT;
@@ -5323,9 +5422,9 @@ function isTokenContained(needle, haystack) {
5323
5422
  return tokens.includes(needle);
5324
5423
  }
5325
5424
  async function bootstrapProject(entry2) {
5326
- 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"));
5327
5426
  try {
5328
- const stat = await import_node_fs21.promises.stat(entry2.path);
5427
+ const stat = await import_node_fs22.promises.stat(entry2.path);
5329
5428
  if (!stat.isDirectory()) {
5330
5429
  throw new Error(`registered path ${entry2.path} is not a directory`);
5331
5430
  }
@@ -5380,27 +5479,30 @@ function resolveOtlpPort(opts) {
5380
5479
  }
5381
5480
  return 4318;
5382
5481
  }
5383
- function resolveHost(opts) {
5482
+ function resolveHost(opts, authTokenSet) {
5384
5483
  if (opts.host && opts.host.length > 0) return opts.host;
5385
5484
  const env = process.env.HOST;
5386
5485
  if (env && env.length > 0) return env;
5486
+ if (!authTokenSet) return "127.0.0.1";
5387
5487
  return "0.0.0.0";
5388
5488
  }
5389
5489
  async function startDaemon(opts = {}) {
5390
5490
  const home = neatHomeFor(opts);
5391
5491
  const regPath = registryPath();
5392
5492
  try {
5393
- await import_node_fs21.promises.access(regPath);
5493
+ await import_node_fs22.promises.access(regPath);
5394
5494
  } catch {
5395
5495
  throw new Error(
5396
5496
  `neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
5397
5497
  );
5398
5498
  }
5399
- const pidPath = import_node_path36.default.join(home, "neatd.pid");
5499
+ const pidPath = import_node_path37.default.join(home, "neatd.pid");
5400
5500
  await writeAtomically(pidPath, `${process.pid}
5401
5501
  `);
5402
5502
  const slots = /* @__PURE__ */ new Map();
5403
5503
  const registry = new Projects();
5504
+ const bootstrapStatus = /* @__PURE__ */ new Map();
5505
+ const bootstrapStartedAt = /* @__PURE__ */ new Map();
5404
5506
  const DROP_WARN_INTERVAL_MS = 6e4;
5405
5507
  const lastDropWarnAt = /* @__PURE__ */ new Map();
5406
5508
  function warnDroppedSpan(project, reason) {
@@ -5412,6 +5514,22 @@ async function startDaemon(opts = {}) {
5412
5514
  `[neatd] dropping span for project "${project}" \u2014 project status: broken (${reason}). Run \`neatd reload\` to retry bootstrap.`
5413
5515
  );
5414
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
+ }
5415
5533
  function upsertRegistryFromSlot(slot) {
5416
5534
  if (slot.status !== "active") return;
5417
5535
  registry.set(slot.entry.name, {
@@ -5440,34 +5558,43 @@ async function startDaemon(opts = {}) {
5440
5558
  return slots.get(entry2.name);
5441
5559
  }
5442
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
+ }
5443
5583
  async function loadAll() {
5444
5584
  const projects = await listProjects();
5445
5585
  const seen = /* @__PURE__ */ new Set();
5586
+ const pending = [];
5446
5587
  for (const entry2 of projects) {
5447
5588
  seen.add(entry2.name);
5448
5589
  const existing = slots.get(entry2.name);
5449
5590
  if (existing) {
5450
5591
  if (existing.status === "broken") {
5451
- await tryRecoverSlot(entry2);
5592
+ pending.push(tryRecoverSlot(entry2).then(() => {
5593
+ }));
5452
5594
  }
5453
5595
  continue;
5454
5596
  }
5455
- try {
5456
- const slot = await bootstrapProject(entry2);
5457
- slots.set(entry2.name, slot);
5458
- upsertRegistryFromSlot(slot);
5459
- if (slot.status === "broken") {
5460
- console.warn(`neatd: project "${entry2.name}" broken \u2014 ${slot.errorReason}`);
5461
- } else {
5462
- console.log(`neatd: project "${entry2.name}" active (${entry2.path})`);
5463
- }
5464
- } catch (err) {
5465
- console.warn(
5466
- `neatd: project "${entry2.name}" failed to bootstrap \u2014 ${err.message}`
5467
- );
5468
- await setStatus(entry2.name, "broken").catch(() => {
5469
- });
5470
- }
5597
+ pending.push(bootstrapOne(entry2));
5471
5598
  }
5472
5599
  for (const [name, slot] of [...slots.entries()]) {
5473
5600
  if (seen.has(name)) continue;
@@ -5476,27 +5603,45 @@ async function startDaemon(opts = {}) {
5476
5603
  } catch {
5477
5604
  }
5478
5605
  slots.delete(name);
5606
+ bootstrapStatus.delete(name);
5607
+ bootstrapStartedAt.delete(name);
5479
5608
  console.log(`neatd: project "${name}" removed from registry \u2014 stopped`);
5480
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());
5481
5616
  }
5482
- await loadAll();
5483
5617
  const bind = opts.bindListeners !== false;
5484
5618
  let restApp = null;
5485
5619
  let otlpApp = null;
5486
5620
  let restAddress = "";
5487
5621
  let otlpAddress = "";
5488
5622
  if (bind) {
5489
- const host = resolveHost(opts);
5623
+ const auth = readAuthEnv();
5624
+ const host = resolveHost(opts, Boolean(auth.authToken));
5490
5625
  const restPort = resolveRestPort(opts);
5491
5626
  const otlpPort = resolveOtlpPort(opts);
5492
- const auth = readAuthEnv();
5493
5627
  assertBindAuthority(host, auth.authToken);
5494
5628
  try {
5495
5629
  restApp = await buildApi({
5496
5630
  projects: registry,
5497
5631
  authToken: auth.authToken,
5498
5632
  trustProxy: auth.trustProxy,
5499
- publicRead: auth.publicRead
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
+ }
5500
5645
  });
5501
5646
  restAddress = await restApp.listen({ port: restPort, host });
5502
5647
  console.log(`neatd: REST listening on ${restAddress}`);
@@ -5509,17 +5654,20 @@ async function startDaemon(opts = {}) {
5509
5654
  }
5510
5655
  if (restApp) await restApp.close().catch(() => {
5511
5656
  });
5512
- await import_node_fs21.promises.unlink(pidPath).catch(() => {
5657
+ await import_node_fs22.promises.unlink(pidPath).catch(() => {
5513
5658
  });
5514
5659
  throw new Error(
5515
5660
  `neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
5516
5661
  );
5517
5662
  }
5518
- async function resolveTargetSlot(serviceName) {
5663
+ async function resolveTargetSlot(serviceName, traceId) {
5519
5664
  const liveEntries = await listProjects().catch(() => []);
5520
5665
  const target = routeSpanToProject(serviceName, liveEntries);
5521
5666
  let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5522
- if (!slot) return null;
5667
+ if (!slot) {
5668
+ await recordUnroutedSpan(serviceName, traceId);
5669
+ return null;
5670
+ }
5523
5671
  if (slot.status === "broken") {
5524
5672
  const entry2 = liveEntries.find((e) => e.name === slot.entry.name);
5525
5673
  if (entry2) {
@@ -5537,7 +5685,7 @@ async function startDaemon(opts = {}) {
5537
5685
  authToken: auth.otelToken,
5538
5686
  trustProxy: auth.trustProxy,
5539
5687
  onSpan: async (span) => {
5540
- const slot = await resolveTargetSlot(span.service);
5688
+ const slot = await resolveTargetSlot(span.service, span.traceId);
5541
5689
  if (!slot) return;
5542
5690
  await handleSpan(
5543
5691
  {
@@ -5551,7 +5699,7 @@ async function startDaemon(opts = {}) {
5551
5699
  );
5552
5700
  },
5553
5701
  onErrorSpanSync: async (span) => {
5554
- const slot = await resolveTargetSlot(span.service);
5702
+ const slot = await resolveTargetSlot(span.service, span.traceId);
5555
5703
  if (!slot) return;
5556
5704
  await makeErrorSpanWriter(slot.paths.errorsPath)(span);
5557
5705
  }
@@ -5569,14 +5717,17 @@ async function startDaemon(opts = {}) {
5569
5717
  });
5570
5718
  if (otlpApp) await otlpApp.close().catch(() => {
5571
5719
  });
5572
- await import_node_fs21.promises.unlink(pidPath).catch(() => {
5720
+ await import_node_fs22.promises.unlink(pidPath).catch(() => {
5573
5721
  });
5574
5722
  throw new Error(
5575
5723
  `neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
5576
5724
  );
5577
5725
  }
5578
5726
  }
5579
- 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;
5580
5731
  const reload = async () => {
5581
5732
  if (reloading) return reloading;
5582
5733
  reloading = (async () => {
@@ -5588,6 +5739,20 @@ async function startDaemon(opts = {}) {
5588
5739
  })();
5589
5740
  return reloading;
5590
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
+ };
5591
5756
  const sighupHandler = () => {
5592
5757
  void reload().catch((err) => {
5593
5758
  console.warn(`neatd: SIGHUP reload failed \u2014 ${err.message}`);
@@ -5609,10 +5774,19 @@ async function startDaemon(opts = {}) {
5609
5774
  } catch {
5610
5775
  }
5611
5776
  }
5612
- await import_node_fs21.promises.unlink(pidPath).catch(() => {
5777
+ await import_node_fs22.promises.unlink(pidPath).catch(() => {
5613
5778
  });
5614
5779
  };
5615
- 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
+ };
5616
5790
  }
5617
5791
 
5618
5792
  // src/neatd.ts
@@ -5622,7 +5796,7 @@ init_auth();
5622
5796
  init_cjs_shims();
5623
5797
  var import_node_child_process = require("child_process");
5624
5798
  var import_node_net = __toESM(require("net"), 1);
5625
- var import_node_path37 = __toESM(require("path"), 1);
5799
+ var import_node_path38 = __toESM(require("path"), 1);
5626
5800
  var DEFAULT_WEB_PORT = 6328;
5627
5801
  async function assertPortFree(port) {
5628
5802
  await new Promise((resolve, reject) => {
@@ -5650,10 +5824,10 @@ function resolveWebPackageDir() {
5650
5824
  eval("require")
5651
5825
  );
5652
5826
  const pkgJsonPath = req.resolve("@neat.is/web/package.json");
5653
- return import_node_path37.default.dirname(pkgJsonPath);
5827
+ return import_node_path38.default.dirname(pkgJsonPath);
5654
5828
  }
5655
5829
  function resolveStandaloneServerEntry(webDir) {
5656
- 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");
5657
5831
  }
5658
5832
  async function spawnWebUI(restPort) {
5659
5833
  const portRaw = process.env.NEAT_WEB_PORT;
@@ -5678,7 +5852,7 @@ async function spawnWebUI(restPort) {
5678
5852
  NEAT_API_URL: process.env.NEAT_API_URL ?? `http://localhost:${restPort}`
5679
5853
  };
5680
5854
  const child = (0, import_node_child_process.spawn)(process.execPath, [serverEntry], {
5681
- cwd: import_node_path37.default.dirname(serverEntry),
5855
+ cwd: import_node_path38.default.dirname(serverEntry),
5682
5856
  env,
5683
5857
  stdio: ["ignore", "inherit", "inherit"],
5684
5858
  detached: false
@@ -5791,14 +5965,14 @@ function localVersion() {
5791
5965
  }
5792
5966
  function neatHome2() {
5793
5967
  if (process.env.NEAT_HOME && process.env.NEAT_HOME.length > 0) {
5794
- return import_node_path38.default.resolve(process.env.NEAT_HOME);
5968
+ return import_node_path39.default.resolve(process.env.NEAT_HOME);
5795
5969
  }
5796
5970
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
5797
- return import_node_path38.default.join(home, ".neat");
5971
+ return import_node_path39.default.join(home, ".neat");
5798
5972
  }
5799
5973
  async function readPid() {
5800
5974
  try {
5801
- 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");
5802
5976
  const n = Number.parseInt(raw.trim(), 10);
5803
5977
  return Number.isFinite(n) ? n : null;
5804
5978
  } catch {