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:
185
packages/core/src/global-settings.test.ts
Normal file
185
packages/core/src/global-settings.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { GlobalSettingsStore } from "./global-settings.js";
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
|
||||
import { readFile, rm, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-global-settings-test-"));
|
||||
}
|
||||
|
||||
describe("GlobalSettingsStore", () => {
|
||||
let dir: string;
|
||||
let store: GlobalSettingsStore;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = makeTmpDir();
|
||||
store = new GlobalSettingsStore(dir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("init()", () => {
|
||||
it("creates the directory and settings.json if missing", async () => {
|
||||
const nested = join(dir, "nested", "deep");
|
||||
const nestedStore = new GlobalSettingsStore(nested);
|
||||
|
||||
const created = await nestedStore.init();
|
||||
|
||||
expect(created).toBe(true);
|
||||
expect(existsSync(join(nested, "settings.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("creates settings.json with defaults on first init", async () => {
|
||||
await store.init();
|
||||
|
||||
const raw = await readFile(join(dir, "settings.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
expect(parsed.themeMode).toBe("dark");
|
||||
expect(parsed.colorTheme).toBe("default");
|
||||
expect(parsed.ntfyEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if settings.json already exists", async () => {
|
||||
await store.init(); // creates file
|
||||
const created = await store.init(); // second call
|
||||
expect(created).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves existing settings on re-init", async () => {
|
||||
await store.init();
|
||||
await store.updateSettings({ themeMode: "light" });
|
||||
|
||||
const created = await store.init();
|
||||
expect(created).toBe(false);
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.themeMode).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettings()", () => {
|
||||
it("returns defaults when file does not exist", async () => {
|
||||
const settings = await store.getSettings();
|
||||
|
||||
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
|
||||
});
|
||||
|
||||
it("returns persisted values merged with defaults", async () => {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(
|
||||
join(dir, "settings.json"),
|
||||
JSON.stringify({ themeMode: "light", colorTheme: "ocean" }),
|
||||
);
|
||||
|
||||
const settings = await store.getSettings();
|
||||
|
||||
expect(settings.themeMode).toBe("light");
|
||||
expect(settings.colorTheme).toBe("ocean");
|
||||
// Defaults are filled in for missing fields
|
||||
expect(settings.ntfyEnabled).toBe(false);
|
||||
expect(settings.defaultProvider).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns defaults on invalid JSON", async () => {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "settings.json"), "not-json{{{");
|
||||
|
||||
const settings = await store.getSettings();
|
||||
|
||||
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
|
||||
});
|
||||
|
||||
it("returns defaults when directory does not exist", async () => {
|
||||
const nonExistent = new GlobalSettingsStore(join(dir, "nope", "nada"));
|
||||
const settings = await nonExistent.getSettings();
|
||||
|
||||
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSettings()", () => {
|
||||
it("persists a partial update and returns merged settings", async () => {
|
||||
await store.init();
|
||||
|
||||
const updated = await store.updateSettings({ themeMode: "system" });
|
||||
|
||||
expect(updated.themeMode).toBe("system");
|
||||
expect(updated.colorTheme).toBe("default"); // unchanged default
|
||||
|
||||
// Verify persistence
|
||||
const raw = await readFile(join(dir, "settings.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
expect(parsed.themeMode).toBe("system");
|
||||
});
|
||||
|
||||
it("merges multiple updates without losing fields", async () => {
|
||||
await store.init();
|
||||
|
||||
await store.updateSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
|
||||
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "my-topic" });
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.defaultProvider).toBe("anthropic");
|
||||
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
|
||||
expect(settings.ntfyEnabled).toBe(true);
|
||||
expect(settings.ntfyTopic).toBe("my-topic");
|
||||
expect(settings.themeMode).toBe("dark"); // preserved default
|
||||
});
|
||||
|
||||
it("creates directory if missing", async () => {
|
||||
const nested = join(dir, "auto", "create");
|
||||
const nestedStore = new GlobalSettingsStore(nested);
|
||||
|
||||
await nestedStore.updateSettings({ themeMode: "light" });
|
||||
|
||||
expect(existsSync(join(nested, "settings.json"))).toBe(true);
|
||||
const settings = await nestedStore.getSettings();
|
||||
expect(settings.themeMode).toBe("light");
|
||||
});
|
||||
|
||||
it("can clear a field by setting it to undefined", async () => {
|
||||
await store.init();
|
||||
await store.updateSettings({ defaultProvider: "anthropic" });
|
||||
await store.updateSettings({ defaultProvider: undefined });
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.defaultProvider).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles concurrent updates safely via locking", async () => {
|
||||
await store.init();
|
||||
|
||||
// Fire 10 concurrent updates
|
||||
const promises = Array.from({ length: 10 }, (_, i) =>
|
||||
store.updateSettings({ ntfyTopic: `topic-${i}` }),
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
||||
// The final value should be one of the submitted values (last writer wins)
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.ntfyTopic).toMatch(/^topic-\d$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettingsPath()", () => {
|
||||
it("returns the path to settings.json", () => {
|
||||
const path = store.getSettingsPath();
|
||||
expect(path).toBe(join(dir, "settings.json"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("atomic writes", () => {
|
||||
it("does not leave tmp files after a successful write", async () => {
|
||||
await store.init();
|
||||
await store.updateSettings({ themeMode: "light" });
|
||||
|
||||
const tmpPath = join(dir, "settings.json.tmp");
|
||||
expect(existsSync(tmpPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
122
packages/core/src/global-settings.ts
Normal file
122
packages/core/src/global-settings.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Global settings store — manages user-level settings in `~/.pi/kb/settings.json`.
|
||||
*
|
||||
* Global settings persist across all kb projects for the current user.
|
||||
* They include UI theme preferences, default AI model selection, and
|
||||
* notification configuration.
|
||||
*
|
||||
* @see {@link GlobalSettings} for the full list of global fields.
|
||||
*/
|
||||
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { GlobalSettings } from "./types.js";
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
|
||||
|
||||
/** Default directory for global kb settings: `~/.pi/kb/` */
|
||||
function defaultGlobalDir(): string {
|
||||
return join(homedir(), ".pi", "kb");
|
||||
}
|
||||
|
||||
export class GlobalSettingsStore {
|
||||
private readonly settingsPath: string;
|
||||
private readonly dir: string;
|
||||
|
||||
/** Promise chain for serializing read-modify-write cycles */
|
||||
private lock: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Create a GlobalSettingsStore.
|
||||
* @param dir — Directory to store settings.json. Defaults to `~/.pi/kb/`.
|
||||
* Accepts a custom path for testing.
|
||||
*/
|
||||
constructor(dir?: string) {
|
||||
this.dir = dir ?? defaultGlobalDir();
|
||||
this.settingsPath = join(this.dir, "settings.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the settings directory exists. Creates it recursively if needed.
|
||||
* If the settings file doesn't exist, creates it with defaults.
|
||||
* Returns true if the file was created for the first time.
|
||||
*/
|
||||
async init(): Promise<boolean> {
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
if (!existsSync(this.settingsPath)) {
|
||||
await this.atomicWrite(DEFAULT_GLOBAL_SETTINGS);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read global settings from disk. Returns defaults merged with persisted values.
|
||||
* If the file doesn't exist or is invalid, returns defaults without throwing.
|
||||
*/
|
||||
async getSettings(): Promise<GlobalSettings> {
|
||||
try {
|
||||
const raw = await readFile(this.settingsPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<GlobalSettings>;
|
||||
return { ...DEFAULT_GLOBAL_SETTINGS, ...parsed };
|
||||
} catch {
|
||||
// File missing, unreadable, or invalid JSON → return defaults
|
||||
return { ...DEFAULT_GLOBAL_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global settings by merging a partial patch into the existing values.
|
||||
* Only fields present in the patch are overwritten; other fields are preserved.
|
||||
* Uses atomic write (write-to-temp-then-rename) and serialized locking.
|
||||
*
|
||||
* @returns The full updated settings after merge.
|
||||
*/
|
||||
async updateSettings(patch: Partial<GlobalSettings>): Promise<GlobalSettings> {
|
||||
return this.withLock(async () => {
|
||||
const current = await this.getSettings();
|
||||
const updated = { ...current, ...patch };
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
await this.atomicWrite(updated);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the settings file (useful for diagnostics/logging).
|
||||
*/
|
||||
getSettingsPath(): string {
|
||||
return this.settingsPath;
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Atomically write settings to disk. Writes to a temp file first,
|
||||
* then renames into place (atomic on POSIX).
|
||||
*/
|
||||
private async atomicWrite(settings: GlobalSettings): Promise<void> {
|
||||
const tmpPath = this.settingsPath + ".tmp";
|
||||
await writeFile(tmpPath, JSON.stringify(settings, null, 2));
|
||||
await rename(tmpPath, this.settingsPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize operations via promise chain to prevent lost-update races.
|
||||
*/
|
||||
private withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
let resolve: () => void;
|
||||
const next = new Promise<void>((r) => { resolve = r; });
|
||||
const prev = this.lock;
|
||||
this.lock = next;
|
||||
|
||||
return prev.then(async () => {
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
resolve!();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset } from "./types.js";
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset } from "./types.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { GlobalSettingsStore } from "./global-settings.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
export {
|
||||
isGhAvailable,
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -3,8 +3,9 @@ import { execSync } from "node:child_process";
|
||||
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
|
||||
import { join, sep } from "node:path";
|
||||
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, ActivityLogEntry, ActivityEventType } from "./types.js";
|
||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js";
|
||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS } from "./types.js";
|
||||
import { GlobalSettingsStore } from "./global-settings.js";
|
||||
|
||||
export interface TaskStoreEvents {
|
||||
"task:created": [task: Task];
|
||||
@@ -37,8 +38,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
private taskLocks: Map<string, Promise<void>> = new Map();
|
||||
/** Promise chain for serializing config.json read-modify-write cycles */
|
||||
private configLock: Promise<void> = Promise.resolve();
|
||||
/** Global settings store (`~/.pi/kb/settings.json`) */
|
||||
private globalSettingsStore: GlobalSettingsStore;
|
||||
|
||||
constructor(private rootDir: string) {
|
||||
constructor(private rootDir: string, globalSettingsDir?: string) {
|
||||
super();
|
||||
this.setMaxListeners(100);
|
||||
this.kbDir = join(rootDir, ".kb");
|
||||
@@ -46,6 +49,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.configPath = join(this.kbDir, "config.json");
|
||||
this.archiveLogPath = join(this.kbDir, "archive.jsonl");
|
||||
this.activityLogPath = join(this.kbDir, "activity-log.jsonl");
|
||||
this.globalSettingsStore = new GlobalSettingsStore(globalSettingsDir);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
@@ -232,23 +236,107 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
await rename(tmpPath, taskJsonPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get merged settings: global defaults ← global user prefs ← project overrides.
|
||||
*
|
||||
* Returns the combined view that most consumers should use. Project-level
|
||||
* values in `.kb/config.json` override global values from `~/.pi/kb/settings.json`.
|
||||
*/
|
||||
async getSettings(): Promise<Settings> {
|
||||
const config = await this.readConfig();
|
||||
return { ...DEFAULT_SETTINGS, ...config.settings };
|
||||
const [globalSettings, config] = await Promise.all([
|
||||
this.globalSettingsStore.getSettings(),
|
||||
this.readConfig(),
|
||||
]);
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...globalSettings,
|
||||
...config.settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings separated by scope. Returns both the global and
|
||||
* project-level settings independently (useful for the UI to show
|
||||
* which scope a value comes from).
|
||||
*/
|
||||
async getSettingsByScope(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
|
||||
const [globalSettings, config] = await Promise.all([
|
||||
this.globalSettingsStore.getSettings(),
|
||||
this.readConfig(),
|
||||
]);
|
||||
|
||||
// Extract only project-level keys from config.settings
|
||||
const projectSettings: Partial<ProjectSettings> = {};
|
||||
if (config.settings) {
|
||||
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
|
||||
for (const key of Object.keys(config.settings)) {
|
||||
if (!globalKeySet.has(key)) {
|
||||
(projectSettings as any)[key] = (config.settings as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { global: globalSettings, project: projectSettings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project-level settings in `.kb/config.json`.
|
||||
*
|
||||
* Accepts `Partial<Settings>` for backward compatibility. Any global-only
|
||||
* fields in the patch are silently filtered out — they will not be persisted
|
||||
* to the project config. Use `updateGlobalSettings()` for global fields.
|
||||
*/
|
||||
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
|
||||
// Filter out global-only fields — they should go through updateGlobalSettings()
|
||||
const projectPatch: Partial<Settings> = {};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (!(GLOBAL_SETTINGS_KEYS as readonly string[]).includes(key)) {
|
||||
(projectPatch as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return this.withConfigLock(async () => {
|
||||
const config = await this.readConfig();
|
||||
const previous = { ...DEFAULT_SETTINGS, ...config.settings };
|
||||
const updated = { ...previous, ...patch };
|
||||
config.settings = updated;
|
||||
const globalSettings = await this.globalSettingsStore.getSettings();
|
||||
const previousMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...config.settings } as Settings;
|
||||
const updatedProjectSettings = { ...config.settings, ...projectPatch };
|
||||
config.settings = updatedProjectSettings as Settings;
|
||||
await this.writeConfig(config);
|
||||
this.emit("settings:updated", { settings: updated, previous });
|
||||
return updated;
|
||||
const updatedMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings;
|
||||
this.emit("settings:updated", { settings: updatedMerged, previous: previousMerged });
|
||||
return updatedMerged;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global (user-level) settings in `~/.pi/kb/settings.json`.
|
||||
*
|
||||
* These settings persist across all kb projects for the current user.
|
||||
* Only fields defined in `GlobalSettings` are accepted.
|
||||
*/
|
||||
async updateGlobalSettings(patch: Partial<GlobalSettings>): Promise<Settings> {
|
||||
// Read previous state BEFORE writing so the diff is correct
|
||||
const [previousGlobal, config] = await Promise.all([
|
||||
this.globalSettingsStore.getSettings(),
|
||||
this.readConfig(),
|
||||
]);
|
||||
const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings;
|
||||
|
||||
const updatedGlobal = await this.globalSettingsStore.updateSettings(patch);
|
||||
const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings;
|
||||
|
||||
// Emit settings:updated so SSE listeners pick up the change
|
||||
this.emit("settings:updated", { settings: merged, previous });
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GlobalSettingsStore instance (used by API routes).
|
||||
*/
|
||||
getGlobalSettingsStore(): GlobalSettingsStore {
|
||||
return this.globalSettingsStore;
|
||||
}
|
||||
|
||||
private async readConfig(): Promise<BoardConfig> {
|
||||
const data = await readFile(this.configPath, "utf-8");
|
||||
return JSON.parse(data);
|
||||
@@ -1917,7 +2005,11 @@ ${notificationsSection}`;
|
||||
|
||||
/**
|
||||
* Synchronous version of getSettings for internal use.
|
||||
* Returns cached settings or default settings if not loaded.
|
||||
* Returns project-level settings merged with defaults.
|
||||
* Note: This does NOT merge global settings because it's synchronous
|
||||
* and global settings require async I/O. For prompt generation this
|
||||
* is fine since the fields used (ntfyEnabled, ntfyTopic) will be
|
||||
* present in project config for backward compatibility.
|
||||
*/
|
||||
private getSettingsSync(): Settings {
|
||||
// Since we can't easily make generateSpecifiedPrompt async,
|
||||
|
||||
@@ -236,7 +236,69 @@ export interface TaskCreateInput {
|
||||
validatorModelId?: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
// ── Settings Scope Types ────────────────────────────────────────────────
|
||||
//
|
||||
// Settings are split into two scopes:
|
||||
//
|
||||
// 1. **GlobalSettings** — User preferences stored in `~/.pi/kb/settings.json`.
|
||||
// These persist across all kb projects for the current user (theme, default
|
||||
// AI models, notification preferences).
|
||||
//
|
||||
// 2. **ProjectSettings** — Project-specific workflow and resource settings stored
|
||||
// in `.kb/config.json`. These control how the engine operates for this
|
||||
// particular project (concurrency, merge strategy, worktree management, etc.).
|
||||
//
|
||||
// The merged view (`Settings`) combines both scopes: project values override
|
||||
// global values. This is the type returned by `TaskStore.getSettings()` and
|
||||
// used by most consumers.
|
||||
//
|
||||
// Computed/server-only fields (like `githubTokenConfigured`) live only on
|
||||
// `Settings` and are injected at read time by the API layer.
|
||||
|
||||
/** Settings scope discriminator for UI and validation. */
|
||||
export type SettingsScope = "global" | "project";
|
||||
|
||||
/**
|
||||
* Global (user-level) settings stored in `~/.pi/kb/settings.json`.
|
||||
*
|
||||
* These are user preferences that persist across all kb projects.
|
||||
* The dashboard UI shows these under a "Global" section.
|
||||
*/
|
||||
export interface GlobalSettings {
|
||||
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
|
||||
themeMode?: ThemeMode;
|
||||
/** Color theme preference for accent colors and styling. Default: "default". */
|
||||
colorTheme?: ColorTheme;
|
||||
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).
|
||||
* Must be set together with `defaultModelId`. When both are undefined,
|
||||
* the engine uses pi's automatic model resolution. */
|
||||
defaultProvider?: string;
|
||||
/** Default AI model ID within the provider (e.g. `"claude-sonnet-4-5"`).
|
||||
* Must be set together with `defaultProvider`. When both are undefined,
|
||||
* the engine uses pi's automatic model resolution. */
|
||||
defaultModelId?: string;
|
||||
/** Default thinking effort level for AI agent sessions.
|
||||
* Controls how much reasoning effort the model uses — higher levels
|
||||
* produce better results but cost more. When undefined, the engine
|
||||
* uses the model's default thinking level. */
|
||||
defaultThinkingLevel?: ThinkingLevel;
|
||||
/** When true, enables ntfy.sh push notifications for task completion and failures.
|
||||
* Requires ntfyTopic to be set. Default: false. */
|
||||
ntfyEnabled?: boolean;
|
||||
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
|
||||
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
|
||||
ntfyTopic?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project-level settings stored in `.kb/config.json`.
|
||||
*
|
||||
* These control how the engine operates for this particular project:
|
||||
* concurrency, merge strategy, worktree management, build/test commands, etc.
|
||||
* Runtime state fields (globalPause, enginePaused) also live here because
|
||||
* different projects may need independent pause control.
|
||||
*/
|
||||
export interface ProjectSettings {
|
||||
/** Hard stop: when true, all automated agent activity is **immediately**
|
||||
* terminated — active triage, execution, and merge agent sessions are
|
||||
* killed, and the scheduler stops dispatching new work. Acts as a
|
||||
@@ -288,21 +350,10 @@ export interface Settings {
|
||||
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
|
||||
* their original IDs. */
|
||||
taskPrefix?: string;
|
||||
/** Whether GitHub token is configured for PR operations (read-only, set by server).
|
||||
* When false, PR creation features are disabled in the UI. */
|
||||
githubTokenConfigured?: boolean;
|
||||
/** When true, merge commit messages include the task ID as the conventional
|
||||
* commit scope (e.g. `feat(KB-001): ...`). When false, the scope is
|
||||
* omitted (e.g. `feat: ...`). Default: true. */
|
||||
includeTaskIdInCommit?: boolean;
|
||||
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).
|
||||
* Must be set together with `defaultModelId`. When both are undefined,
|
||||
* the engine uses pi's automatic model resolution. */
|
||||
defaultProvider?: string;
|
||||
/** Default AI model ID within the provider (e.g. `"claude-sonnet-4-5"`).
|
||||
* Must be set together with `defaultProvider`. When both are undefined,
|
||||
* the engine uses pi's automatic model resolution. */
|
||||
defaultModelId?: string;
|
||||
/** AI model provider for planning/triage (specification) agent.
|
||||
* Must be set together with `planningModelId`. When both are undefined,
|
||||
* falls back to `defaultProvider`/`defaultModelId`. */
|
||||
@@ -325,11 +376,6 @@ export interface Settings {
|
||||
autoSelectModelPreset?: boolean;
|
||||
/** Mapping of task sizes to preset IDs used for auto-selection during task creation. */
|
||||
defaultPresetBySize?: { S?: string; M?: string; L?: string };
|
||||
/** Default thinking effort level for AI agent sessions.
|
||||
* Controls how much reasoning effort the model uses — higher levels
|
||||
* produce better results but cost more. When undefined, the engine
|
||||
* uses the model's default thinking level. */
|
||||
defaultThinkingLevel?: ThinkingLevel;
|
||||
/** When true, auto-merge will automatically resolve common conflict patterns
|
||||
* (lock files, generated files, trivial conflicts) without requiring AI
|
||||
* intervention. When AI resolution fails, the system will retry with escalating
|
||||
@@ -344,24 +390,41 @@ export interface Settings {
|
||||
* remain in triage with status "awaiting-approval" until a user approves
|
||||
* or rejects the plan. Default: false. */
|
||||
requirePlanApproval?: boolean;
|
||||
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
|
||||
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
|
||||
ntfyTopic?: string;
|
||||
/** When true, enables ntfy.sh push notifications for task completion and failures.
|
||||
* Requires ntfyTopic to be set. Default: false. */
|
||||
ntfyEnabled?: boolean;
|
||||
/** Timeout in milliseconds for detecting stuck tasks. When a task's agent session
|
||||
* shows no activity (no text deltas, tool calls, or progress updates) for longer
|
||||
* than this duration, the task is considered stuck and will be terminated and retried.
|
||||
* Default: undefined (disabled). Suggested value: 600000 (10 minutes). */
|
||||
taskStuckTimeoutMs?: number;
|
||||
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
|
||||
themeMode?: ThemeMode;
|
||||
/** Color theme preference for accent colors and styling. Default: "default". */
|
||||
colorTheme?: ColorTheme;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
/**
|
||||
* Merged settings view combining global and project scopes.
|
||||
*
|
||||
* This is the primary type returned by `TaskStore.getSettings()` and used
|
||||
* by most consumers. Project settings override global settings.
|
||||
*
|
||||
* Also includes computed/server-only fields like `githubTokenConfigured`
|
||||
* that are injected at read time by the API layer.
|
||||
*/
|
||||
export interface Settings extends GlobalSettings, ProjectSettings {
|
||||
/** Whether GitHub token is configured for PR operations (read-only, set by server).
|
||||
* When false, PR creation features are disabled in the UI. */
|
||||
githubTokenConfigured?: boolean;
|
||||
}
|
||||
|
||||
/** Default values for global (user-level) settings. */
|
||||
export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode" | "colorTheme">> & GlobalSettings = {
|
||||
themeMode: "dark",
|
||||
colorTheme: "default",
|
||||
defaultProvider: undefined,
|
||||
defaultModelId: undefined,
|
||||
defaultThinkingLevel: undefined,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
};
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
maxConcurrent: 2,
|
||||
@@ -375,8 +438,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
worktreeNaming: "random",
|
||||
taskPrefix: undefined,
|
||||
includeTaskIdInCommit: true,
|
||||
defaultProvider: undefined,
|
||||
defaultModelId: undefined,
|
||||
planningProvider: undefined,
|
||||
planningModelId: undefined,
|
||||
validatorProvider: undefined,
|
||||
@@ -384,17 +445,63 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
defaultThinkingLevel: undefined,
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
requirePlanApproval: false,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
taskStuckTimeoutMs: undefined,
|
||||
themeMode: "dark",
|
||||
colorTheme: "default",
|
||||
};
|
||||
|
||||
/**
|
||||
* Merged default settings (backward compatible).
|
||||
* This combines global and project defaults into a single object
|
||||
* that matches the legacy `DEFAULT_SETTINGS` shape.
|
||||
*/
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
};
|
||||
|
||||
/** Keys that belong to the global settings scope. */
|
||||
export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
|
||||
"themeMode",
|
||||
"colorTheme",
|
||||
"defaultProvider",
|
||||
"defaultModelId",
|
||||
"defaultThinkingLevel",
|
||||
"ntfyEnabled",
|
||||
"ntfyTopic",
|
||||
] as const;
|
||||
|
||||
/** Keys that belong to the project settings scope. */
|
||||
export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
||||
"globalPause",
|
||||
"enginePaused",
|
||||
"maxConcurrent",
|
||||
"maxWorktrees",
|
||||
"pollIntervalMs",
|
||||
"groupOverlappingFiles",
|
||||
"autoMerge",
|
||||
"mergeStrategy",
|
||||
"worktreeInitCommand",
|
||||
"testCommand",
|
||||
"buildCommand",
|
||||
"recycleWorktrees",
|
||||
"worktreeNaming",
|
||||
"taskPrefix",
|
||||
"includeTaskIdInCommit",
|
||||
"planningProvider",
|
||||
"planningModelId",
|
||||
"validatorProvider",
|
||||
"validatorModelId",
|
||||
"modelPresets",
|
||||
"autoSelectModelPreset",
|
||||
"defaultPresetBySize",
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"requirePlanApproval",
|
||||
"taskStuckTimeoutMs",
|
||||
] as const;
|
||||
|
||||
export interface BoardConfig {
|
||||
nextId: number;
|
||||
settings?: Settings;
|
||||
|
||||
Reference in New Issue
Block a user