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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
134
packages/core/src/__tests__/docker-client.test.ts
Normal file
134
packages/core/src/__tests__/docker-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
491
packages/core/src/__tests__/docker-provisioning.test.ts
Normal file
491
packages/core/src/__tests__/docker-provisioning.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
185
packages/core/src/__tests__/insight-run-executor.test.ts
Normal file
185
packages/core/src/__tests__/insight-run-executor.test.ts
Normal 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>);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
502
packages/core/src/__tests__/mesh-config-generator.test.ts
Normal file
502
packages/core/src/__tests__/mesh-config-generator.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
101
packages/core/src/__tests__/oauth-credential-interop.test.ts
Normal file
101
packages/core/src/__tests__/oauth-credential-interop.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
56
packages/core/src/__tests__/vitest-workers.test.ts
Normal file
56
packages/core/src/__tests__/vitest-workers.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
|
||||
@@ -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)`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
300
packages/core/src/docker-client.ts
Normal file
300
packages/core/src/docker-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
341
packages/core/src/docker-provisioning.ts
Normal file
341
packages/core/src/docker-provisioning.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
303
packages/core/src/insight-run-executor.ts
Normal file
303
packages/core/src/insight-run-executor.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 ?? {};
|
||||
})(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }];
|
||||
}
|
||||
|
||||
273
packages/core/src/mesh-config-generator.ts
Normal file
273
packages/core/src/mesh-config-generator.ts
Normal 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";
|
||||
}
|
||||
243
packages/core/src/oauth-credential-interop.ts
Normal file
243
packages/core/src/oauth-credential-interop.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }];
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
updateCheckEnabled: true,
|
||||
fnBinaryCheckEnabled: true,
|
||||
updateCheckFrequency: "daily",
|
||||
autoReloadOnVersionChange: true,
|
||||
showGitHubStarButton: true,
|
||||
modelOnboardingComplete: undefined,
|
||||
useClaudeCli: undefined,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user