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

@@ -1,5 +1,18 @@
# @fusion/core
## 0.17.2
### Patch Changes
- 17a6634: Fix pre-merge workflow steps stalling on tasks with no relevant changes (FN-3327 post-mortem).
- **`@fusion/engine`**: `executeWorkflowStep` now computes the diff scope (`git diff --name-only` plus `--shortstat` against `task.baseCommitSha`) before spawning the reviewer agent and injects a "Diff Scope" block into the system prompt. The block lists every file the task actually changed and adds explicit scoping rules: review only those files, and if none match the step's category respond immediately with a short approval line and stop. Without this, an open-ended review prompt (e.g. WS-005 "Frontend UX Design") would drift into pre-existing files matching the task description's keywords, exhaust the 360 s timeout, and trigger the auto-revive → re-finalize → re-fail loop that had FN-3327 wedged in `in-review`. Both git calls are best-effort; failures degrade to a "no modified files detected" notice rather than blocking the step.
- **`@fusion/core`**: The built-in `frontend-ux-design` workflow step template (WS-005) now opens with a FAST-BAIL rule telling the reviewer to inspect the Diff Scope first and return an immediate one-line approval when no UI/CSS/component files are present. New installs and freshly-materialized templates pick this up automatically; existing DB rows are unaffected but are still rescued by the executor-side scope injection above.
## 0.17.1
## 0.17.0
## 0.16.0
## 0.15.0

View File

@@ -1,6 +1,6 @@
{
"name": "@fusion/core",
"version": "0.16.0",
"version": "0.17.2",
"license": "MIT",
"description": "Fusion core: task store, scheduler, settings, and shared domain types backing the Fusion AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
@@ -38,6 +38,7 @@
"test": "vitest run --silent=passed-only --reporter=dot"
},
"devDependencies": {
"@types/dockerode": "^3.3.41",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^3.1.0",
"typescript": "^5.7.0",
@@ -51,6 +52,7 @@
"bonjour-service": "^1.3.0",
"check-disk-space": "^3.4.0",
"cron-parser": "^5.5.0",
"dockerode": "^4.0.2",
"extract-zip": "^2.0.1",
"tar": "^7.5.13",
"yaml": "^2.8.3"

View File

@@ -4,6 +4,12 @@ interface ComputeMaxWorkersOptions {
defaultCap?: number;
}
function parsePositiveInt(value: string | undefined): number | undefined {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
return parsed;
}
// Shared worker-budget computation for every package's vitest.config.
//
// Resolution order:
@@ -19,21 +25,21 @@ export function computeMaxWorkers(options: ComputeMaxWorkersOptions = {}): numbe
const cpuCap = Math.max(1, cpus().length - 1);
const explicit = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "", 10);
if (Number.isFinite(explicit) && explicit > 0) {
const clamped = Math.min(Math.max(1, explicit), cpuCap);
process.env.VITEST_MAX_WORKERS = String(clamped);
return clamped;
}
const totalBudget = Number.parseInt(process.env.FUSION_TEST_TOTAL_WORKERS ?? "", 10);
const concurrency = Math.max(
1,
Number.parseInt(process.env.FUSION_TEST_CONCURRENCY ?? "1", 10) || 1,
);
const explicit = parsePositiveInt(process.env.VITEST_MAX_WORKERS);
const totalBudget = parsePositiveInt(process.env.FUSION_TEST_TOTAL_WORKERS);
const concurrency = Math.max(1, parsePositiveInt(process.env.FUSION_TEST_CONCURRENCY) ?? 1);
let workers: number;
if (Number.isFinite(totalBudget) && totalBudget > 0) {
if (explicit !== undefined) {
// In recursive workspace runs we provide a global worker budget via
// FUSION_TEST_TOTAL_WORKERS/FUSION_TEST_CONCURRENCY. Clamp explicit
// VITEST_MAX_WORKERS to that per-package share so `VITEST_MAX_WORKERS=4`
// at the workspace root doesn't fan out to 4 workers in every package.
const workspaceBudget = totalBudget !== undefined
? Math.max(1, Math.floor(totalBudget / concurrency))
: undefined;
workers = workspaceBudget !== undefined ? Math.min(explicit, workspaceBudget) : explicit;
} else if (totalBudget !== undefined) {
workers = Math.max(1, Math.floor(totalBudget / concurrency));
} else {
workers = defaultCap;

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");
});
});

View File

@@ -63,6 +63,8 @@ export type EnrichedChatSession = ChatSession & {
lastMessagePreview?: string;
/** Timestamp of the last message in the session */
lastMessageAt?: string;
/** Whether a generation is currently in progress for this session */
isGenerating?: boolean;
};
/** A parsed @ mention of an agent in a chat message */

View File

@@ -88,7 +88,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 59;
const SCHEMA_VERSION = 60;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -215,6 +215,7 @@ CREATE TABLE IF NOT EXISTS tasks (
missionId TEXT,
sliceId TEXT,
assignedAgentId TEXT,
pausedByAgentId TEXT,
assigneeUserId TEXT,
sourceType TEXT,
sourceAgentId TEXT,
@@ -419,6 +420,8 @@ CREATE TABLE IF NOT EXISTS research_runs (
query TEXT NOT NULL,
topic TEXT,
status TEXT NOT NULL,
projectId TEXT,
trigger TEXT,
providerConfig TEXT,
sources TEXT NOT NULL DEFAULT '[]',
events TEXT NOT NULL DEFAULT '[]',
@@ -427,6 +430,7 @@ CREATE TABLE IF NOT EXISTS research_runs (
tokenUsage TEXT,
tags TEXT NOT NULL DEFAULT '[]',
metadata TEXT,
lifecycle TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
startedAt TEXT,
@@ -448,6 +452,20 @@ CREATE TABLE IF NOT EXISTS research_exports (
);
CREATE INDEX IF NOT EXISTS idxResearchExportsRunId ON research_exports(runId);
CREATE TABLE IF NOT EXISTS research_run_events (
id TEXT PRIMARY KEY,
runId TEXT NOT NULL,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT,
classification TEXT,
metadata TEXT,
createdAt TEXT NOT NULL,
FOREIGN KEY (runId) REFERENCES research_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxResearchRunEventsRunIdSeq ON research_run_events(runId, seq);
-- Schema version tracking
CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
@@ -643,9 +661,11 @@ CREATE TABLE IF NOT EXISTS project_insight_runs (
insightsUpdated INTEGER NOT NULL DEFAULT 0,
inputMetadata TEXT,
outputMetadata TEXT,
lifecycle TEXT,
createdAt TEXT NOT NULL,
startedAt TEXT,
completedAt TEXT
completedAt TEXT,
cancelledAt TEXT
);
-- Index for filtering insights by projectId
@@ -663,6 +683,23 @@ CREATE INDEX IF NOT EXISTS idxProjectInsightsCategory
-- Index for filtering runs by projectId
CREATE INDEX IF NOT EXISTS idxInsightRunsProjectId
ON project_insight_runs(projectId);
CREATE INDEX IF NOT EXISTS idxInsightRunsProjectTriggerStatus
ON project_insight_runs(projectId, trigger, status);
CREATE TABLE IF NOT EXISTS project_insight_run_events (
id TEXT PRIMARY KEY,
runId TEXT NOT NULL,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT,
classification TEXT,
metadata TEXT,
createdAt TEXT NOT NULL,
FOREIGN KEY (runId) REFERENCES project_insight_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxInsightRunEventsRunIdSeq
ON project_insight_run_events(runId, seq);
-- Todo list persistence tables (FN-2575)
-- Project-scoped todo lists and ordered checklist items
@@ -1828,9 +1865,11 @@ export class Database {
insightsUpdated INTEGER NOT NULL DEFAULT 0,
inputMetadata TEXT,
outputMetadata TEXT,
lifecycle TEXT,
createdAt TEXT NOT NULL,
startedAt TEXT,
completedAt TEXT
completedAt TEXT,
cancelledAt TEXT
)
`);
@@ -2194,6 +2233,8 @@ export class Database {
query TEXT NOT NULL,
topic TEXT,
status TEXT NOT NULL,
projectId TEXT,
trigger TEXT,
providerConfig TEXT,
sources TEXT NOT NULL DEFAULT '[]',
events TEXT NOT NULL DEFAULT '[]',
@@ -2202,6 +2243,7 @@ export class Database {
tokenUsage TEXT,
tags TEXT NOT NULL DEFAULT '[]',
metadata TEXT,
lifecycle TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
startedAt TEXT,
@@ -2213,6 +2255,7 @@ export class Database {
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsStatus ON research_runs(status)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsCreatedAt ON research_runs(createdAt)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsUpdatedAt ON research_runs(updatedAt)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsProjectTriggerStatus ON research_runs(projectId, trigger, status)`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS research_exports (
@@ -2293,6 +2336,64 @@ export class Database {
this.applyMigration(59, () => {
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTasksColumn ON tasks("column")`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTasksUpdatedAt ON tasks(updatedAt DESC)`);
if (this.hasTable("research_runs")) {
this.addColumnIfMissing("research_runs", "projectId", "TEXT");
this.addColumnIfMissing("research_runs", "trigger", "TEXT");
this.addColumnIfMissing("research_runs", "lifecycle", "TEXT");
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunsProjectTriggerStatus ON research_runs(projectId, trigger, status)`);
}
this.db.exec(`
CREATE TABLE IF NOT EXISTS research_run_events (
id TEXT PRIMARY KEY,
runId TEXT NOT NULL,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT,
classification TEXT,
metadata TEXT,
createdAt TEXT NOT NULL,
FOREIGN KEY (runId) REFERENCES research_runs(id) ON DELETE CASCADE
)
`);
if (this.hasTable("research_run_events")) {
this.addColumnIfMissing("research_run_events", "seq", "INTEGER NOT NULL DEFAULT 0");
}
this.db.exec(`CREATE INDEX IF NOT EXISTS idxResearchRunEventsRunIdSeq ON research_run_events(runId, seq)`);
if (this.hasTable("project_insight_runs")) {
this.addColumnIfMissing("project_insight_runs", "lifecycle", "TEXT");
this.addColumnIfMissing("project_insight_runs", "cancelledAt", "TEXT");
this.db.exec(`CREATE INDEX IF NOT EXISTS idxInsightRunsProjectTriggerStatus ON project_insight_runs(projectId, trigger, status)`);
}
this.db.exec(`
CREATE TABLE IF NOT EXISTS project_insight_run_events (
id TEXT PRIMARY KEY,
runId TEXT NOT NULL,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT,
classification TEXT,
metadata TEXT,
createdAt TEXT NOT NULL,
FOREIGN KEY (runId) REFERENCES project_insight_runs(id) ON DELETE CASCADE
)
`);
if (this.hasTable("project_insight_run_events")) {
this.addColumnIfMissing("project_insight_run_events", "seq", "INTEGER NOT NULL DEFAULT 0");
}
this.db.exec(`CREATE INDEX IF NOT EXISTS idxInsightRunEventsRunIdSeq ON project_insight_run_events(runId, seq)`);
});
}
if (version < 60) {
this.applyMigration(60, () => {
this.addColumnIfMissing("tasks", "pausedByAgentId", "TEXT");
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTasksPausedByAgentId ON tasks(pausedByAgentId)`);
});
}

View File

@@ -0,0 +1,300 @@
import Docker from "dockerode";
import { exec } from "node:child_process";
import { readFile } from "node:fs/promises";
import { promisify } from "node:util";
import type {
DockerConnectivityResult,
DockerContainerInspectResult,
DockerContextInfo,
DockerHostConfig,
DockerVolumeMount,
} from "./types.js";
const EXEC_OPTIONS = {
timeout: 15_000,
maxBuffer: 5 * 1024 * 1024,
} as const;
function isLocalDaemonHost(host?: string): boolean {
return !host || host.trim() === "" || host === "unix:///var/run/docker.sock";
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return String(error);
}
interface DockerContextCliEntry {
Name?: string;
Description?: string;
DockerEndpoint?: string;
DockerHost?: string;
Current?: boolean;
Error?: string;
}
export class DockerClientService {
private dockerInstance: Docker | null = null;
constructor(private readonly defaultHostConfig?: DockerHostConfig) {}
private async createDockerInstance(hostConfig?: DockerHostConfig): Promise<Docker> {
if (hostConfig?.context) {
const contextName = hostConfig.context.trim();
if (!contextName) throw new Error("Docker context name cannot be empty");
let stdout: string;
try {
({ stdout } = await promisify(exec)(`docker context inspect ${JSON.stringify(contextName)}`, EXEC_OPTIONS));
} catch (error) {
throw new Error(`Failed to inspect Docker context "${contextName}": ${toErrorMessage(error)}`);
}
const parsed = JSON.parse(stdout) as Array<{ Endpoints?: { docker?: { Host?: string } } }>;
const dockerHost = parsed[0]?.Endpoints?.docker?.Host;
if (!dockerHost) throw new Error(`Docker context "${contextName}" does not define a Docker endpoint host`);
return new Docker({ host: dockerHost });
}
if (hostConfig?.host) {
const options: {
host: string;
ca?: Buffer;
cert?: Buffer;
key?: Buffer;
rejectUnauthorized?: boolean;
} = {
host: hostConfig.host,
};
if (hostConfig.tlsCaPath) options.ca = await readFile(hostConfig.tlsCaPath);
if (hostConfig.tlsCertPath) options.cert = await readFile(hostConfig.tlsCertPath);
if (hostConfig.tlsKeyPath) options.key = await readFile(hostConfig.tlsKeyPath);
if (hostConfig.tlsVerify === false) options.rejectUnauthorized = false;
return new Docker(options);
}
return new Docker();
}
async testConnection(hostConfig?: DockerHostConfig): Promise<DockerConnectivityResult> {
const isLocalDaemon = isLocalDaemonHost(hostConfig?.host) && !hostConfig?.context;
try {
const docker = await this.createDockerInstance(hostConfig);
await docker.ping();
const version = await docker.version();
return {
success: true,
dockerVersion: version.Version,
apiVersion: version.ApiVersion,
operatingSystem: version.Os,
isLocalDaemon,
};
} catch (error) {
return {
success: false,
error: toErrorMessage(error),
isLocalDaemon,
};
}
}
async listContexts(): Promise<DockerContextInfo[]> {
try {
const { stdout } = await promisify(exec)("docker context ls --format json", EXEC_OPTIONS);
const lines = stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
return [{ name: "default", isCurrentContext: true, description: "Current Docker context" }];
}
try {
return lines.map((line) => {
const entry = JSON.parse(line) as DockerContextCliEntry;
return {
name: entry.Name ?? "default",
description: entry.Description,
dockerHost: entry.DockerEndpoint ?? entry.DockerHost,
isCurrentContext: Boolean(entry.Current),
isError: Boolean(entry.Error),
errorMessage: entry.Error,
} satisfies DockerContextInfo;
});
} catch {
const tableLines = lines.slice(1);
const contexts: DockerContextInfo[] = [];
for (const line of tableLines) {
const parts = line.split(/\s{2,}/).map((part) => part.trim()).filter(Boolean);
if (parts.length === 0) continue;
const rawName = parts[0] ?? "";
const isCurrentContext = rawName.startsWith("*");
const name = rawName.replace(/^\*\s*/, "") || "default";
contexts.push({
name,
description: parts[2] || undefined,
dockerHost: parts[1] || undefined,
isCurrentContext,
});
}
return contexts;
}
} catch (error) {
const message = toErrorMessage(error);
if (message.includes("ENOENT")) {
return [{ name: "default", isCurrentContext: true, description: "Current Docker context" }];
}
throw error;
}
}
/**
* Get a Docker instance for the given host config.
* If no host config is provided, uses the default config (cached).
* Otherwise creates a fresh instance for the custom config.
*/
async getDockerInstance(hostConfig?: DockerHostConfig): Promise<Docker> {
if (!hostConfig || hostConfig === this.defaultHostConfig) {
return this.getInstance();
}
return this.createDockerInstance(hostConfig);
}
async getContainerInfo(containerId: string, hostConfig?: DockerHostConfig): Promise<DockerContainerInspectResult | null> {
try {
const docker = await this.getDockerInstance(hostConfig);
const inspect = await docker.getContainer(containerId).inspect();
const ports = Object.entries(inspect.NetworkSettings?.Ports ?? {}).reduce<Record<string, string>>((acc, [key, value]) => {
const binding = Array.isArray(value) && value.length > 0 ? value[0] : undefined;
if (binding?.HostPort) {
acc[key] = binding.HostPort;
}
return acc;
}, {});
return {
id: inspect.Id,
name: (inspect.Name ?? "").replace(/^\//, ""),
status: inspect.State?.Status ?? "unknown",
image: inspect.Config?.Image ?? "",
created: inspect.Created ? Date.parse(inspect.Created) : 0,
state: {
running: Boolean(inspect.State?.Running),
paused: Boolean(inspect.State?.Paused),
restarting: Boolean(inspect.State?.Restarting),
dead: Boolean(inspect.State?.Dead),
error: inspect.State?.Error || undefined,
exitCode: typeof inspect.State?.ExitCode === "number" ? inspect.State.ExitCode : undefined,
startedAt: inspect.State?.StartedAt || undefined,
finishedAt: inspect.State?.FinishedAt || undefined,
},
ports,
};
} catch (error) {
const message = toErrorMessage(error);
if (message.includes("404") || message.toLowerCase().includes("no such container")) {
return null;
}
throw error;
}
}
async getContainerLogs(containerId: string, hostConfig?: DockerHostConfig, options?: { tail?: number }): Promise<string> {
const docker = await this.getDockerInstance(hostConfig);
const stream = await docker.getContainer(containerId).logs({
stdout: true,
stderr: true,
tail: options?.tail ?? 100,
});
if (Buffer.isBuffer(stream)) {
return stream.toString("utf8");
}
return String(stream ?? "");
}
private async getInstance(): Promise<Docker> {
if (!this.dockerInstance) {
this.dockerInstance = await this.createDockerInstance(this.defaultHostConfig);
}
return this.dockerInstance;
}
/**
* Recreate a container with updated environment variables.
*
* Docker environment variables are baked in at container creation time and
* cannot be changed without recreating the container. This method:
* 1. Inspects the old container to capture its configuration
* 2. Stops and removes the old container
* 3. Creates a new container with the same image and volumes but updated env vars
* 4. Starts the new container
* 5. Returns the new container ID
*
* Volume mounts are preserved across recreation. If persistentStorage is false,
* volumes are not included in the new container.
*/
async recreateContainer(
containerId: string,
options: {
envVars: Record<string, string>;
imageName: string;
volumeMounts: DockerVolumeMount[];
hostConfig?: DockerHostConfig;
},
): Promise<string> {
const docker = await this.getDockerInstance(options.hostConfig);
const container = docker.getContainer(containerId);
// Inspect the old container to capture its config
const inspect = await container.inspect();
const oldName = (inspect.Name ?? "").replace(/^\//, "");
// Build environment variable array from the provided map
const envArray = Object.entries(options.envVars).map(
([key, value]) => `${key}=${value}`,
);
// Build binds for volume mounts
const binds = options.volumeMounts.map(
(mount) => `${mount.hostPath}:${mount.containerPath}:${mount.mode}`,
);
// Stop and remove the old container
try {
await container.stop({ t: 5 });
} catch (error) {
// Container may already be stopped
const message = toErrorMessage(error);
if (!message.includes("is not running") && !message.includes("already stopped")) {
throw error;
}
}
await container.remove({ force: true });
// Create the new container with updated env vars
const newContainer = await docker.createContainer({
name: oldName || undefined,
Image: options.imageName,
Env: envArray,
HostConfig: {
Binds: binds.length > 0 ? binds : undefined,
RestartPolicy: {
Name: "unless-stopped",
},
},
});
// Start the new container
await newContainer.start();
return newContainer.id;
}
dispose(): void {
this.dockerInstance = null;
}
}

View File

@@ -0,0 +1,341 @@
import { randomUUID } from "node:crypto";
import type Docker from "dockerode";
import type {
DockerContainerInspectResult,
DockerHostConfig,
DockerProvisionInput,
DockerProvisionResult,
} from "./types.js";
import { DockerClientService } from "./docker-client.js";
const log = {
info: (...args: unknown[]) => console.log("[docker-provisioning]", ...args),
error: (...args: unknown[]) => console.error("[docker-provisioning]", ...args),
warn: (...args: unknown[]) => console.warn("[docker-provisioning]", ...args),
};
/**
* Service for provisioning Docker-based Fusion nodes.
* Creates containers from a prebuilt image, manages their lifecycle,
* and provides start/stop/restart operations.
*/
export class DockerProvisioningService {
constructor(private readonly dockerClientService: DockerClientService) {}
/**
* Provision a new Docker container for a Fusion node.
* Pulls or validates the image, creates and starts the container,
* and returns the result with container details.
*/
async provision(input: DockerProvisionInput): Promise<DockerProvisionResult> {
const startTime = Date.now();
try {
const docker = await this.dockerClientService.getDockerInstance(input.hostConfig);
const { image, tag } = input.imageConfig;
const imageRef = `${image}:${tag}`;
// Step 1: Pull or validate image
if (input.imageConfig.pullImage) {
log.info(`Pulling image ${imageRef}...`);
try {
const authOptions =
input.imageConfig.registryUsername || input.imageConfig.registryPassword
? {
authconfig: {
username: input.imageConfig.registryUsername ?? "",
password: input.imageConfig.registryPassword ?? "",
},
}
: undefined;
const stream = await docker.pull(imageRef, authOptions);
await new Promise<void>((resolve, reject) => {
docker.modem.followProgress(stream, (err: Error | null) =>
err ? reject(err) : resolve(),
);
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`Image pull failed: ${message}`);
return {
success: false,
error: `Failed to pull image ${imageRef}: ${message}`,
failedStage: "image-pull",
durationMs: Date.now() - startTime,
};
}
} else {
// Validate image exists locally
try {
await docker.getImage(imageRef).inspect();
} catch {
log.error(`Image ${imageRef} not found locally`);
return {
success: false,
error: `Image ${imageRef} not found locally. Set pullImage: true to pull it.`,
failedStage: "image-pull",
durationMs: Date.now() - startTime,
};
}
}
// Step 2: Generate container name
const containerName = `fusion-${input.nodeName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}-${randomUUID().slice(0, 8)}`;
// Step 3: Generate API key if needed
const apiKey = input.autoGenerateApiKey
? `fn_${randomUUID().replace(/-/g, "")}`
: input.apiKey ?? "";
// Step 4: Build environment
const envArray: string[] = [
`FUSION_NODE_NAME=${input.nodeName}`,
`FUSION_API_KEY=${apiKey}`,
"FUSION_MODE=serve",
"FUSION_PORT=4040",
"FUSION_DATA_DIR=/data",
];
if (input.reachableUrl) {
envArray.push(`FUSION_REACHABLE_URL=${input.reachableUrl}`);
}
if (input.extraClis && input.extraClis.length > 0) {
envArray.push(`FUSION_EXTRA_CLIS=${input.extraClis.join(",")}`);
}
// User-provided environment appended last (allows overrides)
if (input.environment) {
envArray.push(...input.environment);
}
// Step 5: Build volume mounts
const mounts: string[] = [];
if (input.persistentVolume) {
mounts.push(`${input.persistentVolume}:/data`);
}
if (input.volumeMounts) {
mounts.push(...input.volumeMounts);
}
// Step 6: Build container create options
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createOptions: any = {
name: containerName,
Image: imageRef,
Env: envArray,
HostConfig: {
PortBindings: { "4040/tcp": [{ HostPort: "0" }] },
...(mounts.length > 0 ? { Binds: mounts } : {}),
...(input.resourceConfig?.memoryLimitMb
? { Memory: input.resourceConfig.memoryLimitMb * 1024 * 1024 }
: {}),
...(input.resourceConfig?.cpuLimit
? { NanoCpus: Math.round(input.resourceConfig.cpuLimit * 1e9) }
: {}),
...(input.resourceConfig?.memorySwapMb !== undefined
? { MemorySwap: input.resourceConfig.memorySwapMb * 1024 * 1024 }
: {}),
RestartPolicy: { Name: "unless-stopped" },
},
...(input.network
? { NetworkingConfig: { EndpointsConfig: { [input.network]: {} } } }
: {}),
Labels: {
"fusion.managed": "true",
"fusion.node-name": input.nodeName,
...(input.labels || {}),
},
ExposedPorts: { "4040/tcp": {} },
};
// Step 7: Create container
let container: Docker.Container;
try {
container = await docker.createContainer(createOptions);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`Container creation failed: ${message}`);
return {
success: false,
error: `Failed to create container: ${message}`,
failedStage: "container-create",
durationMs: Date.now() - startTime,
};
}
// Step 8: Start container
try {
await container.start();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`Container start failed: ${message}, cleaning up...`);
// Attempt cleanup
try {
await container.remove({ force: true });
} catch {
// Best effort cleanup
}
return {
success: false,
error: `Failed to start container: ${message}`,
failedStage: "container-start",
durationMs: Date.now() - startTime,
};
}
// Step 9: Inspect to get port mapping
let portMapping: string | undefined;
try {
const inspectResult = await container.inspect();
const portInfo = inspectResult.NetworkSettings?.Ports?.["4040/tcp"]?.[0];
if (portInfo?.HostPort) {
portMapping = `4040:${portInfo.HostPort}`;
}
} catch {
// Container is running, port info is nice-to-have
}
const durationMs = Date.now() - startTime;
log.info(`Container ${containerName} provisioned successfully in ${durationMs}ms`);
return {
success: true,
containerId: container.id,
containerName,
apiKey,
portMapping,
durationMs,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`Unexpected provisioning error: ${message}`);
return {
success: false,
error: message,
failedStage: "container-create",
durationMs: Date.now() - startTime,
};
}
}
/**
* Deprovision a Docker container — stop and remove it.
*/
async deprovision(
containerId: string,
hostConfig: DockerHostConfig,
removeVolumes: boolean,
): Promise<{ success: boolean; error?: string }> {
try {
const docker = await this.dockerClientService.getDockerInstance(hostConfig);
const container = docker.getContainer(containerId);
// Stop with timeout — ignore "already stopped" errors
try {
await container.stop({ t: 10 });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("already stopped") && !message.includes("is not running")) {
log.warn(`Container stop returned: ${message}`);
}
}
await container.remove({ force: true, v: removeVolumes });
log.info(`Container ${containerId} deprovisioned`);
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`Deprovision failed for ${containerId}: ${message}`);
return { success: false, error: message };
}
}
/**
* Get the runtime status of a container.
* Creates a Docker client for the given hostConfig to query container info.
*/
async getContainerStatus(
containerId: string,
hostConfig: DockerHostConfig,
): Promise<DockerContainerInspectResult | null> {
try {
const docker = await this.dockerClientService.getDockerInstance(hostConfig);
const inspect = await docker.getContainer(containerId).inspect();
return {
id: inspect.Id,
name: (inspect.Name ?? "").replace(/^\//, ""),
status: inspect.State?.Status ?? "unknown",
image: inspect.Config?.Image ?? "",
created: inspect.Created ? Date.parse(inspect.Created) : 0,
state: {
running: Boolean(inspect.State?.Running),
paused: Boolean(inspect.State?.Paused),
restarting: Boolean(inspect.State?.Restarting),
dead: Boolean(inspect.State?.Dead),
error: inspect.State?.Error || undefined,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("404") || message.toLowerCase().includes("no such container")) {
return null;
}
throw error;
}
}
/**
* Start an existing container.
*/
async startContainer(
containerId: string,
hostConfig: DockerHostConfig,
): Promise<{ success: boolean; error?: string }> {
try {
const docker = await this.dockerClientService.getDockerInstance(hostConfig);
const container = docker.getContainer(containerId);
await container.start();
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}
/**
* Stop a running container with a 10-second timeout.
*/
async stopContainer(
containerId: string,
hostConfig: DockerHostConfig,
): Promise<{ success: boolean; error?: string }> {
try {
const docker = await this.dockerClientService.getDockerInstance(hostConfig);
const container = docker.getContainer(containerId);
await container.stop({ t: 10 });
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}
/**
* Restart a container (stop then start).
*/
async restartContainer(
containerId: string,
hostConfig: DockerHostConfig,
): Promise<{ success: boolean; error?: string }> {
try {
const docker = await this.dockerClientService.getDockerInstance(hostConfig);
const container = docker.getContainer(containerId);
await container.restart();
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}
}

View File

@@ -391,6 +391,9 @@ export { NodeConnection } from "./node-connection.js";
export { NodeDiscovery } from "./node-discovery.js";
export { collectSystemMetrics } from "./system-metrics.js";
export { getAppVersion, parseSemver } from "./app-version.js";
export { DockerClientService } from "./docker-client.js";
export { MeshConfigGenerator } from "./mesh-config-generator.js";
export { DockerProvisioningService } from "./docker-provisioning.js";
export type {
ConnectionErrorType,
ConnectionOptions,
@@ -414,9 +417,20 @@ export type {
DockerResourceSizing,
DockerVolumeMount,
DockerExtraCli,
DockerContextInfo,
DockerConnectivityResult,
DockerContainerInspectResult,
DockerNodeImageConfig,
DockerNodeResourceConfig,
DockerProvisionInput,
DockerProvisionResult,
ManagedDockerNode,
ManagedDockerNodeInput,
ManagedDockerNodeUpdate,
MeshConfigGeneratorInput,
FullProvisioningInput,
MeshConnectionConfig,
MeshConfigResult,
NodeDiscoveryEvent,
DiscoveryConfig,
DiscoveredNode,
@@ -586,7 +600,12 @@ export type { AgentDreamProcessorResult, DreamProcessorResult, DreamPromptExecut
// ── Project Insights ──────────────────────────────────────────────────────
export { InsightStore, computeInsightFingerprint } from "./insight-store.js";
export { InsightLifecycleError, InsightStore, computeInsightFingerprint } from "./insight-store.js";
export {
classifyInsightRunError,
executeInsightRunLifecycle,
retryInsightRunLifecycle,
} from "./insight-run-executor.js";
export type {
InsightCategory,
InsightStatus,
@@ -599,6 +618,10 @@ export type {
InsightRun,
InsightRunStatus,
InsightRunTrigger,
InsightRunFailureClass,
InsightRunLifecycle,
InsightRunEventType,
InsightRunEvent,
InsightRunInputMetadata,
InsightRunOutputMetadata,
InsightRunCreateInput,
@@ -606,10 +629,16 @@ export type {
InsightRunListOptions,
InsightStoreEvents,
} from "./insight-types.js";
export type {
InsightRunAttemptContext,
InsightRunAttemptResult,
InsightRunExecutorErrorClassification,
InsightRunExecutorOptions,
} from "./insight-run-executor.js";
// ── Research System ───────────────────────────────────────────────────────
export { ResearchStore } from "./research-store.js";
export { ResearchLifecycleError, ResearchStore } from "./research-store.js";
export {
RESEARCH_RUN_STATUSES,
RESEARCH_SOURCE_STATUSES,
@@ -618,6 +647,7 @@ export {
RESEARCH_EVENT_TYPES,
RESEARCH_ORCHESTRATION_PHASES,
RESEARCH_ORCHESTRATION_STEP_STATUSES,
RESEARCH_RUN_FAILURE_CLASSES,
} from "./research-types.js";
export type {
ResearchRunStatus,
@@ -631,6 +661,9 @@ export type {
ResearchResult,
ResearchTokenUsage,
ResearchRun,
ResearchRunLifecycle,
ResearchRunFailureClass,
ResearchRunEvent,
ResearchExport,
ResearchRunCreateInput,
ResearchRunUpdateInput,
@@ -730,6 +763,14 @@ export type {
} from "./chat-types.js";
export { ChatStore } from "./chat-store.js";
export type { ChatStoreEvents } from "./chat-store.js";
export {
choosePreferredStoredCredential,
extractCodexCliStoredCredential,
getCodexCliAuthPath,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
} from "./oauth-credential-interop.js";
export type { StoredAuthCredential } from "./oauth-credential-interop.js";
// ── Error helpers ─────────────────────────────────────────
export { getErrorMessage } from "./error-message.js";

View File

@@ -0,0 +1,303 @@
import { setTimeout as delay } from "node:timers/promises";
import type {
InsightRun,
InsightRunCreateInput,
InsightRunFailureClass,
InsightRunOutputMetadata,
InsightRunTrigger,
InsightRunUpdateInput,
} from "./insight-types.js";
import { InsightLifecycleError, InsightStore } from "./insight-store.js";
export interface InsightRunAttemptResult {
summary?: string | null;
insightsCreated: number;
insightsUpdated: number;
outputMetadata?: InsightRunOutputMetadata;
}
export interface InsightRunAttemptContext {
run: InsightRun;
attempt: number;
maxAttempts: number;
signal: AbortSignal;
}
export interface InsightRunExecutorOptions {
store: InsightStore;
projectId: string;
input: InsightRunCreateInput;
executeAttempt: (ctx: InsightRunAttemptContext) => Promise<InsightRunAttemptResult>;
timeoutMs?: number;
maxAttempts?: number;
retryDelayMs?: number;
signal?: AbortSignal;
}
export interface InsightRunExecutorErrorClassification {
failureClass: InsightRunFailureClass;
retryable: boolean;
terminalReason: "cancelled" | "failed" | "timed_out";
terminalCause: string;
}
function isAbortLike(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
function asErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function classifyInsightRunError(error: unknown): InsightRunExecutorErrorClassification {
if (isAbortLike(error)) {
return {
failureClass: "cancelled",
retryable: false,
terminalReason: "cancelled",
terminalCause: asErrorMessage(error),
};
}
const message = asErrorMessage(error);
if (/timeout|timed out|deadline/i.test(message)) {
return {
failureClass: "timed_out",
retryable: true,
terminalReason: "timed_out",
terminalCause: message,
};
}
if (/ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|429|5\d\d/i.test(message)) {
return {
failureClass: "retryable_transient",
retryable: true,
terminalReason: "failed",
terminalCause: message,
};
}
return {
failureClass: "non_retryable",
retryable: false,
terminalReason: "failed",
terminalCause: message,
};
}
function composeSignal(timeoutMs: number | undefined, parent?: AbortSignal): { signal: AbortSignal; clear: () => void } {
const controller = new AbortController();
const timeoutId = timeoutMs && timeoutMs > 0
? setTimeout(() => controller.abort(new Error(`Insight run timed out after ${timeoutMs}ms`)), timeoutMs)
: undefined;
const onAbort = () => {
controller.abort(parent?.reason ?? new DOMException("Aborted", "AbortError"));
};
if (parent) {
if (parent.aborted) onAbort();
else parent.addEventListener("abort", onAbort, { once: true });
}
return {
signal: controller.signal,
clear: () => {
if (timeoutId) clearTimeout(timeoutId);
if (parent) parent.removeEventListener("abort", onAbort);
},
};
}
function patchForStatus(status: "completed" | "failed" | "cancelled", patch: InsightRunUpdateInput): InsightRunUpdateInput {
if (status === "cancelled") {
return {
...patch,
cancelledAt: patch.cancelledAt ?? new Date().toISOString(),
};
}
return patch;
}
async function executeExistingRun(
store: InsightStore,
run: InsightRun,
options: Omit<InsightRunExecutorOptions, "input" | "projectId"> & { maxAttempts: number; retryDelayMs: number },
): Promise<InsightRun> {
const started = store.updateRun(run.id, {
status: "running",
startedAt: run.startedAt ?? new Date().toISOString(),
lifecycle: {
...run.lifecycle,
maxAttempts: options.maxAttempts,
attempt: run.lifecycle.attempt ?? 1,
},
});
let active = started ?? run;
store.appendRunEvent(active.id, { type: "status_changed", status: "running", message: "Run started" });
for (let attempt = active.lifecycle.attempt ?? 1; attempt <= options.maxAttempts; attempt += 1) {
const { signal, clear } = composeSignal(options.timeoutMs, options.signal);
try {
if (signal.aborted) {
throw signal.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError");
}
store.appendRunEvent(active.id, {
type: "info",
message: `Attempt ${attempt}/${options.maxAttempts}`,
metadata: { attempt, maxAttempts: options.maxAttempts },
});
const result = await options.executeAttempt({ run: active, attempt, maxAttempts: options.maxAttempts, signal });
const completed = store.updateRun(active.id, {
status: "completed",
summary: result.summary ?? null,
insightsCreated: result.insightsCreated,
insightsUpdated: result.insightsUpdated,
outputMetadata: result.outputMetadata,
lifecycle: {
...active.lifecycle,
attempt,
maxAttempts: options.maxAttempts,
terminalReason: "completed",
retryable: false,
},
});
if (!completed) throw new Error(`Run disappeared while completing: ${active.id}`);
store.appendRunEvent(completed.id, { type: "status_changed", status: "completed", message: "Run completed" });
return completed;
} catch (error) {
const classification = classifyInsightRunError(error);
const canRetry = classification.retryable && attempt < options.maxAttempts;
store.appendRunEvent(active.id, {
type: canRetry ? "retry_scheduled" : "error",
status: canRetry ? "running" : classification.terminalReason === "cancelled" ? "cancelled" : "failed",
classification: classification.failureClass,
message: canRetry
? `Attempt ${attempt} failed (${classification.failureClass}); retrying`
: `Run failed (${classification.failureClass})`,
metadata: { attempt, maxAttempts: options.maxAttempts, error: asErrorMessage(error) },
});
if (canRetry) {
active = store.updateRun(active.id, {
lifecycle: {
...active.lifecycle,
attempt: attempt + 1,
maxAttempts: options.maxAttempts,
failureClass: classification.failureClass,
retryable: true,
},
}) ?? active;
if (options.retryDelayMs > 0) {
await delay(options.retryDelayMs, undefined, { signal: options.signal });
}
continue;
}
const terminalStatus = classification.terminalReason === "cancelled" ? "cancelled" : "failed";
const terminal = store.updateRun(active.id, patchForStatus(terminalStatus, {
status: terminalStatus,
error: asErrorMessage(error),
lifecycle: {
...active.lifecycle,
attempt,
maxAttempts: options.maxAttempts,
terminalReason: classification.terminalReason,
terminalCause: classification.terminalCause,
failureClass: classification.failureClass,
retryable: classification.failureClass === "retryable_transient",
timeoutAt: classification.failureClass === "timed_out" ? new Date().toISOString() : active.lifecycle.timeoutAt,
},
}));
if (!terminal) throw new Error(`Run disappeared while failing: ${active.id}`);
return terminal;
} finally {
clear();
}
}
const failed = store.updateRun(active.id, {
status: "failed",
error: "Run exhausted attempts",
lifecycle: {
...active.lifecycle,
terminalReason: "failed",
terminalCause: "Run exhausted attempts",
failureClass: "non_retryable",
retryable: false,
attempt: options.maxAttempts,
maxAttempts: options.maxAttempts,
},
});
if (!failed) throw new Error(`Run disappeared after attempts exhausted: ${active.id}`);
return failed;
}
export async function executeInsightRunLifecycle(options: InsightRunExecutorOptions): Promise<InsightRun> {
const maxAttempts = Math.max(1, options.maxAttempts ?? 2);
const retryDelayMs = Math.max(0, options.retryDelayMs ?? 250);
let run: InsightRun;
try {
run = options.store.createRunOrThrowConflict(options.projectId, {
...options.input,
lifecycle: {
...options.input.lifecycle,
attempt: options.input.lifecycle?.attempt ?? 1,
maxAttempts,
rootRunId: options.input.lifecycle?.rootRunId,
},
});
} catch (error) {
if (error instanceof InsightLifecycleError && error.code === "active_run_conflict") {
throw error;
}
throw error;
}
options.store.appendRunEvent(run.id, {
type: "status_changed",
status: "pending",
message: "Run created",
});
return executeExistingRun(options.store, run, {
...options,
maxAttempts,
retryDelayMs,
});
}
export async function retryInsightRunLifecycle(
options: Omit<InsightRunExecutorOptions, "input" | "projectId"> & { runId: string; trigger?: InsightRunTrigger; inputMetadata?: InsightRunCreateInput["inputMetadata"] },
): Promise<{ run: InsightRun; retryOf: InsightRun }> {
const original = options.store.getRun(options.runId);
if (!original) {
throw new Error(`Insight run not found: ${options.runId}`);
}
if (original.status !== "failed") {
throw new InsightLifecycleError(`Run ${original.id} must be failed to retry`, "not_retryable");
}
if (!original.lifecycle.retryable || original.lifecycle.failureClass !== "retryable_transient") {
throw new InsightLifecycleError(`Run ${original.id} is non-retryable`, "not_retryable");
}
const run = await executeInsightRunLifecycle({
...options,
projectId: original.projectId,
input: {
trigger: options.trigger ?? original.trigger,
inputMetadata: options.inputMetadata ?? original.inputMetadata,
lifecycle: {
retryOfRunId: original.id,
rootRunId: original.lifecycle.rootRunId ?? original.id,
attempt: 1,
},
},
});
return { run, retryOf: original };
}

View File

@@ -27,6 +27,7 @@
*/
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import type { Database } from "./db.js";
import { toJsonNullable, fromJson } from "./db.js";
import type {
@@ -46,6 +47,10 @@ import type {
InsightRunTrigger,
InsightRunInputMetadata,
InsightRunOutputMetadata,
InsightRunLifecycle,
InsightRunFailureClass,
InsightRunEvent,
InsightRunEventType,
} from "./insight-types.js";
import type { InsightStoreEvents } from "./insight-types.js";
@@ -63,6 +68,29 @@ function generateRunId(): string {
return `INSR-${timestamp}-${random}`;
}
function generateRunEventId(): string {
return `INSEVT-${randomUUID()}`;
}
const TERMINAL_RUN_STATUSES = new Set<InsightRunStatus>(["completed", "failed", "cancelled"]);
const VALID_RUN_STATUS_TRANSITIONS: Record<InsightRunStatus, InsightRunStatus[]> = {
pending: ["running", "completed", "failed", "cancelled"],
running: ["completed", "failed", "cancelled"],
completed: [],
failed: [],
cancelled: [],
};
export class InsightLifecycleError extends Error {
constructor(
message: string,
readonly code: "invalid_transition" | "terminal_immutable" | "active_run_conflict" | "not_retryable",
) {
super(message);
this.name = "InsightLifecycleError";
}
}
// ── Fingerprint Helper ────────────────────────────────────────────────
/**
@@ -403,14 +431,21 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
const now = new Date().toISOString();
const id = generateRunId();
const inputMetadata = input.inputMetadata ?? {};
const lifecycle: InsightRunLifecycle = {
attempt: input.lifecycle?.attempt ?? 1,
maxAttempts: input.lifecycle?.maxAttempts ?? 1,
rootRunId: input.lifecycle?.rootRunId,
retryOfRunId: input.lifecycle?.retryOfRunId,
...input.lifecycle,
};
this.db.prepare(`
INSERT INTO project_insight_runs (
id, projectId, trigger, status, summary, error,
insightsCreated, insightsUpdated,
inputMetadata, outputMetadata,
createdAt, startedAt, completedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
inputMetadata, outputMetadata, lifecycle,
createdAt, startedAt, completedAt, cancelledAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
projectId,
@@ -422,9 +457,11 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
0,
toJsonNullable(inputMetadata) ?? null,
null,
toJsonNullable(lifecycle),
now,
null,
null,
null,
);
this.db.bumpLastModified();
@@ -443,6 +480,8 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
createdAt: now,
startedAt: null,
completedAt: null,
cancelledAt: null,
lifecycle,
};
this.emit("run:created", run);
@@ -499,9 +538,27 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
const existing = this.getRun(id);
if (!existing) return undefined;
const mutatingKeys = Object.keys(input);
if (TERMINAL_RUN_STATUSES.has(existing.status) && mutatingKeys.length > 0) {
throw new InsightLifecycleError(`Run ${id} is terminal and immutable`, "terminal_immutable");
}
if (input.status && input.status !== existing.status) {
const allowed = VALID_RUN_STATUS_TRANSITIONS[existing.status];
if (!allowed.includes(input.status)) {
throw new InsightLifecycleError(
`Invalid run status transition: ${existing.status} -> ${input.status}`,
"invalid_transition",
);
}
}
const now = new Date().toISOString();
const isTerminal = input.status !== undefined && ["completed", "failed", "cancelled"].includes(input.status);
const autoComplete = isTerminal && input.completedAt === undefined && existing.completedAt === null;
const nextStatus = input.status ?? existing.status;
const isTerminal = TERMINAL_RUN_STATUSES.has(nextStatus);
const lifecycle = { ...existing.lifecycle, ...(input.lifecycle ?? {}) };
const autoCompleteAt = isTerminal && input.completedAt === undefined && existing.completedAt === null ? now : undefined;
const autoCancelledAt = nextStatus === "cancelled" && input.cancelledAt === undefined && existing.cancelledAt === null ? now : undefined;
const sets: string[] = [];
const params: (string | number | null)[] = [];
@@ -530,6 +587,10 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
sets.push("outputMetadata = ?");
params.push(toJsonNullable(input.outputMetadata));
}
if (input.lifecycle !== undefined) {
sets.push("lifecycle = ?");
params.push(toJsonNullable(lifecycle));
}
if (input.startedAt !== undefined) {
sets.push("startedAt = ?");
params.push(input.startedAt);
@@ -538,22 +599,29 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
sets.push("completedAt = ?");
params.push(input.completedAt);
}
if (input.cancelledAt !== undefined) {
sets.push("cancelledAt = ?");
params.push(input.cancelledAt);
}
if (autoCompleteAt !== undefined) {
sets.push("completedAt = ?");
params.push(autoCompleteAt);
}
if (autoCancelledAt !== undefined) {
sets.push("cancelledAt = ?");
params.push(autoCancelledAt);
}
if (sets.length === 0) return existing;
// Auto-set completedAt for terminal transitions
if (autoComplete) {
sets.push("completedAt = ?");
params.push(now);
}
params.push(id);
this.db.prepare(`UPDATE project_insight_runs SET ${sets.join(", ")} WHERE id = ?`).run(...params);
this.db.bumpLastModified();
const updated = this.getRun(id)!;
if (isTerminal) {
if (isTerminal && updated.status !== existing.status) {
this.emit("run:completed", updated);
}
this.emit("run:updated", updated);
@@ -573,21 +641,102 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
* @returns The created or existing run
*/
upsertRun(projectId: string, trigger: InsightRunTrigger, input: InsightRunCreateInput): InsightRun {
// Find most recent pending/running run for this project + trigger
const existing = this.findActiveRun(projectId, trigger);
if (existing) {
return existing;
}
return this.createRun(projectId, input);
}
findActiveRun(projectId: string, trigger: InsightRunTrigger): InsightRun | undefined {
const existingRow = this.db.prepare(`
SELECT * FROM project_insight_runs
SELECT id FROM project_insight_runs
WHERE projectId = ? AND trigger = ? AND status IN ('pending', 'running')
ORDER BY createdAt DESC, id DESC
LIMIT 1
`).get(projectId, trigger) as Record<string, unknown> | undefined;
`).get(projectId, trigger) as { id: string } | undefined;
return existingRow ? this.getRun(existingRow.id) : undefined;
}
if (existingRow) {
return this.getRun(existingRow.id as string)!;
createRunOrThrowConflict(projectId: string, input: InsightRunCreateInput): InsightRun {
const existing = this.findActiveRun(projectId, input.trigger);
if (existing) {
throw new InsightLifecycleError(
`Active run already exists for project ${projectId} trigger ${input.trigger}: ${existing.id}`,
"active_run_conflict",
);
}
return this.createRun(projectId, input);
}
appendRunEvent(
runId: string,
event: {
type: InsightRunEventType;
message: string;
status?: InsightRunStatus;
classification?: InsightRunFailureClass;
metadata?: Record<string, unknown>;
},
): InsightRunEvent {
const run = this.getRun(runId);
if (!run) {
throw new Error(`Insight run not found: ${runId}`);
}
const createdAt = new Date().toISOString();
const row = this.db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 as nextSeq FROM project_insight_run_events WHERE runId = ?").get(runId) as { nextSeq: number };
const runEvent: InsightRunEvent = {
id: generateRunEventId(),
runId,
seq: Number(row?.nextSeq ?? 1),
type: event.type,
message: event.message,
status: event.status,
classification: event.classification,
metadata: event.metadata,
createdAt,
};
this.db.prepare(`
INSERT INTO project_insight_run_events (id, runId, seq, type, message, status, classification, metadata, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
runEvent.id,
runEvent.runId,
runEvent.seq,
runEvent.type,
runEvent.message,
runEvent.status ?? null,
runEvent.classification ?? null,
toJsonNullable(runEvent.metadata),
runEvent.createdAt,
);
this.db.bumpLastModified();
this.emit("run:event", { runId, event: runEvent });
return runEvent;
}
listRunEvents(runId: string): InsightRunEvent[] {
const rows = this.db.prepare(`
SELECT * FROM project_insight_run_events
WHERE runId = ?
ORDER BY seq ASC
`).all(runId) as Record<string, unknown>[];
return rows.map((row) => ({
id: row.id as string,
runId: row.runId as string,
seq: Number(row.seq),
type: row.type as InsightRunEventType,
message: row.message as string,
status: (row.status as InsightRunStatus | null) ?? undefined,
classification: (row.classification as InsightRunFailureClass | null) ?? undefined,
metadata: fromJson<Record<string, unknown>>(row.metadata as string | null),
createdAt: row.createdAt as string,
}));
}
/**
* Get the count of runs matching the given filter.
*/
@@ -642,6 +791,11 @@ export class InsightStore extends EventEmitter<InsightStoreEvents> {
createdAt: row.createdAt as string,
startedAt: row.startedAt as string | null,
completedAt: row.completedAt as string | null,
cancelledAt: row.cancelledAt as string | null,
lifecycle: (() => {
const m = fromJson<InsightRunLifecycle>(row.lifecycle as string | null);
return m ?? {};
})(),
};
}
}

View File

@@ -275,6 +275,42 @@ export type InsightRunTrigger = "schedule" | "manual" | "task_completion" | "mer
* Runs track the full lifecycle of an analysis pass — from scheduling
* through input processing to output persistence.
*/
export type InsightRunFailureClass = "cancelled" | "timed_out" | "retryable_transient" | "non_retryable";
export interface InsightRunLifecycle {
terminalReason?: "completed" | "cancelled" | "failed" | "timed_out";
terminalCause?: string;
failureClass?: InsightRunFailureClass;
retryable?: boolean;
cancellationRequestedAt?: string;
timeoutAt?: string;
retryOfRunId?: string;
rootRunId?: string;
attempt?: number;
maxAttempts?: number;
}
export type InsightRunEventType =
| "status_changed"
| "retry_scheduled"
| "cancel_requested"
| "timeout"
| "info"
| "warning"
| "error";
export interface InsightRunEvent {
id: string;
runId: string;
seq: number;
type: InsightRunEventType;
message: string;
status?: InsightRunStatus;
classification?: InsightRunFailureClass;
metadata?: Record<string, unknown>;
createdAt: string;
}
export interface InsightRun {
/**
* Unique identifier for this run (e.g., "INSR-xxx").
@@ -344,6 +380,13 @@ export interface InsightRun {
* When the run reached a terminal state.
*/
completedAt: string | null;
/**
* When cancellation was applied.
*/
cancelledAt: string | null;
lifecycle: InsightRunLifecycle;
}
// ── Run Input / Output Metadata ──────────────────────────────────────
@@ -422,6 +465,7 @@ export interface InsightRunOutputMetadata {
export interface InsightRunCreateInput {
trigger: InsightRunTrigger;
inputMetadata?: InsightRunInputMetadata;
lifecycle?: InsightRunLifecycle;
}
// ── Run Update Input ─────────────────────────────────────────────────
@@ -437,8 +481,10 @@ export interface InsightRunUpdateInput {
insightsCreated?: number;
insightsUpdated?: number;
outputMetadata?: InsightRunOutputMetadata;
lifecycle?: InsightRunLifecycle;
startedAt?: string | null;
completedAt?: string | null;
cancelledAt?: string | null;
}
// ── Run List Options ─────────────────────────────────────────────────
@@ -478,4 +524,6 @@ export interface InsightStoreEvents {
"run:updated": [InsightRun];
/** Emitted when a run reaches a terminal state */
"run:completed": [InsightRun];
/** Emitted when a durable run event is appended */
"run:event": [{ runId: string; event: InsightRunEvent }];
}

View File

@@ -0,0 +1,273 @@
import { randomUUID } from "node:crypto";
import type {
DockerHostConfig,
FullProvisioningInput,
ManagedDockerNode,
MeshConfigGeneratorInput,
MeshConfigResult,
MeshConnectionConfig,
NodeStatus,
} from "./types.js";
import type { CentralCore } from "./central-core.js";
import type { DockerClientService } from "./docker-client.js";
/** Default container port — NOT 4040 (reserved for the production dashboard per AGENTS.md). */
const DEFAULT_CONTAINER_PORT = 4041;
/** Maximum time to wait for a new node to report healthy (ms). */
const HEALTH_CHECK_TIMEOUT_MS = 30_000;
/** Polling interval between health check attempts (ms). */
const HEALTH_CHECK_INTERVAL_MS = 3_000;
/** Brief pause after container recreation to allow startup (ms). */
const POST_RECREATE_DELAY_MS = 2_500;
/**
* Service for generating mesh connection configuration for newly provisioned Docker nodes.
*
* Generates an API key, assembles connection environment variables, injects them into
* the running container (via recreation), registers the node in the mesh, and verifies
* connectivity. This is the glue that turns a provisioned container into a reachable mesh peer.
*/
export class MeshConfigGenerator {
constructor(
private readonly deps: {
central: CentralCore;
dockerClient: DockerClientService;
},
) {}
/**
* Assemble the mesh connection configuration from the provided inputs.
* Pure function — no side effects.
*/
generateConfig(input: MeshConfigGeneratorInput): MeshConnectionConfig {
const { managedNode, orchestratorUrl, orchestratorApiKey, nodeApiKey, containerPort } = input;
const resolvedApiKey = nodeApiKey ?? this.generateApiKey();
const resolvedPort = containerPort ?? DEFAULT_CONTAINER_PORT;
const resolvedUrl = this.determineReachableUrl(managedNode, resolvedPort);
// Build mesh env vars (these override any user-provided values with the same keys)
const meshEnvVars: Record<string, string> = {
FUSION_DAEMON_TOKEN: resolvedApiKey,
PORT: String(resolvedPort),
FUSION_NODE_NAME: managedNode.name,
};
// Merge with existing user env vars — mesh config keys take precedence
const envVars: Record<string, string> = {
...(managedNode.envVars ?? {}),
...meshEnvVars,
};
return {
nodeApiKey: resolvedApiKey,
reachableUrl: resolvedUrl,
orchestratorUrl,
orchestratorApiKey,
containerPort: resolvedPort,
envVars,
};
}
/**
* Apply the mesh configuration to a provisioned Docker node by recreating
* its container with updated environment variables.
*/
async applyConfig(
managedNodeId: string,
config: MeshConnectionConfig,
hostConfig: DockerHostConfig,
): Promise<void> {
const managedNode = await this.deps.central.getManagedDockerNode(managedNodeId);
if (!managedNode) {
throw new Error(`Managed Docker node not found: ${managedNodeId}`);
}
if (!managedNode.containerId) {
throw new Error(
`Cannot apply config: node "${managedNode.name}" (${managedNodeId}) has no container ID. ` +
"The node must be provisioned first.",
);
}
// Set status to "recreating" before touching the container
await this.deps.central.updateManagedDockerNode(managedNodeId, {
status: "recreating",
});
try {
const newContainerId = await this.deps.dockerClient.recreateContainer(
managedNode.containerId,
{
envVars: config.envVars,
imageName: `${managedNode.imageName}:${managedNode.imageTag}`,
volumeMounts: managedNode.volumeMounts ?? [],
hostConfig,
},
);
// Brief pause to allow container to start
await new Promise((resolve) => setTimeout(resolve, POST_RECREATE_DELAY_MS));
// Update the managed node record with new state
await this.deps.central.updateManagedDockerNode(managedNodeId, {
apiKey: config.nodeApiKey,
reachableUrl: config.reachableUrl,
envVars: config.envVars,
status: "running",
containerId: newContainerId,
});
} catch (error) {
// Update status to error and re-throw
const message = error instanceof Error ? error.message : String(error);
await this.deps.central.updateManagedDockerNode(managedNodeId, {
status: "error",
errorMessage: message,
});
throw error;
}
}
/**
* Register the provisioned node in the mesh and verify connectivity.
*/
async registerInMesh(
managedNodeId: string,
config: MeshConnectionConfig,
): Promise<MeshConfigResult> {
const managedNode = await this.deps.central.getManagedDockerNode(managedNodeId);
if (!managedNode) {
throw new Error(`Managed Docker node not found: ${managedNodeId}`);
}
// Register a new NodeConfig in the mesh
const node = await this.deps.central.registerNode({
name: managedNode.name,
type: "remote",
url: config.reachableUrl,
apiKey: config.nodeApiKey,
maxConcurrent: 2,
});
// Link the managed Docker node to the new NodeConfig
await this.deps.central.linkManagedDockerNodeToNode(managedNodeId, node.id);
// Wait for the node to come online (polling health check)
const { healthy, latencyMs } = await this.waitForNodeHealth(
node.id,
HEALTH_CHECK_TIMEOUT_MS,
HEALTH_CHECK_INTERVAL_MS,
);
const result: MeshConfigResult = {
config,
node,
isHealthy: healthy,
healthCheckLatencyMs: latencyMs,
};
if (!healthy) {
result.error = `Node did not reach online status within ${HEALTH_CHECK_TIMEOUT_MS / 1000}s`;
}
return result;
}
/**
* End-to-end convenience method: generate config → apply → register.
* On failure at any step, sets managed node status to "error" and re-throws.
*/
async provisionAndRegister(input: FullProvisioningInput): Promise<MeshConfigResult> {
const managedNodeId = input.managedNode.id;
try {
const config = this.generateConfig(input);
await this.applyConfig(managedNodeId, config, input.managedNode.hostConfig);
return await this.registerInMesh(managedNodeId, config);
} catch (error) {
// Ensure the managed node is in error state
const message = error instanceof Error ? error.message : String(error);
try {
await this.deps.central.updateManagedDockerNode(managedNodeId, {
status: "error",
errorMessage: message,
});
} catch {
// Best-effort status update — the original error is more important
}
throw error;
}
}
// ─── Private helpers ──────────────────────────────────────────────────
/** Generate a 32-character hex API key from randomUUID(). */
private generateApiKey(): string {
return randomUUID().replace(/-/g, "");
}
/** Resolve the reachable URL from the managed node configuration. */
private determineReachableUrl(
managedNode: ManagedDockerNode,
containerPort: number,
): string {
// Use explicit user-provided URL if set
if (managedNode.reachableUrl) {
return managedNode.reachableUrl;
}
// Determine from host config
const host = managedNode.hostConfig?.host;
if (!host || isLocalDaemonHost(host)) {
return `http://localhost:${containerPort}`;
}
// Extract hostname from the Docker host URL (e.g., "tcp://192.168.1.50:2376" → "192.168.1.50")
try {
const url = new URL(host);
return `http://${url.hostname}:${containerPort}`;
} catch {
// Fallback: use the raw host value
return `http://${host}:${containerPort}`;
}
}
/**
* Poll the node health check until it reports online or times out.
* Returns whether the node is healthy and the latency of the successful check.
*/
private async waitForNodeHealth(
nodeId: string,
timeoutMs: number,
intervalMs: number,
): Promise<{ healthy: boolean; latencyMs?: number }> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const checkStart = Date.now();
try {
const status: NodeStatus = await this.deps.central.checkNodeHealth(nodeId);
const latencyMs = Date.now() - checkStart;
if (status === "online") {
return { healthy: true, latencyMs };
}
} catch {
// Health check failed — node not ready yet
}
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return { healthy: false };
}
}
/** Check if the Docker host is a local daemon. */
function isLocalDaemonHost(host?: string): boolean {
return !host || host.trim() === "" || host === "unix:///var/run/docker.sock";
}

View File

@@ -0,0 +1,243 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export type StoredAuthCredential = {
type?: string;
key?: string;
access?: string;
refresh?: string;
expires?: number;
accountId?: string;
[key: string]: unknown;
};
const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
const CODEX_REFRESH_FALLBACK_WINDOW_MS = 55 * 60 * 1000;
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
}
export function getCodexCliAuthPath(home = getHomeDir()): string {
return join(home, ".codex", "auth.json");
}
function parseJwtPayload(token: string): Record<string, unknown> | null {
try {
const [, payload = ""] = token.split(".", 3);
if (!payload) {
return null;
}
return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as Record<string, unknown>;
} catch {
return null;
}
}
function getJwtExpiryMs(token: string | undefined): number | undefined {
if (!token) {
return undefined;
}
const payload = parseJwtPayload(token);
const exp = payload?.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) {
return undefined;
}
return exp * 1000;
}
function getCodexAccountId(accessToken: string, fallbackAccountId: unknown): string | undefined {
const payload = parseJwtPayload(accessToken);
const authClaim = payload?.[OPENAI_AUTH_CLAIM];
const claimAccountId =
authClaim && typeof authClaim === "object"
? (authClaim as Record<string, unknown>).chatgpt_account_id
: undefined;
if (typeof claimAccountId === "string" && claimAccountId.trim().length > 0) {
return claimAccountId;
}
if (typeof fallbackAccountId === "string" && fallbackAccountId.trim().length > 0) {
return fallbackAccountId;
}
return undefined;
}
function getLastRefreshFallbackExpiryMs(lastRefresh: unknown): number | undefined {
if (typeof lastRefresh !== "string" || lastRefresh.trim().length === 0) {
return undefined;
}
const parsed = Date.parse(lastRefresh);
if (!Number.isFinite(parsed)) {
return undefined;
}
return parsed + CODEX_REFRESH_FALLBACK_WINDOW_MS;
}
function isStoredAuthCredential(value: unknown): value is StoredAuthCredential {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
return record.type === "oauth" || record.type === "api_key";
}
function isValidOauthCredential(credential: StoredAuthCredential | undefined): boolean {
return credential?.type === "oauth"
&& typeof credential.access === "string"
&& credential.access.length > 0
&& typeof credential.refresh === "string"
&& credential.refresh.length > 0
&& typeof credential.expires === "number"
&& Number.isFinite(credential.expires)
&& Date.now() < credential.expires;
}
function isRefreshableOauthCredential(credential: StoredAuthCredential | undefined): boolean {
return credential?.type === "oauth"
&& typeof credential.refresh === "string"
&& credential.refresh.length > 0
&& typeof credential.expires === "number"
&& Number.isFinite(credential.expires);
}
function compareStoredCredentials(
left: StoredAuthCredential | undefined,
right: StoredAuthCredential | undefined,
): number {
if (!left && !right) {
return 0;
}
if (left && !right) {
return 1;
}
if (!left && right) {
return -1;
}
if (left?.type === "api_key" && right?.type !== "api_key") {
return 1;
}
if (right?.type === "api_key" && left?.type !== "api_key") {
return -1;
}
if (left?.type === "oauth" && right?.type === "oauth") {
const leftValid = isValidOauthCredential(left);
const rightValid = isValidOauthCredential(right);
if (leftValid !== rightValid) {
return leftValid ? 1 : -1;
}
const leftRefreshable = isRefreshableOauthCredential(left);
const rightRefreshable = isRefreshableOauthCredential(right);
if (leftRefreshable !== rightRefreshable) {
return leftRefreshable ? 1 : -1;
}
const leftExpiry = typeof left.expires === "number" && Number.isFinite(left.expires) ? left.expires : -Infinity;
const rightExpiry = typeof right.expires === "number" && Number.isFinite(right.expires) ? right.expires : -Infinity;
if (leftExpiry !== rightExpiry) {
return leftExpiry > rightExpiry ? 1 : -1;
}
const leftAccessLength = typeof left.access === "string" ? left.access.length : 0;
const rightAccessLength = typeof right.access === "string" ? right.access.length : 0;
if (leftAccessLength !== rightAccessLength) {
return leftAccessLength > rightAccessLength ? 1 : -1;
}
}
return 0;
}
export function choosePreferredStoredCredential(
...credentials: Array<StoredAuthCredential | undefined>
): StoredAuthCredential | undefined {
let best: StoredAuthCredential | undefined;
for (const credential of credentials) {
if (compareStoredCredentials(credential, best) > 0) {
best = credential;
}
}
return best;
}
export function shouldHydrateStoredCredential(
current: StoredAuthCredential | undefined,
candidate: StoredAuthCredential | undefined,
): boolean {
if (!candidate || candidate.type !== "oauth") {
return false;
}
if (current?.type === "api_key") {
return false;
}
return compareStoredCredentials(candidate, current) > 0;
}
export function extractCodexCliStoredCredential(raw: unknown): StoredAuthCredential | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const record = raw as Record<string, unknown>;
const tokens = record.tokens;
if (!tokens || typeof tokens !== "object" || Array.isArray(tokens)) {
return undefined;
}
const tokenRecord = tokens as Record<string, unknown>;
const access = typeof tokenRecord.access_token === "string" ? tokenRecord.access_token : undefined;
const refresh = typeof tokenRecord.refresh_token === "string" ? tokenRecord.refresh_token : undefined;
if (!access || !refresh) {
return undefined;
}
const expires =
getJwtExpiryMs(access)
?? getJwtExpiryMs(typeof tokenRecord.id_token === "string" ? tokenRecord.id_token : undefined)
?? getLastRefreshFallbackExpiryMs(record.last_refresh);
if (typeof expires !== "number" || !Number.isFinite(expires)) {
return undefined;
}
const accountId = getCodexAccountId(access, tokenRecord.account_id);
return {
type: "oauth",
access,
refresh,
expires,
...(accountId ? { accountId } : {}),
};
}
export function readStoredCredentialsFromAuthFile(authPath: string): Record<string, StoredAuthCredential> {
if (!existsSync(authPath)) {
return {};
}
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as unknown;
const codexCliCredential = extractCodexCliStoredCredential(parsed);
if (codexCliCredential) {
return { "openai-codex": codexCliCredential };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
const credentials: Record<string, StoredAuthCredential> = {};
for (const [providerId, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!isStoredAuthCredential(value)) {
continue;
}
credentials[providerId] = value;
}
return credentials;
} catch {
return {};
}
}

View File

@@ -24,6 +24,10 @@ import { existsSync } from "node:fs";
import {
ensureOpenClawMemoryFiles,
memoryLongTermPath,
resolveMemoryBackend,
MEMORY_BACKEND_SETTINGS_KEYS,
DEFAULT_MEMORY_BACKEND,
scheduleQmdInstallAndRefresh,
type MemorySearchOptions,
type MemorySearchResult,
type MemoryGetOptions,
@@ -93,17 +97,6 @@ type MemorySettings = {
[key: string]: unknown;
};
// Import memory backend utilities lazily to avoid circular dependencies
async function getMemoryBackendUtils() {
const module = await import("./memory-backend.js");
return {
resolveMemoryBackend: module.resolveMemoryBackend,
getMemoryBackendCapabilities: module.getMemoryBackendCapabilities,
MEMORY_BACKEND_SETTINGS_KEYS: module.MEMORY_BACKEND_SETTINGS_KEYS,
DEFAULT_MEMORY_BACKEND: module.DEFAULT_MEMORY_BACKEND,
scheduleQmdInstallAndRefresh: module.scheduleQmdInstallAndRefresh,
};
}
// ── Memory Instruction Context ─────────────────────────────────────────
@@ -236,13 +229,6 @@ export async function ensureMemoryFileWithBackend(
rootDir: string,
settings?: MemorySettings,
): Promise<boolean> {
const {
resolveMemoryBackend,
MEMORY_BACKEND_SETTINGS_KEYS,
DEFAULT_MEMORY_BACKEND,
scheduleQmdInstallAndRefresh,
} = await getMemoryBackendUtils();
const backendType =
(settings?.[MEMORY_BACKEND_SETTINGS_KEYS.MEMORY_BACKEND_TYPE] as string) ||
DEFAULT_MEMORY_BACKEND;
@@ -296,7 +282,6 @@ export async function readProjectMemoryWithBackend(
rootDir: string,
settings?: MemorySettings,
): Promise<string> {
const { resolveMemoryBackend } = await getMemoryBackendUtils();
const backend = resolveMemoryBackend(settings);
try {
@@ -313,7 +298,6 @@ export async function searchProjectMemory(
options: MemorySearchOptions,
settings?: MemorySettings,
): Promise<MemorySearchResult[]> {
const { resolveMemoryBackend } = await getMemoryBackendUtils();
const backend = resolveMemoryBackend(settings);
if (!backend.search) {
return [];
@@ -326,7 +310,6 @@ export async function getProjectMemory(
options: MemoryGetOptions,
settings?: MemorySettings,
): Promise<MemoryGetResult> {
const { resolveMemoryBackend } = await getMemoryBackendUtils();
const backend = resolveMemoryBackend(settings);
if (!backend.get) {
throw new Error(`Memory backend '${backend.type}' does not support memory_get`);

View File

@@ -9,6 +9,9 @@ import type {
ResearchResult,
ResearchRun,
ResearchRunCreateInput,
ResearchRunEvent,
ResearchErrorCode,
ResearchRunFailureClass,
ResearchRunListOptions,
ResearchRunStatus,
ResearchRunUpdateInput,
@@ -26,6 +29,16 @@ function generateId(prefix: string): string {
return `${prefix}-${randomUUID()}`;
}
export class ResearchLifecycleError extends Error {
constructor(
message: string,
readonly code: "invalid_transition" | "terminal_immutable" | "active_run_conflict" | "not_retryable",
) {
super(message);
this.name = "ResearchLifecycleError";
}
}
function mergeRecord(
currentValue: Record<string, unknown> | undefined,
patchValue: Record<string, unknown> | undefined,
@@ -35,6 +48,36 @@ function mergeRecord(
return Object.keys(merged).length > 0 ? merged : undefined;
}
const TERMINAL_STATUSES = new Set<ResearchRunStatus>([
"completed",
"failed",
"cancelled",
"timed_out",
"retry_exhausted",
]);
const VALID_STATUS_TRANSITIONS: Record<ResearchRunStatus, ResearchRunStatus[]> = {
queued: ["running", "cancelling", "cancelled", "failed", "retry_waiting", "timed_out"],
running: ["completed", "failed", "cancelling", "cancelled", "retry_waiting", "timed_out"],
cancelling: ["cancelled", "failed", "timed_out"],
retry_waiting: ["queued", "running", "cancelled", "retry_exhausted", "failed"],
completed: [],
failed: ["retry_exhausted"],
cancelled: [],
timed_out: ["retry_exhausted"],
retry_exhausted: [],
};
function normalizeStatus(status: ResearchRunStatus | "pending"): ResearchRunStatus {
return status === "pending" ? "queued" : status;
}
function defaultErrorCodeForFailureClass(failureClass?: ResearchRunFailureClass): ResearchErrorCode {
if (failureClass === "timed_out") return "PROVIDER_TIMEOUT";
if (failureClass === "cancelled") return "RUN_CANCELLED";
if (failureClass === "non_retryable") return "NON_RETRYABLE_PROVIDER_ERROR";
return "INTERNAL_ERROR";
}
export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
constructor(private readonly db: Database) {
super();
@@ -47,27 +90,38 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
id: generateRunId(),
query: input.query,
topic: input.topic,
status: "pending",
status: "queued",
projectId: input.projectId,
trigger: input.trigger,
providerConfig: input.providerConfig,
sources: input.sources ?? [],
events: input.events ?? [],
results: input.results,
tags: input.tags ?? [],
metadata: input.metadata,
lifecycle: {
attempt: input.lifecycle?.attempt ?? 1,
maxAttempts: input.lifecycle?.maxAttempts ?? 3,
rootRunId: input.lifecycle?.rootRunId,
retryOfRunId: input.lifecycle?.retryOfRunId,
...input.lifecycle,
},
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO research_runs (
id, query, topic, status, providerConfig, sources, events, results, error,
tokenUsage, tags, metadata, createdAt, updatedAt, startedAt, completedAt, cancelledAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
id, query, topic, status, projectId, trigger, providerConfig, sources, events, results, error,
tokenUsage, tags, metadata, lifecycle, createdAt, updatedAt, startedAt, completedAt, cancelledAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
run.id,
run.query,
run.topic ?? null,
run.status,
run.projectId ?? null,
run.trigger ?? null,
toJsonNullable(run.providerConfig),
toJson(run.sources),
toJson(run.events),
@@ -76,6 +130,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
null,
toJson(run.tags),
toJsonNullable(run.metadata),
toJsonNullable(run.lifecycle),
run.createdAt,
run.updatedAt,
null,
@@ -97,15 +152,42 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
const existing = this.getRun(id);
if (!existing) return undefined;
const normalizedExistingStatus = normalizeStatus(existing.status as ResearchRunStatus | "pending");
const normalizedInputStatus = input.status
? normalizeStatus(input.status as ResearchRunStatus | "pending")
: undefined;
const nonMutableKeys = Object.keys(input).filter((key) => key !== "events" && key !== "metadata");
if (
TERMINAL_STATUSES.has(normalizedExistingStatus)
&& nonMutableKeys.length > 0
&& !(nonMutableKeys.length === 1 && nonMutableKeys[0] === "status")
) {
throw new ResearchLifecycleError(`Run ${id} is terminal and immutable`, "terminal_immutable");
}
if (normalizedInputStatus && normalizedInputStatus !== normalizedExistingStatus) {
const allowed = VALID_STATUS_TRANSITIONS[normalizedExistingStatus];
if (!allowed.includes(normalizedInputStatus)) {
throw new ResearchLifecycleError(
`Invalid run status transition: ${normalizedExistingStatus} -> ${normalizedInputStatus}`,
"invalid_transition",
);
}
}
const now = new Date().toISOString();
const mergedProviderConfig = mergeRecord(existing.providerConfig, input.providerConfig);
const mergedMetadata = mergeRecord(existing.metadata, input.metadata);
const mergedLifecycle = { ...(existing.lifecycle ?? {}), ...(input.lifecycle ?? {}) };
const updated: ResearchRun = {
...existing,
...input,
status: normalizedInputStatus ?? normalizedExistingStatus,
providerConfig: mergedProviderConfig,
metadata: mergedMetadata,
lifecycle: Object.keys(mergedLifecycle).length > 0 ? mergedLifecycle : undefined,
error: input.error === null ? undefined : (input.error ?? existing.error),
updatedAt: now,
startedAt: input.startedAt === null ? undefined : (input.startedAt ?? existing.startedAt),
@@ -180,7 +262,24 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
metadata: event.metadata,
};
this.updateRun(runId, { events: [...run.events, created] });
const seq = this.getNextEventSeq(runId);
this.db.prepare(`
INSERT INTO research_run_events (id, runId, seq, type, message, status, classification, metadata, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
created.id,
runId,
seq,
created.type,
created.message,
run.status,
null,
toJsonNullable(created.metadata),
created.timestamp,
);
this.persistRun({ ...run, events: [...run.events, created], updatedAt: new Date().toISOString() });
this.db.bumpLastModified();
this.emit("event:added", { runId, event: created });
return created;
}
@@ -189,6 +288,62 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
return this.addEvent(runId, event);
}
appendLifecycleEvent(
runId: string,
event: { type: ResearchEvent["type"]; message: string; status?: ResearchRunStatus; classification?: ResearchRunFailureClass; metadata?: Record<string, unknown> },
): ResearchRunEvent {
const run = this.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
const createdAt = new Date().toISOString();
const lifecycleEvent: ResearchRunEvent = {
id: generateId("REVT"),
runId,
seq: this.getNextEventSeq(runId),
type: event.type,
message: event.message,
status: event.status,
classification: event.classification,
metadata: event.metadata,
createdAt,
};
this.db.prepare(`
INSERT INTO research_run_events (id, runId, seq, type, message, status, classification, metadata, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
lifecycleEvent.id,
lifecycleEvent.runId,
lifecycleEvent.seq,
lifecycleEvent.type,
lifecycleEvent.message,
lifecycleEvent.status ?? null,
lifecycleEvent.classification ?? null,
toJsonNullable(lifecycleEvent.metadata),
lifecycleEvent.createdAt,
);
this.db.bumpLastModified();
return lifecycleEvent;
}
listRunEvents(runId: string): ResearchRunEvent[] {
const rows = this.db.prepare(`
SELECT * FROM research_run_events
WHERE runId = ?
ORDER BY seq ASC
`).all(runId) as Record<string, unknown>[];
return rows.map((row) => ({
id: row.id as string,
runId: row.runId as string,
seq: Number(row.seq),
type: row.type as ResearchEvent["type"],
message: row.message as string,
status: (row.status as ResearchRunStatus | null) ?? undefined,
classification: (row.classification as ResearchRunFailureClass | null) ?? undefined,
metadata: fromJson<Record<string, unknown>>(row.metadata as string | null),
createdAt: row.createdAt as string,
}));
}
addSource(runId: string, source: Omit<ResearchSource, "id">): ResearchSource {
const run = this.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
@@ -224,29 +379,72 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
const run = this.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
const normalizedStatus = normalizeStatus(status as ResearchRunStatus | "pending");
const now = new Date().toISOString();
const patch: ResearchRunUpdateInput = {
...(extra ?? {}),
status,
status: normalizedStatus,
lifecycle: {
...(run.lifecycle ?? {}),
},
};
if (status === "running" && !run.startedAt) {
patch.startedAt = now;
}
if ((status === "completed" || status === "failed") && !run.completedAt) {
patch.completedAt = now;
}
if (status === "cancelled" && !run.cancelledAt) {
patch.cancelledAt = now;
if (normalizedStatus === "running" && !run.startedAt) patch.startedAt = now;
if (TERMINAL_STATUSES.has(normalizedStatus) && !run.completedAt) patch.completedAt = now;
if (normalizedStatus === "cancelled" && !run.cancelledAt) patch.cancelledAt = now;
if (normalizedStatus === "completed") {
patch.lifecycle = { ...(patch.lifecycle ?? {}), terminalReason: "completed", retryable: false, errorCode: undefined };
} else if (normalizedStatus === "failed") {
const failureClass = patch.lifecycle?.failureClass;
patch.lifecycle = {
...(patch.lifecycle ?? {}),
terminalReason: "failed",
retryable: failureClass === "retryable_transient",
errorCode: patch.lifecycle?.errorCode ?? defaultErrorCodeForFailureClass(failureClass),
};
} else if (normalizedStatus === "cancelled") {
patch.lifecycle = {
...(patch.lifecycle ?? {}),
terminalReason: "cancelled",
retryable: false,
failureClass: "cancelled",
errorCode: patch.lifecycle?.errorCode ?? "RUN_CANCELLED",
};
} else if (normalizedStatus === "timed_out") {
patch.lifecycle = {
...(patch.lifecycle ?? {}),
terminalReason: "timed_out",
retryable: true,
failureClass: "timed_out",
errorCode: patch.lifecycle?.errorCode ?? "PROVIDER_TIMEOUT",
timeoutAt: patch.lifecycle?.timeoutAt ?? now,
};
} else if (normalizedStatus === "retry_exhausted") {
patch.lifecycle = {
...(patch.lifecycle ?? {}),
terminalReason: "retry_exhausted",
retryable: false,
failureClass: patch.lifecycle?.failureClass ?? "non_retryable",
errorCode: "RETRY_EXHAUSTED",
};
}
const updated = this.updateRun(runId, patch);
if (!updated) return;
this.appendLifecycleEvent(runId, {
type: "status_changed",
message: `Status changed to ${normalizedStatus}`,
status: normalizedStatus,
classification: updated.lifecycle?.failureClass,
});
this.emit("run:status_changed", updated);
if (status === "completed") this.emit("run:completed", updated);
if (status === "failed") this.emit("run:failed", updated);
if (status === "cancelled") this.emit("run:cancelled", updated);
if (normalizedStatus === "completed") this.emit("run:completed", updated);
if (normalizedStatus === "failed") this.emit("run:failed", updated);
if (normalizedStatus === "cancelled") this.emit("run:cancelled", updated);
if (normalizedStatus === "timed_out") this.emit("run:timed_out", updated);
}
createExport(runId: string, format: ResearchExportFormat, content: string): ResearchExport {
@@ -304,11 +502,15 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
`).all() as Array<{ status: ResearchRunStatus; count: number }>;
const byStatus: Record<ResearchRunStatus, number> = {
pending: 0,
queued: 0,
running: 0,
cancelling: 0,
retry_waiting: 0,
completed: 0,
failed: 0,
cancelled: 0,
timed_out: 0,
retry_exhausted: 0,
};
for (const row of rows) {
@@ -319,17 +521,118 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
return { total, byStatus };
}
getActiveRun(projectId: string, trigger: string): ResearchRun | undefined {
const row = this.db.prepare(`
SELECT * FROM research_runs
WHERE projectId = ? AND trigger = ? AND status IN ('queued', 'running', 'cancelling', 'retry_waiting')
ORDER BY createdAt DESC
LIMIT 1
`).get(projectId, trigger) as Record<string, unknown> | undefined;
return row ? this.rowToRun(row) : undefined;
}
assertNoActiveRun(projectId: string, trigger: string): void {
const active = this.getActiveRun(projectId, trigger);
if (active) {
throw new ResearchLifecycleError(
`Active run already exists for projectId=${projectId} trigger=${trigger}: ${active.id}`,
"active_run_conflict",
);
}
}
requestCancellation(runId: string, reason = "Cancelled by user"): ResearchRun {
const run = this.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
if (TERMINAL_STATUSES.has(run.status)) {
return run;
}
const now = new Date().toISOString();
const alreadyCancelling = run.status === "cancelling";
const updated = this.updateRun(runId, {
status: "cancelling",
lifecycle: {
...(run.lifecycle ?? {}),
cancellationRequestedAt: run.lifecycle?.cancellationRequestedAt ?? now,
terminalCause: reason,
errorCode: "RUN_CANCELLED",
retryable: false,
},
});
if (!updated) throw new Error(`Research run not found: ${runId}`);
if (!alreadyCancelling) {
this.appendLifecycleEvent(runId, { type: "cancel_requested", message: reason, status: "cancelling", classification: "cancelled" });
}
return updated;
}
createRetryRun(runId: string, maxAttempts?: number): ResearchRun {
const run = this.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
if (run.status !== "failed" && run.status !== "timed_out") {
throw new ResearchLifecycleError(`Run ${runId} is not retryable from status ${run.status}`, "invalid_transition");
}
const currentAttempt = run.lifecycle?.attempt ?? 1;
const configuredMaxAttempts = maxAttempts ?? run.lifecycle?.maxAttempts ?? 3;
const nextAttempt = currentAttempt + 1;
if (nextAttempt > configuredMaxAttempts) {
this.updateRun(runId, { status: "retry_exhausted" });
throw new ResearchLifecycleError(`Run ${runId} exhausted retries`, "not_retryable");
}
if (!run.lifecycle?.retryable) {
throw new ResearchLifecycleError(`Run ${runId} is non-retryable`, "not_retryable");
}
const rootRunId = run.lifecycle?.rootRunId ?? run.id;
const retryRun = this.createRun({
query: run.query,
topic: run.topic,
projectId: run.projectId,
trigger: run.trigger,
providerConfig: run.providerConfig,
tags: run.tags,
metadata: run.metadata,
lifecycle: {
attempt: nextAttempt,
maxAttempts: configuredMaxAttempts,
retryOfRunId: run.id,
rootRunId,
},
});
this.updateStatus(retryRun.id, "retry_waiting", {
lifecycle: {
...(retryRun.lifecycle ?? {}),
retryable: true,
},
});
this.appendLifecycleEvent(retryRun.id, {
type: "retry_scheduled",
message: `Retry scheduled from ${run.id}`,
metadata: { retryOfRunId: run.id, rootRunId, attempt: nextAttempt },
});
return retryRun;
}
private getNextEventSeq(runId: string): number {
const row = this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM research_run_events WHERE runId = ?").get(runId) as { seq?: number };
return Number(row?.seq ?? 0) + 1;
}
private persistRun(run: ResearchRun): void {
this.db.prepare(`
UPDATE research_runs
SET query = ?, topic = ?, status = ?, providerConfig = ?, sources = ?, events = ?,
results = ?, error = ?, tokenUsage = ?, tags = ?, metadata = ?, updatedAt = ?,
SET query = ?, topic = ?, status = ?, projectId = ?, trigger = ?, providerConfig = ?, sources = ?, events = ?,
results = ?, error = ?, tokenUsage = ?, tags = ?, metadata = ?, lifecycle = ?, updatedAt = ?,
startedAt = ?, completedAt = ?, cancelledAt = ?
WHERE id = ?
`).run(
run.query,
run.topic ?? null,
run.status,
run.projectId ?? null,
run.trigger ?? null,
toJsonNullable(run.providerConfig),
toJson(run.sources),
toJson(run.events),
@@ -338,6 +641,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
toJsonNullable(run.tokenUsage),
toJson(run.tags),
toJsonNullable(run.metadata),
toJsonNullable(run.lifecycle),
run.updatedAt,
run.startedAt ?? null,
run.completedAt ?? null,
@@ -353,7 +657,9 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
id: row.id as string,
query: row.query as string,
topic: (row.topic as string | null) ?? undefined,
status: row.status as ResearchRunStatus,
status: normalizeStatus((row.status as ResearchRunStatus | "pending") ?? "queued"),
projectId: (row.projectId as string | null) ?? undefined,
trigger: (row.trigger as string | null) ?? undefined,
providerConfig: fromJson<Record<string, unknown>>(row.providerConfig as string | null),
sources: fromJson<ResearchSource[]>(row.sources as string | null) ?? [],
events: fromJson<ResearchEvent[]>(row.events as string | null) ?? [],
@@ -362,6 +668,7 @@ export class ResearchStore extends EventEmitter<ResearchStoreEvents> {
tokenUsage: fromJson<ResearchRun["tokenUsage"]>(row.tokenUsage as string | null),
tags: fromJson<string[]>(row.tags as string | null) ?? [],
metadata: fromJson<Record<string, unknown>>(row.metadata as string | null),
lifecycle: fromJson<ResearchRun["lifecycle"]>(row.lifecycle as string | null),
createdAt: row.createdAt as string,
updatedAt: row.updatedAt as string,
startedAt: (row.startedAt as string | null) ?? undefined,

View File

@@ -5,11 +5,15 @@
*/
export const RESEARCH_RUN_STATUSES = [
"pending",
"queued",
"running",
"cancelling",
"retry_waiting",
"completed",
"failed",
"cancelled",
"timed_out",
"retry_exhausted",
] as const;
export type ResearchRunStatus = typeof RESEARCH_RUN_STATUSES[number];
@@ -38,10 +42,65 @@ export const RESEARCH_EVENT_TYPES = [
"source_added",
"result_updated",
"progress",
"status_changed",
"retry_scheduled",
"cancel_requested",
"timeout",
] as const;
export type ResearchEventType = typeof RESEARCH_EVENT_TYPES[number];
export const RESEARCH_RUN_FAILURE_CLASSES = [
"cancelled",
"timed_out",
"retryable_transient",
"non_retryable",
] as const;
export const RESEARCH_ERROR_CODES = [
"FEATURE_DISABLED",
"MISSING_CREDENTIALS",
"PROVIDER_UNAVAILABLE",
"RATE_LIMITED",
"PROVIDER_TIMEOUT",
"RUN_CANCELLED",
"RETRY_EXHAUSTED",
"INVALID_TRANSITION",
"NON_RETRYABLE_PROVIDER_ERROR",
"INTERNAL_ERROR",
] as const;
export type ResearchErrorCode = typeof RESEARCH_ERROR_CODES[number];
export type ResearchRunFailureClass = typeof RESEARCH_RUN_FAILURE_CLASSES[number];
export interface ResearchRunLifecycle {
terminalReason?: "completed" | "cancelled" | "failed" | "timed_out" | "retry_exhausted";
terminalCause?: string;
failureClass?: ResearchRunFailureClass;
errorCode?: ResearchErrorCode;
retryable?: boolean;
retryAfterMs?: number;
cancellationRequestedAt?: string;
timeoutAt?: string;
retryOfRunId?: string;
rootRunId?: string;
attempt?: number;
maxAttempts?: number;
}
export interface ResearchRunEvent {
id: string;
runId: string;
seq: number;
type: ResearchEventType;
message: string;
status?: ResearchRunStatus;
classification?: ResearchRunFailureClass;
metadata?: Record<string, unknown>;
createdAt: string;
}
export interface ResearchSource {
id: string;
type: ResearchSourceType;
@@ -89,6 +148,8 @@ export interface ResearchRun {
query: string;
topic?: string;
status: ResearchRunStatus;
projectId?: string;
trigger?: string;
providerConfig?: Record<string, unknown>;
sources: ResearchSource[];
events: ResearchEvent[];
@@ -97,6 +158,7 @@ export interface ResearchRun {
tokenUsage?: ResearchTokenUsage;
tags: string[];
metadata?: Record<string, unknown>;
lifecycle?: ResearchRunLifecycle;
createdAt: string;
updatedAt: string;
startedAt?: string;
@@ -116,18 +178,23 @@ export interface ResearchExport {
export interface ResearchRunCreateInput {
query: string;
topic?: string;
projectId?: string;
trigger?: string;
providerConfig?: Record<string, unknown>;
sources?: ResearchSource[];
events?: ResearchEvent[];
results?: ResearchResult;
tags?: string[];
metadata?: Record<string, unknown>;
lifecycle?: ResearchRunLifecycle;
}
export interface ResearchRunUpdateInput {
query?: string;
topic?: string;
status?: ResearchRunStatus;
projectId?: string;
trigger?: string;
providerConfig?: Record<string, unknown>;
sources?: ResearchSource[];
events?: ResearchEvent[];
@@ -136,6 +203,7 @@ export interface ResearchRunUpdateInput {
tokenUsage?: ResearchTokenUsage;
tags?: string[];
metadata?: Record<string, unknown>;
lifecycle?: ResearchRunLifecycle;
startedAt?: string | null;
completedAt?: string | null;
cancelledAt?: string | null;
@@ -159,6 +227,7 @@ export interface ResearchStoreEvents {
"run:completed": [ResearchRun];
"run:failed": [ResearchRun];
"run:cancelled": [ResearchRun];
"run:timed_out": [ResearchRun];
"event:added": [{ runId: string; event: ResearchEvent }];
"source:added": [{ runId: string; source: ResearchSource }];
}

View File

@@ -48,6 +48,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
updateCheckEnabled: true,
fnBinaryCheckEnabled: true,
updateCheckFrequency: "daily",
autoReloadOnVersionChange: true,
showGitHubStarButton: true,
modelOnboardingComplete: undefined,
useClaudeCli: undefined,

View File

@@ -95,6 +95,7 @@ interface TaskRow {
missionId: string | null;
sliceId: string | null;
assignedAgentId: string | null;
pausedByAgentId: string | null;
assigneeUserId: string | null;
nodeId: string | null;
effectiveNodeId: string | null;
@@ -744,6 +745,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
missionId: row.missionId || undefined,
sliceId: row.sliceId || undefined,
assignedAgentId: row.assignedAgentId || undefined,
pausedByAgentId: row.pausedByAgentId || undefined,
assigneeUserId: row.assigneeUserId || undefined,
nodeId: row.nodeId || undefined,
effectiveNodeId: row.effectiveNodeId || undefined,
@@ -981,7 +983,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
"checkedOutBy", "checkedOutAt",
// `log` is fetched in slim mode so the server can aggregate
@@ -1030,7 +1032,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"dependencies", "steps", "attachments", "steeringComments",
"comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
"checkedOutBy", "checkedOutAt",
];
@@ -1073,9 +1075,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
@@ -1144,6 +1146,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
missionId = excluded.missionId,
sliceId = excluded.sliceId,
assignedAgentId = excluded.assignedAgentId,
pausedByAgentId = excluded.pausedByAgentId,
assigneeUserId = excluded.assigneeUserId,
nodeId = excluded.nodeId,
effectiveNodeId = excluded.effectiveNodeId,
@@ -1225,6 +1228,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.missionId ?? null,
task.sliceId ?? null,
task.assignedAgentId ?? null,
task.pausedByAgentId ?? null,
task.assigneeUserId ?? null,
task.nodeId ?? null,
task.effectiveNodeId ?? null,
@@ -2668,6 +2672,31 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return limit >= 0 ? matches.slice(0, limit) : matches;
}
async getTasksByAssignedAgent(
agentId: string,
options?: { pausedOnly?: boolean; excludeArchived?: boolean },
): Promise<Task[]> {
const whereClauses = ["assignedAgentId = ?"];
const params: Array<string | number> = [agentId];
if (options?.pausedOnly) {
whereClauses.push("paused = 1");
}
if (options?.excludeArchived) {
whereClauses.push('"column" != \'archived\'');
}
const selectClause = this.getTaskSelectClause(false);
const rows = this.db.prepare(`
SELECT ${selectClause} FROM tasks
WHERE ${whereClauses.join(" AND ")}
ORDER BY createdAt ASC
`).all(...params) as TaskRow[];
return rows.map((row) => this.rowToTask(row));
}
async selectNextTaskForAgent(agentId: string): Promise<InboxTask | null> {
const tasks = await this.listTasks({ slim: true });
if (tasks.length === 0) {
@@ -2933,7 +2962,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -3017,6 +3046,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.assignedAgentId !== undefined) {
task.assignedAgentId = updates.assignedAgentId;
}
if (updates.pausedByAgentId === null) {
task.pausedByAgentId = undefined;
} else if (updates.pausedByAgentId !== undefined) {
task.pausedByAgentId = updates.pausedByAgentId;
}
if (updates.assigneeUserId === null) {
task.assigneeUserId = undefined;
} else if (updates.assigneeUserId !== undefined) {
@@ -3294,7 +3328,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* Pause or unpause a task. Paused tasks are excluded from all automated
* agent and scheduler interaction. Logs the action and emits `task:updated`.
*/
async pauseTask(id: string, paused: boolean, runContext?: RunMutationContext): Promise<Task> {
async pauseTask(
id: string,
paused: boolean,
runContext?: RunMutationContext,
agentOptions?: { pausedByAgentId?: string },
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
@@ -3304,7 +3343,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.log = [];
}
const previousPausedByAgentId = task.pausedByAgentId;
task.paused = paused || undefined;
if (paused && agentOptions?.pausedByAgentId) {
task.pausedByAgentId = agentOptions.pausedByAgentId;
}
if (!paused) {
task.pausedByAgentId = undefined;
}
// When pausing an in-progress/in-review task, set status so the UI can show the state.
// When unpausing, clear the "paused" status.
if (task.column === "in-progress" || task.column === "in-review") {
@@ -3314,7 +3360,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.updatedAt = now;
const logEntry: TaskLogEntry = {
timestamp: now,
action: paused ? "Task paused" : "Task unpaused",
action: paused
? (agentOptions?.pausedByAgentId
? `Task paused (agent ${agentOptions.pausedByAgentId} paused)`
: "Task paused")
: (previousPausedByAgentId
? `Task unpaused (agent ${previousPausedByAgentId} resumed)`
: "Task unpaused"),
};
if (runContext) {
logEntry.runContext = runContext;

View File

@@ -506,7 +506,14 @@ Note: Refs (@e1, @e2) are invalidated after page navigation. Re-snapshot after c
toolMode: "readonly",
prompt: `You are a UX design reviewer. Verify frontend changes maintain visual polish and consistency with existing UI patterns and design tokens.
Design System Review:
FAST-BAIL RULE (check this FIRST):
- The task harness gives you a "Diff Scope" listing the files this task actually changed.
- If that list contains NO frontend/UI files (no .tsx/.jsx/.ts/.js component files, no .css/.scss/.sass/.styl, no .html/.vue/.svelte/.astro, no design-token/theme files), respond IMMEDIATELY with a single short line such as "No UI changes in scope — approved." and STOP.
- Do NOT explore the worktree looking for related-looking UI code to critique. If this task didn't change a UI file, your review is a no-op by definition.
Otherwise, restrict your review to the UI files actually present in the diff scope.
Design System Review (only for UI files in the diff scope):
1. **Visual Hierarchy** — Check that the changes maintain consistent heading levels, content flow, and information architecture
2. **Spacing and Typography** — Verify consistent spacing (margins, padding, gaps) and typography scale usage
3. **Color and Token Consistency** — Check that CSS custom properties and design tokens are used correctly; no hardcoded color values that bypass the design system
@@ -514,15 +521,16 @@ Design System Review:
5. **Responsive Behavior** — Check that layouts adapt properly across viewport sizes and maintain usability on mobile
6. **Fit with Design Language** — Verify the visual style matches existing patterns (border radius, shadows, transitions, icon style, etc.)
Files to Review:
Files to Review (only those that appear in the Diff Scope):
- Modified UI components (React, Vue, Angular, HTML)
- CSS/SCSS/styled-component files
- Design token or theme configuration files
Output Requirements:
- If design is consistent and polished: call task_done() with success status
- If issues found: describe each finding with specific file paths and suggested corrections via task_log()
- Prioritize issues by impact: layout breaks > visual inconsistency > style preferences`,
- If design is consistent and polished (or there are no UI files in scope): respond with a brief approval line and stop.
- If issues found: start your response with "REQUEST REVISION" and describe each finding with specific file paths and suggested corrections.
- Prioritize issues by impact: layout breaks > visual inconsistency > style preferences.
- Do NOT spend time on stylistic nits when no real issues exist.`,
},
];
@@ -871,6 +879,8 @@ export interface Task {
blockedBy?: string;
/** When true, all automated agent and scheduler interaction is suspended. */
paused?: boolean;
/** When set, this task was paused because the agent with this ID was paused. Cleared when the agent resumes. Distinct from user-initiated pause. */
pausedByAgentId?: string;
/** Git branch name (or task ID) to use as the starting point when
* creating this task's worktree. Set by the scheduler when a task's
* explicit dependency or `blockedBy` task is in-review with an
@@ -1365,6 +1375,11 @@ export interface GlobalSettings {
* - `weekly`: 7-day cache TTL
*/
updateCheckFrequency?: "manual" | "on-startup" | "daily" | "weekly";
/** When true (default), the dashboard automatically reloads when a new build
* version is detected via /version.json polling or service worker activation.
* Set to false to suppress automatic reloads — the user must manually
* refresh to pick up updates. */
autoReloadOnVersionChange?: boolean;
/** When true, indicates the user has completed the AI model onboarding flow
* (connected at least one provider and selected a default model). When
* false/undefined, the dashboard will auto-open the onboarding modal.
@@ -2581,6 +2596,201 @@ export type ManagedDockerNodeUpdate = Partial<
Omit<ManagedDockerNode, "id" | "createdAt">
>;
/** Input to the mesh configuration generation process. */
export interface MeshConfigGeneratorInput {
/** The managed Docker node record (from FN-3107). */
managedNode: ManagedDockerNode;
/** The orchestrating node's URL (e.g., "http://192.168.1.10:4040"). */
orchestratorUrl: string;
/** The orchestrating node's API key for authentication. */
orchestratorApiKey: string;
/** Optional user-provided API key. If omitted, one is auto-generated. */
nodeApiKey?: string;
/** Optional container port override. If omitted, defaults to 4041. */
containerPort?: number;
}
/** Input to the end-to-end provision-and-register flow. */
export interface FullProvisioningInput {
/** The managed Docker node to configure and register. */
managedNode: ManagedDockerNode;
/** The orchestrating node's URL. */
orchestratorUrl: string;
/** The orchestrating node's API key. */
orchestratorApiKey: string;
/** Optional user-provided API key for the new node. */
nodeApiKey?: string;
/** Optional container port override. */
containerPort?: number;
}
/** Configuration bundle needed for a new node to join the mesh. */
export interface MeshConnectionConfig {
/** API key for authenticating to this node. Auto-generated if not provided by user. */
nodeApiKey: string;
/** The URL the orchestrating node uses to reach the new container. */
reachableUrl: string;
/** Orchestrating node's URL, pushed to the container so it knows its mesh parent. */
orchestratorUrl: string;
/** Orchestrating node's API key for inbound settings sync authentication. */
orchestratorApiKey: string;
/** Port the container's Fusion server will listen on. */
containerPort: number;
/** Environment variables assembled from the above for injection into the container. */
envVars: Record<string, string>;
}
/** Result of applying mesh config to a provisioned node. */
export interface MeshConfigResult {
/** The generated/applied connection config. */
config: MeshConnectionConfig;
/** The registered NodeConfig in the mesh. */
node: NodeConfig;
/** Whether the node health check passed after registration. */
isHealthy: boolean;
/** Latency of the health check in ms, if successful. */
healthCheckLatencyMs?: number;
/** Error if health check or registration failed. */
error?: string;
}
/** Information about a discovered Docker context */
export interface DockerContextInfo {
/** Context name (e.g., "default", "my-remote") */
name: string;
/** Human-readable description */
description?: string;
/** Docker host URI for this context (e.g., "tcp://192.168.1.50:2376") */
dockerHost?: string;
/** Whether this is the currently active context */
isCurrentContext: boolean;
/** Whether this context has a connection error */
isError?: boolean;
/** Error message if the context is unreachable */
errorMessage?: string;
}
/** Result of testing Docker daemon connectivity */
export interface DockerConnectivityResult {
/** Whether the connection succeeded */
success: boolean;
/** Docker Engine version string */
dockerVersion?: string;
/** Docker API version string */
apiVersion?: string;
/** Docker Engine OS/arch info */
operatingSystem?: string;
/** Error message if connection failed */
error?: string;
/** Whether the target is the local Docker daemon */
isLocalDaemon: boolean;
}
/** Minimal container inspection result from Docker */
export interface DockerContainerInspectResult {
/** Container ID */
id: string;
/** Container name (with leading / stripped) */
name: string;
/** Container status string (e.g., "running", "exited") */
status: string;
/** Image name/tag */
image: string;
/** Creation timestamp (Unix epoch) */
created: number;
/** Detailed container state */
state: {
running: boolean;
paused: boolean;
restarting: boolean;
dead: boolean;
error?: string;
exitCode?: number;
startedAt?: string;
finishedAt?: string;
};
/** Optional exposed ports summary */
ports?: Record<string, string>;
}
/** Configuration for the Fusion Docker image to use for provisioning */
export interface DockerNodeImageConfig {
/** Image name (e.g., "runfusion/fusion" or "ghcr.io/runfusion/fusion") */
image: string;
/** Image tag (e.g., "latest", "0.14.1") */
tag: string;
/** Whether to pull the image before creating the container */
pullImage: boolean;
/** Optional registry authentication — username */
registryUsername?: string;
/** Optional registry authentication — password/token */
registryPassword?: string;
}
/** Resource constraints for a provisioned Docker container */
export interface DockerNodeResourceConfig {
/** CPU limit in cores (e.g., 2 = 2 CPUs). Undefined = unlimited */
cpuLimit?: number;
/** Memory limit in megabytes. Undefined = unlimited */
memoryLimitMb?: number;
/** Memory swap limit in megabytes. -1 = unlimited swap. Undefined = default */
memorySwapMb?: number;
}
/** Input for provisioning a new Docker-based Fusion node */
export interface DockerProvisionInput {
/** Display name for the node (must be unique) */
nodeName: string;
/** Docker host configuration — where to create the container */
hostConfig: DockerHostConfig;
/** Image configuration — which Fusion image to use */
imageConfig: DockerNodeImageConfig;
/** Resource constraints for the container */
resourceConfig?: DockerNodeResourceConfig;
/** Environment variables to set in the container (KEY=VALUE strings) */
environment?: string[];
/** Volume mount specifications (e.g., ["fusion-data:/data", "/host/path:/container/path"]) */
volumeMounts?: string[];
/** Named volume for persistent Fusion data storage. If provided, mounted at /data */
persistentVolume?: string;
/** Optional extra CLI tools to include in the container (e.g., ["claude", "droid"]) */
extraClis?: string[];
/** The URL/hostname where this node will be reachable by other nodes */
reachableUrl?: string;
/** Whether to auto-generate an API key for this node */
autoGenerateApiKey: boolean;
/** Explicit API key to use (if autoGenerateApiKey is false) */
apiKey?: string;
/** Maximum concurrent tasks for this node (default: 2) */
maxConcurrent?: number;
/** Optional Docker network to attach the container to */
network?: string;
/** Optional container labels (key-value pairs) */
labels?: Record<string, string>;
}
/** Result of a Docker node provisioning operation */
export interface DockerProvisionResult {
/** Whether provisioning succeeded */
success: boolean;
/** The container ID created by Docker */
containerId?: string;
/** The container name (generated or specified) */
containerName?: string;
/** The registered node ID in CentralCore */
nodeId?: string;
/** The API key generated or assigned for this node */
apiKey?: string;
/** The port mapping (if applicable) */
portMapping?: string;
/** Error message if provisioning failed */
error?: string;
/** The stage at which failure occurred (for error reporting) */
failedStage?: "image-pull" | "container-create" | "container-start" | "node-register" | "config-apply";
/** Duration of the provisioning operation in ms */
durationMs?: number;
}
/** A single plugin's version information for sync comparison */
export interface PluginVersionEntry {
/** Plugin ID (matches PluginManifest.id) */

View File

@@ -8,6 +8,7 @@ export default defineConfig({
resolve: {
alias: {
"@fusion/test-utils": resolve(__dirname, "./src/__test-utils__/workspace.ts"),
"@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"),
},
},
test: {