FN-6208: sync workflow settings across nodes

Synchronize workflow setting values through node settings sync.

- Include workflow settings in settings payloads, diffs, status responses, and API types.
- Apply inbound and pulled workflow settings through the TaskStore while ignoring rejected setting ids.
- Add core, route, hook, API, and Nodes view coverage for workflow settings sync behavior.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/loud-nodes-sync.md                      |   5 +
 packages/core/src/__tests__/central-core.test.ts   |  37 ++++
 packages/core/src/central-core.ts                  |  13 +-
 packages/core/src/types.ts                         |   4 +
 packages/dashboard/app/__tests__/api-node.test.ts  |   4 +-
 packages/dashboard/app/api-node.ts                 |   3 +
 .../app/components/__tests__/NodesView.test.tsx    |   8 +-
 .../hooks/__tests__/useNodeSettingsSync.test.ts    |  33 +++-
 .../dashboard/app/hooks/useNodeSettingsSync.ts     |   4 +-
 .../__tests__/routes-nodes-sync-contract.test.ts   |  18 +-
 .../src/__tests__/routes-nodes-sync.test.ts        | 191 ++++++++++++++++++++-
 .../src/routes/register-settings-memory-routes.ts  |   7 +-
 .../register-settings-sync-inbound-routes.ts       |  73 +++++++-
 .../src/routes/register-settings-sync-routes.ts    | 128 ++++++++++++--
 14 files changed, 488 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-6208

Fusion-Task-Lineage: 9b5e7e56-6287-4bc5-966a-2bcc2ba292a7
This commit is contained in:
gsxdsm
2026-06-10 23:19:09 -07:00
parent 648d3598e0
commit 07d5262a83
14 changed files with 488 additions and 40 deletions

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { CentralCore } from "../central-core.js";
import { NodeDiscovery } from "../node-discovery.js";
import { NodeConnection, type ConnectionResult } from "../node-connection.js";
@@ -2875,6 +2876,7 @@ describe("CentralCore", () => {
expect(result.globalCount).toBe(1);
expect(result.projectCount).toBe(0);
expect(result.authCount).toBe(0);
expect(result.workflowSettingsCount).toBe(0);
expect(result.error).toBeUndefined();
});
@@ -2888,6 +2890,7 @@ describe("CentralCore", () => {
const result = await central.applyRemoteSettings(payload);
expect(result.success).toBe(false);
expect(result.workflowSettingsCount).toBe(0);
expect(result.error).toContain("Unsupported settings sync version");
});
@@ -2901,6 +2904,7 @@ describe("CentralCore", () => {
const result = await central.applyRemoteSettings(payload);
expect(result.success).toBe(false);
expect(result.workflowSettingsCount).toBe(0);
expect(result.error).toContain("Checksum mismatch");
});
@@ -2958,9 +2962,41 @@ describe("CentralCore", () => {
expect(result.success).toBe(true);
expect(result.authCount).toBe(2); // Both entries counted
expect(result.workflowSettingsCount).toBe(0);
// Auth is not applied - that's the caller's responsibility
});
it("should accept payloads with workflowSettings without applying them in CentralCore", async () => {
const payloadWithoutChecksum = {
global: { themeMode: "dark" as const },
workflowSettings: { "builtin:coding": { workflowStepTimeoutMs: 240000 } },
exportedAt: new Date().toISOString(),
version: 1 as const,
};
const checksum = createHash("sha256").update(JSON.stringify(payloadWithoutChecksum)).digest("hex");
const result = await central.applyRemoteSettings({ ...payloadWithoutChecksum, checksum });
expect(result.success).toBe(true);
expect(result.globalCount).toBe(1);
expect(result.workflowSettingsCount).toBe(0);
});
it("should handle payloads without workflowSettings gracefully", async () => {
const payloadWithoutChecksum = {
global: { themeMode: "dark" as const },
exportedAt: new Date().toISOString(),
version: 1 as const,
};
const checksum = createHash("sha256").update(JSON.stringify(payloadWithoutChecksum)).digest("hex");
const result = await central.applyRemoteSettings({ ...payloadWithoutChecksum, checksum });
expect(result.success).toBe(true);
expect(result.globalCount).toBe(1);
expect(result.workflowSettingsCount).toBe(0);
});
it("should handle empty payload gracefully", async () => {
// Create an empty but valid payload using getSettingsForSync
const emptyPayload = await central.getSettingsForSync({});
@@ -2971,6 +3007,7 @@ describe("CentralCore", () => {
expect(result.globalCount).toBeGreaterThanOrEqual(0);
expect(result.projectCount).toBe(0);
expect(result.authCount).toBe(0);
expect(result.workflowSettingsCount).toBe(0);
});
});

View File

@@ -3644,6 +3644,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
globalCount: 0,
projectCount: 0,
authCount: 0,
workflowSettingsCount: 0,
error: `Unsupported settings sync version: ${payload.version}`,
};
}
@@ -3653,6 +3654,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
global: payload.global,
projects: payload.projects,
providerAuth: payload.providerAuth,
workflowSettings: payload.workflowSettings,
exportedAt: payload.exportedAt,
version: payload.version,
};
@@ -3666,6 +3668,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
globalCount: 0,
projectCount: 0,
authCount: 0,
workflowSettingsCount: 0,
error: "Checksum mismatch - payload may have been corrupted",
};
}
@@ -3713,14 +3716,20 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
}
}
// Provider auth is transported but NOT applied here
// The caller (dashboard route) handles auth application
// Provider auth is transported but NOT applied here.
// Workflow setting values are also transported in the checksum-protected
// payload but NOT applied here; dashboard sync routes write them through
// TaskStore so validation, project scoping, and cache/listener behavior stay
// consistent. Cross-node settings sync expects both peers to run the same
// payload shape: a new node sending workflowSettings to a pre-FN-6208 node
// can checksum-mismatch, which is acceptable for mixed-version peers.
return {
success: true,
globalCount,
projectCount,
authCount,
workflowSettingsCount: 0,
};
}

View File

@@ -4781,6 +4781,8 @@ export interface SettingsSyncPayload {
* Values contain the credential type and key. Only transmitted over authenticated
* node connections. */
providerAuth?: Record<string, ProviderAuthEntry>;
/** Per-project workflow setting values keyed `workflowId → { settingKey: value }`. */
workflowSettings?: Record<string, Record<string, unknown>>;
/** ISO timestamp when this snapshot was generated. */
exportedAt: string;
/** Checksum of the settings data for change detection (SHA-256 hex of JSON). */
@@ -4817,6 +4819,8 @@ export interface SettingsSyncResult {
projectCount: number;
/** Number of provider auth entries synced. */
authCount: number;
/** Number of workflow setting values applied by the caller. */
workflowSettingsCount: number;
/** Whether the sync was successful. */
success: boolean;
/** Error message if sync failed. */

View File

@@ -405,7 +405,7 @@ describe("api-node", () => {
lastSyncDirection: "sync",
localUpdatedAt: "2026-04-01T00:00:00.000Z",
remoteReachable: true,
diff: { global: ["theme"], project: [] },
diff: { global: ["theme"], project: [], workflowSettings: {} },
};
mockApi.mockResolvedValueOnce(mockStatus);
@@ -422,7 +422,7 @@ describe("api-node", () => {
lastSyncDirection: null,
localUpdatedAt: "2026-04-01T00:00:00.000Z",
remoteReachable: false,
diff: { global: [], project: [] },
diff: { global: [], project: [], workflowSettings: {} },
});
await fetchNodeSettingsSyncStatus("node/abc+def");

View File

@@ -58,6 +58,7 @@ export async function fetchRemoteNodeProjectHealth(
export interface NodeSettingsScopes {
global: Record<string, unknown>;
project: Record<string, unknown>;
workflowSettings?: Record<string, Record<string, unknown>>;
}
/** Result from settings push/pull operations */
@@ -66,6 +67,7 @@ export interface NodeSettingsSyncResult {
syncedFields?: string[];
appliedFields?: string[];
skippedFields?: string[];
workflowSettingsCount?: number;
error?: string;
}
@@ -78,6 +80,7 @@ export interface NodeSettingsSyncStatus {
diff: {
global: string[];
project: string[];
workflowSettings: Record<string, string[]>;
};
/** Overall auth credential sync state: "match" if credentials match between local and remote,
* "differs" if they differ, "not-synced" if auth sync has never been performed. */

View File

@@ -516,7 +516,7 @@ describe("NodesView", () => {
lastSyncDirection: "push",
localUpdatedAt: new Date().toISOString(),
remoteReachable: true,
diff: { global: [], project: [] },
diff: { global: [], project: [], workflowSettings: {} },
};
mockUseNodeSettingsSync.mockReturnValue({
@@ -554,7 +554,7 @@ describe("NodesView", () => {
lastSyncDirection: "push",
localUpdatedAt: new Date().toISOString(),
remoteReachable: true,
diff: { global: ["theme"], project: [] },
diff: { global: ["theme"], project: [], workflowSettings: {} },
};
mockUseNodeSettingsSync.mockReturnValue({
@@ -591,7 +591,7 @@ describe("NodesView", () => {
lastSyncDirection: "push",
localUpdatedAt: new Date().toISOString(),
remoteReachable: true,
diff: { global: [], project: [] },
diff: { global: [], project: [], workflowSettings: {} },
};
mockUseNodeSettingsSync.mockReturnValue({
@@ -630,7 +630,7 @@ describe("NodesView", () => {
lastSyncDirection: "push",
localUpdatedAt: new Date().toISOString(),
remoteReachable: true,
diff: { global: [], project: [] },
diff: { global: [], project: [], workflowSettings: {} },
};
mockUseNodeSettingsSync.mockReturnValue({

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useNodeSettingsSync } from "../useNodeSettingsSync";
import { computeSyncState, useNodeSettingsSync } from "../useNodeSettingsSync";
import * as apiNode from "../../api-node";
import type { NodeSettingsSyncStatus, NodeSettingsSyncResult, NodeAuthSyncResult } from "../../api-node";
@@ -22,7 +22,7 @@ function makeSyncStatus(overrides: Partial<NodeSettingsSyncStatus> = {}): NodeSe
lastSyncDirection: "sync",
localUpdatedAt: "2026-04-01T00:00:00.000Z",
remoteReachable: true,
diff: { global: [], project: [] },
diff: { global: [], project: [], workflowSettings: {} },
...overrides,
};
}
@@ -42,6 +42,35 @@ async function flushPromises(): Promise<void> {
await Promise.resolve();
}
describe("computeSyncState", () => {
it("counts workflow setting diffs in the derived diff count", () => {
const status = makeSyncStatus({
diff: {
global: ["theme"],
project: ["maxConcurrent"],
workflowSettings: {
"builtin:coding": ["workflowStepTimeoutMs", "reviewModel"],
"WF-123": ["executionModel"],
},
},
});
expect(computeSyncState(status)).toEqual({
syncState: "diff",
lastSyncAt: "2026-04-01T00:00:00.000Z",
diffCount: 5,
});
});
it("treats an empty workflow settings diff as synced", () => {
expect(computeSyncState(makeSyncStatus())).toEqual({
syncState: "synced",
lastSyncAt: "2026-04-01T00:00:00.000Z",
diffCount: 0,
});
});
});
describe("useNodeSettingsSync", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });

View File

@@ -32,7 +32,9 @@ export interface ComputedNodeSyncStatus {
*/
export function computeSyncState(status: NodeSettingsSyncStatus): ComputedNodeSyncStatus {
const { lastSyncAt, remoteReachable, diff } = status;
const diffCount = diff.global.length + diff.project.length;
const workflowDiffCount = Object.values(diff.workflowSettings ?? {})
.reduce((total, keys) => total + keys.length, 0);
const diffCount = diff.global.length + diff.project.length + workflowDiffCount;
if (lastSyncAt === null) {
return { syncState: "never-synced", lastSyncAt, diffCount: 0 };

View File

@@ -113,6 +113,18 @@ class MockStore extends EventEmitter {
},
};
}
listWorkflowSettingValuesForProject(): Record<string, Record<string, unknown>> {
return {};
}
getWorkflowSettingsProjectId(): string {
return "project-local-001";
}
async updateWorkflowSettingValues(_workflowId: string, _projectId: string, patch: Record<string, unknown>) {
return patch;
}
}
function createMockRemoteNode(overrides: Record<string, unknown> = {}) {
@@ -192,7 +204,7 @@ describe("Node settings/auth sync contract matrix", () => {
mockGetLocalPeerInfo.mockResolvedValue({ nodeId: "node-local-001", nodeName: "Local Node" });
mockGetSettingsSyncState.mockResolvedValue(null);
mockUpdateSettingsSyncState.mockResolvedValue({});
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0, workflowSettingsCount: 0 });
mockGetSettingsForSync.mockResolvedValue({});
mockGetAuthMaterialSnapshot.mockReturnValue({
version: 1,
@@ -294,7 +306,7 @@ describe("Node settings/auth sync contract matrix", () => {
expect(res.status).toBe(200);
expect(res.body.remoteReachable).toBe(false);
expect(res.body.diff).toEqual({ global: [], project: [] });
expect(res.body.diff).toEqual({ global: [], project: [], workflowSettings: {} });
expect(mockFetch).not.toHaveBeenCalled();
});
@@ -306,7 +318,7 @@ describe("Node settings/auth sync contract matrix", () => {
expect(res.status).toBe(200);
expect(res.body.remoteReachable).toBe(false);
expect(res.body.diff).toEqual({ global: [], project: [] });
expect(res.body.diff).toEqual({ global: [], project: [], workflowSettings: {} });
expect(mockFetch).not.toHaveBeenCalled();
});

View File

@@ -4,6 +4,7 @@ import { request, get } from "../test-request.js";
import { createServer } from "../server.js";
import { resetRuntimeLogSink, setRuntimeLogSink, type RuntimeLogContext } from "../runtime-logger.js";
import { MISSING_REMOTE_NODE_API_KEY_MESSAGE } from "../routes/register-settings-sync-helpers.js";
import { computeSettingsDiff } from "../routes/register-settings-sync-routes.js";
import { MOVED_SETTINGS_KEYS } from "@fusion/core";
// Mock node:fs for auth.json reading
@@ -36,6 +37,7 @@ const mockGetSettingsForSync = vi.fn();
const mockGetAuthMaterialSnapshot = vi.fn();
const mockApplyAuthMaterialSnapshot = vi.fn();
const mockStoreUpdateGlobalSettings = vi.fn().mockResolvedValue({});
const mockUpdateWorkflowSettingValues = vi.fn().mockResolvedValue({});
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null);
@@ -90,6 +92,10 @@ vi.mock("@earendil-works/pi-coding-agent", () => {
// ── Mock Store ────────────────────────────────────────────────────────
class MockStore extends EventEmitter {
workflowSettings: Record<string, Record<string, unknown>> = {
"builtin:coding": { workflowStepTimeoutMs: 120000 },
};
getRootDir(): string {
return "/tmp/fn-1821-test";
}
@@ -127,6 +133,18 @@ class MockStore extends EventEmitter {
async updateGlobalSettings(patch: Record<string, unknown>) {
return mockStoreUpdateGlobalSettings(patch);
}
listWorkflowSettingValuesForProject(): Record<string, Record<string, unknown>> {
return this.workflowSettings;
}
getWorkflowSettingsProjectId(): string {
return "project-local-001";
}
async updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record<string, unknown>) {
return mockUpdateWorkflowSettingValues(workflowId, projectId, patch);
}
}
// ── Test helpers ──────────────────────────────────────────────────────
@@ -163,6 +181,36 @@ function createMockLocalNode(overrides: Record<string, unknown> = {}) {
// ── Tests ─────────────────────────────────────────────────────────────
describe("computeSettingsDiff", () => {
it("diffs workflow settings per workflow while filtering moved flat keys", () => {
const movedKey = MOVED_SETTINGS_KEYS[0];
const diff = computeSettingsDiff(
{
global: { defaultProvider: "openai", [movedKey]: "remote" },
project: { maxConcurrent: 3, [movedKey]: 123 },
workflowSettings: {
"builtin:coding": { workflowStepTimeoutMs: 120000, reviewModel: "claude" },
"WF-remote": { executionModel: "gpt-5" },
},
},
{ defaultProvider: "anthropic", [movedKey]: "local" },
{ maxConcurrent: 2, [movedKey]: 456 },
{
"builtin:coding": { workflowStepTimeoutMs: 120000, reviewModel: "gpt-4" },
"WF-local": { executionModel: "claude" },
},
);
expect(diff.global).toEqual(["defaultProvider"]);
expect(diff.project).toEqual(["maxConcurrent"]);
expect(diff.workflowSettings).toEqual({
"builtin:coding": ["reviewModel"],
"WF-remote": ["executionModel"],
"WF-local": ["executionModel"],
});
});
});
interface RuntimeEvent {
level: "info" | "warn" | "error";
scope: string;
@@ -185,8 +233,9 @@ describe("Node settings sync routes", () => {
mockGetLocalPeerInfo.mockResolvedValue({ nodeId: "node-local-001", nodeName: "Local Node" });
mockGetSettingsSyncState.mockResolvedValue(null);
mockUpdateSettingsSyncState.mockResolvedValue({});
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0, workflowSettingsCount: 0 });
mockStoreUpdateGlobalSettings.mockReset();
mockUpdateWorkflowSettingValues.mockReset().mockResolvedValue({});
mockGetSettingsForSync.mockResolvedValue({});
mockGetAuthMaterialSnapshot.mockReturnValue({
version: 1,
@@ -312,6 +361,11 @@ describe("Node settings sync routes", () => {
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.syncedFields).toContain("defaultProvider");
expect(res.body.syncedFields).toContain("workflowStepTimeoutMs");
const [, pushOptions] = mockFetch.mock.calls[0] as [string, { body?: string }];
expect(JSON.parse(pushOptions.body ?? "{}").workflowSettings).toEqual({
"builtin:coding": { workflowStepTimeoutMs: 120000 },
});
expect(mockFetch).toHaveBeenCalledWith(
"http://192.168.1.100:3001/api/settings/sync-receive",
expect.objectContaining({
@@ -420,6 +474,36 @@ describe("Node settings sync routes", () => {
expect(mockApplyRemoteSettings).toHaveBeenCalled();
});
it("applies remote workflow settings locally with last-write-wins", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
global: { defaultProvider: "openai" },
project: { maxConcurrent: 3 },
workflowSettings: { "builtin:coding": { workflowStepTimeoutMs: 240000 } },
}),
});
const res = await request(
app,
"POST",
"/api/nodes/node-remote-001/settings/pull",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.appliedFields).toContain("workflowStepTimeoutMs");
expect(res.body.workflowSettingsCount).toBe(1);
expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith(
"builtin:coding",
"project-local-001",
{ workflowStepTimeoutMs: 240000 },
);
});
it("returns diff without applying when conflictResolution is manual", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
@@ -441,8 +525,13 @@ describe("Node settings sync routes", () => {
expect(res.status).toBe(200);
expect(res.body.diff).toBeDefined();
expect(res.body.diff.workflowSettings).toEqual({
"builtin:coding": ["workflowStepTimeoutMs"],
});
expect(res.body.remoteSettings).toBeDefined();
expect(res.body.localSettings).toBeDefined();
expect(res.body.localSettings.workflowSettings).toEqual({
"builtin:coding": { workflowStepTimeoutMs: 120000 },
});
expect(mockApplyRemoteSettings).not.toHaveBeenCalled();
});
@@ -716,6 +805,9 @@ describe("Node settings sync routes", () => {
expect(res.body.lastSyncAt).toBe("2026-04-14T10:00:00.000Z");
expect(res.body.remoteReachable).toBe(true);
expect(res.body.diff).toBeDefined();
expect(res.body.diff.workflowSettings).toEqual({
"builtin:coding": ["workflowStepTimeoutMs"],
});
});
it("returns remoteReachable false with empty diff when remote is down", async () => {
@@ -1077,9 +1169,92 @@ describe("Node settings sync routes", () => {
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.workflowSettingsCount).toBe(0);
expect(mockApplyRemoteSettings).toHaveBeenCalled();
});
it("applies inbound workflow settings through the workflow settings write path", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
mockApplyRemoteSettings.mockResolvedValue({
success: true,
globalCount: 0,
projectCount: 0,
authCount: 0,
});
const res = await request(
app,
"POST",
"/api/settings/sync-receive",
JSON.stringify({
sourceNodeId: "node-remote-001",
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "abc123",
version: 1,
workflowSettings: { "builtin:coding": { workflowStepTimeoutMs: 240000 } },
}),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.appliedFields).toContain("workflowStepTimeoutMs");
expect(res.body.workflowSettingsCount).toBe(1);
expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith(
"builtin:coding",
"project-local-001",
{ workflowStepTimeoutMs: 240000 },
);
});
it("drops invalid inbound workflow settings without failing the sync", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
mockApplyRemoteSettings.mockResolvedValue({
success: true,
globalCount: 0,
projectCount: 0,
authCount: 0,
});
mockUpdateWorkflowSettingValues
.mockRejectedValueOnce(Object.assign(new Error("bad setting"), {
rejections: [{ settingId: "invalidSetting" }],
}))
.mockResolvedValueOnce({});
const res = await request(
app,
"POST",
"/api/settings/sync-receive",
JSON.stringify({
sourceNodeId: "node-remote-001",
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "abc123",
version: 1,
workflowSettings: {
"builtin:coding": {
workflowStepTimeoutMs: 240000,
invalidSetting: "bad",
},
},
}),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.appliedFields).toContain("workflowStepTimeoutMs");
expect(res.body.appliedFields).not.toContain("invalidSetting");
expect(res.body.workflowSettingsCount).toBe(1);
expect(mockUpdateWorkflowSettingValues).toHaveBeenNthCalledWith(
2,
"builtin:coding",
"project-local-001",
{ workflowStepTimeoutMs: 240000 },
);
});
it("applies inbound global settings via store.updateGlobalSettings when local values are unset", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
@@ -1559,6 +1734,7 @@ describe("Node settings sync routes", () => {
expect(postedBody).toEqual(expect.objectContaining({
global: expect.any(Object),
projects: expect.any(Object),
workflowSettings: { "builtin:coding": { workflowStepTimeoutMs: 120000 } },
exportedAt: expect.any(String),
version: 1,
checksum: expect.any(String),
@@ -1571,6 +1747,7 @@ describe("Node settings sync routes", () => {
.update(JSON.stringify({
global: postedBody.global,
projects: postedBody.projects,
workflowSettings: postedBody.workflowSettings,
exportedAt: postedBody.exportedAt,
version: postedBody.version,
}))
@@ -1638,7 +1815,7 @@ describe("Node settings sync routes", () => {
expect(res.body.error).toBe(MISSING_REMOTE_NODE_API_KEY_MESSAGE);
} else {
expect(res.body.remoteReachable).toBe(false);
expect(res.body.diff).toEqual({ global: [], project: [] });
expect(res.body.diff).toEqual({ global: [], project: [], workflowSettings: {} });
}
expect(mockFetch).not.toHaveBeenCalled();
});
@@ -1704,7 +1881,7 @@ describe("Node settings sync routes", () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0, workflowSettingsCount: 0 });
const inboundRes = await request(
app,
@@ -1729,7 +1906,7 @@ describe("Node settings sync routes", () => {
project: { defaultProvider: "openai" },
};
mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(remotePayload) });
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0, workflowSettingsCount: 0 });
const res = await request(
app,
@@ -1974,7 +2151,7 @@ describe("Node settings sync routes", () => {
expect(res.status).toBe(200);
expect(res.body.remoteReachable).toBe(false);
expect(res.body.actionableDenialReason).toBe("missing-remote-api-key");
expect(res.body.diff).toEqual({ global: [], project: [] });
expect(res.body.diff).toEqual({ global: [], project: [], workflowSettings: {} });
expect(mockFetch).not.toHaveBeenCalled();
});
@@ -2233,7 +2410,7 @@ describe("Node settings sync routes", () => {
it("accepts POST /api/settings/sync-receive with correct bearer and applies remote settings", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0, workflowSettingsCount: 0 });
const payload = { sourceNodeId: "node-remote-001", exportedAt: "2026-05-17T00:00:00.000Z", global: { theme: "dark" }, projects: { kb: { model: "gpt-5" } } };
const res = await request(

View File

@@ -1940,14 +1940,17 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
/**
* GET /api/settings/scopes
* Returns settings separated by scope: { global, project }.
* Returns settings separated by scope: { global, project, workflowSettings }.
* Useful for the UI to show which scope each setting comes from.
*/
router.get("/settings/scopes", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const scopes = await scopedStore.getSettingsByScopeFast();
res.json(scopes);
res.json({
...scopes,
workflowSettings: scopedStore.listWorkflowSettingValuesForProject(),
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;

View File

@@ -5,6 +5,59 @@ import { getFusionAuthPath } from "../auth-paths.js";
import { readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.js";
type WorkflowSettingsSyncSection = Record<string, Record<string, unknown>>;
type WorkflowSettingsSyncStore = {
getWorkflowSettingsProjectId(): string;
updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
};
function extractRejectedSettingIds(err: unknown): string[] {
if (!err || typeof err !== "object") return [];
const rejections = (err as { rejections?: unknown }).rejections;
if (!Array.isArray(rejections)) return [];
const ids: string[] = [];
for (const rejection of rejections) {
if (rejection && typeof rejection === "object" && typeof (rejection as { settingId?: unknown }).settingId === "string") {
ids.push((rejection as { settingId: string }).settingId);
}
}
return ids;
}
async function applyWorkflowSettingsSection(
store: WorkflowSettingsSyncStore,
section: WorkflowSettingsSyncSection,
): Promise<{ count: number; keys: string[] }> {
const projectId = store.getWorkflowSettingsProjectId();
let count = 0;
const keys: string[] = [];
for (const [workflowId, rawValues] of Object.entries(section)) {
if (!rawValues || typeof rawValues !== "object" || Array.isArray(rawValues)) continue;
const patch: Record<string, unknown> = { ...rawValues };
while (Object.keys(patch).length > 0) {
try {
await store.updateWorkflowSettingValues(workflowId, projectId, patch);
const appliedKeys = Object.entries(patch)
.filter(([, value]) => value !== null)
.map(([key]) => key);
count += appliedKeys.length;
keys.push(...appliedKeys);
break;
} catch (err) {
const rejectedIds = extractRejectedSettingIds(err);
if (rejectedIds.length === 0) break;
for (const settingId of rejectedIds) {
delete patch[settingId];
}
}
}
}
return { count, keys };
}
export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
const { router, store, emitAuthSyncAuditLog, rethrowAsApiError } = ctx;
@@ -82,13 +135,24 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
}
}
let workflowSettingsCount = 0;
let appliedWorkflowSettingKeys: string[] = [];
if (result.success && payload.workflowSettings && typeof payload.workflowSettings === "object" && !Array.isArray(payload.workflowSettings)) {
const workflowApplyResult = await applyWorkflowSettingsSection(store, payload.workflowSettings as WorkflowSettingsSyncSection);
workflowSettingsCount = workflowApplyResult.count;
appliedWorkflowSettingKeys = workflowApplyResult.keys;
}
// Build applied/skipped field lists. Moved keys are excluded so the reported
// applied set matches what actually persisted (the store + applyRemoteSettings
// both drop them).
// both drop them). Workflow setting values sync in their own section.
const appliedFields = [
...Object.keys(payload.global || {}),
...Object.keys(payload.projects || {}),
].filter((key) => !isMovedSettingsKey(key));
...[
...Object.keys(payload.global || {}),
...Object.keys(payload.projects || {}),
].filter((key) => !isMovedSettingsKey(key)),
...appliedWorkflowSettingKeys,
];
const skippedFields = result.error ? appliedFields : [];
await central.close();
@@ -97,6 +161,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
success: result.success,
appliedFields,
skippedFields,
workflowSettingsCount,
error: result.error,
});
} catch (err: unknown) {

View File

@@ -13,14 +13,79 @@ import {
} from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.js";
function computeSettingsDiff(
remoteSettings: { global?: Record<string, unknown>; project?: Record<string, unknown> },
type WorkflowSettingsSyncSection = Record<string, Record<string, unknown>>;
type WorkflowSettingsSyncStore = {
getWorkflowSettingsProjectId(): string;
updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
};
export type SettingsDiff = {
global: string[];
project: string[];
workflowSettings: Record<string, string[]>;
};
function extractRejectedSettingIds(err: unknown): string[] {
if (!err || typeof err !== "object") return [];
const rejections = (err as { rejections?: unknown }).rejections;
if (!Array.isArray(rejections)) return [];
const ids: string[] = [];
for (const rejection of rejections) {
if (rejection && typeof rejection === "object" && typeof (rejection as { settingId?: unknown }).settingId === "string") {
ids.push((rejection as { settingId: string }).settingId);
}
}
return ids;
}
async function applyWorkflowSettingsSection(
store: WorkflowSettingsSyncStore,
section: WorkflowSettingsSyncSection | undefined,
): Promise<{ count: number; keys: string[] }> {
if (!section) return { count: 0, keys: [] };
const projectId = store.getWorkflowSettingsProjectId();
let count = 0;
const keys: string[] = [];
for (const [workflowId, rawValues] of Object.entries(section)) {
if (!rawValues || typeof rawValues !== "object" || Array.isArray(rawValues)) continue;
const patch: Record<string, unknown> = { ...rawValues };
while (Object.keys(patch).length > 0) {
try {
await store.updateWorkflowSettingValues(workflowId, projectId, patch);
const appliedKeys = Object.entries(patch)
.filter(([, value]) => value !== null)
.map(([key]) => key);
count += appliedKeys.length;
keys.push(...appliedKeys);
break;
} catch (err) {
const rejectedIds = extractRejectedSettingIds(err);
if (rejectedIds.length === 0) break;
for (const settingId of rejectedIds) {
delete patch[settingId];
}
}
}
}
return { count, keys };
}
export function computeSettingsDiff(
remoteSettings: {
global?: Record<string, unknown>;
project?: Record<string, unknown>;
workflowSettings?: Record<string, Record<string, unknown>>;
},
localGlobalSettings: Record<string, unknown>,
localProjectSettings: Record<string, unknown>,
): { global: string[]; project: string[] } {
// Moved (tombstoned) keys are excluded from the diff entirely (KTD-8): workflow
// settings are not synced across nodes yet, so they must never appear in a
// diff/push/pull field list — even if a mid-migration peer still carries them.
localWorkflowSettings?: Record<string, Record<string, unknown>>,
): SettingsDiff {
// Moved (tombstoned) keys are excluded from the global/project diff entirely
// (KTD-8): workflow settings now sync in a dedicated payload section, and a
// mid-migration peer's stale flat values must never reappear in global/project
// diff/push/pull field lists.
const globalKeys = Array.from(new Set([
...Object.keys(remoteSettings.global ?? {}),
...Object.keys(localGlobalSettings ?? {}),
@@ -30,9 +95,30 @@ function computeSettingsDiff(
...Object.keys(localProjectSettings ?? {}),
])).filter((key) => !isMovedSettingsKey(key));
const remoteWorkflowSettings = remoteSettings.workflowSettings ?? {};
const localWorkflowValues = localWorkflowSettings ?? {};
const workflowSettings: Record<string, string[]> = {};
const workflowIds = Array.from(new Set([
...Object.keys(remoteWorkflowSettings),
...Object.keys(localWorkflowValues),
]));
for (const workflowId of workflowIds) {
const remoteValues = remoteWorkflowSettings[workflowId] ?? {};
const localValues = localWorkflowValues[workflowId] ?? {};
const settingKeys = Array.from(new Set([
...Object.keys(remoteValues),
...Object.keys(localValues),
]));
const differingKeys = settingKeys.filter((key) => JSON.stringify(remoteValues[key]) !== JSON.stringify(localValues[key]));
if (differingKeys.length > 0) {
workflowSettings[workflowId] = differingKeys;
}
}
return {
global: globalKeys.filter((key) => JSON.stringify(remoteSettings.global?.[key]) !== JSON.stringify(localGlobalSettings[key])),
project: projectKeys.filter((key) => JSON.stringify(remoteSettings.project?.[key]) !== JSON.stringify(localProjectSettings[key])),
workflowSettings,
};
}
@@ -102,18 +188,20 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
// Get local global settings
const globalSettingsStore = store.getGlobalSettingsStore();
const globalSettings = await globalSettingsStore.getSettings();
const workflowSettings = store.listWorkflowSettingValuesForProject();
// Build sync payload
const payloadWithoutChecksum = {
global: globalSettings,
projects: { [basename(store.getRootDir())]: projectSettings.project },
workflowSettings,
exportedAt: new Date().toISOString(),
version: 1 as const,
};
// Compute checksum over the canonical settings payload shape only.
// Do not include sourceNodeId in this hash; applyRemoteSettings() validates
// checksums against { global, projects, exportedAt, version }.
// checksums against the payload fields, including workflowSettings when present.
const { createHash } = await import("node:crypto");
const checksum = createHash("sha256").update(JSON.stringify(payloadWithoutChecksum)).digest("hex");
const localPeerInfo = await central.getLocalPeerInfo();
@@ -140,6 +228,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
const syncedFields = [
...Object.keys(globalSettings),
...Object.keys(projectSettings.project),
...Object.values(workflowSettings).flatMap((values) => Object.keys(values)),
];
res.json({ success: true, syncedFields });
@@ -185,6 +274,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
const remoteSettings = await fetchFromRemoteNode(node, "/api/settings/scopes") as {
global: Record<string, unknown>;
project: Record<string, unknown>;
workflowSettings?: WorkflowSettingsSyncSection;
};
if (conflictResolution === "manual") {
@@ -196,20 +286,22 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
// Get local settings for diff comparison
const localProjectSettings = await store.getSettingsByScope();
const localGlobalSettings = await store.getGlobalSettingsStore().getSettings();
const localWorkflowSettings = store.listWorkflowSettingValuesForProject();
// Compute diff: field names that differ between local and remote
const { global: diffGlobal, project: diffProject } = computeSettingsDiff(
const { global: diffGlobal, project: diffProject, workflowSettings: diffWorkflowSettings } = computeSettingsDiff(
remoteSettings,
localGlobalSettings as Record<string, unknown>,
localProjectSettings.project as Record<string, unknown>,
localWorkflowSettings,
);
await central.close();
res.json({
diff: { global: diffGlobal, project: diffProject },
diff: { global: diffGlobal, project: diffProject, workflowSettings: diffWorkflowSettings },
remoteSettings,
localSettings: { global: localGlobalSettings, project: localProjectSettings.project },
localSettings: { global: localGlobalSettings, project: localProjectSettings.project, workflowSettings: localWorkflowSettings },
});
return;
}
@@ -221,6 +313,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
const payloadWithoutChecksum = {
global: remoteSettings.global,
projects: remoteSettings.project as Record<string, ProjectSettings>,
workflowSettings: remoteSettings.workflowSettings,
exportedAt,
version: 1 as const,
};
@@ -230,6 +323,9 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
...payloadWithoutChecksum,
checksum,
});
const workflowApplyResult = result.success
? await applyWorkflowSettingsSection(store, remoteSettings.workflowSettings)
: { count: 0, keys: [] };
// Record sync
await central.updateSettingsSyncState(node.id, {
@@ -243,6 +339,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
const appliedFields = [
...Object.keys(remoteSettings.global || {}),
...Object.keys(remoteSettings.project || {}),
...workflowApplyResult.keys,
];
const skippedFields = result.error ? Object.keys(remoteSettings.global || {}) : [];
@@ -250,6 +347,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
success: result.success,
appliedFields,
skippedFields,
workflowSettingsCount: workflowApplyResult.count,
error: result.error,
});
} catch (err: unknown) {
@@ -268,7 +366,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
* lastSyncDirection: string | null,
* localUpdatedAt: string,
* remoteReachable: boolean,
* diff: { global: string[], project: string[] }
* diff: { global: string[], project: string[], workflowSettings: Record<string, string[]> }
* }
*/
router.get("/nodes/:id/settings/sync-status", async (req, res) => {
@@ -297,15 +395,17 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
// Try to fetch remote settings
let remoteReachable = false;
let remoteSettings: { global: Record<string, unknown>; project: Record<string, unknown> } | null = null;
let remoteSettings: { global: Record<string, unknown>; project: Record<string, unknown>; workflowSettings?: WorkflowSettingsSyncSection } | null = null;
let diffGlobal: string[] = [];
let diffProject: string[] = [];
let diffWorkflowSettings: Record<string, string[]> = {};
let denialReason: SyncStatusDenialReason | null = null; // FN-4847: stable, non-leaking denial classification for degraded probes.
try {
remoteSettings = await fetchFromRemoteNode(node, "/api/settings/scopes") as {
global: Record<string, unknown>;
project: Record<string, unknown>;
workflowSettings?: WorkflowSettingsSyncSection;
};
remoteReachable = true;
@@ -315,9 +415,11 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
rs,
localGlobalSettings as Record<string, unknown>,
localProjectSettings.project as Record<string, unknown>,
store.listWorkflowSettingValuesForProject(),
);
diffGlobal = diff.global;
diffProject = diff.project;
diffWorkflowSettings = diff.workflowSettings;
} catch (err) {
// FN-4847: Remote probe failures are classified into actionable, enum-only denial reasons.
denialReason = classifySyncStatusDenialReason(err);
@@ -331,7 +433,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
localUpdatedAt: syncState?.updatedAt ?? new Date().toISOString(),
remoteReachable,
actionableDenialReason: denialReason, // FN-4847: explicit null on success, enum value on degraded failures.
diff: { global: diffGlobal, project: diffProject },
diff: { global: diffGlobal, project: diffProject, workflowSettings: diffWorkflowSettings },
});
} catch (err: unknown) {
if (err instanceof ApiError) {