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 e0980c01c6
commit 7ddf58d635
14 changed files with 488 additions and 40 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Sync workflow setting values across nodes in settings push, pull, receive, and status flows.

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createHash } from "node:crypto";
import { CentralCore } from "../central-core.js"; import { CentralCore } from "../central-core.js";
import { NodeDiscovery } from "../node-discovery.js"; import { NodeDiscovery } from "../node-discovery.js";
import { NodeConnection, type ConnectionResult } from "../node-connection.js"; import { NodeConnection, type ConnectionResult } from "../node-connection.js";
@@ -2875,6 +2876,7 @@ describe("CentralCore", () => {
expect(result.globalCount).toBe(1); expect(result.globalCount).toBe(1);
expect(result.projectCount).toBe(0); expect(result.projectCount).toBe(0);
expect(result.authCount).toBe(0); expect(result.authCount).toBe(0);
expect(result.workflowSettingsCount).toBe(0);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
}); });
@@ -2888,6 +2890,7 @@ describe("CentralCore", () => {
const result = await central.applyRemoteSettings(payload); const result = await central.applyRemoteSettings(payload);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.workflowSettingsCount).toBe(0);
expect(result.error).toContain("Unsupported settings sync version"); expect(result.error).toContain("Unsupported settings sync version");
}); });
@@ -2901,6 +2904,7 @@ describe("CentralCore", () => {
const result = await central.applyRemoteSettings(payload); const result = await central.applyRemoteSettings(payload);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.workflowSettingsCount).toBe(0);
expect(result.error).toContain("Checksum mismatch"); expect(result.error).toContain("Checksum mismatch");
}); });
@@ -2958,9 +2962,41 @@ describe("CentralCore", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.authCount).toBe(2); // Both entries counted expect(result.authCount).toBe(2); // Both entries counted
expect(result.workflowSettingsCount).toBe(0);
// Auth is not applied - that's the caller's responsibility // 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 () => { it("should handle empty payload gracefully", async () => {
// Create an empty but valid payload using getSettingsForSync // Create an empty but valid payload using getSettingsForSync
const emptyPayload = await central.getSettingsForSync({}); const emptyPayload = await central.getSettingsForSync({});
@@ -2971,6 +3007,7 @@ describe("CentralCore", () => {
expect(result.globalCount).toBeGreaterThanOrEqual(0); expect(result.globalCount).toBeGreaterThanOrEqual(0);
expect(result.projectCount).toBe(0); expect(result.projectCount).toBe(0);
expect(result.authCount).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, globalCount: 0,
projectCount: 0, projectCount: 0,
authCount: 0, authCount: 0,
workflowSettingsCount: 0,
error: `Unsupported settings sync version: ${payload.version}`, error: `Unsupported settings sync version: ${payload.version}`,
}; };
} }
@@ -3653,6 +3654,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
global: payload.global, global: payload.global,
projects: payload.projects, projects: payload.projects,
providerAuth: payload.providerAuth, providerAuth: payload.providerAuth,
workflowSettings: payload.workflowSettings,
exportedAt: payload.exportedAt, exportedAt: payload.exportedAt,
version: payload.version, version: payload.version,
}; };
@@ -3666,6 +3668,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
globalCount: 0, globalCount: 0,
projectCount: 0, projectCount: 0,
authCount: 0, authCount: 0,
workflowSettingsCount: 0,
error: "Checksum mismatch - payload may have been corrupted", 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 // Provider auth is transported but NOT applied here.
// The caller (dashboard route) handles auth application // 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 { return {
success: true, success: true,
globalCount, globalCount,
projectCount, projectCount,
authCount, authCount,
workflowSettingsCount: 0,
}; };
} }

View File

@@ -4701,6 +4701,8 @@ export interface SettingsSyncPayload {
* Values contain the credential type and key. Only transmitted over authenticated * Values contain the credential type and key. Only transmitted over authenticated
* node connections. */ * node connections. */
providerAuth?: Record<string, ProviderAuthEntry>; 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. */ /** ISO timestamp when this snapshot was generated. */
exportedAt: string; exportedAt: string;
/** Checksum of the settings data for change detection (SHA-256 hex of JSON). */ /** Checksum of the settings data for change detection (SHA-256 hex of JSON). */
@@ -4737,6 +4739,8 @@ export interface SettingsSyncResult {
projectCount: number; projectCount: number;
/** Number of provider auth entries synced. */ /** Number of provider auth entries synced. */
authCount: number; authCount: number;
/** Number of workflow setting values applied by the caller. */
workflowSettingsCount: number;
/** Whether the sync was successful. */ /** Whether the sync was successful. */
success: boolean; success: boolean;
/** Error message if sync failed. */ /** Error message if sync failed. */

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react"; import { renderHook, act, waitFor } from "@testing-library/react";
import { useNodeSettingsSync } from "../useNodeSettingsSync"; import { computeSyncState, useNodeSettingsSync } from "../useNodeSettingsSync";
import * as apiNode from "../../api-node"; import * as apiNode from "../../api-node";
import type { NodeSettingsSyncStatus, NodeSettingsSyncResult, NodeAuthSyncResult } from "../../api-node"; import type { NodeSettingsSyncStatus, NodeSettingsSyncResult, NodeAuthSyncResult } from "../../api-node";
@@ -22,7 +22,7 @@ function makeSyncStatus(overrides: Partial<NodeSettingsSyncStatus> = {}): NodeSe
lastSyncDirection: "sync", lastSyncDirection: "sync",
localUpdatedAt: "2026-04-01T00:00:00.000Z", localUpdatedAt: "2026-04-01T00:00:00.000Z",
remoteReachable: true, remoteReachable: true,
diff: { global: [], project: [] }, diff: { global: [], project: [], workflowSettings: {} },
...overrides, ...overrides,
}; };
} }
@@ -42,6 +42,35 @@ async function flushPromises(): Promise<void> {
await Promise.resolve(); 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", () => { describe("useNodeSettingsSync", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });

View File

@@ -32,7 +32,9 @@ export interface ComputedNodeSyncStatus {
*/ */
export function computeSyncState(status: NodeSettingsSyncStatus): ComputedNodeSyncStatus { export function computeSyncState(status: NodeSettingsSyncStatus): ComputedNodeSyncStatus {
const { lastSyncAt, remoteReachable, diff } = status; 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) { if (lastSyncAt === null) {
return { syncState: "never-synced", lastSyncAt, diffCount: 0 }; 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> = {}) { 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" }); mockGetLocalPeerInfo.mockResolvedValue({ nodeId: "node-local-001", nodeName: "Local Node" });
mockGetSettingsSyncState.mockResolvedValue(null); mockGetSettingsSyncState.mockResolvedValue(null);
mockUpdateSettingsSyncState.mockResolvedValue({}); 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({}); mockGetSettingsForSync.mockResolvedValue({});
mockGetAuthMaterialSnapshot.mockReturnValue({ mockGetAuthMaterialSnapshot.mockReturnValue({
version: 1, version: 1,
@@ -294,7 +306,7 @@ describe("Node settings/auth sync contract matrix", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.remoteReachable).toBe(false); 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(); expect(mockFetch).not.toHaveBeenCalled();
}); });
@@ -306,7 +318,7 @@ describe("Node settings/auth sync contract matrix", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.remoteReachable).toBe(false); 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(); expect(mockFetch).not.toHaveBeenCalled();
}); });

View File

@@ -4,6 +4,7 @@ import { request, get } from "../test-request.js";
import { createServer } from "../server.js"; import { createServer } from "../server.js";
import { resetRuntimeLogSink, setRuntimeLogSink, type RuntimeLogContext } from "../runtime-logger.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 { 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"; import { MOVED_SETTINGS_KEYS } from "@fusion/core";
// Mock node:fs for auth.json reading // Mock node:fs for auth.json reading
@@ -36,6 +37,7 @@ const mockGetSettingsForSync = vi.fn();
const mockGetAuthMaterialSnapshot = vi.fn(); const mockGetAuthMaterialSnapshot = vi.fn();
const mockApplyAuthMaterialSnapshot = vi.fn(); const mockApplyAuthMaterialSnapshot = vi.fn();
const mockStoreUpdateGlobalSettings = vi.fn().mockResolvedValue({}); const mockStoreUpdateGlobalSettings = vi.fn().mockResolvedValue({});
const mockUpdateWorkflowSettingValues = vi.fn().mockResolvedValue({});
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined); const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null); const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null);
@@ -90,6 +92,10 @@ vi.mock("@earendil-works/pi-coding-agent", () => {
// ── Mock Store ──────────────────────────────────────────────────────── // ── Mock Store ────────────────────────────────────────────────────────
class MockStore extends EventEmitter { class MockStore extends EventEmitter {
workflowSettings: Record<string, Record<string, unknown>> = {
"builtin:coding": { workflowStepTimeoutMs: 120000 },
};
getRootDir(): string { getRootDir(): string {
return "/tmp/fn-1821-test"; return "/tmp/fn-1821-test";
} }
@@ -127,6 +133,18 @@ class MockStore extends EventEmitter {
async updateGlobalSettings(patch: Record<string, unknown>) { async updateGlobalSettings(patch: Record<string, unknown>) {
return mockStoreUpdateGlobalSettings(patch); 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 ────────────────────────────────────────────────────── // ── Test helpers ──────────────────────────────────────────────────────
@@ -163,6 +181,36 @@ function createMockLocalNode(overrides: Record<string, unknown> = {}) {
// ── Tests ───────────────────────────────────────────────────────────── // ── 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 { interface RuntimeEvent {
level: "info" | "warn" | "error"; level: "info" | "warn" | "error";
scope: string; scope: string;
@@ -185,8 +233,9 @@ describe("Node settings sync routes", () => {
mockGetLocalPeerInfo.mockResolvedValue({ nodeId: "node-local-001", nodeName: "Local Node" }); mockGetLocalPeerInfo.mockResolvedValue({ nodeId: "node-local-001", nodeName: "Local Node" });
mockGetSettingsSyncState.mockResolvedValue(null); mockGetSettingsSyncState.mockResolvedValue(null);
mockUpdateSettingsSyncState.mockResolvedValue({}); 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(); mockStoreUpdateGlobalSettings.mockReset();
mockUpdateWorkflowSettingValues.mockReset().mockResolvedValue({});
mockGetSettingsForSync.mockResolvedValue({}); mockGetSettingsForSync.mockResolvedValue({});
mockGetAuthMaterialSnapshot.mockReturnValue({ mockGetAuthMaterialSnapshot.mockReturnValue({
version: 1, version: 1,
@@ -312,6 +361,11 @@ describe("Node settings sync routes", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
expect(res.body.syncedFields).toContain("defaultProvider"); 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( expect(mockFetch).toHaveBeenCalledWith(
"http://192.168.1.100:3001/api/settings/sync-receive", "http://192.168.1.100:3001/api/settings/sync-receive",
expect.objectContaining({ expect.objectContaining({
@@ -420,6 +474,36 @@ describe("Node settings sync routes", () => {
expect(mockApplyRemoteSettings).toHaveBeenCalled(); 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 () => { it("returns diff without applying when conflictResolution is manual", async () => {
const remoteNode = createMockRemoteNode(); const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode); mockGetNode.mockResolvedValue(remoteNode);
@@ -441,8 +525,13 @@ describe("Node settings sync routes", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.diff).toBeDefined(); expect(res.body.diff).toBeDefined();
expect(res.body.diff.workflowSettings).toEqual({
"builtin:coding": ["workflowStepTimeoutMs"],
});
expect(res.body.remoteSettings).toBeDefined(); expect(res.body.remoteSettings).toBeDefined();
expect(res.body.localSettings).toBeDefined(); expect(res.body.localSettings.workflowSettings).toEqual({
"builtin:coding": { workflowStepTimeoutMs: 120000 },
});
expect(mockApplyRemoteSettings).not.toHaveBeenCalled(); 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.lastSyncAt).toBe("2026-04-14T10:00:00.000Z");
expect(res.body.remoteReachable).toBe(true); expect(res.body.remoteReachable).toBe(true);
expect(res.body.diff).toBeDefined(); 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 () => { 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.status).toBe(200);
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
expect(res.body.workflowSettingsCount).toBe(0);
expect(mockApplyRemoteSettings).toHaveBeenCalled(); 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 () => { it("applies inbound global settings via store.updateGlobalSettings when local values are unset", async () => {
const localNode = createMockLocalNode(); const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]); mockListNodes.mockResolvedValue([localNode]);
@@ -1559,6 +1734,7 @@ describe("Node settings sync routes", () => {
expect(postedBody).toEqual(expect.objectContaining({ expect(postedBody).toEqual(expect.objectContaining({
global: expect.any(Object), global: expect.any(Object),
projects: expect.any(Object), projects: expect.any(Object),
workflowSettings: { "builtin:coding": { workflowStepTimeoutMs: 120000 } },
exportedAt: expect.any(String), exportedAt: expect.any(String),
version: 1, version: 1,
checksum: expect.any(String), checksum: expect.any(String),
@@ -1571,6 +1747,7 @@ describe("Node settings sync routes", () => {
.update(JSON.stringify({ .update(JSON.stringify({
global: postedBody.global, global: postedBody.global,
projects: postedBody.projects, projects: postedBody.projects,
workflowSettings: postedBody.workflowSettings,
exportedAt: postedBody.exportedAt, exportedAt: postedBody.exportedAt,
version: postedBody.version, version: postedBody.version,
})) }))
@@ -1638,7 +1815,7 @@ describe("Node settings sync routes", () => {
expect(res.body.error).toBe(MISSING_REMOTE_NODE_API_KEY_MESSAGE); expect(res.body.error).toBe(MISSING_REMOTE_NODE_API_KEY_MESSAGE);
} else { } else {
expect(res.body.remoteReachable).toBe(false); 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(); expect(mockFetch).not.toHaveBeenCalled();
}); });
@@ -1704,7 +1881,7 @@ describe("Node settings sync routes", () => {
const localNode = createMockLocalNode(); const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]); 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( const inboundRes = await request(
app, app,
@@ -1729,7 +1906,7 @@ describe("Node settings sync routes", () => {
project: { defaultProvider: "openai" }, project: { defaultProvider: "openai" },
}; };
mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(remotePayload) }); 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( const res = await request(
app, app,
@@ -1974,7 +2151,7 @@ describe("Node settings sync routes", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.remoteReachable).toBe(false); expect(res.body.remoteReachable).toBe(false);
expect(res.body.actionableDenialReason).toBe("missing-remote-api-key"); 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(); 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 () => { it("accepts POST /api/settings/sync-receive with correct bearer and applies remote settings", async () => {
const localNode = createMockLocalNode(); const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]); 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 payload = { sourceNodeId: "node-remote-001", exportedAt: "2026-05-17T00:00:00.000Z", global: { theme: "dark" }, projects: { kb: { model: "gpt-5" } } };
const res = await request( const res = await request(

View File

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

View File

@@ -5,6 +5,59 @@ import { getFusionAuthPath } from "../auth-paths.js";
import { readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js"; import { readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.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) => { export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
const { router, store, emitAuthSyncAuditLog, rethrowAsApiError } = 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 // Build applied/skipped field lists. Moved keys are excluded so the reported
// applied set matches what actually persisted (the store + applyRemoteSettings // 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 = [ const appliedFields = [
...Object.keys(payload.global || {}), ...[
...Object.keys(payload.projects || {}), ...Object.keys(payload.global || {}),
].filter((key) => !isMovedSettingsKey(key)); ...Object.keys(payload.projects || {}),
].filter((key) => !isMovedSettingsKey(key)),
...appliedWorkflowSettingKeys,
];
const skippedFields = result.error ? appliedFields : []; const skippedFields = result.error ? appliedFields : [];
await central.close(); await central.close();
@@ -97,6 +161,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
success: result.success, success: result.success,
appliedFields, appliedFields,
skippedFields, skippedFields,
workflowSettingsCount,
error: result.error, error: result.error,
}); });
} catch (err: unknown) { } catch (err: unknown) {

View File

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