merge main and fix CI: add plugin build step to pr-checks workflow

Merges origin/main to bring in the droid runtime plugin (needed by
plugin-loader test) and all recent changes. Adds a "Build plugins"
step to pr-checks.yml so plugin dist/ directories are compiled before
tests run — fixes ERR_MODULE_NOT_FOUND for hermes and droid plugins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Timothy Laurent
2026-05-04 11:41:37 -07:00
389 changed files with 28316 additions and 3520 deletions

View File

@@ -155,7 +155,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
});
it("seeds lastModified", () => {
@@ -178,7 +178,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
});
it("does not overwrite existing config on re-init", () => {
@@ -952,7 +952,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -977,11 +977,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
db.close();
});
@@ -1016,7 +1016,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1057,7 +1057,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1126,7 +1126,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1229,7 +1229,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1303,7 +1303,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1327,7 +1327,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1431,7 +1431,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1900,7 +1900,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -0,0 +1,134 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor } = vi.hoisted(() => {
const execMock = vi.fn();
const readFileMock = vi.fn();
const pingMock = vi.fn();
const versionMock = vi.fn();
const inspectMock = vi.fn();
const dockerCtor = vi.fn().mockImplementation(() => ({
ping: pingMock,
version: versionMock,
getContainer: vi.fn(() => ({ inspect: inspectMock })),
}));
return { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor };
});
vi.mock("dockerode", () => ({ default: dockerCtor }));
vi.mock("node:child_process", () => ({ exec: execMock }));
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
import { DockerClientService } from "../docker-client";
describe("DockerClientService", () => {
beforeEach(() => {
vi.clearAllMocks();
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: "", stderr: "" }));
pingMock.mockResolvedValue(undefined);
versionMock.mockResolvedValue({ Version: "24.0.0", ApiVersion: "1.43", Os: "linux" });
readFileMock.mockResolvedValue(Buffer.from("x"));
});
it.each([
[undefined, undefined],
[{ host: "tcp://1.2.3.4:2376" }, { host: "tcp://1.2.3.4:2376" }],
])("creates docker instance for mode", async (hostConfig, expected) => {
const service = new DockerClientService();
await service.testConnection(hostConfig as never);
if (expected) expect(dockerCtor).toHaveBeenCalledWith(expected);
else expect(dockerCtor).toHaveBeenCalledWith();
});
it("returns success connection result", async () => {
const service = new DockerClientService();
const result = await service.testConnection();
expect(result.success).toBe(true);
expect(result.dockerVersion).toBe("24.0.0");
expect(result.apiVersion).toBe("1.43");
expect(result.operatingSystem).toBe("linux");
expect(result.isLocalDaemon).toBe(true);
});
it("marks remote host as non-local daemon", async () => {
const service = new DockerClientService();
const result = await service.testConnection({ host: "tcp://1.2.3.4:2376" });
expect(result.success).toBe(true);
expect(result.isLocalDaemon).toBe(false);
});
it.each([
{ mode: "context", hostConfig: { context: "my-remote" } },
{
mode: "host+tls",
hostConfig: {
host: "tcp://1.2.3.4:2376",
tlsVerify: true,
tlsCaPath: "/ca.pem",
tlsCertPath: "/cert.pem",
tlsKeyPath: "/key.pem",
},
},
])("covers additional mode $mode", async ({ hostConfig }) => {
if ((hostConfig as any).context) {
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => {
if (cmd.includes("context inspect")) cb(null, { stdout: '[{"Endpoints":{"docker":{"Host":"tcp://ctx:2376"}}}]', stderr: "" });
else cb(null, { stdout: "", stderr: "" });
});
}
const service = new DockerClientService();
await service.testConnection(hostConfig as any);
expect(dockerCtor).toHaveBeenCalled();
});
it("uses docker context", async () => {
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => {
if (cmd.includes("context inspect")) cb(null, { stdout: '[{"Endpoints":{"docker":{"Host":"tcp://ctx:2376"}}}]', stderr: "" });
else cb(null, { stdout: "", stderr: "" });
});
const service = new DockerClientService();
await service.testConnection({ context: "my-remote" });
expect(execMock.mock.calls[0][0]).toContain("docker context inspect");
expect(dockerCtor).toHaveBeenCalledWith({ host: "tcp://ctx:2376" });
});
it("supports host with tls", async () => {
const service = new DockerClientService();
await service.testConnection({ host: "tcp://1.2.3.4:2376", tlsVerify: true, tlsCaPath: "/ca.pem", tlsCertPath: "/cert.pem", tlsKeyPath: "/key.pem" });
expect(readFileMock).toHaveBeenCalledTimes(3);
});
it("returns failure when ping fails", async () => {
pingMock.mockRejectedValue(new Error("connect ECONNREFUSED"));
const service = new DockerClientService();
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.error).toContain("ECONNREFUSED");
});
it("lists contexts and ENOENT fallback", async () => {
execMock.mockImplementationOnce((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: '{"Name":"default","Current":true}\n{"Name":"remote","DockerHost":"tcp://1.2.3.4:2376","Current":false}\n', stderr: "" }));
const service = new DockerClientService();
const contexts = await service.listContexts();
expect(contexts).toHaveLength(2);
execMock.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (err: unknown) => void) => cb(new Error("ENOENT")));
const fallback = await service.listContexts();
expect(fallback[0].name).toBe("default");
});
it("gets container info and not found", async () => {
inspectMock.mockResolvedValue({ Id: "abc", Name: "/container", Created: "2020-01-01T00:00:00Z", Config: { Image: "img:latest" }, State: { Status: "running", Running: true, Paused: false, Restarting: false, Dead: false } });
const service = new DockerClientService();
const container = await service.getContainerInfo("abc");
expect(container?.name).toBe("container");
inspectMock.mockRejectedValue(new Error("404 no such container"));
const missing = await service.getContainerInfo("missing");
expect(missing).toBeNull();
});
it("does not use execSync", async () => {
const source = await import("node:fs/promises").then((m) => m.readFile(new URL("../docker-client.ts", import.meta.url), "utf8"));
expect(source.includes("execSync")).toBe(false);
});
});

View File

@@ -0,0 +1,491 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
const {
getDockerInstanceMock,
pullMock,
followProgressMock,
createContainerMock,
startMock,
stopMock,
removeMock,
restartMock,
inspectMock,
getImageInspectMock,
getContainerMock,
dockerCtor,
} = vi.hoisted(() => {
const pullMock = vi.fn();
const followProgressMock = vi.fn();
const createContainerMock = vi.fn();
const startMock = vi.fn();
const stopMock = vi.fn();
const removeMock = vi.fn();
const restartMock = vi.fn();
const inspectMock = vi.fn();
const getImageInspectMock = vi.fn();
const getContainerMock = vi.fn();
const dockerCtor = vi.fn();
getContainerMock.mockReturnValue({
start: startMock,
stop: stopMock,
remove: removeMock,
restart: restartMock,
inspect: inspectMock,
});
return {
getDockerInstanceMock: vi.fn(),
pullMock,
followProgressMock,
createContainerMock,
startMock,
stopMock,
removeMock,
restartMock,
inspectMock,
getImageInspectMock,
getContainerMock,
dockerCtor,
};
});
vi.mock("../docker-client.js", () => ({
DockerClientService: vi.fn().mockImplementation(() => ({
getDockerInstance: getDockerInstanceMock,
getContainerInfo: vi.fn(),
})),
}));
import { DockerProvisioningService } from "../docker-provisioning";
import { DockerClientService } from "../docker-client";
import type { DockerProvisionInput } from "../types";
function createMockDocker() {
return {
pull: pullMock,
modem: { followProgress: followProgressMock },
createContainer: createContainerMock,
getImage: vi.fn(() => ({ inspect: getImageInspectMock })),
getContainer: getContainerMock,
};
}
function createBaseInput(overrides?: Partial<DockerProvisionInput>): DockerProvisionInput {
return {
nodeName: "test-node",
hostConfig: {},
imageConfig: {
image: "runfusion/fusion",
tag: "latest",
pullImage: true,
},
autoGenerateApiKey: true,
...overrides,
};
}
describe("DockerProvisioningService", () => {
let service: DockerProvisioningService;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01"));
const mockDocker = createMockDocker();
getDockerInstanceMock.mockResolvedValue(mockDocker);
pullMock.mockResolvedValue("stream");
followProgressMock.mockImplementation((_stream: unknown, cb: (err: Error | null) => void) => cb(null));
createContainerMock.mockResolvedValue({
id: "container-abc123",
start: startMock,
inspect: inspectMock,
remove: removeMock,
});
startMock.mockResolvedValue(undefined);
inspectMock.mockResolvedValue({
Id: "container-abc123",
NetworkSettings: { Ports: { "4040/tcp": [{ HostPort: "49152" }] } },
});
removeMock.mockResolvedValue(undefined);
stopMock.mockResolvedValue(undefined);
restartMock.mockResolvedValue(undefined);
getImageInspectMock.mockResolvedValue({});
const clientService = new DockerClientService();
service = new DockerProvisioningService(clientService);
});
afterEach(() => {
vi.useRealTimers();
});
describe("provision", () => {
it("provisions a container successfully", async () => {
const result = await service.provision(createBaseInput());
expect(result.success).toBe(true);
expect(result.containerId).toBe("container-abc123");
expect(result.containerName).toMatch(/^fusion-test-node-[a-f0-9]{8}$/);
expect(result.apiKey).toMatch(/^fn_[a-f0-9]+$/);
expect(result.portMapping).toBe("4040:49152");
expect(result.durationMs).toBeGreaterThanOrEqual(0);
// Verify delegation to DockerClientService
expect(getDockerInstanceMock).toHaveBeenCalledWith({});
// Verify image pull
expect(pullMock).toHaveBeenCalledWith("runfusion/fusion:latest", undefined);
// Verify container creation
expect(createContainerMock).toHaveBeenCalledWith(
expect.objectContaining({
Image: "runfusion/fusion:latest",
ExposedPorts: { "4040/tcp": {} },
}),
);
// Verify environment includes required vars
const createCall = createContainerMock.mock.calls[0][0];
expect(createCall.Env).toContain("FUSION_NODE_NAME=test-node");
expect(createCall.Env).toContain("FUSION_MODE=serve");
expect(createCall.Env).toContain("FUSION_PORT=4040");
expect(createCall.Env).toContain("FUSION_DATA_DIR=/data");
expect(createCall.Env.some((e: string) => e.startsWith("FUSION_API_KEY="))).toBe(true);
// Verify labels
expect(createCall.Labels).toEqual(
expect.objectContaining({
"fusion.managed": "true",
"fusion.node-name": "test-node",
}),
);
// Verify HostConfig
expect(createCall.HostConfig.RestartPolicy).toEqual({ Name: "unless-stopped" });
expect(createCall.HostConfig.PortBindings).toEqual({ "4040/tcp": [{ HostPort: "0" }] });
});
it("includes extra CLIs in environment", async () => {
const result = await service.provision(
createBaseInput({ extraClis: ["claude", "droid"] }),
);
expect(result.success).toBe(true);
const createCall = createContainerMock.mock.calls[0][0];
expect(createCall.Env).toContain("FUSION_EXTRA_CLIS=claude,droid");
});
it("applies resource limits to HostConfig", async () => {
await service.provision(
createBaseInput({
resourceConfig: {
cpuLimit: 2,
memoryLimitMb: 4096,
memorySwapMb: 8192,
},
}),
);
const hostConfig = createContainerMock.mock.calls[0][0].HostConfig;
expect(hostConfig.Memory).toBe(4096 * 1024 * 1024);
expect(hostConfig.NanoCpus).toBe(2_000_000_000);
expect(hostConfig.MemorySwap).toBe(8192 * 1024 * 1024);
});
it("applies custom network config", async () => {
await service.provision(
createBaseInput({ network: "fusion-net" }),
);
const createCall = createContainerMock.mock.calls[0][0];
expect(createCall.NetworkingConfig).toEqual({
EndpointsConfig: { "fusion-net": {} },
});
});
it("uses persistent volume mount", async () => {
await service.provision(
createBaseInput({ persistentVolume: "fusion-data" }),
);
const hostConfig = createContainerMock.mock.calls[0][0].HostConfig;
expect(hostConfig.Binds).toContain("fusion-data:/data");
});
it("appends user-provided volume mounts", async () => {
await service.provision(
createBaseInput({
persistentVolume: "fusion-data",
volumeMounts: ["/host/path:/container/path"],
}),
);
const hostConfig = createContainerMock.mock.calls[0][0].HostConfig;
expect(hostConfig.Binds).toEqual(["fusion-data:/data", "/host/path:/container/path"]);
});
it("appends user-provided environment variables", async () => {
await service.provision(
createBaseInput({ environment: ["CUSTOM_VAR=value"] }),
);
const createCall = createContainerMock.mock.calls[0][0];
expect(createCall.Env).toContain("CUSTOM_VAR=value");
});
it("uses user-provided API key when autoGenerateApiKey is false", async () => {
const result = await service.provision(
createBaseInput({
autoGenerateApiKey: false,
apiKey: "my-custom-key",
}),
);
expect(result.success).toBe(true);
expect(result.apiKey).toBe("my-custom-key");
const createCall = createContainerMock.mock.calls[0][0];
expect(createCall.Env).toContain("FUSION_API_KEY=my-custom-key");
});
it("returns error on image pull failure", async () => {
pullMock.mockRejectedValue(new Error("registry unavailable"));
const result = await service.provision(createBaseInput());
expect(result.success).toBe(false);
expect(result.failedStage).toBe("image-pull");
expect(result.error).toContain("registry unavailable");
});
it("returns error when local image not found and pullImage is false", async () => {
getImageInspectMock.mockRejectedValue(new Error("no such image"));
const result = await service.provision(
createBaseInput({ imageConfig: { image: "runfusion/fusion", tag: "latest", pullImage: false } }),
);
expect(result.success).toBe(false);
expect(result.failedStage).toBe("image-pull");
expect(result.error).toContain("not found locally");
});
it("returns error on container create failure", async () => {
createContainerMock.mockRejectedValue(new Error("name conflict"));
const result = await service.provision(createBaseInput());
expect(result.success).toBe(false);
expect(result.failedStage).toBe("container-create");
expect(result.error).toContain("name conflict");
});
it("returns error on container start failure and cleans up", async () => {
startMock.mockRejectedValue(new Error("port already in use"));
const result = await service.provision(createBaseInput());
expect(result.success).toBe(false);
expect(result.failedStage).toBe("container-start");
expect(result.error).toContain("port already in use");
expect(removeMock).toHaveBeenCalledWith({ force: true });
});
it("passes registry auth when credentials are provided", async () => {
await service.provision(
createBaseInput({
imageConfig: {
image: "ghcr.io/runfusion/fusion",
tag: "v1",
pullImage: true,
registryUsername: "user",
registryPassword: "pass",
},
}),
);
expect(pullMock).toHaveBeenCalledWith(
"ghcr.io/runfusion/fusion:v1",
{ authconfig: { username: "user", password: "pass" } },
);
});
it("delegates getDockerInstance to DockerClientService", async () => {
const hostConfig = { host: "tcp://1.2.3.4:2376" };
await service.provision(createBaseInput({ hostConfig }));
expect(getDockerInstanceMock).toHaveBeenCalledWith(hostConfig);
});
it("includes reachableUrl in environment when provided", async () => {
await service.provision(
createBaseInput({ reachableUrl: "http://my-node:4040" }),
);
const createCall = createContainerMock.mock.calls[0][0];
expect(createCall.Env).toContain("FUSION_REACHABLE_URL=http://my-node:4040");
});
it("merges user-provided labels", async () => {
await service.provision(
createBaseInput({ labels: { env: "production" } }),
);
const createCall = createContainerMock.mock.calls[0][0];
expect(createCall.Labels).toEqual({
"fusion.managed": "true",
"fusion.node-name": "test-node",
env: "production",
});
});
});
describe("deprovision", () => {
it("stops and removes container", async () => {
const result = await service.deprovision("container-123", {}, false);
expect(result.success).toBe(true);
expect(getContainerMock).toHaveBeenCalledWith("container-123");
expect(stopMock).toHaveBeenCalledWith({ t: 10 });
expect(removeMock).toHaveBeenCalledWith({ force: true, v: false });
});
it("passes removeVolumes flag to remove", async () => {
await service.deprovision("container-123", {}, true);
expect(removeMock).toHaveBeenCalledWith({ force: true, v: true });
});
it("proceeds to remove when container is already stopped", async () => {
stopMock.mockRejectedValue(new Error("is not running"));
const result = await service.deprovision("container-123", {}, false);
expect(result.success).toBe(true);
expect(removeMock).toHaveBeenCalled();
});
it("proceeds to remove on 'already stopped' error", async () => {
stopMock.mockRejectedValue(new Error("already stopped"));
const result = await service.deprovision("container-123", {}, false);
expect(result.success).toBe(true);
expect(removeMock).toHaveBeenCalled();
});
it("returns error on remove failure", async () => {
removeMock.mockRejectedValue(new Error("removal failed"));
const result = await service.deprovision("container-123", {}, false);
expect(result.success).toBe(false);
expect(result.error).toContain("removal failed");
});
});
describe("startContainer", () => {
it("starts a container", async () => {
const result = await service.startContainer("container-123", {});
expect(result.success).toBe(true);
expect(getContainerMock).toHaveBeenCalledWith("container-123");
expect(startMock).toHaveBeenCalled();
});
it("returns error on start failure", async () => {
startMock.mockRejectedValue(new Error("already running"));
const result = await service.startContainer("container-123", {});
expect(result.success).toBe(false);
expect(result.error).toContain("already running");
});
});
describe("stopContainer", () => {
it("stops a container", async () => {
const result = await service.stopContainer("container-123", {});
expect(result.success).toBe(true);
expect(stopMock).toHaveBeenCalledWith({ t: 10 });
});
it("returns error on stop failure", async () => {
stopMock.mockRejectedValue(new Error("not running"));
const result = await service.stopContainer("container-123", {});
expect(result.success).toBe(false);
expect(result.error).toContain("not running");
});
});
describe("restartContainer", () => {
it("restarts a container", async () => {
const result = await service.restartContainer("container-123", {});
expect(result.success).toBe(true);
expect(restartMock).toHaveBeenCalled();
});
it("returns error on restart failure", async () => {
restartMock.mockRejectedValue(new Error("timeout"));
const result = await service.restartContainer("container-123", {});
expect(result.success).toBe(false);
expect(result.error).toContain("timeout");
});
});
describe("getContainerStatus", () => {
it("returns container status via DockerClientService", async () => {
const mockDocker = createMockDocker();
mockDocker.getContainer = vi.fn().mockReturnValue({
inspect: vi.fn().mockResolvedValue({
Id: "container-abc",
Name: "/fusion-test",
State: { Status: "running", Running: true, Paused: false, Restarting: false, Dead: false },
Config: { Image: "runfusion/fusion:latest" },
Created: "2025-01-01T00:00:00Z",
}),
});
getDockerInstanceMock.mockResolvedValue(mockDocker);
const result = await service.getContainerStatus("container-abc", {});
expect(result).not.toBeNull();
expect(result!.id).toBe("container-abc");
expect(result!.name).toBe("fusion-test");
expect(result!.status).toBe("running");
expect(result!.state.running).toBe(true);
});
it("returns null for non-existent container", async () => {
const mockDocker = createMockDocker();
mockDocker.getContainer = vi.fn().mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error("404 no such container")),
});
getDockerInstanceMock.mockResolvedValue(mockDocker);
const result = await service.getContainerStatus("nonexistent", {});
expect(result).toBeNull();
});
it("delegates to getDockerInstance with hostConfig", async () => {
const hostConfig = { host: "tcp://1.2.3.4:2376" };
const mockDocker = createMockDocker();
mockDocker.getContainer = vi.fn().mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error("404")),
});
getDockerInstanceMock.mockResolvedValue(mockDocker);
await service.getContainerStatus("abc", hostConfig);
expect(getDockerInstanceMock).toHaveBeenCalledWith(hostConfig);
});
});
});

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDatabase } from "../db.js";
import { InsightLifecycleError, InsightStore } from "../insight-store.js";
import { classifyInsightRunError, executeInsightRunLifecycle, retryInsightRunLifecycle } from "../insight-run-executor.js";
function createStore(): InsightStore {
const fusionDir = mkdtempSync(join(tmpdir(), "fn-insight-executor-"));
const db = createDatabase(fusionDir, { inMemory: true });
db.init();
return new InsightStore(db);
}
describe("classifyInsightRunError", () => {
it("classifies cancellation", () => {
const result = classifyInsightRunError(new DOMException("Aborted", "AbortError"));
expect(result.failureClass).toBe("cancelled");
});
it("classifies timeout", () => {
const result = classifyInsightRunError(new Error("timed out while calling provider"));
expect(result.failureClass).toBe("timed_out");
expect(result.retryable).toBe(true);
});
it("classifies transient provider errors as retryable", () => {
const result = classifyInsightRunError(new Error("HTTP 503 from provider"));
expect(result.failureClass).toBe("retryable_transient");
expect(result.retryable).toBe(true);
});
it("classifies deterministic failures as non-retryable", () => {
const result = classifyInsightRunError(new Error("invalid JSON contract response"));
expect(result.failureClass).toBe("non_retryable");
expect(result.retryable).toBe(false);
});
});
describe("executeInsightRunLifecycle", () => {
it("completes and persists events", async () => {
const store = createStore();
const run = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
executeAttempt: async () => ({
summary: "done",
insightsCreated: 2,
insightsUpdated: 1,
}),
});
expect(run.status).toBe("completed");
const events = store.listRunEvents(run.id);
expect(events.map((event) => event.type)).toEqual(["status_changed", "status_changed", "info", "status_changed"]);
});
it("retries transient failures with bounded attempts", async () => {
const store = createStore();
let calls = 0;
const run = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
maxAttempts: 2,
retryDelayMs: 0,
executeAttempt: async () => {
calls += 1;
if (calls === 1) {
throw new Error("HTTP 503");
}
return {
summary: "recovered",
insightsCreated: 1,
insightsUpdated: 0,
};
},
});
expect(calls).toBe(2);
expect(run.status).toBe("completed");
const events = store.listRunEvents(run.id);
expect(events.some((event) => event.type === "retry_scheduled")).toBe(true);
});
it("fails non-retryable errors without retry", async () => {
const store = createStore();
const run = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
maxAttempts: 3,
executeAttempt: async () => {
throw new Error("validation failed");
},
});
expect(run.status).toBe("failed");
expect(run.lifecycle.failureClass).toBe("non_retryable");
expect(run.lifecycle.retryable).toBe(false);
});
it("blocks duplicate active runs for same project+trigger", async () => {
const store = createStore();
store.createRun("proj", { trigger: "manual" });
await expect(() => executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
executeAttempt: async () => ({ insightsCreated: 0, insightsUpdated: 0 }),
})).rejects.toMatchObject({ code: "active_run_conflict" } satisfies Partial<InsightLifecycleError>);
});
it("marks timeout as terminal failure classification", async () => {
const store = createStore();
const run = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
timeoutMs: 10,
maxAttempts: 1,
executeAttempt: async ({ signal }) => {
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, 50);
signal.addEventListener("abort", () => {
clearTimeout(timeout);
reject(signal.reason ?? new Error("aborted"));
});
});
return { insightsCreated: 0, insightsUpdated: 0 };
},
});
expect(run.status).toBe("failed");
expect(run.lifecycle.failureClass).toBe("timed_out");
});
});
describe("retryInsightRunLifecycle", () => {
it("creates a new run from retryable failed run", async () => {
const store = createStore();
const failed = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
maxAttempts: 1,
executeAttempt: async () => {
throw new Error("HTTP 503");
},
});
const retried = await retryInsightRunLifecycle({
store,
runId: failed.id,
executeAttempt: async () => ({ insightsCreated: 1, insightsUpdated: 0 }),
});
expect(retried.run.id).not.toBe(failed.id);
expect(retried.run.lifecycle.retryOfRunId).toBe(failed.id);
expect(retried.run.status).toBe("completed");
});
it("rejects retry for non-retryable failures", async () => {
const store = createStore();
const failed = await executeInsightRunLifecycle({
store,
projectId: "proj",
input: { trigger: "manual" },
maxAttempts: 1,
executeAttempt: async () => {
throw new Error("invalid input");
},
});
await expect(retryInsightRunLifecycle({
store,
runId: failed.id,
executeAttempt: async () => ({ insightsCreated: 1, insightsUpdated: 0 }),
})).rejects.toMatchObject({ code: "not_retryable" } satisfies Partial<InsightLifecycleError>);
});
});

View File

@@ -749,16 +749,14 @@ describe("InsightStore Run CRUD", () => {
expect(fromDb).toEqual(updated);
});
it("preserves existing completedAt on later updates", () => {
it("rejects updates after terminal completion", () => {
const run = store.createRun("proj", { trigger: "manual" });
const completed = store.updateRun(run.id, { status: "failed", error: "boom" });
const firstCompletedAt = completed?.completedAt;
expect(completed?.completedAt).toBeTruthy();
const patched = store.updateRun(run.id, { summary: "postmortem" });
expect(firstCompletedAt).toBeTruthy();
expect(patched?.completedAt).toBe(firstCompletedAt);
expect(patched?.summary).toBe("postmortem");
expect(() => store.updateRun(run.id, { summary: "postmortem" })).toThrow(
/terminal and immutable/i,
);
});
it("does not override completedAt if already provided", () => {
@@ -871,7 +869,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(59);
expect(db1.getSchemaVersion()).toBe(60);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -906,7 +904,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(59);
expect(db3.getSchemaVersion()).toBe(60);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -937,12 +935,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(59);
expect(db1.getSchemaVersion()).toBe(60);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(59);
expect(db2.getSchemaVersion()).toBe(60);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });

View File

@@ -0,0 +1,502 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ManagedDockerNode, MeshConnectionConfig } from "../types.js";
// ── Mocks ──────────────────────────────────────────────────────────────────
const mockCentral = {
getManagedDockerNode: vi.fn(),
updateManagedDockerNode: vi.fn(),
registerNode: vi.fn(),
linkManagedDockerNodeToNode: vi.fn(),
checkNodeHealth: vi.fn(),
};
const mockDockerClient = {
recreateContainer: vi.fn(),
};
vi.mock("../central-core.js", () => ({
CentralCore: vi.fn(),
}));
vi.mock("../docker-client.js", () => ({
DockerClientService: vi.fn(),
}));
// ── Helpers ────────────────────────────────────────────────────────────────
function createManagedNode(overrides: Partial<ManagedDockerNode> = {}): ManagedDockerNode {
return {
id: "dn_test123",
nodeId: null,
name: "test-node",
imageName: "runfusion/fusion",
imageTag: "latest",
containerId: "container_abc",
status: "creating",
hostConfig: { host: undefined },
envVars: {},
volumeMounts: [],
resourceSizing: { memoryMB: 4096, cpus: 2 },
extraClis: [],
persistentStorage: true,
reachableUrl: null,
apiKey: null,
errorMessage: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
// Import after mocks are set up
const { MeshConfigGenerator } = await import("../mesh-config-generator.js");
function createGenerator() {
return new MeshConfigGenerator({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
central: mockCentral as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dockerClient: mockDockerClient as any,
});
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe("MeshConfigGenerator", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── generateConfig ─────────────────────────────────────────────────────
describe("generateConfig", () => {
it("uses managed node's reachableUrl when set", () => {
const generator = createGenerator();
const node = createManagedNode({ reachableUrl: "http://custom:5000" });
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
});
expect(config.reachableUrl).toBe("http://custom:5000");
});
it("auto-generates 32-char hex API key when none provided", () => {
const generator = createGenerator();
const node = createManagedNode();
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
});
expect(config.nodeApiKey).toMatch(/^[0-9a-f]{32}$/);
});
it("preserves user-provided API key", () => {
const generator = createGenerator();
const node = createManagedNode();
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
nodeApiKey: "user-provided-key",
});
expect(config.nodeApiKey).toBe("user-provided-key");
});
it("assembles all mesh env vars with correct values", () => {
const generator = createGenerator();
const node = createManagedNode({ name: "my-node" });
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
nodeApiKey: "test-api-key",
containerPort: 5000,
});
expect(config.envVars).toMatchObject({
FUSION_DAEMON_TOKEN: "test-api-key",
PORT: "5000",
FUSION_NODE_NAME: "my-node",
});
});
it("merges with existing user env vars, mesh config overrides on conflict", () => {
const generator = createGenerator();
const node = createManagedNode({
envVars: { PORT: "3000", CUSTOM_VAR: "custom-value" },
});
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
nodeApiKey: "test-key",
});
// User env var preserved
expect(config.envVars.CUSTOM_VAR).toBe("custom-value");
// Mesh config overrides user PORT
expect(config.envVars.PORT).toBe("4041");
});
it("defaults container port to 4041", () => {
const generator = createGenerator();
const node = createManagedNode();
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
});
expect(config.containerPort).toBe(4041);
expect(config.envVars.PORT).toBe("4041");
});
it("uses explicit containerPort override", () => {
const generator = createGenerator();
const node = createManagedNode();
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 5050,
});
expect(config.containerPort).toBe(5050);
});
it("passes orchestrator URL and API key through to config", () => {
const generator = createGenerator();
const node = createManagedNode();
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key-123",
});
expect(config.orchestratorUrl).toBe("http://orchestrator:4040");
expect(config.orchestratorApiKey).toBe("orch-key-123");
});
it("resolves localhost URL for local Docker daemon", () => {
const generator = createGenerator();
const node = createManagedNode({ hostConfig: { host: undefined } });
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
});
expect(config.reachableUrl).toBe("http://localhost:4041");
});
it("resolves remote host URL from hostConfig", () => {
const generator = createGenerator();
const node = createManagedNode({
hostConfig: { host: "tcp://192.168.1.50:2376" },
});
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 5000,
});
expect(config.reachableUrl).toBe("http://192.168.1.50:5000");
});
});
// ── applyConfig ────────────────────────────────────────────────────────
describe("applyConfig", () => {
const config: MeshConnectionConfig = {
nodeApiKey: "test-key",
reachableUrl: "http://localhost:4041",
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 4041,
envVars: {
FUSION_DAEMON_TOKEN: "test-key",
PORT: "4041",
FUSION_NODE_NAME: "test-node",
},
};
it("sets status to recreating, recreates container, updates to running", async () => {
const generator = createGenerator();
const node = createManagedNode();
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockDockerClient.recreateContainer.mockResolvedValue("new-container-id");
mockCentral.updateManagedDockerNode.mockResolvedValue({
...node,
status: "running",
containerId: "new-container-id",
});
await generator.applyConfig("dn_test123", config, { host: undefined });
// Status set to "recreating" first
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
"dn_test123",
expect.objectContaining({ status: "recreating" }),
);
// Container recreated with correct params
expect(mockDockerClient.recreateContainer).toHaveBeenCalledWith(
"container_abc",
expect.objectContaining({
envVars: config.envVars,
imageName: "runfusion/fusion:latest",
volumeMounts: [],
}),
);
// Final update with running status
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
"dn_test123",
expect.objectContaining({
status: "running",
containerId: "new-container-id",
apiKey: "test-key",
reachableUrl: "http://localhost:4041",
envVars: config.envVars,
}),
);
});
it("throws descriptive error when node has no containerId", async () => {
const generator = createGenerator();
const node = createManagedNode({ containerId: null });
mockCentral.getManagedDockerNode.mockResolvedValue(node);
await expect(
generator.applyConfig("dn_test123", config, { host: undefined }),
).rejects.toThrow("has no container ID");
});
it("sets status to error and re-throws when recreation fails", async () => {
const generator = createGenerator();
const node = createManagedNode();
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockDockerClient.recreateContainer.mockRejectedValue(new Error("Docker error"));
await expect(
generator.applyConfig("dn_test123", config, { host: undefined }),
).rejects.toThrow("Docker error");
// Status should be set to error
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
"dn_test123",
expect.objectContaining({
status: "error",
errorMessage: "Docker error",
}),
);
});
});
// ── registerInMesh ────────────────────────────────────────────────────
describe("registerInMesh", () => {
it("registers node, links it, and returns healthy result", async () => {
const generator = createGenerator();
const node = createManagedNode();
const registeredNode = { id: "node_new", name: "test-node", type: "remote" as const };
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockCentral.registerNode.mockResolvedValue(registeredNode);
mockCentral.linkManagedDockerNodeToNode.mockResolvedValue(node);
mockCentral.checkNodeHealth.mockResolvedValue("online");
const config: MeshConnectionConfig = {
nodeApiKey: "test-key",
reachableUrl: "http://localhost:4041",
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 4041,
envVars: {},
};
const result = await generator.registerInMesh("dn_test123", config);
expect(mockCentral.registerNode).toHaveBeenCalledWith({
name: "test-node",
type: "remote",
url: "http://localhost:4041",
apiKey: "test-key",
maxConcurrent: 2,
});
expect(mockCentral.linkManagedDockerNodeToNode).toHaveBeenCalledWith(
"dn_test123",
"node_new",
);
expect(result.isHealthy).toBe(true);
expect(result.node).toBe(registeredNode);
expect(result.config).toBe(config);
});
it("returns unhealthy when health check times out", async () => {
const generator = createGenerator();
const node = createManagedNode();
const registeredNode = { id: "node_new", name: "test-node", type: "remote" as const };
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockCentral.registerNode.mockResolvedValue(registeredNode);
mockCentral.linkManagedDockerNodeToNode.mockResolvedValue(node);
// Always return "offline" — simulates timeout
mockCentral.checkNodeHealth.mockResolvedValue("offline");
const config: MeshConnectionConfig = {
nodeApiKey: "test-key",
reachableUrl: "http://localhost:4041",
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 4041,
envVars: {},
};
// Use fake timers to speed up the timeout test
vi.useFakeTimers();
const resultPromise = generator.registerInMesh("dn_test123", config);
// Fast-forward through the polling
await vi.advanceTimersByTimeAsync(35_000);
const result = await resultPromise;
expect(result.isHealthy).toBe(false);
expect(result.error).toContain("did not reach online status");
vi.useRealTimers();
});
it("re-throws when registration fails", async () => {
const generator = createGenerator();
const node = createManagedNode();
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockCentral.registerNode.mockRejectedValue(new Error("Name collision"));
const config: MeshConnectionConfig = {
nodeApiKey: "test-key",
reachableUrl: "http://localhost:4041",
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 4041,
envVars: {},
};
await expect(
generator.registerInMesh("dn_test123", config),
).rejects.toThrow("Name collision");
});
});
// ── provisionAndRegister ──────────────────────────────────────────────
describe("provisionAndRegister", () => {
it("runs full end-to-end flow: generate → apply → register", async () => {
const generator = createGenerator();
const node = createManagedNode();
const registeredNode = { id: "node_new", name: "test-node", type: "remote" as const };
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockDockerClient.recreateContainer.mockResolvedValue("new-container-id");
mockCentral.updateManagedDockerNode.mockImplementation((_id: string, updates: Record<string, unknown>) =>
Promise.resolve({ ...node, ...updates }),
);
mockCentral.registerNode.mockResolvedValue(registeredNode);
mockCentral.linkManagedDockerNodeToNode.mockResolvedValue(node);
mockCentral.checkNodeHealth.mockResolvedValue("online");
const result = await generator.provisionAndRegister({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
nodeApiKey: "my-key",
containerPort: 4041,
});
expect(result.isHealthy).toBe(true);
expect(result.config.nodeApiKey).toBe("my-key");
expect(result.config.envVars.FUSION_DAEMON_TOKEN).toBe("my-key");
expect(result.node).toBe(registeredNode);
});
it("sets managed node to error when apply fails", async () => {
const generator = createGenerator();
const node = createManagedNode();
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockCentral.updateManagedDockerNode.mockImplementation((_id: string, updates: Record<string, unknown>) =>
Promise.resolve({ ...node, ...updates }),
);
mockDockerClient.recreateContainer.mockRejectedValue(new Error("Recreate failed"));
await expect(
generator.provisionAndRegister({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
}),
).rejects.toThrow("Recreate failed");
// Error status update should have happened
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
"dn_test123",
expect.objectContaining({
status: "error",
errorMessage: "Recreate failed",
}),
);
});
it("sets managed node to error when register fails", async () => {
const generator = createGenerator();
const node = createManagedNode();
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockCentral.updateManagedDockerNode.mockImplementation((_id: string, updates: Record<string, unknown>) =>
Promise.resolve({ ...node, ...updates }),
);
mockDockerClient.recreateContainer.mockResolvedValue("new-container-id");
mockCentral.registerNode.mockRejectedValue(new Error("Registration failed"));
await expect(
generator.provisionAndRegister({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
}),
).rejects.toThrow("Registration failed");
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
"dn_test123",
expect.objectContaining({
status: "error",
errorMessage: "Registration failed",
}),
);
});
});
});

View File

@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
});
it("mission_features table has loop state columns", () => {

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
choosePreferredStoredCredential,
extractCodexCliStoredCredential,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
} from "../oauth-credential-interop.js";
function encodeBase64Url(value: string): string {
return Buffer.from(value, "utf-8").toString("base64url");
}
function createJwt(payload: Record<string, unknown>): string {
return [
encodeBase64Url(JSON.stringify({ alg: "none", typ: "JWT" })),
encodeBase64Url(JSON.stringify(payload)),
"signature",
].join(".");
}
describe("oauth credential interop", () => {
it("extracts Codex CLI OAuth credentials from auth.json token payload", () => {
const expiresAtSeconds = Math.floor(Date.now() / 1000) + 3600;
const accessToken = createJwt({
exp: expiresAtSeconds,
"https://api.openai.com/auth": {
chatgpt_account_id: "acct_123",
},
});
const credential = extractCodexCliStoredCredential({
tokens: {
access_token: accessToken,
refresh_token: "refresh-token",
},
});
expect(credential).toEqual({
type: "oauth",
access: accessToken,
refresh: "refresh-token",
expires: expiresAtSeconds * 1000,
accountId: "acct_123",
});
});
it("falls back to last_refresh when Codex CLI JWT has no exp claim", () => {
const accessToken = createJwt({
sub: "user-123",
});
const lastRefresh = "2026-05-03T10:00:00.000Z";
const credential = extractCodexCliStoredCredential({
last_refresh: lastRefresh,
tokens: {
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_from_token",
},
});
expect(credential?.type).toBe("oauth");
expect(credential?.accountId).toBe("acct_from_token");
expect(credential?.expires).toBe(Date.parse(lastRefresh) + 55 * 60 * 1000);
});
it("prefers a valid OAuth credential over an expired one and hydrates only when better", () => {
const expired = {
type: "oauth",
access: "expired-access",
refresh: "expired-refresh",
expires: Date.now() - 60_000,
} as const;
const valid = {
type: "oauth",
access: "valid-access",
refresh: "valid-refresh",
expires: Date.now() + 60_000,
} as const;
expect(choosePreferredStoredCredential(expired, valid)).toEqual(valid);
expect(shouldHydrateStoredCredential(expired, valid)).toBe(true);
expect(shouldHydrateStoredCredential({ type: "api_key", key: "sk-live" }, valid)).toBe(false);
});
it("gracefully ignores malformed auth files", () => {
const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-"));
try {
const malformedPath = join(tempDir, "auth.json");
writeFileSync(malformedPath, "{ not-json");
expect(readStoredCredentialsFromAuthFile(malformedPath)).toEqual({});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,9 +1,18 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { PluginLoader } from "../plugin-loader.js";
vi.mock("@mariozechner/pi-ai", () => ({
AssistantMessageEventStream: class AssistantMessageEventStream {
push() {}
end() {}
},
calculateCost: () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }),
}));
import { PluginStore } from "../plugin-store.js";
import { setCreateAiSessionFactory } from "../ai-engine-loader.js";
import type { CreateAiSessionOptions, FusionPlugin, PluginManifest } from "../plugin-types.js";
@@ -105,6 +114,12 @@ function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-plugin-loader-test-"));
}
function droidPluginModulePath(): string {
return fileURLToPath(
new URL("../../../../plugins/fusion-plugin-droid-runtime/src/index.ts", import.meta.url),
);
}
// Mock TaskStore for testing
const mockTaskStore = {
logActivity: vi.fn(),
@@ -310,6 +325,64 @@ describe("PluginLoader", () => {
expect(loader.isPluginLoaded("load-test")).toBe(true);
});
it("loads the migrated Droid plugin through register→loadAllPlugins→loadPlugin pipeline", async () => {
await pluginStore.init();
const droidManifest = {
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime Plugin",
version: "0.1.0",
description: "Droid runtime plugin for Fusion",
runtime: {
runtimeId: "droid",
name: "Droid Runtime",
description: "Drives the Droid CLI for Fusion agents",
version: "0.1.0",
},
} as const;
await pluginStore.registerPlugin({
manifest: droidManifest,
path: droidPluginModulePath(),
});
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const loadAllResult = await loader.loadAllPlugins();
expect(loadAllResult).toEqual({ loaded: 1, errors: 0 });
expect(loader.isPluginLoaded("fusion-plugin-droid-runtime")).toBe(true);
const loaded = await loader.loadPlugin("fusion-plugin-droid-runtime");
expect(loaded.manifest.id).toBe("fusion-plugin-droid-runtime");
expect(loaded.state).toBe("started");
const installed = await pluginStore.getPlugin("fusion-plugin-droid-runtime");
expect(installed.state).toBe("started");
const slots = loader
.getPluginUiSlots()
.filter((entry) => entry.pluginId === "fusion-plugin-droid-runtime");
expect(slots.map((entry) => entry.slot.slotId)).toEqual([
"onboarding-provider-card",
"onboarding-setup-help",
"post-onboarding-recommendation",
"settings-provider-card",
]);
expect(slots[0]?.slot).toHaveProperty("label");
expect(slots[0]?.slot).toHaveProperty("componentPath");
const runtimes = loader
.getPluginRuntimes()
.filter((entry) => entry.pluginId === "fusion-plugin-droid-runtime");
expect(runtimes).toHaveLength(1);
expect(runtimes[0].runtime.metadata).toMatchObject({
runtimeId: "droid",
name: "Droid Runtime",
version: "0.1.0",
});
expect(typeof runtimes[0].runtime.factory).toBe("function");
});
it("updates plugin state to started", async () => {
await pluginStore.init();
@@ -415,6 +488,56 @@ describe("PluginLoader", () => {
);
});
it("fails when plugin module manifest is invalid", async () => {
await pluginStore.init();
const pluginDir = join(rootDir, "plugins");
const pluginPath = join(pluginDir, "invalid-manifest.js");
await mkdir(pluginDir, { recursive: true });
await writeFile(
pluginPath,
`
const plugin = {
manifest: { id: "invalid-manifest", version: "1.0.0" },
state: "installed",
hooks: {},
};
export default plugin;
`,
);
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "invalid-manifest" }),
path: pluginPath,
});
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
await expect(loader.loadPlugin("invalid-manifest")).rejects.toThrow(
"Invalid plugin manifest",
);
const stored = await pluginStore.getPlugin("invalid-manifest");
expect(stored.state).toBe("error");
});
it("fails when plugin entrypoint is missing", async () => {
await pluginStore.init();
const missingPath = join(rootDir, "plugins", "missing-entrypoint.js");
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "missing-entrypoint" }),
path: missingPath,
});
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
await expect(loader.loadPlugin("missing-entrypoint")).rejects.toThrow();
const stored = await pluginStore.getPlugin("missing-entrypoint");
expect(stored.state).toBe("error");
expect(stored.error).toBeTruthy();
});
it("error isolation - plugin crash during load doesn't crash loader", async () => {
await pluginStore.init();
@@ -487,6 +610,28 @@ describe("PluginLoader", () => {
expect(loader.isPluginLoaded("all-b")).toBe(true);
});
it("skips disabled plugins during loadAllPlugins", async () => {
await pluginStore.init();
const pluginDir = join(rootDir, "plugins");
const enabledPlugin = makePlugin(makeManifest({ id: "enabled-plugin" }));
const disabledPlugin = makePlugin(makeManifest({ id: "disabled-plugin" }));
const enabledPath = await writePluginModule(pluginDir, "enabled.js", enabledPlugin);
const disabledPath = await writePluginModule(pluginDir, "disabled.js", disabledPlugin);
await pluginStore.registerPlugin({ manifest: enabledPlugin.manifest, path: enabledPath });
await pluginStore.registerPlugin({ manifest: disabledPlugin.manifest, path: disabledPath });
await pluginStore.disablePlugin("disabled-plugin");
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const result = await loader.loadAllPlugins();
expect(result).toEqual({ loaded: 1, errors: 0 });
expect(loader.isPluginLoaded("enabled-plugin")).toBe(true);
expect(loader.isPluginLoaded("disabled-plugin")).toBe(false);
});
it("returns error count for failed plugins", async () => {
await pluginStore.init();

View File

@@ -13,6 +13,7 @@ import {
readProjectMemory,
readProjectMemoryWithBackend,
searchProjectMemory,
getProjectMemory,
resolveMemoryInstructionContext,
} from "../project-memory.js";
@@ -649,4 +650,45 @@ describe("project-memory", () => {
expect(results.some((result) => result.path === ".fusion/memory/DREAMS.md")).toBe(true);
});
});
describe("getProjectMemory", () => {
it("reads bounded memory window via file backend", async () => {
const memoryDir = join(testDir, ".fusion", "memory");
await mkdir(memoryDir, { recursive: true });
await writeFile(
join(memoryDir, "MEMORY.md"),
"# Memory\nline-a\nline-b\nline-c\nline-d\n",
"utf-8",
);
const result = await getProjectMemory(
testDir,
{ path: ".fusion/memory/MEMORY.md", startLine: 2, lineCount: 2 },
{ memoryBackendType: "file" },
);
expect(result.path).toBe(".fusion/memory/MEMORY.md");
expect(result.content).toBe("line-a\nline-b");
expect(result.startLine).toBe(2);
expect(result.endLine).toBe(3);
expect(result.totalLines).toBeGreaterThanOrEqual(5);
expect(result.backend).toBe("file");
});
it("returns qmd backend marker for memory_get contract", async () => {
const memoryDir = join(testDir, ".fusion", "memory");
await mkdir(memoryDir, { recursive: true });
await writeFile(join(memoryDir, "MEMORY.md"), "# Memory\nqmd-line\n", "utf-8");
const result = await getProjectMemory(
testDir,
{ path: ".fusion/memory/MEMORY.md", startLine: 1, lineCount: 5 },
{ memoryBackendType: "qmd" },
);
expect(result.path).toBe(".fusion/memory/MEMORY.md");
expect(result.content).toContain("qmd-line");
expect(result.backend).toBe("qmd");
});
});
});

View File

@@ -3,7 +3,7 @@ import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createDatabase, type Database } from "../db.js";
import { ResearchStore } from "../research-store.js";
import { ResearchLifecycleError, ResearchStore } from "../research-store.js";
describe("ResearchStore", () => {
let db: Database;
@@ -24,7 +24,7 @@ describe("ResearchStore", () => {
const updated = store.updateRun(run.id, { topic: "new topic", error: "oops" });
expect(updated?.topic).toBe("new topic");
const listed = store.listRuns({ status: "pending" });
const listed = store.listRuns({ status: "queued" });
expect(listed.map((r) => r.id)).toContain(run.id);
expect(store.deleteRun(run.id)).toBe(true);
@@ -51,6 +51,36 @@ describe("ResearchStore", () => {
expect(store.getRun(cancelled.id)?.cancelledAt).toBeTruthy();
});
it("enforces terminal immutability and valid transitions", () => {
const run = store.createRun({ query: "guarded" });
store.updateStatus(run.id, "running");
store.updateStatus(run.id, "completed");
expect(() => store.updateRun(run.id, { topic: "changed" })).toThrow(ResearchLifecycleError);
const queued = store.createRun({ query: "queued" });
expect(() => store.updateStatus(queued.id, "completed")).toThrow(/Invalid run status transition/i);
});
it("persists lifecycle events to research_run_events", () => {
const run = store.createRun({ query: "events" });
store.updateStatus(run.id, "running");
store.appendLifecycleEvent(run.id, { type: "info", message: "custom event" });
const events = store.listRunEvents(run.id);
expect(events.length).toBeGreaterThanOrEqual(2);
expect(events.at(-1)?.message).toBe("custom event");
});
it("guards against duplicate active runs per project and trigger", () => {
const run = store.createRun({ query: "r1", projectId: "p1", trigger: "manual" });
expect(store.getActiveRun("p1", "manual")?.id).toBe(run.id);
expect(() => store.assertNoActiveRun("p1", "manual")).toThrow(ResearchLifecycleError);
store.updateStatus(run.id, "cancelled");
expect(() => store.assertNoActiveRun("p1", "manual")).not.toThrow();
});
it("appends events, manages sources, and sets results", () => {
const run = store.createRun({ query: "events" });
const event = store.appendEvent(run.id, { type: "info", message: "started" });
@@ -91,6 +121,7 @@ describe("ResearchStore", () => {
expect(store.getExport("REXP-missing")).toBeUndefined();
store.updateStatus(r1.id, "running");
store.updateStatus(r2.id, "running");
store.updateStatus(r2.id, "completed");
const stats = store.getStats();
expect(stats.total).toBeGreaterThanOrEqual(2);
@@ -100,6 +131,32 @@ describe("ResearchStore", () => {
expect(store.getExports(r1.id)).toHaveLength(0);
});
it("supports idempotent cancellation request transition", () => {
const run = store.createRun({ query: "cancel me" });
const first = store.requestCancellation(run.id);
expect(first.status).toBe("cancelling");
const second = store.requestCancellation(run.id);
expect(second.status).toBe("cancelling");
store.updateStatus(run.id, "cancelled");
const terminal = store.requestCancellation(run.id);
expect(terminal.status).toBe("cancelled");
});
it("marks retry exhaustion when max attempts reached", () => {
const run = store.createRun({ query: "retry", lifecycle: { attempt: 3, maxAttempts: 3 } });
store.updateStatus(run.id, "failed", {
lifecycle: {
...(run.lifecycle ?? {}),
retryable: true,
failureClass: "retryable_transient",
},
});
expect(() => store.createRetryRun(run.id)).toThrow(/non-retryable|exhausted retries/i);
expect(store.getRun(run.id)?.status).toBe("retry_exhausted");
});
it("emits status events and throws for missing run mutations", () => {
const onStatus = vi.fn();
const onCompleted = vi.fn();
@@ -107,6 +164,7 @@ describe("ResearchStore", () => {
store.on("run:completed", onCompleted);
const run = store.createRun({ query: "events" });
store.updateStatus(run.id, "running");
store.updateStatus(run.id, "completed");
expect(onStatus).toHaveBeenCalled();
expect(onCompleted).toHaveBeenCalled();

View File

@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
});
});

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
});
});
});

View File

@@ -480,6 +480,33 @@ describe("TaskStore", () => {
});
});
describe("pausedByAgentId persistence", () => {
it("creates and lists a task with pausedByAgentId", async () => {
const task = await store.createTask({ description: "Agent paused task" });
const updated = await store.updateTask(task.id, { pausedByAgentId: "agent-1" });
expect(updated.pausedByAgentId).toBe("agent-1");
const detail = await store.getTask(task.id);
expect(detail.pausedByAgentId).toBe("agent-1");
const tasks = await store.listTasks();
const listed = tasks.find((t) => t.id === task.id);
expect(listed?.pausedByAgentId).toBe("agent-1");
});
it("clears pausedByAgentId with null via updateTask", async () => {
const task = await store.createTask({ description: "Clear agent pause marker" });
await store.updateTask(task.id, { pausedByAgentId: "agent-2" });
const cleared = await store.updateTask(task.id, { pausedByAgentId: null });
expect(cleared.pausedByAgentId).toBeUndefined();
const detail = await store.getTask(task.id);
expect(detail.pausedByAgentId).toBeUndefined();
});
});
describe("nodeId persistence", () => {
it("creates a task with nodeId when provided", async () => {
const task = await store.createTask({
@@ -592,6 +619,36 @@ describe("TaskStore", () => {
});
});
describe("getTasksByAssignedAgent", () => {
it("returns only tasks assigned to the requested agent", async () => {
const mine = await store.createTask({ description: "mine", assignedAgentId: "agent-1" });
await store.createTask({ description: "other", assignedAgentId: "agent-2" });
await store.createTask({ description: "unassigned" });
const tasks = await store.getTasksByAssignedAgent("agent-1");
expect(tasks.map((task) => task.id)).toEqual([mine.id]);
});
it("supports pausedOnly filter", async () => {
const paused = await store.createTask({ description: "paused", assignedAgentId: "agent-1" });
const active = await store.createTask({ description: "active", assignedAgentId: "agent-1" });
await store.updateTask(paused.id, { paused: true });
const tasks = await store.getTasksByAssignedAgent("agent-1", { pausedOnly: true });
expect(tasks.map((task) => task.id)).toEqual([paused.id]);
expect(tasks.some((task) => task.id === active.id)).toBe(false);
});
it("supports excludeArchived filter", async () => {
const active = await store.createTask({ description: "active", assignedAgentId: "agent-1" });
const archived = await store.createTask({ description: "archived", assignedAgentId: "agent-1", column: "done" });
await store.archiveTask(archived.id, false);
const tasks = await store.getTasksByAssignedAgent("agent-1", { excludeArchived: true });
expect(tasks.map((task) => task.id)).toEqual([active.id]);
});
});
describe("selectNextTaskForAgent", () => {
it("returns null when no tasks exist", async () => {
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
@@ -3546,6 +3603,39 @@ describe("TaskStore", () => {
fetched = await store.getTask(task.id);
expect(fetched.paused).toBe(true);
});
it("sets pausedByAgentId and logs agent pause reason", async () => {
const task = await createTestTask();
const paused = await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-1" });
expect(paused.pausedByAgentId).toBe("agent-1");
expect(paused.log.at(-1)?.action).toBe("Task paused (agent agent-1 paused)");
});
it("clears pausedByAgentId and logs agent resume reason", async () => {
const task = await createTestTask();
await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-2" });
const unpaused = await store.pauseTask(task.id, false);
expect(unpaused.pausedByAgentId).toBeUndefined();
expect(unpaused.log.at(-1)?.action).toBe("Task unpaused (agent agent-2 resumed)");
});
it("uses standard unpause log when task was not paused by an agent", async () => {
const task = await createTestTask();
await store.pauseTask(task.id, true);
const unpaused = await store.pauseTask(task.id, false);
expect(unpaused.pausedByAgentId).toBeUndefined();
expect(unpaused.log.at(-1)?.action).toBe("Task unpaused");
});
it("keeps pausedByAgentId undefined when pausing without agent options", async () => {
const task = await createTestTask();
const paused = await store.pauseTask(task.id, true);
expect(paused.pausedByAgentId).toBeUndefined();
});
});
describe("updateTask — paused", () => {

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(59);
expect(db.getSchemaVersion()).toBe(60);
const index = db
.prepare(

View File

@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it } from "vitest";
import { cpus } from "node:os";
import { computeMaxWorkers } from "../__test-utils__/vitest-workers";
const ORIGINAL_ENV = { ...process.env };
describe("computeMaxWorkers", () => {
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("uses explicit VITEST_MAX_WORKERS for package-scoped runs", () => {
process.env.VITEST_MAX_WORKERS = "4";
delete process.env.FUSION_TEST_TOTAL_WORKERS;
delete process.env.FUSION_TEST_CONCURRENCY;
const workers = computeMaxWorkers({ defaultCap: 2 });
const cpuCap = Math.max(1, cpus().length - 1);
expect(workers).toBe(Math.min(4, cpuCap));
expect(process.env.VITEST_MAX_WORKERS).toBe(String(workers));
});
it("clamps explicit VITEST_MAX_WORKERS to workspace per-package budget", () => {
process.env.VITEST_MAX_WORKERS = "4";
process.env.FUSION_TEST_TOTAL_WORKERS = "4";
process.env.FUSION_TEST_CONCURRENCY = "2";
const workers = computeMaxWorkers({ defaultCap: 2 });
expect(workers).toBe(2);
expect(process.env.VITEST_MAX_WORKERS).toBe("2");
});
it("still derives workers from workspace budget when explicit override is absent", () => {
delete process.env.VITEST_MAX_WORKERS;
process.env.FUSION_TEST_TOTAL_WORKERS = "6";
process.env.FUSION_TEST_CONCURRENCY = "2";
const workers = computeMaxWorkers({ defaultCap: 2 });
expect(workers).toBe(3);
expect(process.env.VITEST_MAX_WORKERS).toBe("3");
});
it("ignores invalid env values and falls back to default cap", () => {
process.env.VITEST_MAX_WORKERS = "abc";
process.env.FUSION_TEST_TOTAL_WORKERS = "0";
process.env.FUSION_TEST_CONCURRENCY = "-1";
const workers = computeMaxWorkers({ defaultCap: 2 });
expect(workers).toBe(2);
expect(process.env.VITEST_MAX_WORKERS).toBe("2");
});
});