@neat.is/core 0.3.0 → 0.3.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.
@@ -1,8 +1,9 @@
1
- // src/otel-grpc.ts
2
- import { fileURLToPath as fileURLToPath2 } from "url";
3
- import path2 from "path";
4
- import * as grpc from "@grpc/grpc-js";
5
- import * as protoLoader from "@grpc/proto-loader";
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
6
7
 
7
8
  // src/otel.ts
8
9
  import path from "path";
@@ -115,8 +116,8 @@ function loadProtobufDecoder() {
115
116
  async function decodeProtobufBody(buf) {
116
117
  const Type = loadProtobufDecoder();
117
118
  const decoded = Type.decode(buf).toJSON();
118
- const { reshapeGrpcRequest: reshapeGrpcRequest2 } = await import("./otel-grpc-USFL3OI3.js");
119
- return reshapeGrpcRequest2(decoded);
119
+ const { reshapeGrpcRequest } = await import("./otel-grpc-J4O2SIBZ.js");
120
+ return reshapeGrpcRequest(decoded);
120
121
  }
121
122
  async function buildOtelReceiver(opts) {
122
123
  const app = Fastify({
@@ -196,130 +197,17 @@ async function buildOtelReceiver(opts) {
196
197
  }
197
198
  function logSpanHandler(span) {
198
199
  const parent = span.parentSpanId ? span.parentSpanId.slice(0, 8) : "<root>";
199
- const status2 = span.statusCode === 2 ? "ERROR" : "OK";
200
+ const status = span.statusCode === 2 ? "ERROR" : "OK";
200
201
  const db = span.dbSystem ? ` db=${span.dbSystem}/${span.dbName ?? "?"}` : "";
201
202
  console.log(
202
- `otel: ${span.service} ${span.name} parent=${parent} status=${status2}${db}`
203
+ `otel: ${span.service} ${span.name} parent=${parent} status=${status}${db}`
203
204
  );
204
205
  }
205
206
 
206
- // src/otel-grpc.ts
207
- function bytesToHex(buf) {
208
- if (!buf) return "";
209
- return Buffer.isBuffer(buf) ? buf.toString("hex") : "";
210
- }
211
- function nanosToString(n) {
212
- if (n === void 0 || n === null) return "0";
213
- return typeof n === "string" ? n : String(n);
214
- }
215
- function reshapeAttributes(attrs) {
216
- const out = (attrs ?? []).map((kv) => ({
217
- key: kv.key ?? "",
218
- value: kv.value ? {
219
- stringValue: kv.value.string_value,
220
- boolValue: kv.value.bool_value,
221
- intValue: kv.value.int_value,
222
- doubleValue: kv.value.double_value,
223
- arrayValue: kv.value.array_value ? {
224
- values: (kv.value.array_value.values ?? []).map((v) => ({
225
- stringValue: v.string_value,
226
- boolValue: v.bool_value,
227
- intValue: v.int_value,
228
- doubleValue: v.double_value
229
- }))
230
- } : void 0
231
- } : void 0
232
- }));
233
- return out;
234
- }
235
- function reshapeGrpcRequest(req) {
236
- return {
237
- resourceSpans: (req.resource_spans ?? []).map((rs) => ({
238
- resource: rs.resource ? { attributes: reshapeAttributes(rs.resource.attributes) } : void 0,
239
- scopeSpans: (rs.scope_spans ?? []).map((ss) => ({
240
- spans: (ss.spans ?? []).map((s) => ({
241
- traceId: bytesToHex(s.trace_id),
242
- spanId: bytesToHex(s.span_id),
243
- parentSpanId: s.parent_span_id ? bytesToHex(s.parent_span_id) : void 0,
244
- name: s.name,
245
- kind: s.kind,
246
- startTimeUnixNano: nanosToString(s.start_time_unix_nano),
247
- endTimeUnixNano: nanosToString(s.end_time_unix_nano),
248
- attributes: reshapeAttributes(s.attributes),
249
- events: (s.events ?? []).map((e) => ({
250
- name: e.name,
251
- timeUnixNano: nanosToString(e.time_unix_nano),
252
- attributes: reshapeAttributes(e.attributes)
253
- })),
254
- status: s.status ? { code: s.status.code, message: s.status.message } : void 0
255
- }))
256
- }))
257
- }))
258
- };
259
- }
260
- function resolveProtoRoot() {
261
- const here = path2.dirname(fileURLToPath2(import.meta.url));
262
- return path2.resolve(here, "..", "proto");
263
- }
264
- function loadTraceService() {
265
- const protoRoot = resolveProtoRoot();
266
- const def = protoLoader.loadSync(
267
- "opentelemetry/proto/collector/trace/v1/trace_service.proto",
268
- {
269
- keepCase: true,
270
- longs: String,
271
- enums: Number,
272
- defaults: true,
273
- oneofs: true,
274
- includeDirs: [protoRoot]
275
- }
276
- );
277
- const pkg = grpc.loadPackageDefinition(def);
278
- return pkg.opentelemetry.proto.collector.trace.v1.TraceService.service;
279
- }
280
- async function startOtelGrpcReceiver(opts) {
281
- const server = new grpc.Server();
282
- const service = loadTraceService();
283
- server.addService(service, {
284
- Export: (call, callback) => {
285
- void (async () => {
286
- try {
287
- const reshaped = reshapeGrpcRequest(call.request ?? {});
288
- const spans = parseOtlpRequest(reshaped);
289
- for (const span of spans) {
290
- await opts.onSpan(span);
291
- }
292
- callback(null, { partial_success: {} });
293
- } catch (err) {
294
- callback({
295
- code: grpc.status.INTERNAL,
296
- message: err instanceof Error ? err.message : String(err)
297
- });
298
- }
299
- })();
300
- }
301
- });
302
- const host = opts.host ?? "0.0.0.0";
303
- const port = opts.port ?? 4317;
304
- const boundPort = await new Promise((resolve, reject) => {
305
- server.bindAsync(`${host}:${port}`, grpc.ServerCredentials.createInsecure(), (err, p) => {
306
- if (err) return reject(err);
307
- resolve(p);
308
- });
309
- });
310
- return {
311
- address: `${host}:${boundPort}`,
312
- stop: () => new Promise((resolve) => {
313
- server.tryShutdown(() => resolve());
314
- })
315
- };
316
- }
317
-
318
207
  export {
319
- reshapeGrpcRequest,
320
- startOtelGrpcReceiver,
208
+ __require,
321
209
  parseOtlpRequest,
322
210
  buildOtelReceiver,
323
211
  logSpanHandler
324
212
  };
325
- //# sourceMappingURL=chunk-QYUB3FQL.js.map
213
+ //# sourceMappingURL=chunk-4ASCXBZF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/otel.ts"],"sourcesContent":["import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport Fastify, { type FastifyInstance } from 'fastify'\nimport protobuf from 'protobufjs'\n\n// OTLP/HTTP receiver. Listens on /v1/traces and decodes the JSON wire format\n// (collector's `otlphttp` exporter with `encoding: json`). Each span is\n// flattened into a ParsedSpan and handed to the configured handler. The\n// handler is the seam #8 wires its edge mapper into; #7 itself stays decoupled\n// from graph mutation.\n\nexport interface ParsedSpan {\n service: string\n traceId: string\n spanId: string\n parentSpanId?: string\n name: string\n kind?: number\n startTimeUnixNano: string\n endTimeUnixNano: string\n // ISO8601 derived from startTimeUnixNano. Production paths (lastObserved on\n // OBSERVED edges) read this so the recorded time reflects when the span fired,\n // not when the receiver received it. Undefined only when startTimeUnixNano is\n // missing or unparseable — handler falls back to wall-clock in that case.\n // See docs/contracts/otel-ingest.md §lastObserved-from-span-time.\n startTimeIso?: string\n // bigint so the 9-digit-nanos arithmetic doesn't lose precision on long traces.\n durationNanos: bigint\n attributes: Record<string, AttributeValue>\n // Convenience accessors for the attributes #8 cares about.\n dbSystem?: string\n dbName?: string\n // 0 = UNSET, 1 = OK, 2 = ERROR per OTLP. We only care that 2 means error.\n statusCode?: number\n errorMessage?: string\n // Pre-extracted from a span event with name=\"exception\". OTLP SDKs record\n // exceptions this way (richer than status.message). handleSpan reads these\n // first, falling back to status.message and span.name. See\n // docs/contracts/otel-ingest.md §exception-data-from-span-events.\n exception?: {\n type?: string\n message?: string\n stacktrace?: string\n }\n}\n\nexport type AttributeValue =\n | string\n | number\n | boolean\n | bigint\n | string[]\n | number[]\n | boolean[]\n | null\n\nexport type SpanHandler = (span: ParsedSpan) => void | Promise<void>\n\nexport interface BuildOtelReceiverOptions {\n onSpan: SpanHandler\n // Synchronous handler for spans with statusCode === 2. The receiver awaits\n // it before replying, so a write failure can return 500 → OTel SDK retries.\n // Optional — wiring is expected to plumb appendErrorEvent here when error\n // durability matters; ad-hoc receivers leave it undefined.\n // See docs/contracts/otel-ingest.md §Error events.\n onErrorSpanSync?: (span: ParsedSpan) => Promise<void>\n // Fastify body limit. OTLP batches can be large; default is 16 MB.\n bodyLimit?: number\n}\n\ninterface OtlpKeyValue {\n key: string\n value?: OtlpAnyValue\n}\n\ninterface OtlpAnyValue {\n stringValue?: string\n intValue?: string | number\n doubleValue?: number\n boolValue?: boolean\n arrayValue?: { values?: OtlpAnyValue[] }\n // kvlistValue / bytesValue are skipped — neither is on the demo path.\n}\n\ninterface OtlpStatus {\n code?: number\n message?: string\n}\n\ninterface OtlpEvent {\n name?: string\n timeUnixNano?: string\n attributes?: OtlpKeyValue[]\n}\n\ninterface OtlpSpan {\n traceId?: string\n spanId?: string\n parentSpanId?: string\n name?: string\n kind?: number\n startTimeUnixNano?: string\n endTimeUnixNano?: string\n attributes?: OtlpKeyValue[]\n events?: OtlpEvent[]\n status?: OtlpStatus\n}\n\nfunction extractExceptionFromEvents(events: OtlpEvent[] | undefined): ParsedSpan['exception'] {\n if (!events) return undefined\n for (const ev of events) {\n if (ev.name !== 'exception') continue\n const attrs = attrsToRecord(ev.attributes)\n const out: ParsedSpan['exception'] = {}\n const t = attrs['exception.type']\n const m = attrs['exception.message']\n const s = attrs['exception.stacktrace']\n if (typeof t === 'string') out.type = t\n if (typeof m === 'string') out.message = m\n if (typeof s === 'string') out.stacktrace = s\n if (out.type || out.message || out.stacktrace) return out\n }\n return undefined\n}\n\ninterface OtlpScopeSpans {\n spans?: OtlpSpan[]\n}\n\ninterface OtlpResourceSpans {\n resource?: { attributes?: OtlpKeyValue[] }\n scopeSpans?: OtlpScopeSpans[]\n}\n\nexport interface OtlpTracesRequest {\n resourceSpans?: OtlpResourceSpans[]\n}\n\nfunction flattenAttribute(v: OtlpAnyValue | undefined): AttributeValue {\n if (!v) return null\n if (v.stringValue !== undefined) return v.stringValue\n if (v.boolValue !== undefined) return v.boolValue\n if (v.intValue !== undefined) {\n return typeof v.intValue === 'string' ? Number(v.intValue) : v.intValue\n }\n if (v.doubleValue !== undefined) return v.doubleValue\n if (v.arrayValue?.values) {\n return v.arrayValue.values.map((x) => flattenAttribute(x)) as AttributeValue\n }\n return null\n}\n\nfunction attrsToRecord(attrs: OtlpKeyValue[] | undefined): Record<string, AttributeValue> {\n const out: Record<string, AttributeValue> = {}\n if (!attrs) return out\n for (const kv of attrs) {\n if (kv.key) out[kv.key] = flattenAttribute(kv.value)\n }\n return out\n}\n\nfunction durationNanos(start?: string, end?: string): bigint {\n if (!start || !end) return 0n\n try {\n return BigInt(end) - BigInt(start)\n } catch {\n return 0n\n }\n}\n\n// Convert OTLP's startTimeUnixNano (a base-10 string of nanoseconds since the\n// Unix epoch) to ISO8601. Returns undefined when the input is missing, zero,\n// or unparseable, so the caller can fall back to wall-clock without surfacing\n// a fake timestamp on the edge.\nexport function isoFromUnixNano(nanos: string | undefined): string | undefined {\n if (!nanos || nanos === '0') return undefined\n try {\n const ms = Number(BigInt(nanos) / 1_000_000n)\n if (!Number.isFinite(ms)) return undefined\n return new Date(ms).toISOString()\n } catch {\n return undefined\n }\n}\n\nexport function parseOtlpRequest(body: OtlpTracesRequest): ParsedSpan[] {\n const out: ParsedSpan[] = []\n for (const rs of body.resourceSpans ?? []) {\n const resourceAttrs = attrsToRecord(rs.resource?.attributes)\n const service = typeof resourceAttrs['service.name'] === 'string'\n ? (resourceAttrs['service.name'] as string)\n : 'unknown'\n\n for (const ss of rs.scopeSpans ?? []) {\n for (const span of ss.spans ?? []) {\n const attrs = attrsToRecord(span.attributes)\n const parsed: ParsedSpan = {\n service,\n traceId: span.traceId ?? '',\n spanId: span.spanId ?? '',\n parentSpanId: span.parentSpanId || undefined,\n name: span.name ?? '',\n kind: span.kind,\n startTimeUnixNano: span.startTimeUnixNano ?? '0',\n endTimeUnixNano: span.endTimeUnixNano ?? '0',\n startTimeIso: isoFromUnixNano(span.startTimeUnixNano),\n durationNanos: durationNanos(span.startTimeUnixNano, span.endTimeUnixNano),\n attributes: attrs,\n dbSystem: typeof attrs['db.system'] === 'string' ? (attrs['db.system'] as string) : undefined,\n dbName: typeof attrs['db.name'] === 'string' ? (attrs['db.name'] as string) : undefined,\n statusCode: span.status?.code,\n errorMessage: span.status?.message,\n exception: extractExceptionFromEvents(span.events),\n }\n out.push(parsed)\n }\n }\n }\n return out\n}\n\nexport interface OtelReceiver {\n app: FastifyInstance\n // Resolves once every span enqueued so far has been handed to opts.onSpan.\n // Test seam — production code never awaits this.\n flushPending: () => Promise<void>\n}\n\n// Lazy-loaded protobuf decoder for ExportTraceServiceRequest. The bundled\n// .proto tree at packages/core/proto/ is shared with the gRPC receiver\n// (ADR-020). Cached after first load so successive receiver builds reuse it.\nlet exportTraceServiceRequestType: protobuf.Type | null = null\n\nfunction loadProtobufDecoder(): protobuf.Type {\n if (exportTraceServiceRequestType) return exportTraceServiceRequestType\n const here = path.dirname(fileURLToPath(import.meta.url))\n const protoRoot = path.resolve(here, '..', 'proto')\n const root = new protobuf.Root()\n root.resolvePath = (_origin, target) => path.resolve(protoRoot, target)\n root.loadSync(\n 'opentelemetry/proto/collector/trace/v1/trace_service.proto',\n { keepCase: true },\n )\n exportTraceServiceRequestType = root.lookupType(\n 'opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest',\n )\n return exportTraceServiceRequestType\n}\n\nasync function decodeProtobufBody(buf: Buffer): Promise<OtlpTracesRequest> {\n const Type = loadProtobufDecoder()\n // Decode keeps the proto field names verbatim (keepCase: true), matching the\n // GrpcExportRequest shape that reshapeGrpcRequest already understands.\n // Dynamic import sidesteps the circular module dep with otel-grpc.ts.\n const decoded = Type.decode(buf).toJSON() as Record<string, unknown>\n const { reshapeGrpcRequest } = await import('./otel-grpc.js')\n return reshapeGrpcRequest(decoded as never)\n}\n\nexport async function buildOtelReceiver(\n opts: BuildOtelReceiverOptions,\n): Promise<FastifyInstance & { flushPending: () => Promise<void> }> {\n const app = Fastify({\n logger: false,\n bodyLimit: opts.bodyLimit ?? 16 * 1024 * 1024,\n })\n\n // Non-blocking ingest (ADR-033). The receiver replies 200 OK as soon as the\n // body is parsed; mutation runs through this queue, drained on the next tick.\n // OTel SDK exporters retry on timeout, so blocking ingest produces observable\n // backpressure on the system being observed — ambient observation requires no\n // observable effect.\n const queue: ParsedSpan[] = []\n let draining = false\n let drainPromise: Promise<void> = Promise.resolve()\n\n const drain = async (): Promise<void> => {\n if (draining) return\n draining = true\n try {\n while (queue.length > 0) {\n const span = queue.shift()!\n try {\n await opts.onSpan(span)\n } catch (err) {\n console.warn(`[neat] otel handler error: ${(err as Error).message}`)\n }\n }\n } finally {\n draining = false\n }\n }\n\n const enqueue = (spans: ParsedSpan[]): void => {\n if (spans.length === 0) return\n for (const s of spans) queue.push(s)\n // Schedule on the next tick so the 200 response is on the wire before any\n // mutation runs. Each call gets its own promise so flushPending() can wait\n // on the latest drain cycle.\n drainPromise = drainPromise.then(() => drain())\n }\n\n // Buffer application/x-protobuf bodies as raw bytes; the route handler\n // decodes them via the bundled .proto tree (ADR-020).\n app.addContentTypeParser(\n 'application/x-protobuf',\n { parseAs: 'buffer', bodyLimit: opts.bodyLimit ?? 16 * 1024 * 1024 },\n (_req, body, done) => {\n done(null, body)\n },\n )\n\n app.get('/health', async () => ({ ok: true }))\n\n app.post('/v1/traces', async (req, reply) => {\n // Content-Type dispatch (ADR-033). Both JSON and protobuf decode to the\n // same OtlpTracesRequest shape, then feed parseOtlpRequest unchanged.\n const ct = (req.headers['content-type'] ?? '').toString().split(';')[0]!.trim().toLowerCase()\n let body: OtlpTracesRequest\n if (ct === 'application/x-protobuf') {\n try {\n body = await decodeProtobufBody(req.body as Buffer)\n } catch (err) {\n return reply.code(400).send({\n error: `protobuf decode failed: ${(err as Error).message}`,\n })\n }\n } else if (!ct || ct === 'application/json') {\n body = (req.body ?? {}) as OtlpTracesRequest\n } else {\n return reply.code(415).send({ error: `unsupported content-type: ${ct}` })\n }\n const spans = parseOtlpRequest(body)\n // Synchronous error-event write before reply (ADR-033 §Error events).\n // Graph mutation stays on the async queue, but the receiver awaits the\n // file write so a write failure surfaces as 500 → OTel SDK retries.\n if (opts.onErrorSpanSync) {\n try {\n for (const span of spans) {\n if (span.statusCode === 2) await opts.onErrorSpanSync(span)\n }\n } catch (err) {\n return reply.code(500).send({\n error: `error-event write failed: ${(err as Error).message}`,\n })\n }\n }\n enqueue(spans)\n // OTLP success response is `{ partialSuccess: {} }` for \"all accepted\".\n return reply.code(200).send({ partialSuccess: {} })\n })\n\n // Attach flushPending so tests can wait for the queue without exporting a\n // separate handle. The cast goes through `unknown` because Fastify's typing\n // is parameterised over the raw server type and the simple intersection\n // confuses TS's structural narrowing.\n const decorated = app as unknown as FastifyInstance & { flushPending: () => Promise<void> }\n decorated.flushPending = async () => {\n // Settle the current drain chain, then loop until the queue is fully empty\n // (a span enqueued mid-flush would otherwise be missed).\n while (queue.length > 0 || draining) {\n await drainPromise\n }\n }\n return decorated\n}\n\nexport function logSpanHandler(span: ParsedSpan): void {\n const parent = span.parentSpanId ? span.parentSpanId.slice(0, 8) : '<root>'\n const status = span.statusCode === 2 ? 'ERROR' : 'OK'\n const db = span.dbSystem ? ` db=${span.dbSystem}/${span.dbName ?? '?'}` : ''\n console.log(\n `otel: ${span.service} ${span.name} parent=${parent} status=${status}${db}`,\n )\n}\n"],"mappings":";;;;;;;;AAAA,OAAO,UAAU;AACjB,SAAS,qBAAqB;AAC9B,OAAO,aAAuC;AAC9C,OAAO,cAAc;AAyGrB,SAAS,2BAA2B,QAA0D;AAC5F,MAAI,CAAC,OAAQ,QAAO;AACpB,aAAW,MAAM,QAAQ;AACvB,QAAI,GAAG,SAAS,YAAa;AAC7B,UAAM,QAAQ,cAAc,GAAG,UAAU;AACzC,UAAM,MAA+B,CAAC;AACtC,UAAM,IAAI,MAAM,gBAAgB;AAChC,UAAM,IAAI,MAAM,mBAAmB;AACnC,UAAM,IAAI,MAAM,sBAAsB;AACtC,QAAI,OAAO,MAAM,SAAU,KAAI,OAAO;AACtC,QAAI,OAAO,MAAM,SAAU,KAAI,UAAU;AACzC,QAAI,OAAO,MAAM,SAAU,KAAI,aAAa;AAC5C,QAAI,IAAI,QAAQ,IAAI,WAAW,IAAI,WAAY,QAAO;AAAA,EACxD;AACA,SAAO;AACT;AAeA,SAAS,iBAAiB,GAA6C;AACrE,MAAI,CAAC,EAAG,QAAO;AACf,MAAI,EAAE,gBAAgB,OAAW,QAAO,EAAE;AAC1C,MAAI,EAAE,cAAc,OAAW,QAAO,EAAE;AACxC,MAAI,EAAE,aAAa,QAAW;AAC5B,WAAO,OAAO,EAAE,aAAa,WAAW,OAAO,EAAE,QAAQ,IAAI,EAAE;AAAA,EACjE;AACA,MAAI,EAAE,gBAAgB,OAAW,QAAO,EAAE;AAC1C,MAAI,EAAE,YAAY,QAAQ;AACxB,WAAO,EAAE,WAAW,OAAO,IAAI,CAAC,MAAM,iBAAiB,CAAC,CAAC;AAAA,EAC3D;AACA,SAAO;AACT;AAEA,SAAS,cAAc,OAAmE;AACxF,QAAM,MAAsC,CAAC;AAC7C,MAAI,CAAC,MAAO,QAAO;AACnB,aAAW,MAAM,OAAO;AACtB,QAAI,GAAG,IAAK,KAAI,GAAG,GAAG,IAAI,iBAAiB,GAAG,KAAK;AAAA,EACrD;AACA,SAAO;AACT;AAEA,SAAS,cAAc,OAAgB,KAAsB;AAC3D,MAAI,CAAC,SAAS,CAAC,IAAK,QAAO;AAC3B,MAAI;AACF,WAAO,OAAO,GAAG,IAAI,OAAO,KAAK;AAAA,EACnC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMO,SAAS,gBAAgB,OAA+C;AAC7E,MAAI,CAAC,SAAS,UAAU,IAAK,QAAO;AACpC,MAAI;AACF,UAAM,KAAK,OAAO,OAAO,KAAK,IAAI,QAAU;AAC5C,QAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO;AACjC,WAAO,IAAI,KAAK,EAAE,EAAE,YAAY;AAAA,EAClC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,iBAAiB,MAAuC;AACtE,QAAM,MAAoB,CAAC;AAC3B,aAAW,MAAM,KAAK,iBAAiB,CAAC,GAAG;AACzC,UAAM,gBAAgB,cAAc,GAAG,UAAU,UAAU;AAC3D,UAAM,UAAU,OAAO,cAAc,cAAc,MAAM,WACpD,cAAc,cAAc,IAC7B;AAEJ,eAAW,MAAM,GAAG,cAAc,CAAC,GAAG;AACpC,iBAAW,QAAQ,GAAG,SAAS,CAAC,GAAG;AACjC,cAAM,QAAQ,cAAc,KAAK,UAAU;AAC3C,cAAM,SAAqB;AAAA,UACzB;AAAA,UACA,SAAS,KAAK,WAAW;AAAA,UACzB,QAAQ,KAAK,UAAU;AAAA,UACvB,cAAc,KAAK,gBAAgB;AAAA,UACnC,MAAM,KAAK,QAAQ;AAAA,UACnB,MAAM,KAAK;AAAA,UACX,mBAAmB,KAAK,qBAAqB;AAAA,UAC7C,iBAAiB,KAAK,mBAAmB;AAAA,UACzC,cAAc,gBAAgB,KAAK,iBAAiB;AAAA,UACpD,eAAe,cAAc,KAAK,mBAAmB,KAAK,eAAe;AAAA,UACzE,YAAY;AAAA,UACZ,UAAU,OAAO,MAAM,WAAW,MAAM,WAAY,MAAM,WAAW,IAAe;AAAA,UACpF,QAAQ,OAAO,MAAM,SAAS,MAAM,WAAY,MAAM,SAAS,IAAe;AAAA,UAC9E,YAAY,KAAK,QAAQ;AAAA,UACzB,cAAc,KAAK,QAAQ;AAAA,UAC3B,WAAW,2BAA2B,KAAK,MAAM;AAAA,QACnD;AACA,YAAI,KAAK,MAAM;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAYA,IAAI,gCAAsD;AAE1D,SAAS,sBAAqC;AAC5C,MAAI,8BAA+B,QAAO;AAC1C,QAAM,OAAO,KAAK,QAAQ,cAAc,YAAY,GAAG,CAAC;AACxD,QAAM,YAAY,KAAK,QAAQ,MAAM,MAAM,OAAO;AAClD,QAAM,OAAO,IAAI,SAAS,KAAK;AAC/B,OAAK,cAAc,CAAC,SAAS,WAAW,KAAK,QAAQ,WAAW,MAAM;AACtE,OAAK;AAAA,IACH;AAAA,IACA,EAAE,UAAU,KAAK;AAAA,EACnB;AACA,kCAAgC,KAAK;AAAA,IACnC;AAAA,EACF;AACA,SAAO;AACT;AAEA,eAAe,mBAAmB,KAAyC;AACzE,QAAM,OAAO,oBAAoB;AAIjC,QAAM,UAAU,KAAK,OAAO,GAAG,EAAE,OAAO;AACxC,QAAM,EAAE,mBAAmB,IAAI,MAAM,OAAO,yBAAgB;AAC5D,SAAO,mBAAmB,OAAgB;AAC5C;AAEA,eAAsB,kBACpB,MACkE;AAClE,QAAM,MAAM,QAAQ;AAAA,IAClB,QAAQ;AAAA,IACR,WAAW,KAAK,aAAa,KAAK,OAAO;AAAA,EAC3C,CAAC;AAOD,QAAM,QAAsB,CAAC;AAC7B,MAAI,WAAW;AACf,MAAI,eAA8B,QAAQ,QAAQ;AAElD,QAAM,QAAQ,YAA2B;AACvC,QAAI,SAAU;AACd,eAAW;AACX,QAAI;AACF,aAAO,MAAM,SAAS,GAAG;AACvB,cAAM,OAAO,MAAM,MAAM;AACzB,YAAI;AACF,gBAAM,KAAK,OAAO,IAAI;AAAA,QACxB,SAAS,KAAK;AACZ,kBAAQ,KAAK,8BAA+B,IAAc,OAAO,EAAE;AAAA,QACrE;AAAA,MACF;AAAA,IACF,UAAE;AACA,iBAAW;AAAA,IACb;AAAA,EACF;AAEA,QAAM,UAAU,CAAC,UAA8B;AAC7C,QAAI,MAAM,WAAW,EAAG;AACxB,eAAW,KAAK,MAAO,OAAM,KAAK,CAAC;AAInC,mBAAe,aAAa,KAAK,MAAM,MAAM,CAAC;AAAA,EAChD;AAIA,MAAI;AAAA,IACF;AAAA,IACA,EAAE,SAAS,UAAU,WAAW,KAAK,aAAa,KAAK,OAAO,KAAK;AAAA,IACnE,CAAC,MAAM,MAAM,SAAS;AACpB,WAAK,MAAM,IAAI;AAAA,IACjB;AAAA,EACF;AAEA,MAAI,IAAI,WAAW,aAAa,EAAE,IAAI,KAAK,EAAE;AAE7C,MAAI,KAAK,cAAc,OAAO,KAAK,UAAU;AAG3C,UAAM,MAAM,IAAI,QAAQ,cAAc,KAAK,IAAI,SAAS,EAAE,MAAM,GAAG,EAAE,CAAC,EAAG,KAAK,EAAE,YAAY;AAC5F,QAAI;AACJ,QAAI,OAAO,0BAA0B;AACnC,UAAI;AACF,eAAO,MAAM,mBAAmB,IAAI,IAAc;AAAA,MACpD,SAAS,KAAK;AACZ,eAAO,MAAM,KAAK,GAAG,EAAE,KAAK;AAAA,UAC1B,OAAO,2BAA4B,IAAc,OAAO;AAAA,QAC1D,CAAC;AAAA,MACH;AAAA,IACF,WAAW,CAAC,MAAM,OAAO,oBAAoB;AAC3C,aAAQ,IAAI,QAAQ,CAAC;AAAA,IACvB,OAAO;AACL,aAAO,MAAM,KAAK,GAAG,EAAE,KAAK,EAAE,OAAO,6BAA6B,EAAE,GAAG,CAAC;AAAA,IAC1E;AACA,UAAM,QAAQ,iBAAiB,IAAI;AAInC,QAAI,KAAK,iBAAiB;AACxB,UAAI;AACF,mBAAW,QAAQ,OAAO;AACxB,cAAI,KAAK,eAAe,EAAG,OAAM,KAAK,gBAAgB,IAAI;AAAA,QAC5D;AAAA,MACF,SAAS,KAAK;AACZ,eAAO,MAAM,KAAK,GAAG,EAAE,KAAK;AAAA,UAC1B,OAAO,6BAA8B,IAAc,OAAO;AAAA,QAC5D,CAAC;AAAA,MACH;AAAA,IACF;AACA,YAAQ,KAAK;AAEb,WAAO,MAAM,KAAK,GAAG,EAAE,KAAK,EAAE,gBAAgB,CAAC,EAAE,CAAC;AAAA,EACpD,CAAC;AAMD,QAAM,YAAY;AAClB,YAAU,eAAe,YAAY;AAGnC,WAAO,MAAM,SAAS,KAAK,UAAU;AACnC,YAAM;AAAA,IACR;AAAA,EACF;AACA,SAAO;AACT;AAEO,SAAS,eAAe,MAAwB;AACrD,QAAM,SAAS,KAAK,eAAe,KAAK,aAAa,MAAM,GAAG,CAAC,IAAI;AACnE,QAAM,SAAS,KAAK,eAAe,IAAI,UAAU;AACjD,QAAM,KAAK,KAAK,WAAW,OAAO,KAAK,QAAQ,IAAI,KAAK,UAAU,GAAG,KAAK;AAC1E,UAAQ;AAAA,IACN,SAAS,KAAK,OAAO,IAAI,KAAK,IAAI,WAAW,MAAM,WAAW,MAAM,GAAG,EAAE;AAAA,EAC3E;AACF;","names":[]}
@@ -1,9 +1,13 @@
1
1
  import {
2
2
  DEFAULT_PROJECT,
3
+ Projects,
4
+ buildApi,
3
5
  extractFromDirectory,
4
6
  getGraph,
7
+ handleSpan,
5
8
  listProjects,
6
9
  loadGraphFromDisk,
10
+ makeErrorSpanWriter,
7
11
  pathsForProject,
8
12
  registryPath,
9
13
  resetGraph,
@@ -11,7 +15,10 @@ import {
11
15
  startPersistLoop,
12
16
  touchLastSeen,
13
17
  writeAtomically
14
- } from "./chunk-B7UUGIXB.js";
18
+ } from "./chunk-FIXKIYNF.js";
19
+ import {
20
+ buildOtelReceiver
21
+ } from "./chunk-4ASCXBZF.js";
15
22
 
16
23
  // src/daemon.ts
17
24
  import { promises as fs } from "fs";
@@ -34,6 +41,7 @@ function routeSpanToProject(serviceName, projects) {
34
41
  return DEFAULT_PROJECT;
35
42
  }
36
43
  async function bootstrapProject(entry) {
44
+ const paths = pathsForProject(entry.name, path.join(entry.path, "neat-out"));
37
45
  try {
38
46
  const stat = await fs.stat(entry.path);
39
47
  if (!stat.isDirectory()) {
@@ -48,6 +56,7 @@ async function bootstrapProject(entry) {
48
56
  // output; nothing routes to it because it's not 'active'.
49
57
  graph: getGraph(`__broken__:${entry.name}`),
50
58
  outPath: "",
59
+ paths,
51
60
  stopPersist: () => {
52
61
  },
53
62
  status: "broken",
@@ -56,10 +65,7 @@ async function bootstrapProject(entry) {
56
65
  }
57
66
  resetGraph(entry.name);
58
67
  const graph = getGraph(entry.name);
59
- const outPath = pathsForProject(
60
- entry.name,
61
- path.join(entry.path, "neat-out")
62
- ).snapshotPath;
68
+ const outPath = paths.snapshotPath;
63
69
  await loadGraphFromDisk(graph, outPath);
64
70
  await extractFromDirectory(graph, entry.path);
65
71
  const stopPersist = startPersistLoop(graph, outPath);
@@ -69,10 +75,35 @@ async function bootstrapProject(entry) {
69
75
  entry,
70
76
  graph,
71
77
  outPath,
78
+ paths,
72
79
  stopPersist,
73
80
  status: "active"
74
81
  };
75
82
  }
83
+ function resolveRestPort(opts) {
84
+ if (typeof opts.restPort === "number") return opts.restPort;
85
+ const env = process.env.PORT;
86
+ if (env && env.length > 0) {
87
+ const n = Number.parseInt(env, 10);
88
+ if (Number.isFinite(n)) return n;
89
+ }
90
+ return 8080;
91
+ }
92
+ function resolveOtlpPort(opts) {
93
+ if (typeof opts.otlpPort === "number") return opts.otlpPort;
94
+ const env = process.env.OTEL_PORT;
95
+ if (env && env.length > 0) {
96
+ const n = Number.parseInt(env, 10);
97
+ if (Number.isFinite(n)) return n;
98
+ }
99
+ return 4318;
100
+ }
101
+ function resolveHost(opts) {
102
+ if (opts.host && opts.host.length > 0) return opts.host;
103
+ const env = process.env.HOST;
104
+ if (env && env.length > 0) return env;
105
+ return "0.0.0.0";
106
+ }
76
107
  async function startDaemon(opts = {}) {
77
108
  const home = neatHomeFor(opts);
78
109
  const regPath = registryPath();
@@ -87,6 +118,15 @@ async function startDaemon(opts = {}) {
87
118
  await writeAtomically(pidPath, `${process.pid}
88
119
  `);
89
120
  const slots = /* @__PURE__ */ new Map();
121
+ const registry = new Projects();
122
+ function upsertRegistryFromSlot(slot) {
123
+ if (slot.status !== "active") return;
124
+ registry.set(slot.entry.name, {
125
+ scanPath: slot.entry.path,
126
+ paths: slot.paths,
127
+ graph: slot.graph
128
+ });
129
+ }
90
130
  async function loadAll() {
91
131
  const projects = await listProjects();
92
132
  const seen = /* @__PURE__ */ new Set();
@@ -96,6 +136,7 @@ async function startDaemon(opts = {}) {
96
136
  try {
97
137
  const slot = await bootstrapProject(entry);
98
138
  slots.set(entry.name, slot);
139
+ upsertRegistryFromSlot(slot);
99
140
  if (slot.status === "broken") {
100
141
  console.warn(`neatd: project "${entry.name}" broken \u2014 ${slot.errorReason}`);
101
142
  } else {
@@ -120,6 +161,80 @@ async function startDaemon(opts = {}) {
120
161
  }
121
162
  }
122
163
  await loadAll();
164
+ const bind = opts.bindListeners !== false;
165
+ let restApp = null;
166
+ let otlpApp = null;
167
+ let restAddress = "";
168
+ let otlpAddress = "";
169
+ if (bind) {
170
+ const host = resolveHost(opts);
171
+ const restPort = resolveRestPort(opts);
172
+ const otlpPort = resolveOtlpPort(opts);
173
+ try {
174
+ restApp = await buildApi({ projects: registry });
175
+ restAddress = await restApp.listen({ port: restPort, host });
176
+ console.log(`neatd: REST listening on ${restAddress}`);
177
+ } catch (err) {
178
+ for (const slot of slots.values()) {
179
+ try {
180
+ slot.stopPersist();
181
+ } catch {
182
+ }
183
+ }
184
+ if (restApp) await restApp.close().catch(() => {
185
+ });
186
+ await fs.unlink(pidPath).catch(() => {
187
+ });
188
+ throw new Error(
189
+ `neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
190
+ );
191
+ }
192
+ try {
193
+ otlpApp = await buildOtelReceiver({
194
+ onSpan: async (span) => {
195
+ const liveEntries = await listProjects().catch(() => []);
196
+ const target = routeSpanToProject(span.service, liveEntries);
197
+ const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
198
+ if (!slot || slot.status !== "active") return;
199
+ await handleSpan(
200
+ {
201
+ graph: slot.graph,
202
+ errorsPath: slot.paths.errorsPath,
203
+ project: slot.entry.name,
204
+ // Receiver already wrote the error event synchronously below.
205
+ writeErrorEventInline: false
206
+ },
207
+ span
208
+ );
209
+ },
210
+ onErrorSpanSync: async (span) => {
211
+ const liveEntries = await listProjects().catch(() => []);
212
+ const target = routeSpanToProject(span.service, liveEntries);
213
+ const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
214
+ if (!slot || slot.status !== "active") return;
215
+ await makeErrorSpanWriter(slot.paths.errorsPath)(span);
216
+ }
217
+ });
218
+ otlpAddress = await otlpApp.listen({ port: otlpPort, host });
219
+ console.log(`neatd: OTLP listening on ${otlpAddress}/v1/traces`);
220
+ } catch (err) {
221
+ for (const slot of slots.values()) {
222
+ try {
223
+ slot.stopPersist();
224
+ } catch {
225
+ }
226
+ }
227
+ if (restApp) await restApp.close().catch(() => {
228
+ });
229
+ if (otlpApp) await otlpApp.close().catch(() => {
230
+ });
231
+ await fs.unlink(pidPath).catch(() => {
232
+ });
233
+ throw new Error(
234
+ `neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
235
+ );
236
+ }
237
+ }
123
238
  let reloading = null;
124
239
  const reload = async () => {
125
240
  if (reloading) return reloading;
@@ -143,6 +258,10 @@ async function startDaemon(opts = {}) {
143
258
  if (stopped) return;
144
259
  stopped = true;
145
260
  process.off("SIGHUP", sighupHandler);
261
+ if (otlpApp) await otlpApp.close().catch(() => {
262
+ });
263
+ if (restApp) await restApp.close().catch(() => {
264
+ });
146
265
  for (const slot of slots.values()) {
147
266
  try {
148
267
  slot.stopPersist();
@@ -152,11 +271,11 @@ async function startDaemon(opts = {}) {
152
271
  await fs.unlink(pidPath).catch(() => {
153
272
  });
154
273
  };
155
- return { slots, reload, stop, pidPath };
274
+ return { slots, reload, stop, pidPath, restAddress, otlpAddress };
156
275
  }
157
276
 
158
277
  export {
159
278
  routeSpanToProject,
160
279
  startDaemon
161
280
  };
162
- //# sourceMappingURL=chunk-I5KODOJV.js.map
281
+ //# sourceMappingURL=chunk-66Z5IEFY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/daemon.ts"],"sourcesContent":["/**\n * Multi-project daemon (ADR-049).\n *\n * Single long-lived process watching every project in the machine-level registry.\n * Per-project graph isolation: each registered project owns its own\n * `MultiDirectedGraph` slot keyed by name (ADR-026), and a failure during\n * one project's bootstrap is logged + marked `broken` without taking down\n * the rest of the daemon.\n *\n * MVP scope (v0.2.5):\n * - Read registry; refuse to boot when it's missing.\n * - Write PID at `~/.neat/neatd.pid` for external supervisors.\n * - Per project: load any existing snapshot, run initial extraction,\n * start a per-project persist loop.\n * - SIGHUP triggers a reload — re-reads the registry, picks up new\n * projects, drops removed ones, leaves untouched ones in place.\n * - Provide `routeSpanToProject(serviceName, projects)` for OTel ingest\n * to dispatch by `service.name` across registered projects, falling\n * back to `default` for unknown services per ADR-033.\n *\n * Out of MVP scope (deferred):\n * - Live OTel listener wiring per project — daemon exposes the routing\n * primitive; the actual receiver attachment lands alongside v0.2.6.\n * - Policy reload on `policy.json` mtime — `startWatch` already does this\n * per-project; the daemon-level loop reuses that machinery in a follow-up.\n * - Auto-restart on crash. PID file is the supervisor handoff.\n */\n\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\nimport type { FastifyInstance } from 'fastify'\nimport { DEFAULT_PROJECT, getGraph, resetGraph, type NeatGraph } from './graph.js'\nimport { extractFromDirectory } from './extract.js'\nimport { loadGraphFromDisk, startPersistLoop } from './persist.js'\nimport { Projects, pathsForProject, type ProjectPaths } from './projects.js'\nimport { buildApi } from './api.js'\nimport { buildOtelReceiver } from './otel.js'\nimport { handleSpan, makeErrorSpanWriter } from './ingest.js'\nimport {\n listProjects,\n registryPath,\n setStatus,\n touchLastSeen,\n writeAtomically,\n} from './registry.js'\nimport type { RegistryEntry } from '@neat.is/types'\n\nexport interface DaemonOptions {\n // Defaults to `~/.neat/`. Honors NEAT_HOME the same way registry.ts does.\n // Tests override via NEAT_HOME and don't pass this directly.\n neatHome?: string\n // ADR-063 — bind targets. Defaults to PORT (8080) / OTEL_PORT (4318) env\n // vars, matching server.ts. Tests pass 0 to get ephemeral ports.\n restPort?: number\n otlpPort?: number\n // ADR-063 — bind host. Defaults to HOST env (0.0.0.0).\n host?: string\n // ADR-063 — opt out of binding entirely (e.g. integration tests that\n // exercise daemon slots without needing the listeners). Production\n // `neatd start` never sets this.\n bindListeners?: boolean\n}\n\nexport interface ProjectSlot {\n entry: RegistryEntry\n graph: NeatGraph\n outPath: string\n paths: ProjectPaths\n stopPersist: () => void\n status: 'active' | 'broken'\n errorReason?: string\n}\n\nexport interface DaemonHandle {\n // The slots currently being managed, keyed by project name. Tests inspect\n // this to assert isolation properties.\n slots: Map<string, ProjectSlot>\n // Re-read the registry. New entries get bootstrapped, removed ones get\n // their persist loops stopped, existing ones stay running.\n reload: () => Promise<void>\n // Graceful shutdown — stop every project's persist loop and remove the\n // PID file.\n stop: () => Promise<void>\n // Path to the PID file the daemon owns. Useful for test assertions.\n pidPath: string\n // ADR-063 — addresses where consumers reach the daemon. Empty string when\n // bindListeners is false. REST is the Fastify app's listening address;\n // OTLP is the receiver's.\n restAddress: string\n otlpAddress: string\n}\n\nfunction neatHomeFor(opts: DaemonOptions): string {\n if (opts.neatHome && opts.neatHome.length > 0) return path.resolve(opts.neatHome)\n const env = process.env.NEAT_HOME\n if (env && env.length > 0) return path.resolve(env)\n const home = process.env.HOME ?? process.env.USERPROFILE ?? ''\n return path.join(home, '.neat')\n}\n\n/**\n * Resolve which project's graph an OTel span belongs to. Looks up the\n * `service.name` against the registry and returns the matching project's\n * name, or `DEFAULT_PROJECT` for unknown services so the FrontierNode\n * auto-creation flow keeps working per ADR-033.\n *\n * Pure function. Daemon callers pass a snapshot of the registry to avoid\n * per-span fs reads.\n */\nexport function routeSpanToProject(\n serviceName: string | undefined,\n projects: ReadonlyArray<RegistryEntry>,\n): string {\n if (!serviceName) return DEFAULT_PROJECT\n for (const entry of projects) {\n if (entry.status !== 'active') continue\n if (entry.languages.length === 0) {\n // No language data yet — still acceptable to match by name.\n }\n if (entry.name === serviceName) return entry.name\n }\n return DEFAULT_PROJECT\n}\n\nasync function bootstrapProject(entry: RegistryEntry): Promise<ProjectSlot> {\n const paths = pathsForProject(entry.name, path.join(entry.path, 'neat-out'))\n\n // Path missing on disk → mark broken and surface the reason. Daemon\n // continues with the rest of the registry.\n try {\n const stat = await fs.stat(entry.path)\n if (!stat.isDirectory()) {\n throw new Error(`registered path ${entry.path} is not a directory`)\n }\n } catch (err) {\n await setStatus(entry.name, 'broken').catch(() => {})\n return {\n entry,\n // Empty graph is fine — `slots` keeps the entry visible in `status`\n // output; nothing routes to it because it's not 'active'.\n graph: getGraph(`__broken__:${entry.name}`),\n outPath: '',\n paths,\n stopPersist: () => {},\n status: 'broken',\n errorReason: (err as Error).message,\n }\n }\n\n // Use the project name as the in-memory graph key. Any prior contents\n // are wiped because the daemon owns the slot for the lifetime of this\n // bootstrap (ADR-030 — mutation authority).\n resetGraph(entry.name)\n const graph = getGraph(entry.name)\n const outPath = paths.snapshotPath\n\n await loadGraphFromDisk(graph, outPath)\n await extractFromDirectory(graph, entry.path)\n const stopPersist = startPersistLoop(graph, outPath)\n await touchLastSeen(entry.name).catch(() => {})\n\n return {\n entry,\n graph,\n outPath,\n paths,\n stopPersist,\n status: 'active',\n }\n}\n\nfunction resolveRestPort(opts: DaemonOptions): number {\n if (typeof opts.restPort === 'number') return opts.restPort\n const env = process.env.PORT\n if (env && env.length > 0) {\n const n = Number.parseInt(env, 10)\n if (Number.isFinite(n)) return n\n }\n return 8080\n}\n\nfunction resolveOtlpPort(opts: DaemonOptions): number {\n if (typeof opts.otlpPort === 'number') return opts.otlpPort\n const env = process.env.OTEL_PORT\n if (env && env.length > 0) {\n const n = Number.parseInt(env, 10)\n if (Number.isFinite(n)) return n\n }\n return 4318\n}\n\nfunction resolveHost(opts: DaemonOptions): string {\n if (opts.host && opts.host.length > 0) return opts.host\n const env = process.env.HOST\n if (env && env.length > 0) return env\n return '0.0.0.0'\n}\n\nexport async function startDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle> {\n const home = neatHomeFor(opts)\n const regPath = registryPath()\n // Graceful degradation per ADR-049 #6: missing registry refuses to boot\n // with a clear error rather than silently coming up empty.\n try {\n await fs.access(regPath)\n } catch {\n throw new Error(\n `neatd: registry not found at ${regPath}. Run \\`neat init <path>\\` to register a project before starting the daemon.`,\n )\n }\n\n const pidPath = path.join(home, 'neatd.pid')\n await writeAtomically(pidPath, `${process.pid}\\n`)\n\n const slots = new Map<string, ProjectSlot>()\n // Projects registry mirrors slots for the REST listener (ADR-063). buildApi\n // reads from this; we keep it in sync as slots come and go.\n const registry = new Projects()\n\n function upsertRegistryFromSlot(slot: ProjectSlot): void {\n if (slot.status !== 'active') return\n registry.set(slot.entry.name, {\n scanPath: slot.entry.path,\n paths: slot.paths,\n graph: slot.graph,\n })\n }\n\n async function loadAll(): Promise<void> {\n const projects = await listProjects()\n const seen = new Set<string>()\n for (const entry of projects) {\n seen.add(entry.name)\n if (slots.has(entry.name)) continue\n try {\n const slot = await bootstrapProject(entry)\n slots.set(entry.name, slot)\n upsertRegistryFromSlot(slot)\n if (slot.status === 'broken') {\n console.warn(`neatd: project \"${entry.name}\" broken — ${slot.errorReason}`)\n } else {\n console.log(`neatd: project \"${entry.name}\" active (${entry.path})`)\n }\n } catch (err) {\n console.warn(\n `neatd: project \"${entry.name}\" failed to bootstrap — ${(err as Error).message}`,\n )\n await setStatus(entry.name, 'broken').catch(() => {})\n }\n }\n // Drop entries the registry no longer carries.\n for (const [name, slot] of [...slots.entries()]) {\n if (seen.has(name)) continue\n try {\n slot.stopPersist()\n } catch {\n // best-effort\n }\n slots.delete(name)\n console.log(`neatd: project \"${name}\" removed from registry — stopped`)\n }\n }\n\n await loadAll()\n\n // ADR-063 — bind the REST host and the OTLP HTTP receiver. One listener\n // each, multi-tenant by project name in the URL (REST) and by service.name\n // dispatch (OTLP). Failure on either listen aborts startDaemon with a\n // surfacing error rather than letting the supervisor sit half-up.\n const bind = opts.bindListeners !== false\n let restApp: FastifyInstance | null = null\n let otlpApp:\n | (FastifyInstance & { flushPending: () => Promise<void> })\n | null = null\n let restAddress = ''\n let otlpAddress = ''\n\n if (bind) {\n const host = resolveHost(opts)\n const restPort = resolveRestPort(opts)\n const otlpPort = resolveOtlpPort(opts)\n\n try {\n restApp = await buildApi({ projects: registry })\n restAddress = await restApp.listen({ port: restPort, host })\n console.log(`neatd: REST listening on ${restAddress}`)\n } catch (err) {\n // Roll back anything we started so far before surfacing the error.\n for (const slot of slots.values()) {\n try {\n slot.stopPersist()\n } catch {\n // best-effort\n }\n }\n if (restApp) await restApp.close().catch(() => {})\n await fs.unlink(pidPath).catch(() => {})\n throw new Error(\n `neatd: failed to bind REST on port ${restPort} — ${(err as Error).message}`,\n )\n }\n\n try {\n otlpApp = await buildOtelReceiver({\n onSpan: async (span) => {\n // ADR-049 OTel routing — dispatch by service.name across active\n // registry entries. Unknown services route to DEFAULT_PROJECT so\n // the FrontierNode auto-creation flow keeps working (ADR-033).\n const liveEntries = await listProjects().catch(() => [])\n const target = routeSpanToProject(span.service, liveEntries)\n const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT)\n if (!slot || slot.status !== 'active') return\n await handleSpan(\n {\n graph: slot.graph,\n errorsPath: slot.paths.errorsPath,\n project: slot.entry.name,\n // Receiver already wrote the error event synchronously below.\n writeErrorEventInline: false,\n },\n span,\n )\n },\n onErrorSpanSync: async (span) => {\n const liveEntries = await listProjects().catch(() => [])\n const target = routeSpanToProject(span.service, liveEntries)\n const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT)\n if (!slot || slot.status !== 'active') return\n await makeErrorSpanWriter(slot.paths.errorsPath)(span)\n },\n })\n otlpAddress = await otlpApp.listen({ port: otlpPort, host })\n console.log(`neatd: OTLP listening on ${otlpAddress}/v1/traces`)\n } catch (err) {\n for (const slot of slots.values()) {\n try {\n slot.stopPersist()\n } catch {\n // best-effort\n }\n }\n if (restApp) await restApp.close().catch(() => {})\n if (otlpApp) await otlpApp.close().catch(() => {})\n await fs.unlink(pidPath).catch(() => {})\n throw new Error(\n `neatd: failed to bind OTLP on port ${otlpPort} — ${(err as Error).message}`,\n )\n }\n }\n\n let reloading: Promise<void> | null = null\n const reload = async (): Promise<void> => {\n if (reloading) return reloading\n reloading = (async () => {\n try {\n await loadAll()\n } finally {\n reloading = null\n }\n })()\n return reloading\n }\n\n // SIGHUP — external \"reload your config\" signal. ADR-049 #2.\n const sighupHandler = (): void => {\n void reload().catch((err) => {\n console.warn(`neatd: SIGHUP reload failed — ${(err as Error).message}`)\n })\n }\n process.on('SIGHUP', sighupHandler)\n\n let stopped = false\n const stop = async (): Promise<void> => {\n if (stopped) return\n stopped = true\n process.off('SIGHUP', sighupHandler)\n if (otlpApp) await otlpApp.close().catch(() => {})\n if (restApp) await restApp.close().catch(() => {})\n for (const slot of slots.values()) {\n try {\n slot.stopPersist()\n } catch {\n // best-effort\n }\n }\n await fs.unlink(pidPath).catch(() => {})\n }\n\n return { slots, reload, stop, pidPath, restAddress, otlpAddress }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AA4BA,SAAS,YAAY,UAAU;AAC/B,OAAO,UAAU;AA+DjB,SAAS,YAAY,MAA6B;AAChD,MAAI,KAAK,YAAY,KAAK,SAAS,SAAS,EAAG,QAAO,KAAK,QAAQ,KAAK,QAAQ;AAChF,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,OAAO,IAAI,SAAS,EAAG,QAAO,KAAK,QAAQ,GAAG;AAClD,QAAM,OAAO,QAAQ,IAAI,QAAQ,QAAQ,IAAI,eAAe;AAC5D,SAAO,KAAK,KAAK,MAAM,OAAO;AAChC;AAWO,SAAS,mBACd,aACA,UACQ;AACR,MAAI,CAAC,YAAa,QAAO;AACzB,aAAW,SAAS,UAAU;AAC5B,QAAI,MAAM,WAAW,SAAU;AAC/B,QAAI,MAAM,UAAU,WAAW,GAAG;AAAA,IAElC;AACA,QAAI,MAAM,SAAS,YAAa,QAAO,MAAM;AAAA,EAC/C;AACA,SAAO;AACT;AAEA,eAAe,iBAAiB,OAA4C;AAC1E,QAAM,QAAQ,gBAAgB,MAAM,MAAM,KAAK,KAAK,MAAM,MAAM,UAAU,CAAC;AAI3E,MAAI;AACF,UAAM,OAAO,MAAM,GAAG,KAAK,MAAM,IAAI;AACrC,QAAI,CAAC,KAAK,YAAY,GAAG;AACvB,YAAM,IAAI,MAAM,mBAAmB,MAAM,IAAI,qBAAqB;AAAA,IACpE;AAAA,EACF,SAAS,KAAK;AACZ,UAAM,UAAU,MAAM,MAAM,QAAQ,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACpD,WAAO;AAAA,MACL;AAAA;AAAA;AAAA,MAGA,OAAO,SAAS,cAAc,MAAM,IAAI,EAAE;AAAA,MAC1C,SAAS;AAAA,MACT;AAAA,MACA,aAAa,MAAM;AAAA,MAAC;AAAA,MACpB,QAAQ;AAAA,MACR,aAAc,IAAc;AAAA,IAC9B;AAAA,EACF;AAKA,aAAW,MAAM,IAAI;AACrB,QAAM,QAAQ,SAAS,MAAM,IAAI;AACjC,QAAM,UAAU,MAAM;AAEtB,QAAM,kBAAkB,OAAO,OAAO;AACtC,QAAM,qBAAqB,OAAO,MAAM,IAAI;AAC5C,QAAM,cAAc,iBAAiB,OAAO,OAAO;AACnD,QAAM,cAAc,MAAM,IAAI,EAAE,MAAM,MAAM;AAAA,EAAC,CAAC;AAE9C,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,QAAQ;AAAA,EACV;AACF;AAEA,SAAS,gBAAgB,MAA6B;AACpD,MAAI,OAAO,KAAK,aAAa,SAAU,QAAO,KAAK;AACnD,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,OAAO,IAAI,SAAS,GAAG;AACzB,UAAM,IAAI,OAAO,SAAS,KAAK,EAAE;AACjC,QAAI,OAAO,SAAS,CAAC,EAAG,QAAO;AAAA,EACjC;AACA,SAAO;AACT;AAEA,SAAS,gBAAgB,MAA6B;AACpD,MAAI,OAAO,KAAK,aAAa,SAAU,QAAO,KAAK;AACnD,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,OAAO,IAAI,SAAS,GAAG;AACzB,UAAM,IAAI,OAAO,SAAS,KAAK,EAAE;AACjC,QAAI,OAAO,SAAS,CAAC,EAAG,QAAO;AAAA,EACjC;AACA,SAAO;AACT;AAEA,SAAS,YAAY,MAA6B;AAChD,MAAI,KAAK,QAAQ,KAAK,KAAK,SAAS,EAAG,QAAO,KAAK;AACnD,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,OAAO,IAAI,SAAS,EAAG,QAAO;AAClC,SAAO;AACT;AAEA,eAAsB,YAAY,OAAsB,CAAC,GAA0B;AACjF,QAAM,OAAO,YAAY,IAAI;AAC7B,QAAM,UAAU,aAAa;AAG7B,MAAI;AACF,UAAM,GAAG,OAAO,OAAO;AAAA,EACzB,QAAQ;AACN,UAAM,IAAI;AAAA,MACR,gCAAgC,OAAO;AAAA,IACzC;AAAA,EACF;AAEA,QAAM,UAAU,KAAK,KAAK,MAAM,WAAW;AAC3C,QAAM,gBAAgB,SAAS,GAAG,QAAQ,GAAG;AAAA,CAAI;AAEjD,QAAM,QAAQ,oBAAI,IAAyB;AAG3C,QAAM,WAAW,IAAI,SAAS;AAE9B,WAAS,uBAAuB,MAAyB;AACvD,QAAI,KAAK,WAAW,SAAU;AAC9B,aAAS,IAAI,KAAK,MAAM,MAAM;AAAA,MAC5B,UAAU,KAAK,MAAM;AAAA,MACrB,OAAO,KAAK;AAAA,MACZ,OAAO,KAAK;AAAA,IACd,CAAC;AAAA,EACH;AAEA,iBAAe,UAAyB;AACtC,UAAM,WAAW,MAAM,aAAa;AACpC,UAAM,OAAO,oBAAI,IAAY;AAC7B,eAAW,SAAS,UAAU;AAC5B,WAAK,IAAI,MAAM,IAAI;AACnB,UAAI,MAAM,IAAI,MAAM,IAAI,EAAG;AAC3B,UAAI;AACF,cAAM,OAAO,MAAM,iBAAiB,KAAK;AACzC,cAAM,IAAI,MAAM,MAAM,IAAI;AAC1B,+BAAuB,IAAI;AAC3B,YAAI,KAAK,WAAW,UAAU;AAC5B,kBAAQ,KAAK,mBAAmB,MAAM,IAAI,mBAAc,KAAK,WAAW,EAAE;AAAA,QAC5E,OAAO;AACL,kBAAQ,IAAI,mBAAmB,MAAM,IAAI,aAAa,MAAM,IAAI,GAAG;AAAA,QACrE;AAAA,MACF,SAAS,KAAK;AACZ,gBAAQ;AAAA,UACN,mBAAmB,MAAM,IAAI,gCAA4B,IAAc,OAAO;AAAA,QAChF;AACA,cAAM,UAAU,MAAM,MAAM,QAAQ,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AAAA,MACtD;AAAA,IACF;AAEA,eAAW,CAAC,MAAM,IAAI,KAAK,CAAC,GAAG,MAAM,QAAQ,CAAC,GAAG;AAC/C,UAAI,KAAK,IAAI,IAAI,EAAG;AACpB,UAAI;AACF,aAAK,YAAY;AAAA,MACnB,QAAQ;AAAA,MAER;AACA,YAAM,OAAO,IAAI;AACjB,cAAQ,IAAI,mBAAmB,IAAI,wCAAmC;AAAA,IACxE;AAAA,EACF;AAEA,QAAM,QAAQ;AAMd,QAAM,OAAO,KAAK,kBAAkB;AACpC,MAAI,UAAkC;AACtC,MAAI,UAEO;AACX,MAAI,cAAc;AAClB,MAAI,cAAc;AAElB,MAAI,MAAM;AACR,UAAM,OAAO,YAAY,IAAI;AAC7B,UAAM,WAAW,gBAAgB,IAAI;AACrC,UAAM,WAAW,gBAAgB,IAAI;AAErC,QAAI;AACF,gBAAU,MAAM,SAAS,EAAE,UAAU,SAAS,CAAC;AAC/C,oBAAc,MAAM,QAAQ,OAAO,EAAE,MAAM,UAAU,KAAK,CAAC;AAC3D,cAAQ,IAAI,4BAA4B,WAAW,EAAE;AAAA,IACvD,SAAS,KAAK;AAEZ,iBAAW,QAAQ,MAAM,OAAO,GAAG;AACjC,YAAI;AACF,eAAK,YAAY;AAAA,QACnB,QAAQ;AAAA,QAER;AAAA,MACF;AACA,UAAI,QAAS,OAAM,QAAQ,MAAM,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACjD,YAAM,GAAG,OAAO,OAAO,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACvC,YAAM,IAAI;AAAA,QACR,sCAAsC,QAAQ,WAAO,IAAc,OAAO;AAAA,MAC5E;AAAA,IACF;AAEA,QAAI;AACF,gBAAU,MAAM,kBAAkB;AAAA,QAChC,QAAQ,OAAO,SAAS;AAItB,gBAAM,cAAc,MAAM,aAAa,EAAE,MAAM,MAAM,CAAC,CAAC;AACvD,gBAAM,SAAS,mBAAmB,KAAK,SAAS,WAAW;AAC3D,gBAAM,OAAO,MAAM,IAAI,MAAM,KAAK,MAAM,IAAI,eAAe;AAC3D,cAAI,CAAC,QAAQ,KAAK,WAAW,SAAU;AACvC,gBAAM;AAAA,YACJ;AAAA,cACE,OAAO,KAAK;AAAA,cACZ,YAAY,KAAK,MAAM;AAAA,cACvB,SAAS,KAAK,MAAM;AAAA;AAAA,cAEpB,uBAAuB;AAAA,YACzB;AAAA,YACA;AAAA,UACF;AAAA,QACF;AAAA,QACA,iBAAiB,OAAO,SAAS;AAC/B,gBAAM,cAAc,MAAM,aAAa,EAAE,MAAM,MAAM,CAAC,CAAC;AACvD,gBAAM,SAAS,mBAAmB,KAAK,SAAS,WAAW;AAC3D,gBAAM,OAAO,MAAM,IAAI,MAAM,KAAK,MAAM,IAAI,eAAe;AAC3D,cAAI,CAAC,QAAQ,KAAK,WAAW,SAAU;AACvC,gBAAM,oBAAoB,KAAK,MAAM,UAAU,EAAE,IAAI;AAAA,QACvD;AAAA,MACF,CAAC;AACD,oBAAc,MAAM,QAAQ,OAAO,EAAE,MAAM,UAAU,KAAK,CAAC;AAC3D,cAAQ,IAAI,4BAA4B,WAAW,YAAY;AAAA,IACjE,SAAS,KAAK;AACZ,iBAAW,QAAQ,MAAM,OAAO,GAAG;AACjC,YAAI;AACF,eAAK,YAAY;AAAA,QACnB,QAAQ;AAAA,QAER;AAAA,MACF;AACA,UAAI,QAAS,OAAM,QAAQ,MAAM,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACjD,UAAI,QAAS,OAAM,QAAQ,MAAM,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACjD,YAAM,GAAG,OAAO,OAAO,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACvC,YAAM,IAAI;AAAA,QACR,sCAAsC,QAAQ,WAAO,IAAc,OAAO;AAAA,MAC5E;AAAA,IACF;AAAA,EACF;AAEA,MAAI,YAAkC;AACtC,QAAM,SAAS,YAA2B;AACxC,QAAI,UAAW,QAAO;AACtB,iBAAa,YAAY;AACvB,UAAI;AACF,cAAM,QAAQ;AAAA,MAChB,UAAE;AACA,oBAAY;AAAA,MACd;AAAA,IACF,GAAG;AACH,WAAO;AAAA,EACT;AAGA,QAAM,gBAAgB,MAAY;AAChC,SAAK,OAAO,EAAE,MAAM,CAAC,QAAQ;AAC3B,cAAQ,KAAK,sCAAkC,IAAc,OAAO,EAAE;AAAA,IACxE,CAAC;AAAA,EACH;AACA,UAAQ,GAAG,UAAU,aAAa;AAElC,MAAI,UAAU;AACd,QAAM,OAAO,YAA2B;AACtC,QAAI,QAAS;AACb,cAAU;AACV,YAAQ,IAAI,UAAU,aAAa;AACnC,QAAI,QAAS,OAAM,QAAQ,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACjD,QAAI,QAAS,OAAM,QAAQ,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACjD,eAAW,QAAQ,MAAM,OAAO,GAAG;AACjC,UAAI;AACF,aAAK,YAAY;AAAA,MACnB,QAAQ;AAAA,MAER;AAAA,IACF;AACA,UAAM,GAAG,OAAO,OAAO,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EACzC;AAEA,SAAO,EAAE,OAAO,QAAQ,MAAM,SAAS,aAAa,YAAY;AAClE;","names":[]}