@neat.is/core 0.7.10 → 0.8.0

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.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import {
3
3
  reconcileDaemonRecordSync,
4
4
  startDaemon
5
- } from "./chunk-D4OX6MUX.js";
5
+ } from "./chunk-OJ6BW63H.js";
6
6
  import {
7
7
  listProjects,
8
8
  registryPath
9
- } from "./chunk-6T7ZHODF.js";
9
+ } from "./chunk-3PXRQH53.js";
10
10
  import {
11
11
  BindAuthorityError,
12
12
  __require
package/dist/server.cjs CHANGED
@@ -4323,6 +4323,63 @@ async function addRoutes(graph, services) {
4323
4323
  return { nodesAdded, edgesAdded };
4324
4324
  }
4325
4325
 
4326
+ // src/latency-digest.ts
4327
+ init_cjs_shims();
4328
+ var SUB = 16;
4329
+ var MIN_EXP = -10;
4330
+ var MAX_EXP = 22;
4331
+ var OCTAVES = MAX_EXP - MIN_EXP + 1;
4332
+ var OVERFLOW_INDEX = OCTAVES * SUB + 1;
4333
+ function latencyBucketIndex(ms) {
4334
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
4335
+ const e = Math.floor(Math.log2(ms));
4336
+ if (e < MIN_EXP) return 0;
4337
+ if (e > MAX_EXP) return OVERFLOW_INDEX;
4338
+ const base = 2 ** e;
4339
+ const raw = Math.floor((ms / base - 1) * SUB);
4340
+ const s = raw < 0 ? 0 : raw >= SUB ? SUB - 1 : raw;
4341
+ return (e - MIN_EXP) * SUB + s + 1;
4342
+ }
4343
+ function bucketRepresentativeMs(index) {
4344
+ if (index <= 0) return 0;
4345
+ if (index >= OVERFLOW_INDEX) return 2 ** (MAX_EXP + 1);
4346
+ const zeroBased = index - 1;
4347
+ const e = MIN_EXP + Math.floor(zeroBased / SUB);
4348
+ const s = zeroBased % SUB;
4349
+ const base = 2 ** e;
4350
+ const lower = base * (1 + s / SUB);
4351
+ const upper = base * (1 + (s + 1) / SUB);
4352
+ return (lower + upper) / 2;
4353
+ }
4354
+ function recordLatency(hist, ms) {
4355
+ const key = String(latencyBucketIndex(ms));
4356
+ hist[key] = (hist[key] ?? 0) + 1;
4357
+ return hist;
4358
+ }
4359
+ function quantile(hist, q) {
4360
+ const entries = Object.entries(hist).map(([k, c]) => [Number(k), c]).filter(([idx, c]) => Number.isFinite(idx) && c > 0).sort((a, b) => a[0] - b[0]);
4361
+ let total = 0;
4362
+ for (const [, c] of entries) total += c;
4363
+ if (total === 0) return 0;
4364
+ const rank = Math.max(1, Math.ceil(q * total));
4365
+ let cumulative = 0;
4366
+ for (const [idx, c] of entries) {
4367
+ cumulative += c;
4368
+ if (cumulative >= rank) return bucketRepresentativeMs(idx);
4369
+ }
4370
+ return bucketRepresentativeMs(entries[entries.length - 1][0]);
4371
+ }
4372
+ function round(ms) {
4373
+ return Math.round(ms * 100) / 100;
4374
+ }
4375
+ function latencyPercentiles(hist) {
4376
+ if (!hist) return void 0;
4377
+ let total = 0;
4378
+ for (const c of Object.values(hist)) total += c;
4379
+ if (total === 0) return void 0;
4380
+ return { p50: round(quantile(hist, 0.5)), p95: round(quantile(hist, 0.95)) };
4381
+ }
4382
+
4326
4383
  // src/ingest.ts
4327
4384
  var HOUR_MS = 60 * 60 * 1e3;
4328
4385
  var DAY_MS = 24 * HOUR_MS;
@@ -5018,7 +5075,7 @@ function ensureFrontierNode(graph, host, ts) {
5018
5075
  graph.addNode(id, node);
5019
5076
  return id;
5020
5077
  }
5021
- function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence) {
5078
+ function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence, durationMs) {
5022
5079
  if (!graph.hasNode(source) || !graph.hasNode(target)) return null;
5023
5080
  const grain = source.startsWith("file:") || source.startsWith("symbol:") ? "file" : "service";
5024
5081
  const id = makeObservedEdgeId(type, source, target);
@@ -5026,10 +5083,15 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
5026
5083
  const existing = graph.getEdgeAttributes(id);
5027
5084
  const newSpanCount = (existing.signal?.spanCount ?? existing.callCount ?? 0) + 1;
5028
5085
  const newErrorCount = (existing.signal?.errorCount ?? 0) + (isError ? 1 : 0);
5086
+ const latencyHist2 = durationMs !== void 0 ? recordLatency({ ...existing.signal?.latencyHist ?? {} }, durationMs) : existing.signal?.latencyHist;
5087
+ const latencyMs2 = latencyPercentiles(latencyHist2) ?? existing.signal?.latencyMs;
5029
5088
  const newSignal = {
5030
5089
  spanCount: newSpanCount,
5031
5090
  errorCount: newErrorCount,
5032
- lastObservedAgeMs: 0
5091
+ lastObservedAgeMs: 0,
5092
+ ...latencyHist2 ? { latencyHist: latencyHist2 } : {},
5093
+ ...latencyMs2 ? { latencyMs: latencyMs2 } : {},
5094
+ ...existing.signal?.anomalous !== void 0 ? { anomalous: existing.signal.anomalous } : {}
5033
5095
  };
5034
5096
  const updated = {
5035
5097
  ...existing,
@@ -5044,10 +5106,14 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
5044
5106
  graph.replaceEdgeAttributes(id, updated);
5045
5107
  return { edge: updated, created: false };
5046
5108
  }
5109
+ const latencyHist = durationMs !== void 0 ? recordLatency({}, durationMs) : void 0;
5110
+ const latencyMs = latencyHist ? latencyPercentiles(latencyHist) : void 0;
5047
5111
  const signal = {
5048
5112
  spanCount: 1,
5049
5113
  errorCount: isError ? 1 : 0,
5050
- lastObservedAgeMs: 0
5114
+ lastObservedAgeMs: 0,
5115
+ ...latencyHist ? { latencyHist } : {},
5116
+ ...latencyMs ? { latencyMs } : {}
5051
5117
  };
5052
5118
  const edge = {
5053
5119
  id,
@@ -5247,6 +5313,7 @@ async function handleSpan(ctx, span) {
5247
5313
  }
5248
5314
  const sourceId = ensureServiceNode(ctx.graph, span.service, env);
5249
5315
  const isError = span.statusCode === 2;
5316
+ const durationMs = span.durationNanos > 0n ? Number(span.durationNanos) / 1e6 : void 0;
5250
5317
  const sourceServiceNode = ctx.graph.getNodeAttributes(sourceId);
5251
5318
  const callSite = callSiteFromSpan(span, sourceServiceNode, ctx.scanPath);
5252
5319
  cacheSpanService(span, nowMs, callSite);
@@ -5285,7 +5352,8 @@ async function handleSpan(ctx, span) {
5285
5352
  targetId,
5286
5353
  ts,
5287
5354
  isError,
5288
- callSiteEvidence
5355
+ callSiteEvidence,
5356
+ durationMs
5289
5357
  );
5290
5358
  if (result) affectedNode = targetId;
5291
5359
  if (span.dbSystem === "mongodb" && span.dbCollection) {
@@ -5297,7 +5365,8 @@ async function handleSpan(ctx, span) {
5297
5365
  collectionId,
5298
5366
  ts,
5299
5367
  isError,
5300
- callSiteEvidence
5368
+ callSiteEvidence,
5369
+ durationMs
5301
5370
  );
5302
5371
  }
5303
5372
  if (span.dbTable) {
@@ -5309,7 +5378,8 @@ async function handleSpan(ctx, span) {
5309
5378
  tableId,
5310
5379
  ts,
5311
5380
  isError,
5312
- callSiteEvidence
5381
+ callSiteEvidence,
5382
+ durationMs
5313
5383
  );
5314
5384
  mergeObservedColumns(ctx.graph, tableId, span.dbColumns);
5315
5385
  }
@@ -5328,7 +5398,8 @@ async function handleSpan(ctx, span) {
5328
5398
  targetId,
5329
5399
  ts,
5330
5400
  isError,
5331
- callSiteEvidence
5401
+ callSiteEvidence,
5402
+ durationMs
5332
5403
  );
5333
5404
  if (result) affectedNode = targetId;
5334
5405
  } else if (span.graphqlOperationName && span.graphqlOperationType && spanServesGraphqlOperation(span.kind)) {
@@ -5345,7 +5416,8 @@ async function handleSpan(ctx, span) {
5345
5416
  targetId,
5346
5417
  ts,
5347
5418
  isError,
5348
- callSiteEvidence
5419
+ callSiteEvidence,
5420
+ durationMs
5349
5421
  );
5350
5422
  if (result) affectedNode = targetId;
5351
5423
  } else if (span.rpcSystem === "grpc" && span.rpcService && span.rpcMethod && spanServesGrpcMethod(span.kind)) {
@@ -5357,7 +5429,8 @@ async function handleSpan(ctx, span) {
5357
5429
  targetId,
5358
5430
  ts,
5359
5431
  isError,
5360
- callSiteEvidence
5432
+ callSiteEvidence,
5433
+ durationMs
5361
5434
  );
5362
5435
  if (result) affectedNode = targetId;
5363
5436
  } else if (span.websocketChannel && spanServesWebsocketChannel(span.kind)) {
@@ -5373,7 +5446,8 @@ async function handleSpan(ctx, span) {
5373
5446
  targetId,
5374
5447
  ts,
5375
5448
  isError,
5376
- callSiteEvidence
5449
+ callSiteEvidence,
5450
+ durationMs
5377
5451
  );
5378
5452
  if (result) affectedNode = targetId;
5379
5453
  } else {
@@ -5389,7 +5463,8 @@ async function handleSpan(ctx, span) {
5389
5463
  targetId,
5390
5464
  ts,
5391
5465
  isError,
5392
- callSiteEvidence
5466
+ callSiteEvidence,
5467
+ durationMs
5393
5468
  );
5394
5469
  affectedNode = targetId;
5395
5470
  resolvedViaAddress = true;
@@ -5402,7 +5477,8 @@ async function handleSpan(ctx, span) {
5402
5477
  frontierNodeId,
5403
5478
  ts,
5404
5479
  isError,
5405
- callSiteEvidence
5480
+ callSiteEvidence,
5481
+ durationMs
5406
5482
  );
5407
5483
  affectedNode = frontierNodeId;
5408
5484
  resolvedViaAddress = true;
@@ -5428,7 +5504,8 @@ async function handleSpan(ctx, span) {
5428
5504
  sourceId,
5429
5505
  ts,
5430
5506
  isError,
5431
- fallbackEvidence
5507
+ fallbackEvidence,
5508
+ durationMs
5432
5509
  );
5433
5510
  }
5434
5511
  }
@@ -5442,7 +5519,7 @@ async function handleSpan(ctx, span) {
5442
5519
  );
5443
5520
  if (routeNodeId) {
5444
5521
  const routeSvc = ctx.graph.getNodeAttributes(routeNodeId).service;
5445
- upsertObservedEdge(ctx.graph, import_types7.EdgeType.CONTAINS, (0, import_types7.serviceId)(routeSvc), routeNodeId, ts, isError);
5522
+ upsertObservedEdge(ctx.graph, import_types7.EdgeType.CONTAINS, (0, import_types7.serviceId)(routeSvc), routeNodeId, ts, isError, void 0, durationMs);
5446
5523
  }
5447
5524
  }
5448
5525
  if (span.statusCode === 2) {
@@ -5942,7 +6019,7 @@ var rootCauseShapes = {
5942
6019
  [import_types8.NodeType.FileNode]: fileRootCauseShape,
5943
6020
  [import_types8.NodeType.SymbolNode]: symbolRootCauseShape
5944
6021
  };
5945
- function getRootCause(graph, errorNodeId, errorEvent, incidents) {
6022
+ function legacyRootCause(graph, errorNodeId, errorEvent, incidents) {
5946
6023
  if (!graph.hasNode(errorNodeId)) return null;
5947
6024
  const origin = graph.getNodeAttributes(errorNodeId);
5948
6025
  const shape = rootCauseShapes[origin.type];
@@ -6199,7 +6276,9 @@ function getObservedDependencies(graph, nodeId) {
6199
6276
  dependencies: [],
6200
6277
  observed: false,
6201
6278
  inboundObservedCount: 0,
6202
- hasExtractedOutbound: false
6279
+ hasExtractedOutbound: false,
6280
+ inboundVolume: 0,
6281
+ window: "lifetime"
6203
6282
  });
6204
6283
  }
6205
6284
  const attrs = graph.getNodeAttributes(nodeId);
@@ -6230,11 +6309,19 @@ function getObservedDependencies(graph, nodeId) {
6230
6309
  }
6231
6310
  }
6232
6311
  let inboundObservedCount = 0;
6312
+ let inboundVolume = 0;
6313
+ let inboundLastObserved;
6233
6314
  for (const tgt of scope) {
6234
6315
  for (const edgeId of graph.inboundEdges(tgt)) {
6235
6316
  const e = graph.getEdgeAttributes(edgeId);
6236
6317
  if (e.type === import_types8.EdgeType.CONTAINS) continue;
6237
- if (e.provenance === import_types8.Provenance.OBSERVED) inboundObservedCount += 1;
6318
+ if (e.provenance === import_types8.Provenance.OBSERVED) {
6319
+ inboundObservedCount += 1;
6320
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 1;
6321
+ if (e.lastObserved && (!inboundLastObserved || e.lastObserved > inboundLastObserved)) {
6322
+ inboundLastObserved = e.lastObserved;
6323
+ }
6324
+ }
6238
6325
  }
6239
6326
  }
6240
6327
  dependencies.sort(
@@ -6245,7 +6332,291 @@ function getObservedDependencies(graph, nodeId) {
6245
6332
  dependencies,
6246
6333
  observed: dependencies.length > 0 || inboundObservedCount > 0,
6247
6334
  inboundObservedCount,
6248
- hasExtractedOutbound
6335
+ hasExtractedOutbound,
6336
+ // The signal is cumulative, so the honest window label is "lifetime" (ADR-190).
6337
+ inboundVolume,
6338
+ window: "lifetime",
6339
+ ...inboundLastObserved ? { inboundLastObserved } : {}
6340
+ });
6341
+ }
6342
+ var SATURATION_P95_MS = 1e3;
6343
+ function nodeScope(graph, nodeId) {
6344
+ const scope = [nodeId];
6345
+ if (!graph.hasNode(nodeId)) return scope;
6346
+ const attrs = graph.getNodeAttributes(nodeId);
6347
+ if (attrs.type === import_types8.NodeType.ServiceNode) {
6348
+ for (const edgeId of graph.outboundEdges(nodeId)) {
6349
+ const e = graph.getEdgeAttributes(edgeId);
6350
+ if (e.type !== import_types8.EdgeType.CONTAINS) continue;
6351
+ const owned = graph.getNodeAttributes(e.target);
6352
+ if (owned.type === import_types8.NodeType.FileNode) scope.push(e.target);
6353
+ }
6354
+ }
6355
+ return scope;
6356
+ }
6357
+ function incidentCountForNode(nodeId, incidents) {
6358
+ if (!incidents || incidents.length === 0) return 0;
6359
+ return incidents.filter((ev) => incidentMatchesNode(ev, nodeId)).length;
6360
+ }
6361
+ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
6362
+ const scope = nodeScope(graph, nodeId);
6363
+ let errorsFromCallers = 0;
6364
+ let inboundVolume = 0;
6365
+ let outboundVolume = 0;
6366
+ let outboundErrors = 0;
6367
+ let latestInboundMs;
6368
+ let latencyP95Ms;
6369
+ let stale = false;
6370
+ for (const n of scope) {
6371
+ if (!graph.hasNode(n)) continue;
6372
+ for (const edgeId of graph.inboundEdges(n)) {
6373
+ const e = graph.getEdgeAttributes(edgeId);
6374
+ if (e.type === import_types8.EdgeType.CONTAINS) continue;
6375
+ errorsFromCallers += e.signal?.errorCount ?? 0;
6376
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
6377
+ if (e.provenance === import_types8.Provenance.STALE) stale = true;
6378
+ const p95 = e.signal?.latencyMs?.p95;
6379
+ if (p95 !== void 0) latencyP95Ms = Math.max(latencyP95Ms ?? 0, p95);
6380
+ if (e.lastObserved) {
6381
+ const t = Date.parse(e.lastObserved);
6382
+ if (Number.isFinite(t)) latestInboundMs = Math.max(latestInboundMs ?? 0, t);
6383
+ }
6384
+ }
6385
+ for (const edgeId of graph.outboundEdges(n)) {
6386
+ const e = graph.getEdgeAttributes(edgeId);
6387
+ if (e.type === import_types8.EdgeType.CONTAINS) continue;
6388
+ outboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
6389
+ if (e.type === import_types8.EdgeType.CALLS) outboundErrors += e.signal?.errorCount ?? 0;
6390
+ if (e.provenance === import_types8.Provenance.STALE) stale = true;
6391
+ }
6392
+ }
6393
+ const errorsEmittedHere = incidentCountForNode(nodeId, incidents) + outboundErrors;
6394
+ const lastObservedAgeMs = latestInboundMs !== void 0 ? Math.max(0, now - latestInboundMs) : void 0;
6395
+ return {
6396
+ errorsEmittedHere,
6397
+ errorsFromCallers,
6398
+ callCount: inboundVolume,
6399
+ outboundVolume,
6400
+ ...lastObservedAgeMs !== void 0 ? { lastObservedAgeMs } : {},
6401
+ ...latencyP95Ms !== void 0 ? { latencyP95Ms } : {},
6402
+ stale
6403
+ };
6404
+ }
6405
+ function isSaturated(ctx) {
6406
+ return ctx.latencyP95Ms !== void 0 && ctx.latencyP95Ms >= SATURATION_P95_MS;
6407
+ }
6408
+ function classifyNode(ctx) {
6409
+ if (ctx.errorsEmittedHere > 0) {
6410
+ if ((ctx.stale || isSaturated(ctx)) && ctx.errorsFromCallers >= ctx.errorsEmittedHere) {
6411
+ return "symptom-only";
6412
+ }
6413
+ return "primary-failure";
6414
+ }
6415
+ if (ctx.errorsFromCallers > 0) return "symptom-only";
6416
+ return "unrelated";
6417
+ }
6418
+ function isVictimSeed(ctx) {
6419
+ return ctx.errorsFromCallers > 0 && (ctx.stale || isSaturated(ctx)) && ctx.errorsEmittedHere <= ctx.errorsFromCallers;
6420
+ }
6421
+ function grainOf(graph, nodeId) {
6422
+ if (!graph.hasNode(nodeId)) return "unknown";
6423
+ const t = graph.getNodeAttributes(nodeId).type;
6424
+ if (t === import_types8.NodeType.ServiceNode) return "service";
6425
+ if (t === import_types8.NodeType.FileNode) return "file";
6426
+ if (t === import_types8.NodeType.SymbolNode) return "symbol";
6427
+ return t;
6428
+ }
6429
+ function findPath(graph, from, to, direction, maxDepth) {
6430
+ if (!graph.hasNode(from) || !graph.hasNode(to)) return null;
6431
+ if (from === to) return { nodes: [from], edges: [] };
6432
+ const queue = [{ nodeId: from, depth: 0, nodes: [from], edges: [] }];
6433
+ const enqueued = /* @__PURE__ */ new Set([from]);
6434
+ while (queue.length > 0) {
6435
+ const frame = queue.shift();
6436
+ if (frame.depth >= maxDepth) continue;
6437
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(frame.nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(frame.nodeId));
6438
+ const neighbours = [...best.entries()].sort((a, b) => a[0].localeCompare(b[0]));
6439
+ for (const [nid, edge] of neighbours) {
6440
+ if (nid === to) return { nodes: [...frame.nodes, nid], edges: [...frame.edges, edge] };
6441
+ if (enqueued.has(nid)) continue;
6442
+ enqueued.add(nid);
6443
+ queue.push({
6444
+ nodeId: nid,
6445
+ depth: frame.depth + 1,
6446
+ nodes: [...frame.nodes, nid],
6447
+ edges: [...frame.edges, edge]
6448
+ });
6449
+ }
6450
+ }
6451
+ return null;
6452
+ }
6453
+ var EMPTY_CONTEXT = {
6454
+ errorsEmittedHere: 0,
6455
+ errorsFromCallers: 0,
6456
+ callCount: 0,
6457
+ outboundVolume: 0,
6458
+ stale: false
6459
+ };
6460
+ function expandNode(graph, nodeId, direction, incidents, now = Date.now()) {
6461
+ if (!graph.hasNode(nodeId)) {
6462
+ return import_types8.ExpandResultSchema.parse({
6463
+ origin: nodeId,
6464
+ direction,
6465
+ node: { id: nodeId, classification: "unrelated", context: EMPTY_CONTEXT },
6466
+ neighbours: []
6467
+ });
6468
+ }
6469
+ const ctx = nodeContext(graph, nodeId, incidents, now);
6470
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(nodeId));
6471
+ const neighbours = [];
6472
+ for (const [nid, edge] of best) {
6473
+ if (edge.type === import_types8.EdgeType.CONTAINS) continue;
6474
+ const nctx = nodeContext(graph, nid, incidents, now);
6475
+ neighbours.push({
6476
+ node: nid,
6477
+ edgeType: edge.type,
6478
+ provenance: edge.provenance,
6479
+ classification: classifyNode(nctx),
6480
+ context: nctx
6481
+ });
6482
+ }
6483
+ neighbours.sort((a, b) => a.node.localeCompare(b.node));
6484
+ return import_types8.ExpandResultSchema.parse({
6485
+ origin: nodeId,
6486
+ direction,
6487
+ node: { id: nodeId, classification: classifyNode(ctx), context: ctx },
6488
+ neighbours
6489
+ });
6490
+ }
6491
+ function relate(graph, a, b, maxDepth = ROOT_CAUSE_MAX_DEPTH) {
6492
+ const buildPath = (fp) => ({
6493
+ nodes: fp.nodes,
6494
+ edgeTypes: fp.edges.map((e) => e.type),
6495
+ provenance: fp.edges.map((e) => e.provenance),
6496
+ grain: fp.nodes.map((n) => grainOf(graph, n)),
6497
+ // The failure runs end to end when every hop carries error / latency / alert
6498
+ // signal — that is what turns reachability into cause-confirmation.
6499
+ carriesSignal: fp.edges.length > 0 && fp.edges.every(
6500
+ (e) => (e.signal?.errorCount ?? 0) > 0 || e.signal?.latencyMs !== void 0 || e.signal?.anomalous !== void 0
6501
+ )
6502
+ });
6503
+ if (!graph.hasNode(a) || !graph.hasNode(b)) {
6504
+ return import_types8.RelateResultSchema.parse({
6505
+ a,
6506
+ b,
6507
+ related: false,
6508
+ direction: null,
6509
+ paths: [],
6510
+ note: !graph.hasNode(a) ? `node not found: ${a}` : `node not found: ${b}`
6511
+ });
6512
+ }
6513
+ const down = findPath(graph, a, b, "down", maxDepth);
6514
+ const up = findPath(graph, a, b, "up", maxDepth);
6515
+ if (!down && !up) {
6516
+ return import_types8.RelateResultSchema.parse({
6517
+ a,
6518
+ b,
6519
+ related: false,
6520
+ direction: null,
6521
+ paths: [],
6522
+ note: `no path within ${maxDepth} hops`
6523
+ });
6524
+ }
6525
+ const paths = [];
6526
+ const direction = down ? "a->b" : "b->a";
6527
+ if (down) paths.push(buildPath(down));
6528
+ if (up) paths.push(buildPath(up));
6529
+ const endpointsFine = grainOf(graph, a) !== "service" && grainOf(graph, b) !== "service";
6530
+ const grainGap = endpointsFine && paths[0].grain.some((g) => g === "service");
6531
+ return import_types8.RelateResultSchema.parse({
6532
+ a,
6533
+ b,
6534
+ related: true,
6535
+ direction,
6536
+ paths,
6537
+ ...grainGap ? { grainGap: true } : {}
6538
+ });
6539
+ }
6540
+ function findLoadOrigin(graph, alertNodeId, incidents, now) {
6541
+ const upstream = getBlastRadius(graph, alertNodeId).affectedNodes.map((n) => n.nodeId);
6542
+ let best = null;
6543
+ for (const nid of upstream) {
6544
+ if (nid === alertNodeId) continue;
6545
+ const ctx = nodeContext(graph, nid, incidents, now);
6546
+ if (ctx.outboundVolume === 0) continue;
6547
+ const isSource = ctx.callCount === 0;
6548
+ const better = !best || (isSource !== best.isSource ? isSource : ctx.outboundVolume !== best.ctx.outboundVolume ? ctx.outboundVolume > best.ctx.outboundVolume : nid < best.node);
6549
+ if (better) best = { node: nid, ctx, isSource };
6550
+ }
6551
+ return best ? { node: best.node, ctx: best.ctx } : null;
6552
+ }
6553
+ function getRootCause(graph, errorNodeId, errorEvent, incidents, opts) {
6554
+ const legacy = legacyRootCause(graph, errorNodeId, errorEvent, incidents);
6555
+ if (!legacy) return null;
6556
+ const navigation = opts?.navigation ?? process.env.NEAT_RCA_NAVIGATION !== "0";
6557
+ if (!navigation) return legacy;
6558
+ return enrichWithNavigation(graph, errorNodeId, legacy, incidents, opts?.now ?? Date.now());
6559
+ }
6560
+ function enrichWithNavigation(graph, errorNodeId, legacy, incidents, now) {
6561
+ const seedNode = legacy.rootCauseNode;
6562
+ const seedCtx = graph.hasNode(seedNode) ? nodeContext(graph, seedNode, incidents, now) : null;
6563
+ const lastProv = legacy.edgeProvenances[legacy.edgeProvenances.length - 1];
6564
+ const candidates = [];
6565
+ if (seedCtx && isVictimSeed(seedCtx)) {
6566
+ const origin = findLoadOrigin(graph, errorNodeId, incidents, now);
6567
+ if (origin) {
6568
+ const path68 = findPath(graph, errorNodeId, origin.node, "up", ROOT_CAUSE_MAX_DEPTH);
6569
+ const originConfidence = confidenceFromMix(path68?.edges ?? [], now);
6570
+ candidates.push({
6571
+ node: origin.node,
6572
+ classification: "primary-failure",
6573
+ reason: `Highest-volume upstream source (${origin.ctx.outboundVolume} observed outbound calls) driving a saturated/stale subgraph; the alerting path decays downstream into a starved victim rather than a fault at the callee.`,
6574
+ context: origin.ctx,
6575
+ confidence: Math.max(0.3, Math.min(0.8, originConfidence || 0.5)),
6576
+ provenance: import_types8.Provenance.OBSERVED
6577
+ });
6578
+ }
6579
+ const staleNote = seedCtx.stale ? "; the node has gone STALE" : "";
6580
+ const satNote = isSaturated(seedCtx) ? `; inbound p95 ${Math.round(seedCtx.latencyP95Ms)}ms is saturated` : "";
6581
+ candidates.push({
6582
+ node: seedNode,
6583
+ classification: "symptom-only",
6584
+ reason: `Errors arrive from callers (${seedCtx.errorsFromCallers}) but none originate here${staleNote}${satNote} \u2014 a downstream victim of load, not the fault.`,
6585
+ context: seedCtx,
6586
+ confidence: Math.min(legacy.confidence, 0.4),
6587
+ ...lastProv ? { provenance: lastProv } : {}
6588
+ });
6589
+ } else {
6590
+ candidates.push({
6591
+ node: seedNode,
6592
+ classification: "primary-failure",
6593
+ reason: legacy.rootCauseReason,
6594
+ context: seedCtx ?? EMPTY_CONTEXT,
6595
+ confidence: legacy.confidence,
6596
+ ...lastProv ? { provenance: lastProv } : {}
6597
+ });
6598
+ }
6599
+ const top = candidates[0];
6600
+ let traversalPath = legacy.traversalPath;
6601
+ let edgeProvenances = legacy.edgeProvenances;
6602
+ if (top.node !== seedNode) {
6603
+ const path68 = findPath(graph, errorNodeId, top.node, "up", ROOT_CAUSE_MAX_DEPTH);
6604
+ if (path68) {
6605
+ traversalPath = path68.nodes;
6606
+ edgeProvenances = path68.edges.map((e) => e.provenance);
6607
+ } else {
6608
+ traversalPath = [errorNodeId, top.node];
6609
+ edgeProvenances = [top.provenance ?? import_types8.Provenance.OBSERVED];
6610
+ }
6611
+ }
6612
+ return import_types8.RootCauseResultSchema.parse({
6613
+ rootCauseNode: top.node,
6614
+ rootCauseReason: top.reason,
6615
+ traversalPath,
6616
+ edgeProvenances,
6617
+ confidence: top.confidence,
6618
+ ...legacy.fixRecommendation ? { fixRecommendation: legacy.fixRecommendation } : {},
6619
+ candidates
6249
6620
  });
6250
6621
  }
6251
6622
 
@@ -18039,6 +18410,34 @@ function registerRoutes(scope, ctx) {
18039
18410
  }
18040
18411
  return getBlastRadius(proj.graph, nodeId, depth);
18041
18412
  });
18413
+ scope.get("/graph/expand/:nodeId", async (req, reply) => {
18414
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18415
+ if (!proj) return;
18416
+ const { nodeId } = req.params;
18417
+ if (!proj.graph.hasNode(nodeId)) {
18418
+ return reply.code(404).send({ error: "node not found", id: nodeId });
18419
+ }
18420
+ const direction = req.query.direction;
18421
+ if (direction !== "up" && direction !== "down") {
18422
+ return reply.code(400).send({ error: 'direction must be "up" or "down"' });
18423
+ }
18424
+ const epath = errorsPathFor(proj);
18425
+ const incidents = epath ? await readErrorEvents(epath) : [];
18426
+ return expandNode(proj.graph, nodeId, direction, incidents);
18427
+ });
18428
+ scope.get("/graph/relate", async (req, reply) => {
18429
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18430
+ if (!proj) return;
18431
+ const { a, b } = req.query;
18432
+ if (!a || !b) {
18433
+ return reply.code(400).send({ error: "both a and b query params are required" });
18434
+ }
18435
+ const maxDepth = req.query.maxDepth ? Number(req.query.maxDepth) : void 0;
18436
+ if (maxDepth !== void 0 && (!Number.isFinite(maxDepth) || maxDepth < 1)) {
18437
+ return reply.code(400).send({ error: "maxDepth must be a positive integer" });
18438
+ }
18439
+ return relate(proj.graph, a, b, maxDepth);
18440
+ });
18042
18441
  scope.get("/search", async (req, reply) => {
18043
18442
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18044
18443
  if (!proj) return;