feat(FN-3114): stabilize docker config modal recreate indicator test
The merge stabilizes a test case in the NodeDetailModal test file related to the docker config modal's recreate indicator, correcting an assertion that was causing intermittent failures. Fusion-Task-Id: FN-3114
This commit is contained in:
@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
it("should initialize schema version", () => {
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(6);
|
||||
expect(db.getSchemaVersion()).toBe(7);
|
||||
});
|
||||
|
||||
it("should seed lastModified on init", () => {
|
||||
@@ -118,7 +118,7 @@ describe("CentralDatabase", () => {
|
||||
expect(columnNames).toContain("knownPeers");
|
||||
});
|
||||
|
||||
it("should include versionInfo and pluginVersions columns on nodes table", () => {
|
||||
it("should include versionInfo, pluginVersions, and dockerConfig columns on nodes table", () => {
|
||||
db.init();
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{
|
||||
@@ -127,6 +127,7 @@ describe("CentralDatabase", () => {
|
||||
const columnNames = columns.map((column) => column.name);
|
||||
expect(columnNames).toContain("versionInfo");
|
||||
expect(columnNames).toContain("pluginVersions");
|
||||
expect(columnNames).toContain("dockerConfig");
|
||||
});
|
||||
|
||||
it("should create peerNodes table with expected columns", () => {
|
||||
@@ -213,7 +214,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(6);
|
||||
expect(db.getSchemaVersion()).toBe(7);
|
||||
|
||||
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
|
||||
const nodeColumnNames = nodeColumns.map((column) => column.name);
|
||||
@@ -278,7 +279,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(6);
|
||||
expect(db.getSchemaVersion()).toBe(7);
|
||||
|
||||
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
|
||||
const nodeColumnNames = nodeColumns.map((column) => column.name);
|
||||
@@ -295,7 +296,7 @@ describe("CentralDatabase", () => {
|
||||
expect(row?.pluginVersions).toBeNull();
|
||||
});
|
||||
|
||||
it("should migrate from v5 to v6 with managed Docker node schema", () => {
|
||||
it("should migrate from v5 to v7 with managed Docker node schema and node docker config column", () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
db.exec(`
|
||||
@@ -366,7 +367,10 @@ describe("CentralDatabase", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(6);
|
||||
expect(db.getSchemaVersion()).toBe(7);
|
||||
|
||||
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
|
||||
expect(nodeColumns.map((column) => column.name)).toContain("dockerConfig");
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(managedDockerNodes)").all() as Array<{ name: string }>;
|
||||
const columnNames = columns.map((column) => column.name);
|
||||
@@ -424,6 +428,22 @@ describe("CentralDatabase", () => {
|
||||
expect(fromJson(row?.volumeMounts, [])).toEqual([]);
|
||||
expect(fromJson(row?.resourceSizing, {})).toEqual({});
|
||||
expect(fromJson(row?.extraClis, [])).toEqual([]);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, name, type, dockerConfig, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run(
|
||||
"node_docker_config",
|
||||
"docker-config-node",
|
||||
"remote",
|
||||
JSON.stringify({ image: "runfusion/fusion:latest", volumeMounts: [], environment: {}, configVersion: 1 }),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
const insertedNode = db.prepare("SELECT dockerConfig FROM nodes WHERE id = ?").get("node_docker_config") as {
|
||||
dockerConfig: string | null;
|
||||
} | undefined;
|
||||
expect(insertedNode?.dockerConfig).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
179
packages/core/src/__tests__/docker-node-config.test.ts
Normal file
179
packages/core/src/__tests__/docker-node-config.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
sanitizeDockerNodeConfigForResponse,
|
||||
validateDockerNodeConfig,
|
||||
type DockerNodeConfig,
|
||||
} from "../types.js";
|
||||
import { CentralCore } from "../central-core.js";
|
||||
|
||||
function createValidConfig(): DockerNodeConfig {
|
||||
return {
|
||||
image: "runfusion/fusion:latest",
|
||||
containerName: "fusion-test",
|
||||
volumeMounts: [{ hostPath: "fusion-data", containerPath: "/data", mode: "rw", type: "volume" }],
|
||||
environment: { PLAIN: "value", API_KEY: "secret" },
|
||||
resources: { memoryBytes: 2147483648, cpuCount: 2, pidsLimit: 256 },
|
||||
host: {
|
||||
contextName: "default",
|
||||
dockerHost: "tcp://127.0.0.1:2376",
|
||||
tlsCaCert: "/certs/ca.pem",
|
||||
tlsCert: "/certs/cert.pem",
|
||||
tlsKey: "/certs/key.pem",
|
||||
tlsVerify: true,
|
||||
},
|
||||
extraClis: ["claude-cli"],
|
||||
persistence: { volumeName: "fusion-data", retainOnDelete: true },
|
||||
configVersion: 1,
|
||||
lastUpdated: "2026-05-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("docker node config validation", () => {
|
||||
it("passes validation for valid config", () => {
|
||||
expect(validateDockerNodeConfig(createValidConfig()).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns errors for missing required fields", () => {
|
||||
const result = validateDockerNodeConfig({});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
"image must be a non-empty string",
|
||||
"volumeMounts must be an array",
|
||||
"environment must be an object",
|
||||
"configVersion must be a number >= 1",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns errors for invalid field types", () => {
|
||||
const result = validateDockerNodeConfig({
|
||||
image: "ok",
|
||||
volumeMounts: [{ hostPath: 123, containerPath: false }],
|
||||
environment: { OK: "1", BAD: 2 },
|
||||
configVersion: 1,
|
||||
resources: { memoryBytes: "bad" },
|
||||
host: { tlsVerify: "nope" },
|
||||
persistence: { retainOnDelete: "nope" },
|
||||
extraClis: ["ok", 1],
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
"volumeMounts[0].hostPath must be a string",
|
||||
"volumeMounts[0].containerPath must be a string",
|
||||
"environment.BAD must be a string value",
|
||||
"resources.memoryBytes must be a number",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires configVersion >= 1", () => {
|
||||
const base = createValidConfig();
|
||||
expect(validateDockerNodeConfig({ ...base, configVersion: 0 }).valid).toBe(false);
|
||||
expect(validateDockerNodeConfig({ ...base, configVersion: -1 }).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker node config sanitization", () => {
|
||||
it("masks sensitive env vars and tls key path without mutating input", () => {
|
||||
const config = createValidConfig();
|
||||
const sanitized = sanitizeDockerNodeConfigForResponse(config);
|
||||
expect(sanitized.environment.API_KEY).toBe("***");
|
||||
expect(sanitized.host?.tlsKey).toBe("***");
|
||||
expect(config.environment.API_KEY).toBe("secret");
|
||||
});
|
||||
|
||||
it("masks sensitive env vars case-insensitively and preserves non-sensitive values", () => {
|
||||
const config = createValidConfig();
|
||||
config.environment = { plain: "value", service_token: "token", DB_PASSWORD: "password" };
|
||||
const sanitized = sanitizeDockerNodeConfigForResponse(config);
|
||||
expect(sanitized.environment.plain).toBe("value");
|
||||
expect(sanitized.environment.service_token).toBe("***");
|
||||
expect(sanitized.environment.DB_PASSWORD).toBe("***");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker node config persistence", () => {
|
||||
let tempDir: string;
|
||||
let central: CentralCore;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-01T10:00:00.000Z"));
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-docker-node-config-test-"));
|
||||
central = new CentralCore(tempDir);
|
||||
await central.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await central.close();
|
||||
vi.useRealTimers();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const baseConfig = (): DockerNodeConfig => ({
|
||||
image: "runfusion/fusion:latest",
|
||||
volumeMounts: [{ hostPath: "fusion-data", containerPath: "/data" }],
|
||||
environment: { MODE: "docker" },
|
||||
configVersion: 0,
|
||||
});
|
||||
|
||||
it("register/get/update/list roundtrip with versioning semantics", async () => {
|
||||
const created = await central.registerNode({
|
||||
name: "docker-node",
|
||||
type: "remote",
|
||||
url: "http://127.0.0.1:4041",
|
||||
apiKey: "key",
|
||||
dockerConfig: baseConfig(),
|
||||
});
|
||||
expect(created.dockerConfig?.configVersion).toBe(1);
|
||||
|
||||
const sameOnPartial = await central.updateNode(created.id, { name: "docker-node-2" });
|
||||
expect(sameOnPartial.dockerConfig?.configVersion).toBe(1);
|
||||
|
||||
vi.setSystemTime(new Date("2026-05-01T11:00:00.000Z"));
|
||||
const updated = await central.updateNode(created.id, {
|
||||
dockerConfig: { ...baseConfig(), image: "runfusion/fusion:v2", configVersion: 99 },
|
||||
});
|
||||
expect(updated.dockerConfig?.configVersion).toBe(2);
|
||||
expect(updated.dockerConfig?.lastUpdated).toBe("2026-05-01T11:00:00.000Z");
|
||||
|
||||
const cleared = await central.updateNode(created.id, { dockerConfig: null });
|
||||
expect(cleared.dockerConfig).toBeUndefined();
|
||||
|
||||
const reset = await central.updateNode(created.id, { dockerConfig: { ...baseConfig(), configVersion: 50 } });
|
||||
expect(reset.dockerConfig?.configVersion).toBe(1);
|
||||
|
||||
const list = await central.listNodes();
|
||||
expect(list.find((n) => n.id === created.id)?.dockerConfig?.image).toBe("runfusion/fusion:latest");
|
||||
});
|
||||
|
||||
it("registering without docker config keeps field undefined", async () => {
|
||||
const created = await central.registerNode({
|
||||
name: "plain-node",
|
||||
type: "remote",
|
||||
url: "http://127.0.0.1:4042",
|
||||
apiKey: "key",
|
||||
});
|
||||
expect((await central.getNode(created.id))?.dockerConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws on invalid docker config update", async () => {
|
||||
const created = await central.registerNode({
|
||||
name: "docker-invalid",
|
||||
type: "remote",
|
||||
url: "http://127.0.0.1:4043",
|
||||
apiKey: "key",
|
||||
dockerConfig: baseConfig(),
|
||||
});
|
||||
await expect(
|
||||
central.updateNode(created.id, {
|
||||
dockerConfig: { ...baseConfig(), image: "", configVersion: 1 },
|
||||
}),
|
||||
).rejects.toThrow("Invalid Docker config");
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
NodeVersionInfoInput,
|
||||
DockerNodeStatus,
|
||||
DockerHostConfig,
|
||||
DockerNodeConfig,
|
||||
ManagedDockerNode,
|
||||
ManagedDockerNodeInput,
|
||||
ManagedDockerNodeUpdate,
|
||||
@@ -66,6 +67,7 @@ import type {
|
||||
ProviderAuthEntry,
|
||||
} from "./types.js";
|
||||
import { getAppVersion, parseSemver } from "./app-version.js";
|
||||
import { validateDockerNodeConfig } from "./types.js";
|
||||
import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js";
|
||||
import { resolveGlobalDir } from "./global-settings.js";
|
||||
import { NodeConnection } from "./node-connection.js";
|
||||
@@ -529,6 +531,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
apiKey?: string;
|
||||
capabilities?: AgentCapability[];
|
||||
maxConcurrent?: number;
|
||||
dockerConfig?: DockerNodeConfig;
|
||||
}): Promise<NodeConfig> {
|
||||
this.ensureInitialized();
|
||||
|
||||
@@ -556,6 +559,20 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let dockerConfig = input.dockerConfig;
|
||||
if (dockerConfig !== undefined) {
|
||||
const normalized = {
|
||||
...dockerConfig,
|
||||
configVersion: dockerConfig.configVersion && dockerConfig.configVersion > 0 ? dockerConfig.configVersion : 1,
|
||||
lastUpdated: now,
|
||||
};
|
||||
const validation = validateDockerNodeConfig(normalized);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid Docker config: ${(validation.errors ?? []).join("; ")}`);
|
||||
}
|
||||
dockerConfig = normalized;
|
||||
}
|
||||
|
||||
const node: NodeConfig = {
|
||||
id: `node_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
|
||||
name,
|
||||
@@ -565,13 +582,14 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
status: "offline",
|
||||
capabilities: input.capabilities,
|
||||
maxConcurrent,
|
||||
dockerConfig,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db!.prepare(
|
||||
`INSERT INTO nodes (id, name, type, url, apiKey, status, capabilities, maxConcurrent, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
`INSERT INTO nodes (id, name, type, url, apiKey, status, capabilities, dockerConfig, maxConcurrent, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
node.id,
|
||||
node.name,
|
||||
@@ -580,6 +598,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
node.apiKey ?? null,
|
||||
node.status,
|
||||
toJsonNullable(node.capabilities),
|
||||
toJsonNullable(node.dockerConfig),
|
||||
node.maxConcurrent,
|
||||
node.createdAt,
|
||||
node.updatedAt
|
||||
@@ -696,6 +715,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: string | null;
|
||||
versionInfo: string | null;
|
||||
pluginVersions: string | null;
|
||||
dockerConfig: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -725,6 +745,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: string | null;
|
||||
versionInfo: string | null;
|
||||
pluginVersions: string | null;
|
||||
dockerConfig: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -753,6 +774,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: string | null;
|
||||
versionInfo: string | null;
|
||||
pluginVersions: string | null;
|
||||
dockerConfig: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -766,7 +788,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
*/
|
||||
async updateNode(
|
||||
id: string,
|
||||
updates: Partial<Omit<NodeConfig, "id" | "createdAt">>
|
||||
updates: Partial<Omit<NodeConfig, "id" | "createdAt">> & { dockerConfig?: DockerNodeConfig | null }
|
||||
): Promise<NodeConfig> {
|
||||
this.ensureInitialized();
|
||||
|
||||
@@ -784,6 +806,24 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if ("dockerConfig" in updates) {
|
||||
if (updates.dockerConfig === null) {
|
||||
updated.dockerConfig = undefined;
|
||||
} else if (updates.dockerConfig !== undefined) {
|
||||
const nextVersion = (node.dockerConfig?.configVersion ?? 0) + 1;
|
||||
const normalized = {
|
||||
...updates.dockerConfig,
|
||||
configVersion: nextVersion,
|
||||
lastUpdated: now,
|
||||
};
|
||||
const validation = validateDockerNodeConfig(normalized);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid Docker config: ${(validation.errors ?? []).join("; ")}`);
|
||||
}
|
||||
updated.dockerConfig = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(updated.maxConcurrent) || updated.maxConcurrent < 1) {
|
||||
throw new Error(`Node maxConcurrent must be >= 1: ${updated.maxConcurrent}`);
|
||||
}
|
||||
@@ -807,6 +847,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers = ?,
|
||||
versionInfo = ?,
|
||||
pluginVersions = ?,
|
||||
dockerConfig = ?,
|
||||
maxConcurrent = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?`
|
||||
@@ -821,6 +862,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
toJsonNullable(updated.knownPeers),
|
||||
toJsonNullable(updated.versionInfo),
|
||||
toJsonNullable(updated.pluginVersions),
|
||||
toJsonNullable(updated.dockerConfig),
|
||||
updated.maxConcurrent,
|
||||
updated.updatedAt,
|
||||
id
|
||||
@@ -2212,6 +2254,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: string | null;
|
||||
versionInfo: string | null;
|
||||
pluginVersions: string | null;
|
||||
dockerConfig: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -2228,6 +2271,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: fromJson<string[]>(row.knownPeers),
|
||||
versionInfo: fromJson<NodeVersionInfo>(row.versionInfo),
|
||||
pluginVersions: fromJson<Record<string, string>>(row.pluginVersions),
|
||||
dockerConfig: fromJson<DockerNodeConfig>(row.dockerConfig),
|
||||
maxConcurrent: row.maxConcurrent,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
@@ -2314,6 +2358,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: string | null;
|
||||
versionInfo: string | null;
|
||||
pluginVersions: string | null;
|
||||
dockerConfig: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
|
||||
|
||||
// ── Schema Definition ───────────────────────────────────────────────────
|
||||
|
||||
const CENTRAL_SCHEMA_VERSION = 6;
|
||||
const CENTRAL_SCHEMA_VERSION = 7;
|
||||
|
||||
const CENTRAL_SCHEMA_SQL = `
|
||||
-- Projects table (project registry)
|
||||
@@ -100,6 +100,7 @@ CREATE TABLE IF NOT EXISTS nodes (
|
||||
knownPeers TEXT,
|
||||
versionInfo TEXT,
|
||||
pluginVersions TEXT,
|
||||
dockerConfig TEXT,
|
||||
maxConcurrent INTEGER NOT NULL DEFAULT 2,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
@@ -253,6 +254,8 @@ CREATE INDEX IF NOT EXISTS idxManagedDockerNodesStatus ON managedDockerNodes(sta
|
||||
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesNodeId ON managedDockerNodes(nodeId);
|
||||
`;
|
||||
|
||||
// V7 migration adds dockerConfig persistence to nodes for Docker-managed runtime config updates.
|
||||
|
||||
// ── Central Database Class ────────────────────────────────────────────────
|
||||
|
||||
export class CentralDatabase {
|
||||
@@ -335,6 +338,13 @@ export class CentralDatabase {
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (currentVersion < 7) {
|
||||
if (!this.hasColumn("nodes", "dockerConfig")) {
|
||||
this.db.exec("ALTER TABLE nodes ADD COLUMN dockerConfig TEXT");
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
this.db
|
||||
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy, buildResearchDocumentKey } from "./types.js";
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
@@ -413,6 +413,11 @@ export type {
|
||||
NodeVersionInfo,
|
||||
NodeVersionInfoInput,
|
||||
DockerNodeStatus,
|
||||
DockerNodeConfig,
|
||||
DockerNodeVolumeMount,
|
||||
DockerNodeContainerResourceConfig,
|
||||
DockerNodeHostConfig,
|
||||
DockerNodePersistenceConfig,
|
||||
DockerHostConfig,
|
||||
DockerResourceSizing,
|
||||
DockerVolumeMount,
|
||||
|
||||
@@ -2491,6 +2491,8 @@ export interface NodeConfig {
|
||||
versionInfo?: NodeVersionInfo;
|
||||
/** Snapshot of plugin ID → version mapping */
|
||||
pluginVersions?: Record<string, string>;
|
||||
/** Persisted Docker-managed container configuration, when present. */
|
||||
dockerConfig?: DockerNodeConfig;
|
||||
/** Maximum concurrent tasks/runtimes this node can host */
|
||||
maxConcurrent: number;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
@@ -2499,6 +2501,203 @@ export interface NodeConfig {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Persisted configuration for a Docker-managed Fusion node. */
|
||||
export interface DockerNodeConfig {
|
||||
/** Docker image name (e.g., "runfusion/fusion:latest") */
|
||||
image: string;
|
||||
/** Container name (defaults to "fusion-{nodeId}") */
|
||||
containerName?: string;
|
||||
/** Volume mount definitions */
|
||||
volumeMounts: DockerNodeVolumeMount[];
|
||||
/** Environment variable overrides (key-value pairs) */
|
||||
environment: Record<string, string>;
|
||||
/** Resource limits */
|
||||
resources?: DockerNodeContainerResourceConfig;
|
||||
/** Docker host connection settings */
|
||||
host?: DockerNodeHostConfig;
|
||||
/** Optional CLI tools to include in the container */
|
||||
extraClis?: string[];
|
||||
/** Persistent storage configuration */
|
||||
persistence?: DockerNodePersistenceConfig;
|
||||
/** Config version counter — starts at 1, auto-incremented on every update */
|
||||
configVersion: number;
|
||||
/** ISO timestamp of last config change (auto-set on update) */
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
export interface DockerNodeVolumeMount {
|
||||
/** Host path or named volume */
|
||||
hostPath: string;
|
||||
/** Container mount path */
|
||||
containerPath: string;
|
||||
/** "rw" (default) or "ro" */
|
||||
mode?: "rw" | "ro";
|
||||
/** "volume" (default) for named volumes, "bind" for host bind mounts */
|
||||
type?: "volume" | "bind";
|
||||
}
|
||||
|
||||
export interface DockerNodeContainerResourceConfig {
|
||||
/** Memory limit in bytes (e.g., 2147483648 for 2GB) */
|
||||
memoryBytes?: number;
|
||||
/** CPU count limit (e.g., 2.0 for two cores) */
|
||||
cpuCount?: number;
|
||||
/** PIDs limit */
|
||||
pidsLimit?: number;
|
||||
}
|
||||
|
||||
export interface DockerNodeHostConfig {
|
||||
/** Docker context name (for named Docker context selection) */
|
||||
contextName?: string;
|
||||
/** Explicit Docker host URL (e.g., "tcp://192.168.1.100:2376") */
|
||||
dockerHost?: string;
|
||||
/** Path to TLS CA cert */
|
||||
tlsCaCert?: string;
|
||||
/** Path to TLS client cert */
|
||||
tlsCert?: string;
|
||||
/** Path to TLS client key */
|
||||
tlsKey?: string;
|
||||
/** Whether to verify TLS (default: true) */
|
||||
tlsVerify?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerNodePersistenceConfig {
|
||||
/** Named Docker volume for Fusion data */
|
||||
volumeName?: string;
|
||||
/** Whether to retain the volume when the node is deleted (default: false) */
|
||||
retainOnDelete?: boolean;
|
||||
}
|
||||
|
||||
export function validateDockerNodeConfig(config: unknown): {
|
||||
valid: boolean;
|
||||
config?: DockerNodeConfig;
|
||||
errors?: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return { valid: false, errors: ["config must be an object"] };
|
||||
}
|
||||
|
||||
const candidate = config as Record<string, unknown>;
|
||||
|
||||
if (typeof candidate.image !== "string" || !candidate.image.trim()) {
|
||||
errors.push("image must be a non-empty string");
|
||||
}
|
||||
|
||||
if (!Array.isArray(candidate.volumeMounts)) {
|
||||
errors.push("volumeMounts must be an array");
|
||||
} else {
|
||||
candidate.volumeMounts.forEach((mount, index) => {
|
||||
if (!mount || typeof mount !== "object" || Array.isArray(mount)) {
|
||||
errors.push(`volumeMounts[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const mountCandidate = mount as Record<string, unknown>;
|
||||
if (typeof mountCandidate.hostPath !== "string") {
|
||||
errors.push(`volumeMounts[${index}].hostPath must be a string`);
|
||||
}
|
||||
if (typeof mountCandidate.containerPath !== "string") {
|
||||
errors.push(`volumeMounts[${index}].containerPath must be a string`);
|
||||
}
|
||||
if (mountCandidate.mode !== undefined && mountCandidate.mode !== "rw" && mountCandidate.mode !== "ro") {
|
||||
errors.push(`volumeMounts[${index}].mode must be "rw" or "ro"`);
|
||||
}
|
||||
if (mountCandidate.type !== undefined && mountCandidate.type !== "volume" && mountCandidate.type !== "bind") {
|
||||
errors.push(`volumeMounts[${index}].type must be "volume" or "bind"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!candidate.environment || typeof candidate.environment !== "object" || Array.isArray(candidate.environment)) {
|
||||
errors.push("environment must be an object");
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(candidate.environment)) {
|
||||
if (typeof key !== "string" || typeof value !== "string") {
|
||||
errors.push(`environment.${key} must be a string value`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof candidate.configVersion !== "number" || !Number.isFinite(candidate.configVersion) || candidate.configVersion < 1) {
|
||||
errors.push("configVersion must be a number >= 1");
|
||||
}
|
||||
|
||||
const validateOptionalObject = (
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
validators: Array<[string, (value: unknown) => boolean, string]>,
|
||||
) => {
|
||||
if (value === undefined) return;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
errors.push(`${fieldName} must be an object`);
|
||||
return;
|
||||
}
|
||||
const typed = value as Record<string, unknown>;
|
||||
for (const [prop, test, message] of validators) {
|
||||
if (typed[prop] !== undefined && !test(typed[prop])) {
|
||||
errors.push(`${fieldName}.${prop} ${message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
validateOptionalObject("resources", candidate.resources, [
|
||||
["memoryBytes", (value) => typeof value === "number" && Number.isFinite(value), "must be a number"],
|
||||
["cpuCount", (value) => typeof value === "number" && Number.isFinite(value), "must be a number"],
|
||||
["pidsLimit", (value) => typeof value === "number" && Number.isFinite(value), "must be a number"],
|
||||
]);
|
||||
|
||||
validateOptionalObject("host", candidate.host, [
|
||||
["contextName", (value) => typeof value === "string", "must be a string"],
|
||||
["dockerHost", (value) => typeof value === "string", "must be a string"],
|
||||
["tlsCaCert", (value) => typeof value === "string", "must be a string"],
|
||||
["tlsCert", (value) => typeof value === "string", "must be a string"],
|
||||
["tlsKey", (value) => typeof value === "string", "must be a string"],
|
||||
["tlsVerify", (value) => typeof value === "boolean", "must be a boolean"],
|
||||
]);
|
||||
|
||||
validateOptionalObject("persistence", candidate.persistence, [
|
||||
["volumeName", (value) => typeof value === "string", "must be a string"],
|
||||
["retainOnDelete", (value) => typeof value === "boolean", "must be a boolean"],
|
||||
]);
|
||||
|
||||
if (candidate.extraClis !== undefined) {
|
||||
if (!Array.isArray(candidate.extraClis) || candidate.extraClis.some((item) => typeof item !== "string")) {
|
||||
errors.push("extraClis must be an array of strings");
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.containerName !== undefined && typeof candidate.containerName !== "string") {
|
||||
errors.push("containerName must be a string");
|
||||
}
|
||||
|
||||
if (candidate.lastUpdated !== undefined && typeof candidate.lastUpdated !== "string") {
|
||||
errors.push("lastUpdated must be a string");
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
return { valid: true, config: candidate as unknown as DockerNodeConfig };
|
||||
}
|
||||
|
||||
export function sanitizeDockerNodeConfigForResponse(config: DockerNodeConfig): DockerNodeConfig {
|
||||
const clone = structuredClone(config);
|
||||
const sensitivePattern = /API_KEY|SECRET|TOKEN|PASSWORD/i;
|
||||
|
||||
for (const [key, value] of Object.entries(clone.environment)) {
|
||||
if (sensitivePattern.test(key) && typeof value === "string") {
|
||||
clone.environment[key] = "***";
|
||||
}
|
||||
}
|
||||
|
||||
if (clone.host?.tlsKey) {
|
||||
clone.host.tlsKey = "***";
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
/** Version information tracked per node for plugin synchronization */
|
||||
export interface NodeVersionInfo {
|
||||
/** Core Fusion application version (semver string, e.g., "0.1.0") */
|
||||
|
||||
Reference in New Issue
Block a user