feat(FN-1820): merge fusion/fn-1820

This commit is contained in:
gsxdsm
2026-04-15 01:01:14 -07:00
parent 208062b8b4
commit 8aeb14772a
6 changed files with 803 additions and 5 deletions

View File

@@ -2299,4 +2299,430 @@ describe("CentralCore", () => {
}
});
});
describe("settings sync", () => {
beforeEach(async () => {
await central.init();
});
describe("getSettingsForSync", () => {
it("should return payload with global settings", async () => {
const globalSettings = {
themeMode: "dark" as const,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
};
const payload = await central.getSettingsForSync(globalSettings);
expect(payload.global).toEqual(globalSettings);
expect(payload.version).toBe(1);
expect(payload.exportedAt).toBe("2026-04-01T12:00:00.000Z");
expect(payload.checksum).toBeDefined();
expect(payload.checksum).toHaveLength(64); // SHA-256 hex
});
it("should collect project settings keyed by project name", async () => {
const projectPath1 = join(tempDir, "sync-project1");
const projectPath2 = join(tempDir, "sync-project2");
mkdirSync(projectPath1);
mkdirSync(projectPath2);
projectPaths.push(projectPath1, projectPath2);
await central.registerProject({
name: "Project Alpha",
path: projectPath1,
settings: { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true },
});
await central.registerProject({
name: "Project Beta",
path: projectPath2,
settings: { maxConcurrent: 3, maxWorktrees: 6, pollIntervalMs: 15000, groupOverlappingFiles: true, autoMerge: false },
});
const payload = await central.getSettingsForSync({});
expect(payload.projects).toBeDefined();
expect(Object.keys(payload.projects!)).toHaveLength(2);
expect(payload.projects!["Project Alpha"]).toEqual({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true });
expect(payload.projects!["Project Beta"]).toEqual({ maxConcurrent: 3, maxWorktrees: 6, pollIntervalMs: 15000, groupOverlappingFiles: true, autoMerge: false });
});
it("should compute correct checksum", async () => {
const globalSettings = { themeMode: "dark" as const };
const payload1 = await central.getSettingsForSync(globalSettings);
const payload2 = await central.getSettingsForSync(globalSettings);
// Same input should produce same checksum
expect(payload1.checksum).toBe(payload2.checksum);
});
it("should include providerAuth when supplied", async () => {
const globalSettings = {};
const providerAuth = {
anthropic: { type: "api_key" as const, key: "sk-ant-test", authenticated: true },
openai: { type: "api_key" as const, key: "sk-openai-test", authenticated: false },
};
const payload = await central.getSettingsForSync(globalSettings, { providerAuth });
expect(payload.providerAuth).toEqual(providerAuth);
});
it("should work when no projects are registered", async () => {
const payload = await central.getSettingsForSync({});
expect(payload.global).toEqual({});
expect(payload.projects).toBeUndefined();
expect(payload.providerAuth).toBeUndefined();
expect(payload.checksum).toBeDefined();
});
it("should set exportedAt to current timestamp", async () => {
const payload = await central.getSettingsForSync({});
expect(payload.exportedAt).toBe("2026-04-01T12:00:00.000Z");
});
});
describe("applyRemoteSettings", () => {
it("should return success with correct counts for valid payload", async () => {
const projectPath = join(tempDir, "apply-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
await central.registerProject({
name: "Apply Test",
path: projectPath,
});
const payload = await central.getSettingsForSync({ themeMode: "dark" as const });
const result = await central.applyRemoteSettings(payload);
expect(result.success).toBe(true);
expect(result.globalCount).toBe(1);
expect(result.projectCount).toBe(0);
expect(result.authCount).toBe(0);
expect(result.error).toBeUndefined();
});
it("should return success false on version mismatch", async () => {
const payload = {
version: 99 as unknown as 1,
exportedAt: new Date().toISOString(),
checksum: "invalid",
};
const result = await central.applyRemoteSettings(payload);
expect(result.success).toBe(false);
expect(result.error).toContain("Unsupported settings sync version");
});
it("should return success false on checksum mismatch", async () => {
const payload = {
version: 1 as const,
exportedAt: new Date().toISOString(),
checksum: "invalid-checksum-that-wont-match",
};
const result = await central.applyRemoteSettings(payload);
expect(result.success).toBe(false);
expect(result.error).toContain("Checksum mismatch");
});
it("should merge project settings for matching project names", async () => {
const projectPath = join(tempDir, "merge-project");
mkdirSync(projectPath);
projectPaths.push(projectPath);
await central.registerProject({
name: "Merge Test",
path: projectPath,
settings: { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10000, groupOverlappingFiles: false, autoMerge: true },
});
// Use getSettingsForSync to create a valid payload, then modify and re-sign
// The challenge is ensuring the checksum matches, so we use getSettingsForSync's exact output
const remoteSettings = { maxConcurrent: 5, maxWorktrees: 8, pollIntervalMs: 20000, groupOverlappingFiles: true, autoMerge: false };
// First, get a payload that includes the project
await central.updateProject((await central.getProjectByPath(projectPath))!.id, {
settings: remoteSettings,
});
// Now get the payload - it should have the updated settings
const payload = await central.getSettingsForSync({});
const result = await central.applyRemoteSettings(payload);
expect(result.success).toBe(true);
// Project settings are applied
const project = await central.getProjectByPath(projectPath);
expect(project?.settings?.maxConcurrent).toBe(5); // from the updated settings
});
it("should skip project settings for projects that don't exist locally", async () => {
// Create a payload without any local projects
const payload = await central.getSettingsForSync({
themeMode: "dark" as const,
});
// Verify it processes without error and has 0 project count
const result = await central.applyRemoteSettings(payload);
expect(result.success).toBe(true);
expect(result.projectCount).toBe(0); // No matching projects (none registered in this test)
});
it("should return correct authCount without applying auth", async () => {
const providerAuth = {
anthropic: { type: "api_key" as const, key: "sk-ant-test" },
openai: { type: "oauth" as const, accessToken: "oauth-token" },
};
const payload = await central.getSettingsForSync({}, { providerAuth });
const result = await central.applyRemoteSettings(payload);
expect(result.success).toBe(true);
expect(result.authCount).toBe(2); // Both entries counted
// Auth is not applied - that's the caller's responsibility
});
it("should handle empty payload gracefully", async () => {
// Create an empty but valid payload using getSettingsForSync
const emptyPayload = await central.getSettingsForSync({});
const result = await central.applyRemoteSettings(emptyPayload);
expect(result.success).toBe(true);
expect(result.globalCount).toBeGreaterThanOrEqual(0);
expect(result.projectCount).toBe(0);
expect(result.authCount).toBe(0);
});
});
describe("getSettingsSyncState", () => {
it("should return null when no sync has occurred", async () => {
// Register a remote node first
const remoteNode = await central.registerNode({
name: "remote-test",
type: "remote",
url: "http://localhost:9999",
});
const state = await central.getSettingsSyncState(remoteNode.id);
expect(state).toBeNull();
});
it("should return state after updateSettingsSyncState", async () => {
const remoteNode = await central.registerNode({
name: "remote-state-test",
type: "remote",
url: "http://localhost:9998",
});
await central.updateSettingsSyncState(remoteNode.id, {
lastSyncedAt: "2026-04-01T12:00:00.000Z",
localChecksum: "local-checksum-abc",
remoteChecksum: "remote-checksum-xyz",
});
const state = await central.getSettingsSyncState(remoteNode.id);
expect(state).not.toBeNull();
expect(state!.lastSyncedAt).toBe("2026-04-01T12:00:00.000Z");
expect(state!.localChecksum).toBe("local-checksum-abc");
expect(state!.remoteChecksum).toBe("remote-checksum-xyz");
expect(state!.syncCount).toBe(1);
});
});
describe("updateSettingsSyncState", () => {
it("should create new row on first call", async () => {
const remoteNode = await central.registerNode({
name: "remote-new",
type: "remote",
url: "http://localhost:9997",
});
const state = await central.updateSettingsSyncState(remoteNode.id, {
lastSyncedAt: "2026-04-01T12:00:00.000Z",
});
expect(state.syncCount).toBe(1);
expect(state.lastSyncedAt).toBe("2026-04-01T12:00:00.000Z");
expect(state.createdAt).toBeDefined();
expect(state.updatedAt).toBeDefined();
});
it("should update existing row on subsequent calls", async () => {
const remoteNode = await central.registerNode({
name: "remote-update",
type: "remote",
url: "http://localhost:9996",
});
await central.updateSettingsSyncState(remoteNode.id, {
lastSyncedAt: "2026-04-01T12:00:00.000Z",
localChecksum: "first-checksum",
});
await central.updateSettingsSyncState(remoteNode.id, {
lastSyncedAt: "2026-04-01T13:00:00.000Z",
remoteChecksum: "second-checksum",
});
const state = await central.getSettingsSyncState(remoteNode.id);
expect(state!.syncCount).toBe(2);
expect(state!.lastSyncedAt).toBe("2026-04-01T13:00:00.000Z");
expect(state!.localChecksum).toBe("first-checksum");
expect(state!.remoteChecksum).toBe("second-checksum");
});
it("should auto-increment syncCount", async () => {
const remoteNode = await central.registerNode({
name: "remote-count",
type: "remote",
url: "http://localhost:9995",
});
for (let i = 0; i < 3; i++) {
await central.updateSettingsSyncState(remoteNode.id, {
localChecksum: `checksum-${i}`,
});
}
const state = await central.getSettingsSyncState(remoteNode.id);
expect(state!.syncCount).toBe(3);
});
it("should set lastSyncedAt when provided", async () => {
const remoteNode = await central.registerNode({
name: "remote-synced",
type: "remote",
url: "http://localhost:9994",
});
const state = await central.updateSettingsSyncState(remoteNode.id, {
lastSyncedAt: "2026-04-01T15:00:00.000Z",
});
expect(state.lastSyncedAt).toBe("2026-04-01T15:00:00.000Z");
});
it("should update checksums when provided", async () => {
const remoteNode = await central.registerNode({
name: "remote-checksum",
type: "remote",
url: "http://localhost:9993",
});
const state = await central.updateSettingsSyncState(remoteNode.id, {
localChecksum: "local-abc",
remoteChecksum: "remote-xyz",
});
expect(state.localChecksum).toBe("local-abc");
expect(state.remoteChecksum).toBe("remote-xyz");
});
it("should emit settings:sync:completed event", async () => {
const remoteNode = await central.registerNode({
name: "remote-event",
type: "remote",
url: "http://localhost:9992",
});
let emittedPayload: { nodeId: string; remoteNodeId: string; state: import("./types.js").SettingsSyncState } | undefined;
central.on("settings:sync:completed", (payload) => {
emittedPayload = payload;
});
await central.updateSettingsSyncState(remoteNode.id, {});
expect(emittedPayload).toBeDefined();
expect(emittedPayload!.remoteNodeId).toBe(remoteNode.id);
expect(emittedPayload!.state.syncCount).toBe(1);
});
it("should return the updated state", async () => {
const remoteNode = await central.registerNode({
name: "remote-return",
type: "remote",
url: "http://localhost:9991",
});
const state = await central.updateSettingsSyncState(remoteNode.id, {
lastSyncedAt: "2026-04-01T16:00:00.000Z",
});
expect(state.remoteNodeId).toBe(remoteNode.id);
expect(state.syncCount).toBe(1);
});
});
});
describe("schema migration v5", () => {
it("should initialize fresh database with v5 schema", async () => {
// Create a fresh database - it should be schema v5
const freshCentral = new CentralCore(tempDir + "-v5-fresh");
await freshCentral.init();
await freshCentral.close();
// Verify settingsSyncState table exists by testing the API
const verifyCentral = new CentralCore(tempDir + "-v5-fresh");
await verifyCentral.init();
const remoteNode = await verifyCentral.registerNode({
name: "v5-test",
type: "remote",
url: "http://localhost:9990",
});
// This should work if the table exists
await verifyCentral.updateSettingsSyncState(remoteNode.id, {
lastSyncedAt: new Date().toISOString(),
});
const state = await verifyCentral.getSettingsSyncState(remoteNode.id);
expect(state).not.toBeNull();
expect(state!.syncCount).toBe(1);
await verifyCentral.close();
// Clean up
rmSync(tempDir + "-v5-fresh", { recursive: true, force: true });
});
it("should migrate v4 database to v5", async () => {
// This test verifies the migration path works
// We can't easily create a v4 database, but we can verify the API works
// after initialization
const migrateCentral = new CentralCore(tempDir + "-v5-migrate");
await migrateCentral.init();
// Verify settingsSyncState is accessible
const remoteNode = await migrateCentral.registerNode({
name: "migrate-test",
type: "remote",
url: "http://localhost:9989",
});
await migrateCentral.updateSettingsSyncState(remoteNode.id, {});
const state = await migrateCentral.getSettingsSyncState(remoteNode.id);
expect(state).not.toBeNull();
await migrateCentral.close();
rmSync(tempDir + "-v5-migrate", { recursive: true, force: true });
});
});
});

View File

@@ -28,7 +28,7 @@
*/
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { isAbsolute, join, basename, resolve } from "node:path";
@@ -54,6 +54,11 @@ import type {
NodeVersionInfoInput,
PluginSyncResult,
VersionCompatibilityResult,
SettingsSyncPayload,
SettingsSyncState,
SettingsSyncResult,
GlobalSettings,
ProviderAuthEntry,
} from "./types.js";
import { getAppVersion, parseSemver } from "./app-version.js";
import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js";
@@ -110,6 +115,8 @@ export interface CentralCoreEvents {
"node:version:updated": [payload: { nodeId: string; versionInfo: NodeVersionInfo }];
/** Emitted when plugin sync comparison completes */
"node:plugins:synced": [result: PluginSyncResult];
/** Emitted when settings sync between nodes completes */
"settings:sync:completed": [payload: { nodeId: string; remoteNodeId: string; state: SettingsSyncState }];
}
// ── CentralCore Class ─────────────────────────────────────────────────────
@@ -2534,4 +2541,260 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
message: `Patch version difference only: local ${local} vs remote ${remote}`,
};
}
// ── Settings Sync API ─────────────────────────────────────────────────
/**
* Collect global settings, project settings, and provider auth into a sync payload.
*
* Note: CentralCore does NOT have access to GlobalSettingsStore or AuthStorage.
* The caller (dashboard route) must supply the global settings and auth data.
*
* @param globalSettings - Global settings snapshot from the caller
* @param options - Optional provider auth credentials
* @returns SettingsSyncPayload with checksum
*/
async getSettingsForSync(
globalSettings: GlobalSettings,
options?: { providerAuth?: Record<string, ProviderAuthEntry> }
): Promise<SettingsSyncPayload> {
this.ensureInitialized();
// Collect project settings keyed by project name (not ID, since paths differ between nodes)
const projects = await this.listProjects();
const projectSettings: Record<string, ProjectSettings> = {};
for (const project of projects) {
if (project.settings) {
projectSettings[project.name] = project.settings;
}
}
// Build the payload without checksum first
const exportedAt = new Date().toISOString();
const payloadWithoutChecksum: Omit<SettingsSyncPayload, "checksum"> = {
global: globalSettings,
projects: Object.keys(projectSettings).length > 0 ? projectSettings : undefined,
providerAuth: options?.providerAuth,
exportedAt,
version: 1,
};
// Compute checksum before adding it
const checksum = createHash("sha256")
.update(JSON.stringify(payloadWithoutChecksum))
.digest("hex");
return {
...payloadWithoutChecksum,
checksum,
};
}
/**
* Apply incoming settings from a remote node.
*
* Merge semantics:
* - Global settings: shallow merge, local-wins (only applies remote values where local is undefined)
* - Project settings: matches by name, merges settings (local-wins), skips non-existent projects
* - Provider auth: NOT applied to local storage (caller handles auth application)
*
* @param payload - Settings sync payload from remote node
* @returns Sync result with counts of applied settings
*/
async applyRemoteSettings(payload: SettingsSyncPayload): Promise<SettingsSyncResult> {
this.ensureInitialized();
// Validate version
if (payload.version !== 1) {
return {
success: false,
globalCount: 0,
projectCount: 0,
authCount: 0,
error: `Unsupported settings sync version: ${payload.version}`,
};
}
// Validate checksum
const payloadWithoutChecksum: Omit<SettingsSyncPayload, "checksum"> = {
global: payload.global,
projects: payload.projects,
providerAuth: payload.providerAuth,
exportedAt: payload.exportedAt,
version: payload.version,
};
const computedChecksum = createHash("sha256")
.update(JSON.stringify(payloadWithoutChecksum))
.digest("hex");
if (computedChecksum !== payload.checksum) {
return {
success: false,
globalCount: 0,
projectCount: 0,
authCount: 0,
error: "Checksum mismatch - payload may have been corrupted",
};
}
let globalCount = 0;
let projectCount = 0;
const authCount = payload.providerAuth ? Object.keys(payload.providerAuth).length : 0;
// Apply global settings (shallow merge, local-wins)
if (payload.global) {
// The actual application of global settings is handled by the caller (dashboard route)
// since CentralCore doesn't have access to GlobalSettingsStore.
// We simply count the number of global settings entries for reporting.
globalCount = Object.keys(payload.global).length;
}
// Apply project settings (match by name, local-wins merge)
if (payload.projects) {
const localProjects = await this.listProjects();
const projectsByName = new Map(localProjects.map((p) => [p.name, p]));
for (const [projectName, remoteSettings] of Object.entries(payload.projects)) {
const localProject = projectsByName.get(projectName);
if (localProject) {
// Merge settings: local values take precedence
const mergedSettings: ProjectSettings = {
...remoteSettings,
...localProject.settings,
};
await this.updateProject(localProject.id, { settings: mergedSettings });
projectCount++;
}
}
}
// Provider auth is transported but NOT applied here
// The caller (dashboard route) handles auth application
return {
success: true,
globalCount,
projectCount,
authCount,
};
}
/**
* Get settings sync state between local node and a remote node.
*
* @param remoteNodeId - Remote node ID
* @returns SettingsSyncState or null if no sync has occurred
*/
async getSettingsSyncState(remoteNodeId: string): Promise<SettingsSyncState | null> {
this.ensureInitialized();
const localNode = await this.getLocalNode();
if (!localNode) {
throw new Error("Local node not found");
}
const row = this.db!.prepare(
"SELECT * FROM settingsSyncState WHERE nodeId = ? AND remoteNodeId = ?"
).get(localNode.id, remoteNodeId) as
| {
nodeId: string;
remoteNodeId: string;
lastSyncedAt: string | null;
localChecksum: string | null;
remoteChecksum: string | null;
syncCount: number;
createdAt: string;
updatedAt: string;
}
| undefined;
if (!row) return null;
return this.rowToSettingsSyncState(row);
}
/**
* Update settings sync state between local node and a remote node.
* Creates a new row on first call, updates on subsequent calls.
* Auto-increments syncCount.
*
* @param remoteNodeId - Remote node ID
* @param updates - Fields to update
* @returns Updated SettingsSyncState
*/
async updateSettingsSyncState(
remoteNodeId: string,
updates: Partial<Pick<SettingsSyncState, "lastSyncedAt" | "localChecksum" | "remoteChecksum" | "syncCount">>
): Promise<SettingsSyncState> {
this.ensureInitialized();
const localNode = await this.getLocalNode();
if (!localNode) {
throw new Error("Local node not found");
}
const now = new Date().toISOString();
const existing = await this.getSettingsSyncState(remoteNodeId);
const syncCount = existing ? (updates.syncCount ?? existing.syncCount + 1) : 1;
const lastSyncedAt = updates.lastSyncedAt ?? existing?.lastSyncedAt ?? null;
const localChecksum = updates.localChecksum ?? existing?.localChecksum ?? null;
const remoteChecksum = updates.remoteChecksum ?? existing?.remoteChecksum ?? null;
if (existing) {
// Update existing row
this.db!.prepare(
`UPDATE settingsSyncState SET
lastSyncedAt = ?,
localChecksum = ?,
remoteChecksum = ?,
syncCount = ?,
updatedAt = ?
WHERE nodeId = ? AND remoteNodeId = ?`
).run(lastSyncedAt, localChecksum, remoteChecksum, syncCount, now, localNode.id, remoteNodeId);
} else {
// Insert new row
this.db!.prepare(
`INSERT INTO settingsSyncState (nodeId, remoteNodeId, lastSyncedAt, localChecksum, remoteChecksum, syncCount, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(localNode.id, remoteNodeId, lastSyncedAt, localChecksum, remoteChecksum, syncCount, now, now);
}
this.db!.bumpLastModified();
const updated = await this.getSettingsSyncState(remoteNodeId);
if (!updated) {
throw new Error("Failed to retrieve updated settings sync state");
}
this.emit("settings:sync:completed", {
nodeId: localNode.id,
remoteNodeId,
state: updated,
});
return updated;
}
private rowToSettingsSyncState(row: {
nodeId: string;
remoteNodeId: string;
lastSyncedAt: string | null;
localChecksum: string | null;
remoteChecksum: string | null;
syncCount: number;
createdAt: string;
updatedAt: string;
}): SettingsSyncState {
return {
nodeId: row.nodeId,
remoteNodeId: row.remoteNodeId,
lastSyncedAt: row.lastSyncedAt,
localChecksum: row.localChecksum,
remoteChecksum: row.remoteChecksum,
syncCount: row.syncCount,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
}

View File

@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
it("should initialize schema version", () => {
db.init();
expect(db.getSchemaVersion()).toBe(4);
expect(db.getSchemaVersion()).toBe(5);
});
it("should seed lastModified on init", () => {
@@ -213,7 +213,7 @@ describe("CentralDatabase", () => {
db.init();
expect(db.getSchemaVersion()).toBe(4);
expect(db.getSchemaVersion()).toBe(5);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name);
@@ -278,7 +278,7 @@ describe("CentralDatabase", () => {
db.init();
expect(db.getSchemaVersion()).toBe(4);
expect(db.getSchemaVersion()).toBe(5);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name);

View File

@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
// ── Schema Definition ───────────────────────────────────────────────────
const CENTRAL_SCHEMA_VERSION = 4;
const CENTRAL_SCHEMA_VERSION = 5;
const CENTRAL_SCHEMA_SQL = `
-- Projects table (project registry)
@@ -122,6 +122,21 @@ CREATE TABLE IF NOT EXISTS peerNodes (
);
CREATE INDEX IF NOT EXISTS idxPeerNodesNodeId ON peerNodes(nodeId);
-- Settings sync state tracking
CREATE TABLE IF NOT EXISTS settingsSyncState (
nodeId TEXT NOT NULL,
remoteNodeId TEXT NOT NULL,
lastSyncedAt TEXT,
localChecksum TEXT,
remoteChecksum TEXT,
syncCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (nodeId, remoteNodeId),
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxSettingsSyncNode ON settingsSyncState(nodeId);
-- Schema version tracking
CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
@@ -174,6 +189,22 @@ ALTER TABLE nodes ADD COLUMN versionInfo TEXT;
ALTER TABLE nodes ADD COLUMN pluginVersions TEXT;
`;
const CENTRAL_SCHEMA_V5_MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS settingsSyncState (
nodeId TEXT NOT NULL,
remoteNodeId TEXT NOT NULL,
lastSyncedAt TEXT,
localChecksum TEXT,
remoteChecksum TEXT,
syncCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (nodeId, remoteNodeId),
FOREIGN KEY (nodeId) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxSettingsSyncNode ON settingsSyncState(nodeId);
`;
// ── Central Database Class ────────────────────────────────────────────────
export class CentralDatabase {
@@ -241,6 +272,11 @@ export class CentralDatabase {
migrated = true;
}
if (currentVersion < 5) {
this.db.exec(CENTRAL_SCHEMA_V5_MIGRATION_SQL);
migrated = true;
}
if (migrated) {
this.db
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")

View File

@@ -314,8 +314,12 @@ export type {
PluginSyncEntry,
PluginSyncAction,
ProjectHealth,
ProviderAuthEntry,
/** @deprecated Use RegisteredProject instead */
ProjectInfo,
SettingsSyncPayload,
SettingsSyncState,
SettingsSyncResult,
SystemMetrics,
ProjectStatus,
RegisteredProject,

View File

@@ -1509,6 +1509,8 @@ export interface PeerSyncRequest {
knownPeers: PeerInfo[];
/** ISO timestamp of when this sync request was generated. */
timestamp: string;
/** Optional settings sync payload included in the request. */
settings?: SettingsSyncPayload;
}
/** Response payload returned after a peer sync exchange. */
@@ -1523,6 +1525,73 @@ export interface PeerSyncResponse {
newPeers: PeerInfo[];
/** ISO timestamp of when this response was generated. */
timestamp: string;
/** Optional settings sync payload included in the response. */
settings?: SettingsSyncPayload;
}
/** A single provider's authentication credential for sync transport. */
export interface ProviderAuthEntry {
/** Credential type: "api_key" or "oauth". */
type: "api_key" | "oauth";
/** The API key value (for "api_key" type). Omitted for OAuth providers. */
key?: string;
/** OAuth access token (for "oauth" type). Omitted for API key providers. */
accessToken?: string;
/** Whether this credential has been validated. */
authenticated?: boolean;
}
/** Payload for synchronizing settings and model auth between nodes. */
export interface SettingsSyncPayload {
/** Global settings (user-level preferences, model defaults). */
global?: GlobalSettings;
/** Map of project name → project settings for projects on this node.
* Keyed by project name (not ID or path) since node paths differ. */
projects?: Record<string, ProjectSettings>;
/** Model provider auth credentials. Keys are provider IDs (e.g., "anthropic", "openai").
* Values contain the credential type and key. Only transmitted over authenticated
* node connections. */
providerAuth?: Record<string, ProviderAuthEntry>;
/** ISO timestamp when this snapshot was generated. */
exportedAt: string;
/** Checksum of the settings data for change detection (SHA-256 hex of JSON). */
checksum: string;
/** Version of the sync payload format. */
version: 1;
}
/** Tracks settings sync state between the local node and a remote node. */
export interface SettingsSyncState {
/** Local node ID. */
nodeId: string;
/** Remote node ID. */
remoteNodeId: string;
/** ISO timestamp of the last successful settings sync. */
lastSyncedAt: string | null;
/** Checksum of local settings at last sync (for change detection). */
localChecksum: string | null;
/** Checksum of remote settings at last sync. */
remoteChecksum: string | null;
/** Number of settings syncs performed. */
syncCount: number;
/** ISO timestamp of creation. */
createdAt: string;
/** ISO timestamp of last update. */
updatedAt: string;
}
/** Result of a settings sync exchange. */
export interface SettingsSyncResult {
/** Number of global settings applied. */
globalCount: number;
/** Number of project settings applied. */
projectCount: number;
/** Number of provider auth entries synced. */
authCount: number;
/** Whether the sync was successful. */
success: boolean;
/** Error message if sync failed. */
error?: string;
}
/** A runtime node that can host project execution (local machine or remote host) */