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:
5
.changeset/move-experimental-remote-to-global.md
Normal file
5
.changeset/move-experimental-remote-to-global.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Move `experimentalFeatures` and `remoteAccess` from project-scoped settings to global-scoped settings, including settings schema/type updates, save-path migration, dashboard routes/UI, and regression coverage updates.
|
||||||
@@ -10,7 +10,7 @@ This runbook is the canonical operator reference for Fusion Remote Access across
|
|||||||
|
|
||||||
It documents only behavior implemented in the current codebase:
|
It documents only behavior implemented in the current codebase:
|
||||||
|
|
||||||
- Project-scoped `remoteAccess` settings in `.fusion/config.json`
|
- Global-scoped `remoteAccess` settings in `~/.fusion/settings.json`
|
||||||
- API endpoints under `/api/remote/*` and `/api/remote-access/auth/login-url`
|
- API endpoints under `/api/remote/*` and `/api/remote-access/auth/login-url`
|
||||||
- Public login handoff route `GET /remote-login?rt=...`
|
- Public login handoff route `GET /remote-login?rt=...`
|
||||||
- Engine tunnel lifecycle and safe restore diagnostics
|
- Engine tunnel lifecycle and safe restore diagnostics
|
||||||
@@ -21,8 +21,8 @@ It documents only behavior implemented in the current codebase:
|
|||||||
|
|
||||||
## 1.1 General requirements
|
## 1.1 General requirements
|
||||||
|
|
||||||
- Remote Access is **project-scoped** (`ProjectSettings.remoteAccess` in `packages/core/src/types.ts`).
|
- Remote Access is **global-scoped** (`GlobalSettings.remoteAccess` in `packages/core/src/types.ts`).
|
||||||
- Configure it in dashboard settings (Remote tab) or via `PUT /api/settings`.
|
- Configure it in dashboard settings (Remote tab) or via `PUT /api/settings/global` or `PUT /api/remote/settings`.
|
||||||
- A provider must be selected (`remoteAccess.activeProvider`) before tunnel start.
|
- A provider must be selected (`remoteAccess.activeProvider`) before tunnel start.
|
||||||
- Start/stop is always manual through `/api/remote/tunnel/start` and `/api/remote/tunnel/stop`.
|
- Start/stop is always manual through `/api/remote/tunnel/start` and `/api/remote/tunnel/stop`.
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
|||||||
| `dashboardCurrentNodeId` | `string` | `undefined` | Currently selected dashboard node ID. Restores the last-viewed node on fresh browser/PWA sessions. `undefined` means viewing the local node. |
|
| `dashboardCurrentNodeId` | `string` | `undefined` | Currently selected dashboard node ID. Restores the last-viewed node on fresh browser/PWA sessions. `undefined` means viewing the local node. |
|
||||||
| `dashboardCurrentProjectIdByNode` | `Record<string, string>` | `undefined` | Map of node ID to last-selected project ID. Use key `"local"` for the local node. Persists project context across browser restarts and PWA sessions. |
|
| `dashboardCurrentProjectIdByNode` | `Record<string, string>` | `undefined` | Map of node ID to last-selected project ID. Use key `"local"` for the local node. Persists project context across browser restarts and PWA sessions. |
|
||||||
| `researchGlobalDefaults` | `ResearchGlobalDefaults` | `{ searchProvider: undefined, synthesisProvider: undefined, synthesisModelId: undefined, enabledSources: { webSearch: true, pageFetch: true, github: false, localDocs: true, llmSynthesis: true }, maxSourcesPerRun: 20, defaultExportFormat: "markdown" }` | Global Research defaults shared by all projects. Project overrides come from `researchSettings`. |
|
| `researchGlobalDefaults` | `ResearchGlobalDefaults` | `{ searchProvider: undefined, synthesisProvider: undefined, synthesisModelId: undefined, enabledSources: { webSearch: true, pageFetch: true, github: false, localDocs: true, llmSynthesis: true }, maxSourcesPerRun: 20, defaultExportFormat: "markdown" }` | Global Research defaults shared by all projects. Project overrides come from `researchSettings`. |
|
||||||
|
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView` for standalone Research route visibility. |
|
||||||
|
| `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. |
|
||||||
|
|
||||||
### Notification providers (pluggable)
|
### Notification providers (pluggable)
|
||||||
|
|
||||||
@@ -240,7 +242,6 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
|||||||
| `reviewHandoffPolicy` | `"disabled" \| "comment-triggered" \| "always"` | `"disabled"` | Policy for agent-to-user review handoff detection. |
|
| `reviewHandoffPolicy` | `"disabled" \| "comment-triggered" \| "always"` | `"disabled"` | Policy for agent-to-user review handoff detection. |
|
||||||
| `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button (chat remains available via More menu). |
|
| `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button (chat remains available via More menu). |
|
||||||
| `researchSettings` | `ResearchProjectSettings` | `{ enabled: true, searchProvider: undefined, synthesisProvider: undefined, synthesisModelId: undefined, enabledSources: { webSearch: true, pageFetch: true, github: false, localDocs: true, llmSynthesis: true }, limits: { maxConcurrentRuns: 3, maxSourcesPerRun: 20, maxDurationMs: 300000, requestTimeoutMs: 30000 } }` | Project-specific Research enablement/overrides. Resolved together with `researchGlobalDefaults` via `resolveResearchSettings()`. |
|
| `researchSettings` | `ResearchProjectSettings` | `{ enabled: true, searchProvider: undefined, synthesisProvider: undefined, synthesisModelId: undefined, enabledSources: { webSearch: true, pageFetch: true, github: false, localDocs: true, llmSynthesis: true }, limits: { maxConcurrentRuns: 3, maxSourcesPerRun: 20, maxDurationMs: 300000, requestTimeoutMs: 30000 } }` | Project-specific Research enablement/overrides. Resolved together with `researchGlobalDefaults` via `resolveResearchSettings()`. |
|
||||||
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Project-scoped experimental feature flags. Includes `experimentalFeatures.researchView` for standalone Research route visibility. |
|
|
||||||
|
|
||||||
### Research settings hierarchy and credentials
|
### Research settings hierarchy and credentials
|
||||||
|
|
||||||
@@ -286,9 +287,9 @@ See also:
|
|||||||
- [Multi-Project → Node Routing](./multi-project.md#node-routing)
|
- [Multi-Project → Node Routing](./multi-project.md#node-routing)
|
||||||
- [Architecture → Task Routing Architecture](./architecture.md#task-routing-architecture)
|
- [Architecture → Task Routing Architecture](./architecture.md#task-routing-architecture)
|
||||||
|
|
||||||
### Remote Access settings (project-scoped)
|
### Remote Access settings (global-scoped)
|
||||||
|
|
||||||
Remote access settings are project-only (stored in `.fusion/config.json`), not global.
|
Remote access settings are global-only (stored in `~/.fusion/settings.json`), not project-scoped.
|
||||||
The canonical persisted shape is a nested `remoteAccess` object.
|
The canonical persisted shape is a nested `remoteAccess` object.
|
||||||
|
|
||||||
Use **[Remote Access runbook](./remote-access.md)** for setup prerequisites (Tailscale/Cloudflare), tokenized login-link security caveats, and operational troubleshooting. Keep this section as a schema reference.
|
Use **[Remote Access runbook](./remote-access.md)** for setup prerequisites (Tailscale/Cloudflare), tokenized login-link security caveats, and operational troubleshooting. Keep this section as a schema reference.
|
||||||
@@ -319,9 +320,9 @@ When `remoteAccess.activeProvider` is `tailscale` and the Fusion-managed tunnel
|
|||||||
| `remoteAccess.lifecycle.wasRunningOnShutdown` | `boolean` | `false` | Internal marker written by runtime lifecycle management; explicit manual stop clears this to prevent unintended restart restore. |
|
| `remoteAccess.lifecycle.wasRunningOnShutdown` | `boolean` | `false` | Internal marker written by runtime lifecycle management; explicit manual stop clears this to prevent unintended restart restore. |
|
||||||
| `remoteAccess.lifecycle.lastRunningProvider` | `"tailscale" \| "cloudflare" \| null` | `null` | Internal provider marker used for startup restore gating; stale markers are cleared when restore is skipped/failed. |
|
| `remoteAccess.lifecycle.lastRunningProvider` | `"tailscale" \| "cloudflare" \| null` | `null` | Internal provider marker used for startup restore gating; stale markers are cleared when restore is skipped/failed. |
|
||||||
|
|
||||||
Patch semantics for `PUT /api/settings`:
|
Patch semantics for global updates (`PUT /api/settings/global` and `PUT /api/remote/settings`):
|
||||||
- `remoteAccess` patches are **deep-merged** so sibling branches are preserved.
|
- `remoteAccess` patches are **deep-merged** so sibling branches are preserved.
|
||||||
- `remoteAccess: null` clears the full project override (falls back to defaults).
|
- `remoteAccess: null` clears the full global override (falls back to defaults).
|
||||||
- Nested `null` clears only the targeted nested key/branch.
|
- Nested `null` clears only the targeted nested key/branch.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -758,7 +759,7 @@ See also: [Workflow Steps](./workflow-steps.md) for how `scripts` and workflow m
|
|||||||
|
|
||||||
## Experimental Features
|
## Experimental Features
|
||||||
|
|
||||||
The `experimentalFeatures` setting provides a first-class mechanism for managing project-scoped experimental feature toggles. This allows teams to explicitly mark capabilities as experimental and toggle them on/off from a dedicated section in the Settings dashboard.
|
The `experimentalFeatures` setting provides a first-class mechanism for managing global-scoped experimental feature toggles. This allows users to explicitly mark capabilities as experimental and toggle them on/off from a dedicated section in the Settings dashboard.
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
@@ -787,7 +788,7 @@ The `experimentalFeatures` setting provides a first-class mechanism for managing
|
|||||||
|
|
||||||
The Experimental Features section in Settings shows:
|
The Experimental Features section in Settings shows:
|
||||||
- Feature name and enabled/disabled toggle for each configured feature
|
- Feature name and enabled/disabled toggle for each configured feature
|
||||||
- Project scope indicator (features are project-specific, not global)
|
- Global scope indicator (features are shared across projects)
|
||||||
- Description explaining the purpose of experimental features
|
- Description explaining the purpose of experimental features
|
||||||
|
|
||||||
Common built-in dashboard flags include:
|
Common built-in dashboard flags include:
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ describe("settings key parity", () => {
|
|||||||
expect(isGlobalSettingsKey("maxConcurrent")).toBe(false);
|
expect(isGlobalSettingsKey("maxConcurrent")).toBe(false);
|
||||||
expect(isProjectSettingsKey("maxConcurrent")).toBe(true);
|
expect(isProjectSettingsKey("maxConcurrent")).toBe(true);
|
||||||
expect(isProjectSettingsKey("heartbeatMultiplier")).toBe(true);
|
expect(isProjectSettingsKey("heartbeatMultiplier")).toBe(true);
|
||||||
expect(isProjectSettingsKey("remoteAccess")).toBe(true);
|
expect(isProjectSettingsKey("remoteAccess")).toBe(false);
|
||||||
expect(isProjectSettingsKey("researchSettings")).toBe(true);
|
expect(isProjectSettingsKey("researchSettings")).toBe(true);
|
||||||
expect(isGlobalSettingsKey("researchGlobalDefaults")).toBe(true);
|
expect(isGlobalSettingsKey("researchGlobalDefaults")).toBe(true);
|
||||||
expect(isProjectSettingsKey("themeMode")).toBe(false);
|
expect(isProjectSettingsKey("themeMode")).toBe(false);
|
||||||
expect(isGlobalSettingsKey("remoteAccess")).toBe(false);
|
expect(isGlobalSettingsKey("remoteAccess")).toBe(true);
|
||||||
expect(isGlobalSettingsKey("researchSettings")).toBe(false);
|
expect(isGlobalSettingsKey("researchSettings")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,14 +62,14 @@ describe("settings key parity", () => {
|
|||||||
expect(DEFAULT_PROJECT_SETTINGS.heartbeatMultiplier).toBe(1);
|
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 globalKeys = GLOBAL_SETTINGS_KEYS as readonly string[];
|
||||||
const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[];
|
const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[];
|
||||||
|
|
||||||
expect(projectKeys).toContain("remoteAccess");
|
expect(projectKeys).not.toContain("remoteAccess");
|
||||||
expect(globalKeys).not.toContain("remoteAccess");
|
expect(globalKeys).toContain("remoteAccess");
|
||||||
expect(DEFAULT_PROJECT_SETTINGS.remoteAccess).toBeDefined();
|
expect(DEFAULT_GLOBAL_SETTINGS.remoteAccess).toBeDefined();
|
||||||
expect((DEFAULT_GLOBAL_SETTINGS as Record<string, unknown>).remoteAccess).toBeUndefined();
|
expect((DEFAULT_PROJECT_SETTINGS as Record<string, unknown>).remoteAccess).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("No key appears in both GLOBAL_SETTINGS_KEYS and PROJECT_SETTINGS_KEYS", () => {
|
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 () => {
|
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.close();
|
||||||
store = new TaskStore(rootDir, globalDir);
|
store = new TaskStore(rootDir, globalDir);
|
||||||
await store.init();
|
await store.init();
|
||||||
|
|
||||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.remoteAccess).toEqual(baseRemoteAccess);
|
expect(settings.remoteAccess).toEqual(baseRemoteAccess);
|
||||||
|
|
||||||
const { project, global } = await store.getSettingsByScope();
|
const { project, global } = await store.getSettingsByScope();
|
||||||
expect(project.remoteAccess).toEqual(baseRemoteAccess);
|
expect((project as Record<string, unknown>).remoteAccess).toBeUndefined();
|
||||||
expect((global as Record<string, unknown>).remoteAccess).toBeUndefined();
|
expect(global.remoteAccess).toEqual(baseRemoteAccess);
|
||||||
|
|
||||||
store.close();
|
store.close();
|
||||||
store = new TaskStore(rootDir, globalDir);
|
store = new TaskStore(rootDir, globalDir);
|
||||||
@@ -2821,53 +2818,22 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("patching remoteAccess.providers.tailscale preserves providers.cloudflare", async () => {
|
it("patching remoteAccess.providers.tailscale preserves providers.cloudflare", async () => {
|
||||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||||
|
await store.updateGlobalSettings({ remoteAccess: { providers: { tailscale: { enabled: false, hostname: "alt-tail.ts.net", targetPort: 3000, acceptRoutes: false } } } } as any);
|
||||||
await store.updateSettings({
|
|
||||||
remoteAccess: {
|
|
||||||
providers: {
|
|
||||||
tailscale: {
|
|
||||||
enabled: false,
|
|
||||||
hostname: "alt-tail.ts.net",
|
|
||||||
targetPort: 3000,
|
|
||||||
acceptRoutes: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.remoteAccess?.providers.cloudflare).toEqual(baseRemoteAccess.providers.cloudflare);
|
expect(settings.remoteAccess?.providers.cloudflare).toEqual(baseRemoteAccess.providers.cloudflare);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("patching remoteAccess.tokenStrategy.shortLived preserves tokenStrategy.persistent", async () => {
|
it("patching remoteAccess.tokenStrategy.shortLived preserves tokenStrategy.persistent", async () => {
|
||||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||||
|
await store.updateGlobalSettings({ remoteAccess: { tokenStrategy: { shortLived: { enabled: true, ttlMs: 120_000, maxTtlMs: 300_000 } } } } as any);
|
||||||
await store.updateSettings({
|
|
||||||
remoteAccess: {
|
|
||||||
tokenStrategy: {
|
|
||||||
shortLived: {
|
|
||||||
enabled: true,
|
|
||||||
ttlMs: 120_000,
|
|
||||||
maxTtlMs: 300_000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.remoteAccess?.tokenStrategy.persistent).toEqual(baseRemoteAccess.tokenStrategy.persistent);
|
expect(settings.remoteAccess?.tokenStrategy.persistent).toEqual(baseRemoteAccess.tokenStrategy.persistent);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("patching only activeProvider preserves providers, tokenStrategy, and lifecycle", async () => {
|
it("patching only activeProvider preserves providers, tokenStrategy, and lifecycle", async () => {
|
||||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||||
|
await store.updateGlobalSettings({ remoteAccess: { activeProvider: "tailscale" } } as any);
|
||||||
await store.updateSettings({
|
|
||||||
remoteAccess: {
|
|
||||||
activeProvider: "tailscale",
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.remoteAccess?.activeProvider).toBe("tailscale");
|
expect(settings.remoteAccess?.activeProvider).toBe("tailscale");
|
||||||
expect(settings.remoteAccess?.providers).toEqual(baseRemoteAccess.providers);
|
expect(settings.remoteAccess?.providers).toEqual(baseRemoteAccess.providers);
|
||||||
@@ -2876,18 +2842,8 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("nested null clear only removes the targeted token field", async () => {
|
it("nested null clear only removes the targeted token field", async () => {
|
||||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||||
|
await store.updateGlobalSettings({ remoteAccess: { tokenStrategy: { persistent: { token: null } } } } as any);
|
||||||
await store.updateSettings({
|
|
||||||
remoteAccess: {
|
|
||||||
tokenStrategy: {
|
|
||||||
persistent: {
|
|
||||||
token: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.remoteAccess?.tokenStrategy.persistent.enabled).toBe(true);
|
expect(settings.remoteAccess?.tokenStrategy.persistent.enabled).toBe(true);
|
||||||
expect(settings.remoteAccess?.tokenStrategy.persistent.token).toBeUndefined();
|
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 () => {
|
it("top-level null clear removes remoteAccess override and falls back to defaults", async () => {
|
||||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
await store.updateGlobalSettings({ remoteAccess: baseRemoteAccess });
|
||||||
await store.updateSettings({ remoteAccess: null as any });
|
await store.updateGlobalSettings({ remoteAccess: null as any });
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.remoteAccess?.activeProvider).toBeNull();
|
expect(settings.remoteAccess?.activeProvider).toBeNull();
|
||||||
expect(settings.remoteAccess?.tokenStrategy.persistent.token).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", () => {
|
describe("experimentalFeatures settings", () => {
|
||||||
it("defaults to empty object {}", async () => {
|
it("defaults to empty object {}", async () => {
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.experimentalFeatures).toEqual({});
|
expect(settings.experimentalFeatures).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can set experimental features via updateSettings", async () => {
|
it("can set experimental features via updateGlobalSettings", async () => {
|
||||||
await store.updateSettings({
|
await store.updateGlobalSettings({ experimentalFeatures: { "my-feature": true, "another-feature": false } });
|
||||||
experimentalFeatures: { "my-feature": true, "another-feature": false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.experimentalFeatures).toEqual({ "my-feature": true, "another-feature": false });
|
expect(settings.experimentalFeatures).toEqual({ "my-feature": true, "another-feature": false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can enable a single experimental feature", async () => {
|
it("can add and update features using merge semantics", async () => {
|
||||||
await store.updateSettings({
|
await store.updateGlobalSettings({ experimentalFeatures: { "feature-a": true } });
|
||||||
experimentalFeatures: { "my-feature": true },
|
await store.updateGlobalSettings({ experimentalFeatures: { "feature-b": true, "feature-a": false } });
|
||||||
});
|
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
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 () => {
|
it("can remove an experimental feature by setting it to null", async () => {
|
||||||
await store.updateSettings({
|
await store.updateGlobalSettings({ experimentalFeatures: { "feature-a": true, "feature-b": true } });
|
||||||
experimentalFeatures: { "my-feature": true },
|
await store.updateGlobalSettings({ experimentalFeatures: { "feature-a": null } as unknown as Record<string, boolean> });
|
||||||
});
|
|
||||||
|
|
||||||
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>,
|
|
||||||
});
|
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.experimentalFeatures).toEqual({ "feature-b": true });
|
expect(settings.experimentalFeatures).toEqual({ "feature-b": true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can clear experimentalFeatures with null (falls back to default {})", async () => {
|
it("can clear experimentalFeatures with null", async () => {
|
||||||
await store.updateSettings({
|
await store.updateGlobalSettings({ experimentalFeatures: { "my-feature": true } });
|
||||||
experimentalFeatures: { "my-feature": true },
|
await store.updateGlobalSettings({ experimentalFeatures: null as unknown as undefined });
|
||||||
});
|
|
||||||
|
|
||||||
await store.updateSettings({
|
|
||||||
experimentalFeatures: null as unknown as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.experimentalFeatures).toEqual({});
|
expect(settings.experimentalFeatures).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves other settings when experimentalFeatures changes", async () => {
|
it("preserves project settings while experimentalFeatures changes", async () => {
|
||||||
await store.updateSettings({
|
await store.updateSettings({ maxConcurrent: 5, autoMerge: false });
|
||||||
maxConcurrent: 5,
|
await store.updateGlobalSettings({ experimentalFeatures: { "my-feature": true } });
|
||||||
autoMerge: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await store.updateSettings({
|
|
||||||
experimentalFeatures: { "my-feature": true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
expect(settings.maxConcurrent).toBe(5);
|
expect(settings.maxConcurrent).toBe(5);
|
||||||
expect(settings.autoMerge).toBe(false);
|
expect(settings.autoMerge).toBe(false);
|
||||||
expect(settings.experimentalFeatures).toEqual({ "my-feature": true });
|
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 () => {
|
it("handles experimentalFeatures in getSettingsByScope", async () => {
|
||||||
await store.updateSettings({
|
await store.updateGlobalSettings({ experimentalFeatures: { "scoped-feature": true } });
|
||||||
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();
|
||||||
const { project } = await store.getSettingsByScope();
|
|
||||||
expect(project.experimentalFeatures).toEqual({ "scoped-feature": true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles experimentalFeatures in getSettingsFast", async () => {
|
it("handles experimentalFeatures in getSettingsFast", async () => {
|
||||||
await store.updateSettings({
|
await store.updateGlobalSettings({ experimentalFeatures: { "fast-feature": true } });
|
||||||
experimentalFeatures: { "fast-feature": true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const settings = await store.getSettingsFast();
|
const settings = await store.getSettingsFast();
|
||||||
expect(settings.experimentalFeatures).toEqual({ "fast-feature": true });
|
expect(settings.experimentalFeatures).toEqual({ "fast-feature": true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,6 +95,41 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
|||||||
researchMaxSearchResults: 10,
|
researchMaxSearchResults: 10,
|
||||||
researchFetchTimeoutMs: 30_000,
|
researchFetchTimeoutMs: 30_000,
|
||||||
researchUserAgent: "FusionResearchBot/1.0",
|
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>;
|
} satisfies CompleteSettings<GlobalSettings>;
|
||||||
|
|
||||||
/** Default values for project-level settings. */
|
/** Default values for project-level settings. */
|
||||||
@@ -204,40 +239,6 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
missionHealthCheckIntervalMs: 300_000,
|
missionHealthCheckIntervalMs: 300_000,
|
||||||
agentPrompts: undefined,
|
agentPrompts: undefined,
|
||||||
promptOverrides: 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,
|
reflectionEnabled: false,
|
||||||
reflectionIntervalMs: 3_600_000,
|
reflectionIntervalMs: 3_600_000,
|
||||||
reflectionAfterTask: true,
|
reflectionAfterTask: true,
|
||||||
@@ -267,7 +268,6 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
researchDefaultTimeout: 300000,
|
researchDefaultTimeout: 300000,
|
||||||
researchMaxSourcesPerRun: 20,
|
researchMaxSourcesPerRun: 20,
|
||||||
researchMaxSynthesisRounds: 2,
|
researchMaxSynthesisRounds: 2,
|
||||||
experimentalFeatures: {},
|
|
||||||
} satisfies CompleteSettings<ProjectSettings>;
|
} 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)
|
// Handle null values for other top-level keys (non-promptOverrides)
|
||||||
for (const key of Object.keys(projectPatch)) {
|
for (const key of Object.keys(projectPatch)) {
|
||||||
if ((projectPatch as Record<string, unknown>)[key] === null) {
|
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 globalSettings = await this.globalSettingsStore.getSettings();
|
||||||
const previousMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...config.settings } as Settings;
|
const previousMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...config.settings } as Settings;
|
||||||
const updatedProjectSettings = { ...config.settings, ...projectPatch };
|
const updatedProjectSettings = { ...config.settings, ...projectPatch };
|
||||||
@@ -1727,7 +1683,48 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
const config = this.readConfigFast();
|
const config = this.readConfigFast();
|
||||||
const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings;
|
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;
|
const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings;
|
||||||
|
|
||||||
// Emit settings:updated so SSE listeners pick up the change
|
// Emit settings:updated so SSE listeners pick up the change
|
||||||
|
|||||||
@@ -1468,6 +1468,24 @@ export interface GlobalSettings {
|
|||||||
researchFetchTimeoutMs?: number;
|
researchFetchTimeoutMs?: number;
|
||||||
/** User-Agent header for HTTP requests made by research providers. Default: "FusionResearchBot/1.0". */
|
/** User-Agent header for HTTP requests made by research providers. Default: "FusionResearchBot/1.0". */
|
||||||
researchUserAgent?: string;
|
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";
|
export type RemoteAccessProvider = "tailscale" | "cloudflare";
|
||||||
@@ -1954,10 +1972,6 @@ export interface ProjectSettings {
|
|||||||
* "executor-completion", "triage-welcome", "triage-context", "reviewer-verdict",
|
* "executor-completion", "triage-welcome", "triage-context", "reviewer-verdict",
|
||||||
* "merger-conflicts". */
|
* "merger-conflicts". */
|
||||||
promptOverrides?: Record<string, string | null>;
|
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. */
|
/** Enable/disable agent self-reflection workflows. Default: false. */
|
||||||
reflectionEnabled?: boolean;
|
reflectionEnabled?: boolean;
|
||||||
/** How often periodic reflections occur in milliseconds. Default: 3_600_000 (1 hour). */
|
/** 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.
|
* When false, the FAB is hidden but chat remains accessible via the More menu.
|
||||||
* Default: false. */
|
* Default: false. */
|
||||||
showQuickChatFAB?: boolean;
|
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>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
|||||||
{ id: "global-models", label: "Models", scope: "global" },
|
{ id: "global-models", label: "Models", scope: "global" },
|
||||||
{ id: "research-global", label: "Research Defaults", scope: "global" },
|
{ id: "research-global", label: "Research Defaults", scope: "global" },
|
||||||
{ id: "updates", label: "Updates", scope: "global" },
|
{ id: "updates", label: "Updates", scope: "global" },
|
||||||
|
{ id: "experimental", label: "Experimental Features", scope: "global" },
|
||||||
|
{ id: "remote", label: "Remote Access", scope: "global" },
|
||||||
|
|
||||||
// Runtimes group (plugin runtimes with their own settings)
|
// Runtimes group (plugin runtimes with their own settings)
|
||||||
{ id: "__runtimes_header", label: "Runtimes", scope: undefined, isGroupHeader: true },
|
{ id: "__runtimes_header", label: "Runtimes", scope: undefined, isGroupHeader: true },
|
||||||
@@ -200,10 +202,8 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
|||||||
{ id: "merge", label: "Merge", scope: "project" },
|
{ id: "merge", label: "Merge", scope: "project" },
|
||||||
{ id: "memory", label: "Memory", scope: "project" },
|
{ id: "memory", label: "Memory", scope: "project" },
|
||||||
{ id: "research-project", label: "Research", scope: "project" },
|
{ id: "research-project", label: "Research", scope: "project" },
|
||||||
{ id: "experimental", label: "Experimental Features", scope: "project" },
|
|
||||||
{ id: "prompts", label: "Prompts", scope: "project" },
|
{ id: "prompts", label: "Prompts", scope: "project" },
|
||||||
{ id: "backups", label: "Backups", scope: "project" },
|
{ id: "backups", label: "Backups", scope: "project" },
|
||||||
{ id: "remote", label: "Remote Access", scope: "project" },
|
|
||||||
{ id: "plugins", label: "Plugins", scope: "project" },
|
{ id: "plugins", label: "Plugins", scope: "project" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1602,10 +1602,10 @@ describe("SettingsModal", () => {
|
|||||||
await userEvent.click(screen.getByText("Save"));
|
await userEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
|
||||||
expect(payload.experimentalFeatures).toEqual({ devServerView: false, devServer: null });
|
expect(payload.experimentalFeatures).toEqual({ devServerView: false, devServer: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1622,10 +1622,10 @@ describe("SettingsModal", () => {
|
|||||||
await userEvent.click(screen.getByText("Save"));
|
await userEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
|
||||||
expect(payload.experimentalFeatures).toEqual({ insights: true });
|
expect(payload.experimentalFeatures).toEqual({ insights: true });
|
||||||
expect(payload.experimentalFeatures.devServer).toBeUndefined();
|
expect(payload.experimentalFeatures.devServer).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -1708,20 +1708,20 @@ describe("SettingsModal", () => {
|
|||||||
await userEvent.click(screen.getByText("Save"));
|
await userEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
|
||||||
expect(payload.experimentalFeatures).toEqual({ "my-feature": true });
|
expect(payload.experimentalFeatures).toEqual({ "my-feature": true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows project scope banner in Experimental Features section", async () => {
|
it("shows global scope banner in Experimental Features section", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
await openExperimentalFeaturesSection();
|
||||||
|
|
||||||
// Should show project scope indicator
|
// Should show global scope indicator
|
||||||
expect(screen.getByText(/only affect this project/i)).toBeInTheDocument();
|
expect(screen.getByText(/shared across all your fusion projects/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles undefined experimentalFeatures (falls back to empty) but still shows known features", async () => {
|
it("handles undefined experimentalFeatures (falls back to empty) but still shows known features", async () => {
|
||||||
@@ -1757,10 +1757,10 @@ describe("SettingsModal", () => {
|
|||||||
await userEvent.click(screen.getByText("Save"));
|
await userEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
|
||||||
expect(payload.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": true });
|
expect(payload.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -157,10 +157,6 @@ describe("remote access provider/lifecycle contracts", () => {
|
|||||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
remoteAccess: expect.objectContaining({
|
remoteAccess: expect.objectContaining({
|
||||||
activeProvider: "cloudflare",
|
activeProvider: "cloudflare",
|
||||||
providers: expect.objectContaining({
|
|
||||||
tailscale: expect.objectContaining({ enabled: false }),
|
|
||||||
cloudflare: expect.objectContaining({ enabled: false }),
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { DEFAULT_PROJECT_SETTINGS, type TaskStore } from "@fusion/core";
|
import { DEFAULT_GLOBAL_SETTINGS, type TaskStore } from "@fusion/core";
|
||||||
import { createApiRoutes } from "../routes.js";
|
import { createApiRoutes } from "../routes.js";
|
||||||
import { request as performRequest } from "../test-request.js";
|
import { request as performRequest } from "../test-request.js";
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|||||||
return {
|
return {
|
||||||
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
|
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
|
||||||
updateSettings: vi.fn(async (patch: Record<string, unknown>) => patch),
|
updateSettings: vi.fn(async (patch: Record<string, unknown>) => patch),
|
||||||
|
updateGlobalSettings: vi.fn(async (patch: Record<string, unknown>) => patch),
|
||||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||||
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
|
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
|
||||||
getDatabase: vi.fn().mockReturnValue({
|
getDatabase: vi.fn().mockReturnValue({
|
||||||
@@ -86,7 +87,7 @@ async function REQUEST(app: express.Express, method: string, path: string, body?
|
|||||||
describe("remote access API route contracts", () => {
|
describe("remote access API route contracts", () => {
|
||||||
it("supports GET and PUT /api/remote/settings", async () => {
|
it("supports GET and PUT /api/remote/settings", async () => {
|
||||||
const store = createMockStore({
|
const store = createMockStore({
|
||||||
updateSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
|
updateGlobalSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
|
||||||
getSettings: vi.fn()
|
getSettings: vi.fn()
|
||||||
.mockResolvedValueOnce({ remoteAccess: buildRemoteAccessSettings() })
|
.mockResolvedValueOnce({ remoteAccess: buildRemoteAccessSettings() })
|
||||||
.mockResolvedValueOnce({ remoteAccess: { ...buildRemoteAccessSettings(), activeProvider: "tailscale" } }),
|
.mockResolvedValueOnce({ remoteAccess: { ...buildRemoteAccessSettings(), activeProvider: "tailscale" } }),
|
||||||
@@ -122,7 +123,7 @@ describe("remote access API route contracts", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
expect(store.updateGlobalSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
remoteAccess: expect.objectContaining({
|
remoteAccess: expect.objectContaining({
|
||||||
providers: expect.objectContaining({
|
providers: expect.objectContaining({
|
||||||
cloudflare: expect.objectContaining({ quickTunnel: true }),
|
cloudflare: expect.objectContaining({ quickTunnel: true }),
|
||||||
@@ -152,10 +153,10 @@ describe("remote access API route contracts", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds defaults when saving remote settings on a fresh project", async () => {
|
it("seeds defaults when saving remote settings on a fresh project", async () => {
|
||||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
const updateGlobalSettings = vi.fn().mockResolvedValue(undefined);
|
||||||
const store = createMockStore({
|
const store = createMockStore({
|
||||||
getSettings: vi.fn().mockResolvedValue({}),
|
getSettings: vi.fn().mockResolvedValue({}),
|
||||||
updateSettings,
|
updateGlobalSettings,
|
||||||
});
|
});
|
||||||
const { app } = createApp({ store });
|
const { app } = createApp({ store });
|
||||||
|
|
||||||
@@ -166,9 +167,9 @@ describe("remote access API route contracts", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(putRes.status).toBe(200);
|
expect(putRes.status).toBe(200);
|
||||||
expect(updateSettings).toHaveBeenCalledWith({
|
expect(updateGlobalSettings).toHaveBeenCalledWith({
|
||||||
remoteAccess: expect.objectContaining({
|
remoteAccess: expect.objectContaining({
|
||||||
...DEFAULT_PROJECT_SETTINGS.remoteAccess,
|
...DEFAULT_GLOBAL_SETTINGS.remoteAccess,
|
||||||
activeProvider: "tailscale",
|
activeProvider: "tailscale",
|
||||||
providers: expect.objectContaining({
|
providers: expect.objectContaining({
|
||||||
tailscale: expect.objectContaining({ enabled: true, hostname: "first-use.ts.net" }),
|
tailscale: expect.objectContaining({ enabled: true, hostname: "first-use.ts.net" }),
|
||||||
|
|||||||
@@ -14415,7 +14415,7 @@ describe("PUT /settings", () => {
|
|||||||
remoteAccess: mergedRemoteAccess,
|
remoteAccess: mergedRemoteAccess,
|
||||||
};
|
};
|
||||||
|
|
||||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||||||
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
(store.getSettingsFast as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||||||
|
|
||||||
const app = buildApp();
|
const app = buildApp();
|
||||||
@@ -14436,13 +14436,13 @@ describe("PUT /settings", () => {
|
|||||||
const updateRes = await REQUEST(
|
const updateRes = await REQUEST(
|
||||||
app,
|
app,
|
||||||
"PUT",
|
"PUT",
|
||||||
"/api/settings",
|
"/api/settings/global",
|
||||||
JSON.stringify(patch),
|
JSON.stringify(patch),
|
||||||
{ "Content-Type": "application/json" },
|
{ "Content-Type": "application/json" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(updateRes.status).toBe(200);
|
expect(updateRes.status).toBe(200);
|
||||||
expect(store.updateSettings).toHaveBeenCalledWith(patch);
|
expect(store.updateGlobalSettings).toHaveBeenCalledWith(patch);
|
||||||
|
|
||||||
const getRes = await GET(app, "/api/settings");
|
const getRes = await GET(app, "/api/settings");
|
||||||
expect(getRes.status).toBe(200);
|
expect(getRes.status).toBe(200);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
import type { ProjectSettings } from "@fusion/core";
|
import type { GlobalSettings } from "@fusion/core";
|
||||||
|
|
||||||
export type RemoteTokenValidationStatus = "valid" | "missing" | "invalid" | "expired" | "disabled";
|
export type RemoteTokenValidationStatus = "valid" | "missing" | "invalid" | "expired" | "disabled";
|
||||||
export type RemoteTokenType = "persistent" | "short-lived";
|
export type RemoteTokenType = "persistent" | "short-lived";
|
||||||
|
|
||||||
type RemoteAccessSettings = NonNullable<ProjectSettings["remoteAccess"]>;
|
type RemoteAccessSettings = NonNullable<GlobalSettings["remoteAccess"]>;
|
||||||
|
|
||||||
interface ShortLivedTokenEntry {
|
interface ShortLivedTokenEntry {
|
||||||
expiresAtMs: number;
|
expiresAtMs: number;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
DEFAULT_GLOBAL_SETTINGS,
|
||||||
DEFAULT_PROJECT_SETTINGS,
|
DEFAULT_PROJECT_SETTINGS,
|
||||||
GLOBAL_SETTINGS_KEYS,
|
GLOBAL_SETTINGS_KEYS,
|
||||||
QMD_INSTALL_COMMAND,
|
QMD_INSTALL_COMMAND,
|
||||||
@@ -506,7 +507,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
|||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
const remoteAccess = settings.remoteAccess ?? DEFAULT_PROJECT_SETTINGS.remoteAccess;
|
const remoteAccess = settings.remoteAccess ?? DEFAULT_GLOBAL_SETTINGS.remoteAccess;
|
||||||
|
|
||||||
res.json({ settings: toRemoteSettingsPayload(remoteAccess) });
|
res.json({ settings: toRemoteSettingsPayload(remoteAccess) });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -519,7 +520,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
|||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
const remoteAccess = settings.remoteAccess ?? DEFAULT_PROJECT_SETTINGS.remoteAccess;
|
const remoteAccess = settings.remoteAccess ?? DEFAULT_GLOBAL_SETTINGS.remoteAccess;
|
||||||
|
|
||||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
const nextRemoteAccess = {
|
const nextRemoteAccess = {
|
||||||
@@ -573,7 +574,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await scopedStore.updateSettings({ remoteAccess: nextRemoteAccess });
|
await scopedStore.updateGlobalSettings({ remoteAccess: nextRemoteAccess });
|
||||||
res.json({ settings: toRemoteSettingsPayload(nextRemoteAccess) });
|
res.json({ settings: toRemoteSettingsPayload(nextRemoteAccess) });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) throw err;
|
if (err instanceof ApiError) throw err;
|
||||||
@@ -644,7 +645,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
|||||||
}
|
}
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
const remoteAccess = settings.remoteAccess ?? DEFAULT_PROJECT_SETTINGS.remoteAccess;
|
const remoteAccess = settings.remoteAccess ?? DEFAULT_GLOBAL_SETTINGS.remoteAccess;
|
||||||
|
|
||||||
await scopedStore.updateSettings({
|
await scopedStore.updateSettings({
|
||||||
remoteAccess: {
|
remoteAccess: {
|
||||||
|
|||||||
Reference in New Issue
Block a user