@neat.is/core 0.2.10 → 0.3.1

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
@@ -1653,6 +1653,29 @@ async function appendErrorEvent(ctx, ev) {
1653
1653
  await import_node_fs3.promises.mkdir(import_node_path3.default.dirname(ctx.errorsPath), { recursive: true });
1654
1654
  await import_node_fs3.promises.appendFile(ctx.errorsPath, JSON.stringify(ev) + "\n", "utf8");
1655
1655
  }
1656
+ function buildErrorEventForReceiver(span) {
1657
+ if (span.statusCode !== 2) return null;
1658
+ const ts = span.startTimeIso ?? (/* @__PURE__ */ new Date()).toISOString();
1659
+ return {
1660
+ id: `${span.traceId}:${span.spanId}`,
1661
+ timestamp: ts,
1662
+ service: span.service,
1663
+ traceId: span.traceId,
1664
+ spanId: span.spanId,
1665
+ errorMessage: span.exception?.message ?? span.errorMessage ?? span.name ?? "unknown error",
1666
+ ...span.exception?.type ? { exceptionType: span.exception.type } : {},
1667
+ ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
1668
+ affectedNode: (0, import_types3.serviceId)(span.service)
1669
+ };
1670
+ }
1671
+ function makeErrorSpanWriter(errorsPath) {
1672
+ return async (span) => {
1673
+ const ev = buildErrorEventForReceiver(span);
1674
+ if (!ev) return;
1675
+ await import_node_fs3.promises.mkdir(import_node_path3.default.dirname(errorsPath), { recursive: true });
1676
+ await import_node_fs3.promises.appendFile(errorsPath, JSON.stringify(ev) + "\n", "utf8");
1677
+ };
1678
+ }
1656
1679
  async function handleSpan(ctx, span) {
1657
1680
  const ts = span.startTimeIso ?? nowIso(ctx);
1658
1681
  const nowMs = ctx.now ? ctx.now() : Date.now();
@@ -4516,9 +4539,15 @@ function registerRoutes(scope, ctx) {
4516
4539
  scope.get("/health", async (req, reply) => {
4517
4540
  const proj = resolveProject(registry, req, reply);
4518
4541
  if (!proj) return;
4542
+ const uptimeMs = Date.now() - startedAt;
4519
4543
  return {
4520
- uptime: Math.floor((Date.now() - startedAt) / 1e3),
4544
+ ok: true,
4521
4545
  project: proj.name,
4546
+ uptimeMs,
4547
+ // Legacy fields kept additively. The web shell's StatusBar reads
4548
+ // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
4549
+ // the canonical triple and lets the extras pass through.
4550
+ uptime: Math.floor(uptimeMs / 1e3),
4522
4551
  nodeCount: proj.graph.order,
4523
4552
  edgeCount: proj.graph.size,
4524
4553
  lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
@@ -4538,7 +4567,7 @@ function registerRoutes(scope, ctx) {
4538
4567
  if (!proj.graph.hasNode(id)) {
4539
4568
  return reply.code(404).send({ error: "node not found", id });
4540
4569
  }
4541
- return proj.graph.getNodeAttributes(id);
4570
+ return { node: proj.graph.getNodeAttributes(id) };
4542
4571
  }
4543
4572
  );
4544
4573
  scope.get(
@@ -4555,12 +4584,12 @@ function registerRoutes(scope, ctx) {
4555
4584
  return { inbound, outbound };
4556
4585
  }
4557
4586
  );
4558
- scope.get("/graph/node/:id/dependencies", async (req, reply) => {
4587
+ scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
4559
4588
  const proj = resolveProject(registry, req, reply);
4560
4589
  if (!proj) return;
4561
- const { id } = req.params;
4562
- if (!proj.graph.hasNode(id)) {
4563
- return reply.code(404).send({ error: "node not found", id });
4590
+ const { nodeId } = req.params;
4591
+ if (!proj.graph.hasNode(nodeId)) {
4592
+ return reply.code(404).send({ error: "node not found", id: nodeId });
4564
4593
  }
4565
4594
  const depth = req.query.depth ? Number(req.query.depth) : TRANSITIVE_DEPENDENCIES_DEFAULT_DEPTH;
4566
4595
  if (!Number.isFinite(depth) || depth < 1 || depth > TRANSITIVE_DEPENDENCIES_MAX_DEPTH) {
@@ -4568,7 +4597,7 @@ function registerRoutes(scope, ctx) {
4568
4597
  error: `depth must be an integer in [1, ${TRANSITIVE_DEPENDENCIES_MAX_DEPTH}]`
4569
4598
  });
4570
4599
  }
4571
- return getTransitiveDependencies(proj.graph, id, depth);
4600
+ return getTransitiveDependencies(proj.graph, nodeId, depth);
4572
4601
  });
4573
4602
  scope.get("/graph/divergences", async (req, reply) => {
4574
4603
  const proj = resolveProject(registry, req, reply);
@@ -4609,19 +4638,26 @@ function registerRoutes(scope, ctx) {
4609
4638
  const proj = resolveProject(registry, req, reply);
4610
4639
  if (!proj) return;
4611
4640
  const epath = errorsPathFor(proj);
4612
- if (!epath) return [];
4613
- return readErrorEvents(epath);
4641
+ if (!epath) return { count: 0, total: 0, events: [] };
4642
+ const events = await readErrorEvents(epath);
4643
+ const total = events.length;
4644
+ const limit = req.query.limit ? Number(req.query.limit) : 50;
4645
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 200) : 50;
4646
+ const sliced = events.slice(0, safeLimit);
4647
+ return { count: sliced.length, total, events: sliced };
4614
4648
  });
4615
- scope.get("/incidents/stale", async (req, reply) => {
4649
+ scope.get("/stale-events", async (req, reply) => {
4616
4650
  const proj = resolveProject(registry, req, reply);
4617
4651
  if (!proj) return;
4618
4652
  const spath = staleEventsPathFor(proj);
4619
- if (!spath) return [];
4653
+ if (!spath) return { count: 0, total: 0, events: [] };
4620
4654
  const events = await readStaleEvents(spath);
4621
4655
  const filtered = req.query.edgeType ? events.filter((e) => e.edgeType === req.query.edgeType) : events;
4622
4656
  const ordered = [...filtered].reverse();
4657
+ const total = ordered.length;
4623
4658
  const limit = req.query.limit ? Number(req.query.limit) : 50;
4624
- return ordered.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 50);
4659
+ const sliced = ordered.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 50);
4660
+ return { count: sliced.length, total, events: sliced };
4625
4661
  });
4626
4662
  scope.get(
4627
4663
  "/incidents/:nodeId",
@@ -4633,14 +4669,15 @@ function registerRoutes(scope, ctx) {
4633
4669
  return reply.code(404).send({ error: "node not found", id: nodeId });
4634
4670
  }
4635
4671
  const epath = errorsPathFor(proj);
4636
- if (!epath) return [];
4672
+ if (!epath) return { count: 0, total: 0, events: [] };
4637
4673
  const events = await readErrorEvents(epath);
4638
- return events.filter(
4674
+ const filtered = events.filter(
4639
4675
  (e) => e.affectedNode === nodeId || e.service === nodeId.replace(/^service:/, "")
4640
4676
  );
4677
+ return { count: filtered.length, total: filtered.length, events: filtered };
4641
4678
  }
4642
4679
  );
4643
- scope.get("/traverse/root-cause/:nodeId", async (req, reply) => {
4680
+ scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
4644
4681
  const proj = resolveProject(registry, req, reply);
4645
4682
  if (!proj) return;
4646
4683
  const { nodeId } = req.params;
@@ -4660,7 +4697,7 @@ function registerRoutes(scope, ctx) {
4660
4697
  if (!result) return reply.code(404).send({ error: "no root cause found", id: nodeId });
4661
4698
  return result;
4662
4699
  });
4663
- scope.get("/traverse/blast-radius/:nodeId", async (req, reply) => {
4700
+ scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
4664
4701
  const proj = resolveProject(registry, req, reply);
4665
4702
  if (!proj) return;
4666
4703
  const { nodeId } = req.params;
@@ -4770,7 +4807,7 @@ function registerRoutes(scope, ctx) {
4770
4807
  if (req.query.policyId) {
4771
4808
  violations = violations.filter((v) => v.policyId === req.query.policyId);
4772
4809
  }
4773
- return violations;
4810
+ return { violations };
4774
4811
  });
4775
4812
  scope.post("/policies/check", async (req, reply) => {
4776
4813
  const proj = resolveProject(registry, req, reply);
@@ -4849,6 +4886,20 @@ async function buildApi(opts) {
4849
4886
  });
4850
4887
  }
4851
4888
  });
4889
+ app.get("/projects/:project", async (req, reply) => {
4890
+ try {
4891
+ const entry = await getProject(req.params.project);
4892
+ if (!entry) {
4893
+ return reply.code(404).send({ error: "project not found", project: req.params.project });
4894
+ }
4895
+ return { project: entry };
4896
+ } catch (err) {
4897
+ return reply.code(500).send({
4898
+ error: "failed to read project registry",
4899
+ details: err.message
4900
+ });
4901
+ }
4902
+ });
4852
4903
  registerRoutes(app, routeCtx);
4853
4904
  await app.register(
4854
4905
  async (scope) => {
@@ -4867,6 +4918,7 @@ init_otel_grpc();
4867
4918
  init_cjs_shims();
4868
4919
  var import_node_fs19 = require("fs");
4869
4920
  var import_node_path33 = __toESM(require("path"), 1);
4921
+ init_otel();
4870
4922
  function neatHomeFor(opts) {
4871
4923
  if (opts.neatHome && opts.neatHome.length > 0) return import_node_path33.default.resolve(opts.neatHome);
4872
4924
  const env = process.env.NEAT_HOME;
@@ -4885,6 +4937,7 @@ function routeSpanToProject(serviceName, projects) {
4885
4937
  return DEFAULT_PROJECT;
4886
4938
  }
4887
4939
  async function bootstrapProject(entry) {
4940
+ const paths = pathsForProject(entry.name, import_node_path33.default.join(entry.path, "neat-out"));
4888
4941
  try {
4889
4942
  const stat = await import_node_fs19.promises.stat(entry.path);
4890
4943
  if (!stat.isDirectory()) {
@@ -4899,6 +4952,7 @@ async function bootstrapProject(entry) {
4899
4952
  // output; nothing routes to it because it's not 'active'.
4900
4953
  graph: getGraph(`__broken__:${entry.name}`),
4901
4954
  outPath: "",
4955
+ paths,
4902
4956
  stopPersist: () => {
4903
4957
  },
4904
4958
  status: "broken",
@@ -4907,10 +4961,7 @@ async function bootstrapProject(entry) {
4907
4961
  }
4908
4962
  resetGraph(entry.name);
4909
4963
  const graph = getGraph(entry.name);
4910
- const outPath = pathsForProject(
4911
- entry.name,
4912
- import_node_path33.default.join(entry.path, "neat-out")
4913
- ).snapshotPath;
4964
+ const outPath = paths.snapshotPath;
4914
4965
  await loadGraphFromDisk(graph, outPath);
4915
4966
  await extractFromDirectory(graph, entry.path);
4916
4967
  const stopPersist = startPersistLoop(graph, outPath);
@@ -4920,10 +4971,35 @@ async function bootstrapProject(entry) {
4920
4971
  entry,
4921
4972
  graph,
4922
4973
  outPath,
4974
+ paths,
4923
4975
  stopPersist,
4924
4976
  status: "active"
4925
4977
  };
4926
4978
  }
4979
+ function resolveRestPort(opts) {
4980
+ if (typeof opts.restPort === "number") return opts.restPort;
4981
+ const env = process.env.PORT;
4982
+ if (env && env.length > 0) {
4983
+ const n = Number.parseInt(env, 10);
4984
+ if (Number.isFinite(n)) return n;
4985
+ }
4986
+ return 8080;
4987
+ }
4988
+ function resolveOtlpPort(opts) {
4989
+ if (typeof opts.otlpPort === "number") return opts.otlpPort;
4990
+ const env = process.env.OTEL_PORT;
4991
+ if (env && env.length > 0) {
4992
+ const n = Number.parseInt(env, 10);
4993
+ if (Number.isFinite(n)) return n;
4994
+ }
4995
+ return 4318;
4996
+ }
4997
+ function resolveHost(opts) {
4998
+ if (opts.host && opts.host.length > 0) return opts.host;
4999
+ const env = process.env.HOST;
5000
+ if (env && env.length > 0) return env;
5001
+ return "0.0.0.0";
5002
+ }
4927
5003
  async function startDaemon(opts = {}) {
4928
5004
  const home = neatHomeFor(opts);
4929
5005
  const regPath = registryPath();
@@ -4938,6 +5014,15 @@ async function startDaemon(opts = {}) {
4938
5014
  await writeAtomically(pidPath, `${process.pid}
4939
5015
  `);
4940
5016
  const slots = /* @__PURE__ */ new Map();
5017
+ const registry = new Projects();
5018
+ function upsertRegistryFromSlot(slot) {
5019
+ if (slot.status !== "active") return;
5020
+ registry.set(slot.entry.name, {
5021
+ scanPath: slot.entry.path,
5022
+ paths: slot.paths,
5023
+ graph: slot.graph
5024
+ });
5025
+ }
4941
5026
  async function loadAll() {
4942
5027
  const projects = await listProjects();
4943
5028
  const seen = /* @__PURE__ */ new Set();
@@ -4947,6 +5032,7 @@ async function startDaemon(opts = {}) {
4947
5032
  try {
4948
5033
  const slot = await bootstrapProject(entry);
4949
5034
  slots.set(entry.name, slot);
5035
+ upsertRegistryFromSlot(slot);
4950
5036
  if (slot.status === "broken") {
4951
5037
  console.warn(`neatd: project "${entry.name}" broken \u2014 ${slot.errorReason}`);
4952
5038
  } else {
@@ -4971,6 +5057,80 @@ async function startDaemon(opts = {}) {
4971
5057
  }
4972
5058
  }
4973
5059
  await loadAll();
5060
+ const bind = opts.bindListeners !== false;
5061
+ let restApp = null;
5062
+ let otlpApp = null;
5063
+ let restAddress = "";
5064
+ let otlpAddress = "";
5065
+ if (bind) {
5066
+ const host = resolveHost(opts);
5067
+ const restPort = resolveRestPort(opts);
5068
+ const otlpPort = resolveOtlpPort(opts);
5069
+ try {
5070
+ restApp = await buildApi({ projects: registry });
5071
+ restAddress = await restApp.listen({ port: restPort, host });
5072
+ console.log(`neatd: REST listening on ${restAddress}`);
5073
+ } catch (err) {
5074
+ for (const slot of slots.values()) {
5075
+ try {
5076
+ slot.stopPersist();
5077
+ } catch {
5078
+ }
5079
+ }
5080
+ if (restApp) await restApp.close().catch(() => {
5081
+ });
5082
+ await import_node_fs19.promises.unlink(pidPath).catch(() => {
5083
+ });
5084
+ throw new Error(
5085
+ `neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
5086
+ );
5087
+ }
5088
+ try {
5089
+ otlpApp = await buildOtelReceiver({
5090
+ onSpan: async (span) => {
5091
+ const liveEntries = await listProjects().catch(() => []);
5092
+ const target = routeSpanToProject(span.service, liveEntries);
5093
+ const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5094
+ if (!slot || slot.status !== "active") return;
5095
+ await handleSpan(
5096
+ {
5097
+ graph: slot.graph,
5098
+ errorsPath: slot.paths.errorsPath,
5099
+ project: slot.entry.name,
5100
+ // Receiver already wrote the error event synchronously below.
5101
+ writeErrorEventInline: false
5102
+ },
5103
+ span
5104
+ );
5105
+ },
5106
+ onErrorSpanSync: async (span) => {
5107
+ const liveEntries = await listProjects().catch(() => []);
5108
+ const target = routeSpanToProject(span.service, liveEntries);
5109
+ const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5110
+ if (!slot || slot.status !== "active") return;
5111
+ await makeErrorSpanWriter(slot.paths.errorsPath)(span);
5112
+ }
5113
+ });
5114
+ otlpAddress = await otlpApp.listen({ port: otlpPort, host });
5115
+ console.log(`neatd: OTLP listening on ${otlpAddress}/v1/traces`);
5116
+ } catch (err) {
5117
+ for (const slot of slots.values()) {
5118
+ try {
5119
+ slot.stopPersist();
5120
+ } catch {
5121
+ }
5122
+ }
5123
+ if (restApp) await restApp.close().catch(() => {
5124
+ });
5125
+ if (otlpApp) await otlpApp.close().catch(() => {
5126
+ });
5127
+ await import_node_fs19.promises.unlink(pidPath).catch(() => {
5128
+ });
5129
+ throw new Error(
5130
+ `neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
5131
+ );
5132
+ }
5133
+ }
4974
5134
  let reloading = null;
4975
5135
  const reload = async () => {
4976
5136
  if (reloading) return reloading;
@@ -4994,6 +5154,10 @@ async function startDaemon(opts = {}) {
4994
5154
  if (stopped) return;
4995
5155
  stopped = true;
4996
5156
  process.off("SIGHUP", sighupHandler);
5157
+ if (otlpApp) await otlpApp.close().catch(() => {
5158
+ });
5159
+ if (restApp) await restApp.close().catch(() => {
5160
+ });
4997
5161
  for (const slot of slots.values()) {
4998
5162
  try {
4999
5163
  slot.stopPersist();
@@ -5003,7 +5167,7 @@ async function startDaemon(opts = {}) {
5003
5167
  await import_node_fs19.promises.unlink(pidPath).catch(() => {
5004
5168
  });
5005
5169
  };
5006
- return { slots, reload, stop, pidPath };
5170
+ return { slots, reload, stop, pidPath, restAddress, otlpAddress };
5007
5171
  }
5008
5172
  // Annotate the CommonJS export names for ESM import in node:
5009
5173
  0 && (module.exports = {