@neat.is/core 0.9.13 → 0.9.15-dev.20260902

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
@@ -1284,13 +1284,19 @@ function resolveOwningService(graph, nodeId) {
1284
1284
  }
1285
1285
  return null;
1286
1286
  }
1287
+ function isFrontierEdge(e) {
1288
+ return e.provenance === import_types.Provenance.FRONTIER;
1289
+ }
1290
+ function rankOf(p) {
1291
+ return p === import_types.Provenance.FRONTIER ? -1 : import_types.PROV_RANK[p];
1292
+ }
1287
1293
  function bestEdgeBySource(graph, edgeIds) {
1288
1294
  const best = /* @__PURE__ */ new Map();
1289
1295
  for (const id of edgeIds) {
1290
1296
  const e = graph.getEdgeAttributes(id);
1291
- if (isFrontierNode(graph, e.source)) continue;
1297
+ if (isFrontierNode(graph, e.source) || isFrontierEdge(e)) continue;
1292
1298
  const cur = best.get(e.source);
1293
- if (!cur || import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[cur.provenance]) {
1299
+ if (!cur || rankOf(e.provenance) > rankOf(cur.provenance)) {
1294
1300
  best.set(e.source, e);
1295
1301
  }
1296
1302
  }
@@ -1300,9 +1306,9 @@ function bestEdgeByTarget(graph, edgeIds) {
1300
1306
  const best = /* @__PURE__ */ new Map();
1301
1307
  for (const id of edgeIds) {
1302
1308
  const e = graph.getEdgeAttributes(id);
1303
- if (isFrontierNode(graph, e.target)) continue;
1309
+ if (isFrontierNode(graph, e.target) || isFrontierEdge(e)) continue;
1304
1310
  const cur = best.get(e.target);
1305
- if (!cur || import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[cur.provenance]) {
1311
+ if (!cur || rankOf(e.provenance) > rankOf(cur.provenance)) {
1306
1312
  best.set(e.target, e);
1307
1313
  }
1308
1314
  }
@@ -1312,7 +1318,10 @@ var PROVENANCE_CEILING = {
1312
1318
  OBSERVED: 1,
1313
1319
  INFERRED: 0.7,
1314
1320
  EXTRACTED: 0.5,
1315
- STALE: 0.3
1321
+ STALE: 0.3,
1322
+ // A FRONTIER surface (ADR-226) is a proposal about a cause NEAT could not observe
1323
+ // — the least-trusted claim it makes, below STALE (which was at least once seen).
1324
+ FRONTIER: 0.2
1316
1325
  };
1317
1326
  function volumeWeight(spanCount) {
1318
1327
  if (!spanCount || spanCount <= 0) return 0.5;
@@ -1547,7 +1556,7 @@ function rootCauseFromIncidents(nodeId, incidents, errorEvent) {
1547
1556
  });
1548
1557
  }
1549
1558
  function isFailingCallEdge(e) {
1550
- return e.type === import_types.EdgeType.CALLS && (e.signal?.errorCount ?? 0) > 0;
1559
+ return e.type === import_types.EdgeType.CALLS && !isFrontierEdge(e) && (e.signal?.errorCount ?? 0) > 0;
1551
1560
  }
1552
1561
  function callSourcesForService(graph, serviceId17) {
1553
1562
  const ids = [serviceId17];
@@ -1570,8 +1579,8 @@ function failingCallDominates(e, id, curEdge, curId) {
1570
1579
  const ec = e.signal?.errorCount ?? 0;
1571
1580
  const cc = curEdge.signal?.errorCount ?? 0;
1572
1581
  if (ec !== cc) return ec > cc;
1573
- if (import_types.PROV_RANK[e.provenance] !== import_types.PROV_RANK[curEdge.provenance]) {
1574
- return import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[curEdge.provenance];
1582
+ if (rankOf(e.provenance) !== rankOf(curEdge.provenance)) {
1583
+ return rankOf(e.provenance) > rankOf(curEdge.provenance);
1575
1584
  }
1576
1585
  return id < curId;
1577
1586
  }
@@ -1622,11 +1631,11 @@ function dominantStaleCall(graph, serviceId17, visited) {
1622
1631
  for (const edgeId of graph.outboundEdges(src)) {
1623
1632
  const e = graph.getEdgeAttributes(edgeId);
1624
1633
  if (e.type !== import_types.EdgeType.CALLS) continue;
1625
- if (isFrontierNode(graph, e.target)) continue;
1634
+ if (isFrontierNode(graph, e.target) || isFrontierEdge(e)) continue;
1626
1635
  const owner = resolveOwningService(graph, e.target);
1627
1636
  if (!owner || visited.has(owner.id)) continue;
1628
1637
  const cur = bestByCallee.get(owner.id);
1629
- if (!cur || import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[cur.provenance]) {
1638
+ if (!cur || rankOf(e.provenance) > rankOf(cur.provenance)) {
1630
1639
  bestByCallee.set(owner.id, e);
1631
1640
  }
1632
1641
  }
@@ -1899,6 +1908,7 @@ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
1899
1908
  for (const edgeId of graph.inboundEdges(n)) {
1900
1909
  const e = graph.getEdgeAttributes(edgeId);
1901
1910
  if (e.type === import_types.EdgeType.CONTAINS) continue;
1911
+ if (isFrontierEdge(e)) continue;
1902
1912
  errorsFromCallers += e.signal?.errorCount ?? 0;
1903
1913
  inboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1904
1914
  if (e.provenance === import_types.Provenance.STALE) stale = true;
@@ -1912,6 +1922,7 @@ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
1912
1922
  for (const edgeId of graph.outboundEdges(n)) {
1913
1923
  const e = graph.getEdgeAttributes(edgeId);
1914
1924
  if (e.type === import_types.EdgeType.CONTAINS) continue;
1925
+ if (isFrontierEdge(e)) continue;
1915
1926
  outboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1916
1927
  if (e.type === import_types.EdgeType.CALLS) outboundErrors += e.signal?.errorCount ?? 0;
1917
1928
  if (e.provenance === import_types.Provenance.STALE) stale = true;
@@ -2195,22 +2206,44 @@ function routeMatches(declared, incident) {
2195
2206
  const b = normalizeRoute(incident);
2196
2207
  return a === b || a.startsWith(b) || b.startsWith(a);
2197
2208
  }
2198
- function structuralUpstreamPointer(graph, boundaryNode, incidents) {
2199
- const route = boundaryIncidentRoute(boundaryNode, incidents);
2200
- const callees = declaredOutboundCallees(graph, boundaryNode);
2209
+ function firstDeclaredCallee(graph, node, route) {
2210
+ const callees = declaredOutboundCallees(graph, node);
2201
2211
  if (callees.length === 0) return null;
2202
2212
  const narrowed = route !== void 0 ? callees.filter((c) => c.route !== void 0 && routeMatches(c.route, route)) : callees;
2203
2213
  const chosen = narrowed.length === 1 ? narrowed[0] : callees.length === 1 ? callees[0] : null;
2204
- if (!chosen) return null;
2205
- let node = chosen.target;
2206
- const seen = /* @__PURE__ */ new Set([boundaryNode, node]);
2207
- for (let depth = 0; depth < ROOT_CAUSE_MAX_DEPTH; depth++) {
2208
- const next = declaredOutboundCallees(graph, node);
2209
- if (next.length !== 1 || seen.has(next[0].target)) break;
2210
- node = next[0].target;
2211
- seen.add(node);
2214
+ return chosen ? chosen.target : null;
2215
+ }
2216
+ function observedOutboundNextServices(graph, nodeId) {
2217
+ const targets = /* @__PURE__ */ new Set();
2218
+ for (const n of nodeScope(graph, nodeId)) {
2219
+ if (!graph.hasNode(n)) continue;
2220
+ for (const edgeId of graph.outboundEdges(n)) {
2221
+ const e = graph.getEdgeAttributes(edgeId);
2222
+ if (!DEP_EDGE_TYPES.has(e.type)) continue;
2223
+ if (e.provenance !== import_types.Provenance.OBSERVED) continue;
2224
+ if (e.target !== nodeId) targets.add(e.target);
2225
+ }
2226
+ }
2227
+ return [...targets];
2228
+ }
2229
+ function resolveHangHop(graph, boundaryNode, incidents) {
2230
+ const route = boundaryIncidentRoute(boundaryNode, incidents);
2231
+ const own = firstDeclaredCallee(graph, boundaryNode, route);
2232
+ if (own) {
2233
+ return route !== void 0 ? { source: boundaryNode, target: own, route } : { source: boundaryNode, target: own };
2234
+ }
2235
+ const resolved = observedOutboundNextServices(graph, boundaryNode).map((n) => ({ source: n, target: firstDeclaredCallee(graph, n, route) })).filter((r) => r.target !== null);
2236
+ if (resolved.length === 1) {
2237
+ const hop = resolved[0];
2238
+ return route !== void 0 ? { ...hop, route } : hop;
2212
2239
  }
2213
- return route !== void 0 ? { node, route } : { node };
2240
+ return null;
2241
+ }
2242
+ function stagedHangProposal(graph, boundaryNode, incidents) {
2243
+ const hop = resolveHangHop(graph, boundaryNode, incidents);
2244
+ if (!hop) return null;
2245
+ if (!graph.hasEdge((0, import_types.frontierEdgeId)(hop.source, hop.target, import_types.EdgeType.CALLS))) return null;
2246
+ return hop.route !== void 0 ? { node: hop.target, route: hop.route } : { node: hop.target };
2214
2247
  }
2215
2248
  function enrichWithNavigation(graph, errorNodeId, tagged, incidents, now) {
2216
2249
  const legacy = tagged.result;
@@ -2256,28 +2289,28 @@ function enrichWithNavigation(graph, errorNodeId, tagged, incidents, now) {
2256
2289
  provenance: import_types.Provenance.OBSERVED
2257
2290
  });
2258
2291
  } else if (seedCtx && isBoundaryTimeoutSymptom(seedCtx)) {
2259
- const pointer = structuralUpstreamPointer(graph, seedNode, incidents);
2260
2292
  const seedName = displayNameOf(seedNode);
2261
- const routeNote = pointer?.route ? ` serving ${pointer.route}` : "";
2262
- if (pointer) {
2263
- const causeName = displayNameOf(pointer.node);
2264
- candidates.push({
2265
- node: pointer.node,
2266
- classification: "primary-failure",
2267
- reason: `${causeName} is the likely root cause (structural, INFERRED \u2014 not observed): the boundary ${seedName} only timed out waiting, and the actual culprit hung without exporting a span, so this is walked from the declared call graph${routeNote}, not from runtime signal. Restore instrumentation / inspect ${causeName} to confirm.`,
2268
- context: nodeContext(graph, pointer.node, incidents, now),
2269
- confidence: Math.min(legacy.confidence, PROVENANCE_CEILING.EXTRACTED),
2270
- provenance: import_types.Provenance.INFERRED
2271
- });
2272
- }
2293
+ const proposal = stagedHangProposal(graph, seedNode, incidents);
2294
+ const routeNote = proposal?.route ? ` serving ${proposal.route}` : "";
2273
2295
  candidates.push({
2274
2296
  node: seedNode,
2275
2297
  classification: "symptom-only",
2276
- reason: pointer ? `${seedName} timed out waiting on a downstream that hung and exported nothing \u2014 a boundary reporting an upstream fault, not the fault itself.` : `${seedName} timed out but nothing it can see downstream is erroring \u2014 the cause is downstream and unobserved (the culprit hung and exported no span). No single declared callee${routeNote} resolves, so no confident cause is named; inspect ${seedName}'s declared downstream.`,
2298
+ reason: proposal ? `${seedName} timed out waiting on a downstream that hung and exported no span \u2014 a boundary reporting an upstream fault, not the fault itself. The cause is unobservable; NEAT proposes the staged FRONTIER surface below, a hypothesis to confirm, not an observed cause.` : `${seedName} timed out but nothing it can see downstream is erroring \u2014 the cause is downstream and unobserved (the culprit hung and exported no span). No FRONTIER surface${routeNote} is staged, so no cause is proposed; inspect ${seedName}'s declared downstream and restore instrumentation.`,
2277
2299
  context: seedCtx,
2278
2300
  confidence: Math.min(legacy.confidence, 0.3),
2279
2301
  ...lastProv ? { provenance: lastProv } : {}
2280
2302
  });
2303
+ if (proposal) {
2304
+ const causeName = displayNameOf(proposal.node);
2305
+ candidates.push({
2306
+ node: proposal.node,
2307
+ classification: "primary-failure",
2308
+ reason: `${causeName} is the proposed hang cause (FRONTIER \u2014 a hypothesis, not observed): the boundary ${seedName} only timed out waiting and the culprit hung without exporting a span, so NEAT staged a FRONTIER surface on the unobservable hop${routeNote}. It graduates to OBSERVED when ${causeName} comes back; until then, inspect it / restore instrumentation to confirm, or it is culled.`,
2309
+ context: nodeContext(graph, proposal.node, incidents, now),
2310
+ confidence: Math.min(legacy.confidence, PROVENANCE_CEILING.FRONTIER),
2311
+ provenance: import_types.Provenance.FRONTIER
2312
+ });
2313
+ }
2281
2314
  } else if (staleChain) {
2282
2315
  const culprit = staleChain.culprit;
2283
2316
  const culpritName = displayNameOf(culprit);
@@ -2341,6 +2374,9 @@ function fixRecommendationForTop(top, seedNode, legacy) {
2341
2374
  return legacy.fixRecommendation;
2342
2375
  }
2343
2376
  const name = top.node.replace(/^service:/, "");
2377
+ if (top.classification === "symptom-only" && top.context.boundaryTimeout === true) {
2378
+ return `${name} timed out waiting on a downstream that hung and exported no span \u2014 the cause is unobservable from traces. Inspect the FRONTIER surface NEAT staged on the unobservable hop (see candidates); it graduates to OBSERVED when that service recovers. Restore instrumentation on that path to confirm, rather than treating ${name} as the fault.`;
2379
+ }
2344
2380
  if (top.provenance === import_types.Provenance.STALE) {
2345
2381
  return `Live telemetry for this path has gone quiet; the last-observed topology traces the failure downstream to ${name}. Restore instrumentation (or re-run with live traces) to confirm, then inspect ${name}.`;
2346
2382
  }
@@ -7006,6 +7042,32 @@ function promoteFrontierNodes(graph, opts = {}) {
7006
7042
  }
7007
7043
  return promoted;
7008
7044
  }
7045
+ function stageFrontierEdge(graph, source, target, type) {
7046
+ if (!graph.hasNode(source) || !graph.hasNode(target)) return false;
7047
+ if (graph.hasEdge((0, import_types8.observedEdgeId)(source, target, type))) return false;
7048
+ const key = (0, import_types8.frontierEdgeId)(source, target, type);
7049
+ if (graph.hasEdge(key)) return false;
7050
+ graph.addEdgeWithKey(key, source, target, {
7051
+ id: key,
7052
+ source,
7053
+ target,
7054
+ type,
7055
+ provenance: import_types8.Provenance.FRONTIER
7056
+ });
7057
+ return true;
7058
+ }
7059
+ function promoteFrontierEdges(graph) {
7060
+ const graduated = [];
7061
+ graph.forEachEdge((edgeId, attrs) => {
7062
+ const e = attrs;
7063
+ if (e.provenance !== import_types8.Provenance.FRONTIER) return;
7064
+ if (graph.hasEdge((0, import_types8.observedEdgeId)(e.source, e.target, e.type))) {
7065
+ graduated.push(edgeId);
7066
+ }
7067
+ });
7068
+ for (const edgeId of graduated) graph.dropEdge(edgeId);
7069
+ return graduated.length;
7070
+ }
7009
7071
  function rewireFrontierEdges(graph, frontierId2, serviceId17) {
7010
7072
  const inbound = [...graph.inboundEdges(frontierId2)];
7011
7073
  const outbound = [...graph.outboundEdges(frontierId2)];
@@ -7114,6 +7176,7 @@ function startStalenessLoop(graph, options = {}) {
7114
7176
  project: options.project
7115
7177
  });
7116
7178
  if (options.onPolicyTrigger) await options.onPolicyTrigger(graph);
7179
+ if (options.onReconcile) await options.onReconcile(graph);
7117
7180
  } catch (err) {
7118
7181
  console.error("staleness tick failed", err);
7119
7182
  }
@@ -23309,6 +23372,36 @@ var import_node_path77 = __toESM(require("path"), 1);
23309
23372
  var import_node_module = require("module");
23310
23373
  init_otel();
23311
23374
 
23375
+ // src/hang-sensor.ts
23376
+ init_cjs_shims();
23377
+ var import_types101 = require("@neat.is/types");
23378
+ var RECONCILE_INCIDENT_LIMIT = 500;
23379
+ function stageHangSurfaces(graph, incidents) {
23380
+ const boundaries = /* @__PURE__ */ new Set();
23381
+ for (const ev of incidents) {
23382
+ if (ev.affectedNode && graph.hasNode(ev.affectedNode)) boundaries.add(ev.affectedNode);
23383
+ }
23384
+ let staged = 0;
23385
+ for (const boundary of boundaries) {
23386
+ const ctx = nodeContext(graph, boundary, incidents);
23387
+ if (!isBoundaryTimeoutSymptom(ctx)) continue;
23388
+ const hop = resolveHangHop(graph, boundary, incidents);
23389
+ if (!hop) continue;
23390
+ if (stageFrontierEdge(graph, hop.source, hop.target, import_types101.EdgeType.CALLS)) staged++;
23391
+ }
23392
+ return staged;
23393
+ }
23394
+ async function reconcileFrontierSurfaces(graph, errorsPath) {
23395
+ promoteFrontierEdges(graph);
23396
+ let incidents = [];
23397
+ try {
23398
+ incidents = await readErrorEvents(errorsPath, { limit: RECONCILE_INCIDENT_LIMIT });
23399
+ } catch {
23400
+ incidents = [];
23401
+ }
23402
+ stageHangSurfaces(graph, incidents);
23403
+ }
23404
+
23312
23405
  // src/connectors/kubernetes/index.ts
23313
23406
  init_cjs_shims();
23314
23407
 
@@ -23658,7 +23751,7 @@ function mapWorkloadsToSignals(deployments, pods, config) {
23658
23751
 
23659
23752
  // src/connectors/kubernetes/resolve.ts
23660
23753
  init_cjs_shims();
23661
- var import_types102 = require("@neat.is/types");
23754
+ var import_types103 = require("@neat.is/types");
23662
23755
  var NO_ENV3 = "unknown";
23663
23756
  function createK8sResolveTarget(graph) {
23664
23757
  return (signal) => {
@@ -23669,7 +23762,7 @@ function createK8sResolveTarget(graph) {
23669
23762
  return {
23670
23763
  targetNodeId: resolveFusedServiceId(graph, serviceName, NO_ENV3),
23671
23764
  serviceName,
23672
- edgeType: import_types102.EdgeType.CALLS
23765
+ edgeType: import_types103.EdgeType.CALLS
23673
23766
  };
23674
23767
  };
23675
23768
  }
@@ -23804,7 +23897,7 @@ function unroutedErrorsPath(neatHome3) {
23804
23897
  }
23805
23898
 
23806
23899
  // src/daemon.ts
23807
- var import_types105 = require("@neat.is/types");
23900
+ var import_types106 = require("@neat.is/types");
23808
23901
  function daemonJsonPath(scanPath) {
23809
23902
  return import_node_path77.default.join(scanPath, "neat-out", "daemon.json");
23810
23903
  }
@@ -23933,7 +24026,7 @@ function spanBelongsToSingleProject(graph, project, serviceName) {
23933
24026
  if (!serviceName) return true;
23934
24027
  if (serviceNameMatchesProject(serviceName, project)) return true;
23935
24028
  return graph.someNode(
23936
- (_id, attrs) => attrs.type === import_types105.NodeType.ServiceNode && attrs.name === serviceName
24029
+ (_id, attrs) => attrs.type === import_types106.NodeType.ServiceNode && attrs.name === serviceName
23937
24030
  );
23938
24031
  }
23939
24032
  async function bootstrapProject(entry, connectors = [], neatHome3) {
@@ -23977,7 +24070,11 @@ async function bootstrapProject(entry, connectors = [], neatHome3) {
23977
24070
  const stopPersist = startPersistLoop(graph, outPath, { exitOnSignal: false });
23978
24071
  const stopStaleness = startStalenessLoop(graph, {
23979
24072
  staleEventsPath: paths.staleEventsPath,
23980
- project: entry.name
24073
+ project: entry.name,
24074
+ // Graduate FRONTIER surfaces whose OBSERVED twin has arrived + stage surfaces
24075
+ // for current hangs (ADR-226), each post-ingest tick — the periodic point
24076
+ // where OBSERVED state has settled and the errors ledger is current.
24077
+ onReconcile: (g) => reconcileFrontierSurfaces(g, paths.errorsPath)
23981
24078
  });
23982
24079
  const stopConnectors = await startConnectorPolling({
23983
24080
  project: entry.name,