feat(FN-1078): add central node registry support

- Add NodeConfig/NodeStatus types, export node types from @fusion/core, and include optional nodeId on registered projects
- Upgrade central DB schema to v2 with a nodes table, node indexes, and projects.nodeId migration handling
- Implement CentralCore node APIs for register/update/list/get/unregister, health checks, and project-to-node assignment flows with emitted events
- Seed a default local node during central init and mark it online using global concurrency settings
- Expand central-core and central-db tests to cover node defaults, schema changes, lifecycle operations, health transitions, and assignment behavior
This commit is contained in:
gsxdsm
2026-04-07 19:23:03 -07:00
parent af4691f17b
commit a11167081f
6 changed files with 728 additions and 15 deletions

View File

@@ -42,6 +42,30 @@ describe("CentralCore", () => {
expect(central.isInitialized()).toBe(true);
});
it("should create a default online local node on init", async () => {
await central.init();
const nodes = await central.listNodes();
const localNodes = nodes.filter((node) => node.type === "local");
expect(localNodes).toHaveLength(1);
expect(localNodes[0].name).toBe("local");
expect(localNodes[0].status).toBe("online");
expect(localNodes[0].maxConcurrent).toBe(4);
});
it("should not create duplicate default local nodes across re-initialization", async () => {
await central.init();
await central.close();
central = new CentralCore(tempDir);
await central.init();
const nodes = await central.listNodes();
const localNodes = nodes.filter((node) => node.type === "local");
expect(localNodes).toHaveLength(1);
expect(localNodes[0].name).toBe("local");
});
it("should close and clean up", async () => {
await central.init();
await central.close();
@@ -534,6 +558,175 @@ describe("CentralCore", () => {
});
});
describe("node management", () => {
beforeEach(async () => {
await central.init();
});
it("should register and retrieve a node", async () => {
const node = await central.registerNode({
name: "executor-node-a",
type: "local",
maxConcurrent: 3,
});
expect(node.id).toMatch(/^node_[a-f0-9]+$/);
expect(node.name).toBe("executor-node-a");
expect(node.type).toBe("local");
expect(node.status).toBe("offline");
expect(node.maxConcurrent).toBe(3);
const fetched = await central.getNode(node.id);
expect(fetched).toEqual(node);
const byName = await central.getNodeByName("executor-node-a");
expect(byName?.id).toBe(node.id);
});
it("should reject duplicate node names", async () => {
await central.registerNode({ name: "dup-node", type: "local" });
await expect(
central.registerNode({ name: "dup-node", type: "local" }),
).rejects.toThrow("already exists");
});
it("should validate node type constraints on register", async () => {
await expect(
central.registerNode({ name: "remote-missing-url", type: "remote" }),
).rejects.toThrow("must include a url");
await expect(
central.registerNode({
name: "local-with-url",
type: "local",
url: "https://example.com",
}),
).rejects.toThrow("must not include url or apiKey");
await expect(
central.registerNode({
name: "local-with-key",
type: "local",
apiKey: "abc",
}),
).rejects.toThrow("must not include url or apiKey");
});
it("should update nodes and enforce type constraints", async () => {
const remote = await central.registerNode({
name: "remote-node",
type: "remote",
url: "https://node.example.com",
apiKey: "secret",
});
const updated = await central.updateNode(remote.id, {
status: "connecting",
maxConcurrent: 4,
});
expect(updated.status).toBe("connecting");
expect(updated.maxConcurrent).toBe(4);
await expect(
central.updateNode(remote.id, {
type: "local",
}),
).rejects.toThrow("must not include url or apiKey");
});
it("should list nodes ordered by name", async () => {
await central.registerNode({ name: "z-node", type: "local" });
await central.registerNode({ name: "a-node", type: "local" });
const nodes = await central.listNodes();
const names = nodes.map((node) => node.name);
expect(names).toContain("a-node");
expect(names).toContain("z-node");
expect(names.indexOf("a-node")).toBeLessThan(names.indexOf("z-node"));
});
it("should assign and unassign projects to nodes", async () => {
const projectPath = join(tempDir, "node-assignment");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Node Assignment",
path: projectPath,
});
const node = await central.registerNode({ name: "assign-node", type: "local" });
const assigned = await central.assignProjectToNode(project.id, node.id);
expect(assigned.nodeId).toBe(node.id);
expect((await central.getProject(project.id))?.nodeId).toBe(node.id);
const unassigned = await central.unassignProjectFromNode(project.id);
expect(unassigned.nodeId).toBeUndefined();
expect((await central.getProject(project.id))?.nodeId).toBeUndefined();
});
it("should throw when assigning to unknown project or node", async () => {
const node = await central.registerNode({ name: "assignment-target", type: "local" });
await expect(central.assignProjectToNode("proj_missing", node.id)).rejects.toThrow("Project not found");
const projectPath = join(tempDir, "node-assignment-errors");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Node Assignment Errors",
path: projectPath,
});
await expect(central.assignProjectToNode(project.id, "node_missing")).rejects.toThrow("Node not found");
await expect(central.unassignProjectFromNode("proj_missing")).rejects.toThrow("Project not found");
});
it("should unassign projects when a node is unregistered", async () => {
const projectPath = join(tempDir, "node-unregister");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Node Unregister",
path: projectPath,
});
const node = await central.registerNode({ name: "ephemeral-node", type: "local" });
await central.assignProjectToNode(project.id, node.id);
await central.unregisterNode(node.id);
expect(await central.getNode(node.id)).toBeUndefined();
expect((await central.getProject(project.id))?.nodeId).toBeUndefined();
});
it("should be idempotent when unregistering missing nodes", async () => {
await expect(central.unregisterNode("node_missing")).resolves.toBeUndefined();
});
it("should check local node health and emit node:health:changed", async () => {
const node = await central.registerNode({ name: "local-health", type: "local" });
let emittedNodeId: string | undefined;
let emittedStatus: string | undefined;
central.on("node:health:changed", (updated) => {
emittedNodeId = updated.id;
emittedStatus = updated.status;
});
const status = await central.checkNodeHealth(node.id);
expect(status).toBe("online");
const stored = await central.getNode(node.id);
expect(stored?.status).toBe("online");
expect(emittedNodeId).toBe(node.id);
expect(emittedStatus).toBe("online");
});
});
describe("project health", () => {
beforeEach(async () => {
await central.init();

View File

@@ -41,6 +41,9 @@ import type {
ProjectStatus,
ActivityEventType,
ProjectSettings,
AgentCapability,
NodeConfig,
NodeStatus,
} from "./types.js";
import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js";
import { resolveGlobalDir } from "./global-settings.js";
@@ -58,6 +61,14 @@ export interface CentralCoreEvents {
"project:health:changed": [health: ProjectHealth];
/** Emitted when a new activity is logged */
"activity:logged": [entry: CentralActivityLogEntry];
/** Emitted when a node is registered */
"node:registered": [node: NodeConfig];
/** Emitted when a node is unregistered */
"node:unregistered": [nodeId: string];
/** Emitted when node metadata is updated */
"node:updated": [node: NodeConfig];
/** Emitted when node health status changes */
"node:health:changed": [node: NodeConfig];
/** Emitted when global concurrency state changes */
"concurrency:changed": [state: GlobalConcurrencyState];
}
@@ -98,6 +109,24 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
}
this.initialized = true;
const existingLocal = this.db
.prepare("SELECT id FROM nodes WHERE type = 'local' LIMIT 1")
.get() as { id: string } | undefined;
if (!existingLocal) {
const concurrency = this.db
.prepare("SELECT globalMaxConcurrent FROM globalConcurrency WHERE id = 1")
.get() as { globalMaxConcurrent: number } | undefined;
const maxConcurrent = concurrency?.globalMaxConcurrent ?? 2;
const localNode = await this.registerNode({
name: "local",
type: "local",
maxConcurrent,
});
await this.updateNode(localNode.id, { status: "online" });
}
}
/**
@@ -237,6 +266,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
createdAt: string;
updatedAt: string;
lastActivityAt: string | null;
nodeId: string | null;
settings: string | null;
}
| undefined;
@@ -265,6 +295,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
createdAt: string;
updatedAt: string;
lastActivityAt: string | null;
nodeId: string | null;
settings: string | null;
}
| undefined;
@@ -291,6 +322,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
createdAt: string;
updatedAt: string;
lastActivityAt: string | null;
nodeId: string | null;
settings: string | null;
}>;
@@ -333,6 +365,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
isolationMode = ?,
updatedAt = ?,
lastActivityAt = ?,
nodeId = ?,
settings = ?
WHERE id = ?`
).run(
@@ -342,6 +375,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
updated.isolationMode,
updated.updatedAt,
updated.lastActivityAt ?? null,
updated.nodeId ?? null,
toJsonNullable(updated.settings),
id
);
@@ -400,6 +434,349 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
return reconciled;
}
// ── Node Registry API ───────────────────────────────────────────────────
/**
* Register a new runtime node.
*
* @param input — Node registration input
* @returns The registered node
* @throws Error if constraints are violated or name already exists
*/
async registerNode(input: {
name: string;
type: "local" | "remote";
url?: string;
apiKey?: string;
capabilities?: AgentCapability[];
maxConcurrent?: number;
}): Promise<NodeConfig> {
this.ensureInitialized();
const name = input.name.trim();
if (!name) {
throw new Error("Node name is required");
}
const existingByName = await this.getNodeByName(name);
if (existingByName) {
throw new Error(`Node already exists with name: ${name}`);
}
const normalizedUrl = input.url?.trim();
if (input.type === "remote" && !normalizedUrl) {
throw new Error("Remote nodes must include a url");
}
if (input.type === "local" && (normalizedUrl || input.apiKey)) {
throw new Error("Local nodes must not include url or apiKey");
}
const maxConcurrent = input.maxConcurrent ?? 2;
if (!Number.isFinite(maxConcurrent) || maxConcurrent < 1) {
throw new Error(`Node maxConcurrent must be >= 1: ${maxConcurrent}`);
}
const now = new Date().toISOString();
const node: NodeConfig = {
id: `node_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
name,
type: input.type,
url: normalizedUrl || undefined,
apiKey: input.apiKey || undefined,
status: "offline",
capabilities: input.capabilities,
maxConcurrent,
createdAt: now,
updatedAt: now,
};
this.db!.prepare(
`INSERT INTO nodes (id, name, type, url, apiKey, status, capabilities, maxConcurrent, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
node.id,
node.name,
node.type,
node.url ?? null,
node.apiKey ?? null,
node.status,
toJsonNullable(node.capabilities),
node.maxConcurrent,
node.createdAt,
node.updatedAt
);
this.db!.bumpLastModified();
this.emit("node:registered", node);
return node;
}
/**
* Unregister a runtime node.
*
* Idempotent. Projects assigned to this node are automatically unassigned.
*
* @param id — Node ID to unregister
*/
async unregisterNode(id: string): Promise<void> {
this.ensureInitialized();
const node = await this.getNode(id);
if (!node) {
return;
}
const now = new Date().toISOString();
this.db!.transaction(() => {
this.db!.prepare("UPDATE projects SET nodeId = NULL, updatedAt = ? WHERE nodeId = ?").run(now, id);
this.db!.prepare("DELETE FROM nodes WHERE id = ?").run(id);
});
this.db!.bumpLastModified();
this.emit("node:unregistered", id);
}
/**
* Get a node by ID.
*/
async getNode(id: string): Promise<NodeConfig | undefined> {
this.ensureInitialized();
const row = this.db!.prepare("SELECT * FROM nodes WHERE id = ?").get(id) as
| {
id: string;
name: string;
type: string;
url: string | null;
apiKey: string | null;
status: string;
capabilities: string | null;
maxConcurrent: number;
createdAt: string;
updatedAt: string;
}
| undefined;
if (!row) return undefined;
return this.rowToNode(row);
}
/**
* Get a node by unique name.
*/
async getNodeByName(name: string): Promise<NodeConfig | undefined> {
this.ensureInitialized();
const row = this.db!.prepare("SELECT * FROM nodes WHERE name = ?").get(name) as
| {
id: string;
name: string;
type: string;
url: string | null;
apiKey: string | null;
status: string;
capabilities: string | null;
maxConcurrent: number;
createdAt: string;
updatedAt: string;
}
| undefined;
if (!row) return undefined;
return this.rowToNode(row);
}
/**
* List all nodes ordered by name.
*/
async listNodes(): Promise<NodeConfig[]> {
this.ensureInitialized();
const rows = this.db!.prepare("SELECT * FROM nodes ORDER BY name").all() as Array<{
id: string;
name: string;
type: string;
url: string | null;
apiKey: string | null;
status: string;
capabilities: string | null;
maxConcurrent: number;
createdAt: string;
updatedAt: string;
}>;
return rows.map((row) => this.rowToNode(row));
}
/**
* Update node metadata.
*/
async updateNode(
id: string,
updates: Partial<Omit<NodeConfig, "id" | "createdAt">>
): Promise<NodeConfig> {
this.ensureInitialized();
const node = await this.getNode(id);
if (!node) {
throw new Error(`Node not found: ${id}`);
}
const now = new Date().toISOString();
const updated: NodeConfig = {
...node,
...updates,
id,
createdAt: node.createdAt,
updatedAt: now,
};
if (!Number.isFinite(updated.maxConcurrent) || updated.maxConcurrent < 1) {
throw new Error(`Node maxConcurrent must be >= 1: ${updated.maxConcurrent}`);
}
if (updated.type === "remote" && !updated.url) {
throw new Error("Remote nodes must include a url");
}
if (updated.type === "local" && (updated.url || updated.apiKey)) {
throw new Error("Local nodes must not include url or apiKey");
}
this.db!.prepare(
`UPDATE nodes SET
name = ?,
type = ?,
url = ?,
apiKey = ?,
status = ?,
capabilities = ?,
maxConcurrent = ?,
updatedAt = ?
WHERE id = ?`
).run(
updated.name,
updated.type,
updated.url ?? null,
updated.apiKey ?? null,
updated.status,
toJsonNullable(updated.capabilities),
updated.maxConcurrent,
updated.updatedAt,
id
);
this.db!.bumpLastModified();
this.emit("node:updated", updated);
return updated;
}
/**
* Check node health and update stored status.
*/
async checkNodeHealth(id: string): Promise<NodeStatus> {
this.ensureInitialized();
const node = await this.getNode(id);
if (!node) {
throw new Error(`Node not found: ${id}`);
}
let nextStatus: NodeStatus;
if (node.type === "local") {
nextStatus = "online";
} else if (!node.url) {
nextStatus = "error";
} else {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5_000);
try {
const healthUrl = new URL("/api/health", node.url).toString();
const response = await fetch(healthUrl, {
method: "GET",
headers: node.apiKey ? { Authorization: `Bearer ${node.apiKey}` } : undefined,
signal: controller.signal,
});
nextStatus = response.ok ? "online" : "offline";
} catch {
nextStatus = "error";
} finally {
clearTimeout(timeout);
}
}
if (nextStatus !== node.status) {
const now = new Date().toISOString();
const updated: NodeConfig = {
...node,
status: nextStatus,
updatedAt: now,
};
this.db!
.prepare("UPDATE nodes SET status = ?, updatedAt = ? WHERE id = ?")
.run(nextStatus, now, id);
this.db!.bumpLastModified();
this.emit("node:health:changed", updated);
}
return nextStatus;
}
/**
* Assign a project to a node.
*/
async assignProjectToNode(projectId: string, nodeId: string): Promise<RegisteredProject> {
this.ensureInitialized();
const project = await this.getProject(projectId);
if (!project) {
throw new Error(`Project not found: ${projectId}`);
}
const node = await this.getNode(nodeId);
if (!node) {
throw new Error(`Node not found: ${nodeId}`);
}
const now = new Date().toISOString();
this.db!.prepare("UPDATE projects SET nodeId = ?, updatedAt = ? WHERE id = ?").run(node.id, now, projectId);
this.db!.bumpLastModified();
const updated: RegisteredProject = {
...project,
nodeId: node.id,
updatedAt: now,
};
this.emit("project:updated", updated);
return updated;
}
/**
* Unassign a project from any node.
*/
async unassignProjectFromNode(projectId: string): Promise<RegisteredProject> {
this.ensureInitialized();
const project = await this.getProject(projectId);
if (!project) {
throw new Error(`Project not found: ${projectId}`);
}
const now = new Date().toISOString();
this.db!.prepare("UPDATE projects SET nodeId = NULL, updatedAt = ? WHERE id = ?").run(now, projectId);
this.db!.bumpLastModified();
const updated: RegisteredProject = {
...project,
nodeId: undefined,
updatedAt: now,
};
this.emit("project:updated", updated);
return updated;
}
// ── Project Health API ──────────────────────────────────────────────────
/**
@@ -928,6 +1305,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
createdAt: string;
updatedAt: string;
lastActivityAt: string | null;
nodeId: string | null;
settings: string | null;
}): RegisteredProject {
return {
@@ -939,10 +1317,37 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastActivityAt: row.lastActivityAt ?? undefined,
nodeId: row.nodeId ?? undefined,
settings: fromJson<ProjectSettings>(row.settings),
};
}
private rowToNode(row: {
id: string;
name: string;
type: string;
url: string | null;
apiKey: string | null;
status: string;
capabilities: string | null;
maxConcurrent: number;
createdAt: string;
updatedAt: string;
}): NodeConfig {
return {
id: row.id,
name: row.name,
type: row.type as NodeConfig["type"],
url: row.url ?? undefined,
apiKey: row.apiKey ?? undefined,
status: row.status as NodeStatus,
capabilities: fromJson<AgentCapability[]>(row.capabilities),
maxConcurrent: row.maxConcurrent,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private rowToHealth(row: {
projectId: string;
status: string;

View File

@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
it("should initialize schema version", () => {
db.init();
expect(db.getSchemaVersion()).toBe(1);
expect(db.getSchemaVersion()).toBe(2);
});
it("should seed lastModified on init", () => {
@@ -62,6 +62,26 @@ describe("CentralDatabase", () => {
expect(row?.queuedCount).toBe(0);
});
it("should apply nodes defaults when optional values are omitted", () => {
db.init();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO nodes (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("node_test", "local-test", "local", now, now);
const row = db.prepare("SELECT status, maxConcurrent FROM nodes WHERE id = ?").get("node_test") as
| {
status: string;
maxConcurrent: number;
}
| undefined;
expect(row).toBeDefined();
expect(row?.status).toBe("offline");
expect(row?.maxConcurrent).toBe(2);
});
it("should create all required tables", () => {
db.init();
const tables = db
@@ -72,9 +92,20 @@ describe("CentralDatabase", () => {
expect(tableNames).toContain("projectHealth");
expect(tableNames).toContain("centralActivityLog");
expect(tableNames).toContain("globalConcurrency");
expect(tableNames).toContain("nodes");
expect(tableNames).toContain("__meta");
});
it("should include nodeId column on projects table", () => {
db.init();
const columns = db.prepare("PRAGMA table_info(projects)").all() as Array<{
name: string;
}>;
const columnNames = columns.map((column) => column.name);
expect(columnNames).toContain("nodeId");
});
it("should create required indexes", () => {
db.init();
const indexes = db
@@ -86,6 +117,8 @@ describe("CentralDatabase", () => {
expect(indexNames).toContain("idxActivityLogTimestamp");
expect(indexNames).toContain("idxActivityLogType");
expect(indexNames).toContain("idxActivityLogProjectId");
expect(indexNames).toContain("idxNodesStatus");
expect(indexNames).toContain("idxNodesType");
});
});

View File

@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
// ── Schema Definition ───────────────────────────────────────────────────
const CENTRAL_SCHEMA_VERSION = 1;
const CENTRAL_SCHEMA_VERSION = 2;
const CENTRAL_SCHEMA_SQL = `
-- Projects table (project registry)
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS projects (
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
lastActivityAt TEXT,
nodeId TEXT,
settings TEXT -- JSON ProjectSettings snapshot
);
CREATE INDEX IF NOT EXISTS idxProjectsPath ON projects(path);
@@ -86,6 +87,22 @@ CREATE TABLE IF NOT EXISTS globalConcurrency (
INSERT OR IGNORE INTO globalConcurrency (id, globalMaxConcurrent, currentlyActive, queuedCount)
VALUES (1, 4, 0, 0);
-- Nodes table (runtime hosts for project execution)
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK (type IN ('local', 'remote')),
url TEXT,
apiKey TEXT,
status TEXT NOT NULL DEFAULT 'offline',
capabilities TEXT,
maxConcurrent INTEGER NOT NULL DEFAULT 2,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idxNodesStatus ON nodes(status);
CREATE INDEX IF NOT EXISTS idxNodesType ON nodes(type);
-- Schema version tracking
CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
@@ -93,6 +110,23 @@ CREATE TABLE IF NOT EXISTS __meta (
);
`;
const CENTRAL_SCHEMA_V2_MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK (type IN ('local', 'remote')),
url TEXT,
apiKey TEXT,
status TEXT NOT NULL DEFAULT 'offline',
capabilities TEXT,
maxConcurrent INTEGER NOT NULL DEFAULT 2,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idxNodesStatus ON nodes(status);
CREATE INDEX IF NOT EXISTS idxNodesType ON nodes(type);
`;
// ── Central Database Class ────────────────────────────────────────────────
export class CentralDatabase {
@@ -126,15 +160,32 @@ export class CentralDatabase {
init(): void {
this.db.exec(CENTRAL_SCHEMA_SQL);
// Seed schemaVersion and lastModified idempotently
this.db.exec(
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('schemaVersion', '${CENTRAL_SCHEMA_VERSION}')`,
);
const currentVersion = this.getSchemaVersion();
if (currentVersion < 2) {
this.db.exec(CENTRAL_SCHEMA_V2_MIGRATION_SQL);
if (!this.hasColumn("projects", "nodeId")) {
this.db.exec("ALTER TABLE projects ADD COLUMN nodeId TEXT");
}
this.db
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run(String(CENTRAL_SCHEMA_VERSION));
} else {
this.db.exec(
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('schemaVersion', '${CENTRAL_SCHEMA_VERSION}')`,
);
}
// Seed lastModified idempotently
this.db.exec(
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('lastModified', '${Date.now()}')`,
);
}
private hasColumn(table: string, column: string): boolean {
const rows = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
return rows.some((row) => row.name === column);
}
/**
* Close the database connection.
*/

View File

@@ -128,18 +128,20 @@ export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";
export { CentralCore } from "./central-core.js";
export type { CentralCoreEvents } from "./central-core.js";
export { CentralDatabase, createCentralDatabase } from "./central-db.js";
export type {
RegisteredProject,
export type {
CentralActivityLogEntry,
GlobalConcurrencyState,
IsolationMode,
MigrationOptions,
NodeConfig,
NodeStatus,
ProjectHealth,
/** @deprecated Use RegisteredProject instead */
ProjectInfo,
IsolationMode,
ProjectStatus,
ProjectHealth,
CentralActivityLogEntry,
GlobalConcurrencyState,
MigrationOptions,
SetupState,
ProjectStatus,
RegisteredProject,
SetupCompletionResult,
SetupState,
} from "./types.js";
// ── Migration and First-Run Experience ────────────────────────────────

View File

@@ -1247,6 +1247,33 @@ export type IsolationMode = "in-process" | "child-process";
/** Project status in the central registry */
export type ProjectStatus = "active" | "paused" | "errored" | "initializing";
/** Node connectivity/health status in the central registry */
export type NodeStatus = "online" | "offline" | "connecting" | "error";
/** A runtime node that can host project execution (local machine or remote host) */
export interface NodeConfig {
/** Unique node ID (e.g., "node_abc123") */
id: string;
/** Display name (unique across all nodes) */
name: string;
/** Node type */
type: "local" | "remote";
/** Base URL for remote nodes. Undefined for local nodes. */
url?: string;
/** API key used for authenticating requests to remote nodes. */
apiKey?: string;
/** Current node status */
status: NodeStatus;
/** Optional capabilities available on this node */
capabilities?: AgentCapability[];
/** Maximum concurrent tasks/runtimes this node can host */
maxConcurrent: number;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/** A project registered in the central database */
export interface RegisteredProject {
/** Unique project ID (e.g., "proj_abc123") */
@@ -1259,6 +1286,8 @@ export interface RegisteredProject {
status: ProjectStatus;
/** Execution isolation mode */
isolationMode: IsolationMode;
/** Optional runtime node assignment */
nodeId?: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */