@neat.is/core 0.4.26-dev.20260702 → 0.4.26-dev.20260703

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.
@@ -64,11 +64,13 @@ function mountBearerAuth(app, opts) {
64
64
  }
65
65
  const header = req.headers.authorization;
66
66
  if (typeof header !== "string" || !header.startsWith("Bearer ")) {
67
+ opts.onReject?.();
67
68
  void reply.code(401).send({ error: "unauthorized" });
68
69
  return;
69
70
  }
70
71
  const provided = Buffer.from(header.slice("Bearer ".length).trim(), "utf8");
71
72
  if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
73
+ opts.onReject?.();
72
74
  void reply.code(401).send({ error: "unauthorized" });
73
75
  return;
74
76
  }
@@ -162,6 +164,13 @@ function pickEnv(spanAttrs, resourceAttrs) {
162
164
  }
163
165
  return ENV_FALLBACK;
164
166
  }
167
+ function messagingDestinationOf(attrs) {
168
+ for (const key of ["messaging.destination.name", "messaging.destination"]) {
169
+ const v = attrs[key];
170
+ if (typeof v === "string" && v.length > 0) return v;
171
+ }
172
+ return void 0;
173
+ }
165
174
  function parseOtlpRequest(body) {
166
175
  const out = [];
167
176
  for (const rs of body.resourceSpans ?? []) {
@@ -188,6 +197,10 @@ function parseOtlpRequest(body) {
188
197
  attributes: attrs,
189
198
  dbSystem: typeof attrs["db.system"] === "string" ? attrs["db.system"] : void 0,
190
199
  dbName: typeof attrs["db.name"] === "string" ? attrs["db.name"] : void 0,
200
+ messagingSystem: typeof attrs["messaging.system"] === "string" ? attrs["messaging.system"] : void 0,
201
+ messagingDestination: messagingDestinationOf(attrs),
202
+ graphqlOperationName: typeof attrs["graphql.operation.name"] === "string" && attrs["graphql.operation.name"].length > 0 ? attrs["graphql.operation.name"] : void 0,
203
+ graphqlOperationType: typeof attrs["graphql.operation.type"] === "string" && attrs["graphql.operation.type"].length > 0 ? attrs["graphql.operation.type"] : void 0,
191
204
  statusCode: span.status?.code,
192
205
  errorMessage: span.status?.message,
193
206
  exception: extractExceptionFromEvents(span.events)
@@ -242,7 +255,7 @@ async function decodeProtobufBody(buf) {
242
255
  longs: String,
243
256
  enums: Number
244
257
  });
245
- const { reshapeGrpcRequest } = await import("./otel-grpc-QYHUJ4OY.js");
258
+ const { reshapeGrpcRequest } = await import("./otel-grpc-LFYZDSK3.js");
246
259
  return reshapeGrpcRequest(decoded);
247
260
  }
248
261
  async function buildOtelReceiver(opts) {
@@ -250,7 +263,21 @@ async function buildOtelReceiver(opts) {
250
263
  logger: false,
251
264
  bodyLimit: opts.bodyLimit ?? 16 * 1024 * 1024
252
265
  });
253
- mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy });
266
+ const REJECT_WARN_INTERVAL_MS = 6e4;
267
+ let lastRejectWarnAt = 0;
268
+ const warnRejectedOtlp = () => {
269
+ const now = Date.now();
270
+ if (now - lastRejectWarnAt < REJECT_WARN_INTERVAL_MS) return;
271
+ lastRejectWarnAt = now;
272
+ console.warn(
273
+ "[neatd] rejecting OTLP spans on /v1/traces \u2014 missing or invalid bearer token (set NEAT_OTEL_TOKEN on the instrumented app)"
274
+ );
275
+ };
276
+ mountBearerAuth(app, {
277
+ token: opts.authToken,
278
+ trustProxy: opts.trustProxy,
279
+ onReject: warnRejectedOtlp
280
+ });
254
281
  const queue = [];
255
282
  let draining = false;
256
283
  let drainPromise = Promise.resolve();
@@ -442,4 +469,4 @@ export {
442
469
  listenSteppingOtlp,
443
470
  logSpanHandler
444
471
  };
445
- //# sourceMappingURL=chunk-ZX7PCMGZ.js.map
472
+ //# sourceMappingURL=chunk-2LNICOVU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../node_modules/tsup/assets/esm_shims.js","../src/auth.ts","../src/otel.ts"],"sourcesContent":["// Shim globals in esm bundle\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst getFilename = () => fileURLToPath(import.meta.url)\nconst getDirname = () => path.dirname(getFilename())\n\nexport const __dirname = /* @__PURE__ */ getDirname()\nexport const __filename = /* @__PURE__ */ getFilename()\n","/**\n * Delegated auth at the daemon boundary (ADR-073 §3 + §4).\n *\n * NEAT does not issue, rotate, or distribute the token — that is the deploy\n * platform's job. This module provides the two surfaces the daemon needs:\n *\n * - `assertBindAuthority(host, token)` — fail-loud pre-bind check. When no\n * token is set, the daemon refuses to bind on any non-loopback address.\n * Loopback-only without a token stays unauthenticated (laptop dev path).\n *\n * - `mountBearerAuth(app, opts)` — Fastify `preHandler` that requires\n * `Authorization: Bearer <token>` on every request other than the\n * unauthenticated health/readiness probes. Constant-time comparison.\n *\n * The same shape covers both the REST host and the OTLP receivers; the OTLP\n * side passes a different token (`NEAT_OTEL_TOKEN ?? NEAT_AUTH_TOKEN`) so the\n * two surfaces rotate independently.\n */\n\nimport { timingSafeEqual } from 'node:crypto'\nimport type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'\n\n// Hosts that count as loopback for the bind-authority gate. `0.0.0.0` is\n// explicitly not loopback — it binds on every interface, including any\n// public one the operator's box happens to have.\nconst LOOPBACK_HOSTS: ReadonlySet<string> = new Set([\n '127.0.0.1',\n 'localhost',\n '::1',\n '::ffff:127.0.0.1',\n])\n\nexport function isLoopbackHost(host: string | undefined | null): boolean {\n if (!host) return false\n return LOOPBACK_HOSTS.has(host)\n}\n\nexport class BindAuthorityError extends Error {\n constructor(host: string) {\n super(\n `NEAT refuses to bind on a public interface without \\`NEAT_AUTH_TOKEN\\` set (host=\"${host}\"). Set the token or bind to loopback only.`,\n )\n this.name = 'BindAuthorityError'\n }\n}\n\nexport function assertBindAuthority(host: string, token: string | undefined): void {\n if (token && token.length > 0) return\n if (isLoopbackHost(host)) return\n throw new BindAuthorityError(host)\n}\n\nexport interface AuthOptions {\n // Bearer token required on every protected route. Undefined / empty → the\n // middleware is not mounted and the route stays unauthenticated. The\n // loopback-only gate above is what keeps that case from being public.\n token?: string\n // When `true`, trust an upstream reverse proxy and skip the request-side\n // check. The fail-loud bind-authority gate still applies upstream. Wired\n // to `NEAT_AUTH_PROXY=true` in production.\n trustProxy?: boolean\n // ADR-073 §3 amendment — public-read mode for reference deployments.\n // When `true`, GET / HEAD / OPTIONS pass through without a bearer; every\n // other verb still requires the token. OTLP ingest is excluded — that\n // surface stays gated unconditionally (the receiver mounts its own\n // middleware without this flag).\n publicRead?: boolean\n // Extra paths (or path suffixes) to leave unauthenticated. Used by tests\n // and by ad-hoc callers that mount their own probes.\n extraUnauthenticatedSuffixes?: ReadonlyArray<string>\n // Diagnostic hook fired whenever a request is rejected with 401 for a\n // missing or invalid bearer. The REST host leaves this unset — a human\n // running `curl` doesn't need a server-side line for their own 401. The\n // OTLP receiver passes it so an app whose telemetry is being dropped for a\n // bad token leaves a signal on the daemon side instead of failing silently.\n // The hook does not change the response body or status; it only observes.\n onReject?: () => void\n}\n\n// Verbs the public-read split treats as reads. Everything else is a write\n// and keeps the bearer requirement.\nconst PUBLIC_READ_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD', 'OPTIONS'])\n\n// Probes that always stay open. Dual-mounted under `/projects/:project/` too,\n// so the check is a suffix match — `/projects/foo/health` is skipped along\n// with the top-level `/health`. ADR-073 §3 names `/healthz` and `/readyz`\n// explicitly; `/health` is the existing endpoint the web shell and CI smoke\n// already lean on, so it keeps the unauthenticated treatment. `/api/config`\n// is the public-read negotiation endpoint — the web shell hits it before any\n// authed call to learn which mode the daemon is running in.\nconst DEFAULT_UNAUTH_SUFFIXES: ReadonlyArray<string> = [\n '/health',\n '/healthz',\n '/readyz',\n '/api/config',\n]\n\nexport function mountBearerAuth(app: FastifyInstance, opts: AuthOptions): void {\n if (!opts.token || opts.token.length === 0) return\n if (opts.trustProxy) return\n\n const expected = Buffer.from(opts.token, 'utf8')\n const suffixes = [...DEFAULT_UNAUTH_SUFFIXES, ...(opts.extraUnauthenticatedSuffixes ?? [])]\n const publicRead = opts.publicRead === true\n\n app.addHook('preHandler', (req: FastifyRequest, reply: FastifyReply, done: (err?: Error) => void) => {\n const path = (req.url.split('?')[0] ?? '').replace(/\\/+$/, '')\n for (const suffix of suffixes) {\n if (path === suffix || path.endsWith(suffix)) {\n done()\n return\n }\n }\n\n // Public-read split: GET / HEAD / OPTIONS pass through anonymously, every\n // other verb keeps the bearer check. The token still authorizes writes,\n // and the bind-authority gate above still demands a token for non-loopback\n // binds — public-read enables anonymous reads on top of that, it doesn't\n // replace either invariant.\n if (publicRead && PUBLIC_READ_METHODS.has(req.method)) {\n done()\n return\n }\n\n const header = req.headers.authorization\n if (typeof header !== 'string' || !header.startsWith('Bearer ')) {\n opts.onReject?.()\n void reply.code(401).send({ error: 'unauthorized' })\n return\n }\n const provided = Buffer.from(header.slice('Bearer '.length).trim(), 'utf8')\n if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {\n opts.onReject?.()\n void reply.code(401).send({ error: 'unauthorized' })\n return\n }\n done()\n })\n}\n\n// Read both tokens from the environment in one place so server.ts, daemon.ts,\n// and the OTel receivers all agree on precedence (ADR-073 §4). `publicRead`\n// rides the same shape so callers don't need a second env read.\nexport interface AuthEnv {\n authToken: string | undefined\n otelToken: string | undefined\n trustProxy: boolean\n publicRead: boolean\n}\n\nfunction parseBoolEnv(v: string | undefined): boolean {\n if (!v) return false\n return v === 'true' || v === '1'\n}\n\nexport function readAuthEnv(env: NodeJS.ProcessEnv = process.env): AuthEnv {\n const t = env.NEAT_AUTH_TOKEN\n const ot = env.NEAT_OTEL_TOKEN\n return {\n authToken: t && t.length > 0 ? t : undefined,\n otelToken: ot && ot.length > 0 ? ot : t && t.length > 0 ? t : undefined,\n trustProxy: env.NEAT_AUTH_PROXY === 'true',\n publicRead: parseBoolEnv(env.NEAT_PUBLIC_READ),\n }\n}\n","import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport Fastify, { type FastifyInstance } from 'fastify'\nimport protobuf from 'protobufjs'\nimport { mountBearerAuth } from './auth.js'\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 // True when the resource carried a `service.name` attribute. False (or\n // omitted, for legacy producers) routes the span to `service:unidentified`\n // in the resolved project and trips a once-per-session-per-project warning\n // on the ingest side (issue #374). OTel spec requires SDKs to set\n // `service.name`, but customised exporters can omit it — diagnostic\n // visibility beats silent drop. Field is optional so test fixtures that\n // hand-construct ParsedSpan with a known service.name don't need to set\n // a flag they don't care about; the receiver always sets it.\n // See docs/contracts/otlp-routing.md §Fallback when `resource.service.name`\n // is missing.\n resourceServiceNamePresent?: boolean\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 // Deployment environment from `deployment.environment(.name)`. Span attrs\n // win over resource attrs (per-span overrides a resource-wide declaration);\n // the canonical `deployment.environment.name` (OTel SC v1.27+) wins over\n // the compat form `deployment.environment`. Literal `'unknown'` is the\n // honest fallback when no env signal is present anywhere on the span or\n // its resource. See ADR-074 §2 / docs/contracts/env-dimension.md.\n env: string\n attributes: Record<string, AttributeValue>\n // Convenience accessors for the attributes #8 cares about.\n dbSystem?: string\n dbName?: string\n // Messaging semconv (OTel). `messaging.system` names the broker family\n // (kafka, rabbitmq, redis, …); the destination is the topic/queue the span\n // produced to or consumed from — the canonical `messaging.destination.name`\n // (SC v1.24+) with the legacy `messaging.destination` as fallback. handleSpan\n // reads these to mint a PUBLISHES_TO (PRODUCER) or CONSUMES_FROM (CONSUMER)\n // OBSERVED edge to the destination node, fusing with the static extractor's\n // topic node. See docs/contracts/otel-ingest.md §Queue producers and consumers.\n messagingSystem?: string\n messagingDestination?: string\n // GraphQL semconv (OTel). `graphql.operation.name` is the client-supplied\n // operation name the execution span resolved (`GetUser`); `graphql.operation.type`\n // is the operation kind (`query` / `mutation` / `subscription`). handleSpan reads\n // both to mint an OBSERVED `CONTAINS` edge from the serving service to a\n // per-operation node, recovering the operation-level topology that HTTP grain\n // collapses onto `POST /graphql`. See docs/contracts/otel-ingest.md §GraphQL\n // operations.\n graphqlOperationName?: string\n graphqlOperationType?: 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// Variant that receives the project the URL already resolved to. Used by the\n// project-scoped route mounted at `/projects/:project/v1/traces` (issue #367):\n// the receiver hands the URL-path project to the daemon directly so the\n// daemon never has to guess via `service.name`-against-registry matching.\nexport type ProjectSpanHandler = (project: string, 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 // Project-scoped variants — used by the `/projects/:project/v1/traces`\n // route. When unset the route is still mounted but falls through to the\n // legacy `onSpan` / `onErrorSpanSync` handlers (the project name is then\n // available to the consumer via the `OTEL_PROJECT_OVERRIDE` attribute on\n // each parsed span; ad-hoc receivers commonly leave both unset).\n onProjectSpan?: ProjectSpanHandler\n onProjectErrorSpanSync?: (project: string, span: ParsedSpan) => Promise<void>\n // Fastify body limit. OTLP batches can be large; default is 16 MB.\n bodyLimit?: number\n // ADR-073 §4 — bearer required on `/v1/traces`. Defaults to `NEAT_AUTH_TOKEN`\n // when unset; `NEAT_OTEL_TOKEN` overrides at the call site so the REST and\n // OTLP surfaces rotate on independent schedules.\n authToken?: string\n // Same shape as the REST middleware: skip the request-side check when an\n // upstream reverse proxy already authenticated.\n trustProxy?: boolean\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\n// Resolve `deployment.environment` per ADR-074 §2. The four-step fallback\n// is: span-attr canonical form → span-attr compat form → resource-attr\n// canonical form → resource-attr compat form → literal `'unknown'`. The\n// literal `'unknown'` is the honest sentinel; defaulting to `'production'`\n// or `'development'` would bake an incorrect assumption into every span\n// from a workload that hasn't yet wired its env signal.\nconst ENV_ATTR_CANONICAL = 'deployment.environment.name'\nconst ENV_ATTR_COMPAT = 'deployment.environment'\nconst ENV_FALLBACK = 'unknown'\n\nfunction pickEnv(\n spanAttrs: Record<string, AttributeValue>,\n resourceAttrs: Record<string, AttributeValue>,\n): string {\n for (const attrs of [spanAttrs, resourceAttrs]) {\n for (const key of [ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT]) {\n const v = attrs[key]\n if (typeof v === 'string' && v.length > 0) return v\n }\n }\n return ENV_FALLBACK\n}\n\n// The messaging destination (topic / queue / stream) a producer or consumer\n// span names. `messaging.destination.name` is the canonical semconv key\n// (SC v1.24+); `messaging.destination` is the older form some instrumentations\n// still emit. Prefer the canonical one, fall back to the legacy, and treat an\n// empty string as absent so an anonymous destination never keys a node.\nfunction messagingDestinationOf(\n attrs: Record<string, AttributeValue>,\n): string | undefined {\n for (const key of ['messaging.destination.name', 'messaging.destination']) {\n const v = attrs[key]\n if (typeof v === 'string' && v.length > 0) return v\n }\n return undefined\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 // OTel spec requires SDKs to set `service.name`, but customised exporters\n // can omit it. Missing `service.name` routes to `service:unidentified`\n // in handleSpan + emits a once-per-session-per-project warning so the\n // diagnostic stays visible (issue #374). Silent drop is not an option.\n const rawServiceName = resourceAttrs['service.name']\n const resourceServiceNamePresent =\n typeof rawServiceName === 'string' && rawServiceName.length > 0\n const service = resourceServiceNamePresent\n ? (rawServiceName as string)\n : 'unidentified'\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 resourceServiceNamePresent,\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 env: pickEnv(attrs, resourceAttrs),\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 messagingSystem:\n typeof attrs['messaging.system'] === 'string'\n ? (attrs['messaging.system'] as string)\n : undefined,\n messagingDestination: messagingDestinationOf(attrs),\n graphqlOperationName:\n typeof attrs['graphql.operation.name'] === 'string' &&\n (attrs['graphql.operation.name'] as string).length > 0\n ? (attrs['graphql.operation.name'] as string)\n : undefined,\n graphqlOperationType:\n typeof attrs['graphql.operation.type'] === 'string' &&\n (attrs['graphql.operation.type'] as string).length > 0\n ? (attrs['graphql.operation.type'] as string)\n : 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\nlet exportTraceServiceResponseType: protobuf.Type | null = null\n\nfunction loadProtoRoot(): protobuf.Root {\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 return root\n}\n\nfunction loadProtobufDecoder(): protobuf.Type {\n if (exportTraceServiceRequestType) return exportTraceServiceRequestType\n const root = loadProtoRoot()\n exportTraceServiceRequestType = root.lookupType(\n 'opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest',\n )\n return exportTraceServiceRequestType\n}\n\nfunction loadProtobufResponseEncoder(): protobuf.Type {\n if (exportTraceServiceResponseType) return exportTraceServiceResponseType\n const root = loadProtoRoot()\n exportTraceServiceResponseType = root.lookupType(\n 'opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse',\n )\n return exportTraceServiceResponseType\n}\n\n// Empty-partial-success response, encoded once and cached. Per ADR-033 the\n// receiver always reports \"all accepted\" today — there's no per-span reject\n// path. Caching the bytes avoids re-running the protobuf encoder per request.\nlet cachedProtobufResponseBody: Buffer | null = null\n\nfunction encodeProtobufResponseBody(): Buffer {\n if (cachedProtobufResponseBody) return cachedProtobufResponseBody\n const Type = loadProtobufResponseEncoder()\n // `partial_success` left unset = empty submessage = \"everything accepted\".\n // verify() returns null on success; the empty payload is always valid.\n const msg = Type.create({})\n const encoded = Type.encode(msg).finish()\n cachedProtobufResponseBody = Buffer.from(encoded)\n return cachedProtobufResponseBody\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 // toObject() options mirror the gRPC receiver's proto-loader config\n // (otel-grpc.ts: longs: String, enums: Number, bytes left as Buffers) so\n // both protobuf paths hand reshapeGrpcRequest the identical shape. The old\n // .toJSON() here rendered bytes as base64 strings (bytesToHex returned ''\n // → empty trace/span IDs) and enums as name strings (\"SPAN_KIND_CLIENT\"\n // never matches the numeric mint gate) — every http/protobuf span was\n // accepted and then silently minted nothing (#468).\n // Dynamic import sidesteps the circular module dep with otel-grpc.ts.\n const decoded = Type.toObject(Type.decode(buf), {\n longs: String,\n enums: Number,\n }) 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 // ADR-073 §4 — bearer on `/v1/traces`. `/health` stays unauthenticated via\n // the default suffix list (the CI smoke and supervisors lean on it for\n // liveness probes).\n //\n // A rejected OTLP POST is a silent failure on the sender's side — the app\n // gets a bare 401 and its telemetry vanishes, so the operator sees an empty\n // OBSERVED layer with no clue why. Emit a server-side warning when that\n // happens, rate-limited to one line per interval so a chatty misconfigured\n // exporter (they retry hard) can't flood the log. The plain REST 401 path\n // stays quiet — that surface leaves this hook unset.\n const REJECT_WARN_INTERVAL_MS = 60_000\n let lastRejectWarnAt = 0\n const warnRejectedOtlp = (): void => {\n const now = Date.now()\n if (now - lastRejectWarnAt < REJECT_WARN_INTERVAL_MS) return\n lastRejectWarnAt = now\n console.warn(\n '[neatd] rejecting OTLP spans on /v1/traces — missing or invalid bearer token (set NEAT_OTEL_TOKEN on the instrumented app)',\n )\n }\n mountBearerAuth(app, {\n token: opts.authToken,\n trustProxy: opts.trustProxy,\n onReject: warnRejectedOtlp,\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 // Per-project queue is reusable across the project-scoped route — the\n // project name rides in the URL, not on the span. Drain semantics mirror\n // the global queue so flushPending() captures both.\n const projectQueue: Array<{ project: string; span: ParsedSpan }> = []\n let projectDraining = false\n let projectDrainPromise: Promise<void> = Promise.resolve()\n const drainProject = async (): Promise<void> => {\n if (projectDraining) return\n projectDraining = true\n try {\n while (projectQueue.length > 0) {\n const { project, span } = projectQueue.shift()!\n try {\n if (opts.onProjectSpan) {\n await opts.onProjectSpan(project, span)\n } else {\n await opts.onSpan(span)\n }\n } catch (err) {\n console.warn(`[neat] otel handler error: ${(err as Error).message}`)\n }\n }\n } finally {\n projectDraining = false\n }\n }\n const enqueueProject = (project: string, spans: ParsedSpan[]): void => {\n if (spans.length === 0) return\n for (const s of spans) projectQueue.push({ project, span: s })\n projectDrainPromise = projectDrainPromise.then(() => drainProject())\n }\n\n // One-time-per-service-name deprecation warning for spans landing on the\n // bare `/v1/traces` endpoint. Under one daemon per project (ADR-096) that\n // route is the project's own ingest path and needs no migration, so the\n // warning is gated on whether this receiver actually offers project-scoped\n // routing: only a receiver wired with `onProjectSpan` (the multi-project\n // daemon that mounts `/projects/<name>/v1/traces`) has somewhere to migrate\n // an exporter to. A single-project receiver leaves the gate closed and never\n // nags. Still once-per-name so a long-running daemon doesn't flood stderr\n // while an operator migrates.\n const offersProjectRouting = opts.onProjectSpan !== undefined\n const legacyEndpointWarned = new Set<string>()\n function warnLegacyEndpoint(serviceName: string): void {\n if (!offersProjectRouting) return\n if (legacyEndpointWarned.has(serviceName)) return\n legacyEndpointWarned.add(serviceName)\n console.warn(\n `[neatd] received span on the global endpoint; migrate OTEL_EXPORTER_OTLP_TRACES_ENDPOINT to /projects/<name>/v1/traces (service.name=\"${serviceName}\").`,\n )\n }\n\n // Shared body-decode + content-negotiation. Both `/v1/traces` and\n // `/projects/:project/v1/traces` go through this so the protobuf/JSON\n // dispatch stays in one place.\n async function readOtlpBody(req: import('fastify').FastifyRequest): Promise<\n | { ok: true; body: OtlpTracesRequest; flavor: 'json' | 'protobuf' }\n | { ok: false; code: 400 | 415; error: string }\n > {\n const ct = (req.headers['content-type'] ?? '').toString().split(';')[0]!.trim().toLowerCase()\n if (ct === 'application/x-protobuf') {\n try {\n const body = await decodeProtobufBody(req.body as Buffer)\n return { ok: true, body, flavor: 'protobuf' }\n } catch (err) {\n return { ok: false, code: 400, error: `protobuf decode failed: ${(err as Error).message}` }\n }\n }\n if (!ct || ct === 'application/json') {\n return { ok: true, body: (req.body ?? {}) as OtlpTracesRequest, flavor: 'json' }\n }\n return { ok: false, code: 415, error: `unsupported content-type: ${ct}` }\n }\n\n function sendOtlpSuccess(reply: import('fastify').FastifyReply, flavor: 'json' | 'protobuf'): unknown {\n if (flavor === 'protobuf') {\n const buf = encodeProtobufResponseBody()\n return reply\n .code(200)\n .header('content-type', 'application/x-protobuf')\n .send(buf)\n }\n return reply\n .code(200)\n .header('content-type', 'application/json')\n .send({ partialSuccess: {} })\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 // Legacy global endpoint. Spans land here when an OTel exporter hasn't\n // migrated to the project-scoped URL yet (issue #367); the daemon still\n // routes by `service.name` against the registry. Per-service-name\n // deprecation warning fires once so an operator notices without their\n // stderr flooding.\n const result = await readOtlpBody(req)\n if (!result.ok) {\n return reply.code(result.code).send({ error: result.error })\n }\n const spans = parseOtlpRequest(result.body)\n for (const s of spans) warnLegacyEndpoint(s.service)\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 return sendOtlpSuccess(reply, result.flavor)\n })\n\n // Project-scoped route (issue #367). The URL `:project` carries the routing\n // key, sidestepping the `service.name`-against-registry heuristic the legacy\n // path uses. Spans get dispatched into the named project's ingest path\n // directly; `OTEL_SERVICE_NAME` regains its proper semantic role of naming\n // the ServiceNode inside the one project the URL already picked.\n app.post<{ Params: { project: string } }>('/projects/:project/v1/traces', async (req, reply) => {\n const project = req.params.project\n const result = await readOtlpBody(req)\n if (!result.ok) {\n return reply.code(result.code).send({ error: result.error })\n }\n const spans = parseOtlpRequest(result.body)\n if (opts.onProjectErrorSpanSync) {\n try {\n for (const span of spans) {\n if (span.statusCode === 2) await opts.onProjectErrorSpanSync(project, span)\n }\n } catch (err) {\n return reply.code(500).send({\n error: `error-event write failed: ${(err as Error).message}`,\n })\n }\n } else 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 enqueueProject(project, spans)\n return sendOtlpSuccess(reply, result.flavor)\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 both drain chains, then loop until both queues are fully empty\n // (a span enqueued mid-flush would otherwise be missed).\n while (queue.length > 0 || draining || projectQueue.length > 0 || projectDraining) {\n await Promise.all([drainPromise, projectDrainPromise])\n }\n }\n return decorated\n}\n\n// How far the OTLP receiver steps before giving up, and the stride between\n// candidate ports. Matches the orchestrator's triple allocator (8 attempts,\n// stride 1) so the daemon's own bind and the pre-spawn allocation reach the\n// same free port under the same contention.\nconst OTLP_STEP_ATTEMPTS = 8\nconst OTLP_STEP_STRIDE = 1\n\n// Bind an OTLP receiver, stepping to the next free port when the requested one\n// is held (daemon.md §Binding — a held OTLP port steps, it does not crash the\n// daemon; project-daemon §3). The REST port is the daemon's identity and stays\n// fatal on collision, but every OTLP consumer resolves the port dynamically\n// from `daemon.json` `ports.otlp`, so stepping the receiver and recording the\n// port it actually bound keeps the OBSERVED layer alive instead of darking the\n// whole daemon on a foreign collector holding `:4318`. Returns the bound\n// address (host:port); callers read the real port back from it. A requested\n// port of `0` means \"let the kernel pick a free one\" — no collision is\n// possible, so no stepping happens. Non-`EADDRINUSE` failures (permission\n// denied) and an exhausted step window propagate to the caller unchanged.\nexport async function listenSteppingOtlp(\n app: FastifyInstance,\n requestedPort: number,\n host: string,\n): Promise<string> {\n let port = requestedPort\n for (let attempt = 0; ; attempt++) {\n try {\n return await app.listen({ port, host })\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n const canStep =\n requestedPort !== 0 && code === 'EADDRINUSE' && attempt < OTLP_STEP_ATTEMPTS - 1\n if (!canStep) throw err\n console.warn(\n `otel: OTLP port ${port} is in use, stepping to ${port + OTLP_STEP_STRIDE}`,\n )\n port += OTLP_STEP_STRIDE\n }\n }\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":"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","names":["path","path","fileURLToPath","path","fileURLToPath"]}
@@ -35,6 +35,18 @@ function embedText(node) {
35
35
  if (filePath) parts.push(`path=${filePath}`);
36
36
  break;
37
37
  }
38
+ case "RouteNode": {
39
+ const method = node.method;
40
+ const tmpl = node.pathTemplate;
41
+ if (method) parts.push(`method=${method}`);
42
+ if (tmpl) parts.push(`path=${tmpl}`);
43
+ break;
44
+ }
45
+ case "GraphQLOperationNode": {
46
+ const opType = node.operationType;
47
+ if (opType) parts.push(`operationType=${opType}`);
48
+ break;
49
+ }
38
50
  default:
39
51
  break;
40
52
  }
@@ -287,4 +299,4 @@ async function buildSearchIndex(graph, options = {}) {
287
299
  export {
288
300
  buildSearchIndex
289
301
  };
290
- //# sourceMappingURL=chunk-XOOCA5T7.js.map
302
+ //# sourceMappingURL=chunk-4OR4RQEO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/search.ts"],"sourcesContent":["// semantic_search — embedding-based node retrieval with a three-tier\n// fallback chain. The chain is settled in ADR-025; this file is the\n// implementation. Public API:\n//\n// buildSearchIndex(graph, opts) → SearchIndex\n// SearchIndex.search(query, limit) → { provider, matches }\n// SearchIndex.refresh(graph) → re-embeds new/changed nodes,\n// drops vanished ones\n//\n// The `/search` route in api.ts holds a single SearchIndex, refreshing it\n// after any extraction. MCP's `semantic_search` tool reads the same shape.\n\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\nimport { createHash } from 'node:crypto'\nimport type { GraphNode } from '@neat.is/types'\nimport type { NeatGraph } from './graph.js'\n\nexport interface ScoredNode {\n node: GraphNode\n score: number\n}\n\nexport interface SearchResponse {\n query: string\n provider: 'ollama' | 'transformers' | 'substring'\n matches: ScoredNode[]\n}\n\nexport interface SearchIndex {\n readonly provider: SearchResponse['provider']\n search(query: string, limit?: number): Promise<SearchResponse>\n refresh(graph: NeatGraph): Promise<void>\n}\n\ninterface Embedder {\n provider: 'ollama' | 'transformers'\n model: string\n dim: number\n embed(texts: string[]): Promise<Float32Array[]>\n}\n\nconst DEFAULT_LIMIT = 10\nconst NOMIC_DIM = 768\nconst MINI_LM_DIM = 384\n\n// FrontierNodes are noise by design (placeholders that should disappear).\n// Embedding them would just clutter results.\nfunction shouldEmbed(node: GraphNode): boolean {\n return node.type !== 'FrontierNode'\n}\n\n// Deterministic per-node text. Stable keys let the cache hit across\n// extractions when nothing material changed.\nexport function embedText(node: GraphNode): string {\n const parts: string[] = [node.id]\n const name = (node as { name?: string }).name\n if (name) parts.push(name)\n switch (node.type) {\n case 'ServiceNode': {\n const lang = (node as { language?: string }).language\n if (lang) parts.push(`language=${lang}`)\n break\n }\n case 'DatabaseNode': {\n const eng = (node as { engine?: string }).engine\n const ver = (node as { engineVersion?: string }).engineVersion\n if (eng) parts.push(`engine=${eng}`)\n if (ver) parts.push(`engineVersion=${ver}`)\n break\n }\n case 'InfraNode': {\n const kind = (node as { kind?: string }).kind\n if (kind) parts.push(`kind=${kind}`)\n break\n }\n case 'ConfigNode': {\n const filePath = (node as { path?: string }).path\n if (filePath) parts.push(`path=${filePath}`)\n break\n }\n case 'RouteNode': {\n const method = (node as { method?: string }).method\n const tmpl = (node as { pathTemplate?: string }).pathTemplate\n if (method) parts.push(`method=${method}`)\n if (tmpl) parts.push(`path=${tmpl}`)\n break\n }\n case 'GraphQLOperationNode': {\n const opType = (node as { operationType?: string }).operationType\n if (opType) parts.push(`operationType=${opType}`)\n break\n }\n default:\n break\n }\n return parts.join(' ')\n}\n\nfunction attrsHash(node: GraphNode): string {\n return createHash('sha1').update(embedText(node)).digest('hex').slice(0, 16)\n}\n\nexport function cosine(a: Float32Array, b: Float32Array): number {\n if (a.length !== b.length) return 0\n let dot = 0\n let na = 0\n let nb = 0\n for (let i = 0; i < a.length; i++) {\n const ai = a[i] ?? 0\n const bi = b[i] ?? 0\n dot += ai * bi\n na += ai * ai\n nb += bi * bi\n }\n if (na === 0 || nb === 0) return 0\n return dot / (Math.sqrt(na) * Math.sqrt(nb))\n}\n\n// ---------------------------------------------------------------- Embedders\n\nfunction ollamaHost(): string | null {\n return process.env.OLLAMA_HOST ?? null\n}\n\nasync function ollamaReachable(host: string): Promise<boolean> {\n try {\n const res = await fetch(`${host.replace(/\\/$/, '')}/api/tags`, {\n signal: AbortSignal.timeout(500),\n })\n return res.ok\n } catch {\n return false\n }\n}\n\nfunction makeOllamaEmbedder(host: string, model = 'nomic-embed-text'): Embedder {\n const root = host.replace(/\\/$/, '')\n return {\n provider: 'ollama',\n model,\n dim: NOMIC_DIM,\n async embed(texts: string[]): Promise<Float32Array[]> {\n const out: Float32Array[] = []\n // Ollama's /api/embeddings is one-text-per-request. ≤10K nodes × ~30ms\n // each is fine for a one-shot index build; if it ever isn't, the API\n // also accepts batched input on /api/embed (newer routes).\n for (const text of texts) {\n const res = await fetch(`${root}/api/embeddings`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ model, prompt: text }),\n })\n if (!res.ok) {\n throw new Error(`ollama embeddings: ${res.status} ${res.statusText}`)\n }\n const data = (await res.json()) as { embedding: number[] }\n out.push(Float32Array.from(data.embedding))\n }\n return out\n },\n }\n}\n\ninterface XenovaPipeline {\n (text: string | string[], options?: { pooling?: string; normalize?: boolean }): Promise<{\n data: Float32Array\n }>\n}\n\nasync function makeTransformersEmbedder(): Promise<Embedder | null> {\n let pipelineFn: ((task: string, model: string) => Promise<XenovaPipeline>) | null = null\n try {\n // Lazy require so server.ts boot doesn't pay the WASM init cost when\n // Ollama is available. The package is heavy — only load it on demand.\n // The package is optional so its types may not be installed in every\n // environment. Use a dynamic specifier so tsc keeps the import dynamic\n // and doesn't try to resolve types at build time.\n const specifier = '@xenova/transformers'\n const mod = (await import(specifier)) as unknown as {\n pipeline: (task: string, model: string) => Promise<XenovaPipeline>\n }\n pipelineFn = mod.pipeline\n } catch {\n return null\n }\n if (!pipelineFn) return null\n const model = 'Xenova/all-MiniLM-L6-v2'\n const extractor = await pipelineFn('feature-extraction', model)\n return {\n provider: 'transformers',\n model,\n dim: MINI_LM_DIM,\n async embed(texts: string[]): Promise<Float32Array[]> {\n const out: Float32Array[] = []\n for (const text of texts) {\n // Mean-pooled, L2-normalized → cosine reduces to dot product but\n // we keep the explicit cosine() for clarity.\n const result = await extractor(text, { pooling: 'mean', normalize: true })\n out.push(Float32Array.from(result.data))\n }\n return out\n },\n }\n}\n\n// Picks the highest-tier embedder available. Returns null when only\n// substring is available (caller decides what to build).\nexport async function pickEmbedder(): Promise<Embedder | null> {\n const host = ollamaHost()\n if (host && (await ollamaReachable(host))) {\n return makeOllamaEmbedder(host)\n }\n return makeTransformersEmbedder()\n}\n\n// ------------------------------------------------------------------ Cache\n\ninterface CacheEntry {\n nodeId: string\n attrsHash: string\n vector: number[]\n}\n\ninterface CacheFile {\n version: 1\n provider: 'ollama' | 'transformers'\n model: string\n dim: number\n entries: CacheEntry[]\n}\n\nasync function readCache(cachePath: string): Promise<CacheFile | null> {\n try {\n const raw = await fs.readFile(cachePath, 'utf8')\n const parsed = JSON.parse(raw) as CacheFile\n if (parsed.version !== 1) return null\n return parsed\n } catch {\n return null\n }\n}\n\nasync function writeCache(cachePath: string, cache: CacheFile): Promise<void> {\n await fs.mkdir(path.dirname(cachePath), { recursive: true })\n await fs.writeFile(cachePath, JSON.stringify(cache))\n}\n\n// ----------------------------------------------------------------- Indexes\n\nclass VectorIndex implements SearchIndex {\n readonly provider: 'ollama' | 'transformers'\n private vectors = new Map<string, { node: GraphNode; vector: Float32Array; hash: string }>()\n\n constructor(\n private embedder: Embedder,\n private cachePath: string | null,\n ) {\n this.provider = embedder.provider\n }\n\n async search(query: string, limit = DEFAULT_LIMIT): Promise<SearchResponse> {\n const trimmed = query.trim()\n if (!trimmed || this.vectors.size === 0) {\n return { query: trimmed, provider: this.provider, matches: [] }\n }\n const embedded = await this.embedder.embed([trimmed])\n const qv = embedded[0]\n if (!qv) {\n return { query: trimmed, provider: this.provider, matches: [] }\n }\n const scored: ScoredNode[] = []\n for (const { node, vector } of this.vectors.values()) {\n const score = cosine(qv, vector)\n scored.push({ node, score })\n }\n scored.sort((a, b) => b.score - a.score)\n return { query: trimmed, provider: this.provider, matches: scored.slice(0, limit) }\n }\n\n async refresh(graph: NeatGraph): Promise<void> {\n const present = new Set<string>()\n const toEmbed: { id: string; node: GraphNode; hash: string; text: string }[] = []\n\n graph.forEachNode((id, attrs) => {\n const node = attrs as GraphNode\n if (!shouldEmbed(node)) return\n present.add(id)\n const hash = attrsHash(node)\n const cached = this.vectors.get(id)\n if (cached && cached.hash === hash) {\n cached.node = node\n return\n }\n toEmbed.push({ id, node, hash, text: embedText(node) })\n })\n\n // Drop vanished nodes\n for (const id of [...this.vectors.keys()]) {\n if (!present.has(id)) this.vectors.delete(id)\n }\n\n if (toEmbed.length > 0) {\n const vectors = await this.embedder.embed(toEmbed.map((e) => e.text))\n toEmbed.forEach((entry, i) => {\n const v = vectors[i]\n if (!v) return\n this.vectors.set(entry.id, { node: entry.node, vector: v, hash: entry.hash })\n })\n }\n\n if (this.cachePath) {\n const entries: CacheEntry[] = []\n for (const [id, { vector, hash }] of this.vectors) {\n entries.push({ nodeId: id, attrsHash: hash, vector: Array.from(vector) })\n }\n await writeCache(this.cachePath, {\n version: 1,\n provider: this.embedder.provider,\n model: this.embedder.model,\n dim: this.embedder.dim,\n entries,\n })\n }\n }\n\n // Hydrate the in-memory map from a previously-written cache. Validates\n // shape against the current embedder; mismatch → empty start.\n loadFromCache(cache: CacheFile, graph: NeatGraph): void {\n if (\n cache.provider !== this.embedder.provider ||\n cache.model !== this.embedder.model ||\n cache.dim !== this.embedder.dim\n ) {\n return\n }\n const present = new Map<string, GraphNode>()\n graph.forEachNode((id, attrs) => {\n const node = attrs as GraphNode\n if (shouldEmbed(node)) present.set(id, node)\n })\n for (const entry of cache.entries) {\n const node = present.get(entry.nodeId)\n if (!node) continue\n // Skip cache entries whose attrs no longer match — they'll be\n // re-embedded by the next refresh().\n if (attrsHash(node) !== entry.attrsHash) continue\n if (entry.vector.length !== this.embedder.dim) continue\n this.vectors.set(entry.nodeId, {\n node,\n hash: entry.attrsHash,\n vector: Float32Array.from(entry.vector),\n })\n }\n }\n}\n\nclass SubstringIndex implements SearchIndex {\n readonly provider = 'substring' as const\n private graph: NeatGraph | null = null\n\n async search(query: string, limit = DEFAULT_LIMIT): Promise<SearchResponse> {\n const q = query.trim().toLowerCase()\n const out: ScoredNode[] = []\n if (!q || !this.graph) {\n return { query: q, provider: 'substring', matches: [] }\n }\n this.graph.forEachNode((id, attrs) => {\n const node = attrs as GraphNode\n const name = (node as { name?: string }).name ?? ''\n if (id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {\n out.push({ node, score: 1 })\n }\n })\n return { query: q, provider: 'substring', matches: out.slice(0, limit) }\n }\n\n async refresh(graph: NeatGraph): Promise<void> {\n this.graph = graph\n }\n}\n\n// ------------------------------------------------------------ Public factory\n\nexport interface BuildSearchIndexOptions {\n // Where to read/write the embedding cache. Falls back to in-memory only\n // if not provided. Pass `null` to explicitly disable caching.\n cachePath?: string | null\n // Override the embedder selection. Useful for tests (substring-only mode\n // skips the Ollama probe + the Transformers.js download).\n forceProvider?: 'ollama' | 'transformers' | 'substring'\n // Pre-built embedder (test injection). Wins over forceProvider.\n embedder?: Embedder\n}\n\nexport async function buildSearchIndex(\n graph: NeatGraph,\n options: BuildSearchIndexOptions = {},\n): Promise<SearchIndex> {\n let embedder: Embedder | null = null\n if (options.embedder) {\n embedder = options.embedder\n } else if (options.forceProvider !== 'substring') {\n embedder = await pickEmbedder()\n if (options.forceProvider === 'ollama' && embedder?.provider !== 'ollama') {\n embedder = null\n }\n if (options.forceProvider === 'transformers' && embedder?.provider !== 'transformers') {\n embedder = null\n }\n }\n\n if (!embedder) {\n const idx = new SubstringIndex()\n await idx.refresh(graph)\n return idx\n }\n\n const cachePath = options.cachePath === undefined ? null : options.cachePath\n const idx = new VectorIndex(embedder, cachePath)\n if (cachePath) {\n const cache = await readCache(cachePath)\n if (cache) idx.loadFromCache(cache, graph)\n }\n await idx.refresh(graph)\n return idx\n}\n"],"mappings":"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","names":["idx"]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  parseOtlpRequest
3
- } from "./chunk-ZX7PCMGZ.js";
3
+ } from "./chunk-2LNICOVU.js";
4
4
 
5
5
  // src/otel-grpc.ts
6
6
  import { fileURLToPath } from "url";
@@ -138,4 +138,4 @@ export {
138
138
  reshapeGrpcRequest,
139
139
  startOtelGrpcReceiver
140
140
  };
141
- //# sourceMappingURL=chunk-GAFTW2OX.js.map
141
+ //# sourceMappingURL=chunk-C5NCCKPZ.js.map
@@ -16,15 +16,16 @@ import {
16
16
  saveGraphToDisk,
17
17
  setStatus,
18
18
  startPersistLoop,
19
+ startStalenessLoop,
19
20
  touchLastSeen,
20
21
  writeAtomically
21
- } from "./chunk-O25KZNZK.js";
22
+ } from "./chunk-WZYH5DVG.js";
22
23
  import {
23
24
  assertBindAuthority,
24
25
  buildOtelReceiver,
25
26
  listenSteppingOtlp,
26
27
  readAuthEnv
27
- } from "./chunk-ZX7PCMGZ.js";
28
+ } from "./chunk-2LNICOVU.js";
28
29
 
29
30
  // src/daemon.ts
30
31
  import {
@@ -143,6 +144,10 @@ function teardownSlot(slot) {
143
144
  slot.stopPersist();
144
145
  } catch {
145
146
  }
147
+ try {
148
+ slot.stopStaleness();
149
+ } catch {
150
+ }
146
151
  try {
147
152
  slot.detachEvents();
148
153
  } catch {
@@ -219,6 +224,8 @@ async function bootstrapProject(entry) {
219
224
  paths,
220
225
  stopPersist: () => {
221
226
  },
227
+ stopStaleness: () => {
228
+ },
222
229
  detachEvents: () => {
223
230
  },
224
231
  status: "broken",
@@ -233,6 +240,10 @@ async function bootstrapProject(entry) {
233
240
  try {
234
241
  await extractFromDirectory(graph, entry.path);
235
242
  const stopPersist = startPersistLoop(graph, outPath, { exitOnSignal: false });
243
+ const stopStaleness = startStalenessLoop(graph, {
244
+ staleEventsPath: paths.staleEventsPath,
245
+ project: entry.name
246
+ });
236
247
  await touchLastSeen(entry.name).catch(() => {
237
248
  });
238
249
  return {
@@ -241,6 +252,7 @@ async function bootstrapProject(entry) {
241
252
  outPath,
242
253
  paths,
243
254
  stopPersist,
255
+ stopStaleness,
244
256
  detachEvents,
245
257
  status: "active"
246
258
  };
@@ -793,4 +805,4 @@ export {
793
805
  resolveHost,
794
806
  startDaemon
795
807
  };
796
- //# sourceMappingURL=chunk-IABNGQT2.js.map
808
+ //# sourceMappingURL=chunk-S7TDPQDD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/daemon.ts","../src/unrouted.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 {\n promises as fs,\n watch,\n renameSync,\n unlinkSync,\n writeFileSync,\n type FSWatcher,\n} from 'node:fs'\nimport path from 'node:path'\nimport { createRequire } from 'node:module'\nimport type { FastifyInstance } from 'fastify'\nimport { DEFAULT_PROJECT, getGraph, resetGraph, type NeatGraph } from './graph.js'\nimport { extractFromDirectory } from './extract.js'\nimport { loadGraphFromDisk, saveGraphToDisk, startPersistLoop } from './persist.js'\nimport { Projects, pathsForProject, type ProjectPaths } from './projects.js'\nimport { buildApi } from './api.js'\nimport { buildOtelReceiver, listenSteppingOtlp } from './otel.js'\nimport { attachGraphToEventBus } from './events.js'\nimport { handleSpan, makeErrorSpanWriter, startStalenessLoop } from './ingest.js'\nimport {\n listProjects,\n pruneRegistry,\n registryPath,\n setStatus,\n touchLastSeen,\n writeAtomically,\n} from './registry.js'\nimport { assertBindAuthority, readAuthEnv } from './auth.js'\nimport {\n appendUnroutedSpan,\n buildUnroutedSpanRecord,\n unroutedErrorsPath,\n} from './unrouted.js'\nimport { NodeType, type RegistryEntry, type ServiceNode } from '@neat.is/types'\n\n// ── Per-project daemon self-description (ADR-096 / project-daemon contract) ──\n//\n// A project's daemon owns one file — `<project>/neat-out/daemon.json` — that\n// records where it bound and what it is serving. This is the single source of\n// truth for \"where is this project's daemon,\" read by the instrumentation (to\n// resolve its OTLP endpoint), the MCP config, the dashboard, and `neat ps`.\n//\n// The shape is pinned: the orchestrator persists `ports` here on first spawn\n// and reuses them on restart (§3), and the generated otel-init reads\n// `ports.otlp` to build its exporter endpoint. Anything that drifts from this\n// shape breaks the OBSERVED layer silently, so the read/write helpers live in\n// one place and every producer/consumer goes through them.\n\nexport interface DaemonPorts {\n rest: number\n otlp: number\n web: number\n}\n\nexport interface DaemonRecord {\n project: string\n projectPath: string\n pid: number\n status: 'running' | 'stopped'\n ports: DaemonPorts\n startedAt: string\n neatVersion: string\n}\n\n// `<project>/neat-out/daemon.json` — the authoritative per-project record.\nexport function daemonJsonPath(scanPath: string): string {\n return path.join(scanPath, 'neat-out', 'daemon.json')\n}\n\n// Machine-wide discovery directory. Honors NEAT_HOME exactly as registry.ts /\n// neatHomeFor do so tests sandboxing under a temp home land here too.\nexport function daemonsDiscoveryDir(home?: string): string {\n const base = home && home.length > 0 ? home : neatHomeFromEnv()\n return path.join(base, 'daemons')\n}\n\n// `~/.neat/daemons/<project>.json` — a lock-free discovery copy (§6). Each\n// daemon owns only its own file; losing the directory costs `neat ps`\n// convenience, never correctness.\nexport function daemonDiscoveryPath(project: string, home?: string): string {\n return path.join(daemonsDiscoveryDir(home), `${sanitizeDiscoveryName(project)}.json`)\n}\n\n// Keep the discovery filename to a safe single path segment. Project names are\n// basenames in practice, but a name carrying a separator must never escape the\n// daemons/ directory.\nfunction sanitizeDiscoveryName(project: string): string {\n return project.replace(/[^A-Za-z0-9._-]/g, '_')\n}\n\nfunction neatHomeFromEnv(): string {\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// Best-effort read of a project's daemon.json. Returns null when the file is\n// absent or malformed — callers treat that as \"no daemon recorded here\" rather\n// than failing, so a corrupt record never wedges spawn-vs-reuse.\nexport async function readDaemonRecord(scanPath: string): Promise<DaemonRecord | null> {\n try {\n const raw = await fs.readFile(daemonJsonPath(scanPath), 'utf8')\n const parsed = JSON.parse(raw) as Partial<DaemonRecord>\n if (\n typeof parsed.project === 'string' &&\n parsed.ports &&\n typeof parsed.ports.rest === 'number' &&\n typeof parsed.ports.otlp === 'number' &&\n typeof parsed.ports.web === 'number'\n ) {\n return parsed as DaemonRecord\n }\n return null\n } catch {\n return null\n }\n}\n\n// Resolve the running @neat.is/core version for the daemon.json stamp. Mirrors\n// neatd.ts#localVersion — NEAT_LOCAL_VERSION overrides for tests, else the\n// bundled package.json, else a safe sentinel.\nexport function resolveNeatVersion(): string {\n if (process.env.NEAT_LOCAL_VERSION && process.env.NEAT_LOCAL_VERSION.length > 0) {\n return process.env.NEAT_LOCAL_VERSION\n }\n try {\n const req = createRequire(import.meta.url)\n const pkg = req('../package.json') as { version?: string }\n return typeof pkg.version === 'string' ? pkg.version : '0.0.0'\n } catch {\n return '0.0.0'\n }\n}\n\n// Write the authoritative record + the machine-wide discovery copy, both\n// atomically (tmp + rename, §2). Best-effort on the discovery copy: it is a\n// read-optimization, so a failure to write it is logged but never aborts the\n// daemon. The neat-out/ record is the one that matters and bubbles its error.\nexport async function writeDaemonRecord(record: DaemonRecord, home?: string): Promise<void> {\n const body = JSON.stringify(record, null, 2) + '\\n'\n await writeAtomically(daemonJsonPath(record.projectPath), body)\n try {\n await writeAtomically(daemonDiscoveryPath(record.project, home), body)\n } catch (err) {\n console.warn(\n `neatd: could not write discovery copy for \"${record.project}\" — ${(err as Error).message}`,\n )\n }\n}\n\n// Mark the record stopped (neat-out/) and clear the discovery copy on graceful\n// shutdown (§2/§6). The neat-out/ record is kept with status:\"stopped\" so a\n// later read can tell \"shut down cleanly\" from \"never ran\"; the discovery copy\n// is removed so `neat ps` stops listing a dead daemon. Both best-effort —\n// shutdown must not throw on a missing file.\nexport async function clearDaemonRecord(record: DaemonRecord, home?: string): Promise<void> {\n try {\n const stopped: DaemonRecord = { ...record, status: 'stopped' }\n await writeAtomically(daemonJsonPath(record.projectPath), JSON.stringify(stopped, null, 2) + '\\n')\n } catch {\n // best-effort\n }\n try {\n await fs.unlink(daemonDiscoveryPath(record.project, home))\n } catch {\n // best-effort — already gone is fine.\n }\n}\n\n// Reconcile a daemon's self-description synchronously on an unsupervised exit\n// (project-daemon contract §2). The graceful `stop()` path already marks the\n// record stopped and clears the discovery copy; this is the backstop for a\n// crash or a fatal signal, where there's no chance to await async fs. A\n// process-exit handler runs synchronously, so we mark the neat-out/ record\n// `stopped` (tmp + renameSync keeps it atomic) and remove the discovery copy\n// with sync calls. Best-effort throughout: a missing or already-reconciled\n// file is fine, and a failure here must never throw out of an exit handler.\nexport function reconcileDaemonRecordSync(record: DaemonRecord, home?: string): void {\n try {\n const stopped: DaemonRecord = { ...record, status: 'stopped' }\n const target = daemonJsonPath(record.projectPath)\n const tmp = `${target}.${process.pid}.tmp`\n writeFileSync(tmp, JSON.stringify(stopped, null, 2) + '\\n')\n renameSync(tmp, target)\n } catch {\n // best-effort\n }\n try {\n unlinkSync(daemonDiscoveryPath(record.project, home))\n } catch {\n // best-effort — already gone is fine.\n }\n}\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-096 — when set, this daemon is scoped to exactly one project. It serves\n // only that project, mounts a bare `/v1/traces` route that assigns every\n // incoming span to it (no service.name routing), and writes its\n // `daemon.json` self-description on start. `projectPath` is the project root\n // (the directory whose `neat-out/` holds the record). Absent → the legacy\n // multi-project daemon behaviour.\n project?: string\n projectPath?: string\n // Dashboard/web port to record in daemon.json. The daemon doesn't bind this\n // itself (neatd spawns the web UI), but it owns the record, so it stamps the\n // allocated value the orchestrator passed through.\n webPort?: number\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 // Stops the OBSERVED→STALE clock-decay loop for this slot. Runs on the same\n // 60s cadence as `neat watch`, so the daemon keeps the STALE provenance state\n // current: once OBSERVED traffic quiets, edges past their threshold decay to\n // STALE instead of sitting live forever. Must be stopped alongside\n // stopPersist so no interval leaks when the slot is torn down or replaced.\n stopStaleness: () => void\n // #475 — removes the event-bus listeners attachGraphToEventBus installed\n // on this slot's graph. No-op for broken slots. Must run wherever the slot\n // is torn down or replaced, or a reloaded slot's old graph keeps emitting.\n detachEvents: () => void\n status: 'active' | 'broken'\n errorReason?: string\n}\n\n// Best-effort slot teardown — stop the persist loop and detach the slot's\n// graph from the event bus (#475). Every path that drops or replaces a slot\n// (registry removal, daemon stop, bind-failure rollback, broken-slot\n// recovery) goes through here so no path leaks listeners.\nfunction teardownSlot(slot: ProjectSlot): void {\n try {\n slot.stopPersist()\n } catch {\n // best-effort\n }\n try {\n slot.stopStaleness()\n } catch {\n // best-effort\n }\n try {\n slot.detachEvents()\n } catch {\n // best-effort\n }\n}\n\n// Issue #340 — per-project bootstrap state surface. The REST listener\n// flips to live the moment `app.listen()` returns; per-project routes\n// branch on this rather than waiting for every registered project's\n// extractFromDirectory pass to finish.\nexport type BootstrapPhase = 'bootstrapping' | 'active' | 'broken'\n\nexport interface BootstrapTracker {\n status: (name: string) => BootstrapPhase | undefined\n list: () => Array<{ name: string; status: BootstrapPhase; elapsedMs: number }>\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 // Issue #340 — per-project bootstrap status, surfaced for orchestrator\n // poll loops and tests.\n bootstrap: BootstrapTracker\n // Resolves when the daemon's initial bootstrap pass has settled. Tests\n // that probe project-scoped routes immediately after startDaemon await\n // this; production callers use /health.\n initialBootstrap: Promise<void>\n // ADR-096 — the per-project self-description this daemon wrote, or null in\n // the legacy multi-project mode. Tests assert the persisted ports + status.\n daemonRecord: DaemonRecord | null\n // The resolved NEAT_HOME this daemon discovers under. The entrypoint needs it\n // to clear the right discovery copy when it reconciles daemon.json on an\n // unsupervised exit (project-daemon contract §2).\n neatHome: 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 *\n * Matching order (ADR-072 — real-world `service.name` rarely equals project\n * name; monorepos publish per-package names like `brief-api` under a\n * project named `brief`):\n *\n * 1. Exact: `entry.name === serviceName`.\n * 2. Hyphen/underscore-separated prefix: `entry.name` is a leading token\n * of `serviceName` (`brief` matches `brief-api`, `brief_worker`).\n * Longest-match wins so `brief-api` beats `brief` when both are\n * registered.\n * 3. Containment as a separator-delimited token (`api` inside\n * `brief-api-staging`).\n *\n * Routing eligibility (ADR-071):\n * - `active` matches at every pass (the steady-state path).\n * - `broken` also matches — the daemon needs the span to reach the broken\n * slot so the ingest-time auto-recover path can attempt a bootstrap and\n * lift the project back to `active`. The router only chooses the target;\n * whether the span actually lands is the ingest handler's decision.\n * - `paused` is intentionally not routed; the operator paused it on\n * purpose, so the span falls through to the default-project flow.\n *\n * Falls back to `DEFAULT_PROJECT` when nothing matches.\n */\nexport function routeSpanToProject(\n serviceName: string | undefined,\n projects: ReadonlyArray<RegistryEntry>,\n): string {\n if (!serviceName) return DEFAULT_PROJECT\n // Pass 1 — exact match.\n for (const entry of projects) {\n if (entry.status === 'paused') continue\n if (entry.name === serviceName) return entry.name\n }\n // Pass 2 — hyphen/underscore-separated prefix. Longest project name wins\n // so a registered `brief-api` outranks a registered `brief` when the\n // span's service.name is `brief-api-staging`.\n const candidates: RegistryEntry[] = []\n for (const entry of projects) {\n if (entry.status === 'paused') continue\n if (isTokenPrefix(entry.name, serviceName)) candidates.push(entry)\n }\n if (candidates.length > 0) {\n candidates.sort((a, b) => b.name.length - a.name.length)\n return candidates[0]!.name\n }\n // Pass 3 — containment as a separator-delimited token. Last-resort match\n // for `api` inside `brief-api-staging` when only `api` is registered.\n for (const entry of projects) {\n if (entry.status === 'paused') continue\n if (isTokenContained(entry.name, serviceName)) return entry.name\n }\n return DEFAULT_PROJECT\n}\n\n// True when `prefix` matches the first hyphen/underscore-separated token(s)\n// of `full`. `brief` matches `brief-api`, `brief_worker`, but not `briefcase`.\nfunction isTokenPrefix(prefix: string, full: string): boolean {\n if (prefix.length >= full.length) return false\n if (!full.startsWith(prefix)) return false\n const sep = full.charAt(prefix.length)\n return sep === '-' || sep === '_'\n}\n\n// True when `needle` appears in `haystack` bordered by separators on both\n// sides (so it's a complete token, not a substring of a longer word).\nfunction isTokenContained(needle: string, haystack: string): boolean {\n if (!haystack.includes(needle)) return false\n const tokens = haystack.split(/[-_]/)\n return tokens.includes(needle)\n}\n\n// Does this span's `service.name` belong to the single project this daemon\n// hosts? Single-project mode (ADR-096) binds the bare `/v1/traces` route to one\n// project, but the OS-default OTLP endpoint (`localhost:4318`) is shared: a\n// sibling service from a *different* project that exports with default settings\n// lands here too. Merging its spans would mint that service's ServiceNode +\n// incidents into this project's graph — cross-project contamination. We scope\n// delivery to the project's owned services and quarantine the rest.\n//\n// A span is owned when:\n// - it carries no `service.name` (SDK misconfig in this project's own app;\n// handleSpan routes it to `service:unidentified`, refs #374), or\n// - its `service.name` matches the project name the same way the multi-\n// project router matches (exact / token-prefix / token-contained — covers\n// the monorepo case where `brief` owns `brief-api`, `brief-worker`), or\n// - a ServiceNode with that name already exists in the project's graph\n// (statically extracted, or observed-and-adopted on an earlier span).\n//\n// Everything else is foreign and gets quarantined to the unrouted ledger rather\n// than merged. The trade is deliberate: a brand-new service of this project that\n// NEAT can't statically read and whose name doesn't echo the project name has\n// its first spans quarantined until extraction registers it — a far smaller\n// failure than an entire sibling project bleeding into this graph.\nfunction serviceNameMatchesProject(serviceName: string, project: string): boolean {\n if (serviceName === project) return true\n if (isTokenPrefix(project, serviceName)) return true\n if (isTokenContained(project, serviceName)) return true\n return false\n}\n\nfunction spanBelongsToSingleProject(\n graph: NeatGraph,\n project: string,\n serviceName: string | undefined,\n): boolean {\n if (!serviceName) return true\n if (serviceNameMatchesProject(serviceName, project)) return true\n return graph.someNode(\n (_id, attrs) =>\n attrs.type === NodeType.ServiceNode &&\n (attrs as ServiceNode).name === serviceName,\n )\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 stopStaleness: () => {},\n detachEvents: () => {},\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 // #475 — wire graph mutations into the event bus (ADR-051) before extract\n // begins so the initial pass also produces node/edge events, mirroring\n // startWatch. Without this the daemon's SSE stream carries heartbeats and\n // nothing else: handleSse subscribes to a bus no producer feeds, and the\n // dashboard only catches up on a manual refresh.\n const detachEvents = attachGraphToEventBus(graph, { project: entry.name })\n try {\n await extractFromDirectory(graph, entry.path)\n // The daemon owns shutdown, so the persist loop must not exit the process\n // on a signal — that would end us before `stop()` clears the daemon.json,\n // discovery copy, and pid file. `stop()` flushes this graph one last time\n // as it tears the slot down (see below).\n const stopPersist = startPersistLoop(graph, outPath, { exitOnSignal: false })\n // Keep the STALE provenance state maintained on the shipped daemon path,\n // the same way `neat watch` does. Once OBSERVED traffic quiets, this loop\n // ticks markStaleEdges so edges past their threshold decay to STALE and the\n // transition lands in this slot's stale-events.ndjson — exactly where the\n // REST `/stale-events` route reads them back from.\n const stopStaleness = startStalenessLoop(graph, {\n staleEventsPath: paths.staleEventsPath,\n project: entry.name,\n })\n await touchLastSeen(entry.name).catch(() => {})\n\n return {\n entry,\n graph,\n outPath,\n paths,\n stopPersist,\n stopStaleness,\n detachEvents,\n status: 'active',\n }\n } catch (err) {\n // Bootstrap died after the attach — detach before surfacing so a failed\n // slot can't leave listeners behind on its orphaned graph.\n detachEvents()\n throw err\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\n// The web/dashboard port the daemon records in daemon.json. The daemon never\n// binds it (neatd spawns the web child), but it owns the self-description, so\n// it stamps the resolved value. NEAT_WEB_PORT overrides the canonical 6328.\nfunction resolveWebPort(): number {\n const env = process.env.NEAT_WEB_PORT\n if (env && env.length > 0) {\n const n = Number.parseInt(env, 10)\n if (Number.isFinite(n)) return n\n }\n return 6328\n}\n\n// Read the real bound port off a Fastify listen address (`http://host:port`).\n// When the requested port was 0 the kernel chose one, and daemon.json must\n// record what the app should actually reach — not the 0 we asked for. Falls\n// back to the requested port if the address can't be parsed.\nexport function portFromListenAddress(address: string, fallback: number): number {\n try {\n const port = new URL(address).port\n const n = Number.parseInt(port, 10)\n if (Number.isFinite(n) && n > 0) return n\n } catch {\n // fall through\n }\n return fallback\n}\n\nexport function resolveHost(opts: DaemonOptions, authTokenSet: boolean): 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 // Issue #341 — loopback-only default when the operator hasn't set a token.\n // Public-bind on a clean install demanded one before binding could\n // succeed, so the npx-`neat .` first-touch path used to refuse to come up;\n // pinning to 127.0.0.1 lets that path bind cleanly. Anyone wanting a\n // public bind sets `NEAT_AUTH_TOKEN` (and `HOST=0.0.0.0` if they want it\n // spelled out). `assertBindAuthority` stays exactly as it is — the\n // contract is right; the default was wrong.\n if (!authTokenSet) return '127.0.0.1'\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\n // ADR-096 — single-project mode. The orchestrator spawns a daemon scoped to\n // one project, passing its name + root. In that mode the daemon serves only\n // that project: it bootstraps the one slot, mounts a bare `/v1/traces` route\n // that assigns every span to it, and writes its `daemon.json`\n // self-description. The legacy multi-project path (no opts.project) is left\n // intact so nothing on main breaks while Wave 2 retires the registry.\n //\n // The mode resolves from explicit opts first (the production path: neatd\n // reads NEAT_PROJECT/NEAT_PROJECT_PATH and passes them through), falling\n // back to the env directly so a bare `NEAT_PROJECT=… neatd start` works too.\n const projectArg =\n typeof opts.project === 'string' && opts.project.length > 0\n ? opts.project\n : process.env.NEAT_PROJECT && process.env.NEAT_PROJECT.length > 0\n ? process.env.NEAT_PROJECT\n : null\n const projectPathArg =\n opts.projectPath && opts.projectPath.length > 0\n ? opts.projectPath\n : process.env.NEAT_PROJECT_PATH && process.env.NEAT_PROJECT_PATH.length > 0\n ? process.env.NEAT_PROJECT_PATH\n : null\n const singleProject = projectArg\n const singleProjectPath =\n singleProject && projectPathArg ? path.resolve(projectPathArg) : null\n if (singleProject && !singleProjectPath) {\n throw new Error(\n `neatd: project \"${singleProject}\" given without a projectPath; pass NEAT_PROJECT_PATH alongside NEAT_PROJECT.`,\n )\n }\n\n // Graceful degradation per ADR-049 #6: missing registry refuses to boot\n // with a clear error rather than silently coming up empty. A single-project\n // daemon takes its project from spawn args, not the registry, so it doesn't\n // gate on the registry file existing.\n if (!singleProject) {\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\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 // Issue #340 — per-project bootstrap status. Populated from the registry\n // before the listener binds so the REST handlers can return 503 instead of\n // 404 for projects still extracting.\n const bootstrapStatus = new Map<string, BootstrapPhase>()\n const bootstrapStartedAt = new Map<string, number>()\n\n // Rate-limit the dropped-span warning to one log line per project per\n // 60 seconds. OTel exporters retry on a tight cadence; without this we\n // flood the console with the same line per batch when a broken project\n // is sitting in the registry.\n const DROP_WARN_INTERVAL_MS = 60_000\n const lastDropWarnAt = new Map<string, number>()\n function warnDroppedSpan(project: string, reason: string): void {\n const now = Date.now()\n const prev = lastDropWarnAt.get(project) ?? 0\n if (now - prev < DROP_WARN_INTERVAL_MS) return\n lastDropWarnAt.set(project, now)\n console.warn(\n `[neatd] dropping span for project \"${project}\" — project status: broken (${reason}). Run \\`neatd reload\\` to retry bootstrap.`,\n )\n }\n\n // v0.4.1 / refs #339 — when a span's `service.name` doesn't match any\n // registered project AND no `default` project is registered, the span has\n // nowhere to land. We still return 200 on the receiver (OTel spec) but the\n // event lands in <NEAT_HOME>/errors.ndjson so the next operator can see\n // what happened instead of the daemon's stderr being the only signal.\n // Same rate limit as the broken-project warning, keyed by service.name.\n const unroutedPath = unroutedErrorsPath(home)\n const lastUnroutedWarnAt = new Map<string, number>()\n async function recordUnroutedSpan(\n serviceName: string | undefined,\n traceId: string | undefined,\n ): Promise<void> {\n const key = serviceName ?? '<missing>'\n const now = Date.now()\n try {\n await appendUnroutedSpan(home, buildUnroutedSpanRecord(serviceName, traceId, new Date(now)))\n } catch {\n // best-effort — failing to log shouldn't cascade into receiver failure.\n }\n const prev = lastUnroutedWarnAt.get(key) ?? 0\n if (now - prev < DROP_WARN_INTERVAL_MS) return\n lastUnroutedWarnAt.set(key, now)\n console.warn(\n `[neatd] dropping span — service.name \"${key}\" matches no registered project and no \\`default\\` project exists. See ${unroutedPath}.`,\n )\n }\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 // Attempt to bring a broken slot back online. Used both on SIGHUP reload\n // and inline on ingest when a span arrives for a broken project. Returns\n // the new slot status so callers can decide whether to deliver the span.\n async function tryRecoverSlot(entry: RegistryEntry): Promise<ProjectSlot> {\n try {\n const fresh = await bootstrapProject(entry)\n // The slot being replaced must release its graph's bus listeners\n // (#475) — a stale attach on the prior graph would double-emit.\n const prior = slots.get(entry.name)\n if (prior) teardownSlot(prior)\n slots.set(entry.name, fresh)\n upsertRegistryFromSlot(fresh)\n if (fresh.status === 'active') {\n await setStatus(entry.name, 'active').catch(() => {})\n console.log(\n `neatd: project \"${entry.name}\" recovered from broken — active`,\n )\n }\n return fresh\n } catch (err) {\n console.warn(\n `neatd: project \"${entry.name}\" still broken after recovery attempt — ${(err as Error).message}`,\n )\n // Leave the existing broken slot in place; nothing changed.\n return slots.get(entry.name)!\n }\n }\n\n async function bootstrapOne(entry: RegistryEntry): Promise<void> {\n bootstrapStatus.set(entry.name, 'bootstrapping')\n bootstrapStartedAt.set(entry.name, Date.now())\n try {\n const slot = await bootstrapProject(entry)\n // Same replacement rule as tryRecoverSlot (#475).\n const prior = slots.get(entry.name)\n if (prior) teardownSlot(prior)\n slots.set(entry.name, slot)\n upsertRegistryFromSlot(slot)\n bootstrapStatus.set(entry.name, slot.status === 'broken' ? 'broken' : 'active')\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 bootstrapStatus.set(entry.name, 'broken')\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\n // The set of projects this daemon manages. Single-project mode (ADR-096)\n // takes its one project from spawn args and never reads the registry for the\n // project list; the legacy daemon enumerates every registered project.\n async function enumerateProjects(): Promise<RegistryEntry[]> {\n if (singleProject && singleProjectPath) {\n return [\n {\n name: singleProject,\n path: singleProjectPath,\n registeredAt: new Date().toISOString(),\n languages: [],\n status: 'active',\n },\n ]\n }\n return listProjects()\n }\n\n async function loadAll(): Promise<void> {\n // #463 — drop long-dead entries before bootstrapping the rest. An entry\n // whose path is gone (definite ENOENT) and that's been quiet past the TTL\n // gets removed instead of marked `broken` and logged forever. Conservative\n // by design: a transient stat error or a fresh ENOENT entry stays, and the\n // staleness TTL is the safety margin. Best-effort — a prune failure never\n // blocks the daemon from coming up. A single-project daemon owns no\n // registry coordination, so it skips the prune entirely.\n if (!singleProject) {\n try {\n const pruned = await pruneRegistry()\n for (const entry of pruned) {\n console.log(\n `neatd: pruned project \"${entry.name}\" — registered path ${entry.path} is gone`,\n )\n slots.delete(entry.name)\n bootstrapStatus.delete(entry.name)\n bootstrapStartedAt.delete(entry.name)\n }\n } catch (err) {\n console.warn(`neatd: registry prune skipped — ${(err as Error).message}`)\n }\n }\n\n const projects = await enumerateProjects()\n const seen = new Set<string>()\n const pending: Promise<void>[] = []\n for (const entry of projects) {\n seen.add(entry.name)\n const existing = slots.get(entry.name)\n if (existing) {\n if (existing.status === 'broken') {\n pending.push(tryRecoverSlot(entry).then(() => {}))\n }\n continue\n }\n pending.push(bootstrapOne(entry))\n }\n for (const [name, slot] of [...slots.entries()]) {\n if (seen.has(name)) continue\n teardownSlot(slot)\n slots.delete(name)\n bootstrapStatus.delete(name)\n bootstrapStartedAt.delete(name)\n console.log(`neatd: project \"${name}\" removed from registry — stopped`)\n }\n await Promise.allSettled(pending)\n }\n\n // Issue #340 — pre-populate bootstrap status from the registry so the REST\n // listener can answer 503 for projects whose slot hasn't loaded yet. Actual\n // bootstrap moves to the background after `listen()` returns.\n const initialEntries = await enumerateProjects().catch(() => [] as RegistryEntry[])\n for (const entry of initialEntries) {\n bootstrapStatus.set(entry.name, 'bootstrapping')\n bootstrapStartedAt.set(entry.name, Date.now())\n }\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 // ADR-096 — the self-description this daemon owns, filled once it binds in\n // single-project mode. Null in legacy multi-project mode and when listeners\n // are skipped (bindListeners:false).\n let daemonRecord: DaemonRecord | null = null\n\n if (bind) {\n // ADR-073 §3 — fail-loud before binding. Loopback-only without a token is\n // fine (laptop dev); a public bind without one is not. Resolved here\n // ahead of the host so the loopback-default branch (issue #341) reads\n // the same token state the bind-authority gate does.\n const auth = readAuthEnv()\n const host = resolveHost(opts, Boolean(auth.authToken))\n const restPort = resolveRestPort(opts)\n const otlpPort = resolveOtlpPort(opts)\n\n assertBindAuthority(host, auth.authToken)\n\n try {\n restApp = await buildApi({\n projects: registry,\n authToken: auth.authToken,\n trustProxy: auth.trustProxy,\n publicRead: auth.publicRead,\n bootstrap: {\n status: (name) => bootstrapStatus.get(name),\n list: () => {\n const now = Date.now()\n return [...bootstrapStatus.entries()].map(([name, status]) => ({\n name,\n status,\n elapsedMs: now - (bootstrapStartedAt.get(name) ?? now),\n }))\n },\n },\n // ADR-096 §4/§5/§7 — hand the daemon's identity to buildApi so the REST\n // surface reflects \"the daemon is the project\": `GET /projects` reports\n // only this project (the dashboard pins to it), and the daemon-wide\n // `/health` carries it at the top level for the spawn-reuse identity\n // check. Absent for the legacy multi-project daemon.\n singleProject:\n singleProject && singleProjectPath\n ? { name: singleProject, path: singleProjectPath }\n : undefined,\n })\n restAddress = await restApp.listen({ port: restPort, host })\n // Fastify reports a 0.0.0.0 bind back as http://127.0.0.1:port, so the\n // raw listen address hides a wildcard bind behind a loopback URL. Log the\n // host we actually asked for so the line matches what the port allocator\n // probed (the orchestrator threads this same host into its free check).\n console.log(\n `neatd: REST listening on http://${host}:${portFromListenAddress(restAddress, restPort)}`,\n )\n } catch (err) {\n // Roll back anything we started so far before surfacing the error.\n for (const slot of slots.values()) {\n teardownSlot(slot)\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 // Resolve a span's target slot — running the broken-state recovery\n // when the routed slot is currently broken. Returns null when the span\n // can't be delivered after the recovery attempt; the caller drops with\n // a rate-limited warning. v0.4.1 / refs #339 — when nothing matches and\n // no default slot exists, the no-project-match event lands in\n // <NEAT_HOME>/errors.ndjson before we return null.\n //\n // ADR-096 single-project mode short-circuits all of that: the daemon hosts\n // exactly one project, so every span on the bare `/v1/traces` route is its\n // span. The 3-pass `routeSpanToProject` heuristic and the unrouted-span\n // drop are moot here — assigning by service.name could only ever mis-route\n // or drop a span the daemon definitionally owns, which is precisely the\n // silent-dark-OBSERVED failure §1 exists to kill. We assign directly,\n // recovering the slot if it's broken, and never write to errors.ndjson on\n // the no-match path.\n async function resolveTargetSlot(\n serviceName: string | undefined,\n traceId: string | undefined,\n ): Promise<ProjectSlot | null> {\n if (singleProject) {\n let slot = slots.get(singleProject)\n if (!slot) {\n // The sole slot hasn't bootstrapped yet (span arrived during the\n // initial extraction window). Build it on demand from spawn args so\n // the span isn't dropped — the OBSERVED layer must not go dark.\n slot = await tryRecoverSlot({\n name: singleProject,\n path: singleProjectPath!,\n registeredAt: new Date().toISOString(),\n languages: [],\n status: 'active',\n })\n } else if (slot.status === 'broken') {\n slot = await tryRecoverSlot(slot.entry)\n }\n if (!slot || slot.status !== 'active') {\n warnDroppedSpan(singleProject, slot?.errorReason ?? 'unknown')\n return null\n }\n // Scope to this project's owned services — quarantine a sibling\n // project's spans that reached our shared OTLP port instead of merging\n // them (cross-project contamination). The unrouted ledger records what\n // we dropped so it isn't silently dark.\n if (!spanBelongsToSingleProject(slot.graph, singleProject, serviceName)) {\n await recordUnroutedSpan(serviceName, traceId)\n return null\n }\n return slot\n }\n const liveEntries = await listProjects().catch(() => [])\n const target = routeSpanToProject(serviceName, liveEntries)\n let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT)\n if (!slot) {\n await recordUnroutedSpan(serviceName, traceId)\n return null\n }\n if (slot.status === 'broken') {\n const entry = liveEntries.find((e) => e.name === slot!.entry.name)\n if (entry) {\n slot = await tryRecoverSlot(entry)\n }\n if (slot.status !== 'active') {\n warnDroppedSpan(slot.entry.name, slot.errorReason ?? 'unknown')\n return null\n }\n }\n return slot.status === 'active' ? slot : null\n }\n\n // Resolve a project slot by its registered name — the path the\n // project-scoped OTLP route (issue #367) takes. URL-extracted project\n // names sidestep the service.name heuristic; we still want the broken\n // -slot recovery + unrouted-span logging so the route's failure modes\n // match the legacy path's.\n async function resolveSlotByName(\n project: string,\n serviceName: string | undefined,\n traceId: string | undefined,\n ): Promise<ProjectSlot | null> {\n const liveEntries = await listProjects().catch(() => [])\n let slot = slots.get(project)\n if (!slot) {\n await recordUnroutedSpan(serviceName, traceId)\n return null\n }\n if (slot.status === 'broken') {\n const entry = liveEntries.find((e) => e.name === slot!.entry.name)\n if (entry) {\n slot = await tryRecoverSlot(entry)\n }\n if (slot.status !== 'active') {\n warnDroppedSpan(slot.entry.name, slot.errorReason ?? 'unknown')\n return null\n }\n }\n return slot.status === 'active' ? slot : null\n }\n\n try {\n otlpApp = await buildOtelReceiver({\n authToken: auth.otelToken,\n trustProxy: auth.trustProxy,\n onSpan: async (span) => {\n // ADR-049 OTel routing — dispatch by service.name. Broken slots\n // get a single inline recovery attempt before the span is dropped\n // with a rate-limited log line. Unknown services route to\n // DEFAULT_PROJECT so the FrontierNode auto-creation flow keeps\n // working (ADR-033); when DEFAULT_PROJECT isn't registered either,\n // resolveTargetSlot writes a no-project-match event to\n // <NEAT_HOME>/errors.ndjson (refs #339).\n const slot = await resolveTargetSlot(span.service, span.traceId)\n if (!slot) return\n await handleSpan(\n {\n graph: slot.graph,\n errorsPath: slot.paths.errorsPath,\n scanPath: slot.entry.path,\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 slot = await resolveTargetSlot(span.service, span.traceId)\n if (!slot) return\n await makeErrorSpanWriter(slot.paths.errorsPath, slot.graph, slot.entry.path)(span)\n },\n // Project-scoped route (issue #367) — the URL already named the\n // project. Resolution is a direct slot lookup; service.name resolves\n // the ServiceNode inside the slot's graph instead of which project\n // owns the span.\n onProjectSpan: async (project, span) => {\n const slot = await resolveSlotByName(project, span.service, span.traceId)\n if (!slot) return\n await handleSpan(\n {\n graph: slot.graph,\n errorsPath: slot.paths.errorsPath,\n scanPath: slot.entry.path,\n project: slot.entry.name,\n writeErrorEventInline: false,\n },\n span,\n )\n },\n onProjectErrorSpanSync: async (project, span) => {\n const slot = await resolveSlotByName(project, span.service, span.traceId)\n if (!slot) return\n await makeErrorSpanWriter(slot.paths.errorsPath, slot.graph, slot.entry.path)(span)\n },\n })\n // A held OTLP port steps to the next free one rather than crashing the\n // daemon (daemon.md §Binding). The recorded daemon.json port below reads\n // back from otlpAddress, so a stepped port is what otel-init resolves.\n otlpAddress = await listenSteppingOtlp(otlpApp, otlpPort, host)\n console.log(`neatd: OTLP listening on ${otlpAddress}/v1/traces`)\n } catch (err) {\n for (const slot of slots.values()) {\n teardownSlot(slot)\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 // ADR-096 §2 — write the self-description now that both listeners are up\n // and we know the real bound ports. Reading them back from the listen\n // addresses (rather than the requested ports) is what makes ephemeral-port\n // tests and the orchestrator's allocation agree: when the requested port\n // was 0, the kernel chose one, and the generated otel-init must read THAT.\n if (singleProject && singleProjectPath) {\n const ports: DaemonPorts = {\n rest: portFromListenAddress(restAddress, restPort),\n otlp: portFromListenAddress(otlpAddress, otlpPort),\n // The daemon doesn't bind the web port itself (neatd spawns the web\n // child); it records the allocated value passed through so the\n // dashboard and `neat ps` agree on where to look.\n web: typeof opts.webPort === 'number' ? opts.webPort : resolveWebPort(),\n }\n daemonRecord = {\n project: singleProject,\n projectPath: singleProjectPath,\n pid: process.pid,\n status: 'running',\n ports,\n startedAt: new Date().toISOString(),\n neatVersion: resolveNeatVersion(),\n }\n try {\n await writeDaemonRecord(daemonRecord, home)\n console.log(\n `neatd: project \"${singleProject}\" → REST ${ports.rest} / OTLP ${ports.otlp} / web ${ports.web} (daemon.json written)`,\n )\n } catch (err) {\n // The neat-out/ record is load-bearing — without it the instrumented\n // app can't resolve its OTLP endpoint and the OBSERVED layer goes\n // dark. Fail loud, rolling back the listeners + pid like the bind\n // failures above.\n for (const slot of slots.values()) teardownSlot(slot)\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 write daemon.json for \"${singleProject}\" — ${(err as Error).message}`,\n )\n }\n }\n }\n\n // Issue #340 — listeners are live; kick off per-project bootstrap in the\n // background. Polled callers watch /health for transitions.\n const initialBootstrap = loadAll().catch((err) => {\n console.warn(`neatd: initial bootstrap pass failed — ${(err as Error).message}`)\n })\n\n let reloading: Promise<void> | null = initialBootstrap\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 void initialBootstrap.finally(() => {\n if (reloading === initialBootstrap) reloading = null\n })\n\n const tracker: BootstrapTracker = {\n status: (name) => bootstrapStatus.get(name),\n list: () => {\n const now = Date.now()\n return [...bootstrapStatus.entries()].map(([name, status]) => ({\n name,\n status,\n elapsedMs: now - (bootstrapStartedAt.get(name) ?? now),\n }))\n },\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 // Issue #382 — registry watcher. The orchestrator writes new projects to\n // the registry file while the daemon is already running; without an explicit\n // `neatd reload`, the slot map stays stale and every span for the freshly-\n // registered project gets rejected as no-project-match. Watching the\n // registry's directory (more robust against the tmp+rename atomic-write\n // pattern from ADR-048 than file-path watches on some platforms) and\n // filtering by basename catches every change. Debounce collapses the 2-3\n // events the rename pattern fires per write into a single reload.\n const REGISTRY_RELOAD_DEBOUNCE_MS = 500\n let registryWatcher: FSWatcher | null = null\n let reloadTimer: NodeJS.Timeout | null = null\n // A single-project daemon takes its one project from spawn args and never\n // reads the machine registry for its project list, so there's nothing for\n // the registry watcher to react to — skip it (ADR-096 §6: no machine-wide\n // coordination surface).\n if (!singleProject) try {\n const regDir = path.dirname(regPath)\n const regBase = path.basename(regPath)\n registryWatcher = watch(regDir, (_eventType, filename) => {\n // filename can be null on some platforms — fall back to firing every\n // event, the debounce + reload's idempotency cover any over-fire.\n if (filename !== null && filename !== regBase) return\n if (reloadTimer) clearTimeout(reloadTimer)\n reloadTimer = setTimeout(() => {\n reloadTimer = null\n void reload().catch((err) => {\n console.warn(\n `neatd: registry-watch reload failed — ${(err as Error).message}`,\n )\n })\n }, REGISTRY_RELOAD_DEBOUNCE_MS)\n })\n } catch (err) {\n // Watching the registry is a best-effort optimisation over SIGHUP — if\n // the kernel refuses (e.g. inotify quota exhausted) we surface the\n // failure but let the daemon keep running.\n console.warn(\n `neatd: failed to watch registry at ${regPath} — ${(err as Error).message}. ` +\n `Run \\`neatd reload\\` (or send SIGHUP) after registering new projects.`,\n )\n }\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 (reloadTimer) {\n clearTimeout(reloadTimer)\n reloadTimer = null\n }\n if (registryWatcher) {\n try {\n registryWatcher.close()\n } catch {\n // best-effort\n }\n registryWatcher = null\n }\n if (otlpApp) await otlpApp.close().catch(() => {})\n if (restApp) await restApp.close().catch(() => {})\n // Listeners are down, so the graph is now at its final state. Flush each\n // active slot once before tearing its persist loop down — the loops run\n // with `exitOnSignal: false`, so this is where the shutdown save lives now.\n for (const slot of slots.values()) {\n if (slot.status === 'active' && slot.outPath) {\n await saveGraphToDisk(slot.graph, slot.outPath).catch(() => {})\n }\n }\n for (const slot of slots.values()) {\n teardownSlot(slot)\n }\n // ADR-096 §2/§6 — mark the neat-out/ record stopped and remove the\n // machine-wide discovery copy so `neat ps` stops listing a dead daemon.\n if (daemonRecord) {\n await clearDaemonRecord(daemonRecord, home)\n }\n await fs.unlink(pidPath).catch(() => {})\n }\n\n return {\n slots,\n reload,\n stop,\n pidPath,\n restAddress,\n otlpAddress,\n bootstrap: tracker,\n initialBootstrap,\n daemonRecord,\n neatHome: home,\n }\n}\n","/**\n * Unrouted-span logging (v0.4.1 — refs #339).\n *\n * When the daemon's routing layer can't deliver a span — service.name\n * matches no registered project AND no `default` project exists — we still\n * return 200 on the receiver (OTLP spec), but the dropped event lands here\n * so the next operator can see what happened. The log lives at\n * `<NEAT_HOME>/errors.ndjson` because the unrouted span doesn't belong to\n * any project's neat-out directory.\n *\n * Owner: this module. Daemon imports the appender + warner; no other code\n * writes to the no-project-match log path. Keeping the writes here keeps\n * daemon.ts free of direct `fs.appendFile` calls (asserted by the daemon\n * contract test).\n */\n\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\n\nexport interface UnroutedSpanRecord {\n timestamp: string\n reason: 'no-project-match'\n service_name: string | null\n traceId: string | null\n}\n\nexport function buildUnroutedSpanRecord(\n serviceName: string | undefined,\n traceId: string | undefined,\n now: Date = new Date(),\n): UnroutedSpanRecord {\n return {\n timestamp: now.toISOString(),\n reason: 'no-project-match',\n service_name: serviceName ?? null,\n traceId: traceId ?? null,\n }\n}\n\nexport async function appendUnroutedSpan(\n neatHome: string,\n record: UnroutedSpanRecord,\n): Promise<void> {\n const target = path.join(neatHome, 'errors.ndjson')\n await fs.mkdir(neatHome, { recursive: true })\n await fs.appendFile(target, JSON.stringify(record) + '\\n', 'utf8')\n}\n\nexport function unroutedErrorsPath(neatHome: string): string {\n return path.join(neatHome, 'errors.ndjson')\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA;AAAA,EACE,YAAYA;AAAA,EACZ;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,OAAOC,WAAU;AACjB,SAAS,qBAAqB;;;ACrB9B,SAAS,YAAY,UAAU;AAC/B,OAAO,UAAU;AASV,SAAS,wBACd,aACA,SACA,MAAY,oBAAI,KAAK,GACD;AACpB,SAAO;AAAA,IACL,WAAW,IAAI,YAAY;AAAA,IAC3B,QAAQ;AAAA,IACR,cAAc,eAAe;AAAA,IAC7B,SAAS,WAAW;AAAA,EACtB;AACF;AAEA,eAAsB,mBACpB,UACA,QACe;AACf,QAAM,SAAS,KAAK,KAAK,UAAU,eAAe;AAClD,QAAM,GAAG,MAAM,UAAU,EAAE,WAAW,KAAK,CAAC;AAC5C,QAAM,GAAG,WAAW,QAAQ,KAAK,UAAU,MAAM,IAAI,MAAM,MAAM;AACnE;AAEO,SAAS,mBAAmB,UAA0B;AAC3D,SAAO,KAAK,KAAK,UAAU,eAAe;AAC5C;;;ADWA,SAAS,gBAAsD;AAgCxD,SAAS,eAAe,UAA0B;AACvD,SAAOC,MAAK,KAAK,UAAU,YAAY,aAAa;AACtD;AAIO,SAAS,oBAAoB,MAAuB;AACzD,QAAM,OAAO,QAAQ,KAAK,SAAS,IAAI,OAAO,gBAAgB;AAC9D,SAAOA,MAAK,KAAK,MAAM,SAAS;AAClC;AAKO,SAAS,oBAAoB,SAAiB,MAAuB;AAC1E,SAAOA,MAAK,KAAK,oBAAoB,IAAI,GAAG,GAAG,sBAAsB,OAAO,CAAC,OAAO;AACtF;AAKA,SAAS,sBAAsB,SAAyB;AACtD,SAAO,QAAQ,QAAQ,oBAAoB,GAAG;AAChD;AAEA,SAAS,kBAA0B;AACjC,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,OAAO,IAAI,SAAS,EAAG,QAAOA,MAAK,QAAQ,GAAG;AAClD,QAAM,OAAO,QAAQ,IAAI,QAAQ,QAAQ,IAAI,eAAe;AAC5D,SAAOA,MAAK,KAAK,MAAM,OAAO;AAChC;AAKA,eAAsB,iBAAiB,UAAgD;AACrF,MAAI;AACF,UAAM,MAAM,MAAMC,IAAG,SAAS,eAAe,QAAQ,GAAG,MAAM;AAC9D,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,QACE,OAAO,OAAO,YAAY,YAC1B,OAAO,SACP,OAAO,OAAO,MAAM,SAAS,YAC7B,OAAO,OAAO,MAAM,SAAS,YAC7B,OAAO,OAAO,MAAM,QAAQ,UAC5B;AACA,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAKO,SAAS,qBAA6B;AAC3C,MAAI,QAAQ,IAAI,sBAAsB,QAAQ,IAAI,mBAAmB,SAAS,GAAG;AAC/E,WAAO,QAAQ,IAAI;AAAA,EACrB;AACA,MAAI;AACF,UAAM,MAAM,cAAc,YAAY,GAAG;AACzC,UAAM,MAAM,IAAI,iBAAiB;AACjC,WAAO,OAAO,IAAI,YAAY,WAAW,IAAI,UAAU;AAAA,EACzD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMA,eAAsB,kBAAkB,QAAsB,MAA8B;AAC1F,QAAM,OAAO,KAAK,UAAU,QAAQ,MAAM,CAAC,IAAI;AAC/C,QAAM,gBAAgB,eAAe,OAAO,WAAW,GAAG,IAAI;AAC9D,MAAI;AACF,UAAM,gBAAgB,oBAAoB,OAAO,SAAS,IAAI,GAAG,IAAI;AAAA,EACvE,SAAS,KAAK;AACZ,YAAQ;AAAA,MACN,8CAA8C,OAAO,OAAO,YAAQ,IAAc,OAAO;AAAA,IAC3F;AAAA,EACF;AACF;AAOA,eAAsB,kBAAkB,QAAsB,MAA8B;AAC1F,MAAI;AACF,UAAM,UAAwB,EAAE,GAAG,QAAQ,QAAQ,UAAU;AAC7D,UAAM,gBAAgB,eAAe,OAAO,WAAW,GAAG,KAAK,UAAU,SAAS,MAAM,CAAC,IAAI,IAAI;AAAA,EACnG,QAAQ;AAAA,EAER;AACA,MAAI;AACF,UAAMA,IAAG,OAAO,oBAAoB,OAAO,SAAS,IAAI,CAAC;AAAA,EAC3D,QAAQ;AAAA,EAER;AACF;AAUO,SAAS,0BAA0B,QAAsB,MAAqB;AACnF,MAAI;AACF,UAAM,UAAwB,EAAE,GAAG,QAAQ,QAAQ,UAAU;AAC7D,UAAM,SAAS,eAAe,OAAO,WAAW;AAChD,UAAM,MAAM,GAAG,MAAM,IAAI,QAAQ,GAAG;AACpC,kBAAc,KAAK,KAAK,UAAU,SAAS,MAAM,CAAC,IAAI,IAAI;AAC1D,eAAW,KAAK,MAAM;AAAA,EACxB,QAAQ;AAAA,EAER;AACA,MAAI;AACF,eAAW,oBAAoB,OAAO,SAAS,IAAI,CAAC;AAAA,EACtD,QAAQ;AAAA,EAER;AACF;AAsDA,SAAS,aAAa,MAAyB;AAC7C,MAAI;AACF,SAAK,YAAY;AAAA,EACnB,QAAQ;AAAA,EAER;AACA,MAAI;AACF,SAAK,cAAc;AAAA,EACrB,QAAQ;AAAA,EAER;AACA,MAAI;AACF,SAAK,aAAa;AAAA,EACpB,QAAQ;AAAA,EAER;AACF;AA8CA,SAAS,YAAY,MAA6B;AAChD,MAAI,KAAK,YAAY,KAAK,SAAS,SAAS,EAAG,QAAOD,MAAK,QAAQ,KAAK,QAAQ;AAChF,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,OAAO,IAAI,SAAS,EAAG,QAAOA,MAAK,QAAQ,GAAG;AAClD,QAAM,OAAO,QAAQ,IAAI,QAAQ,QAAQ,IAAI,eAAe;AAC5D,SAAOA,MAAK,KAAK,MAAM,OAAO;AAChC;AAkCO,SAAS,mBACd,aACA,UACQ;AACR,MAAI,CAAC,YAAa,QAAO;AAEzB,aAAW,SAAS,UAAU;AAC5B,QAAI,MAAM,WAAW,SAAU;AAC/B,QAAI,MAAM,SAAS,YAAa,QAAO,MAAM;AAAA,EAC/C;AAIA,QAAM,aAA8B,CAAC;AACrC,aAAW,SAAS,UAAU;AAC5B,QAAI,MAAM,WAAW,SAAU;AAC/B,QAAI,cAAc,MAAM,MAAM,WAAW,EAAG,YAAW,KAAK,KAAK;AAAA,EACnE;AACA,MAAI,WAAW,SAAS,GAAG;AACzB,eAAW,KAAK,CAAC,GAAG,MAAM,EAAE,KAAK,SAAS,EAAE,KAAK,MAAM;AACvD,WAAO,WAAW,CAAC,EAAG;AAAA,EACxB;AAGA,aAAW,SAAS,UAAU;AAC5B,QAAI,MAAM,WAAW,SAAU;AAC/B,QAAI,iBAAiB,MAAM,MAAM,WAAW,EAAG,QAAO,MAAM;AAAA,EAC9D;AACA,SAAO;AACT;AAIA,SAAS,cAAc,QAAgB,MAAuB;AAC5D,MAAI,OAAO,UAAU,KAAK,OAAQ,QAAO;AACzC,MAAI,CAAC,KAAK,WAAW,MAAM,EAAG,QAAO;AACrC,QAAM,MAAM,KAAK,OAAO,OAAO,MAAM;AACrC,SAAO,QAAQ,OAAO,QAAQ;AAChC;AAIA,SAAS,iBAAiB,QAAgB,UAA2B;AACnE,MAAI,CAAC,SAAS,SAAS,MAAM,EAAG,QAAO;AACvC,QAAM,SAAS,SAAS,MAAM,MAAM;AACpC,SAAO,OAAO,SAAS,MAAM;AAC/B;AAwBA,SAAS,0BAA0B,aAAqB,SAA0B;AAChF,MAAI,gBAAgB,QAAS,QAAO;AACpC,MAAI,cAAc,SAAS,WAAW,EAAG,QAAO;AAChD,MAAI,iBAAiB,SAAS,WAAW,EAAG,QAAO;AACnD,SAAO;AACT;AAEA,SAAS,2BACP,OACA,SACA,aACS;AACT,MAAI,CAAC,YAAa,QAAO;AACzB,MAAI,0BAA0B,aAAa,OAAO,EAAG,QAAO;AAC5D,SAAO,MAAM;AAAA,IACX,CAAC,KAAK,UACJ,MAAM,SAAS,SAAS,eACvB,MAAsB,SAAS;AAAA,EACpC;AACF;AAEA,eAAe,iBAAiB,OAA4C;AAC1E,QAAM,QAAQ,gBAAgB,MAAM,MAAMA,MAAK,KAAK,MAAM,MAAM,UAAU,CAAC;AAI3E,MAAI;AACF,UAAM,OAAO,MAAMC,IAAG,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,eAAe,MAAM;AAAA,MAAC;AAAA,MACtB,cAAc,MAAM;AAAA,MAAC;AAAA,MACrB,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;AAMtC,QAAM,eAAe,sBAAsB,OAAO,EAAE,SAAS,MAAM,KAAK,CAAC;AACzE,MAAI;AACF,UAAM,qBAAqB,OAAO,MAAM,IAAI;AAK5C,UAAM,cAAc,iBAAiB,OAAO,SAAS,EAAE,cAAc,MAAM,CAAC;AAM5E,UAAM,gBAAgB,mBAAmB,OAAO;AAAA,MAC9C,iBAAiB,MAAM;AAAA,MACvB,SAAS,MAAM;AAAA,IACjB,CAAC;AACD,UAAM,cAAc,MAAM,IAAI,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAE9C,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,QAAQ;AAAA,IACV;AAAA,EACF,SAAS,KAAK;AAGZ,iBAAa;AACb,UAAM;AAAA,EACR;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;AAKA,SAAS,iBAAyB;AAChC,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;AAMO,SAAS,sBAAsB,SAAiB,UAA0B;AAC/E,MAAI;AACF,UAAM,OAAO,IAAI,IAAI,OAAO,EAAE;AAC9B,UAAM,IAAI,OAAO,SAAS,MAAM,EAAE;AAClC,QAAI,OAAO,SAAS,CAAC,KAAK,IAAI,EAAG,QAAO;AAAA,EAC1C,QAAQ;AAAA,EAER;AACA,SAAO;AACT;AAEO,SAAS,YAAY,MAAqB,cAA+B;AAC9E,MAAI,KAAK,QAAQ,KAAK,KAAK,SAAS,EAAG,QAAO,KAAK;AACnD,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,OAAO,IAAI,SAAS,EAAG,QAAO;AAQlC,MAAI,CAAC,aAAc,QAAO;AAC1B,SAAO;AACT;AAEA,eAAsB,YAAY,OAAsB,CAAC,GAA0B;AACjF,QAAM,OAAO,YAAY,IAAI;AAC7B,QAAM,UAAU,aAAa;AAY7B,QAAM,aACJ,OAAO,KAAK,YAAY,YAAY,KAAK,QAAQ,SAAS,IACtD,KAAK,UACL,QAAQ,IAAI,gBAAgB,QAAQ,IAAI,aAAa,SAAS,IAC5D,QAAQ,IAAI,eACZ;AACR,QAAM,iBACJ,KAAK,eAAe,KAAK,YAAY,SAAS,IAC1C,KAAK,cACL,QAAQ,IAAI,qBAAqB,QAAQ,IAAI,kBAAkB,SAAS,IACtE,QAAQ,IAAI,oBACZ;AACR,QAAM,gBAAgB;AACtB,QAAM,oBACJ,iBAAiB,iBAAiBD,MAAK,QAAQ,cAAc,IAAI;AACnE,MAAI,iBAAiB,CAAC,mBAAmB;AACvC,UAAM,IAAI;AAAA,MACR,mBAAmB,aAAa;AAAA,IAClC;AAAA,EACF;AAMA,MAAI,CAAC,eAAe;AAClB,QAAI;AACF,YAAMC,IAAG,OAAO,OAAO;AAAA,IACzB,QAAQ;AACN,YAAM,IAAI;AAAA,QACR,gCAAgC,OAAO;AAAA,MACzC;AAAA,IACF;AAAA,EACF;AAEA,QAAM,UAAUD,MAAK,KAAK,MAAM,WAAW;AAC3C,QAAM,gBAAgB,SAAS,GAAG,QAAQ,GAAG;AAAA,CAAI;AAEjD,QAAM,QAAQ,oBAAI,IAAyB;AAG3C,QAAM,WAAW,IAAI,SAAS;AAI9B,QAAM,kBAAkB,oBAAI,IAA4B;AACxD,QAAM,qBAAqB,oBAAI,IAAoB;AAMnD,QAAM,wBAAwB;AAC9B,QAAM,iBAAiB,oBAAI,IAAoB;AAC/C,WAAS,gBAAgB,SAAiB,QAAsB;AAC9D,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,OAAO,eAAe,IAAI,OAAO,KAAK;AAC5C,QAAI,MAAM,OAAO,sBAAuB;AACxC,mBAAe,IAAI,SAAS,GAAG;AAC/B,YAAQ;AAAA,MACN,sCAAsC,OAAO,oCAA+B,MAAM;AAAA,IACpF;AAAA,EACF;AAQA,QAAM,eAAe,mBAAmB,IAAI;AAC5C,QAAM,qBAAqB,oBAAI,IAAoB;AACnD,iBAAe,mBACb,aACA,SACe;AACf,UAAM,MAAM,eAAe;AAC3B,UAAM,MAAM,KAAK,IAAI;AACrB,QAAI;AACF,YAAM,mBAAmB,MAAM,wBAAwB,aAAa,SAAS,IAAI,KAAK,GAAG,CAAC,CAAC;AAAA,IAC7F,QAAQ;AAAA,IAER;AACA,UAAM,OAAO,mBAAmB,IAAI,GAAG,KAAK;AAC5C,QAAI,MAAM,OAAO,sBAAuB;AACxC,uBAAmB,IAAI,KAAK,GAAG;AAC/B,YAAQ;AAAA,MACN,8CAAyC,GAAG,0EAA0E,YAAY;AAAA,IACpI;AAAA,EACF;AAEA,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;AAKA,iBAAe,eAAe,OAA4C;AACxE,QAAI;AACF,YAAM,QAAQ,MAAM,iBAAiB,KAAK;AAG1C,YAAM,QAAQ,MAAM,IAAI,MAAM,IAAI;AAClC,UAAI,MAAO,cAAa,KAAK;AAC7B,YAAM,IAAI,MAAM,MAAM,KAAK;AAC3B,6BAAuB,KAAK;AAC5B,UAAI,MAAM,WAAW,UAAU;AAC7B,cAAM,UAAU,MAAM,MAAM,QAAQ,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AACpD,gBAAQ;AAAA,UACN,mBAAmB,MAAM,IAAI;AAAA,QAC/B;AAAA,MACF;AACA,aAAO;AAAA,IACT,SAAS,KAAK;AACZ,cAAQ;AAAA,QACN,mBAAmB,MAAM,IAAI,gDAA4C,IAAc,OAAO;AAAA,MAChG;AAEA,aAAO,MAAM,IAAI,MAAM,IAAI;AAAA,IAC7B;AAAA,EACF;AAEA,iBAAe,aAAa,OAAqC;AAC/D,oBAAgB,IAAI,MAAM,MAAM,eAAe;AAC/C,uBAAmB,IAAI,MAAM,MAAM,KAAK,IAAI,CAAC;AAC7C,QAAI;AACF,YAAM,OAAO,MAAM,iBAAiB,KAAK;AAEzC,YAAM,QAAQ,MAAM,IAAI,MAAM,IAAI;AAClC,UAAI,MAAO,cAAa,KAAK;AAC7B,YAAM,IAAI,MAAM,MAAM,IAAI;AAC1B,6BAAuB,IAAI;AAC3B,sBAAgB,IAAI,MAAM,MAAM,KAAK,WAAW,WAAW,WAAW,QAAQ;AAC9E,UAAI,KAAK,WAAW,UAAU;AAC5B,gBAAQ,KAAK,mBAAmB,MAAM,IAAI,mBAAc,KAAK,WAAW,EAAE;AAAA,MAC5E,OAAO;AACL,gBAAQ,IAAI,mBAAmB,MAAM,IAAI,aAAa,MAAM,IAAI,GAAG;AAAA,MACrE;AAAA,IACF,SAAS,KAAK;AACZ,sBAAgB,IAAI,MAAM,MAAM,QAAQ;AACxC,cAAQ;AAAA,QACN,mBAAmB,MAAM,IAAI,gCAA4B,IAAc,OAAO;AAAA,MAChF;AACA,YAAM,UAAU,MAAM,MAAM,QAAQ,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IACtD;AAAA,EACF;AAKA,iBAAe,oBAA8C;AAC3D,QAAI,iBAAiB,mBAAmB;AACtC,aAAO;AAAA,QACL;AAAA,UACE,MAAM;AAAA,UACN,MAAM;AAAA,UACN,eAAc,oBAAI,KAAK,GAAE,YAAY;AAAA,UACrC,WAAW,CAAC;AAAA,UACZ,QAAQ;AAAA,QACV;AAAA,MACF;AAAA,IACF;AACA,WAAO,aAAa;AAAA,EACtB;AAEA,iBAAe,UAAyB;AAQtC,QAAI,CAAC,eAAe;AAClB,UAAI;AACF,cAAM,SAAS,MAAM,cAAc;AACnC,mBAAW,SAAS,QAAQ;AAC1B,kBAAQ;AAAA,YACN,0BAA0B,MAAM,IAAI,4BAAuB,MAAM,IAAI;AAAA,UACvE;AACA,gBAAM,OAAO,MAAM,IAAI;AACvB,0BAAgB,OAAO,MAAM,IAAI;AACjC,6BAAmB,OAAO,MAAM,IAAI;AAAA,QACtC;AAAA,MACF,SAAS,KAAK;AACZ,gBAAQ,KAAK,wCAAoC,IAAc,OAAO,EAAE;AAAA,MAC1E;AAAA,IACF;AAEA,UAAM,WAAW,MAAM,kBAAkB;AACzC,UAAM,OAAO,oBAAI,IAAY;AAC7B,UAAM,UAA2B,CAAC;AAClC,eAAW,SAAS,UAAU;AAC5B,WAAK,IAAI,MAAM,IAAI;AACnB,YAAM,WAAW,MAAM,IAAI,MAAM,IAAI;AACrC,UAAI,UAAU;AACZ,YAAI,SAAS,WAAW,UAAU;AAChC,kBAAQ,KAAK,eAAe,KAAK,EAAE,KAAK,MAAM;AAAA,UAAC,CAAC,CAAC;AAAA,QACnD;AACA;AAAA,MACF;AACA,cAAQ,KAAK,aAAa,KAAK,CAAC;AAAA,IAClC;AACA,eAAW,CAAC,MAAM,IAAI,KAAK,CAAC,GAAG,MAAM,QAAQ,CAAC,GAAG;AAC/C,UAAI,KAAK,IAAI,IAAI,EAAG;AACpB,mBAAa,IAAI;AACjB,YAAM,OAAO,IAAI;AACjB,sBAAgB,OAAO,IAAI;AAC3B,yBAAmB,OAAO,IAAI;AAC9B,cAAQ,IAAI,mBAAmB,IAAI,wCAAmC;AAAA,IACxE;AACA,UAAM,QAAQ,WAAW,OAAO;AAAA,EAClC;AAKA,QAAM,iBAAiB,MAAM,kBAAkB,EAAE,MAAM,MAAM,CAAC,CAAoB;AAClF,aAAW,SAAS,gBAAgB;AAClC,oBAAgB,IAAI,MAAM,MAAM,eAAe;AAC/C,uBAAmB,IAAI,MAAM,MAAM,KAAK,IAAI,CAAC;AAAA,EAC/C;AAMA,QAAM,OAAO,KAAK,kBAAkB;AACpC,MAAI,UAAkC;AACtC,MAAI,UAEO;AACX,MAAI,cAAc;AAClB,MAAI,cAAc;AAIlB,MAAI,eAAoC;AAExC,MAAI,MAAM;AAKR,UAAM,OAAO,YAAY;AACzB,UAAM,OAAO,YAAY,MAAM,QAAQ,KAAK,SAAS,CAAC;AACtD,UAAM,WAAW,gBAAgB,IAAI;AACrC,UAAM,WAAW,gBAAgB,IAAI;AAErC,wBAAoB,MAAM,KAAK,SAAS;AAExC,QAAI;AACF,gBAAU,MAAM,SAAS;AAAA,QACvB,UAAU;AAAA,QACV,WAAW,KAAK;AAAA,QAChB,YAAY,KAAK;AAAA,QACjB,YAAY,KAAK;AAAA,QACjB,WAAW;AAAA,UACT,QAAQ,CAAC,SAAS,gBAAgB,IAAI,IAAI;AAAA,UAC1C,MAAM,MAAM;AACV,kBAAM,MAAM,KAAK,IAAI;AACrB,mBAAO,CAAC,GAAG,gBAAgB,QAAQ,CAAC,EAAE,IAAI,CAAC,CAAC,MAAM,MAAM,OAAO;AAAA,cAC7D;AAAA,cACA;AAAA,cACA,WAAW,OAAO,mBAAmB,IAAI,IAAI,KAAK;AAAA,YACpD,EAAE;AAAA,UACJ;AAAA,QACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAMA,eACE,iBAAiB,oBACb,EAAE,MAAM,eAAe,MAAM,kBAAkB,IAC/C;AAAA,MACR,CAAC;AACD,oBAAc,MAAM,QAAQ,OAAO,EAAE,MAAM,UAAU,KAAK,CAAC;AAK3D,cAAQ;AAAA,QACN,mCAAmC,IAAI,IAAI,sBAAsB,aAAa,QAAQ,CAAC;AAAA,MACzF;AAAA,IACF,SAAS,KAAK;AAEZ,iBAAW,QAAQ,MAAM,OAAO,GAAG;AACjC,qBAAa,IAAI;AAAA,MACnB;AACA,UAAI,QAAS,OAAM,QAAQ,MAAM,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACjD,YAAMC,IAAG,OAAO,OAAO,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACvC,YAAM,IAAI;AAAA,QACR,sCAAsC,QAAQ,WAAO,IAAc,OAAO;AAAA,MAC5E;AAAA,IACF;AAiBA,mBAAe,kBACb,aACA,SAC6B;AAC7B,UAAI,eAAe;AACjB,YAAIC,QAAO,MAAM,IAAI,aAAa;AAClC,YAAI,CAACA,OAAM;AAIT,UAAAA,QAAO,MAAM,eAAe;AAAA,YAC1B,MAAM;AAAA,YACN,MAAM;AAAA,YACN,eAAc,oBAAI,KAAK,GAAE,YAAY;AAAA,YACrC,WAAW,CAAC;AAAA,YACZ,QAAQ;AAAA,UACV,CAAC;AAAA,QACH,WAAWA,MAAK,WAAW,UAAU;AACnC,UAAAA,QAAO,MAAM,eAAeA,MAAK,KAAK;AAAA,QACxC;AACA,YAAI,CAACA,SAAQA,MAAK,WAAW,UAAU;AACrC,0BAAgB,eAAeA,OAAM,eAAe,SAAS;AAC7D,iBAAO;AAAA,QACT;AAKA,YAAI,CAAC,2BAA2BA,MAAK,OAAO,eAAe,WAAW,GAAG;AACvE,gBAAM,mBAAmB,aAAa,OAAO;AAC7C,iBAAO;AAAA,QACT;AACA,eAAOA;AAAA,MACT;AACA,YAAM,cAAc,MAAM,aAAa,EAAE,MAAM,MAAM,CAAC,CAAC;AACvD,YAAM,SAAS,mBAAmB,aAAa,WAAW;AAC1D,UAAI,OAAO,MAAM,IAAI,MAAM,KAAK,MAAM,IAAI,eAAe;AACzD,UAAI,CAAC,MAAM;AACT,cAAM,mBAAmB,aAAa,OAAO;AAC7C,eAAO;AAAA,MACT;AACA,UAAI,KAAK,WAAW,UAAU;AAC5B,cAAM,QAAQ,YAAY,KAAK,CAAC,MAAM,EAAE,SAAS,KAAM,MAAM,IAAI;AACjE,YAAI,OAAO;AACT,iBAAO,MAAM,eAAe,KAAK;AAAA,QACnC;AACA,YAAI,KAAK,WAAW,UAAU;AAC5B,0BAAgB,KAAK,MAAM,MAAM,KAAK,eAAe,SAAS;AAC9D,iBAAO;AAAA,QACT;AAAA,MACF;AACA,aAAO,KAAK,WAAW,WAAW,OAAO;AAAA,IAC3C;AAOA,mBAAe,kBACb,SACA,aACA,SAC6B;AAC7B,YAAM,cAAc,MAAM,aAAa,EAAE,MAAM,MAAM,CAAC,CAAC;AACvD,UAAI,OAAO,MAAM,IAAI,OAAO;AAC5B,UAAI,CAAC,MAAM;AACT,cAAM,mBAAmB,aAAa,OAAO;AAC7C,eAAO;AAAA,MACT;AACA,UAAI,KAAK,WAAW,UAAU;AAC5B,cAAM,QAAQ,YAAY,KAAK,CAAC,MAAM,EAAE,SAAS,KAAM,MAAM,IAAI;AACjE,YAAI,OAAO;AACT,iBAAO,MAAM,eAAe,KAAK;AAAA,QACnC;AACA,YAAI,KAAK,WAAW,UAAU;AAC5B,0BAAgB,KAAK,MAAM,MAAM,KAAK,eAAe,SAAS;AAC9D,iBAAO;AAAA,QACT;AAAA,MACF;AACA,aAAO,KAAK,WAAW,WAAW,OAAO;AAAA,IAC3C;AAEA,QAAI;AACF,gBAAU,MAAM,kBAAkB;AAAA,QAChC,WAAW,KAAK;AAAA,QAChB,YAAY,KAAK;AAAA,QACjB,QAAQ,OAAO,SAAS;AAQtB,gBAAM,OAAO,MAAM,kBAAkB,KAAK,SAAS,KAAK,OAAO;AAC/D,cAAI,CAAC,KAAM;AACX,gBAAM;AAAA,YACJ;AAAA,cACE,OAAO,KAAK;AAAA,cACZ,YAAY,KAAK,MAAM;AAAA,cACvB,UAAU,KAAK,MAAM;AAAA,cACrB,SAAS,KAAK,MAAM;AAAA;AAAA,cAEpB,uBAAuB;AAAA,YACzB;AAAA,YACA;AAAA,UACF;AAAA,QACF;AAAA,QACA,iBAAiB,OAAO,SAAS;AAC/B,gBAAM,OAAO,MAAM,kBAAkB,KAAK,SAAS,KAAK,OAAO;AAC/D,cAAI,CAAC,KAAM;AACX,gBAAM,oBAAoB,KAAK,MAAM,YAAY,KAAK,OAAO,KAAK,MAAM,IAAI,EAAE,IAAI;AAAA,QACpF;AAAA;AAAA;AAAA;AAAA;AAAA,QAKA,eAAe,OAAO,SAAS,SAAS;AACtC,gBAAM,OAAO,MAAM,kBAAkB,SAAS,KAAK,SAAS,KAAK,OAAO;AACxE,cAAI,CAAC,KAAM;AACX,gBAAM;AAAA,YACJ;AAAA,cACE,OAAO,KAAK;AAAA,cACZ,YAAY,KAAK,MAAM;AAAA,cACvB,UAAU,KAAK,MAAM;AAAA,cACrB,SAAS,KAAK,MAAM;AAAA,cACpB,uBAAuB;AAAA,YACzB;AAAA,YACA;AAAA,UACF;AAAA,QACF;AAAA,QACA,wBAAwB,OAAO,SAAS,SAAS;AAC/C,gBAAM,OAAO,MAAM,kBAAkB,SAAS,KAAK,SAAS,KAAK,OAAO;AACxE,cAAI,CAAC,KAAM;AACX,gBAAM,oBAAoB,KAAK,MAAM,YAAY,KAAK,OAAO,KAAK,MAAM,IAAI,EAAE,IAAI;AAAA,QACpF;AAAA,MACF,CAAC;AAID,oBAAc,MAAM,mBAAmB,SAAS,UAAU,IAAI;AAC9D,cAAQ,IAAI,4BAA4B,WAAW,YAAY;AAAA,IACjE,SAAS,KAAK;AACZ,iBAAW,QAAQ,MAAM,OAAO,GAAG;AACjC,qBAAa,IAAI;AAAA,MACnB;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,YAAMD,IAAG,OAAO,OAAO,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACvC,YAAM,IAAI;AAAA,QACR,sCAAsC,QAAQ,WAAO,IAAc,OAAO;AAAA,MAC5E;AAAA,IACF;AAOA,QAAI,iBAAiB,mBAAmB;AACtC,YAAM,QAAqB;AAAA,QACzB,MAAM,sBAAsB,aAAa,QAAQ;AAAA,QACjD,MAAM,sBAAsB,aAAa,QAAQ;AAAA;AAAA;AAAA;AAAA,QAIjD,KAAK,OAAO,KAAK,YAAY,WAAW,KAAK,UAAU,eAAe;AAAA,MACxE;AACA,qBAAe;AAAA,QACb,SAAS;AAAA,QACT,aAAa;AAAA,QACb,KAAK,QAAQ;AAAA,QACb,QAAQ;AAAA,QACR;AAAA,QACA,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,QAClC,aAAa,mBAAmB;AAAA,MAClC;AACA,UAAI;AACF,cAAM,kBAAkB,cAAc,IAAI;AAC1C,gBAAQ;AAAA,UACN,mBAAmB,aAAa,iBAAY,MAAM,IAAI,WAAW,MAAM,IAAI,UAAU,MAAM,GAAG;AAAA,QAChG;AAAA,MACF,SAAS,KAAK;AAKZ,mBAAW,QAAQ,MAAM,OAAO,EAAG,cAAa,IAAI;AACpD,YAAI,QAAS,OAAM,QAAQ,MAAM,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AACjD,YAAI,QAAS,OAAM,QAAQ,MAAM,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AACjD,cAAMA,IAAG,OAAO,OAAO,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AACvC,cAAM,IAAI;AAAA,UACR,2CAA2C,aAAa,YAAQ,IAAc,OAAO;AAAA,QACvF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAIA,QAAM,mBAAmB,QAAQ,EAAE,MAAM,CAAC,QAAQ;AAChD,YAAQ,KAAK,+CAA2C,IAAc,OAAO,EAAE;AAAA,EACjF,CAAC;AAED,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;AACA,OAAK,iBAAiB,QAAQ,MAAM;AAClC,QAAI,cAAc,iBAAkB,aAAY;AAAA,EAClD,CAAC;AAED,QAAM,UAA4B;AAAA,IAChC,QAAQ,CAAC,SAAS,gBAAgB,IAAI,IAAI;AAAA,IAC1C,MAAM,MAAM;AACV,YAAM,MAAM,KAAK,IAAI;AACrB,aAAO,CAAC,GAAG,gBAAgB,QAAQ,CAAC,EAAE,IAAI,CAAC,CAAC,MAAM,MAAM,OAAO;AAAA,QAC7D;AAAA,QACA;AAAA,QACA,WAAW,OAAO,mBAAmB,IAAI,IAAI,KAAK;AAAA,MACpD,EAAE;AAAA,IACJ;AAAA,EACF;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;AAUlC,QAAM,8BAA8B;AACpC,MAAI,kBAAoC;AACxC,MAAI,cAAqC;AAKzC,MAAI,CAAC,cAAe,KAAI;AACtB,UAAM,SAASD,MAAK,QAAQ,OAAO;AACnC,UAAM,UAAUA,MAAK,SAAS,OAAO;AACrC,sBAAkB,MAAM,QAAQ,CAAC,YAAY,aAAa;AAGxD,UAAI,aAAa,QAAQ,aAAa,QAAS;AAC/C,UAAI,YAAa,cAAa,WAAW;AACzC,oBAAc,WAAW,MAAM;AAC7B,sBAAc;AACd,aAAK,OAAO,EAAE,MAAM,CAAC,QAAQ;AAC3B,kBAAQ;AAAA,YACN,8CAA0C,IAAc,OAAO;AAAA,UACjE;AAAA,QACF,CAAC;AAAA,MACH,GAAG,2BAA2B;AAAA,IAChC,CAAC;AAAA,EACH,SAAS,KAAK;AAIZ,YAAQ;AAAA,MACN,sCAAsC,OAAO,WAAO,IAAc,OAAO;AAAA,IAE3E;AAAA,EACF;AAEA,MAAI,UAAU;AACd,QAAM,OAAO,YAA2B;AACtC,QAAI,QAAS;AACb,cAAU;AACV,YAAQ,IAAI,UAAU,aAAa;AACnC,QAAI,aAAa;AACf,mBAAa,WAAW;AACxB,oBAAc;AAAA,IAChB;AACA,QAAI,iBAAiB;AACnB,UAAI;AACF,wBAAgB,MAAM;AAAA,MACxB,QAAQ;AAAA,MAER;AACA,wBAAkB;AAAA,IACpB;AACA,QAAI,QAAS,OAAM,QAAQ,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACjD,QAAI,QAAS,OAAM,QAAQ,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAIjD,eAAW,QAAQ,MAAM,OAAO,GAAG;AACjC,UAAI,KAAK,WAAW,YAAY,KAAK,SAAS;AAC5C,cAAM,gBAAgB,KAAK,OAAO,KAAK,OAAO,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AAAA,MAChE;AAAA,IACF;AACA,eAAW,QAAQ,MAAM,OAAO,GAAG;AACjC,mBAAa,IAAI;AAAA,IACnB;AAGA,QAAI,cAAc;AAChB,YAAM,kBAAkB,cAAc,IAAI;AAAA,IAC5C;AACA,UAAMC,IAAG,OAAO,OAAO,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EACzC;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,WAAW;AAAA,IACX;AAAA,IACA;AAAA,IACA,UAAU;AAAA,EACZ;AACF;","names":["fs","path","path","fs","slot"]}