@neat.is/core 0.4.27 → 0.4.28-dev.20260708

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.
@@ -1,907 +0,0 @@
1
- import {
2
- DEFAULT_PROJECT,
3
- Projects,
4
- attachGraphToEventBus,
5
- buildApi,
6
- ensureObservedFileNode,
7
- ensureServiceNode,
8
- extractFromDirectory,
9
- getGraph,
10
- handleSpan,
11
- listProjects,
12
- loadGraphFromDisk,
13
- makeErrorSpanWriter,
14
- pathsForProject,
15
- pruneRegistry,
16
- reconcileObservedRelPath,
17
- registryPath,
18
- resetGraph,
19
- saveGraphToDisk,
20
- setStatus,
21
- startPersistLoop,
22
- startStalenessLoop,
23
- touchLastSeen,
24
- upsertObservedEdge,
25
- writeAtomically
26
- } from "./chunk-QM6BMPVJ.js";
27
- import {
28
- assertBindAuthority,
29
- buildOtelReceiver,
30
- listenSteppingOtlp,
31
- readAuthEnv
32
- } from "./chunk-A3322JYS.js";
33
-
34
- // src/daemon.ts
35
- import {
36
- promises as fs2,
37
- watch,
38
- renameSync,
39
- unlinkSync,
40
- writeFileSync
41
- } from "fs";
42
- import path2 from "path";
43
- import { createRequire } from "module";
44
-
45
- // src/connectors/index.ts
46
- var NO_ENV = "unknown";
47
- async function runConnectorPoll(connector, ctx, graph, resolveTarget) {
48
- const signals = await connector.poll(ctx);
49
- let edgesCreated = 0;
50
- let edgesUpdated = 0;
51
- let unresolved = 0;
52
- for (const signal of signals) {
53
- const resolved = resolveTarget(signal, ctx);
54
- if (!resolved) {
55
- unresolved++;
56
- continue;
57
- }
58
- const serviceNodeId = ensureServiceNode(graph, resolved.serviceName, NO_ENV);
59
- const callSite = signal.callSite ? { relPath: signal.callSite.file, line: signal.callSite.line } : void 0;
60
- const sourceId = callSite ? ensureObservedFileNode(graph, resolved.serviceName, serviceNodeId, callSite) : serviceNodeId;
61
- const evidence = callSite ? {
62
- file: reconcileObservedRelPath(graph, resolved.serviceName, callSite.relPath),
63
- line: callSite.line
64
- } : void 0;
65
- const calls = Math.trunc(signal.callCount);
66
- if (calls < 1) continue;
67
- const errors = Math.min(Math.max(Math.trunc(signal.errorCount), 0), calls);
68
- let created = false;
69
- let ok = true;
70
- for (let i = 0; i < calls; i++) {
71
- const result = upsertObservedEdge(
72
- graph,
73
- resolved.edgeType,
74
- sourceId,
75
- resolved.targetNodeId,
76
- signal.lastObservedIso,
77
- i < errors,
78
- evidence
79
- );
80
- if (!result) {
81
- ok = false;
82
- break;
83
- }
84
- if (i === 0) created = result.created;
85
- }
86
- if (!ok) {
87
- unresolved++;
88
- continue;
89
- }
90
- if (created) edgesCreated++;
91
- else edgesUpdated++;
92
- }
93
- return { signalCount: signals.length, edgesCreated, edgesUpdated, unresolved };
94
- }
95
- var DEFAULT_POLL_INTERVAL_MS = 6e4;
96
- function startConnectorPollLoop(connector, ctx, graph, resolveTarget, options = {}) {
97
- let stopped = false;
98
- let since = ctx.since;
99
- const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
100
- const onError = options.onError ?? ((err) => console.error(`[neatd] connector poll failed (${connector.provider})`, err));
101
- const tick = () => {
102
- if (stopped) return;
103
- void (async () => {
104
- const tickStartedAt = (/* @__PURE__ */ new Date()).toISOString();
105
- try {
106
- await runConnectorPoll(connector, { ...ctx, since }, graph, resolveTarget);
107
- since = tickStartedAt;
108
- } catch (err) {
109
- onError(err);
110
- }
111
- })();
112
- };
113
- const interval = setInterval(tick, intervalMs);
114
- if (typeof interval.unref === "function") interval.unref();
115
- return () => {
116
- stopped = true;
117
- clearInterval(interval);
118
- };
119
- }
120
-
121
- // src/unrouted.ts
122
- import { promises as fs } from "fs";
123
- import path from "path";
124
- function buildUnroutedSpanRecord(serviceName, traceId, now = /* @__PURE__ */ new Date()) {
125
- return {
126
- timestamp: now.toISOString(),
127
- reason: "no-project-match",
128
- service_name: serviceName ?? null,
129
- traceId: traceId ?? null
130
- };
131
- }
132
- async function appendUnroutedSpan(neatHome, record) {
133
- const target = path.join(neatHome, "errors.ndjson");
134
- await fs.mkdir(neatHome, { recursive: true });
135
- await fs.appendFile(target, JSON.stringify(record) + "\n", "utf8");
136
- }
137
- function unroutedErrorsPath(neatHome) {
138
- return path.join(neatHome, "errors.ndjson");
139
- }
140
-
141
- // src/daemon.ts
142
- import { NodeType } from "@neat.is/types";
143
- function daemonJsonPath(scanPath) {
144
- return path2.join(scanPath, "neat-out", "daemon.json");
145
- }
146
- function daemonsDiscoveryDir(home) {
147
- const base = home && home.length > 0 ? home : neatHomeFromEnv();
148
- return path2.join(base, "daemons");
149
- }
150
- function daemonDiscoveryPath(project, home) {
151
- return path2.join(daemonsDiscoveryDir(home), `${sanitizeDiscoveryName(project)}.json`);
152
- }
153
- function sanitizeDiscoveryName(project) {
154
- return project.replace(/[^A-Za-z0-9._-]/g, "_");
155
- }
156
- function neatHomeFromEnv() {
157
- const env = process.env.NEAT_HOME;
158
- if (env && env.length > 0) return path2.resolve(env);
159
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
160
- return path2.join(home, ".neat");
161
- }
162
- async function readDaemonRecord(scanPath) {
163
- try {
164
- const raw = await fs2.readFile(daemonJsonPath(scanPath), "utf8");
165
- const parsed = JSON.parse(raw);
166
- if (typeof parsed.project === "string" && parsed.ports && typeof parsed.ports.rest === "number" && typeof parsed.ports.otlp === "number" && typeof parsed.ports.web === "number") {
167
- return parsed;
168
- }
169
- return null;
170
- } catch {
171
- return null;
172
- }
173
- }
174
- function resolveNeatVersion() {
175
- if (process.env.NEAT_LOCAL_VERSION && process.env.NEAT_LOCAL_VERSION.length > 0) {
176
- return process.env.NEAT_LOCAL_VERSION;
177
- }
178
- try {
179
- const req = createRequire(import.meta.url);
180
- const pkg = req("../package.json");
181
- return typeof pkg.version === "string" ? pkg.version : "0.0.0";
182
- } catch {
183
- return "0.0.0";
184
- }
185
- }
186
- async function writeDaemonRecord(record, home) {
187
- const body = JSON.stringify(record, null, 2) + "\n";
188
- await writeAtomically(daemonJsonPath(record.projectPath), body);
189
- try {
190
- await writeAtomically(daemonDiscoveryPath(record.project, home), body);
191
- } catch (err) {
192
- console.warn(
193
- `neatd: could not write discovery copy for "${record.project}" \u2014 ${err.message}`
194
- );
195
- }
196
- }
197
- async function clearDaemonRecord(record, home) {
198
- try {
199
- const stopped = { ...record, status: "stopped" };
200
- await writeAtomically(daemonJsonPath(record.projectPath), JSON.stringify(stopped, null, 2) + "\n");
201
- } catch {
202
- }
203
- try {
204
- await fs2.unlink(daemonDiscoveryPath(record.project, home));
205
- } catch {
206
- }
207
- }
208
- function reconcileDaemonRecordSync(record, home) {
209
- try {
210
- const stopped = { ...record, status: "stopped" };
211
- const target = daemonJsonPath(record.projectPath);
212
- const tmp = `${target}.${process.pid}.tmp`;
213
- writeFileSync(tmp, JSON.stringify(stopped, null, 2) + "\n");
214
- renameSync(tmp, target);
215
- } catch {
216
- }
217
- try {
218
- unlinkSync(daemonDiscoveryPath(record.project, home));
219
- } catch {
220
- }
221
- }
222
- function teardownSlot(slot) {
223
- try {
224
- slot.stopPersist();
225
- } catch {
226
- }
227
- try {
228
- slot.stopStaleness();
229
- } catch {
230
- }
231
- try {
232
- slot.stopConnectors();
233
- } catch {
234
- }
235
- try {
236
- slot.detachEvents();
237
- } catch {
238
- }
239
- }
240
- function neatHomeFor(opts) {
241
- if (opts.neatHome && opts.neatHome.length > 0) return path2.resolve(opts.neatHome);
242
- const env = process.env.NEAT_HOME;
243
- if (env && env.length > 0) return path2.resolve(env);
244
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
245
- return path2.join(home, ".neat");
246
- }
247
- function routeSpanToProject(serviceName, projects) {
248
- if (!serviceName) return DEFAULT_PROJECT;
249
- for (const entry of projects) {
250
- if (entry.status === "paused") continue;
251
- if (entry.name === serviceName) return entry.name;
252
- }
253
- const candidates = [];
254
- for (const entry of projects) {
255
- if (entry.status === "paused") continue;
256
- if (isTokenPrefix(entry.name, serviceName)) candidates.push(entry);
257
- }
258
- if (candidates.length > 0) {
259
- candidates.sort((a, b) => b.name.length - a.name.length);
260
- return candidates[0].name;
261
- }
262
- for (const entry of projects) {
263
- if (entry.status === "paused") continue;
264
- if (isTokenContained(entry.name, serviceName)) return entry.name;
265
- }
266
- return DEFAULT_PROJECT;
267
- }
268
- function isTokenPrefix(prefix, full) {
269
- if (prefix.length >= full.length) return false;
270
- if (!full.startsWith(prefix)) return false;
271
- const sep = full.charAt(prefix.length);
272
- return sep === "-" || sep === "_";
273
- }
274
- function isTokenContained(needle, haystack) {
275
- if (!haystack.includes(needle)) return false;
276
- const tokens = haystack.split(/[-_]/);
277
- return tokens.includes(needle);
278
- }
279
- function serviceNameMatchesProject(serviceName, project) {
280
- if (serviceName === project) return true;
281
- if (isTokenPrefix(project, serviceName)) return true;
282
- if (isTokenContained(project, serviceName)) return true;
283
- return false;
284
- }
285
- function spanBelongsToSingleProject(graph, project, serviceName) {
286
- if (!serviceName) return true;
287
- if (serviceNameMatchesProject(serviceName, project)) return true;
288
- return graph.someNode(
289
- (_id, attrs) => attrs.type === NodeType.ServiceNode && attrs.name === serviceName
290
- );
291
- }
292
- async function bootstrapProject(entry, connectors = []) {
293
- const paths = pathsForProject(entry.name, path2.join(entry.path, "neat-out"));
294
- try {
295
- const stat = await fs2.stat(entry.path);
296
- if (!stat.isDirectory()) {
297
- throw new Error(`registered path ${entry.path} is not a directory`);
298
- }
299
- } catch (err) {
300
- await setStatus(entry.name, "broken").catch(() => {
301
- });
302
- return {
303
- entry,
304
- // Empty graph is fine — `slots` keeps the entry visible in `status`
305
- // output; nothing routes to it because it's not 'active'.
306
- graph: getGraph(`__broken__:${entry.name}`),
307
- outPath: "",
308
- paths,
309
- stopPersist: () => {
310
- },
311
- stopStaleness: () => {
312
- },
313
- stopConnectors: () => {
314
- },
315
- detachEvents: () => {
316
- },
317
- status: "broken",
318
- errorReason: err.message
319
- };
320
- }
321
- resetGraph(entry.name);
322
- const graph = getGraph(entry.name);
323
- const outPath = paths.snapshotPath;
324
- await loadGraphFromDisk(graph, outPath);
325
- const detachEvents = attachGraphToEventBus(graph, { project: entry.name });
326
- try {
327
- await extractFromDirectory(graph, entry.path);
328
- const stopPersist = startPersistLoop(graph, outPath, { exitOnSignal: false });
329
- const stopStaleness = startStalenessLoop(graph, {
330
- staleEventsPath: paths.staleEventsPath,
331
- project: entry.name
332
- });
333
- const stopFns = connectors.map(
334
- (registration) => startConnectorPollLoop(
335
- registration.connector,
336
- { projectDir: entry.path, credentials: registration.credentials },
337
- graph,
338
- registration.resolveTarget,
339
- { intervalMs: registration.intervalMs }
340
- )
341
- );
342
- const stopConnectors = () => {
343
- for (const stop of stopFns) stop();
344
- };
345
- await touchLastSeen(entry.name).catch(() => {
346
- });
347
- return {
348
- entry,
349
- graph,
350
- outPath,
351
- paths,
352
- stopPersist,
353
- stopStaleness,
354
- stopConnectors,
355
- detachEvents,
356
- status: "active"
357
- };
358
- } catch (err) {
359
- detachEvents();
360
- throw err;
361
- }
362
- }
363
- function resolveRestPort(opts) {
364
- if (typeof opts.restPort === "number") return opts.restPort;
365
- const env = process.env.PORT;
366
- if (env && env.length > 0) {
367
- const n = Number.parseInt(env, 10);
368
- if (Number.isFinite(n)) return n;
369
- }
370
- return 8080;
371
- }
372
- function resolveOtlpPort(opts) {
373
- if (typeof opts.otlpPort === "number") return opts.otlpPort;
374
- const env = process.env.OTEL_PORT;
375
- if (env && env.length > 0) {
376
- const n = Number.parseInt(env, 10);
377
- if (Number.isFinite(n)) return n;
378
- }
379
- return 4318;
380
- }
381
- function resolveWebPort() {
382
- const env = process.env.NEAT_WEB_PORT;
383
- if (env && env.length > 0) {
384
- const n = Number.parseInt(env, 10);
385
- if (Number.isFinite(n)) return n;
386
- }
387
- return 6328;
388
- }
389
- function portFromListenAddress(address, fallback) {
390
- try {
391
- const port = new URL(address).port;
392
- const n = Number.parseInt(port, 10);
393
- if (Number.isFinite(n) && n > 0) return n;
394
- } catch {
395
- }
396
- return fallback;
397
- }
398
- function resolveHost(opts, authTokenSet) {
399
- if (opts.host && opts.host.length > 0) return opts.host;
400
- const env = process.env.HOST;
401
- if (env && env.length > 0) return env;
402
- if (!authTokenSet) return "127.0.0.1";
403
- return "0.0.0.0";
404
- }
405
- async function startDaemon(opts = {}) {
406
- const home = neatHomeFor(opts);
407
- const regPath = registryPath();
408
- const projectArg = typeof opts.project === "string" && opts.project.length > 0 ? opts.project : process.env.NEAT_PROJECT && process.env.NEAT_PROJECT.length > 0 ? process.env.NEAT_PROJECT : null;
409
- const projectPathArg = opts.projectPath && opts.projectPath.length > 0 ? opts.projectPath : process.env.NEAT_PROJECT_PATH && process.env.NEAT_PROJECT_PATH.length > 0 ? process.env.NEAT_PROJECT_PATH : null;
410
- const singleProject = projectArg;
411
- const singleProjectPath = singleProject && projectPathArg ? path2.resolve(projectPathArg) : null;
412
- if (singleProject && !singleProjectPath) {
413
- throw new Error(
414
- `neatd: project "${singleProject}" given without a projectPath; pass NEAT_PROJECT_PATH alongside NEAT_PROJECT.`
415
- );
416
- }
417
- if (!singleProject) {
418
- try {
419
- await fs2.access(regPath);
420
- } catch {
421
- throw new Error(
422
- `neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
423
- );
424
- }
425
- }
426
- const pidPath = path2.join(home, "neatd.pid");
427
- await writeAtomically(pidPath, `${process.pid}
428
- `);
429
- const slots = /* @__PURE__ */ new Map();
430
- const registry = new Projects();
431
- const bootstrapStatus = /* @__PURE__ */ new Map();
432
- const bootstrapStartedAt = /* @__PURE__ */ new Map();
433
- const DROP_WARN_INTERVAL_MS = 6e4;
434
- const lastDropWarnAt = /* @__PURE__ */ new Map();
435
- function warnDroppedSpan(project, reason) {
436
- const now = Date.now();
437
- const prev = lastDropWarnAt.get(project) ?? 0;
438
- if (now - prev < DROP_WARN_INTERVAL_MS) return;
439
- lastDropWarnAt.set(project, now);
440
- console.warn(
441
- `[neatd] dropping span for project "${project}" \u2014 project status: broken (${reason}). Run \`neatd reload\` to retry bootstrap.`
442
- );
443
- }
444
- const unroutedPath = unroutedErrorsPath(home);
445
- const lastUnroutedWarnAt = /* @__PURE__ */ new Map();
446
- async function recordUnroutedSpan(serviceName, traceId) {
447
- const key = serviceName ?? "<missing>";
448
- const now = Date.now();
449
- try {
450
- await appendUnroutedSpan(home, buildUnroutedSpanRecord(serviceName, traceId, new Date(now)));
451
- } catch {
452
- }
453
- const prev = lastUnroutedWarnAt.get(key) ?? 0;
454
- if (now - prev < DROP_WARN_INTERVAL_MS) return;
455
- lastUnroutedWarnAt.set(key, now);
456
- console.warn(
457
- `[neatd] dropping span \u2014 service.name "${key}" matches no registered project and no \`default\` project exists. See ${unroutedPath}.`
458
- );
459
- }
460
- function upsertRegistryFromSlot(slot) {
461
- if (slot.status !== "active") return;
462
- registry.set(slot.entry.name, {
463
- scanPath: slot.entry.path,
464
- paths: slot.paths,
465
- graph: slot.graph
466
- });
467
- }
468
- async function tryRecoverSlot(entry) {
469
- try {
470
- const fresh = await bootstrapProject(entry, opts.connectors ?? []);
471
- const prior = slots.get(entry.name);
472
- if (prior) teardownSlot(prior);
473
- slots.set(entry.name, fresh);
474
- upsertRegistryFromSlot(fresh);
475
- if (fresh.status === "active") {
476
- await setStatus(entry.name, "active").catch(() => {
477
- });
478
- console.log(
479
- `neatd: project "${entry.name}" recovered from broken \u2014 active`
480
- );
481
- }
482
- return fresh;
483
- } catch (err) {
484
- console.warn(
485
- `neatd: project "${entry.name}" still broken after recovery attempt \u2014 ${err.message}`
486
- );
487
- return slots.get(entry.name);
488
- }
489
- }
490
- async function bootstrapOne(entry) {
491
- bootstrapStatus.set(entry.name, "bootstrapping");
492
- bootstrapStartedAt.set(entry.name, Date.now());
493
- try {
494
- const slot = await bootstrapProject(entry, opts.connectors ?? []);
495
- const prior = slots.get(entry.name);
496
- if (prior) teardownSlot(prior);
497
- slots.set(entry.name, slot);
498
- upsertRegistryFromSlot(slot);
499
- bootstrapStatus.set(entry.name, slot.status === "broken" ? "broken" : "active");
500
- if (slot.status === "broken") {
501
- console.warn(`neatd: project "${entry.name}" broken \u2014 ${slot.errorReason}`);
502
- } else {
503
- console.log(`neatd: project "${entry.name}" active (${entry.path})`);
504
- }
505
- } catch (err) {
506
- bootstrapStatus.set(entry.name, "broken");
507
- console.warn(
508
- `neatd: project "${entry.name}" failed to bootstrap \u2014 ${err.message}`
509
- );
510
- await setStatus(entry.name, "broken").catch(() => {
511
- });
512
- }
513
- }
514
- async function enumerateProjects() {
515
- if (singleProject && singleProjectPath) {
516
- return [
517
- {
518
- name: singleProject,
519
- path: singleProjectPath,
520
- registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
521
- languages: [],
522
- status: "active"
523
- }
524
- ];
525
- }
526
- return listProjects();
527
- }
528
- async function loadAll() {
529
- if (!singleProject) {
530
- try {
531
- const pruned = await pruneRegistry();
532
- for (const entry of pruned) {
533
- console.log(
534
- `neatd: pruned project "${entry.name}" \u2014 registered path ${entry.path} is gone`
535
- );
536
- slots.delete(entry.name);
537
- bootstrapStatus.delete(entry.name);
538
- bootstrapStartedAt.delete(entry.name);
539
- }
540
- } catch (err) {
541
- console.warn(`neatd: registry prune skipped \u2014 ${err.message}`);
542
- }
543
- }
544
- const projects = await enumerateProjects();
545
- const seen = /* @__PURE__ */ new Set();
546
- const pending = [];
547
- for (const entry of projects) {
548
- seen.add(entry.name);
549
- const existing = slots.get(entry.name);
550
- if (existing) {
551
- if (existing.status === "broken") {
552
- pending.push(tryRecoverSlot(entry).then(() => {
553
- }));
554
- }
555
- continue;
556
- }
557
- pending.push(bootstrapOne(entry));
558
- }
559
- for (const [name, slot] of [...slots.entries()]) {
560
- if (seen.has(name)) continue;
561
- teardownSlot(slot);
562
- slots.delete(name);
563
- bootstrapStatus.delete(name);
564
- bootstrapStartedAt.delete(name);
565
- console.log(`neatd: project "${name}" removed from registry \u2014 stopped`);
566
- }
567
- await Promise.allSettled(pending);
568
- }
569
- const initialEntries = await enumerateProjects().catch(() => []);
570
- for (const entry of initialEntries) {
571
- bootstrapStatus.set(entry.name, "bootstrapping");
572
- bootstrapStartedAt.set(entry.name, Date.now());
573
- }
574
- const bind = opts.bindListeners !== false;
575
- let restApp = null;
576
- let otlpApp = null;
577
- let restAddress = "";
578
- let otlpAddress = "";
579
- let daemonRecord = null;
580
- if (bind) {
581
- const auth = readAuthEnv();
582
- const host = resolveHost(opts, Boolean(auth.authToken));
583
- const restPort = resolveRestPort(opts);
584
- const otlpPort = resolveOtlpPort(opts);
585
- assertBindAuthority(host, auth.authToken);
586
- try {
587
- restApp = await buildApi({
588
- projects: registry,
589
- authToken: auth.authToken,
590
- trustProxy: auth.trustProxy,
591
- publicRead: auth.publicRead,
592
- bootstrap: {
593
- status: (name) => bootstrapStatus.get(name),
594
- list: () => {
595
- const now = Date.now();
596
- return [...bootstrapStatus.entries()].map(([name, status]) => ({
597
- name,
598
- status,
599
- elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
600
- }));
601
- }
602
- },
603
- // ADR-096 §4/§5/§7 — hand the daemon's identity to buildApi so the REST
604
- // surface reflects "the daemon is the project": `GET /projects` reports
605
- // only this project (the dashboard pins to it), and the daemon-wide
606
- // `/health` carries it at the top level for the spawn-reuse identity
607
- // check. Absent for the legacy multi-project daemon.
608
- singleProject: singleProject && singleProjectPath ? { name: singleProject, path: singleProjectPath } : void 0
609
- });
610
- restAddress = await restApp.listen({ port: restPort, host });
611
- console.log(
612
- `neatd: REST listening on http://${host}:${portFromListenAddress(restAddress, restPort)}`
613
- );
614
- } catch (err) {
615
- for (const slot of slots.values()) {
616
- teardownSlot(slot);
617
- }
618
- if (restApp) await restApp.close().catch(() => {
619
- });
620
- await fs2.unlink(pidPath).catch(() => {
621
- });
622
- throw new Error(
623
- `neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
624
- );
625
- }
626
- async function resolveTargetSlot(serviceName, traceId) {
627
- if (singleProject) {
628
- let slot2 = slots.get(singleProject);
629
- if (!slot2) {
630
- slot2 = await tryRecoverSlot({
631
- name: singleProject,
632
- path: singleProjectPath,
633
- registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
634
- languages: [],
635
- status: "active"
636
- });
637
- } else if (slot2.status === "broken") {
638
- slot2 = await tryRecoverSlot(slot2.entry);
639
- }
640
- if (!slot2 || slot2.status !== "active") {
641
- warnDroppedSpan(singleProject, slot2?.errorReason ?? "unknown");
642
- return null;
643
- }
644
- if (!spanBelongsToSingleProject(slot2.graph, singleProject, serviceName)) {
645
- await recordUnroutedSpan(serviceName, traceId);
646
- return null;
647
- }
648
- return slot2;
649
- }
650
- const liveEntries = await listProjects().catch(() => []);
651
- const target = routeSpanToProject(serviceName, liveEntries);
652
- let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
653
- if (!slot) {
654
- await recordUnroutedSpan(serviceName, traceId);
655
- return null;
656
- }
657
- if (slot.status === "broken") {
658
- const entry = liveEntries.find((e) => e.name === slot.entry.name);
659
- if (entry) {
660
- slot = await tryRecoverSlot(entry);
661
- }
662
- if (slot.status !== "active") {
663
- warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
664
- return null;
665
- }
666
- }
667
- return slot.status === "active" ? slot : null;
668
- }
669
- async function resolveSlotByName(project, serviceName, traceId) {
670
- const liveEntries = await listProjects().catch(() => []);
671
- let slot = slots.get(project);
672
- if (!slot) {
673
- await recordUnroutedSpan(serviceName, traceId);
674
- return null;
675
- }
676
- if (slot.status === "broken") {
677
- const entry = liveEntries.find((e) => e.name === slot.entry.name);
678
- if (entry) {
679
- slot = await tryRecoverSlot(entry);
680
- }
681
- if (slot.status !== "active") {
682
- warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
683
- return null;
684
- }
685
- }
686
- return slot.status === "active" ? slot : null;
687
- }
688
- try {
689
- otlpApp = await buildOtelReceiver({
690
- authToken: auth.otelToken,
691
- trustProxy: auth.trustProxy,
692
- onSpan: async (span) => {
693
- const slot = await resolveTargetSlot(span.service, span.traceId);
694
- if (!slot) return;
695
- await handleSpan(
696
- {
697
- graph: slot.graph,
698
- errorsPath: slot.paths.errorsPath,
699
- scanPath: slot.entry.path,
700
- project: slot.entry.name,
701
- // Receiver already wrote the error event synchronously below.
702
- writeErrorEventInline: false
703
- },
704
- span
705
- );
706
- },
707
- onErrorSpanSync: async (span) => {
708
- const slot = await resolveTargetSlot(span.service, span.traceId);
709
- if (!slot) return;
710
- await makeErrorSpanWriter(slot.paths.errorsPath, slot.graph, slot.entry.path)(span);
711
- },
712
- // Project-scoped route (issue #367) — the URL already named the
713
- // project. Resolution is a direct slot lookup; service.name resolves
714
- // the ServiceNode inside the slot's graph instead of which project
715
- // owns the span.
716
- onProjectSpan: async (project, span) => {
717
- const slot = await resolveSlotByName(project, span.service, span.traceId);
718
- if (!slot) return;
719
- await handleSpan(
720
- {
721
- graph: slot.graph,
722
- errorsPath: slot.paths.errorsPath,
723
- scanPath: slot.entry.path,
724
- project: slot.entry.name,
725
- writeErrorEventInline: false
726
- },
727
- span
728
- );
729
- },
730
- onProjectErrorSpanSync: async (project, span) => {
731
- const slot = await resolveSlotByName(project, span.service, span.traceId);
732
- if (!slot) return;
733
- await makeErrorSpanWriter(slot.paths.errorsPath, slot.graph, slot.entry.path)(span);
734
- }
735
- });
736
- otlpAddress = await listenSteppingOtlp(otlpApp, otlpPort, host);
737
- console.log(`neatd: OTLP listening on ${otlpAddress}/v1/traces`);
738
- } catch (err) {
739
- for (const slot of slots.values()) {
740
- teardownSlot(slot);
741
- }
742
- if (restApp) await restApp.close().catch(() => {
743
- });
744
- if (otlpApp) await otlpApp.close().catch(() => {
745
- });
746
- await fs2.unlink(pidPath).catch(() => {
747
- });
748
- throw new Error(
749
- `neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
750
- );
751
- }
752
- if (singleProject && singleProjectPath) {
753
- const ports = {
754
- rest: portFromListenAddress(restAddress, restPort),
755
- otlp: portFromListenAddress(otlpAddress, otlpPort),
756
- // The daemon doesn't bind the web port itself (neatd spawns the web
757
- // child); it records the allocated value passed through so the
758
- // dashboard and `neat ps` agree on where to look.
759
- web: typeof opts.webPort === "number" ? opts.webPort : resolveWebPort()
760
- };
761
- daemonRecord = {
762
- project: singleProject,
763
- projectPath: singleProjectPath,
764
- pid: process.pid,
765
- status: "running",
766
- ports,
767
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
768
- neatVersion: resolveNeatVersion()
769
- };
770
- try {
771
- await writeDaemonRecord(daemonRecord, home);
772
- console.log(
773
- `neatd: project "${singleProject}" \u2192 REST ${ports.rest} / OTLP ${ports.otlp} / web ${ports.web} (daemon.json written)`
774
- );
775
- } catch (err) {
776
- for (const slot of slots.values()) teardownSlot(slot);
777
- if (restApp) await restApp.close().catch(() => {
778
- });
779
- if (otlpApp) await otlpApp.close().catch(() => {
780
- });
781
- await fs2.unlink(pidPath).catch(() => {
782
- });
783
- throw new Error(
784
- `neatd: failed to write daemon.json for "${singleProject}" \u2014 ${err.message}`
785
- );
786
- }
787
- }
788
- }
789
- const initialBootstrap = loadAll().catch((err) => {
790
- console.warn(`neatd: initial bootstrap pass failed \u2014 ${err.message}`);
791
- });
792
- let reloading = initialBootstrap;
793
- const reload = async () => {
794
- if (reloading) return reloading;
795
- reloading = (async () => {
796
- try {
797
- await loadAll();
798
- } finally {
799
- reloading = null;
800
- }
801
- })();
802
- return reloading;
803
- };
804
- void initialBootstrap.finally(() => {
805
- if (reloading === initialBootstrap) reloading = null;
806
- });
807
- const tracker = {
808
- status: (name) => bootstrapStatus.get(name),
809
- list: () => {
810
- const now = Date.now();
811
- return [...bootstrapStatus.entries()].map(([name, status]) => ({
812
- name,
813
- status,
814
- elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
815
- }));
816
- }
817
- };
818
- const sighupHandler = () => {
819
- void reload().catch((err) => {
820
- console.warn(`neatd: SIGHUP reload failed \u2014 ${err.message}`);
821
- });
822
- };
823
- process.on("SIGHUP", sighupHandler);
824
- const REGISTRY_RELOAD_DEBOUNCE_MS = 500;
825
- let registryWatcher = null;
826
- let reloadTimer = null;
827
- if (!singleProject) try {
828
- const regDir = path2.dirname(regPath);
829
- const regBase = path2.basename(regPath);
830
- registryWatcher = watch(regDir, (_eventType, filename) => {
831
- if (filename !== null && filename !== regBase) return;
832
- if (reloadTimer) clearTimeout(reloadTimer);
833
- reloadTimer = setTimeout(() => {
834
- reloadTimer = null;
835
- void reload().catch((err) => {
836
- console.warn(
837
- `neatd: registry-watch reload failed \u2014 ${err.message}`
838
- );
839
- });
840
- }, REGISTRY_RELOAD_DEBOUNCE_MS);
841
- });
842
- } catch (err) {
843
- console.warn(
844
- `neatd: failed to watch registry at ${regPath} \u2014 ${err.message}. Run \`neatd reload\` (or send SIGHUP) after registering new projects.`
845
- );
846
- }
847
- let stopped = false;
848
- const stop = async () => {
849
- if (stopped) return;
850
- stopped = true;
851
- process.off("SIGHUP", sighupHandler);
852
- if (reloadTimer) {
853
- clearTimeout(reloadTimer);
854
- reloadTimer = null;
855
- }
856
- if (registryWatcher) {
857
- try {
858
- registryWatcher.close();
859
- } catch {
860
- }
861
- registryWatcher = null;
862
- }
863
- if (otlpApp) await otlpApp.close().catch(() => {
864
- });
865
- if (restApp) await restApp.close().catch(() => {
866
- });
867
- for (const slot of slots.values()) {
868
- if (slot.status === "active" && slot.outPath) {
869
- await saveGraphToDisk(slot.graph, slot.outPath).catch(() => {
870
- });
871
- }
872
- }
873
- for (const slot of slots.values()) {
874
- teardownSlot(slot);
875
- }
876
- if (daemonRecord) {
877
- await clearDaemonRecord(daemonRecord, home);
878
- }
879
- await fs2.unlink(pidPath).catch(() => {
880
- });
881
- };
882
- return {
883
- slots,
884
- reload,
885
- stop,
886
- pidPath,
887
- restAddress,
888
- otlpAddress,
889
- bootstrap: tracker,
890
- initialBootstrap,
891
- daemonRecord,
892
- neatHome: home
893
- };
894
- }
895
-
896
- export {
897
- readDaemonRecord,
898
- resolveNeatVersion,
899
- writeDaemonRecord,
900
- clearDaemonRecord,
901
- reconcileDaemonRecordSync,
902
- routeSpanToProject,
903
- portFromListenAddress,
904
- resolveHost,
905
- startDaemon
906
- };
907
- //# sourceMappingURL=chunk-XV4D7A3Z.js.map