@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.d.cts CHANGED
@@ -440,6 +440,7 @@ interface StalenessLoopOptions {
440
440
  staleEventsPath?: string;
441
441
  project?: string;
442
442
  onPolicyTrigger?: (graph: NeatGraph) => Promise<void> | void;
443
+ onReconcile?: (graph: NeatGraph) => Promise<void> | void;
443
444
  }
444
445
  declare function startStalenessLoop(graph: NeatGraph, options?: StalenessLoopOptions): () => void;
445
446
  declare function readErrorEvents(errorsPath: string, opts?: {
package/dist/index.d.ts CHANGED
@@ -440,6 +440,7 @@ interface StalenessLoopOptions {
440
440
  staleEventsPath?: string;
441
441
  project?: string;
442
442
  onPolicyTrigger?: (graph: NeatGraph) => Promise<void> | void;
443
+ onReconcile?: (graph: NeatGraph) => Promise<void> | void;
443
444
  }
444
445
  declare function startStalenessLoop(graph: NeatGraph, options?: StalenessLoopOptions): () => void;
445
446
  declare function readErrorEvents(errorsPath: string, opts?: {
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  routeSpanToProject,
3
3
  startDaemon
4
- } from "./chunk-BKSM2YLV.js";
4
+ } from "./chunk-NV4WWJSU.js";
5
5
  import {
6
6
  ProjectNameCollisionError,
7
7
  addProject,
@@ -37,7 +37,7 @@ import {
37
37
  thresholdForEdgeType,
38
38
  touchLastSeen,
39
39
  writeAtomically
40
- } from "./chunk-RL6H3VVD.js";
40
+ } from "./chunk-SSLPBQWY.js";
41
41
  import {
42
42
  startOtelGrpcReceiver
43
43
  } from "./chunk-ERE47MCR.js";
package/dist/neatd.cjs CHANGED
@@ -1247,13 +1247,19 @@ function resolveOwningService(graph, nodeId) {
1247
1247
  }
1248
1248
  return null;
1249
1249
  }
1250
+ function isFrontierEdge(e) {
1251
+ return e.provenance === import_types.Provenance.FRONTIER;
1252
+ }
1253
+ function rankOf(p) {
1254
+ return p === import_types.Provenance.FRONTIER ? -1 : import_types.PROV_RANK[p];
1255
+ }
1250
1256
  function bestEdgeBySource(graph, edgeIds) {
1251
1257
  const best = /* @__PURE__ */ new Map();
1252
1258
  for (const id of edgeIds) {
1253
1259
  const e = graph.getEdgeAttributes(id);
1254
- if (isFrontierNode(graph, e.source)) continue;
1260
+ if (isFrontierNode(graph, e.source) || isFrontierEdge(e)) continue;
1255
1261
  const cur = best.get(e.source);
1256
- if (!cur || import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[cur.provenance]) {
1262
+ if (!cur || rankOf(e.provenance) > rankOf(cur.provenance)) {
1257
1263
  best.set(e.source, e);
1258
1264
  }
1259
1265
  }
@@ -1263,9 +1269,9 @@ function bestEdgeByTarget(graph, edgeIds) {
1263
1269
  const best = /* @__PURE__ */ new Map();
1264
1270
  for (const id of edgeIds) {
1265
1271
  const e = graph.getEdgeAttributes(id);
1266
- if (isFrontierNode(graph, e.target)) continue;
1272
+ if (isFrontierNode(graph, e.target) || isFrontierEdge(e)) continue;
1267
1273
  const cur = best.get(e.target);
1268
- if (!cur || import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[cur.provenance]) {
1274
+ if (!cur || rankOf(e.provenance) > rankOf(cur.provenance)) {
1269
1275
  best.set(e.target, e);
1270
1276
  }
1271
1277
  }
@@ -1275,7 +1281,10 @@ var PROVENANCE_CEILING = {
1275
1281
  OBSERVED: 1,
1276
1282
  INFERRED: 0.7,
1277
1283
  EXTRACTED: 0.5,
1278
- STALE: 0.3
1284
+ STALE: 0.3,
1285
+ // A FRONTIER surface (ADR-226) is a proposal about a cause NEAT could not observe
1286
+ // — the least-trusted claim it makes, below STALE (which was at least once seen).
1287
+ FRONTIER: 0.2
1279
1288
  };
1280
1289
  function volumeWeight(spanCount) {
1281
1290
  if (!spanCount || spanCount <= 0) return 0.5;
@@ -1510,7 +1519,7 @@ function rootCauseFromIncidents(nodeId, incidents, errorEvent) {
1510
1519
  });
1511
1520
  }
1512
1521
  function isFailingCallEdge(e) {
1513
- return e.type === import_types.EdgeType.CALLS && (e.signal?.errorCount ?? 0) > 0;
1522
+ return e.type === import_types.EdgeType.CALLS && !isFrontierEdge(e) && (e.signal?.errorCount ?? 0) > 0;
1514
1523
  }
1515
1524
  function callSourcesForService(graph, serviceId17) {
1516
1525
  const ids = [serviceId17];
@@ -1533,8 +1542,8 @@ function failingCallDominates(e, id, curEdge, curId) {
1533
1542
  const ec = e.signal?.errorCount ?? 0;
1534
1543
  const cc = curEdge.signal?.errorCount ?? 0;
1535
1544
  if (ec !== cc) return ec > cc;
1536
- if (import_types.PROV_RANK[e.provenance] !== import_types.PROV_RANK[curEdge.provenance]) {
1537
- return import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[curEdge.provenance];
1545
+ if (rankOf(e.provenance) !== rankOf(curEdge.provenance)) {
1546
+ return rankOf(e.provenance) > rankOf(curEdge.provenance);
1538
1547
  }
1539
1548
  return id < curId;
1540
1549
  }
@@ -1585,11 +1594,11 @@ function dominantStaleCall(graph, serviceId17, visited) {
1585
1594
  for (const edgeId of graph.outboundEdges(src)) {
1586
1595
  const e = graph.getEdgeAttributes(edgeId);
1587
1596
  if (e.type !== import_types.EdgeType.CALLS) continue;
1588
- if (isFrontierNode(graph, e.target)) continue;
1597
+ if (isFrontierNode(graph, e.target) || isFrontierEdge(e)) continue;
1589
1598
  const owner = resolveOwningService(graph, e.target);
1590
1599
  if (!owner || visited.has(owner.id)) continue;
1591
1600
  const cur = bestByCallee.get(owner.id);
1592
- if (!cur || import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[cur.provenance]) {
1601
+ if (!cur || rankOf(e.provenance) > rankOf(cur.provenance)) {
1593
1602
  bestByCallee.set(owner.id, e);
1594
1603
  }
1595
1604
  }
@@ -1862,6 +1871,7 @@ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
1862
1871
  for (const edgeId of graph.inboundEdges(n)) {
1863
1872
  const e = graph.getEdgeAttributes(edgeId);
1864
1873
  if (e.type === import_types.EdgeType.CONTAINS) continue;
1874
+ if (isFrontierEdge(e)) continue;
1865
1875
  errorsFromCallers += e.signal?.errorCount ?? 0;
1866
1876
  inboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1867
1877
  if (e.provenance === import_types.Provenance.STALE) stale = true;
@@ -1875,6 +1885,7 @@ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
1875
1885
  for (const edgeId of graph.outboundEdges(n)) {
1876
1886
  const e = graph.getEdgeAttributes(edgeId);
1877
1887
  if (e.type === import_types.EdgeType.CONTAINS) continue;
1888
+ if (isFrontierEdge(e)) continue;
1878
1889
  outboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1879
1890
  if (e.type === import_types.EdgeType.CALLS) outboundErrors += e.signal?.errorCount ?? 0;
1880
1891
  if (e.provenance === import_types.Provenance.STALE) stale = true;
@@ -2158,22 +2169,44 @@ function routeMatches(declared, incident) {
2158
2169
  const b = normalizeRoute(incident);
2159
2170
  return a === b || a.startsWith(b) || b.startsWith(a);
2160
2171
  }
2161
- function structuralUpstreamPointer(graph, boundaryNode, incidents) {
2162
- const route = boundaryIncidentRoute(boundaryNode, incidents);
2163
- const callees = declaredOutboundCallees(graph, boundaryNode);
2172
+ function firstDeclaredCallee(graph, node, route) {
2173
+ const callees = declaredOutboundCallees(graph, node);
2164
2174
  if (callees.length === 0) return null;
2165
2175
  const narrowed = route !== void 0 ? callees.filter((c) => c.route !== void 0 && routeMatches(c.route, route)) : callees;
2166
2176
  const chosen = narrowed.length === 1 ? narrowed[0] : callees.length === 1 ? callees[0] : null;
2167
- if (!chosen) return null;
2168
- let node = chosen.target;
2169
- const seen = /* @__PURE__ */ new Set([boundaryNode, node]);
2170
- for (let depth = 0; depth < ROOT_CAUSE_MAX_DEPTH; depth++) {
2171
- const next = declaredOutboundCallees(graph, node);
2172
- if (next.length !== 1 || seen.has(next[0].target)) break;
2173
- node = next[0].target;
2174
- seen.add(node);
2177
+ return chosen ? chosen.target : null;
2178
+ }
2179
+ function observedOutboundNextServices(graph, nodeId) {
2180
+ const targets = /* @__PURE__ */ new Set();
2181
+ for (const n of nodeScope(graph, nodeId)) {
2182
+ if (!graph.hasNode(n)) continue;
2183
+ for (const edgeId of graph.outboundEdges(n)) {
2184
+ const e = graph.getEdgeAttributes(edgeId);
2185
+ if (!DEP_EDGE_TYPES.has(e.type)) continue;
2186
+ if (e.provenance !== import_types.Provenance.OBSERVED) continue;
2187
+ if (e.target !== nodeId) targets.add(e.target);
2188
+ }
2189
+ }
2190
+ return [...targets];
2191
+ }
2192
+ function resolveHangHop(graph, boundaryNode, incidents) {
2193
+ const route = boundaryIncidentRoute(boundaryNode, incidents);
2194
+ const own = firstDeclaredCallee(graph, boundaryNode, route);
2195
+ if (own) {
2196
+ return route !== void 0 ? { source: boundaryNode, target: own, route } : { source: boundaryNode, target: own };
2197
+ }
2198
+ const resolved = observedOutboundNextServices(graph, boundaryNode).map((n) => ({ source: n, target: firstDeclaredCallee(graph, n, route) })).filter((r) => r.target !== null);
2199
+ if (resolved.length === 1) {
2200
+ const hop = resolved[0];
2201
+ return route !== void 0 ? { ...hop, route } : hop;
2175
2202
  }
2176
- return route !== void 0 ? { node, route } : { node };
2203
+ return null;
2204
+ }
2205
+ function stagedHangProposal(graph, boundaryNode, incidents) {
2206
+ const hop = resolveHangHop(graph, boundaryNode, incidents);
2207
+ if (!hop) return null;
2208
+ if (!graph.hasEdge((0, import_types.frontierEdgeId)(hop.source, hop.target, import_types.EdgeType.CALLS))) return null;
2209
+ return hop.route !== void 0 ? { node: hop.target, route: hop.route } : { node: hop.target };
2177
2210
  }
2178
2211
  function enrichWithNavigation(graph, errorNodeId, tagged, incidents, now) {
2179
2212
  const legacy = tagged.result;
@@ -2219,28 +2252,28 @@ function enrichWithNavigation(graph, errorNodeId, tagged, incidents, now) {
2219
2252
  provenance: import_types.Provenance.OBSERVED
2220
2253
  });
2221
2254
  } else if (seedCtx && isBoundaryTimeoutSymptom(seedCtx)) {
2222
- const pointer = structuralUpstreamPointer(graph, seedNode, incidents);
2223
2255
  const seedName = displayNameOf(seedNode);
2224
- const routeNote = pointer?.route ? ` serving ${pointer.route}` : "";
2225
- if (pointer) {
2226
- const causeName = displayNameOf(pointer.node);
2227
- candidates.push({
2228
- node: pointer.node,
2229
- classification: "primary-failure",
2230
- 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.`,
2231
- context: nodeContext(graph, pointer.node, incidents, now),
2232
- confidence: Math.min(legacy.confidence, PROVENANCE_CEILING.EXTRACTED),
2233
- provenance: import_types.Provenance.INFERRED
2234
- });
2235
- }
2256
+ const proposal = stagedHangProposal(graph, seedNode, incidents);
2257
+ const routeNote = proposal?.route ? ` serving ${proposal.route}` : "";
2236
2258
  candidates.push({
2237
2259
  node: seedNode,
2238
2260
  classification: "symptom-only",
2239
- 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.`,
2261
+ 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.`,
2240
2262
  context: seedCtx,
2241
2263
  confidence: Math.min(legacy.confidence, 0.3),
2242
2264
  ...lastProv ? { provenance: lastProv } : {}
2243
2265
  });
2266
+ if (proposal) {
2267
+ const causeName = displayNameOf(proposal.node);
2268
+ candidates.push({
2269
+ node: proposal.node,
2270
+ classification: "primary-failure",
2271
+ 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.`,
2272
+ context: nodeContext(graph, proposal.node, incidents, now),
2273
+ confidence: Math.min(legacy.confidence, PROVENANCE_CEILING.FRONTIER),
2274
+ provenance: import_types.Provenance.FRONTIER
2275
+ });
2276
+ }
2244
2277
  } else if (staleChain) {
2245
2278
  const culprit = staleChain.culprit;
2246
2279
  const culpritName = displayNameOf(culprit);
@@ -2304,6 +2337,9 @@ function fixRecommendationForTop(top, seedNode, legacy) {
2304
2337
  return legacy.fixRecommendation;
2305
2338
  }
2306
2339
  const name = top.node.replace(/^service:/, "");
2340
+ if (top.classification === "symptom-only" && top.context.boundaryTimeout === true) {
2341
+ 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.`;
2342
+ }
2307
2343
  if (top.provenance === import_types.Provenance.STALE) {
2308
2344
  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}.`;
2309
2345
  }
@@ -6969,6 +7005,32 @@ function promoteFrontierNodes(graph, opts = {}) {
6969
7005
  }
6970
7006
  return promoted;
6971
7007
  }
7008
+ function stageFrontierEdge(graph, source, target, type) {
7009
+ if (!graph.hasNode(source) || !graph.hasNode(target)) return false;
7010
+ if (graph.hasEdge((0, import_types8.observedEdgeId)(source, target, type))) return false;
7011
+ const key = (0, import_types8.frontierEdgeId)(source, target, type);
7012
+ if (graph.hasEdge(key)) return false;
7013
+ graph.addEdgeWithKey(key, source, target, {
7014
+ id: key,
7015
+ source,
7016
+ target,
7017
+ type,
7018
+ provenance: import_types8.Provenance.FRONTIER
7019
+ });
7020
+ return true;
7021
+ }
7022
+ function promoteFrontierEdges(graph) {
7023
+ const graduated = [];
7024
+ graph.forEachEdge((edgeId, attrs) => {
7025
+ const e = attrs;
7026
+ if (e.provenance !== import_types8.Provenance.FRONTIER) return;
7027
+ if (graph.hasEdge((0, import_types8.observedEdgeId)(e.source, e.target, e.type))) {
7028
+ graduated.push(edgeId);
7029
+ }
7030
+ });
7031
+ for (const edgeId of graduated) graph.dropEdge(edgeId);
7032
+ return graduated.length;
7033
+ }
6972
7034
  function rewireFrontierEdges(graph, frontierId2, serviceId17) {
6973
7035
  const inbound = [...graph.inboundEdges(frontierId2)];
6974
7036
  const outbound = [...graph.outboundEdges(frontierId2)];
@@ -7074,6 +7136,7 @@ function startStalenessLoop(graph, options = {}) {
7074
7136
  project: options.project
7075
7137
  });
7076
7138
  if (options.onPolicyTrigger) await options.onPolicyTrigger(graph);
7139
+ if (options.onReconcile) await options.onReconcile(graph);
7077
7140
  } catch (err) {
7078
7141
  console.error("staleness tick failed", err);
7079
7142
  }
@@ -23203,6 +23266,36 @@ async function buildApi(opts) {
23203
23266
  // src/daemon.ts
23204
23267
  init_otel();
23205
23268
 
23269
+ // src/hang-sensor.ts
23270
+ init_cjs_shims();
23271
+ var import_types101 = require("@neat.is/types");
23272
+ var RECONCILE_INCIDENT_LIMIT = 500;
23273
+ function stageHangSurfaces(graph, incidents) {
23274
+ const boundaries = /* @__PURE__ */ new Set();
23275
+ for (const ev of incidents) {
23276
+ if (ev.affectedNode && graph.hasNode(ev.affectedNode)) boundaries.add(ev.affectedNode);
23277
+ }
23278
+ let staged = 0;
23279
+ for (const boundary of boundaries) {
23280
+ const ctx = nodeContext(graph, boundary, incidents);
23281
+ if (!isBoundaryTimeoutSymptom(ctx)) continue;
23282
+ const hop = resolveHangHop(graph, boundary, incidents);
23283
+ if (!hop) continue;
23284
+ if (stageFrontierEdge(graph, hop.source, hop.target, import_types101.EdgeType.CALLS)) staged++;
23285
+ }
23286
+ return staged;
23287
+ }
23288
+ async function reconcileFrontierSurfaces(graph, errorsPath) {
23289
+ promoteFrontierEdges(graph);
23290
+ let incidents = [];
23291
+ try {
23292
+ incidents = await readErrorEvents(errorsPath, { limit: RECONCILE_INCIDENT_LIMIT });
23293
+ } catch {
23294
+ incidents = [];
23295
+ }
23296
+ stageHangSurfaces(graph, incidents);
23297
+ }
23298
+
23206
23299
  // src/connectors/kubernetes/index.ts
23207
23300
  init_cjs_shims();
23208
23301
 
@@ -23552,7 +23645,7 @@ function mapWorkloadsToSignals(deployments, pods, config) {
23552
23645
 
23553
23646
  // src/connectors/kubernetes/resolve.ts
23554
23647
  init_cjs_shims();
23555
- var import_types102 = require("@neat.is/types");
23648
+ var import_types103 = require("@neat.is/types");
23556
23649
  var NO_ENV3 = "unknown";
23557
23650
  function createK8sResolveTarget(graph) {
23558
23651
  return (signal) => {
@@ -23563,7 +23656,7 @@ function createK8sResolveTarget(graph) {
23563
23656
  return {
23564
23657
  targetNodeId: resolveFusedServiceId(graph, serviceName, NO_ENV3),
23565
23658
  serviceName,
23566
- edgeType: import_types102.EdgeType.CALLS
23659
+ edgeType: import_types103.EdgeType.CALLS
23567
23660
  };
23568
23661
  };
23569
23662
  }
@@ -23698,7 +23791,7 @@ function unroutedErrorsPath(neatHome4) {
23698
23791
  }
23699
23792
 
23700
23793
  // src/daemon.ts
23701
- var import_types105 = require("@neat.is/types");
23794
+ var import_types106 = require("@neat.is/types");
23702
23795
  function daemonJsonPath(scanPath) {
23703
23796
  return import_node_path77.default.join(scanPath, "neat-out", "daemon.json");
23704
23797
  }
@@ -23841,7 +23934,7 @@ function spanBelongsToSingleProject(graph, project, serviceName) {
23841
23934
  if (!serviceName) return true;
23842
23935
  if (serviceNameMatchesProject(serviceName, project)) return true;
23843
23936
  return graph.someNode(
23844
- (_id, attrs) => attrs.type === import_types105.NodeType.ServiceNode && attrs.name === serviceName
23937
+ (_id, attrs) => attrs.type === import_types106.NodeType.ServiceNode && attrs.name === serviceName
23845
23938
  );
23846
23939
  }
23847
23940
  async function bootstrapProject(entry2, connectors = [], neatHome4) {
@@ -23885,7 +23978,11 @@ async function bootstrapProject(entry2, connectors = [], neatHome4) {
23885
23978
  const stopPersist = startPersistLoop(graph, outPath, { exitOnSignal: false });
23886
23979
  const stopStaleness = startStalenessLoop(graph, {
23887
23980
  staleEventsPath: paths.staleEventsPath,
23888
- project: entry2.name
23981
+ project: entry2.name,
23982
+ // Graduate FRONTIER surfaces whose OBSERVED twin has arrived + stage surfaces
23983
+ // for current hangs (ADR-226), each post-ingest tick — the periodic point
23984
+ // where OBSERVED state has settled and the errors ledger is current.
23985
+ onReconcile: (g) => reconcileFrontierSurfaces(g, paths.errorsPath)
23889
23986
  });
23890
23987
  const stopConnectors = await startConnectorPolling({
23891
23988
  project: entry2.name,