diff --git a/packages/cli/src/__tests__/bundle-output-helpers.test.ts b/packages/cli/src/__tests__/bundle-output-helpers.test.ts index 83bc9d32b9..5d42cbb999 100644 --- a/packages/cli/src/__tests__/bundle-output-helpers.test.ts +++ b/packages/cli/src/__tests__/bundle-output-helpers.test.ts @@ -2,79 +2,180 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const state = { existingPaths: new Set(), + files: new Map(), + mtimes: new Map(), indexHtml: "", }; vi.mock("node:fs", () => ({ - existsSync: (path: string) => state.existingPaths.has(path), - readFileSync: () => state.indexHtml, - execFileSync: vi.fn(), - execSync: vi.fn(), + existsSync: (path: string) => state.existingPaths.has(path) || state.files.has(path), + readFileSync: (path: string) => { + if (state.files.has(path)) return state.files.get(path)!; + if (state.existingPaths.has(path)) return state.indexHtml; + const error = new Error(`ENOENT: ${path}`) as Error & { code: string }; + error.code = "ENOENT"; + throw error; + }, + mkdirSync: (path: string) => { + if (state.existingPaths.has(path) || state.files.has(path)) { + const error = new Error(`EEXIST: ${path}`) as Error & { code: string }; + error.code = "EEXIST"; + throw error; + } + state.existingPaths.add(path); + state.mtimes.set(path, 0); + }, + writeFileSync: (path: string, content: string) => { + state.files.set(path, content); + }, + renameSync: (from: string, to: string) => { + state.files.set(to, state.files.get(from)!); + state.files.delete(from); + }, + rmSync: (path: string) => { + for (const existing of [...state.existingPaths]) { + if (existing === path || existing.startsWith(`${path}/`)) state.existingPaths.delete(existing); + } + for (const file of [...state.files.keys()]) { + if (file === path || file.startsWith(`${path}/`)) state.files.delete(file); + } + }, + statSync: (path: string) => ({ mtimeMs: state.mtimes.get(path) ?? 0 }), })); import { - bundlePath, + BUILD_LOCK_STALE_MS, + buildCliWithRealDashboardAssets, clientIndexPath, dashboardClientStubMarker, - droidPluginMcpServerPath, hasBuiltDashboardAssets, - openclawMcpSchemaServerPath, + requiredBuildAssetPaths, } from "./bundle-output-helpers"; -const cursorPluginManifestPath = bundlePath.replace( - "dist/bin.js", - "dist/plugins/fusion-plugin-cursor-runtime/manifest.json", -); -const roadmapPluginBundledPath = bundlePath.replace( - "dist/bin.js", - "dist/plugins/fusion-plugin-roadmap/bundled.js", -); +function addAllAssets() { + for (const path of requiredBuildAssetPaths) state.existingPaths.add(path); + state.files.set(clientIndexPath, state.indexHtml); +} +/** + * FNXC:TestInfrastructure 2026-07-16-09:10: + * The fake filesystem plus awaitable waitTick drives every build-lock branch + * deterministically. Loaded-lane parallelism is only a symptom check, never + * the proof that a waiter preserves live ownership or recovers stale holders. + */ describe("hasBuiltDashboardAssets", () => { beforeEach(() => { state.existingPaths.clear(); + state.files.clear(); + state.mtimes.clear(); state.indexHtml = ""; }); - it("returns false when openclaw mcp-schema-server.cjs is missing", () => { - state.existingPaths.add(bundlePath); - state.existingPaths.add(clientIndexPath); - state.existingPaths.add(cursorPluginManifestPath); - state.existingPaths.add(roadmapPluginBundledPath); - - expect(hasBuiltDashboardAssets()).toBe(false); - }); - - it("returns false when droid mcp-schema-server.cjs is missing", () => { - state.existingPaths.add(bundlePath); - state.existingPaths.add(clientIndexPath); - state.existingPaths.add(cursorPluginManifestPath); - state.existingPaths.add(roadmapPluginBundledPath); - state.existingPaths.add(openclawMcpSchemaServerPath); + it("returns false when a required asset is missing", () => { + addAllAssets(); + state.existingPaths.delete(requiredBuildAssetPaths.at(-1)!); expect(hasBuiltDashboardAssets()).toBe(false); }); it("returns true when all required assets exist and dashboard stub marker is absent", () => { - state.existingPaths.add(bundlePath); - state.existingPaths.add(clientIndexPath); - state.existingPaths.add(cursorPluginManifestPath); - state.existingPaths.add(roadmapPluginBundledPath); - state.existingPaths.add(openclawMcpSchemaServerPath); - state.existingPaths.add(droidPluginMcpServerPath); + addAllAssets(); expect(hasBuiltDashboardAssets()).toBe(true); }); it("returns false when dashboard client index contains stub marker", () => { - state.existingPaths.add(bundlePath); - state.existingPaths.add(clientIndexPath); - state.existingPaths.add(cursorPluginManifestPath); - state.existingPaths.add(roadmapPluginBundledPath); - state.existingPaths.add(openclawMcpSchemaServerPath); - state.existingPaths.add(droidPluginMcpServerPath); state.indexHtml = dashboardClientStubMarker; + addAllAssets(); expect(hasBuiltDashboardAssets()).toBe(false); }); }); + +describe("buildCliWithRealDashboardAssets lock protocol", () => { + beforeEach(() => { + state.existingPaths.clear(); + state.files.clear(); + state.mtimes.clear(); + }); + + it("winner writes owner metadata, builds, and releases its lock", async () => { + const lockDir = "/locks/winner"; + let assetsReady = false; + const build = vi.fn(async () => { assetsReady = true; }); + + await buildCliWithRealDashboardAssets({ lockDir, hasAssets: () => assetsReady, build, now: () => 10 }); + + expect(build).toHaveBeenCalledTimes(1); + expect(state.files.get(`${lockDir}/owner.json`)).toBeUndefined(); + expect(state.existingPaths.has(lockDir)).toBe(false); + }); + + it("waits for a live owner, then reads completed assets without reclaiming or rebuilding", async () => { + const lockDir = "/locks/live-owner"; + let assetsReady = false; + state.existingPaths.add(lockDir); + state.files.set(`${lockDir}/owner.json`, JSON.stringify({ pid: 99, acquiredAt: 100 })); + state.mtimes.set(lockDir, 100); + const build = vi.fn(); + const waitTick = vi.fn(async () => { + assetsReady = true; + state.existingPaths.delete(lockDir); + state.files.delete(`${lockDir}/owner.json`); + }); + + await buildCliWithRealDashboardAssets({ + lockDir, + hasAssets: () => assetsReady, + build, + now: () => 101, + isProcessAlive: (pid) => pid === 99, + statMtimeMs: () => 100, + waitTick, + }); + + expect(waitTick).toHaveBeenCalledTimes(1); + expect(build).not.toHaveBeenCalled(); + }); + + it("atomically reclaims a dead owner past the stale threshold and rebuilds", async () => { + const lockDir = "/locks/dead-owner"; + let assetsReady = false; + state.existingPaths.add(lockDir); + state.files.set(`${lockDir}/owner.json`, JSON.stringify({ pid: 404, acquiredAt: 0 })); + state.mtimes.set(lockDir, 0); + const build = vi.fn(async () => { assetsReady = true; }); + + await buildCliWithRealDashboardAssets({ + lockDir, + hasAssets: () => assetsReady, + build, + now: () => BUILD_LOCK_STALE_MS + 1, + isProcessAlive: () => false, + statMtimeMs: () => 0, + }); + + expect(build).toHaveBeenCalledTimes(1); + expect(state.existingPaths.has(lockDir)).toBe(false); + }); + + it("reclaims missing owner metadata using the stale directory mtime fallback", async () => { + const lockDir = "/locks/missing-owner"; + let assetsReady = false; + state.existingPaths.add(lockDir); + state.mtimes.set(lockDir, 0); + const build = vi.fn(async () => { assetsReady = true; }); + + await buildCliWithRealDashboardAssets({ + lockDir, + hasAssets: () => assetsReady, + build, + now: () => BUILD_LOCK_STALE_MS + 1, + isProcessAlive: () => true, + statMtimeMs: () => 0, + }); + + expect(build).toHaveBeenCalledTimes(1); + expect(state.existingPaths.has(lockDir)).toBe(false); + }); +}); diff --git a/packages/cli/src/__tests__/bundle-output-helpers.ts b/packages/cli/src/__tests__/bundle-output-helpers.ts index af704fb26b..658400d7c3 100644 --- a/packages/cli/src/__tests__/bundle-output-helpers.ts +++ b/packages/cli/src/__tests__/bundle-output-helpers.ts @@ -1,5 +1,5 @@ import { execFileSync, execSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -44,6 +44,19 @@ export const droidPluginMcpServerPath = join( export const dashboardClientStubMarker = "Dashboard assets not built"; +export const requiredBuildAssetPaths = [ + bundlePath, + clientIndexPath, + cursorPluginManifestPath, + roadmapPluginBundledPath, + reportsPluginBundledPath, + cliPrintingPressPluginBundledPath, + whatsappChatPluginBundledPath, + compoundEngineeringSkillPath, + openclawMcpSchemaServerPath, + droidPluginMcpServerPath, +] as const; + function runBuildCommand(command: string, cwd: string) { const npmExecPath = process.env.npm_execpath; if (npmExecPath && existsSync(npmExecPath)) { @@ -63,98 +76,193 @@ function runBuildCommand(command: string, cwd: string) { } export function hasBuiltDashboardAssets(): boolean { - if ( - !existsSync(bundlePath) || - !existsSync(clientIndexPath) || - !existsSync(cursorPluginManifestPath) || - !existsSync(roadmapPluginBundledPath) || - !existsSync(reportsPluginBundledPath) || - !existsSync(cliPrintingPressPluginBundledPath) || - !existsSync(whatsappChatPluginBundledPath) || - !existsSync(compoundEngineeringSkillPath) || - !existsSync(openclawMcpSchemaServerPath) || - !existsSync(droidPluginMcpServerPath) - ) { + if (!requiredBuildAssetPaths.every(existsSync)) { return false; } return !readFileSync(clientIndexPath, "utf-8").includes(dashboardClientStubMarker); } -/** - * This suite verifies real copied dashboard client assets in CLI dist output. - * It must build those assets explicitly instead of skip-gating on ambient dist/. - * - * FNXC:TestInfrastructure 2026-07-13-12:20: - * bundle-output.test.ts and extension-integration.test.ts both call this helper, - * and Vitest runs test files in parallel (pool: "forks", fileParallelism: true). - * Without a cross-worker lock, two workers can simultaneously trigger vite/tsup - * builds that clean and write dist/client concurrently, causing ENOENT on - * content-hashed chunk files. The lock uses atomic mkdirSync — the winner builds, - * losers poll until the lock disappears then re-check hasBuiltDashboardAssets(). - */ -const buildLockDir = join(tmpdir(), "fusion-cli-build-assets.lock"); -const BUILD_LOCK_TIMEOUT_MS = 300_000; +function buildRealDashboardAssets(): void { + runBuildCommand(`node ${join(workspaceRoot, "scripts", "ensure-test-artifacts.mjs")}`, workspaceRoot); + runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot); + /* + * FNXC:BundledPlugins 2026-07-15-09:08: + * bundle-output tests assert the published CLI packaging surface, including staged bundled plugins and skill assets. Local `pnpm build` may use fast package mode, so bootstrap with `build:package` to force FUSION_CLI_FULL_PACKAGE and avoid reading stale raw-src plugin output from dist/. + */ + runBuildCommand("pnpm build:package", cliRoot); -export function buildCliWithRealDashboardAssets() { if (hasBuiltDashboardAssets()) { return; } - // Try to acquire the lock atomically. mkdirSync throws EEXIST if the dir exists. - let acquiredLock = false; - try { - mkdirSync(buildLockDir); - acquiredLock = true; - } catch { - // Another worker holds the lock — wait for it. - } + // Fallback for environments where build:client alone does not refresh the + // dashboard dist/client bundle consumed by the CLI copy step. + runBuildCommand("pnpm --filter @fusion/dashboard build", workspaceRoot); + runBuildCommand("pnpm build:package", cliRoot); +} - if (!acquiredLock) { - const deadline = Date.now() + BUILD_LOCK_TIMEOUT_MS; - while (existsSync(buildLockDir) && Date.now() < deadline) { - // Synchronous sleep without spawning a child process. - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500); +/** + * This suite verifies real copied dashboard client assets in CLI dist output. + * It must build those assets explicitly instead of skip-gating on ambient dist/. + * + * FNXC:TestInfrastructure 2026-07-16-09:10: + * Parallel Vitest workers share this lock while bundle-output and built-extension + * suites build CLI assets. A lock records its PID and acquisition time: waiters + * never reclaim a live or fresh owner, but recover a dead/invalid owner only + * after the stale threshold, preventing a crashed holder from wedging the lane. + */ +const buildLockDir = join(tmpdir(), "fusion-cli-build-assets.lock"); +const BUILD_LOCK_TIMEOUT_MS = 300_000; +export const BUILD_LOCK_STALE_MS = 60_000; +const BUILD_LOCK_OWNER_FILE = "owner.json"; +const BUILD_LOCK_RECLAIM_SUFFIX = ".reclaim"; + +interface BuildLockOwner { + pid: number; + acquiredAt: number; +} + +export interface BuildCliAssetsOptions { + /** Test-only isolated lock directory; production uses the cross-worker temp lock. */ + lockDir?: string; + hasAssets?: () => boolean; + build?: () => void | Promise; + now?: () => number; + isProcessAlive?: (pid: number) => boolean; + statMtimeMs?: (path: string) => number; + waitTick?: () => void | Promise; +} + +function readLockOwner(lockDir: string): BuildLockOwner | null { + try { + const parsed = JSON.parse(readFileSync(join(lockDir, BUILD_LOCK_OWNER_FILE), "utf8")) as Partial; + return typeof parsed.pid === "number" && Number.isSafeInteger(parsed.pid) && parsed.pid > 0 + && typeof parsed.acquiredAt === "number" && Number.isFinite(parsed.acquiredAt) + ? { pid: parsed.pid, acquiredAt: parsed.acquiredAt } + : null; + } catch { + return null; + } +} + +function defaultIsProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !(error && typeof error === "object" && "code" in error && error.code === "ESRCH"); + } +} + +async function defaultWaitTick(): Promise { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500); +} + +/** + * Build the dashboard and CLI package exactly once across Vitest workers. + * The optional options bag is a test-only seam for deterministic owner/waiter + * and stale-lock coverage; default behavior uses the shared real build lock. + */ +export async function buildCliWithRealDashboardAssets(options: BuildCliAssetsOptions = {}): Promise { + const lockDir = options.lockDir ?? buildLockDir; + const hasAssets = options.hasAssets ?? hasBuiltDashboardAssets; + const build = options.build ?? buildRealDashboardAssets; + const now = options.now ?? Date.now; + const isProcessAlive = options.isProcessAlive ?? defaultIsProcessAlive; + const statMtimeMs = options.statMtimeMs ?? ((path: string) => statSync(path).mtimeMs); + const waitTick = options.waitTick ?? defaultWaitTick; + const deadline = now() + BUILD_LOCK_TIMEOUT_MS; + + if (hasAssets()) return; + + const reclaimDir = `${lockDir}${BUILD_LOCK_RECLAIM_SUFFIX}`; + while (true) { + /* + * FNXC:TestInfrastructure 2026-07-16-09:25: + * The reclaim guard serializes stale-owner rechecks with acquisition, so a + * newly-created owner cannot be mistaken for and deleted as the stale one. + */ + if (existsSync(reclaimDir)) { + if (now() >= deadline) { + throw new Error( + `buildCliWithRealDashboardAssets: timed out after ${BUILD_LOCK_TIMEOUT_MS}ms waiting for stale-lock reclamation at ${lockDir}.`, + ); + } + await waitTick(); + continue; } - // The other worker should have finished building. Re-check assets. - if (hasBuiltDashboardAssets()) { - return; + + let acquiredLock = false; + try { + mkdirSync(lockDir); + acquiredLock = true; + const ownerPath = join(lockDir, BUILD_LOCK_OWNER_FILE); + const ownerTempPath = join(lockDir, `.owner-${process.pid}-${now()}.tmp`); + writeFileSync(ownerTempPath, JSON.stringify({ pid: process.pid, acquiredAt: now() }), "utf8"); + renameSync(ownerTempPath, ownerPath); + try { + await build(); + return; + } finally { + rmSync(lockDir, { recursive: true, force: true }); + } + } catch (error) { + /* + * FNXC:TestInfrastructure 2026-07-16-09:25: + * Only EEXIST from mkdir is normal contention. Build and owner-metadata + * failures must surface immediately rather than become waiter timeouts. + */ + if (acquiredLock || !(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) { + if (acquiredLock) rmSync(lockDir, { recursive: true, force: true }); + throw error; + } } - // Lock is still held after timeout — do NOT build without owning the lock - // (that reintroduces the concurrent-build race). Throw so the test fails loudly. - if (existsSync(buildLockDir)) { + + if (hasAssets()) return; + + const owner = readLockOwner(lockDir); + let ageMs: number; + try { + ageMs = now() - (owner?.acquiredAt ?? statMtimeMs(lockDir)); + } catch { + // FNXC:TestInfrastructure 2026-07-16-09:25: A winner can remove the directory between existsSync and statSync. + continue; + } + const stale = ageMs > BUILD_LOCK_STALE_MS && (!owner || !isProcessAlive(owner.pid)); + if (stale) { + try { + mkdirSync(reclaimDir); + } catch { + // FNXC:TestInfrastructure 2026-07-16-09:25: Another waiter is already rechecking the stale candidate. + continue; + } + try { + /* + * FNXC:TestInfrastructure 2026-07-16-09:25: + * Re-read under the reclaim guard because an acquirer can win just + * before it is created; that fresh owner must never be removed. + */ + const currentOwner = readLockOwner(lockDir); + const currentAge = now() - (currentOwner?.acquiredAt ?? statMtimeMs(lockDir)); + if (currentAge > BUILD_LOCK_STALE_MS && (!currentOwner || !isProcessAlive(currentOwner.pid))) { + rmSync(lockDir, { recursive: true, force: true }); + } + } catch { + // FNXC:TestInfrastructure 2026-07-16-09:25: The owner may have finished while we acquired the guard; retry normally. + } finally { + rmSync(reclaimDir, { recursive: true, force: true }); + } + continue; + } + + if (now() >= deadline) { throw new Error( - `buildCliWithRealDashboardAssets: timed out after ${BUILD_LOCK_TIMEOUT_MS}ms waiting for another worker's build lock at ${buildLockDir}. ` + - `If the other worker crashed, remove the lock dir manually and rerun.`, + `buildCliWithRealDashboardAssets: timed out after ${BUILD_LOCK_TIMEOUT_MS}ms waiting for another worker's build lock at ${lockDir}.`, ); } - // Lock disappeared but assets weren't built — try to acquire for our own build. - try { mkdirSync(buildLockDir); acquiredLock = true; } catch { - throw new Error(`buildCliWithRealDashboardAssets: could not acquire build lock at ${buildLockDir} after previous holder exited.`); - } - } - - try { - runBuildCommand(`node ${join(workspaceRoot, "scripts", "ensure-test-artifacts.mjs")}`, workspaceRoot); - runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot); - /* - * FNXC:BundledPlugins 2026-07-15-09:08: - * bundle-output tests assert the published CLI packaging surface, including staged bundled plugins and skill assets. Local `pnpm build` may use fast package mode, so bootstrap with `build:package` to force FUSION_CLI_FULL_PACKAGE and avoid reading stale raw-src plugin output from dist/. - */ - runBuildCommand("pnpm build:package", cliRoot); - - if (hasBuiltDashboardAssets()) { - return; - } - - // Fallback for environments where build:client alone does not refresh the - // dashboard dist/client bundle consumed by the CLI copy step. - runBuildCommand("pnpm --filter @fusion/dashboard build", workspaceRoot); - runBuildCommand("pnpm build:package", cliRoot); - } finally { - if (acquiredLock) { - rmSync(buildLockDir, { recursive: true, force: true }); - } + await waitTick(); + if (hasAssets()) return; } } diff --git a/packages/cli/src/__tests__/bundle-output.test.ts b/packages/cli/src/__tests__/bundle-output.test.ts index be45910bf2..28a9a1b4d0 100644 --- a/packages/cli/src/__tests__/bundle-output.test.ts +++ b/packages/cli/src/__tests__/bundle-output.test.ts @@ -79,11 +79,11 @@ function expectSelfContainedBundle(pluginId: typeof selfContainedBundlePluginIds } describe("CLI bundle output", () => { - beforeAll(() => { + beforeAll(async () => { // Intentional: bundle-output tests validate compiled artifacts, so they // perform their own explicit build bootstrap instead of relying on ambient // workspace dist/ state. - buildCliWithRealDashboardAssets(); + await buildCliWithRealDashboardAssets(); }, 300_000); it("dist/bin.js exists", () => { diff --git a/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts b/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts index 4db294f3de..39c1e16f2d 100644 --- a/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts +++ b/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts @@ -1,23 +1,21 @@ -import { describe, it, expect } from "vitest"; -import { mkdtemp, rm } from "node:fs/promises"; +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { AgentStore } from "@fusion/core"; -import kbExtension, { closeCachedStores } from "../extension.js"; +import { + createMockApi, + createPgExtensionHarness, + pgDescribe, + registerExtension, + requireTool, +} from "./pg-extension-harness"; -function createMockAPI() { - const tools = new Map(); - return { - registerTool(def: any) { - tools.set(def.name, def); - }, - registerCommand() {}, - registerShortcut() {}, - registerFlag() {}, - on() {}, - tools, - } as any; -} +/* +FNXC:PostgresCutover 2026-07-16-08:45: +Instruction-tool fixtures seed the PG TaskStore cache injected for the extension, +so its AgentStore avoids the removed SQLite path and tools observe the same data. +*/ + +const h = createPgExtensionHarness("extension-agent-set-instructions"); async function withOrg( run: (ctx: { @@ -27,8 +25,11 @@ async function withOrg( ids: { manager: string; middle: string; leaf: string; peer: string }; }) => Promise, ): Promise { - const cwd = await mkdtemp(join(tmpdir(), "fn-ext-agent-instructions-")); - const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion") }); + const cwd = h.rootDir(); + const agentStore = new AgentStore({ + rootDir: join(cwd, ".fusion"), + asyncLayer: h.store().getAsyncLayer()!, + }); try { await agentStore.init(); const manager = await agentStore.createAgent({ name: "manager", role: "engineer", metadata: {} }); @@ -46,10 +47,9 @@ async function withOrg( }); const peer = await agentStore.createAgent({ name: "peer-agent", role: "executor", metadata: {} }); - const api = createMockAPI(); - kbExtension(api); - const tool = api.tools.get("fn_agent_set_instructions"); - expect(tool).toBeTruthy(); + const api = createMockApi(); + registerExtension(api); + const tool = requireTool(api, "fn_agent_set_instructions"); await run({ cwd, @@ -58,13 +58,15 @@ async function withOrg( ids: { manager: manager.id, middle: middle.id, leaf: leaf.id, peer: peer.id }, }); } finally { - await closeCachedStores(); agentStore.close(); - await rm(cwd, { recursive: true, force: true }); } } -describe("fn_agent_set_instructions", () => { +pgDescribe("fn_agent_set_instructions", () => { + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); it("allows a manager to set inline instructions for a direct report", async () => { await withOrg(async ({ cwd, tool, agentStore, ids }) => { const result = await tool.execute( diff --git a/packages/cli/src/__tests__/extension-agent-update.test.ts b/packages/cli/src/__tests__/extension-agent-update.test.ts index 1b6f64a16b..6894a9b0fe 100644 --- a/packages/cli/src/__tests__/extension-agent-update.test.ts +++ b/packages/cli/src/__tests__/extension-agent-update.test.ts @@ -1,23 +1,22 @@ -import { describe, it, expect, vi } from "vitest"; -import { mkdtemp, rm } from "node:fs/promises"; +import { beforeAll, beforeEach, afterEach, afterAll, expect, it, vi } from "vitest"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { AgentStore } from "@fusion/core"; -import kbExtension, { closeCachedStores } from "../extension.js"; +import { + createMockApi, + createPgExtensionHarness, + pgDescribe, + registerExtension, + requireTool, +} from "./pg-extension-harness"; -function createMockAPI() { - const tools = new Map(); - return { - registerTool(def: any) { - tools.set(def.name, def); - }, - registerCommand() {}, - registerShortcut() {}, - registerFlag() {}, - on() {}, - tools, - } as any; -} +/* +FNXC:PostgresCutover 2026-07-16-08:45: +Agent-update tool fixtures must seed the exact PG TaskStore injected into the +extension cache. Bare AgentStore construction selected the removed SQLite path +and a separate backend would make seeded agents invisible to extension tools. +*/ + +const h = createPgExtensionHarness("extension-agent-update"); async function withOrg( run: (ctx: { @@ -35,8 +34,11 @@ async function withOrg( }; }) => Promise, ): Promise { - const cwd = await mkdtemp(join(tmpdir(), "fn-ext-agent-update-")); - const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion") }); + const cwd = h.rootDir(); + const agentStore = new AgentStore({ + rootDir: join(cwd, ".fusion"), + asyncLayer: h.store().getAsyncLayer()!, + }); try { await agentStore.init(); const manager = await agentStore.createAgent({ name: "manager", role: "engineer", metadata: {} }); @@ -66,12 +68,10 @@ async function withOrg( metadata: { agentKind: "task-worker" }, }); - const api = createMockAPI(); - kbExtension(api); - const tool = api.tools.get("fn_agent_update"); - const setInstructionsTool = api.tools.get("fn_agent_set_instructions"); - expect(tool).toBeTruthy(); - expect(setInstructionsTool).toBeTruthy(); + const api = createMockApi(); + registerExtension(api); + const tool = requireTool(api, "fn_agent_update"); + const setInstructionsTool = requireTool(api, "fn_agent_set_instructions"); await run({ cwd, @@ -88,13 +88,15 @@ async function withOrg( }, }); } finally { - await closeCachedStores(); agentStore.close(); - await rm(cwd, { recursive: true, force: true }); } } -describe("fn_agent_update", () => { +pgDescribe("fn_agent_update", () => { + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); it("allows privileged operator calls to update config fields and preserve runtime keys", async () => { await withOrg(async ({ cwd, tool, agentStore, ids }) => { const updateSpy = vi.spyOn(AgentStore.prototype, "updateAgent"); diff --git a/packages/cli/src/__tests__/extension-integration.test.ts b/packages/cli/src/__tests__/extension-integration.test.ts index 398050dbc1..66309f9400 100644 --- a/packages/cli/src/__tests__/extension-integration.test.ts +++ b/packages/cli/src/__tests__/extension-integration.test.ts @@ -73,6 +73,7 @@ function makeCtx(cwd: string) { interface BuiltExtensionModule { default: (api: MockExtensionApi) => void; __setCachedStoreForTesting: (projectRoot: string, store: unknown) => void; + closeCachedStores: () => Promise; } async function importBuiltExtension(): Promise { @@ -111,7 +112,7 @@ pgDescribe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension inte let builtExtension: BuiltExtensionModule; beforeAll(async () => { - buildCliWithRealDashboardAssets(); + await buildCliWithRealDashboardAssets(); await h.beforeAll(); builtExtension = await importBuiltExtension(); extension = builtExtension.default; @@ -133,6 +134,13 @@ pgDescribe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension inte afterEach(async () => { const shutdown = api.events.get("session_shutdown"); if (shutdown) await shutdown(); + /* + * FNXC:PostgresCutover 2026-07-16-09:05: + * The bundle owns a separate module cache. Clear its externally-owned PG + * entry before the shared harness closes that store, even if the shutdown + * hook already cleared it, so no built-module cache leaks across tests. + */ + await builtExtension.closeCachedStores(); await h.afterEach(); }); @@ -272,14 +280,16 @@ pgDescribe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension inte }); /* - * FNXC:CliTests 2026-07-16-07:47: - * FN-8081 removes this SQLite-trigger collision reproduction. The opt-in - * built-extension fixture has no backend-supported allocator seam without - * changing FN-8097-owned AgentStore/build setup; FN-8100 restores this exact - * assertion through a PostgreSQL fixture. + * FNXC:PostgresCutover 2026-07-16-16:00: + * SQLite triggers were removed with the backend cutover. Inject the collision + * at the shared PostgreSQL TaskStore boundary so this built-extension test + * continues to prove the user-facing delegate error rather than skipping it. */ - it.skip("returns explicit error when fn_delegate_task hits task-id collision", async () => { + it("returns explicit error when fn_delegate_task hits task-id collision", async () => { const agent = await seedAgent(tmpDir, h.layer(), { name: "release-agent" }); + const createTask = vi + .spyOn(h.store(), "createTask") + .mockRejectedValueOnce(new Error("Task ID already exists: FN-001")); const delegateTool = api.tools.get("fn_delegate_task")!; const result = await delegateTool.execute( @@ -290,6 +300,7 @@ pgDescribe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension inte makeCtx(tmpDir), ); + expect(createTask).toHaveBeenCalledTimes(1); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Task ID already exists: FN-001"); expect(result.details.error).toContain("Task ID already exists: FN-001"); diff --git a/packages/cli/src/__tests__/task-plan.test.ts b/packages/cli/src/__tests__/task-plan.test.ts index 1a6f224bb9..1c5efc0484 100644 --- a/packages/cli/src/__tests__/task-plan.test.ts +++ b/packages/cli/src/__tests__/task-plan.test.ts @@ -1,39 +1,27 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -function makeConstructibleMock unknown>(impl?: T) { - const mock = vi.fn(function () {}); - const originalMockImplementation = mock.mockImplementation.bind(mock); - const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); - const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters) { - return nextImpl(...args); - }; - mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; - mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; - if (impl) { - mock.mockImplementation(impl); - } - return mock; -} - // Mock node:readline/promises before importing vi.mock("node:readline/promises", () => ({ createInterface: vi.fn(), })); -// Mock @fusion/core before importing +/* +FNXC:CliTests 2026-07-16-08:55: +Planning now resolves a ProjectContext through the backend factory rather than +constructing TaskStore directly. Derive the core module from its real export +surface and inject only the command's project-context seam. +*/ +const mockResolveProject = vi.fn(); +const mockCloseProjectStore = vi.fn(async () => undefined); + vi.mock("@fusion/core", async (importOriginal) => ({ ...(await importOriginal()), - TaskStore: makeConstructibleMock(), - COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"], - COLUMN_LABELS: { - triage: "Triage", - todo: "Todo", - "in-progress": "In Progress", - "in-review": "In Review", - done: "Done", - archived: "Archived", - }, - deterministicGuardLocks: new Map(), +})); + +vi.mock("../project-context.js", () => ({ + resolveProject: (...args: unknown[]) => mockResolveProject(...args), + createLocalStore: vi.fn(), + closeProjectStore: (...args: unknown[]) => mockCloseProjectStore(...args), })); // Mock @fusion/dashboard/planning @@ -62,7 +50,6 @@ vi.mock("@fusion/dashboard/planning", () => ({ // Import after mocking import { createInterface } from "node:readline/promises"; -import { TaskStore } from "@fusion/core"; import { createSession, submitResponse, RateLimitError, SessionNotFoundError } from "@fusion/dashboard/planning"; import { runTaskPlan } from "../commands/task.js"; @@ -92,17 +79,23 @@ describe("runTaskPlan", () => { }); function setupTaskStoreMock(overrides: Record = {}) { - (TaskStore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), + const store = { createTask: vi.fn().mockResolvedValue({ id: "FN-042", title: "Test Task Title", description: "Test description", column: "triage", dependencies: ["FN-001"], - ...overrides, }), - })); + ...overrides, + }; + mockResolveProject.mockResolvedValue({ + projectPath: "/test/project", + projectName: "test-project", + isRegistered: true, + store, + }); + return store; } it("prompts for initial plan when not provided", async () => { @@ -394,10 +387,7 @@ describe("runTaskPlan", () => { dependencies: ["FN-001"], }); - (TaskStore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - createTask: mockCreateTask, - })); + setupTaskStoreMock({ createTask: mockCreateTask }); (createSession as unknown as ReturnType).mockResolvedValueOnce({ sessionId: "test-session-123", @@ -554,10 +544,7 @@ describe("runTaskPlan", () => { it("skips task creation when user declines confirmation", async () => { const mockCreateTask = vi.fn().mockResolvedValue({ id: "FN-042" }); - (TaskStore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - createTask: mockCreateTask, - })); + setupTaskStoreMock({ createTask: mockCreateTask }); (createSession as unknown as ReturnType).mockResolvedValueOnce({ sessionId: "test-session-123", diff --git a/packages/cli/src/__tests__/task-steer.test.ts b/packages/cli/src/__tests__/task-steer.test.ts index 4bc8becb4c..3b353177db 100644 --- a/packages/cli/src/__tests__/task-steer.test.ts +++ b/packages/cli/src/__tests__/task-steer.test.ts @@ -1,44 +1,30 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -function makeConstructibleMock unknown>(impl?: T) { - const mock = vi.fn(function () {}); - const originalMockImplementation = mock.mockImplementation.bind(mock); - const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); - const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters) { - return nextImpl(...args); - }; - mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; - mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; - if (impl) { - mock.mockImplementation(impl); - } - return mock; -} - // Mock node:readline/promises before importing vi.mock("node:readline/promises", () => ({ createInterface: vi.fn(), })); -// Mock @fusion/core before importing +/* +FNXC:CliTests 2026-07-16-08:55: +Steering now writes through a resolved ProjectContext. Keep the real core export +shape and control only that boundary so the command cannot fall into a real store. +*/ +const mockResolveProject = vi.fn(); +const mockCloseProjectStore = vi.fn(async () => undefined); + vi.mock("@fusion/core", async (importOriginal) => ({ ...(await importOriginal()), - TaskStore: makeConstructibleMock(), - COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"], - COLUMN_LABELS: { - triage: "Triage", - todo: "Todo", - "in-progress": "In Progress", - "in-review": "In Review", - done: "Done", - archived: "Archived", - }, - deterministicGuardLocks: new Map(), +})); + +vi.mock("../project-context.js", () => ({ + resolveProject: (...args: unknown[]) => mockResolveProject(...args), + createLocalStore: vi.fn(), + closeProjectStore: (...args: unknown[]) => mockCloseProjectStore(...args), })); // Import after mocking import { createInterface } from "node:readline/promises"; -import { TaskStore } from "@fusion/core"; import { runTaskSteer } from "../commands/task.js"; describe("runTaskSteer", () => { @@ -65,11 +51,15 @@ describe("runTaskSteer", () => { }); function setupTaskStoreMock(overrides: Record = {}) { - (TaskStore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - addSteeringComment: mockAddComment, - ...overrides, - })); + mockResolveProject.mockResolvedValue({ + projectPath: "/test/project", + projectName: "test-project", + isRegistered: true, + store: { + addSteeringComment: mockAddComment, + ...overrides, + }, + }); } it("adds steering comment with message argument", async () => { diff --git a/packages/cli/src/commands/__tests__/agent-export.test.ts b/packages/cli/src/commands/__tests__/agent-export.test.ts index 4f8c3c901f..bbcd3d809c 100644 --- a/packages/cli/src/commands/__tests__/agent-export.test.ts +++ b/packages/cli/src/commands/__tests__/agent-export.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { createSharedPgTaskStoreTestHarness, pgDescribe } from "../../../../core/src/__test-utils__/pg-test-harness.js"; import { mkdirSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -6,10 +7,13 @@ import { tmpdir } from "node:os"; import { AgentStore } from "@fusion/core"; const mockResolveProject = vi.fn(); +const mockResolveAgentStoreBase = vi.fn(); +let activeProjectDir = ""; +let activeAsyncLayer: () => unknown = () => undefined; vi.mock("../../project-context.js", () => ({ - // FNXC:PostgresCutover 2026-07-10: branch agent commands resolve their AgentStore base (rootDir + asyncLayer) via this helper. - resolveAgentStoreBase: vi.fn(async () => ({ rootDir: process.cwd(), asyncLayer: {}, cleanup: vi.fn(async () => undefined) })), + // FNXC:PostgresCutover 2026-07-16-08:50: Agent export must use the real PG async layer, not an empty stub that would bypass persistence behavior. + resolveAgentStoreBase: (project?: string) => mockResolveAgentStoreBase(project), resolveProject: (...args: unknown[]) => mockResolveProject(...args), })); @@ -21,7 +25,15 @@ import { runAgentExport } from "../agent-export.js"; const SHOULD_RUN_SLOW_CLI = process.env.FUSION_TEST_SLOW_CLI === "1" || process.env.FUSION_TEST_SLOW_CLI === "true"; +/* +FNXC:PostgresCutover 2026-07-16-08:50: +The opt-in export suite still exercises real AgentStore persistence, now through a +shared PG harness. `pgDescribe` registers a clean skip when the external server is unavailable. +*/ +const h = createSharedPgTaskStoreTestHarness({ prefix: "agent-export" }); + describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", () => { + pgDescribe("postgres-backed export", () => { const tmpRoot = join(tmpdir(), `fn-agent-export-test-${process.pid}`); let projectDir: string; let outputDir: string; @@ -29,10 +41,20 @@ describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + beforeAll(h.beforeAll); + beforeEach(async () => { + await h.beforeEach(); vi.clearAllMocks(); projectDir = join(tmpRoot, `project-${Date.now()}-${Math.random().toString(16).slice(2)}`); + activeProjectDir = projectDir; + activeAsyncLayer = () => h.store().getAsyncLayer()!; + mockResolveAgentStoreBase.mockImplementation(async () => ({ + rootDir: activeProjectDir, + asyncLayer: activeAsyncLayer(), + cleanup: vi.fn(async () => undefined), + })); outputDir = join(projectDir, "exports", "company"); mkdirSync(projectDir, { recursive: true }); @@ -46,17 +68,22 @@ describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", () => { }); }); - afterEach(() => { + afterEach(async () => { rmSync(projectDir, { recursive: true, force: true }); + await h.afterEach(); }); - afterAll(() => { + afterAll(async () => { logSpy.mockRestore(); errorSpy.mockRestore(); + await h.afterAll(); }); async function seedAgents(): Promise { - const store = new AgentStore({ rootDir: join(projectDir, ".fusion") }); + const store = new AgentStore({ + rootDir: join(projectDir, ".fusion"), + asyncLayer: h.store().getAsyncLayer()!, + }); await store.init(); const ceo = await store.createAgent({ @@ -107,7 +134,7 @@ describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", () => { project: "my-project", }); - expect(mockResolveProject).toHaveBeenCalledWith("my-project"); + expect(mockResolveAgentStoreBase).toHaveBeenCalledWith("my-project"); expect(existsSync(join(outputDir, "COMPANY.md"))).toBe(true); }); @@ -127,4 +154,5 @@ describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", () => { exitSpy.mockRestore(); }); + }); });