feat(FN-1627): expose global execution concurrency limit in settings
- Add global max concurrent agent limit to settings UI (Scheduling section) - Add null-as-delete semantics for global settings persistence - Add fetchGlobalConcurrency and updateGlobalConcurrency API integrations - Update SettingsModal tests with comprehensive coverage for new features - Fix lint issues in modified files
This commit is contained in:
11
.changeset/fix-notification-settings-persistence.md
Normal file
11
.changeset/fix-notification-settings-persistence.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
"@gsxdsm/fusion": patch
|
||||
---
|
||||
|
||||
Fix notification settings persistence when clearing fields
|
||||
|
||||
Previously, clearing notification fields (ntfyTopic, ntfyDashboardHost, ntfyEvents) in the Settings modal would not persist - the old values would remain. This was because `undefined` values are dropped during JSON serialization.
|
||||
|
||||
Now uses null-as-delete semantics: when a user explicitly clears a notification field, the dashboard sends `null` to the server, which explicitly removes the field from settings and falls back to defaults on next read.
|
||||
|
||||
Fixes the round-trip: Save + reopen settings now correctly shows cleared notification fields.
|
||||
@@ -172,15 +172,68 @@ describe("GlobalSettingsStore", () => {
|
||||
expect(settings.themeMode).toBe("light");
|
||||
});
|
||||
|
||||
it("can clear a field by setting it to undefined", async () => {
|
||||
it("can clear a field by setting it to null (null-as-delete semantics)", async () => {
|
||||
await store.init();
|
||||
await store.updateSettings({ defaultProvider: "anthropic" });
|
||||
await store.updateSettings({ defaultProvider: undefined });
|
||||
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
|
||||
await store.updateSettings({ defaultProvider: null });
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.defaultProvider).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearing ntfyTopic with null removes it from disk and returns undefined", async () => {
|
||||
await store.init();
|
||||
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "my-topic" });
|
||||
|
||||
// Verify it was persisted
|
||||
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
expect(raw.ntfyTopic).toBe("my-topic");
|
||||
|
||||
// Clear the topic with null
|
||||
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
|
||||
await store.updateSettings({ ntfyTopic: null });
|
||||
|
||||
// Verify it was removed from disk
|
||||
const rawAfter = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
expect(rawAfter.ntfyTopic).toBeUndefined();
|
||||
|
||||
// Verify getSettings returns undefined
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.ntfyTopic).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearing ntfyDashboardHost with null removes it from disk", async () => {
|
||||
await store.init();
|
||||
await store.updateSettings({ ntfyDashboardHost: "https://dashboard.example.com" });
|
||||
|
||||
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
|
||||
await store.updateSettings({ ntfyDashboardHost: null });
|
||||
|
||||
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
expect(raw.ntfyDashboardHost).toBeUndefined();
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.ntfyDashboardHost).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearing ntfyEvents with null resets to default on read", async () => {
|
||||
await store.init();
|
||||
await store.updateSettings({ ntfyEvents: ["in-review", "failed"] });
|
||||
|
||||
// Verify it was persisted with custom value
|
||||
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
expect(raw.ntfyEvents).toEqual(["in-review", "failed"]);
|
||||
|
||||
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
|
||||
await store.updateSettings({ ntfyEvents: null });
|
||||
|
||||
// After clear, reading back gives the default value
|
||||
// (either undefined on disk with default applied, or default written directly)
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.ntfyEvents).toEqual(["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"]);
|
||||
});
|
||||
|
||||
it("handles concurrent updates safely via locking", async () => {
|
||||
await store.init();
|
||||
|
||||
|
||||
@@ -132,16 +132,42 @@ export class GlobalSettingsStore {
|
||||
* merging, so fields that were removed from the TypeScript schema are not
|
||||
* silently dropped during save cycles.
|
||||
*
|
||||
* **Null-as-delete semantics**: Fields set to `null` in the patch are
|
||||
* explicitly deleted from the settings. This allows the frontend to clear
|
||||
* a setting by sending `null` instead of `undefined` (since JSON.stringify
|
||||
* drops `undefined` values, `null` serves as the explicit clear sentinel).
|
||||
*
|
||||
* @returns The full updated settings after merge.
|
||||
*/
|
||||
async updateSettings(patch: Partial<GlobalSettings>): Promise<GlobalSettings> {
|
||||
async updateSettings(patch: Partial<GlobalSettings> & Record<string, unknown>): Promise<GlobalSettings> {
|
||||
return this.withLock(async () => {
|
||||
const raw = await this.readRaw();
|
||||
const merged = { ...DEFAULT_GLOBAL_SETTINGS, ...raw, ...patch };
|
||||
|
||||
// Apply null-as-delete semantics: null means "remove this field"
|
||||
// Merge order: defaults → raw (disk) → patch
|
||||
// This means: patch values win, then raw, then defaults
|
||||
// But null in patch means "delete" - so we delete from raw first
|
||||
const merged: Record<string, unknown> = { ...raw };
|
||||
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
// null → delete this key from the merged object
|
||||
// This effectively makes it fall through to the default
|
||||
delete merged[key];
|
||||
} else {
|
||||
// normal value → set it
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// After merging, fill in defaults for any missing keys
|
||||
// This ensures fields that were deleted (by null) get their default value
|
||||
const withDefaults = { ...DEFAULT_GLOBAL_SETTINGS, ...merged } as GlobalSettings;
|
||||
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
await this.atomicWrite(merged as GlobalSettings);
|
||||
await this.atomicWrite(withDefaults);
|
||||
// Update the write-through cache
|
||||
this.cachedSettings = merged as GlobalSettings;
|
||||
this.cachedSettings = withDefaults;
|
||||
return this.cachedSettings;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,8 +39,6 @@ vi.mock("../api", () => ({
|
||||
saveMemory: (...args: unknown[]) => mockSaveMemory(...args),
|
||||
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
|
||||
updateGlobalConcurrency: (...args: unknown[]) => mockUpdateGlobalConcurrency(...args),
|
||||
saveApiKey: vi.fn().mockResolvedValue(undefined),
|
||||
clearApiKey: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
@@ -103,6 +103,8 @@ export function SettingsModal({
|
||||
}: SettingsModalProps) {
|
||||
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: true, autoMerge: true, mergeStrategy: "direct", recycleWorktrees: false, worktreeNaming: "random", includeTaskIdInCommit: true, worktreeInitCommand: "", ntfyEnabled: false, ntfyTopic: undefined });
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Track initial values to detect explicit clears for null-as-delete semantics
|
||||
const [initialValues, setInitialValues] = useState<Settings | null>(null);
|
||||
// Find the first non-group-header section for default active section
|
||||
const firstNonHeaderSection = SETTINGS_SECTIONS.find((s) => !s.isGroupHeader);
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? firstNonHeaderSection?.id ?? "authentication");
|
||||
@@ -139,6 +141,9 @@ export function SettingsModal({
|
||||
const [memoryLoading, setMemoryLoading] = useState(false);
|
||||
const [memoryDirty, setMemoryDirty] = useState(false);
|
||||
|
||||
// Global concurrency state
|
||||
const [globalMaxConcurrent, setGlobalMaxConcurrent] = useState<number>(4);
|
||||
|
||||
// Import/Export state
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [, setImportFile] = useState<File | null>(null);
|
||||
@@ -149,12 +154,10 @@ export function SettingsModal({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchSettings(projectId), fetchGlobalConcurrency().catch(() => null)])
|
||||
.then(([s, concurrency]) => {
|
||||
setForm({
|
||||
...s,
|
||||
globalMaxConcurrent: concurrency?.globalMaxConcurrent,
|
||||
});
|
||||
fetchSettings(projectId)
|
||||
.then((s) => {
|
||||
setForm(s);
|
||||
setInitialValues(s); // Store initial values to detect explicit clears
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -163,6 +166,14 @@ export function SettingsModal({
|
||||
});
|
||||
}, [addToast, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGlobalConcurrency()
|
||||
.then((state) => setGlobalMaxConcurrent(state.globalMaxConcurrent))
|
||||
.catch(() => {
|
||||
// Silently fail — global concurrency may not be available
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load auth status when the authentication section is active
|
||||
const loadAuthStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -515,24 +526,28 @@ export function SettingsModal({
|
||||
const globalPatch: Partial<GlobalSettings> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (isGlobalSettingsKey(key)) {
|
||||
(globalPatch as any)[key] = value;
|
||||
// Implement null-as-delete semantics for global settings:
|
||||
// - undefined values are dropped during JSON serialization
|
||||
// - To explicitly clear a field, send null instead
|
||||
// - We detect explicit clears by comparing with initial values:
|
||||
// if current value is undefined AND initial was defined, use null
|
||||
const initialValue = initialValues?.[key as keyof GlobalSettings];
|
||||
if (value === undefined && initialValue !== undefined) {
|
||||
(globalPatch as any)[key] = null; // null means "explicitly clear"
|
||||
} else {
|
||||
(globalPatch as any)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const projectPatch: Partial<Settings> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "githubTokenConfigured") continue; // server-only field
|
||||
if (key === "globalMaxConcurrent") continue; // central-core field, saved below
|
||||
if (isProjectSettingsKey(key)) {
|
||||
(projectPatch as any)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const globalMaxConcurrent =
|
||||
typeof payload.globalMaxConcurrent === "number" && Number.isFinite(payload.globalMaxConcurrent)
|
||||
? Math.max(1, Math.round(payload.globalMaxConcurrent))
|
||||
: undefined;
|
||||
|
||||
// Save both scopes in parallel if they have changes.
|
||||
// Note: themeMode/colorTheme may also be write-through via useTheme callbacks
|
||||
// in the Appearance section; duplicate global writes are intentional/idempotent,
|
||||
@@ -540,7 +555,7 @@ export function SettingsModal({
|
||||
await Promise.all([
|
||||
Object.keys(globalPatch).length > 0 ? updateGlobalSettings(globalPatch) : Promise.resolve(),
|
||||
Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch, projectId) : Promise.resolve(),
|
||||
globalMaxConcurrent !== undefined ? updateGlobalConcurrency({ globalMaxConcurrent }) : Promise.resolve(),
|
||||
updateGlobalConcurrency({ globalMaxConcurrent }),
|
||||
]);
|
||||
|
||||
addToast("Settings saved", "success");
|
||||
@@ -548,7 +563,7 @@ export function SettingsModal({
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [form, prefixError, presetDraft, onClose, addToast, projectId]);
|
||||
}, [form, globalMaxConcurrent, prefixError, presetDraft, initialValues, onClose, addToast, projectId]);
|
||||
|
||||
const handleSaveMemory = useCallback(async () => {
|
||||
try {
|
||||
@@ -1002,7 +1017,7 @@ export function SettingsModal({
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
if (inUsePresetIds.has(preset.id) && !confirm(`Preset \"${preset.name}\" is used in auto-selection. Delete it anyway?`)) {
|
||||
if (inUsePresetIds.has(preset.id) && !confirm(`Preset "${preset.name}" is used in auto-selection. Delete it anyway?`)) {
|
||||
return;
|
||||
}
|
||||
setForm((current) => ({
|
||||
@@ -1295,6 +1310,18 @@ export function SettingsModal({
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<h4 className="settings-section-heading">Scheduling</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalMaxConcurrent">Global Max Concurrent</label>
|
||||
<input
|
||||
id="globalMaxConcurrent"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={globalMaxConcurrent}
|
||||
onChange={(e) => setGlobalMaxConcurrent(Number(e.target.value))}
|
||||
/>
|
||||
<small className="form-text text-muted">Maximum concurrent agents across all projects</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
|
||||
<input
|
||||
@@ -1307,21 +1334,6 @@ export function SettingsModal({
|
||||
setForm((f) => ({ ...f, maxConcurrent: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
<small>Project-level agent limit for this board.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalMaxConcurrent">Global Concurrent Agents</label>
|
||||
<input
|
||||
id="globalMaxConcurrent"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={form.globalMaxConcurrent ?? 4}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, globalMaxConcurrent: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
<small>System-wide limit shared by triage, execution, and merge agents across all registered projects.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { Settings, ThemeMode, ColorTheme } from "@fusion/core";
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
globalMaxConcurrent: 4,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
@@ -231,7 +230,7 @@ describe("SettingsModal", () => {
|
||||
// Click Scheduling
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Global Concurrent Agents")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Global Max Concurrent")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Task Prefix")).toBeNull();
|
||||
|
||||
// Click Commands
|
||||
@@ -312,7 +311,7 @@ describe("SettingsModal", () => {
|
||||
// Scheduling
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Global Concurrent Agents")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Global Max Concurrent")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Poll Interval (ms)")).toBeTruthy();
|
||||
|
||||
// Worktrees
|
||||
@@ -676,15 +675,13 @@ describe("SettingsModal", () => {
|
||||
it("loads and saves the central global concurrency limit", async () => {
|
||||
(fetchGlobalConcurrency as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
globalMaxConcurrent: 8,
|
||||
currentlyActive: 3,
|
||||
queuedCount: 0,
|
||||
projectsActive: {},
|
||||
currentUsage: 3,
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} initialSection="scheduling" />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const input = screen.getByLabelText("Global Concurrent Agents") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement;
|
||||
expect(input.value).toBe("8");
|
||||
|
||||
fireEvent.change(input, { target: { value: "10" } });
|
||||
@@ -942,7 +939,7 @@ describe("SettingsModal", () => {
|
||||
expect(payload.defaultModelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("Use default option clears model selection", async () => {
|
||||
it("Use default option clears model selection (sends null for explicit clear)", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
@@ -972,11 +969,12 @@ describe("SettingsModal", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
// defaultProvider and defaultModelId are global settings
|
||||
// Clearing sends null (null-as-delete semantics)
|
||||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.defaultProvider).toBeUndefined();
|
||||
expect(payload.defaultModelId).toBeUndefined();
|
||||
expect(payload.defaultProvider).toBeNull();
|
||||
expect(payload.defaultModelId).toBeNull();
|
||||
});
|
||||
|
||||
it("shows empty state when no models available", async () => {
|
||||
@@ -1792,7 +1790,7 @@ describe("SettingsModal", () => {
|
||||
expect(payload.ntfyTopic).toBe("my-topic");
|
||||
});
|
||||
|
||||
it("ntfy topic field submits undefined when empty", async () => {
|
||||
it("ntfy topic field submits null when cleared (null-as-delete semantics)", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
ntfyEnabled: true,
|
||||
@@ -1807,11 +1805,11 @@ describe("SettingsModal", () => {
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
// ntfyTopic is a global setting
|
||||
// ntfyTopic is a global setting - clearing it sends null (null-as-delete)
|
||||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.ntfyTopic).toBeUndefined();
|
||||
expect(payload.ntfyTopic).toBeNull(); // null means "explicitly clear this field"
|
||||
});
|
||||
|
||||
it("ntfy topic shows validation error for invalid input", async () => {
|
||||
@@ -2005,7 +2003,7 @@ describe("SettingsModal", () => {
|
||||
expect(payload.ntfyEvents).toEqual(["in-review", "failed", "awaiting-approval", "awaiting-user-review"]);
|
||||
});
|
||||
|
||||
it("sets ntfyEvents to undefined when all checkboxes are unchecked", async () => {
|
||||
it("sets ntfyEvents to null when all checkboxes are unchecked (null-as-delete)", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
ntfyEnabled: true,
|
||||
@@ -2029,7 +2027,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.ntfyEvents).toBeUndefined();
|
||||
expect(payload.ntfyEvents).toBeNull(); // null means "explicitly clear this field"
|
||||
});
|
||||
|
||||
it("restores ntfyEvents from saved settings", async () => {
|
||||
|
||||
Reference in New Issue
Block a user