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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3644,6 +3644,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
globalCount: 0,
|
||||
projectCount: 0,
|
||||
authCount: 0,
|
||||
workflowSettingsCount: 0,
|
||||
error: `Unsupported settings sync version: ${payload.version}`,
|
||||
};
|
||||
}
|
||||
@@ -3653,6 +3654,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
global: payload.global,
|
||||
projects: payload.projects,
|
||||
providerAuth: payload.providerAuth,
|
||||
workflowSettings: payload.workflowSettings,
|
||||
exportedAt: payload.exportedAt,
|
||||
version: payload.version,
|
||||
};
|
||||
@@ -3666,6 +3668,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
globalCount: 0,
|
||||
projectCount: 0,
|
||||
authCount: 0,
|
||||
workflowSettingsCount: 0,
|
||||
error: "Checksum mismatch - payload may have been corrupted",
|
||||
};
|
||||
}
|
||||
@@ -3713,14 +3716,20 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
// Provider auth is transported but NOT applied here
|
||||
// The caller (dashboard route) handles auth application
|
||||
// Provider auth is transported but NOT applied here.
|
||||
// Workflow setting values are also transported in the checksum-protected
|
||||
// payload but NOT applied here; dashboard sync routes write them through
|
||||
// TaskStore so validation, project scoping, and cache/listener behavior stay
|
||||
// consistent. Cross-node settings sync expects both peers to run the same
|
||||
// payload shape: a new node sending workflowSettings to a pre-FN-6208 node
|
||||
// can checksum-mismatch, which is acceptable for mixed-version peers.
|
||||
|
||||
return {
|
||||
success: true,
|
||||
globalCount,
|
||||
projectCount,
|
||||
authCount,
|
||||
workflowSettingsCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4781,6 +4781,8 @@ export interface SettingsSyncPayload {
|
||||
* Values contain the credential type and key. Only transmitted over authenticated
|
||||
* node connections. */
|
||||
providerAuth?: Record<string, ProviderAuthEntry>;
|
||||
/** Per-project workflow setting values keyed `workflowId → { settingKey: value }`. */
|
||||
workflowSettings?: Record<string, Record<string, unknown>>;
|
||||
/** ISO timestamp when this snapshot was generated. */
|
||||
exportedAt: string;
|
||||
/** Checksum of the settings data for change detection (SHA-256 hex of JSON). */
|
||||
@@ -4817,6 +4819,8 @@ export interface SettingsSyncResult {
|
||||
projectCount: number;
|
||||
/** Number of provider auth entries synced. */
|
||||
authCount: number;
|
||||
/** Number of workflow setting values applied by the caller. */
|
||||
workflowSettingsCount: number;
|
||||
/** Whether the sync was successful. */
|
||||
success: boolean;
|
||||
/** Error message if sync failed. */
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -58,6 +58,7 @@ export async function fetchRemoteNodeProjectHealth(
|
||||
export interface NodeSettingsScopes {
|
||||
global: Record<string, unknown>;
|
||||
project: Record<string, unknown>;
|
||||
workflowSettings?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Result from settings push/pull operations */
|
||||
@@ -66,6 +67,7 @@ export interface NodeSettingsSyncResult {
|
||||
syncedFields?: string[];
|
||||
appliedFields?: string[];
|
||||
skippedFields?: string[];
|
||||
workflowSettingsCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -78,6 +80,7 @@ export interface NodeSettingsSyncStatus {
|
||||
diff: {
|
||||
global: string[];
|
||||
project: string[];
|
||||
workflowSettings: Record<string, string[]>;
|
||||
};
|
||||
/** Overall auth credential sync state: "match" if credentials match between local and remote,
|
||||
* "differs" if they differ, "not-synced" if auth sync has never been performed. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useNodeSettingsSync } from "../useNodeSettingsSync";
|
||||
import { computeSyncState, useNodeSettingsSync } from "../useNodeSettingsSync";
|
||||
import * as apiNode from "../../api-node";
|
||||
import type { NodeSettingsSyncStatus, NodeSettingsSyncResult, NodeAuthSyncResult } from "../../api-node";
|
||||
|
||||
@@ -22,7 +22,7 @@ function makeSyncStatus(overrides: Partial<NodeSettingsSyncStatus> = {}): NodeSe
|
||||
lastSyncDirection: "sync",
|
||||
localUpdatedAt: "2026-04-01T00:00:00.000Z",
|
||||
remoteReachable: true,
|
||||
diff: { global: [], project: [] },
|
||||
diff: { global: [], project: [], workflowSettings: {} },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,35 @@ async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("computeSyncState", () => {
|
||||
it("counts workflow setting diffs in the derived diff count", () => {
|
||||
const status = makeSyncStatus({
|
||||
diff: {
|
||||
global: ["theme"],
|
||||
project: ["maxConcurrent"],
|
||||
workflowSettings: {
|
||||
"builtin:coding": ["workflowStepTimeoutMs", "reviewModel"],
|
||||
"WF-123": ["executionModel"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(computeSyncState(status)).toEqual({
|
||||
syncState: "diff",
|
||||
lastSyncAt: "2026-04-01T00:00:00.000Z",
|
||||
diffCount: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an empty workflow settings diff as synced", () => {
|
||||
expect(computeSyncState(makeSyncStatus())).toEqual({
|
||||
syncState: "synced",
|
||||
lastSyncAt: "2026-04-01T00:00:00.000Z",
|
||||
diffCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useNodeSettingsSync", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -113,6 +113,18 @@ class MockStore extends EventEmitter {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
listWorkflowSettingValuesForProject(): Record<string, Record<string, unknown>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
getWorkflowSettingsProjectId(): string {
|
||||
return "project-local-001";
|
||||
}
|
||||
|
||||
async updateWorkflowSettingValues(_workflowId: string, _projectId: string, patch: Record<string, unknown>) {
|
||||
return patch;
|
||||
}
|
||||
}
|
||||
|
||||
function createMockRemoteNode(overrides: Record<string, unknown> = {}) {
|
||||
@@ -192,7 +204,7 @@ describe("Node settings/auth sync contract matrix", () => {
|
||||
mockGetLocalPeerInfo.mockResolvedValue({ nodeId: "node-local-001", nodeName: "Local Node" });
|
||||
mockGetSettingsSyncState.mockResolvedValue(null);
|
||||
mockUpdateSettingsSyncState.mockResolvedValue({});
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0, workflowSettingsCount: 0 });
|
||||
mockGetSettingsForSync.mockResolvedValue({});
|
||||
mockGetAuthMaterialSnapshot.mockReturnValue({
|
||||
version: 1,
|
||||
@@ -294,7 +306,7 @@ describe("Node settings/auth sync contract matrix", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.remoteReachable).toBe(false);
|
||||
expect(res.body.diff).toEqual({ global: [], project: [] });
|
||||
expect(res.body.diff).toEqual({ global: [], project: [], workflowSettings: {} });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -306,7 +318,7 @@ describe("Node settings/auth sync contract matrix", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.remoteReachable).toBe(false);
|
||||
expect(res.body.diff).toEqual({ global: [], project: [] });
|
||||
expect(res.body.diff).toEqual({ global: [], project: [], workflowSettings: {} });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { request, get } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
import { resetRuntimeLogSink, setRuntimeLogSink, type RuntimeLogContext } from "../runtime-logger.js";
|
||||
import { MISSING_REMOTE_NODE_API_KEY_MESSAGE } from "../routes/register-settings-sync-helpers.js";
|
||||
import { computeSettingsDiff } from "../routes/register-settings-sync-routes.js";
|
||||
import { MOVED_SETTINGS_KEYS } from "@fusion/core";
|
||||
|
||||
// Mock node:fs for auth.json reading
|
||||
@@ -36,6 +37,7 @@ const mockGetSettingsForSync = vi.fn();
|
||||
const mockGetAuthMaterialSnapshot = vi.fn();
|
||||
const mockApplyAuthMaterialSnapshot = vi.fn();
|
||||
const mockStoreUpdateGlobalSettings = vi.fn().mockResolvedValue({});
|
||||
const mockUpdateWorkflowSettingValues = vi.fn().mockResolvedValue({});
|
||||
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null);
|
||||
@@ -90,6 +92,10 @@ vi.mock("@earendil-works/pi-coding-agent", () => {
|
||||
// ── Mock Store ────────────────────────────────────────────────────────
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
workflowSettings: Record<string, Record<string, unknown>> = {
|
||||
"builtin:coding": { workflowStepTimeoutMs: 120000 },
|
||||
};
|
||||
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-1821-test";
|
||||
}
|
||||
@@ -127,6 +133,18 @@ class MockStore extends EventEmitter {
|
||||
async updateGlobalSettings(patch: Record<string, unknown>) {
|
||||
return mockStoreUpdateGlobalSettings(patch);
|
||||
}
|
||||
|
||||
listWorkflowSettingValuesForProject(): Record<string, Record<string, unknown>> {
|
||||
return this.workflowSettings;
|
||||
}
|
||||
|
||||
getWorkflowSettingsProjectId(): string {
|
||||
return "project-local-001";
|
||||
}
|
||||
|
||||
async updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record<string, unknown>) {
|
||||
return mockUpdateWorkflowSettingValues(workflowId, projectId, patch);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────
|
||||
@@ -163,6 +181,36 @@ function createMockLocalNode(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("computeSettingsDiff", () => {
|
||||
it("diffs workflow settings per workflow while filtering moved flat keys", () => {
|
||||
const movedKey = MOVED_SETTINGS_KEYS[0];
|
||||
const diff = computeSettingsDiff(
|
||||
{
|
||||
global: { defaultProvider: "openai", [movedKey]: "remote" },
|
||||
project: { maxConcurrent: 3, [movedKey]: 123 },
|
||||
workflowSettings: {
|
||||
"builtin:coding": { workflowStepTimeoutMs: 120000, reviewModel: "claude" },
|
||||
"WF-remote": { executionModel: "gpt-5" },
|
||||
},
|
||||
},
|
||||
{ defaultProvider: "anthropic", [movedKey]: "local" },
|
||||
{ maxConcurrent: 2, [movedKey]: 456 },
|
||||
{
|
||||
"builtin:coding": { workflowStepTimeoutMs: 120000, reviewModel: "gpt-4" },
|
||||
"WF-local": { executionModel: "claude" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(diff.global).toEqual(["defaultProvider"]);
|
||||
expect(diff.project).toEqual(["maxConcurrent"]);
|
||||
expect(diff.workflowSettings).toEqual({
|
||||
"builtin:coding": ["reviewModel"],
|
||||
"WF-remote": ["executionModel"],
|
||||
"WF-local": ["executionModel"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface RuntimeEvent {
|
||||
level: "info" | "warn" | "error";
|
||||
scope: string;
|
||||
@@ -185,8 +233,9 @@ describe("Node settings sync routes", () => {
|
||||
mockGetLocalPeerInfo.mockResolvedValue({ nodeId: "node-local-001", nodeName: "Local Node" });
|
||||
mockGetSettingsSyncState.mockResolvedValue(null);
|
||||
mockUpdateSettingsSyncState.mockResolvedValue({});
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0, workflowSettingsCount: 0 });
|
||||
mockStoreUpdateGlobalSettings.mockReset();
|
||||
mockUpdateWorkflowSettingValues.mockReset().mockResolvedValue({});
|
||||
mockGetSettingsForSync.mockResolvedValue({});
|
||||
mockGetAuthMaterialSnapshot.mockReturnValue({
|
||||
version: 1,
|
||||
@@ -312,6 +361,11 @@ describe("Node settings sync routes", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.syncedFields).toContain("defaultProvider");
|
||||
expect(res.body.syncedFields).toContain("workflowStepTimeoutMs");
|
||||
const [, pushOptions] = mockFetch.mock.calls[0] as [string, { body?: string }];
|
||||
expect(JSON.parse(pushOptions.body ?? "{}").workflowSettings).toEqual({
|
||||
"builtin:coding": { workflowStepTimeoutMs: 120000 },
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://192.168.1.100:3001/api/settings/sync-receive",
|
||||
expect.objectContaining({
|
||||
@@ -420,6 +474,36 @@ describe("Node settings sync routes", () => {
|
||||
expect(mockApplyRemoteSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies remote workflow settings locally with last-write-wins", async () => {
|
||||
const remoteNode = createMockRemoteNode();
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
global: { defaultProvider: "openai" },
|
||||
project: { maxConcurrent: 3 },
|
||||
workflowSettings: { "builtin:coding": { workflowStepTimeoutMs: 240000 } },
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/node-remote-001/settings/pull",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.appliedFields).toContain("workflowStepTimeoutMs");
|
||||
expect(res.body.workflowSettingsCount).toBe(1);
|
||||
expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith(
|
||||
"builtin:coding",
|
||||
"project-local-001",
|
||||
{ workflowStepTimeoutMs: 240000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("returns diff without applying when conflictResolution is manual", async () => {
|
||||
const remoteNode = createMockRemoteNode();
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
@@ -441,8 +525,13 @@ describe("Node settings sync routes", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.diff).toBeDefined();
|
||||
expect(res.body.diff.workflowSettings).toEqual({
|
||||
"builtin:coding": ["workflowStepTimeoutMs"],
|
||||
});
|
||||
expect(res.body.remoteSettings).toBeDefined();
|
||||
expect(res.body.localSettings).toBeDefined();
|
||||
expect(res.body.localSettings.workflowSettings).toEqual({
|
||||
"builtin:coding": { workflowStepTimeoutMs: 120000 },
|
||||
});
|
||||
expect(mockApplyRemoteSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -716,6 +805,9 @@ describe("Node settings sync routes", () => {
|
||||
expect(res.body.lastSyncAt).toBe("2026-04-14T10:00:00.000Z");
|
||||
expect(res.body.remoteReachable).toBe(true);
|
||||
expect(res.body.diff).toBeDefined();
|
||||
expect(res.body.diff.workflowSettings).toEqual({
|
||||
"builtin:coding": ["workflowStepTimeoutMs"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns remoteReachable false with empty diff when remote is down", async () => {
|
||||
@@ -1077,9 +1169,92 @@ describe("Node settings sync routes", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.workflowSettingsCount).toBe(0);
|
||||
expect(mockApplyRemoteSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies inbound workflow settings through the workflow settings write path", async () => {
|
||||
const localNode = createMockLocalNode();
|
||||
mockListNodes.mockResolvedValue([localNode]);
|
||||
mockApplyRemoteSettings.mockResolvedValue({
|
||||
success: true,
|
||||
globalCount: 0,
|
||||
projectCount: 0,
|
||||
authCount: 0,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/settings/sync-receive",
|
||||
JSON.stringify({
|
||||
sourceNodeId: "node-remote-001",
|
||||
exportedAt: "2026-04-14T10:00:00.000Z",
|
||||
checksum: "abc123",
|
||||
version: 1,
|
||||
workflowSettings: { "builtin:coding": { workflowStepTimeoutMs: 240000 } },
|
||||
}),
|
||||
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.appliedFields).toContain("workflowStepTimeoutMs");
|
||||
expect(res.body.workflowSettingsCount).toBe(1);
|
||||
expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith(
|
||||
"builtin:coding",
|
||||
"project-local-001",
|
||||
{ workflowStepTimeoutMs: 240000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("drops invalid inbound workflow settings without failing the sync", async () => {
|
||||
const localNode = createMockLocalNode();
|
||||
mockListNodes.mockResolvedValue([localNode]);
|
||||
mockApplyRemoteSettings.mockResolvedValue({
|
||||
success: true,
|
||||
globalCount: 0,
|
||||
projectCount: 0,
|
||||
authCount: 0,
|
||||
});
|
||||
mockUpdateWorkflowSettingValues
|
||||
.mockRejectedValueOnce(Object.assign(new Error("bad setting"), {
|
||||
rejections: [{ settingId: "invalidSetting" }],
|
||||
}))
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/settings/sync-receive",
|
||||
JSON.stringify({
|
||||
sourceNodeId: "node-remote-001",
|
||||
exportedAt: "2026-04-14T10:00:00.000Z",
|
||||
checksum: "abc123",
|
||||
version: 1,
|
||||
workflowSettings: {
|
||||
"builtin:coding": {
|
||||
workflowStepTimeoutMs: 240000,
|
||||
invalidSetting: "bad",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.appliedFields).toContain("workflowStepTimeoutMs");
|
||||
expect(res.body.appliedFields).not.toContain("invalidSetting");
|
||||
expect(res.body.workflowSettingsCount).toBe(1);
|
||||
expect(mockUpdateWorkflowSettingValues).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"builtin:coding",
|
||||
"project-local-001",
|
||||
{ workflowStepTimeoutMs: 240000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("applies inbound global settings via store.updateGlobalSettings when local values are unset", async () => {
|
||||
const localNode = createMockLocalNode();
|
||||
mockListNodes.mockResolvedValue([localNode]);
|
||||
@@ -1559,6 +1734,7 @@ describe("Node settings sync routes", () => {
|
||||
expect(postedBody).toEqual(expect.objectContaining({
|
||||
global: expect.any(Object),
|
||||
projects: expect.any(Object),
|
||||
workflowSettings: { "builtin:coding": { workflowStepTimeoutMs: 120000 } },
|
||||
exportedAt: expect.any(String),
|
||||
version: 1,
|
||||
checksum: expect.any(String),
|
||||
@@ -1571,6 +1747,7 @@ describe("Node settings sync routes", () => {
|
||||
.update(JSON.stringify({
|
||||
global: postedBody.global,
|
||||
projects: postedBody.projects,
|
||||
workflowSettings: postedBody.workflowSettings,
|
||||
exportedAt: postedBody.exportedAt,
|
||||
version: postedBody.version,
|
||||
}))
|
||||
@@ -1638,7 +1815,7 @@ describe("Node settings sync routes", () => {
|
||||
expect(res.body.error).toBe(MISSING_REMOTE_NODE_API_KEY_MESSAGE);
|
||||
} else {
|
||||
expect(res.body.remoteReachable).toBe(false);
|
||||
expect(res.body.diff).toEqual({ global: [], project: [] });
|
||||
expect(res.body.diff).toEqual({ global: [], project: [], workflowSettings: {} });
|
||||
}
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1704,7 +1881,7 @@ describe("Node settings sync routes", () => {
|
||||
|
||||
const localNode = createMockLocalNode();
|
||||
mockListNodes.mockResolvedValue([localNode]);
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0, workflowSettingsCount: 0 });
|
||||
|
||||
const inboundRes = await request(
|
||||
app,
|
||||
@@ -1729,7 +1906,7 @@ describe("Node settings sync routes", () => {
|
||||
project: { defaultProvider: "openai" },
|
||||
};
|
||||
mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(remotePayload) });
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0, workflowSettingsCount: 0 });
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
@@ -1974,7 +2151,7 @@ describe("Node settings sync routes", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.remoteReachable).toBe(false);
|
||||
expect(res.body.actionableDenialReason).toBe("missing-remote-api-key");
|
||||
expect(res.body.diff).toEqual({ global: [], project: [] });
|
||||
expect(res.body.diff).toEqual({ global: [], project: [], workflowSettings: {} });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2233,7 +2410,7 @@ describe("Node settings sync routes", () => {
|
||||
it("accepts POST /api/settings/sync-receive with correct bearer and applies remote settings", async () => {
|
||||
const localNode = createMockLocalNode();
|
||||
mockListNodes.mockResolvedValue([localNode]);
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0, workflowSettingsCount: 0 });
|
||||
const payload = { sourceNodeId: "node-remote-001", exportedAt: "2026-05-17T00:00:00.000Z", global: { theme: "dark" }, projects: { kb: { model: "gpt-5" } } };
|
||||
|
||||
const res = await request(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,59 @@ import { getFusionAuthPath } from "../auth-paths.js";
|
||||
import { readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
type WorkflowSettingsSyncSection = Record<string, Record<string, unknown>>;
|
||||
type WorkflowSettingsSyncStore = {
|
||||
getWorkflowSettingsProjectId(): string;
|
||||
updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function extractRejectedSettingIds(err: unknown): string[] {
|
||||
if (!err || typeof err !== "object") return [];
|
||||
const rejections = (err as { rejections?: unknown }).rejections;
|
||||
if (!Array.isArray(rejections)) return [];
|
||||
const ids: string[] = [];
|
||||
for (const rejection of rejections) {
|
||||
if (rejection && typeof rejection === "object" && typeof (rejection as { settingId?: unknown }).settingId === "string") {
|
||||
ids.push((rejection as { settingId: string }).settingId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function applyWorkflowSettingsSection(
|
||||
store: WorkflowSettingsSyncStore,
|
||||
section: WorkflowSettingsSyncSection,
|
||||
): Promise<{ count: number; keys: string[] }> {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
let count = 0;
|
||||
const keys: string[] = [];
|
||||
|
||||
for (const [workflowId, rawValues] of Object.entries(section)) {
|
||||
if (!rawValues || typeof rawValues !== "object" || Array.isArray(rawValues)) continue;
|
||||
const patch: Record<string, unknown> = { ...rawValues };
|
||||
|
||||
while (Object.keys(patch).length > 0) {
|
||||
try {
|
||||
await store.updateWorkflowSettingValues(workflowId, projectId, patch);
|
||||
const appliedKeys = Object.entries(patch)
|
||||
.filter(([, value]) => value !== null)
|
||||
.map(([key]) => key);
|
||||
count += appliedKeys.length;
|
||||
keys.push(...appliedKeys);
|
||||
break;
|
||||
} catch (err) {
|
||||
const rejectedIds = extractRejectedSettingIds(err);
|
||||
if (rejectedIds.length === 0) break;
|
||||
for (const settingId of rejectedIds) {
|
||||
delete patch[settingId];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { count, keys };
|
||||
}
|
||||
|
||||
export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, store, emitAuthSyncAuditLog, rethrowAsApiError } = ctx;
|
||||
|
||||
@@ -82,13 +135,24 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
}
|
||||
}
|
||||
|
||||
let workflowSettingsCount = 0;
|
||||
let appliedWorkflowSettingKeys: string[] = [];
|
||||
if (result.success && payload.workflowSettings && typeof payload.workflowSettings === "object" && !Array.isArray(payload.workflowSettings)) {
|
||||
const workflowApplyResult = await applyWorkflowSettingsSection(store, payload.workflowSettings as WorkflowSettingsSyncSection);
|
||||
workflowSettingsCount = workflowApplyResult.count;
|
||||
appliedWorkflowSettingKeys = workflowApplyResult.keys;
|
||||
}
|
||||
|
||||
// Build applied/skipped field lists. Moved keys are excluded so the reported
|
||||
// applied set matches what actually persisted (the store + applyRemoteSettings
|
||||
// both drop them).
|
||||
// both drop them). Workflow setting values sync in their own section.
|
||||
const appliedFields = [
|
||||
...Object.keys(payload.global || {}),
|
||||
...Object.keys(payload.projects || {}),
|
||||
].filter((key) => !isMovedSettingsKey(key));
|
||||
...[
|
||||
...Object.keys(payload.global || {}),
|
||||
...Object.keys(payload.projects || {}),
|
||||
].filter((key) => !isMovedSettingsKey(key)),
|
||||
...appliedWorkflowSettingKeys,
|
||||
];
|
||||
const skippedFields = result.error ? appliedFields : [];
|
||||
|
||||
await central.close();
|
||||
@@ -97,6 +161,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
success: result.success,
|
||||
appliedFields,
|
||||
skippedFields,
|
||||
workflowSettingsCount,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -13,14 +13,79 @@ import {
|
||||
} from "./register-settings-sync-helpers.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
function computeSettingsDiff(
|
||||
remoteSettings: { global?: Record<string, unknown>; project?: Record<string, unknown> },
|
||||
type WorkflowSettingsSyncSection = Record<string, Record<string, unknown>>;
|
||||
|
||||
type WorkflowSettingsSyncStore = {
|
||||
getWorkflowSettingsProjectId(): string;
|
||||
updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type SettingsDiff = {
|
||||
global: string[];
|
||||
project: string[];
|
||||
workflowSettings: Record<string, string[]>;
|
||||
};
|
||||
|
||||
function extractRejectedSettingIds(err: unknown): string[] {
|
||||
if (!err || typeof err !== "object") return [];
|
||||
const rejections = (err as { rejections?: unknown }).rejections;
|
||||
if (!Array.isArray(rejections)) return [];
|
||||
const ids: string[] = [];
|
||||
for (const rejection of rejections) {
|
||||
if (rejection && typeof rejection === "object" && typeof (rejection as { settingId?: unknown }).settingId === "string") {
|
||||
ids.push((rejection as { settingId: string }).settingId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function applyWorkflowSettingsSection(
|
||||
store: WorkflowSettingsSyncStore,
|
||||
section: WorkflowSettingsSyncSection | undefined,
|
||||
): Promise<{ count: number; keys: string[] }> {
|
||||
if (!section) return { count: 0, keys: [] };
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
let count = 0;
|
||||
const keys: string[] = [];
|
||||
|
||||
for (const [workflowId, rawValues] of Object.entries(section)) {
|
||||
if (!rawValues || typeof rawValues !== "object" || Array.isArray(rawValues)) continue;
|
||||
const patch: Record<string, unknown> = { ...rawValues };
|
||||
while (Object.keys(patch).length > 0) {
|
||||
try {
|
||||
await store.updateWorkflowSettingValues(workflowId, projectId, patch);
|
||||
const appliedKeys = Object.entries(patch)
|
||||
.filter(([, value]) => value !== null)
|
||||
.map(([key]) => key);
|
||||
count += appliedKeys.length;
|
||||
keys.push(...appliedKeys);
|
||||
break;
|
||||
} catch (err) {
|
||||
const rejectedIds = extractRejectedSettingIds(err);
|
||||
if (rejectedIds.length === 0) break;
|
||||
for (const settingId of rejectedIds) {
|
||||
delete patch[settingId];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { count, keys };
|
||||
}
|
||||
|
||||
export function computeSettingsDiff(
|
||||
remoteSettings: {
|
||||
global?: Record<string, unknown>;
|
||||
project?: Record<string, unknown>;
|
||||
workflowSettings?: Record<string, Record<string, unknown>>;
|
||||
},
|
||||
localGlobalSettings: Record<string, unknown>,
|
||||
localProjectSettings: Record<string, unknown>,
|
||||
): { global: string[]; project: string[] } {
|
||||
// Moved (tombstoned) keys are excluded from the diff entirely (KTD-8): workflow
|
||||
// settings are not synced across nodes yet, so they must never appear in a
|
||||
// diff/push/pull field list — even if a mid-migration peer still carries them.
|
||||
localWorkflowSettings?: Record<string, Record<string, unknown>>,
|
||||
): SettingsDiff {
|
||||
// Moved (tombstoned) keys are excluded from the global/project diff entirely
|
||||
// (KTD-8): workflow settings now sync in a dedicated payload section, and a
|
||||
// mid-migration peer's stale flat values must never reappear in global/project
|
||||
// diff/push/pull field lists.
|
||||
const globalKeys = Array.from(new Set([
|
||||
...Object.keys(remoteSettings.global ?? {}),
|
||||
...Object.keys(localGlobalSettings ?? {}),
|
||||
@@ -30,9 +95,30 @@ function computeSettingsDiff(
|
||||
...Object.keys(localProjectSettings ?? {}),
|
||||
])).filter((key) => !isMovedSettingsKey(key));
|
||||
|
||||
const remoteWorkflowSettings = remoteSettings.workflowSettings ?? {};
|
||||
const localWorkflowValues = localWorkflowSettings ?? {};
|
||||
const workflowSettings: Record<string, string[]> = {};
|
||||
const workflowIds = Array.from(new Set([
|
||||
...Object.keys(remoteWorkflowSettings),
|
||||
...Object.keys(localWorkflowValues),
|
||||
]));
|
||||
for (const workflowId of workflowIds) {
|
||||
const remoteValues = remoteWorkflowSettings[workflowId] ?? {};
|
||||
const localValues = localWorkflowValues[workflowId] ?? {};
|
||||
const settingKeys = Array.from(new Set([
|
||||
...Object.keys(remoteValues),
|
||||
...Object.keys(localValues),
|
||||
]));
|
||||
const differingKeys = settingKeys.filter((key) => JSON.stringify(remoteValues[key]) !== JSON.stringify(localValues[key]));
|
||||
if (differingKeys.length > 0) {
|
||||
workflowSettings[workflowId] = differingKeys;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
global: globalKeys.filter((key) => JSON.stringify(remoteSettings.global?.[key]) !== JSON.stringify(localGlobalSettings[key])),
|
||||
project: projectKeys.filter((key) => JSON.stringify(remoteSettings.project?.[key]) !== JSON.stringify(localProjectSettings[key])),
|
||||
workflowSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,18 +188,20 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
// Get local global settings
|
||||
const globalSettingsStore = store.getGlobalSettingsStore();
|
||||
const globalSettings = await globalSettingsStore.getSettings();
|
||||
const workflowSettings = store.listWorkflowSettingValuesForProject();
|
||||
|
||||
// Build sync payload
|
||||
const payloadWithoutChecksum = {
|
||||
global: globalSettings,
|
||||
projects: { [basename(store.getRootDir())]: projectSettings.project },
|
||||
workflowSettings,
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: 1 as const,
|
||||
};
|
||||
|
||||
// Compute checksum over the canonical settings payload shape only.
|
||||
// Do not include sourceNodeId in this hash; applyRemoteSettings() validates
|
||||
// checksums against { global, projects, exportedAt, version }.
|
||||
// checksums against the payload fields, including workflowSettings when present.
|
||||
const { createHash } = await import("node:crypto");
|
||||
const checksum = createHash("sha256").update(JSON.stringify(payloadWithoutChecksum)).digest("hex");
|
||||
const localPeerInfo = await central.getLocalPeerInfo();
|
||||
@@ -140,6 +228,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const syncedFields = [
|
||||
...Object.keys(globalSettings),
|
||||
...Object.keys(projectSettings.project),
|
||||
...Object.values(workflowSettings).flatMap((values) => Object.keys(values)),
|
||||
];
|
||||
|
||||
res.json({ success: true, syncedFields });
|
||||
@@ -185,6 +274,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const remoteSettings = await fetchFromRemoteNode(node, "/api/settings/scopes") as {
|
||||
global: Record<string, unknown>;
|
||||
project: Record<string, unknown>;
|
||||
workflowSettings?: WorkflowSettingsSyncSection;
|
||||
};
|
||||
|
||||
if (conflictResolution === "manual") {
|
||||
@@ -196,20 +286,22 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
// Get local settings for diff comparison
|
||||
const localProjectSettings = await store.getSettingsByScope();
|
||||
const localGlobalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
const localWorkflowSettings = store.listWorkflowSettingValuesForProject();
|
||||
|
||||
// Compute diff: field names that differ between local and remote
|
||||
const { global: diffGlobal, project: diffProject } = computeSettingsDiff(
|
||||
const { global: diffGlobal, project: diffProject, workflowSettings: diffWorkflowSettings } = computeSettingsDiff(
|
||||
remoteSettings,
|
||||
localGlobalSettings as Record<string, unknown>,
|
||||
localProjectSettings.project as Record<string, unknown>,
|
||||
localWorkflowSettings,
|
||||
);
|
||||
|
||||
await central.close();
|
||||
|
||||
res.json({
|
||||
diff: { global: diffGlobal, project: diffProject },
|
||||
diff: { global: diffGlobal, project: diffProject, workflowSettings: diffWorkflowSettings },
|
||||
remoteSettings,
|
||||
localSettings: { global: localGlobalSettings, project: localProjectSettings.project },
|
||||
localSettings: { global: localGlobalSettings, project: localProjectSettings.project, workflowSettings: localWorkflowSettings },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -221,6 +313,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const payloadWithoutChecksum = {
|
||||
global: remoteSettings.global,
|
||||
projects: remoteSettings.project as Record<string, ProjectSettings>,
|
||||
workflowSettings: remoteSettings.workflowSettings,
|
||||
exportedAt,
|
||||
version: 1 as const,
|
||||
};
|
||||
@@ -230,6 +323,9 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
...payloadWithoutChecksum,
|
||||
checksum,
|
||||
});
|
||||
const workflowApplyResult = result.success
|
||||
? await applyWorkflowSettingsSection(store, remoteSettings.workflowSettings)
|
||||
: { count: 0, keys: [] };
|
||||
|
||||
// Record sync
|
||||
await central.updateSettingsSyncState(node.id, {
|
||||
@@ -243,6 +339,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const appliedFields = [
|
||||
...Object.keys(remoteSettings.global || {}),
|
||||
...Object.keys(remoteSettings.project || {}),
|
||||
...workflowApplyResult.keys,
|
||||
];
|
||||
const skippedFields = result.error ? Object.keys(remoteSettings.global || {}) : [];
|
||||
|
||||
@@ -250,6 +347,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
success: result.success,
|
||||
appliedFields,
|
||||
skippedFields,
|
||||
workflowSettingsCount: workflowApplyResult.count,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
@@ -268,7 +366,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
* lastSyncDirection: string | null,
|
||||
* localUpdatedAt: string,
|
||||
* remoteReachable: boolean,
|
||||
* diff: { global: string[], project: string[] }
|
||||
* diff: { global: string[], project: string[], workflowSettings: Record<string, string[]> }
|
||||
* }
|
||||
*/
|
||||
router.get("/nodes/:id/settings/sync-status", async (req, res) => {
|
||||
@@ -297,15 +395,17 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
|
||||
// Try to fetch remote settings
|
||||
let remoteReachable = false;
|
||||
let remoteSettings: { global: Record<string, unknown>; project: Record<string, unknown> } | null = null;
|
||||
let remoteSettings: { global: Record<string, unknown>; project: Record<string, unknown>; workflowSettings?: WorkflowSettingsSyncSection } | null = null;
|
||||
let diffGlobal: string[] = [];
|
||||
let diffProject: string[] = [];
|
||||
let diffWorkflowSettings: Record<string, string[]> = {};
|
||||
let denialReason: SyncStatusDenialReason | null = null; // FN-4847: stable, non-leaking denial classification for degraded probes.
|
||||
|
||||
try {
|
||||
remoteSettings = await fetchFromRemoteNode(node, "/api/settings/scopes") as {
|
||||
global: Record<string, unknown>;
|
||||
project: Record<string, unknown>;
|
||||
workflowSettings?: WorkflowSettingsSyncSection;
|
||||
};
|
||||
remoteReachable = true;
|
||||
|
||||
@@ -315,9 +415,11 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
rs,
|
||||
localGlobalSettings as Record<string, unknown>,
|
||||
localProjectSettings.project as Record<string, unknown>,
|
||||
store.listWorkflowSettingValuesForProject(),
|
||||
);
|
||||
diffGlobal = diff.global;
|
||||
diffProject = diff.project;
|
||||
diffWorkflowSettings = diff.workflowSettings;
|
||||
} catch (err) {
|
||||
// FN-4847: Remote probe failures are classified into actionable, enum-only denial reasons.
|
||||
denialReason = classifySyncStatusDenialReason(err);
|
||||
@@ -331,7 +433,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
localUpdatedAt: syncState?.updatedAt ?? new Date().toISOString(),
|
||||
remoteReachable,
|
||||
actionableDenialReason: denialReason, // FN-4847: explicit null on success, enum value on degraded failures.
|
||||
diff: { global: diffGlobal, project: diffProject },
|
||||
diff: { global: diffGlobal, project: diffProject, workflowSettings: diffWorkflowSettings },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
Reference in New Issue
Block a user