feat(FN-3009): migrate remote settings to global scope
This merge migrates Fusion's settings architecture to a unified global scope (FN-3009), consolidating settings types, defaults, merge semantics, updater logic, and routing into a centralized system that aligns dashboard remote settings with global configuration. Secondary changes include adding the Fusion-Task-Id: FN-3009
This commit is contained in:
@@ -50,11 +50,11 @@ describe("settings key parity", () => {
|
||||
expect(isGlobalSettingsKey("maxConcurrent")).toBe(false);
|
||||
expect(isProjectSettingsKey("maxConcurrent")).toBe(true);
|
||||
expect(isProjectSettingsKey("heartbeatMultiplier")).toBe(true);
|
||||
expect(isProjectSettingsKey("remoteAccess")).toBe(true);
|
||||
expect(isProjectSettingsKey("remoteAccess")).toBe(false);
|
||||
expect(isProjectSettingsKey("researchSettings")).toBe(true);
|
||||
expect(isGlobalSettingsKey("researchGlobalDefaults")).toBe(true);
|
||||
expect(isProjectSettingsKey("themeMode")).toBe(false);
|
||||
expect(isGlobalSettingsKey("remoteAccess")).toBe(false);
|
||||
expect(isGlobalSettingsKey("remoteAccess")).toBe(true);
|
||||
expect(isGlobalSettingsKey("researchSettings")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -62,14 +62,14 @@ describe("settings key parity", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.heartbeatMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps remoteAccess scoped to project settings only", () => {
|
||||
it("keeps remoteAccess scoped to global settings only", () => {
|
||||
const globalKeys = GLOBAL_SETTINGS_KEYS as readonly string[];
|
||||
const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[];
|
||||
|
||||
expect(projectKeys).toContain("remoteAccess");
|
||||
expect(globalKeys).not.toContain("remoteAccess");
|
||||
expect(DEFAULT_PROJECT_SETTINGS.remoteAccess).toBeDefined();
|
||||
expect((DEFAULT_GLOBAL_SETTINGS as Record<string, unknown>).remoteAccess).toBeUndefined();
|
||||
expect(projectKeys).not.toContain("remoteAccess");
|
||||
expect(globalKeys).toContain("remoteAccess");
|
||||
expect(DEFAULT_GLOBAL_SETTINGS.remoteAccess).toBeDefined();
|
||||
expect((DEFAULT_PROJECT_SETTINGS as Record<string, unknown>).remoteAccess).toBeUndefined();
|
||||
});
|
||||
|
||||
it("No key appears in both GLOBAL_SETTINGS_KEYS and PROJECT_SETTINGS_KEYS", () => {
|
||||
|
||||
@@ -2796,21 +2796,18 @@ describe("TaskStore", () => {
|
||||
};
|
||||
|
||||
it("round-trips nested remoteAccess settings with both providers, token strategy, and lifecycle", async () => {
|
||||
// Cross-instance persistence test — beforeEach uses in-memory DB
|
||||
// for speed, but this case reloads via a second TaskStore on the
|
||||
// same dir, so we need disk-backed for both.
|
||||
store.close();
|
||||
store = new TaskStore(rootDir, globalDir);
|
||||
await store.init();
|
||||
|
||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
||||
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.remoteAccess).toEqual(baseRemoteAccess);
|
||||
|
||||
const { project, global } = await store.getSettingsByScope();
|
||||
expect(project.remoteAccess).toEqual(baseRemoteAccess);
|
||||
expect((global as Record<string, unknown>).remoteAccess).toBeUndefined();
|
||||
expect((project as Record<string, unknown>).remoteAccess).toBeUndefined();
|
||||
expect(global.remoteAccess).toEqual(baseRemoteAccess);
|
||||
|
||||
store.close();
|
||||
store = new TaskStore(rootDir, globalDir);
|
||||
@@ -2821,53 +2818,22 @@ describe("TaskStore", () => {
|
||||
});
|
||||
|
||||
it("patching remoteAccess.providers.tailscale preserves providers.cloudflare", async () => {
|
||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
||||
|
||||
await store.updateSettings({
|
||||
remoteAccess: {
|
||||
providers: {
|
||||
tailscale: {
|
||||
enabled: false,
|
||||
hostname: "alt-tail.ts.net",
|
||||
targetPort: 3000,
|
||||
acceptRoutes: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||
await store.updateGlobalSettings({ remoteAccess: { providers: { tailscale: { enabled: false, hostname: "alt-tail.ts.net", targetPort: 3000, acceptRoutes: false } } } } as any);
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.remoteAccess?.providers.cloudflare).toEqual(baseRemoteAccess.providers.cloudflare);
|
||||
});
|
||||
|
||||
it("patching remoteAccess.tokenStrategy.shortLived preserves tokenStrategy.persistent", async () => {
|
||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
||||
|
||||
await store.updateSettings({
|
||||
remoteAccess: {
|
||||
tokenStrategy: {
|
||||
shortLived: {
|
||||
enabled: true,
|
||||
ttlMs: 120_000,
|
||||
maxTtlMs: 300_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||
await store.updateGlobalSettings({ remoteAccess: { tokenStrategy: { shortLived: { enabled: true, ttlMs: 120_000, maxTtlMs: 300_000 } } } } as any);
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.remoteAccess?.tokenStrategy.persistent).toEqual(baseRemoteAccess.tokenStrategy.persistent);
|
||||
});
|
||||
|
||||
it("patching only activeProvider preserves providers, tokenStrategy, and lifecycle", async () => {
|
||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
||||
|
||||
await store.updateSettings({
|
||||
remoteAccess: {
|
||||
activeProvider: "tailscale",
|
||||
},
|
||||
} as any);
|
||||
|
||||
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||
await store.updateGlobalSettings({ remoteAccess: { activeProvider: "tailscale" } } as any);
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.remoteAccess?.activeProvider).toBe("tailscale");
|
||||
expect(settings.remoteAccess?.providers).toEqual(baseRemoteAccess.providers);
|
||||
@@ -2876,18 +2842,8 @@ describe("TaskStore", () => {
|
||||
});
|
||||
|
||||
it("nested null clear only removes the targeted token field", async () => {
|
||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
||||
|
||||
await store.updateSettings({
|
||||
remoteAccess: {
|
||||
tokenStrategy: {
|
||||
persistent: {
|
||||
token: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||
await store.updateGlobalSettings({ remoteAccess: { tokenStrategy: { persistent: { token: null } } } } as any);
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.remoteAccess?.tokenStrategy.persistent.enabled).toBe(true);
|
||||
expect(settings.remoteAccess?.tokenStrategy.persistent.token).toBeUndefined();
|
||||
@@ -2895,144 +2851,66 @@ describe("TaskStore", () => {
|
||||
});
|
||||
|
||||
it("top-level null clear removes remoteAccess override and falls back to defaults", async () => {
|
||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
||||
await store.updateSettings({ remoteAccess: null as any });
|
||||
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||
await store.updateGlobalSettings({ remoteAccess: null as any });
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.remoteAccess?.activeProvider).toBeNull();
|
||||
expect(settings.remoteAccess?.tokenStrategy.persistent.token).toBeNull();
|
||||
|
||||
const db = (store as any).db;
|
||||
const row = db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined;
|
||||
const projectSettings = row?.settings ? JSON.parse(row.settings) : {};
|
||||
expect(projectSettings.remoteAccess).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Experimental Features Tests ─────────────────────────────────
|
||||
|
||||
describe("experimentalFeatures settings", () => {
|
||||
it("defaults to empty object {}", async () => {
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.experimentalFeatures).toEqual({});
|
||||
});
|
||||
|
||||
it("can set experimental features via updateSettings", async () => {
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "my-feature": true, "another-feature": false },
|
||||
});
|
||||
|
||||
it("can set experimental features via updateGlobalSettings", async () => {
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { "my-feature": true, "another-feature": false } });
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.experimentalFeatures).toEqual({ "my-feature": true, "another-feature": false });
|
||||
});
|
||||
|
||||
it("can enable a single experimental feature", async () => {
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "my-feature": true },
|
||||
});
|
||||
|
||||
it("can add and update features using merge semantics", async () => {
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { "feature-a": true } });
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { "feature-b": true, "feature-a": false } });
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.experimentalFeatures).toEqual({ "my-feature": true });
|
||||
expect(settings.experimentalFeatures).toEqual({ "feature-a": false, "feature-b": true });
|
||||
});
|
||||
|
||||
it("can update an existing experimental feature", async () => {
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "my-feature": true },
|
||||
});
|
||||
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "my-feature": false },
|
||||
});
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.experimentalFeatures).toEqual({ "my-feature": false });
|
||||
});
|
||||
|
||||
it("can add a new experimental feature (merges with existing)", async () => {
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "feature-a": true },
|
||||
});
|
||||
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "feature-b": true },
|
||||
});
|
||||
|
||||
const settings = await store.getSettings();
|
||||
// Note: updateSettings merges experimentalFeatures, not replaces
|
||||
// To replace entirely, pass null first to clear, then the new values
|
||||
expect(settings.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": true });
|
||||
});
|
||||
|
||||
it("can remove an experimental feature by setting it to null (selective removal)", async () => {
|
||||
// Features can be selectively removed by setting them to null
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "feature-a": true, "feature-b": true },
|
||||
});
|
||||
|
||||
// Remove feature-a by setting it to null (cast needed for TypeScript type safety)
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "feature-a": null } as unknown as Record<string, boolean>,
|
||||
});
|
||||
|
||||
it("can remove an experimental feature by setting it to null", async () => {
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { "feature-a": true, "feature-b": true } });
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { "feature-a": null } as unknown as Record<string, boolean> });
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.experimentalFeatures).toEqual({ "feature-b": true });
|
||||
});
|
||||
|
||||
it("can clear experimentalFeatures with null (falls back to default {})", async () => {
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "my-feature": true },
|
||||
});
|
||||
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: null as unknown as undefined,
|
||||
});
|
||||
|
||||
it("can clear experimentalFeatures with null", async () => {
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { "my-feature": true } });
|
||||
await store.updateGlobalSettings({ experimentalFeatures: null as unknown as undefined });
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.experimentalFeatures).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves other settings when experimentalFeatures changes", async () => {
|
||||
await store.updateSettings({
|
||||
maxConcurrent: 5,
|
||||
autoMerge: false,
|
||||
});
|
||||
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "my-feature": true },
|
||||
});
|
||||
|
||||
it("preserves project settings while experimentalFeatures changes", async () => {
|
||||
await store.updateSettings({ maxConcurrent: 5, autoMerge: false });
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { "my-feature": true } });
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.maxConcurrent).toBe(5);
|
||||
expect(settings.autoMerge).toBe(false);
|
||||
expect(settings.experimentalFeatures).toEqual({ "my-feature": true });
|
||||
});
|
||||
|
||||
it("preserves experimentalFeatures when updating other settings", async () => {
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "feature-a": true, "feature-b": false },
|
||||
});
|
||||
|
||||
await store.updateSettings({ maxConcurrent: 7 });
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.maxConcurrent).toBe(7);
|
||||
expect(settings.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": false });
|
||||
});
|
||||
|
||||
it("handles experimentalFeatures in getSettingsByScope", async () => {
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "scoped-feature": true },
|
||||
});
|
||||
|
||||
const { project } = await store.getSettingsByScope();
|
||||
expect(project.experimentalFeatures).toEqual({ "scoped-feature": true });
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { "scoped-feature": true } });
|
||||
const { global, project } = await store.getSettingsByScope();
|
||||
expect(global.experimentalFeatures).toEqual({ "scoped-feature": true });
|
||||
expect((project as Record<string, unknown>).experimentalFeatures).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles experimentalFeatures in getSettingsFast", async () => {
|
||||
await store.updateSettings({
|
||||
experimentalFeatures: { "fast-feature": true },
|
||||
});
|
||||
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { "fast-feature": true } });
|
||||
const settings = await store.getSettingsFast();
|
||||
expect(settings.experimentalFeatures).toEqual({ "fast-feature": true });
|
||||
});
|
||||
|
||||
@@ -95,6 +95,41 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
researchMaxSearchResults: 10,
|
||||
researchFetchTimeoutMs: 30_000,
|
||||
researchUserAgent: "FusionResearchBot/1.0",
|
||||
remoteAccess: {
|
||||
activeProvider: null,
|
||||
providers: {
|
||||
tailscale: {
|
||||
enabled: false,
|
||||
hostname: "",
|
||||
targetPort: 0,
|
||||
acceptRoutes: false,
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: false,
|
||||
quickTunnel: true,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
},
|
||||
},
|
||||
tokenStrategy: {
|
||||
persistent: {
|
||||
enabled: true,
|
||||
token: null,
|
||||
},
|
||||
shortLived: {
|
||||
enabled: false,
|
||||
ttlMs: 900000,
|
||||
maxTtlMs: 86400000,
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
rememberLastRunning: false,
|
||||
wasRunningOnShutdown: false,
|
||||
lastRunningProvider: null,
|
||||
},
|
||||
},
|
||||
experimentalFeatures: {},
|
||||
} satisfies CompleteSettings<GlobalSettings>;
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
@@ -204,40 +239,6 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
missionHealthCheckIntervalMs: 300_000,
|
||||
agentPrompts: undefined,
|
||||
promptOverrides: undefined,
|
||||
remoteAccess: {
|
||||
activeProvider: null,
|
||||
providers: {
|
||||
tailscale: {
|
||||
enabled: false,
|
||||
hostname: "",
|
||||
targetPort: 0,
|
||||
acceptRoutes: false,
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: false,
|
||||
quickTunnel: true,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
},
|
||||
},
|
||||
tokenStrategy: {
|
||||
persistent: {
|
||||
enabled: true,
|
||||
token: null,
|
||||
},
|
||||
shortLived: {
|
||||
enabled: false,
|
||||
ttlMs: 900000,
|
||||
maxTtlMs: 86400000,
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
rememberLastRunning: false,
|
||||
wasRunningOnShutdown: false,
|
||||
lastRunningProvider: null,
|
||||
},
|
||||
},
|
||||
reflectionEnabled: false,
|
||||
reflectionIntervalMs: 3_600_000,
|
||||
reflectionAfterTask: true,
|
||||
@@ -267,7 +268,6 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
researchDefaultTimeout: 300000,
|
||||
researchMaxSourcesPerRun: 20,
|
||||
researchMaxSynthesisRounds: 2,
|
||||
experimentalFeatures: {},
|
||||
} satisfies CompleteSettings<ProjectSettings>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1636,24 +1636,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deep merge + targeted null clear semantics for remoteAccess
|
||||
const incomingRemoteAccess = (projectPatch as Record<string, unknown>)["remoteAccess"];
|
||||
if (incomingRemoteAccess === null) {
|
||||
delete (config.settings as unknown as Record<string, unknown>)["remoteAccess"];
|
||||
delete (projectPatch as Record<string, unknown>)["remoteAccess"];
|
||||
} else if (isPlainObject(incomingRemoteAccess)) {
|
||||
const existingRemoteAccess = (config.settings as unknown as Record<string, unknown>)["remoteAccess"];
|
||||
const mergedRemoteAccess = deepMergeWithNullDelete(existingRemoteAccess, incomingRemoteAccess);
|
||||
|
||||
if (mergedRemoteAccess === undefined) {
|
||||
delete (config.settings as unknown as Record<string, unknown>)["remoteAccess"];
|
||||
delete (projectPatch as Record<string, unknown>)["remoteAccess"];
|
||||
} else {
|
||||
(config.settings as unknown as Record<string, unknown>)["remoteAccess"] = mergedRemoteAccess;
|
||||
(projectPatch as Record<string, unknown>)["remoteAccess"] = mergedRemoteAccess;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle null values for other top-level keys (non-promptOverrides)
|
||||
for (const key of Object.keys(projectPatch)) {
|
||||
if ((projectPatch as Record<string, unknown>)[key] === null) {
|
||||
@@ -1662,32 +1644,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle experimentalFeatures merging (similar to promptOverrides)
|
||||
const incomingExperimentalFeatures = (projectPatch as Record<string, unknown>)["experimentalFeatures"];
|
||||
if (
|
||||
incomingExperimentalFeatures !== undefined &&
|
||||
typeof incomingExperimentalFeatures === "object" &&
|
||||
incomingExperimentalFeatures !== null &&
|
||||
!Array.isArray(incomingExperimentalFeatures)
|
||||
) {
|
||||
// experimentalFeatures: { key: value } → merge with existing
|
||||
const incomingMap = incomingExperimentalFeatures as Record<string, unknown>;
|
||||
const existingMap = ((config.settings as unknown as Record<string, unknown>)["experimentalFeatures"] as Record<string, boolean>) ?? {};
|
||||
const mergedMap: Record<string, boolean> = { ...existingMap };
|
||||
|
||||
for (const [key, value] of Object.entries(incomingMap)) {
|
||||
// null values remove the feature
|
||||
if (value === null) {
|
||||
delete mergedMap[key];
|
||||
} else if (typeof value === "boolean") {
|
||||
mergedMap[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
(config.settings as unknown as Record<string, unknown>)["experimentalFeatures"] = mergedMap;
|
||||
(projectPatch as Record<string, unknown>)["experimentalFeatures"] = mergedMap;
|
||||
}
|
||||
|
||||
const globalSettings = await this.globalSettingsStore.getSettings();
|
||||
const previousMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...config.settings } as Settings;
|
||||
const updatedProjectSettings = { ...config.settings, ...projectPatch };
|
||||
@@ -1727,7 +1683,48 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
const config = this.readConfigFast();
|
||||
const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings;
|
||||
|
||||
const updatedGlobal = await this.globalSettingsStore.updateSettings(patch);
|
||||
const globalPatch: Partial<GlobalSettings> = { ...patch };
|
||||
|
||||
// Handle deep merge + targeted null clear semantics for remoteAccess
|
||||
const incomingRemoteAccess = (globalPatch as Record<string, unknown>)["remoteAccess"];
|
||||
if (incomingRemoteAccess === null) {
|
||||
(globalPatch as Record<string, unknown>)["remoteAccess"] = null;
|
||||
} else if (isPlainObject(incomingRemoteAccess)) {
|
||||
const existingRemoteAccess = (previousGlobal as Record<string, unknown>)["remoteAccess"];
|
||||
const mergedRemoteAccess = deepMergeWithNullDelete(existingRemoteAccess, incomingRemoteAccess);
|
||||
|
||||
if (mergedRemoteAccess === undefined) {
|
||||
(globalPatch as Record<string, unknown>)["remoteAccess"] = null;
|
||||
} else {
|
||||
(globalPatch as Record<string, unknown>)["remoteAccess"] = mergedRemoteAccess;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle experimentalFeatures merging (similar to promptOverrides)
|
||||
const incomingExperimentalFeatures = (globalPatch as Record<string, unknown>)["experimentalFeatures"];
|
||||
if (incomingExperimentalFeatures === null) {
|
||||
(globalPatch as Record<string, unknown>)["experimentalFeatures"] = null;
|
||||
} else if (
|
||||
incomingExperimentalFeatures !== undefined &&
|
||||
typeof incomingExperimentalFeatures === "object" &&
|
||||
!Array.isArray(incomingExperimentalFeatures)
|
||||
) {
|
||||
const incomingMap = incomingExperimentalFeatures as Record<string, unknown>;
|
||||
const existingMap = ((previousGlobal as Record<string, unknown>)["experimentalFeatures"] as Record<string, boolean>) ?? {};
|
||||
const mergedMap: Record<string, boolean> = { ...existingMap };
|
||||
|
||||
for (const [key, value] of Object.entries(incomingMap)) {
|
||||
if (value === null) {
|
||||
delete mergedMap[key];
|
||||
} else if (typeof value === "boolean") {
|
||||
mergedMap[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
(globalPatch as Record<string, unknown>)["experimentalFeatures"] = mergedMap;
|
||||
}
|
||||
|
||||
const updatedGlobal = await this.globalSettingsStore.updateSettings(globalPatch);
|
||||
const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings;
|
||||
|
||||
// Emit settings:updated so SSE listeners pick up the change
|
||||
|
||||
@@ -1468,6 +1468,24 @@ export interface GlobalSettings {
|
||||
researchFetchTimeoutMs?: number;
|
||||
/** User-Agent header for HTTP requests made by research providers. Default: "FusionResearchBot/1.0". */
|
||||
researchUserAgent?: string;
|
||||
/** Global-scoped remote access configuration persisted in `~/.fusion/settings.json`.
|
||||
* Stores both provider configs, active provider selection, token strategy,
|
||||
* and lifecycle restart metadata for remote tunnel orchestration. */
|
||||
remoteAccess?: RemoteAccessProjectSettings;
|
||||
/** Global-scoped experimental feature toggles.
|
||||
* Each key is a feature flag name, and the value indicates whether it is enabled.
|
||||
* Features not present in this map are considered disabled (fallback to false).
|
||||
* This allows users to explicitly mark capabilities as experimental and toggle
|
||||
* them on/off from the Settings dashboard.
|
||||
*
|
||||
* Example shape:
|
||||
* {
|
||||
* "my-new-feature": true,
|
||||
* "another-experiment": false
|
||||
* }
|
||||
*
|
||||
* Default: {} (empty object — no experimental features enabled). */
|
||||
experimentalFeatures?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export type RemoteAccessProvider = "tailscale" | "cloudflare";
|
||||
@@ -1954,10 +1972,6 @@ export interface ProjectSettings {
|
||||
* "executor-completion", "triage-welcome", "triage-context", "reviewer-verdict",
|
||||
* "merger-conflicts". */
|
||||
promptOverrides?: Record<string, string | null>;
|
||||
/** Project-scoped remote access configuration persisted in `.fusion/config.json`.
|
||||
* Stores both provider configs, active provider selection, token strategy,
|
||||
* and lifecycle restart metadata for remote tunnel orchestration. */
|
||||
remoteAccess?: RemoteAccessProjectSettings;
|
||||
/** Enable/disable agent self-reflection workflows. Default: false. */
|
||||
reflectionEnabled?: boolean;
|
||||
/** How often periodic reflections occur in milliseconds. Default: 3_600_000 (1 hour). */
|
||||
@@ -1975,20 +1989,6 @@ export interface ProjectSettings {
|
||||
* When false, the FAB is hidden but chat remains accessible via the More menu.
|
||||
* Default: false. */
|
||||
showQuickChatFAB?: boolean;
|
||||
/** Project-scoped experimental feature toggles.
|
||||
* Each key is a feature flag name, and the value indicates whether it is enabled.
|
||||
* Features not present in this map are considered disabled (fallback to false).
|
||||
* This allows teams to explicitly mark capabilities as experimental and toggle
|
||||
* them on/off from the Settings dashboard.
|
||||
*
|
||||
* Example shape:
|
||||
* {
|
||||
* "my-new-feature": true,
|
||||
* "another-experiment": false
|
||||
* }
|
||||
*
|
||||
* Default: {} (empty object — no experimental features enabled). */
|
||||
experimentalFeatures?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user