@neat.is/core 0.4.18 → 0.4.19

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.
@@ -13,11 +13,12 @@ import {
13
13
  pruneRegistry,
14
14
  registryPath,
15
15
  resetGraph,
16
+ saveGraphToDisk,
16
17
  setStatus,
17
18
  startPersistLoop,
18
19
  touchLastSeen,
19
20
  writeAtomically
20
- } from "./chunk-T3X6XJPU.js";
21
+ } from "./chunk-Q5EDVWKZ.js";
21
22
  import {
22
23
  assertBindAuthority,
23
24
  buildOtelReceiver,
@@ -196,7 +197,7 @@ async function bootstrapProject(entry) {
196
197
  const detachEvents = attachGraphToEventBus(graph, { project: entry.name });
197
198
  try {
198
199
  await extractFromDirectory(graph, entry.path);
199
- const stopPersist = startPersistLoop(graph, outPath);
200
+ const stopPersist = startPersistLoop(graph, outPath, { exitOnSignal: false });
200
201
  await touchLastSeen(entry.name).catch(() => {
201
202
  });
202
203
  return {
@@ -452,22 +453,14 @@ async function startDaemon(opts = {}) {
452
453
  elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
453
454
  }));
454
455
  }
455
- }
456
+ },
457
+ // ADR-096 §4/§5/§7 — hand the daemon's identity to buildApi so the REST
458
+ // surface reflects "the daemon is the project": `GET /projects` reports
459
+ // only this project (the dashboard pins to it), and the daemon-wide
460
+ // `/health` carries it at the top level for the spawn-reuse identity
461
+ // check. Absent for the legacy multi-project daemon.
462
+ singleProject: singleProject && singleProjectPath ? { name: singleProject, path: singleProjectPath } : void 0
456
463
  });
457
- if (singleProject) {
458
- restApp.addHook("onSend", async (req, _reply, payload) => {
459
- if (req.url.split("?")[0] !== "/health") return payload;
460
- if (typeof payload !== "string") return payload;
461
- try {
462
- const body = JSON.parse(payload);
463
- if (typeof body !== "object" || body === null) return payload;
464
- body.project = singleProject;
465
- return JSON.stringify(body);
466
- } catch {
467
- return payload;
468
- }
469
- });
470
- }
471
464
  restAddress = await restApp.listen({ port: restPort, host });
472
465
  console.log(`neatd: REST listening on ${restAddress}`);
473
466
  } catch (err) {
@@ -719,6 +712,12 @@ async function startDaemon(opts = {}) {
719
712
  });
720
713
  if (restApp) await restApp.close().catch(() => {
721
714
  });
715
+ for (const slot of slots.values()) {
716
+ if (slot.status === "active" && slot.outPath) {
717
+ await saveGraphToDisk(slot.graph, slot.outPath).catch(() => {
718
+ });
719
+ }
720
+ }
722
721
  for (const slot of slots.values()) {
723
722
  teardownSlot(slot);
724
723
  }
@@ -746,4 +745,4 @@ export {
746
745
  routeSpanToProject,
747
746
  startDaemon
748
747
  };
749
- //# sourceMappingURL=chunk-GXWBMJDJ.js.map
748
+ //# sourceMappingURL=chunk-ZV7GHZ2D.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 { promises as fs, watch, type FSWatcher } 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 } from './otel.js'\nimport { attachGraphToEventBus } from './events.js'\nimport { handleSpan, makeErrorSpanWriter } 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 type { RegistryEntry } 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.\nfunction 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.\nasync 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.\nasync 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\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 // #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.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}\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\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 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 await touchLastSeen(entry.name).catch(() => {})\n\n return {\n entry,\n graph,\n outPath,\n paths,\n stopPersist,\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.\nfunction 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\nfunction 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 console.log(`neatd: REST listening on ${restAddress}`)\n } catch (err) {\n // Roll back anything we started so far before surfacing the error.\n for (const slot of slots.values()) {\n 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 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)(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)(span)\n },\n })\n otlpAddress = await otlpApp.listen({ port: otlpPort, host })\n console.log(`neatd: OTLP listening on ${otlpAddress}/v1/traces`)\n } catch (err) {\n for (const slot of slots.values()) {\n 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 }\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":"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","names":["fs","path","path","fs","slot"]}
package/dist/cli.cjs CHANGED
@@ -5617,7 +5617,9 @@ async function loadGraphFromDisk(graph, outPath) {
5617
5617
  graph.clear();
5618
5618
  graph.import(payload.graph);
5619
5619
  }
5620
- function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5620
+ function startPersistLoop(graph, outPath, opts = {}) {
5621
+ const intervalMs = opts.intervalMs ?? 6e4;
5622
+ const exitOnSignal = opts.exitOnSignal ?? true;
5621
5623
  let stopped = false;
5622
5624
  const tick = async () => {
5623
5625
  if (stopped) return;
@@ -5630,7 +5632,7 @@ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5630
5632
  const interval = setInterval(() => {
5631
5633
  void tick();
5632
5634
  }, intervalMs);
5633
- const onSignal = (signal) => {
5635
+ const onSignal = exitOnSignal ? (signal) => {
5634
5636
  void (async () => {
5635
5637
  try {
5636
5638
  await saveGraphToDisk(graph, outPath);
@@ -5640,14 +5642,18 @@ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5640
5642
  process.exit(0);
5641
5643
  }
5642
5644
  })();
5643
- };
5644
- process.on("SIGTERM", onSignal);
5645
- process.on("SIGINT", onSignal);
5645
+ } : null;
5646
+ if (onSignal) {
5647
+ process.on("SIGTERM", onSignal);
5648
+ process.on("SIGINT", onSignal);
5649
+ }
5646
5650
  return () => {
5647
5651
  stopped = true;
5648
5652
  clearInterval(interval);
5649
- process.off("SIGTERM", onSignal);
5650
- process.off("SIGINT", onSignal);
5653
+ if (onSignal) {
5654
+ process.off("SIGTERM", onSignal);
5655
+ process.off("SIGINT", onSignal);
5656
+ }
5651
5657
  };
5652
5658
  }
5653
5659
 
@@ -6694,12 +6700,16 @@ function serializeGraph(graph) {
6694
6700
  });
6695
6701
  return { nodes, edges };
6696
6702
  }
6697
- function projectFromReq(req) {
6703
+ function projectFromReq(req, singleProject) {
6698
6704
  const params = req.params;
6699
- return params.project ?? DEFAULT_PROJECT;
6705
+ const named = params.project;
6706
+ if (singleProject) {
6707
+ return named === void 0 || named === DEFAULT_PROJECT ? singleProject : named;
6708
+ }
6709
+ return named ?? DEFAULT_PROJECT;
6700
6710
  }
6701
- function resolveProject(registry, req, reply, bootstrap) {
6702
- const name = projectFromReq(req);
6711
+ function resolveProject(registry, req, reply, bootstrap, singleProject) {
6712
+ const name = projectFromReq(req, singleProject);
6703
6713
  const ctx = registry.get(name);
6704
6714
  if (!ctx) {
6705
6715
  const phase = bootstrap?.status(name);
@@ -6740,13 +6750,13 @@ function buildLegacyRegistry(opts) {
6740
6750
  function registerRoutes(scope, ctx) {
6741
6751
  const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
6742
6752
  scope.get("/events", (req, reply) => {
6743
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6753
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6744
6754
  if (!proj) return;
6745
6755
  handleSse(req, reply, { project: proj.name });
6746
6756
  });
6747
6757
  if (ctx.scope === "project") {
6748
6758
  scope.get("/health", async (req, reply) => {
6749
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6759
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6750
6760
  if (!proj) return;
6751
6761
  const uptimeMs = Date.now() - startedAt;
6752
6762
  return {
@@ -6764,14 +6774,14 @@ function registerRoutes(scope, ctx) {
6764
6774
  });
6765
6775
  }
6766
6776
  scope.get("/graph", async (req, reply) => {
6767
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6777
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6768
6778
  if (!proj) return;
6769
6779
  return serializeGraph(proj.graph);
6770
6780
  });
6771
6781
  scope.get(
6772
6782
  "/graph/node/:id",
6773
6783
  async (req, reply) => {
6774
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6784
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6775
6785
  if (!proj) return;
6776
6786
  const { id } = req.params;
6777
6787
  if (!proj.graph.hasNode(id)) {
@@ -6783,7 +6793,7 @@ function registerRoutes(scope, ctx) {
6783
6793
  scope.get(
6784
6794
  "/graph/edges/:id",
6785
6795
  async (req, reply) => {
6786
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6796
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6787
6797
  if (!proj) return;
6788
6798
  const { id } = req.params;
6789
6799
  if (!proj.graph.hasNode(id)) {
@@ -6795,7 +6805,7 @@ function registerRoutes(scope, ctx) {
6795
6805
  }
6796
6806
  );
6797
6807
  scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
6798
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6808
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6799
6809
  if (!proj) return;
6800
6810
  const { nodeId } = req.params;
6801
6811
  if (!proj.graph.hasNode(nodeId)) {
@@ -6810,7 +6820,7 @@ function registerRoutes(scope, ctx) {
6810
6820
  return getTransitiveDependencies(proj.graph, nodeId, depth);
6811
6821
  });
6812
6822
  scope.get("/graph/divergences", async (req, reply) => {
6813
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6823
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6814
6824
  if (!proj) return;
6815
6825
  let typeFilter;
6816
6826
  if (req.query.type) {
@@ -6845,7 +6855,7 @@ function registerRoutes(scope, ctx) {
6845
6855
  });
6846
6856
  });
6847
6857
  scope.get("/incidents", async (req, reply) => {
6848
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6858
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6849
6859
  if (!proj) return;
6850
6860
  const epath = errorsPathFor(proj);
6851
6861
  if (!epath) return { count: 0, total: 0, events: [] };
@@ -6857,7 +6867,7 @@ function registerRoutes(scope, ctx) {
6857
6867
  return { count: sliced.length, total, events: sliced };
6858
6868
  });
6859
6869
  scope.get("/stale-events", async (req, reply) => {
6860
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6870
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6861
6871
  if (!proj) return;
6862
6872
  const spath = staleEventsPathFor(proj);
6863
6873
  if (!spath) return { count: 0, total: 0, events: [] };
@@ -6872,7 +6882,7 @@ function registerRoutes(scope, ctx) {
6872
6882
  scope.get(
6873
6883
  "/incidents/:nodeId",
6874
6884
  async (req, reply) => {
6875
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6885
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6876
6886
  if (!proj) return;
6877
6887
  const { nodeId } = req.params;
6878
6888
  if (!proj.graph.hasNode(nodeId)) {
@@ -6888,7 +6898,7 @@ function registerRoutes(scope, ctx) {
6888
6898
  }
6889
6899
  );
6890
6900
  scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
6891
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6901
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6892
6902
  if (!proj) return;
6893
6903
  const { nodeId } = req.params;
6894
6904
  if (!proj.graph.hasNode(nodeId)) {
@@ -6908,7 +6918,7 @@ function registerRoutes(scope, ctx) {
6908
6918
  return result;
6909
6919
  });
6910
6920
  scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
6911
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6921
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6912
6922
  if (!proj) return;
6913
6923
  const { nodeId } = req.params;
6914
6924
  if (!proj.graph.hasNode(nodeId)) {
@@ -6921,7 +6931,7 @@ function registerRoutes(scope, ctx) {
6921
6931
  return getBlastRadius(proj.graph, nodeId, depth);
6922
6932
  });
6923
6933
  scope.get("/search", async (req, reply) => {
6924
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6934
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6925
6935
  if (!proj) return;
6926
6936
  const raw = (req.query.q ?? "").trim();
6927
6937
  if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
@@ -6952,7 +6962,7 @@ function registerRoutes(scope, ctx) {
6952
6962
  scope.get(
6953
6963
  "/graph/diff",
6954
6964
  async (req, reply) => {
6955
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6965
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6956
6966
  if (!proj) return;
6957
6967
  const against = req.query.against;
6958
6968
  if (!against) {
@@ -6967,7 +6977,7 @@ function registerRoutes(scope, ctx) {
6967
6977
  }
6968
6978
  );
6969
6979
  scope.post("/snapshot", async (req, reply) => {
6970
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6980
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6971
6981
  if (!proj) return;
6972
6982
  const body = req.body;
6973
6983
  if (!body || typeof body !== "object" || !body.snapshot) {
@@ -6996,7 +7006,7 @@ function registerRoutes(scope, ctx) {
6996
7006
  }
6997
7007
  });
6998
7008
  scope.post("/graph/scan", async (req, reply) => {
6999
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
7009
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
7000
7010
  if (!proj) return;
7001
7011
  if (!proj.scanPath) {
7002
7012
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -7012,7 +7022,7 @@ function registerRoutes(scope, ctx) {
7012
7022
  };
7013
7023
  });
7014
7024
  scope.get("/policies", async (req, reply) => {
7015
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
7025
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
7016
7026
  if (!proj) return;
7017
7027
  const policyPath = ctx.policyFilePathFor(proj);
7018
7028
  if (!policyPath) {
@@ -7029,7 +7039,7 @@ function registerRoutes(scope, ctx) {
7029
7039
  }
7030
7040
  });
7031
7041
  scope.get("/policies/violations", async (req, reply) => {
7032
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
7042
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
7033
7043
  if (!proj) return;
7034
7044
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
7035
7045
  let violations = await log.readAll();
@@ -7049,7 +7059,7 @@ function registerRoutes(scope, ctx) {
7049
7059
  return { violations };
7050
7060
  });
7051
7061
  scope.post("/policies/check", async (req, reply) => {
7052
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
7062
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
7053
7063
  if (!proj) return;
7054
7064
  const parsed = import_types26.PoliciesCheckBodySchema.safeParse(req.body ?? {});
7055
7065
  if (!parsed.success) {
@@ -7085,7 +7095,7 @@ function registerRoutes(scope, ctx) {
7085
7095
  };
7086
7096
  });
7087
7097
  scope.get("/extend/list-uninstrumented", async (req, reply) => {
7088
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
7098
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
7089
7099
  if (!proj) return;
7090
7100
  if (!proj.scanPath) {
7091
7101
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -7098,7 +7108,7 @@ function registerRoutes(scope, ctx) {
7098
7108
  }
7099
7109
  });
7100
7110
  scope.get("/extend/lookup", async (req, reply) => {
7101
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
7111
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
7102
7112
  if (!proj) return;
7103
7113
  const { library, version } = req.query;
7104
7114
  if (!library) {
@@ -7111,7 +7121,7 @@ function registerRoutes(scope, ctx) {
7111
7121
  return result;
7112
7122
  });
7113
7123
  scope.get("/extend/describe", async (req, reply) => {
7114
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
7124
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
7115
7125
  if (!proj) return;
7116
7126
  if (!proj.scanPath) {
7117
7127
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -7124,7 +7134,7 @@ function registerRoutes(scope, ctx) {
7124
7134
  }
7125
7135
  });
7126
7136
  scope.post("/extend/apply", async (req, reply) => {
7127
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
7137
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
7128
7138
  if (!proj) return;
7129
7139
  if (!proj.scanPath) {
7130
7140
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -7144,7 +7154,7 @@ function registerRoutes(scope, ctx) {
7144
7154
  }
7145
7155
  });
7146
7156
  scope.post("/extend/dry-run", async (req, reply) => {
7147
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
7157
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
7148
7158
  if (!proj) return;
7149
7159
  if (!proj.scanPath) {
7150
7160
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -7164,7 +7174,7 @@ function registerRoutes(scope, ctx) {
7164
7174
  }
7165
7175
  });
7166
7176
  scope.post("/extend/rollback", async (req, reply) => {
7167
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
7177
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
7168
7178
  if (!proj) return;
7169
7179
  if (!proj.scanPath) {
7170
7180
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -7227,7 +7237,8 @@ async function buildApi(opts) {
7227
7237
  errorsPathFor,
7228
7238
  staleEventsPathFor,
7229
7239
  policyFilePathFor,
7230
- bootstrap: opts.bootstrap
7240
+ bootstrap: opts.bootstrap,
7241
+ singleProject: opts.singleProject?.name
7231
7242
  };
7232
7243
  app.get("/health", async () => {
7233
7244
  const uptimeMs = Date.now() - startedAt;
@@ -7250,10 +7261,29 @@ async function buildApi(opts) {
7250
7261
  return {
7251
7262
  ok: true,
7252
7263
  uptimeMs,
7264
+ // ADR-096 §7 — a per-project daemon carries its project at the top level
7265
+ // so a spawn can confirm a daemon found on a reused port is serving THIS
7266
+ // project before reusing it. The legacy multi-project daemon omits it and
7267
+ // is matched against the `projects` array instead.
7268
+ ...opts.singleProject ? { project: opts.singleProject.name } : {},
7253
7269
  projects
7254
7270
  };
7255
7271
  });
7256
7272
  app.get("/projects", async (_req, reply) => {
7273
+ if (opts.singleProject) {
7274
+ const phase = opts.bootstrap?.status(opts.singleProject.name);
7275
+ const entry2 = {
7276
+ name: opts.singleProject.name,
7277
+ path: opts.singleProject.path,
7278
+ registeredAt: new Date(startedAt).toISOString(),
7279
+ languages: [],
7280
+ // The daemon is serving this project, so it's active unless its
7281
+ // bootstrap broke. ('bootstrapping' still reads as active — the project
7282
+ // is the one to land on; its graph route answers 503 until ready.)
7283
+ status: phase === "broken" ? "broken" : "active"
7284
+ };
7285
+ return [entry2];
7286
+ }
7257
7287
  try {
7258
7288
  return await listProjects();
7259
7289
  } catch (err) {