@neat.is/core 0.3.8 → 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/{chunk-LQ3JFBTX.js → chunk-CB2UK4EH.js} +139 -41
- package/dist/chunk-CB2UK4EH.js.map +1 -0
- package/dist/{chunk-V4TU7OKZ.js → chunk-D5PIJFBE.js} +2 -2
- package/dist/{chunk-7TYESDAI.js → chunk-KYRIQIPG.js} +48 -11
- package/dist/chunk-KYRIQIPG.js.map +1 -0
- package/dist/{chunk-CZ3T6TE2.js → chunk-NTQHMXWE.js} +239 -71
- package/dist/chunk-NTQHMXWE.js.map +1 -0
- package/dist/cli.cjs +1209 -176
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.d.cts +4 -1
- package/dist/cli.d.ts +4 -1
- package/dist/cli.js +940 -99
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +418 -122
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +4 -4
- package/dist/neatd.cjs +427 -131
- package/dist/neatd.cjs.map +1 -1
- package/dist/neatd.js +3 -3
- package/dist/{otel-grpc-S3AENOZ6.js → otel-grpc-QTX2YQJZ.js} +3 -3
- package/dist/server.cjs +401 -203
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +5 -4
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-7TYESDAI.js.map +0 -1
- package/dist/chunk-CZ3T6TE2.js.map +0 -1
- package/dist/chunk-LQ3JFBTX.js.map +0 -1
- /package/dist/{chunk-V4TU7OKZ.js.map → chunk-D5PIJFBE.js.map} +0 -0
- /package/dist/{otel-grpc-S3AENOZ6.js.map → otel-grpc-QTX2YQJZ.js.map} +0 -0
package/dist/server.cjs
CHANGED
|
@@ -54,6 +54,7 @@ function mountBearerAuth(app, opts) {
|
|
|
54
54
|
if (opts.trustProxy) return;
|
|
55
55
|
const expected = Buffer.from(opts.token, "utf8");
|
|
56
56
|
const suffixes = [...DEFAULT_UNAUTH_SUFFIXES, ...opts.extraUnauthenticatedSuffixes ?? []];
|
|
57
|
+
const publicRead = opts.publicRead === true;
|
|
57
58
|
app.addHook("preHandler", (req, reply, done) => {
|
|
58
59
|
const path38 = (req.url.split("?")[0] ?? "").replace(/\/+$/, "");
|
|
59
60
|
for (const suffix of suffixes) {
|
|
@@ -62,6 +63,10 @@ function mountBearerAuth(app, opts) {
|
|
|
62
63
|
return;
|
|
63
64
|
}
|
|
64
65
|
}
|
|
66
|
+
if (publicRead && PUBLIC_READ_METHODS.has(req.method)) {
|
|
67
|
+
done();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
65
70
|
const header = req.headers.authorization;
|
|
66
71
|
if (typeof header !== "string" || !header.startsWith("Bearer ")) {
|
|
67
72
|
void reply.code(401).send({ error: "unauthorized" });
|
|
@@ -75,16 +80,21 @@ function mountBearerAuth(app, opts) {
|
|
|
75
80
|
done();
|
|
76
81
|
});
|
|
77
82
|
}
|
|
83
|
+
function parseBoolEnv(v) {
|
|
84
|
+
if (!v) return false;
|
|
85
|
+
return v === "true" || v === "1";
|
|
86
|
+
}
|
|
78
87
|
function readAuthEnv(env = process.env) {
|
|
79
88
|
const t = env.NEAT_AUTH_TOKEN;
|
|
80
89
|
const ot = env.NEAT_OTEL_TOKEN;
|
|
81
90
|
return {
|
|
82
91
|
authToken: t && t.length > 0 ? t : void 0,
|
|
83
92
|
otelToken: ot && ot.length > 0 ? ot : t && t.length > 0 ? t : void 0,
|
|
84
|
-
trustProxy: env.NEAT_AUTH_PROXY === "true"
|
|
93
|
+
trustProxy: env.NEAT_AUTH_PROXY === "true",
|
|
94
|
+
publicRead: parseBoolEnv(env.NEAT_PUBLIC_READ)
|
|
85
95
|
};
|
|
86
96
|
}
|
|
87
|
-
var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, DEFAULT_UNAUTH_SUFFIXES;
|
|
97
|
+
var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, PUBLIC_READ_METHODS, DEFAULT_UNAUTH_SUFFIXES;
|
|
88
98
|
var init_auth = __esm({
|
|
89
99
|
"src/auth.ts"() {
|
|
90
100
|
"use strict";
|
|
@@ -104,7 +114,13 @@ var init_auth = __esm({
|
|
|
104
114
|
this.name = "BindAuthorityError";
|
|
105
115
|
}
|
|
106
116
|
};
|
|
107
|
-
|
|
117
|
+
PUBLIC_READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
118
|
+
DEFAULT_UNAUTH_SUFFIXES = [
|
|
119
|
+
"/health",
|
|
120
|
+
"/healthz",
|
|
121
|
+
"/readyz",
|
|
122
|
+
"/api/config"
|
|
123
|
+
];
|
|
108
124
|
}
|
|
109
125
|
});
|
|
110
126
|
|
|
@@ -307,6 +323,15 @@ function isoFromUnixNano(nanos) {
|
|
|
307
323
|
return void 0;
|
|
308
324
|
}
|
|
309
325
|
}
|
|
326
|
+
function pickEnv(spanAttrs, resourceAttrs) {
|
|
327
|
+
for (const attrs of [spanAttrs, resourceAttrs]) {
|
|
328
|
+
for (const key of [ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT]) {
|
|
329
|
+
const v = attrs[key];
|
|
330
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return ENV_FALLBACK;
|
|
334
|
+
}
|
|
310
335
|
function parseOtlpRequest(body) {
|
|
311
336
|
const out = [];
|
|
312
337
|
for (const rs of body.resourceSpans ?? []) {
|
|
@@ -326,6 +351,7 @@ function parseOtlpRequest(body) {
|
|
|
326
351
|
endTimeUnixNano: span.endTimeUnixNano ?? "0",
|
|
327
352
|
startTimeIso: isoFromUnixNano(span.startTimeUnixNano),
|
|
328
353
|
durationNanos: durationNanos(span.startTimeUnixNano, span.endTimeUnixNano),
|
|
354
|
+
env: pickEnv(attrs, resourceAttrs),
|
|
329
355
|
attributes: attrs,
|
|
330
356
|
dbSystem: typeof attrs["db.system"] === "string" ? attrs["db.system"] : void 0,
|
|
331
357
|
dbName: typeof attrs["db.name"] === "string" ? attrs["db.name"] : void 0,
|
|
@@ -464,7 +490,7 @@ async function buildOtelReceiver(opts) {
|
|
|
464
490
|
};
|
|
465
491
|
return decorated;
|
|
466
492
|
}
|
|
467
|
-
var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
|
|
493
|
+
var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT, ENV_FALLBACK, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
|
|
468
494
|
var init_otel = __esm({
|
|
469
495
|
"src/otel.ts"() {
|
|
470
496
|
"use strict";
|
|
@@ -474,6 +500,9 @@ var init_otel = __esm({
|
|
|
474
500
|
import_fastify2 = __toESM(require("fastify"), 1);
|
|
475
501
|
import_protobufjs = __toESM(require("protobufjs"), 1);
|
|
476
502
|
init_auth();
|
|
503
|
+
ENV_ATTR_CANONICAL = "deployment.environment.name";
|
|
504
|
+
ENV_ATTR_COMPAT = "deployment.environment";
|
|
505
|
+
ENV_FALLBACK = "unknown";
|
|
477
506
|
exportTraceServiceRequestType = null;
|
|
478
507
|
exportTraceServiceResponseType = null;
|
|
479
508
|
cachedProtobufResponseBody = null;
|
|
@@ -506,7 +535,7 @@ function getGraph(project = DEFAULT_PROJECT) {
|
|
|
506
535
|
init_cjs_shims();
|
|
507
536
|
var import_fastify = __toESM(require("fastify"), 1);
|
|
508
537
|
var import_cors = __toESM(require("@fastify/cors"), 1);
|
|
509
|
-
var
|
|
538
|
+
var import_types22 = require("@neat.is/types");
|
|
510
539
|
|
|
511
540
|
// src/divergences.ts
|
|
512
541
|
init_cjs_shims();
|
|
@@ -1763,52 +1792,64 @@ function cacheSpanService(span, now) {
|
|
|
1763
1792
|
if (!span.traceId || !span.spanId) return;
|
|
1764
1793
|
const key = parentSpanKey(span.traceId, span.spanId);
|
|
1765
1794
|
parentSpanCache.delete(key);
|
|
1766
|
-
parentSpanCache.set(key, {
|
|
1795
|
+
parentSpanCache.set(key, {
|
|
1796
|
+
service: span.service,
|
|
1797
|
+
env: span.env ?? "unknown",
|
|
1798
|
+
expiresAt: now + PARENT_SPAN_CACHE_TTL_MS
|
|
1799
|
+
});
|
|
1767
1800
|
while (parentSpanCache.size > PARENT_SPAN_CACHE_SIZE) {
|
|
1768
1801
|
const oldest = parentSpanCache.keys().next().value;
|
|
1769
1802
|
if (!oldest) break;
|
|
1770
1803
|
parentSpanCache.delete(oldest);
|
|
1771
1804
|
}
|
|
1772
1805
|
}
|
|
1773
|
-
function
|
|
1806
|
+
function lookupParentSpan(traceId, parentSpanId, now) {
|
|
1774
1807
|
const entry = parentSpanCache.get(parentSpanKey(traceId, parentSpanId));
|
|
1775
1808
|
if (!entry) return null;
|
|
1776
1809
|
if (entry.expiresAt <= now) {
|
|
1777
1810
|
parentSpanCache.delete(parentSpanKey(traceId, parentSpanId));
|
|
1778
1811
|
return null;
|
|
1779
1812
|
}
|
|
1780
|
-
return entry.service;
|
|
1813
|
+
return { service: entry.service, env: entry.env };
|
|
1781
1814
|
}
|
|
1782
|
-
function resolveServiceId(graph, host) {
|
|
1783
|
-
const
|
|
1784
|
-
if (graph.hasNode(
|
|
1785
|
-
|
|
1815
|
+
function resolveServiceId(graph, host, env) {
|
|
1816
|
+
const envTagged = (0, import_types4.serviceId)(host, env);
|
|
1817
|
+
if (graph.hasNode(envTagged)) return envTagged;
|
|
1818
|
+
const envLess = (0, import_types4.serviceId)(host);
|
|
1819
|
+
if (envLess !== envTagged && graph.hasNode(envLess)) return envLess;
|
|
1820
|
+
let sameEnv = null;
|
|
1821
|
+
let envLessMatch = null;
|
|
1822
|
+
let anyMatch = null;
|
|
1786
1823
|
graph.forEachNode((id, attrs) => {
|
|
1787
|
-
if (
|
|
1824
|
+
if (sameEnv) return;
|
|
1788
1825
|
const a = attrs;
|
|
1789
1826
|
if (a.type !== import_types4.NodeType.ServiceNode) return;
|
|
1790
|
-
|
|
1791
|
-
|
|
1827
|
+
const matchesByName = a.name === host;
|
|
1828
|
+
const matchesByAlias = a.aliases ? a.aliases.includes(host) : false;
|
|
1829
|
+
if (!matchesByName && !matchesByAlias) return;
|
|
1830
|
+
const nodeEnv = a.env ?? "unknown";
|
|
1831
|
+
if (nodeEnv === env) {
|
|
1832
|
+
sameEnv = id;
|
|
1792
1833
|
return;
|
|
1793
1834
|
}
|
|
1794
|
-
if (
|
|
1795
|
-
|
|
1796
|
-
}
|
|
1835
|
+
if (nodeEnv === "unknown" && !envLessMatch) envLessMatch = id;
|
|
1836
|
+
else if (!anyMatch) anyMatch = id;
|
|
1797
1837
|
});
|
|
1798
|
-
return
|
|
1838
|
+
return sameEnv ?? envLessMatch ?? anyMatch;
|
|
1799
1839
|
}
|
|
1800
1840
|
function frontierIdFor(host) {
|
|
1801
1841
|
return (0, import_types4.frontierId)(host);
|
|
1802
1842
|
}
|
|
1803
|
-
function ensureServiceNode(graph, serviceName) {
|
|
1804
|
-
const id = (0, import_types4.serviceId)(serviceName);
|
|
1843
|
+
function ensureServiceNode(graph, serviceName, env) {
|
|
1844
|
+
const id = (0, import_types4.serviceId)(serviceName, env);
|
|
1805
1845
|
if (graph.hasNode(id)) return id;
|
|
1806
1846
|
const node = {
|
|
1807
1847
|
id,
|
|
1808
1848
|
type: import_types4.NodeType.ServiceNode,
|
|
1809
1849
|
name: serviceName,
|
|
1810
1850
|
language: "unknown",
|
|
1811
|
-
discoveredVia: "otel"
|
|
1851
|
+
discoveredVia: "otel",
|
|
1852
|
+
...env !== "unknown" ? { env } : {}
|
|
1812
1853
|
};
|
|
1813
1854
|
graph.addNode(id, node);
|
|
1814
1855
|
return id;
|
|
@@ -1943,7 +1984,8 @@ function sanitizeAttributes(attrs) {
|
|
|
1943
1984
|
async function handleSpan(ctx, span) {
|
|
1944
1985
|
const ts = span.startTimeIso ?? nowIso(ctx);
|
|
1945
1986
|
const nowMs = ctx.now ? ctx.now() : Date.now();
|
|
1946
|
-
const
|
|
1987
|
+
const env = span.env ?? "unknown";
|
|
1988
|
+
const sourceId = ensureServiceNode(ctx.graph, span.service, env);
|
|
1947
1989
|
const isError = span.statusCode === 2;
|
|
1948
1990
|
cacheSpanService(span, nowMs);
|
|
1949
1991
|
let affectedNode = sourceId;
|
|
@@ -1966,7 +2008,7 @@ async function handleSpan(ctx, span) {
|
|
|
1966
2008
|
const host = pickAddress(span);
|
|
1967
2009
|
let resolvedViaAddress = false;
|
|
1968
2010
|
if (host && host !== span.service) {
|
|
1969
|
-
const targetId = resolveServiceId(ctx.graph, host);
|
|
2011
|
+
const targetId = resolveServiceId(ctx.graph, host, env);
|
|
1970
2012
|
if (targetId && targetId !== sourceId) {
|
|
1971
2013
|
upsertObservedEdge(
|
|
1972
2014
|
ctx.graph,
|
|
@@ -1993,9 +2035,9 @@ async function handleSpan(ctx, span) {
|
|
|
1993
2035
|
}
|
|
1994
2036
|
}
|
|
1995
2037
|
if (!resolvedViaAddress && span.parentSpanId) {
|
|
1996
|
-
const
|
|
1997
|
-
if (
|
|
1998
|
-
const parentId = ensureServiceNode(ctx.graph,
|
|
2038
|
+
const parent = lookupParentSpan(span.traceId, span.parentSpanId, nowMs);
|
|
2039
|
+
if (parent && parent.service !== span.service) {
|
|
2040
|
+
const parentId = ensureServiceNode(ctx.graph, parent.service, parent.env);
|
|
1999
2041
|
upsertObservedEdge(
|
|
2000
2042
|
ctx.graph,
|
|
2001
2043
|
import_types4.EdgeType.CALLS,
|
|
@@ -2191,6 +2233,28 @@ async function readErrorEvents(errorsPath) {
|
|
|
2191
2233
|
throw err;
|
|
2192
2234
|
}
|
|
2193
2235
|
}
|
|
2236
|
+
function mergeSnapshot(graph, snapshot) {
|
|
2237
|
+
const exported = snapshot.graph;
|
|
2238
|
+
let nodesAdded = 0;
|
|
2239
|
+
let edgesAdded = 0;
|
|
2240
|
+
for (const node of exported.nodes ?? []) {
|
|
2241
|
+
if (graph.hasNode(node.key)) continue;
|
|
2242
|
+
if (!node.attributes) continue;
|
|
2243
|
+
graph.addNode(node.key, node.attributes);
|
|
2244
|
+
nodesAdded++;
|
|
2245
|
+
}
|
|
2246
|
+
for (const edge of exported.edges ?? []) {
|
|
2247
|
+
const attrs = edge.attributes;
|
|
2248
|
+
if (!attrs) continue;
|
|
2249
|
+
const id = edge.key ?? attrs.id;
|
|
2250
|
+
if (!id) continue;
|
|
2251
|
+
if (graph.hasEdge(id)) continue;
|
|
2252
|
+
if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
|
|
2253
|
+
graph.addEdgeWithKey(id, edge.source, edge.target, attrs);
|
|
2254
|
+
edgesAdded++;
|
|
2255
|
+
}
|
|
2256
|
+
return { nodesAdded, edgesAdded };
|
|
2257
|
+
}
|
|
2194
2258
|
|
|
2195
2259
|
// src/extract/services.ts
|
|
2196
2260
|
init_cjs_shims();
|
|
@@ -2214,8 +2278,36 @@ var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
|
2214
2278
|
".turbo",
|
|
2215
2279
|
"dist",
|
|
2216
2280
|
"build",
|
|
2217
|
-
".next"
|
|
2281
|
+
".next",
|
|
2282
|
+
// Python virtualenv shapes (issue #344). Walking into a venv pulls in the
|
|
2283
|
+
// entire CPython stdlib + every installed package as if it were first-party
|
|
2284
|
+
// service code — 20k+ files, none of which the user wrote. The shape names
|
|
2285
|
+
// cover the three common venv tools (`venv` / `python -m venv`,
|
|
2286
|
+
// `virtualenv`) and the tox + PEP 582 conventions.
|
|
2287
|
+
".venv",
|
|
2288
|
+
"venv",
|
|
2289
|
+
"__pypackages__",
|
|
2290
|
+
".tox",
|
|
2291
|
+
// `site-packages` shows up nested inside venvs that don't carry one of the
|
|
2292
|
+
// outer names (system Python on macOS, in-place pyenv layouts). Listing it
|
|
2293
|
+
// here means we stop the walk at the boundary even when the wrapper dir
|
|
2294
|
+
// wasn't recognisable.
|
|
2295
|
+
"site-packages"
|
|
2218
2296
|
]);
|
|
2297
|
+
var PYVENV_MARKER_CACHE = /* @__PURE__ */ new Map();
|
|
2298
|
+
async function isPythonVenvDir(dir) {
|
|
2299
|
+
const cached = PYVENV_MARKER_CACHE.get(dir);
|
|
2300
|
+
if (cached !== void 0) return cached;
|
|
2301
|
+
try {
|
|
2302
|
+
const stat = await import_node_fs4.promises.stat(import_node_path4.default.join(dir, "pyvenv.cfg"));
|
|
2303
|
+
const ok = stat.isFile();
|
|
2304
|
+
PYVENV_MARKER_CACHE.set(dir, ok);
|
|
2305
|
+
return ok;
|
|
2306
|
+
} catch {
|
|
2307
|
+
PYVENV_MARKER_CACHE.set(dir, false);
|
|
2308
|
+
return false;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2219
2311
|
function isConfigFile(name) {
|
|
2220
2312
|
const ext = import_node_path4.default.extname(name);
|
|
2221
2313
|
if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
|
|
@@ -2552,6 +2644,7 @@ async function walkDirs(start, scanPath, options, visit) {
|
|
|
2552
2644
|
const rel = import_node_path8.default.relative(scanPath, child).split(import_node_path8.default.sep).join("/");
|
|
2553
2645
|
if (rel && options.ig.ignores(rel + "/")) continue;
|
|
2554
2646
|
}
|
|
2647
|
+
if (await isPythonVenvDir(child)) continue;
|
|
2555
2648
|
await visit(child);
|
|
2556
2649
|
await recurse(child, depth + 1);
|
|
2557
2650
|
}
|
|
@@ -2587,6 +2680,18 @@ async function expandWorkspaceGlobs(scanPath, globs) {
|
|
|
2587
2680
|
}
|
|
2588
2681
|
return [...found];
|
|
2589
2682
|
}
|
|
2683
|
+
function detectJsFramework(pkg) {
|
|
2684
|
+
const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
2685
|
+
if (deps["next"] !== void 0) return "next";
|
|
2686
|
+
if (deps["remix"] !== void 0) return "remix";
|
|
2687
|
+
for (const k of Object.keys(deps)) {
|
|
2688
|
+
if (k.startsWith("@remix-run/")) return "remix";
|
|
2689
|
+
}
|
|
2690
|
+
if (deps["@sveltejs/kit"] !== void 0) return "sveltekit";
|
|
2691
|
+
if (deps["nuxt"] !== void 0) return "nuxt";
|
|
2692
|
+
if (deps["astro"] !== void 0) return "astro";
|
|
2693
|
+
return void 0;
|
|
2694
|
+
}
|
|
2590
2695
|
async function discoverNodeService(scanPath, dir) {
|
|
2591
2696
|
const pkgPath = import_node_path8.default.join(dir, "package.json");
|
|
2592
2697
|
if (!await exists(pkgPath)) return null;
|
|
@@ -2598,6 +2703,7 @@ async function discoverNodeService(scanPath, dir) {
|
|
|
2598
2703
|
return null;
|
|
2599
2704
|
}
|
|
2600
2705
|
if (!pkg.name) return null;
|
|
2706
|
+
const framework = detectJsFramework(pkg);
|
|
2601
2707
|
const node = {
|
|
2602
2708
|
id: (0, import_types6.serviceId)(pkg.name),
|
|
2603
2709
|
type: import_types6.NodeType.ServiceNode,
|
|
@@ -2606,7 +2712,8 @@ async function discoverNodeService(scanPath, dir) {
|
|
|
2606
2712
|
version: pkg.version,
|
|
2607
2713
|
dependencies: pkg.dependencies ?? {},
|
|
2608
2714
|
repoPath: import_node_path8.default.relative(scanPath, dir),
|
|
2609
|
-
...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {}
|
|
2715
|
+
...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {},
|
|
2716
|
+
...framework ? { framework } : {}
|
|
2610
2717
|
};
|
|
2611
2718
|
return { pkg, dir, node };
|
|
2612
2719
|
}
|
|
@@ -2816,7 +2923,9 @@ async function walkYamlFiles(start, depth = 0, max = 5) {
|
|
|
2816
2923
|
for (const entry of entries) {
|
|
2817
2924
|
if (entry.isDirectory()) {
|
|
2818
2925
|
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
2819
|
-
|
|
2926
|
+
const child = import_node_path9.default.join(start, entry.name);
|
|
2927
|
+
if (await isPythonVenvDir(child)) continue;
|
|
2928
|
+
out.push(...await walkYamlFiles(child, depth + 1, max));
|
|
2820
2929
|
} else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path9.default.extname(entry.name))) {
|
|
2821
2930
|
out.push(import_node_path9.default.join(start, entry.name));
|
|
2822
2931
|
}
|
|
@@ -3498,7 +3607,9 @@ async function walkConfigFiles(dir) {
|
|
|
3498
3607
|
for (const entry of entries) {
|
|
3499
3608
|
const full = import_node_path18.default.join(current, entry.name);
|
|
3500
3609
|
if (entry.isDirectory()) {
|
|
3501
|
-
if (
|
|
3610
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
3611
|
+
if (await isPythonVenvDir(full)) continue;
|
|
3612
|
+
await walk(full);
|
|
3502
3613
|
} else if (entry.isFile() && isConfigFile(entry.name).match) {
|
|
3503
3614
|
out.push(full);
|
|
3504
3615
|
}
|
|
@@ -3566,7 +3677,9 @@ async function walkSourceFiles(dir) {
|
|
|
3566
3677
|
for (const entry of entries) {
|
|
3567
3678
|
const full = import_node_path19.default.join(current, entry.name);
|
|
3568
3679
|
if (entry.isDirectory()) {
|
|
3569
|
-
if (
|
|
3680
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
3681
|
+
if (await isPythonVenvDir(full)) continue;
|
|
3682
|
+
await walk(full);
|
|
3570
3683
|
} else if (entry.isFile() && SERVICE_FILE_EXTENSIONS.has(import_node_path19.default.extname(entry.name))) {
|
|
3571
3684
|
out.push(full);
|
|
3572
3685
|
}
|
|
@@ -4202,7 +4315,9 @@ async function walkTfFiles(start, depth = 0, max = 5) {
|
|
|
4202
4315
|
for (const entry of entries) {
|
|
4203
4316
|
if (entry.isDirectory()) {
|
|
4204
4317
|
if (IGNORED_DIRS.has(entry.name) || entry.name === ".terraform") continue;
|
|
4205
|
-
|
|
4318
|
+
const child = import_node_path27.default.join(start, entry.name);
|
|
4319
|
+
if (await isPythonVenvDir(child)) continue;
|
|
4320
|
+
out.push(...await walkTfFiles(child, depth + 1, max));
|
|
4206
4321
|
} else if (entry.isFile() && entry.name.endsWith(".tf")) {
|
|
4207
4322
|
out.push(import_node_path27.default.join(start, entry.name));
|
|
4208
4323
|
}
|
|
@@ -4250,7 +4365,9 @@ async function walkYamlFiles2(start, depth = 0, max = 5) {
|
|
|
4250
4365
|
for (const entry of entries) {
|
|
4251
4366
|
if (entry.isDirectory()) {
|
|
4252
4367
|
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
4253
|
-
|
|
4368
|
+
const child = import_node_path28.default.join(start, entry.name);
|
|
4369
|
+
if (await isPythonVenvDir(child)) continue;
|
|
4370
|
+
out.push(...await walkYamlFiles2(child, depth + 1, max));
|
|
4254
4371
|
} else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path28.default.extname(entry.name))) {
|
|
4255
4372
|
out.push(import_node_path28.default.join(start, entry.name));
|
|
4256
4373
|
}
|
|
@@ -4461,25 +4578,138 @@ function canonicalJson(value) {
|
|
|
4461
4578
|
});
|
|
4462
4579
|
}
|
|
4463
4580
|
|
|
4464
|
-
// src/
|
|
4581
|
+
// src/persist.ts
|
|
4465
4582
|
init_cjs_shims();
|
|
4583
|
+
var import_node_fs19 = require("fs");
|
|
4466
4584
|
var import_node_path31 = __toESM(require("path"), 1);
|
|
4585
|
+
var import_types20 = require("@neat.is/types");
|
|
4586
|
+
var SCHEMA_VERSION = 4;
|
|
4587
|
+
function migrateV1ToV2(payload) {
|
|
4588
|
+
const nodes = payload.graph.nodes;
|
|
4589
|
+
if (Array.isArray(nodes)) {
|
|
4590
|
+
for (const node of nodes) {
|
|
4591
|
+
if (node.attributes && "pgDriverVersion" in node.attributes) {
|
|
4592
|
+
delete node.attributes.pgDriverVersion;
|
|
4593
|
+
}
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
return { ...payload, schemaVersion: 2 };
|
|
4597
|
+
}
|
|
4598
|
+
function migrateV3ToV4(payload) {
|
|
4599
|
+
return { ...payload, schemaVersion: 4 };
|
|
4600
|
+
}
|
|
4601
|
+
function migrateV2ToV3(payload) {
|
|
4602
|
+
const edges = payload.graph.edges;
|
|
4603
|
+
if (Array.isArray(edges)) {
|
|
4604
|
+
for (const edge of edges) {
|
|
4605
|
+
const attrs = edge.attributes;
|
|
4606
|
+
if (!attrs || attrs.provenance !== "FRONTIER") continue;
|
|
4607
|
+
attrs.provenance = import_types20.Provenance.OBSERVED;
|
|
4608
|
+
const type = typeof attrs.type === "string" ? attrs.type : void 0;
|
|
4609
|
+
const source = typeof attrs.source === "string" ? attrs.source : void 0;
|
|
4610
|
+
const target = typeof attrs.target === "string" ? attrs.target : void 0;
|
|
4611
|
+
if (type && source && target) {
|
|
4612
|
+
const newId = (0, import_types20.observedEdgeId)(source, target, type);
|
|
4613
|
+
attrs.id = newId;
|
|
4614
|
+
if (edge.key) edge.key = newId;
|
|
4615
|
+
}
|
|
4616
|
+
}
|
|
4617
|
+
}
|
|
4618
|
+
return { ...payload, schemaVersion: 3 };
|
|
4619
|
+
}
|
|
4620
|
+
async function ensureDir(filePath) {
|
|
4621
|
+
await import_node_fs19.promises.mkdir(import_node_path31.default.dirname(filePath), { recursive: true });
|
|
4622
|
+
}
|
|
4623
|
+
async function saveGraphToDisk(graph, outPath) {
|
|
4624
|
+
await ensureDir(outPath);
|
|
4625
|
+
const payload = {
|
|
4626
|
+
schemaVersion: SCHEMA_VERSION,
|
|
4627
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4628
|
+
graph: graph.export()
|
|
4629
|
+
};
|
|
4630
|
+
const tmp = `${outPath}.tmp`;
|
|
4631
|
+
await import_node_fs19.promises.writeFile(tmp, JSON.stringify(payload), "utf8");
|
|
4632
|
+
await import_node_fs19.promises.rename(tmp, outPath);
|
|
4633
|
+
}
|
|
4634
|
+
async function loadGraphFromDisk(graph, outPath) {
|
|
4635
|
+
let raw;
|
|
4636
|
+
try {
|
|
4637
|
+
raw = await import_node_fs19.promises.readFile(outPath, "utf8");
|
|
4638
|
+
} catch (err) {
|
|
4639
|
+
if (err.code === "ENOENT") return;
|
|
4640
|
+
throw err;
|
|
4641
|
+
}
|
|
4642
|
+
let payload = JSON.parse(raw);
|
|
4643
|
+
if (payload.schemaVersion === 1) {
|
|
4644
|
+
payload = migrateV1ToV2(payload);
|
|
4645
|
+
}
|
|
4646
|
+
if (payload.schemaVersion === 2) {
|
|
4647
|
+
payload = migrateV2ToV3(payload);
|
|
4648
|
+
}
|
|
4649
|
+
if (payload.schemaVersion === 3) {
|
|
4650
|
+
payload = migrateV3ToV4(payload);
|
|
4651
|
+
}
|
|
4652
|
+
if (payload.schemaVersion !== SCHEMA_VERSION) {
|
|
4653
|
+
throw new Error(
|
|
4654
|
+
`persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
|
|
4655
|
+
);
|
|
4656
|
+
}
|
|
4657
|
+
graph.clear();
|
|
4658
|
+
graph.import(payload.graph);
|
|
4659
|
+
}
|
|
4660
|
+
function startPersistLoop(graph, outPath, intervalMs = 6e4) {
|
|
4661
|
+
let stopped = false;
|
|
4662
|
+
const tick = async () => {
|
|
4663
|
+
if (stopped) return;
|
|
4664
|
+
try {
|
|
4665
|
+
await saveGraphToDisk(graph, outPath);
|
|
4666
|
+
} catch (err) {
|
|
4667
|
+
console.error("persist: periodic save failed", err);
|
|
4668
|
+
}
|
|
4669
|
+
};
|
|
4670
|
+
const interval = setInterval(() => {
|
|
4671
|
+
void tick();
|
|
4672
|
+
}, intervalMs);
|
|
4673
|
+
const onSignal = (signal) => {
|
|
4674
|
+
void (async () => {
|
|
4675
|
+
try {
|
|
4676
|
+
await saveGraphToDisk(graph, outPath);
|
|
4677
|
+
} catch (err) {
|
|
4678
|
+
console.error(`persist: ${signal} save failed`, err);
|
|
4679
|
+
} finally {
|
|
4680
|
+
process.exit(0);
|
|
4681
|
+
}
|
|
4682
|
+
})();
|
|
4683
|
+
};
|
|
4684
|
+
process.on("SIGTERM", onSignal);
|
|
4685
|
+
process.on("SIGINT", onSignal);
|
|
4686
|
+
return () => {
|
|
4687
|
+
stopped = true;
|
|
4688
|
+
clearInterval(interval);
|
|
4689
|
+
process.off("SIGTERM", onSignal);
|
|
4690
|
+
process.off("SIGINT", onSignal);
|
|
4691
|
+
};
|
|
4692
|
+
}
|
|
4693
|
+
|
|
4694
|
+
// src/projects.ts
|
|
4695
|
+
init_cjs_shims();
|
|
4696
|
+
var import_node_path32 = __toESM(require("path"), 1);
|
|
4467
4697
|
function pathsForProject(project, baseDir) {
|
|
4468
4698
|
if (project === DEFAULT_PROJECT) {
|
|
4469
4699
|
return {
|
|
4470
|
-
snapshotPath:
|
|
4471
|
-
errorsPath:
|
|
4472
|
-
staleEventsPath:
|
|
4473
|
-
embeddingsCachePath:
|
|
4474
|
-
policyViolationsPath:
|
|
4700
|
+
snapshotPath: import_node_path32.default.join(baseDir, "graph.json"),
|
|
4701
|
+
errorsPath: import_node_path32.default.join(baseDir, "errors.ndjson"),
|
|
4702
|
+
staleEventsPath: import_node_path32.default.join(baseDir, "stale-events.ndjson"),
|
|
4703
|
+
embeddingsCachePath: import_node_path32.default.join(baseDir, "embeddings.json"),
|
|
4704
|
+
policyViolationsPath: import_node_path32.default.join(baseDir, "policy-violations.ndjson")
|
|
4475
4705
|
};
|
|
4476
4706
|
}
|
|
4477
4707
|
return {
|
|
4478
|
-
snapshotPath:
|
|
4479
|
-
errorsPath:
|
|
4480
|
-
staleEventsPath:
|
|
4481
|
-
embeddingsCachePath:
|
|
4482
|
-
policyViolationsPath:
|
|
4708
|
+
snapshotPath: import_node_path32.default.join(baseDir, `${project}.json`),
|
|
4709
|
+
errorsPath: import_node_path32.default.join(baseDir, `errors.${project}.ndjson`),
|
|
4710
|
+
staleEventsPath: import_node_path32.default.join(baseDir, `stale-events.${project}.ndjson`),
|
|
4711
|
+
embeddingsCachePath: import_node_path32.default.join(baseDir, `embeddings.${project}.json`),
|
|
4712
|
+
policyViolationsPath: import_node_path32.default.join(baseDir, `policy-violations.${project}.ndjson`)
|
|
4483
4713
|
};
|
|
4484
4714
|
}
|
|
4485
4715
|
var Projects = class {
|
|
@@ -4519,23 +4749,23 @@ function parseExtraProjects(raw) {
|
|
|
4519
4749
|
|
|
4520
4750
|
// src/registry.ts
|
|
4521
4751
|
init_cjs_shims();
|
|
4522
|
-
var
|
|
4752
|
+
var import_node_fs20 = require("fs");
|
|
4523
4753
|
var import_node_os2 = __toESM(require("os"), 1);
|
|
4524
|
-
var
|
|
4525
|
-
var
|
|
4754
|
+
var import_node_path33 = __toESM(require("path"), 1);
|
|
4755
|
+
var import_types21 = require("@neat.is/types");
|
|
4526
4756
|
function neatHome() {
|
|
4527
4757
|
const override = process.env.NEAT_HOME;
|
|
4528
|
-
if (override && override.length > 0) return
|
|
4529
|
-
return
|
|
4758
|
+
if (override && override.length > 0) return import_node_path33.default.resolve(override);
|
|
4759
|
+
return import_node_path33.default.join(import_node_os2.default.homedir(), ".neat");
|
|
4530
4760
|
}
|
|
4531
4761
|
function registryPath() {
|
|
4532
|
-
return
|
|
4762
|
+
return import_node_path33.default.join(neatHome(), "projects.json");
|
|
4533
4763
|
}
|
|
4534
4764
|
async function readRegistry() {
|
|
4535
4765
|
const file = registryPath();
|
|
4536
4766
|
let raw;
|
|
4537
4767
|
try {
|
|
4538
|
-
raw = await
|
|
4768
|
+
raw = await import_node_fs20.promises.readFile(file, "utf8");
|
|
4539
4769
|
} catch (err) {
|
|
4540
4770
|
if (err.code === "ENOENT") {
|
|
4541
4771
|
return { version: 1, projects: [] };
|
|
@@ -4543,7 +4773,7 @@ async function readRegistry() {
|
|
|
4543
4773
|
throw err;
|
|
4544
4774
|
}
|
|
4545
4775
|
const parsed = JSON.parse(raw);
|
|
4546
|
-
return
|
|
4776
|
+
return import_types21.RegistryFileSchema.parse(parsed);
|
|
4547
4777
|
}
|
|
4548
4778
|
async function getProject(name) {
|
|
4549
4779
|
const reg = await readRegistry();
|
|
@@ -4626,10 +4856,19 @@ function projectFromReq(req) {
|
|
|
4626
4856
|
const params = req.params;
|
|
4627
4857
|
return params.project ?? DEFAULT_PROJECT;
|
|
4628
4858
|
}
|
|
4629
|
-
function resolveProject(registry, req, reply) {
|
|
4859
|
+
function resolveProject(registry, req, reply, bootstrap) {
|
|
4630
4860
|
const name = projectFromReq(req);
|
|
4631
4861
|
const ctx = registry.get(name);
|
|
4632
4862
|
if (!ctx) {
|
|
4863
|
+
const phase = bootstrap?.status(name);
|
|
4864
|
+
if (phase === "bootstrapping") {
|
|
4865
|
+
void reply.code(503).send({ ready: false, project: name, status: "bootstrapping" });
|
|
4866
|
+
return null;
|
|
4867
|
+
}
|
|
4868
|
+
if (phase === "broken") {
|
|
4869
|
+
void reply.code(503).send({ ready: false, project: name, status: "broken" });
|
|
4870
|
+
return null;
|
|
4871
|
+
}
|
|
4633
4872
|
void reply.code(404).send({ error: "project not found", project: name });
|
|
4634
4873
|
return null;
|
|
4635
4874
|
}
|
|
@@ -4659,36 +4898,38 @@ function buildLegacyRegistry(opts) {
|
|
|
4659
4898
|
function registerRoutes(scope, ctx) {
|
|
4660
4899
|
const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
|
|
4661
4900
|
scope.get("/events", (req, reply) => {
|
|
4662
|
-
const proj = resolveProject(registry, req, reply);
|
|
4901
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4663
4902
|
if (!proj) return;
|
|
4664
4903
|
handleSse(req, reply, { project: proj.name });
|
|
4665
4904
|
});
|
|
4666
|
-
scope
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4905
|
+
if (ctx.scope === "project") {
|
|
4906
|
+
scope.get("/health", async (req, reply) => {
|
|
4907
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4908
|
+
if (!proj) return;
|
|
4909
|
+
const uptimeMs = Date.now() - startedAt;
|
|
4910
|
+
return {
|
|
4911
|
+
ok: true,
|
|
4912
|
+
project: proj.name,
|
|
4913
|
+
uptimeMs,
|
|
4914
|
+
// Legacy fields kept additively. The web shell's StatusBar reads
|
|
4915
|
+
// nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
|
|
4916
|
+
// the canonical triple and lets the extras pass through.
|
|
4917
|
+
uptime: Math.floor(uptimeMs / 1e3),
|
|
4918
|
+
nodeCount: proj.graph.order,
|
|
4919
|
+
edgeCount: proj.graph.size,
|
|
4920
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
4921
|
+
};
|
|
4922
|
+
});
|
|
4923
|
+
}
|
|
4683
4924
|
scope.get("/graph", async (req, reply) => {
|
|
4684
|
-
const proj = resolveProject(registry, req, reply);
|
|
4925
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4685
4926
|
if (!proj) return;
|
|
4686
4927
|
return serializeGraph(proj.graph);
|
|
4687
4928
|
});
|
|
4688
4929
|
scope.get(
|
|
4689
4930
|
"/graph/node/:id",
|
|
4690
4931
|
async (req, reply) => {
|
|
4691
|
-
const proj = resolveProject(registry, req, reply);
|
|
4932
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4692
4933
|
if (!proj) return;
|
|
4693
4934
|
const { id } = req.params;
|
|
4694
4935
|
if (!proj.graph.hasNode(id)) {
|
|
@@ -4700,7 +4941,7 @@ function registerRoutes(scope, ctx) {
|
|
|
4700
4941
|
scope.get(
|
|
4701
4942
|
"/graph/edges/:id",
|
|
4702
4943
|
async (req, reply) => {
|
|
4703
|
-
const proj = resolveProject(registry, req, reply);
|
|
4944
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4704
4945
|
if (!proj) return;
|
|
4705
4946
|
const { id } = req.params;
|
|
4706
4947
|
if (!proj.graph.hasNode(id)) {
|
|
@@ -4712,7 +4953,7 @@ function registerRoutes(scope, ctx) {
|
|
|
4712
4953
|
}
|
|
4713
4954
|
);
|
|
4714
4955
|
scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
|
|
4715
|
-
const proj = resolveProject(registry, req, reply);
|
|
4956
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4716
4957
|
if (!proj) return;
|
|
4717
4958
|
const { nodeId } = req.params;
|
|
4718
4959
|
if (!proj.graph.hasNode(nodeId)) {
|
|
@@ -4727,18 +4968,18 @@ function registerRoutes(scope, ctx) {
|
|
|
4727
4968
|
return getTransitiveDependencies(proj.graph, nodeId, depth);
|
|
4728
4969
|
});
|
|
4729
4970
|
scope.get("/graph/divergences", async (req, reply) => {
|
|
4730
|
-
const proj = resolveProject(registry, req, reply);
|
|
4971
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4731
4972
|
if (!proj) return;
|
|
4732
4973
|
let typeFilter;
|
|
4733
4974
|
if (req.query.type) {
|
|
4734
4975
|
const candidates = req.query.type.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
4735
4976
|
const parsed = [];
|
|
4736
4977
|
for (const c of candidates) {
|
|
4737
|
-
const r =
|
|
4978
|
+
const r = import_types22.DivergenceTypeSchema.safeParse(c);
|
|
4738
4979
|
if (!r.success) {
|
|
4739
4980
|
return reply.code(400).send({
|
|
4740
4981
|
error: `unknown divergence type "${c}"`,
|
|
4741
|
-
allowed:
|
|
4982
|
+
allowed: import_types22.DivergenceTypeSchema.options
|
|
4742
4983
|
});
|
|
4743
4984
|
}
|
|
4744
4985
|
parsed.push(r.data);
|
|
@@ -4762,7 +5003,7 @@ function registerRoutes(scope, ctx) {
|
|
|
4762
5003
|
});
|
|
4763
5004
|
});
|
|
4764
5005
|
scope.get("/incidents", async (req, reply) => {
|
|
4765
|
-
const proj = resolveProject(registry, req, reply);
|
|
5006
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4766
5007
|
if (!proj) return;
|
|
4767
5008
|
const epath = errorsPathFor(proj);
|
|
4768
5009
|
if (!epath) return { count: 0, total: 0, events: [] };
|
|
@@ -4774,7 +5015,7 @@ function registerRoutes(scope, ctx) {
|
|
|
4774
5015
|
return { count: sliced.length, total, events: sliced };
|
|
4775
5016
|
});
|
|
4776
5017
|
scope.get("/stale-events", async (req, reply) => {
|
|
4777
|
-
const proj = resolveProject(registry, req, reply);
|
|
5018
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4778
5019
|
if (!proj) return;
|
|
4779
5020
|
const spath = staleEventsPathFor(proj);
|
|
4780
5021
|
if (!spath) return { count: 0, total: 0, events: [] };
|
|
@@ -4789,7 +5030,7 @@ function registerRoutes(scope, ctx) {
|
|
|
4789
5030
|
scope.get(
|
|
4790
5031
|
"/incidents/:nodeId",
|
|
4791
5032
|
async (req, reply) => {
|
|
4792
|
-
const proj = resolveProject(registry, req, reply);
|
|
5033
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4793
5034
|
if (!proj) return;
|
|
4794
5035
|
const { nodeId } = req.params;
|
|
4795
5036
|
if (!proj.graph.hasNode(nodeId)) {
|
|
@@ -4805,7 +5046,7 @@ function registerRoutes(scope, ctx) {
|
|
|
4805
5046
|
}
|
|
4806
5047
|
);
|
|
4807
5048
|
scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
|
|
4808
|
-
const proj = resolveProject(registry, req, reply);
|
|
5049
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4809
5050
|
if (!proj) return;
|
|
4810
5051
|
const { nodeId } = req.params;
|
|
4811
5052
|
if (!proj.graph.hasNode(nodeId)) {
|
|
@@ -4825,7 +5066,7 @@ function registerRoutes(scope, ctx) {
|
|
|
4825
5066
|
return result;
|
|
4826
5067
|
});
|
|
4827
5068
|
scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
|
|
4828
|
-
const proj = resolveProject(registry, req, reply);
|
|
5069
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4829
5070
|
if (!proj) return;
|
|
4830
5071
|
const { nodeId } = req.params;
|
|
4831
5072
|
if (!proj.graph.hasNode(nodeId)) {
|
|
@@ -4838,7 +5079,7 @@ function registerRoutes(scope, ctx) {
|
|
|
4838
5079
|
return getBlastRadius(proj.graph, nodeId, depth);
|
|
4839
5080
|
});
|
|
4840
5081
|
scope.get("/search", async (req, reply) => {
|
|
4841
|
-
const proj = resolveProject(registry, req, reply);
|
|
5082
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4842
5083
|
if (!proj) return;
|
|
4843
5084
|
const raw = (req.query.q ?? "").trim();
|
|
4844
5085
|
if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
|
|
@@ -4869,7 +5110,7 @@ function registerRoutes(scope, ctx) {
|
|
|
4869
5110
|
scope.get(
|
|
4870
5111
|
"/graph/diff",
|
|
4871
5112
|
async (req, reply) => {
|
|
4872
|
-
const proj = resolveProject(registry, req, reply);
|
|
5113
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4873
5114
|
if (!proj) return;
|
|
4874
5115
|
const against = req.query.against;
|
|
4875
5116
|
if (!against) {
|
|
@@ -4883,8 +5124,37 @@ function registerRoutes(scope, ctx) {
|
|
|
4883
5124
|
}
|
|
4884
5125
|
}
|
|
4885
5126
|
);
|
|
5127
|
+
scope.post("/snapshot", async (req, reply) => {
|
|
5128
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
5129
|
+
if (!proj) return;
|
|
5130
|
+
const body = req.body;
|
|
5131
|
+
if (!body || typeof body !== "object" || !body.snapshot) {
|
|
5132
|
+
return reply.code(400).send({ error: "request body must be { snapshot: <persisted-graph> }" });
|
|
5133
|
+
}
|
|
5134
|
+
const snap = body.snapshot;
|
|
5135
|
+
if (typeof snap.schemaVersion !== "number" || snap.schemaVersion !== SCHEMA_VERSION) {
|
|
5136
|
+
return reply.code(400).send({
|
|
5137
|
+
error: `unsupported snapshot schemaVersion ${snap.schemaVersion} (expected ${SCHEMA_VERSION})`
|
|
5138
|
+
});
|
|
5139
|
+
}
|
|
5140
|
+
try {
|
|
5141
|
+
const result = mergeSnapshot(proj.graph, snap);
|
|
5142
|
+
return {
|
|
5143
|
+
project: proj.name,
|
|
5144
|
+
nodesAdded: result.nodesAdded,
|
|
5145
|
+
edgesAdded: result.edgesAdded,
|
|
5146
|
+
nodeCount: proj.graph.order,
|
|
5147
|
+
edgeCount: proj.graph.size
|
|
5148
|
+
};
|
|
5149
|
+
} catch (err) {
|
|
5150
|
+
return reply.code(400).send({
|
|
5151
|
+
error: "snapshot merge failed",
|
|
5152
|
+
details: err.message
|
|
5153
|
+
});
|
|
5154
|
+
}
|
|
5155
|
+
});
|
|
4886
5156
|
scope.post("/graph/scan", async (req, reply) => {
|
|
4887
|
-
const proj = resolveProject(registry, req, reply);
|
|
5157
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4888
5158
|
if (!proj) return;
|
|
4889
5159
|
if (!proj.scanPath) {
|
|
4890
5160
|
return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
|
|
@@ -4900,7 +5170,7 @@ function registerRoutes(scope, ctx) {
|
|
|
4900
5170
|
};
|
|
4901
5171
|
});
|
|
4902
5172
|
scope.get("/policies", async (req, reply) => {
|
|
4903
|
-
const proj = resolveProject(registry, req, reply);
|
|
5173
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4904
5174
|
if (!proj) return;
|
|
4905
5175
|
const policyPath = ctx.policyFilePathFor(proj);
|
|
4906
5176
|
if (!policyPath) {
|
|
@@ -4917,12 +5187,12 @@ function registerRoutes(scope, ctx) {
|
|
|
4917
5187
|
}
|
|
4918
5188
|
});
|
|
4919
5189
|
scope.get("/policies/violations", async (req, reply) => {
|
|
4920
|
-
const proj = resolveProject(registry, req, reply);
|
|
5190
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4921
5191
|
if (!proj) return;
|
|
4922
5192
|
const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
|
|
4923
5193
|
let violations = await log.readAll();
|
|
4924
5194
|
if (req.query.severity) {
|
|
4925
|
-
const sev =
|
|
5195
|
+
const sev = import_types22.PolicySeveritySchema.safeParse(req.query.severity);
|
|
4926
5196
|
if (!sev.success) {
|
|
4927
5197
|
return reply.code(400).send({
|
|
4928
5198
|
error: "invalid severity",
|
|
@@ -4937,9 +5207,9 @@ function registerRoutes(scope, ctx) {
|
|
|
4937
5207
|
return { violations };
|
|
4938
5208
|
});
|
|
4939
5209
|
scope.post("/policies/check", async (req, reply) => {
|
|
4940
|
-
const proj = resolveProject(registry, req, reply);
|
|
5210
|
+
const proj = resolveProject(registry, req, reply, ctx.bootstrap);
|
|
4941
5211
|
if (!proj) return;
|
|
4942
|
-
const parsed =
|
|
5212
|
+
const parsed = import_types22.PoliciesCheckBodySchema.safeParse(req.body ?? {});
|
|
4943
5213
|
if (!parsed.success) {
|
|
4944
5214
|
return reply.code(400).send({
|
|
4945
5215
|
error: "invalid /policies/check body",
|
|
@@ -4976,7 +5246,15 @@ function registerRoutes(scope, ctx) {
|
|
|
4976
5246
|
async function buildApi(opts) {
|
|
4977
5247
|
const app = (0, import_fastify.default)({ logger: false });
|
|
4978
5248
|
await app.register(import_cors.default, { origin: true });
|
|
4979
|
-
mountBearerAuth(app, {
|
|
5249
|
+
mountBearerAuth(app, {
|
|
5250
|
+
token: opts.authToken,
|
|
5251
|
+
trustProxy: opts.trustProxy,
|
|
5252
|
+
publicRead: opts.publicRead
|
|
5253
|
+
});
|
|
5254
|
+
app.get("/api/config", async () => ({
|
|
5255
|
+
publicRead: opts.publicRead === true,
|
|
5256
|
+
authProxy: opts.trustProxy === true
|
|
5257
|
+
}));
|
|
4980
5258
|
const startedAt = opts.startedAt ?? Date.now();
|
|
4981
5259
|
const registry = buildLegacyRegistry(opts);
|
|
4982
5260
|
const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
|
|
@@ -5000,10 +5278,36 @@ async function buildApi(opts) {
|
|
|
5000
5278
|
const routeCtx = {
|
|
5001
5279
|
registry,
|
|
5002
5280
|
startedAt,
|
|
5281
|
+
scope: "root",
|
|
5003
5282
|
errorsPathFor,
|
|
5004
5283
|
staleEventsPathFor,
|
|
5005
|
-
policyFilePathFor
|
|
5284
|
+
policyFilePathFor,
|
|
5285
|
+
bootstrap: opts.bootstrap
|
|
5006
5286
|
};
|
|
5287
|
+
app.get("/health", async () => {
|
|
5288
|
+
const uptimeMs = Date.now() - startedAt;
|
|
5289
|
+
const bootstrapList = opts.bootstrap?.list() ?? [];
|
|
5290
|
+
const byName = new Map(bootstrapList.map((p) => [p.name, p]));
|
|
5291
|
+
const names = /* @__PURE__ */ new Set([
|
|
5292
|
+
...registry.list(),
|
|
5293
|
+
...bootstrapList.map((p) => p.name)
|
|
5294
|
+
]);
|
|
5295
|
+
const projects = [...names].sort().map((name) => {
|
|
5296
|
+
const proj = registry.get(name);
|
|
5297
|
+
const tracked = byName.get(name);
|
|
5298
|
+
return {
|
|
5299
|
+
name,
|
|
5300
|
+
nodeCount: proj?.graph.order ?? 0,
|
|
5301
|
+
edgeCount: proj?.graph.size ?? 0,
|
|
5302
|
+
...tracked ? { status: tracked.status, elapsedMs: tracked.elapsedMs } : {}
|
|
5303
|
+
};
|
|
5304
|
+
});
|
|
5305
|
+
return {
|
|
5306
|
+
ok: true,
|
|
5307
|
+
uptimeMs,
|
|
5308
|
+
projects
|
|
5309
|
+
};
|
|
5310
|
+
});
|
|
5007
5311
|
app.get("/projects", async (_req, reply) => {
|
|
5008
5312
|
try {
|
|
5009
5313
|
return await listProjects();
|
|
@@ -5031,120 +5335,13 @@ async function buildApi(opts) {
|
|
|
5031
5335
|
registerRoutes(app, routeCtx);
|
|
5032
5336
|
await app.register(
|
|
5033
5337
|
async (scope) => {
|
|
5034
|
-
registerRoutes(scope, routeCtx);
|
|
5338
|
+
registerRoutes(scope, { ...routeCtx, scope: "project" });
|
|
5035
5339
|
},
|
|
5036
5340
|
{ prefix: "/projects/:project" }
|
|
5037
5341
|
);
|
|
5038
5342
|
return app;
|
|
5039
5343
|
}
|
|
5040
5344
|
|
|
5041
|
-
// src/persist.ts
|
|
5042
|
-
init_cjs_shims();
|
|
5043
|
-
var import_node_fs20 = require("fs");
|
|
5044
|
-
var import_node_path33 = __toESM(require("path"), 1);
|
|
5045
|
-
var import_types22 = require("@neat.is/types");
|
|
5046
|
-
var SCHEMA_VERSION = 3;
|
|
5047
|
-
function migrateV1ToV2(payload) {
|
|
5048
|
-
const nodes = payload.graph.nodes;
|
|
5049
|
-
if (Array.isArray(nodes)) {
|
|
5050
|
-
for (const node of nodes) {
|
|
5051
|
-
if (node.attributes && "pgDriverVersion" in node.attributes) {
|
|
5052
|
-
delete node.attributes.pgDriverVersion;
|
|
5053
|
-
}
|
|
5054
|
-
}
|
|
5055
|
-
}
|
|
5056
|
-
return { ...payload, schemaVersion: 2 };
|
|
5057
|
-
}
|
|
5058
|
-
function migrateV2ToV3(payload) {
|
|
5059
|
-
const edges = payload.graph.edges;
|
|
5060
|
-
if (Array.isArray(edges)) {
|
|
5061
|
-
for (const edge of edges) {
|
|
5062
|
-
const attrs = edge.attributes;
|
|
5063
|
-
if (!attrs || attrs.provenance !== "FRONTIER") continue;
|
|
5064
|
-
attrs.provenance = import_types22.Provenance.OBSERVED;
|
|
5065
|
-
const type = typeof attrs.type === "string" ? attrs.type : void 0;
|
|
5066
|
-
const source = typeof attrs.source === "string" ? attrs.source : void 0;
|
|
5067
|
-
const target = typeof attrs.target === "string" ? attrs.target : void 0;
|
|
5068
|
-
if (type && source && target) {
|
|
5069
|
-
const newId = (0, import_types22.observedEdgeId)(source, target, type);
|
|
5070
|
-
attrs.id = newId;
|
|
5071
|
-
if (edge.key) edge.key = newId;
|
|
5072
|
-
}
|
|
5073
|
-
}
|
|
5074
|
-
}
|
|
5075
|
-
return { ...payload, schemaVersion: 3 };
|
|
5076
|
-
}
|
|
5077
|
-
async function ensureDir(filePath) {
|
|
5078
|
-
await import_node_fs20.promises.mkdir(import_node_path33.default.dirname(filePath), { recursive: true });
|
|
5079
|
-
}
|
|
5080
|
-
async function saveGraphToDisk(graph, outPath) {
|
|
5081
|
-
await ensureDir(outPath);
|
|
5082
|
-
const payload = {
|
|
5083
|
-
schemaVersion: SCHEMA_VERSION,
|
|
5084
|
-
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5085
|
-
graph: graph.export()
|
|
5086
|
-
};
|
|
5087
|
-
const tmp = `${outPath}.tmp`;
|
|
5088
|
-
await import_node_fs20.promises.writeFile(tmp, JSON.stringify(payload), "utf8");
|
|
5089
|
-
await import_node_fs20.promises.rename(tmp, outPath);
|
|
5090
|
-
}
|
|
5091
|
-
async function loadGraphFromDisk(graph, outPath) {
|
|
5092
|
-
let raw;
|
|
5093
|
-
try {
|
|
5094
|
-
raw = await import_node_fs20.promises.readFile(outPath, "utf8");
|
|
5095
|
-
} catch (err) {
|
|
5096
|
-
if (err.code === "ENOENT") return;
|
|
5097
|
-
throw err;
|
|
5098
|
-
}
|
|
5099
|
-
let payload = JSON.parse(raw);
|
|
5100
|
-
if (payload.schemaVersion === 1) {
|
|
5101
|
-
payload = migrateV1ToV2(payload);
|
|
5102
|
-
}
|
|
5103
|
-
if (payload.schemaVersion === 2) {
|
|
5104
|
-
payload = migrateV2ToV3(payload);
|
|
5105
|
-
}
|
|
5106
|
-
if (payload.schemaVersion !== SCHEMA_VERSION) {
|
|
5107
|
-
throw new Error(
|
|
5108
|
-
`persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
|
|
5109
|
-
);
|
|
5110
|
-
}
|
|
5111
|
-
graph.clear();
|
|
5112
|
-
graph.import(payload.graph);
|
|
5113
|
-
}
|
|
5114
|
-
function startPersistLoop(graph, outPath, intervalMs = 6e4) {
|
|
5115
|
-
let stopped = false;
|
|
5116
|
-
const tick = async () => {
|
|
5117
|
-
if (stopped) return;
|
|
5118
|
-
try {
|
|
5119
|
-
await saveGraphToDisk(graph, outPath);
|
|
5120
|
-
} catch (err) {
|
|
5121
|
-
console.error("persist: periodic save failed", err);
|
|
5122
|
-
}
|
|
5123
|
-
};
|
|
5124
|
-
const interval = setInterval(() => {
|
|
5125
|
-
void tick();
|
|
5126
|
-
}, intervalMs);
|
|
5127
|
-
const onSignal = (signal) => {
|
|
5128
|
-
void (async () => {
|
|
5129
|
-
try {
|
|
5130
|
-
await saveGraphToDisk(graph, outPath);
|
|
5131
|
-
} catch (err) {
|
|
5132
|
-
console.error(`persist: ${signal} save failed`, err);
|
|
5133
|
-
} finally {
|
|
5134
|
-
process.exit(0);
|
|
5135
|
-
}
|
|
5136
|
-
})();
|
|
5137
|
-
};
|
|
5138
|
-
process.on("SIGTERM", onSignal);
|
|
5139
|
-
process.on("SIGINT", onSignal);
|
|
5140
|
-
return () => {
|
|
5141
|
-
stopped = true;
|
|
5142
|
-
clearInterval(interval);
|
|
5143
|
-
process.off("SIGTERM", onSignal);
|
|
5144
|
-
process.off("SIGINT", onSignal);
|
|
5145
|
-
};
|
|
5146
|
-
}
|
|
5147
|
-
|
|
5148
5345
|
// src/server.ts
|
|
5149
5346
|
init_otel();
|
|
5150
5347
|
init_otel_grpc();
|
|
@@ -5489,7 +5686,8 @@ async function main() {
|
|
|
5489
5686
|
const app = await buildApi({
|
|
5490
5687
|
projects: registry,
|
|
5491
5688
|
authToken: auth.authToken,
|
|
5492
|
-
trustProxy: auth.trustProxy
|
|
5689
|
+
trustProxy: auth.trustProxy,
|
|
5690
|
+
publicRead: auth.publicRead
|
|
5493
5691
|
});
|
|
5494
5692
|
await app.listen({ port, host });
|
|
5495
5693
|
console.log(`neat-core listening on http://${host}:${port}`);
|