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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/postgres-cutover-safety.md
Normal file
7
.changeset/postgres-cutover-safety.md
Normal file
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<T>(
|
||||
homeDir: string,
|
||||
fn: (detector: FirstRunDetector) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<ReturnType<TaskStore["getMissionStore"]>["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> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, NodeStatus>();
|
||||
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<string, number>();
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>; resolve: () => void } {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((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<void>; release: () => void } {
|
||||
const reached = deferred();
|
||||
const release = deferred();
|
||||
let firstTransaction = true;
|
||||
const controlled = {
|
||||
...layer,
|
||||
transactionImmediate: async <T>(
|
||||
fn: Parameters<AsyncDataLayer["transactionImmediate"]>[0],
|
||||
options?: Parameters<AsyncDataLayer["transactionImmediate"]>[1],
|
||||
): Promise<T> => layer.transactionImmediate(async (tx) => {
|
||||
if (!firstTransaction) return fn(tx) as Promise<T>;
|
||||
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<typeof tx.execute>) => {
|
||||
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<T>;
|
||||
}, 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" })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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" });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<void> {
|
||||
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`
|
||||
|
||||
@@ -350,6 +350,12 @@ async function teardownCtx(ctx: TestCtx | null): Promise<void> {
|
||||
pgDescribe("SQLite-to-PostgreSQL migrator", () => {
|
||||
let ctx: TestCtx | null = null;
|
||||
|
||||
const migrateTest = (
|
||||
db: Parameters<typeof migrateSqliteToPostgres>[0],
|
||||
sources: Parameters<typeof migrateSqliteToPostgres>[1],
|
||||
options: Parameters<typeof migrateSqliteToPostgres>[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 },
|
||||
]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof createTestProject>>): Promise<SecretsStore> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>];
|
||||
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<string, unknown>];
|
||||
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<ReturnType<typeof store.getTask>>) => {
|
||||
expect(task.paused).toBeUndefined();
|
||||
expect(task.userPaused).toBeUndefined();
|
||||
expect(task.pausedByAgentId).toBeUndefined();
|
||||
expect(task.pausedReason).toBeUndefined();
|
||||
};
|
||||
|
||||
async function moveTaskToDone(id: string): Promise<void> {
|
||||
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<string, unknown>];
|
||||
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<string, unknown>];
|
||||
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<string, unknown>];
|
||||
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<string, unknown>];
|
||||
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") + " | ||||