## Why The Import Tasks panel routinely lists issues in languages the operator cannot read. Translation already shipped in #2128, but deliberately **opt-in and preview-only** — its header comment read *"Translation is opt-in (never automatic) so import provenance stays faithful until the operator asks."* This reverses that decision **behind a default-off setting**, so operators who never opt in keep byte-faithful import provenance. The superseded comment is kept and annotated rather than deleted, so the reason the rule changed stays in the code. ### The structural gap #2128 left `POST /github/issues/import` accepts only `{owner, repo, issueNumber}` and **re-fetches the issue server-side**. A translation held in React state could never reach the created task, and the in-memory cache died with the modal. That is why the cache here is server-side rather than in the hook — it's what makes "imported issues carry the translated version" actually true. ## What operators get Auto-translate is **off by default**. When enabled: - The **50 most recent OPEN** foreign-language issues translate on panel load — **list titles**, not just the preview, so the list reads in your language before you click anything. - Translations show **by default**, with a toggle back to the original (hover a translated list title to see the original). - Translations **persist until the issue closes**, so re-opening the panel neither waits nor re-bills. - **Both single and batch import** carry the translation, so the created task reads like the preview you approved. - A **target language** setting (unset = follow the dashboard language) and a dedicated **model lane**, so you can pin a cheap/fast model without dragging the summarization lane onto it. ## Notable decisions | Decision | Why | |---|---| | Detect **before** the model | An issue already in the target language is never sent. Without this, an English repo with the setting on would bill every issue to return its input unchanged. | | Detection moved to `@fusion/core` | The panel and the server must not disagree about which issues are foreign; two copies of a heuristic drift. | | Own rate-limit budget | Translation shared a 10/hour budget with refine/goal-draft. Fanning out per-issue would fail partway **and** starve refine for the hour. | | Cache keyed on a **source hash** | An edited issue misses the cache and re-translates instead of serving stale prose. | | Import is **cache-read only** | A miss imports the original. Import must never block on, or fail because of, translation. | | `project_id` leads the cache PK + full RLS contract | All projects share one flat `project` schema. `verification_cache`'s PK predates that discipline; this table does not copy that mistake. | ## Verification - ✅ `pnpm lint`, `@fusion/core` + `@fusion/dashboard` typecheck - ✅ `pnpm verify:fast` — build + scoped typecheck + real boot smoke (`/api/health`) - ✅ `pnpm test:gate` — 479 tests - ✅ 19 new tests covering the billing invariants (off/closed/same-language ⇒ **no model call**), cache hit/miss-on-edit, the 50 cap, and per-item fail-soft - ✅ `schema-applier` real-Postgres suite (46 tests) exercises migration `0010` and its isolation invariant **Pre-existing failures NOT touched** (confirmed red on `HEAD` before this branch): `AppearanceSection`'s task-popup test, and two PG-cutover keys (`sqliteMigrationNotice`, `postgresMigrationInboxMessageSentAt`) missing description mappings. I left the latter rather than guess an allowlist entry that could mask a real coverage gap. ## Reviewer notes - Short Latin-script prose (a one-line Spanish title) rates only *medium* confidence and won't auto-translate — the existing heuristic is deliberately conservative so English issues are never billed. CJK detects regardless of length. The threshold is the knob if you'd rather bias toward translating. - The RLS/isolation contract in migration `0010` is the part most worth a careful look. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
537 lines
23 KiB
TypeScript
537 lines
23 KiB
TypeScript
/**
|
|
* Save-split logic for SettingsModal (U9 / KTD-10).
|
|
*
|
|
* The modal edits a single merged form that mixes global-scope and
|
|
* project-scope keys. On save it must split that form into two patches with
|
|
* strict scope separation and preserve three subtle semantics:
|
|
*
|
|
* 1. Global keys are routed via {@link isGlobalSettingsKey} to the global
|
|
* patch; project keys via {@link isProjectSettingsKey} to the project
|
|
* patch. (A key can be neither — server-only/UI-only fields are dropped.)
|
|
* 2. Global and project writes are changed-only. This prevents any Settings
|
|
* save from re-sending default global values that can overwrite unrelated
|
|
* user preferences such as notifications or onboarding state.
|
|
* 3. null-as-delete: an explicit clear (current value `undefined`, but the
|
|
* initial value was defined) is written as `null` so it survives
|
|
* `JSON.stringify` and tells the server to delete the key. Plain
|
|
* `undefined` is dropped.
|
|
* 4. changed-only project writes: an inherited/effective project value that
|
|
* the user never touched is NOT serialized as an explicit override —
|
|
* doing so would silently break inheritance for every project setting on
|
|
* every save. Only keys whose value differs from the initial project-scoped
|
|
* value are written.
|
|
*
|
|
* This module is pure (no React, no network) so the regression-critical split
|
|
* behavior is characterized in isolation; the modal shell calls it and performs
|
|
* the actual `updateGlobalSettings`/`updateSettings` writes.
|
|
*/
|
|
import { isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
|
|
import type { GlobalSettings, McpServersSettings, Settings } from "@fusion/core";
|
|
|
|
/**
|
|
* Project-scoped model-override keys whose overrides track inheritance
|
|
* explicitly (changed-only writes with null-as-delete in the project branch).
|
|
*
|
|
* The title-summarizer lane was restored to project settings in FN-5994, so it
|
|
* needs the same changed-only/null-as-delete handling as the project default
|
|
* lane overrides. Execution/planning/validator lanes still live on workflow
|
|
* settings and are filtered out before the project branch is reached.
|
|
*
|
|
* FNXC:Settings-ThinkingLevel 2026-07-10-12:10:
|
|
* The project-scoped title-summarizer fallback thinking companion must travel
|
|
* with its provider/model pair so clearing the inline selector serializes as
|
|
* null-as-delete instead of being dropped as an unchanged inherited value.
|
|
*
|
|
* FNXC:Settings-MergerModel 2026-07-13-07:52:
|
|
* Merger project lane (provider/model/thinking) is project-scoped like
|
|
* title summarizer — not workflow-moved — so it participates in the same
|
|
* changed-only/null-as-delete project-branch write path.
|
|
*
|
|
* FNXC:GitHubImportTranslate 2026-07-15-09:30:
|
|
* The import auto-translation settings are PROJECT-scoped: which language a repo's
|
|
* issues get translated into, and whether to translate at all, is a per-project
|
|
* decision, so they must route to the project patch and inherit when untouched.
|
|
* The lane's provider/model/thinking trio travels with the two non-model keys
|
|
* (`githubImportAutoTranslate`, `importTranslateTargetLocale`) so that clearing the
|
|
* toggle or the target locale serializes as null-as-delete (restoring inheritance)
|
|
* instead of being dropped as an unchanged inherited value. The companion
|
|
* `importTranslateGlobal*` keys are deliberately NOT listed here — they are global
|
|
* scope and are gated by GLOBAL_SECTION_KEYS below.
|
|
*/
|
|
export const MODEL_LANE_KEYS = [
|
|
"defaultProviderOverride", "defaultModelIdOverride",
|
|
"titleSummarizerProvider", "titleSummarizerModelId",
|
|
"titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", "titleSummarizerFallbackThinkingLevel",
|
|
"mergerProvider", "mergerModelId", "mergerThinkingLevel",
|
|
"githubImportAutoTranslate", "importTranslateTargetLocale",
|
|
"importTranslateProvider", "importTranslateModelId", "importTranslateThinkingLevel",
|
|
] as const;
|
|
|
|
const MODEL_LANE_KEY_SET = new Set<string>(MODEL_LANE_KEYS);
|
|
|
|
/*
|
|
FNXC:GitLabEnablement 2026-07-04-00:00:
|
|
FN-7535: the five global GitLab keys must be diffed against the SCOPED global
|
|
initial only — never the merged, project-effective `initialValues` — because
|
|
SettingsModal already edits these keys through a dedicated `globalGitlabSettings`
|
|
state seeded from `scoped.global` (FN-7453). Falling back to merged initialValues
|
|
when the scoped global object lacks the key (e.g. the operator has never saved a
|
|
global value before) let a project override's effective value silently stand in
|
|
for "no change", so a genuine global edit that happened to match the merged value
|
|
was dropped from the global patch. These keys never fall back to `initialValues`.
|
|
*/
|
|
const GLOBAL_GITLAB_SCOPED_ONLY_KEYS = new Set<string>([
|
|
"gitlabEnabled",
|
|
"gitlabInstanceUrl",
|
|
"gitlabApiBaseUrl",
|
|
"gitlabAuthToken",
|
|
"gitlabAuthTokenType",
|
|
]);
|
|
|
|
type RemoteAccessProvider = "tailscale" | "cloudflare";
|
|
type RemoteAccessPatch = NonNullable<GlobalSettings["remoteAccess"]>;
|
|
|
|
/*
|
|
FNXC:SettingsReset 2026-07-04-00:00:
|
|
Exported (not just module-private) so the FN-7506 section-keys registry
|
|
(settings/section-keys.ts) can reuse this as the single source of truth for
|
|
which GLOBAL keys belong to which settings section, instead of duplicating
|
|
the list for the "Reset this menu" feature.
|
|
*/
|
|
export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
|
|
appearance: new Set([
|
|
"themeMode",
|
|
"colorTheme",
|
|
"dashboardFontScalePct",
|
|
"shadcnCustomColors",
|
|
]),
|
|
notifications: new Set([
|
|
"ntfyEnabled",
|
|
"ntfyTopic",
|
|
"ntfyBaseUrl",
|
|
"ntfyAccessToken",
|
|
"ntfyEvents",
|
|
"ntfyDashboardHost",
|
|
"failureNotificationDelayMs",
|
|
"failureNotificationMode",
|
|
"webhookEnabled",
|
|
"webhookUrl",
|
|
"webhookFormat",
|
|
"webhookEvents",
|
|
"notificationProviders",
|
|
]),
|
|
experimental: new Set(["experimentalFeatures"]),
|
|
"global-general": new Set([
|
|
"githubTrackingDefaultRepo",
|
|
"gitlabEnabled",
|
|
"gitlabInstanceUrl",
|
|
"gitlabApiBaseUrl",
|
|
"gitlabAuthToken",
|
|
"gitlabAuthTokenType",
|
|
"language",
|
|
"dismissModalsOnOutsideClick",
|
|
"persistAgentToolOutput",
|
|
"persistAgentThinkingLogPermanent",
|
|
"persistAgentThinkingLogEphemeral",
|
|
"fnBinaryCheckEnabled",
|
|
"updateCheckEnabled",
|
|
"updateCheckFrequency",
|
|
"autoReloadOnVersionChange",
|
|
]),
|
|
/*
|
|
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
|
FN-7553 moves `dashboardKeyboardShortcuts` ownership out of "global-general" into its own dedicated section so the new Keyboard Shortcuts settings section (not General) owns save/reset for this key.
|
|
*/
|
|
"keyboard-shortcuts": new Set(["dashboardKeyboardShortcuts"]),
|
|
"global-mcp": new Set(["mcpServers"]),
|
|
"global-models": new Set([
|
|
"defaultProvider",
|
|
"defaultModelId",
|
|
"fallbackProvider",
|
|
"fallbackModelId",
|
|
"fallbackThinkingLevel",
|
|
"defaultThinkingLevel",
|
|
"modelRouterEnabled",
|
|
"modelRouterCheapProvider",
|
|
"modelRouterCheapModelId",
|
|
"opencodeGoModelSync",
|
|
"openrouterAppAttribution",
|
|
"openrouterModelFilters",
|
|
"openrouterModelSync",
|
|
"openrouterProviderPreferences",
|
|
"executionGlobalProvider",
|
|
"executionGlobalModelId",
|
|
"planningGlobalProvider",
|
|
"planningGlobalModelId",
|
|
"validatorGlobalProvider",
|
|
"validatorGlobalModelId",
|
|
"titleSummarizerGlobalProvider",
|
|
"titleSummarizerGlobalModelId",
|
|
"mergerGlobalProvider",
|
|
"mergerGlobalModelId",
|
|
"mergerGlobalThinkingLevel",
|
|
/*
|
|
FNXC:GitHubImportTranslate 2026-07-15-09:30:
|
|
The import-translate GLOBAL lane keys must be section-allowlisted in both Models
|
|
sections (mirroring merger), otherwise the section gate in the global branch of
|
|
splitSettingsSave silently drops an operator's global lane edit on Save.
|
|
*/
|
|
"importTranslateGlobalProvider",
|
|
"importTranslateGlobalModelId",
|
|
"importTranslateGlobalThinkingLevel",
|
|
]),
|
|
"project-models": new Set([
|
|
"defaultProvider",
|
|
"defaultModelId",
|
|
"fallbackProvider",
|
|
"fallbackModelId",
|
|
"fallbackThinkingLevel",
|
|
"defaultThinkingLevel",
|
|
"modelRouterEnabled",
|
|
"modelRouterCheapProvider",
|
|
"modelRouterCheapModelId",
|
|
"opencodeGoModelSync",
|
|
"openrouterAppAttribution",
|
|
"openrouterModelFilters",
|
|
"openrouterModelSync",
|
|
"openrouterProviderPreferences",
|
|
"executionGlobalProvider",
|
|
"executionGlobalModelId",
|
|
"planningGlobalProvider",
|
|
"planningGlobalModelId",
|
|
"validatorGlobalProvider",
|
|
"validatorGlobalModelId",
|
|
"titleSummarizerGlobalProvider",
|
|
"titleSummarizerGlobalModelId",
|
|
"mergerGlobalProvider",
|
|
"mergerGlobalModelId",
|
|
"mergerGlobalThinkingLevel",
|
|
"importTranslateGlobalProvider",
|
|
"importTranslateGlobalModelId",
|
|
"importTranslateGlobalThinkingLevel",
|
|
]),
|
|
"node-sync": new Set([
|
|
"settingsSyncEnabled",
|
|
"settingsSyncAuth",
|
|
"settingsSyncInterval",
|
|
"settingsSyncConflictResolution",
|
|
]),
|
|
"research-global": new Set([
|
|
"researchGlobalDefaults",
|
|
"researchGlobalEnabled",
|
|
"researchGlobalMaxConcurrentRuns",
|
|
"researchGlobalDefaultTimeout",
|
|
"researchGlobalMaxSourcesPerRun",
|
|
"researchGlobalMaxSynthesisRounds",
|
|
"researchGlobalWebSearchProvider",
|
|
"researchGlobalSearxngUrl",
|
|
"researchGlobalBraveApiKey",
|
|
"researchGlobalGoogleSearchApiKey",
|
|
"researchGlobalGoogleSearchCx",
|
|
"researchGlobalTavilyApiKey",
|
|
"researchGlobalGitHubEnabled",
|
|
"researchGlobalLocalDocsEnabled",
|
|
"researchGlobalMaxSearchResults",
|
|
"researchGlobalFetchTimeoutMs",
|
|
"researchGlobalUserAgent",
|
|
]),
|
|
remote: new Set(["remoteAccess"]),
|
|
};
|
|
|
|
function isGlobalKeyAllowedForSection(key: string, activeSection: string): boolean {
|
|
const sectionKeys = GLOBAL_SECTION_KEYS[activeSection];
|
|
return !sectionKeys || sectionKeys.has(key);
|
|
}
|
|
|
|
export interface SaveSplitInput {
|
|
/** The fully-normalized form payload (after trimming/normalization). */
|
|
payload: Record<string, unknown>;
|
|
/** Initial merged settings, used to detect explicit clears of global keys. */
|
|
initialValues: Settings | null;
|
|
/** Initial scoped values, used to detect changed/cleared project overrides. */
|
|
initialScopedValues: { global: GlobalSettings; project: Partial<Settings> } | null;
|
|
/** The active section id; gates where section-owned values are written. */
|
|
activeSection: string;
|
|
/** Current raw MCP values for both scopes, preserved even after section navigation. */
|
|
scopedMcpValues?: { global: McpServersSettings | undefined; project: McpServersSettings | undefined };
|
|
}
|
|
|
|
export interface SaveSplitResult {
|
|
globalPatch: Partial<GlobalSettings>;
|
|
projectPatch: Partial<Settings>;
|
|
}
|
|
|
|
export type McpSettingsScope = "global" | "project";
|
|
export type ScopedSettingsValues = { global: GlobalSettings; project: Partial<Settings> };
|
|
|
|
/**
|
|
* Return the raw MCP value owned by one settings scope.
|
|
*
|
|
* FNXC:McpSettingsScopes 2026-07-14-21:59:
|
|
* SettingsModal's general form is project-effective, so MCP editing and saving must use the raw values returned by `/api/settings/scopes`. Preserve `undefined` for an absent project override: normalizing it to `{ enabled: false, servers: [] }` would replace global inheritance with an explicit disabled project setting on a no-op save.
|
|
*/
|
|
export function resolveScopedMcpSettings(
|
|
scope: McpSettingsScope,
|
|
scopedSettings: ScopedSettingsValues | null,
|
|
): McpServersSettings | undefined {
|
|
return scope === "global"
|
|
? scopedSettings?.global.mcpServers
|
|
: scopedSettings?.project.mcpServers;
|
|
}
|
|
|
|
function hasOwn(obj: object | null | undefined, key: string): boolean {
|
|
return !!obj && Object.prototype.hasOwnProperty.call(obj, key);
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function settingsValueEquals(left: unknown, right: unknown): boolean {
|
|
if (Object.is(left, right)) return true;
|
|
if (Array.isArray(left) || Array.isArray(right)) {
|
|
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
if (left.length !== right.length) return false;
|
|
return left.every((item, index) => settingsValueEquals(item, right[index]));
|
|
}
|
|
if (isPlainObject(left) || isPlainObject(right)) {
|
|
if (!isPlainObject(left) || !isPlainObject(right)) return false;
|
|
const leftKeys = Object.keys(left);
|
|
const rightKeys = Object.keys(right);
|
|
if (leftKeys.length !== rightKeys.length) return false;
|
|
return leftKeys.every((key) => hasOwn(right, key) && settingsValueEquals(left[key], right[key]));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function readString(payload: Record<string, unknown>, key: string): string | undefined {
|
|
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
|
|
return String(payload[key] ?? "");
|
|
}
|
|
|
|
function readNullableString(payload: Record<string, unknown>, key: string): string | null | undefined {
|
|
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
|
|
return payload[key] ? String(payload[key]) : null;
|
|
}
|
|
|
|
function readBoolean(payload: Record<string, unknown>, key: string): boolean | undefined {
|
|
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
|
|
return Boolean(payload[key]);
|
|
}
|
|
|
|
function readNumber(payload: Record<string, unknown>, key: string, fallback: number): number | undefined {
|
|
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
|
|
return Number(payload[key] ?? fallback);
|
|
}
|
|
|
|
function assignIfPresent<T extends object, K extends keyof T>(target: T, key: K, value: T[K] | undefined): void {
|
|
if (value !== undefined) {
|
|
target[key] = value;
|
|
}
|
|
}
|
|
|
|
function buildRemoteAccessPatch(payload: Record<string, unknown>): Partial<RemoteAccessPatch> | null {
|
|
const patch: Partial<RemoteAccessPatch> = {};
|
|
const activeProvider = hasOwn(payload, "remoteActiveProvider")
|
|
? (payload.remoteActiveProvider as RemoteAccessProvider | null)
|
|
: undefined;
|
|
|
|
if (activeProvider !== undefined) {
|
|
patch.activeProvider = activeProvider;
|
|
}
|
|
|
|
const tailscalePatch: Partial<RemoteAccessPatch["providers"]["tailscale"]> = {};
|
|
assignIfPresent(tailscalePatch, "enabled", readBoolean(payload, "remoteTailscaleEnabled"));
|
|
assignIfPresent(tailscalePatch, "hostname", readString(payload, "remoteTailscaleHostname"));
|
|
assignIfPresent(tailscalePatch, "targetPort", readNumber(payload, "remoteTailscaleTargetPort", 4040));
|
|
assignIfPresent(tailscalePatch, "acceptRoutes", readBoolean(payload, "remoteTailscaleAcceptRoutes"));
|
|
if (activeProvider === "tailscale") {
|
|
tailscalePatch.enabled = true;
|
|
}
|
|
|
|
const cloudflarePatch: Partial<RemoteAccessPatch["providers"]["cloudflare"]> = {};
|
|
assignIfPresent(cloudflarePatch, "enabled", readBoolean(payload, "remoteCloudflareEnabled"));
|
|
assignIfPresent(cloudflarePatch, "quickTunnel", readBoolean(payload, "remoteCloudflareQuickTunnel"));
|
|
assignIfPresent(cloudflarePatch, "tunnelName", readString(payload, "remoteCloudflareTunnelName"));
|
|
assignIfPresent(cloudflarePatch, "tunnelToken", readNullableString(payload, "remoteCloudflareTunnelToken"));
|
|
assignIfPresent(cloudflarePatch, "ingressUrl", readString(payload, "remoteCloudflareIngressUrl"));
|
|
if (activeProvider === "cloudflare") {
|
|
cloudflarePatch.enabled = true;
|
|
}
|
|
|
|
if (Object.keys(tailscalePatch).length > 0 || Object.keys(cloudflarePatch).length > 0) {
|
|
patch.providers = {} as RemoteAccessPatch["providers"];
|
|
if (Object.keys(tailscalePatch).length > 0) {
|
|
patch.providers.tailscale = tailscalePatch as RemoteAccessPatch["providers"]["tailscale"];
|
|
}
|
|
if (Object.keys(cloudflarePatch).length > 0) {
|
|
patch.providers.cloudflare = cloudflarePatch as RemoteAccessPatch["providers"]["cloudflare"];
|
|
}
|
|
}
|
|
|
|
const shortLivedPatch: Partial<RemoteAccessPatch["tokenStrategy"]["shortLived"]> = {};
|
|
assignIfPresent(shortLivedPatch, "enabled", readBoolean(payload, "remoteShortLivedEnabled"));
|
|
assignIfPresent(shortLivedPatch, "ttlMs", readNumber(payload, "remoteShortLivedTtlMs", 900_000));
|
|
assignIfPresent(shortLivedPatch, "maxTtlMs", readNumber(payload, "remoteShortLivedMaxTtlMs", 86_400_000));
|
|
if (Object.keys(shortLivedPatch).length > 0) {
|
|
patch.tokenStrategy = {
|
|
shortLived: shortLivedPatch as RemoteAccessPatch["tokenStrategy"]["shortLived"],
|
|
} as RemoteAccessPatch["tokenStrategy"];
|
|
}
|
|
|
|
const lifecyclePatch: Partial<RemoteAccessPatch["lifecycle"]> = {};
|
|
assignIfPresent(lifecyclePatch, "rememberLastRunning", readBoolean(payload, "remoteRememberLastRunning"));
|
|
assignIfPresent(lifecyclePatch, "wasRunningOnShutdown", readBoolean(payload, "remoteWasRunningOnShutdown"));
|
|
if (hasOwn(payload, "remoteLastStartedProvider") && payload.remoteLastStartedProvider !== undefined) {
|
|
lifecyclePatch.lastRunningProvider = payload.remoteLastStartedProvider as RemoteAccessProvider | null;
|
|
}
|
|
if (Object.keys(lifecyclePatch).length > 0) {
|
|
patch.lifecycle = lifecyclePatch as RemoteAccessPatch["lifecycle"];
|
|
}
|
|
|
|
return Object.keys(patch).length > 0 ? patch : null;
|
|
}
|
|
|
|
/**
|
|
* Split a normalized settings form payload into global and project patches,
|
|
* preserving null-as-delete and changed-only-project-write semantics.
|
|
*/
|
|
export function splitSettingsSave({
|
|
payload,
|
|
initialValues,
|
|
initialScopedValues,
|
|
activeSection,
|
|
scopedMcpValues,
|
|
}: SaveSplitInput): SaveSplitResult {
|
|
const globalPatch: Partial<GlobalSettings> = {};
|
|
|
|
if (activeSection === "remote") {
|
|
/*
|
|
FNXC:RemoteAccessSettings 2026-06-30-00:00:
|
|
Main Settings Save must persist the Remote Access section's flattened form fields into the canonical nested remoteAccess object. Windows users commonly configure Tailscale options and click Save without starting the tunnel, so this path cannot rely on the Start Tunnel auto-save.
|
|
*/
|
|
const remoteAccessPatch = buildRemoteAccessPatch(payload);
|
|
if (remoteAccessPatch) {
|
|
globalPatch.remoteAccess = remoteAccessPatch as RemoteAccessPatch;
|
|
}
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(payload)) {
|
|
if (key === "githubTrackingDefaultRepo" && activeSection !== "global-general") {
|
|
continue;
|
|
}
|
|
if ((key === "gitlabEnabled" || key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection !== "global-general") {
|
|
continue;
|
|
}
|
|
if (key === "mcpServers" && scopedMcpValues) {
|
|
continue;
|
|
}
|
|
if (key === "mcpServers" && activeSection !== "global-mcp") {
|
|
continue;
|
|
}
|
|
if (key === "persistAgentThinkingLog") {
|
|
continue;
|
|
}
|
|
// customProviders is a global key, but it is NOT written through the
|
|
// save-split form. It is persisted via its own REST routes
|
|
// (register-custom-provider-routes.ts -> store.updateGlobalSettings) which
|
|
// mask API keys on read (sanitizeProvider). Routing it through this patch
|
|
// would write the masked keys back and clobber the real credentials.
|
|
if (key === "customProviders") {
|
|
continue;
|
|
}
|
|
if (isGlobalSettingsKey(key)) {
|
|
/*
|
|
FNXC:SettingsPersistence 2026-06-23-00:55:
|
|
Global settings saves must be changed-only, just like project settings. The Settings form carries full default-shaped global values, so emitting unchanged globals can overwrite unrelated user preferences (notifications, onboarding state, theme) when a user saves another section or when experimental-feature normalization allocates a fresh but equivalent object.
|
|
|
|
FNXC:SettingsPersistence 2026-06-23-01:18:
|
|
Global Settings saves are also gated by the active settings section. The form can contain stale/default values from sections the user did not edit, so changed-only comparison alone cannot distinguish an intentional Appearance edit from a default-filled Notifications or onboarding field.
|
|
*/
|
|
if (!isGlobalKeyAllowedForSection(key, activeSection)) {
|
|
continue;
|
|
}
|
|
|
|
if (value === undefined && key === "ntfyAccessToken" && activeSection === "notifications") {
|
|
(globalPatch as Record<string, unknown>)[key] = null;
|
|
continue;
|
|
}
|
|
|
|
const scopedOnly = GLOBAL_GITLAB_SCOPED_ONLY_KEYS.has(key);
|
|
const hasScopedInitial = hasOwn(initialScopedValues?.global, key);
|
|
const hasMergedInitial = !scopedOnly && hasOwn(initialValues, key);
|
|
const initialValue = hasScopedInitial
|
|
? initialScopedValues?.global?.[key as keyof GlobalSettings]
|
|
: scopedOnly
|
|
? undefined
|
|
: initialValues?.[key as keyof GlobalSettings];
|
|
const hasInitialValue = hasScopedInitial || hasMergedInitial;
|
|
|
|
if (settingsValueEquals(value, initialValue)) {
|
|
continue;
|
|
}
|
|
|
|
// null-as-delete: explicit clear is sent as null, plain undefined dropped.
|
|
if (value === undefined && hasInitialValue && initialValue !== undefined) {
|
|
(globalPatch as Record<string, unknown>)[key] = null;
|
|
} else if (value !== undefined) {
|
|
(globalPatch as Record<string, unknown>)[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
const projectPatch: Partial<Settings> = {};
|
|
for (const [key, value] of Object.entries(payload)) {
|
|
if (key === "githubTokenConfigured" || key === "prAuthAvailable") continue; // server-only
|
|
if (key === "customProviders") continue; // persisted via dedicated routes, not save-split (see global branch above)
|
|
if (key === "githubTrackingDefaultRepo" && activeSection === "global-general") continue;
|
|
if ((key === "gitlabEnabled" || key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection === "global-general") continue;
|
|
if (key === "mcpServers" && scopedMcpValues) continue;
|
|
if (key === "mcpServers" && activeSection === "global-mcp") continue;
|
|
if (!isProjectSettingsKey(key)) continue;
|
|
|
|
const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings];
|
|
|
|
if (MODEL_LANE_KEY_SET.has(key)) {
|
|
if (!settingsValueEquals(value, initialProjectValue)) {
|
|
if (
|
|
(value === undefined || value === null) &&
|
|
initialProjectValue !== undefined &&
|
|
initialProjectValue !== null
|
|
) {
|
|
(projectPatch as Record<string, unknown>)[key] = null;
|
|
} else if (value !== undefined) {
|
|
(projectPatch as Record<string, unknown>)[key] = value;
|
|
}
|
|
}
|
|
} else {
|
|
// Changed-only gate + null-as-delete for non-model project settings.
|
|
if (!settingsValueEquals(value, initialProjectValue)) {
|
|
if (value === undefined && initialProjectValue !== undefined && initialProjectValue !== null) {
|
|
(projectPatch as Record<string, unknown>)[key] = null;
|
|
} else if (value !== undefined) {
|
|
(projectPatch as Record<string, unknown>)[key] = value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
FNXC:McpSettingsScopes 2026-07-14-22:10:
|
|
Saving must not discard scoped MCP edits merely because the initial scoped snapshot is unavailable. Compare against an undefined baseline in that case so the current raw scoped values are still persisted.
|
|
*/
|
|
if (scopedMcpValues) {
|
|
const initialGlobalMcp = resolveScopedMcpSettings("global", initialScopedValues);
|
|
if (!settingsValueEquals(scopedMcpValues.global, initialGlobalMcp)) {
|
|
(globalPatch as Record<string, unknown>).mcpServers = scopedMcpValues.global ?? null;
|
|
}
|
|
|
|
const initialProjectMcp = resolveScopedMcpSettings("project", initialScopedValues);
|
|
if (!settingsValueEquals(scopedMcpValues.project, initialProjectMcp)) {
|
|
(projectPatch as Record<string, unknown>).mcpServers = scopedMcpValues.project ?? null;
|
|
}
|
|
}
|
|
|
|
return { globalPatch, projectPatch };
|
|
}
|