From c25f8b796df29e3c10bd8b64b107c32e0d3e0750 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 14 Jul 2026 08:16:42 -0700 Subject: [PATCH] Harden PostgreSQL migration foundation (#2088) ## Summary - make SQLite-to-PostgreSQL cutover retryable, fail-closed, versioned, and transactionally serialized - isolate migration sessions from runtime traffic and apply schema upgrades through `0002` - enforce tenant ownership across automations, analytics, activity, usage, agent runs, evals, and todos - replace expired SQLite-only coverage with PostgreSQL parity and concurrency coverage This is PR 1 of 2. The stacked follow-up restores PostgreSQL parity for CLI, engine, dashboard, and bundled integrations. ## Verification - `pnpm check:changesets --strict` - `pnpm --filter @fusion/core typecheck` - migration schema, connection, and SQLite cutover suite: 57 tests passed - `pnpm test:gate`: 463 tests passed ## Post-Deploy Monitoring & Validation - take a restorable PostgreSQL backup before deploy - confirm `fusion_schema_migrations` contains `0002` - confirm each expected project has a complete `fusion_sqlite_migrations` row - verify no null or empty tenant ownership in automations, activity logs, agent runs, and usage events - monitor for ownership inference failures, cutover verification failures, and migration session errors - restore the backup for data rollback; do not downgrade the tenant-isolation schema in place ## Summary by CodeRabbit * **New Features** * PostgreSQL-backed analytics and live dashboard metrics are now project-scoped (activity, tools, monitor, signals, and live snapshots). * Evaluation runs and scheduled eval batches received lifecycle improvements (ordering, updates, and execution flow). * Todo list changes now emit events; WhatsApp persistence and project-scoped roadmap data are supported. * **Bug Fixes** * SQLite-to-PostgreSQL cutovers now fail safely with stronger verification, serialized cutover handling, and safer project ownership. * PostgreSQL backend writes and reads are now strictly project-isolated and fail closed when project context is missing. --- .changeset/postgres-cutover-safety.md | 7 + .../src/__tests__/central-claim-mutex.test.ts | 101 - .../central-core-docker-node.test.ts | 156 - .../central-core-ensure-project.test.ts | 135 - .../core/src/__tests__/central-core.test.ts | 3369 ----------------- .../src/__tests__/central-integration.test.ts | 227 -- .../central-project-node-mappings.test.ts | 158 - ...association-diff-backfill.real-git.test.ts | 140 - .../src/__tests__/docker-node-config.test.ts | 179 - packages/core/src/__tests__/first-run.test.ts | 327 -- .../__tests__/migration-orchestrator.test.ts | 405 -- packages/core/src/__tests__/migration.test.ts | 823 ---- ...mission-factory-parity.integration.test.ts | 544 --- .../src/__tests__/mission-integration.test.ts | 668 ---- .../__tests__/multi-node-dashboard.test.ts | 559 --- .../postgres/activity-log-parity.pg.test.ts | 103 + .../agent-logs-and-monitor.pg.test.ts | 187 +- .../artifacts-documents-evals.pg.test.ts | 149 +- .../postgres/central-archive-secrets.test.ts | 52 +- .../command-center-analytics.pg.test.ts | 74 +- ...mand-center-remaining-analytics.pg.test.ts | 107 +- .../src/__tests__/postgres/connection.test.ts | 13 + .../handoff-to-review-atomicity.pg.test.ts | 30 + .../satellite-fusiondir-stores.test.ts | 134 +- .../__tests__/postgres/schema-applier.test.ts | 199 +- .../postgres/sqlite-migrator.test.ts | 205 +- .../startup-factory-integration.test.ts | 171 + .../postgres/taskstore-remaining.test.ts | 10 +- .../__tests__/postgres/todo-store.pg.test.ts | 81 +- .../project-isolation-transition.test.ts | 53 - .../core/src/__tests__/secrets-store.test.ts | 128 - .../__tests__/secrets-sync-passphrase.test.ts | 129 - .../core/src/__tests__/store-activity.test.ts | 878 ----- .../__tests__/store-handoff-to-review.test.ts | 240 -- .../store-plugin-store-close.test.ts | 63 - .../store-secrets-store-global-dir.test.ts | 47 - ...ore-settings-sync-passphrase-probe.test.ts | 85 - .../core/src/__tests__/todo-store.test.ts | 263 -- packages/core/src/activity-analytics.ts | 215 +- packages/core/src/agent-store.ts | 28 +- packages/core/src/async-agent-store.ts | 24 +- .../core/src/async-approval-request-store.ts | 12 +- packages/core/src/async-automation-store.ts | 86 +- packages/core/src/async-eval-store.ts | 39 +- packages/core/src/async-todo-store.ts | 44 +- packages/core/src/automation-store.ts | 35 +- packages/core/src/command-center-live.ts | 11 +- packages/core/src/eval-automation.ts | 64 +- packages/core/src/eval-store.ts | 54 +- packages/core/src/eval-types.ts | 1 + packages/core/src/index.ts | 2 + packages/core/src/postgres/connection.ts | 41 +- packages/core/src/postgres/index.ts | 2 + .../src/postgres/migrations/0000_initial.sql | 26 +- .../0001_automation_project_isolation.sql | 31 + .../0002_analytics_project_isolation.sql | 55 + ...003_monitor_approval_project_isolation.sql | 55 + .../core/src/postgres/plugin-schema-hook.ts | 133 + packages/core/src/postgres/schema-applier.ts | 112 +- packages/core/src/postgres/schema/plugin.ts | 4 + packages/core/src/postgres/schema/project.ts | 30 +- packages/core/src/postgres/sqlite-migrator.ts | 377 +- packages/core/src/postgres/startup-factory.ts | 99 +- packages/core/src/task-store/async-audit.ts | 7 +- packages/core/src/task-store/async-events.ts | 5 +- packages/core/src/task-store/async-monitor.ts | 31 +- .../core/src/task-store/remaining-ops-1.ts | 3 +- .../core/src/task-store/remaining-ops-10.ts | 6 +- .../core/src/task-store/remaining-ops-2.ts | 3 +- .../core/src/task-store/remaining-ops-4.ts | 3 +- .../core/src/task-store/remaining-ops-7.ts | 3 +- packages/core/src/tool-analytics.ts | 23 +- packages/core/vitest.config.ts | 118 +- .../src/__tests__/monitor-store.pg.test.ts | 57 + packages/dashboard/src/monitor-store.ts | 20 +- scripts/__tests__/check-no-nohup.test.mjs | 19 +- scripts/check-no-nohup.mjs | 16 +- scripts/lib/backend-db.mjs | 13 +- 78 files changed, 2888 insertions(+), 10218 deletions(-) create mode 100644 .changeset/postgres-cutover-safety.md delete mode 100644 packages/core/src/__tests__/central-claim-mutex.test.ts delete mode 100644 packages/core/src/__tests__/central-core-docker-node.test.ts delete mode 100644 packages/core/src/__tests__/central-core-ensure-project.test.ts delete mode 100644 packages/core/src/__tests__/central-core.test.ts delete mode 100644 packages/core/src/__tests__/central-integration.test.ts delete mode 100644 packages/core/src/__tests__/central-project-node-mappings.test.ts delete mode 100644 packages/core/src/__tests__/commit-association-diff-backfill.real-git.test.ts delete mode 100644 packages/core/src/__tests__/docker-node-config.test.ts delete mode 100644 packages/core/src/__tests__/first-run.test.ts delete mode 100644 packages/core/src/__tests__/migration-orchestrator.test.ts delete mode 100644 packages/core/src/__tests__/migration.test.ts delete mode 100644 packages/core/src/__tests__/mission-factory-parity.integration.test.ts delete mode 100644 packages/core/src/__tests__/mission-integration.test.ts delete mode 100644 packages/core/src/__tests__/multi-node-dashboard.test.ts create mode 100644 packages/core/src/__tests__/postgres/activity-log-parity.pg.test.ts delete mode 100644 packages/core/src/__tests__/project-isolation-transition.test.ts delete mode 100644 packages/core/src/__tests__/secrets-store.test.ts delete mode 100644 packages/core/src/__tests__/secrets-sync-passphrase.test.ts delete mode 100644 packages/core/src/__tests__/store-activity.test.ts delete mode 100644 packages/core/src/__tests__/store-handoff-to-review.test.ts delete mode 100644 packages/core/src/__tests__/store-plugin-store-close.test.ts delete mode 100644 packages/core/src/__tests__/store-secrets-store-global-dir.test.ts delete mode 100644 packages/core/src/__tests__/store-settings-sync-passphrase-probe.test.ts delete mode 100644 packages/core/src/__tests__/todo-store.test.ts create mode 100644 packages/core/src/postgres/migrations/0001_automation_project_isolation.sql create mode 100644 packages/core/src/postgres/migrations/0002_analytics_project_isolation.sql create mode 100644 packages/core/src/postgres/migrations/0003_monitor_approval_project_isolation.sql create mode 100644 packages/dashboard/src/__tests__/monitor-store.pg.test.ts diff --git a/.changeset/postgres-cutover-safety.md b/.changeset/postgres-cutover-safety.md new file mode 100644 index 0000000000..32adb82883 --- /dev/null +++ b/.changeset/postgres-cutover-safety.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make PostgreSQL cutover fail safely and preserve project-scoped core data. +category: fix +dev: Adds versioned tenant isolation, dedicated migration sessions, and strict SQLite cutover verification. diff --git a/packages/core/src/__tests__/central-claim-mutex.test.ts b/packages/core/src/__tests__/central-claim-mutex.test.ts deleted file mode 100644 index 06df0724a4..0000000000 --- a/packages/core/src/__tests__/central-claim-mutex.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { mkdtempSync } from "node:fs"; -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { createCentralDatabase, type CentralDatabase } from "../central-db.js"; - -function makeTmpDir(): string { - return mkdtempSync(join(tmpdir(), "fn-central-claim-test-")); -} - -describe("central claim mutex", () => { - let globalDir: string; - let db: CentralDatabase; - - beforeEach(() => { - globalDir = makeTmpDir(); - db = createCentralDatabase(globalDir); - db.init(); - }); - - afterEach(async () => { - db.close(); - await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - }); - - it("first claim creates epoch 1 row", () => { - const result = db.tryClaimTask({ - projectId: "P-1", - taskId: "FN-1", - nodeId: "node-a", - agentId: "agent-a", - runId: "run-1", - renewedAt: "2026-05-16T00:00:00.000Z", - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.claim.leaseEpoch).toBe(1); - expect(result.claim.ownerAgentId).toBe("agent-a"); - expect(result.claim.ownerNodeId).toBe("node-a"); - }); - - it("different owner without expectedEpoch conflicts and does not mutate", () => { - db.tryClaimTask({ projectId: "P-1", taskId: "FN-1", nodeId: "node-a", agentId: "agent-a", runId: "run-1", renewedAt: "2026-05-16T00:00:00.000Z" }); - const conflict = db.tryClaimTask({ projectId: "P-1", taskId: "FN-1", nodeId: "node-b", agentId: "agent-b", runId: "run-2", renewedAt: "2026-05-16T00:01:00.000Z" }); - expect(conflict.ok).toBe(false); - if (conflict.ok) return; - expect(conflict.reason).toBe("conflict"); - expect(conflict.current.ownerAgentId).toBe("agent-a"); - const row = db.getTaskClaim("P-1", "FN-1"); - expect(row?.ownerAgentId).toBe("agent-a"); - expect(row?.leaseEpoch).toBe(1); - }); - - it("owner-change with matching expectedEpoch increments exactly by one", () => { - db.tryClaimTask({ projectId: "P-1", taskId: "FN-1", nodeId: "node-a", agentId: "agent-a", runId: "run-1", renewedAt: "2026-05-16T00:00:00.000Z" }); - const changed = db.tryClaimTask({ projectId: "P-1", taskId: "FN-1", nodeId: "node-b", agentId: "agent-b", runId: "run-2", renewedAt: "2026-05-16T00:01:00.000Z", expectedEpoch: 1 }); - expect(changed.ok).toBe(true); - if (!changed.ok) return; - expect(changed.claim.leaseEpoch).toBe(2); - expect(changed.claim.ownerAgentId).toBe("agent-b"); - }); - - it("renew with matching expectedEpoch preserves epoch and updates renewedAt", () => { - db.tryClaimTask({ projectId: "P-1", taskId: "FN-1", nodeId: "node-a", agentId: "agent-a", runId: "run-1", renewedAt: "2026-05-16T00:00:00.000Z" }); - const renewed = db.renewTaskClaim({ projectId: "P-1", taskId: "FN-1", nodeId: "node-a", agentId: "agent-a", runId: "run-2", renewedAt: "2026-05-16T00:02:00.000Z", expectedEpoch: 1 }); - expect(renewed.ok).toBe(true); - if (!renewed.ok) return; - expect(renewed.claim.leaseEpoch).toBe(1); - expect(renewed.claim.ownerRunId).toBe("run-2"); - expect(renewed.claim.leaseRenewedAt).toBe("2026-05-16T00:02:00.000Z"); - }); - - it("renew with stale expectedEpoch conflicts", () => { - db.tryClaimTask({ projectId: "P-1", taskId: "FN-1", nodeId: "node-a", agentId: "agent-a", runId: "run-1", renewedAt: "2026-05-16T00:00:00.000Z" }); - const renewed = db.renewTaskClaim({ projectId: "P-1", taskId: "FN-1", nodeId: "node-a", agentId: "agent-a", runId: "run-2", renewedAt: "2026-05-16T00:02:00.000Z", expectedEpoch: 0 }); - expect(renewed.ok).toBe(false); - if (renewed.ok) return; - expect(renewed.reason).toBe("conflict"); - }); - - it("release succeeds for owner and not_owner for other agent", () => { - db.tryClaimTask({ projectId: "P-1", taskId: "FN-1", nodeId: "node-a", agentId: "agent-a", runId: "run-1", renewedAt: "2026-05-16T00:00:00.000Z" }); - const notOwner = db.releaseTaskClaim({ projectId: "P-1", taskId: "FN-1", nodeId: "node-b", agentId: "agent-b" }); - expect(notOwner.ok).toBe(false); - if (!notOwner.ok) { - expect(notOwner.reason).toBe("not_owner"); - expect(notOwner.current?.ownerAgentId).toBe("agent-a"); - } - const released = db.releaseTaskClaim({ projectId: "P-1", taskId: "FN-1", nodeId: "node-a", agentId: "agent-a" }); - expect(released).toEqual({ ok: true }); - }); - - it("getTaskClaim returns full row before release and null after", () => { - db.tryClaimTask({ projectId: "P-1", taskId: "FN-1", nodeId: "node-a", agentId: "agent-a", runId: "run-1", renewedAt: "2026-05-16T00:00:00.000Z" }); - const before = db.getTaskClaim("P-1", "FN-1"); - expect(before).toMatchObject({ projectId: "P-1", taskId: "FN-1", ownerAgentId: "agent-a", ownerNodeId: "node-a", leaseEpoch: 1 }); - db.releaseTaskClaim({ projectId: "P-1", taskId: "FN-1", nodeId: "node-a", agentId: "agent-a" }); - expect(db.getTaskClaim("P-1", "FN-1")).toBeNull(); - }); -}); diff --git a/packages/core/src/__tests__/central-core-docker-node.test.ts b/packages/core/src/__tests__/central-core-docker-node.test.ts deleted file mode 100644 index 38beffde8f..0000000000 --- a/packages/core/src/__tests__/central-core-docker-node.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { CentralCore } from "../central-core.js"; - -describe("CentralCore managed Docker nodes", () => { - let tempDir: string; - let central: CentralCore; - - beforeEach(async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-01T10:00:00.000Z")); - tempDir = mkdtempSync(join(tmpdir(), "kb-central-docker-node-test-")); - central = new CentralCore(tempDir); - await central.init(); - }); - - afterEach(async () => { - await central.close(); - vi.useRealTimers(); - vi.restoreAllMocks(); - rmSync(tempDir, { recursive: true, force: true }); - }); - - const buildInput = (name: string) => ({ - nodeId: null, - name, - imageName: "runfusion/fusion", - imageTag: "latest", - hostConfig: { context: "default", tlsVerify: false }, - envVars: { FUSION_NODE_NAME: name, FUSION_MODE: "managed" }, - volumeMounts: [{ hostPath: "/var/lib/fusion", containerPath: "/data", mode: "rw" as const }], - resourceSizing: { memoryMB: 4096, cpus: 2, memorySwapMB: 0 }, - extraClis: ["droid-cli" as const], - persistentStorage: true, - reachableUrl: "http://127.0.0.1:4041", - apiKey: "secret-key", - }); - - it("createManagedDockerNode creates full record with dn_ id and creating status", async () => { - const created = await central.createManagedDockerNode(buildInput("docker-a")); - - expect(created.id.startsWith("dn_")).toBe(true); - expect(created.name).toBe("docker-a"); - expect(created.status).toBe("creating"); - expect(created.containerId).toBeNull(); - expect(created.errorMessage).toBeNull(); - }); - - it("createManagedDockerNode enforces unique names", async () => { - await central.createManagedDockerNode(buildInput("docker-unique")); - - await expect(central.createManagedDockerNode(buildInput("docker-unique"))).rejects.toThrow( - "already exists with name", - ); - }); - - it("createManagedDockerNode validates required name", async () => { - await expect(central.createManagedDockerNode(buildInput(" "))).rejects.toThrow( - "between 1 and 64 characters", - ); - }); - - it("getManagedDockerNode returns found and undefined for missing", async () => { - const created = await central.createManagedDockerNode(buildInput("docker-get")); - - await expect(central.getManagedDockerNode(created.id)).resolves.toBeDefined(); - await expect(central.getManagedDockerNode("dn_missing")).resolves.toBeUndefined(); - }); - - it("getManagedDockerNodeByName returns found and undefined for missing", async () => { - const created = await central.createManagedDockerNode(buildInput("docker-by-name")); - - const found = await central.getManagedDockerNodeByName("docker-by-name"); - expect(found?.id).toBe(created.id); - await expect(central.getManagedDockerNodeByName("missing-name")).resolves.toBeUndefined(); - }); - - it("listManagedDockerNodes returns all ordered by name", async () => { - await central.createManagedDockerNode(buildInput("zeta")); - await central.createManagedDockerNode(buildInput("alpha")); - - const list = await central.listManagedDockerNodes(); - expect(list.map((item) => item.name)).toEqual(["alpha", "zeta"]); - }); - - it("updateManagedDockerNode applies partial changes and updates updatedAt", async () => { - const created = await central.createManagedDockerNode(buildInput("docker-update")); - - vi.setSystemTime(new Date("2026-05-01T10:05:00.000Z")); - - const updated = await central.updateManagedDockerNode(created.id, { - status: "running", - envVars: { ...created.envVars, EXTRA: "1" }, - volumeMounts: [ - ...created.volumeMounts, - { hostPath: "/var/log/fusion", containerPath: "/logs", mode: "ro" }, - ], - }); - - expect(updated.status).toBe("running"); - expect(updated.envVars.EXTRA).toBe("1"); - expect(updated.volumeMounts).toHaveLength(2); - expect(updated.updatedAt).not.toBe(created.updatedAt); - }); - - it("updateManagedDockerNode throws for unknown id", async () => { - await expect(central.updateManagedDockerNode("dn_missing", { status: "error" })).rejects.toThrow( - "not found", - ); - }); - - it("deleteManagedDockerNode removes record", async () => { - const created = await central.createManagedDockerNode(buildInput("docker-delete")); - - await central.deleteManagedDockerNode(created.id); - - await expect(central.getManagedDockerNode(created.id)).resolves.toBeUndefined(); - }); - - it("linkManagedDockerNodeToNode sets nodeId", async () => { - const managed = await central.createManagedDockerNode(buildInput("docker-link")); - const node = await central.registerNode({ - name: "remote-link-target", - type: "remote", - url: "http://127.0.0.1:5050", - apiKey: "remote-key", - }); - - const linked = await central.linkManagedDockerNodeToNode(managed.id, node.id); - expect(linked.nodeId).toBe(node.id); - }); - - it("JSON fields round-trip through storage", async () => { - const created = await central.createManagedDockerNode({ - ...buildInput("docker-json"), - hostConfig: { - host: "tcp://192.168.1.50:2376", - context: "prod", - tlsVerify: true, - tlsCaPath: "/certs/ca.pem", - tlsCertPath: "/certs/cert.pem", - tlsKeyPath: "/certs/key.pem", - }, - extraClis: ["claude-cli", "droid-cli"], - }); - - const fetched = await central.getManagedDockerNode(created.id); - expect(fetched?.hostConfig).toEqual(created.hostConfig); - expect(fetched?.envVars).toEqual(created.envVars); - expect(fetched?.volumeMounts).toEqual(created.volumeMounts); - expect(fetched?.resourceSizing).toEqual(created.resourceSizing); - expect(fetched?.extraClis).toEqual(created.extraClis); - }); -}); diff --git a/packages/core/src/__tests__/central-core-ensure-project.test.ts b/packages/core/src/__tests__/central-core-ensure-project.test.ts deleted file mode 100644 index a19b5b1e49..0000000000 --- a/packages/core/src/__tests__/central-core-ensure-project.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { execFile } from "node:child_process"; -import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { promisify } from "node:util"; -import { CentralCore } from "../central-core.js"; -import { ProjectIdentityConflictError } from "../project-identity.js"; - -const execFileAsync = promisify(execFile); - -async function isGitRepository(path: string): Promise { - try { - const { stdout } = await execFileAsync("git", ["-C", path, "rev-parse", "--is-inside-work-tree"], { - encoding: "utf-8", - timeout: 10_000, - }); - return stdout.trim() === "true"; - } catch { - return false; - } -} - -describe("CentralCore.ensureProjectForPath", () => { - const cleanup: string[] = []; - afterEach(() => cleanup.splice(0).forEach((p) => rmSync(p, { recursive: true, force: true }))); - - it("covers existing, reattach, fresh, and conflict", async () => { - const globalDir = mkdtempSync(join(tmpdir(), "central-")); - const p1 = mkdtempSync(join(tmpdir(), "proj-a-")); - const p2 = mkdtempSync(join(tmpdir(), "proj-b-")); - mkdirSync(join(p1, ".fusion")); - mkdirSync(join(p2, ".fusion")); - cleanup.push(globalDir, p1, p2); - - const central = new CentralCore(globalDir); - await central.init(); - - const first = await central.ensureProjectForPath({ path: p1, name: "A" }); - expect(first.reattached).toBe(false); - expect(first.gitRepository).toBe("initialized"); - await expect(isGitRepository(p1)).resolves.toBe(true); - - const existing = await central.ensureProjectForPath({ path: p1, name: "A" }); - expect(existing.outcome).toBe("existing"); - expect(existing.gitRepository).toBeUndefined(); - - await central.unregisterProject(first.project.id); - const events: Array<[string, string]> = []; - central.on("project:reattached", (project, reason) => events.push([project.id, reason])); - const reattached = await central.ensureProjectForPath({ - path: p1, - name: "A", - identity: { id: first.project.id, createdAt: first.project.createdAt }, - }); - expect(reattached.reattached).toBe(true); - expect(reattached.gitRepository).toBe("existing"); - expect(events).toEqual([[first.project.id, "identity-recovered"]]); - - await expect( - central.ensureProjectForPath({ - path: p2, - name: "B", - identity: { id: first.project.id, createdAt: first.project.createdAt }, - }), - ).rejects.toBeInstanceOf(ProjectIdentityConflictError); - - await central.close(); - }); - - it("leaves already-registered legacy paths untouched", async () => { - const globalDir = mkdtempSync(join(tmpdir(), "central-")); - const projectPath = mkdtempSync(join(tmpdir(), "proj-legacy-")); - cleanup.push(globalDir, projectPath); - - const central = new CentralCore(globalDir); - await central.init(); - - const registered = await central.registerProject({ path: projectPath, name: "Legacy" }); - expect(existsSync(join(projectPath, ".git"))).toBe(false); - - const ensured = await central.ensureProjectForPath({ path: projectPath, name: "Legacy" }); - - expect(ensured.outcome).toBe("existing"); - expect(ensured.project.id).toBe(registered.id); - expect(ensured.gitRepository).toBeUndefined(); - expect(existsSync(join(projectPath, ".git"))).toBe(false); - - await central.close(); - }); - - it("does not persist fresh registrations when git initialization fails", async () => { - const globalDir = mkdtempSync(join(tmpdir(), "central-")); - const projectPath = mkdtempSync(join(tmpdir(), "proj-fail-")); - cleanup.push(globalDir, projectPath); - - const central = new CentralCore(globalDir, { - ensureGitRepositoryForProjectPath: async () => { - throw new Error("Could not initialize Git repository at project: git is not installed"); - }, - }); - await central.init(); - - await expect(central.ensureProjectForPath({ path: projectPath, name: "Fail" })).rejects.toThrow( - "Could not initialize Git repository", - ); - await expect(central.getProjectByPath(projectPath)).resolves.toBeUndefined(); - - await central.close(); - }); - - it("does not persist reattachments when git initialization fails", async () => { - const globalDir = mkdtempSync(join(tmpdir(), "central-")); - const projectPath = mkdtempSync(join(tmpdir(), "proj-reattach-fail-")); - cleanup.push(globalDir, projectPath); - - const central = new CentralCore(globalDir, { - ensureGitRepositoryForProjectPath: async () => { - throw new Error("Could not initialize Git repository at project: permission denied"); - }, - }); - await central.init(); - - await expect( - central.ensureProjectForPath({ - path: projectPath, - name: "Fail", - identity: { id: "proj_abcdef1234567890", createdAt: "2026-06-06T00:00:00.000Z" }, - }), - ).rejects.toThrow("Could not initialize Git repository"); - await expect(central.getProject("proj_abcdef1234567890")).resolves.toBeUndefined(); - - await central.close(); - }); -}); diff --git a/packages/core/src/__tests__/central-core.test.ts b/packages/core/src/__tests__/central-core.test.ts deleted file mode 100644 index df549523df..0000000000 --- a/packages/core/src/__tests__/central-core.test.ts +++ /dev/null @@ -1,3369 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createHash } from "node:crypto"; -import { CentralCore } from "../central-core.js"; -import { setRunningAgentCountSource } from "../live-agent-count.js"; -import { NodeDiscovery } from "../node-discovery.js"; -import { NodeConnection, type ConnectionResult } from "../node-connection.js"; -import { getAppVersion } from "../app-version.js"; -import * as systemMetrics from "../system-metrics.js"; -import type { - RegisteredProject, - ProjectHealth, - CentralActivityLogEntry, - GlobalConcurrencyState, - SystemMetrics, - DiscoveryConfig, - DiscoveredNode, -} from "../types.js"; - -describe("CentralCore", () => { - let tempDir: string; - let central: CentralCore; - let projectPaths: string[] = []; - - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-01T12:00:00.000Z")); - tempDir = mkdtempSync(join(tmpdir(), "kb-central-core-test-")); - central = new CentralCore(tempDir); - projectPaths = []; - }); - - afterEach(async () => { - setRunningAgentCountSource(undefined); - await central.close(); - vi.useRealTimers(); - vi.restoreAllMocks(); - rmSync(tempDir, { recursive: true, force: true }); - }); - - describe("lifecycle", () => { - it("should initialize and create database", async () => { - await central.init(); - expect(central.isInitialized()).toBe(true); - expect(central.getDatabasePath()).toBe(join(tempDir, "fusion-central.db")); - }); - - it("should be idempotent on multiple init calls", async () => { - await central.init(); - await central.init(); - expect(central.isInitialized()).toBe(true); - }); - - it("should create a default online local node on init", async () => { - await central.init(); - - const nodes = await central.listNodes(); - const localNodes = nodes.filter((node) => node.type === "local"); - expect(localNodes).toHaveLength(1); - expect(localNodes[0].name).toBe("local"); - expect(localNodes[0].status).toBe("online"); - expect(localNodes[0].maxConcurrent).toBe(4); - }); - - it("should not create duplicate default local nodes across re-initialization", async () => { - await central.init(); - await central.close(); - - central = new CentralCore(tempDir); - await central.init(); - - const nodes = await central.listNodes(); - const localNodes = nodes.filter((node) => node.type === "local"); - expect(localNodes).toHaveLength(1); - expect(localNodes[0].name).toBe("local"); - }); - - it("should close and clean up", async () => { - await central.init(); - await central.close(); - expect(central.isInitialized()).toBe(false); - }); - - it("should throw if operations called before init", async () => { - await expect(central.listProjects()).rejects.toThrow("not initialized"); - }); - }); - - describe("project registration", () => { - beforeEach(async () => { - await central.init(); - }); - - it("should register a project with valid inputs", async () => { - const projectPath = join(tempDir, "project1"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Test Project", - path: projectPath, - }); - - expect(project.id).toMatch(/^proj_[a-f0-9]+$/); - expect(project.name).toBe("Test Project"); - expect(project.path).toBe(projectPath); - expect(project.status).toBe("initializing"); - expect(project.isolationMode).toBe("in-process"); - expect(project.createdAt).toBeDefined(); - expect(project.updatedAt).toBeDefined(); - expect(project.lastActivityAt).toBeDefined(); - }); - - it("should reject relative paths", async () => { - await expect( - central.registerProject({ - name: "Test", - path: "relative/path", - }) - ).rejects.toThrow("must be absolute"); - }); - - it("should reject non-existent paths", async () => { - await expect( - central.registerProject({ - name: "Test", - path: "/nonexistent/path", - }) - ).rejects.toThrow("does not exist"); - }); - - it("should reject non-directory paths", async () => { - const filePath = join(tempDir, "not-a-dir.txt"); - // Create a file (can't use writeFileSync with these imports, use native fs via db or skip) - // Actually let's create it using standard fs which is available in node - const { writeFileSync } = await import("node:fs"); - writeFileSync(filePath, "content"); - - await expect( - central.registerProject({ - name: "Test", - path: filePath, - }) - ).rejects.toThrow("must be a directory"); - }); - - it("should reject duplicate paths", async () => { - const projectPath = join(tempDir, "dup-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - await central.registerProject({ - name: "First", - path: projectPath, - }); - - await expect( - central.registerProject({ - name: "Second", - path: projectPath, - }) - ).rejects.toThrow("already registered"); - }); - - it("should accept custom isolation mode", async () => { - const projectPath = join(tempDir, "isolated-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Isolated", - path: projectPath, - isolationMode: "child-process", - }); - - expect(project.isolationMode).toBe("child-process"); - }); - - it("should emit project:registered event", async () => { - const projectPath = join(tempDir, "event-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - let emittedProject: RegisteredProject | undefined; - central.on("project:registered", (p) => { - emittedProject = p; - }); - - await central.registerProject({ - name: "Event Test", - path: projectPath, - }); - - expect(emittedProject).toBeDefined(); - expect(emittedProject?.name).toBe("Event Test"); - }); - - it("should initialize project health on registration", async () => { - const projectPath = join(tempDir, "health-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Health Test", - path: projectPath, - }); - - const health = await central.getProjectHealth(project.id); - expect(health).toBeDefined(); - expect(health?.projectId).toBe(project.id); - expect(health?.status).toBe("initializing"); - expect(health?.activeTaskCount).toBe(0); - expect(health?.inFlightAgentCount).toBe(0); - expect(health?.totalTasksCompleted).toBe(0); - expect(health?.totalTasksFailed).toBe(0); - }); - - it("should persist nodeId when provided on registration", async () => { - const projectPath = join(tempDir, "node-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Node On Register", - path: projectPath, - nodeId: "node_abc123", - }); - - expect(project.nodeId).toBe("node_abc123"); - - const retrieved = await central.getProject(project.id); - expect(retrieved?.nodeId).toBe("node_abc123"); - }); - - it("should have undefined nodeId when not provided on registration", async () => { - const projectPath = join(tempDir, "no-node-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "No Node", - path: projectPath, - }); - - expect(project.nodeId).toBeUndefined(); - - const retrieved = await central.getProject(project.id); - expect(retrieved?.nodeId).toBeUndefined(); - }); - - it("should reattach using supplied id and initialize mapping + health", async () => { - const projectPath = join(tempDir, "reattach-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.reattachProject({ - id: "proj_0123456789abcdef", - name: "Reattach", - path: projectPath, - }); - - expect(project.id).toBe("proj_0123456789abcdef"); - const mappings = await central.listProjectNodePathMappingsForProject(project.id); - expect(mappings.length).toBeGreaterThan(0); - const health = await central.getProjectHealth(project.id); - expect(health?.projectId).toBe(project.id); - }); - - it("should reject reattach when id is already bound to another path", async () => { - const projectPathA = join(tempDir, "reattach-a"); - const projectPathB = join(tempDir, "reattach-b"); - mkdirSync(projectPathA); - mkdirSync(projectPathB); - projectPaths.push(projectPathA, projectPathB); - - await central.reattachProject({ - id: "proj_0123456789abcdef", - name: "A", - path: projectPathA, - }); - - await expect( - central.reattachProject({ - id: "proj_0123456789abcdef", - name: "B", - path: projectPathB, - }), - ).rejects.toThrow("already registered at a different path"); - }); - - it("should reject reattach when path already has another id", async () => { - const projectPath = join(tempDir, "reattach-dup-path"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - await central.registerProject({ name: "Original", path: projectPath }); - - await expect( - central.reattachProject({ - id: "proj_0123456789abcdef", - name: "Other", - path: projectPath, - }), - ).rejects.toThrow("refusing silent reassignment"); - }); - - it("should ensure project for path with existing, reattached, and registered outcomes", async () => { - const existingPath = join(tempDir, "ensure-existing"); - const reattachPath = join(tempDir, "ensure-reattach"); - const newPath = join(tempDir, "ensure-new"); - mkdirSync(existingPath); - mkdirSync(reattachPath); - mkdirSync(newPath); - projectPaths.push(existingPath, reattachPath, newPath); - - const existing = await central.registerProject({ name: "Existing", path: existingPath }); - const existingResult = await central.ensureProjectForPath({ path: existingPath }); - expect(existingResult.outcome).toBe("existing"); - expect(existingResult.project.id).toBe(existing.id); - - const reattachResult = await central.ensureProjectForPath({ - path: reattachPath, - identity: { id: "proj_fedcba9876543210", createdAt: "2026-05-20T00:00:00.000Z" }, - name: "Recovered", - }); - expect(reattachResult.outcome).toBe("reattached"); - expect(reattachResult.project.id).toBe("proj_fedcba9876543210"); - - const registeredResult = await central.ensureProjectForPath({ path: newPath, name: "New" }); - expect(registeredResult.outcome).toBe("registered"); - expect(registeredResult.project.id).toMatch(/^proj_[a-f0-9]{16}$/); - }); - - it("should error when ensure identity id is already bound to another path", async () => { - const originalPath = join(tempDir, "ensure-id-original"); - const secondPath = join(tempDir, "ensure-id-second"); - mkdirSync(originalPath); - mkdirSync(secondPath); - projectPaths.push(originalPath, secondPath); - - await central.reattachProject({ - id: "proj_0123456789abcdef", - name: "Original", - path: originalPath, - }); - - await expect( - central.ensureProjectForPath({ - path: secondPath, - identity: { id: "proj_0123456789abcdef", createdAt: "2026-05-20T00:00:00.000Z" }, - name: "Second", - }), - ).rejects.toThrow("Project identity conflict"); - }); - }); - - describe("project unregistration", () => { - beforeEach(async () => { - await central.init(); - }); - - it("should unregister a project", async () => { - const projectPath = join(tempDir, "unreg-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "To Unregister", - path: projectPath, - }); - - await central.unregisterProject(project.id); - - const found = await central.getProject(project.id); - expect(found).toBeUndefined(); - }); - - it("should be idempotent for non-existent projects", async () => { - await expect(central.unregisterProject("nonexistent")).resolves.toBeUndefined(); - }); - - it("should emit project:unregistered event", async () => { - const projectPath = join(tempDir, "unreg-event-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "To Unregister", - path: projectPath, - }); - - let emittedId: string | undefined; - central.on("project:unregistered", (id) => { - emittedId = id; - }); - - await central.unregisterProject(project.id); - - expect(emittedId).toBe(project.id); - }); - - it("should cascade delete health records", async () => { - const projectPath = join(tempDir, "cascade-health"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Cascade", - path: projectPath, - }); - - await central.unregisterProject(project.id); - - const health = await central.getProjectHealth(project.id); - expect(health).toBeUndefined(); - }); - - it("should cascade delete activity log entries", async () => { - const projectPath = join(tempDir, "cascade-activity"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Cascade Activity", - path: projectPath, - }); - - await central.logActivity({ - type: "task:created", - projectId: project.id, - projectName: project.name, - timestamp: new Date().toISOString(), - details: "Test activity", - }); - - await central.unregisterProject(project.id); - - const activities = await central.getRecentActivity({ projectId: project.id }); - expect(activities).toHaveLength(0); - }); - }); - - describe("project queries", () => { - beforeEach(async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-01T12:00:00.000Z")); - await central.init(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("should get project by id", async () => { - const projectPath = join(tempDir, "get-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Get Test", - path: projectPath, - }); - - const found = await central.getProject(project.id); - expect(found).toEqual(project); - }); - - it("should return undefined for non-existent id", async () => { - const found = await central.getProject("nonexistent"); - expect(found).toBeUndefined(); - }); - - it("should get project by path", async () => { - const projectPath = join(tempDir, "by-path-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "By Path", - path: projectPath, - }); - - const found = await central.getProjectByPath(projectPath); - expect(found).toEqual(project); - }); - - it("should list all projects", async () => { - const projects: RegisteredProject[] = []; - for (let i = 0; i < 3; i++) { - const projectPath = join(tempDir, `list-project-${i}`); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: `Project ${i}`, - path: projectPath, - }); - projects.push(project); - } - - const listed = await central.listProjects(); - expect(listed).toHaveLength(3); - // Should be sorted by name - expect(listed.map((p) => p.name)).toEqual(["Project 0", "Project 1", "Project 2"]); - }); - - it("should return empty array when no projects", async () => { - const listed = await central.listProjects(); - expect(listed).toEqual([]); - }); - - it("should update project fields", async () => { - const projectPath = join(tempDir, "update-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Original", - path: projectPath, - }); - - vi.setSystemTime(new Date("2026-04-01T12:00:00.010Z")); - - const updated = await central.updateProject(project.id, { - name: "Updated", - status: "active", - }); - - expect(updated.name).toBe("Updated"); - expect(updated.status).toBe("active"); - expect(updated.id).toBe(project.id); - expect(updated.createdAt).toBe(project.createdAt); - expect(updated.updatedAt).not.toBe(project.updatedAt); - }); - - it("should throw when updating non-existent project", async () => { - await expect( - central.updateProject("nonexistent", { name: "New Name" }) - ).rejects.toThrow("not found"); - }); - - it("should emit project:updated event", async () => { - const projectPath = join(tempDir, "update-event-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Original", - path: projectPath, - }); - - let emittedProject: RegisteredProject | undefined; - central.on("project:updated", (p) => { - emittedProject = p; - }); - - await central.updateProject(project.id, { name: "Updated" }); - - expect(emittedProject).toBeDefined(); - expect(emittedProject?.name).toBe("Updated"); - }); - }); - - describe("project status reconciliation", () => { - beforeEach(async () => { - await central.init(); - }); - - it("should promote stale initializing projects to active", async () => { - const projectPath = join(tempDir, "stale-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - // Register a project (starts as "initializing") - const project = await central.registerProject({ - name: "Stale Project", - path: projectPath, - }); - expect(project.status).toBe("initializing"); - - // Reconcile — should promote to active - const reconciled = await central.reconcileProjectStatuses(); - expect(reconciled).toHaveLength(1); - expect(reconciled[0].projectId).toBe(project.id); - expect(reconciled[0].previousStatus).toBe("initializing"); - - // Verify project is now active - const updated = await central.getProject(project.id); - expect(updated?.status).toBe("active"); - }); - - it("should update both projects and projectHealth tables", async () => { - const projectPath = join(tempDir, "health-stale"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Health Stale", - path: projectPath, - }); - - // Health row should be "initializing" initially - const healthBefore = await central.getProjectHealth(project.id); - expect(healthBefore?.status).toBe("initializing"); - - // Reconcile - await central.reconcileProjectStatuses(); - - // Both project and health should be "active" - const updatedProject = await central.getProject(project.id); - expect(updatedProject?.status).toBe("active"); - - const updatedHealth = await central.getProjectHealth(project.id); - expect(updatedHealth?.status).toBe("active"); - }); - - it("should not affect active projects", async () => { - const projectPath = join(tempDir, "active-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Active Project", - path: projectPath, - }); - await central.updateProject(project.id, { status: "active" }); - - const reconciled = await central.reconcileProjectStatuses(); - expect(reconciled).toHaveLength(0); - - const unchanged = await central.getProject(project.id); - expect(unchanged?.status).toBe("active"); - }); - - it("should not affect paused or errored projects", async () => { - const pausedPath = join(tempDir, "paused-project"); - mkdirSync(pausedPath); - projectPaths.push(pausedPath); - - const erroredPath = join(tempDir, "errored-project"); - mkdirSync(erroredPath); - projectPaths.push(erroredPath); - - const paused = await central.registerProject({ - name: "Paused Project", - path: pausedPath, - }); - await central.updateProject(paused.id, { status: "paused" }); - - const errored = await central.registerProject({ - name: "Errored Project", - path: erroredPath, - }); - await central.updateProject(errored.id, { status: "errored" }); - - const reconciled = await central.reconcileProjectStatuses(); - expect(reconciled).toHaveLength(0); - - expect((await central.getProject(paused.id))?.status).toBe("paused"); - expect((await central.getProject(errored.id))?.status).toBe("errored"); - }); - - it("should be idempotent — calling twice is a no-op after promotion", async () => { - const projectPath = join(tempDir, "idempotent-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - await central.registerProject({ - name: "Idempotent Project", - path: projectPath, - }); - - // First call promotes - const first = await central.reconcileProjectStatuses(); - expect(first).toHaveLength(1); - - // Second call is a no-op - const second = await central.reconcileProjectStatuses(); - expect(second).toHaveLength(0); - }); - - it("should reconcile multiple stale projects at once", async () => { - const paths: string[] = []; - for (let i = 0; i < 3; i++) { - const p = join(tempDir, `multi-stale-${i}`); - mkdirSync(p); - projectPaths.push(p); - paths.push(p); - } - - await central.registerProject({ name: "Stale A", path: paths[0] }); - await central.registerProject({ name: "Stale B", path: paths[1] }); - await central.registerProject({ name: "Stale C", path: paths[2] }); - - const reconciled = await central.reconcileProjectStatuses(); - expect(reconciled).toHaveLength(3); - - const projects = await central.listProjects(); - expect(projects.every((p) => p.status === "active")).toBe(true); - }); - - it("should return empty array when no projects exist", async () => { - const reconciled = await central.reconcileProjectStatuses(); - expect(reconciled).toEqual([]); - }); - }); - - describe("node management", () => { - beforeEach(async () => { - await central.init(); - }); - - it("should register and retrieve a node", async () => { - const node = await central.registerNode({ - name: "executor-node-a", - type: "local", - maxConcurrent: 3, - }); - - expect(node.id).toMatch(/^node_[a-f0-9]+$/); - expect(node.name).toBe("executor-node-a"); - expect(node.type).toBe("local"); - expect(node.status).toBe("offline"); - expect(node.maxConcurrent).toBe(3); - - const fetched = await central.getNode(node.id); - expect(fetched).toEqual(node); - - const byName = await central.getNodeByName("executor-node-a"); - expect(byName?.id).toBe(node.id); - }); - - it("should reject duplicate node names", async () => { - await central.registerNode({ name: "dup-node", type: "local" }); - - await expect( - central.registerNode({ name: "dup-node", type: "local" }), - ).rejects.toThrow("already exists"); - }); - - it("should validate node type constraints on register", async () => { - await expect( - central.registerNode({ name: "remote-missing-url", type: "remote" }), - ).rejects.toThrow("must include a url"); - - await expect( - central.registerNode({ - name: "local-with-url", - type: "local", - url: "https://example.com", - }), - ).rejects.toThrow("must not include url or apiKey"); - - await expect( - central.registerNode({ - name: "local-with-key", - type: "local", - apiKey: "abc", - }), - ).rejects.toThrow("must not include url or apiKey"); - }); - - it("should update nodes and enforce type constraints", async () => { - const remote = await central.registerNode({ - name: "remote-node", - type: "remote", - url: "https://node.example.com", - apiKey: "secret", - }); - - const updated = await central.updateNode(remote.id, { - status: "connecting", - maxConcurrent: 4, - }); - - expect(updated.status).toBe("connecting"); - expect(updated.maxConcurrent).toBe(4); - - await expect( - central.updateNode(remote.id, { - type: "local", - }), - ).rejects.toThrow("must not include url or apiKey"); - }); - - it("should list nodes ordered by name", async () => { - await central.registerNode({ name: "z-node", type: "local" }); - await central.registerNode({ name: "a-node", type: "local" }); - - const nodes = await central.listNodes(); - const names = nodes.map((node) => node.name); - expect(names).toContain("a-node"); - expect(names).toContain("z-node"); - expect(names.indexOf("a-node")).toBeLessThan(names.indexOf("z-node")); - }); - - it("should assign and unassign projects to nodes", async () => { - const projectPath = join(tempDir, "node-assignment"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Node Assignment", - path: projectPath, - }); - const node = await central.registerNode({ name: "assign-node", type: "local" }); - - const assigned = await central.assignProjectToNode(project.id, node.id); - expect(assigned.nodeId).toBe(node.id); - expect((await central.getProject(project.id))?.nodeId).toBe(node.id); - - const unassigned = await central.unassignProjectFromNode(project.id); - expect(unassigned.nodeId).toBeUndefined(); - expect((await central.getProject(project.id))?.nodeId).toBeUndefined(); - }); - - it("should throw when assigning to unknown project or node", async () => { - const node = await central.registerNode({ name: "assignment-target", type: "local" }); - - await expect(central.assignProjectToNode("proj_missing", node.id)).rejects.toThrow("Project not found"); - - const projectPath = join(tempDir, "node-assignment-errors"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Node Assignment Errors", - path: projectPath, - }); - - await expect(central.assignProjectToNode(project.id, "node_missing")).rejects.toThrow("Node not found"); - await expect(central.unassignProjectFromNode("proj_missing")).rejects.toThrow("Project not found"); - }); - - it("should unassign projects when a node is unregistered", async () => { - const projectPath = join(tempDir, "node-unregister"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Node Unregister", - path: projectPath, - }); - const node = await central.registerNode({ name: "ephemeral-node", type: "local" }); - - await central.assignProjectToNode(project.id, node.id); - await central.unregisterNode(node.id); - - expect(await central.getNode(node.id)).toBeUndefined(); - expect((await central.getProject(project.id))?.nodeId).toBeUndefined(); - }); - - it("should be idempotent when unregistering missing nodes", async () => { - await expect(central.unregisterNode("node_missing")).resolves.toBeUndefined(); - }); - - it("should upsert, read, and remove project-node path mappings", async () => { - const projectPath = join(tempDir, "mapping-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Mapping Project", - path: projectPath, - }); - const nodeA = await central.registerNode({ name: "mapping-node-a", type: "local" }); - const nodeB = await central.registerNode({ name: "mapping-node-b", type: "local" }); - - const created = await central.upsertProjectNodePathMapping({ - projectId: project.id, - nodeId: nodeA.id, - path: "/node-a/worktree", - }); - expect(created.path).toBe("/node-a/worktree"); - - const updated = await central.upsertProjectNodePathMapping({ - projectId: project.id, - nodeId: nodeA.id, - path: "/node-a/worktree-updated", - }); - expect(updated.path).toBe("/node-a/worktree-updated"); - expect(updated.createdAt).toBe(created.createdAt); - - await central.upsertProjectNodePathMapping({ - projectId: project.id, - nodeId: nodeB.id, - path: "/node-b/worktree", - }); - - await expect(central.listProjectNodePathMappingsForProject(project.id)).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - projectId: project.id, - nodeId: nodeA.id, - path: "/node-a/worktree-updated", - }), - expect.objectContaining({ - projectId: project.id, - nodeId: nodeB.id, - path: "/node-b/worktree", - }), - ]), - ); - - await expect(central.listProjectNodePathMappingsForNode(nodeA.id)).resolves.toMatchObject([ - { projectId: project.id, nodeId: nodeA.id, path: "/node-a/worktree-updated" }, - ]); - - await central.removeProjectNodePathMapping({ projectId: project.id, nodeId: nodeA.id }); - await expect(central.getProjectNodePathMapping(project.id, nodeA.id)).resolves.toBeUndefined(); - }); - - it("should return exact mapped path via getProjectNodePath and undefined for unmapped pairs", async () => { - const projectPath = join(tempDir, "mapping-read-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Mapping Read Project", - path: projectPath, - }); - const mappedNode = await central.registerNode({ name: "mapping-read-node", type: "local" }); - const otherNode = await central.registerNode({ name: "mapping-read-node-other", type: "local" }); - - await central.upsertProjectNodePathMapping({ - projectId: project.id, - nodeId: mappedNode.id, - path: "/mapped/node/path", - }); - - await expect(central.getProjectNodePath(project.id, mappedNode.id)).resolves.toBe("/mapped/node/path"); - await expect(central.getProjectNodePath(project.id, otherNode.id)).resolves.toBeUndefined(); - await expect(central.getProjectNodePath("proj_missing", mappedNode.id)).resolves.toBeUndefined(); - }); - - it("should validate project and node existence for mapping APIs", async () => { - const node = await central.registerNode({ name: "mapping-validation-node", type: "local" }); - - await expect( - central.upsertProjectNodePathMapping({ - projectId: "proj_missing", - nodeId: node.id, - path: "/missing/project", - }), - ).rejects.toThrow("Project not found"); - - const projectPath = join(tempDir, "mapping-validation-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - const project = await central.registerProject({ - name: "Mapping Validation", - path: projectPath, - }); - - await expect( - central.upsertProjectNodePathMapping({ - projectId: project.id, - nodeId: "node_missing", - path: "/missing/node", - }), - ).rejects.toThrow("Node not found"); - - await expect(central.listProjectNodePathMappingsForProject("proj_missing")).rejects.toThrow( - "Project not found", - ); - await expect(central.listProjectNodePathMappingsForNode("node_missing")).rejects.toThrow( - "Node not found", - ); - }); - - it("resolves working directories strictly from exact project/node mappings", async () => { - const projectPath = join(tempDir, "mapping-resolver-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Mapping Resolver", - path: projectPath, - }); - const remoteNode = await central.registerNode({ name: "mapping-resolver-remote", type: "remote", url: "http://remote.example" }); - const otherNode = await central.registerNode({ name: "mapping-resolver-other", type: "remote", url: "http://other.example" }); - - const localNode = (await central.listNodes()).find((node) => node.type === "local"); - expect(localNode).toBeDefined(); - - await expect(central.resolveLocalProjectWorkingDirectory(project.id)).resolves.toBe(projectPath); - - await central.upsertProjectNodePathMapping({ - projectId: project.id, - nodeId: remoteNode.id, - path: "/remote/project/root", - }); - - await expect(central.resolveProjectWorkingDirectory(project.id, remoteNode.id)).resolves.toBe( - "/remote/project/root", - ); - await expect(central.resolveProjectWorkingDirectory("proj_missing", remoteNode.id)).rejects.toThrow( - "Project not found: proj_missing", - ); - await expect(central.resolveProjectWorkingDirectory(project.id, "node_missing")).rejects.toThrow( - "Node not found: node_missing", - ); - await expect(central.resolveProjectWorkingDirectory(project.id, otherNode.id)).rejects.toThrow( - `Project/node path mapping not found for projectId=${project.id} nodeId=${otherNode.id}`, - ); - }); - - it("should check local node health and emit node:health:changed", async () => { - const node = await central.registerNode({ name: "local-health", type: "local" }); - - let emittedNodeId: string | undefined; - let emittedStatus: string | undefined; - central.on("node:health:changed", (updated) => { - emittedNodeId = updated.id; - emittedStatus = updated.status; - }); - - const status = await central.checkNodeHealth(node.id); - expect(status).toBe("online"); - - const stored = await central.getNode(node.id); - expect(stored?.status).toBe("online"); - expect(emittedNodeId).toBe(node.id); - expect(emittedStatus).toBe("online"); - }); - - it("should test node connection and emit node:connection:test", async () => { - const connectionResult = { - success: true, - url: "http://remote.example:3000", - latencyMs: 12, - nodeInfo: { - name: "remote", - version: "1.0.0", - uptime: 5, - capabilities: ["executor"], - }, - }; - const testSpy = vi.spyOn(NodeConnection.prototype, "test").mockResolvedValue(connectionResult); - - let emittedResult: unknown; - central.on("node:connection:test", (result) => { - emittedResult = result; - }); - - const result = await central.testNodeConnection({ - host: "remote.example", - port: 3000, - apiKey: "secret", - }); - - expect(result).toEqual(connectionResult); - expect(emittedResult).toEqual(connectionResult); - expect(testSpy).toHaveBeenCalledWith({ - host: "remote.example", - port: 3000, - apiKey: "secret", - }); - }); - - it("should return failed testNodeConnection results", async () => { - const connectionResult: ConnectionResult = { - success: false, - url: "http://offline.example:3000", - error: { - type: "connection-refused", - message: "fetch failed: ECONNREFUSED", - }, - }; - vi.spyOn(NodeConnection.prototype, "test").mockResolvedValue(connectionResult); - - const result = await central.testNodeConnection({ - host: "offline.example", - port: 3000, - }); - - expect(result).toEqual(connectionResult); - }); - - it("should connect to remote node and register when test succeeds", async () => { - const connectionResult = { - success: true, - url: "http://remote.example:3000", - latencyMs: 10, - nodeInfo: { - name: "remote", - version: "1.0.0", - uptime: 30, - capabilities: ["executor"], - }, - }; - vi.spyOn(NodeConnection.prototype, "test").mockResolvedValue(connectionResult); - const registerSpy = vi.spyOn(central, "registerNode"); - const healthSpy = vi.spyOn(central, "checkNodeHealth").mockResolvedValue("online"); - - let emittedResult: unknown; - central.on("node:connection:test", (result) => { - emittedResult = result; - }); - - const output = await central.connectToRemoteNode({ - name: "remote-node", - host: "remote.example", - port: 3000, - apiKey: "secret", - maxConcurrent: 4, - }); - - expect(output.result).toEqual(connectionResult); - expect(output.node).toBeDefined(); - expect(output.node?.name).toBe("remote-node"); - expect(output.node?.type).toBe("remote"); - expect(output.node?.url).toBe("http://remote.example:3000"); - expect(emittedResult).toEqual(connectionResult); - expect(registerSpy).toHaveBeenCalledWith({ - name: "remote-node", - type: "remote", - url: "http://remote.example:3000", - apiKey: "secret", - maxConcurrent: 4, - }); - expect(healthSpy).toHaveBeenCalledWith(output.node!.id); - }); - - it("should reject duplicate node names before testing connection", async () => { - await central.registerNode({ name: "existing-node", type: "local" }); - - const testSpy = vi.spyOn(NodeConnection.prototype, "test"); - - await expect( - central.connectToRemoteNode({ - name: "existing-node", - host: "remote.example", - port: 3000, - }) - ).rejects.toThrow("Node already exists with name: existing-node"); - - expect(testSpy).not.toHaveBeenCalled(); - }); - - it("should return connection result without registration when test fails", async () => { - const connectionResult: ConnectionResult = { - success: false, - url: "http://offline.example:3000", - error: { - type: "timeout", - message: "Connection timed out after 10000ms", - }, - }; - vi.spyOn(NodeConnection.prototype, "test").mockResolvedValue(connectionResult); - const registerSpy = vi.spyOn(central, "registerNode"); - const healthSpy = vi.spyOn(central, "checkNodeHealth"); - - let emittedResult: unknown; - central.on("node:connection:test", (result) => { - emittedResult = result; - }); - - const output = await central.connectToRemoteNode({ - name: "offline-node", - host: "offline.example", - port: 3000, - }); - - expect(output).toEqual({ result: connectionResult }); - expect(registerSpy).not.toHaveBeenCalled(); - expect(healthSpy).not.toHaveBeenCalled(); - expect(emittedResult).toEqual(connectionResult); - }); - - it("should start and stop discovery lifecycle", async () => { - const startSpy = vi.spyOn(NodeDiscovery.prototype, "start").mockImplementation(() => {}); - const stopSpy = vi.spyOn(NodeDiscovery.prototype, "stop").mockImplementation(() => {}); - const config: DiscoveryConfig = { - broadcast: true, - listen: true, - serviceType: "_fusion._tcp", - port: 4040, - staleTimeoutMs: 300_000, - }; - - const discovery = await central.startDiscovery(config); - const local = (await central.listNodes()).find((node) => node.type === "local"); - - expect(discovery).toBeInstanceOf(NodeDiscovery); - expect(startSpy).toHaveBeenCalledWith(local?.id, local?.name); - expect(central.isDiscoveryActive()).toBe(true); - expect(central.getDiscoveryConfig()).toEqual(config); - - central.stopDiscovery(); - - expect(stopSpy).toHaveBeenCalledTimes(1); - expect(central.isDiscoveryActive()).toBe(false); - expect(central.getDiscoveryConfig()).toBeNull(); - }); - - it("should forward discovery events and track discovered nodes", async () => { - vi.spyOn(NodeDiscovery.prototype, "start").mockImplementation(() => {}); - await central.startDiscovery({ - broadcast: false, - listen: true, - serviceType: "_fusion._tcp", - port: 4040, - staleTimeoutMs: 300_000, - }); - - const discovery = (central as unknown as { nodeDiscovery: NodeDiscovery | null }).nodeDiscovery; - expect(discovery).toBeTruthy(); - - const discovered: DiscoveredNode = { - name: "mesh-peer", - host: "192.168.0.42", - port: 4040, - nodeType: "remote", - nodeId: "node_remote", - discoveredAt: "2026-04-01T12:00:00.000Z", - lastSeenAt: "2026-04-01T12:00:00.000Z", - }; - - let eventPayload: DiscoveredNode | undefined; - central.on("discovery:node:found", (node) => { - eventPayload = node; - }); - - discovery!.emit("node:discovered", discovered); - await Promise.resolve(); - await Promise.resolve(); - - expect(eventPayload).toEqual(discovered); - expect(central.getDiscoveredNodes()).toEqual([discovered]); - - const updated = { - ...discovered, - lastSeenAt: "2026-04-01T12:01:00.000Z", - }; - discovery!.emit("node:updated", updated); - await Promise.resolve(); - await Promise.resolve(); - - expect(central.getDiscoveredNodes()).toEqual([updated]); - - let lostName: string | undefined; - central.on("discovery:node:lost", (name) => { - lostName = name; - }); - - discovery!.emit("node:lost", discovered.name); - await Promise.resolve(); - await Promise.resolve(); - - expect(lostName).toBe(discovered.name); - expect(central.getDiscoveredNodes()).toEqual([]); - }); - - it("should set registered nodes online/offline from discovery events", async () => { - vi.spyOn(NodeDiscovery.prototype, "start").mockImplementation(() => {}); - const remote = await central.registerNode({ - name: "remote-peer", - type: "remote", - url: "http://remote-peer:4040", - }); - - await central.startDiscovery({ - broadcast: false, - listen: true, - serviceType: "_fusion._tcp", - port: 4040, - staleTimeoutMs: 300_000, - }); - - const discovery = (central as unknown as { nodeDiscovery: NodeDiscovery | null }).nodeDiscovery; - expect(discovery).toBeTruthy(); - - discovery!.emit("node:discovered", { - name: "remote-peer", - host: "192.168.0.22", - port: 4040, - nodeType: "remote", - nodeId: "node_remote_peer", - discoveredAt: "2026-04-01T12:00:00.000Z", - lastSeenAt: "2026-04-01T12:00:00.000Z", - } satisfies DiscoveredNode); - await Promise.resolve(); - await Promise.resolve(); - - expect((await central.getNode(remote.id))?.status).toBe("online"); - expect(central.getDiscoveredNodes()).toEqual([]); - - discovery!.emit("node:lost", "remote-peer"); - await Promise.resolve(); - await Promise.resolve(); - expect((await central.getNode(remote.id))?.status).toBe("offline"); - }); - - it("should return empty discovered node list when discovery is inactive", () => { - expect(central.isDiscoveryActive()).toBe(false); - expect(central.getDiscoveredNodes()).toEqual([]); - expect(central.getDiscoveryConfig()).toBeNull(); - }); - - it("should update node metrics and emit node:metrics:updated", async () => { - const local = (await central.listNodes()).find((node) => node.type === "local"); - expect(local).toBeDefined(); - - const metrics: SystemMetrics = { - cpuUsage: 23, - memoryUsed: 200, - memoryTotal: 500, - storageUsed: 1_500, - storageTotal: 4_000, - uptime: 12_000, - reportedAt: "2026-04-01T12:00:00.000Z", - }; - - let eventPayload: { nodeId: string; metrics: SystemMetrics } | undefined; - central.on("node:metrics:updated", (payload) => { - eventPayload = payload; - }); - - const updated = await central.updateNodeMetrics(local!.id, metrics); - expect(updated.systemMetrics).toEqual(metrics); - expect(eventPayload).toEqual({ nodeId: local!.id, metrics }); - }); - - it("should register peer nodes, list peers, and keep knownPeers in sync", async () => { - const local = (await central.listNodes()).find((node) => node.type === "local"); - expect(local).toBeDefined(); - - const firstPeer = await central.registerPeerNode({ - nodeId: local!.id, - peerNodeId: "node_peer_b", - name: "Peer B", - url: "https://peer-b.example", - }); - const secondPeer = await central.registerPeerNode({ - nodeId: local!.id, - peerNodeId: "node_peer_a", - name: "Peer A", - url: "https://peer-a.example", - }); - - expect(firstPeer.peerNodeId).toBe("node_peer_b"); - expect(secondPeer.peerNodeId).toBe("node_peer_a"); - - const peers = await central.listPeers(local!.id); - expect(peers.map((peer) => peer.name)).toEqual(["Peer A", "Peer B"]); - - const storedNode = await central.getNode(local!.id); - expect(storedNode?.knownPeers).toEqual(expect.arrayContaining(["node_peer_a", "node_peer_b"])); - expect(storedNode?.knownPeers).toHaveLength(2); - }); - - it("should emit mesh:peer:added and mesh:peer:removed events", async () => { - const local = (await central.listNodes()).find((node) => node.type === "local"); - expect(local).toBeDefined(); - - let addedPayload: - | { - nodeId: string; - peer: { - peerNodeId: string; - }; - } - | undefined; - let removedPayload: { nodeId: string; peerNodeId: string } | undefined; - - central.on("mesh:peer:added", (payload) => { - addedPayload = payload as typeof addedPayload; - }); - central.on("mesh:peer:removed", (payload) => { - removedPayload = payload; - }); - - await central.registerPeerNode({ - nodeId: local!.id, - peerNodeId: "node_peer_event", - name: "Peer Event", - url: "https://peer-event.example", - }); - - expect(addedPayload?.nodeId).toBe(local!.id); - expect(addedPayload?.peer.peerNodeId).toBe("node_peer_event"); - - await central.unregisterPeerNode(local!.id, "node_peer_event"); - expect(removedPayload).toEqual({ nodeId: local!.id, peerNodeId: "node_peer_event" }); - }); - - it("should handle duplicate peer registration idempotently", async () => { - const local = (await central.listNodes()).find((node) => node.type === "local"); - expect(local).toBeDefined(); - - await central.registerPeerNode({ - nodeId: local!.id, - peerNodeId: "node_dup_peer", - name: "Peer Original", - url: "https://peer-original.example", - }); - await central.registerPeerNode({ - nodeId: local!.id, - peerNodeId: "node_dup_peer", - name: "Peer Updated", - url: "https://peer-updated.example", - }); - - const peers = await central.listPeers(local!.id); - expect(peers).toHaveLength(1); - expect(peers[0].peerNodeId).toBe("node_dup_peer"); - expect(peers[0].name).toBe("Peer Updated"); - - const node = await central.getNode(local!.id); - expect(node?.knownPeers).toEqual(["node_dup_peer"]); - }); - - it("should unregister peers and remove IDs from knownPeers", async () => { - const local = (await central.listNodes()).find((node) => node.type === "local"); - expect(local).toBeDefined(); - - await central.registerPeerNode({ - nodeId: local!.id, - peerNodeId: "node_peer_remove", - name: "Peer Remove", - url: "https://peer-remove.example", - }); - - await central.unregisterPeerNode(local!.id, "node_peer_remove"); - - const peers = await central.listPeers(local!.id); - expect(peers).toHaveLength(0); - - const node = await central.getNode(local!.id); - expect(node?.knownPeers ?? []).not.toContain("node_peer_remove"); - }); - - it("should return mesh state with metrics and peers", async () => { - const local = (await central.listNodes()).find((node) => node.type === "local"); - expect(local).toBeDefined(); - - const metrics: SystemMetrics = { - cpuUsage: 45, - memoryUsed: 100, - memoryTotal: 200, - storageUsed: 300, - storageTotal: 500, - uptime: 90_000, - reportedAt: "2026-04-01T12:00:00.000Z", - }; - - await central.updateNodeMetrics(local!.id, metrics); - await central.registerPeerNode({ - nodeId: local!.id, - peerNodeId: "node_mesh_peer", - name: "Mesh Peer", - url: "https://mesh-peer.example", - }); - - const state = await central.getMeshState(local!.id); - expect(state.nodeId).toBe(local!.id); - expect(state.nodeType).toBe("local"); - expect(state.metrics).toEqual(metrics); - expect(state.knownPeers).toHaveLength(1); - expect(state.knownPeers[0].peerNodeId).toBe("node_mesh_peer"); - }); - - it("should report local mesh state using collected system metrics", async () => { - const metrics: SystemMetrics = { - cpuUsage: 18, - memoryUsed: 150, - memoryTotal: 250, - storageUsed: 1_000, - storageTotal: 2_000, - uptime: 50_000, - reportedAt: "2026-04-01T12:00:00.000Z", - }; - const metricsSpy = vi.spyOn(systemMetrics, "collectSystemMetrics").mockResolvedValue(metrics); - - const state = await central.reportMeshState(); - - expect(metricsSpy).toHaveBeenCalledTimes(1); - expect(state.nodeName).toBe("local"); - expect(state.nodeType).toBe("local"); - expect(state.metrics).toEqual(metrics); - expect(state.knownPeers).toEqual([]); - }); - - it("should return local mesh snapshots for all known nodes", async () => { - const remoteNode = await central.registerNode({ - name: "Snapshot Remote", - type: "remote", - url: "https://snapshot-remote.example", - }); - - const snapshots = await central.getLocalMeshSnapshot(); - const remote = snapshots.find((entry) => entry.nodeId === remoteNode.id); - const local = snapshots.find((entry) => entry.nodeType === "local"); - - expect(local).toBeDefined(); - expect(remote).toBeDefined(); - expect(remote?.nodeType).toBe("remote"); - }); - - describe("mesh outage persistence", () => { - it("persists and reloads mesh snapshot records", async () => { - await central.recordMeshSnapshot({ - nodeId: "node-a", - projectId: "proj-1", - scope: "mesh.state", - payload: { value: 1 }, - snapshotVersion: "a".repeat(64), - capturedAt: "2026-05-10T00:00:00.000Z", - sourceNodeId: "node-b", - }); - - const loaded = await central.getLatestMeshSnapshot({ nodeId: "node-a", projectId: "proj-1", scope: "mesh.state" }); - expect(loaded?.payload).toEqual({ value: 1 }); - expect(loaded?.snapshotVersion).toBe("a".repeat(64)); - expect(loaded?.sourceNodeId).toBe("node-b"); - }); - - it("supports queue lifecycle transitions and filters", async () => { - const entry = await central.enqueueMeshWrite({ - originNodeId: "origin-1", - targetNodeId: "target-1", - projectId: "proj-1", - scope: "mesh.settings", - entityType: "project-settings", - entityId: "settings", - operation: "upsert", - payload: { ok: true }, - intentVersion: "v1", - }); - - expect(entry.status).toBe("pending"); - - const replaying = await central.markMeshWriteReplayStarted(entry.id); - expect(replaying.status).toBe("replaying"); - expect(replaying.attemptCount).toBe(1); - - const failed = await central.markMeshWriteFailed(entry.id, { lastError: "timeout" }); - expect(failed.status).toBe("failed"); - expect(failed.lastError).toBe("timeout"); - - const failedRows = await central.listPendingMeshWrites({ targetNodeId: "target-1", status: "failed" }); - expect(failedRows.map((row) => row.id)).toContain(entry.id); - - const applied = await central.markMeshWriteApplied(entry.id, {}); - expect(applied.status).toBe("applied"); - expect(applied.appliedAt).toBeTruthy(); - }); - - it("computes degraded read state from durable snapshot and queue", async () => { - await central.recordMeshSnapshot({ - nodeId: "node-degraded", - scope: "mesh.tasks", - payload: { tasks: [] }, - snapshotVersion: "b".repeat(64), - capturedAt: new Date(Date.now() - 5_000).toISOString(), - sourceNodeId: "node-source", - }); - - await central.enqueueMeshWrite({ - originNodeId: "origin-2", - targetNodeId: "target-2", - scope: "mesh.tasks", - entityType: "task", - entityId: "T-1", - operation: "create", - payload: { id: "T-1" }, - intentVersion: "v1", - }); - - const state = await central.getMeshDegradedReadState({ nodeId: "node-degraded", scope: "mesh.tasks" }); - expect(state.mode).toBe("degraded"); - expect(state.sourceNodeId).toBe("node-source"); - expect(state.snapshotVersion).toBe("b".repeat(64)); - expect(state.stalenessMs).toBeGreaterThanOrEqual(0); - expect(state.queueDepth).toBeGreaterThanOrEqual(1); - }); - - it("keeps queue and snapshots across close and re-init", async () => { - const initial = new CentralCore(tempDir); - await initial.init(); - await initial.recordMeshSnapshot({ - nodeId: "node-restart", - scope: "mesh.restart", - payload: { restart: true }, - snapshotVersion: "c".repeat(64), - capturedAt: "2026-05-10T00:00:00.000Z", - }); - const queued = await initial.enqueueMeshWrite({ - originNodeId: "origin-restart", - targetNodeId: "target-restart", - scope: "mesh.restart", - entityType: "task", - entityId: "R-1", - operation: "update", - payload: { id: "R-1" }, - intentVersion: "v1", - }); - await initial.close(); - - const restarted = new CentralCore(tempDir); - await restarted.init(); - const snapshot = await restarted.getLatestMeshSnapshot({ nodeId: "node-restart", scope: "mesh.restart" }); - const queue = await restarted.listPendingMeshWrites({ targetNodeId: "target-restart" }); - expect(snapshot?.payload).toEqual({ restart: true }); - expect(queue.map((row) => row.id)).toContain(queued.id); - await restarted.close(); - }); - }); - - describe("peer exchange methods", () => { - it("should register a gossip peer and preserve its nodeId", async () => { - const peerInfo = { - nodeId: "node_remote_gossip", - nodeName: "Gossip Peer", - nodeUrl: "https://gossip.example.com", - status: "online" as const, - metrics: null, - lastSeen: "2026-04-01T12:00:00.000Z", - maxConcurrent: 3, - }; - - const registered = await central.registerGossipPeer(peerInfo); - - expect(registered.id).toBe("node_remote_gossip"); - expect(registered.name).toBe("Gossip Peer"); - expect(registered.type).toBe("remote"); - expect(registered.url).toBe("https://gossip.example.com"); - expect(registered.status).toBe("online"); - expect(registered.maxConcurrent).toBe(3); - - // Verify it can be retrieved by the preserved ID - const fetched = await central.getNode("node_remote_gossip"); - expect(fetched?.id).toBe("node_remote_gossip"); - }); - - it("should handle duplicate peer names by appending suffix", async () => { - // First, register a local node with the same name - await central.registerNode({ name: "Same Name", type: "local" }); - - const peerInfo = { - nodeId: "node_same_1", - nodeName: "Same Name", - nodeUrl: "https://same1.example.com", - status: "online" as const, - metrics: null, - lastSeen: "2026-04-01T12:00:00.000Z", - maxConcurrent: 2, - }; - - const registered = await central.registerGossipPeer(peerInfo); - - // Should have suffix added to avoid collision - expect(registered.name).toBe("Same Name-2"); - }); - - it("should merge peers - add new peers", async () => { - const peerInfo = { - nodeId: "node_new_peer", - nodeName: "New Peer", - nodeUrl: "https://new-peer.example.com", - status: "online" as const, - metrics: null, - lastSeen: "2026-04-01T12:00:00.000Z", - maxConcurrent: 2, - }; - - const result = await central.mergePeers([peerInfo]); - - expect(result.added).toContain("node_new_peer"); - expect(result.updated).toEqual([]); - expect(await central.getNode("node_new_peer")).toBeDefined(); - }); - - it("should merge peers - update stale peers", async () => { - // First, register a peer - const peerInfo = { - nodeId: "node_stale_peer", - nodeName: "Stale Peer", - nodeUrl: "https://stale-peer.example.com", - status: "offline" as const, - metrics: null, - lastSeen: "2026-04-01T11:00:00.000Z", - maxConcurrent: 2, - }; - await central.registerGossipPeer(peerInfo); - - // Now merge with fresher data - const fresherPeer = { - ...peerInfo, - status: "online" as const, - lastSeen: "2026-04-01T12:30:00.000Z", - }; - - const result = await central.mergePeers([fresherPeer]); - - expect(result.added).toEqual([]); - expect(result.updated).toContain("node_stale_peer"); - const updated = await central.getNode("node_stale_peer"); - expect(updated?.status).toBe("online"); - }); - - it("should merge peers - skip fresher local data", async () => { - // First, register a peer - const peerInfo = { - nodeId: "node_fresher_local", - nodeName: "Fresher Local", - nodeUrl: "https://fresher-local.example.com", - status: "online" as const, - metrics: null, - lastSeen: "2026-04-01T11:00:00.000Z", - maxConcurrent: 2, - }; - await central.registerGossipPeer(peerInfo); - - // Manually update to be fresher - await central.updateNode("node_fresher_local", { - status: "offline", - }); - - // Now merge with older data - should not update - const olderPeer = { - ...peerInfo, - status: "online" as const, - lastSeen: "2026-04-01T10:00:00.000Z", - }; - - const result = await central.mergePeers([olderPeer]); - - expect(result.updated).toEqual([]); - const updated = await central.getNode("node_fresher_local"); - expect(updated?.status).toBe("offline"); - }); - - it("should merge peers - never overwrite local node", async () => { - const local = (await central.listNodes()).find((node) => node.type === "local"); - expect(local).toBeDefined(); - - // Create a fake peer info with the local node's ID - const fakePeerInfo = { - nodeId: local!.id, - nodeName: "Fake Local", - nodeUrl: "https://fake-local.example.com", - status: "online" as const, - metrics: null, - lastSeen: "2026-04-01T12:00:00.000Z", - maxConcurrent: 10, - }; - - const result = await central.mergePeers([fakePeerInfo]); - - // Should not add or update - expect(result.added).toEqual([]); - expect(result.updated).toEqual([]); - - // Local node should be unchanged - const unchanged = await central.getNode(local!.id); - expect(unchanged?.maxConcurrent).toBe(4); // Default local node maxConcurrent - }); - - it("should merge peers - emit events correctly", async () => { - let gossipEvent: { nodeId: string; peer: unknown } | undefined; - let stateChangedEvent: { nodeId: string } | undefined; - - central.on("gossip:peer:registered", (payload) => { - gossipEvent = payload; - }); - central.on("mesh:state:changed", (payload) => { - stateChangedEvent = payload; - }); - - const peerInfo = { - nodeId: "node_event_peer", - nodeName: "Event Peer", - nodeUrl: "https://event-peer.example.com", - status: "online" as const, - metrics: null, - lastSeen: "2026-04-01T12:00:00.000Z", - maxConcurrent: 2, - }; - - await central.mergePeers([peerInfo]); - - expect(gossipEvent?.nodeId).toBe("node_event_peer"); - expect(stateChangedEvent?.nodeId).toBeDefined(); - }); - - it("should merge peers - empty input returns empty result", async () => { - const result = await central.mergePeers([]); - - expect(result.added).toEqual([]); - expect(result.updated).toEqual([]); - }); - - it("should get local peer info", async () => { - const peerInfo = await central.getLocalPeerInfo(); - - expect(peerInfo.nodeId).toBeDefined(); - expect(peerInfo.nodeName).toBe("local"); - expect(peerInfo.nodeUrl).toBe(""); - expect(peerInfo.status).toBe("online"); - expect(peerInfo.lastSeen).toBe("2026-04-01T12:00:00.000Z"); - expect(peerInfo.maxConcurrent).toBe(4); - }); - - it("should get all known peer info", async () => { - // Register some peers - await central.registerGossipPeer({ - nodeId: "node_all_peer_1", - nodeName: "All Peer 1", - nodeUrl: "https://all-peer-1.example.com", - status: "online" as const, - metrics: null, - lastSeen: "2026-04-01T12:00:00.000Z", - maxConcurrent: 2, - }); - - await central.registerGossipPeer({ - nodeId: "node_all_peer_2", - nodeName: "All Peer 2", - nodeUrl: "https://all-peer-2.example.com", - status: "offline" as const, - metrics: null, - lastSeen: "2026-04-01T11:00:00.000Z", - maxConcurrent: 3, - }); - - const allPeers = await central.getAllKnownPeerInfo(); - - // Should include local node plus 2 registered peers - expect(allPeers.length).toBeGreaterThanOrEqual(3); - expect(allPeers.map((p) => p.nodeId)).toContain("node_all_peer_1"); - expect(allPeers.map((p) => p.nodeId)).toContain("node_all_peer_2"); - }); - - it("should get all known peer info - empty list", async () => { - // Don't register any peers, just check the local node - const allPeers = await central.getAllKnownPeerInfo(); - - // Should at least include the local node - expect(allPeers.length).toBeGreaterThanOrEqual(1); - expect(allPeers.some((p) => p.nodeName === "local")).toBe(true); - }); - }); - }); - - describe("node version sync", () => { - beforeEach(async () => { - await central.init(); - }); - - describe("updateNodeVersionInfo", () => { - it("should store version info on a node", async () => { - const node = await central.registerNode({ name: "version-node", type: "local" }); - - const versionInfo = { - appVersion: "0.1.0", - pluginVersions: { "plugin-a": "1.0.0", "plugin-b": "2.0.0" }, - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }; - - const updated = await central.updateNodeVersionInfo(node.id, versionInfo); - - expect(updated.versionInfo).toBeDefined(); - expect(updated.versionInfo?.appVersion).toBe("0.1.0"); - expect(updated.versionInfo?.pluginVersions).toEqual({ "plugin-a": "1.0.0", "plugin-b": "2.0.0" }); - expect(updated.pluginVersions).toEqual({ "plugin-a": "1.0.0", "plugin-b": "2.0.0" }); - }); - - it("should auto-fill appVersion if not provided", async () => { - const node = await central.registerNode({ name: "auto-version-node", type: "local" }); - - const versionInfo = { - pluginVersions: { "plugin-a": "1.0.0" }, - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }; - - const updated = await central.updateNodeVersionInfo(node.id, versionInfo); - - expect(updated.versionInfo?.appVersion).toBe(getAppVersion()); - }); - - it("should emit node:version:updated and node:updated events", async () => { - const node = await central.registerNode({ name: "event-node", type: "local" }); - - let versionEmitted = false; - let nodeEmitted = false; - central.on("node:version:updated", () => { - versionEmitted = true; - }); - central.on("node:updated", () => { - nodeEmitted = true; - }); - - await central.updateNodeVersionInfo(node.id, { - appVersion: "0.1.0", - pluginVersions: {}, - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }); - - expect(versionEmitted).toBe(true); - expect(nodeEmitted).toBe(true); - }); - - it("should throw if node not found", async () => { - await expect( - central.updateNodeVersionInfo("node_missing", { - appVersion: "0.1.0", - pluginVersions: {}, - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }), - ).rejects.toThrow("Node not found"); - }); - }); - - describe("getNodeVersionInfo", () => { - it("should return stored version info", async () => { - const node = await central.registerNode({ name: "get-version-node", type: "local" }); - - await central.updateNodeVersionInfo(node.id, { - appVersion: "0.2.0", - pluginVersions: { "plugin-c": "1.5.0" }, - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }); - - const versionInfo = await central.getNodeVersionInfo(node.id); - - expect(versionInfo).toBeDefined(); - expect(versionInfo?.appVersion).toBe("0.2.0"); - expect(versionInfo?.pluginVersions).toEqual({ "plugin-c": "1.5.0" }); - }); - - it("should return undefined if not set", async () => { - const node = await central.registerNode({ name: "no-version-node", type: "local" }); - - const versionInfo = await central.getNodeVersionInfo(node.id); - - expect(versionInfo).toBeUndefined(); - }); - }); - - describe("syncPlugins", () => { - it("should return no-action for matching versions", async () => { - const node1 = await central.registerNode({ name: "sync-node-1", type: "local" }); - const node2 = await central.registerNode({ name: "sync-node-2", type: "local" }); - - await central.updateNodeVersionInfo(node1.id, { - appVersion: "0.1.0", - pluginVersions: { "plugin-a": "1.0.0" }, - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }); - - await central.updateNodeVersionInfo(node2.id, { - appVersion: "0.1.0", - pluginVersions: { "plugin-a": "1.0.0" }, - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }); - - const result = await central.syncPlugins(node1.id, node2.id); - - expect(result.isCompatible).toBe(true); - expect(result.plugins).toHaveLength(1); - expect(result.plugins[0].action).toBe("no-action"); - }); - - it("should return install action for missing plugins", async () => { - const node1 = await central.registerNode({ name: "install-node-1", type: "local" }); - const node2 = await central.registerNode({ name: "install-node-2", type: "local" }); - - await central.updateNodeVersionInfo(node1.id, { - appVersion: "0.1.0", - pluginVersions: { "plugin-a": "1.0.0", "plugin-b": "2.0.0" }, - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }); - - await central.updateNodeVersionInfo(node2.id, { - appVersion: "0.1.0", - pluginVersions: { "plugin-a": "1.0.0" }, - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }); - - const result = await central.syncPlugins(node1.id, node2.id); - - expect(result.isCompatible).toBe(false); - const pluginB = result.plugins.find((p) => p.pluginId === "plugin-b"); - expect(pluginB?.action).toBe("install"); - expect(pluginB?.targetVersion).toBe("2.0.0"); - }); - - it("should return update action for version differences", async () => { - const node1 = await central.registerNode({ name: "update-node-1", type: "local" }); - const node2 = await central.registerNode({ name: "update-node-2", type: "local" }); - - await central.updateNodeVersionInfo(node1.id, { - appVersion: "0.1.0", - pluginVersions: { "plugin-a": "2.0.0" }, - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }); - - await central.updateNodeVersionInfo(node2.id, { - appVersion: "0.1.0", - pluginVersions: { "plugin-a": "1.0.0" }, - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }); - - const result = await central.syncPlugins(node1.id, node2.id); - - expect(result.isCompatible).toBe(false); - const pluginA = result.plugins.find((p) => p.pluginId === "plugin-a"); - expect(pluginA?.action).toBe("update"); - }); - - it("should handle nodes with no version info", async () => { - const node1 = await central.registerNode({ name: "empty-node-1", type: "local" }); - const node2 = await central.registerNode({ name: "empty-node-2", type: "local" }); - - const result = await central.syncPlugins(node1.id, node2.id); - - expect(result.isCompatible).toBe(true); - expect(result.plugins).toHaveLength(0); - }); - - it("should emit node:plugins:synced event", async () => { - const node1 = await central.registerNode({ name: "event-sync-1", type: "local" }); - const node2 = await central.registerNode({ name: "event-sync-2", type: "local" }); - - let emittedResult: unknown; - central.on("node:plugins:synced", (result) => { - emittedResult = result; - }); - - await central.syncPlugins(node1.id, node2.id); - - expect(emittedResult).toBeDefined(); - }); - - it("should throw if either node not found", async () => { - const node = await central.registerNode({ name: "partial-node", type: "local" }); - - await expect(central.syncPlugins(node.id, "node_missing")).rejects.toThrow( - "Remote node not found", - ); - - await expect(central.syncPlugins("node_missing", node.id)).rejects.toThrow( - "Local node not found", - ); - }); - }); - - describe("checkVersionCompatibility", () => { - it("should return compatible for identical versions", () => { - const result = central.checkVersionCompatibility("1.2.3", "1.2.3"); - - expect(result.status).toBe("compatible"); - expect(result.message).toContain("match"); - }); - - it("should return compatible for patch-only differences", () => { - const result = central.checkVersionCompatibility("1.2.3", "1.2.4"); - - expect(result.status).toBe("compatible"); - expect(result.message).toContain("Patch"); - }); - - it("should return minor-difference for minor version mismatch", () => { - const result = central.checkVersionCompatibility("1.2.3", "1.3.0"); - - expect(result.status).toBe("minor-difference"); - expect(result.message).toContain("Minor"); - }); - - it("should return major-difference for major version mismatch", () => { - const result = central.checkVersionCompatibility("1.2.3", "2.0.0"); - - expect(result.status).toBe("major-difference"); - expect(result.message).toContain("Major"); - }); - - it("should return incompatible for invalid version strings", () => { - const result = central.checkVersionCompatibility("invalid", "1.0.0"); - - expect(result.status).toBe("incompatible"); - expect(result.message).toContain("Invalid"); - }); - - it("should handle prerelease versions", () => { - const result = central.checkVersionCompatibility("1.2.3-beta.1", "1.2.3-beta.2"); - - expect(result.status).toBe("compatible"); - }); - }); - }); - - describe("project health", () => { - beforeEach(async () => { - await central.init(); - }); - - it("should update health metrics", async () => { - const projectPath = join(tempDir, "health-update"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Health Update", - path: projectPath, - }); - - const updated = await central.updateProjectHealth(project.id, { - activeTaskCount: 5, - inFlightAgentCount: 2, - status: "active", - }); - - expect(updated.activeTaskCount).toBe(5); - expect(updated.inFlightAgentCount).toBe(2); - expect(updated.status).toBe("active"); - }); - - it("should emit project:health:changed event", async () => { - const projectPath = join(tempDir, "health-event"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Health Event", - path: projectPath, - }); - - let emittedHealth: ProjectHealth | undefined; - central.on("project:health:changed", (h) => { - emittedHealth = h; - }); - - await central.updateProjectHealth(project.id, { activeTaskCount: 3 }); - - expect(emittedHealth).toBeDefined(); - expect(emittedHealth?.activeTaskCount).toBe(3); - }); - - it("should record successful task completion", async () => { - const projectPath = join(tempDir, "complete-task"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Complete Task", - path: projectPath, - }); - - await central.recordTaskCompletion(project.id, 5000, true); - - const health = await central.getProjectHealth(project.id); - expect(health?.totalTasksCompleted).toBe(1); - expect(health?.totalTasksFailed).toBe(0); - expect(health?.averageTaskDurationMs).toBe(5000); - }); - - it("should record failed task completion", async () => { - const projectPath = join(tempDir, "fail-task"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Fail Task", - path: projectPath, - }); - - await central.recordTaskCompletion(project.id, 3000, false); - - const health = await central.getProjectHealth(project.id); - expect(health?.totalTasksCompleted).toBe(0); - expect(health?.totalTasksFailed).toBe(1); - // Average duration should not be updated for failures - expect(health?.averageTaskDurationMs).toBeUndefined(); - }); - - it("should calculate rolling average duration", async () => { - const projectPath = join(tempDir, "rolling-avg"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Rolling Avg", - path: projectPath, - }); - - await central.recordTaskCompletion(project.id, 1000, true); - await central.recordTaskCompletion(project.id, 2000, true); - await central.recordTaskCompletion(project.id, 3000, true); - - const health = await central.getProjectHealth(project.id); - expect(health?.totalTasksCompleted).toBe(3); - // Average of 1000, 2000, 3000 = 2000 - expect(health?.averageTaskDurationMs).toBe(2000); - }); - - it("should list all health records", async () => { - const projects: RegisteredProject[] = []; - for (let i = 0; i < 3; i++) { - const projectPath = join(tempDir, `health-list-${i}`); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: `Health ${i}`, - path: projectPath, - }); - projects.push(project); - } - - const allHealth = await central.listAllHealth(); - expect(allHealth).toHaveLength(3); - }); - }); - - describe("unified activity feed", () => { - beforeEach(async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-01T12:00:00.000Z")); - await central.init(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("should log activity with auto-generated id", async () => { - const projectPath = join(tempDir, "activity-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Activity Test", - path: projectPath, - }); - - const entry = await central.logActivity({ - type: "task:created", - projectId: project.id, - projectName: project.name, - timestamp: new Date().toISOString(), - details: "Task created", - }); - - expect(entry.id).toMatch(/^[0-9a-f-]+$/); // UUID format - expect(entry.type).toBe("task:created"); - }); - - it("should update project lastActivityAt on log", async () => { - const projectPath = join(tempDir, "activity-update"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Activity Update", - path: projectPath, - }); - - const beforeActivity = project.lastActivityAt; - - vi.setSystemTime(new Date("2026-04-01T12:00:00.010Z")); - - await central.logActivity({ - type: "task:moved", - projectId: project.id, - projectName: project.name, - timestamp: new Date().toISOString(), - details: "Task moved", - }); - - const updated = await central.getProject(project.id); - expect(updated?.lastActivityAt).not.toBe(beforeActivity); - }); - - it("should emit activity:logged event", async () => { - const projectPath = join(tempDir, "activity-event"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Activity Event", - path: projectPath, - }); - - let emittedEntry: CentralActivityLogEntry | undefined; - central.on("activity:logged", (e) => { - emittedEntry = e; - }); - - await central.logActivity({ - type: "task:created", - projectId: project.id, - projectName: project.name, - timestamp: new Date().toISOString(), - details: "Event test", - }); - - expect(emittedEntry).toBeDefined(); - expect(emittedEntry?.details).toBe("Event test"); - }); - - it("should get recent activity with default limit", async () => { - const projectPath = join(tempDir, "recent-activity"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Recent Activity", - path: projectPath, - }); - - // Log 150 activities - for (let i = 0; i < 150; i++) { - await central.logActivity({ - type: "task:created", - projectId: project.id, - projectName: project.name, - timestamp: new Date().toISOString(), - details: `Activity ${i}`, - }); - } - - const recent = await central.getRecentActivity(); - expect(recent).toHaveLength(100); // Default limit - // Should be newest first - expect(recent[0].details).toBe("Activity 149"); - expect(recent[99].details).toBe("Activity 50"); - }); - - it("should filter activity by project", async () => { - const projectPath1 = join(tempDir, "filter-project-1"); - const projectPath2 = join(tempDir, "filter-project-2"); - mkdirSync(projectPath1); - mkdirSync(projectPath2); - projectPaths.push(projectPath1, projectPath2); - - const project1 = await central.registerProject({ - name: "Filter 1", - path: projectPath1, - }); - const project2 = await central.registerProject({ - name: "Filter 2", - path: projectPath2, - }); - - await central.logActivity({ - type: "task:created", - projectId: project1.id, - projectName: project1.name, - timestamp: new Date().toISOString(), - details: "Project 1 activity", - }); - - await central.logActivity({ - type: "task:created", - projectId: project2.id, - projectName: project2.name, - timestamp: new Date().toISOString(), - details: "Project 2 activity", - }); - - const p1Activities = await central.getRecentActivity({ projectId: project1.id }); - expect(p1Activities).toHaveLength(1); - expect(p1Activities[0].details).toBe("Project 1 activity"); - }); - - it("should filter activity by type", async () => { - const projectPath = join(tempDir, "type-filter"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Type Filter", - path: projectPath, - }); - - await central.logActivity({ - type: "task:created", - projectId: project.id, - projectName: project.name, - timestamp: new Date().toISOString(), - details: "Created", - }); - - await central.logActivity({ - type: "task:moved", - projectId: project.id, - projectName: project.name, - timestamp: new Date().toISOString(), - details: "Moved", - }); - - const createdActivities = await central.getRecentActivity({ - types: ["task:created"], - }); - expect(createdActivities).toHaveLength(1); - expect(createdActivities[0].details).toBe("Created"); - }); - - it("should get activity count", async () => { - const projectPath = join(tempDir, "count-activity"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Count Activity", - path: projectPath, - }); - - for (let i = 0; i < 5; i++) { - await central.logActivity({ - type: "task:created", - projectId: project.id, - projectName: project.name, - timestamp: new Date().toISOString(), - details: `Count ${i}`, - }); - } - - const totalCount = await central.getActivityCount(); - expect(totalCount).toBe(5); - - const projectCount = await central.getActivityCount(project.id); - expect(projectCount).toBe(5); - }); - - it("should cleanup only entries older than the cutoff and retain the exact boundary", async () => { - const projectPath = join(tempDir, "cleanup-activity"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Cleanup Activity", - path: projectPath, - }); - - const now = new Date("2026-04-01T12:00:00.000Z"); - vi.setSystemTime(now); - - const olderThanCutoff = new Date("2026-03-31T11:59:59.999Z").toISOString(); - const exactlyAtCutoff = new Date("2026-03-31T12:00:00.000Z").toISOString(); - const newerThanCutoff = new Date("2026-03-31T12:00:00.001Z").toISOString(); - - await central.logActivity({ - type: "task:created", - projectId: project.id, - projectName: project.name, - timestamp: olderThanCutoff, - details: "Older than cutoff", - }); - - await central.logActivity({ - type: "task:moved", - projectId: project.id, - projectName: project.name, - timestamp: exactlyAtCutoff, - details: "Exactly at cutoff", - }); - - await central.logActivity({ - type: "task:updated", - projectId: project.id, - projectName: project.name, - timestamp: newerThanCutoff, - details: "Newer than cutoff", - }); - - const deleted = await central.cleanupOldActivity(1); - expect(deleted).toBe(1); - - const countAfter = await central.getActivityCount(); - expect(countAfter).toBe(2); - - const remaining = await central.getRecentActivity({ limit: 10, projectId: project.id }); - expect(remaining.map((entry) => entry.details)).toEqual([ - "Newer than cutoff", - "Exactly at cutoff", - ]); - expect(remaining.map((entry) => entry.timestamp)).toEqual([ - newerThanCutoff, - exactlyAtCutoff, - ]); - }); - }); - - describe("default project setting", () => { - beforeEach(async () => { - await central.init(); - }); - - it("should be undefined by default", async () => { - await expect(central.getDefaultProjectId()).resolves.toBeUndefined(); - }); - - it("should set/get and clear default project id", async () => { - const projectPath = join(tempDir, "default-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Default Project", - path: projectPath, - }); - - await central.setDefaultProjectId(project.id); - await expect(central.getDefaultProjectId()).resolves.toBe(project.id); - - await central.setDefaultProjectId(null); - await expect(central.getDefaultProjectId()).resolves.toBeUndefined(); - }); - - it("should reject unknown project id", async () => { - await expect(central.setDefaultProjectId("missing-project-id")).rejects.toThrow( - "Cannot set default project: project not found: missing-project-id", - ); - }); - - it("should persist setting across reopen", async () => { - const projectPath = join(tempDir, "persisted-default-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Persisted Default Project", - path: projectPath, - }); - - await central.setDefaultProjectId(project.id); - await central.close(); - - central = new CentralCore(tempDir); - await central.init(); - - await expect(central.getDefaultProjectId()).resolves.toBe(project.id); - }); - }); - - describe("global concurrency", () => { - beforeEach(async () => { - await central.init(); - }); - - it("should get initial concurrency state", async () => { - const state = await central.getGlobalConcurrencyState(); - expect(state.globalMaxConcurrent).toBe(4); - expect(state.currentlyActive).toBe(0); - expect(state.queuedCount).toBe(0); - expect(state.projectsActive).toEqual({}); - }); - - it("should update global max concurrent", async () => { - await central.updateGlobalConcurrency({ globalMaxConcurrent: 8 }); - - const state = await central.getGlobalConcurrencyState(); - expect(state.globalMaxConcurrent).toBe(8); - }); - - it("should emit concurrency:changed event on update", async () => { - let emittedState: GlobalConcurrencyState | undefined; - central.on("concurrency:changed", (s) => { - emittedState = s; - }); - - await central.updateGlobalConcurrency({ globalMaxConcurrent: 6 }); - - expect(emittedState).toBeDefined(); - expect(emittedState?.globalMaxConcurrent).toBe(6); - }); - - it("should acquire slot when available", async () => { - const projectPath = join(tempDir, "acquire-slot"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Acquire Slot", - path: projectPath, - }); - - const acquired = await central.acquireGlobalSlot(project.id); - expect(acquired).toBe(true); - - const state = await central.getGlobalConcurrencyState(); - expect(state.currentlyActive).toBe(1); - expect(state.projectsActive[project.id]).toBe(1); - }); - - it("should fail to acquire when at limit", async () => { - const projectPath = join(tempDir, "at-limit"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "At Limit", - path: projectPath, - }); - - // Set limit to 1 - await central.updateGlobalConcurrency({ globalMaxConcurrent: 1 }); - - // First acquire succeeds - const first = await central.acquireGlobalSlot(project.id); - expect(first).toBe(true); - - // Second acquire fails (queued) - const second = await central.acquireGlobalSlot(project.id); - expect(second).toBe(false); - - const state = await central.getGlobalConcurrencyState(); - expect(state.currentlyActive).toBe(1); - expect(state.queuedCount).toBe(1); - }); - - it("should release slot", async () => { - const projectPath = join(tempDir, "release-slot"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Release Slot", - path: projectPath, - }); - - await central.acquireGlobalSlot(project.id); - await central.releaseGlobalSlot(project.id); - - const state = await central.getGlobalConcurrencyState(); - expect(state.currentlyActive).toBe(0); - expect(state.projectsActive[project.id]).toBeUndefined(); - }); - - it("should track per-project active counts", async () => { - const projectPath1 = join(tempDir, "multi-1"); - const projectPath2 = join(tempDir, "multi-2"); - mkdirSync(projectPath1); - mkdirSync(projectPath2); - projectPaths.push(projectPath1, projectPath2); - - const project1 = await central.registerProject({ - name: "Multi 1", - path: projectPath1, - }); - const project2 = await central.registerProject({ - name: "Multi 2", - path: projectPath2, - }); - - await central.acquireGlobalSlot(project1.id); - await central.acquireGlobalSlot(project1.id); - await central.acquireGlobalSlot(project2.id); - - const state = await central.getGlobalConcurrencyState(); - expect(state.currentlyActive).toBe(3); - expect(state.projectsActive[project1.id]).toBe(2); - expect(state.projectsActive[project2.id]).toBe(1); - }); - - it("should throw when acquiring for non-existent project", async () => { - await expect(central.acquireGlobalSlot("nonexistent")).rejects.toThrow("not found"); - }); - - it("should throw when releasing for non-existent project", async () => { - await expect(central.releaseGlobalSlot("nonexistent")).rejects.toThrow("not found"); - }); - - async function registerProjectForLiveCount(name: string) { - const projectPath = join(tempDir, name); - mkdirSync(projectPath); - projectPaths.push(projectPath); - return central.registerProject({ name, path: projectPath }); - } - - it("derives live running-agent counts from a side-effect-safe source across project data states", async () => { - const projectA = await registerProjectForLiveCount("live-count-a"); - const projectB = await registerProjectForLiveCount("live-count-b"); - const unopenedProject = await registerProjectForLiveCount("live-count-unopened"); - await central.updateGlobalConcurrency({ globalMaxConcurrent: 2 }); - - const source = vi.fn(async (projectIds: readonly string[]) => { - expect(projectIds).toEqual([projectA.id, projectB.id, unopenedProject.id]); - return { - [projectA.id]: 3, - [projectB.id]: 2, - [unopenedProject.id]: 0, - }; - }); - - const counts = await central.getLiveRunningAgentCounts({ source }); - - expect(source).toHaveBeenCalledOnce(); - expect(counts).toEqual({ - currentlyActive: 5, - projectsActive: { - [projectA.id]: 3, - [projectB.id]: 2, - }, - }); - expect(counts.currentlyActive).toBeGreaterThan(2); - }); - - it.each([ - { name: "zero in-progress", perProject: { a: 0 }, expected: { currentlyActive: 0, projectsActive: {} } }, - { name: "one in-progress", perProject: { a: 1 }, expected: { currentlyActive: 1, projectsActive: { a: 1 } } }, - { name: "multiple in one project", perProject: { a: 4 }, expected: { currentlyActive: 4, projectsActive: { a: 4 } } }, - { name: "multiple projects", perProject: { a: 2, b: 3, c: 0 }, expected: { currentlyActive: 5, projectsActive: { a: 2, b: 3 } } }, - ])("normalizes live running-agent count data state: $name", async ({ perProject, expected }) => { - await registerProjectForLiveCount("live-count-state"); - - await expect(central.getLiveRunningAgentCounts({ source: async () => perProject })).resolves.toEqual(expected); - }); - - it("falls back to persisted slot and health bookkeeping when no live source is registered", async () => { - const project = await registerProjectForLiveCount("live-count-fallback"); - await central.acquireGlobalSlot(project.id); - await central.acquireGlobalSlot(project.id); - - const persisted = await central.getGlobalConcurrencyState(); - const counts = await central.getLiveRunningAgentCounts(); - - expect(counts).toEqual({ - currentlyActive: persisted.currentlyActive, - projectsActive: persisted.projectsActive, - }); - expect(counts.projectsActive).toEqual({ [project.id]: 2 }); - }); - - it("does not mutate slot or health bookkeeping during a live-count read", async () => { - const project = await registerProjectForLiveCount("live-count-no-mutation"); - await central.acquireGlobalSlot(project.id); - await central.updateGlobalConcurrency({ queuedCount: 4 }); - const beforeGlobal = await central.getGlobalConcurrencyState(); - const beforeHealth = await central.getProjectHealth(project.id); - const watchSpy = vi.fn(); - const engineStartSpy = vi.fn(); - - const counts = await central.getLiveRunningAgentCounts({ - source: async () => { - expect(watchSpy).not.toHaveBeenCalled(); - expect(engineStartSpy).not.toHaveBeenCalled(); - return { [project.id]: 7 }; - }, - }); - - expect(counts).toEqual({ currentlyActive: 7, projectsActive: { [project.id]: 7 } }); - expect(await central.getGlobalConcurrencyState()).toEqual(beforeGlobal); - expect(await central.getProjectHealth(project.id)).toEqual(beforeHealth); - expect(watchSpy).not.toHaveBeenCalled(); - expect(engineStartSpy).not.toHaveBeenCalled(); - }); - - it("keeps acquire and release slot bookkeeping isolated from live count reads", async () => { - const project = await registerProjectForLiveCount("live-count-limiter-isolation"); - const source = vi.fn(async () => ({ [project.id]: 5 })); - await central.acquireGlobalSlot(project.id); - - expect((await central.getGlobalConcurrencyState()).currentlyActive).toBe(1); - expect(await central.getLiveRunningAgentCounts({ source })).toEqual({ - currentlyActive: 5, - projectsActive: { [project.id]: 5 }, - }); - - await central.releaseGlobalSlot(project.id); - expect((await central.getGlobalConcurrencyState()).currentlyActive).toBe(0); - expect(await central.getLiveRunningAgentCounts({ source })).toEqual({ - currentlyActive: 5, - projectsActive: { [project.id]: 5 }, - }); - }); - }); - - describe("utility methods", () => { - beforeEach(async () => { - await central.init(); - }); - - it("should get database path", async () => { - const path = central.getDatabasePath(); - expect(path).toBe(join(tempDir, "fusion-central.db")); - }); - - it("should get global directory", async () => { - const dir = central.getGlobalDir(); - expect(dir).toBe(tempDir); - }); - - it("should get stats", async () => { - const stats = await central.getStats(); - expect(stats.projectCount).toBe(0); - expect(stats.totalTasksCompleted).toBe(0); - expect(typeof stats.dbSizeBytes).toBe("number"); - }); - - it("should update stats after project registration", async () => { - const projectPath = join(tempDir, "stats-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - await central.registerProject({ - name: "Stats Test", - path: projectPath, - }); - - const stats = await central.getStats(); - expect(stats.projectCount).toBe(1); - }); - - it("should update stats after task completion", async () => { - const projectPath = join(tempDir, "stats-tasks"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Stats Tasks", - path: projectPath, - }); - - await central.recordTaskCompletion(project.id, 5000, true); - await central.recordTaskCompletion(project.id, 3000, true); - - const stats = await central.getStats(); - expect(stats.totalTasksCompleted).toBe(2); - }); - }); - - describe("isolation modes", () => { - beforeEach(async () => { - await central.init(); - }); - - it("should support in-process isolation", async () => { - const projectPath = join(tempDir, "in-process"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "In Process", - path: projectPath, - isolationMode: "in-process", - }); - - expect(project.isolationMode).toBe("in-process"); - }); - - it("should support child-process isolation", async () => { - const projectPath = join(tempDir, "child-process"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Child Process", - path: projectPath, - isolationMode: "child-process", - }); - - expect(project.isolationMode).toBe("child-process"); - }); - - it("should support all project statuses", async () => { - const projectPath = join(tempDir, "status-test"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - const project = await central.registerProject({ - name: "Status Test", - path: projectPath, - }); - - const statuses = ["active", "paused", "errored", "initializing"] as const; - for (const status of statuses) { - const updated = await central.updateProject(project.id, { status }); - expect(updated.status).toBe(status); - } - }); - }); - - describe("settings sync", () => { - beforeEach(async () => { - await central.init(); - }); - - describe("getSettingsForSync", () => { - it("should return payload with global settings", async () => { - const globalSettings = { - themeMode: "dark" as const, - defaultProvider: "anthropic", - defaultModelId: "claude-sonnet-4-5", - }; - - const payload = await central.getSettingsForSync(globalSettings); - - expect(payload.global).toEqual(globalSettings); - expect(payload.version).toBe(1); - expect(payload.exportedAt).toBe("2026-04-01T12:00:00.000Z"); - expect(payload.checksum).toBeDefined(); - expect(payload.checksum).toHaveLength(64); // SHA-256 hex - }); - - it("should collect project settings keyed by project name", async () => { - const projectPath1 = join(tempDir, "sync-project1"); - const projectPath2 = join(tempDir, "sync-project2"); - mkdirSync(projectPath1); - mkdirSync(projectPath2); - projectPaths.push(projectPath1, projectPath2); - - await central.registerProject({ - name: "Project Alpha", - path: projectPath1, - settings: { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true }, - }); - - await central.registerProject({ - name: "Project Beta", - path: projectPath2, - settings: { maxConcurrent: 3, maxWorktrees: 6, pollIntervalMs: 15000, groupOverlappingFiles: true, autoMerge: false }, - }); - - const payload = await central.getSettingsForSync({}); - - expect(payload.projects).toBeDefined(); - expect(Object.keys(payload.projects!)).toHaveLength(2); - expect(payload.projects!["Project Alpha"]).toEqual({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true }); - expect(payload.projects!["Project Beta"]).toEqual({ maxConcurrent: 3, maxWorktrees: 6, pollIntervalMs: 15000, groupOverlappingFiles: true, autoMerge: false }); - }); - - it("should compute correct checksum", async () => { - const globalSettings = { themeMode: "dark" as const }; - - const payload1 = await central.getSettingsForSync(globalSettings); - const payload2 = await central.getSettingsForSync(globalSettings); - - // Same input should produce same checksum - expect(payload1.checksum).toBe(payload2.checksum); - }); - - it("should include providerAuth when supplied", async () => { - const globalSettings = {}; - const providerAuth = { - anthropic: { type: "api_key" as const, key: "sk-ant-test", authenticated: true }, - openai: { type: "api_key" as const, key: "sk-openai-test", authenticated: false }, - }; - - const payload = await central.getSettingsForSync(globalSettings, { providerAuth }); - - expect(payload.providerAuth).toEqual(providerAuth); - }); - - it("should work when no projects are registered", async () => { - const payload = await central.getSettingsForSync({}); - - expect(payload.global).toEqual({}); - expect(payload.projects).toBeUndefined(); - expect(payload.providerAuth).toBeUndefined(); - expect(payload.checksum).toBeDefined(); - }); - - it("should set exportedAt to current timestamp", async () => { - const payload = await central.getSettingsForSync({}); - - expect(payload.exportedAt).toBe("2026-04-01T12:00:00.000Z"); - }); - }); - - describe("applyRemoteSettings", () => { - it("should return success with correct counts for valid payload", async () => { - const projectPath = join(tempDir, "apply-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - await central.registerProject({ - name: "Apply Test", - path: projectPath, - }); - - const payload = await central.getSettingsForSync({ themeMode: "dark" as const }); - - const result = await central.applyRemoteSettings(payload); - - expect(result.success).toBe(true); - expect(result.globalCount).toBe(1); - expect(result.projectCount).toBe(0); - expect(result.authCount).toBe(0); - expect(result.workflowSettingsCount).toBe(0); - expect(result.error).toBeUndefined(); - }); - - it("should return success false on version mismatch", async () => { - const payload = { - version: 99 as unknown as 1, - exportedAt: new Date().toISOString(), - checksum: "invalid", - }; - - const result = await central.applyRemoteSettings(payload); - - expect(result.success).toBe(false); - expect(result.workflowSettingsCount).toBe(0); - expect(result.error).toContain("Unsupported settings sync version"); - }); - - it("should return success false on checksum mismatch", async () => { - const payload = { - version: 1 as const, - exportedAt: new Date().toISOString(), - checksum: "invalid-checksum-that-wont-match", - }; - - const result = await central.applyRemoteSettings(payload); - - expect(result.success).toBe(false); - expect(result.workflowSettingsCount).toBe(0); - expect(result.error).toContain("Checksum mismatch"); - }); - - it("should merge project settings for matching project names", async () => { - const projectPath = join(tempDir, "merge-project"); - mkdirSync(projectPath); - projectPaths.push(projectPath); - - await central.registerProject({ - name: "Merge Test", - path: projectPath, - settings: { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true }, - }); - - // Use getSettingsForSync to create a valid payload, then modify and re-sign - // The challenge is ensuring the checksum matches, so we use getSettingsForSync's exact output - const remoteSettings = { maxConcurrent: 5, maxWorktrees: 8, pollIntervalMs: 20000, groupOverlappingFiles: true, autoMerge: false }; - - // First, get a payload that includes the project - await central.updateProject((await central.getProjectByPath(projectPath))!.id, { - settings: remoteSettings, - }); - - // Now get the payload - it should have the updated settings - const payload = await central.getSettingsForSync({}); - const result = await central.applyRemoteSettings(payload); - - expect(result.success).toBe(true); - // Project settings are applied - const project = await central.getProjectByPath(projectPath); - expect(project?.settings?.maxConcurrent).toBe(5); // from the updated settings - }); - - it("should skip project settings for projects that don't exist locally", async () => { - // Create a payload without any local projects - const payload = await central.getSettingsForSync({ - themeMode: "dark" as const, - }); - - // Verify it processes without error and has 0 project count - const result = await central.applyRemoteSettings(payload); - - expect(result.success).toBe(true); - expect(result.projectCount).toBe(0); // No matching projects (none registered in this test) - }); - - it("should return correct authCount without applying auth", async () => { - const providerAuth = { - anthropic: { type: "api_key" as const, key: "sk-ant-test" }, - openai: { type: "oauth" as const, accessToken: "oauth-token" }, - }; - const payload = await central.getSettingsForSync({}, { providerAuth }); - - const result = await central.applyRemoteSettings(payload); - - expect(result.success).toBe(true); - expect(result.authCount).toBe(2); // Both entries counted - expect(result.workflowSettingsCount).toBe(0); - // Auth is not applied - that's the caller's responsibility - }); - - it("should accept payloads with workflowSettings without applying them in CentralCore", async () => { - const payloadWithoutChecksum = { - global: { themeMode: "dark" as const }, - workflowSettings: { "builtin:coding": { workflowStepTimeoutMs: 240000 } }, - exportedAt: new Date().toISOString(), - version: 1 as const, - }; - const checksum = createHash("sha256").update(JSON.stringify(payloadWithoutChecksum)).digest("hex"); - - const result = await central.applyRemoteSettings({ ...payloadWithoutChecksum, checksum }); - - expect(result.success).toBe(true); - expect(result.globalCount).toBe(1); - expect(result.workflowSettingsCount).toBe(0); - }); - - it("should handle payloads without workflowSettings gracefully", async () => { - const payloadWithoutChecksum = { - global: { themeMode: "dark" as const }, - exportedAt: new Date().toISOString(), - version: 1 as const, - }; - const checksum = createHash("sha256").update(JSON.stringify(payloadWithoutChecksum)).digest("hex"); - - const result = await central.applyRemoteSettings({ ...payloadWithoutChecksum, checksum }); - - expect(result.success).toBe(true); - expect(result.globalCount).toBe(1); - expect(result.workflowSettingsCount).toBe(0); - }); - - it("should handle empty payload gracefully", async () => { - // Create an empty but valid payload using getSettingsForSync - const emptyPayload = await central.getSettingsForSync({}); - - const result = await central.applyRemoteSettings(emptyPayload); - - expect(result.success).toBe(true); - expect(result.globalCount).toBeGreaterThanOrEqual(0); - expect(result.projectCount).toBe(0); - expect(result.authCount).toBe(0); - expect(result.workflowSettingsCount).toBe(0); - }); - }); - - describe("getSettingsSyncState", () => { - it("should return null when no sync has occurred", async () => { - // Register a remote node first - const remoteNode = await central.registerNode({ - name: "remote-test", - type: "remote", - url: "http://localhost:9999", - }); - - const state = await central.getSettingsSyncState(remoteNode.id); - - expect(state).toBeNull(); - }); - - it("should return state after updateSettingsSyncState", async () => { - const remoteNode = await central.registerNode({ - name: "remote-state-test", - type: "remote", - url: "http://localhost:9998", - }); - - await central.updateSettingsSyncState(remoteNode.id, { - lastSyncedAt: "2026-04-01T12:00:00.000Z", - localChecksum: "local-checksum-abc", - remoteChecksum: "remote-checksum-xyz", - }); - - const state = await central.getSettingsSyncState(remoteNode.id); - - expect(state).not.toBeNull(); - expect(state!.lastSyncedAt).toBe("2026-04-01T12:00:00.000Z"); - expect(state!.localChecksum).toBe("local-checksum-abc"); - expect(state!.remoteChecksum).toBe("remote-checksum-xyz"); - expect(state!.syncCount).toBe(1); - }); - }); - - describe("updateSettingsSyncState", () => { - it("should create new row on first call", async () => { - const remoteNode = await central.registerNode({ - name: "remote-new", - type: "remote", - url: "http://localhost:9997", - }); - - const state = await central.updateSettingsSyncState(remoteNode.id, { - lastSyncedAt: "2026-04-01T12:00:00.000Z", - }); - - expect(state.syncCount).toBe(1); - expect(state.lastSyncedAt).toBe("2026-04-01T12:00:00.000Z"); - expect(state.createdAt).toBeDefined(); - expect(state.updatedAt).toBeDefined(); - }); - - it("should update existing row on subsequent calls", async () => { - const remoteNode = await central.registerNode({ - name: "remote-update", - type: "remote", - url: "http://localhost:9996", - }); - - await central.updateSettingsSyncState(remoteNode.id, { - lastSyncedAt: "2026-04-01T12:00:00.000Z", - localChecksum: "first-checksum", - }); - - await central.updateSettingsSyncState(remoteNode.id, { - lastSyncedAt: "2026-04-01T13:00:00.000Z", - remoteChecksum: "second-checksum", - }); - - const state = await central.getSettingsSyncState(remoteNode.id); - - expect(state!.syncCount).toBe(2); - expect(state!.lastSyncedAt).toBe("2026-04-01T13:00:00.000Z"); - expect(state!.localChecksum).toBe("first-checksum"); - expect(state!.remoteChecksum).toBe("second-checksum"); - }); - - it("should auto-increment syncCount", async () => { - const remoteNode = await central.registerNode({ - name: "remote-count", - type: "remote", - url: "http://localhost:9995", - }); - - for (let i = 0; i < 3; i++) { - await central.updateSettingsSyncState(remoteNode.id, { - localChecksum: `checksum-${i}`, - }); - } - - const state = await central.getSettingsSyncState(remoteNode.id); - - expect(state!.syncCount).toBe(3); - }); - - it("should set lastSyncedAt when provided", async () => { - const remoteNode = await central.registerNode({ - name: "remote-synced", - type: "remote", - url: "http://localhost:9994", - }); - - const state = await central.updateSettingsSyncState(remoteNode.id, { - lastSyncedAt: "2026-04-01T15:00:00.000Z", - }); - - expect(state.lastSyncedAt).toBe("2026-04-01T15:00:00.000Z"); - }); - - it("should update checksums when provided", async () => { - const remoteNode = await central.registerNode({ - name: "remote-checksum", - type: "remote", - url: "http://localhost:9993", - }); - - const state = await central.updateSettingsSyncState(remoteNode.id, { - localChecksum: "local-abc", - remoteChecksum: "remote-xyz", - }); - - expect(state.localChecksum).toBe("local-abc"); - expect(state.remoteChecksum).toBe("remote-xyz"); - }); - - it("should emit settings:sync:completed event", async () => { - const remoteNode = await central.registerNode({ - name: "remote-event", - type: "remote", - url: "http://localhost:9992", - }); - - let emittedPayload: { nodeId: string; remoteNodeId: string; state: import("../types.js").SettingsSyncState } | undefined; - central.on("settings:sync:completed", (payload) => { - emittedPayload = payload; - }); - - await central.updateSettingsSyncState(remoteNode.id, {}); - - expect(emittedPayload).toBeDefined(); - expect(emittedPayload!.remoteNodeId).toBe(remoteNode.id); - expect(emittedPayload!.state.syncCount).toBe(1); - }); - - it("should return the updated state", async () => { - const remoteNode = await central.registerNode({ - name: "remote-return", - type: "remote", - url: "http://localhost:9991", - }); - - const state = await central.updateSettingsSyncState(remoteNode.id, { - lastSyncedAt: "2026-04-01T16:00:00.000Z", - }); - - expect(state.remoteNodeId).toBe(remoteNode.id); - expect(state.syncCount).toBe(1); - }); - }); - }); - - describe("schema migration v5", () => { - it("should initialize fresh database with v5 schema", async () => { - // Create a fresh database - it should be schema v5 - const freshCentral = new CentralCore(tempDir + "-v5-fresh"); - await freshCentral.init(); - await freshCentral.close(); - - // Verify settingsSyncState table exists by testing the API - const verifyCentral = new CentralCore(tempDir + "-v5-fresh"); - await verifyCentral.init(); - - const remoteNode = await verifyCentral.registerNode({ - name: "v5-test", - type: "remote", - url: "http://localhost:9990", - }); - - // This should work if the table exists - await verifyCentral.updateSettingsSyncState(remoteNode.id, { - lastSyncedAt: new Date().toISOString(), - }); - - const state = await verifyCentral.getSettingsSyncState(remoteNode.id); - expect(state).not.toBeNull(); - expect(state!.syncCount).toBe(1); - - await verifyCentral.close(); - - // Clean up - rmSync(tempDir + "-v5-fresh", { recursive: true, force: true }); - }); - - it("should migrate v4 database to v5", async () => { - // This test verifies the migration path works - // We can't easily create a v4 database, but we can verify the API works - // after initialization - const migrateCentral = new CentralCore(tempDir + "-v5-migrate"); - await migrateCentral.init(); - - // Verify settingsSyncState is accessible - const remoteNode = await migrateCentral.registerNode({ - name: "migrate-test", - type: "remote", - url: "http://localhost:9989", - }); - - await migrateCentral.updateSettingsSyncState(remoteNode.id, {}); - - const state = await migrateCentral.getSettingsSyncState(remoteNode.id); - expect(state).not.toBeNull(); - - await migrateCentral.close(); - - rmSync(tempDir + "-v5-migrate", { recursive: true, force: true }); - }); - }); - - it("exports and applies settings/auth snapshots", async () => { - const syncCentral = new CentralCore(tempDir + "-snapshot"); - await syncCentral.init(); - try { - const legacy = await syncCentral.getSettingsForSync({}); - const snapshot = await syncCentral.getProjectSettingsSnapshot({}); - const result = await syncCentral.applyProjectSettingsSnapshot(snapshot); - const authSnapshot = syncCentral.getAuthMaterialSnapshot({ - foo: { - type: "oauth", - accessToken: "access-token", - refreshToken: "refresh-token", - expires: Date.now() + 60_000, - accountId: "acct", - }, - }); - - expect(snapshot.payload.global).toEqual(legacy.global); - expect(snapshot.payload.projects).toEqual(legacy.projects); - expect(typeof result.success).toBe("boolean"); - const authApplyResult = syncCentral.applyAuthMaterialSnapshot(authSnapshot); - expect(authApplyResult.authCount).toBe(1); - expect(authApplyResult.providerAuth.foo.accountId).toBe("acct"); - } finally { - await syncCentral.close(); - rmSync(tempDir + "-snapshot", { recursive: true, force: true }); - } - }); -}); diff --git a/packages/core/src/__tests__/central-integration.test.ts b/packages/core/src/__tests__/central-integration.test.ts deleted file mode 100644 index 5ae1d3d5bb..0000000000 --- a/packages/core/src/__tests__/central-integration.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Integration test for CentralCore infrastructure. - * - * This test verifies the end-to-end functionality of the central infrastructure: - * - Project registration and management - * - Activity logging across projects - * - Health tracking - * - Global concurrency management - */ - -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { CentralCore } from "../central-core.js"; -import type { RegisteredProject } from "../types.js"; - -describe("CentralCore Integration", () => { - let tempDir: string; - let central: CentralCore; - const projects: RegisteredProject[] = []; - - beforeAll(async () => { - // Create temp directory for test - tempDir = mkdtempSync(join(tmpdir(), "kb-central-integration-")); - - // Initialize CentralCore - central = new CentralCore(tempDir); - await central.init(); - }); - - afterAll(async () => { - // Teardown order: entity cleanup first, then infrastructure, then filesystem - // Unregister all tracked projects first (not just a subset from assertions) - for (const project of projects) { - try { - await central.unregisterProject(project.id); - } catch { - // Ignore cleanup errors for already-removed entities - } - } - projects.length = 0; - - // Close CentralCore before filesystem cleanup - try { - await central.close(); - } catch { - // Ignore close errors - } - - // Filesystem cleanup last - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - }); - - it("should register multiple projects", async () => { - for (let i = 0; i < 3; i++) { - const projectPath = join(tempDir, `integration-project-${i}`); - mkdirSync(projectPath); - - const project = await central.registerProject({ - name: `Integration Project ${i}`, - path: projectPath, - }); - - projects.push(project); - expect(project.id).toMatch(/^proj_/); - expect(project.name).toBe(`Integration Project ${i}`); - } - - const allProjects = await central.listProjects(); - expect(allProjects).toHaveLength(3); - }); - - it("should log activity for each project", async () => { - for (const project of projects) { - await central.logActivity({ - type: "task:created", - projectId: project.id, - projectName: project.name, - taskId: `KB-${projects.indexOf(project) + 1}`, - taskTitle: `Test Task ${projects.indexOf(project) + 1}`, - timestamp: new Date().toISOString(), - details: `Task created in ${project.name}`, - }); - } - - const allActivity = await central.getRecentActivity(); - expect(allActivity).toHaveLength(3); - - for (const project of projects) { - const projectActivity = await central.getRecentActivity({ projectId: project.id }); - expect(projectActivity).toHaveLength(1); - expect(projectActivity[0].projectId).toBe(project.id); - } - }); - - it("should update health for each project", async () => { - for (const project of projects) { - await central.updateProjectHealth(project.id, { - activeTaskCount: projects.indexOf(project) + 1, - inFlightAgentCount: 1, - status: "active", - }); - - const health = await central.getProjectHealth(project.id); - expect(health).toBeDefined(); - expect(health?.activeTaskCount).toBe(projects.indexOf(project) + 1); - expect(health?.inFlightAgentCount).toBe(1); - expect(health?.status).toBe("active"); - } - - const allHealth = await central.listAllHealth(); - expect(allHealth).toHaveLength(3); - }); - - it("should record task completions", async () => { - for (const project of projects) { - // Record some successful completions - await central.recordTaskCompletion(project.id, 5000, true); - await central.recordTaskCompletion(project.id, 3000, true); - - // Record a failure - await central.recordTaskCompletion(project.id, 1000, false); - } - - for (const project of projects) { - const health = await central.getProjectHealth(project.id); - expect(health?.totalTasksCompleted).toBe(2); - expect(health?.totalTasksFailed).toBe(1); - } - - const stats = await central.getStats(); - expect(stats.projectCount).toBe(3); - expect(stats.totalTasksCompleted).toBe(6); - }); - - it("should manage global concurrency", async () => { - // Reset state first by releasing any held slots - for (const project of projects) { - const health = await central.getProjectHealth(project.id); - if (health && health.inFlightAgentCount > 0) { - // Release all held slots - for (let i = 0; i < health.inFlightAgentCount; i++) { - await central.releaseGlobalSlot(project.id); - } - } - } - - // Set a low limit for testing - await central.updateGlobalConcurrency({ globalMaxConcurrent: 2, currentlyActive: 0, queuedCount: 0 }); - - const initialState = await central.getGlobalConcurrencyState(); - expect(initialState.globalMaxConcurrent).toBe(2); - expect(initialState.currentlyActive).toBe(0); - - // Acquire slots - const acquired1 = await central.acquireGlobalSlot(projects[0].id); - expect(acquired1).toBe(true); - - const acquired2 = await central.acquireGlobalSlot(projects[1].id); - expect(acquired2).toBe(true); - - // Third should fail (at limit) - const acquired3 = await central.acquireGlobalSlot(projects[2].id); - expect(acquired3).toBe(false); - - const atLimitState = await central.getGlobalConcurrencyState(); - expect(atLimitState.currentlyActive).toBe(2); - expect(atLimitState.queuedCount).toBe(1); - - // Release slots - await central.releaseGlobalSlot(projects[0].id); - await central.releaseGlobalSlot(projects[1].id); - - const finalState = await central.getGlobalConcurrencyState(); - expect(finalState.currentlyActive).toBe(0); - expect(finalState.projectsActive[projects[0].id]).toBeUndefined(); - expect(finalState.projectsActive[projects[1].id]).toBeUndefined(); - }); - - it("should have consistent unified feed across projects", async () => { - // Get all activity - const allActivity = await central.getRecentActivity({ limit: 10 }); - - // Verify we have activity from all projects - const projectIds = new Set(allActivity.map((a) => a.projectId)); - expect(projectIds.size).toBeGreaterThanOrEqual(1); - - // Verify activity count matches - const count = await central.getActivityCount(); - expect(count).toBeGreaterThanOrEqual(3); - }); - - it("should unregister projects cleanly", async () => { - // Keep the first project, unregister the others - for (let i = 1; i < projects.length; i++) { - await central.unregisterProject(projects[i].id); - } - - const remainingProjects = await central.listProjects(); - expect(remainingProjects).toHaveLength(1); - expect(remainingProjects[0].id).toBe(projects[0].id); - - // Health records for unregistered projects should be gone - for (let i = 1; i < projects.length; i++) { - const health = await central.getProjectHealth(projects[i].id); - expect(health).toBeUndefined(); - } - }); - - it("should verify database path and stats", async () => { - const dbPath = central.getDatabasePath(); - expect(dbPath).toContain("fusion-central.db"); - - const globalDir = central.getGlobalDir(); - expect(globalDir).toBe(tempDir); - - const stats = await central.getStats(); - expect(stats.projectCount).toBe(1); // Only first project remains - expect(typeof stats.dbSizeBytes).toBe("number"); - expect(typeof stats.totalTasksCompleted).toBe("number"); - }); -}); diff --git a/packages/core/src/__tests__/central-project-node-mappings.test.ts b/packages/core/src/__tests__/central-project-node-mappings.test.ts deleted file mode 100644 index 732e678146..0000000000 --- a/packages/core/src/__tests__/central-project-node-mappings.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { CentralCore } from "../central-core.js"; - -describe("CentralCore project-node path mappings", () => { - let tempDir: string; - let central: CentralCore; - - beforeEach(async () => { - tempDir = mkdtempSync(join(tmpdir(), "kb-central-mapping-test-")); - central = new CentralCore(tempDir); - await central.init(); - }); - - afterEach(async () => { - await central.close(); - rmSync(tempDir, { recursive: true, force: true }); - }); - - it("creates a local-node mapping when registering a project", async () => { - const projectPath = join(tempDir, "project-a"); - mkdirSync(projectPath); - - const project = await central.registerProject({ name: "Project A", path: projectPath }); - const localNode = (await central.listNodes()).find((node) => node.type === "local"); - - const mapping = await central.getProjectNodePathMapping(project.id, localNode!.id); - expect(mapping?.path).toBe(projectPath); - }); - - it("keeps local mapping in sync when project.path is updated", async () => { - const projectPath = join(tempDir, "project-b"); - const projectPathNext = join(tempDir, "project-b-renamed"); - mkdirSync(projectPath); - mkdirSync(projectPathNext); - - const project = await central.registerProject({ name: "Project B", path: projectPath }); - const localNode = (await central.listNodes()).find((node) => node.type === "local"); - - await central.updateProject(project.id, { path: projectPathNext }); - - const mapping = await central.getProjectNodePathMapping(project.id, localNode!.id); - expect(mapping?.path).toBe(projectPathNext); - }); - - it("supports create/update/list/remove mapping CRUD", async () => { - const projectPath = join(tempDir, "project-c"); - mkdirSync(projectPath); - - const project = await central.registerProject({ name: "Project C", path: projectPath }); - const remoteNode = await central.registerNode({ - name: "remote-c", - type: "remote", - url: "https://remote-c.example", - apiKey: "secret", - }); - - const created = await central.createProjectNodePathMapping({ - projectId: project.id, - nodeId: remoteNode.id, - path: "/srv/project-c", - }); - expect(created.path).toBe("/srv/project-c"); - - const updated = await central.updateProjectNodePathMapping({ - projectId: project.id, - nodeId: remoteNode.id, - path: "/srv/project-c-next", - }); - expect(updated.path).toBe("/srv/project-c-next"); - - const listedByProject = await central.listProjectNodePathMappings({ projectId: project.id }); - expect(listedByProject.some((row) => row.nodeId === remoteNode.id)).toBe(true); - - await central.removeProjectNodePathMapping(project.id, remoteNode.id); - const removed = await central.getProjectNodePathMapping(project.id, remoteNode.id); - expect(removed).toBeUndefined(); - }); - - it("rejects unknown project/node and duplicate/conflicting mappings", async () => { - const projectPath = join(tempDir, "project-d"); - mkdirSync(projectPath); - const project = await central.registerProject({ name: "Project D", path: projectPath }); - const remoteNode = await central.registerNode({ - name: "remote-d", - type: "remote", - url: "https://remote-d.example", - apiKey: "secret", - }); - - await expect( - central.createProjectNodePathMapping({ - projectId: "proj_missing", - nodeId: remoteNode.id, - path: "/x", - }), - ).rejects.toThrow("Project not found"); - - await expect( - central.createProjectNodePathMapping({ - projectId: project.id, - nodeId: "node_missing", - path: "/x", - }), - ).rejects.toThrow("Node not found"); - - await central.createProjectNodePathMapping({ - projectId: project.id, - nodeId: remoteNode.id, - path: "/srv/project-d", - }); - - await expect( - central.createProjectNodePathMapping({ - projectId: project.id, - nodeId: remoteNode.id, - path: "/srv/project-d-other", - }), - ).rejects.toThrow("already exists"); - - await expect( - central.updateProjectNodePathMapping({ - projectId: project.id, - nodeId: "node_missing", - path: "/y", - }), - ).rejects.toThrow("Node not found"); - }); - - it("cleans up mappings when project or node is deleted", async () => { - const projectPath = join(tempDir, "project-e"); - mkdirSync(projectPath); - const project = await central.registerProject({ name: "Project E", path: projectPath }); - const remoteNode = await central.registerNode({ - name: "remote-e", - type: "remote", - url: "https://remote-e.example", - apiKey: "secret", - }); - - await central.createProjectNodePathMapping({ - projectId: project.id, - nodeId: remoteNode.id, - path: "/srv/project-e", - }); - - await central.unregisterNode(remoteNode.id); - expect(await central.getProjectNodePathMapping(project.id, remoteNode.id)).toBeUndefined(); - - const localNode = (await central.listNodes()).find((node) => node.type === "local"); - expect(localNode).toBeDefined(); - - await central.unregisterProject(project.id); - expect(await central.getProjectNodePathMapping(project.id, localNode!.id)).toBeUndefined(); - }); -}); diff --git a/packages/core/src/__tests__/commit-association-diff-backfill.real-git.test.ts b/packages/core/src/__tests__/commit-association-diff-backfill.real-git.test.ts deleted file mode 100644 index 1a6eda81a7..0000000000 --- a/packages/core/src/__tests__/commit-association-diff-backfill.real-git.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { execSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { TaskStore } from "../store.js"; - -function git(command: string, cwd: string): string { - return execSync(command, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim(); -} - -function insertAssociation( - store: TaskStore, - input: { - id: string; - lineageId: string; - sha: string; - matchedBy?: string; - additions?: number | null; - deletions?: number | null; - }, -): void { - const authoredAt = "2026-06-19T00:00:00.000Z"; - (store as any).db.prepare( - `INSERT INTO task_commit_associations - (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, - matchedBy, confidence, additions, deletions, createdAt, updatedAt) - VALUES (?, ?, 'FN-6714', ?, 'subject', ?, ?, 'canonical', ?, ?, ?, ?)`, - ).run( - input.id, - input.lineageId, - input.sha, - authoredAt, - input.matchedBy ?? "canonical-lineage-trailer", - input.additions ?? null, - input.deletions ?? null, - authoredAt, - authoredAt, - ); -} - -function readStats(store: TaskStore, id: string): { additions: number | null; deletions: number | null; updatedAt: string } { - return (store as any).db.prepare( - `SELECT additions, deletions, updatedAt FROM task_commit_associations WHERE id = ?`, - ).get(id) as { additions: number | null; deletions: number | null; updatedAt: string }; -} - -/** - * FNXC:CommandCenterProductivity 2026-06-21-00:00: - * Historical task commit associations may predate LOC columns, so the backfill contract must be proven against real git shortstat output while preserving populated rows and treating invalid or unavailable SHAs as non-fatal. - */ -describe("TaskStore.backfillCommitAssociationDiffStats", () => { - let rootDir: string; - let globalDir: string; - let store: TaskStore; - - beforeEach(async () => { - rootDir = mkdtempSync(join(tmpdir(), "fn-commit-diff-backfill-repo-")); - globalDir = mkdtempSync(join(tmpdir(), "fn-commit-diff-backfill-global-")); - git("git init --initial-branch=main", rootDir); - git('git config user.name "Fusion Test"', rootDir); - git('git config user.email "test@example.com"', rootDir); - - store = new TaskStore(rootDir, globalDir); - await store.init(); - }); - - afterEach(async () => { - store.close(); - rmSync(rootDir, { recursive: true, force: true }); - rmSync(globalDir, { recursive: true, force: true }); - }); - - it("fills only NULL historical rows from local git and leaves unknown objects NULL", async () => { - mkdirSync(join(rootDir, "src"), { recursive: true }); - writeFileSync(join(rootDir, "src", "added.txt"), "one\n"); - git("git add src/added.txt", rootDir); - git('git commit -m "add one line"', rootDir); - const addOnlySha = git("git rev-parse HEAD", rootDir); - - writeFileSync(join(rootDir, "src", "changed.txt"), "one\ntwo\nthree\n"); - git("git add src/changed.txt", rootDir); - git('git commit -m "add three lines"', rootDir); - - writeFileSync(join(rootDir, "src", "changed.txt"), "one\n"); - git("git add src/changed.txt", rootDir); - git('git commit -m "delete two lines"', rootDir); - const deletionSha = git("git rev-parse HEAD", rootDir); - - const unavailableSha = "abcdef1"; - const maliciousSha = "bad;touch should-not-exist"; - insertAssociation(store, { id: "null-add-1", lineageId: "lin-a", sha: addOnlySha }); - insertAssociation(store, { id: "null-add-2", lineageId: "lin-b", sha: addOnlySha, matchedBy: "legacy-subject" }); - insertAssociation(store, { id: "null-delete", lineageId: "lin-c", sha: deletionSha }); - insertAssociation(store, { id: "unavailable", lineageId: "lin-d", sha: unavailableSha }); - insertAssociation(store, { id: "malformed", lineageId: "lin-e", sha: maliciousSha }); - insertAssociation(store, { id: "already-populated", lineageId: "lin-f", sha: addOnlySha, additions: 99, deletions: 88 }); - const populatedBefore = readStats(store, "already-populated"); - - const dryRun = await store.backfillCommitAssociationDiffStats({ dryRun: true }); - expect(dryRun).toEqual({ - scannedRows: 5, - distinctCommits: 4, - updatedRows: 3, - skippedUnavailableCommits: 1, - skippedInvalidShas: 1, - dryRun: true, - }); - expect(readStats(store, "null-add-1")).toMatchObject({ additions: null, deletions: null }); - expect(readStats(store, "unavailable")).toMatchObject({ additions: null, deletions: null }); - - const report = await store.backfillCommitAssociationDiffStats({ dryRun: false }); - expect(report).toEqual({ - scannedRows: 5, - distinctCommits: 4, - updatedRows: 3, - skippedUnavailableCommits: 1, - skippedInvalidShas: 1, - dryRun: false, - }); - - expect(readStats(store, "null-add-1")).toMatchObject({ additions: 1, deletions: 0 }); - expect(readStats(store, "null-add-2")).toMatchObject({ additions: 1, deletions: 0 }); - expect(readStats(store, "null-delete")).toMatchObject({ additions: 0, deletions: 2 }); - expect(readStats(store, "unavailable")).toMatchObject({ additions: null, deletions: null }); - expect(readStats(store, "malformed")).toMatchObject({ additions: null, deletions: null }); - expect(readStats(store, "already-populated")).toEqual(populatedBefore); - - const secondRun = await store.backfillCommitAssociationDiffStats({ dryRun: false }); - expect(secondRun).toEqual({ - scannedRows: 2, - distinctCommits: 2, - updatedRows: 0, - skippedUnavailableCommits: 1, - skippedInvalidShas: 1, - dryRun: false, - }); - }); -}); diff --git a/packages/core/src/__tests__/docker-node-config.test.ts b/packages/core/src/__tests__/docker-node-config.test.ts deleted file mode 100644 index 0d2fc74d48..0000000000 --- a/packages/core/src/__tests__/docker-node-config.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - sanitizeDockerNodeConfigForResponse, - validateDockerNodeConfig, - type DockerNodeConfig, -} from "../types.js"; -import { CentralCore } from "../central-core.js"; - -function createValidConfig(): DockerNodeConfig { - return { - image: "runfusion/fusion:latest", - containerName: "fusion-test", - volumeMounts: [{ hostPath: "fusion-data", containerPath: "/data", mode: "rw", type: "volume" }], - environment: { PLAIN: "value", API_KEY: "secret" }, - resources: { memoryBytes: 2147483648, cpuCount: 2, pidsLimit: 256 }, - host: { - contextName: "default", - dockerHost: "tcp://127.0.0.1:2376", - tlsCaCert: "/certs/ca.pem", - tlsCert: "/certs/cert.pem", - tlsKey: "/certs/key.pem", - tlsVerify: true, - }, - extraClis: ["claude-cli"], - persistence: { volumeName: "fusion-data", retainOnDelete: true }, - configVersion: 1, - lastUpdated: "2026-05-01T00:00:00.000Z", - }; -} - -describe("docker node config validation", () => { - it("passes validation for valid config", () => { - expect(validateDockerNodeConfig(createValidConfig()).valid).toBe(true); - }); - - it("returns errors for missing required fields", () => { - const result = validateDockerNodeConfig({}); - expect(result.valid).toBe(false); - expect(result.errors).toEqual( - expect.arrayContaining([ - "image must be a non-empty string", - "volumeMounts must be an array", - "environment must be an object", - "configVersion must be a number >= 1", - ]), - ); - }); - - it("returns errors for invalid field types", () => { - const result = validateDockerNodeConfig({ - image: "ok", - volumeMounts: [{ hostPath: 123, containerPath: false }], - environment: { OK: "1", BAD: 2 }, - configVersion: 1, - resources: { memoryBytes: "bad" }, - host: { tlsVerify: "nope" }, - persistence: { retainOnDelete: "nope" }, - extraClis: ["ok", 1], - }); - expect(result.valid).toBe(false); - expect(result.errors).toEqual( - expect.arrayContaining([ - "volumeMounts[0].hostPath must be a string", - "volumeMounts[0].containerPath must be a string", - "environment.BAD must be a string value", - "resources.memoryBytes must be a number", - ]), - ); - }); - - it("requires configVersion >= 1", () => { - const base = createValidConfig(); - expect(validateDockerNodeConfig({ ...base, configVersion: 0 }).valid).toBe(false); - expect(validateDockerNodeConfig({ ...base, configVersion: -1 }).valid).toBe(false); - }); -}); - -describe("docker node config sanitization", () => { - it("masks sensitive env vars and tls key path without mutating input", () => { - const config = createValidConfig(); - const sanitized = sanitizeDockerNodeConfigForResponse(config); - expect(sanitized.environment.API_KEY).toBe("***"); - expect(sanitized.host?.tlsKey).toBe("***"); - expect(config.environment.API_KEY).toBe("secret"); - }); - - it("masks sensitive env vars case-insensitively and preserves non-sensitive values", () => { - const config = createValidConfig(); - config.environment = { plain: "value", service_token: "token", DB_PASSWORD: "password" }; - const sanitized = sanitizeDockerNodeConfigForResponse(config); - expect(sanitized.environment.plain).toBe("value"); - expect(sanitized.environment.service_token).toBe("***"); - expect(sanitized.environment.DB_PASSWORD).toBe("***"); - }); -}); - -describe("docker node config persistence", () => { - let tempDir: string; - let central: CentralCore; - - beforeEach(async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-01T10:00:00.000Z")); - tempDir = mkdtempSync(join(tmpdir(), "kb-docker-node-config-test-")); - central = new CentralCore(tempDir); - await central.init(); - }); - - afterEach(async () => { - await central.close(); - vi.useRealTimers(); - rmSync(tempDir, { recursive: true, force: true }); - }); - - const baseConfig = (): DockerNodeConfig => ({ - image: "runfusion/fusion:latest", - volumeMounts: [{ hostPath: "fusion-data", containerPath: "/data" }], - environment: { MODE: "docker" }, - configVersion: 0, - }); - - it("register/get/update/list roundtrip with versioning semantics", async () => { - const created = await central.registerNode({ - name: "docker-node", - type: "remote", - url: "http://127.0.0.1:4041", - apiKey: "key", - dockerConfig: baseConfig(), - }); - expect(created.dockerConfig?.configVersion).toBe(1); - - const sameOnPartial = await central.updateNode(created.id, { name: "docker-node-2" }); - expect(sameOnPartial.dockerConfig?.configVersion).toBe(1); - - vi.setSystemTime(new Date("2026-05-01T11:00:00.000Z")); - const updated = await central.updateNode(created.id, { - dockerConfig: { ...baseConfig(), image: "runfusion/fusion:v2", configVersion: 99 }, - }); - expect(updated.dockerConfig?.configVersion).toBe(2); - expect(updated.dockerConfig?.lastUpdated).toBe("2026-05-01T11:00:00.000Z"); - - const cleared = await central.updateNode(created.id, { dockerConfig: null }); - expect(cleared.dockerConfig).toBeUndefined(); - - const reset = await central.updateNode(created.id, { dockerConfig: { ...baseConfig(), configVersion: 50 } }); - expect(reset.dockerConfig?.configVersion).toBe(1); - - const list = await central.listNodes(); - expect(list.find((n) => n.id === created.id)?.dockerConfig?.image).toBe("runfusion/fusion:latest"); - }); - - it("registering without docker config keeps field undefined", async () => { - const created = await central.registerNode({ - name: "plain-node", - type: "remote", - url: "http://127.0.0.1:4042", - apiKey: "key", - }); - expect((await central.getNode(created.id))?.dockerConfig).toBeUndefined(); - }); - - it("throws on invalid docker config update", async () => { - const created = await central.registerNode({ - name: "docker-invalid", - type: "remote", - url: "http://127.0.0.1:4043", - apiKey: "key", - dockerConfig: baseConfig(), - }); - await expect( - central.updateNode(created.id, { - dockerConfig: { ...baseConfig(), image: "", configVersion: 1 }, - }), - ).rejects.toThrow("Invalid Docker config"); - }); -}); diff --git a/packages/core/src/__tests__/first-run.test.ts b/packages/core/src/__tests__/first-run.test.ts deleted file mode 100644 index 06dc8e1ba7..0000000000 --- a/packages/core/src/__tests__/first-run.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { execFile } from "node:child_process"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { realpath } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; -import { tempWorkspace } from "@fusion/test-utils"; -import { FirstRunExperience, createFirstRunExperience } from "../first-run.js"; -import { CentralCore } from "../central-core.js"; - -const execFileAsync = promisify(execFile); - -// Helper to create a fake kb project structure -function createFakeKbProject(dir: string): void { - mkdirSync(join(dir, ".fusion"), { recursive: true }); - writeFileSync(join(dir, ".fusion", "fusion.db"), ""); -} - -async function isGitRepository(path: string): Promise { - try { - const { stdout } = await execFileAsync("git", ["-C", path, "rev-parse", "--is-inside-work-tree"], { - encoding: "utf-8", - timeout: 10_000, - }); - return stdout.trim() === "true"; - } catch { - return false; - } -} - -const TEST_FILE_DIR = dirname(fileURLToPath(import.meta.url)); - -function getSafeCwd(): string { - try { - return process.cwd(); - } catch { - process.chdir(TEST_FILE_DIR); - return process.cwd(); - } -} - -describe("FirstRunExperience", () => { - let tempDir: string; - let centralCore: CentralCore; - let firstRun: FirstRunExperience; - let originalCwd: string; - - beforeEach(async () => { - tempDir = tempWorkspace("kb-first-run-test-"); - centralCore = new CentralCore(tempDir); - await centralCore.init(); - // Create GlobalSettingsStore with temp directory for isolation - const { GlobalSettingsStore } = await import("../global-settings.js"); - const globalSettingsStore = new GlobalSettingsStore(tempDir); - await globalSettingsStore.init(); - firstRun = new FirstRunExperience(centralCore, globalSettingsStore); - originalCwd = getSafeCwd(); - }); - - afterEach(() => { - try { - process.chdir(originalCwd); - } catch { - // Ignore cleanup errors - } - }); - - describe("isFirstRun", () => { - it("should return true when no projects registered", async () => { - const result = await firstRun.isFirstRun(); - expect(result).toBe(true); - }); - - it("should return false when projects are registered", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - - await centralCore.registerProject({ - name: "my-project", - path: projectDir, - isolationMode: "in-process", - }); - - const result = await firstRun.isFirstRun(); - expect(result).toBe(false); - }); - - it("should return true when central core is not initialized", async () => { - const uninitializedCore = new CentralCore(tempDir); - const { GlobalSettingsStore } = await import("../global-settings.js"); - const globalSettingsStore = new GlobalSettingsStore(tempDir); - const uninitializedFirstRun = new FirstRunExperience(uninitializedCore, globalSettingsStore); - - const result = await uninitializedFirstRun.isFirstRun(); - expect(result).toBe(true); - }); - }); - - describe("detectOrCreateInitialProject", () => { - it("should detect and register project from cwd", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - // Change to the project directory - process.chdir(projectDir); - - const result = await firstRun.detectOrCreateInitialProject(); - - expect(result.type).toBe("detected"); - if (result.type === "detected") { - expect(result.project.name).toBe("my-project"); - // Use realpath comparison to handle macOS /private prefix - const realProjectDir = await realpath(projectDir); - expect(result.project.path).toBe(realProjectDir); - } - }); - - it("should return manual-setup when no project in cwd", async () => { - // Stay in tempDir which has no kb project - process.chdir(tempDir); - - const result = await firstRun.detectOrCreateInitialProject(); - - expect(result.type).toBe("manual-setup"); - }); - - it("should return manual-setup with detected projects", async () => { - // Create a project in a subdirectory - const projectDir = join(tempDir, "sub-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - process.chdir(tempDir); - - const result = await firstRun.detectOrCreateInitialProject(); - - expect(result.type).toBe("manual-setup"); - if (result.type === "manual-setup") { - expect(result.detectedFromCwd).toBeDefined(); - expect(result.detectedFromCwd!.length).toBeGreaterThan(0); - } - }); - }); - - describe("getSetupState", () => { - it("should return complete setup state for first run", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - process.chdir(tempDir); - - const state = await firstRun.getSetupState(); - - expect(state.isFirstRun).toBe(true); - expect(state.hasDetectedProjects).toBe(true); - expect(state.detectedProjects.length).toBeGreaterThan(0); - expect(state.registeredProjects).toHaveLength(0); - expect(state.recommendedAction).toBe("auto-detect"); - }); - - it("should return create-new when no projects detected", async () => { - process.chdir(tempDir); - - const state = await firstRun.getSetupState(); - - expect(state.isFirstRun).toBe(true); - expect(state.hasDetectedProjects).toBe(false); - expect(state.recommendedAction).toBe("create-new"); - }); - - it("should return manual-setup when not first run and no projects", async () => { - // Register a project first - const projectDir = join(tempDir, "existing-project"); - mkdirSync(projectDir, { recursive: true }); - await centralCore.registerProject({ - name: "existing", - path: projectDir, - isolationMode: "in-process", - }); - - process.chdir(tempDir); - - const state = await firstRun.getSetupState(); - - expect(state.isFirstRun).toBe(false); - expect(state.registeredProjects).toHaveLength(1); - expect(state.recommendedAction).toBe("manual-setup"); - }); - }); - - describe("completeSetup", () => { - it("should register projects and return success", async () => { - const projectDir = join(tempDir, "new-project"); - mkdirSync(projectDir, { recursive: true }); - - const result = await firstRun.completeSetup([ - { path: projectDir, name: "new-project" }, - ]); - - expect(result.success).toBe(true); - expect(result.projects).toHaveLength(1); - expect(result.projects[0].name).toBe("new-project"); - await expect(isGitRepository(projectDir)).resolves.toBe(true); - expect(result.nextSteps.length).toBeGreaterThan(0); - }); - - it("should handle multiple projects", async () => { - const project1 = join(tempDir, "project-1"); - const project2 = join(tempDir, "project-2"); - mkdirSync(project1, { recursive: true }); - mkdirSync(project2, { recursive: true }); - - const result = await firstRun.completeSetup([ - { path: project1, name: "project-1" }, - { path: project2, name: "project-2" }, - ]); - - expect(result.success).toBe(true); - expect(result.projects).toHaveLength(2); - }); - - it("should skip already registered projects", async () => { - const projectDir = join(tempDir, "existing-project"); - mkdirSync(projectDir, { recursive: true }); - - // Register first - await centralCore.registerProject({ - name: "existing-project", - path: projectDir, - isolationMode: "in-process", - }); - - const result = await firstRun.completeSetup([ - { path: projectDir, name: "existing-project" }, - ]); - - expect(result.success).toBe(true); - expect(result.projects).toHaveLength(1); - // Should use the existing registration - expect(result.projects[0].path).toBe(projectDir); - }); - - it("should handle invalid paths gracefully", async () => { - const result = await firstRun.completeSetup([ - { path: "/non/existent/path", name: "invalid" }, - ]); - - expect(result.success).toBe(false); - expect(result.projects).toHaveLength(0); - }); - - it("should set projects to active status", async () => { - const projectDir = join(tempDir, "new-project"); - mkdirSync(projectDir, { recursive: true }); - - const result = await firstRun.completeSetup([ - { path: projectDir, name: "new-project" }, - ]); - - expect(result.projects[0].status).toBe("active"); - }); - - it("should be idempotent (running twice doesn't duplicate)", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - - // First call - const result1 = await firstRun.completeSetup([ - { path: projectDir, name: "my-project" }, - ]); - expect(result1.success).toBe(true); - - // Second call - should not error or duplicate - const result2 = await firstRun.completeSetup([ - { path: projectDir, name: "my-project" }, - ]); - expect(result2.success).toBe(true); - - // Should still only have 1 project - const projects = await centralCore.listProjects(); - expect(projects).toHaveLength(1); - expect(projects[0].name).toBe("my-project"); - }); - - it("should persist setupComplete in global settings", async () => { - const projectDir = join(tempDir, "new-project"); - mkdirSync(projectDir, { recursive: true }); - - // Before setup, setupComplete should not be set - const settingsBefore = await firstRun["globalSettingsStore"].getSettings(); - expect(settingsBefore.setupComplete).toBeUndefined(); - - // Complete setup - await firstRun.completeSetup([{ path: projectDir, name: "new-project" }]); - - // After successful setup, setupComplete should be true - const settingsAfter = await firstRun["globalSettingsStore"].getSettings(); - expect(settingsAfter.setupComplete).toBe(true); - }); - - it("should check setupComplete flag in isFirstRun", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - - // Initially should be first run - expect(await firstRun.isFirstRun()).toBe(true); - - // Complete setup - await firstRun.completeSetup([{ path: projectDir, name: "my-project" }]); - - // After setup, should NOT be first run (due to setupComplete flag) - expect(await firstRun.isFirstRun()).toBe(false); - }); - }); - - describe("createFirstRunExperience", () => { - it("should create a FirstRunExperience instance", async () => { - const { GlobalSettingsStore } = await import("../global-settings.js"); - const globalSettingsStore = new GlobalSettingsStore(tempDir); - const instance = createFirstRunExperience(centralCore, globalSettingsStore); - expect(instance).toBeInstanceOf(FirstRunExperience); - }); - }); -}); diff --git a/packages/core/src/__tests__/migration-orchestrator.test.ts b/packages/core/src/__tests__/migration-orchestrator.test.ts deleted file mode 100644 index 63e041e6cd..0000000000 --- a/packages/core/src/__tests__/migration-orchestrator.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, basename } from "node:path"; -import { MigrationOrchestrator, createMigrationOrchestrator, MAX_AUTO_REGISTER_PROJECTS } from "../migration-orchestrator.js"; -import { CentralCore } from "../central-core.js"; - -// Helper to create a temp directory -function createTempDir(): string { - return mkdtempSync(join(tmpdir(), "kb-migration-test-")); -} - -// Helper to create a fake kb project structure -function createFakeKbProject(dir: string): void { - mkdirSync(join(dir, ".fusion"), { recursive: true }); - // Create an empty file as the database (enough for detection) - writeFileSync(join(dir, ".fusion", "fusion.db"), ""); -} - -describe("MigrationOrchestrator", () => { - let tempDir: string; - let centralCore: CentralCore; - let orchestrator: MigrationOrchestrator; - - beforeEach(async () => { - tempDir = createTempDir(); - centralCore = new CentralCore(tempDir); - await centralCore.init(); - orchestrator = new MigrationOrchestrator(centralCore); - }); - - afterEach(() => { - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - }); - - describe("detectExistingProjects", () => { - it("should detect a single kb project", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - const detected = await orchestrator.detectExistingProjects(projectDir); - - expect(detected).toHaveLength(1); - expect(detected[0].path).toBe(projectDir); - expect(detected[0].name).toBe("my-project"); - expect(detected[0].hasDb).toBe(true); - }); - - it("should detect multiple kb projects in subdirectories", async () => { - const project1 = join(tempDir, "project-a"); - const project2 = join(tempDir, "project-b"); - mkdirSync(project1, { recursive: true }); - mkdirSync(project2, { recursive: true }); - createFakeKbProject(project1); - createFakeKbProject(project2); - - const detected = await orchestrator.detectExistingProjects(tempDir); - - expect(detected).toHaveLength(2); - expect(detected.map((p) => p.name).sort()).toEqual(["project-a", "project-b"]); - }); - - it("should skip node_modules directories", async () => { - const projectDir = join(tempDir, "my-project"); - const nodeModules = join(projectDir, "node_modules", "some-package"); - mkdirSync(nodeModules, { recursive: true }); - createFakeKbProject(nodeModules); - createFakeKbProject(projectDir); - - const detected = await orchestrator.detectExistingProjects(tempDir); - - // Should only find the main project, not the one in node_modules - expect(detected).toHaveLength(1); - expect(detected[0].name).toBe("my-project"); - }); - - it("should skip hidden directories", async () => { - const projectDir = join(tempDir, "my-project"); - const hiddenDir = join(tempDir, ".hidden-project"); - mkdirSync(projectDir, { recursive: true }); - mkdirSync(hiddenDir, { recursive: true }); - createFakeKbProject(projectDir); - createFakeKbProject(hiddenDir); - - const detected = await orchestrator.detectExistingProjects(tempDir); - - // Should not detect the hidden directory - expect(detected).toHaveLength(1); - expect(detected[0].name).toBe("my-project"); - }); - - it("should skip build and cache directories", async () => { - const projectDir = join(tempDir, "my-project"); - const distDir = join(tempDir, "dist", "some-project"); - mkdirSync(projectDir, { recursive: true }); - mkdirSync(distDir, { recursive: true }); - createFakeKbProject(projectDir); - createFakeKbProject(distDir); - - const detected = await orchestrator.detectExistingProjects(tempDir); - - // Should not detect the one in dist - expect(detected).toHaveLength(1); - expect(detected[0].name).toBe("my-project"); - }); - - it("should respect maxDepth parameter", async () => { - // Create nested structure: temp/a/b/c/project - const nested = join(tempDir, "a", "b", "c", "project"); - mkdirSync(nested, { recursive: true }); - createFakeKbProject(nested); - - // With maxDepth=2, should not find the project at depth 4 - const detected = await orchestrator.detectExistingProjects(tempDir, 2); - - expect(detected).toHaveLength(0); - - // With maxDepth=5, should find it - const detectedDeep = await orchestrator.detectExistingProjects(tempDir, 5); - expect(detectedDeep).toHaveLength(1); - }); - - it("should stop recursion at a project root (don't look inside projects)", async () => { - const projectDir = join(tempDir, "my-project"); - const nestedProject = join(projectDir, "packages", "sub-project"); - mkdirSync(nestedProject, { recursive: true }); - createFakeKbProject(projectDir); - createFakeKbProject(nestedProject); - - const detected = await orchestrator.detectExistingProjects(tempDir); - - // Should only detect the top-level project, not the nested one - // (because we stop recursing when we find a project) - expect(detected).toHaveLength(1); - expect(detected[0].name).toBe("my-project"); - }); - - it("should return empty array when no projects found", async () => { - const detected = await orchestrator.detectExistingProjects(tempDir); - - expect(detected).toHaveLength(0); - }); - - it("should throw on non-existent path", async () => { - await expect( - orchestrator.detectExistingProjects("/non/existent/path") - ).rejects.toThrow("Scan path does not exist"); - }); - - it("should throw on relative path", async () => { - // A path starting with './' is relative - after resolve() it becomes absolute - // but we need to check before resolving - await expect( - orchestrator.detectExistingProjects("./relative/path") - ).rejects.toThrow("Scan path must be absolute"); - }); - - it("should detect projects without valid database as hasDb=false", async () => { - const projectDir = join(tempDir, "incomplete-project"); - mkdirSync(projectDir, { recursive: true }); - mkdirSync(join(projectDir, ".fusion"), { recursive: true }); - // Create directory but no fusion.db file - - const detected = await orchestrator.detectExistingProjects(projectDir); - - expect(detected).toHaveLength(0); - }); - - it("should ignore header-only fusion.db files that are not real SQLite databases", async () => { - const projectDir = join(tempDir, "invalid-project"); - mkdirSync(projectDir, { recursive: true }); - mkdirSync(join(projectDir, ".fusion"), { recursive: true }); - writeFileSync(join(projectDir, ".fusion", "fusion.db"), "SQLite format 3\x00"); - - const detected = await orchestrator.detectExistingProjects(projectDir); - - expect(detected).toHaveLength(0); - }); - }); - - describe("autoRegisterProjects", () => { - it("should register detected projects", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - const detected = [{ path: projectDir, name: "my-project", hasDb: true }]; - const registered = await orchestrator.autoRegisterProjects(detected); - - expect(registered).toHaveLength(1); - expect(registered[0].name).toBe("my-project"); - expect(registered[0].path).toBe(projectDir); - expect(registered[0].isolationMode).toBe("in-process"); - expect(registered[0].status).toBe("active"); - }); - - it("should skip projects without valid database", async () => { - const validProject = join(tempDir, "valid"); - const invalidProject = join(tempDir, "invalid"); - mkdirSync(validProject, { recursive: true }); - mkdirSync(join(invalidProject, ".fusion"), { recursive: true }); - createFakeKbProject(validProject); - - const detected = [ - { path: validProject, name: "valid", hasDb: true }, - { path: invalidProject, name: "invalid", hasDb: false }, - ]; - - const registered = await orchestrator.autoRegisterProjects(detected); - - expect(registered).toHaveLength(1); - expect(registered[0].name).toBe("valid"); - }); - - it("should skip already registered projects", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - // Register first - await centralCore.registerProject({ - name: "my-project", - path: projectDir, - isolationMode: "in-process", - }); - - // Try to register again - const detected = [{ path: projectDir, name: "my-project", hasDb: true }]; - const registered = await orchestrator.autoRegisterProjects(detected); - - expect(registered).toHaveLength(0); - }); - - it("should generate unique names for duplicate basenames", async () => { - const project1 = join(tempDir, "repos", "my-project"); - const project2 = join(tempDir, "other", "my-project"); - mkdirSync(project1, { recursive: true }); - mkdirSync(project2, { recursive: true }); - createFakeKbProject(project1); - createFakeKbProject(project2); - - // Register first project directly - await centralCore.registerProject({ - name: "my-project", - path: project1, - isolationMode: "in-process", - }); - - // Auto-register should use name-2 for second project - const detected = [ - { path: project1, name: "my-project", hasDb: true }, - { path: project2, name: "my-project", hasDb: true }, - ]; - const registered = await orchestrator.autoRegisterProjects(detected); - - expect(registered).toHaveLength(1); - expect(registered[0].name).toBe("my-project-2"); - }); - - it("should skip projects that would create circular references", async () => { - const parent = join(tempDir, "parent-project"); - const child = join(parent, "child-project"); - mkdirSync(parent, { recursive: true }); - mkdirSync(child, { recursive: true }); - createFakeKbProject(parent); - createFakeKbProject(child); - - // Register parent first - await centralCore.registerProject({ - name: "parent-project", - path: parent, - isolationMode: "in-process", - }); - - // Try to register child (should be skipped as circular) - const detected = [ - { path: parent, name: "parent-project", hasDb: true }, - { path: child, name: "child-project", hasDb: true }, - ]; - const registered = await orchestrator.autoRegisterProjects(detected); - - expect(registered).toHaveLength(0); // Both skipped (parent already registered, child circular) - }); - - it("should throw when exceeding MAX_AUTO_REGISTER_PROJECTS", async () => { - // Create too many projects - const detected: Array<{ path: string; name: string; hasDb: boolean }> = []; - for (let i = 0; i < MAX_AUTO_REGISTER_PROJECTS + 1; i++) { - detected.push({ path: `/project/${i}`, name: `project-${i}`, hasDb: true }); - } - - await expect(orchestrator.autoRegisterProjects(detected)).rejects.toThrow( - "Too many projects detected" - ); - }); - }); - - describe("needsMigration", () => { - it("should return true when no projects registered and projects exist", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - const needsMigration = await orchestrator.needsMigration(tempDir); - - expect(needsMigration).toBe(true); - }); - - it("should return false when projects already registered", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - await centralCore.registerProject({ - name: "my-project", - path: projectDir, - isolationMode: "in-process", - }); - - const needsMigration = await orchestrator.needsMigration(tempDir); - - expect(needsMigration).toBe(false); - }); - - it("should return false when no projects exist on filesystem", async () => { - // No projects created - const needsMigration = await orchestrator.needsMigration(tempDir); - - expect(needsMigration).toBe(false); - }); - }); - - describe("runMigration", () => { - it("should run full migration with autoRegister", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - const result = await orchestrator.runMigration({ startPath: tempDir, autoRegister: true }); - - expect(result.projectsDetected).toHaveLength(1); - expect(result.projectsRegistered).toHaveLength(1); - expect(result.projectsRegistered[0].name).toBe("my-project"); - expect(result.errors).toHaveLength(0); - }); - - it("should run detection only without autoRegister", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - const result = await orchestrator.runMigration({ startPath: tempDir, autoRegister: false }); - - expect(result.projectsDetected).toHaveLength(1); - expect(result.projectsRegistered).toHaveLength(0); - expect(result.projectsSkipped).toHaveLength(1); - }); - - it("should support dry-run mode", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - const result = await orchestrator.runMigration({ startPath: tempDir, dryRun: true }); - - expect(result.projectsDetected).toHaveLength(1); - expect(result.projectsRegistered).toHaveLength(0); - expect(result.projectsSkipped[0].reason).toContain("DRY RUN"); - }); - - it("should call progress callback", async () => { - const projectDir = join(tempDir, "my-project"); - mkdirSync(projectDir, { recursive: true }); - createFakeKbProject(projectDir); - - const onProgress = vi.fn(); - await orchestrator.runMigration({ autoRegister: true, onProgress }); - - expect(onProgress).toHaveBeenCalled(); - }); - - it("should handle detection errors gracefully", async () => { - // Pass a non-existent directory to cause an error - const nonExistentPath = join(tempDir, "does-not-exist"); - - const result = await orchestrator.runMigration({ startPath: nonExistentPath }); - - expect(result.errors).toHaveLength(1); - expect(result.errors[0].error).toContain("Detection failed"); - }); - }); - - describe("createMigrationOrchestrator", () => { - it("should create an orchestrator instance", () => { - const instance = createMigrationOrchestrator(centralCore); - - expect(instance).toBeInstanceOf(MigrationOrchestrator); - }); - }); -}); diff --git a/packages/core/src/__tests__/migration.test.ts b/packages/core/src/__tests__/migration.test.ts deleted file mode 100644 index 9d36a96cdf..0000000000 --- a/packages/core/src/__tests__/migration.test.ts +++ /dev/null @@ -1,823 +0,0 @@ -/** - * Tests for migration and first-run detection - */ - -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { execFile } from "node:child_process"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import { tempWorkspace, useIsolatedCwd } from "@fusion/test-utils"; -import { - FirstRunDetector, - MigrationCoordinator, - BackwardCompat, - ProjectRequiredError, - type ProjectSetupInput, -} from "../migration.js"; -import { CentralCore } from "../central-core.js"; - -const execFileAsync = promisify(execFile); - -// Helper to create a fake kb project -function createFakeKbProject(dir: string): void { - const kbDir = join(dir, ".fusion"); - mkdirSync(kbDir, { recursive: true }); - // A zero-byte file is a valid SQLite bootstrap database. - writeFileSync(join(kbDir, "fusion.db"), ""); -} - -async function isGitRepository(path: string): Promise { - try { - const { stdout } = await execFileAsync("git", ["-C", path, "rev-parse", "--is-inside-work-tree"], { - encoding: "utf-8", - timeout: 10_000, - }); - return stdout.trim() === "true"; - } catch { - return false; - } -} - -function createInvalidKbProject(dir: string): void { - const kbDir = join(dir, ".fusion"); - mkdirSync(kbDir, { recursive: true }); - writeFileSync(join(kbDir, "fusion.db"), "SQLite format 3\x00"); -} - -async function withDefaultFirstRunDetector( - homeDir: string, - fn: (detector: FirstRunDetector) => Promise | T, -): Promise { - const savedHome = process.env.HOME; - const savedUserProfile = process.env.USERPROFILE; - const savedVitest = process.env.VITEST; - process.env.HOME = homeDir; - process.env.USERPROFILE = homeDir; - delete process.env.VITEST; - - try { - return await fn(new FirstRunDetector()); - } finally { - if (savedHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = savedHome; - } - if (savedUserProfile === undefined) { - delete process.env.USERPROFILE; - } else { - process.env.USERPROFILE = savedUserProfile; - } - if (savedVitest === undefined) { - delete process.env.VITEST; - } else { - process.env.VITEST = savedVitest; - } - } -} - -// Helper to create a fake git remote -async function initGitRepo(dir: string, remoteUrl?: string): Promise { - const { execFile } = await import("node:child_process"); - const { promisify } = await import("node:util"); - const execFileAsync = promisify(execFile); - - await execFileAsync("git", ["init"], { cwd: dir }); - await execFileAsync("git", ["config", "user.email", "test@test.com"], { cwd: dir }); - await execFileAsync("git", ["config", "user.name", "Test"], { cwd: dir }); - - if (remoteUrl) { - await execFileAsync("git", ["remote", "add", "origin", remoteUrl], { cwd: dir }); - } -} - -describe("FirstRunDetector", () => { - let tempGlobalDir: string; - - beforeEach(() => { - tempGlobalDir = tempWorkspace("kb-migration-test-"); - }); - - describe("detectFirstRunState", () => { - it("should detect fresh-install when no central DB and no local .fusion/", async () => { - useIsolatedCwd("kb-fresh-"); - - const detector = new FirstRunDetector(tempGlobalDir); - const state = await detector.detectFirstRunState(); - - expect(state).toBe("fresh-install"); - }); - - it("should detect setup-wizard when local .fusion/ exists but no central DB", async () => { - const tempProjectDir = useIsolatedCwd("kb-needs-migration-"); - createFakeKbProject(tempProjectDir); - - const detector = new FirstRunDetector(tempGlobalDir); - const state = await detector.detectFirstRunState(); - - expect(state).toBe("setup-wizard"); - }); - - it("should detect setup-wizard from nested directory inside an existing project with no central DB", async () => { - const tempProjectDir = tempWorkspace("kb-needs-migration-nested-"); - createFakeKbProject(tempProjectDir); - const nestedDir = join(tempProjectDir, "src", "features", "deep"); - mkdirSync(nestedDir, { recursive: true }); - const originalCwd = process.cwd(); - process.chdir(nestedDir); - - try { - const detector = new FirstRunDetector(tempGlobalDir); - const state = await detector.detectFirstRunState(); - - expect(state).toBe("setup-wizard"); - } finally { - process.chdir(originalCwd); - } - }); - - it("should detect setup-wizard when central DB exists but is empty", async () => { - // Initialize central DB with no projects - const central = new CentralCore(tempGlobalDir); - await central.init(); - await central.close(); - - useIsolatedCwd("kb-setup-wizard-"); - - const detector = new FirstRunDetector(tempGlobalDir); - const state = await detector.detectFirstRunState(); - - expect(state).toBe("setup-wizard"); - }); - - it("should detect normal-operation when central DB has projects", async () => { - // Create a separate global dir for this test to avoid conflicts with beforeEach's tempGlobalDir - const testGlobalDir = tempWorkspace("kb-normal-op-global-"); - - // Create and initialize central - const testCentral = new CentralCore(testGlobalDir); - await testCentral.init(); - - // Register a project - const projectDir = tempWorkspace("kb-test-project-"); - await testCentral.registerProject({ - name: "Test Project", - path: projectDir, - }); - - // Create a temp dir for the cwd - useIsolatedCwd("kb-normal-op-"); - - try { - // Pass existing central to avoid concurrent connection issues - const detector = new FirstRunDetector(testGlobalDir); - const state = await detector.detectFirstRunState(testCentral); - - expect(state).toBe("normal-operation"); - } finally { - await testCentral.close(); - } - }, 15_000); - - it("should return fresh-install when central DB exists but is unreadable", async () => { - const tempProjectDir = useIsolatedCwd("kb-corrupt-central-"); - createFakeKbProject(tempProjectDir); - - mkdirSync(tempGlobalDir, { recursive: true }); - writeFileSync(join(tempGlobalDir, "fusion-central.db"), "not a sqlite database"); - - const detector = new FirstRunDetector(tempGlobalDir); - const state = await detector.detectFirstRunState(); - - expect(state).toBe("fresh-install"); - }); - - it("should return fresh-install when central DB exists but is unreadable and no local project is found", async () => { - useIsolatedCwd("kb-corrupt-central-no-local-"); - - mkdirSync(tempGlobalDir, { recursive: true }); - writeFileSync(join(tempGlobalDir, "fusion-central.db"), "not a sqlite database"); - - const detector = new FirstRunDetector(tempGlobalDir); - const state = await detector.detectFirstRunState(); - - expect(state).toBe("fresh-install"); - }); - }); - - describe("hasCentralDb", () => { - it("should return false when central DB does not exist", () => { - const detector = new FirstRunDetector(tempGlobalDir); - expect(detector.hasCentralDb()).toBe(false); - }); - - it("should return true when central DB exists", async () => { - const central = new CentralCore(tempGlobalDir); - await central.init(); - await central.close(); - - const detector = new FirstRunDetector(tempGlobalDir); - expect(detector.hasCentralDb()).toBe(true); - }); - }); - - describe("detectExistingProjects", () => { - it("should detect project in cwd", async () => { - const tempProjectDir = tempWorkspace("kb-detect-"); - createFakeKbProject(tempProjectDir); - - const detector = new FirstRunDetector(tempGlobalDir); - const projects = await detector.detectExistingProjects(tempProjectDir); - - expect(projects).toHaveLength(1); - expect(projects[0].path).toBe(tempProjectDir); - expect(projects[0].hasDb).toBe(true); - }); - - it("should walk up directory tree to find .fusion/", async () => { - const tempProjectDir = tempWorkspace("kb-parent-"); - createFakeKbProject(tempProjectDir); - const nestedDir = join(tempProjectDir, "src", "components"); - mkdirSync(nestedDir, { recursive: true }); - - const detector = new FirstRunDetector(tempGlobalDir); - const projects = await detector.detectExistingProjects(nestedDir); - - expect(projects).toHaveLength(1); - expect(projects[0].path).toBe(tempProjectDir); - }); - - it("should stop safely at home/root boundaries when no project is found", async () => { - const detector = new FirstRunDetector(tempGlobalDir); - const projects = await detector.detectExistingProjects(tmpdir()); - - expect(Array.isArray(projects)).toBe(true); - expect(projects.length).toBe(0); - }); - - it("should still check the starting directory when cwd matches the stop boundary", async () => { - const fakeHome = tempWorkspace("kb-home-boundary-"); - createFakeKbProject(fakeHome); - - const detector = new FirstRunDetector(fakeHome); - const projects = await detector.detectExistingProjects(fakeHome); - - expect(projects).toHaveLength(1); - expect(projects[0].path).toBe(fakeHome); - }); - - it("should return empty array when no project found", async () => { - const emptyDir = tempWorkspace("kb-empty-"); - - const detector = new FirstRunDetector(tempGlobalDir); - const projects = await detector.detectExistingProjects(emptyDir); - - expect(projects).toHaveLength(0); - }); - - it("should ignore invalid fusion.db files when scanning for local projects", async () => { - const tempProjectDir = tempWorkspace("kb-invalid-detect-"); - createInvalidKbProject(tempProjectDir); - - const detector = new FirstRunDetector(tempGlobalDir); - const projects = await detector.detectExistingProjects(tempProjectDir); - - expect(projects).toHaveLength(0); - }); - }); - - describe("generateProjectName", () => { - it("should use directory basename when no git remote", async () => { - const tempProjectDir = tempWorkspace("my-awesome-project-"); - - const detector = new FirstRunDetector(tempGlobalDir); - const name = await detector.generateProjectName(tempProjectDir); - - expect(name).toContain("my-awesome-project"); - }); - - it("should extract repo name from HTTPS git remote", async () => { - const tempProjectDir = tempWorkspace("kb-git-https-"); - await initGitRepo(tempProjectDir, "https://github.com/owner/my-repo.git"); - - const detector = new FirstRunDetector(tempGlobalDir); - const name = await detector.generateProjectName(tempProjectDir); - - expect(name).toBe("my-repo"); - }); - - it("should extract repo name from SSH git remote", async () => { - const tempProjectDir = tempWorkspace("kb-git-ssh-"); - await initGitRepo(tempProjectDir, "git@github.com:owner/my-ssh-repo"); - - const detector = new FirstRunDetector(tempGlobalDir); - const name = await detector.generateProjectName(tempProjectDir); - - expect(name).toBe("my-ssh-repo"); - }); - }); - - describe("getCentralDbPath", () => { - it("should return correct path", () => { - const detector = new FirstRunDetector(tempGlobalDir); - expect(detector.getCentralDbPath()).toBe(join(tempGlobalDir, "fusion-central.db")); - }); - - it("should default to ~/.fusion when no explicit global dir is provided", async () => { - const homeDir = tempWorkspace("kb-default-global-dir-"); - - try { - await withDefaultFirstRunDetector(homeDir, (detector) => { - expect(detector.getCentralDbPath()).toBe(join(homeDir, ".fusion", "fusion-central.db")); - }); - } finally { - rmSync(homeDir, { recursive: true, force: true }); - } - }); - }); -}); - -describe("MigrationCoordinator", () => { - let tempGlobalDir: string; - let central: CentralCore; - - beforeEach(async () => { - tempGlobalDir = tempWorkspace("kb-coordinator-test-"); - central = new CentralCore(tempGlobalDir); - await central.init(); - }); - - afterEach(async () => { - await central.close(); - }); - - describe("registerSingleProject", () => { - it("should register a new project successfully", async () => { - const tempProjectDir = tempWorkspace("kb-register-"); - createFakeKbProject(tempProjectDir); - - const coordinator = new MigrationCoordinator(central); - const result = await coordinator.registerSingleProject(tempProjectDir); - - expect(result.success).toBe(true); - expect(result.projectsRegistered).toHaveLength(1); - expect(result.errors).toHaveLength(0); - - // Verify project was registered - const project = await central.getProject(result.projectsRegistered[0]); - expect(project).toBeDefined(); - expect(project!.path).toBe(tempProjectDir); - expect(project!.status).toBe("active"); - }); - - it("should be idempotent - return existing project if already registered", async () => { - const tempProjectDir = tempWorkspace("kb-idempotent-"); - createFakeKbProject(tempProjectDir); - - const coordinator = new MigrationCoordinator(central); - - // First registration - const result1 = await coordinator.registerSingleProject(tempProjectDir); - expect(result1.success).toBe(true); - - // Second registration - should be idempotent - const result2 = await coordinator.registerSingleProject(tempProjectDir); - expect(result2.success).toBe(true); - expect(result2.projectsRegistered).toEqual(result1.projectsRegistered); - expect(result2.errors).toHaveLength(0); - }); - - it("should reject relative paths", async () => { - const coordinator = new MigrationCoordinator(central); - const result = await coordinator.registerSingleProject("./relative/path"); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors[0]).toContain("must be absolute"); - }); - - it("should reject absolute paths that are not valid kb projects", async () => { - const tempProjectDir = tempWorkspace("kb-invalid-project-"); - - const coordinator = new MigrationCoordinator(central); - const result = await coordinator.registerSingleProject(tempProjectDir); - - expect(result.success).toBe(false); - expect(result.projectsRegistered).toHaveLength(0); - expect(result.errors[0]).toContain("not a valid kb project"); - }); - - it("should handle duplicate names by appending suffix", async () => { - const tempRoot = tempWorkspace("kb-duplicate-names-"); - const tempProjectDir1 = join(tempRoot, "same-project"); - const tempProjectDir2 = join(tempRoot, "group", "same-project"); - mkdirSync(tempProjectDir1, { recursive: true }); - mkdirSync(tempProjectDir2, { recursive: true }); - createFakeKbProject(tempProjectDir1); - createFakeKbProject(tempProjectDir2); - - const coordinator = new MigrationCoordinator(central); - const result1 = await coordinator.registerSingleProject(tempProjectDir1); - const result2 = await coordinator.registerSingleProject(tempProjectDir2); - - expect(result1.success).toBe(true); - expect(result2.success).toBe(true); - - const project1 = await central.getProject(result1.projectsRegistered[0]); - const project2 = await central.getProject(result2.projectsRegistered[0]); - expect(project1!.name).toBe("same-project"); - expect(project2!.name).toBe("same-project-1"); - }); - - it("should reject nested project registration when parent is already registered", async () => { - const parentProjectDir = tempWorkspace("kb-parent-project-"); - createFakeKbProject(parentProjectDir); - const nestedProjectDir = join(parentProjectDir, "apps", "nested-project"); - mkdirSync(nestedProjectDir, { recursive: true }); - createFakeKbProject(nestedProjectDir); - - const coordinator = new MigrationCoordinator(central); - const parentResult = await coordinator.registerSingleProject(parentProjectDir); - const nestedResult = await coordinator.registerSingleProject(nestedProjectDir); - - expect(parentResult.success).toBe(true); - expect(nestedResult.success).toBe(false); - expect(nestedResult.errors[0]).toContain("overlaps an existing registered project"); - }); - - it("should register the detected ancestor project root when called from a nested directory", async () => { - const tempProjectDir = tempWorkspace("kb-nested-register-"); - createFakeKbProject(tempProjectDir); - const nestedDir = join(tempProjectDir, "packages", "feature"); - mkdirSync(nestedDir, { recursive: true }); - - const detector = new FirstRunDetector(tempGlobalDir); - const detected = await detector.detectExistingProjects(nestedDir); - expect(detected).toHaveLength(1); - - const coordinator = new MigrationCoordinator(central); - const result = await coordinator.registerSingleProject(detected[0].path); - - expect(result.success).toBe(true); - const projects = await central.listProjects(); - expect(projects).toHaveLength(1); - expect(projects[0].path.endsWith(tempProjectDir)).toBe(true); - }); - }); - - describe("completeSetup", () => { - it("should register multiple projects from wizard", async () => { - const tempProjectDir1 = tempWorkspace("kb-setup1-"); - const tempProjectDir2 = tempWorkspace("kb-setup2-"); - createFakeKbProject(tempProjectDir1); - createFakeKbProject(tempProjectDir2); - - const coordinator = new MigrationCoordinator(central); - const inputs: ProjectSetupInput[] = [ - { path: tempProjectDir1, name: "Project One" }, - { path: tempProjectDir2, name: "Project Two" }, - ]; - - const result = await coordinator.completeSetup(inputs); - - expect(result.success).toBe(true); - expect(result.projectsRegistered).toHaveLength(2); - expect(result.errors).toHaveLength(0); - await expect(isGitRepository(tempProjectDir1)).resolves.toBe(true); - await expect(isGitRepository(tempProjectDir2)).resolves.toBe(true); - }); - - it("should skip already registered projects", async () => { - const tempProjectDir = tempWorkspace("kb-setup-existing-"); - createFakeKbProject(tempProjectDir); - - const coordinator = new MigrationCoordinator(central); - - // Register first - const result1 = await coordinator.registerSingleProject(tempProjectDir); - - // Try to register again via completeSetup - const inputs: ProjectSetupInput[] = [{ path: tempProjectDir, name: "Some Name" }]; - const result2 = await coordinator.completeSetup(inputs); - - expect(result2.success).toBe(true); - expect(result2.projectsRegistered).toEqual(result1.projectsRegistered); - }); - - it("should reject invalid setup project paths", async () => { - const validProjectDir = tempWorkspace("kb-setup-valid-"); - const invalidProjectDir = tempWorkspace("kb-setup-invalid-"); - createFakeKbProject(validProjectDir); - - const inputs: ProjectSetupInput[] = [ - { path: validProjectDir, name: "Valid Project" }, - { path: invalidProjectDir, name: "Invalid Project" }, - ]; - - const coordinator = new MigrationCoordinator(central); - const result = await coordinator.completeSetup(inputs); - - expect(result.success).toBe(false); - expect(result.projectsRegistered).toHaveLength(1); - expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain("not a valid kb project"); - }); - }); - - describe("coordinateMigration", () => { - it("should auto-register an existing local project when no projects are registered", async () => { - const tempProjectDir = tempWorkspace("kb-coordinate-migration-"); - createFakeKbProject(tempProjectDir); - const nestedDir = join(tempProjectDir, "src", "feature"); - mkdirSync(nestedDir, { recursive: true }); - const originalCwd = process.cwd(); - process.chdir(nestedDir); - - try { - const coordinator = new MigrationCoordinator(central); - const result = await coordinator.coordinateMigration(); - - expect(result.success).toBe(true); - expect(result.projectsRegistered).toHaveLength(1); - expect(result.errors).toHaveLength(0); - - const registered = await central.listProjects(); - expect(registered).toHaveLength(1); - expect(registered[0].path.endsWith(tempProjectDir)).toBe(true); - } finally { - process.chdir(originalCwd); - } - }); - - it("should return success for fresh-install state", async () => { - // Close and remove central to simulate fresh state - await central.close(); - const { rmSync } = await import("node:fs"); - rmSync(join(tempGlobalDir, "fusion-central.db"), { force: true }); - - central = new CentralCore(tempGlobalDir); - await central.init(); - - // Change to fresh dir (no .fusion/) - useIsolatedCwd("kb-fresh-coord-"); - - const coordinator = new MigrationCoordinator(central); - const result = await coordinator.coordinateMigration(); - - expect(result.success).toBe(true); - expect(result.projectsRegistered).toHaveLength(0); - expect(result.errors).toHaveLength(0); - }); - - it("should be a no-op in setup-wizard state when no local project exists", async () => { - useIsolatedCwd("kb-setup-wizard-coord-"); - - const coordinator = new MigrationCoordinator(central); - const result = await coordinator.coordinateMigration(); - - expect(result.success).toBe(true); - expect(result.projectsRegistered).toHaveLength(0); - expect(result.errors).toHaveLength(0); - }); - - it("should be a no-op in normal-operation when projects already exist", async () => { - const existingProjectDir = tempWorkspace("kb-normal-op-existing-"); - await central.registerProject({ - name: "Existing Project", - path: existingProjectDir, - }); - - const localProjectDir = useIsolatedCwd("kb-normal-op-local-"); - createFakeKbProject(localProjectDir); - - const coordinator = new MigrationCoordinator(central); - const result = await coordinator.coordinateMigration(); - - expect(result.success).toBe(true); - expect(result.projectsRegistered).toHaveLength(0); - expect(result.errors).toHaveLength(0); - - const registered = await central.listProjects(); - expect(registered).toHaveLength(1); - }); - }); -}); - -describe("BackwardCompat", () => { - let tempGlobalDir: string; - let central: CentralCore; - - beforeEach(async () => { - tempGlobalDir = tempWorkspace("kb-compat-test-"); - central = new CentralCore(tempGlobalDir); - await central.init(); - }); - - afterEach(async () => { - await central.close(); - }); - - describe("resolveProjectContext", () => { - it("should use explicit project ID when provided", async () => { - const tempProjectDir = tempWorkspace("kb-explicit-"); - const project = await central.registerProject({ - name: "Explicit Project", - path: tempProjectDir, - }); - - const compat = new BackwardCompat(central); - const context = await compat.resolveProjectContext("/some/other/dir", project.id); - - expect(context.projectId).toBe(project.id); - expect(context.workingDirectory).toBe(tempProjectDir); - expect(context.isLegacy).toBe(false); - }); - - it("should auto-use single project when no explicit ID provided", async () => { - const tempProjectDir = tempWorkspace("kb-single-"); - const project = await central.registerProject({ - name: "Single Project", - path: tempProjectDir, - }); - - const compat = new BackwardCompat(central); - const context = await compat.resolveProjectContext("/some/other/dir"); - - expect(context.projectId).toBe(project.id); - expect(context.workingDirectory).toBe(tempProjectDir); - expect(context.isLegacy).toBe(false); - }); - - it("should throw ProjectRequiredError when multiple projects and no selection", async () => { - const tempProjectDir1 = tempWorkspace("kb-multi1-"); - const tempProjectDir2 = tempWorkspace("kb-multi2-"); - await central.registerProject({ name: "Project 1", path: tempProjectDir1 }); - await central.registerProject({ name: "Project 2", path: tempProjectDir2 }); - - const compat = new BackwardCompat(central); - - await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow( - ProjectRequiredError - ); - - try { - await compat.resolveProjectContext("/some/dir"); - } catch (err) { - expect(err).toBeInstanceOf(ProjectRequiredError); - expect((err as ProjectRequiredError).availableProjects).toHaveLength(2); - } - }); - - it("should find project by name (case-insensitive)", async () => { - const tempProjectDir = tempWorkspace("kb-byname-"); - const project = await central.registerProject({ - name: "My Project", - path: tempProjectDir, - }); - - const compat = new BackwardCompat(central); - const context = await compat.resolveProjectContext("/some/dir", "my project"); - - expect(context.projectId).toBe(project.id); - }); - - it("should throw when project not found", async () => { - const compat = new BackwardCompat(central); - - await expect(compat.resolveProjectContext("/some/dir", "nonexistent")).rejects.toThrow( - ProjectRequiredError - ); - }); - }); - - describe("isLegacyMode", () => { - it("should return false when central DB exists", async () => { - const compat = new BackwardCompat(central); - expect(await compat.isLegacyMode()).toBe(false); - }); - - it("should return true when no central DB", async () => { - // Close and remove central DB - await central.close(); - const { rmSync } = await import("node:fs"); - rmSync(join(tempGlobalDir, "fusion-central.db"), { force: true }); - - // Need to re-init CentralCore for it to work - central = new CentralCore(tempGlobalDir); - - const compat = new BackwardCompat(central); - expect(await compat.isLegacyMode()).toBe(true); - }); - }); -}); - -describe("CentralCore migration helpers", () => { - let tempGlobalDir: string; - let central: CentralCore; - - beforeEach(async () => { - tempGlobalDir = tempWorkspace("kb-central-migration-test-"); - central = new CentralCore(tempGlobalDir); - await central.init(); - }); - - afterEach(async () => { - await central.close(); - }); - - describe("autoRegisterProject", () => { - it("should auto-register a project with generated name", async () => { - const tempProjectDir = tempWorkspace("kb-autoreg-"); - createFakeKbProject(tempProjectDir); - - const project = await central.autoRegisterProject(tempProjectDir); - - expect(project).toBeDefined(); - expect(project.path).toBe(tempProjectDir); - expect(project.isolationMode).toBe("in-process"); - expect(project.status).toBe("active"); - expect(project.name).toContain("kb-autoreg"); // Based on directory name - }); - - it("should reject nested auto-registration when parent project is already registered", async () => { - const parentProjectDir = tempWorkspace("kb-central-parent-"); - createFakeKbProject(parentProjectDir); - const nestedProjectDir = join(parentProjectDir, "packages", "nested"); - mkdirSync(nestedProjectDir, { recursive: true }); - createFakeKbProject(nestedProjectDir); - - await central.autoRegisterProject(parentProjectDir); - - await expect(central.autoRegisterProject(nestedProjectDir)).rejects.toThrow(/overlaps an existing registered project/); - }); - - it("should be idempotent - return existing project if already registered", async () => { - const tempProjectDir = tempWorkspace("kb-autoreg-dup-"); - createFakeKbProject(tempProjectDir); - - const project1 = await central.autoRegisterProject(tempProjectDir); - const project2 = await central.autoRegisterProject(tempProjectDir); - - expect(project1.id).toBe(project2.id); - expect(project1.name).toBe(project2.name); - }); - }); - - describe("isProjectRegistered", () => { - it("should return false for unregistered project", async () => { - const tempProjectDir = tempWorkspace("kb-unreg-"); - - const isRegistered = await central.isProjectRegistered(tempProjectDir); - - expect(isRegistered).toBe(false); - }); - - it("should return true for registered project", async () => { - const tempProjectDir = tempWorkspace("kb-registered-"); - await central.registerProject({ - name: "Registered", - path: tempProjectDir, - }); - - const isRegistered = await central.isProjectRegistered(tempProjectDir); - - expect(isRegistered).toBe(true); - }); - }); - - describe("getFirstRunState", () => { - it("should return setup-wizard when no projects", async () => { - const state = await central.getFirstRunState(); - expect(state).toBe("setup-wizard"); - }); - - it("should return normal-operation when projects exist", async () => { - const tempProjectDir = tempWorkspace("kb-state-test-"); - await central.registerProject({ - name: "State Test", - path: tempProjectDir, - }); - - const state = await central.getFirstRunState(); - - expect(state).toBe("normal-operation"); - }); - }); -}); - -describe("ProjectRequiredError", () => { - it("should include available projects in error", () => { - const available = [ - { id: "proj_1", name: "Project One" }, - { id: "proj_2", name: "Project Two" }, - ]; - - const error = new ProjectRequiredError("Test message", available); - - expect(error.message).toBe("Test message"); - expect(error.name).toBe("ProjectRequiredError"); - expect(error.availableProjects).toEqual(available); - }); -}); diff --git a/packages/core/src/__tests__/mission-factory-parity.integration.test.ts b/packages/core/src/__tests__/mission-factory-parity.integration.test.ts deleted file mode 100644 index 3da4087d81..0000000000 --- a/packages/core/src/__tests__/mission-factory-parity.integration.test.ts +++ /dev/null @@ -1,544 +0,0 @@ -/** - * Mission Factory Parity Integration Tests - * - * These tests verify that Factory mission behavior stays consistent across - * MissionStore persistence layers. They test: - * - Clarification artifacts (planningNotes, verification) persist across restart - * - Feature execution transitions stay synchronized - * - Retry round behavior is consistent - * - Blocked paths prevent further scheduling - * - * Run: pnpm --filter @fusion/core exec vitest run src/mission-factory-parity.integration.test.ts - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync } from "node:fs"; -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { TaskStore } from "../store.js"; - -function makeTmpDir(): string { - return mkdtempSync(join(tmpdir(), "kb-mission-factory-parity-")); -} - -/** - * Parity Matrix: Maps scenario → MissionStore API → persisted field - * - * | Scenario | API | Field | - * |------------------------------------|------------------------------|--------------------------| - * | Planning notes persist | updateMilestone/slice | planningNotes | - * | Verification criteria persist | updateMilestone/slice | verification | - * | Enriched context tied to hierarchy | buildEnrichedDescription | (computed) | - * | Feature link stable across restart | linkFeatureToTask | taskId | - * | Feature status transitions | updateFeatureStatus | status | - * | Rollup reflects current state | getMissionHealth | tasksCompleted, etc. | - * | Autopilot enabled persists | updateMission(autopilot) | autopilotEnabled | - * | Blocked features tracked | updateFeatureStatus(blocked) | status=blocked | - */ - -describe("MissionFactory Parity: Core MissionStore", () => { - let rootDir: string; - let taskStore: TaskStore; - - beforeEach(async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-11T00:00:00.000Z")); - - rootDir = makeTmpDir(); - taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore.init(); - }); - - afterEach(async () => { - vi.useRealTimers(); - await rm(rootDir, { recursive: true, force: true }); - }); - - describe("Parity Matrix: Clarification Artifacts Persistence", () => { - it("milestone planningNotes persist across store restart", async () => { - const missionStore = taskStore.getMissionStore(); - - // Create hierarchy - const mission = missionStore.createMission({ - title: "Auth System", - description: "Build authentication", - }); - const milestone = missionStore.addMilestone(mission.id, { - title: "Core Auth", - description: "Implement JWT", - }); - - // Update planning notes - const planningNotes = "Using RS256 signing strategy"; - missionStore.updateMilestone(milestone.id, { planningNotes }); - - // Simulate restart by creating new store instance - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); - const missionStore2 = taskStore2.getMissionStore(); - - // Verify persistence - const retrieved = missionStore2.getMilestone(milestone.id); - expect(retrieved).toBeDefined(); - expect(retrieved!.planningNotes).toBe(planningNotes); - }); - - it("milestone verification persists across store restart", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test Mission" }); - const milestone = missionStore.addMilestone(mission.id, { - title: "Core", - description: "Core implementation", - }); - - const verification = "Users can authenticate with email/password"; - missionStore.updateMilestone(milestone.id, { verification }); - - // Restart - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); - const missionStore2 = taskStore2.getMissionStore(); - - const retrieved = missionStore2.getMilestone(milestone.id); - expect(retrieved!.verification).toBe(verification); - }); - - it("slice planningNotes persist across store restart", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test Mission" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { - title: "S1", - description: "Slice 1", - }); - - const planningNotes = "Use existing design system tokens"; - missionStore.updateSlice(slice.id, { planningNotes }); - - // Restart - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); - const missionStore2 = taskStore2.getMissionStore(); - - const retrieved = missionStore2.getSlice(slice.id); - expect(retrieved!.planningNotes).toBe(planningNotes); - }); - - it("slice verification persists across store restart", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test Mission" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { - title: "S1", - description: "Slice 1", - }); - - const verification = "Login form accepts valid credentials"; - missionStore.updateSlice(slice.id, { verification }); - - // Restart - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); - const missionStore2 = taskStore2.getMissionStore(); - - const retrieved = missionStore2.getSlice(slice.id); - expect(retrieved!.verification).toBe(verification); - }); - - it("enriched description tied to correct hierarchy node", async () => { - const missionStore = taskStore.getMissionStore(); - - // Create hierarchy with distinct context at each level - const mission = missionStore.createMission({ - title: "Auth Mission", - description: "Build complete auth system", - }); - const milestone = missionStore.addMilestone(mission.id, { - title: "Login Milestone", - description: "Implement login flow", - planningNotes: "JWT with refresh tokens", - verification: "Users can log in", - }); - const slice = missionStore.addSlice(milestone.id, { - title: "Login Slice", - description: "Build login UI", - planningNotes: "Use existing components", - verification: "Form validates input", - }); - const feature = missionStore.addFeature(slice.id, { - title: "Login Form Feature", - description: "Email/password form", - acceptanceCriteria: "Shows validation errors", - }); - - // Build enriched description - const enriched = missionStore.buildEnrichedDescription(feature.id); - - expect(enriched).toBeDefined(); - // Verify context is tied to correct levels - expect(enriched).toContain("Auth Mission"); - expect(enriched).toContain("Login Milestone"); - expect(enriched).toContain("Login Slice"); - expect(enriched).toContain("Login Form Feature"); - // Verify distinct planning notes - expect(enriched).toContain("JWT with refresh tokens"); - expect(enriched).toContain("Use existing components"); - }); - - it("enriched description omits empty sections", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ - title: "Minimal Mission", - description: "Just basics", - }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { title: "S1" }); - const feature = missionStore.addFeature(slice.id, { - title: "F1", - description: "Feature", - }); - - const enriched = missionStore.buildEnrichedDescription(feature.id); - - // Should not have undefined/null strings in output - expect(enriched).not.toMatch(/Planning Notes:\s*undefined/); - expect(enriched).not.toMatch(/Verification:\s*undefined/); - expect(enriched).not.toMatch(/Description:\s*undefined/); - }); - }); - - describe("Parity Matrix: Feature Execution Transitions", () => { - it("linkFeatureToTask creates stable link", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { title: "S1" }); - const feature = missionStore.addFeature(slice.id, { - title: "F1", - description: "Feature 1", - }); - - // First create the task in the store (linkFeatureToTask requires task to exist) - const task = await taskStore.createTask({ - title: "Task for F1", - description: "Created for feature link", - }); - - // Link feature to task - missionStore.linkFeatureToTask(feature.id, task.id); - - // Restart and verify link persists - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); - const missionStore2 = taskStore2.getMissionStore(); - - const linked = missionStore2.getFeatureByTaskId(task.id); - expect(linked).toBeDefined(); - expect(linked!.id).toBe(feature.id); - }); - - it("updateFeatureStatus transitions are recorded correctly", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { title: "S1" }); - const feature = missionStore.addFeature(slice.id, { - title: "F1", - description: "Feature", - }); - - // Transition through states (note: 'done' not 'completed') - missionStore.updateFeatureStatus(feature.id, "defined"); - missionStore.updateFeatureStatus(feature.id, "in-progress"); - missionStore.updateFeatureStatus(feature.id, "blocked"); - missionStore.updateFeatureStatus(feature.id, "done"); - - // Verify final state - const hierarchy = missionStore.getMissionWithHierarchy(mission.id); - const featureState = hierarchy!.milestones[0].slices[0].features[0]; - expect(featureState.status).toBe("done"); - }); - - it("triageFeature enriches task with context", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ - title: "Auth Mission", - description: "Build auth", - }); - const milestone = missionStore.addMilestone(mission.id, { - title: "Core Auth", - description: "Implement JWT", - verification: "Login works", - }); - const slice = missionStore.addSlice(milestone.id, { - title: "Login", - description: "Login UI", - }); - const feature = missionStore.addFeature(slice.id, { - title: "Login Form", - description: "Standard form", - }); - - // Triage the feature (creates task and links) - const updatedFeature = await missionStore.triageFeature(feature.id); - - expect(updatedFeature).toBeDefined(); - expect(updatedFeature.taskId).toBeDefined(); - expect(updatedFeature.taskId).toMatch(/^FN-/); - - // Verify the task has enriched description - const task = await taskStore.getTask(updatedFeature.taskId!); - expect(task).toBeDefined(); - expect(task!.description).toContain("Auth Mission"); - expect(task!.description).toContain("Core Auth"); - expect(task!.description).toContain("Login Form"); - }); - }); - - describe("Parity Matrix: Mission Health Rollups", () => { - it("getMissionHealth reflects current feature states", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { title: "S1" }); - - // Add features with various states - const f1 = missionStore.addFeature(slice.id, { title: "F1" }); - const f2 = missionStore.addFeature(slice.id, { title: "F2" }); - const f3 = missionStore.addFeature(slice.id, { title: "F3" }); - - // Use correct status values - missionStore.updateFeatureStatus(f1.id, "done"); - missionStore.updateFeatureStatus(f2.id, "in-progress"); - missionStore.updateFeatureStatus(f3.id, "blocked"); - - const health = missionStore.getMissionHealth(mission.id); - - expect(health).toBeDefined(); - expect(health!.totalTasks).toBe(3); - expect(health!.tasksCompleted).toBe(1); - expect(health!.tasksInFlight).toBe(1); - }); - - it("health rollup updates when feature status changes", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { title: "S1" }); - const feature = missionStore.addFeature(slice.id, { title: "F1" }); - - // Initial health - no completed features - let health = missionStore.getMissionHealth(mission.id); - expect(health!.tasksCompleted).toBe(0); - - // Complete the feature (status = 'done') - missionStore.updateFeatureStatus(feature.id, "done"); - - // Health should update - health = missionStore.getMissionHealth(mission.id); - expect(health!.tasksCompleted).toBe(1); - }); - - it("blocked features tracked in health", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { title: "S1" }); - - // Create blocked features - const f1 = missionStore.addFeature(slice.id, { title: "F1" }); - const f2 = missionStore.addFeature(slice.id, { title: "F2" }); - - missionStore.updateFeatureStatus(f1.id, "blocked"); - missionStore.updateFeatureStatus(f2.id, "blocked"); - - // Note: MissionHealth doesn't have a blockedFeatures field, - // but it does track tasksFailed for failed tasks - const health = missionStore.getMissionHealth(mission.id); - expect(health).toBeDefined(); - expect(health!.totalTasks).toBe(2); - }); - }); - - describe("Parity Matrix: Autopilot Configuration", () => { - it("missions created with autopilotEnabled start disabled across restart", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ - title: "Test", - autopilotEnabled: true, - }); - - // Verify initial state - let retrieved = missionStore.getMission(mission.id); - expect(retrieved!.autopilotEnabled).toBe(false); - - // Restart - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); - const missionStore2 = taskStore2.getMissionStore(); - - // Verify persistence - retrieved = missionStore2.getMission(mission.id); - expect(retrieved!.autopilotEnabled).toBe(false); - }); - - it("autopilotEnabled can be toggled", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ - title: "Test", - autopilotEnabled: false, - }); - - // Enable autopilot - missionStore.updateMission(mission.id, { autopilotEnabled: true }); - - let retrieved = missionStore.getMission(mission.id); - expect(retrieved!.autopilotEnabled).toBe(true); - - // Disable autopilot - missionStore.updateMission(mission.id, { autopilotEnabled: false }); - - retrieved = missionStore.getMission(mission.id); - expect(retrieved!.autopilotEnabled).toBe(false); - }); - - it("autopilotState persists across restart", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ - title: "Test", - autopilotEnabled: true, - }); - - // Update autopilot state - missionStore.updateMission(mission.id, { autopilotState: "watching" }); - - // Restart - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); - const missionStore2 = taskStore2.getMissionStore(); - - const retrieved = missionStore2.getMission(mission.id); - expect(retrieved!.autopilotState).toBe("watching"); - }); - }); - - describe("Parity Matrix: Blocked Feature Paths", () => { - it("blocked features remain blocked across restart", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { title: "S1" }); - const feature = missionStore.addFeature(slice.id, { title: "F1" }); - - missionStore.updateFeatureStatus(feature.id, "blocked"); - - // Restart - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); - const missionStore2 = taskStore2.getMissionStore(); - - // Verify blocked status persisted - const hierarchy = missionStore2.getMissionWithHierarchy(mission.id); - const fState = hierarchy!.milestones[0].slices[0].features[0]; - expect(fState.status).toBe("blocked"); - }); - - it("blocked features affect mission health", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { title: "S1" }); - const feature = missionStore.addFeature(slice.id, { title: "F1" }); - - missionStore.updateFeatureStatus(feature.id, "blocked"); - - const health = missionStore.getMissionHealth(mission.id); - expect(health).toBeDefined(); - expect(health!.totalTasks).toBe(1); - // Mission is in planning status since we haven't activated it yet - expect(health!.status).toBe("planning"); - }); - - it("blocked feature can be unblocked", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { title: "S1" }); - const feature = missionStore.addFeature(slice.id, { title: "F1" }); - - // Block then unblock - missionStore.updateFeatureStatus(feature.id, "blocked"); - missionStore.updateFeatureStatus(feature.id, "defined"); - - const hierarchy = missionStore.getMissionWithHierarchy(mission.id); - const fState = hierarchy!.milestones[0].slices[0].features[0]; - expect(fState.status).toBe("defined"); - }); - }); - - describe("Parity Matrix: Deterministic Event Ordering", () => { - it("mission events ordered by timestamp with stable tiebreaker", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test" }); - - // Create events in rapid succession (same millisecond) - vi.advanceTimersByTime(0); - missionStore.logMissionEvent(mission.id, "warning", "First"); - vi.advanceTimersByTime(1); - missionStore.logMissionEvent(mission.id, "warning", "Second"); - vi.advanceTimersByTime(1); - missionStore.logMissionEvent(mission.id, "warning", "Third"); - - const result = missionStore.getMissionEvents(mission.id); - - // Events are ordered by timestamp DESC, id DESC (most recent first) - expect(result.events.length).toBeGreaterThanOrEqual(3); - // Most recent event should be first - expect(result.events[0].description).toBe("Third"); - }); - - it("event log persists across restart", async () => { - const missionStore = taskStore.getMissionStore(); - - const mission = missionStore.createMission({ title: "Test" }); - missionStore.logMissionEvent(mission.id, "warning", "Test message", { - source: "parity_test", - }); - - // Restart - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); - const missionStore2 = taskStore2.getMissionStore(); - - const result = missionStore2.getMissionEvents(mission.id); - expect(result.events.some((e) => e.description === "Test message")).toBe(true); - }); - }); -}); diff --git a/packages/core/src/__tests__/mission-integration.test.ts b/packages/core/src/__tests__/mission-integration.test.ts deleted file mode 100644 index 38473e530f..0000000000 --- a/packages/core/src/__tests__/mission-integration.test.ts +++ /dev/null @@ -1,668 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync } from "node:fs"; -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { TaskStore } from "../store.js"; -import { Database } from "../db.js"; - -function makeTmpDir(): string { - return mkdtempSync(join(tmpdir(), "kb-mission-integration-")); -} - -function getPrivateDb(store: TaskStore): Database | null { - return (store as unknown as { _db: Database | null })._db; -} - -function assertHierarchyIntegrity( - hierarchy: NonNullable["getMissionWithHierarchy"]>>, -) { - expect(hierarchy.milestones.every((milestone, index) => milestone.orderIndex === index)).toBe(true); - expect(new Set(hierarchy.milestones.map((milestone) => milestone.id)).size).toBe(hierarchy.milestones.length); - - for (const milestone of hierarchy.milestones) { - expect(milestone.slices.every((slice, index) => slice.orderIndex === index)).toBe(true); - expect(new Set(milestone.slices.map((slice) => slice.id)).size).toBe(milestone.slices.length); - for (const slice of milestone.slices) { - expect(slice.milestoneId).toBe(milestone.id); - expect(new Set(slice.features.map((feature) => feature.id)).size).toBe(slice.features.length); - for (const feature of slice.features) { - expect(feature.sliceId).toBe(slice.id); - } - } - } -} - -/** - * Creates a mission hierarchy large enough to exercise rollups, reorder logic, - * and cascade deletions in integration scenarios. - */ -async function createHierarchy(store: TaskStore) { - const missionStore = store.getMissionStore(); - const mission = missionStore.createMission({ - title: "Launch authentication", - description: "Mission hierarchy integration test", - }); - - const milestones = Array.from({ length: 3 }, (_, milestoneIndex) => { - const milestone = missionStore.addMilestone(mission.id, { - title: `Milestone ${milestoneIndex + 1}`, - description: `Phase ${milestoneIndex + 1}`, - }); - - const slices = Array.from({ length: 2 }, (_, sliceIndex) => { - const slice = missionStore.addSlice(milestone.id, { - title: `Slice ${milestoneIndex + 1}.${sliceIndex + 1}`, - description: `Slice ${milestoneIndex + 1}.${sliceIndex + 1}`, - }); - - const features = Array.from({ length: 3 }, (_, featureIndex) => - missionStore.addFeature(slice.id, { - title: `Feature ${milestoneIndex + 1}.${sliceIndex + 1}.${featureIndex + 1}`, - description: "Feature description", - acceptanceCriteria: "criterion", - }), - ); - - return { ...slice, features }; - }); - - return { ...milestone, slices }; - }); - - return { missionStore, mission, milestones }; -} - -/** - * MissionStore integration tests verify the missions hierarchy when it shares - * the same SQLite database as TaskStore. These scenarios cover linking tasks - * to features, rollup state transitions, hierarchy integrity after reorders and - * deletions, foreign-key cleanup, and event emissions that other packages rely on. - */ -describe("MissionStore integration with TaskStore", () => { - let rootDir: string; - let taskStore: TaskStore; - let storesToClose: TaskStore[]; - - /** - * FNXC:CoreTests 2026-06-17-14:36: - * Restart-fidelity coverage must prove committed mission rows survive TaskStore.close() and a fresh - * TaskStore(rootDir).init() across every mission read path, including empty and populated hierarchies. - * Register reopened stores so fake timers and WAL-backed SQLite handles are closed before temp-root - * cleanup instead of leaking across package fan-out. - */ - function registerStore(store: TaskStore): TaskStore { - storesToClose.push(store); - return store; - } - - async function openRestartedStore(): Promise { - taskStore.close(); - const restarted = registerStore( - new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")), - ); - await restarted.init(); - taskStore = restarted; - return restarted; - } - - beforeEach(async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z")); - - rootDir = makeTmpDir(); - storesToClose = []; - taskStore = registerStore(new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"))); - await taskStore.init(); - }); - - afterEach(async () => { - for (const store of [...storesToClose].reverse()) { - store.close(); - } - storesToClose = []; - vi.useRealTimers(); - await rm(rootDir, { recursive: true, force: true }); - }); - - it("creates and retrieves a full hierarchy through the shared MissionStore", async () => { - const { missionStore, mission } = await createHierarchy(taskStore); - - const fullMission = missionStore.getMissionWithHierarchy(mission.id); - - expect(fullMission).toBeDefined(); - expect(fullMission?.milestones).toHaveLength(3); - expect(fullMission?.milestones.every((milestone) => milestone.slices.length === 2)).toBe(true); - expect( - fullMission?.milestones.every((milestone) => - milestone.slices.every((slice) => { - const hierarchySlice = slice as typeof slice & { features: Array<{ id: string }> }; - return hierarchySlice.features.length === 3; - }), - ), - ).toBe(true); - }); - - it("links features to real TaskStore tasks and updates sliceId without populating missionId", async () => { - const { missionStore, mission, milestones } = await createHierarchy(taskStore); - const feature = milestones[0].slices[0].features[0]; - - const linkedTask = await taskStore.createTask({ - title: "Build login form", - description: "Implement the login form task used for mission linking.", - column: "todo", - }); - - // TaskStore persists tasks to disk first, so create a DB-backed snapshot - // using a normal update path before MissionStore writes the linkage field. - await taskStore.moveTask(linkedTask.id, "in-progress"); - - const linkedFeature = missionStore.linkFeatureToTask(feature.id, linkedTask.id); - const storedTask = await taskStore.getTask(linkedTask.id); - const taskRow = getPrivateDb(taskStore)?.prepare( - "SELECT missionId, sliceId FROM tasks WHERE id = ?", - ).get(linkedTask.id) as { missionId: string | null; sliceId: string | null } | undefined; - - expect(linkedFeature.taskId).toBe(linkedTask.id); - expect(linkedFeature.status).toBe("triaged"); - expect(storedTask.sliceId).toBe(milestones[0].slices[0].id); - expect(taskRow?.sliceId).toBe(milestones[0].slices[0].id); - expect(taskRow?.missionId).toBe(mission.id); - - const linkedHierarchy = missionStore.getMissionWithHierarchy(mission.id); - expect(linkedHierarchy?.milestones[0].slices[0].features[0].taskId).toBe(linkedTask.id); - }); - - it("rolls up status from features to slices, milestones, and mission", async () => { - const { missionStore, mission, milestones } = await createHierarchy(taskStore); - const [firstMilestone] = milestones; - const [firstSlice] = firstMilestone.slices; - - const linkedFeatures: { featureId: string; taskId: string }[] = []; - for (const feature of firstSlice.features) { - const task = await taskStore.createTask({ - title: feature.title, - description: `Task for ${feature.title}`, - column: "todo", - }); - await taskStore.moveTask(task.id, "in-progress"); - missionStore.linkFeatureToTask(feature.id, task.id); - linkedFeatures.push({ featureId: feature.id, taskId: task.id }); - } - - let updatedSlice = missionStore.getSlice(firstSlice.id); - let updatedMilestone = missionStore.getMilestone(firstMilestone.id); - let updatedMission = missionStore.getMission(mission.id); - - expect(updatedSlice?.status).toBe("active"); - expect(updatedMilestone?.status).toBe("active"); - expect(updatedMission?.status).toBe("active"); - - for (const { featureId, taskId } of linkedFeatures) { - await taskStore.moveTask(taskId, "in-review"); - await taskStore.moveTask(taskId, "done"); - missionStore.updateFeature(featureId, { taskId, status: "done", lastValidatorStatus: "passed" }); - } - - updatedSlice = missionStore.getSlice(firstSlice.id); - updatedMilestone = missionStore.getMilestone(firstMilestone.id); - updatedMission = missionStore.getMission(mission.id); - - expect(updatedSlice?.status).toBe("complete"); - expect(updatedMilestone?.status).toBe("active"); - expect(updatedMission?.status).toBe("active"); - }); - - it("persists missionId and sliceId when linking a feature to a task", async () => { - const missionStore = taskStore.getMissionStore(); - const mission = missionStore.createMission({ title: "Test Mission" }); - const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" }); - const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" }); - const feature = missionStore.addFeature(slice.id, { title: "Feature 1" }); - - const task = await taskStore.createTask({ - description: "Implement feature", - title: "Feature implementation", - column: "todo", - }); - - missionStore.linkFeatureToTask(feature.id, task.id); - - const reloaded = await taskStore.getTask(task.id); - expect(reloaded.missionId).toBe(mission.id); - expect(reloaded.sliceId).toBe(slice.id); - }); - - it("clears missionId and sliceId when unlinking a feature from a task", async () => { - const missionStore = taskStore.getMissionStore(); - const mission = missionStore.createMission({ title: "Test Mission" }); - const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" }); - const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" }); - const feature = missionStore.addFeature(slice.id, { title: "Feature 1" }); - - const task = await taskStore.createTask({ - description: "Implement feature", - title: "Feature implementation", - column: "todo", - }); - - missionStore.linkFeatureToTask(feature.id, task.id); - missionStore.unlinkFeatureFromTask(feature.id); - - const reloaded = await taskStore.getTask(task.id); - expect(reloaded.missionId).toBeUndefined(); - expect(reloaded.sliceId).toBeUndefined(); - }); - - it("cascades mission deletion across milestones, slices, and features", async () => { - const { missionStore, mission, milestones } = await createHierarchy(taskStore); - const milestoneIds = milestones.map((milestone) => milestone.id); - const sliceIds = milestones.flatMap((milestone) => milestone.slices.map((slice) => slice.id)); - const featureIds = milestones.flatMap((milestone) => - milestone.slices.flatMap((slice) => slice.features.map((feature) => feature.id)), - ); - - missionStore.deleteMission(mission.id); - - expect(missionStore.getMission(mission.id)).toBeUndefined(); - expect(milestoneIds.every((id) => missionStore.getMilestone(id) === undefined)).toBe(true); - expect(sliceIds.every((id) => missionStore.getSlice(id) === undefined)).toBe(true); - expect(featureIds.every((id) => missionStore.getFeature(id) === undefined)).toBe(true); - }); - - it("recomputes order indexes after deleting a middle milestone and preserves child integrity after reorder", async () => { - const { missionStore, mission, milestones } = await createHierarchy(taskStore); - const [firstMilestone, middleMilestone, lastMilestone] = milestones; - - missionStore.deleteMilestone(middleMilestone.id); - - const afterDelete = missionStore.listMilestones(mission.id); - expect(afterDelete.map((milestone) => milestone.id)).toEqual([firstMilestone.id, lastMilestone.id]); - missionStore.reorderMilestones(mission.id, [firstMilestone.id, lastMilestone.id]); - - const afterRecompute = missionStore.listMilestones(mission.id); - expect(afterRecompute.map((milestone) => milestone.orderIndex)).toEqual([0, 1]); - expect(missionStore.getSlice(middleMilestone.slices[0].id)).toBeUndefined(); - expect(missionStore.getFeature(middleMilestone.slices[0].features[0].id)).toBeUndefined(); - - missionStore.reorderMilestones(mission.id, [lastMilestone.id, firstMilestone.id]); - const reordered = missionStore.getMissionWithHierarchy(mission.id); - - expect(reordered?.milestones.map((milestone) => milestone.id)).toEqual([ - lastMilestone.id, - firstMilestone.id, - ]); - expect(reordered?.milestones[0].slices.map((slice) => slice.id)).toEqual( - lastMilestone.slices.map((slice) => slice.id), - ); - expect(reordered?.milestones[1].slices[0].features.map((feature) => feature.id)).toEqual( - firstMilestone.slices[0].features.map((feature) => feature.id), - ); - expect(reordered).toBeDefined(); - assertHierarchyIntegrity(reordered!); - }); - - it("emits mission lifecycle events for creation, linking, and slice activation in order", async () => { - const missionStore = taskStore.getMissionStore(); - const events: string[] = []; - - missionStore.on("mission:created", () => events.push("mission:created")); - missionStore.on("feature:linked", () => events.push("feature:linked")); - missionStore.on("slice:activated", () => events.push("slice:activated")); - - const mission = missionStore.createMission({ title: "Event mission" }); - const milestone = missionStore.addMilestone(mission.id, { title: "Event milestone" }); - const slice = missionStore.addSlice(milestone.id, { title: "Event slice" }); - const feature = missionStore.addFeature(slice.id, { title: "Event feature" }); - const task = await taskStore.createTask({ - title: "Event task", - description: "Task for event assertions", - column: "todo", - }); - await taskStore.moveTask(task.id, "in-progress"); - - missionStore.linkFeatureToTask(feature.id, task.id); - await missionStore.activateSlice(slice.id); - - expect(events).toEqual(["mission:created", "feature:linked", "slice:activated"]); - }); - - it("uses the same Database instance for TaskStore and MissionStore", () => { - const missionStore = taskStore.getMissionStore(); - const db = getPrivateDb(taskStore); - const missionStoreDb = (missionStore as unknown as { db: Database }).db; - - expect(db).toBeDefined(); - expect(missionStoreDb).toBe(db); - }); - - it("keeps hierarchy retrievable after repeated deterministic reorder operations", async () => { - const { missionStore, mission, milestones } = await createHierarchy(taskStore); - - missionStore.reorderMilestones(mission.id, [milestones[2].id, milestones[0].id, milestones[1].id]); - missionStore.reorderMilestones(mission.id, [milestones[1].id, milestones[2].id, milestones[0].id]); - - for (const milestone of missionStore.listMilestones(mission.id)) { - const slices = missionStore.listSlices(milestone.id); - missionStore.reorderSlices( - milestone.id, - slices - .map((slice) => slice.id) - .reverse(), - ); - } - - const hierarchy = missionStore.getMissionWithHierarchy(mission.id); - - expect(hierarchy?.milestones).toHaveLength(3); - expect(hierarchy).toBeDefined(); - assertHierarchyIntegrity(hierarchy!); - }); - - it("keeps hierarchy valid under overlapping reorder and lookup operations", async () => { - const { missionStore, mission, milestones } = await createHierarchy(taskStore); - - await Promise.all([ - Promise.resolve().then(() => - missionStore.reorderMilestones(mission.id, [milestones[1].id, milestones[2].id, milestones[0].id]), - ), - Promise.resolve().then(() => { - const slices = missionStore.listSlices(milestones[0].id); - missionStore.reorderSlices(milestones[0].id, slices.map((slice) => slice.id).reverse()); - }), - Promise.resolve().then(() => missionStore.getMissionWithHierarchy(mission.id)), - Promise.resolve().then(() => missionStore.listMissions()), - ]); - - const hierarchy = missionStore.getMissionWithHierarchy(mission.id); - expect(hierarchy).toBeDefined(); - assertHierarchyIntegrity(hierarchy!); - }); - - it("keeps all descendants retrievable after bulk feature completion updates", async () => { - const { missionStore, mission } = await createHierarchy(taskStore); - const hierarchy = missionStore.getMissionWithHierarchy(mission.id)!; - - for (const milestone of hierarchy.milestones) { - for (const slice of milestone.slices) { - for (const feature of slice.features) { - missionStore.updateFeature(feature.id, { status: "done" }); - } - } - } - - const refreshed = missionStore.getMissionWithHierarchy(mission.id)!; - expect(refreshed.milestones).toHaveLength(3); - expect( - refreshed.milestones.every((milestone) => - milestone.slices.every((slice) => { - const hierarchySlice = slice as typeof slice & { features: Array<{ status: string }> }; - return hierarchySlice.features.every((feature) => feature.status === "done"); - }), - ), - ).toBe(true); - }); - - it("clears mission feature task links when a linked task is deleted", async () => { - const { missionStore, milestones } = await createHierarchy(taskStore); - const feature = milestones[0].slices[0].features[0]; - const task = await taskStore.createTask({ - title: "Delete linked task", - description: "Task used to verify foreign key cleanup.", - column: "todo", - }); - await taskStore.moveTask(task.id, "in-progress"); - missionStore.linkFeatureToTask(feature.id, task.id); - - await taskStore.deleteTask(task.id); - - const refreshed = missionStore.getFeature(feature.id); - expect(refreshed?.taskId).toBeUndefined(); - }, 15000); - - // ── Parity: Restart Fidelity Tests ────────────────────────────────── - - describe("Parity: Restart Fidelity", () => { - it("persists mission status across store restart", async () => { - const missionStore = taskStore.getMissionStore(); - const mission = missionStore.createMission({ - title: "Restart Test Mission", - description: "Testing persistence", - }); - - // Verify initial status is planning - expect(mission.status).toBe("planning"); - - // Update to active - missionStore.updateMission(mission.id, { status: "active", autopilotEnabled: true }); - - // Restart store - const taskStore2 = await openRestartedStore(); - const missionStore2 = taskStore2.getMissionStore(); - - const retrieved = missionStore2.getMission(mission.id); - expect(retrieved).toBeDefined(); - expect(retrieved!.title).toBe("Restart Test Mission"); - expect(retrieved!.status).toBe("active"); - expect(retrieved!.autopilotEnabled).toBe(true); - }); - - it("persists autopilot state across store restart", async () => { - const missionStore = taskStore.getMissionStore(); - const mission = missionStore.createMission({ - title: "Autopilot State Test", - autopilotEnabled: true, - }); - - // Update autopilot state - missionStore.updateMission(mission.id, { autopilotState: "watching" }); - - // Update to a different state - missionStore.updateMission(mission.id, { autopilotState: "inactive" }); - - // Restart store - const taskStore2 = await openRestartedStore(); - const missionStore2 = taskStore2.getMissionStore(); - - const retrieved = missionStore2.getMission(mission.id); - expect(retrieved!.autopilotState).toBe("inactive"); - }); - - it("persists feature-to-task linkage across store restart", async () => { - const missionStore = taskStore.getMissionStore(); - const mission = missionStore.createMission({ title: "Linkage Test" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { title: "S1" }); - const feature = missionStore.addFeature(slice.id, { title: "F1" }); - - const task = await taskStore.createTask({ - title: "Linked Task", - description: "Task linked to feature", - column: "todo", - }); - - missionStore.linkFeatureToTask(feature.id, task.id); - - // Restart store - const taskStore2 = await openRestartedStore(); - const missionStore2 = taskStore2.getMissionStore(); - - const retrieved = missionStore2.getFeature(feature.id); - expect(retrieved!.taskId).toBe(task.id); - expect(retrieved!.status).toBe("triaged"); - }); - - it("persists feature status across store restart", async () => { - const missionStore = taskStore.getMissionStore(); - const mission = missionStore.createMission({ title: "Status Test" }); - const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); - const slice = missionStore.addSlice(milestone.id, { title: "S1" }); - const feature = missionStore.addFeature(slice.id, { title: "F1" }); - - // Transition through states - missionStore.updateFeatureStatus(feature.id, "triaged"); - missionStore.updateFeatureStatus(feature.id, "in-progress"); - missionStore.updateFeatureStatus(feature.id, "blocked"); - - // Restart store - const taskStore2 = await openRestartedStore(); - const missionStore2 = taskStore2.getMissionStore(); - - const hierarchy = missionStore2.getMissionWithHierarchy(mission.id); - expect(hierarchy!.milestones[0].slices[0].features[0].status).toBe("blocked"); - }); - - it("persists mission events across store restart", async () => { - const missionStore = taskStore.getMissionStore(); - const mission = missionStore.createMission({ title: "Events Test" }); - - // Log multiple events - vi.advanceTimersByTime(1); - missionStore.logMissionEvent(mission.id, "mission_started", "Mission started"); - vi.advanceTimersByTime(1); - missionStore.logMissionEvent(mission.id, "slice_activated", "Slice activated"); - vi.advanceTimersByTime(1); - missionStore.logMissionEvent(mission.id, "feature_triaged", "Feature triaged"); - - // Restart store - const taskStore2 = await openRestartedStore(); - const missionStore2 = taskStore2.getMissionStore(); - - const events = missionStore2.getMissionEvents(mission.id); - expect(events.events.length).toBe(3); - // Events are ordered by timestamp DESC, so most recent first - expect(events.events[0].eventType).toBe("feature_triaged"); // Most recent - expect(events.events[1].eventType).toBe("slice_activated"); - expect(events.events[2].eventType).toBe("mission_started"); // Oldest - }); - - it("persists hierarchy ordering across store restart", async () => { - const { missionStore, mission, milestones } = await createHierarchy(taskStore); - - // Reorder milestones - missionStore.reorderMilestones(mission.id, [ - milestones[2].id, - milestones[0].id, - milestones[1].id, - ]); - - // Restart store - const taskStore2 = await openRestartedStore(); - const missionStore2 = taskStore2.getMissionStore(); - - const hierarchy = missionStore2.getMissionWithHierarchy(mission.id); - expect(hierarchy!.milestones[0].id).toBe(milestones[2].id); - expect(hierarchy!.milestones[1].id).toBe(milestones[0].id); - expect(hierarchy!.milestones[2].id).toBe(milestones[1].id); - }); - - it("persists all mission hierarchy read paths across store restart", async () => { - const missionStore = taskStore.getMissionStore(); - const emptyMission = missionStore.createMission({ title: "Empty Restart Mission" }); - vi.advanceTimersByTime(1); - missionStore.logMissionEvent(emptyMission.id, "mission_created", "Empty mission event"); - - const { mission, milestones } = await createHierarchy(taskStore); - const firstMilestone = milestones[0]; - const firstSlice = firstMilestone.slices[0]; - const firstFeature = firstSlice.features[0]; - - missionStore.updateMission(mission.id, { status: "active" }); - missionStore.updateMilestone(firstMilestone.id, { - planningNotes: "Persist milestone planning", - verification: "Persist milestone verification", - }); - missionStore.updateSlice(firstSlice.id, { - planningNotes: "Persist slice planning", - verification: "Persist slice verification", - }); - missionStore.updateFeature(firstFeature.id, { - status: "in-progress", - lastValidatorStatus: "running", - }); - missionStore.reorderMilestones(mission.id, [ - milestones[2].id, - milestones[0].id, - milestones[1].id, - ]); - vi.advanceTimersByTime(1); - missionStore.logMissionEvent(mission.id, "mission_started", "Populated mission event"); - - const taskStore2 = await openRestartedStore(); - const missionStore2 = taskStore2.getMissionStore(); - - const emptyHierarchy = missionStore2.getMissionWithHierarchy(emptyMission.id); - expect(emptyHierarchy).toBeDefined(); - expect(emptyHierarchy?.milestones).toEqual([]); - expect(missionStore2.getMissionEvents(emptyMission.id).events).toHaveLength(1); - - const retrievedMission = missionStore2.getMission(mission.id); - const retrievedMilestone = missionStore2.getMilestone(firstMilestone.id); - const retrievedSlice = missionStore2.getSlice(firstSlice.id); - const retrievedFeature = missionStore2.getFeature(firstFeature.id); - const hierarchy = missionStore2.getMissionWithHierarchy(mission.id); - const events = missionStore2.getMissionEvents(mission.id); - - expect(retrievedMission).toMatchObject({ id: mission.id, status: "active" }); - expect(retrievedMilestone).toMatchObject({ - id: firstMilestone.id, - planningNotes: "Persist milestone planning", - verification: "Persist milestone verification", - }); - expect(retrievedSlice).toMatchObject({ - id: firstSlice.id, - planningNotes: "Persist slice planning", - verification: "Persist slice verification", - }); - expect(retrievedFeature).toMatchObject({ - id: firstFeature.id, - status: "in-progress", - lastValidatorStatus: "running", - }); - expect(hierarchy).toBeDefined(); - expect(hierarchy?.milestones).toHaveLength(3); - expect(hierarchy?.milestones[0].id).toBe(milestones[2].id); - expect(hierarchy?.milestones.every((milestone) => milestone.slices.length === 2)).toBe(true); - expect( - hierarchy?.milestones.every((milestone) => - milestone.slices.every((slice) => { - const hierarchySlice = slice as typeof slice & { features: Array<{ id: string }> }; - return hierarchySlice.features.length === 3; - }), - ), - ).toBe(true); - expect(events.events).toHaveLength(1); - expect(events.events[0]).toMatchObject({ - eventType: "mission_started", - description: "Populated mission event", - }); - }); - - it("persists planning notes and verification across store restart", async () => { - const missionStore = taskStore.getMissionStore(); - const mission = missionStore.createMission({ title: "Planning Context Test" }); - const milestone = missionStore.addMilestone(mission.id, { - title: "M1", - planningNotes: "Use JWT authentication", - verification: "Users can log in", - }); - const slice = missionStore.addSlice(milestone.id, { - title: "S1", - planningNotes: "Build login form component", - verification: "Form validates input", - }); - - // Restart store - const taskStore2 = await openRestartedStore(); - const missionStore2 = taskStore2.getMissionStore(); - - const retrievedMilestone = missionStore2.getMilestone(milestone.id); - expect(retrievedMilestone!.planningNotes).toBe("Use JWT authentication"); - expect(retrievedMilestone!.verification).toBe("Users can log in"); - - const retrievedSlice = missionStore2.getSlice(slice.id); - expect(retrievedSlice!.planningNotes).toBe("Build login form component"); - expect(retrievedSlice!.verification).toBe("Form validates input"); - }); - }); -}); diff --git a/packages/core/src/__tests__/multi-node-dashboard.test.ts b/packages/core/src/__tests__/multi-node-dashboard.test.ts deleted file mode 100644 index 648640e765..0000000000 --- a/packages/core/src/__tests__/multi-node-dashboard.test.ts +++ /dev/null @@ -1,559 +0,0 @@ -/** - * Integration tests for multi-node dashboard functionality. - * - * Tests the full flow of node registration, status management, - * and dashboard display in the multi-node context. - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { CentralCore } from "../central-core.js"; -import { seedSampleNodes } from "./seed-sample-nodes.js"; -import type { NodeConfig, NodeStatus } from "../types.js"; - -describe("Multi-Node Dashboard", () => { - let tempDir: string; - let central: CentralCore; - - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-01T12:00:00.000Z")); - tempDir = mkdtempSync(join(tmpdir(), "kb-multi-node-test-")); - central = new CentralCore(tempDir); - }); - - afterEach(async () => { - await central.close(); - vi.useRealTimers(); - vi.restoreAllMocks(); - rmSync(tempDir, { recursive: true, force: true }); - }); - - describe("node registration with mixed types", () => { - it("should register remote nodes and list all sorted by name", async () => { - await central.init(); - - // Note: init() creates a default "local" node automatically - // Register remote nodes only (to avoid duplicate local nodes) - await central.registerNode({ - name: "Alpha Remote", - type: "remote", - url: "https://alpha.example.com", - maxConcurrent: 2, - }); - - await central.registerNode({ - name: "Beta Remote", - type: "remote", - url: "https://beta.example.com", - maxConcurrent: 4, - }); - - await central.registerNode({ - name: "Gamma Remote", - type: "remote", - url: "https://gamma.example.com", - maxConcurrent: 8, - }); - - const nodes = await central.listNodes(); - - // 1 default local + 3 remote = 4 nodes - expect(nodes).toHaveLength(4); - - // Should be sorted alphabetically by name - const names = nodes.map((n) => n.name); - expect(names).toEqual([ - "Alpha Remote", - "Beta Remote", - "Gamma Remote", - "local", // auto-created local node - ]); - - // Verify types - const localNodes = nodes.filter((n) => n.type === "local"); - const remoteNodes = nodes.filter((n) => n.type === "remote"); - expect(localNodes).toHaveLength(1); - expect(remoteNodes).toHaveLength(3); - }); - - it("should create 1 local + 5 remote nodes via seed function", async () => { - await central.init(); - const nodes = await seedSampleNodes(central); - - expect(nodes).toHaveLength(6); - - // Verify 1 local node - const localNodes = nodes.filter((n) => n.type === "local"); - expect(localNodes).toHaveLength(1); - expect(localNodes[0].name).toBe("local"); - expect(localNodes[0].status).toBe("online"); - - // Verify 5 remote nodes - const remoteNodes = nodes.filter((n) => n.type === "remote"); - expect(remoteNodes).toHaveLength(5); - - // Verify expected remote nodes exist - const remoteNames = remoteNodes.map((n) => n.name).sort(); - expect(remoteNames).toEqual([ - "Build Machine", - "Dev Box (John)", - "GPU Cluster", - "QA Environment", - "Staging Server", - ]); - }); - }); - - describe("node status transitions", () => { - it("should transition node from offline to online", async () => { - await central.init(); - - const node = await central.registerNode({ - name: "Status Test Node", - type: "local", - maxConcurrent: 2, - }); - - expect(node.status).toBe("offline"); - - // Transition to online - const onlineNode = await central.updateNode(node.id, { status: "online" }); - expect(onlineNode.status).toBe("online"); - - // Verify persisted - const fetched = await central.getNode(node.id); - expect(fetched?.status).toBe("online"); - }); - - it("should transition node through multiple statuses", async () => { - await central.init(); - - const node = await central.registerNode({ - name: "Multi Status Node", - type: "remote", - url: "https://multi-status.example.com", - maxConcurrent: 2, - }); - - // offline -> connecting -> online - let updated = await central.updateNode(node.id, { status: "connecting" }); - expect(updated.status).toBe("connecting"); - - updated = await central.updateNode(node.id, { status: "online" }); - expect(updated.status).toBe("online"); - - // online -> error - updated = await central.updateNode(node.id, { status: "error" }); - expect(updated.status).toBe("error"); - - // error -> offline - updated = await central.updateNode(node.id, { status: "offline" }); - expect(updated.status).toBe("offline"); - }); - - it("should handle seed nodes with varied statuses", async () => { - await central.init(); - const nodes = await seedSampleNodes(central); - - // Verify all expected statuses - const statusMap = new Map(); - for (const node of nodes) { - statusMap.set(node.name, node.status); - } - - expect(statusMap.get("local")).toBe("online"); - expect(statusMap.get("Staging Server")).toBe("online"); - expect(statusMap.get("Build Machine")).toBe("online"); - expect(statusMap.get("GPU Cluster")).toBe("offline"); - expect(statusMap.get("Dev Box (John)")).toBe("error"); - expect(statusMap.get("QA Environment")).toBe("connecting"); - }); - }); - - describe("list nodes returns correct type distribution", () => { - it("should return correct local vs remote count", async () => { - await central.init(); - - // Add via seed which has 1 local + 5 remote - await seedSampleNodes(central); - - const nodes = await central.listNodes(); - const localNodes = nodes.filter((n) => n.type === "local"); - const remoteNodes = nodes.filter((n) => n.type === "remote"); - - expect(localNodes).toHaveLength(1); - expect(remoteNodes).toHaveLength(5); - expect(nodes).toHaveLength(6); - }); - - it("should return correct status distribution", async () => { - await central.init(); - await seedSampleNodes(central); - - const nodes = await central.listNodes(); - - const online = nodes.filter((n) => n.status === "online").length; - const offline = nodes.filter((n) => n.status === "offline").length; - const error = nodes.filter((n) => n.status === "error").length; - const connecting = nodes.filter((n) => n.status === "connecting").length; - - expect(online).toBe(3); // local + 2 remote - expect(offline).toBe(1); // GPU Cluster - expect(error).toBe(1); // Dev Box (John) - expect(connecting).toBe(1); // QA Environment - }); - }); - - describe("concurrent max tracking", () => { - it("should preserve maxConcurrent on registration", async () => { - await central.init(); - - const node = await central.registerNode({ - name: "Max Concurrent Test", - type: "local", - maxConcurrent: 8, - }); - - expect(node.maxConcurrent).toBe(8); - - const fetched = await central.getNode(node.id); - expect(fetched?.maxConcurrent).toBe(8); - }); - - it("should preserve maxConcurrent on update", async () => { - await central.init(); - - const node = await central.registerNode({ - name: "Max Concurrent Update", - type: "local", - maxConcurrent: 2, - }); - - const updated = await central.updateNode(node.id, { maxConcurrent: 16 }); - expect(updated.maxConcurrent).toBe(16); - - const fetched = await central.getNode(node.id); - expect(fetched?.maxConcurrent).toBe(16); - }); - - it("should preserve maxConcurrent from seed nodes", async () => { - await central.init(); - await seedSampleNodes(central); - - const nodes = await central.listNodes(); - const maxConcurrentMap = new Map(); - for (const node of nodes) { - maxConcurrentMap.set(node.name, node.maxConcurrent); - } - - expect(maxConcurrentMap.get("local")).toBe(4); - expect(maxConcurrentMap.get("Staging Server")).toBe(4); - expect(maxConcurrentMap.get("Build Machine")).toBe(8); - expect(maxConcurrentMap.get("GPU Cluster")).toBe(16); - expect(maxConcurrentMap.get("Dev Box (John)")).toBe(2); - expect(maxConcurrentMap.get("QA Environment")).toBe(4); - }); - - it("should reject invalid maxConcurrent values", async () => { - await central.init(); - - // Test zero - await expect( - central.registerNode({ - name: "Zero Max", - type: "local", - maxConcurrent: 0, - }), - ).rejects.toThrow("maxConcurrent must be >= 1"); - - // Test negative - await expect( - central.registerNode({ - name: "Negative Max", - type: "local", - maxConcurrent: -1, - }), - ).rejects.toThrow("maxConcurrent must be >= 1"); - - // Test Infinity - await expect( - central.registerNode({ - name: "Infinity Max", - type: "local", - maxConcurrent: Infinity, - }), - ).rejects.toThrow("maxConcurrent must be >= 1"); - - // Test NaN - await expect( - central.registerNode({ - name: "NaN Max", - type: "local", - maxConcurrent: NaN, - }), - ).rejects.toThrow("maxConcurrent must be >= 1"); - }); - }); - - describe("unregister removes node from listing", () => { - it("should remove node from list after unregister", async () => { - await central.init(); - await seedSampleNodes(central); - - const nodesBefore = await central.listNodes(); - expect(nodesBefore).toHaveLength(6); - - // Unregister one remote node - const stagingServer = nodesBefore.find((n) => n.name === "Staging Server"); - expect(stagingServer).toBeDefined(); - - await central.unregisterNode(stagingServer!.id); - - const nodesAfter = await central.listNodes(); - expect(nodesAfter).toHaveLength(5); - - // Verify removed - const names = nodesAfter.map((n) => n.name); - expect(names).not.toContain("Staging Server"); - }); - - it("should remove multiple nodes and leave correct count", async () => { - await central.init(); - await seedSampleNodes(central); - - // Unregister 2 nodes (GPU Cluster and QA Environment) - const nodes = await central.listNodes(); - const gpuCluster = nodes.find((n) => n.name === "GPU Cluster"); - const qaEnv = nodes.find((n) => n.name === "QA Environment"); - - await central.unregisterNode(gpuCluster!.id); - await central.unregisterNode(qaEnv!.id); - - const remaining = await central.listNodes(); - expect(remaining).toHaveLength(4); - - // Verify remaining nodes - const remainingNames = remaining.map((n) => n.name).sort(); - expect(remainingNames).toEqual([ - "Build Machine", - "Dev Box (John)", - "Staging Server", - "local", - ]); - }); - - it("should be idempotent when unregistering non-existent node", async () => { - await central.init(); - await seedSampleNodes(central); - - const nodesBefore = await central.listNodes(); - - // Try to unregister non-existent node - await expect(central.unregisterNode("non_existent_id")).resolves.toBeUndefined(); - - const nodesAfter = await central.listNodes(); - expect(nodesAfter).toHaveLength(nodesBefore.length); - }); - }); - - describe("node name uniqueness enforcement", () => { - it("should reject duplicate node names", async () => { - await central.init(); - - await central.registerNode({ - name: "Unique Name", - type: "local", - maxConcurrent: 2, - }); - - await expect( - central.registerNode({ - name: "Unique Name", - type: "local", - maxConcurrent: 2, - }), - ).rejects.toThrow("already exists"); - }); - - it("should allow same name after unregister", async () => { - await central.init(); - - const node = await central.registerNode({ - name: "Reusable Name", - type: "local", - maxConcurrent: 2, - }); - - await central.unregisterNode(node.id); - - // Should be able to register again with same name - const newNode = await central.registerNode({ - name: "Reusable Name", - type: "local", - maxConcurrent: 4, - }); - - expect(newNode.name).toBe("Reusable Name"); - }); - - it("should reject duplicate names during seed idempotency", async () => { - await central.init(); - await seedSampleNodes(central); - - // Running seed again should update existing nodes, not fail - const nodes = await seedSampleNodes(central); - expect(nodes).toHaveLength(6); - }); - }); - - describe("remote nodes require URL", () => { - it("should reject remote node without URL", async () => { - await central.init(); - - await expect( - central.registerNode({ - name: "Remote Without URL", - type: "remote", - maxConcurrent: 2, - }), - ).rejects.toThrow("must include a url"); - }); - - it("should reject remote node with empty URL", async () => { - await central.init(); - - await expect( - central.registerNode({ - name: "Remote With Empty URL", - type: "remote", - url: "", - maxConcurrent: 2, - }), - ).rejects.toThrow("must include a url"); - }); - - it("should accept remote node with valid URL", async () => { - await central.init(); - - const node = await central.registerNode({ - name: "Remote With URL", - type: "remote", - url: "https://valid.example.com", - maxConcurrent: 2, - }); - - expect(node.url).toBe("https://valid.example.com"); - expect(node.type).toBe("remote"); - }); - - it("should allow remote node URL update", async () => { - await central.init(); - - const node = await central.registerNode({ - name: "URL Update Test", - type: "remote", - url: "https://old.example.com", - maxConcurrent: 2, - }); - - const updated = await central.updateNode(node.id, { - url: "https://new.example.com", - }); - - expect(updated.url).toBe("https://new.example.com"); - }); - }); - - describe("local nodes must not have URL/apiKey", () => { - it("should reject local node with URL", async () => { - await central.init(); - - await expect( - central.registerNode({ - name: "Local With URL", - type: "local", - url: "https://should-fail.example.com", - maxConcurrent: 2, - }), - ).rejects.toThrow("must not include url or apiKey"); - }); - - it("should reject local node with apiKey", async () => { - await central.init(); - - await expect( - central.registerNode({ - name: "Local With API Key", - type: "local", - apiKey: "secret-key", - maxConcurrent: 2, - }), - ).rejects.toThrow("must not include url or apiKey"); - }); - - it("should reject local node with both URL and apiKey", async () => { - await central.init(); - - await expect( - central.registerNode({ - name: "Local With Both", - type: "local", - url: "https://fail.example.com", - apiKey: "secret-key", - maxConcurrent: 2, - }), - ).rejects.toThrow("must not include url or apiKey"); - }); - - it("should accept local node without URL or apiKey", async () => { - await central.init(); - - const node = await central.registerNode({ - name: "Valid Local", - type: "local", - maxConcurrent: 2, - }); - - expect(node.type).toBe("local"); - expect(node.url).toBeUndefined(); - expect(node.apiKey).toBeUndefined(); - }); - }); - - describe("seed function idempotency", () => { - it("should handle multiple seed calls without creating duplicates", async () => { - await central.init(); - - // First seed - const firstSeed = await seedSampleNodes(central); - expect(firstSeed).toHaveLength(6); - - // Second seed - should update existing, not create duplicates - const secondSeed = await seedSampleNodes(central); - expect(secondSeed).toHaveLength(6); - - // Verify only 6 nodes exist - const allNodes = await central.listNodes(); - expect(allNodes).toHaveLength(6); - }); - - it("should update existing node statuses on re-seed", async () => { - await central.init(); - await seedSampleNodes(central); - - // Manually change a status - const gpuCluster = await central.getNodeByName("GPU Cluster"); - expect(gpuCluster).toBeDefined(); - await central.updateNode(gpuCluster!.id, { status: "online" }); - - // Re-seed should restore original status - await seedSampleNodes(central); - - const updated = await central.getNodeByName("GPU Cluster"); - expect(updated?.status).toBe("offline"); // Original status from seed - }); - }); -}); diff --git a/packages/core/src/__tests__/postgres/activity-log-parity.pg.test.ts b/packages/core/src/__tests__/postgres/activity-log-parity.pg.test.ts new file mode 100644 index 0000000000..1345a9a565 --- /dev/null +++ b/packages/core/src/__tests__/postgres/activity-log-parity.pg.test.ts @@ -0,0 +1,103 @@ +/** + * FNXC:PostgresMigrationCoverage 2026-07-13-22:54: + * The PostgreSQL cutover must preserve the activity log's best-effort write contract, structured metadata, newest-first filtering, bounded reads, and explicit clearing. These are live operator-facing audit invariants formerly asserted only by the removed SQLite TaskStore suite. + */ + +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; + +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; +import * as schema from "../../postgres/schema/index.js"; + +pgDescribe("activity log parity (PostgreSQL)", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_activity_parity", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + it("keeps failed writes best-effort so audit storage cannot break product operations", async () => { + const layer = h.layer(); + const insert = vi.spyOn(layer.db, "insert").mockImplementation(() => { + throw new Error("activity insert failed"); + }); + + try { + await expect( + h.store().recordActivity({ + type: "task:created", + taskId: "FN-404", + taskTitle: "Resilient operation", + details: "Create event", + metadata: { source: "test" }, + }), + ).resolves.toMatchObject({ + type: "task:created", + taskId: "FN-404", + metadata: { source: "test" }, + }); + expect(await h.store().getActivityLog()).toEqual([]); + } finally { + insert.mockRestore(); + } + }); + + it("filters by timestamp and type, orders newest first, applies a limit, and clears", async () => { + const store = h.store(); + const first = await store.recordActivity({ + type: "task:created", + taskId: "FN-001", + taskTitle: "First task", + details: "Created", + }); + const moved = await store.recordActivity({ + type: "task:moved", + taskId: "FN-001", + taskTitle: "First task", + details: "Moved", + metadata: { from: "todo", to: "in-progress" }, + }); + const latest = await store.recordActivity({ + type: "task:created", + taskId: "FN-002", + taskTitle: "Second task", + details: "Created later", + }); + await h.adminDb().insert(schema.project.activityLog).values({ + projectId: "other-project", + id: "other-project-event", + timestamp: "2026-07-13T20:03:00.000Z", + type: "task:created", + taskId: "FN-OTHER", + details: "Must remain isolated", + }); + + await h.adminDb().update(schema.project.activityLog).set({ timestamp: "2026-07-13T20:00:00.000Z" }).where(eq(schema.project.activityLog.id, first.id)); + await h.adminDb().update(schema.project.activityLog).set({ timestamp: "2026-07-13T20:01:00.000Z" }).where(eq(schema.project.activityLog.id, moved.id)); + await h.adminDb().update(schema.project.activityLog).set({ timestamp: "2026-07-13T20:02:00.000Z" }).where(eq(schema.project.activityLog.id, latest.id)); + + expect((await store.getActivityLog({ limit: 2 })).map((event) => event.taskId)).toEqual([ + "FN-002", + "FN-001", + ]); + const movedEvents = await store.getActivityLog({ type: "task:moved" }); + expect(movedEvents).toHaveLength(1); + expect(movedEvents[0]?.metadata).toEqual({ from: "todo", to: "in-progress" }); + expect((await store.getActivityLog({ since: "2026-07-13T20:01:30.000Z" })).map((event) => event.taskId)).toEqual(["FN-002"]); + + await store.clearActivityLog(); + expect(await store.getActivityLog()).toEqual([]); + const otherProject = await h.adminDb() + .select({ id: schema.project.activityLog.id }) + .from(schema.project.activityLog) + .where(eq(schema.project.activityLog.projectId, "other-project")); + expect(otherProject).toEqual([{ id: "other-project-event" }]); + }); +}); diff --git a/packages/core/src/__tests__/postgres/agent-logs-and-monitor.pg.test.ts b/packages/core/src/__tests__/postgres/agent-logs-and-monitor.pg.test.ts index c63996359a..8cf2679458 100644 --- a/packages/core/src/__tests__/postgres/agent-logs-and-monitor.pg.test.ts +++ b/packages/core/src/__tests__/postgres/agent-logs-and-monitor.pg.test.ts @@ -23,7 +23,9 @@ import { createSharedPgTaskStoreTestHarness, type SharedPgTaskStoreHarness, } from "../../__test-utils__/pg-test-harness.js"; -import { aggregateActivityAnalytics } from "../../activity-analytics.js"; +import { aggregateActivityAnalytics, aggregateMonitorMetrics } from "../../activity-analytics.js"; +import { sql } from "drizzle-orm"; +import * as schema from "../../postgres/schema/index.js"; const pgTest = pgDescribe; @@ -87,4 +89,187 @@ pgTest("agent-log buffer + monitor metrics (PostgreSQL backend mode)", () => { expect(result.monitor.incidentsOpened).toBe(0); expect(result.monitor.mttr.unavailable).toBe(true); }); + + /** + * FNXC:MonitorAnalyticsIsolation 2026-07-14-01:04: + * An unbound Command Center monitor read intentionally aggregates every project, while a project-bound layer must isolate deployments, incident counts, and MTTR to that tenant. + */ + it("monitor metrics aggregate all projects when unbound and isolate a bound project", async () => { + const adminDb = h.adminDb(); + await adminDb.execute(sql` + INSERT INTO project.deployments + (project_id, deployment_id, deployed_at, created_at) + VALUES + ('monitor-project-a', 'deployment-a', '2026-07-13T12:00:00.000Z', '2026-07-13T12:00:00.000Z'), + ('monitor-project-b', 'deployment-b', '2026-07-13T12:00:00.000Z', '2026-07-13T12:00:00.000Z') + `); + await adminDb.execute(sql` + INSERT INTO project.incidents + (project_id, incident_id, grouping_key, title, status, opened_at, resolved_at, created_at, updated_at) + VALUES + ('monitor-project-a', 'incident-a', 'group-a', 'A', 'resolved', '2026-07-13T10:00:00.000Z', '2026-07-13T11:00:00.000Z', '2026-07-13T10:00:00.000Z', '2026-07-13T11:00:00.000Z'), + ('monitor-project-b', 'incident-b', 'group-b', 'B', 'open', '2026-07-13T12:00:00.000Z', NULL, '2026-07-13T12:00:00.000Z', '2026-07-13T12:00:00.000Z') + `); + + const range = { from: "2026-07-13T00:00:00.000Z", to: "2026-07-13T23:59:59.999Z" }; + const layer = h.layer(); + const unbound = await aggregateMonitorMetrics(layer, range); + expect(unbound).toMatchObject({ deployments: 2, incidentsOpened: 2, incidentsResolved: 1, openIncidents: 1 }); + expect(unbound.mttr).toEqual({ value: 60, unavailable: false, sampleCount: 1 }); + + const bound = await aggregateMonitorMetrics({ ...layer, projectId: "monitor-project-a" }, range); + expect(bound).toMatchObject({ deployments: 1, incidentsOpened: 1, incidentsResolved: 1, openIncidents: 0 }); + expect(bound.mttr).toEqual({ value: 60, unavailable: false, sampleCount: 1 }); + }); + + /* + FNXC:ActivityAnalyticsPostgres 2026-07-13-22:38: + PostgreSQL activity analytics must report persisted sessions, messages, nodes, agents, and heartbeat runs instead of valid-looking zeros. Seed every contributing surface so the dashboard contract is verified across summary and daily aggregation. + + FNXC:ActivityAnalyticsPostgres 2026-07-14-00:37: + Unbound command-center analytics intentionally aggregate all project partitions, while an explicitly bound layer remains isolated. Cover sessions, usage, runs, daily activity, and SDLC funnel transitions together so those views cannot disagree about the project scope. + */ + it("aggregateActivityAnalytics aggregates all projects when unbound and isolates a bound project", async () => { + const layer = h.layer(); + await layer.db.insert(schema.project.agents).values({ + id: "agent-analytics", + name: "Analytics Agent", + role: "worker", + createdAt: "2026-07-13T10:00:00.000Z", + updatedAt: "2026-07-13T10:00:00.000Z", + }); + await layer.db.insert(schema.project.agentRuns).values({ + projectId: layer.projectId ?? "", + id: "run-analytics", + agentId: "agent-analytics", + data: {}, + startedAt: "2026-07-13T12:00:00.000Z", + status: "completed", + }); + await layer.db.insert(schema.project.usageEvents).values([ + { projectId: layer.projectId ?? "", ts: "2026-07-13T11:00:00.000Z", kind: "user_message", agentId: "agent-analytics", nodeId: "node-1" }, + { projectId: layer.projectId ?? "", ts: "2026-07-13T11:05:00.000Z", kind: "tool_call", agentId: "agent-analytics", nodeId: "node-2" }, + ]); + await layer.db.insert(schema.project.agents).values({ + id: "agent-other-project", + name: "Other Project Agent", + role: "worker", + createdAt: "2026-07-13T10:00:00.000Z", + updatedAt: "2026-07-13T10:00:00.000Z", + }); + await layer.db.insert(schema.project.agentRuns).values({ + projectId: "other-project", + id: "run-other-project", + agentId: "agent-other-project", + data: {}, + startedAt: "2026-07-13T12:00:00.000Z", + status: "failed", + }); + await layer.db.insert(schema.project.usageEvents).values({ + projectId: "other-project", + ts: "2026-07-13T11:00:00.000Z", + kind: "user_message", + agentId: "agent-other-project", + nodeId: "other-node", + }); + await layer.db.insert(schema.project.cliSessions).values({ + id: "cli-analytics", + purpose: "chat", + projectId: layer.projectId ?? "", + adapterId: "test", + createdAt: "2026-07-13T10:30:00.000Z", + updatedAt: "2026-07-13T10:30:00.000Z", + }); + await layer.db.insert(schema.project.cliSessions).values({ + id: "cli-other-project", + purpose: "chat", + projectId: "other-project", + adapterId: "test", + createdAt: "2026-07-13T10:45:00.000Z", + updatedAt: "2026-07-13T10:45:00.000Z", + }); + await layer.db.insert(schema.project.activityLog).values([ + { + projectId: "", + id: "activity-analytics-local", + timestamp: "2026-07-13T13:00:00.000Z", + type: "task:moved", + taskId: "FN-ANALYTICS-LOCAL", + details: "moved", + metadata: { to: "todo" }, + }, + { + projectId: "other-project", + id: "activity-analytics-other", + timestamp: "2026-07-13T13:05:00.000Z", + type: "task:moved", + taskId: "FN-ANALYTICS-OTHER", + details: "moved", + metadata: { to: "todo" }, + }, + ]); + + const range = { + from: "2026-07-13T00:00:00.000Z", + to: "2026-07-13T23:59:59.999Z", + }; + const result = await aggregateActivityAnalytics(layer, range); + + expect(result).toMatchObject({ sessions: 2, messages: 2, activeNodes: 3, activeAgents: 2 }); + expect(result.agentRuns).toMatchObject({ total: 2, completed: 1, failed: 1 }); + expect(result.daily).toEqual([ + expect.objectContaining({ day: "2026-07-13", messages: 2, activeNodes: 3, activeAgents: 2, agentRuns: 2 }), + ]); + expect(result.funnel.stages.find(({ stage }) => stage === "todo")?.entered).toBe(2); + + const boundResult = await aggregateActivityAnalytics({ ...layer, projectId: "" }, range); + expect(boundResult).toMatchObject({ sessions: 1, messages: 1, activeNodes: 2, activeAgents: 1 }); + expect(boundResult.agentRuns).toMatchObject({ total: 1, completed: 1, failed: 0 }); + expect(boundResult.daily).toEqual([ + expect.objectContaining({ day: "2026-07-13", messages: 1, activeNodes: 2, activeAgents: 1, agentRuns: 1 }), + ]); + expect(boundResult.funnel.stages.find(({ stage }) => stage === "todo")?.entered).toBe(1); + }); + + /** + * FNXC:ActivityAnalyticsPostgres 2026-07-14-01:41: + * Legacy or schema-drift agent runs may lack an agent ID. Such rows still count as runs, but they must not create a phantom daily active agent or push stickiness above the range-active-agent population. + */ + it("excludes null run agent IDs from daily active agents and stickiness", async () => { + const layer = h.layer(); + await layer.db.insert(schema.project.agents).values({ + id: "agent-real", + name: "Real Agent", + role: "worker", + createdAt: "2026-07-13T10:00:00.000Z", + updatedAt: "2026-07-13T10:00:00.000Z", + }); + await layer.db.insert(schema.project.usageEvents).values({ + projectId: "", + ts: "2026-07-13T11:00:00.000Z", + kind: "user_message", + agentId: "agent-real", + }); + await layer.db.execute(sql`ALTER TABLE project.agent_runs ALTER COLUMN agent_id DROP NOT NULL`); + try { + await layer.db.execute(sql` + INSERT INTO project.agent_runs (project_id, id, agent_id, data, started_at, status) + VALUES ('', 'run-without-agent', NULL, '{}'::jsonb, '2026-07-13T12:00:00.000Z', 'completed') + `); + const result = await aggregateActivityAnalytics(layer, { + from: "2026-07-13T00:00:00.000Z", + to: "2026-07-13T23:59:59.999Z", + }); + + expect(result.agentRuns.total).toBe(1); + expect(result.activeAgents).toBe(1); + expect(result.daily).toEqual([ + expect.objectContaining({ day: "2026-07-13", activeAgents: 1, agentRuns: 1 }), + ]); + expect(result.stickiness).toBe(1); + } finally { + await layer.db.execute(sql`DELETE FROM project.agent_runs WHERE agent_id IS NULL`); + await layer.db.execute(sql`ALTER TABLE project.agent_runs ALTER COLUMN agent_id SET NOT NULL`); + } + }); }); diff --git a/packages/core/src/__tests__/postgres/artifacts-documents-evals.pg.test.ts b/packages/core/src/__tests__/postgres/artifacts-documents-evals.pg.test.ts index 218eb11f9a..6315ac4cb9 100644 --- a/packages/core/src/__tests__/postgres/artifacts-documents-evals.pg.test.ts +++ b/packages/core/src/__tests__/postgres/artifacts-documents-evals.pg.test.ts @@ -12,7 +12,7 @@ * joined parent-task fields. Runs in the blocking gate (test:pg-gate). */ -import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; import { pgDescribe, @@ -20,9 +20,64 @@ import { type SharedPgTaskStoreHarness, } from "../../__test-utils__/pg-test-harness.js"; import { AsyncEvalStore } from "../../async-eval-store.js"; +import { runScheduledEvalBatch } from "../../eval-automation.js"; +import type { AsyncDataLayer } from "../../postgres/data-layer.js"; +import type { EvalRunStatus } from "../../eval-types.js"; const pgTest = pgDescribe; +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +/** + * FNXC:ScheduledEvalsPostgres 2026-07-14-01:41: + * Eval lifecycle race tests coordinate at the transaction's advisory-lock query. The first updater pauses after acquiring the real PostgreSQL lock, while the second signals before its lock attempt blocks, proving terminal transition serialization without sleeps or polling. + */ +function controlledEvalUpdateLayer( + layer: AsyncDataLayer, + mode: "hold-after-first-query" | "signal-first-query", +): { layer: AsyncDataLayer; reached: Promise; release: () => void } { + const reached = deferred(); + const release = deferred(); + let firstTransaction = true; + const controlled = { + ...layer, + transactionImmediate: async ( + fn: Parameters[0], + options?: Parameters[1], + ): Promise => layer.transactionImmediate(async (tx) => { + if (!firstTransaction) return fn(tx) as Promise; + firstTransaction = false; + let firstQuery = true; + const proxy = new Proxy(tx, { + get(target, property, receiver) { + if (property !== "execute") + return Reflect.get(target, property, receiver); + return async (...args: Parameters) => { + if (!firstQuery) return tx.execute(...args); + firstQuery = false; + if (mode === "signal-first-query") { + reached.resolve(); + return tx.execute(...args); + } + const result = await tx.execute(...args); + reached.resolve(); + await release.promise; + return result; + }; + }, + }); + return fn(proxy) as Promise; + }, options), + } as AsyncDataLayer; + return { layer: controlled, reached: reached.promise, release: release.resolve }; +} + pgTest("Artifacts / Documents / Evals (PostgreSQL backend mode)", () => { const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_artifacts_documents_evals", @@ -183,4 +238,96 @@ pgTest("Artifacts / Documents / Evals (PostgreSQL backend mode)", () => { expect(got?.overallScore).toBe(87); expect(got?.taskSnapshot.title).toBe("Snapshot title"); }); + + it.each<[EvalRunStatus, EvalRunStatus]>([ + ["completed", "failed"], + ["failed", "cancelled"], + ["cancelled", "completed"], + ])( + "serializes concurrent terminal transitions after %s wins", + async (winningStatus, losingStatus) => { + const setup = new AsyncEvalStore(h.layer()); + const run = await setup.createRun({ + projectId: "P-EVAL-CONCURRENCY", + scope: "all", + trigger: "manual", + }); + const firstControl = controlledEvalUpdateLayer( + h.layer(), + "hold-after-first-query", + ); + const secondControl = controlledEvalUpdateLayer( + h.layer(), + "signal-first-query", + ); + const first = new AsyncEvalStore(firstControl.layer); + const second = new AsyncEvalStore(secondControl.layer); + + const winningUpdate = first.updateRun(run.id, { status: winningStatus }); + await firstControl.reached; + const losingUpdate = second.updateRun(run.id, { status: losingStatus }); + await secondControl.reached; + firstControl.release(); + + await expect(winningUpdate).resolves.toMatchObject({ + status: winningStatus, + }); + await expect(losingUpdate).rejects.toMatchObject({ + name: "EvalLifecycleError", + code: "invalid_transition", + }); + expect(await setup.getRun(run.id)).toMatchObject({ status: winningStatus }); + }, + ); + + /* + FNXC:ScheduledEvalsPostgres 2026-07-13-22:38: + Scheduled evaluation must use the same lifecycle on PostgreSQL as manual dashboard runs. This integration proof drives the real TaskStore and AsyncEvalStore through selection, scoring, event persistence, and terminal completion. + */ + it("runs a scheduled evaluation batch through AsyncEvalStore", async () => { + const store = h.store(); + const completedAt = "2026-07-13T20:00:00.000Z"; + const task = await store.createTask({ description: "Scheduled evaluation candidate" }); + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + await store.moveTask(task.id, "done"); + await store.updateTask(task.id, { executionCompletedAt: completedAt }); + + const result = await runScheduledEvalBatch({ + store, + projectId: "P-EVAL-SCHEDULED", + startedAt: "2026-07-13T21:00:00.000Z", + evaluator: async () => ({ status: "scored", overallScore: 91, maxScore: 100 }), + }); + + expect(result).toMatchObject({ status: "completed", selectedTaskIds: [task.id], tasksSelected: 1 }); + const evalStore = store.getEvalStore() as AsyncEvalStore; + expect((await evalStore.getRun(result.runId))?.status).toBe("completed"); + expect(await evalStore.listTaskResults({ runId: result.runId })).toHaveLength(1); + expect((await evalStore.listRunEvents(result.runId)).map((event) => event.status)).toContain("completed"); + }); + + it("persists a failed lifecycle when scheduled task selection fails", async () => { + const store = h.store(); + const result = await runScheduledEvalBatch({ + store: { + getEvalStore: () => store.getEvalStore(), + listTasks: async () => { throw new Error("selection unavailable"); }, + }, + projectId: "P-EVAL-FAILURE", + startedAt: "2026-07-13T22:00:00.000Z", + evaluator: async () => ({ status: "scored", overallScore: 100, maxScore: 100 }), + }); + + expect(result.status).toBe("failed"); + const evalStore = store.getEvalStore() as AsyncEvalStore; + expect(await evalStore.getRun(result.runId)).toMatchObject({ + status: "failed", + error: "selection unavailable", + }); + expect(await evalStore.listRunEvents(result.runId)).toEqual( + expect.arrayContaining([expect.objectContaining({ status: "failed", type: "error" })]), + ); + }); }); diff --git a/packages/core/src/__tests__/postgres/central-archive-secrets.test.ts b/packages/core/src/__tests__/postgres/central-archive-secrets.test.ts index 8d20504b86..5974d9094f 100644 --- a/packages/core/src/__tests__/postgres/central-archive-secrets.test.ts +++ b/packages/core/src/__tests__/postgres/central-archive-secrets.test.ts @@ -27,7 +27,7 @@ * gate stays green without a running server. */ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { execSync } from "node:child_process"; import { randomBytes } from "node:crypto"; import { createAsyncDataLayer, type AsyncDataLayer } from "../../postgres/data-layer.js"; @@ -404,4 +404,54 @@ pgDescribe("PostgreSQL central-db / archive-db / secrets-store (U6 satellite-cen const store = new AsyncSecretsStore(ctx.layer, fixedMasterKeyProvider()); await expect(store.deleteSecret("nope", "project")).rejects.toMatchObject({ code: "not-found" }); }); + + /** + * FNXC:PostgresMigrationCoverage 2026-07-13-22:54: + * Secret audit notifications must cover create, update, read, and delete without ever including plaintext, and a failing observer must remain non-blocking so audit infrastructure cannot break credential operations. + */ + it("SecretsStore: audit events omit plaintext and emitter failures stay non-blocking", async () => { + ctx = await setupCtx(); + const { AsyncSecretsStore } = await import("../../async-secrets-store.js"); + const events: Array> = []; + const store = new AsyncSecretsStore(ctx.layer, fixedMasterKeyProvider(), { + auditEmitter: (event) => events.push(event), + }); + + const created = await store.createSecret({ + scope: "project", + key: "AUDITED_TOKEN", + plaintextValue: "must-never-enter-audit", + }); + await store.updateSecret(created.id, "project", { plaintextValue: "rotated-secret" }); + await store.revealSecret(created.id, "project", { agentId: "agent-1" }); + await store.deleteSecret(created.id, "project"); + + expect(events.map((event) => event.mutationType)).toEqual([ + "secret:create", + "secret:update", + "secret:read", + "secret:delete", + ]); + expect(events.every((event) => event.secretId === created.id && event.key === "AUDITED_TOKEN")).toBe(true); + expect(JSON.stringify(events)).not.toContain("must-never-enter-audit"); + expect(JSON.stringify(events)).not.toContain("rotated-secret"); + + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const resilientStore = new AsyncSecretsStore(ctx.layer, fixedMasterKeyProvider(), { + auditEmitter: () => { + throw new Error("audit transport unavailable"); + }, + }); + try { + await expect( + resilientStore.createSecret({ scope: "project", key: "RESILIENT", plaintextValue: "still-created" }), + ).resolves.toMatchObject({ key: "RESILIENT" }); + expect(warning).toHaveBeenCalledWith( + "[async-secrets-store] audit emitter failed", + expect.objectContaining({ message: "audit transport unavailable" }), + ); + } finally { + warning.mockRestore(); + } + }); }); diff --git a/packages/core/src/__tests__/postgres/command-center-analytics.pg.test.ts b/packages/core/src/__tests__/postgres/command-center-analytics.pg.test.ts index 51866cca9d..e04d433ae0 100644 --- a/packages/core/src/__tests__/postgres/command-center-analytics.pg.test.ts +++ b/packages/core/src/__tests__/postgres/command-center-analytics.pg.test.ts @@ -51,7 +51,7 @@ pgTest("Command Center analytics aggregators (PostgreSQL backend mode)", () => { // ── Empty project: each aggregator resolves with a zero/empty shape ───────── it("all four aggregators resolve (no throw) against an empty project", async () => { - const layer = h.layer(); + const layer = Object.assign(h.layer(), { projectId: "p1" }); const range = { from: FROM, to: TO }; const productivity = await aggregateProductivityAnalytics(layer, range); @@ -145,22 +145,22 @@ pgTest("Command Center analytics aggregators (PostgreSQL backend mode)", () => { // Usage events: tool calls + a session start. await adminDb.execute(sql` - INSERT INTO project.usage_events (ts, kind, tool_name, category) + INSERT INTO project.usage_events (project_id, ts, kind, tool_name, category) VALUES - (${IN_RANGE}, 'tool_call', 'Read', 'other'), - (${IN_RANGE}, 'tool_call', 'Edit', 'other'), - (${IN_RANGE}, 'session_start', NULL, NULL) + ('p1', ${IN_RANGE}, 'tool_call', 'Read', 'other'), + ('p1', ${IN_RANGE}, 'tool_call', 'Edit', 'other'), + ('p1', ${IN_RANGE}, 'session_start', NULL, NULL) `); // An approval event (human intervention). await adminDb.execute(sql` INSERT INTO project.approval_request_audit_events - (id, request_id, event_type, actor_id, actor_type, actor_name, created_at) + (project_id, id, request_id, event_type, actor_id, actor_type, actor_name, created_at) VALUES - ('ev-1', 'req-1', 'approved', 'user-1', 'user', 'User One', ${IN_RANGE}) + ('p1', 'ev-1', 'req-1', 'approved', 'user-1', 'user', 'User One', ${IN_RANGE}) `); - const layer = h.layer(); + const layer = Object.assign(h.layer(), { projectId: "p1" }); const range = { from: FROM, to: TO }; // Productivity. @@ -200,4 +200,62 @@ pgTest("Command Center analytics aggregators (PostgreSQL backend mode)", () => { const byCat = Object.fromEntries(tools.byCategory.map((c) => [c.category, c.count])); expect(Object.values(byCat).reduce((a, b) => a + b, 0)).toBe(2); }); + + /* + FNXC:PostgresCommandCenterAnalytics 2026-07-14-00:49: + Unbound tool analytics intentionally combine all project partitions, including usage-event totals/categories/sessions and task-backed user steers. Binding the same read layer must isolate every one of those query surfaces to the selected project. + */ + it("tool analytics aggregate all projects when unbound and isolate a bound project", async () => { + const store = h.store(); + const layer = Object.assign(h.layer(), { projectId: undefined as string | undefined }); + const adminDb = h.adminDb(); + + layer.projectId = "tool-project-a"; + await store.createTaskWithReservedId( + { description: "tool analytics A", column: "todo" }, + { taskId: "FN-TOOL-A", createdAt: IN_RANGE, updatedAt: IN_RANGE, applyDefaultWorkflowSteps: false }, + ); + layer.projectId = "tool-project-b"; + await store.createTaskWithReservedId( + { description: "tool analytics B", column: "todo" }, + { taskId: "FN-TOOL-B", createdAt: IN_RANGE, updatedAt: IN_RANGE, applyDefaultWorkflowSteps: false }, + ); + await adminDb.execute(sql` + UPDATE project.tasks + SET steering_comments = ${JSON.stringify([{ id: "steer-a", author: "user", content: "A", createdAt: IN_RANGE }])}::jsonb + WHERE id = 'FN-TOOL-A' + `); + await adminDb.execute(sql` + UPDATE project.tasks + SET steering_comments = ${JSON.stringify([{ id: "steer-b", author: "user", content: "B", createdAt: IN_RANGE }])}::jsonb + WHERE id = 'FN-TOOL-B' + `); + await adminDb.execute(sql` + INSERT INTO project.usage_events (project_id, ts, kind, tool_name, category) + VALUES + ('tool-project-a', ${IN_RANGE}, 'tool_call', 'Read', 'other'), + ('tool-project-a', ${IN_RANGE}, 'session_start', NULL, NULL), + ('tool-project-b', ${IN_RANGE}, 'tool_call', 'Edit', 'other'), + ('tool-project-b', ${IN_RANGE}, 'session_start', NULL, NULL) + `); + await adminDb.execute(sql` + INSERT INTO project.approval_request_audit_events + (project_id, id, request_id, event_type, actor_id, actor_type, actor_name, created_at) + VALUES + ('tool-project-a', 'tool-approval-a', 'tool-request-a', 'approved', 'user-a', 'user', 'User A', ${IN_RANGE}), + ('tool-project-b', 'tool-approval-b', 'tool-request-b', 'approved', 'user-b', 'user', 'User B', ${IN_RANGE}) + `); + + delete layer.projectId; + const range = { from: FROM, to: TO }; + const unbound = await aggregateToolAnalytics(layer, range); + expect(unbound).toMatchObject({ toolCalls: 2, sessions: 2 }); + expect(unbound.interventions).toMatchObject({ approvals: 2, userSteers: 2, total: 4 }); + expect(Object.values(Object.fromEntries(unbound.byCategory.map((row) => [row.category, row.count]))).reduce((a, b) => a + b, 0)).toBe(2); + + const bound = await aggregateToolAnalytics({ ...layer, projectId: "tool-project-a" }, range); + expect(bound).toMatchObject({ toolCalls: 1, sessions: 1 }); + expect(bound.interventions).toMatchObject({ approvals: 1, userSteers: 1, total: 2 }); + expect(Object.values(Object.fromEntries(bound.byCategory.map((row) => [row.category, row.count]))).reduce((a, b) => a + b, 0)).toBe(1); + }); }); diff --git a/packages/core/src/__tests__/postgres/command-center-remaining-analytics.pg.test.ts b/packages/core/src/__tests__/postgres/command-center-remaining-analytics.pg.test.ts index eef25d75a9..0da31926e3 100644 --- a/packages/core/src/__tests__/postgres/command-center-remaining-analytics.pg.test.ts +++ b/packages/core/src/__tests__/postgres/command-center-remaining-analytics.pg.test.ts @@ -51,7 +51,7 @@ pgTest("Command Center remaining analytics aggregators (PostgreSQL backend mode) // ── Empty project: each aggregator resolves with a zero/empty shape ───────── it("all four aggregators resolve (no throw) against an empty project", async () => { - const layer = h.layer(); + const layer = Object.assign(h.layer(), { projectId: "p1" }); const range = { from: FROM, to: TO }; const workflow = await aggregateWorkflowAnalytics(layer, { @@ -154,9 +154,9 @@ pgTest("Command Center remaining analytics aggregators (PostgreSQL backend mode) // ── Signals: one resolved-in-range incident ────────────────────────────── await adminDb.execute(sql` INSERT INTO project.incidents - (incident_id, grouping_key, title, severity, status, source, opened_at, resolved_at, created_at, updated_at) + (project_id, incident_id, grouping_key, title, severity, status, source, opened_at, resolved_at, created_at, updated_at) VALUES - ('inc-1', 'gk-1', 'DB down', 'high', 'resolved', 'datadog', ${IN_RANGE}, ${RESOLVED_IN_RANGE}, ${IN_RANGE}, ${RESOLVED_IN_RANGE}) + ('p1', 'inc-1', 'gk-1', 'DB down', 'high', 'resolved', 'datadog', ${IN_RANGE}, ${RESOLVED_IN_RANGE}, ${IN_RANGE}, ${RESOLVED_IN_RANGE}) `); // ── Live snapshot: one active session + one active run ──────────────────── @@ -165,8 +165,8 @@ pgTest("Command Center remaining analytics aggregators (PostgreSQL backend mode) VALUES ('agent-live', 'Live Agent', 'executor', 'idle', ${IN_RANGE}, ${IN_RANGE}) `); await adminDb.execute(sql` - INSERT INTO project.agent_runs (id, agent_id, data, started_at, status) - VALUES ('run-1', 'agent-live', ${JSON.stringify({ taskId: "FN-WF-1" })}::jsonb, ${IN_RANGE}, 'active') + INSERT INTO project.agent_runs (project_id, id, agent_id, data, started_at, status) + VALUES ('p1', 'run-1', 'agent-live', ${JSON.stringify({ taskId: "FN-WF-1" })}::jsonb, ${IN_RANGE}, 'active') `); await adminDb.execute(sql` INSERT INTO project.cli_sessions @@ -175,7 +175,7 @@ pgTest("Command Center remaining analytics aggregators (PostgreSQL backend mode) ('cli-1', 'FN-WF-1', 'task', 'p1', 'claude-local', 'working', '/tmp/wt/FN-WF-1', ${IN_RANGE}, ${IN_RANGE}) `); - const layer = h.layer(); + const layer = Object.assign(h.layer(), { projectId: "p1" }); const range = { from: FROM, to: TO }; // Workflow. @@ -258,7 +258,7 @@ pgTest("Command Center remaining analytics aggregators (PostgreSQL backend mode) WHERE id = 'FN-SNAP-1' `); - const layer = h.layer(); + const layer = Object.assign(h.layer(), { projectId: "p1" }); const workflow = await aggregateWorkflowAnalytics(layer, { from: FROM, to: TO, @@ -269,4 +269,97 @@ pgTest("Command Center remaining analytics aggregators (PostgreSQL backend mode) expect(workflow.workflows[0].cost.usd).toBeCloseTo(4, 2); expect(workflow.totals.cost.usd).toBeCloseTo(4, 2); }); + + /* + FNXC:PostgresCommandCenterAnalytics 2026-07-14-00:49: + An unbound live snapshot intentionally composes sessions, heartbeat runs, active nodes, and task-column counts across every project partition. Binding the same layer must scope every live surface together so the snapshot cannot mix global and project-local counts. + */ + it("live snapshot aggregates all projects when unbound and isolates a bound project", async () => { + const store = h.store(); + const layer = Object.assign(h.layer(), { projectId: undefined as string | undefined }); + const adminDb = h.adminDb(); + + layer.projectId = "live-project-a"; + await store.createTaskWithReservedId( + { description: "live task A", column: "todo" }, + { taskId: "FN-LIVE-A", createdAt: IN_RANGE, updatedAt: IN_RANGE, applyDefaultWorkflowSteps: false }, + ); + layer.projectId = "live-project-b"; + await store.createTaskWithReservedId( + { description: "live task B", column: "in-progress" }, + { taskId: "FN-LIVE-B", createdAt: IN_RANGE, updatedAt: IN_RANGE, applyDefaultWorkflowSteps: false }, + ); + await adminDb.execute(sql` + INSERT INTO project.agents (id, name, role, state, created_at, updated_at) + VALUES + ('agent-live-a', 'Live A', 'executor', 'idle', ${IN_RANGE}, ${IN_RANGE}), + ('agent-live-b', 'Live B', 'executor', 'idle', ${IN_RANGE}, ${IN_RANGE}) + `); + await adminDb.execute(sql` + INSERT INTO project.agent_runs (project_id, id, agent_id, data, started_at, status) + VALUES + ('live-project-a', 'run-live-a', 'agent-live-a', ${JSON.stringify({ taskId: "FN-LIVE-A" })}::jsonb, ${IN_RANGE}, 'active'), + ('live-project-b', 'run-live-b', 'agent-live-b', ${JSON.stringify({ taskId: "FN-LIVE-B" })}::jsonb, ${IN_RANGE}, 'active') + `); + await adminDb.execute(sql` + INSERT INTO project.cli_sessions + (id, task_id, purpose, project_id, adapter_id, agent_state, worktree_path, created_at, updated_at) + VALUES + ('cli-live-a', 'FN-LIVE-A', 'task', 'live-project-a', 'test', 'working', '/tmp/live-a', ${IN_RANGE}, ${IN_RANGE}), + ('cli-live-b', 'FN-LIVE-B', 'task', 'live-project-b', 'test', 'working', '/tmp/live-b', ${IN_RANGE}, ${IN_RANGE}) + `); + + delete layer.projectId; + const unbound = await composeLiveSnapshot(layer, Date.parse(IN_RANGE)); + expect(unbound).toMatchObject({ activeSessions: 2, activeRuns: 2, activeNodes: 2 }); + expect(unbound.sessions.map(({ id }) => id).sort()).toEqual(["cli-live-a", "cli-live-b"]); + expect(unbound.runs.map(({ id }) => id).sort()).toEqual(["run-live-a", "run-live-b"]); + expect(Object.fromEntries(unbound.columns.map(({ column, count }) => [column, count]))).toMatchObject({ todo: 1, "in-progress": 1 }); + + const bound = await composeLiveSnapshot({ ...layer, projectId: "live-project-a" }, Date.parse(IN_RANGE)); + expect(bound).toMatchObject({ activeSessions: 1, activeRuns: 1, activeNodes: 1 }); + expect(bound.sessions.map(({ id }) => id)).toEqual(["cli-live-a"]); + expect(bound.runs.map(({ id }) => id)).toEqual(["run-live-a"]); + expect(Object.fromEntries(bound.columns.map(({ column, count }) => [column, count]))).toEqual({ todo: 1 }); + }); + + /** + * FNXC:SignalsAnalyticsIsolation 2026-07-14-01:26: + * Unbound Signals analytics intentionally aggregate every project partition, while a bound layer must apply one tenant scope to totals, open/resolved counts, MTTR samples, and every source/severity/status breakdown. + */ + it("signals analytics aggregate all projects when unbound and isolate a bound project", async () => { + const layer = Object.assign(h.layer(), { projectId: undefined as string | undefined }); + const adminDb = h.adminDb(); + await adminDb.execute(sql` + INSERT INTO project.incidents + (project_id, incident_id, grouping_key, title, severity, status, source, opened_at, resolved_at, created_at, updated_at) + VALUES + ('signal-project-a', 'signal-a', 'group-a', 'A', 'high', 'resolved', 'datadog', ${IN_RANGE}, ${RESOLVED_IN_RANGE}, ${IN_RANGE}, ${RESOLVED_IN_RANGE}), + ('signal-project-b', 'signal-b', 'group-b', 'B', 'critical', 'open', 'sentry', ${IN_RANGE}, NULL, ${IN_RANGE}, ${IN_RANGE}) + `); + + const range = { from: FROM, to: TO }; + const unbound = await aggregateSignalsAnalytics(layer, range); + expect(unbound).toMatchObject({ totalSignals: 2, open: 1, resolved: 1 }); + expect(unbound.mttr).toEqual({ value: 60, unavailable: false, sampleCount: 1 }); + expect(unbound.bySource).toEqual([ + { source: "datadog", count: 1 }, + { source: "sentry", count: 1 }, + ]); + expect(unbound.bySeverity).toEqual([ + { severity: "critical", count: 1 }, + { severity: "high", count: 1 }, + ]); + expect(unbound.byStatus).toEqual([ + { status: "open", count: 1 }, + { status: "resolved", count: 1 }, + ]); + + const bound = await aggregateSignalsAnalytics({ ...layer, projectId: "signal-project-a" }, range); + expect(bound).toMatchObject({ totalSignals: 1, open: 0, resolved: 1 }); + expect(bound.mttr).toEqual({ value: 60, unavailable: false, sampleCount: 1 }); + expect(bound.bySource).toEqual([{ source: "datadog", count: 1 }]); + expect(bound.bySeverity).toEqual([{ severity: "high", count: 1 }]); + expect(bound.byStatus).toEqual([{ status: "resolved", count: 1 }]); + }); }); diff --git a/packages/core/src/__tests__/postgres/connection.test.ts b/packages/core/src/__tests__/postgres/connection.test.ts index 80369ae435..e9779a2a8c 100644 --- a/packages/core/src/__tests__/postgres/connection.test.ts +++ b/packages/core/src/__tests__/postgres/connection.test.ts @@ -108,6 +108,19 @@ pgDescribe("connection: external PostgreSQL integration (VAL-CONN-002)", () => { expect(result).toBeDefined(); }); + it("reserves migration work on a session separate from the runtime pool", async () => { + const backend: ResolvedBackend = { + mode: "external", + runtimeUrl: PG_TEST_URL, + migrationUrl: PG_TEST_URL, + migrationUrlOverridden: false, + }; + connections = await createConnectionSetFromUrl(backend, { poolMax: 3, connectTimeoutSeconds: 5 }); + const runtimeRows = await connections.runtime.execute("SELECT pg_backend_pid() AS pid") as unknown as Array<{ pid: number }>; + const migrationRows = await connections.migration.execute("SELECT pg_backend_pid() AS pid") as unknown as Array<{ pid: number }>; + expect(migrationRows[0]?.pid).not.toBe(runtimeRows[0]?.pid); + }); + it("close() cleanly shuts down the pool without error", async () => { const backend: ResolvedBackend = { mode: "external", diff --git a/packages/core/src/__tests__/postgres/handoff-to-review-atomicity.pg.test.ts b/packages/core/src/__tests__/postgres/handoff-to-review-atomicity.pg.test.ts index b7b5114160..14cc69a417 100644 --- a/packages/core/src/__tests__/postgres/handoff-to-review-atomicity.pg.test.ts +++ b/packages/core/src/__tests__/postgres/handoff-to-review-atomicity.pg.test.ts @@ -82,6 +82,36 @@ pgTest("handoff-to-review transactional invariant (PostgreSQL)", () => { expect(audits.some((a) => a.mutationType === "task:handoff")).toBe(true); }); + /** + * FNXC:PostgresMigrationCoverage 2026-07-13-22:54: + * Executor retries can repeat a completed handoff, so PostgreSQL must keep the operation idempotent, retain one merge-queue row, and audit the second call as an already-enqueued handoff from in-review. + */ + it("is idempotent and records the retry status in the handoff audit", async () => { + const store = h.store(); + const task = await store.createTask({ description: "handoff retry", column: "in-progress" }); + + await store.handoffToReview(task.id, { + ownerAgentId: "agent-1", + evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-1" }, + now: "2026-07-13T20:00:00.000Z", + }); + const retried = await store.handoffToReview(task.id, { + ownerAgentId: "agent-1", + evidence: { reason: "fn_task_done", runId: "run-2", agentId: "agent-1" }, + now: "2026-07-13T20:00:05.000Z", + }); + + expect(retried.column).toBe("in-review"); + const queueRows = await h.adminDb().select().from(schema.project.mergeQueue).where(eq(schema.project.mergeQueue.taskId, task.id)); + expect(queueRows).toHaveLength(1); + const audits = await h.adminDb().select({ metadata: schema.project.runAuditEvents.metadata }).from(schema.project.runAuditEvents).where(and(eq(schema.project.runAuditEvents.taskId, task.id), eq(schema.project.runAuditEvents.mutationType, "task:handoff"))); + expect(audits).toHaveLength(2); + expect(audits.some((audit) => { + const metadata = audit.metadata as Record; + return metadata.runId === "run-2" && metadata.fromColumn === "in-review" && metadata.alreadyEnqueued === true; + })).toBe(true); + }); + it("rejects handoff of a soft-deleted task without partial writes", async () => { const store = h.store(); const task = await store.createTask({ description: "handoff deleted", column: "in-progress" }); diff --git a/packages/core/src/__tests__/postgres/satellite-fusiondir-stores.test.ts b/packages/core/src/__tests__/postgres/satellite-fusiondir-stores.test.ts index 5c2c2a2c2c..07e07bc1e4 100644 --- a/packages/core/src/__tests__/postgres/satellite-fusiondir-stores.test.ts +++ b/packages/core/src/__tests__/postgres/satellite-fusiondir-stores.test.ts @@ -110,6 +110,7 @@ pgDescribe("PostgreSQL satellite fusion-dir stores (VAL-DATA-015, VAL-DATA-016)" it("AutomationStore: create → get → list → update (upsert) → due query → delete", async () => { ctx = await setupCtx(); const { upsertSchedule, getSchedule, findSchedule, listSchedules, deleteSchedule, getDueSchedules } = await import("../../async-automation-store.js"); + const layer = { ...ctx.layer, projectId: "automation-round-trip" } as AsyncDataLayer; const now = new Date().toISOString(); const past = new Date(Date.now() - 60_000).toISOString(); @@ -133,8 +134,8 @@ pgDescribe("PostgreSQL satellite fusion-dir stores (VAL-DATA-015, VAL-DATA-016)" updatedAt: now, }; - await upsertSchedule(ctx.layer.db, schedule); - const fetched = await getSchedule(ctx.layer.db, schedule.id); + await upsertSchedule(layer, schedule); + const fetched = await getSchedule(layer, schedule.id); expect(fetched.name).toBe("Nightly Build"); expect(fetched.enabled).toBe(true); expect(fetched.steps).toHaveLength(1); @@ -150,31 +151,96 @@ pgDescribe("PostgreSQL satellite fusion-dir stores (VAL-DATA-015, VAL-DATA-016)" runHistory: [{ success: true, output: "ok", startedAt: past, completedAt: now }], updatedAt: now, }; - await upsertSchedule(ctx.layer.db, updated); - const afterUpdate = await getSchedule(ctx.layer.db, schedule.id); + await upsertSchedule(layer, updated); + const afterUpdate = await getSchedule(layer, schedule.id); expect(afterUpdate.enabled).toBe(false); expect(afterUpdate.runCount).toBe(1); expect(afterUpdate.lastRunResult).toEqual(updated.lastRunResult); expect(afterUpdate.runHistory).toHaveLength(1); // List - const all = await listSchedules(ctx.layer.db); + const all = await listSchedules(layer); expect(all).toHaveLength(1); // Due query (enabled=false now, so not due) - const dueDisabled = await getDueSchedules(ctx.layer.db, now, "project"); + const dueDisabled = await getDueSchedules(layer, now, "project"); expect(dueDisabled).toHaveLength(0); // Re-enable and check due - await upsertSchedule(ctx.layer.db, { ...updated, enabled: true }); - const dueEnabled = await getDueSchedules(ctx.layer.db, now, "project"); + await upsertSchedule(layer, { ...updated, enabled: true }); + const dueEnabled = await getDueSchedules(layer, now, "project"); expect(dueEnabled).toHaveLength(1); expect(dueEnabled[0]!.id).toBe(schedule.id); // findSchedule returns the row, deleteSchedule removes it - expect((await findSchedule(ctx.layer.db, schedule.id))?.id).toBe(schedule.id); - expect(await deleteSchedule(ctx.layer.db, schedule.id)).toBe(true); - expect(await findSchedule(ctx.layer.db, schedule.id)).toBeUndefined(); + expect((await findSchedule(layer, schedule.id))?.id).toBe(schedule.id); + expect(await deleteSchedule(layer, schedule.id)).toBe(true); + expect(await findSchedule(layer, schedule.id)).toBeUndefined(); + }); + + /** + * FNXC:AutomationIsolation 2026-07-13-22:37: + * Embedded PostgreSQL shares one physical automations table across projects, while SQLite provided isolation through one file per project. Every automation operation must therefore use the bound AsyncDataLayer projectId. This regression covers unbound rejection, empty, duplicate-ID, and populated states and proves that listing, mutation, deletion, and the due-run claim boundary cannot cross projects. A `global` scope remains an execution lane owned by the project that created it; it is not permission for another project's cron runner to execute the command. + * + * FNXC:AutomationIsolation 2026-07-14-00:37: + * Missing project ownership is an invalid automation-store state. Unbound helpers must reject rather than creating an invisible schedule in the empty-string partition. + */ + it("AutomationStore: isolates duplicate IDs and due-run claims across two bound projects", async () => { + ctx = await setupCtx(); + const { upsertSchedule, listSchedules } = await import("../../async-automation-store.js"); + const { AutomationStore } = await import("../../automation-store.js"); + const layerA = { ...ctx.layer, projectId: "project-a" } as AsyncDataLayer; + const layerB = { ...ctx.layer, projectId: "project-b" } as AsyncDataLayer; + const now = new Date().toISOString(); + const past = new Date(Date.now() - 60_000).toISOString(); + const duplicateId = "shared-automation-id"; + const schedule = (name: string, scope: "global" | "project") => ({ + id: duplicateId, + name, + scheduleType: "custom" as const, + cronExpression: "* * * * *", + command: `echo ${name}`, + enabled: true, + runCount: 0, + runHistory: [], + nextRunAt: past, + scope, + createdAt: now, + updatedAt: now, + }); + + expect(await listSchedules(layerA)).toEqual([]); + expect(await listSchedules(layerB)).toEqual([]); + + await expect( + upsertSchedule(ctx.layer, { ...schedule("unbound", "project"), id: "unbound-id" }), + ).rejects.toThrow("AutomationStore backend operations require asyncLayer.projectId"); + expect(await listSchedules(layerA)).toEqual([]); + expect(await listSchedules(layerB)).toEqual([]); + + await upsertSchedule(layerA, schedule("project-a", "project")); + await upsertSchedule(layerB, schedule("project-b-global", "global")); + + const storeA = new AutomationStore("/tmp/fusion-automation-project-a", { asyncLayer: layerA }); + const storeB = new AutomationStore("/tmp/fusion-automation-project-b", { asyncLayer: layerB }); + expect((await storeA.listSchedules()).map(({ name }) => name)).toEqual(["project-a"]); + expect((await storeB.listSchedules()).map(({ name }) => name)).toEqual(["project-b-global"]); + + await storeA.updateSchedule(duplicateId, { name: "project-a-updated" }); + expect((await storeA.getSchedule(duplicateId)).name).toBe("project-a-updated"); + expect((await storeB.getSchedule(duplicateId)).name).toBe("project-b-global"); + + expect((await storeA.getDueSchedules("project")).map(({ name }) => name)).toEqual(["project-a-updated"]); + expect((await storeB.getDueSchedules("project"))).toEqual([]); + expect((await storeB.getDueSchedules("global")).map(({ name }) => name)).toEqual(["project-b-global"]); + + expect(await storeA.claimDueSchedule(duplicateId, past)).toBe(true); + expect(await storeA.getDueSchedules("project")).toEqual([]); + expect((await storeB.getDueSchedules("global")).map(({ name }) => name)).toEqual(["project-b-global"]); + + await storeA.deleteSchedule(duplicateId); + expect(await storeA.listSchedules()).toEqual([]); + expect((await storeB.listSchedules()).map(({ name }) => name)).toEqual(["project-b-global"]); }); // ── RoutineStore ── @@ -400,6 +466,36 @@ pgDescribe("PostgreSQL satellite fusion-dir stores (VAL-DATA-015, VAL-DATA-016)" // ── AgentStore ── + /** + * FNXC:AgentHeartbeatIsolation 2026-07-14-00:37: + * Heartbeat and run APIs are project-owned in backend mode. An unbound AgentStore must reject before it can read or write the shared PostgreSQL heartbeat/run state. + */ + it("AgentStore: unbound backend heartbeat/run APIs fail closed", async () => { + ctx = await setupCtx(); + const { AgentStore } = await import("../../agent-store.js"); + const store = new AgentStore({ rootDir: "/tmp/fusion-unbound-agent-store", asyncLayer: ctx.layer }); + const run = { + id: "unbound-run", + agentId: "unbound-agent", + startedAt: new Date().toISOString(), + endedAt: null, + status: "active" as const, + }; + + await expect(store.saveRun(run)).rejects.toThrow( + "AgentStore backend heartbeat/run operations require asyncLayer.projectId", + ); + await expect(store.listActiveHeartbeatRuns()).rejects.toThrow( + "AgentStore backend heartbeat/run operations require asyncLayer.projectId", + ); + await expect(store.recordHeartbeat(run.agentId, "ok", run.id)).rejects.toThrow( + "AgentStore backend heartbeat/run operations require asyncLayer.projectId", + ); + await expect(store.getHeartbeatHistory(run.agentId)).rejects.toThrow( + "AgentStore backend heartbeat/run operations require asyncLayer.projectId", + ); + }); + it("AgentStore: write/read agent (jsonb data) → list → find by name → delete", async () => { ctx = await setupCtx(); const { writeAgent, readAgent, listAgentRows, findAgentRowsByName, deleteAgent, agentToData } = await import("../../async-agent-store.js"); @@ -486,30 +582,30 @@ pgDescribe("PostgreSQL satellite fusion-dir stores (VAL-DATA-015, VAL-DATA-016)" status: "active" as const, }; - await saveRun(ctx.layer.db, run); - expect((await getRunDetail(ctx.layer.db, agentId, run.id))?.id).toBe(run.id); - const byId = await getRunById(ctx.layer.db, run.id); + await saveRun(ctx.layer.db, ctx.layer.projectId ?? "", run); + expect((await getRunDetail(ctx.layer.db, ctx.layer.projectId ?? "", agentId, run.id))?.id).toBe(run.id); + const byId = await getRunById(ctx.layer.db, ctx.layer.projectId ?? "", run.id); expect(byId?.agentId).toBe(agentId); expect(byId?.run?.id).toBe(run.id); // recent runs - const recent = await getRecentRuns(ctx.layer.db, agentId, 10); + const recent = await getRecentRuns(ctx.layer.db, ctx.layer.projectId ?? "", agentId, 10); expect(recent).toHaveLength(1); // active list - const active = await listActiveHeartbeatRuns(ctx.layer.db); + const active = await listActiveHeartbeatRuns(ctx.layer.db, ctx.layer.projectId ?? ""); expect(active).toHaveLength(1); expect(active[0]!.id).toBe(run.id); // end the run const endedRun = { ...run, endedAt: new Date().toISOString(), status: "completed" as const }; - await saveRun(ctx.layer.db, endedRun); - const counts = await getRunStatusCounts(ctx.layer.db, [agentId]); + await saveRun(ctx.layer.db, ctx.layer.projectId ?? "", endedRun); + const counts = await getRunStatusCounts(ctx.layer.db, ctx.layer.projectId ?? "", [agentId]); expect(counts.completedRuns).toBe(1); expect(counts.failedRuns).toBe(0); // insertRunIfAbsent is a no-op on existing - expect(await insertRunIfAbsent(ctx.layer.db, run)).toBe(false); + expect(await insertRunIfAbsent(ctx.layer.db, ctx.layer.projectId ?? "", run)).toBe(false); }); it("AgentStore: task session upsert/get/delete", async () => { diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index 7db9af7944..af13a2333d 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -28,9 +28,11 @@ import { sql } from "drizzle-orm"; import { execSync } from "node:child_process"; import { applySchemaBaseline, + getAppliedMigrations, SCHEMA_BASELINE_VERSION, roadmapPluginSchemaInit, } from "../../postgres/index.js"; +import { MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION } from "../../postgres/schema-applier.js"; const PG_ADMIN_URL = process.env.FUSION_PG_TEST_ADMIN_URL ?? "postgresql://localhost:5432/postgres"; @@ -41,6 +43,14 @@ const PG_AVAILABLE = const pgDescribe = PG_AVAILABLE ? describe : describe.skip; +describe("schema-applier: immutable migration identities", () => { + it("keeps monitor and approval isolation assigned to version 0003", () => { + expect(MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION).toBe("0003"); + expect(Number(SCHEMA_BASELINE_VERSION)) + .toBeGreaterThanOrEqual(Number(MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION)); + }); +}); + /** * FNXC:PostgresSchema 2026-06-24-04:00: * Create a uniquely-named fresh database for each test so tests are hermetic @@ -393,6 +403,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 index parity (every SQLite index has // SQLite indexes that were renamed in PostgreSQL for clarity. const RENAMED_TO: Record = { + idxAutomationsScope: "idxAutomationsProjectScope", idxSecretsKey: "secrets_key_unique", idxSecretsGlobalKey: "secrets_global_key_unique", idxTaskDocumentsTaskKey: "task_documents_task_id_key_unique", @@ -447,6 +458,178 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 index parity (every SQLite index has }); }); +pgDescribe("schema-applier: automation project-isolation upgrade", () => { + let ctx: TestContext | null = null; + + afterEach(async () => { + await teardownDb(ctx); + ctx = null; + }); + + async function seedVersion0000Automation( + db: TestContext["db"], + projectIds: readonly string[], + ): Promise { + await db.execute(sql.raw(` + CREATE SCHEMA project; + CREATE SCHEMA central; + CREATE TABLE central.projects ( + id text PRIMARY KEY, + name text NOT NULL, + path text NOT NULL UNIQUE, + status text NOT NULL DEFAULT 'active', + isolation_mode text NOT NULL DEFAULT 'in-process', + created_at text NOT NULL, + updated_at text NOT NULL + ); + CREATE TABLE project.automations ( + id text PRIMARY KEY, + name text NOT NULL, + description text, + schedule_type text NOT NULL, + cron_expression text NOT NULL, + command text NOT NULL, + enabled integer DEFAULT 1, + timeout_ms integer, + steps jsonb, + next_run_at text, + last_run_at text, + last_run_result jsonb, + run_count integer DEFAULT 0, + run_history jsonb DEFAULT '[]', + scope text DEFAULT 'project', + created_at text NOT NULL, + updated_at text NOT NULL + ); + CREATE INDEX "idxAutomationsScope" ON project.automations(scope); + CREATE TABLE public.fusion_schema_migrations ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ); + INSERT INTO public.fusion_schema_migrations(version) VALUES ('0000'); + INSERT INTO project.automations ( + id, name, schedule_type, cron_expression, command, enabled, + next_run_at, scope, created_at, updated_at + ) VALUES ( + 'legacy-automation', 'Legacy', 'custom', '* * * * *', 'echo legacy', 1, + '2026-01-01T00:00:00.000Z', 'project', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z' + ); + `)); + for (const projectId of projectIds) { + await db.execute(sql` + INSERT INTO central.projects(id, name, path, created_at, updated_at) + VALUES (${projectId}, ${projectId}, ${`/repo/${projectId}`}, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z') + `); + } + } + + /** + * FNXC:AutomationIsolation 2026-07-13-22:37: + * The versioned upgrade must preserve existing schedules. A sole registered project is deterministic ownership evidence; multiple projects are ambiguous and must fail loudly before a bound cron runner starts. + */ + it("upgrades 0000 rows into the sole registered project and records version 0001", async () => { + ctx = await setupFreshDb(); + await seedVersion0000Automation(ctx.db, ["project-a"]); + + expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true); + + const rows = (await ctx.db.execute(sql` + SELECT project_id, id, name FROM project.automations + `)) as unknown as Array<{ project_id: string; id: string; name: string }>; + expect(rows).toEqual([{ project_id: "project-a", id: "legacy-automation", name: "Legacy" }]); + const versions = (await ctx.db.execute(sql` + SELECT version FROM public.fusion_schema_migrations ORDER BY version + `)) as unknown as Array<{ version: string }>; + expect(versions.map(({ version }) => version)).toEqual(["0000", "0001", "0002", SCHEMA_BASELINE_VERSION]); + expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); + }); + + it("fails loudly when legacy automation ownership is ambiguous", async () => { + ctx = await setupFreshDb(); + await seedVersion0000Automation(ctx.db, ["project-a", "project-b"]); + + await expect(applySchemaBaseline(ctx.db, { pluginHooks: [] })).rejects.toThrow( + /Cannot assign legacy automations to a project/, + ); + const versions = (await ctx.db.execute(sql` + SELECT version FROM public.fusion_schema_migrations ORDER BY version + `)) as unknown as Array<{ version: string }>; + expect(versions.map(({ version }) => version)).toEqual(["0000"]); + }); + + it("serializes concurrent schema appliers", async () => { + ctx = await setupFreshDb(); + const results = await Promise.all([ + applySchemaBaseline(ctx.db, { pluginHooks: [] }), + applySchemaBaseline(ctx.db, { pluginHooks: [] }), + ]); + expect(results.filter(({ applied }) => applied)).toHaveLength(1); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", SCHEMA_BASELINE_VERSION]); + }); + + it("upgrades a 0001 database by backfilling analytics ownership", async () => { + ctx = await setupFreshDb(); + await applySchemaBaseline(ctx.db, { pluginHooks: [] }); + await ctx.db.execute(sql.raw(` + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0002', '0003'); + ALTER TABLE project.activity_log DROP COLUMN project_id; + ALTER TABLE project.agent_runs DROP COLUMN project_id; + ALTER TABLE project.usage_events DROP COLUMN project_id; + INSERT INTO central.projects(id, name, path, created_at, updated_at) + VALUES ('project-a', 'Project A', '/repo/project-a', '2026-01-01', '2026-01-01'); + INSERT INTO project.agents(id, name, role, created_at, updated_at) + VALUES ('agent-a', 'Agent A', 'worker', '2026-01-01', '2026-01-01'); + INSERT INTO project.activity_log(id, timestamp, type, details) + VALUES ('activity-a', '2026-01-01', 'task:created', 'created'); + INSERT INTO project.agent_runs(id, agent_id, data, started_at, status) + VALUES ('run-a', 'agent-a', '{}'::jsonb, '2026-01-01', 'completed'); + INSERT INTO project.usage_events(ts, kind) + VALUES ('2026-01-01', 'tool_call'); + `)); + + expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true); + for (const table of ["activity_log", "agent_runs", "usage_events"] as const) { + const rows = (await ctx.db.execute(sql.raw( + `SELECT project_id FROM project.${table}`, + ))) as unknown as Array<{ project_id: string }>; + expect(rows).toEqual([{ project_id: "project-a" }]); + } + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003"]); + }); + + /** + * FNXC:CommandCenterTenantIsolation 2026-07-14-01:04: + * A database that already recorded analytics migration 0002 must still backfill monitor and approval ownership from the sole registered project before bound Command Center reads are enabled. + */ + it("upgrades a 0002 database by backfilling monitor and approval ownership", async () => { + ctx = await setupFreshDb(); + await applySchemaBaseline(ctx.db, { pluginHooks: [] }); + await ctx.db.execute(sql.raw(` + DELETE FROM public.fusion_schema_migrations WHERE version = '0003'; + ALTER TABLE project.deployments DROP COLUMN project_id; + ALTER TABLE project.incidents DROP COLUMN project_id; + ALTER TABLE project.approval_request_audit_events DROP COLUMN project_id; + INSERT INTO central.projects(id, name, path, created_at, updated_at) + VALUES ('project-a', 'Project A', '/repo/project-a', '2026-01-01', '2026-01-01'); + INSERT INTO project.deployments(deployment_id, deployed_at, created_at) + VALUES ('deployment-a', '2026-01-01', '2026-01-01'); + INSERT INTO project.incidents(incident_id, grouping_key, title, status, opened_at, created_at, updated_at) + VALUES ('incident-a', 'group-a', 'Incident A', 'open', '2026-01-01', '2026-01-01', '2026-01-01'); + INSERT INTO project.approval_request_audit_events(id, request_id, event_type, actor_id, actor_type, actor_name, created_at) + VALUES ('event-a', 'request-a', 'approved', 'user-a', 'user', 'User A', '2026-01-01'); + `)); + + expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true); + for (const table of ["deployments", "incidents", "approval_request_audit_events"] as const) { + const rows = (await ctx.db.execute(sql.raw( + `SELECT project_id FROM project.${table}`, + ))) as unknown as Array<{ project_id: string }>; + expect(rows).toEqual([{ project_id: "project-a" }]); + } + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003"]); + }); +}); + pgDescribe("schema-applier: VAL-SCHEMA-006 AUTOINCREMENT → identity with sequence continuity", () => { let ctx: TestContext | null = null; @@ -488,10 +671,10 @@ pgDescribe("schema-applier: VAL-SCHEMA-006 AUTOINCREMENT → identity with seque ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db); await ctx.db.execute(sql` - INSERT INTO project.usage_events (ts, kind) VALUES ('2026-01-01', 'test') + INSERT INTO project.usage_events (project_id, ts, kind) VALUES ('schema-test', '2026-01-01', 'test') `); await ctx.db.execute(sql` - INSERT INTO project.usage_events (ts, kind) VALUES ('2026-01-02', 'test') + INSERT INTO project.usage_events (project_id, ts, kind) VALUES ('schema-test', '2026-01-02', 'test') `); const rows = (await ctx.db.execute(sql` SELECT id FROM project.usage_events ORDER BY id @@ -747,16 +930,16 @@ pgDescribe("schema-applier: VAL-SCHEMA-007 plugin-owned tables materialize via s ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [roadmapPluginInitHook] }); await ctx.db.execute(sql` - INSERT INTO project.roadmaps (id, title, created_at, updated_at) - VALUES ('rm1', 'R', '2026-01-01', '2026-01-01') + INSERT INTO project.roadmaps (id, project_id, title, created_at, updated_at) + VALUES ('rm1', 'schema-test', 'R', '2026-01-01', '2026-01-01') `); await ctx.db.execute(sql` - INSERT INTO project.roadmap_milestones (id, roadmap_id, title, order_index, created_at, updated_at) - VALUES ('rmm1', 'rm1', 'M', 0, '2026-01-01', '2026-01-01') + INSERT INTO project.roadmap_milestones (id, roadmap_id, project_id, title, order_index, created_at, updated_at) + VALUES ('rmm1', 'rm1', 'schema-test', 'M', 0, '2026-01-01', '2026-01-01') `); await ctx.db.execute(sql` - INSERT INTO project.roadmap_features (id, milestone_id, title, order_index, created_at, updated_at) - VALUES ('rmf1', 'rmm1', 'F', 0, '2026-01-01', '2026-01-01') + INSERT INTO project.roadmap_features (id, milestone_id, project_id, title, order_index, created_at, updated_at) + VALUES ('rmf1', 'rmm1', 'schema-test', 'F', 0, '2026-01-01', '2026-01-01') `); await ctx.db.execute(sql`DELETE FROM project.roadmaps WHERE id = 'rm1'`); const ms = (await ctx.db.execute(sql` diff --git a/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts b/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts index 72f29419ac..357f15f9e5 100644 --- a/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts +++ b/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts @@ -350,6 +350,12 @@ async function teardownCtx(ctx: TestCtx | null): Promise { pgDescribe("SQLite-to-PostgreSQL migrator", () => { let ctx: TestCtx | null = null; + const migrateTest = ( + db: Parameters[0], + sources: Parameters[1], + options: Parameters[2] = {}, + ) => migrateSqliteToPostgres(db, sources, { projectId: "migration-test", ...options }); + beforeEach(async () => { ctx = await setupCtx(); }); @@ -370,7 +376,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { // VAL-MIGRATE-001 — row-count verified migration it("migrates all rows with matching per-table row counts", async () => { - const report = await migrateSqliteToPostgres(ctx!.db, [ + const report = await migrateTest(ctx!.db, [ { sqlitePath: join(ctx!.fusionDir, "archive.db"), pgSchema: "archive" as const }, { sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }, ]); @@ -402,6 +408,148 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { expect(archived.targetRows).toBe(1); }); + /* + FNXC:PostgresMigration 2026-07-13-22:37: + Every user table in a legacy SQLite database must be represented in the migration report. An unknown table is retained as an explicit failed verification so startup cannot claim a complete cutover while silently abandoning operator data. + */ + it("reports an unmapped SQLite user table as a verification failure", async () => { + const sqlitePath = join(ctx!.fusionDir, "fusion.db"); + const legacy = new DatabaseSync(sqlitePath); + try { + legacy.exec(`CREATE TABLE operator_extension_data (id TEXT PRIMARY KEY, payload TEXT NOT NULL)`); + legacy.prepare(`INSERT INTO operator_extension_data (id, payload) VALUES (?, ?)`).run("row-1", "must-not-disappear"); + } finally { + legacy.close(); + } + + const report = await migrateTest(ctx!.db, [ + { sqlitePath, pgSchema: "project" as const }, + ]); + + expect(report.tables).toContainEqual( + expect.objectContaining({ + schema: "project", + table: "operator_extension_data", + sourceRows: 1, + insertedRows: 0, + verified: false, + skipped: false, + }), + ); + }); + + /* + FNXC:PostgresMigration 2026-07-13-23:08: + FTS5 shadow tables are disposable implementation details, but the virtual table is the user-visible search dataset. Verification must distinguish the two so an unmapped search surface fails cutover while its internal indexes remain intentional skips. + */ + it("fails an unmapped FTS5 virtual table while allowing only its shadow tables to skip", async () => { + const sqlitePath = join(ctx!.fusionDir, "fusion.db"); + const legacy = new DatabaseSync(sqlitePath); + try { + legacy.exec(`CREATE VIRTUAL TABLE operator_notes_fts USING fts5(body)`); + legacy.prepare(`INSERT INTO operator_notes_fts (body) VALUES (?)`).run("retain searchable content"); + } finally { + legacy.close(); + } + + const report = await migrateTest(ctx!.db, [ + { sqlitePath, pgSchema: "project" as const }, + ]); + + expect(report.tables).toContainEqual(expect.objectContaining({ + table: "operator_notes_fts", + sourceRows: 1, + verified: false, + skipped: false, + })); + expect(report.tables).toContainEqual(expect.objectContaining({ + table: "operator_notes_fts_data", + verified: true, + skipped: true, + })); + }); + + /* + FNXC:AutomationIsolation 2026-07-13-22:37: + Legacy project databases do not carry project_id on automation rows. Migration must inject the resolved registry identity before verification so bound automation stores and cron runners see only their project's schedules, including when legacy automation IDs overlap. + */ + it("injects and verifies the project partition for migrated automations", async () => { + const sqlitePath = join(ctx!.fusionDir, "fusion.db"); + const legacy = new DatabaseSync(sqlitePath); + try { + legacy.exec(`CREATE TABLE automations ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, scheduleType TEXT NOT NULL, + cronExpression TEXT NOT NULL, command TEXT NOT NULL, + createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL + )`); + legacy.prepare(`INSERT INTO automations VALUES (?, ?, ?, ?, ?, ?, ?)`).run( + "auto-shared", "Nightly", "cron", "0 0 * * *", "pnpm check", "2026-06-01", "2026-06-01", + ); + } finally { + legacy.close(); + } + + for (const projectId of ["project-a", "project-b"]) { + const report = await migrateTest( + ctx!.db, + [{ sqlitePath, pgSchema: "project" as const }], + { projectId }, + ); + expect(report.tables.find((table) => table.table === "automations")).toEqual( + expect.objectContaining({ sourceRows: 1, targetRows: 1, verified: true }), + ); + } + + const rows = (await ctx!.db.execute(sql` + SELECT project_id, id FROM project.automations WHERE id = 'auto-shared' ORDER BY project_id + `)) as unknown as Array<{ project_id: string; id: string }>; + expect(rows).toEqual([ + { project_id: "project-a", id: "auto-shared" }, + { project_id: "project-b", id: "auto-shared" }, + ]); + }); + + /* + FNXC:WhatsAppPostgres 2026-07-13-23:29: + Existing WhatsApp history, dedupe markers, credentials, and Signal keys must survive cutover. The generic camelCase mapper must target all four plugin hook tables and inject the registered project partition before verification. + */ + it("migrates every legacy WhatsApp persistence table into the bound project", async () => { + const sqlitePath = join(ctx!.fusionDir, "fusion.db"); + const legacy = new DatabaseSync(sqlitePath); + try { + legacy.exec(` + CREATE TABLE whatsapp_chat_sessions (sender TEXT PRIMARY KEY, history TEXT NOT NULL, updatedAt TEXT NOT NULL); + CREATE TABLE whatsapp_chat_dedupe (messageId TEXT PRIMARY KEY, sender TEXT NOT NULL, receivedAt TEXT NOT NULL); + CREATE TABLE whatsapp_auth_creds (id TEXT PRIMARY KEY, value TEXT NOT NULL, updatedAt TEXT NOT NULL); + CREATE TABLE whatsapp_auth_keys (category TEXT NOT NULL, keyId TEXT NOT NULL, value TEXT NOT NULL, updatedAt TEXT NOT NULL, PRIMARY KEY (category, keyId)); + `); + legacy.prepare(`INSERT INTO whatsapp_chat_sessions VALUES (?, ?, ?)`).run("+1555", "[]", "2026-07-01"); + legacy.prepare(`INSERT INTO whatsapp_chat_dedupe VALUES (?, ?, ?)`).run("msg-1", "+1555", "2026-07-01"); + legacy.prepare(`INSERT INTO whatsapp_auth_creds VALUES (?, ?, ?)`).run("creds", "{}", "2026-07-01"); + legacy.prepare(`INSERT INTO whatsapp_auth_keys VALUES (?, ?, ?, ?)`).run("session", "key-1", "{}", "2026-07-01"); + } finally { + legacy.close(); + } + + const report = await migrateTest( + ctx!.db, + [{ sqlitePath, pgSchema: "project" as const }], + { projectId: "project-whatsapp" }, + ); + + for (const table of ["whatsapp_chat_sessions", "whatsapp_chat_dedupe", "whatsapp_auth_creds", "whatsapp_auth_keys"]) { + expect(report.tables).toContainEqual(expect.objectContaining({ table, sourceRows: 1, targetRows: 1, verified: true })); + } + const partitions = await ctx!.db.execute(sql` + SELECT project_id FROM project.whatsapp_chat_sessions + UNION ALL SELECT project_id FROM project.whatsapp_chat_dedupe + UNION ALL SELECT project_id FROM project.whatsapp_auth_creds + UNION ALL SELECT project_id FROM project.whatsapp_auth_keys + `) as unknown as Array<{ project_id: string }>; + expect(partitions).toHaveLength(4); + expect(partitions.every(({ project_id }) => project_id === "project-whatsapp")).toBe(true); + }); + // FNXC:PostgresMigration 2026-07-13-20:30: // Legacy camelCase TABLE names (activityLog, runAuditEvents, mergeQueue, // projectNodePathMappings, …) must be snake_cased when matched against @@ -410,7 +558,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { // counterpart"), surfacing post-cutover as // `Project/node path mapping not found` on engine start. it("migrates legacy camelCase-named tables into their snake_case PostgreSQL counterparts", async () => { - const report = await migrateSqliteToPostgres(ctx!.db, [ + const report = await migrateTest(ctx!.db, [ { sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }, ]); @@ -438,7 +586,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { // mapping is now table-scoped: the agents.data column is classified as // jsonb (its type in the agents table specifically), not text. it("classifies the jsonb `data` column correctly per-table (P1 #14 collision fix)", async () => { - const report = await migrateSqliteToPostgres(ctx!.db, [ + const report = await migrateTest(ctx!.db, [ { sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }, ]); @@ -474,7 +622,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { ]; // First migration: clean. - const first = await migrateSqliteToPostgres(ctx!.db, sources); + const first = await migrateTest(ctx!.db, sources); const tasksFirst = first.tables.find((t) => t.table === "tasks")!; expect(tasksFirst.verified).toBe(true); @@ -486,7 +634,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { // (same PK), so the content checksum MUST now mismatch and report // verified: false for tasks. This proves the content check catches what // the row-count check could not. - const second = await migrateSqliteToPostgres(ctx!.db, sources); + const second = await migrateTest(ctx!.db, sources); const tasksSecond = second.tables.find((t) => t.table === "tasks")!; expect(tasksSecond.verified).toBe(false); expect(tasksSecond.targetRows).toBe(tasksSecond.sourceRows); // counts still match @@ -494,7 +642,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { // VAL-MIGRATE-003 — JSON column fidelity it("round-trips JSON columns with identical shape (text-JSON → jsonb)", async () => { - await migrateSqliteToPostgres(ctx!.db, [ + await migrateTest(ctx!.db, [ { sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }, ]); @@ -516,7 +664,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { }); it("materializes defaults for legacy NULL values targeting required jsonb columns", async () => { - await migrateSqliteToPostgres(ctx!.db, [ + await migrateTest(ctx!.db, [ { sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }, ]); @@ -529,7 +677,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { // VAL-MIGRATE-003 — bytea fidelity it("round-trips bytea columns (BLOB → bytea) byte-identical", async () => { - await migrateSqliteToPostgres(ctx!.db, [ + await migrateTest(ctx!.db, [ { sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }, ]); @@ -544,7 +692,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { // VAL-DATA-005/006 + soft-delete handling: deletedAt rows are migrated verbatim it("migrates soft-deleted rows verbatim (deletedAt preserved)", async () => { - await migrateSqliteToPostgres(ctx!.db, [ + await migrateTest(ctx!.db, [ { sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }, ]); @@ -558,7 +706,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { // VAL-MIGRATE-004 — sequence continuity it("bumps identity sequences to max(id)+1 so new inserts do not collide", async () => { - const report = await migrateSqliteToPostgres(ctx!.db, [ + const report = await migrateTest(ctx!.db, [ { sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }, ]); @@ -588,7 +736,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { { sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }, ]; - const first = await migrateSqliteToPostgres(ctx!.db, sources); + const first = await migrateTest(ctx!.db, sources); const firstCounts = new Map(first.tables.map((t) => [`${t.schema}.${t.table}`, t.targetRows])); // FNXC:PostgresMigration 2026-07-13-21:05: @@ -602,7 +750,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { } // Second run — should be a clean re-sync (ON CONFLICT DO NOTHING). - const second = await migrateSqliteToPostgres(ctx!.db, sources); + const second = await migrateTest(ctx!.db, sources); for (const t of second.tables) { const key = `${t.schema}.${t.table}`; expect(t.targetRows, `${key} row count should be unchanged on re-run`).toBe(firstCounts.get(key)); @@ -611,9 +759,36 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { } }); + it("serializes concurrent cutovers on a multi-connection migration pool", async () => { + /* + * FNXC:PostgresMigration 2026-07-13-23:35: + * Exercise session pinning independently of the bulk-copy matrix so the + * concurrency invariant stays inside the merge-gate budget. + */ + const sqlitePath = join(ctx!.fusionDir, "concurrent.db"); + const sqliteDb = new DatabaseSync(sqlitePath); + sqliteDb.close(); + const sources = [ + { sqlitePath, pgSchema: "project" as const }, + ]; + const reports = await Promise.all([ + migrateTest(ctx!.db, sources, { migrationKey: "concurrent-project", skipBaseline: true }), + migrateTest(ctx!.db, sources, { migrationKey: "concurrent-project", skipBaseline: true }), + ]); + + for (const report of reports) { + expect(report.tables).toEqual([]); + } + const rows = (await ctx!.db.execute(sql` + SELECT status, project_id FROM public.fusion_sqlite_migrations + WHERE migration_key = 'concurrent-project' + `)) as unknown as Array<{ status: string; project_id: string }>; + expect(rows).toEqual([{ status: "complete", project_id: "migration-test" }]); + }); + // VAL-MIGRATE-005 — dry-run reports without writing it("dry-run reports the plan without modifying PostgreSQL", async () => { - const report = await migrateSqliteToPostgres( + const report = await migrateTest( ctx!.db, [{ sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }], { dryRun: true }, @@ -638,7 +813,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { // VAL-SEARCH-002 (search_vector population) — generated column auto-populates it("populates the search_vector generated column after migration", async () => { - await migrateSqliteToPostgres(ctx!.db, [ + await migrateTest(ctx!.db, [ { sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }, ]); @@ -652,7 +827,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { // VAL-MIGRATE-006 — migrated DB shape matches native store expectations it("produces a target whose columns match the native schema shape", async () => { - await migrateSqliteToPostgres(ctx!.db, [ + await migrateTest(ctx!.db, [ { sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }, ]); diff --git a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts index 91bad73d87..ecf6d549a6 100644 --- a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts +++ b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts @@ -20,6 +20,7 @@ import { tmpdir } from "node:os"; import { createTaskStoreForBackend } from "../../postgres/startup-factory.js"; import { mkdirSync } from "node:fs"; import { DatabaseSync } from "../../sqlite-adapter.js"; +import postgres from "postgres"; const PG_TEST_URL_BASE = process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432"; @@ -43,6 +44,42 @@ function adminExec(statement: string): void { ); } +function seedLegacyTask(root: string, taskId: string, title: string): void { + const fusionDir = join(root, ".fusion"); + mkdirSync(fusionDir, { recursive: true }); + const legacy = new DatabaseSync(join(fusionDir, "fusion.db")); + try { + legacy.exec(`CREATE TABLE tasks ( + id TEXT PRIMARY KEY, title TEXT, description TEXT NOT NULL, "column" TEXT NOT NULL, + createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL + )`); + legacy.prepare( + `INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`, + ).run(taskId, title, "legacy", "todo", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z"); + } finally { + legacy.close(); + } +} + +function seedLegacyRegistry(globalDir: string, projects: Array<{ id: string; path: string }>): void { + mkdirSync(globalDir, { recursive: true }); + const legacy = new DatabaseSync(join(globalDir, "fusion-central.db")); + try { + legacy.exec(`CREATE TABLE projects ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', + createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL + )`); + const insert = legacy.prepare( + `INSERT INTO projects (id, name, path, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`, + ); + for (const project of projects) { + insert.run(project.id, project.id, project.path, "active", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z"); + } + } finally { + legacy.close(); + } +} + pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { let rootDir: string; let dbName: string; @@ -418,4 +455,138 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { await connections.close().catch(() => undefined); } }); + + /* + FNXC:MultiProjectMigration 2026-07-13-22:37: + A rootDir-only boot must resolve project identity before deciding whether PostgreSQL is empty. Existing rows owned by project A must not suppress project B's first-boot migration, and inserted rows plus verification must stay scoped to the corresponding registry identity. + */ + it("migrates a second registered rootDir-only project after the first project has rows", async () => { + rootDir = await mkdtemp(join(tmpdir(), "startup-factory-two-projects-")); + dbName = uniqueDbName(); + adminExec(`CREATE DATABASE "${dbName}"`); + const testUrl = `${PG_TEST_URL_BASE}/${dbName}`; + const projectA = join(rootDir, "project-a"); + const projectB = join(rootDir, "project-b"); + const globalDir = join(rootDir, "global"); + seedLegacyTask(projectA, "A-1", "Project A task"); + seedLegacyTask(projectB, "B-1", "Project B task"); + seedLegacyRegistry(globalDir, [ + { id: "project-a", path: projectA }, + { id: "project-b", path: projectB }, + ]); + + const first = await createTaskStoreForBackend({ rootDir: projectA, globalSettingsDir: globalDir, env: { DATABASE_URL: testUrl } }); + expect(first).not.toBeNull(); + await first!.shutdown(); + + const second = await createTaskStoreForBackend({ rootDir: projectB, globalSettingsDir: globalDir, env: { DATABASE_URL: testUrl } }); + expect(second).not.toBeNull(); + try { + expect(second!.taskStore.getAsyncLayer()!.projectId).toBe("project-b"); + expect((await second!.taskStore.getTask("B-1")).title).toBe("Project B task"); + await expect(second!.taskStore.getTask("A-1")).rejects.toThrow(); + } finally { + await second!.shutdown(); + } + }); + + /* + FNXC:MultiProjectMigration 2026-07-13-22:37: + Legacy task IDs remain globally unique in the PostgreSQL schema. When two projects contain the same ID, the second migration must diagnose the collision through project-scoped verification and abort before stamping or recording success; it must never silently attribute project A's row to project B. + */ + it("fails closed when a second project's legacy task id collides with the first project", async () => { + rootDir = await mkdtemp(join(tmpdir(), "startup-factory-project-collision-")); + dbName = uniqueDbName(); + adminExec(`CREATE DATABASE "${dbName}"`); + const testUrl = `${PG_TEST_URL_BASE}/${dbName}`; + const projectA = join(rootDir, "project-a"); + const projectB = join(rootDir, "project-b"); + const globalDir = join(rootDir, "global"); + seedLegacyTask(projectA, "SHARED-1", "Project A owns this id"); + seedLegacyTask(projectB, "SHARED-1", "Project B collides"); + seedLegacyRegistry(globalDir, [ + { id: "project-a", path: projectA }, + { id: "project-b", path: projectB }, + ]); + + const first = await createTaskStoreForBackend({ rootDir: projectA, globalSettingsDir: globalDir, env: { DATABASE_URL: testUrl } }); + expect(first).not.toBeNull(); + await first!.shutdown(); + + await expect( + createTaskStoreForBackend({ rootDir: projectB, globalSettingsDir: globalDir, env: { DATABASE_URL: testUrl } }), + ).rejects.toThrow(/failed verification.*project\.tasks/i); + + const client = postgres(testUrl, { max: 1 }); + try { + const rows = await client<{ title: string; project_id: string | null }[]>` + SELECT title, project_id FROM project.tasks WHERE id = 'SHARED-1' + `; + expect(rows).toEqual([{ title: "Project A owns this id", project_id: "project-a" }]); + } finally { + await client.end(); + } + }); + + /* + FNXC:PostgresMigrationVerification 2026-07-13-22:37: + Verification failure is a hard startup boundary. ID collisions or unmapped operator tables may leave attempted rows behind for diagnosis, but startup must close connections and must not stamp rows or persist the successful-migration notice. + */ + it("fails closed without a success notice when migration verification fails", async () => { + rootDir = await mkdtemp(join(tmpdir(), "startup-factory-fail-closed-")); + dbName = uniqueDbName(); + adminExec(`CREATE DATABASE "${dbName}"`); + const testUrl = `${PG_TEST_URL_BASE}/${dbName}`; + seedLegacyTask(rootDir, "FAIL-1", "Must not be announced as migrated"); + const legacy = new DatabaseSync(join(rootDir, ".fusion", "fusion.db")); + try { + legacy.exec(`CREATE TABLE operator_extension_data (id TEXT PRIMARY KEY, payload TEXT NOT NULL)`); + legacy.prepare(`INSERT INTO operator_extension_data VALUES (?, ?)`).run("opaque-1", "preserve me"); + } finally { + legacy.close(); + } + + await expect( + createTaskStoreForBackend({ rootDir, env: { DATABASE_URL: testUrl } }), + ).rejects.toThrow(/failed verification.*operator_extension_data/i); + + const client = postgres(testUrl, { max: 1 }); + try { + const configs = await client<{ settings: unknown }[]>`SELECT settings FROM project.config`; + expect(configs.some((row) => JSON.stringify(row.settings).includes("sqliteMigrationNotice"))).toBe(false); + const tasks = await client<{ project_id: string | null }[]>`SELECT project_id FROM project.tasks WHERE id = 'FAIL-1'`; + expect(tasks[0]?.project_id ?? null).toBeNull(); + } finally { + await client.end(); + } + + /* + * FNXC:PostgresMigration 2026-07-14-00:05: + * The first attempt copied its task before the unmapped table failed. + * Removing the source defect and rebooting must resume from the durable + * incomplete marker even though PostgreSQL is no longer empty. + */ + const repairedLegacy = new DatabaseSync(join(rootDir, ".fusion", "fusion.db")); + try { + repairedLegacy.exec("DROP TABLE operator_extension_data"); + } finally { + repairedLegacy.close(); + } + const retried = await createTaskStoreForBackend({ rootDir, env: { DATABASE_URL: testUrl } }); + expect(retried).not.toBeNull(); + try { + expect((await retried!.taskStore.getTask("FAIL-1")).title).toBe("Must not be announced as migrated"); + } finally { + await retried!.shutdown(); + } + const verifyClient = postgres(testUrl, { max: 1 }); + try { + const states = await verifyClient<{ status: string }[]>` + SELECT status FROM public.fusion_sqlite_migrations WHERE migration_key = ${`project:${rootDir}`} + `; + expect(states).toEqual([{ status: "complete" }]); + } finally { + await verifyClient.end(); + } + }); }); diff --git a/packages/core/src/__tests__/postgres/taskstore-remaining.test.ts b/packages/core/src/__tests__/postgres/taskstore-remaining.test.ts index 01961dddc7..13ae4cff89 100644 --- a/packages/core/src/__tests__/postgres/taskstore-remaining.test.ts +++ b/packages/core/src/__tests__/postgres/taskstore-remaining.test.ts @@ -367,7 +367,7 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => { ctx = await setupCtx(); await insertTaskRow(ctx.layer, makeMinimalTask("KB-ACT"), { lineageId: null }); - await recordActivityLogEntry(ctx.layer.db, { + await recordActivityLogEntry(ctx.layer.db, ctx.layer.projectId ?? "", { type: "task:moved", taskId: "KB-ACT", taskTitle: "Test Task", @@ -375,7 +375,7 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => { metadata: { from: "todo", to: "in-progress" }, }); - const entries = await getActivityLog(ctx.layer.db, { type: "task:moved" }); + const entries = await getActivityLog(ctx.layer.db, ctx.layer.projectId ?? "", { type: "task:moved" }); expect(entries).toHaveLength(1); expect(entries[0]?.taskId).toBe("KB-ACT"); expect(entries[0]?.metadata).toEqual({ from: "todo", to: "in-progress" }); @@ -615,7 +615,7 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => { it("usage events round-trip (emit + query)", async () => { ctx = await setupCtx(); - const inserted = await emitUsageEvent(ctx.layer.db, { + const inserted = await emitUsageEvent(ctx.layer.db, ctx.layer.projectId ?? "", { kind: "tool_call", taskId: "KB-USAGE", agentId: "agent-1", @@ -625,7 +625,7 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => { }); expect(inserted).toBe(true); - const events = await queryUsageEvents(ctx.layer.db, { taskId: "KB-USAGE" }); + const events = await queryUsageEvents(ctx.layer.db, ctx.layer.projectId ?? "", { taskId: "KB-USAGE" }); expect(events).toHaveLength(1); expect(events[0]?.toolName).toBe("edit"); expect(events[0]?.meta).toEqual({ duration: 42 }); @@ -633,7 +633,7 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => { it("usage events fail-soft on unknown kind", async () => { ctx = await setupCtx(); - const inserted = await emitUsageEvent(ctx.layer.db, { + const inserted = await emitUsageEvent(ctx.layer.db, ctx.layer.projectId ?? "", { // @ts-expect-error — intentionally invalid kind kind: "bogus_kind", }); diff --git a/packages/core/src/__tests__/postgres/todo-store.pg.test.ts b/packages/core/src/__tests__/postgres/todo-store.pg.test.ts index d01dedf825..4164faa039 100644 --- a/packages/core/src/__tests__/postgres/todo-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/todo-store.pg.test.ts @@ -9,7 +9,7 @@ * and list-with-items grouping. Runs in the blocking gate (test:pg-gate). */ -import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest"; import { pgDescribe, @@ -76,4 +76,83 @@ pgTest("TodoStore (PostgreSQL backend mode)", () => { it("createItem rejects a missing list with a clear error (parity with sync store)", async () => { await expect(todo().createItem("TDL-DOES-NOT-EXIST", { text: "x" })).rejects.toThrow(/not found/); }); + + /** + * FNXC:PostgresMigrationCoverage 2026-07-13-22:54: + * PostgreSQL stores every project's todo lists in one physical table, so list reads must retain the former per-project SQLite-file isolation for empty and populated projects. + */ + it("does not expose another project's lists or items", async () => { + const t = todo(); + const alpha = await t.createList("project-alpha", { title: "Alpha" }); + const beta = await t.createList("project-beta", { title: "Beta" }); + await t.createItem(alpha.id, { text: "alpha-only" }); + await t.createItem(beta.id, { text: "beta-only" }); + + expect((await t.getListsWithItems("project-empty"))).toEqual([]); + expect((await t.getListsWithItems("project-alpha")).map((list) => list.title)).toEqual(["Alpha"]); + expect((await t.getListsWithItems("project-alpha"))[0]?.items.map((item) => item.text)).toEqual(["alpha-only"]); + expect((await t.getListsWithItems("project-beta"))[0]?.items.map((item) => item.text)).toEqual(["beta-only"]); + }); + + /** + * FNXC:PostgresMigrationCoverage 2026-07-13-22:54: + * Todo mutations must retain the exact SQLite EventEmitter contract because dashboard SSE subscribers use these payloads to refresh lists and items without polling. + */ + it("emits the sync TodoStore event names and payloads after successful mutations", async () => { + const t = todo(); + const onListCreated = vi.fn(); + const onListUpdated = vi.fn(); + const onListDeleted = vi.fn(); + const onItemCreated = vi.fn(); + const onItemUpdated = vi.fn(); + const onItemDeleted = vi.fn(); + const onItemsReordered = vi.fn(); + t.on("list:created", onListCreated); + t.on("list:updated", onListUpdated); + t.on("list:deleted", onListDeleted); + t.on("item:created", onItemCreated); + t.on("item:updated", onItemUpdated); + t.on("item:deleted", onItemDeleted); + t.on("items:reordered", onItemsReordered); + + const list = await t.createList("project-events", { title: "Before" }); + expect(onListCreated).toHaveBeenCalledWith(list); + const updatedList = await t.updateList(list.id, { title: "After" }); + expect(onListUpdated).toHaveBeenCalledWith(updatedList); + + const first = await t.createItem(list.id, { text: "first" }); + const second = await t.createItem(list.id, { text: "second" }); + expect(onItemCreated).toHaveBeenNthCalledWith(1, first); + expect(onItemCreated).toHaveBeenNthCalledWith(2, second); + const updatedItem = await t.updateItem(first.id, { completed: true }); + expect(onItemUpdated).toHaveBeenCalledWith(updatedItem); + + const reordered = await t.reorderItems(list.id, [second.id, first.id]); + expect(onItemsReordered).toHaveBeenCalledWith({ listId: list.id, items: reordered }); + expect(await t.deleteItem(first.id)).toBe(true); + expect(onItemDeleted).toHaveBeenCalledWith(first.id); + expect(await t.deleteList(list.id)).toBe(true); + expect(onListDeleted).toHaveBeenCalledWith(list.id); + }); + + it("does not emit update or delete events for missing records", async () => { + const t = todo(); + const onListUpdated = vi.fn(); + const onListDeleted = vi.fn(); + const onItemUpdated = vi.fn(); + const onItemDeleted = vi.fn(); + t.on("list:updated", onListUpdated); + t.on("list:deleted", onListDeleted); + t.on("item:updated", onItemUpdated); + t.on("item:deleted", onItemDeleted); + + expect(await t.updateList("TDL-MISSING", { title: "x" })).toBeUndefined(); + expect(await t.deleteList("TDL-MISSING")).toBe(false); + expect(await t.updateItem("TDI-MISSING", { text: "x" })).toBeUndefined(); + expect(await t.deleteItem("TDI-MISSING")).toBe(false); + expect(onListUpdated).not.toHaveBeenCalled(); + expect(onListDeleted).not.toHaveBeenCalled(); + expect(onItemUpdated).not.toHaveBeenCalled(); + expect(onItemDeleted).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/__tests__/project-isolation-transition.test.ts b/packages/core/src/__tests__/project-isolation-transition.test.ts deleted file mode 100644 index 68316ea588..0000000000 --- a/packages/core/src/__tests__/project-isolation-transition.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { mkdirSync, mkdtempSync } from "node:fs"; -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { CentralCore } from "../central-core.js"; - -describe("CentralCore.transitionProjectIsolation", () => { - let tempDir: string; - let projectPath: string; - let core: CentralCore; - - beforeEach(async () => { - tempDir = mkdtempSync(join(tmpdir(), "fn-project-isolation-transition-")); - projectPath = join(tempDir, "project"); - mkdirSync(projectPath, { recursive: true }); - core = new CentralCore(tempDir); - await core.init(); - }); - - afterEach(async () => { - await core.close(); - await rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - }); - - it("returns noop when next mode matches existing mode", async () => { - const project = await core.registerProject({ - name: "Test", - path: projectPath, - isolationMode: "in-process", - }); - - const result = await core.transitionProjectIsolation(project.id, "in-process"); - expect(result).toEqual({ ok: false, reason: "noop" }); - }); - - it("updates mode and logs activity on success", async () => { - const project = await core.registerProject({ - name: "Test", - path: projectPath, - isolationMode: "in-process", - }); - - const result = await core.transitionProjectIsolation(project.id, "child-process"); - expect(result).toEqual({ ok: true }); - - const updated = await core.getProject(project.id); - expect(updated?.isolationMode).toBe("child-process"); - - const activity = await core.getRecentActivity({ projectId: project.id, limit: 10 }); - expect(activity.some((entry) => entry.type === "project:isolation-transition")).toBe(true); - }); -}); diff --git a/packages/core/src/__tests__/secrets-store.test.ts b/packages/core/src/__tests__/secrets-store.test.ts deleted file mode 100644 index b99d834aa4..0000000000 --- a/packages/core/src/__tests__/secrets-store.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createTestProject } from "./test-project.js"; -import { CentralCore } from "../central-core.js"; -import { MasterKeyManager } from "../master-key.js"; -import { SecretsStore } from "../secrets-store.js"; - -async function createSecretsStore(auditEmitter?: (event: any) => void) { - const fixture = await createTestProject(); - const central = new CentralCore(fixture.globalDir); - await central.init(); - const centralDb = (central as unknown as { db: import("../central-db.js").CentralDatabase | null }).db; - if (!centralDb) throw new Error("central db unavailable"); - const masterKeyManager = new MasterKeyManager({ globalDir: fixture.globalDir }); - const store = new SecretsStore(fixture.store.getDatabase(), centralDb, () => masterKeyManager.getOrCreateKey(), { auditEmitter }); - return { fixture, store }; -} - -describe("SecretsStore audit emitter", () => { - const emitter = vi.fn(); - - beforeEach(() => { - emitter.mockReset(); - }); - - it("emits create/update/delete/read without secret values", async () => { - const { fixture, store } = await createSecretsStore(emitter); - try { - const created = await store.createSecret({ scope: "project", key: "API_KEY", plaintextValue: "secret-a" }); - await store.updateSecret(created.id, "project", { plaintextValue: "secret-b", key: "API_KEY_2" }); - await store.revealSecret(created.id, "project", { agentId: "agent-1" }); - await store.deleteSecret(created.id, "project"); - - expect(emitter).toHaveBeenCalledTimes(4); - for (const event of emitter.mock.calls.map((call) => call[0])) { - expect(event).toHaveProperty("key"); - expect(event).toHaveProperty("scope"); - expect(event).not.toHaveProperty("plaintextValue"); - expect(event).not.toHaveProperty("value"); - expect(event).not.toHaveProperty("ciphertext"); - expect(event).not.toHaveProperty("nonce"); - } - expect(emitter.mock.calls[2][0]).toMatchObject({ mutationType: "secret:read", actor: { agentId: "agent-1" } }); - } finally { - await fixture.cleanup(); - } - }); - - it("swallows emitter exceptions", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); - const { fixture, store } = await createSecretsStore(() => { - throw new Error("boom"); - }); - try { - await expect(store.createSecret({ scope: "project", key: "API_KEY", plaintextValue: "secret-a" })).resolves.toBeTruthy(); - } finally { - warnSpy.mockRestore(); - await fixture.cleanup(); - } - }); -}); - -describe("SecretsStore.listEnvExportable", () => { - it("returns empty for empty store", async () => { - const { fixture, store } = await createSecretsStore(); - try { - await expect(store.listEnvExportable()).resolves.toEqual([]); - } finally { - await fixture.cleanup(); - } - }); - - it("filters exportables, applies keyPrefix, prefers project collisions, and skips decrypt failures", async () => { - const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => undefined); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); - const { fixture, store } = await createSecretsStore(); - try { - const project = await store.createSecret({ - scope: "project", - key: "STRIPE_PROJECT", - plaintextValue: "project-value", - envExportable: true, - envExportKey: "STRIPE_KEY", - }); - await store.createSecret({ - scope: "global", - key: "STRIPE_GLOBAL", - plaintextValue: "global-value", - envExportable: true, - envExportKey: "STRIPE_KEY", - }); - await store.createSecret({ - scope: "project", - key: "PLAIN", - plaintextValue: "plain-value", - envExportable: true, - }); - const broken = await store.createSecret({ - scope: "project", - key: "STRIPE_BROKEN", - plaintextValue: "broken", - envExportable: true, - }); - await store.createSecret({ - scope: "project", - key: "HIDDEN", - plaintextValue: "hidden", - envExportable: false, - }); - - fixture.store.getDatabase().prepare("UPDATE secrets SET value_ciphertext = ? WHERE id = ?").run(Buffer.from("bad"), broken.id); - - const all = await store.listEnvExportable(); - expect(all.map((item) => item.exportKey).sort()).toEqual(["PLAIN", "STRIPE_KEY"]); - expect(all.find((item) => item.exportKey === "STRIPE_KEY")?.id).toBe(project.id); - expect(all.find((item) => item.exportKey === "STRIPE_KEY")?.scope).toBe("project"); - - const prefixed = await store.listEnvExportable({ keyPrefix: "STRIPE_" }); - expect(prefixed.map((item) => item.exportKey)).toEqual(["STRIPE_KEY"]); - - expect(debugSpy).toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalled(); - } finally { - debugSpy.mockRestore(); - warnSpy.mockRestore(); - await fixture.cleanup(); - } - }); -}); diff --git a/packages/core/src/__tests__/secrets-sync-passphrase.test.ts b/packages/core/src/__tests__/secrets-sync-passphrase.test.ts deleted file mode 100644 index 620692c842..0000000000 --- a/packages/core/src/__tests__/secrets-sync-passphrase.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createTestProject } from "./test-project.js"; -import { - RESERVED_SYNC_PASSPHRASE_KEY, - clearSyncPassphrase, - getSyncPassphrase, - hasSyncPassphraseConfigured, - setSyncPassphrase, -} from "../secrets-sync-passphrase.js"; -import { wrapSecretsBundle } from "../secrets-sync.js"; -import { CentralCore } from "../central-core.js"; -import { MasterKeyManager } from "../master-key.js"; -import { SecretsStore, type SecretRecord } from "../secrets-store.js"; - -async function createSecretsStore(fixture: Awaited>): Promise { - const central = new CentralCore(fixture.globalDir); - await central.init(); - const centralDb = (central as unknown as { db: import("../central-db.js").CentralDatabase | null }).db; - if (!centralDb) { - throw new Error("central db unavailable"); - } - const masterKeyManager = new MasterKeyManager({ globalDir: fixture.globalDir }); - return new SecretsStore(fixture.store.getDatabase(), centralDb, () => masterKeyManager.getOrCreateKey()); -} - -describe("secrets-sync-passphrase", () => { - it("set/get roundtrips and clear resets to null", async () => { - const fixture = await createTestProject(); - try { - const secrets = await createSecretsStore(fixture); - await setSyncPassphrase(secrets, "pass-1"); - expect(await getSyncPassphrase(secrets)).toBe("pass-1"); - - await clearSyncPassphrase(secrets); - expect(await getSyncPassphrase(secrets)).toBeNull(); - } finally { - await fixture.cleanup(); - } - }); - - it("rejects empty or whitespace passphrases", async () => { - const fixture = await createTestProject(); - try { - const secrets = await createSecretsStore(fixture); - await expect(setSyncPassphrase(secrets, "")).rejects.toThrow("Sync passphrase must be a non-empty string"); - await expect(setSyncPassphrase(secrets, " ")).rejects.toThrow("Sync passphrase must be a non-empty string"); - } finally { - await fixture.cleanup(); - } - }); - - it("re-setting overwrites existing passphrase", async () => { - const fixture = await createTestProject(); - try { - const secrets = await createSecretsStore(fixture); - await setSyncPassphrase(secrets, "first"); - await setSyncPassphrase(secrets, "second"); - expect(await getSyncPassphrase(secrets)).toBe("second"); - expect((await secrets.listSecrets("global")).filter((record) => record.key === RESERVED_SYNC_PASSPHRASE_KEY)).toHaveLength(1); - } finally { - await fixture.cleanup(); - } - }); - - it("hasSyncPassphraseConfigured flips false -> true -> false", async () => { - const fixture = await createTestProject(); - try { - const secrets = await createSecretsStore(fixture); - expect(await hasSyncPassphraseConfigured(secrets)).toBe(false); - await setSyncPassphrase(secrets, "ready"); - expect(await hasSyncPassphraseConfigured(secrets)).toBe(true); - await clearSyncPassphrase(secrets); - expect(await hasSyncPassphraseConfigured(secrets)).toBe(false); - } finally { - await fixture.cleanup(); - } - }); - - it("stores reserved row with deny access and non-exportable flags", async () => { - const fixture = await createTestProject(); - try { - const secrets = await createSecretsStore(fixture); - await setSyncPassphrase(secrets, "policy-check"); - const row = (await secrets.listSecrets("global")).find((record) => record.key === RESERVED_SYNC_PASSPHRASE_KEY); - expect(row).toBeTruthy(); - expect(row?.accessPolicy).toBe("deny"); - expect(row?.envExportable).toBe(false); - } finally { - await fixture.cleanup(); - } - }); - - it("reserved passphrase row is filtered from wrapped bundles", async () => { - const fixture = await createTestProject(); - try { - const secrets = await createSecretsStore(fixture); - await setSyncPassphrase(secrets, "shared"); - await secrets.createSecret({ - scope: "global", - key: "REAL_SECRET", - plaintextValue: "value", - }); - - const passphrase = await getSyncPassphrase(secrets); - const records = [] as Array<{ key: string; value: string; scope: SecretRecord["scope"]; description?: string | null; accessPolicy: SecretRecord["accessPolicy"]; envExportable: boolean; envExportKey?: string | null }>; - for (const record of await secrets.listSecrets()) { - if (record.key === RESERVED_SYNC_PASSPHRASE_KEY) { - continue; - } - const revealed = await secrets.revealSecret(record.id, record.scope, { agentId: null, userId: null }); - records.push({ - key: record.key, - value: revealed.plaintextValue, - scope: record.scope, - description: record.description, - accessPolicy: record.accessPolicy, - envExportable: record.envExportable, - envExportKey: record.envExportKey, - }); - } - - const envelope = await wrapSecretsBundle(records, passphrase!); - expect(JSON.stringify(envelope)).not.toContain(RESERVED_SYNC_PASSPHRASE_KEY); - expect(records.map((record) => record.key)).toEqual(["REAL_SECRET"]); - } finally { - await fixture.cleanup(); - } - }); -}); diff --git a/packages/core/src/__tests__/store-activity.test.ts b/packages/core/src/__tests__/store-activity.test.ts deleted file mode 100644 index 2d793dae0f..0000000000 --- a/packages/core/src/__tests__/store-activity.test.ts +++ /dev/null @@ -1,878 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; - -import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises"; -import { join } from "node:path"; -import { existsSync } from "node:fs"; -import * as projectMemory from "../project-memory.js"; -import { AgentStore } from "../agent-store.js"; -import { CentralDatabase } from "../central-db.js"; -import { TaskStore, TaskHasDependentsError } from "../store.js"; -import { buildResearchDocumentKey, type Task } from "../types.js"; -import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js"; - -describe("TaskStore", () => { - const harness = createTaskStoreTestHarness(); - let rootDir: string; - let globalDir: string; - let store: TaskStore; - - beforeEach(async () => { - await harness.beforeEach(); - rootDir = harness.rootDir(); - globalDir = harness.globalDir(); - store = harness.store(); - }); - - afterEach(async () => { - await harness.afterEach(); - }); - - const createTestTask = () => harness.createTestTask(); - const createTaskWithSteps = () => harness.createTaskWithSteps(); - const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId); - const createSourceIssueFixture = () => harness.createSourceIssueFixture(); - const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args); - - describe("activity log", () => { - it("recordActivity appends to log file", async () => { - await store.recordActivity({ type: "task:created", taskId: "FN-001", taskTitle: "Test", details: "Created" }); - const logs = await store.getActivityLog(); - expect(logs).toHaveLength(1); - expect(logs[0].type).toBe("task:created"); - expect(logs[0].id).toBeDefined(); - expect(logs[0].timestamp).toBeDefined(); - }); - - it("recordActivity logs failures and stays best-effort", async () => { - const storeAny = store as any; - const originalPrepare = storeAny.db.prepare.bind(storeAny.db); - const prepareSpy = vi.spyOn(storeAny.db, "prepare").mockImplementation((sql: string) => { - if (sql.includes("INSERT INTO activityLog")) { - return { - run: () => { - throw new Error("activity insert failed"); - }, - }; - } - return originalPrepare(sql); - }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - await expect( - store.recordActivity({ - type: "task:created", - taskId: "FN-404", - taskTitle: "Resilient record", - details: "Create event", - metadata: { source: "test" }, - }), - ).resolves.toMatchObject({ - type: "task:created", - taskId: "FN-404", - taskTitle: "Resilient record", - details: "Create event", - }); - - const failureCall = errorSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("[task-store] Failed to record activity"), - ); - expect(failureCall).toBeDefined(); - const [, context] = failureCall as [string, Record]; - expect(context).toMatchObject({ - type: "task:created", - taskId: "FN-404", - taskTitle: "Resilient record", - detailsLength: "Create event".length, - hasMetadata: true, - error: "activity insert failed", - }); - } finally { - prepareSpy.mockRestore(); - errorSpy.mockRestore(); - } - }); - - it("logs listener-level activity recording failures without throwing", async () => { - const task = await store.createTask({ description: "Listener test" }); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const recordSpy = vi.spyOn(store, "recordActivity").mockRejectedValue(new Error("listener rejected")); - - try { - expect(() => { - store.emit("task:created", task); - }).not.toThrow(); - - await Promise.resolve(); - - const warningCall = warnSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("[task-store] Activity logging listener failed"), - ); - expect(warningCall).toBeDefined(); - - const [, context] = warningCall as [string, Record]; - expect(context).toMatchObject({ - sourceEvent: "task:created", - type: "task:created", - taskId: task.id, - error: "listener rejected", - }); - } finally { - recordSpy.mockRestore(); - warnSpy.mockRestore(); - } - }); - - it("getActivityLog returns entries newest first", async () => { - await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "First" }); - await new Promise((r) => setTimeout(r, 10)); - await store.recordActivity({ type: "task:created", taskId: "FN-002", details: "Second" }); - const logs = await store.getActivityLog(); - expect(logs[0].taskId).toBe("FN-002"); - expect(logs[1].taskId).toBe("FN-001"); - }); - - it("getActivityLog respects limit", async () => { - await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "First" }); - await new Promise((r) => setTimeout(r, 10)); - await store.recordActivity({ type: "task:created", taskId: "FN-002", details: "Second" }); - const logs = await store.getActivityLog({ limit: 1 }); - expect(logs).toHaveLength(1); - expect(logs[0].taskId).toBe("FN-002"); - }); - - it("getActivityLog filters by type", async () => { - await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Created" }); - await store.recordActivity({ type: "task:moved", taskId: "FN-001", details: "Moved" }); - const logs = await store.getActivityLog({ type: "task:created" }); - expect(logs).toHaveLength(1); - expect(logs[0].type).toBe("task:created"); - }); - - it("getActivityLog filters by since timestamp", async () => { - const first = await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Created" }); - await new Promise((r) => setTimeout(r, 50)); - const second = await store.recordActivity({ type: "task:created", taskId: "FN-002", details: "Created later" }); - - // Filter for entries strictly after the first one (should return only second) - const logs = await store.getActivityLog({ since: first.timestamp }); - expect(logs).toHaveLength(1); - expect(logs[0].taskId).toBe("FN-002"); - - // Filter for entries strictly after a time before the first one (should return both) - const beforeFirst = new Date(new Date(first.timestamp).getTime() - 100).toISOString(); - const allLogs = await store.getActivityLog({ since: beforeFirst }); - expect(allLogs).toHaveLength(2); - }); - - it("clearActivityLog removes all entries", async () => { - await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Test" }); - await store.clearActivityLog(); - const logs = await store.getActivityLog(); - expect(logs).toHaveLength(0); - }); - - it("handles missing log file gracefully", async () => { - const logs = await store.getActivityLog(); - expect(logs).toHaveLength(0); - }); - - it("recordActivity includes metadata when provided", async () => { - await store.recordActivity({ - type: "task:moved", - taskId: "FN-001", - taskTitle: "Test Task", - details: "Moved to in-progress", - metadata: { from: "todo", to: "in-progress" }, - }); - const logs = await store.getActivityLog(); - expect(logs[0].metadata).toEqual({ from: "todo", to: "in-progress" }); - expect(logs[0].taskTitle).toBe("Test Task"); - }); - - it("activity log survives TaskStore reinitialization", async () => { - // Cross-instance persistence test — see archive-log counterpart - // above for the in-memory carve-out rationale. - store.close(); - store = new TaskStore(rootDir, globalDir); - await store.init(); - - await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Test" }); - - // Create new store instance - const newStore = new TaskStore(rootDir, globalDir); - await newStore.init(); - - const logs = await newStore.getActivityLog(); - expect(logs).toHaveLength(1); - expect(logs[0].taskId).toBe("FN-001"); - newStore.close(); - }); - }); - - // ── Activity Log Event Listener Tests ──────────────────────────── - - - describe("activity log event listeners", () => { - it("records activity on task:created", async () => { - const task = await store.createTask({ description: "Test created event" }); - // Wait for async activity recording - await new Promise((r) => setTimeout(r, 10)); - const logs = await store.getActivityLog({ type: "task:created" }); - expect(logs.length).toBeGreaterThanOrEqual(1); - expect(logs[0].taskId).toBe(task.id); - expect(logs[0].type).toBe("task:created"); - }); - - it("records activity on task:moved", async () => { - const task = await store.createTask({ description: "Test moved event" }); - await store.moveTask(task.id, "todo"); - // Wait for async activity recording - await new Promise((r) => setTimeout(r, 10)); - const logs = await store.getActivityLog({ type: "task:moved" }); - expect(logs.length).toBeGreaterThanOrEqual(1); - expect(logs[0].taskId).toBe(task.id); - expect(logs[0].type).toBe("task:moved"); - expect(logs[0].metadata).toHaveProperty("from"); - expect(logs[0].metadata).toHaveProperty("to"); - }); - - it("records activity when task status becomes failed", async () => { - const task = await store.createTask({ description: "Test failure event" }); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); - await store.updateTask(task.id, { status: "failed", error: "Something went wrong" }); - // Wait for async activity recording - await new Promise((r) => setTimeout(r, 10)); - const logs = await store.getActivityLog({ type: "task:failed" }); - expect(logs.length).toBeGreaterThanOrEqual(1); - expect(logs[0].taskId).toBe(task.id); - expect(logs[0].type).toBe("task:failed"); - }); - - it("records activity on settings:updated for important changes", async () => { - // ntfyEnabled/ntfyTopic are now global settings, use updateGlobalSettings - await store.updateGlobalSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); - // Wait for async activity recording - await new Promise((r) => setTimeout(r, 10)); - const logs = await store.getActivityLog({ type: "settings:updated" }); - expect(logs.length).toBeGreaterThanOrEqual(1); - expect(logs[0].type).toBe("settings:updated"); - }); - - it("a second TaskStore polling the same DB does not double-log activity", async () => { - // Reproduces the duplicate-emitter bug: dashboard + engine each construct - // their own TaskStore against the same SQLite file. Without suppression, - // every move was recorded once per polling instance. - const observer = new TaskStore(rootDir, globalDir); - await observer.init(); - await observer.watch(); - try { - const task = await store.createTask({ description: "Test polling dedup" }); - await store.moveTask(task.id, "todo"); - // Drive the observer's poll cycle directly so we don't wait 1s. - await (observer as any).checkForChanges(); - await new Promise((r) => setTimeout(r, 10)); - - const movedLogs = await store.getActivityLog({ type: "task:moved" }); - const moves = movedLogs.filter((l) => l.taskId === task.id); - expect(moves).toHaveLength(1); - - const createdLogs = await store.getActivityLog({ type: "task:created" }); - const creates = createdLogs.filter((l) => l.taskId === task.id); - expect(creates).toHaveLength(1); - } finally { - await observer.close(); - } - }); - - it("records activity on task:deleted", async () => { - const task = await store.createTask({ description: "Test deleted event" }); - await store.deleteTask(task.id); - // Wait for async activity recording - await new Promise((r) => setTimeout(r, 10)); - const logs = await store.getActivityLog({ type: "task:deleted" }); - expect(logs.length).toBeGreaterThanOrEqual(1); - expect(logs[0].taskId).toBe(task.id); - expect(logs[0].type).toBe("task:deleted"); - }); - - it("captures merge details when merging a task", async () => { - const task = await store.createTask({ description: "Test merge details" }); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); - await store.moveTask(task.id, "in-review"); - await store.updateTask(task.id, { - worktree: "/tmp/test-worktree", - }); - - const { execSync } = await import("node:child_process"); - try { - execSync(`git checkout -b fusion/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" }); - execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" }); - execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" }); - } catch { - return; - } - - try { - const result = await store.mergeTask(task.id); - expect(result.mergeConfirmed ?? result.merged).toBeDefined(); - expect(result.task.mergeDetails).toBeDefined(); - if (result.merged) { - expect(result.task.mergeDetails?.commitSha).toBeTruthy(); - expect(result.task.mergeDetails?.mergeCommitMessage).toContain(task.id); - expect(result.task.mergeDetails?.mergedAt).toBeDefined(); - } - } catch { - // merge may fail depending on repo state; skip strict assertions in that case - } - }); - - it("records activity on task:merged", async () => { - const task = await store.createTask({ description: "Test merged event" }); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); - await store.moveTask(task.id, "in-review"); - // Manually set worktree for merge - await store.updateTask(task.id, { worktree: "/tmp/test-worktree" }); - - // Create branch for merge - const { execSync } = await import("node:child_process"); - try { - execSync(`git checkout -b fusion/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" }); - execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" }); - execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" }); - } catch { - // Branch may already exist or no main/master, skip merge test - } - - try { - await store.mergeTask(task.id); - } catch { - // Merge may fail due to branch setup, that's ok for activity log test - } - - // Wait for async activity recording - await new Promise((r) => setTimeout(r, 10)); - const logs = await store.getActivityLog({ type: "task:merged" }); - // We check if the merge was attempted (logs may exist even if merge failed) - // The key is that the event listener was called - }); - - it("does not record activity for non-failure task updates", async () => { - const task = await store.createTask({ description: "Test non-failure update" }); - await store.moveTask(task.id, "todo"); - await store.updateTask(task.id, { status: "in-progress" }); - // Wait for any async activity recording - await new Promise((r) => setTimeout(r, 10)); - - // Get all failed logs - should not include this task - const failedLogs = await store.getActivityLog({ type: "task:failed" }); - const taskFailedLogs = failedLogs.filter((l) => l.taskId === task.id); - expect(taskFailedLogs).toHaveLength(0); - }); - }); - - // ── Workflow Steps ───────────────────────────────────────────────── - - - - describe("event emissions", () => { - it("createTask emits task:created with the new task", async () => { - const events: any[] = []; - store.on("task:created", (t: any) => events.push(t)); - const task = await store.createTask({ description: "event test" }); - expect(events).toHaveLength(1); - expect(events[0].id).toBe(task.id); - expect(events[0].description).toBe("event test"); - }); - - it("moveTask emits task:moved with from/to columns and source", async () => { - const task = await createTestTask(); - const events: any[] = []; - store.on("task:moved", (data: any) => events.push(data)); - await store.moveTask(task.id, "todo", { moveSource: "user" }); - expect(events).toHaveLength(1); - expect(events[0].from).toBe("triage"); - expect(events[0].to).toBe("todo"); - expect(events[0].source).toBe("user"); - expect(events[0].task.id).toBe(task.id); - - await store.moveTask(task.id, "in-progress"); - expect(events.at(-1)?.source).toBe("engine"); - }); - - it("updateTask emits task:updated with the updated task", async () => { - const task = await createTestTask(); - const events: any[] = []; - store.on("task:updated", (t: any) => events.push(t)); - await store.updateTask(task.id, { title: "Updated" }); - expect(events.length).toBeGreaterThanOrEqual(1); - expect(events.some((e: any) => e.title === "Updated")).toBe(true); - }); - - it("pauseTask emits task:updated", async () => { - const task = await createTestTask(); - await store.moveTask(task.id, "todo"); - const events: any[] = []; - store.on("task:updated", (t: any) => events.push(t)); - await store.pauseTask(task.id, true); - expect(events.length).toBeGreaterThanOrEqual(1); - expect(events.some((e: any) => e.paused === true)).toBe(true); - }); - - it("updateStep emits task:updated", async () => { - const task = await createTaskWithSteps(); - await store.moveTask(task.id, "todo"); - const events: any[] = []; - store.on("task:updated", (t: any) => events.push(t)); - await store.updateStep(task.id, 0, "in-progress"); - expect(events.length).toBeGreaterThanOrEqual(1); - }); - - it("deleteTask emits task:deleted", async () => { - const task = await createTestTask(); - const events: any[] = []; - store.on("task:deleted", (t: any) => events.push(t)); - await store.deleteTask(task.id); - expect(events).toHaveLength(1); - expect(events[0].id).toBe(task.id); - }); - - it("logEntry emits task:updated", async () => { - const task = await createTestTask(); - const events: any[] = []; - store.on("task:updated", (t: any) => events.push(t)); - await store.logEntry(task.id, "test action", "test outcome"); - expect(events.length).toBeGreaterThanOrEqual(1); - }); - - }); - - - describe("userPaused move semantics", () => { - it("sets userPaused for user move to todo and clears on in-progress", async () => { - const task = await createTestTask(); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); - - await store.moveTask(task.id, "todo", { moveSource: "user" }); - const parked = await store.getTask(task.id); - expect(parked.userPaused).toBe(true); - - await store.moveTask(task.id, "in-progress"); - const resumed = await store.getTask(task.id); - expect(resumed.userPaused).toBeUndefined(); - }); - - it("does not set userPaused for engine preserve-resume move", async () => { - const task = await createTestTask(); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); - - await store.moveTask(task.id, "todo", { preserveResumeState: true }); - const bounced = await store.getTask(task.id); - expect(bounced.userPaused).toBeUndefined(); - }); - - it("clears userPaused when pauseTask(false) is called on a todo user-paused task", async () => { - const task = await createTestTask(); - await store.moveTask(task.id, "todo"); - await store.updateTask(task.id, { userPaused: true }); - - const unpaused = await store.pauseTask(task.id, false); - expect(unpaused.userPaused).toBeUndefined(); - expect(unpaused.paused).toBeUndefined(); - }); - - it("clears paused and userPaused when unpausing a paused todo task", async () => { - const task = await createTestTask(); - await store.moveTask(task.id, "todo"); - await store.updateTask(task.id, { userPaused: true, paused: true }); - - const unpaused = await store.pauseTask(task.id, false); - expect(unpaused.paused).toBeUndefined(); - expect(unpaused.userPaused).toBeUndefined(); - }); - - it("pauseTask(true) on todo does not set userPaused", async () => { - const task = await createTestTask(); - await store.moveTask(task.id, "todo"); - - const paused = await store.pauseTask(task.id, true); - expect(paused.paused).toBe(true); - expect(paused.userPaused).toBeUndefined(); - }); - }); - - describe("paused state on completion", () => { - const expectPauseFieldsCleared = (task: Awaited>) => { - expect(task.paused).toBeUndefined(); - expect(task.userPaused).toBeUndefined(); - expect(task.pausedByAgentId).toBeUndefined(); - expect(task.pausedReason).toBeUndefined(); - }; - - async function moveTaskToDone(id: string): Promise { - await store.moveTask(id, "todo"); - await store.moveTask(id, "in-progress"); - await store.moveTask(id, "in-review"); - await store.moveTask(id, "done"); - } - - it("clears pause fields on moveTask(in-review → done)", async () => { - const task = await createTestTask(); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); - await store.moveTask(task.id, "in-review"); - await store.updateTask(task.id, { - pausedByAgentId: "agent-x", - pausedReason: "manual-hold", - }); - - await store.moveTask(task.id, "done"); - const doneTask = await store.getTask(task.id); - expectPauseFieldsCleared(doneTask); - }); - - it("clears pause fields on moveTask(in-progress → done) when userPaused was set", async () => { - const task = await createTestTask(); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); - await store.updateTask(task.id, { userPaused: true }); - - await store.moveTask(task.id, "done"); - const doneTask = await store.getTask(task.id); - expectPauseFieldsCleared(doneTask); - }); - - it("clears pause fields when mergeTask handles an already-done task", async () => { - const task = await createTestTask(); - await moveTaskToDone(task.id); - await store.updateTask(task.id, { paused: true, pausedByAgentId: "agent-x" }); - - await store.mergeTask(task.id); - const doneTask = await store.getTask(task.id); - expectPauseFieldsCleared(doneTask); - }); - - it("clears pause fields on unarchiveTask transition to done", async () => { - const task = await createTestTask(); - await moveTaskToDone(task.id); - await store.updateTask(task.id, { paused: true, pausedByAgentId: "agent-x", pausedReason: "manual-hold" }); - await store.archiveTask(task.id); - - await store.unarchiveTask(task.id); - const restored = await store.getTask(task.id); - expect(restored.column).toBe("done"); - expectPauseFieldsCleared(restored); - }); - - it("remains idempotent across repeated done transitions", async () => { - const task = await createTestTask(); - await moveTaskToDone(task.id); - await store.moveTask(task.id, "archived"); - await store.updateTask(task.id, { paused: true, pausedByAgentId: "agent-x" }); - - await store.unarchiveTask(task.id); - await store.moveTask(task.id, "archived"); - await store.unarchiveTask(task.id); - const doneTask = await store.getTask(task.id); - expectPauseFieldsCleared(doneTask); - }); - }); - - describe("execution timing timestamps", () => { - it("preserves the original executionStartedAt across an internal rerun bounce", async () => { - const task = await store.createTask({ description: "retry bounce timing" }); - await store.moveTask(task.id, "todo"); - const started = await store.moveTask(task.id, "in-progress"); - const originalExecutionStartedAt = started.executionStartedAt; - - expect(originalExecutionStartedAt).toBeDefined(); - - await new Promise((r) => setTimeout(r, 10)); - - const bouncedToTodo = await store.moveTask(task.id, "todo"); - expect(bouncedToTodo.executionStartedAt).toBeUndefined(); - - await store.updateTask(task.id, { - worktree: "/tmp/retry-bounce", - executionStartedAt: originalExecutionStartedAt ?? null, - }); - - const bouncedBack = await store.moveTask(task.id, "in-progress"); - expect(bouncedBack.executionStartedAt).toBe(originalExecutionStartedAt); - }); - }); - - - describe("settings:updated event", () => { - it("fires on updateSettings with correct old and new values", async () => { - const events: { settings: any; previous: any }[] = []; - store.on("settings:updated", (data) => events.push(data)); - - await store.updateSettings({ maxConcurrent: 5 }); - - expect(events).toHaveLength(1); - expect(events[0].previous.maxConcurrent).toBe(2); // DEFAULT_SETTINGS value - expect(events[0].settings.maxConcurrent).toBe(5); - }); - - it("includes previous globalPause: false → new globalPause: true when toggled", async () => { - const events: { settings: any; previous: any }[] = []; - store.on("settings:updated", (data) => events.push(data)); - - // Default globalPause is false - await store.updateSettings({ globalPause: true }); - - expect(events).toHaveLength(1); - expect(events[0].previous.globalPause).toBe(false); - expect(events[0].settings.globalPause).toBe(true); - }); - - it("includes previous globalPause: true → new globalPause: false when toggled off", async () => { - await store.updateSettings({ globalPause: true }); - - const events: { settings: any; previous: any }[] = []; - store.on("settings:updated", (data) => events.push(data)); - - await store.updateSettings({ globalPause: false }); - - expect(events).toHaveLength(1); - expect(events[0].previous.globalPause).toBe(true); - expect(events[0].settings.globalPause).toBe(false); - }); - - it("fires on every updateSettings call even when value unchanged", async () => { - const events: { settings: any; previous: any }[] = []; - store.on("settings:updated", (data) => events.push(data)); - - await store.updateSettings({ maxConcurrent: 2 }); - await store.updateSettings({ maxConcurrent: 2 }); - - expect(events).toHaveLength(2); - }); - }); - - // ── Duplicate Task Tests ───────────────────────────────────────── - - - describe("task-store diagnostics for best-effort catch paths", () => { - it("logs init config sync failures without blocking startup", async () => { - const localRoot = makeTmpDir(); - const localGlobal = makeTmpDir(); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - let localStore: TaskStore | undefined; - - try { - localStore = new TaskStore(localRoot, localGlobal, { inMemoryDb: true }); - (localStore as any).configPath = join(localRoot, ".fusion", "missing-dir", "config.json"); - - await expect(localStore.init()).resolves.toBeUndefined(); - await expect(localStore.createTask({ description: "still boots" })).resolves.toMatchObject({ - id: "FN-001", - }); - - const warningCall = warnSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed during init"), - ); - expect(warningCall).toBeDefined(); - - const [, context] = warningCall as [string, Record]; - expect(context).toMatchObject({ - phase: "init:config-sync", - configPath: join(localRoot, ".fusion", "missing-dir", "config.json"), - }); - expect(typeof context.error).toBe("string"); - } finally { - localStore?.close(); - warnSpy.mockRestore(); - await rm(localRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - await rm(localGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - } - }); - - it("logs writeConfig disk sync failures while preserving project settings updates", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const storeAny = store as any; - const originalConfigPath = storeAny.configPath; - storeAny.configPath = join(rootDir, ".fusion", "missing-sync", "config.json"); - - try { - const updated = await store.updateSettings({ mergeStrategy: "pull-request" }); - expect(updated.mergeStrategy).toBe("pull-request"); - - const warningCall = warnSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed after config write"), - ); - expect(warningCall).toBeDefined(); - - const [, context] = warningCall as [string, Record]; - expect(context).toMatchObject({ - phase: "writeConfig:disk-sync", - configPath: join(rootDir, ".fusion", "missing-sync", "config.json"), - }); - expect(typeof context.error).toBe("string"); - - const settings = await store.getSettings(); - expect(settings.mergeStrategy).toBe("pull-request"); - } finally { - storeAny.configPath = originalConfigPath; - warnSpy.mockRestore(); - } - }); - - it("creates tasks through distributed allocation without config.json sync dependency", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const storeAny = store as any; - const originalConfigPath = storeAny.configPath; - storeAny.configPath = join(rootDir, ".fusion", "missing-sync", "config.json"); - - try { - const task = await store.createTask({ description: "allocate without config sync" }); - expect(task.id).toBe("FN-001"); - - const warningCall = warnSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("after ID allocation"), - ); - expect(warningCall).toBeUndefined(); - } finally { - storeAny.configPath = originalConfigPath; - warnSpy.mockRestore(); - } - }); - - it("logs init memory bootstrap failures without blocking startup", async () => { - const localRoot = makeTmpDir(); - const localGlobal = makeTmpDir(); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const ensureSpy = vi - .spyOn(projectMemory, "ensureMemoryFileWithBackend") - .mockRejectedValueOnce(new Error("memory backend unavailable")); - let localStore: TaskStore | undefined; - - try { - localStore = new TaskStore(localRoot, localGlobal, { inMemoryDb: true }); - await expect(localStore.init()).resolves.toBeUndefined(); - - const warningCall = warnSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("[task-store] Project-memory bootstrap failed during init"), - ); - expect(warningCall).toBeDefined(); - - const [, context] = warningCall as [string, Record]; - expect(context).toMatchObject({ - phase: "init:memory-bootstrap", - rootDir: localRoot, - error: "memory backend unavailable", - }); - expect(ensureSpy).toHaveBeenCalled(); - } finally { - localStore?.close(); - ensureSpy.mockRestore(); - warnSpy.mockRestore(); - await rm(localRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - await rm(localGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - } - }); - - it("logs memory toggle-on bootstrap failures without blocking settings updates", async () => { - await store.updateSettings({ memoryEnabled: false } as any); - - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const ensureSpy = vi - .spyOn(projectMemory, "ensureMemoryFileWithBackend") - .mockRejectedValueOnce(new Error("memory toggle write failed")); - - try { - const updated = await store.updateSettings({ memoryEnabled: true } as any); - expect(updated.memoryEnabled).toBe(true); - - const warningCall = warnSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("[task-store] Project-memory bootstrap failed after memory toggle-on"), - ); - expect(warningCall).toBeDefined(); - - const [, context] = warningCall as [string, Record]; - expect(context).toMatchObject({ - phase: "updateSettings:memory-toggle-on", - rootDir, - error: "memory toggle write failed", - }); - expect(ensureSpy).toHaveBeenCalled(); - } finally { - ensureSpy.mockRestore(); - warnSpy.mockRestore(); - } - }); - - it("logs fs.watch setup failures and keeps polling active", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const storeAny = store as any; - const originalTasksDir = storeAny.tasksDir; - // Force fs.watch to throw synchronously on every platform. A non- - // existent directory only fails reliably on macOS — Linux Node with - // `recursive: true` silently returns a no-op watcher (no sync throw, - // no async error event), so the catch arm we want to exercise never - // fires. Embedding a NUL byte makes Node reject the path argument - // up front with ERR_INVALID_ARG_VALUE, which is platform-agnostic. - const invalidTasksDir = join(rootDir, ".fusion", "missing-tasks-dir") + "bad"; - storeAny.tasksDir = invalidTasksDir; - - try { - await store.watch(); - - const warningCall = warnSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("[task-store] fs.watch unavailable; falling back to polling-only updates"), - ); - expect(warningCall).toBeDefined(); - - const [, context] = warningCall as [string, Record]; - expect(context).toMatchObject({ - phase: "watch:fs-watch-setup", - tasksDir: invalidTasksDir, - }); - expect(typeof context.error).toBe("string"); - expect(storeAny.pollInterval).not.toBeNull(); - await expect(storeAny.checkForChanges()).resolves.toBeUndefined(); - } finally { - store.stopWatching(); - storeAny.tasksDir = originalTasksDir; - warnSpy.mockRestore(); - } - }); - - it("logs unreadable legacy agent.log files while keeping import non-fatal", async () => { - const task = await createTestTask(); - const taskDir = join(rootDir, ".fusion", "tasks", task.id); - const logPath = join(taskDir, "agent.log"); - await mkdir(logPath); - - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - await expect(store.importLegacyAgentLogs()).resolves.toBe(0); - - const warningCall = warnSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("[task-store] Skipping unreadable legacy agent.log file during import"), - ); - expect(warningCall).toBeDefined(); - - const [, context] = warningCall as [string, Record]; - expect(context).toMatchObject({ - phase: "importLegacyAgentLogs:read-file", - taskId: task.id, - logPath, - }); - expect(typeof context.error).toBe("string"); - } finally { - warnSpy.mockRestore(); - } - }); - }); - - // ── Branch Cleanup on Delete/Archive ──────────────────────────── - - -}); diff --git a/packages/core/src/__tests__/store-handoff-to-review.test.ts b/packages/core/src/__tests__/store-handoff-to-review.test.ts deleted file mode 100644 index 67978ff311..0000000000 --- a/packages/core/src/__tests__/store-handoff-to-review.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync } from "node:fs"; -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { HandoffInvariantViolationError, TaskStore } from "../store.js"; - -function makeTmpDir(): string { - return mkdtempSync(join(tmpdir(), "kb-handoff-to-review-test-")); -} - -describe("TaskStore handoffToReview", () => { - let rootDir: string; - let globalDir: string; - let store: TaskStore; - - beforeEach(async () => { - rootDir = makeTmpDir(); - globalDir = join(rootDir, ".fusion-global"); - store = new TaskStore(rootDir, globalDir); - await store.init(); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - store.close(); - await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - }); - - async function createTask(priority: "low" | "normal" | "high" | "urgent" = "normal") { - return store.createTask({ description: `handoff ${priority}`, priority }); - } - - async function createInProgressTask(priority: "low" | "normal" | "high" | "urgent" = "normal") { - const task = await createTask(priority); - await store.moveTask(task.id, "todo"); - return store.moveTask(task.id, "in-progress"); - } - - function getAuditEventsByInsertion(taskId: string): Array<{ - mutationType: string; - metadata: Record | undefined; - }> { - const rows = store.getDatabase().prepare(` - SELECT mutationType, metadata - FROM runAuditEvents - WHERE taskId = ? - ORDER BY timestamp ASC, rowid ASC - `).all(taskId) as Array<{ mutationType: string; metadata: string | null }>; - return rows.map((row) => ({ - mutationType: row.mutationType, - metadata: row.metadata ? JSON.parse(row.metadata) as Record : undefined, - })); - } - - it("atomically moves an in-progress task to in-review and enqueues merge work", async () => { - const task = await createInProgressTask("high"); - const beforeEvents = getAuditEventsByInsertion(task.id).length; - - const handedOff = await store.handoffToReview(task.id, { - ownerAgentId: "agent-1", - evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-1" }, - now: "2026-05-19T00:00:00.000Z", - }); - - expect(handedOff.column).toBe("in-review"); - expect(await store.peekMergeQueue()).toEqual([ - expect.objectContaining({ taskId: task.id, priority: "high" }), - ]); - - const relevantEvents = getAuditEventsByInsertion(task.id) - .slice(beforeEvents) - .filter((event) => - ["task:move", "mergeQueue:enqueue", "task:handoff", "task:handoff-invariant-violation"].includes(event.mutationType) - ); - expect(relevantEvents.map((event) => event.mutationType)).toEqual([ - "task:move", - "mergeQueue:enqueue", - "task:handoff", - ]); - expect(relevantEvents[0].metadata).toMatchObject({ from: "in-progress", to: "in-review" }); - expect(relevantEvents[1].metadata).toMatchObject({ taskId: task.id, priority: "high", alreadyEnqueued: false }); - expect(relevantEvents[2].metadata).toMatchObject({ - taskId: task.id, - fromColumn: "in-progress", - ownerAgentId: "agent-1", - reason: "fn_task_done", - runId: "run-1", - agentId: "agent-1", - alreadyEnqueued: false, - }); - }); - - it("is idempotent and reports alreadyEnqueued on a second handoff", async () => { - const task = await createInProgressTask(); - - await store.handoffToReview(task.id, { - ownerAgentId: "agent-1", - evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-1" }, - now: "2026-05-19T00:00:00.000Z", - }); - const second = await store.handoffToReview(task.id, { - ownerAgentId: "agent-1", - evidence: { reason: "fn_task_done", runId: "run-2", agentId: "agent-1" }, - now: "2026-05-19T00:00:05.000Z", - }); - - expect(second.column).toBe("in-review"); - expect(await store.peekMergeQueue()).toHaveLength(1); - - const handoffEvents = getAuditEventsByInsertion(task.id).filter((event) => event.mutationType === "task:handoff"); - expect(handoffEvents).toHaveLength(2); - expect(handoffEvents[1].metadata).toMatchObject({ - taskId: task.id, - fromColumn: "in-review", - alreadyEnqueued: true, - runId: "run-2", - }); - }); - - it("rolls back the column move and audit trail when enqueueMergeQueue throws", async () => { - const task = await createInProgressTask(); - const beforeEvents = getAuditEventsByInsertion(task.id).length; - vi.spyOn(store as never, "enqueueMergeQueueSyncInternal").mockImplementationOnce((() => { - throw new Error("boom"); - }) as never); - - await expect(store.handoffToReview(task.id, { - ownerAgentId: "agent-1", - evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-1" }, - now: "2026-05-19T00:00:00.000Z", - })).rejects.toThrow("boom"); - - expect((await store.getTask(task.id))?.column).toBe("in-progress"); - expect(await store.peekMergeQueue()).toHaveLength(0); - const newEvents = getAuditEventsByInsertion(task.id).slice(beforeEvents); - expect(newEvents.filter((event) => event.mutationType === "task:move")).toHaveLength(0); - expect(newEvents.filter((event) => event.mutationType === "task:handoff")).toHaveLength(0); - }); - - it("rejects archived or deleted tasks without changing queue state", async () => { - const archived = await createTask(); - const deleted = await createInProgressTask(); - store.getDatabase().prepare('UPDATE tasks SET "column" = ?, "deletedAt" = ? WHERE id = ?').run( - "archived", - null, - archived.id, - ); - store.getDatabase().prepare('UPDATE tasks SET "deletedAt" = ? WHERE id = ?').run( - "2026-05-19T00:00:00.000Z", - deleted.id, - ); - - await expect(store.handoffToReview(archived.id, { - ownerAgentId: "agent-1", - evidence: { reason: "archived" }, - now: "2026-05-19T00:00:01.000Z", - })).rejects.toBeInstanceOf(HandoffInvariantViolationError); - await expect(store.handoffToReview(deleted.id, { - ownerAgentId: "agent-1", - evidence: { reason: "deleted" }, - now: "2026-05-19T00:00:02.000Z", - })).rejects.toBeInstanceOf(HandoffInvariantViolationError); - - expect((await store.getTask(archived.id))?.column).toBe("archived"); - expect(await store.peekMergeQueue()).toHaveLength(0); - expect(getAuditEventsByInsertion(archived.id).filter((event) => event.mutationType === "task:handoff")).toHaveLength(0); - expect(getAuditEventsByInsertion(deleted.id).filter((event) => event.mutationType === "task:handoff")).toHaveLength(0); - }); - - it("audits direct moveTask in-review transitions as invariant violations", async () => { - const task = await createInProgressTask(); - - const moved = await store.moveTask(task.id, "in-review"); - - expect(moved.column).toBe("in-review"); - const violations = getAuditEventsByInsertion(task.id).filter((event) => event.mutationType === "task:handoff-invariant-violation"); - expect(violations).toHaveLength(1); - expect(violations[0].metadata).toMatchObject({ - taskId: task.id, - fromColumn: "in-progress", - callerStack: expect.any(String), - }); - expect(String(violations[0].metadata?.callerStack ?? "").split("\n").length).toBeLessThanOrEqual(8); - }); - - it("skips invariant-violation auditing when allowDirectInReviewMove is true", async () => { - const task = await createInProgressTask(); - - const moved = await store.moveTask(task.id, "in-review", { allowDirectInReviewMove: true }); - - expect(moved.column).toBe("in-review"); - expect(getAuditEventsByInsertion(task.id).filter((event) => event.mutationType === "task:handoff-invariant-violation")).toHaveLength(0); - }); - - it("clears scheduler-state queued/blockedBy/overlapBlockedBy on handoff to in-review", async () => { - // Regression for FN-5434: a task that picked up status='queued' or - // overlapBlockedBy while waiting in todo would carry those todo-dispatch - // markers into in-review, where the merge gate then refuses to merge it - // with "task is marked 'queued'". Handoff must scrub those fields. - const task = await createInProgressTask("high"); - await store.updateTask(task.id, { - status: "queued", - blockedBy: "FN-OTHER", - overlapBlockedBy: "FN-OTHER", - }); - - const handedOff = await store.handoffToReview(task.id, { - ownerAgentId: "agent-1", - evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-1" }, - now: "2026-05-19T00:00:00.000Z", - }); - - expect(handedOff.column).toBe("in-review"); - expect(handedOff.status).toBeUndefined(); - expect(handedOff.blockedBy).toBeUndefined(); - expect(handedOff.overlapBlockedBy).toBeUndefined(); - }); - - it("preserves failed status and error details during handoff", async () => { - const task = await createInProgressTask(); - await store.updateTask(task.id, { - status: "failed", - error: "step session failed", - }); - - const handedOff = await store.handoffToReview(task.id, { - ownerAgentId: "agent-1", - evidence: { reason: "execution-failed" }, - now: "2026-05-19T00:00:00.000Z", - }); - - expect(handedOff.column).toBe("in-review"); - expect(handedOff.status).toBe("failed"); - expect(handedOff.error).toBe("step session failed"); - expect(await store.peekMergeQueue()).toEqual([ - expect.objectContaining({ taskId: task.id }), - ]); - }); -}); diff --git a/packages/core/src/__tests__/store-plugin-store-close.test.ts b/packages/core/src/__tests__/store-plugin-store-close.test.ts deleted file mode 100644 index 7081e9657c..0000000000 --- a/packages/core/src/__tests__/store-plugin-store-close.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { PluginStore } from "../plugin-store.js"; -import { createTaskStoreTestHarness } from "./store-test-helpers.js"; - -async function withTaskStoreHarness( - fn: (harness: ReturnType) => Promise, -): Promise { - const harness = createTaskStoreTestHarness(); - await harness.beforeEach(); - try { - return await fn(harness); - } finally { - await harness.afterEach(); - } -} - -describe("TaskStore pluginStore close lifecycle", () => { - it("closes and nulls the cached plugin store when TaskStore.close runs", async () => { - await withTaskStoreHarness(async (harness) => { - const store = harness.store(); - const pluginStore = store.getPluginStore(); - await pluginStore.listPlugins(); - const closeSpy = vi.spyOn(pluginStore, "close"); - - await store.close(); - - expect(closeSpy).toHaveBeenCalledTimes(1); - expect((store as any).pluginStore).toBeNull(); - - await expect(store.close()).resolves.toBeUndefined(); - expect(closeSpy).toHaveBeenCalledTimes(1); - }); - }); - - it("does not attempt plugin-store teardown when the cached store was never created", async () => { - await withTaskStoreHarness(async (harness) => { - const store = harness.store(); - const prototypeCloseSpy = vi.spyOn(PluginStore.prototype, "close"); - - await expect(store.close()).resolves.toBeUndefined(); - - expect(prototypeCloseSpy).not.toHaveBeenCalled(); - expect((store as any).pluginStore).toBeNull(); - prototypeCloseSpy.mockRestore(); - }); - }); - - it("closes disk-backed plugin stores during reopenDiskBackedStore", async () => { - await withTaskStoreHarness(async (harness) => { - await harness.reopenDiskBackedStore(); - const originalStore = harness.store(); - const pluginStore = originalStore.getPluginStore(); - await pluginStore.listPlugins(); - const closeSpy = vi.spyOn(pluginStore, "close"); - - await expect(harness.reopenDiskBackedStore()).resolves.toBeUndefined(); - - expect(closeSpy).toHaveBeenCalledTimes(1); - expect((originalStore as any).pluginStore).toBeNull(); - expect((harness.store() as any).pluginStore).toBeNull(); - }); - }); -}); diff --git a/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts b/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts deleted file mode 100644 index 2f6cd1c4ab..0000000000 --- a/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { TaskStore } from "../store.js"; - -/* -FNXC:GlobalDirGuard 2026-06-25-23:05: -Symptom-based regression for the "all my global settings reset" bug. The root cause was getSecretsStore() (and dashboard routes) constructing CentralCore with `store.getFusionDir()` (the project's `.fusion/`), which created a stray per-project `fusion-central.db` seeded with default global state that shadowed the real global DB. These tests assert the INVARIANT directly: the secrets store's central DB lands in the resolved GLOBAL dir and NOT inside the project `.fusion/` dir, and that getGlobalSettingsDir() is distinct from getFusionDir(). Surface enumeration: this covers the store/secrets surface; the resolveGlobalDir guard surfaces are covered in global-settings-guard.test.ts. -*/ -describe("TaskStore.getSecretsStore() central DB location (global, not project-local)", () => { - let root: string; - let globalDir: string; - let store: TaskStore; - - beforeEach(async () => { - root = mkdtempSync(join(tmpdir(), "fn-secrets-global-dir-")); - globalDir = join(root, ".fusion-global-settings"); - store = new TaskStore(root, globalDir, { inMemoryDb: true }); - await store.init(); - }); - - afterEach(() => { - rmSync(root, { recursive: true, force: true }); - }); - - it("resolves getGlobalSettingsDir() to the global dir, distinct from getFusionDir()", () => { - expect(store.getGlobalSettingsDir()).toBe(globalDir); - expect(store.getFusionDir()).toBe(join(root, ".fusion")); - expect(store.getGlobalSettingsDir()).not.toBe(store.getFusionDir()); - }); - - it("creates the secrets central DB in the global dir and never in the project .fusion/", async () => { - await store.getSecretsStore(); - - // The central DB must live in the resolved global dir... - expect(existsSync(join(globalDir, "fusion-central.db"))).toBe(true); - // ...and must NOT have spawned a stray per-project central DB (the original bug). - expect(existsSync(join(store.getFusionDir(), "fusion-central.db"))).toBe(false); - }); - - it("returns a stable singleton secrets store across calls", async () => { - const a = await store.getSecretsStore(); - const b = await store.getSecretsStore(); - expect(a).toBe(b); - }); -}); diff --git a/packages/core/src/__tests__/store-settings-sync-passphrase-probe.test.ts b/packages/core/src/__tests__/store-settings-sync-passphrase-probe.test.ts deleted file mode 100644 index 42300675b9..0000000000 --- a/packages/core/src/__tests__/store-settings-sync-passphrase-probe.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createTestProject } from "./test-project.js"; -import { clearSyncPassphrase, setSyncPassphrase } from "../secrets-sync-passphrase.js"; -import { CentralCore } from "../central-core.js"; -import { MasterKeyManager } from "../master-key.js"; -import { SecretsStore } from "../secrets-store.js"; - -async function createSecretsStore(fixture: Awaited>): Promise { - const central = new CentralCore(fixture.globalDir); - await central.init(); - const centralDb = (central as unknown as { db: import("../central-db.js").CentralDatabase | null }).db; - if (!centralDb) throw new Error("central db unavailable"); - const masterKeyManager = new MasterKeyManager({ globalDir: fixture.globalDir }); - return new SecretsStore(fixture.store.getDatabase(), centralDb, () => masterKeyManager.getOrCreateKey()); -} - -describe("TaskStore secretsSyncPassphraseConfigured probe", () => { - it("returns false when reserved passphrase row is absent", async () => { - const fixture = await createTestProject(); - try { - expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(false); - expect((await fixture.store.getSettingsFast()).secretsSyncPassphraseConfigured).toBe(false); - } finally { - await fixture.cleanup(); - } - }); - - it("flips false -> true -> false as passphrase is set and cleared", async () => { - const fixture = await createTestProject(); - try { - const secrets = await createSecretsStore(fixture); - const spy = vi.spyOn(fixture.store, "getSecretsStore").mockResolvedValue(secrets); - expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(false); - - await setSyncPassphrase(secrets, "pp"); - expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(true); - expect((await fixture.store.getSettingsFast()).secretsSyncPassphraseConfigured).toBe(true); - - await clearSyncPassphrase(secrets); - expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(false); - expect((await fixture.store.getSettingsFast()).secretsSyncPassphraseConfigured).toBe(false); - spy.mockRestore(); - } finally { - await fixture.cleanup(); - } - }); - - it("exposes probe under global scope only", async () => { - const fixture = await createTestProject(); - try { - const byScope = await fixture.store.getSettingsByScope(); - const byScopeFast = await fixture.store.getSettingsByScopeFast(); - expect(byScope.global.secretsSyncPassphraseConfigured).toBe(false); - expect(byScopeFast.global.secretsSyncPassphraseConfigured).toBe(false); - expect(byScope.project).not.toHaveProperty("secretsSyncPassphraseConfigured"); - expect(byScopeFast.project).not.toHaveProperty("secretsSyncPassphraseConfigured"); - } finally { - await fixture.cleanup(); - } - }); - - it("does not persist writable overrides from updateSettings", async () => { - const fixture = await createTestProject(); - try { - await fixture.store.updateSettings({ secretsSyncPassphraseConfigured: true }); - expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(false); - - await fixture.store.updateGlobalSettings({ secretsSyncPassphraseConfigured: true }); - expect((await fixture.store.getSettings()).secretsSyncPassphraseConfigured).toBe(false); - } finally { - await fixture.cleanup(); - } - }); - - it("falls back to false when secrets store lookup throws", async () => { - const fixture = await createTestProject(); - try { - const spy = vi.spyOn(fixture.store, "getSecretsStore").mockRejectedValueOnce(new Error("boom")); - await expect(fixture.store.getSettings()).resolves.toMatchObject({ secretsSyncPassphraseConfigured: false }); - spy.mockRestore(); - } finally { - await fixture.cleanup(); - } - }); -}); diff --git a/packages/core/src/__tests__/todo-store.test.ts b/packages/core/src/__tests__/todo-store.test.ts deleted file mode 100644 index 38731bb7c4..0000000000 --- a/packages/core/src/__tests__/todo-store.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; -import { createDatabase, type Database } from "../db.js"; -import { TodoStore } from "../todo-store.js"; - -function makeTmpDir(): string { - return mkdtempSync(join(tmpdir(), "fn-todo-store-")); -} - -let fusionDir: string; -let db: Database; -let store: TodoStore; - -afterEach(() => { - db.close(); - rmSync(fusionDir, { recursive: true, force: true }); -}); - -beforeEach(() => { - fusionDir = makeTmpDir(); - db = createDatabase(fusionDir); - db.init(); - store = new TodoStore(db); -}); - -describe("TodoStore", () => { - describe("list CRUD", () => { - it("createList returns a list with generated id and timestamps", () => { - const list = store.createList("proj-a", { title: "Inbox" }); - - expect(list.id).toMatch(/^TDL-[A-Z0-9]+-[A-Z0-9]+$/); - expect(list.projectId).toBe("proj-a"); - expect(list.title).toBe("Inbox"); - expect(list.createdAt).toBeTruthy(); - expect(list.updatedAt).toBeTruthy(); - }); - - it("getList returns list by id and undefined when missing", () => { - const list = store.createList("proj-a", { title: "Backlog" }); - - expect(store.getList(list.id)).toEqual(list); - expect(store.getList("TDL-MISSING")).toBeUndefined(); - }); - - it("listLists returns lists ordered by createdAt and scoped by project", () => { - const now = new Date(); - db.prepare( - "INSERT INTO todo_lists (id, projectId, title, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)" - ).run("TDL-OLD", "proj-a", "Older", new Date(now.getTime() - 10_000).toISOString(), new Date(now.getTime() - 10_000).toISOString()); - db.prepare( - "INSERT INTO todo_lists (id, projectId, title, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)" - ).run("TDL-NEW", "proj-a", "Newer", now.toISOString(), now.toISOString()); - store.createList("proj-b", { title: "Other project" }); - - const lists = store.listLists("proj-a"); - expect(lists.map((l) => l.id)).toEqual(["TDL-OLD", "TDL-NEW"]); - }); - - it("updateList updates title and updatedAt; returns undefined when missing", () => { - const list = store.createList("proj-a", { title: "Before" }); - const updated = store.updateList(list.id, { title: "After" }); - - expect(updated).toBeDefined(); - expect(updated?.title).toBe("After"); - expect(updated?.updatedAt >= list.updatedAt).toBe(true); - expect(store.updateList("TDL-MISSING", { title: "x" })).toBeUndefined(); - }); - - it("deleteList removes list, returns true/false, and cascades items", () => { - const list = store.createList("proj-a", { title: "Delete me" }); - const item = store.createItem(list.id, { text: "child" }); - - expect(store.deleteList(list.id)).toBe(true); - expect(store.getList(list.id)).toBeUndefined(); - expect(store.getItem(item.id)).toBeUndefined(); - expect(store.deleteList(list.id)).toBe(false); - }); - }); - - describe("item CRUD", () => { - it("createItem auto-increments sortOrder and accepts explicit sortOrder", () => { - const list = store.createList("proj-a", { title: "L" }); - const first = store.createItem(list.id, { text: "first" }); - const second = store.createItem(list.id, { text: "second" }); - const explicit = store.createItem(list.id, { text: "explicit", sortOrder: 10 }); - - expect(first.sortOrder).toBe(0); - expect(second.sortOrder).toBe(1); - expect(explicit.sortOrder).toBe(10); - }); - - it("getItem retrieves an item by id", () => { - const list = store.createList("proj-a", { title: "L" }); - const item = store.createItem(list.id, { text: "fetch me" }); - - expect(store.getItem(item.id)).toEqual(item); - expect(store.getItem("TDI-MISSING")).toBeUndefined(); - }); - - it("listItems returns items ordered by sortOrder then createdAt", () => { - const list = store.createList("proj-a", { title: "L" }); - const now = new Date().toISOString(); - db.prepare( - `INSERT INTO todo_items (id, listId, text, completed, completedAt, sortOrder, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run("TDI-B", list.id, "b", 0, null, 0, now, now); - db.prepare( - `INSERT INTO todo_items (id, listId, text, completed, completedAt, sortOrder, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run("TDI-A", list.id, "a", 0, null, 0, new Date(Date.now() - 1000).toISOString(), new Date(Date.now() - 1000).toISOString()); - db.prepare( - `INSERT INTO todo_items (id, listId, text, completed, completedAt, sortOrder, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run("TDI-C", list.id, "c", 0, null, 1, now, now); - - const items = store.listItems(list.id); - expect(items.map((i) => i.id)).toEqual(["TDI-A", "TDI-B", "TDI-C"]); - }); - - it("updateItem updates text and bumps updatedAt", () => { - const list = store.createList("proj-a", { title: "L" }); - const item = store.createItem(list.id, { text: "before" }); - - const updated = store.updateItem(item.id, { text: "after" }); - expect(updated?.text).toBe("after"); - expect(updated?.updatedAt >= item.updatedAt).toBe(true); - }); - - it("toggleItem flips completion and completedAt", () => { - const list = store.createList("proj-a", { title: "L" }); - const item = store.createItem(list.id, { text: "toggle" }); - - const completed = store.toggleItem(item.id)!; - expect(completed.completed).toBe(true); - expect(completed.completedAt).toBeTruthy(); - - const reopened = store.toggleItem(item.id)!; - expect(reopened.completed).toBe(false); - expect(reopened.completedAt).toBeNull(); - }); - - it.each([ - { completed: true, expectedCompletedAt: "set" }, - { completed: false, expectedCompletedAt: "cleared" }, - ])("updateItem handles completed=$completed by setting/clearing completedAt", ({ completed, expectedCompletedAt }) => { - const list = store.createList("proj-a", { title: "L" }); - const item = store.createItem(list.id, { text: "status" }); - if (!completed) { - store.updateItem(item.id, { completed: true }); - } - - const updated = store.updateItem(item.id, { completed })!; - expect(updated.completed).toBe(completed); - if (expectedCompletedAt === "set") { - expect(updated.completedAt).toBeTruthy(); - } else { - expect(updated.completedAt).toBeNull(); - } - }); - - it("deleteItem removes item and returns true/false", () => { - const list = store.createList("proj-a", { title: "L" }); - const item = store.createItem(list.id, { text: "x" }); - - expect(store.deleteItem(item.id)).toBe(true); - expect(store.getItem(item.id)).toBeUndefined(); - expect(store.deleteItem(item.id)).toBe(false); - }); - - it("reorderItems reassigns sortOrder and validates list membership", () => { - const list = store.createList("proj-a", { title: "L" }); - const i1 = store.createItem(list.id, { text: "1" }); - const i2 = store.createItem(list.id, { text: "2" }); - const i3 = store.createItem(list.id, { text: "3" }); - - const reordered = store.reorderItems(list.id, [i3.id, i1.id, i2.id]); - expect(reordered.map((i) => [i.id, i.sortOrder])).toEqual([ - [i3.id, 0], - [i1.id, 1], - [i2.id, 2], - ]); - - const other = store.createList("proj-a", { title: "Other" }); - const otherItem = store.createItem(other.id, { text: "other" }); - expect(() => store.reorderItems(list.id, [i1.id, i2.id, otherItem.id])).toThrow(/does not belong to list/); - expect(() => store.reorderItems(list.id, [i1.id, i2.id])).toThrow(/must include all items/); - }); - }); - - describe("composite queries", () => { - it("getListsWithItems returns all lists with populated items", () => { - const l1 = store.createList("proj-a", { title: "A" }); - const l2 = store.createList("proj-a", { title: "B" }); - const i1 = store.createItem(l1.id, { text: "a1" }); - const i2 = store.createItem(l1.id, { text: "a2" }); - const i3 = store.createItem(l2.id, { text: "b1" }); - - const lists = store.getListsWithItems("proj-a"); - expect(lists).toHaveLength(2); - expect(lists.find((l) => l.id === l1.id)?.items.map((i) => i.id)).toEqual([i1.id, i2.id]); - expect(lists.find((l) => l.id === l2.id)?.items.map((i) => i.id)).toEqual([i3.id]); - }); - - it("getListsWithItems is scoped by projectId", () => { - const listA = store.createList("proj-a", { title: "A" }); - store.createItem(listA.id, { text: "a1" }); - const listB = store.createList("proj-b", { title: "B" }); - store.createItem(listB.id, { text: "b1" }); - - const lists = store.getListsWithItems("proj-a"); - expect(lists).toHaveLength(1); - expect(lists[0].id).toBe(listA.id); - }); - }); - - describe("event emissions", () => { - it("emits list events with expected payloads", () => { - const createdHandler = vi.fn(); - const updatedHandler = vi.fn(); - const deletedHandler = vi.fn(); - store.on("list:created", createdHandler); - store.on("list:updated", updatedHandler); - store.on("list:deleted", deletedHandler); - - const list = store.createList("proj-a", { title: "Events" }); - const updated = store.updateList(list.id, { title: "Events 2" })!; - store.deleteList(list.id); - - expect(createdHandler).toHaveBeenCalledWith(list); - expect(updatedHandler).toHaveBeenCalledWith(updated); - expect(deletedHandler).toHaveBeenCalledWith(list.id); - }); - - it("emits item and reorder events", () => { - const list = store.createList("proj-a", { title: "Events" }); - const createdHandler = vi.fn(); - const updatedHandler = vi.fn(); - const deletedHandler = vi.fn(); - const reorderedHandler = vi.fn(); - - store.on("item:created", createdHandler); - store.on("item:updated", updatedHandler); - store.on("item:deleted", deletedHandler); - store.on("items:reordered", reorderedHandler); - - const a = store.createItem(list.id, { text: "A" }); - const b = store.createItem(list.id, { text: "B" }); - const updated = store.updateItem(a.id, { text: "A+" })!; - store.reorderItems(list.id, [b.id, a.id]); - store.deleteItem(a.id); - - expect(createdHandler).toHaveBeenCalledTimes(2); - expect(updatedHandler).toHaveBeenCalledWith(updated); - expect(reorderedHandler).toHaveBeenCalledWith({ - listId: list.id, - items: expect.any(Array), - }); - expect(deletedHandler).toHaveBeenCalledWith(a.id); - }); - }); -}); diff --git a/packages/core/src/activity-analytics.ts b/packages/core/src/activity-analytics.ts index 130fcf3260..8bc18684b0 100644 --- a/packages/core/src/activity-analytics.ts +++ b/packages/core/src/activity-analytics.ts @@ -220,30 +220,7 @@ export async function aggregateActivityAnalytics( // P1 fix (review #17): use `"ping" in dbOrLayer` (unique to AsyncDataLayer) // instead of the broken `"transactionImmediate" in dbOrLayer`. if ("ping" in dbOrLayer) { - const monitor = await aggregateMonitorMetrics(dbOrLayer, query); - return { - from: query.from ?? null, - to: query.to ?? null, - sessions: 0, - messages: 0, - activeNodes: 0, - activeAgents: 0, - agentRuns: { total: 0, active: 0, completed: 0, failed: 0 }, - stickiness: 0, - daily: [], - mttr: monitor.mttr, - monitor, - funnel: { - from: query.from ?? null, - to: query.to ?? null, - stages: [], - enteredInRange: 0, - doneInRange: 0, - completionRate: null, - rangeDays: 0, - throughputPerDay: 0, - }, - }; + return aggregatePostgresActivityAnalytics(dbOrLayer, query); } const db = dbOrLayer as Database; // Sessions from cli_sessions (by createdAt). @@ -357,6 +334,129 @@ export async function aggregateActivityAnalytics( }; } +/* +FNXC:ActivityAnalyticsPostgres 2026-07-13-22:38: +Command Center activity must never substitute plausible zeroes for unported PostgreSQL queries. Aggregate the same session, usage-event, agent-run, daily-stickiness, monitor, and funnel inputs as SQLite so operators can distinguish no activity from a storage regression. +*/ +interface PostgresEventSummaryRow { + messages?: number; + active_nodes?: number; + agent_ids?: string[]; +} + +interface PostgresEventDailyRow extends PostgresEventSummaryRow { + day: string; +} + +interface PostgresRunDailyRow { + day: string; + count: number; + agent_ids?: string[]; +} + +async function aggregatePostgresActivityAnalytics( + layer: AsyncDataLayer, + query: ActivityAnalyticsQuery, +): Promise { + const eventFrom = query.from ? sql`AND ts >= ${query.from}` : sql``; + const eventTo = query.to ? sql`AND ts <= ${query.to}` : sql``; + const runFrom = query.from ? sql`AND started_at >= ${query.from}` : sql``; + const runTo = query.to ? sql`AND started_at <= ${query.to}` : sql``; + const sessionFrom = query.from ? sql`AND created_at >= ${query.from}` : sql``; + const sessionTo = query.to ? sql`AND created_at <= ${query.to}` : sql``; + /* + FNXC:ActivityAnalyticsPostgres 2026-07-14-00:37: + An unbound analytics layer is deliberately project-agnostic and must aggregate every project partition consistently. A bound layer scopes sessions, usage, agent runs, and funnel activity to its project; never reinterpret an absent binding as the legacy empty-string partition. + */ + const analyticsProject = layer.projectId !== undefined + ? sql`AND project_id = ${layer.projectId}` + : sql``; + + const [sessionResult, eventSummaryResult, eventDailyResult, runStatusResult, runDailyResult, runAgentResult, monitor, funnel] = await Promise.all([ + layer.db.execute(sql`SELECT count(*)::int AS count FROM project.cli_sessions WHERE 1=1 ${analyticsProject} ${sessionFrom} ${sessionTo}`), + layer.db.execute(sql` + SELECT + count(*) FILTER (WHERE kind = 'user_message')::int AS messages, + count(DISTINCT node_id) FILTER (WHERE node_id IS NOT NULL)::int AS active_nodes, + array_remove(array_agg(DISTINCT agent_id), NULL) AS agent_ids + FROM project.usage_events WHERE 1=1 ${analyticsProject} ${eventFrom} ${eventTo} + `), + layer.db.execute(sql` + SELECT left(ts, 10) AS day, + count(DISTINCT node_id) FILTER (WHERE node_id IS NOT NULL)::int AS active_nodes, + count(*) FILTER (WHERE kind = 'user_message')::int AS messages, + array_remove(array_agg(DISTINCT agent_id), NULL) AS agent_ids + FROM project.usage_events WHERE 1=1 ${analyticsProject} ${eventFrom} ${eventTo} + GROUP BY left(ts, 10) ORDER BY day + `), + layer.db.execute(sql`SELECT status, count(*)::int AS count FROM project.agent_runs WHERE 1=1 ${analyticsProject} ${runFrom} ${runTo} GROUP BY status`), + /* + FNXC:ActivityAnalyticsPostgres 2026-07-14-01:41: + Null agent IDs may survive in legacy or schema-drift run rows. Count those rows as runs, but remove NULL from the daily identity set so daily active agents and stickiness use the same non-null population as the range summary. + */ + layer.db.execute(sql`SELECT left(started_at, 10) AS day, count(*)::int AS count, array_remove(array_agg(DISTINCT agent_id), NULL) AS agent_ids FROM project.agent_runs WHERE 1=1 ${analyticsProject} ${runFrom} ${runTo} GROUP BY left(started_at, 10) ORDER BY day`), + layer.db.execute(sql`SELECT DISTINCT agent_id FROM project.agent_runs WHERE agent_id IS NOT NULL ${analyticsProject} ${runFrom} ${runTo}`), + aggregateMonitorMetrics(layer, query), + aggregatePostgresSdlcFunnel(layer, query), + ]); + const sessionRows = sessionResult as unknown as Array<{ count?: number }>; + const eventSummaryRows = eventSummaryResult as unknown as PostgresEventSummaryRow[]; + const eventDailyRows = eventDailyResult as unknown as PostgresEventDailyRow[]; + const runStatusRows = runStatusResult as unknown as Array<{ status: string; count: number }>; + const runDailyRows = runDailyResult as unknown as PostgresRunDailyRow[]; + const runAgentRows = runAgentResult as unknown as Array<{ agent_id: string }>; + + const eventSummary = eventSummaryRows[0]; + const rangeAgentIds = new Set(eventSummary?.agent_ids ?? []); + for (const row of runAgentRows) rangeAgentIds.add(row.agent_id); + + const agentRuns = zeroAgentRunSummary(); + for (const row of runStatusRows) { + agentRuns.total += row.count; + if (row.status === "active") agentRuns.active = row.count; + if (row.status === "completed") agentRuns.completed = row.count; + if (row.status === "failed") agentRuns.failed = row.count; + } + + const dailyByDay = new Map(); + const eventAgentIdsByDay = new Map(); + for (const row of eventDailyRows) { + eventAgentIdsByDay.set(row.day, row.agent_ids ?? []); + dailyByDay.set(row.day, { + day: row.day, + activeNodes: row.active_nodes ?? 0, + activeAgents: new Set(row.agent_ids ?? []).size, + messages: row.messages ?? 0, + agentRuns: 0, + }); + } + for (const row of runDailyRows) { + const existing = dailyByDay.get(row.day) ?? { day: row.day, activeNodes: 0, activeAgents: 0, messages: 0, agentRuns: 0 }; + const eventAgents = eventAgentIdsByDay.get(row.day) ?? []; + existing.activeAgents = new Set([...eventAgents, ...(row.agent_ids ?? [])]).size; + existing.agentRuns = row.count; + dailyByDay.set(row.day, existing); + } + const daily = [...dailyByDay.values()].sort((a, b) => a.day.localeCompare(b.day)); + const activeAgents = rangeAgentIds.size; + const dau = daily.length > 0 ? daily.reduce((sum, day) => sum + day.activeAgents, 0) / daily.length : 0; + + return { + from: query.from ?? null, + to: query.to ?? null, + sessions: Number(sessionRows[0]?.count ?? 0), + messages: Number(eventSummary?.messages ?? 0), + activeNodes: Number(eventSummary?.active_nodes ?? 0), + activeAgents, + agentRuns, + daily, + stickiness: activeAgents > 0 ? dau / activeAgents : 0, + mttr: monitor.mttr, + monitor, + funnel, + }; +} + function zeroAgentRunSummary(): AgentRunSummary { return { total: 0, active: 0, completed: 0, failed: 0 }; } @@ -646,12 +746,6 @@ export function aggregateSdlcFunnel( query: SdlcFunnelQuery = {}, ): SdlcFunnel { const columns = query.columns ?? defaultColumns(); - const stageMap = buildColumnStageMap(columns); - const stageOf = (columnId: string | null): SdlcStageKey => { - if (columnId === null) return OTHER_STAGE; - return stageMap.get(columnId) ?? OTHER_STAGE; - }; - const range = rangeClauses("timestamp", query); const where = range.where ? `${range.where} AND type = 'task:moved'` @@ -670,7 +764,36 @@ export function aggregateSdlcFunnel( ) .all(...range.params) as MoveRow[]; - // Distinct tasks per stage. + return buildSdlcFunnelFromRows(rows, query, columns); +} + +async function aggregatePostgresSdlcFunnel( + layer: AsyncDataLayer, + query: SdlcFunnelQuery, +): Promise { + const projectScope = layer.projectId !== undefined + ? sql`AND project_id = ${layer.projectId}` + : sql``; + const from = query.from ? sql`AND timestamp >= ${query.from}` : sql``; + const to = query.to ? sql`AND timestamp <= ${query.to}` : sql``; + const rows = await layer.db.execute(sql` + SELECT task_id AS "taskId", metadata ->> 'to' AS "to", timestamp AS ts + FROM project.activity_log + WHERE type = 'task:moved' ${projectScope} ${from} ${to} + `) as unknown as MoveRow[]; + return buildSdlcFunnelFromRows(rows, query, query.columns ?? defaultColumns()); +} + +function buildSdlcFunnelFromRows( + rows: readonly MoveRow[], + query: SdlcFunnelQuery, + columns: readonly FunnelColumnTraitSource[], +): SdlcFunnel { + const stageMap = buildColumnStageMap(columns); + const stageOf = (columnId: string | null): SdlcStageKey => { + if (columnId === null) return OTHER_STAGE; + return stageMap.get(columnId) ?? OTHER_STAGE; + }; const perStage = new Map>(); const ensure = (s: SdlcStageKey): Set => { let set = perStage.get(s); @@ -783,11 +906,18 @@ export async function aggregateMonitorMetrics( // deployments read previously sat OUTSIDE the try/catch, so this error 500'd // the whole /command-center/activity route instead of degrading. Deployment // frequency filters on deployed_at (deploy time), not the incident openedAt. + /* + FNXC:MonitorAnalyticsIsolation 2026-07-14-01:04: + A bound PostgreSQL layer must apply one shared tenant predicate to every deployment and incident metric, including the point-in-time open count and MTTR sample. An unbound layer deliberately omits it for global Command Center aggregation. + */ + const projectScope = layer.projectId !== undefined + ? sql`AND project_id = ${layer.projectId}` + : sql``; let deployments = 0; try { const depFrom = query.from ? sql`AND deployed_at >= ${query.from}` : sql``; const depTo = query.to ? sql`AND deployed_at <= ${query.to}` : sql``; - const deploymentsRows = await layer.db.execute(sql`SELECT count(*)::int AS count FROM project.deployments WHERE 1=1 ${depFrom} ${depTo}`); + const deploymentsRows = await layer.db.execute(sql`SELECT count(*)::int AS count FROM project.deployments WHERE 1=1 ${projectScope} ${depFrom} ${depTo}`); deployments = (deploymentsRows[0] as { count?: number } | undefined)?.count ?? 0; } catch (err) { // FNXC:PostgresMonitorMetrics 2026-06-27-00:40: @@ -802,15 +932,15 @@ export async function aggregateMonitorMetrics( const openedTo = query.to ? sql`AND opened_at <= ${query.to}` : sql``; const resolvedFrom = query.from ? sql`AND resolved_at >= ${query.from}` : sql``; const resolvedTo = query.to ? sql`AND resolved_at <= ${query.to}` : sql``; - const incidentsOpenedRows = await layer.db.execute(sql`SELECT count(*)::int AS count FROM project.incidents WHERE 1=1 ${openedFrom} ${openedTo}`); + const incidentsOpenedRows = await layer.db.execute(sql`SELECT count(*)::int AS count FROM project.incidents WHERE 1=1 ${projectScope} ${openedFrom} ${openedTo}`); const incidentsOpened = (incidentsOpenedRows[0] as { count?: number } | undefined)?.count ?? 0; - const openIncidentsRows = await layer.db.execute(sql`SELECT count(*)::int AS count FROM project.incidents WHERE status = 'open'`); + const openIncidentsRows = await layer.db.execute(sql`SELECT count(*)::int AS count FROM project.incidents WHERE status = 'open' ${projectScope}`); const openIncidents = (openIncidentsRows[0] as { count?: number } | undefined)?.count ?? 0; // FNXC:PostgresMonitorMetrics 2026-06-27-00:40: // resolvedDetailRows already returns every resolved-in-range incident, so // incidentsResolved is its row count — drop the separate COUNT query that // had an identical WHERE clause (one fewer round-trip per activity load). - const resolvedDetailRows = await layer.db.execute(sql`SELECT opened_at AS "openedAt", resolved_at AS "resolvedAt" FROM project.incidents WHERE resolved_at IS NOT NULL ${resolvedFrom} ${resolvedTo}`) as Array<{ openedAt: string; resolvedAt: string }>; + const resolvedDetailRows = await layer.db.execute(sql`SELECT opened_at AS "openedAt", resolved_at AS "resolvedAt" FROM project.incidents WHERE resolved_at IS NOT NULL ${projectScope} ${resolvedFrom} ${resolvedTo}`) as Array<{ openedAt: string; resolvedAt: string }>; const incidentsResolved = resolvedDetailRows.length; let totalMs = 0; let sampleCount = 0; @@ -1058,25 +1188,32 @@ async function aggregateSignalsAnalyticsAsync( layer: AsyncDataLayer, query: ActivityAnalyticsQuery, ): Promise { + /* + FNXC:SignalsAnalyticsIsolation 2026-07-14-01:26: + A project-bound Signals read must apply the same tenant predicate to totals, open incidents, resolved/MTTR samples, and every breakdown. An unbound Command Center layer deliberately omits the predicate to preserve global aggregation. + */ + const projectScope = layer.projectId !== undefined + ? sql`AND project_id = ${layer.projectId}` + : sql``; const openedFrom = query.from !== undefined ? sql`AND opened_at >= ${query.from}` : sql``; const openedTo = query.to !== undefined ? sql`AND opened_at <= ${query.to}` : sql``; const resolvedFrom = query.from !== undefined ? sql`AND resolved_at >= ${query.from}` : sql``; const resolvedTo = query.to !== undefined ? sql`AND resolved_at <= ${query.to}` : sql``; const totalRows = (await layer.db.execute( - sql`SELECT count(*)::int AS count FROM project.incidents WHERE 1=1 ${openedFrom} ${openedTo}`, + sql`SELECT count(*)::int AS count FROM project.incidents WHERE 1=1 ${projectScope} ${openedFrom} ${openedTo}`, )) as Array<{ count: number }>; const totalSignals = Number(totalRows[0]?.count ?? 0); const openRows = (await layer.db.execute( - sql`SELECT count(*)::int AS count FROM project.incidents WHERE status = 'open' ${openedFrom} ${openedTo}`, + sql`SELECT count(*)::int AS count FROM project.incidents WHERE status = 'open' ${projectScope} ${openedFrom} ${openedTo}`, )) as Array<{ count: number }>; const open = Number(openRows[0]?.count ?? 0); const resolvedRows = (await layer.db.execute( sql`SELECT opened_at AS "openedAt", resolved_at AS "resolvedAt" FROM project.incidents - WHERE resolved_at IS NOT NULL ${resolvedFrom} ${resolvedTo}`, + WHERE resolved_at IS NOT NULL ${projectScope} ${resolvedFrom} ${resolvedTo}`, )) as Array<{ openedAt: string; resolvedAt: string }>; const resolved = resolvedRows.length; @@ -1085,7 +1222,7 @@ async function aggregateSignalsAnalyticsAsync( const rows = (await layer.db.execute( sql`SELECT COALESCE(NULLIF(TRIM(${col}), ''), 'unknown') AS key, count(*)::int AS count FROM project.incidents - WHERE 1=1 ${openedFrom} ${openedTo} + WHERE 1=1 ${projectScope} ${openedFrom} ${openedTo} GROUP BY 1 ORDER BY count DESC, key ASC`, )) as Array<{ key: string | null; count: number }>; diff --git a/packages/core/src/agent-store.ts b/packages/core/src/agent-store.ts index 41d3315296..590bce3baa 100644 --- a/packages/core/src/agent-store.ts +++ b/packages/core/src/agent-store.ts @@ -365,6 +365,18 @@ export class AgentStore extends EventEmitter { this.asyncLayer = options.asyncLayer ?? null; } + private get backendProjectId(): string { + const projectId = this.asyncLayer?.projectId; + /* + FNXC:AgentHeartbeatIsolation 2026-07-14-00:37: + Backend heartbeat runs are project-owned. Reject unbound backend heartbeat/run access instead of silently reading or writing the legacy empty-string partition, which could mix ownership on a shared PostgreSQL cluster. + */ + if (!projectId) { + throw new Error("AgentStore backend heartbeat/run operations require asyncLayer.projectId"); + } + return projectId; + } + private get db(): Database { if (this.backendMode) { throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)"); @@ -2119,6 +2131,9 @@ export class AgentStore extends EventEmitter { status: AgentHeartbeatEvent["status"], runId?: string ): Promise { + if (this.backendMode) { + void this.backendProjectId; + } return this.withLock(agentId, async () => { // Verify agent exists const agent = await this.getAgent(agentId); @@ -2183,6 +2198,7 @@ export class AgentStore extends EventEmitter { // FNXC:SqliteFinalRemoval 2026-06-26-00:05: // Backend mode: read via async Drizzle helper. if (this.backendMode) { + void this.backendProjectId; return getHeartbeatHistoryAsync(this.asyncLayer!.db, agentId, limit); } const rows = this.db.prepare(` @@ -2244,7 +2260,7 @@ export class AgentStore extends EventEmitter { let agentId: string; let existingRun: AgentHeartbeatRun; if (this.backendMode) { - const found = await getRunByIdAsync(this.asyncLayer!.db, runId); + const found = await getRunByIdAsync(this.asyncLayer!.db, this.backendProjectId, runId); if (!found) { return; } @@ -2324,7 +2340,7 @@ export class AgentStore extends EventEmitter { * Backend-mode: delegate to async Drizzle listActiveHeartbeatRuns helper. */ if (this.backendMode) { - return listActiveHeartbeatRunsAsync(this.asyncLayer!.db); + return listActiveHeartbeatRunsAsync(this.asyncLayer!.db, this.backendProjectId); } const rows = this.db.prepare(` SELECT data FROM agentRuns @@ -2539,7 +2555,7 @@ export class AgentStore extends EventEmitter { * Backend-mode: delegate to async Drizzle saveRun helper. */ if (this.backendMode) { - await saveRunAsync(this.asyncLayer!.db, run); + await saveRunAsync(this.asyncLayer!.db, this.backendProjectId, run); return; } this.db.prepare(` @@ -2567,7 +2583,7 @@ export class AgentStore extends EventEmitter { * Backend-mode: delegate to async Drizzle getRunDetail helper. */ if (this.backendMode) { - return getRunDetailAsync(this.asyncLayer!.db, agentId, runId); + return getRunDetailAsync(this.asyncLayer!.db, this.backendProjectId, agentId, runId); } const row = this.db.prepare(` SELECT data FROM agentRuns WHERE agentId = ? AND id = ? @@ -2587,7 +2603,7 @@ export class AgentStore extends EventEmitter { * Backend-mode: delegate to async Drizzle getRecentRuns helper. */ if (this.backendMode) { - return getRecentRunsAsync(this.asyncLayer!.db, agentId, limit); + return getRecentRunsAsync(this.asyncLayer!.db, this.backendProjectId, agentId, limit); } const rows = this.db.prepare(` SELECT data FROM agentRuns @@ -2606,7 +2622,7 @@ export class AgentStore extends EventEmitter { * Backend-mode: delegate to async Drizzle getRunStatusCounts helper. */ if (this.backendMode) { - return getRunStatusCountsAsync(this.asyncLayer!.db, agentIds); + return getRunStatusCountsAsync(this.asyncLayer!.db, this.backendProjectId, agentIds); } let rows: Array<{ status: string; count: number }>; diff --git a/packages/core/src/async-agent-store.ts b/packages/core/src/async-agent-store.ts index 9f8b8685dc..0623fbf72a 100644 --- a/packages/core/src/async-agent-store.ts +++ b/packages/core/src/async-agent-store.ts @@ -350,10 +350,11 @@ export async function getHeartbeatHistory( * FNXC:AgentStore 2026-06-24-14:35: * Upsert a structured heartbeat run record (INSERT ... ON CONFLICT(id) DO UPDATE). */ -export async function saveRun(handle: QueryHandle, run: AgentHeartbeatRun): Promise { +export async function saveRun(handle: QueryHandle, projectId: string, run: AgentHeartbeatRun): Promise { await handle .insert(schema.project.agentRuns) .values({ + projectId, id: run.id, agentId: run.agentId, data: run, @@ -378,6 +379,7 @@ export async function saveRun(handle: QueryHandle, run: AgentHeartbeatRun): Prom */ export async function getRunDetail( handle: QueryHandle, + projectId: string, agentId: string, runId: string, ): Promise { @@ -386,6 +388,7 @@ export async function getRunDetail( .from(schema.project.agentRuns) .where( and( + eq(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.agentId, agentId), eq(schema.project.agentRuns.id, runId), ), @@ -400,6 +403,7 @@ export async function getRunDetail( */ export async function getRunById( handle: QueryHandle, + projectId: string, runId: string, ): Promise<{ agentId: string; run: AgentHeartbeatRun | null } | null> { const rows = await handle @@ -408,7 +412,7 @@ export async function getRunById( data: schema.project.agentRuns.data, }) .from(schema.project.agentRuns) - .where(eq(schema.project.agentRuns.id, runId)); + .where(and(eq(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.id, runId))); const row = rows[0] as { agentId: string; data: Record | null } | undefined; if (!row) return null; return { agentId: row.agentId, run: (row.data as AgentHeartbeatRun | null) ?? null }; @@ -419,13 +423,14 @@ export async function getRunById( */ export async function getRecentRuns( handle: QueryHandle, + projectId: string, agentId: string, limit = 20, ): Promise { const rows = await handle .select({ data: schema.project.agentRuns.data }) .from(schema.project.agentRuns) - .where(eq(schema.project.agentRuns.agentId, agentId)) + .where(and(eq(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.agentId, agentId))) .orderBy(desc(schema.project.agentRuns.startedAt)) .limit(limit); return rows @@ -438,11 +443,11 @@ export async function getRecentRuns( * List every run currently in `status = 'active'` across all agents. Used by * self-healing to detect orphaned runs from prior process incarnations. */ -export async function listActiveHeartbeatRuns(handle: QueryHandle): Promise { +export async function listActiveHeartbeatRuns(handle: QueryHandle, projectId: string): Promise { const rows = await handle .select({ data: schema.project.agentRuns.data }) .from(schema.project.agentRuns) - .where(eq(schema.project.agentRuns.status, "active")) + .where(and(eq(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.status, "active"))) .orderBy(asc(schema.project.agentRuns.startedAt)); return rows .map((row) => (row.data as AgentHeartbeatRun | null) ?? null) @@ -458,6 +463,7 @@ export async function listActiveHeartbeatRuns(handle: QueryHandle): Promise { const normalizedLimit = @@ -466,11 +472,13 @@ export async function listAllAgentRuns( ? await handle .select({ data: schema.project.agentRuns.data }) .from(schema.project.agentRuns) + .where(eq(schema.project.agentRuns.projectId, projectId)) .orderBy(desc(schema.project.agentRuns.startedAt), desc(schema.project.agentRuns.id)) .limit(normalizedLimit) : await handle .select({ data: schema.project.agentRuns.data }) .from(schema.project.agentRuns) + .where(eq(schema.project.agentRuns.projectId, projectId)) .orderBy(asc(schema.project.agentRuns.startedAt), asc(schema.project.agentRuns.id)); return rows .map((row) => (row.data as AgentHeartbeatRun | null) ?? null) @@ -484,6 +492,7 @@ export async function listAllAgentRuns( */ export async function getRunStatusCounts( handle: QueryHandle, + projectId: string, agentIds?: readonly string[], ): Promise<{ completedRuns: number; failedRuns: number }> { let rows: Array<{ status: string; count: number }>; @@ -494,7 +503,7 @@ export async function getRunStatusCounts( count: sql`count(*)::int`, }) .from(schema.project.agentRuns) - .where(inArray(schema.project.agentRuns.agentId, [...agentIds])) + .where(and(eq(schema.project.agentRuns.projectId, projectId), inArray(schema.project.agentRuns.agentId, [...agentIds]))) .groupBy(schema.project.agentRuns.status); } else { rows = await handle @@ -503,6 +512,7 @@ export async function getRunStatusCounts( count: sql`count(*)::int`, }) .from(schema.project.agentRuns) + .where(eq(schema.project.agentRuns.projectId, projectId)) .groupBy(schema.project.agentRuns.status); } @@ -521,11 +531,13 @@ export async function getRunStatusCounts( */ export async function insertRunIfAbsent( handle: QueryHandle, + projectId: string, run: AgentHeartbeatRun, ): Promise { const result = await handle .insert(schema.project.agentRuns) .values({ + projectId, id: run.id, agentId: run.agentId, data: run, diff --git a/packages/core/src/async-approval-request-store.ts b/packages/core/src/async-approval-request-store.ts index 273599266d..787fcb3baa 100644 --- a/packages/core/src/async-approval-request-store.ts +++ b/packages/core/src/async-approval-request-store.ts @@ -116,9 +116,13 @@ function rowToAuditEvent(row: ApprovalRequestAuditEventRow): ApprovalRequestAudi /** * Append an audit event row inside the given transaction handle. + * + * FNXC:ApprovalAnalyticsIsolation 2026-07-14-01:04: + * Audit events must carry the bound layer's project ID at write time because request IDs alone do not provide a reliable tenant ownership join for Command Center intervention analytics. */ async function appendAuditEvent( tx: DbTransaction, + projectId: string, requestId: string, eventType: ApprovalRequestAuditEventType, actor: ApprovalRequestActorSnapshot, @@ -135,6 +139,7 @@ async function appendAuditEvent( createdAt, }; await tx.insert(schema.project.approvalRequestAuditEvents).values({ + projectId, id, requestId, eventType, @@ -158,6 +163,7 @@ export async function createApprovalRequest( input: ApprovalRequestCreateInput & { id: string }, ): Promise { const now = new Date().toISOString(); + const projectId = layer.projectId ?? ""; const request: ApprovalRequest = { id: input.id, status: "pending", @@ -193,7 +199,7 @@ export async function createApprovalRequest( createdAt: request.createdAt, updatedAt: request.updatedAt, }); - await appendAuditEvent(tx, request.id, "created", input.requester, now); + await appendAuditEvent(tx, projectId, request.id, "created", input.requester, now); }); return request; } @@ -259,7 +265,7 @@ export async function decideApprovalRequest( .update(schema.project.approvalRequests) .set({ status, decidedAt: now, updatedAt: now }) .where(eq(schema.project.approvalRequests.id, requestId)); - await appendAuditEvent(tx, requestId, status, input.actor, now, input.note); + await appendAuditEvent(tx, layer.projectId ?? "", requestId, status, input.actor, now, input.note); }); return (await getApprovalRequest(layer.db, requestId))!; } @@ -284,7 +290,7 @@ export async function markApprovalRequestCompleted( .update(schema.project.approvalRequests) .set({ status: "completed", completedAt: now, updatedAt: now }) .where(eq(schema.project.approvalRequests.id, requestId)); - await appendAuditEvent(tx, requestId, "completed", input.actor, now, input.note); + await appendAuditEvent(tx, layer.projectId ?? "", requestId, "completed", input.actor, now, input.note); }); return (await getApprovalRequest(layer.db, requestId))!; } diff --git a/packages/core/src/async-automation-store.ts b/packages/core/src/async-automation-store.ts index 0fea91dfb5..aed0b8f353 100644 --- a/packages/core/src/async-automation-store.ts +++ b/packages/core/src/async-automation-store.ts @@ -30,7 +30,7 @@ */ import { and, asc, eq, lte, sql } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; -import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; +import type { AsyncDataLayer } from "./postgres/data-layer.js"; import type { ScheduledTask, ScheduledTaskCreateInput, @@ -39,8 +39,26 @@ import type { ScheduleType, } from "./automation.js"; -/** A query-capable handle: either the top-level db or a transaction handle. */ -type QueryHandle = AsyncDataLayer["db"] | DbTransaction; +/** The bound project context required by every automation query. */ +type AutomationDataLayer = Pick; + +/* + * FNXC:AutomationIsolation 2026-07-13-22:37: + * The embedded PostgreSQL cluster stores every project's automations in one physical table. Every CRUD and due-run operation filters one and only one project partition. Global automations remain global execution-lane entries owned by their creating project, matching the former per-project SQLite file semantics; they are never cross-project rows. + * + * FNXC:AutomationIsolation 2026-07-14-00:37: + * Automation schedules are project-owned, so an unbound async layer is invalid. Fail closed before any query instead of treating a missing project identity as ownership of the legacy empty-string partition. + */ +function automationProjectId(layer: AutomationDataLayer): string { + if (!layer.projectId) { + throw new Error("AutomationStore backend operations require asyncLayer.projectId"); + } + return layer.projectId; +} + +function automationProjectScope(layer: AutomationDataLayer) { + return eq(schema.project.automations.projectId, automationProjectId(layer)); +} /** Row shape for automations (camelCase column aliases via Drizzle). */ interface AutomationRow { @@ -111,10 +129,11 @@ function rowToSchedule(row: AutomationRow): ScheduledTask { * every persistence path (update, recordRun). Non-destructive on the primary * key: an existing row is updated in place. */ -export async function upsertSchedule(handle: QueryHandle, schedule: ScheduledTask): Promise { - await handle +export async function upsertSchedule(layer: AutomationDataLayer, schedule: ScheduledTask): Promise { + await layer.db .insert(schema.project.automations) .values({ + projectId: automationProjectId(layer), id: schedule.id, name: schedule.name, description: schedule.description ?? null, @@ -134,7 +153,7 @@ export async function upsertSchedule(handle: QueryHandle, schedule: ScheduledTas updatedAt: schedule.updatedAt, }) .onConflictDoUpdate({ - target: schema.project.automations.id, + target: [schema.project.automations.projectId, schema.project.automations.id], set: { name: schedule.name, description: schedule.description ?? null, @@ -161,21 +180,21 @@ export async function upsertSchedule(handle: QueryHandle, schedule: ScheduledTas * responsible for computing cronExpression/nextRunAt before calling. */ export async function createScheduleRow( - handle: QueryHandle, + layer: AutomationDataLayer, schedule: ScheduledTask, ): Promise { - await upsertSchedule(handle, schedule); + await upsertSchedule(layer, schedule); return schedule; } /** * Get a single schedule by id. Throws ENOENT if not found (matches sync shape). */ -export async function getSchedule(handle: QueryHandle, id: string): Promise { - const rows = await handle +export async function getSchedule(layer: AutomationDataLayer, id: string): Promise { + const rows = await layer.db .select(automationColumns) .from(schema.project.automations) - .where(eq(schema.project.automations.id, id)); + .where(and(automationProjectScope(layer), eq(schema.project.automations.id, id))); const row = rows[0]; if (!row) { throw Object.assign(new Error(`Schedule '${id}' not found`), { code: "ENOENT" }); @@ -187,23 +206,24 @@ export async function getSchedule(handle: QueryHandle, id: string): Promise { - const rows = await handle + const rows = await layer.db .select(automationColumns) .from(schema.project.automations) - .where(eq(schema.project.automations.id, id)); + .where(and(automationProjectScope(layer), eq(schema.project.automations.id, id))); return rows[0] ? rowToSchedule(rows[0] as AutomationRow) : undefined; } /** * List all schedules ordered by createdAt ASC. */ -export async function listSchedules(handle: QueryHandle): Promise { - const rows = await handle +export async function listSchedules(layer: AutomationDataLayer): Promise { + const rows = await layer.db .select(automationColumns) .from(schema.project.automations) + .where(automationProjectScope(layer)) .orderBy(asc(schema.project.automations.createdAt), asc(schema.project.automations.id)); return rows.map((row) => rowToSchedule(row as AutomationRow)); } @@ -212,10 +232,10 @@ export async function listSchedules(handle: QueryHandle): Promise { - const result = await handle +export async function deleteSchedule(layer: AutomationDataLayer, id: string): Promise { + const result = await layer.db .delete(schema.project.automations) - .where(eq(schema.project.automations.id, id)) + .where(and(automationProjectScope(layer), eq(schema.project.automations.id, id))) .returning({ id: schema.project.automations.id }); return result.length > 0; } @@ -226,11 +246,12 @@ export async function deleteSchedule(handle: QueryHandle, id: string): Promise { const conditions = [ + automationProjectScope(layer), eq(schema.project.automations.enabled, 1), sql`${schema.project.automations.nextRunAt} IS NOT NULL`, lte(schema.project.automations.nextRunAt, nowIso), @@ -238,12 +259,35 @@ export async function getDueSchedules( if (scope !== undefined) { conditions.push(eq(schema.project.automations.scope, scope)); } - const rows = await handle + const rows = await layer.db .select(automationColumns) .from(schema.project.automations) .where(and(...conditions)); return rows.map((row) => rowToSchedule(row as AutomationRow)); } +/** + * Atomically advance one due occurrence inside the caller's project partition. + */ +export async function claimDueSchedule( + layer: AutomationDataLayer, + id: string, + expectedNextRunAt: string, + nextRunAt: string, + updatedAt: string, +): Promise { + const rows = await layer.db + .update(schema.project.automations) + .set({ nextRunAt, updatedAt }) + .where(and( + automationProjectScope(layer), + eq(schema.project.automations.id, id), + eq(schema.project.automations.enabled, 1), + eq(schema.project.automations.nextRunAt, expectedNextRunAt), + )) + .returning({ id: schema.project.automations.id }); + return rows.length === 1; +} + // Re-export the input types for callers constructing schedules via the helper. export type { ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult }; diff --git a/packages/core/src/async-eval-store.ts b/packages/core/src/async-eval-store.ts index 4cbfb8b6fb..cb64fb5319 100644 --- a/packages/core/src/async-eval-store.ts +++ b/packages/core/src/async-eval-store.ts @@ -18,16 +18,17 @@ * flip. These helpers are the async target the PostgreSQL integration tests * consume. */ -import { and, asc, eq, sql } from "drizzle-orm"; +import { and, asc, desc, eq, sql } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import * as schema from "./postgres/schema/index.js"; import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; -import { EvalLifecycleError } from "./eval-store.js"; +import { EvalLifecycleError, applyEvalRunUpdate } from "./eval-store.js"; import type { EvalRun, EvalRunCreateInput, EvalRunListOptions, EvalRunStatus, + EvalRunUpdateInput, EvalTaskResult, EvalTaskResultCreateInput, EvalTaskResultListOptions, @@ -183,16 +184,17 @@ export async function listEvalRuns(handle: QueryHandle, options: EvalRunListOpti if (options.projectId) conditions.push(eq(schema.project.evalRuns.projectId, options.projectId)); if (options.status) conditions.push(eq(schema.project.evalRuns.status, options.status)); if (options.trigger) conditions.push(eq(schema.project.evalRuns.trigger, options.trigger)); - const query = handle + let query = handle .select() .from(schema.project.evalRuns) - .orderBy(asc(schema.project.evalRuns.createdAt), asc(schema.project.evalRuns.id)); - const rows = conditions.length > 0 - ? await query.where(and(...conditions)) - : await query; - const limited = options.limit !== undefined ? rows.slice(0, options.limit) : rows; - const offsetted = options.offset !== undefined ? limited.slice(options.offset) : limited; - return offsetted.map(rowToRun); + .$dynamic(); + if (conditions.length > 0) query = query.where(and(...conditions)); + query = options.order === "desc" + ? query.orderBy(desc(schema.project.evalRuns.createdAt), desc(schema.project.evalRuns.id)) + : query.orderBy(asc(schema.project.evalRuns.createdAt), asc(schema.project.evalRuns.id)); + if (options.offset !== undefined) query = query.offset(options.offset); + if (options.limit !== undefined) query = query.limit(options.limit); + return (await query).map(rowToRun); } /** @@ -430,6 +432,23 @@ export class AsyncEvalStore { return run; } + /* + FNXC:ScheduledEvalsPostgres 2026-07-14-01:41: + PostgreSQL scheduled batches require one serialized lifecycle mutation per run. Hold a transaction-scoped run lock across the authoritative read, applyEvalRunUpdate validation, and persistence so concurrent terminal transitions cannot both validate a stale active row and the later writer cannot overwrite the committed terminal state. Preserve terminal immutability, transition validation, nullable clears, and metadata/provenance merge semantics through the shared helper. + */ + async updateRun(id: string, input: EvalRunUpdateInput): Promise { + return this.layer.transactionImmediate(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`fusion:eval-run-update:${id}`}, 0))`, + ); + const existing = await getEvalRun(tx, id); + if (!existing) return undefined; + const updated = applyEvalRunUpdate(existing, input); + await persistEvalRun(tx, updated); + return updated; + }); + } + async getTaskResult(id: string): Promise { return getEvalTaskResult(this.layer.db, id); } diff --git a/packages/core/src/async-todo-store.ts b/packages/core/src/async-todo-store.ts index e7c20c96e4..2815906955 100644 --- a/packages/core/src/async-todo-store.ts +++ b/packages/core/src/async-todo-store.ts @@ -20,9 +20,11 @@ * integration tests consume. They program against the stable * `AsyncDataLayer` interface (U4), not the underlying driver. */ +import { EventEmitter } from "node:events"; import { and, asc, eq, inArray, sql } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; +import type { TodoStoreEvents } from "./todo-store.js"; import type { TodoList, TodoItem, @@ -350,12 +352,16 @@ export async function getTodoListsWithItems( * the list-existence check mirror the sync store; sortOrder auto-assignment and * the completed→completedAt toggle live in the helper functions above. * - * Known gap vs the sync store: the sync TodoStore is an EventEmitter that emits - * list:created/item:updated/… for SSE live-refresh. This wrapper performs the - * CRUD only; UI updates land on the next read/refresh, not via live events. + * FNXC:PostgresMigrationCoverage 2026-07-13-22:54: + * The dashboard's SSE refresh path depends on the TodoStore event contract, so + * the PostgreSQL implementation must emit the same event names and payloads as + * the former SQLite store after successful mutations. */ -export class AsyncTodoStore { - constructor(private readonly layer: AsyncDataLayer) {} +export class AsyncTodoStore extends EventEmitter { + constructor(private readonly layer: AsyncDataLayer) { + super(); + this.setMaxListeners(50); + } private static newId(prefix: "TDL" | "TDI"): string { const timestamp = Date.now().toString(36).toUpperCase(); @@ -369,21 +375,27 @@ export class AsyncTodoStore { async createList(projectId: string, input: TodoListCreateInput): Promise { const now = new Date().toISOString(); - return createTodoList(this.layer.db, { + const list = await createTodoList(this.layer.db, { id: AsyncTodoStore.newId("TDL"), projectId, title: input.title, createdAt: now, updatedAt: now, }); + this.emit("list:created", list); + return list; } async updateList(id: string, input: TodoListUpdateInput): Promise { - return updateTodoList(this.layer.db, id, input); + const updated = await updateTodoList(this.layer.db, id, input); + if (updated) this.emit("list:updated", updated); + return updated; } async deleteList(id: string): Promise { - return deleteTodoList(this.layer.db, id); + const deleted = await deleteTodoList(this.layer.db, id); + if (deleted) this.emit("list:deleted", id); + return deleted; } async createItem(listId: string, input: TodoItemCreateInput): Promise { @@ -394,7 +406,7 @@ export class AsyncTodoStore { throw new Error(`Todo list ${listId} not found`); } const now = new Date().toISOString(); - return createTodoItem(this.layer.db, { + const item = await createTodoItem(this.layer.db, { id: AsyncTodoStore.newId("TDI"), listId, text: input.text, @@ -404,17 +416,25 @@ export class AsyncTodoStore { createdAt: now, updatedAt: now, }); + this.emit("item:created", item); + return item; } async updateItem(id: string, input: TodoItemUpdateInput): Promise { - return updateTodoItem(this.layer.db, id, input); + const updated = await updateTodoItem(this.layer.db, id, input); + if (updated) this.emit("item:updated", updated); + return updated; } async deleteItem(id: string): Promise { - return deleteTodoItem(this.layer.db, id); + const deleted = await deleteTodoItem(this.layer.db, id); + if (deleted) this.emit("item:deleted", id); + return deleted; } async reorderItems(listId: string, itemIds: string[]): Promise { - return reorderTodoItems(this.layer, listId, itemIds); + const items = await reorderTodoItems(this.layer, listId, itemIds); + this.emit("items:reordered", { listId, items }); + return items; } } diff --git a/packages/core/src/automation-store.ts b/packages/core/src/automation-store.ts index de2c3d243b..7b194f00a1 100644 --- a/packages/core/src/automation-store.ts +++ b/packages/core/src/automation-store.ts @@ -26,6 +26,7 @@ import { listSchedules as listSchedulesAsync, deleteSchedule as deleteScheduleAsync, getDueSchedules as getDueSchedulesAsync, + claimDueSchedule as claimDueScheduleAsync, } from "./async-automation-store.js"; const CRON_TIMEZONE = "UTC"; @@ -219,7 +220,7 @@ export class AutomationStore extends EventEmitter { private async readScheduleJson(id: string): Promise { if (this.backendMode) { - return getScheduleAsync(this.asyncLayer!.db, id); + return getScheduleAsync(this.asyncLayer!, id); } const row = this.db.prepare('SELECT * FROM automations WHERE id = ?').get(id) as unknown as ScheduleRow | undefined; if (!row) { @@ -230,7 +231,7 @@ export class AutomationStore extends EventEmitter { private async persistSchedule(schedule: ScheduledTask): Promise { if (this.backendMode) { - await upsertScheduleAsync(this.asyncLayer!.db, schedule); + await upsertScheduleAsync(this.asyncLayer!, schedule); return; } this.upsertSchedule(schedule); @@ -320,14 +321,14 @@ export class AutomationStore extends EventEmitter { async getSchedule(id: string): Promise { if (this.backendMode) { - return getScheduleAsync(this.asyncLayer!.db, id); + return getScheduleAsync(this.asyncLayer!, id); } return this.readScheduleJson(id); } async listSchedules(): Promise { if (this.backendMode) { - return listSchedulesAsync(this.asyncLayer!.db); + return listSchedulesAsync(this.asyncLayer!); } const rows = this.db.prepare('SELECT * FROM automations ORDER BY createdAt ASC').all() as unknown as ScheduleRow[]; return rows.map((row) => this.rowToSchedule(row)); @@ -444,7 +445,7 @@ export class AutomationStore extends EventEmitter { return this.withScheduleLock(id, async () => { const schedule = await this.getSchedule(id); if (this.backendMode) { - await deleteScheduleAsync(this.asyncLayer!.db, id); + await deleteScheduleAsync(this.asyncLayer!, id); } else { // Delete from SQLite this.db.prepare('DELETE FROM automations WHERE id = ?').run(id); @@ -459,10 +460,28 @@ export class AutomationStore extends EventEmitter { * Atomically claim one due schedule occurrence before execution. * * FNXC:Automations 2026-06-27-00:00: - * Claiming advances nextRunAt before executing the schedule so concurrent CronRunner pollers, overlapping scopes, and separate engine processes sharing one SQLite DB cannot double-fire the same due window. The conditional UPDATE is the cross-process claim boundary; losers observe zero changed rows and skip execution. + * Claiming advances nextRunAt before executing the schedule so concurrent CronRunner pollers, overlapping scopes, and separate engine processes sharing one database cannot double-fire the same due window. The conditional UPDATE is the cross-process claim boundary; losers observe zero changed rows and skip execution. + * + * FNXC:AutomationIsolation 2026-07-13-22:37: + * In PostgreSQL mode both the preliminary read and conditional claim use the bound AsyncDataLayer so a duplicate automation ID in another project cannot be observed or advanced. */ async claimDueSchedule(id: string, expectedNextRunAt: string): Promise { return this.withScheduleLock(id, async () => { + if (this.backendMode) { + const schedule = await getScheduleAsync(this.asyncLayer!, id).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + }); + if (!schedule?.enabled || !schedule.nextRunAt) return false; + return claimDueScheduleAsync( + this.asyncLayer!, + id, + expectedNextRunAt, + this.computeNextRun(schedule.cronExpression), + new Date().toISOString(), + ); + } + const row = this.db.prepare( 'SELECT id, cronExpression, enabled, nextRunAt FROM automations WHERE id = ?', ).get(id) as unknown as Pick | undefined; @@ -525,7 +544,7 @@ export class AutomationStore extends EventEmitter { async getDueSchedules(scope: "global" | "project"): Promise { const now = new Date().toISOString(); if (this.backendMode) { - return getDueSchedulesAsync(this.asyncLayer!.db, now, scope); + return getDueSchedulesAsync(this.asyncLayer!, now, scope); } const rows = this.db.prepare( 'SELECT * FROM automations WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ? AND scope = ?' @@ -540,7 +559,7 @@ export class AutomationStore extends EventEmitter { async getDueSchedulesAllScopes(): Promise { const now = new Date().toISOString(); if (this.backendMode) { - return getDueSchedulesAsync(this.asyncLayer!.db, now); + return getDueSchedulesAsync(this.asyncLayer!, now); } const rows = this.db.prepare( 'SELECT * FROM automations WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ?' diff --git a/packages/core/src/command-center-live.ts b/packages/core/src/command-center-live.ts index 4116a65d91..58ab590989 100644 --- a/packages/core/src/command-center-live.ts +++ b/packages/core/src/command-center-live.ts @@ -212,6 +212,13 @@ export async function composeLiveSnapshot( */ async function composeLiveSnapshotAsync(layer: AsyncDataLayer, now?: number): Promise { const capturedAt = new Date(now ?? Date.now()).toISOString(); + /* + FNXC:PostgresCommandCenterAnalytics 2026-07-14-00:49: + An unbound live Command Center layer is deliberately project-agnostic. Sessions, heartbeat runs, and board-column counts must all omit the project predicate together, while an explicitly bound layer remains isolated to its project. + */ + const projectScope = layer.projectId !== undefined + ? sql`AND project_id = ${layer.projectId}` + : sql``; const sessionRows = (await layer.db.execute( sql`SELECT id, @@ -223,6 +230,7 @@ async function composeLiveSnapshotAsync(layer: AsyncDataLayer, now?: number): Pr updated_at AS "updatedAt" FROM project.cli_sessions WHERE agent_state NOT IN ('done', 'dead') + ${projectScope} AND termination_reason IS NULL ORDER BY updated_at DESC`, )) as Array>; @@ -245,7 +253,7 @@ async function composeLiveSnapshotAsync(layer: AsyncDataLayer, now?: number): Pr const runRows = (await layer.db.execute( sql`SELECT id, agent_id AS "agentId", started_at AS "startedAt", data FROM project.agent_runs - WHERE status = 'active' + WHERE status = 'active' ${projectScope} ORDER BY started_at DESC`, )) as Array<{ id: string; agentId: string; startedAt: string; data: unknown }>; const runs: LiveRun[] = runRows.map((r) => { @@ -260,6 +268,7 @@ async function composeLiveSnapshotAsync(layer: AsyncDataLayer, now?: number): Pr const columnRows = (await layer.db.execute( sql`SELECT "column" AS column, count(*)::int AS count FROM project.tasks + WHERE 1=1 ${projectScope} GROUP BY "column" ORDER BY count DESC`, )) as Array<{ column: string; count: number }>; diff --git a/packages/core/src/eval-automation.ts b/packages/core/src/eval-automation.ts index e201ae3f29..5330d3c57d 100644 --- a/packages/core/src/eval-automation.ts +++ b/packages/core/src/eval-automation.ts @@ -1,7 +1,8 @@ import type { AutomationStore } from "./automation-store.js"; import type { ScheduledTask, ScheduledTaskCreateInput } from "./automation.js"; import type { EvalRun, EvalTaskResultCreateInput } from "./eval-types.js"; -import { EvalLifecycleError, EvalStore } from "./eval-store.js"; +import { EvalLifecycleError } from "./eval-store.js"; +import type { EvalStore } from "./eval-store.js"; import type { AsyncEvalStore } from "./async-eval-store.js"; import type { ProjectSettings, Task } from "./types.js"; @@ -98,12 +99,7 @@ export type CompletedTaskEvaluator = ( export interface EvalBatchTaskStore { listTasks(options?: { column?: string }): Promise; - // FNXC:Evals 2026-06-27-12:45: - // Widened to the TaskStore union so a backend-mode TaskStore satisfies this - // interface at the type level. runScheduledEvalBatch is a sync-EvalStore path - // (heavy lifecycle chaining); it instanceof-narrows to the sync EvalStore and - // fails fast under PG backend mode (scheduled eval batches are out of scope - // for the PG migration's dashboard-read fixes). + // Both stores expose the same lifecycle API; the batch awaits every call. getEvalStore(): EvalStore | AsyncEvalStore; } @@ -128,23 +124,17 @@ export async function runScheduledEvalBatch( ): Promise { const startedAt = params.startedAt ?? new Date().toISOString(); const evalStore = params.store.getEvalStore(); - if (!(evalStore instanceof EvalStore)) { - // Scheduled eval batches rely on the synchronous SQLite EvalStore's - // lifecycle chaining; the PG-backed AsyncEvalStore path is not yet wired - // for this flow (out of scope for the dashboard-read PG fixes). - throw new Error("Scheduled eval batch requires the synchronous EvalStore (not available in PostgreSQL backend mode)"); - } - const priorRuns = evalStore - .listRuns({ projectId: params.projectId, trigger: "schedule" }) - .filter((run) => run.status === "completed") - .sort((a, b) => { - const aWindowEnd = (a.metadata?.windowEndInclusive as string | undefined) ?? a.window.until ?? ""; - const bWindowEnd = (b.metadata?.windowEndInclusive as string | undefined) ?? b.window.until ?? ""; - if (aWindowEnd !== bWindowEnd) return aWindowEnd.localeCompare(bWindowEnd); - return a.id.localeCompare(b.id); - }); - - const previousScheduledBatch = priorRuns.at(-1); + /* + FNXC:ScheduledEvalsPostgres 2026-07-13-22:38: + Scheduled evaluation is a backend-independent operator workflow. Await the shared EvalStore/AsyncEvalStore contract throughout so PostgreSQL performs the same window selection, lifecycle transitions, scoring, and audit-event writes as the legacy synchronous path. + */ + const [previousScheduledBatch] = await evalStore.listRuns({ + projectId: params.projectId, + trigger: "schedule", + status: "completed", + order: "desc", + limit: 1, + }); const windowStartExclusive = (previousScheduledBatch?.metadata?.windowEndInclusive as string | undefined) ?? previousScheduledBatch?.window.until; @@ -152,7 +142,7 @@ export async function runScheduledEvalBatch( let run: EvalRun; try { - run = evalStore.createRun({ + run = await evalStore.createRun({ projectId: params.projectId, trigger: "schedule", scope: "completed-tasks", @@ -172,14 +162,14 @@ export async function runScheduledEvalBatch( throw error; } - evalStore.appendRunEvent(run.id, { + await evalStore.appendRunEvent(run.id, { type: "info", message: "Scheduled eval batch started", status: "pending", metadata: { windowStartExclusive, windowEndInclusive }, }); - evalStore.updateRun(run.id, { status: "running", startedAt }); + await evalStore.updateRun(run.id, { status: "running", startedAt }); try { const doneTasks = (await params.store.listTasks({ column: "done" })).filter((task) => @@ -198,7 +188,7 @@ export async function runScheduledEvalBatch( }); const selectedTaskIds = doneTasks.map((task) => task.id); - evalStore.updateRun(run.id, { + await evalStore.updateRun(run.id, { counts: { totalTasks: selectedTaskIds.length, scoredTasks: 0, skippedTasks: 0, erroredTasks: 0 }, metadata: { windowStartExclusive, @@ -209,13 +199,13 @@ export async function runScheduledEvalBatch( }); if (doneTasks.length === 0) { - evalStore.appendRunEvent(run.id, { + await evalStore.appendRunEvent(run.id, { type: "info", status: "completed", message: "Scheduled eval batch completed with no newly done tasks", metadata: { tasksSelected: 0 }, }); - evalStore.updateRun(run.id, { + await evalStore.updateRun(run.id, { status: "completed", completedAt: new Date().toISOString(), summary: "No newly completed tasks found in evaluation window", @@ -245,7 +235,7 @@ export async function runScheduledEvalBatch( window: { windowStartExclusive, windowEndInclusive }, }); - evalStore.createTaskResult(run.id, { + await evalStore.createTaskResult(run.id, { ...result, taskId: task.id, taskSnapshot: { @@ -268,7 +258,7 @@ export async function runScheduledEvalBatch( else if (result.status === "skipped") skippedTasks += 1; else erroredTasks += 1; - evalStore.appendRunEvent(run.id, { + await evalStore.appendRunEvent(run.id, { type: "task_evaluated", message: `Evaluated task ${task.id}`, taskId: task.id, @@ -276,7 +266,7 @@ export async function runScheduledEvalBatch( }); } catch (error) { erroredTasks += 1; - evalStore.appendRunEvent(run.id, { + await evalStore.appendRunEvent(run.id, { type: "error", message: `Failed evaluating task ${task.id}`, taskId: task.id, @@ -285,7 +275,7 @@ export async function runScheduledEvalBatch( } } - evalStore.updateRun(run.id, { + await evalStore.updateRun(run.id, { status: "completed", evaluatedTaskIds, counts: { @@ -304,7 +294,7 @@ export async function runScheduledEvalBatch( }, }); - evalStore.appendRunEvent(run.id, { + await evalStore.appendRunEvent(run.id, { type: "status_changed", status: "completed", message: `Scheduled eval batch completed (${doneTasks.length} tasks selected)`, @@ -320,7 +310,7 @@ export async function runScheduledEvalBatch( tasksSelected: selectedTaskIds.length, }; } catch (error) { - evalStore.updateRun(run.id, { + await evalStore.updateRun(run.id, { status: "failed", completedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error), @@ -329,7 +319,7 @@ export async function runScheduledEvalBatch( windowEndInclusive, }, }); - evalStore.appendRunEvent(run.id, { + await evalStore.appendRunEvent(run.id, { type: "error", status: "failed", message: "Scheduled eval batch failed", diff --git a/packages/core/src/eval-store.ts b/packages/core/src/eval-store.ts index 500b370684..e989df6cc6 100644 --- a/packages/core/src/eval-store.ts +++ b/packages/core/src/eval-store.ts @@ -42,6 +42,34 @@ export class EvalLifecycleError extends Error { } } +/* +FNXC:ScheduledEvalsPostgres 2026-07-13-23:18: +SQLite and PostgreSQL eval stores must enforce one lifecycle invariant. Keep transition validation, terminal immutability, nullable clears, and metadata/provenance merging in this pure helper so persistence adapters cannot drift. +*/ +export function applyEvalRunUpdate( + existing: EvalRun, + input: EvalRunUpdateInput, + updatedAt = new Date().toISOString(), +): EvalRun { + if (TERMINAL_STATUSES.has(existing.status) && Object.keys(input).some((key) => key !== "status")) { + throw new EvalLifecycleError(`Eval run ${existing.id} is terminal and immutable`, "terminal_immutable"); + } + if (input.status && input.status !== existing.status && !VALID_TRANSITIONS[existing.status].includes(input.status)) { + throw new EvalLifecycleError(`Invalid eval run status transition: ${existing.status} -> ${input.status}`, "invalid_transition"); + } + return { + ...existing, + ...input, + error: input.error === null ? undefined : (input.error ?? existing.error), + metadata: input.metadata ? { ...(existing.metadata ?? {}), ...input.metadata } : existing.metadata, + provenance: input.provenance ? { ...(existing.provenance ?? {}), ...input.provenance } : existing.provenance, + updatedAt, + startedAt: input.startedAt === null ? undefined : (input.startedAt ?? existing.startedAt), + completedAt: input.completedAt === null ? undefined : (input.completedAt ?? existing.completedAt), + cancelledAt: input.cancelledAt === null ? undefined : (input.cancelledAt ?? existing.cancelledAt), + }; +} + function generateRunId(): string { return `ER-${Date.now().toString(36).toUpperCase()}-${Math.random().toString(36).slice(2, 7).toUpperCase()}`; } @@ -213,7 +241,7 @@ export class EvalStore extends EventEmitter { const rows = this.db.prepare(` SELECT * FROM eval_runs ${where} - ORDER BY createdAt ASC, id ASC + ORDER BY createdAt ${options.order === "desc" ? "DESC" : "ASC"}, id ${options.order === "desc" ? "DESC" : "ASC"} ${limit} ${offset} `).all(...params) as Record[]; @@ -224,29 +252,7 @@ export class EvalStore extends EventEmitter { updateRun(id: string, input: EvalRunUpdateInput): EvalRun | undefined { const existing = this.getRun(id); if (!existing) return undefined; - - if (TERMINAL_STATUSES.has(existing.status) && Object.keys(input).some((k) => k !== "status")) { - throw new EvalLifecycleError(`Eval run ${id} is terminal and immutable`, "terminal_immutable"); - } - - if (input.status && input.status !== existing.status) { - if (!VALID_TRANSITIONS[existing.status].includes(input.status)) { - throw new EvalLifecycleError(`Invalid eval run status transition: ${existing.status} -> ${input.status}`, "invalid_transition"); - } - } - - const now = new Date().toISOString(); - const updated: EvalRun = { - ...existing, - ...input, - error: input.error === null ? undefined : (input.error ?? existing.error), - metadata: input.metadata ? { ...(existing.metadata ?? {}), ...input.metadata } : existing.metadata, - provenance: input.provenance ? { ...(existing.provenance ?? {}), ...input.provenance } : existing.provenance, - updatedAt: now, - startedAt: input.startedAt === null ? undefined : (input.startedAt ?? existing.startedAt), - completedAt: input.completedAt === null ? undefined : (input.completedAt ?? existing.completedAt), - cancelledAt: input.cancelledAt === null ? undefined : (input.cancelledAt ?? existing.cancelledAt), - }; + const updated = applyEvalRunUpdate(existing, input); this.persistRun(updated); this.emit("run:updated", updated); diff --git a/packages/core/src/eval-types.ts b/packages/core/src/eval-types.ts index a7727d2fed..7d27377605 100644 --- a/packages/core/src/eval-types.ts +++ b/packages/core/src/eval-types.ts @@ -408,6 +408,7 @@ export interface EvalRunListOptions { trigger?: EvalRunTrigger; limit?: number; offset?: number; + order?: "asc" | "desc"; } export interface EvalTaskResultCreateInput { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0cb9764085..9a7a719996 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2225,6 +2225,8 @@ export { PROJECT_BACKUP_SCHEMAS, CENTRAL_BACKUP_SCHEMAS, migrateSqliteToPostgres, + isSqliteMigrationComplete, + completeSqliteMigration, defaultMigrationSources, // FNXC:CentralProjectIdentity 2026-07-13-23:10: // Post-migration project-partition stamping, shared by the startup-factory diff --git a/packages/core/src/postgres/connection.ts b/packages/core/src/postgres/connection.ts index 14485c94dc..ca0900d7c4 100644 --- a/packages/core/src/postgres/connection.ts +++ b/packages/core/src/postgres/connection.ts @@ -206,37 +206,28 @@ export async function createConnectionSetFromUrl( // Always prepare: false for migration work (DDL under a pooler must not use // prepared statements). const migrationUrl = backend.migrationUrl ?? runtimeUrl; - const migrationIsSameUrl = migrationUrl === runtimeUrl; - let migrationSql: ReturnType; - let migrationDb: PostgresJsDatabase; - - if (migrationIsSameUrl && runtimePrepare) { - // Reuse the runtime connection when there's no split and prepared statements - // are safe. This avoids opening a second pool unnecessarily. - migrationSql = runtimeSql; - migrationDb = runtimeDb; - } else { - migrationSql = postgres(migrationUrl, { - max: 1, // Migration work is serial; a single direct connection suffices. - connect_timeout: connectTimeout, - idle_timeout: idleTimeout, - prepare: false, - onnotice: () => {}, - }); - migrationDb = drizzle(migrationSql); - } + /* + FNXC:PostgresMigrationSession 2026-07-14-00:05: + Migration work always owns a dedicated single-connection pool, even when runtime and migration URLs match. Session advisory locks and session_replication_role must cover the same backend session for the entire copy and must never leak trigger-disabled state into runtime traffic. + */ + const migrationSql = postgres(migrationUrl, { + max: 1, + connect_timeout: connectTimeout, + idle_timeout: idleTimeout, + prepare: false, + onnotice: () => {}, + }); + const migrationDb: PostgresJsDatabase = drizzle(migrationSql); const connections: PostgresConnections = { runtime: runtimeDb, migration: migrationDb, backend, async close() { - // Always close the migration connection first if it's separate. - const closePromises: Promise[] = []; - if (migrationSql !== runtimeSql) { - closePromises.push(migrationSql.end({ timeout: 5 })); - } - closePromises.push(runtimeSql.end({ timeout: 5 })); + const closePromises: Promise[] = [ + migrationSql.end({ timeout: 5 }), + runtimeSql.end({ timeout: 5 }), + ]; await Promise.allSettled(closePromises); }, async ping() { diff --git a/packages/core/src/postgres/index.ts b/packages/core/src/postgres/index.ts index 28121b25f7..6575101121 100644 --- a/packages/core/src/postgres/index.ts +++ b/packages/core/src/postgres/index.ts @@ -155,6 +155,8 @@ export { */ export { migrateSqliteToPostgres, + isSqliteMigrationComplete, + completeSqliteMigration, defaultMigrationSources, toSnakeCase, type SqliteMigrationSource, diff --git a/packages/core/src/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index 2b4c4e0c80..39df0d9536 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -277,6 +277,7 @@ CREATE TABLE IF NOT EXISTS project.task_workflow_selection ( ); CREATE TABLE IF NOT EXISTS project.activity_log ( + project_id text NOT NULL, id text PRIMARY KEY, timestamp text NOT NULL, type text NOT NULL, @@ -286,6 +287,7 @@ CREATE TABLE IF NOT EXISTS project.activity_log ( metadata jsonb ); CREATE INDEX IF NOT EXISTS "idxActivityLogTimestamp" ON project.activity_log(timestamp); +CREATE INDEX IF NOT EXISTS "idxActivityLogProjectTimestamp" ON project.activity_log(project_id, timestamp); CREATE INDEX IF NOT EXISTS "idxActivityLogType" ON project.activity_log(type); CREATE INDEX IF NOT EXISTS "idxActivityLogTaskId" ON project.activity_log(task_id); @@ -326,7 +328,8 @@ CREATE INDEX IF NOT EXISTS "idxTaskCommitAssociationsCommitSha" ON project.task_commit_associations(commit_sha); CREATE TABLE IF NOT EXISTS project.automations ( - id text PRIMARY KEY, + project_id text NOT NULL DEFAULT '', + id text NOT NULL, name text NOT NULL, description text, schedule_type text NOT NULL, @@ -342,7 +345,8 @@ CREATE TABLE IF NOT EXISTS project.automations ( run_history jsonb DEFAULT '[]', scope text DEFAULT 'project', created_at text NOT NULL, - updated_at text NOT NULL + updated_at text NOT NULL, + PRIMARY KEY (project_id, id) ); CREATE TABLE IF NOT EXISTS project.agents ( @@ -371,6 +375,7 @@ CREATE INDEX IF NOT EXISTS "idxAgentHeartbeatsAgentId" ON project.agent_heartbea CREATE INDEX IF NOT EXISTS "idxAgentHeartbeatsRunId" ON project.agent_heartbeats(run_id); CREATE TABLE IF NOT EXISTS project.agent_runs ( + project_id text NOT NULL, id text PRIMARY KEY, agent_id text NOT NULL, data jsonb NOT NULL, @@ -381,6 +386,7 @@ CREATE TABLE IF NOT EXISTS project.agent_runs ( FOREIGN KEY (agent_id) REFERENCES project.agents(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS "idxAgentRunsAgentIdStartedAt" ON project.agent_runs(agent_id, started_at); +CREATE INDEX IF NOT EXISTS "idxAgentRunsProjectStartedAt" ON project.agent_runs(project_id, started_at); CREATE INDEX IF NOT EXISTS "idxAgentRunsStatus" ON project.agent_runs(status); CREATE TABLE IF NOT EXISTS project.agent_task_sessions ( @@ -1114,6 +1120,7 @@ CREATE INDEX IF NOT EXISTS "idxTodoItemsListId" ON project.todo_items(list_id); CREATE INDEX IF NOT EXISTS "idxTodoItemsSortOrder" ON project.todo_items(list_id, sort_order); CREATE TABLE IF NOT EXISTS project.usage_events ( + project_id text NOT NULL, id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ts text NOT NULL, kind text NOT NULL, @@ -1127,6 +1134,7 @@ CREATE TABLE IF NOT EXISTS project.usage_events ( meta jsonb ); CREATE INDEX IF NOT EXISTS "idxUsageEventsTs" ON project.usage_events(ts); +CREATE INDEX IF NOT EXISTS "idxUsageEventsProjectTs" ON project.usage_events(project_id, ts); CREATE INDEX IF NOT EXISTS "idxUsageEventsTaskId" ON project.usage_events(task_id); CREATE INDEX IF NOT EXISTS "idxUsageEventsAgentId" ON project.usage_events(agent_id); CREATE INDEX IF NOT EXISTS "idxUsageEventsKindTs" ON project.usage_events(kind, ts); @@ -1163,7 +1171,8 @@ CREATE INDEX IF NOT EXISTS "idxKnowledgePagesUpdatedAt" CREATE TABLE IF NOT EXISTS project.deployments ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - deployment_id text NOT NULL UNIQUE, + project_id text NOT NULL DEFAULT '', + deployment_id text NOT NULL, service text, environment text, version text, @@ -1173,11 +1182,14 @@ CREATE TABLE IF NOT EXISTS project.deployments ( meta jsonb, created_at text NOT NULL ); +CREATE UNIQUE INDEX IF NOT EXISTS "idxDeploymentsProjectDeploymentId" ON project.deployments(project_id, deployment_id); +CREATE INDEX IF NOT EXISTS "idxDeploymentsProjectDeployedAt" ON project.deployments(project_id, deployed_at); CREATE INDEX IF NOT EXISTS "idxDeploymentsDeployedAt" ON project.deployments(deployed_at); CREATE INDEX IF NOT EXISTS "idxDeploymentsService" ON project.deployments(service); CREATE TABLE IF NOT EXISTS project.incidents ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id text NOT NULL DEFAULT '', incident_id text NOT NULL UNIQUE, grouping_key text NOT NULL, title text NOT NULL, @@ -1192,6 +1204,8 @@ CREATE TABLE IF NOT EXISTS project.incidents ( created_at text NOT NULL, updated_at text NOT NULL ); +CREATE INDEX IF NOT EXISTS "idxIncidentsProjectOpenedAt" ON project.incidents(project_id, opened_at); +CREATE INDEX IF NOT EXISTS "idxIncidentsProjectStatus" ON project.incidents(project_id, status); CREATE INDEX IF NOT EXISTS "idxIncidentsGroupingKey" ON project.incidents(grouping_key); CREATE INDEX IF NOT EXISTS "idxIncidentsStatus" ON project.incidents(status); CREATE INDEX IF NOT EXISTS "idxIncidentsOpenedAt" ON project.incidents(opened_at); @@ -1410,6 +1424,7 @@ CREATE TABLE IF NOT EXISTS project.approval_requests ( ); CREATE TABLE IF NOT EXISTS project.approval_request_audit_events ( + project_id text NOT NULL DEFAULT '', id text PRIMARY KEY, request_id text NOT NULL, event_type text NOT NULL, @@ -1576,6 +1591,8 @@ CREATE INDEX IF NOT EXISTS "idxApprovalRequestsTaskCreatedAt" ON project.approva -- approval_request_audit_events CREATE INDEX IF NOT EXISTS "idxApprovalRequestAuditRequestCreatedAt" ON project.approval_request_audit_events(request_id, created_at, id); +CREATE INDEX IF NOT EXISTS "idxApprovalRequestAuditProjectCreatedAt" + ON project.approval_request_audit_events(project_id, created_at); -- chat_rooms CREATE UNIQUE INDEX IF NOT EXISTS "idxChatRoomsSlug" ON project.chat_rooms(project_id, slug); @@ -1590,7 +1607,8 @@ CREATE INDEX IF NOT EXISTS "idxChatRoomMessagesRoomCreatedAt" ON project.chat_ro CREATE INDEX IF NOT EXISTS "idxChatRoomMessagesRoomId" ON project.chat_room_messages(room_id); -- automations -CREATE INDEX IF NOT EXISTS "idxAutomationsScope" ON project.automations(scope); +CREATE INDEX IF NOT EXISTS "idxAutomationsProjectScope" ON project.automations(project_id, scope); +CREATE INDEX IF NOT EXISTS "idxAutomationsProjectDue" ON project.automations(project_id, enabled, next_run_at); -- routines CREATE INDEX IF NOT EXISTS "idxRoutinesNextRunAt" ON project.routines(next_run_at); diff --git a/packages/core/src/postgres/migrations/0001_automation_project_isolation.sql b/packages/core/src/postgres/migrations/0001_automation_project_isolation.sql new file mode 100644 index 0000000000..4e402f7875 --- /dev/null +++ b/packages/core/src/postgres/migrations/0001_automation_project_isolation.sql @@ -0,0 +1,31 @@ +-- FNXC:AutomationIsolation 2026-07-14-00:05: +-- Existing PostgreSQL installations created automations without project ownership. Derive ownership only when exactly one registered project proves it; otherwise abort with operator remediation instead of silently parking schedules where no project can inspect or run them. +ALTER TABLE project.automations + ADD COLUMN IF NOT EXISTS project_id text NOT NULL DEFAULT ''; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM project.automations WHERE project_id = '') + AND (SELECT count(*) FROM central.projects) <> 1 THEN + RAISE EXCEPTION USING + MESSAGE = 'Cannot assign legacy automations to a project', + DETAIL = 'Legacy automation rows have no project_id and the central registry does not contain exactly one project.', + HINT = 'Back up the database, assign project.automations.project_id explicitly, then restart Fusion to resume migration 0001.'; + END IF; +END $$; + +UPDATE project.automations +SET project_id = (SELECT min(id) FROM central.projects) +WHERE project_id = ''; + +ALTER TABLE project.automations + DROP CONSTRAINT IF EXISTS automations_pkey; + +ALTER TABLE project.automations + ADD CONSTRAINT automations_pkey PRIMARY KEY (project_id, id); + +DROP INDEX IF EXISTS project."idxAutomationsScope"; +CREATE INDEX IF NOT EXISTS "idxAutomationsProjectScope" + ON project.automations(project_id, scope); +CREATE INDEX IF NOT EXISTS "idxAutomationsProjectDue" + ON project.automations(project_id, enabled, next_run_at); diff --git a/packages/core/src/postgres/migrations/0002_analytics_project_isolation.sql b/packages/core/src/postgres/migrations/0002_analytics_project_isolation.sql new file mode 100644 index 0000000000..c6ffad588b --- /dev/null +++ b/packages/core/src/postgres/migrations/0002_analytics_project_isolation.sql @@ -0,0 +1,55 @@ +/* +FNXC:AnalyticsIsolation 2026-07-13-23:41: +Telemetry from legacy single-project PostgreSQL installations may be assigned only when ownership is unambiguous. A multi-project database with unstamped rows must fail migration for operator repair instead of leaking analytics across projects. +*/ +DO $$ +DECLARE + project_count integer; + sole_project_id text; + unstamped_count bigint := 0; + table_unstamped_count bigint; +BEGIN + SELECT count(*), min(id) INTO project_count, sole_project_id FROM central.projects; + + IF to_regclass('project.activity_log') IS NOT NULL THEN + ALTER TABLE project.activity_log ADD COLUMN IF NOT EXISTS project_id text; + SELECT count(*) INTO table_unstamped_count FROM project.activity_log WHERE project_id IS NULL OR project_id = ''; + unstamped_count := unstamped_count + table_unstamped_count; + END IF; + IF to_regclass('project.agent_runs') IS NOT NULL THEN + ALTER TABLE project.agent_runs ADD COLUMN IF NOT EXISTS project_id text; + SELECT count(*) INTO table_unstamped_count FROM project.agent_runs WHERE project_id IS NULL OR project_id = ''; + unstamped_count := unstamped_count + table_unstamped_count; + END IF; + IF to_regclass('project.usage_events') IS NOT NULL THEN + ALTER TABLE project.usage_events ADD COLUMN IF NOT EXISTS project_id text; + SELECT count(*) INTO table_unstamped_count FROM project.usage_events WHERE project_id IS NULL OR project_id = ''; + unstamped_count := unstamped_count + table_unstamped_count; + END IF; + + IF unstamped_count > 0 AND project_count <> 1 THEN + RAISE EXCEPTION 'Cannot infer project ownership for % analytics rows across % registered projects; assign project_id before retrying migration', unstamped_count, project_count; + END IF; + + IF to_regclass('project.activity_log') IS NOT NULL THEN + IF unstamped_count > 0 THEN + UPDATE project.activity_log SET project_id = sole_project_id WHERE project_id IS NULL OR project_id = ''; + END IF; + ALTER TABLE project.activity_log ALTER COLUMN project_id SET NOT NULL; + CREATE INDEX IF NOT EXISTS "idxActivityLogProjectTimestamp" ON project.activity_log(project_id, timestamp); + END IF; + IF to_regclass('project.agent_runs') IS NOT NULL THEN + IF unstamped_count > 0 THEN + UPDATE project.agent_runs SET project_id = sole_project_id WHERE project_id IS NULL OR project_id = ''; + END IF; + ALTER TABLE project.agent_runs ALTER COLUMN project_id SET NOT NULL; + CREATE INDEX IF NOT EXISTS "idxAgentRunsProjectStartedAt" ON project.agent_runs(project_id, started_at); + END IF; + IF to_regclass('project.usage_events') IS NOT NULL THEN + IF unstamped_count > 0 THEN + UPDATE project.usage_events SET project_id = sole_project_id WHERE project_id IS NULL OR project_id = ''; + END IF; + ALTER TABLE project.usage_events ALTER COLUMN project_id SET NOT NULL; + CREATE INDEX IF NOT EXISTS "idxUsageEventsProjectTs" ON project.usage_events(project_id, ts); + END IF; +END $$; diff --git a/packages/core/src/postgres/migrations/0003_monitor_approval_project_isolation.sql b/packages/core/src/postgres/migrations/0003_monitor_approval_project_isolation.sql new file mode 100644 index 0000000000..3b7c5a3a0e --- /dev/null +++ b/packages/core/src/postgres/migrations/0003_monitor_approval_project_isolation.sql @@ -0,0 +1,55 @@ +/* +FNXC:CommandCenterTenantIsolation 2026-07-14-01:04: +Monitor and approval analytics share PostgreSQL tables across projects. Legacy rows may be assigned only when one registered project proves ownership; ambiguous data must fail closed before bound analytics can expose another tenant's events. +*/ +DO $$ +DECLARE + project_count integer; + sole_project_id text; + unstamped_count bigint := 0; + table_unstamped_count bigint; +BEGIN + SELECT count(*), min(id) INTO project_count, sole_project_id FROM central.projects; + + IF to_regclass('project.deployments') IS NOT NULL THEN + ALTER TABLE project.deployments ADD COLUMN IF NOT EXISTS project_id text; + SELECT count(*) INTO table_unstamped_count FROM project.deployments WHERE project_id IS NULL OR project_id = ''; + unstamped_count := unstamped_count + table_unstamped_count; + END IF; + IF to_regclass('project.incidents') IS NOT NULL THEN + ALTER TABLE project.incidents ADD COLUMN IF NOT EXISTS project_id text; + SELECT count(*) INTO table_unstamped_count FROM project.incidents WHERE project_id IS NULL OR project_id = ''; + unstamped_count := unstamped_count + table_unstamped_count; + END IF; + IF to_regclass('project.approval_request_audit_events') IS NOT NULL THEN + ALTER TABLE project.approval_request_audit_events ADD COLUMN IF NOT EXISTS project_id text; + SELECT count(*) INTO table_unstamped_count FROM project.approval_request_audit_events WHERE project_id IS NULL OR project_id = ''; + unstamped_count := unstamped_count + table_unstamped_count; + END IF; + + IF unstamped_count > 0 AND project_count <> 1 THEN + RAISE EXCEPTION 'Cannot infer project ownership for % monitor/approval rows across % registered projects; assign project_id before retrying migration', unstamped_count, project_count; + END IF; + + IF to_regclass('project.deployments') IS NOT NULL THEN + UPDATE project.deployments SET project_id = sole_project_id WHERE project_id IS NULL OR project_id = ''; + ALTER TABLE project.deployments ALTER COLUMN project_id SET DEFAULT ''; + ALTER TABLE project.deployments ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.deployments DROP CONSTRAINT IF EXISTS deployments_deployment_id_key; + CREATE UNIQUE INDEX IF NOT EXISTS "idxDeploymentsProjectDeploymentId" ON project.deployments(project_id, deployment_id); + CREATE INDEX IF NOT EXISTS "idxDeploymentsProjectDeployedAt" ON project.deployments(project_id, deployed_at); + END IF; + IF to_regclass('project.incidents') IS NOT NULL THEN + UPDATE project.incidents SET project_id = sole_project_id WHERE project_id IS NULL OR project_id = ''; + ALTER TABLE project.incidents ALTER COLUMN project_id SET DEFAULT ''; + ALTER TABLE project.incidents ALTER COLUMN project_id SET NOT NULL; + CREATE INDEX IF NOT EXISTS "idxIncidentsProjectOpenedAt" ON project.incidents(project_id, opened_at); + CREATE INDEX IF NOT EXISTS "idxIncidentsProjectStatus" ON project.incidents(project_id, status); + END IF; + IF to_regclass('project.approval_request_audit_events') IS NOT NULL THEN + UPDATE project.approval_request_audit_events SET project_id = sole_project_id WHERE project_id IS NULL OR project_id = ''; + ALTER TABLE project.approval_request_audit_events ALTER COLUMN project_id SET DEFAULT ''; + ALTER TABLE project.approval_request_audit_events ALTER COLUMN project_id SET NOT NULL; + CREATE INDEX IF NOT EXISTS "idxApprovalRequestAuditProjectCreatedAt" ON project.approval_request_audit_events(project_id, created_at); + END IF; +END $$; diff --git a/packages/core/src/postgres/plugin-schema-hook.ts b/packages/core/src/postgres/plugin-schema-hook.ts index 99011dddac..b8593ad96f 100644 --- a/packages/core/src/postgres/plugin-schema-hook.ts +++ b/packages/core/src/postgres/plugin-schema-hook.ts @@ -41,6 +41,7 @@ export const roadmapPluginSchemaInit: PluginSchemaInitHook = { await db.execute(sql.raw(` CREATE TABLE IF NOT EXISTS project.roadmaps ( id text PRIMARY KEY, + project_id text, title text NOT NULL, description text, created_at text NOT NULL, @@ -49,6 +50,7 @@ export const roadmapPluginSchemaInit: PluginSchemaInitHook = { CREATE TABLE IF NOT EXISTS project.roadmap_milestones ( id text PRIMARY KEY, + project_id text, roadmap_id text NOT NULL, title text NOT NULL, description text, @@ -63,6 +65,7 @@ export const roadmapPluginSchemaInit: PluginSchemaInitHook = { CREATE TABLE IF NOT EXISTS project.roadmap_features ( id text PRIMARY KEY, + project_id text, milestone_id text NOT NULL, title text NOT NULL, description text, @@ -74,6 +77,82 @@ export const roadmapPluginSchemaInit: PluginSchemaInitHook = { ); CREATE INDEX IF NOT EXISTS "idxRoadmapFeaturesMilestoneOrder" ON project.roadmap_features(milestone_id, order_index, created_at, id); + + /* + * FNXC:PluginPostgresIsolation 2026-07-13-22:37: + * Bundled plugin rows share one embedded PostgreSQL schema, so every roadmap hierarchy row must carry the bound project ID. The upgrade below derives or rejects legacy ownership before enforcing non-null, while runtime stores reject unbound layers and always filter these columns. + */ + ALTER TABLE project.roadmaps ADD COLUMN IF NOT EXISTS project_id text; + ALTER TABLE project.roadmap_milestones ADD COLUMN IF NOT EXISTS project_id text; + ALTER TABLE project.roadmap_features ADD COLUMN IF NOT EXISTS project_id text; + + /* + * FNXC:RoadmapPostgresUpgrade 2026-07-13-23:40: + * Project-bound Roadmap readers must never silently hide pre-partition PostgreSQL rows. Derive child ownership from an owned parent first, use the sole registered project only when that mapping is unambiguous, and abort schema startup when multiple/no projects leave ownership unknowable. Validate the complete hierarchy before making ownership mandatory. + */ + UPDATE project.roadmap_milestones milestone + SET project_id = roadmap.project_id + FROM project.roadmaps roadmap + WHERE milestone.roadmap_id = roadmap.id + AND (milestone.project_id IS NULL OR milestone.project_id = '') + AND roadmap.project_id IS NOT NULL + AND roadmap.project_id <> ''; + UPDATE project.roadmap_features feature + SET project_id = milestone.project_id + FROM project.roadmap_milestones milestone + WHERE feature.milestone_id = milestone.id + AND (feature.project_id IS NULL OR feature.project_id = '') + AND milestone.project_id IS NOT NULL + AND milestone.project_id <> ''; + + DO $roadmap_upgrade$ + DECLARE + unowned_count bigint; + registered_project_count bigint; + singleton_project_id text; + ownership_conflicts bigint; + BEGIN + SELECT + (SELECT count(*) FROM project.roadmaps WHERE project_id IS NULL OR project_id = '') + + (SELECT count(*) FROM project.roadmap_milestones WHERE project_id IS NULL OR project_id = '') + + (SELECT count(*) FROM project.roadmap_features WHERE project_id IS NULL OR project_id = '') + INTO unowned_count; + + IF unowned_count > 0 THEN + SELECT count(*), min(id) INTO registered_project_count, singleton_project_id + FROM central.projects; + IF registered_project_count <> 1 THEN + RAISE EXCEPTION 'Roadmap PostgreSQL upgrade cannot assign % pre-project row(s) across % registered projects', + unowned_count, registered_project_count; + END IF; + UPDATE project.roadmaps SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id = ''; + UPDATE project.roadmap_milestones SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id = ''; + UPDATE project.roadmap_features SET project_id = singleton_project_id + WHERE project_id IS NULL OR project_id = ''; + END IF; + + SELECT + (SELECT count(*) FROM project.roadmap_milestones milestone + JOIN project.roadmaps roadmap ON roadmap.id = milestone.roadmap_id + WHERE milestone.project_id IS DISTINCT FROM roadmap.project_id) + + (SELECT count(*) FROM project.roadmap_features feature + JOIN project.roadmap_milestones milestone ON milestone.id = feature.milestone_id + WHERE feature.project_id IS DISTINCT FROM milestone.project_id) + INTO ownership_conflicts; + IF ownership_conflicts > 0 THEN + RAISE EXCEPTION 'Roadmap PostgreSQL upgrade found % cross-project hierarchy relationship(s)', ownership_conflicts; + END IF; + END + $roadmap_upgrade$; + + ALTER TABLE project.roadmaps ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.roadmap_milestones ALTER COLUMN project_id SET NOT NULL; + ALTER TABLE project.roadmap_features ALTER COLUMN project_id SET NOT NULL; + CREATE INDEX IF NOT EXISTS "idxRoadmapsProject" ON project.roadmaps(project_id, created_at, id); + CREATE INDEX IF NOT EXISTS "idxRoadmapMilestonesProject" ON project.roadmap_milestones(project_id, roadmap_id, order_index, id); + CREATE INDEX IF NOT EXISTS "idxRoadmapFeaturesProject" ON project.roadmap_features(project_id, milestone_id, order_index, id); `)); }, }; @@ -124,6 +203,16 @@ export const cePluginSchemaInit: PluginSchemaInitHook = { CREATE INDEX IF NOT EXISTS "idxCeSessionsProject" ON project.ce_sessions(project_id, updated_at DESC, id); + CREATE TABLE IF NOT EXISTS project.ce_plan_handoff_claims ( + project_id text NOT NULL, + artifact_path text NOT NULL, + session_id text NOT NULL, + created_at text NOT NULL, + PRIMARY KEY (project_id, artifact_path), + CONSTRAINT ce_plan_handoff_claims_session_id_fkey + FOREIGN KEY (session_id) REFERENCES project.ce_sessions(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS project.ce_pipeline_links ( id text PRIMARY KEY, task_id text NOT NULL, @@ -168,6 +257,49 @@ export const cePluginSchemaInit: PluginSchemaInitHook = { }, }; +/** + * FNXC:WhatsAppPostgresPersistence 2026-07-13-22:37: + * WhatsApp credentials, Signal keys, replay protection, and conversation history are durable plugin data. Store them in PostgreSQL and include project_id in every key so two projects using the bundled plugin cannot share auth state or suppress each other's inbound messages. + */ +export const whatsappPluginSchemaInit: PluginSchemaInitHook = { + pluginId: "fusion-plugin-whatsapp-chat", + async init(db) { + await db.execute(sql.raw(` + CREATE TABLE IF NOT EXISTS project.whatsapp_chat_sessions ( + project_id text NOT NULL, + sender text NOT NULL, + history text NOT NULL, + updated_at text NOT NULL, + PRIMARY KEY (project_id, sender) + ); + CREATE TABLE IF NOT EXISTS project.whatsapp_chat_dedupe ( + project_id text NOT NULL, + message_id text NOT NULL, + sender text NOT NULL, + received_at text NOT NULL, + PRIMARY KEY (project_id, message_id) + ); + CREATE INDEX IF NOT EXISTS "idxWhatsAppDedupeRetention" + ON project.whatsapp_chat_dedupe(project_id, received_at); + CREATE TABLE IF NOT EXISTS project.whatsapp_auth_creds ( + project_id text NOT NULL, + id text NOT NULL, + value text NOT NULL, + updated_at text NOT NULL, + PRIMARY KEY (project_id, id) + ); + CREATE TABLE IF NOT EXISTS project.whatsapp_auth_keys ( + project_id text NOT NULL, + category text NOT NULL, + key_id text NOT NULL, + value text NOT NULL, + updated_at text NOT NULL, + PRIMARY KEY (project_id, category, key_id) + ); + `)); + }, +}; + /** * FNXC:PostgresSchema 2026-07-04-00:00: * Reports plugin schema-init hook. Creates the reports table in the project @@ -322,6 +454,7 @@ export const cliPressPluginSchemaInit: PluginSchemaInitHook = { export const DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS: readonly PluginSchemaInitHook[] = [ roadmapPluginSchemaInit, cePluginSchemaInit, + whatsappPluginSchemaInit, reportsPluginSchemaInit, cliPressPluginSchemaInit, ]; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 4d1b2cde78..febc1aa46d 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -26,14 +26,37 @@ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { sql } from "drizzle-orm"; import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type PluginSchemaInitHook } from "./plugin-schema-hook.js"; -/** The single migration version this applier knows about. */ -export const SCHEMA_BASELINE_VERSION = "0000"; +/** The latest PostgreSQL schema version known to this applier. */ +export const SCHEMA_BASELINE_VERSION = "0003"; +const INITIAL_SCHEMA_VERSION = "0000"; +const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; +const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; +/** + * FNXC:PostgresMigrationIdentity 2026-07-14-01:41: + * Each migration keeps an immutable bookkeeping identity even as SCHEMA_BASELINE_VERSION advances to newer migrations. Upgrade checks and inserts must use this dedicated 0003 identifier so a later latest-version marker cannot make an unrecorded monitor/approval migration look applied. + */ +export const MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION = "0003"; /** Bookkeeping table for the fresh Drizzle migration history. */ export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BASELINE_MIGRATION_PATH = join(__dirname, "migrations", "0000_initial.sql"); +const AUTOMATION_ISOLATION_MIGRATION_PATH = join( + __dirname, + "migrations", + "0001_automation_project_isolation.sql", +); +const ANALYTICS_ISOLATION_MIGRATION_PATH = join( + __dirname, + "migrations", + "0002_analytics_project_isolation.sql", +); +const MONITOR_APPROVAL_ISOLATION_MIGRATION_PATH = join( + __dirname, + "migrations", + "0003_monitor_approval_project_isolation.sql", +); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -80,27 +103,80 @@ export async function applySchemaBaseline( db: PostgresJsDatabase>, options: { pluginHooks?: readonly PluginSchemaInitHook[] } = {}, ): Promise<{ applied: boolean; pluginHooksRun: number }> { - await ensureBookkeepingTable(db); - const applied = await getAppliedMigrations(db); - const alreadyApplied = applied.includes(SCHEMA_BASELINE_VERSION); + /* + * FNXC:PostgresSchema 2026-07-14-00:05: + * Schema versions are a cluster-wide invariant. Serialize version discovery, + * DDL, and bookkeeping in one transaction so concurrent Fusion processes + * cannot both apply a version or race its primary-key marker. + */ + return db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('fusion:schema-applier'))`); + await ensureBookkeepingTable(tx); + const applied = await getAppliedMigrations(tx); + const baselineAlreadyApplied = applied.includes(INITIAL_SCHEMA_VERSION); + const automationIsolationAlreadyApplied = applied.includes(AUTOMATION_ISOLATION_SCHEMA_VERSION); + const analyticsIsolationAlreadyApplied = applied.includes(ANALYTICS_ISOLATION_SCHEMA_VERSION); + const monitorApprovalIsolationAlreadyApplied = applied.includes(MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION); + let schemaChanged = false; - if (!alreadyApplied) { - const baselineSql = await readBaselineMigrationSql(); - // The baseline contains multiple statements including CREATE SCHEMA, CREATE - // TABLE, CREATE INDEX, and seed INSERTs. postgres.js executes a single - // query string as one batch (simple query protocol when unparameterized). - await db.execute(sql.raw(baselineSql)); - await db.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SCHEMA_BASELINE_VERSION})`, - ); - } + if (!baselineAlreadyApplied) { + const baselineSql = await readBaselineMigrationSql(); + // The baseline contains multiple statements including CREATE SCHEMA, CREATE + // TABLE, CREATE INDEX, and seed INSERTs. postgres.js executes a single + // query string as one batch (simple query protocol when unparameterized). + await tx.execute(sql.raw(baselineSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${INITIAL_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + * FNXC:AutomationIsolation 2026-07-13-22:37: + * A database that already recorded the initial PostgreSQL baseline must still receive project-scoped automation storage. Apply this version independently of 0000; ambiguous legacy ownership fails closed before any bound cron runner can silently omit those schedules. + */ + if (!automationIsolationAlreadyApplied) { + const migrationSql = await readFile(AUTOMATION_ISOLATION_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${AUTOMATION_ISOLATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:AnalyticsIsolation 2026-07-14-00:05: + Existing PostgreSQL databases that already recorded 0001 must independently receive analytics project partitions before project-scoped readers and writers start. Keep 0002 versioned so a fresh baseline cannot hide a skipped upgrade path. + */ + if (!analyticsIsolationAlreadyApplied) { + const migrationSql = await readFile(ANALYTICS_ISOLATION_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${ANALYTICS_ISOLATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:CommandCenterTenantIsolation 2026-07-14-01:04: + Version 0003 supplies durable ownership for monitor and approval analytics. It must run independently after 0002 so databases that already accepted the earlier analytics migration cannot silently skip the remaining tenant partitions. + */ + if (!monitorApprovalIsolationAlreadyApplied) { + const migrationSql = await readFile(MONITOR_APPROVAL_ISOLATION_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } // Run plugin schema-init hooks regardless of whether the baseline was just // applied or already present — plugin tables must exist on every connection // the applier touches. The hooks are themselves idempotent (CREATE TABLE IF // NOT EXISTS), so re-running is safe. - const pluginHooks = options.pluginHooks ?? DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS; - await runPluginSchemaInitHooks(db, pluginHooks); + const pluginHooks = options.pluginHooks ?? DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS; + await runPluginSchemaInitHooks(tx, pluginHooks); - return { applied: !alreadyApplied, pluginHooksRun: pluginHooks.length }; + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; + }); } diff --git a/packages/core/src/postgres/schema/plugin.ts b/packages/core/src/postgres/schema/plugin.ts index 08fbf75bc2..80bd2bc35a 100644 --- a/packages/core/src/postgres/schema/plugin.ts +++ b/packages/core/src/postgres/schema/plugin.ts @@ -25,6 +25,8 @@ import { projectSchema } from "./project.js"; */ export const roadmaps = projectSchema.table("roadmaps", { id: text("id").primaryKey(), + /** FNXC:RoadmapPostgresUpgrade 2026-07-13-23:40: Runtime Roadmap rows always carry the project partition enforced by the plugin upgrade hook. */ + projectId: text("project_id").notNull(), title: text("title").notNull(), description: text("description"), createdAt: text("created_at").notNull(), @@ -33,6 +35,7 @@ export const roadmaps = projectSchema.table("roadmaps", { export const roadmapMilestones = projectSchema.table("roadmap_milestones", { id: text("id").primaryKey(), + projectId: text("project_id").notNull(), roadmapId: text("roadmap_id").notNull(), title: text("title").notNull(), description: text("description"), @@ -46,6 +49,7 @@ export const roadmapMilestones = projectSchema.table("roadmap_milestones", { export const roadmapFeatures = projectSchema.table("roadmap_features", { id: text("id").primaryKey(), + projectId: text("project_id").notNull(), milestoneId: text("milestone_id").notNull(), title: text("title").notNull(), description: text("description"), diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 9339674965..0d7e53b2d2 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -432,6 +432,8 @@ export const taskWorkflowSelection = projectSchema.table("task_workflow_selectio // ── Activity log ───────────────────────────────────────────────────── export const activityLog = projectSchema.table("activity_log", { + // FNXC:AnalyticsIsolation 2026-07-13-23:41: Shared PostgreSQL telemetry must carry an explicit project partition; dashboard ranges must never aggregate another project's activity. + projectId: text("project_id").notNull(), id: text("id").primaryKey(), timestamp: text("timestamp").notNull(), type: text("type").notNull(), @@ -441,6 +443,7 @@ export const activityLog = projectSchema.table("activity_log", { metadata: jsonb("metadata"), }, (t) => [ index("idxActivityLogTimestamp").on(t.timestamp), + index("idxActivityLogProjectTimestamp").on(t.projectId, t.timestamp), index("idxActivityLogType").on(t.type), index("idxActivityLogTaskId").on(t.taskId), index("idxActivityLogTaskIdTimestamp").on(t.taskId, t.timestamp), @@ -491,7 +494,12 @@ export const taskCommitAssociations = projectSchema.table("task_commit_associati // ── Automations ────────────────────────────────────────────────────── export const automations = projectSchema.table("automations", { - id: text("id").primaryKey(), + /* + * FNXC:AutomationIsolation 2026-07-13-22:37: + * Automations are partitioned by the AsyncDataLayer's project ID because embedded PostgreSQL consolidates the per-project SQLite files into one table. The composite key deliberately permits the same automation ID in two projects without allowing either project's CRUD or cron-claim path to address the other row. The empty default preserves an explicit partition for legacy and project-agnostic callers until startup stamps migrated rows. + */ + projectId: text("project_id").notNull().default(""), + id: text("id").notNull(), name: text("name").notNull(), description: text("description"), scheduleType: text("schedule_type").notNull(), @@ -509,7 +517,9 @@ export const automations = projectSchema.table("automations", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ - index("idxAutomationsScope").on(t.scope), + primaryKey({ columns: [t.projectId, t.id] }), + index("idxAutomationsProjectScope").on(t.projectId, t.scope), + index("idxAutomationsProjectDue").on(t.projectId, t.enabled, t.nextRunAt), ]); // ── Agents ─────────────────────────────────────────────────────────── @@ -542,6 +552,8 @@ export const agentHeartbeats = projectSchema.table("agent_heartbeats", { ]); export const agentRuns = projectSchema.table("agent_runs", { + // FNXC:AnalyticsIsolation 2026-07-13-23:41: Agent-run analytics are project-scoped even though run IDs remain globally unique. + projectId: text("project_id").notNull(), id: text("id").primaryKey(), agentId: text("agent_id").notNull(), data: jsonb("data").notNull(), @@ -551,6 +563,7 @@ export const agentRuns = projectSchema.table("agent_runs", { }, (t) => [ foreignKey({ columns: [t.agentId], foreignColumns: [agents.id] }).onDelete("cascade"), index("idxAgentRunsAgentIdStartedAt").on(t.agentId, t.startedAt), + index("idxAgentRunsProjectStartedAt").on(t.projectId, t.startedAt), index("idxAgentRunsStatus").on(t.status), ]); @@ -1309,6 +1322,8 @@ export const todoItems = projectSchema.table("todo_items", { // ── Usage events / plugin activations / knowledge pages / monitor ──── export const usageEvents = projectSchema.table("usage_events", { + // FNXC:AnalyticsIsolation 2026-07-13-23:41: Usage events share one PostgreSQL table, so every write and query requires the owning project ID. + projectId: text("project_id").notNull(), id: integer("id").generatedAlwaysAsIdentity().primaryKey(), ts: text("ts").notNull(), kind: text("kind").notNull(), @@ -1322,6 +1337,7 @@ export const usageEvents = projectSchema.table("usage_events", { meta: jsonb("meta"), }, (t) => [ index("idxUsageEventsTs").on(t.ts), + index("idxUsageEventsProjectTs").on(t.projectId, t.ts), index("idxUsageEventsTaskId").on(t.taskId), index("idxUsageEventsAgentId").on(t.agentId), index("idxUsageEventsKindTs").on(t.kind, t.ts), @@ -1357,7 +1373,8 @@ export const knowledgePages = projectSchema.table("knowledge_pages", { export const deployments = projectSchema.table("deployments", { id: integer("id").generatedAlwaysAsIdentity().primaryKey(), - deploymentId: text("deployment_id").notNull().unique(), + projectId: text("project_id").notNull().default(""), + deploymentId: text("deployment_id").notNull(), service: text("service"), environment: text("environment"), version: text("version"), @@ -1367,12 +1384,15 @@ export const deployments = projectSchema.table("deployments", { meta: jsonb("meta"), createdAt: text("created_at").notNull(), }, (t) => [ + uniqueIndex("idxDeploymentsProjectDeploymentId").on(t.projectId, t.deploymentId), + index("idxDeploymentsProjectDeployedAt").on(t.projectId, t.deployedAt), index("idxDeploymentsDeployedAt").on(t.deployedAt), index("idxDeploymentsService").on(t.service), ]); export const incidents = projectSchema.table("incidents", { id: integer("id").generatedAlwaysAsIdentity().primaryKey(), + projectId: text("project_id").notNull().default(""), incidentId: text("incident_id").notNull().unique(), groupingKey: text("grouping_key").notNull(), title: text("title").notNull(), @@ -1387,6 +1407,8 @@ export const incidents = projectSchema.table("incidents", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ + index("idxIncidentsProjectOpenedAt").on(t.projectId, t.openedAt), + index("idxIncidentsProjectStatus").on(t.projectId, t.status), index("idxIncidentsGroupingKey").on(t.groupingKey), index("idxIncidentsStatus").on(t.status), index("idxIncidentsOpenedAt").on(t.openedAt), @@ -1667,6 +1689,7 @@ export const approvalRequests = projectSchema.table("approval_requests", { ]); export const approvalRequestAuditEvents = projectSchema.table("approval_request_audit_events", { + projectId: text("project_id").notNull().default(""), id: text("id").primaryKey(), requestId: text("request_id").notNull(), eventType: text("event_type").notNull(), @@ -1677,6 +1700,7 @@ export const approvalRequestAuditEvents = projectSchema.table("approval_request_ createdAt: text("created_at").notNull(), }, (t) => [ index("idxApprovalRequestAuditRequestCreatedAt").on(t.requestId, t.createdAt, t.id), + index("idxApprovalRequestAuditProjectCreatedAt").on(t.projectId, t.createdAt), ]); export const chatRooms = projectSchema.table("chat_rooms", { diff --git a/packages/core/src/postgres/sqlite-migrator.ts b/packages/core/src/postgres/sqlite-migrator.ts index 47ff2a318c..7a7c6f9e5e 100644 --- a/packages/core/src/postgres/sqlite-migrator.ts +++ b/packages/core/src/postgres/sqlite-migrator.ts @@ -136,6 +136,10 @@ interface TablePlan { */ readonly pgTable: string; readonly columns: readonly ColumnMapping[]; + /** Bound project identity injected into partitioned PostgreSQL tables. */ + readonly partitionProjectId?: string; + /** Why a source table has no target mapping, when it is intentionally disposable. */ + readonly allowedSkipReason?: string; } /** Per-table migration result. */ @@ -169,6 +173,49 @@ export interface MigrationOptions { * the caller guarantees the schema is already present. */ readonly skipBaseline?: boolean; + /** Project partition used when importing one project's legacy databases into a shared cluster. */ + readonly projectId?: string; + /** Durable identity used to serialize and record one project's cutover. */ + readonly migrationKey?: string; + /** Leave a verified migration running until caller-side project stamping succeeds. */ + readonly deferCompletion?: boolean; +} + +const SQLITE_MIGRATION_STATE_TABLE = "fusion_sqlite_migrations"; + +async function ensureMigrationStateTable(db: PostgresJsDatabase>): Promise { + await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS public.${SQLITE_MIGRATION_STATE_TABLE} ( + migration_key text PRIMARY KEY, + project_id text, + status text NOT NULL CHECK (status IN ('running', 'complete', 'failed')), + last_error text, + updated_at timestamptz NOT NULL DEFAULT now() + )`)); +} + +/** Return true only after a fully verified cutover records its durable marker. */ +export async function isSqliteMigrationComplete( + db: PostgresJsDatabase>, + migrationKey: string, +): Promise { + await ensureMigrationStateTable(db); + const rows = (await db.execute(sql` + SELECT status FROM public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} + WHERE migration_key = ${migrationKey} + `)) as unknown as Array<{ status: string }>; + return rows[0]?.status === "complete"; +} + +/** Mark caller-side stamping and verification complete for a durable cutover. */ +export async function completeSqliteMigration( + db: PostgresJsDatabase>, + migrationKey: string, +): Promise { + await db.execute(sql` + UPDATE public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} + SET status = 'complete', last_error = NULL, updated_at = now() + WHERE migration_key = ${migrationKey} + `); } /** @@ -189,16 +236,90 @@ export async function migrateSqliteToPostgres( migrationDb: PostgresJsDatabase>, sources: readonly SqliteMigrationSource[], options: MigrationOptions = {}, +): Promise { + const migrationKey = options.migrationKey ?? `project:${options.projectId ?? "unbound"}`; + try { + /* + FNXC:PostgresMigrationSession 2026-07-14-00:05: + Pin the complete cutover to one transaction-backed PostgreSQL session. Advisory locking, trigger deferral, copy, verification, and reset must not hop across connections when callers provide a multi-connection pool. + */ + return await migrationDb.transaction((tx) => + migrateSqliteToPostgresOnSession( + tx as unknown as PostgresJsDatabase>, + sources, + options, + ), + ); + } catch (error) { + if (options.dryRun !== true) { + await migrationDb.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('fusion:sqlite-migration-state'))`); + await ensureMigrationStateTable(tx); + await tx.execute(sql` + INSERT INTO public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} + (migration_key, project_id, status, last_error, updated_at) + VALUES (${migrationKey}, ${options.projectId ?? null}, 'failed', ${error instanceof Error ? error.message : String(error)}, now()) + ON CONFLICT (migration_key) DO UPDATE + SET project_id = EXCLUDED.project_id, status = 'failed', last_error = EXCLUDED.last_error, updated_at = now() + `); + }); + } + throw error; + } +} + +async function migrateSqliteToPostgresOnSession( + migrationDb: PostgresJsDatabase>, + sources: readonly SqliteMigrationSource[], + options: MigrationOptions, ): Promise { const dryRun = options.dryRun === true; + const migrationKey = options.migrationKey ?? `project:${options.projectId ?? "unbound"}`; + + /* + * FNXC:PostgresMigration 2026-07-14-00:05: + * A failed cutover may already have copied rows. Serialize each project on + * the migration connection and persist completion only after every table + * verifies; startup can then retry idempotently instead of treating any + * copied task as proof that the whole migration finished. + */ + if (!dryRun) { + await migrationDb.execute(sql`SELECT pg_advisory_xact_lock(hashtext('fusion:sqlite-migration-state'))`); + await ensureMigrationStateTable(migrationDb); + /* + * FNXC:PostgresMigrationSession 2026-07-14-00:14: + * Hold project serialization through transaction commit. Releasing a + * session lock before commit lets the next cutover block on the prior + * transaction's migration-state row and can deadlock pooled callers. + */ + await migrationDb.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${migrationKey}, 0))`); + await migrationDb.execute(sql` + INSERT INTO public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} + (migration_key, project_id, status, last_error, updated_at) + VALUES (${migrationKey}, ${options.projectId ?? null}, 'running', NULL, now()) + ON CONFLICT (migration_key) DO UPDATE + SET project_id = EXCLUDED.project_id, status = 'running', last_error = NULL, updated_at = now() + `); + } // 1. Apply the schema baseline (idempotent). In dry-run we still need to // read the PostgreSQL column types, so the schema must exist. If the // caller set skipBaseline, assume it's already there. let appliedBaseline = false; - if (!options.skipBaseline) { - const result = await applySchemaBaseline(migrationDb); - appliedBaseline = result.applied; + try { + if (!options.skipBaseline) { + const result = await applySchemaBaseline(migrationDb); + appliedBaseline = result.applied; + } + } catch (error) { + if (!dryRun) { + await migrationDb.execute(sql` + UPDATE public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} + SET status = 'failed', last_error = ${error instanceof Error ? error.message : String(error)}, updated_at = now() + WHERE migration_key = ${migrationKey} + `); + } + throw error; } const tableResults: TableMigrationResult[] = []; @@ -230,9 +351,10 @@ export async function migrateSqliteToPostgres( } } + let copyError: unknown; try { for (const source of sources) { - const plan = await buildMigrationPlan(migrationDb, source); + const plan = await buildMigrationPlan(migrationDb, source, options.projectId); for (const tablePlan of plan) { const result = await migrateTable(migrationDb, source, tablePlan, dryRun); tableResults.push(result); @@ -255,6 +377,8 @@ export async function migrateSqliteToPostgres( } } } + } catch (error) { + copyError = error; } finally { // Re-enable FK enforcement (triggers) after the copy, regardless of outcome. if (!dryRun) { @@ -266,6 +390,17 @@ export async function migrateSqliteToPostgres( } } + if (copyError !== undefined) { + if (!dryRun) { + await migrationDb.execute(sql` + UPDATE public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} + SET status = 'failed', last_error = ${copyError instanceof Error ? copyError.message : String(copyError)}, updated_at = now() + WHERE migration_key = ${migrationKey} + `); + } + throw copyError; + } + const report: MigrationReport = { dryRun, sources, @@ -279,6 +414,13 @@ export async function migrateSqliteToPostgres( } else { const ok = tableResults.filter((t) => t.verified).length; const bad = tableResults.length - ok; + await migrationDb.execute(sql` + UPDATE public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} + SET status = ${bad === 0 ? (options.deferCompletion ? "running" : "complete") : "failed"}, + last_error = ${bad === 0 ? null : `${bad} table(s) failed verification`}, + updated_at = now() + WHERE migration_key = ${migrationKey} + `); log.log(`Migration complete: ${ok}/${tableResults.length} tables verified (${bad} failed verification). ${sequenceBumps.length} sequences bumped.`); } @@ -296,23 +438,47 @@ export async function migrateSqliteToPostgres( async function buildMigrationPlan( db: PostgresJsDatabase>, source: SqliteMigrationSource, + projectId?: string, ): Promise { const sqlite = openSqlite(source.sqlitePath); try { const tables = listSqliteTables(sqlite); + const ftsVirtualTables = listFtsVirtualTables(sqlite); + const targetColumnsByTable = await loadTargetColumnMetadata(db, source.pgSchema); const plans: TablePlan[] = []; for (const table of tables) { // Legacy SQLite table names are camelCase; PostgreSQL tables are // snake_case. toSnakeCase is the identity for already-snake names. const pgTable = toSnakeCase(table); - const cols = await resolveColumnMapping(db, source.pgSchema, pgTable, table, sqlite); + const { columns: cols, targetColumnNames } = resolveColumnMapping( + pgTable, + table, + sqlite, + targetColumnsByTable, + ); + const partitionProjectId = + projectId && + source.pgSchema !== CENTRAL_SCHEMA && + !cols.some((column) => column.pgName === "project_id") && + targetColumnNames.has("project_id") + ? projectId + : undefined; if (cols.length === 0) { - // Table exists in SQLite but has no mappable columns in PostgreSQL — - // skip it (e.g. FTS5 shadow tables). Logged at the table-migration - // step, not here. + /* + FNXC:PostgresMigration 2026-07-13-22:37: + The migration report is the completeness contract for cutover. Preserve every SQLite table in the plan; only known SQLite bookkeeping and FTS5 implementation tables may be reported as intentional skips. Any other unmapped table must remain an unverified, non-skipped result so automated startup fails closed instead of silently abandoning data. + */ + plans.push({ + pgSchema: source.pgSchema, + table, + pgTable, + columns: cols, + partitionProjectId, + allowedSkipReason: disposableSqliteTableReason(ftsVirtualTables, table), + }); continue; } - plans.push({ pgSchema: source.pgSchema, table, pgTable, columns: cols }); + plans.push({ pgSchema: source.pgSchema, table, pgTable, columns: cols, partitionProjectId }); } return plans; } finally { @@ -336,25 +502,48 @@ function openSqlite(path: string): DatabaseSync { return db; } -/** List user tables (excluding sqlite_ internal tables and FTS5 shadow tables). */ +/** List every SQLite table so the migration report can account for all source data. */ function listSqliteTables(db: DatabaseSync): string[] { const rows = db .prepare( `SELECT name, type FROM sqlite_master WHERE type = 'table' - AND name NOT LIKE 'sqlite_%' - AND name NOT LIKE '%_fts%' - AND name NOT LIKE '%_data' - AND name NOT LIKE '%_idx' - AND name NOT LIKE '%_content' - AND name NOT LIKE '%_docsize' - AND name NOT LIKE '%_config' ORDER BY name`, ) .all() as Array<{ name: string; type: string }>; return rows.map((r) => r.name); } +function listFtsVirtualTables(db: DatabaseSync): readonly string[] { + return (db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND lower(sql) LIKE '%using fts5%'`) + .all() as Array<{ name: string }>).map(({ name }) => name); +} + +/** Return the narrow allowlisted reason for SQLite-owned or FTS5-owned tables. */ +function disposableSqliteTableReason(virtualTables: readonly string[], table: string): string | undefined { + if (table === "sqlite_sequence" || table.startsWith("sqlite_stat")) { + return "SQLite internal bookkeeping table"; + } + + for (const name of virtualTables) { + /* + FNXC:PostgresMigration 2026-07-13-23:02: + An FTS5 virtual table is a logical, user-visible data surface even though SQLite stores it through shadow tables. Only the implementation-owned shadow tables may be skipped; an unmapped virtual table must fail verification so search content cannot disappear during cutover. + */ + if ( + table === `${name}_data` || + table === `${name}_idx` || + table === `${name}_content` || + table === `${name}_docsize` || + table === `${name}_config` + ) { + return `FTS5 implementation table for ${name}`; + } + } + return undefined; +} + /** * FNXC:PostgresMigration 2026-06-24-08:20: * Resolve the column mapping for a table between SQLite and PostgreSQL. @@ -373,17 +562,60 @@ function listSqliteTables(db: DatabaseSync): string[] { * - "generated" → omitted from INSERT (GENERATED ALWAYS AS, e.g. search_vector) * - "plain" → passed through verbatim * - * Returns an empty list if the table does not exist in PostgreSQL (it is a - * SQLite-only table with no PostgreSQL counterpart, e.g. an FTS5 shadow table - * that escaped the name filter). + * Returns an empty column list plus an empty target-column set when the table + * does not exist in PostgreSQL. The target-column set also lets planning detect + * a project_id partition without a second metadata query per table. */ -async function resolveColumnMapping( +interface PostgresColumnMetadata { + column_name: string; + data_type: string; + is_nullable: string; + column_default: string | null; + attidentity: string | null; + is_generated: number | string; +} + +async function loadTargetColumnMetadata( db: PostgresJsDatabase>, pgSchema: string, +): Promise> { + /* + FNXC:PostgresMigration 2026-07-13-23:24: + Migration planning must introspect a target schema once, not issue one or two catalog queries for every SQLite table. Group the verified 1:1 catalog rows by table and build each plan locally; this keeps startup cutover bounded as plugin tables grow. + */ + const rows = (await db.execute(sql` + SELECT + c.table_name, + c.column_name, + c.data_type, + c.is_nullable, + c.column_default, + a.attidentity, + CASE WHEN a.attgenerated <> '' THEN 1 ELSE 0 END AS is_generated + FROM information_schema.columns c + JOIN pg_attribute a ON a.attname = c.column_name + JOIN pg_class cls ON cls.oid = a.attrelid + JOIN pg_namespace n ON n.oid = cls.relnamespace + WHERE c.table_schema = ${pgSchema} + AND n.nspname = c.table_schema + AND cls.relname = c.table_name + AND a.attnum > 0 + `)) as unknown as Array; + const byTable = new Map(); + for (const { table_name: tableName, ...column } of rows) { + const columns = byTable.get(tableName) ?? []; + columns.push(column); + byTable.set(tableName, columns); + } + return byTable; +} + +function resolveColumnMapping( pgTable: string, table: string, sqlite: DatabaseSync, -): Promise { + targetColumnsByTable: ReadonlyMap, +): { columns: readonly ColumnMapping[]; targetColumnNames: ReadonlySet } { // PostgreSQL columns from information_schema + pg_attribute. // FNXC:PostgresMigration 2026-06-26-15:30 (fix migration-review P1 #14): // The join between information_schema.columns and pg_attribute MUST be @@ -396,39 +628,14 @@ async function resolveColumnMapping( // row for that column name across the schema and the JOIN exploded to one // arbitrary row — classifications were then random. Adding the table // predicate (cls.relname = c.table_name AND n.nspname = c.table_schema) - // makes the join 1:1 per table and the data_type deterministic. The - // table_schema/table_name predicates are also moved up into the - // information_schema WHERE so we don't even consult other tables. - const pgCols = (await db.execute(sql` - SELECT - c.column_name, - c.data_type, - c.is_nullable, - c.column_default, - a.attidentity, - CASE WHEN a.attgenerated <> '' THEN 1 ELSE 0 END AS is_generated - FROM information_schema.columns c - JOIN pg_attribute a - ON a.attname = c.column_name - JOIN pg_class cls ON cls.oid = a.attrelid - JOIN pg_namespace n ON n.oid = cls.relnamespace - WHERE c.table_schema = ${pgSchema} - AND c.table_name = ${pgTable} - AND n.nspname = c.table_schema - AND cls.relname = c.table_name - AND a.attnum > 0 - `)) as unknown as Array<{ - column_name: string; - data_type: string; - is_nullable: string; - column_default: string | null; - attidentity: string | null; - is_generated: number | string; - }>; + // makes the join 1:1 per table and the data_type deterministic. Planning now + // loads those verified rows once per schema and supplies this table's group, + // avoiding repeated catalog queries without weakening the join invariant. + const pgCols = targetColumnsByTable.get(pgTable) ?? []; if (pgCols.length === 0) { // No PostgreSQL table with this name — skip. - return []; + return { columns: [], targetColumnNames: new Set() }; } const pgByName = new Map(pgCols.map((c) => [c.column_name, c])); @@ -470,7 +677,7 @@ async function resolveColumnMapping( mapping.push({ sqliteName: sc.name, pgName, type, nullJsonbFallback }); } - return mapping; + return { columns: mapping, targetColumnNames: new Set(pgByName.keys()) }; } /** Classify a PostgreSQL column into a conversion type. */ @@ -576,6 +783,25 @@ async function migrateTable( plan: TablePlan, dryRun: boolean, ): Promise { + if (plan.columns.length === 0) { + const sqlite = openSqlite(source.sqlitePath); + try { + const countRow = sqlite.prepare(`SELECT COUNT(*) AS n FROM ${quoteIdent(plan.table)}`).get() as { n: number }; + const allowedSkip = plan.allowedSkipReason !== undefined; + return { + schema: plan.pgSchema, + table: plan.pgTable, + sourceRows: Number(countRow.n), + insertedRows: 0, + targetRows: 0, + verified: allowedSkip, + skipped: allowedSkip, + skipReason: plan.allowedSkipReason ?? "no PostgreSQL table or mappable columns", + }; + } finally { + sqlite.close(); + } + } // FNXC:PostgresMigration 2026-06-24-09:20: // Identity columns ARE copied (with OVERRIDING SYSTEM VALUE) so the actual // id values from SQLite are preserved. This is required for two reasons: @@ -590,7 +816,7 @@ async function migrateTable( if (insertableCols.length === 0) { // No insertable columns (e.g. a pure-generated table). Verify the target // exists but copy nothing. - const targetRows = await countTargetRows(db, plan.pgSchema, plan.pgTable); + const targetRows = await countTargetRows(db, plan.pgSchema, plan.pgTable, plan.partitionProjectId); return { schema: plan.pgSchema, table: plan.pgTable, @@ -623,7 +849,7 @@ async function migrateTable( table: plan.pgTable, sourceRows, insertedRows: 0, - targetRows: dryRun ? 0 : await countTargetRows(db, plan.pgSchema, plan.pgTable), + targetRows: dryRun ? 0 : await countTargetRows(db, plan.pgSchema, plan.pgTable, plan.partitionProjectId), verified: dryRun ? false : true, skipped: dryRun ? true : false, skipReason: dryRun ? "dry-run" : "no source rows", @@ -668,7 +894,7 @@ async function migrateTable( // Both layers must pass for `verified: true`. The MD5 is computed in SQL // (md5(string_agg(...)) on PostgreSQL, and a Node-side md5 over the SQLite // converted stream) so the comparison is a single short string per side. - const targetRows = await countTargetRows(db, plan.pgSchema, plan.pgTable); + const targetRows = await countTargetRows(db, plan.pgSchema, plan.pgTable, plan.partitionProjectId); const rowCountOk = targetRows === sourceRows; let contentOk = true; if (rowCountOk && sourceRows > 0) { @@ -678,6 +904,7 @@ async function migrateTable( plan.pgSchema, plan.pgTable, insertableCols, + plan.partitionProjectId, ); contentOk = sourceChecksum === targetChecksum; if (!contentOk) { @@ -728,7 +955,10 @@ async function insertBatch( hasIdentityCol: boolean, ): Promise { if (rows.length === 0) return 0; - const colList = cols.map((c) => quoteIdent(c.pgName)).join(", "); + const colList = [ + ...cols.map((c) => quoteIdent(c.pgName)), + ...(plan.partitionProjectId ? [quoteIdent("project_id")] : []), + ].join(", "); const schemaQualifiedTable = `${quoteIdent(plan.pgSchema)}.${quoteIdent(plan.pgTable)}`; // OVERRIDING SYSTEM VALUE lets us write explicit values into GENERATED ALWAYS // AS IDENTITY columns so the SQLite id is preserved (VAL-MIGRATE-002/004). @@ -750,12 +980,11 @@ async function insertBatch( return sql`${value}`; }; - const valueRowsBuilt = rows.map( - (row) => sql`(${sql.join( - cols.map((c) => buildCell(c, row[c.pgName])), - sql`, `, - )})`, - ); + const valueRowsBuilt = rows.map((row) => { + const cells = cols.map((c) => buildCell(c, row[c.pgName])); + if (plan.partitionProjectId) cells.push(sql`${plan.partitionProjectId}`); + return sql`(${sql.join(cells, sql`, `)})`; + }); /* FNXC:PostgresMigration 2026-07-13-21:05: @@ -780,9 +1009,12 @@ async function countTargetRows( db: PostgresJsDatabase>, pgSchema: string, table: string, + projectId?: string, ): Promise { const result = (await db.execute( - sql`SELECT COUNT(*)::int AS n FROM ${sql.raw(quoteIdent(pgSchema))}.${sql.raw(quoteIdent(table))}`, + projectId + ? sql`SELECT COUNT(*)::int AS n FROM ${sql.raw(quoteIdent(pgSchema))}.${sql.raw(quoteIdent(table))} WHERE project_id = ${projectId}` + : sql`SELECT COUNT(*)::int AS n FROM ${sql.raw(quoteIdent(pgSchema))}.${sql.raw(quoteIdent(table))}`, )) as unknown as Array<{ n: number }>; return Number(result[0]?.n ?? 0); } @@ -925,9 +1157,8 @@ function stableJsonStringify(value: unknown): string { * the SAME insertable columns the copy used (so unmapped/generated columns do * not pollute the checksum), applies the SAME per-cell conversion the copy * used (so a jsonb cell is checksummed in its converted form), and MD5s the - * resulting canonical row stream. Rows are sorted by their primary-key column - * (the first insertable column) so row order from SQLite (insertion order) - * does not matter. + * resulting canonical row stream. Rows are sorted by every insertable column + * so composite keys and duplicate leading values remain deterministic. * * FNXC:PostgresMigration 2026-06-26-15:50: * The checksum is computed over the CONVERTED values, not the raw SQLite @@ -942,10 +1173,10 @@ function computeSourceContentChecksum( cols: readonly ColumnMapping[], ): string { if (cols.length === 0) return ""; - const pkCol = cols[0]; // first insertable column is the identity/PK for sorting const selectCols = cols.map((c) => quoteIdent(c.sqliteName)).join(", "); + const orderCols = cols.map((c) => quoteIdent(c.sqliteName)).join(", "); const rows = sqlite - .prepare(`SELECT ${selectCols} FROM ${quoteIdent(table)} ORDER BY ${quoteIdent(pkCol.sqliteName)}`) + .prepare(`SELECT ${selectCols} FROM ${quoteIdent(table)} ORDER BY ${orderCols}`) .all() as Array>; const hash = createHash("md5"); @@ -963,8 +1194,7 @@ function computeSourceContentChecksum( /** * Compute a content checksum over the PostgreSQL target rows for a table. * Selects the SAME insertable columns the copy used and MD5s the canonical - * row stream. Rows are sorted by the same primary-key column as the source - * checksum so the two streams align row-for-row. + * row stream. Rows use the same complete ordering as the source checksum. * * jsonb columns come back from postgres.js as already-parsed JS values, and * bytea as Buffer, so canonicalizeCell handles them directly. The PostgreSQL @@ -978,14 +1208,15 @@ async function computeTargetContentChecksum( pgSchema: string, table: string, cols: readonly ColumnMapping[], + projectId?: string, ): Promise { if (cols.length === 0) return ""; - const pkCol = cols[0]; const selectCols = cols.map((c) => quoteIdent(c.pgName)).join(", "); + const orderCols = cols.map((c) => `${quoteIdent(c.pgName)} NULLS FIRST`).join(", "); const rows = (await db.execute( sql`SELECT ${sql.raw(selectCols)} FROM ${sql.raw(quoteIdent(pgSchema))}.${sql.raw( quoteIdent(table), - )} ORDER BY ${sql.raw(quoteIdent(pkCol.pgName))}`, + )}${projectId ? sql` WHERE project_id = ${projectId}` : sql``} ORDER BY ${sql.raw(orderCols)}`, )) as unknown as Array>; const hash = createHash("md5"); diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index 789ae68331..51649c4a21 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -45,7 +45,6 @@ import { join, resolve } from "node:path"; import { existsSync } from "node:fs"; -import { sql as drizzleSql } from "drizzle-orm"; import { isValidSqliteDatabaseFile } from "../sqlite-validation.js"; import { createLogger } from "../logger.js"; import { TaskStore } from "../store.js"; @@ -61,6 +60,7 @@ import { } from "./connection.js"; import { applySchemaBaseline } from "./schema-applier.js"; import { createAsyncDataLayer, type AsyncDataLayer } from "./data-layer.js"; +import { lookupRegisteredProjectIdByPath, stampMigratedProjectRows } from "./migration-stamping.js"; // FNXC:RuntimeStartupWiring 2026-06-24-10:55: // The embedded PostgreSQL lifecycle module imports the `embedded-postgres` @@ -382,32 +382,52 @@ export async function createTaskStoreForBackend( is not registered (legacy/unregistered single-project setups stay unbound, matching their unfiltered readers). */ - const lookupRegisteredProjectIdByPath = async (): Promise => { - if (!rootDir) return undefined; - try { - const projectRows = (await connections.migration.execute( - drizzleSql`SELECT id FROM central.projects WHERE path = ${rootDir} LIMIT 1`, - )) as Array<{ id: string }>; - return projectRows[0]?.id; - } catch { - return undefined; - } - }; if (rootDir) { try { const fusionDir = join(rootDir, ".fusion"); const legacySqlitePath = join(fusionDir, "fusion.db"); if (existsSync(legacySqlitePath)) { + let globalDir = options.globalSettingsDir; + if (!globalDir) { + try { + const { resolveGlobalDir } = await import("../global-settings.js"); + globalDir = resolveGlobalDir(); + } catch { + globalDir = undefined; + } + } + + let migrationProjectId = options.projectId + ?? (await lookupRegisteredProjectIdByPath(connections.migration, rootDir)); + const legacyCentralPath = globalDir ? join(globalDir, "fusion-central.db") : undefined; + if (!migrationProjectId && legacyCentralPath && existsSync(legacyCentralPath) && isValidSqliteDatabaseFile(legacyCentralPath)) { + const { DatabaseSync } = await import("../sqlite-adapter.js"); + const legacyCentral = new DatabaseSync(legacyCentralPath); + try { + const row = legacyCentral.prepare(`SELECT id FROM projects WHERE path = ? LIMIT 1`).get(rootDir) as + | { id: string } + | undefined; + migrationProjectId = row?.id; + } catch { + // A pre-registry central database leaves legacy single-project startup unbound. + } finally { + legacyCentral.close(); + } + } /* FNXC:MultiProjectIsolation 2026-07-11: With per-project task partitioning (project_id on project.tasks), the first-boot emptiness check must be scoped to THIS project — otherwise the second project booting against the shared embedded cluster sees the first project's rows and silently skips migrating its own legacy - fusion.db (the exact data-loss trap Step 5.5 exists to close). NULL - project_id rows are counted as blocking: they may be this project's - pre-isolation data, and migrating on top of them risks id collisions. - Without a bound projectId the pre-isolation whole-table check applies. + fusion.db (the exact data-loss trap Step 5.5 exists to close). + + FNXC:MultiProjectMigration 2026-07-13-22:37: + Resolve identity from PostgreSQL or the legacy central registry before + the emptiness check, then count only that partition. NULL or another + project's rows cannot suppress this migration; global-key collisions + instead surface through scoped post-copy verification and fail closed. + Without a registered identity the legacy whole-table check applies. FNXC:PostgresCutover 2026-07-13-20:50: Order matters: the PostgreSQL emptiness count runs BEFORE the SQLite @@ -419,31 +439,34 @@ export async function createTaskStoreForBackend( only on the rare empty-PG path where auto-migration is actually being considered. */ - const countRows = (await connections.migration.execute( - options.projectId - ? drizzleSql`SELECT count(*)::int AS count FROM project.tasks WHERE project_id = ${options.projectId} OR project_id IS NULL` - : drizzleSql`SELECT count(*)::int AS count FROM project.tasks`, - )) as Array<{ count: number }>; - const pgTaskCount = Number(countRows[0]?.count ?? 0); - if (pgTaskCount === 0 && isValidSqliteDatabaseFile(legacySqlitePath)) { - const { migrateSqliteToPostgres, defaultMigrationSources } = await import("./sqlite-migrator.js"); + const migrationKey = `project:${migrationProjectId ?? rootDir}`; + const { migrateSqliteToPostgres, defaultMigrationSources, isSqliteMigrationComplete, completeSqliteMigration } = await import("./sqlite-migrator.js"); + const migrationComplete = await isSqliteMigrationComplete(connections.migration, migrationKey); + if (!migrationComplete && isValidSqliteDatabaseFile(legacySqlitePath)) { // The central (global-dir) source is optional: when no global dir is // resolvable (e.g. tests without an explicit dir), migrate only the // project-local sources rather than failing the boot. - let globalDir = options.globalSettingsDir; - if (!globalDir) { - try { - const { resolveGlobalDir } = await import("../global-settings.js"); - globalDir = resolveGlobalDir(); - } catch { - globalDir = undefined; - } - } const sources = defaultMigrationSources(fusionDir, globalDir ?? join(fusionDir, "__no-global-dir__")) .filter((source) => existsSync(source.sqlitePath) && isValidSqliteDatabaseFile(source.sqlitePath)); if (sources.length > 0) { log.log(`startup-factory: empty PostgreSQL database with legacy SQLite data present — auto-migrating ${sources.length} source(s) (SQLite files are kept as backups)`); - const report = await migrateSqliteToPostgres(connections.migration, sources, { skipBaseline: true }); + const report = await migrateSqliteToPostgres(connections.migration, sources, { + skipBaseline: true, + projectId: migrationProjectId, + migrationKey, + deferCompletion: true, + }); + /* + FNXC:PostgresMigrationVerification 2026-07-13-22:37: + Startup may advertise and bind a migrated database only after every non-disposable source table passes row-count and content verification. Fail before project stamping and before the migration notice so conflicts and unmapped operator tables remain diagnosable rather than becoming a false successful cutover. + */ + const failedTables = report.tables.filter((table) => !table.skipped && !table.verified); + if (failedTables.length > 0) { + const failures = failedTables + .map((table) => `${table.schema}.${table.table} (${table.skipReason ?? `source=${table.sourceRows}, target=${table.targetRows}`})`) + .join(", "); + throw new Error(`${failedTables.length} table(s) failed verification: ${failures}`); + } const migratedRows = report.tables.reduce((sum, table) => sum + table.insertedRows, 0); /* FNXC:MultiProjectIsolation 2026-07-11: @@ -470,7 +493,8 @@ export async function createTaskStoreForBackend( centrally, leave rows NULL — readers for unregistered single-project setups use an unbound layer with no scope filter. */ - const stampProjectId = options.projectId ?? (await lookupRegisteredProjectIdByPath()); + const stampProjectId = migrationProjectId + ?? (await lookupRegisteredProjectIdByPath(connections.migration, rootDir)); if (stampProjectId) { /* FNXC:CentralProjectIdentity 2026-07-13-23:10: @@ -480,12 +504,12 @@ export async function createTaskStoreForBackend( stampMigratedProjectRows. rootDir is the pre-isolation key for the workflow tables, so it is passed alongside the stamp id. */ - const { stampMigratedProjectRows } = await import("./migration-stamping.js"); await stampMigratedProjectRows(connections.migration, { projectId: stampProjectId, rootDir, }); } + await completeSqliteMigration(connections.migration, migrationKey); /* FNXC:PostgresMigrationBanner 2026-07-12: Remember the successful auto-migration so the dashboard can show a @@ -533,7 +557,8 @@ export async function createTaskStoreForBackend( Unregistered paths resolve to undefined and boot unbound, preserving legacy single-project behavior. */ - const resolvedProjectId = options.projectId ?? (await lookupRegisteredProjectIdByPath()); + const resolvedProjectId = options.projectId + ?? (rootDir ? await lookupRegisteredProjectIdByPath(connections.migration, rootDir) : undefined); const asyncLayer = createAsyncDataLayer(connections, { projectId: resolvedProjectId }); // Step 7: construct the TaskStore in backend mode. diff --git a/packages/core/src/task-store/async-audit.ts b/packages/core/src/task-store/async-audit.ts index 1cd26d1f27..c12949929a 100644 --- a/packages/core/src/task-store/async-audit.ts +++ b/packages/core/src/task-store/async-audit.ts @@ -204,6 +204,7 @@ function rowToActivityLogEntry(row: ActivityLogRow): ActivityLogEntry { */ export async function recordActivityLogEntry( db: AsyncDataLayer["db"] | DbTransaction, + projectId: string, entry: Omit, ): Promise { const fullEntry: ActivityLogEntry = { @@ -214,6 +215,7 @@ export async function recordActivityLogEntry( try { await db.insert(schema.project.activityLog).values({ + projectId, id: fullEntry.id, timestamp: fullEntry.timestamp, type: fullEntry.type, @@ -243,9 +245,10 @@ export async function recordActivityLogEntry( */ export async function getActivityLog( db: AsyncDataLayer["db"] | DbTransaction, + projectId: string, options?: { limit?: number; since?: string; type?: ActivityEventType }, ): Promise { - const conditions = []; + const conditions = [eq(schema.project.activityLog.projectId, projectId)]; if (options?.since) { conditions.push(gte(schema.project.activityLog.timestamp, options.since)); } @@ -283,9 +286,11 @@ export async function getActivityLog( */ export async function getTaskMovedCountsByDay( db: AsyncDataLayer["db"] | DbTransaction, + projectId: string, options: { since: string; until: string; fromColumn?: string; toColumn?: string }, ): Promise> { const conditions = [ + eq(schema.project.activityLog.projectId, projectId), eq(schema.project.activityLog.type, "task:moved"), gte(schema.project.activityLog.timestamp, options.since), lte(schema.project.activityLog.timestamp, options.until), diff --git a/packages/core/src/task-store/async-events.ts b/packages/core/src/task-store/async-events.ts index 9298b5e4df..1d4420edb3 100644 --- a/packages/core/src/task-store/async-events.ts +++ b/packages/core/src/task-store/async-events.ts @@ -226,6 +226,7 @@ function rowToUsageEvent(row: Record): UsageEvent { */ export async function emitUsageEvent( db: AsyncDataLayer["db"] | DbTransaction, + projectId: string, event: UsageEventInput, ): Promise { try { @@ -235,6 +236,7 @@ export async function emitUsageEvent( const ts = event.ts ?? new Date().toISOString(); const meta = serializeMeta(event.meta); await db.insert(schema.project.usageEvents).values({ + projectId, ts, kind: event.kind, taskId: event.taskId ?? null, @@ -259,9 +261,10 @@ export async function emitUsageEvent( */ export async function queryUsageEvents( db: AsyncDataLayer["db"] | DbTransaction, + projectId: string, query: UsageEventRangeQuery = {}, ): Promise { - const conditions = []; + const conditions = [eq(schema.project.usageEvents.projectId, projectId)]; if (query.from) { conditions.push(gte(schema.project.usageEvents.ts, query.from)); } diff --git a/packages/core/src/task-store/async-monitor.ts b/packages/core/src/task-store/async-monitor.ts index a799af97db..14b9e7a676 100644 --- a/packages/core/src/task-store/async-monitor.ts +++ b/packages/core/src/task-store/async-monitor.ts @@ -165,10 +165,15 @@ function incidentFromRow(row: typeof schema.project.incidents.$inferSelect): Inc * * @param db The Drizzle instance (or transaction handle) from the AsyncDataLayer. * @param input The deployment input. + * @param projectId The owning project partition; dashboard callers pass the bound AsyncDataLayer project ID. + * + * FNXC:MonitorAnalyticsIsolation 2026-07-14-01:04: + * Monitor writes must persist tenant ownership so bound deployment and incident analytics can filter without inferring ownership from provider identifiers. */ export async function recordDeploymentAsync( db: AsyncDataLayer["db"] | DbTransaction, input: DeploymentInput, + projectId = "", ): Promise { const deploymentId = input.deploymentId?.trim() || `dep-${randomUUID()}`; const now = new Date().toISOString(); @@ -177,6 +182,7 @@ export async function recordDeploymentAsync( await db .insert(schema.project.deployments) .values({ + projectId, deploymentId, service: input.service ?? null, environment: input.environment ?? null, @@ -188,7 +194,7 @@ export async function recordDeploymentAsync( createdAt: now, }) .onConflictDoUpdate({ - target: schema.project.deployments.deploymentId, + target: [schema.project.deployments.projectId, schema.project.deployments.deploymentId], set: { service: input.service ?? null, environment: input.environment ?? null, @@ -203,7 +209,7 @@ export async function recordDeploymentAsync( const rows = await db .select() .from(schema.project.deployments) - .where(eq(schema.project.deployments.deploymentId, deploymentId)); + .where(and(eq(schema.project.deployments.projectId, projectId), eq(schema.project.deployments.deploymentId, deploymentId))); const row = rows[0]; if (!row) throw new Error(`deployment ${deploymentId} not found after upsert`); return deploymentFromRow(row); @@ -220,11 +226,12 @@ export async function recordDeploymentAsync( export async function getOpenIncidentByGroupingKeyAsync( db: AsyncDataLayer["db"] | DbTransaction, groupingKey: string, + projectId = "", ): Promise { const rows = await db .select() .from(schema.project.incidents) - .where(and(eq(schema.project.incidents.groupingKey, groupingKey), eq(schema.project.incidents.status, "open"))) + .where(and(eq(schema.project.incidents.projectId, projectId), eq(schema.project.incidents.groupingKey, groupingKey), eq(schema.project.incidents.status, "open"))) .orderBy(desc(schema.project.incidents.openedAt), desc(schema.project.incidents.id)) .limit(1); return rows[0] ? incidentFromRow(rows[0]) : null; @@ -239,11 +246,12 @@ export async function getOpenIncidentByGroupingKeyAsync( export async function getIncidentAsync( db: AsyncDataLayer["db"] | DbTransaction, incidentId: string, + projectId = "", ): Promise { const rows = await db .select() .from(schema.project.incidents) - .where(eq(schema.project.incidents.incidentId, incidentId)) + .where(and(eq(schema.project.incidents.projectId, projectId), eq(schema.project.incidents.incidentId, incidentId))) .limit(1); return rows[0] ? incidentFromRow(rows[0]) : null; } @@ -266,9 +274,10 @@ export async function getIncidentAsync( export async function ingestIncidentSignalAsync( db: AsyncDataLayer["db"] | DbTransaction, input: IncidentSignalInput, + projectId = "", ): Promise<{ incident: Incident; created: boolean }> { const now = input.at ?? new Date().toISOString(); - const existing = await getOpenIncidentByGroupingKeyAsync(db, input.groupingKey); + const existing = await getOpenIncidentByGroupingKeyAsync(db, input.groupingKey, projectId); if (existing) { // Absorb the re-firing signal into the open incident. @@ -284,7 +293,7 @@ export async function ingestIncidentSignalAsync( .update(schema.project.incidents) .set({ updatedAt: now, meta: nextMeta }) .where(eq(schema.project.incidents.incidentId, existing.incidentId)); - const updated = await getIncidentAsync(db, existing.incidentId); + const updated = await getIncidentAsync(db, existing.incidentId, projectId); return { incident: updated ?? existing, created: false }; } @@ -295,6 +304,7 @@ export async function ingestIncidentSignalAsync( [FIRST_FIRED_META_KEY]: now, }; await db.insert(schema.project.incidents).values({ + projectId, incidentId, groupingKey: input.groupingKey, title: input.title, @@ -309,7 +319,7 @@ export async function ingestIncidentSignalAsync( createdAt: now, updatedAt: now, }); - const incident = await getIncidentAsync(db, incidentId); + const incident = await getIncidentAsync(db, incidentId, projectId); if (!incident) throw new Error(`incident ${incidentId} not found after insert`); return { incident, created: true }; } @@ -326,15 +336,16 @@ export async function resolveIncidentAsync( db: AsyncDataLayer["db"] | DbTransaction, groupingKey: string, at?: string, + projectId = "", ): Promise { - const open = await getOpenIncidentByGroupingKeyAsync(db, groupingKey); + const open = await getOpenIncidentByGroupingKeyAsync(db, groupingKey, projectId); if (!open) return null; const now = at ?? new Date().toISOString(); await db .update(schema.project.incidents) .set({ status: "resolved", resolvedAt: now, updatedAt: now }) - .where(eq(schema.project.incidents.incidentId, open.incidentId)); - return getIncidentAsync(db, open.incidentId); + .where(and(eq(schema.project.incidents.projectId, projectId), eq(schema.project.incidents.incidentId, open.incidentId))); + return getIncidentAsync(db, open.incidentId, projectId); } /** diff --git a/packages/core/src/task-store/remaining-ops-1.ts b/packages/core/src/task-store/remaining-ops-1.ts index e6f13d4482..775ed81a1d 100644 --- a/packages/core/src/task-store/remaining-ops-1.ts +++ b/packages/core/src/task-store/remaining-ops-1.ts @@ -1114,7 +1114,7 @@ export async function recordActivityImpl(store: TaskStore, entry: Omit { * Drizzle instead of the SQLite-specific db.prepare() path. */ if (store.backendMode) { - await store.asyncLayer!.db.delete(schema.project.activityLog); + const layer = store.asyncLayer!; + await layer.db + .delete(schema.project.activityLog) + .where(eq(schema.project.activityLog.projectId, layer.projectId ?? "")); return; } store.db.prepare("DELETE FROM activityLog").run(); @@ -325,4 +328,3 @@ export function getTodoStoreImpl(store: TaskStore): TodoStore | AsyncTodoStore { } - diff --git a/packages/core/src/task-store/remaining-ops-2.ts b/packages/core/src/task-store/remaining-ops-2.ts index b9c43ce447..a681580eaa 100644 --- a/packages/core/src/task-store/remaining-ops-2.ts +++ b/packages/core/src/task-store/remaining-ops-2.ts @@ -1458,7 +1458,7 @@ export async function getActivityLogImpl(store: TaskStore, options?: { limit?: n // Backend-mode: delegate to the async audit helper. if (store.backendMode) { const layer = store.asyncLayer!; - return getActivityLogAsync(layer.db, options); + return getActivityLogAsync(layer.db, layer.projectId ?? "", options); } let sql = "SELECT * FROM activityLog WHERE 1=1"; const params: (string | number)[] = []; @@ -1491,4 +1491,3 @@ export async function getActivityLogImpl(store: TaskStore, options?: { limit?: n metadata: row.metadata ? JSON.parse(row.metadata) : undefined, })); } - diff --git a/packages/core/src/task-store/remaining-ops-4.ts b/packages/core/src/task-store/remaining-ops-4.ts index cd449431b1..74883aea01 100644 --- a/packages/core/src/task-store/remaining-ops-4.ts +++ b/packages/core/src/task-store/remaining-ops-4.ts @@ -669,7 +669,7 @@ export async function getTaskMovedCountsByDayImpl(store: TaskStore, options: { s // Backend-mode: delegate to the async audit helper. if (store.backendMode) { const layer = store.asyncLayer!; - return getTaskMovedCountsByDayAsync(layer.db, options); + return getTaskMovedCountsByDayAsync(layer.db, layer.projectId ?? "", options); } let sql = "SELECT substr(timestamp, 1, 10) AS day, COUNT(*) AS count FROM activityLog WHERE type = 'task:moved' AND timestamp > ? AND timestamp <= ?"; @@ -800,4 +800,3 @@ export async function upsertTaskCommitAssociationImpl(store: TaskStore, input: O ); return association; } - diff --git a/packages/core/src/task-store/remaining-ops-7.ts b/packages/core/src/task-store/remaining-ops-7.ts index e3f29a076d..f2437bf033 100644 --- a/packages/core/src/task-store/remaining-ops-7.ts +++ b/packages/core/src/task-store/remaining-ops-7.ts @@ -565,7 +565,7 @@ export async function getAttachmentImpl(store: TaskStore, export async function emitUsageEventImpl(store: TaskStore, event: UsageEventInput): Promise { if (store.backendMode) { const layer = store.asyncLayer!; - return emitUsageEventAsync(layer.db, event); + return emitUsageEventAsync(layer.db, layer.projectId ?? "", event); } return emitUsageEventToDb(store.db, event); } @@ -1086,4 +1086,3 @@ export async function getAgentLogCountImpl(store: TaskStore, taskId: string): Pr } return countAgentLogEntries(store.taskDir(taskId)); } - diff --git a/packages/core/src/tool-analytics.ts b/packages/core/src/tool-analytics.ts index ffa8fd95f1..aabf00e816 100644 --- a/packages/core/src/tool-analytics.ts +++ b/packages/core/src/tool-analytics.ts @@ -108,17 +108,25 @@ export async function countInterventions( // user-authored-steer in-range counting mirrors the sync branch exactly. if ("ping" in dbOrLayer) { const layer = dbOrLayer as AsyncDataLayer; + /* + FNXC:PostgresCommandCenterAnalytics 2026-07-14-00:49: + An unbound Command Center layer intentionally aggregates every project. Apply the optional project predicate to approval events and task-backed steering reads only when a project is explicitly bound; never reinterpret an absent binding as the empty-string partition. + */ + const projectScope = layer.projectId !== undefined + ? sql`AND project_id = ${layer.projectId}` + : sql``; const aFrom = query.from !== undefined ? sql`AND created_at >= ${query.from}` : sql``; const aTo = query.to !== undefined ? sql`AND created_at <= ${query.to}` : sql``; const approvalRows = (await layer.db.execute( sql`SELECT count(*)::int AS count FROM project.approval_request_audit_events - WHERE event_type IN ('created', 'approved') ${aFrom} ${aTo}`, + WHERE event_type IN ('created', 'approved') ${projectScope} ${aFrom} ${aTo}`, )) as Array<{ count: number }>; const approvals = approvalRows[0]?.count ?? 0; const steeringRows = (await layer.db.execute( sql`SELECT steering_comments AS "steeringComments" FROM project.tasks WHERE steering_comments IS NOT NULL + ${projectScope} AND jsonb_typeof(steering_comments) = 'array' AND jsonb_array_length(steering_comments) > 0`, )) as Array<{ steeringComments: unknown }>; @@ -202,25 +210,32 @@ export async function aggregateToolAnalytics( // run via the shared buildToolAnalytics, identical to the sync branch. if ("ping" in dbOrLayer) { const layer = dbOrLayer as AsyncDataLayer; + /* + FNXC:PostgresCommandCenterAnalytics 2026-07-14-00:49: + Tool-call totals, category groups, and session counts must share the same optional project scope. Unbound reads aggregate all partitions; explicitly bound reads remain isolated. + */ + const projectScope = layer.projectId !== undefined + ? sql`AND project_id = ${layer.projectId}` + : sql``; const eFrom = query.from !== undefined ? sql`AND ts >= ${query.from}` : sql``; const eTo = query.to !== undefined ? sql`AND ts <= ${query.to}` : sql``; const toolCallsRows = (await layer.db.execute( sql`SELECT count(*)::int AS count FROM project.usage_events - WHERE kind = 'tool_call' ${eFrom} ${eTo}`, + WHERE kind = 'tool_call' ${projectScope} ${eFrom} ${eTo}`, )) as Array<{ count: number }>; const toolCalls = toolCallsRows[0]?.count ?? 0; const categoryRows = (await layer.db.execute( sql`SELECT tool_name AS "toolName", category AS category, count(*)::int AS count FROM project.usage_events - WHERE kind = 'tool_call' ${eFrom} ${eTo} + WHERE kind = 'tool_call' ${projectScope} ${eFrom} ${eTo} GROUP BY tool_name, category`, )) as unknown as CategoryRow[]; const sessionsRows = (await layer.db.execute( sql`SELECT count(*)::int AS count FROM project.usage_events - WHERE kind = 'session_start' ${eFrom} ${eTo}`, + WHERE kind = 'session_start' ${projectScope} ${eFrom} ${eTo}`, )) as Array<{ count: number }>; const sessions = sessionsRows[0]?.count ?? 0; diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index d0ceea2547..34a43f611d 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -4,119 +4,11 @@ import { computeMaxWorkers } from "./src/__test-utils__/vitest-workers"; const maxWorkers = computeMaxWorkers(); -const quarantinedCoreTests = [ - /* - FNXC:CoreTests 2026-06-13-17:43: - The full workspace suite must not fail on suite-load-sensitive tests that pass standalone or only fail after excessive wall time. Quarantine observed core offenders after package-lane hook timeouts instead of appeasing them with wider hook timeouts. - - FNXC:CoreTests 2026-06-14-02:14: - FN-6433 re-ran the core quarantine batch after FN-6430's shared fixture cleanup and rescued all five files without timeout or assertion changes. Keep this array empty unless a future quarantine is mirrored in scripts/lib/test-quarantine.json in the same commit. - - FNXC:CoreTests 2026-06-15-03:13: - FN-6481 observed the disk-backed concurrent write test fail in the changed-package workspace lane with a transient SQLite BEGIN IMMEDIATE lock after the gate had already passed. Quarantine the flaky file instead of widening lock-recovery timeouts or weakening assertions. - - FNXC:CoreTests 2026-06-15-07:39: - FN-6486 rescued store-concurrent-writes by making the transient lock helper release independent of event-loop timer scheduling, then removed the quarantine in lockstep with scripts/lib/test-quarantine.json. Keep this array empty unless a future observed flake is mirrored in the ledger in the same commit. - - FNXC:CoreTests 2026-06-17-17:21: - FN-6596 verification observed task-list-format and test-project timing out only in the broad changed-package core lane after the merge gate had passed; both files passed immediate isolated reruns. Quarantine the suite-load flakes without widening timeouts or weakening assertions. - - FNXC:CoreTests 2026-06-17-17:55: - FN-6592 rescued mission-integration by closing every reopened TaskStore handle and strengthening restart-fidelity assertions across mission hierarchy read paths. Keep the quarantine absent in both this exclude list and scripts/lib/test-quarantine.json unless a future observed flake is mirrored in both files. - - FNXC:CoreTests 2026-06-17-19:03: - FN-6600 re-ran the core quarantine candidates under the broad-run worker budget and rescued the current core ledger entries without timeout, retry, assertion, or worker-budget appeasement. - Keep core quarantines mirrored here only when a loaded run still fails after shared teardown cleanup has been ruled out. - - FNXC:CoreTests 2026-06-19-10:00: - FN-6705 verification observed these five files fail only in the broad changed-package core lane with hook/test timeouts, ENOTEMPTY cleanup, or a missed deferred hook after the same files passed an immediate targeted rerun. Quarantine the suite-load flakes instead of widening timeouts, adding retries, or weakening assertions. - - FNXC:CoreTests 2026-06-19-10:24: - FN-6705 verification then observed settings-export time out in beforeEach only under the broad changed-package core lane while the targeted file rerun passed in 5.1s. Quarantine the suite-load hook flake instead of increasing hookTimeout. - - FNXC:CoreTests 2026-06-19-14:31: - FN-6741 reloaded the six 2026-06-19 core quarantine files under the broad @fusion/core lane and rescued them in lockstep with scripts/lib/test-quarantine.json. Keep this array empty; future core suite-load flakes must prove a remaining shared worker-root/temp-redirect or fixture close-order gap before re-quarantining. - - FNXC:CoreTests 2026-06-19-15:05: - Merge verification for FN-6741 observed store-concurrent-writes fail again under the broad @fusion/core lane with SQLite BEGIN IMMEDIATE lock exhaustion. Re-quarantine that single suite-load lock flake in lockstep with the ledger; keep the other rescued core files loaded. - - FNXC:CoreTests 2026-06-20-05:19: - FN-6790 found no task-documents quarantine half-state on HEAD and rescued the ENOENT-rename class by quiescing deferred task-created write/hook work on TaskStore.close(). Keep task-documents loaded; do not add a ledger/config exclude unless a new loaded run fails after this lifecycle seam is ruled out. - - FNXC:CoreTests 2026-06-20-09:48: - FN-6795 reloaded the remaining 2026-06-19 store-concurrent-writes quarantine under the full @fusion/core package lane and no longer reproduced SQLite BEGIN IMMEDIATE exhaustion. Rescue the file by keeping this exclude list empty in lockstep with scripts/lib/test-quarantine.json; future lock flakes need a fresh root-cause seam, not timeout/retry/worker appeasement. - - FNXC:CoreTests 2026-06-25-11:15: - The SQLite-to-PostgreSQL cutover (feature quarantine-sqlite-internals-tests) quarantines the SQLite-internals test files that exercise SQLite-only behavior (PRAGMA, FTS5, VACUUM, ATTACH DATABASE, sqlite_master, node:sqlite DatabaseSync, migration sequencing) with no PostgreSQL equivalent. PgBackupManager is now the sole production backup path and the legacy SQLite BackupManager is being removed; the 'preserves branch groups' case in backup.test.ts also fails on clean baseline with a node:sqlite ERR_INVALID_ARG_TYPE binding error (TypeError: Provided value cannot be bound to SQLite parameter 1 via sqlite-adapter.ts:96). These files are mirrored in scripts/lib/test-quarantine.json and will be DELETED when the SQLite code is removed (Phase B/C of sqlite-final-removal). PG counterparts exist for backup (postgres/pg-backup.test.ts), FTS (postgres/fts-replacement.test.ts), schema (postgres/schema-applier.test.ts), central/secrets (postgres/central-archive-secrets.test.ts, postgres/secrets-roundtrip.test.ts), and mission store (postgres/satellite-mission-store.test.ts). - */ - // SQLite-internals quarantine (cutover): see scripts/lib/test-quarantine.json. - // SQLite-path failures under Node 26 node:sqlite ERR_INVALID_ARG_TYPE binding - // (quarantined on sight per AGENTS.md flaky-test rule, same cutover batch). - // Pre-existing load-sensitive PG flake (getRatings ordering under concurrent load); - // quarantined on sight per AGENTS.md so verify:workspace goes green. - /* - FNXC:CoreTests 2026-06-25-16:30: - The SQLite-to-PostgreSQL cutover (feature delete-sqlite-runtime-final, PHASE A) - quarantines the remaining non-quarantined test files that construct a SQLite-backed - store (new TaskStore(..., {inMemoryDb: true}) / new Database(...) / new AgentStore(...) - with inMemoryDb) or use the sync SQLite data path. The SQLite runtime code - (Database class, inMemoryDb option, sync prepare()/getDatabase() surface) is being - deleted in this feature. Per the AGENTS.md flaky-test deletion ratchet, these tests - are quarantined on sight (not migrated to PG) because they exercise code that will - be deleted. PG counterparts for the critical invariants exist under - src/__tests__/postgres/*.pg.test.ts. Mirrored in scripts/lib/test-quarantine.json; - will be DELETED when the SQLite code is removed. - */ - /* - FNXC:CoreTests 2026-06-25-18:00: - The SQLite-to-PostgreSQL cutover (feature delete-sqlite-runtime-final, SESSION 3 PHASE A) - quarantines the remaining 74 active test files that import store-test-helpers.ts (which - constructs TaskStore with inMemoryDb:true) or use inMemoryDb:false / new TaskStore() with - the sync SQLite data path. These tests exercise the SQLite Database class that is being - deleted in this feature. Per the AGENTS.md flaky-test deletion ratchet, they are - quarantined on sight (not migrated to PG) because they test code we are about to delete. - PG counterparts for critical invariants exist under src/__tests__/postgres/*.pg.test.ts. - Mirrored in scripts/lib/test-quarantine.json; will be DELETED when the SQLite code is removed. - */ - /* - FNXC:SqliteFinalRemoval 2026-06-26-10:15: - The SQLite Database/CentralDatabase/ArchiveDatabase class bodies were deleted - (VAL-REMOVAL-005, feature physical-delete-db-class-final). These test files - exercise the legacy sync SQLite data path — they construct Database/ - CentralDatabase/CentralCore(in non-backend mode)/TaskStore(sync path) directly - and call init()/prepare()/transaction() which now throw because the SQLite - runtime is removed. They are quarantined on sight per the AGENTS.md flaky-test - deletion ratchet (not migrated to PG) because they test code that has been - deleted. PG counterparts for the critical invariants exist under - src/__tests__/postgres/*.pg.test.ts (central-core-backend, secrets-roundtrip, - satellite stores, etc). Mirrored in scripts/lib/test-quarantine.json; these - files will be DELETED on the 14-day ratchet (2026-07-10) unless rescued. - */ - "src/__tests__/central-core.test.ts", - "src/__tests__/central-claim-mutex.test.ts", - "src/__tests__/central-integration.test.ts", - "src/__tests__/central-core-docker-node.test.ts", - "src/__tests__/central-core-ensure-project.test.ts", - "src/__tests__/central-project-node-mappings.test.ts", - "src/__tests__/commit-association-diff-backfill.real-git.test.ts", - "src/__tests__/docker-node-config.test.ts", - "src/__tests__/first-run.test.ts", - "src/__tests__/migration-orchestrator.test.ts", - "src/__tests__/migration.test.ts", - "src/__tests__/mission-factory-parity.integration.test.ts", - "src/__tests__/mission-integration.test.ts", - "src/__tests__/multi-node-dashboard.test.ts", - "src/__tests__/project-isolation-transition.test.ts", - "src/__tests__/secrets-store.test.ts", - "src/__tests__/secrets-sync-passphrase.test.ts", - "src/__tests__/settings-parity.test.ts", - "src/__tests__/store-activity.test.ts", - "src/__tests__/store-handoff-to-review.test.ts", - "src/__tests__/store-plugin-store-close.test.ts", - "src/__tests__/store-secrets-store-global-dir.test.ts", - "src/__tests__/store-settings-sync-passphrase-probe.test.ts", - "src/__tests__/todo-store.test.ts", -]; +/* +FNXC:CoreTestInventory 2026-07-13-22:38: +Core test exclusions must exactly mirror the dated quarantine ledger. The PostgreSQL cutover removed the SQLite runtime and the expired 2026-07-10 exclusions no longer have ledger authority; keep this list empty and preserve behavior through active PostgreSQL counterparts. +*/ +const quarantinedCoreTests: string[] = []; export default defineConfig({ resolve: { diff --git a/packages/dashboard/src/__tests__/monitor-store.pg.test.ts b/packages/dashboard/src/__tests__/monitor-store.pg.test.ts new file mode 100644 index 0000000000..47b5504d5e --- /dev/null +++ b/packages/dashboard/src/__tests__/monitor-store.pg.test.ts @@ -0,0 +1,57 @@ +/** + * FNXC:MonitorWriteIsolation 2026-07-14-01:13: + * PostgreSQL monitor writes are project-owned. An unbound layer must reject before touching the legacy empty partition, while the same connection with an explicit project binding must persist and resolve rows visible to that project. + */ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../../core/src/__test-utils__/pg-test-harness.js"; +import * as schema from "../../../core/src/postgres/schema/index.js"; +import { + ingestIncidentSignal, + recordDeployment, + resolveIncident, +} from "../monitor-store.js"; + +pgDescribe("monitor-store PostgreSQL project binding", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_monitor_write_scope", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + it("rejects unbound writes without rows and persists bound writes", async () => { + const layer = h.layer(); + const deployment = { deploymentId: "bound-deployment", deployedAt: "2026-07-14T01:00:00.000Z" }; + const signal = { groupingKey: "bound-incident", title: "Bound incident", at: "2026-07-14T01:00:00.000Z" }; + + await expect(recordDeployment(layer, deployment)).rejects.toThrow( + "PostgreSQL monitor writes require asyncLayer.projectId", + ); + await expect(ingestIncidentSignal(layer, signal)).rejects.toThrow( + "PostgreSQL monitor writes require asyncLayer.projectId", + ); + await expect(resolveIncident(layer, signal.groupingKey)).rejects.toThrow( + "PostgreSQL monitor writes require asyncLayer.projectId", + ); + expect(await layer.db.select().from(schema.project.deployments)).toEqual([]); + expect(await layer.db.select().from(schema.project.incidents)).toEqual([]); + + const boundLayer = { ...layer, projectId: "monitor-project" }; + await recordDeployment(boundLayer, deployment); + const created = await ingestIncidentSignal(boundLayer, signal); + expect(created.incident.status).toBe("open"); + const resolved = await resolveIncident(boundLayer, signal.groupingKey, "2026-07-14T02:00:00.000Z"); + expect(resolved?.status).toBe("resolved"); + + const deployments = await layer.db.select().from(schema.project.deployments); + const incidents = await layer.db.select().from(schema.project.incidents); + expect(deployments).toEqual([expect.objectContaining({ projectId: "monitor-project", deploymentId: "bound-deployment" })]); + expect(incidents).toEqual([expect.objectContaining({ projectId: "monitor-project", groupingKey: "bound-incident", status: "resolved" })]); + }); +}); diff --git a/packages/dashboard/src/monitor-store.ts b/packages/dashboard/src/monitor-store.ts index 429ab0ea9c..2495b3d2c4 100644 --- a/packages/dashboard/src/monitor-store.ts +++ b/packages/dashboard/src/monitor-store.ts @@ -131,6 +131,17 @@ interface IncidentRow { updatedAt: string; } +/** + * FNXC:MonitorWriteIsolation 2026-07-14-01:13: + * PostgreSQL monitor mutations are always project-owned. Reject an unbound layer before calling the core helpers so a missing identity cannot be converted into the legacy empty-string partition and become invisible to later bound reads. + */ +function monitorProjectId(layer: AsyncDataLayer): string { + if (!layer.projectId) { + throw new Error("PostgreSQL monitor writes require asyncLayer.projectId"); + } + return layer.projectId; +} + function parseMeta(value: string | null): Record | null { if (!value) return null; try { @@ -175,7 +186,8 @@ export async function recordDeployment(db: Database | AsyncDataLayer, input: Dep // uniquely exposes `ping()` (the connectivity probe); SQLite `Database` does // not, so `"ping" in db` correctly distinguishes the two backends. if ("ping" in db) { - return recordDeploymentAsync((db as AsyncDataLayer).db, input); + const layer = db as AsyncDataLayer; + return recordDeploymentAsync(layer.db, input, monitorProjectId(layer)); } const sqliteDb = db as Database; const deploymentId = input.deploymentId?.trim() || `dep-${randomUUID()}`; @@ -263,7 +275,8 @@ export async function ingestIncidentSignal( // helper returns the core `Incident` shape, structurally identical to this // module's `Incident`. if ("ping" in db) { - return ingestIncidentSignalAsync((db as AsyncDataLayer).db, input) as Promise<{ + const layer = db as AsyncDataLayer; + return ingestIncidentSignalAsync(layer.db, input, monitorProjectId(layer)) as Promise<{ incident: Incident; created: boolean; }>; @@ -335,7 +348,8 @@ export async function resolveIncident( // P1 fix (review #17): use `"ping" in db` (unique to AsyncDataLayer) instead // of the broken `"transactionImmediate" in db` (SQLite Database also has it). if ("ping" in db) { - return resolveIncidentAsync((db as AsyncDataLayer).db, groupingKey, at); + const layer = db as AsyncDataLayer; + return resolveIncidentAsync(layer.db, groupingKey, at, monitorProjectId(layer)); } const sqliteDb = db as Database; const open = getOpenIncidentByGroupingKey(sqliteDb, groupingKey); diff --git a/scripts/__tests__/check-no-nohup.test.mjs b/scripts/__tests__/check-no-nohup.test.mjs index 405f2cdd30..ac94cfe23a 100644 --- a/scripts/__tests__/check-no-nohup.test.mjs +++ b/scripts/__tests__/check-no-nohup.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; const checkerModule = await import(["..", "/check-no-no", "hup", ".mjs"].join("")); -const { formatFailureMessage, scanFileContent } = checkerModule; +const { formatFailureMessage, scanFileContent, scanTrackedFiles } = checkerModule; const bannedToken = ["no", "hup"].join(""); test("scanFileContent reports banned token matches", () => { @@ -26,3 +26,20 @@ test("formatFailureMessage points callers at superviseSpawn", () => { assert.match(message, /superviseSpawn/); assert.match(message, /scripts\/example\.mjs:3/); }); + +test("scanTrackedFiles skips only tracked files missing from the working tree", () => { + const missing = Object.assign(new Error("missing"), { code: "ENOENT" }); + const readFile = () => { throw missing; }; + + assert.deepEqual(scanTrackedFiles(["scripts/deleted.mjs"], readFile), []); +}); + +test("scanTrackedFiles rethrows tracked-file read failures other than ENOENT", () => { + const denied = Object.assign(new Error("denied"), { code: "EACCES" }); + const readFile = () => { throw denied; }; + + assert.throws( + () => scanTrackedFiles(["scripts/unreadable.mjs"], readFile), + (error) => error === denied, + ); +}); diff --git a/scripts/check-no-nohup.mjs b/scripts/check-no-nohup.mjs index 2c78be0c19..445ebb5416 100644 --- a/scripts/check-no-nohup.mjs +++ b/scripts/check-no-nohup.mjs @@ -36,10 +36,22 @@ export function scanFileContent(content, filePath) { return matches; } -export function scanTrackedFiles(files = listTrackedTargets()) { +export function scanTrackedFiles(files = listTrackedTargets(), readFile = readFileSync) { const matches = []; for (const filePath of files) { - const content = readFileSync(filePath, "utf8"); + let content; + try { + content = readFile(filePath, "utf8"); + } catch (error) { + /* + FNXC:MergeGateSourceScan 2026-07-14-01:41: + Git continues listing an unstaged deletion as tracked, so source guards may skip ENOENT while the deletion awaits commit. Permission, I/O, and other read failures must still fail the gate rather than silently omitting tracked source from enforcement. + */ + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + continue; + } + throw error; + } matches.push(...scanFileContent(content, filePath)); } return matches; diff --git a/scripts/lib/backend-db.mjs b/scripts/lib/backend-db.mjs index 5c78bb62c2..ec39bfbf6f 100644 --- a/scripts/lib/backend-db.mjs +++ b/scripts/lib/backend-db.mjs @@ -11,14 +11,14 @@ * * Requires packages/core to be built (`pnpm --filter @fusion/core build`). */ -import { cpSync, existsSync } from "node:fs"; +import { cpSync, existsSync, readdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); /** - * Stage the PostgreSQL baseline migration SQL into core's dist. `tsc` emits + * Stage the PostgreSQL migration SQL into core's dist. `tsc` emits * only JS, so dist lacks src/postgres/migrations/*.sql; the schema applier * resolves them relative to the compiled file (__dirname/migrations). The CLI * bundle does the same staging in packages/cli/tsup.config.ts. @@ -26,7 +26,14 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); function ensureMigrationsStaged() { const src = resolve(repoRoot, "packages/core/src/postgres/migrations"); const dest = resolve(repoRoot, "packages/core/dist/postgres/migrations"); - if (existsSync(src) && !existsSync(resolve(dest, "0000_initial.sql"))) { + /* + * FNXC:AutomationIsolation 2026-07-13-22:37: + * Operational scripts must stage every versioned PostgreSQL migration, not merely the initial baseline, so an already-initialized database receives the automation project-isolation upgrade before scripts open it. + */ + const requiredMigrations = existsSync(src) + ? readdirSync(src).filter((file) => file.endsWith(".sql")) + : []; + if (existsSync(src) && requiredMigrations.some((file) => !existsSync(resolve(dest, file)))) { cpSync(src, dest, { recursive: true }); } }