diff --git a/.changeset/loud-nodes-sync.md b/.changeset/loud-nodes-sync.md new file mode 100644 index 0000000000..a0181373f6 --- /dev/null +++ b/.changeset/loud-nodes-sync.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Sync workflow setting values across nodes in settings push, pull, receive, and status flows. diff --git a/packages/core/src/__tests__/central-core.test.ts b/packages/core/src/__tests__/central-core.test.ts index fb2edb15ba..32e18aa812 100644 --- a/packages/core/src/__tests__/central-core.test.ts +++ b/packages/core/src/__tests__/central-core.test.ts @@ -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); }); }); diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index 3a2264494c..98ee6a678d 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -3644,6 +3644,7 @@ export class CentralCore extends EventEmitter { globalCount: 0, projectCount: 0, authCount: 0, + workflowSettingsCount: 0, error: `Unsupported settings sync version: ${payload.version}`, }; } @@ -3653,6 +3654,7 @@ export class CentralCore extends EventEmitter { 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 { 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 { } } - // 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, }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7623c22449..12bad910f7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4781,6 +4781,8 @@ export interface SettingsSyncPayload { * Values contain the credential type and key. Only transmitted over authenticated * node connections. */ providerAuth?: Record; + /** Per-project workflow setting values keyed `workflowId → { settingKey: value }`. */ + workflowSettings?: Record>; /** 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. */ diff --git a/packages/dashboard/app/__tests__/api-node.test.ts b/packages/dashboard/app/__tests__/api-node.test.ts index 535dff6e1c..a03320688a 100644 --- a/packages/dashboard/app/__tests__/api-node.test.ts +++ b/packages/dashboard/app/__tests__/api-node.test.ts @@ -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"); diff --git a/packages/dashboard/app/api-node.ts b/packages/dashboard/app/api-node.ts index 8a3f21c699..b5be7e927b 100644 --- a/packages/dashboard/app/api-node.ts +++ b/packages/dashboard/app/api-node.ts @@ -58,6 +58,7 @@ export async function fetchRemoteNodeProjectHealth( export interface NodeSettingsScopes { global: Record; project: Record; + workflowSettings?: Record>; } /** 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; }; /** 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. */ diff --git a/packages/dashboard/app/components/__tests__/NodesView.test.tsx b/packages/dashboard/app/components/__tests__/NodesView.test.tsx index fc7d36030a..b0be73c474 100644 --- a/packages/dashboard/app/components/__tests__/NodesView.test.tsx +++ b/packages/dashboard/app/components/__tests__/NodesView.test.tsx @@ -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({ diff --git a/packages/dashboard/app/hooks/__tests__/useNodeSettingsSync.test.ts b/packages/dashboard/app/hooks/__tests__/useNodeSettingsSync.test.ts index 159dccb25b..13cdc9038a 100644 --- a/packages/dashboard/app/hooks/__tests__/useNodeSettingsSync.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useNodeSettingsSync.test.ts @@ -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 = {}): 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 { 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 }); diff --git a/packages/dashboard/app/hooks/useNodeSettingsSync.ts b/packages/dashboard/app/hooks/useNodeSettingsSync.ts index 6c874851f7..4511423806 100644 --- a/packages/dashboard/app/hooks/useNodeSettingsSync.ts +++ b/packages/dashboard/app/hooks/useNodeSettingsSync.ts @@ -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 }; diff --git a/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts b/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts index ec25b3616e..944c22d0c9 100644 --- a/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts +++ b/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts @@ -113,6 +113,18 @@ class MockStore extends EventEmitter { }, }; } + + listWorkflowSettingValuesForProject(): Record> { + return {}; + } + + getWorkflowSettingsProjectId(): string { + return "project-local-001"; + } + + async updateWorkflowSettingValues(_workflowId: string, _projectId: string, patch: Record) { + return patch; + } } function createMockRemoteNode(overrides: Record = {}) { @@ -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(); }); diff --git a/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts b/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts index 3b782f32de..5f4a4b50c0 100644 --- a/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts +++ b/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts @@ -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> = { + "builtin:coding": { workflowStepTimeoutMs: 120000 }, + }; + getRootDir(): string { return "/tmp/fn-1821-test"; } @@ -127,6 +133,18 @@ class MockStore extends EventEmitter { async updateGlobalSettings(patch: Record) { return mockStoreUpdateGlobalSettings(patch); } + + listWorkflowSettingValuesForProject(): Record> { + return this.workflowSettings; + } + + getWorkflowSettingsProjectId(): string { + return "project-local-001"; + } + + async updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record) { + return mockUpdateWorkflowSettingValues(workflowId, projectId, patch); + } } // ── Test helpers ────────────────────────────────────────────────────── @@ -163,6 +181,36 @@ function createMockLocalNode(overrides: Record = {}) { // ── 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( diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index 9a2e74e763..0a707487be 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -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; diff --git a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts index 5dc9046b2f..57a287dc1d 100644 --- a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts +++ b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts @@ -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>; +type WorkflowSettingsSyncStore = { + getWorkflowSettingsProjectId(): string; + updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record): Promise>; +}; + +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 = { ...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) { diff --git a/packages/dashboard/src/routes/register-settings-sync-routes.ts b/packages/dashboard/src/routes/register-settings-sync-routes.ts index 95eb66bb9d..48a44fb878 100644 --- a/packages/dashboard/src/routes/register-settings-sync-routes.ts +++ b/packages/dashboard/src/routes/register-settings-sync-routes.ts @@ -13,14 +13,79 @@ import { } from "./register-settings-sync-helpers.js"; import type { ApiRouteRegistrar } from "./types.js"; -function computeSettingsDiff( - remoteSettings: { global?: Record; project?: Record }, +type WorkflowSettingsSyncSection = Record>; + +type WorkflowSettingsSyncStore = { + getWorkflowSettingsProjectId(): string; + updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record): Promise>; +}; + +export type SettingsDiff = { + global: string[]; + project: string[]; + workflowSettings: Record; +}; + +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 = { ...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; + project?: Record; + workflowSettings?: Record>; + }, localGlobalSettings: Record, localProjectSettings: Record, -): { 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>, +): 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 = {}; + 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; project: Record; + 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, localProjectSettings.project as Record, + 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, + 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 } * } */ 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; project: Record } | null = null; + let remoteSettings: { global: Record; project: Record; workflowSettings?: WorkflowSettingsSyncSection } | null = null; let diffGlobal: string[] = []; let diffProject: string[] = []; + let diffWorkflowSettings: Record = {}; 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; project: Record; + workflowSettings?: WorkflowSettingsSyncSection; }; remoteReachable = true; @@ -315,9 +415,11 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => { rs, localGlobalSettings as Record, localProjectSettings.project as Record, + 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) {