From 2f18798eeb266db2f1298cb89b352f30f94aee62 Mon Sep 17 00:00:00 2001 From: Mario Aldayuz Date: Sun, 5 Jul 2026 09:14:42 -0400 Subject: [PATCH] Fix nectar dormancy: live-reload projects/credentials so post-boot bindings activate without restart - Wire the active-projects supervisor unconditionally and re-resolve ~/.deeplake/credentials.json + projects.json on the reconcile tick (LiveActiveProjects), so a project bound (or a login that lands) after the daemon booted activates brooding without a manual restart. Previously the daemon snapshotted tenancy at boot and, when credentials were absent at boot, never armed the supervisor at all (the "no active projects" dormancy). Reads stay fail-soft (a transient rewrite retries next tick). - Deferred (flagged): the primary-scoped enricher loop and /api/hive-graph API still read the boot credentials, so a late login leaves those two subsystems dormant until restart. Co-Authored-By: Claude Opus 4.8 --- src/cli.ts | 104 +++++++---- src/hive-graph/live-active-projects.ts | Bin 0 -> 7937 bytes test/live-active-projects.test.ts | 233 +++++++++++++++++++++++++ 3 files changed, 299 insertions(+), 38 deletions(-) create mode 100644 src/hive-graph/live-active-projects.ts create mode 100644 test/live-active-projects.test.ts diff --git a/src/cli.ts b/src/cli.ts index ed87000..f75b866 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -62,6 +62,7 @@ import type { HiveGraphStore, AsyncHiveGraphStore } from "./hive-graph/store.js" import { DeepLakeHiveGraphStore } from "./hive-graph/deeplake-store.js"; import { InMemoryHiveGraphStore } from "./hive-graph/memory-store.js"; import { loadDeepLakeCredentials, credentialsPath } from "./hive-graph/deeplake-credentials.js"; +import { LiveActiveProjects } from "./hive-graph/live-active-projects.js"; import { broodPrereqsFromEnv, formatFirstRunGuidance } from "./brood-prereqs.js"; import { resolveNectarTunables } from "./config-file.js"; import { resolveProjectScope, type ProjectScopeSource } from "./hive-graph/project-scope.js"; @@ -1303,33 +1304,56 @@ async function runDaemon(): Promise { // factory stands up one brood + watch context per active project, each rooted // at its own bound path and scoped to its own tenancy project id. Live brood // (describe) deps are attached only when Portkey is configured. - const controlOptions: ProjectsControlOptions = - creds !== undefined ? { expect: { org: creds.orgId, workspace: creds.workspaceId } } : {}; - const broodDeps = - creds !== undefined && portkey.enabled - ? { portkey: portkey as PortkeyEnabled, embedProvider, embedModelId: embedModel } - : undefined; - - const activeProjects = - durableStore !== undefined && creds !== undefined - ? { - resolve: () => readActiveProjects(controlOptions).resolution, - factory: (project: ResolvedProject): RunningContext => - createProjectContext({ - project, - tenancy: { orgId: creds.orgId, workspaceId: creds.workspaceId, projectId: project.projectId }, - store: durableStore, - ...(broodDeps !== undefined ? { broodDeps } : {}), - log: (line) => process.stderr.write(`${JSON.stringify({ ts: new Date().toISOString(), ...line })}\n`), - }), - } - : undefined; + // + // W1-N remediation (live-reload of bindings/credentials without a restart): + // the seam is wired UNCONDITIONALLY (not gated on a boot-time credentials + // snapshot) and delegates to `LiveActiveProjects`, which re-resolves + // `~/.deeplake/credentials.json` on EVERY reconcile tick. So a daemon that + // booted BEFORE login, then had a project bound afterward, activates that + // project on its next tick (or immediately, when the credentials watch fires a + // reconcile the moment credentials appear) - no daemon restart. The tenancy + // `expect` guard is derived fresh from the currently-resolved credentials, so + // it is never frozen at boot. Absent creds -> an empty resolution (dormant). + const liveControlOptions: Omit = {}; + const liveActive = new LiveActiveProjects({ + loadCredentials: () => { + try { + return loadDeepLakeCredentials(); + } catch { + return undefined; + } + }, + createStore: (credentials) => new DeepLakeHiveGraphStore({ credentials }), + buildContext: ({ project, credentials, store }): RunningContext => + createProjectContext({ + project, + tenancy: { orgId: credentials.orgId, workspaceId: credentials.workspaceId, projectId: project.projectId }, + store, + ...(portkey.enabled + ? { broodDeps: { portkey: portkey as PortkeyEnabled, embedProvider, embedModelId: embedModel } } + : {}), + log: (line) => process.stderr.write(`${JSON.stringify({ ts: new Date().toISOString(), ...line })}\n`), + }), + controlOptions: liveControlOptions, + onError: (err) => + process.stderr.write( + `nectar daemon: active-project resolve failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`, + ), + }); + const activeProjects = { + resolve: () => liveActive.resolve(), + factory: (project: ResolvedProject): RunningContext => liveActive.factory(project), + }; - // The daemon-level enricher stays scoped to the PRIMARY active project at boot - // (the common single-project case); per-project enrichment for additional - // active projects is a documented follow-up. Absent creds/Portkey/active set - // -> the enricher stays dormant (honest). - const primary = durableStore !== undefined ? readActiveProjects(controlOptions).resolution.active[0] : undefined; + // The daemon-level enricher + hive-graph API stay scoped to the PRIMARY active + // project resolved at boot (the common single-project case); per-project + // enrichment for additional active projects is a documented follow-up. These + // read the BOOT-time tenancy snapshot (creds at boot); the multi-root brood + + // watch supervisor above is the credentials-live path that removes the dormancy + // bug. Absent creds/Portkey/active set -> the enricher stays dormant (honest). + const bootControlOptions: ProjectsControlOptions = + creds !== undefined ? { expect: { org: creds.orgId, workspace: creds.workspaceId } } : {}; + const primary = durableStore !== undefined ? readActiveProjects(bootControlOptions).resolution.active[0] : undefined; const primaryTenancy: Tenancy | undefined = primary !== undefined && creds !== undefined ? { orgId: creds.orgId, workspaceId: creds.workspaceId, projectId: primary.projectId } @@ -1348,13 +1372,17 @@ async function runDaemon(): Promise { // pre-login fleet posture is honest; the watch below flips it healthy the // moment credentials appear (a-AC-2), no restart needed. // - // ACCEPTED DEVIATION (W1-N, ledger 2026-07-04): credentials appearing after - // boot flip /health healthy and reconcile active projects, but the - // creds-dependent subsystems assembled above from this boot-time snapshot - // (the durable store, brood wiring, enricher loop) are NOT hot-attached; - // they stay dormant until the next daemon restart. Same posture as - // honeycomb. If hot-attach ever lands, it belongs in the credentialsWatch - // onChange path in daemon.ts. + // W1-N remediation (live-reload without a daemon restart): the multi-root + // brood + watch supervisor (the `activeProjects` seam below) now re-resolves + // credentials + `projects.json` on EVERY reconcile tick via + // `LiveActiveProjects`, so credentials appearing after boot (and any project + // bound afterward) DO hot-attach - the daemon starts brooding/watching the + // newly-active project on its next tick, or immediately when the credentials + // watch fires `reconcileActiveProjects`. Remaining boot-snapshot scope: the + // PRIMARY-project enricher loop + hive-graph API assembled below still read + // the boot-time creds (a documented single-project follow-up), so those two + // subsystems alone stay dormant until the next restart when creds arrive + // late. The dormancy-until-restart bug (no brooding at all) is fixed. storageCredentialsPresent: creds !== undefined, credentialsWatch: { probe: () => { @@ -1366,7 +1394,7 @@ async function runDaemon(): Promise { } }, }, - ...(activeProjects !== undefined ? { activeProjects } : {}), + activeProjects, ...(live !== undefined && primaryTenancy !== undefined && primary !== undefined ? { tenancy: primaryTenancy, @@ -1400,17 +1428,17 @@ async function runDaemon(): Promise { // PRD-019b: the projects + brooding-control endpoints (consumed by Hive's // dashboard). Persisting a toggle triggers an immediate active-set reconcile. try { - if (activeProjects !== undefined) { + { mountProjectsApi(daemon, { view: () => { - const { resolution, cache } = readActiveProjects(controlOptions); + const { resolution, cache } = readActiveProjects(bootControlOptions); return buildProjectsView(resolution, cache, (id) => daemonWatcherState(daemon, id)); }, setProject: (projectId, brooding) => { - persistProjectBrooding(projectId, brooding, controlOptions); + persistProjectBrooding(projectId, brooding, bootControlOptions); }, setGlobal: (global) => { - persistGlobalBrooding(global, controlOptions); + persistGlobalBrooding(global, bootControlOptions); }, reconcile: () => daemon.reconcileActiveProjects(), // b-AC-6: the HTTP body carries only this path + a stable reason; the diff --git a/src/hive-graph/live-active-projects.ts b/src/hive-graph/live-active-projects.ts new file mode 100644 index 0000000000000000000000000000000000000000..37660349305d582865cda900b0953b6de2145e5b GIT binary patch literal 7937 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 literal 0 HcmV?d00001 diff --git a/test/live-active-projects.test.ts b/test/live-active-projects.test.ts new file mode 100644 index 0000000..c370adb --- /dev/null +++ b/test/live-active-projects.test.ts @@ -0,0 +1,233 @@ +/** + * W1-N remediation: live-reload of project bindings + credentials WITHOUT a + * daemon restart. Proves the credentials-live active-project resolver + * (`LiveActiveProjects`) picks up a `projects.json` binding written AFTER "boot" + * on a subsequent resolve, and that the whole path stays dormant + fail-soft + * while credentials are absent. + * + * Hermetic: a temp `~/.deeplake` cache dir + a temp brooding-state dir + an + * injected (mutable) credentials seam. No real home, no network, no fs.watch, + * no real sleeps - the "next tick" is just a second `resolve()` call. Runs + * against the compiled `dist/` output. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LiveActiveProjects } from "../dist/hive-graph/live-active-projects.js"; +import { assembleDaemon } from "../dist/index.js"; +import type { DeepLakeCredentials } from "../dist/hive-graph/deeplake-credentials.js"; +import type { AsyncHiveGraphStore } from "../dist/hive-graph/store.js"; +import type { ResolvedProject } from "../dist/hive-graph/active-projects.js"; +import type { RunningContext } from "../dist/project-supervisor.js"; +import { get } from "node:http"; + +const CREDS: DeepLakeCredentials = { + apiUrl: "https://api.deeplake.test", + token: "tok-abcd", + orgId: "org-1", + workspaceId: "ws-1", +}; + +/** A trivial async store stand-in (the resolver never queries it; the supervisor holds it). */ +const fakeStore = {} as unknown as AsyncHiveGraphStore; + +/** Write a schema-v1 projects.json binding `path -> projectId` into `dir/projects.json`. */ +function writeProjectsCache(dir: string, org: string, workspace: string, bindings: Array<{ path: string; projectId: string }>): void { + writeFileSync( + join(dir, "projects.json"), + JSON.stringify({ schemaVersion: 1, org, workspace, bindings, projects: [] }), + "utf8", + ); +} + +function tempDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +test("W1-N: a projects.json binding written AFTER boot is picked up on the next resolve (no restart)", () => { + const cacheDir = tempDir("nectar-live-cache-"); + const stateDir = tempDir("nectar-live-state-"); + const builtFor: string[] = []; + // Credentials are PRESENT here (the daemon booted after login); the point of + // this test is that a BINDING added after boot activates without a restart. + const live = new LiveActiveProjects({ + loadCredentials: () => CREDS, + createStore: () => fakeStore, + buildContext: ({ project }): RunningContext => { + builtFor.push(project.projectId); + return { projectId: project.projectId, path: project.path, watcherState: () => "running", start: async () => {}, stop: async () => {} }; + }, + controlOptions: { cacheDir, broodingState: { dir: stateDir } }, + }); + + try { + // Boot: no bindings yet -> dormant. + writeProjectsCache(cacheDir, CREDS.orgId, CREDS.workspaceId, []); + assert.equal(live.resolve().active.length, 0, "no bindings => nothing active at boot"); + + // A project is bound AFTER boot (honeycomb writes projects.json). + writeProjectsCache(cacheDir, CREDS.orgId, CREDS.workspaceId, [{ path: "/work/repo-a", projectId: "proj-a" }]); + + // The NEXT resolve (the reconcile loop's next tick) sees the new binding. + const after = live.resolve(); + assert.equal(after.active.length, 1, "the post-boot binding is now active"); + assert.equal(after.active[0].projectId, "proj-a"); + assert.equal(after.active[0].path, "/work/repo-a"); + + // The factory builds a context for it against the live credentials. + const ctx = live.factory(after.active[0]); + assert.equal(ctx.projectId, "proj-a"); + assert.deepEqual(builtFor, ["proj-a"]); + } finally { + rmSync(cacheDir, { recursive: true, force: true }); + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("W1-N: with credentials absent the resolver is dormant and fail-soft (empty resolution, factory is a no-op)", async () => { + const cacheDir = tempDir("nectar-live-cache-"); + const stateDir = tempDir("nectar-live-state-"); + let present = false; + let buildContextCalls = 0; + const live = new LiveActiveProjects({ + loadCredentials: () => (present ? CREDS : undefined), + createStore: () => fakeStore, + buildContext: (): RunningContext => { + buildContextCalls += 1; + throw new Error("buildContext must not be reached while credentials are absent"); + }, + controlOptions: { cacheDir, broodingState: { dir: stateDir } }, + }); + + try { + // A binding exists on disk, but credentials are ABSENT -> dormant regardless. + writeProjectsCache(cacheDir, CREDS.orgId, CREDS.workspaceId, [{ path: "/work/repo-a", projectId: "proj-a" }]); + assert.equal(live.resolve().active.length, 0, "no credentials => dormant even with a binding present"); + + // A factory reached without creds (a race) yields a no-op context, never a throw. + const ctx = live.factory({ projectId: "proj-a", path: "/work/repo-a", brooding: "active" }); + assert.equal(buildContextCalls, 0, "buildContext is skipped without credentials"); + assert.equal(ctx.watcherState(), "stopped"); + await ctx.start(); // must not throw + await ctx.stop(); + + // Credentials appear (login lands): the SAME resolver now activates the binding. + present = true; + const after = live.resolve(); + assert.equal(after.active.length, 1, "credentials appearing activates the already-present binding"); + assert.equal(after.active[0].projectId, "proj-a"); + } finally { + rmSync(cacheDir, { recursive: true, force: true }); + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("W1-N: a mismatched-tenancy cache reads empty (the expect guard is derived from the live credentials)", () => { + const cacheDir = tempDir("nectar-live-cache-"); + const stateDir = tempDir("nectar-live-state-"); + const live = new LiveActiveProjects({ + loadCredentials: () => CREDS, // org-1 / ws-1 + createStore: () => fakeStore, + buildContext: ({ project }): RunningContext => ({ + projectId: project.projectId, path: project.path, watcherState: () => "running", start: async () => {}, stop: async () => {}, + }), + controlOptions: { cacheDir, broodingState: { dir: stateDir } }, + }); + try { + // Cache synced for a DIFFERENT tenancy -> the guard drops it (wrong projects). + writeProjectsCache(cacheDir, "org-OTHER", "ws-OTHER", [{ path: "/work/repo-a", projectId: "proj-a" }]); + assert.equal(live.resolve().active.length, 0, "a tenancy-mismatched cache reads empty"); + + // Re-synced for the live tenancy -> activates (no restart, no reconstruction). + writeProjectsCache(cacheDir, CREDS.orgId, CREDS.workspaceId, [{ path: "/work/repo-a", projectId: "proj-a" }]); + assert.equal(live.resolve().active.length, 1, "the correctly-scoped cache activates"); + } finally { + rmSync(cacheDir, { recursive: true, force: true }); + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +// ── Integration: the daemon's reconcile loop drives LiveActiveProjects ──────── + +function fetchHealth(port: number): Promise<{ status: number; body: any }> { + return new Promise((resolve, reject) => { + get({ host: "127.0.0.1", port, path: "/health" }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) })); + }).on("error", reject); + }); +} + +/** A timer that captures scheduled callbacks without firing them (reconcile is driven explicitly). */ +function inertTimer() { + return { set: (_fn: () => void, _ms: number) => 1, clear: (_h: unknown) => {} }; +} + +test("W1-N integration: a daemon booted dormant activates a post-boot binding on a driven reconcile tick", async () => { + const runtimeDir = tempDir("nectar-live-daemon-"); + const cacheDir = tempDir("nectar-live-cache-"); + const stateDir = tempDir("nectar-live-state-"); + const events: string[] = []; + // Credentials appear AFTER boot (login lands late). + let present = false; + + const live = new LiveActiveProjects({ + loadCredentials: () => (present ? CREDS : undefined), + createStore: () => fakeStore, + buildContext: ({ project }): RunningContext => { + let state: "stopped" | "running" = "stopped"; + return { + projectId: project.projectId, + path: project.path, + watcherState: () => state, + start: async () => { + events.push(`start:${project.projectId}`); + state = "running"; + }, + stop: async () => { + events.push(`stop:${project.projectId}`); + state = "stopped"; + }, + }; + }, + controlOptions: { cacheDir, broodingState: { dir: stateDir } }, + }); + + const daemon = assembleDaemon({ + port: 0, + runtimeDir, + log: () => {}, + activeProjects: { + resolve: () => live.resolve(), + factory: (p: ResolvedProject) => live.factory(p), + timer: inertTimer(), + }, + }); + + try { + const port = await daemon.start(); + // Dormant at boot: no credentials on disk yet. + assert.equal((await fetchHealth(port)).body.activeProjects.count, 0); + + // Login lands and honeycomb binds a project (both appear on disk). + present = true; + writeProjectsCache(cacheDir, CREDS.orgId, CREDS.workspaceId, [{ path: "/work/repo-a", projectId: "proj-a" }]); + + // The reconcile loop's next tick (driven explicitly) activates the project - + // no daemon restart. + await daemon.reconcileActiveProjects(); + assert.deepEqual(events, ["start:proj-a"]); + const health = await fetchHealth(port); + assert.equal(health.body.activeProjects.count, 1); + assert.equal(health.body.activeProjects.projects[0].projectId, "proj-a"); + assert.equal(health.body.activeProjects.projects[0].watcher, "running"); + } finally { + await daemon.shutdown(); + rmSync(runtimeDir, { recursive: true, force: true }); + rmSync(cacheDir, { recursive: true, force: true }); + rmSync(stateDir, { recursive: true, force: true }); + } +});