feat(KB-185): add global/project settings hierarchy with scope-aware UI

- Add GlobalSettingsStore with file-based storage in ~/.pi/kb/settings.json
- Implement two-tier hierarchy: global (user prefs) and project (.kb/config.json)
- Update TaskStore to merge settings with project overriding global
- Add API endpoints: GET/PUT /api/settings/global and GET /api/settings/scopes
- Add scope indicators (🌐 global, 📁 project) to settings modal sidebar
- Implement scope-aware save: only save active scope, reject global-only fields
- Add frontend API functions for global settings operations
- Add tests for GlobalSettingsStore, settings routes, and SettingsModal scopes
- Create changeset for the new settings hierarchy feature
- Update AGENTS.md with settings hierarchy documentation
This commit is contained in:
gsxdsm
2026-03-31 03:12:33 -07:00
parent dfe5601622
commit dac4705761
15 changed files with 1178 additions and 104 deletions

View File

@@ -12,17 +12,20 @@ function makeTmpDir(): string {
describe("TaskStore", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new TaskStore(rootDir);
globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir);
await store.init();
});
afterEach(async () => {
store.stopWatching();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
});
async function createTestTask(): Promise<Task> {
@@ -356,8 +359,8 @@ describe("TaskStore", () => {
// ── Settings tests ────────────────────────────────────────────────
describe("model settings", () => {
it("persists defaultProvider and defaultModelId and returns them via getSettings", async () => {
await store.updateSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
it("persists defaultProvider and defaultModelId via updateGlobalSettings", async () => {
await store.updateGlobalSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
@@ -409,6 +412,111 @@ describe("TaskStore", () => {
});
});
// ── Global/Project Settings Merging ─────────────────────────────
describe("global/project settings merging", () => {
it("getSettings returns global defaults when no overrides exist", async () => {
const settings = await store.getSettings();
expect(settings.themeMode).toBe("dark");
expect(settings.colorTheme).toBe("default");
expect(settings.maxConcurrent).toBe(2);
});
it("global settings are visible through getSettings", async () => {
await store.updateGlobalSettings({ themeMode: "light", colorTheme: "ocean" });
const settings = await store.getSettings();
expect(settings.themeMode).toBe("light");
expect(settings.colorTheme).toBe("ocean");
});
it("project settings override global defaults", async () => {
await store.updateSettings({ maxConcurrent: 8 });
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(8);
});
it("updateSettings silently filters out global-only fields", async () => {
// themeMode is a global field — should not be persisted to project config
await store.updateSettings({ maxConcurrent: 5, themeMode: "light" } as any);
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(5);
// themeMode should still be the global default, not "light"
expect(settings.themeMode).toBe("dark");
// Verify the project config doesn't contain themeMode
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.settings.themeMode).toBeUndefined();
});
it("updateGlobalSettings persists global fields", async () => {
await store.updateGlobalSettings({ defaultProvider: "openai", defaultModelId: "gpt-4o" });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("openai");
expect(settings.defaultModelId).toBe("gpt-4o");
});
it("updateGlobalSettings emits settings:updated event", async () => {
const events: Array<{ settings: any; previous: any }> = [];
store.on("settings:updated", (data) => events.push(data));
await store.updateGlobalSettings({ ntfyEnabled: true, ntfyTopic: "test" });
expect(events).toHaveLength(1);
expect(events[0].settings.ntfyEnabled).toBe(true);
expect(events[0].settings.ntfyTopic).toBe("test");
});
it("getSettingsByScope returns separated global and project settings", async () => {
await store.updateGlobalSettings({ themeMode: "system", defaultProvider: "anthropic" });
await store.updateSettings({ maxConcurrent: 4, autoMerge: false });
const { global, project } = await store.getSettingsByScope();
expect(global.themeMode).toBe("system");
expect(global.defaultProvider).toBe("anthropic");
expect(project.maxConcurrent).toBe(4);
expect(project.autoMerge).toBe(false);
});
it("getSettingsByScope does not include global keys in project settings", async () => {
// Write global-key directly into config.json for backward compat testing
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
config.settings = { maxConcurrent: 3, themeMode: "light" };
await writeFile(join(rootDir, ".kb", "config.json"), JSON.stringify(config));
const { project } = await store.getSettingsByScope();
expect(project.maxConcurrent).toBe(3);
// themeMode is a global key — should not appear in project scope
expect((project as any).themeMode).toBeUndefined();
});
it("backward compat: existing projects with global fields in config.json still work", async () => {
// Simulate an old config that has both global and project fields
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
config.settings = { maxConcurrent: 6, themeMode: "system", ntfyEnabled: true };
await writeFile(join(rootDir, ".kb", "config.json"), JSON.stringify(config));
// getSettings should still see these values (project overrides global)
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(6);
// These are global fields stored in old config — they show up via config.settings spread
expect(settings.themeMode).toBe("system");
expect(settings.ntfyEnabled).toBe(true);
});
it("getGlobalSettingsStore returns the store instance", () => {
const globalStore = store.getGlobalSettingsStore();
expect(globalStore).toBeDefined();
expect(globalStore.getSettingsPath()).toContain("settings.json");
});
});
// ── Concurrent stress test ───────────────────────────────────────
describe("concurrent stress", () => {
@@ -2773,7 +2881,7 @@ describe("TaskStore", () => {
await store.cleanupArchivedTasks();
// Create new store instance
const newStore = new TaskStore(rootDir);
const newStore = new TaskStore(rootDir, globalDir);
await newStore.init();
const entries = await newStore.readArchiveLog();
@@ -2866,7 +2974,7 @@ describe("TaskStore", () => {
await store.recordActivity({ type: "task:created", taskId: "KB-001", details: "Test" });
// Create new store instance
const newStore = new TaskStore(rootDir);
const newStore = new TaskStore(rootDir, globalDir);
await newStore.init();
const logs = await newStore.getActivityLog();
@@ -2915,7 +3023,8 @@ describe("TaskStore", () => {
});
it("records activity on settings:updated for important changes", async () => {
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
// ntfyEnabled/ntfyTopic are now global settings, use updateGlobalSettings
await store.updateGlobalSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
// Wait for async activity recording
await new Promise((r) => setTimeout(r, 10));
const logs = await store.getActivityLog({ type: "settings:updated" });