diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 16:16:45 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 16:16:45 +0900 |
| commit | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (patch) | |
| tree | dff8ce0e55f27b490445ddaf9fb6536d1313ffcf | |
| parent | 24bdaa6ca0333b91369ac50b23e929f83af01c3a (diff) | |
| download | dispatch-v2-deprecated.tar.gz dispatch-v2-deprecated.zip | |
feat(config): add subdirectory LSP config watchers and fix permission orderingv2-deprecated
- Add watchDirConfig() for per-directory config watching
- Register watchers for subdirectories with their own dispatch.toml
- Fix permission ordering: move "*" wildcard to front so findLast
reaches specific rules first (was silently breaking all specific
bash permission rules)
- Add comprehensive tests for watcher functionality
- Update mocks in test files
| -rw-r--r-- | dispatch.toml | 8 | ||||
| -rw-r--r-- | packages/api/src/agent-manager.ts | 52 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 3 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 3 | ||||
| -rw-r--r-- | packages/core/src/config/index.ts | 2 | ||||
| -rw-r--r-- | packages/core/src/config/watcher.ts | 58 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/tests/config/watcher.test.ts | 50 |
8 files changed, 175 insertions, 2 deletions
diff --git a/dispatch.toml b/dispatch.toml index 9477842..6964993 100644 --- a/dispatch.toml +++ b/dispatch.toml @@ -48,11 +48,18 @@ base_url = "https://opencode.ai/zen/go/v1" [permissions] read = "allow" +# NOTE on ordering: rules are flattened in file order and the LAST matching +# rule wins (see evaluate()'s findLast). So a broad fallback like "*" must be +# placed FIRST and the more-specific overrides AFTER it — otherwise a trailing +# "*" would shadow every specific rule above it. (Global+local merge preserves +# this: global patterns are emitted before local ones so local overrides win.) + [permissions.edit] "*" = "ask" "src/**" = "allow" [permissions.bash] +"*" = "ask" "npm test" = "allow" "npm run *" = "allow" "git status" = "allow" @@ -63,7 +70,6 @@ read = "allow" "git commit *" = "allow" "git push *" = "allow" "ls *" = "allow" -"*" = "ask" [permissions.external_directory] "~/*" = "ask" diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 913fb15..539663c 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -75,6 +75,7 @@ import { type UsageStats, type UserContentPart, validateConfig, + watchDirConfig, } from "@dispatch/core"; import type { PermissionManager } from "./permission-manager.js"; import { setConfigGetter } from "./routes/config.js"; @@ -299,11 +300,23 @@ export class AgentManager { * configs are re-read on demand after a clear). */ private lspServersByDir: Map<string, ResolvedLspServer[]> = new Map(); + /** + * One file watcher per distinct SUBDIRECTORY config we've cached in + * `lspServersByDir`. The main `configWatcher` only watches the root + + * global `dispatch.toml`; a tab whose effective working directory is a + * subdirectory with its own `dispatch.toml` needs its cache entry cleared + * when THAT file changes. Keyed by directory; closed on full reload (the + * cache is dropped wholesale then) and in `destroy()`. + */ + private lspDirWatchers: Map<string, { close(): void }> = new Map(); + /** Root working directory watched by `configWatcher` (constructor). */ + private rootWorkingDirectory = ""; constructor(permissionManager?: PermissionManager) { this.permissionManager = permissionManager; const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); + this.rootWorkingDirectory = workingDirectory; // Load initial config this.config = loadConfig(workingDirectory); @@ -345,6 +358,10 @@ export class AgentManager { // so the next tool build re-reads each working directory's // `dispatch.toml` `[lsp]` block. this.lspServersByDir.clear(); + // Tear down the per-subdirectory LSP watchers too; they are lazily + // re-registered by `getLspServersForDir` as directories are re-cached. + for (const watcher of this.lspDirWatchers.values()) watcher.close(); + this.lspDirWatchers.clear(); // Re-discover Claude accounts: a config reload may accompany freshly // imported credentials, and (critically) lets a process that failed // account discovery at boot recover without a full restart. @@ -413,10 +430,43 @@ export class AgentManager { servers = []; } this.lspServersByDir.set(dir, servers); + // Hot-reload for SUBDIRECTORY configs: the root/global watcher in the + // constructor does not cover a nested `dispatch.toml`. Register a + // one-per-dir watcher the first time we cache a directory so editing + // its config invalidates just this entry (and cached agents) without a + // restart. The root working directory is already covered by + // `configWatcher`, so skip it to avoid a redundant watch. + this.ensureLspDirWatcher(dir); return servers; } /** + * Register (once) a file watcher on `<dir>/dispatch.toml` so a change to a + * subdirectory config invalidates that directory's LSP cache entry and + * any cached agents. No-op for the root working directory (already watched + * by `configWatcher`) and for directories already being watched. + */ + private ensureLspDirWatcher(dir: string): void { + if (dir === this.rootWorkingDirectory) return; + if (this.lspDirWatchers.has(dir)) return; + const watcher = watchDirConfig(dir, () => { + // Drop just this directory's resolved servers; the next tool build + // re-reads (and re-merges global) for it. + this.lspServersByDir.delete(dir); + // Invalidate cached agents so the next message rebuilds tools with + // the updated server set. + for (const tabAgent of this.tabAgents.values()) { + tabAgent.agent = null; + } + for (const tabId of this.tabAgents.keys()) { + this.emit({ type: "config-reload" }, tabId); + this.routeSystemEventToTab(tabId, "config-reload", "Configuration reloaded"); + } + }); + this.lspDirWatchers.set(dir, watcher); + } + + /** * Build the `onAfterWrite` hook for `createWriteFileTool` when the tab's * working directory has LSP servers configured. The hook touches the * just-written file through the LSP and returns a formatted diagnostics @@ -2393,6 +2443,8 @@ export class AgentManager { destroy(): void { this.configWatcher?.close(); this.skillsWatcher?.close(); + for (const watcher of this.lspDirWatchers.values()) watcher.close(); + this.lspDirWatchers.clear(); // Shut down all long-lived LSP server processes. Fire-and-forget: the // promise is detached so `destroy()` stays synchronous (matching its // existing contract), but every client gets `shutdown()` called. diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index 5032689..7d8342e 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -328,6 +328,9 @@ vi.mock("@dispatch/core", () => ({ createConfigWatcher(_dir: string, _onChange: unknown) { return { close() {} }; }, + watchDirConfig(_dir: string, _onChange: unknown) { + return { close() {} }; + }, loadSkills(_dir: string) { return { skills: [], mappings: [] }; }, diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index a1bf4a6..2d5c7c7 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -141,6 +141,9 @@ vi.mock("@dispatch/core", () => ({ createConfigWatcher(_dir: string, _onChange: unknown) { return { close() {} }; }, + watchDirConfig(_dir: string, _onChange: unknown) { + return { close() {} }; + }, loadSkills(_dir: string) { return { skills: [], mappings: [] }; }, diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 4806409..7f76dd7 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -6,4 +6,4 @@ export { mergeConfigs, } from "./loader.js"; export { validateConfig } from "./schema.js"; -export { createConfigWatcher } from "./watcher.js"; +export { createConfigWatcher, watchDirConfig } from "./watcher.js"; diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts index 9414ef6..ad55804 100644 --- a/packages/core/src/config/watcher.ts +++ b/packages/core/src/config/watcher.ts @@ -69,3 +69,61 @@ export function createConfigWatcher( }, }; } + +/** + * Watch a SINGLE directory's `dispatch.toml` (no global merge, no reload — just + * a debounced change signal). Used by the agent manager to invalidate its + * per-directory LSP cache when a tab's effective working directory is a + * SUBDIRECTORY with its own `dispatch.toml`: the main `createConfigWatcher` + * only watches the root + global configs, so without this a nested config edit + * would never clear `lspServersByDir[subdir]` and agents there would keep using + * stale LSP servers until a root-config change or restart. + * + * `onChange` fires (debounced) on add/change/unlink of `<dir>/dispatch.toml`. + */ +export function watchDirConfig(dir: string, onChange: () => void): { close(): void } { + const tomlPath = join(dir, "dispatch.toml"); + let debounceTimer: ReturnType<typeof setTimeout> | null = null; + + const watcher = watch(tomlPath, { + ignoreInitial: true, + persistent: false, + }); + + const handleChange = () => { + if (debounceTimer !== null) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + try { + onChange(); + } catch (err) { + console.warn( + `dispatch: dir config watcher onChange error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, 300); + }; + + watcher.on("change", handleChange); + watcher.on("add", handleChange); + watcher.on("unlink", handleChange); + watcher.on("error", (err) => { + console.warn( + `dispatch: dir config watcher error: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + + return { + close() { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + watcher.close().catch((err) => { + console.warn( + `dispatch: error closing dir config watcher: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b636cde..e951d08 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,6 +44,7 @@ export { loadGlobalConfig, mergeConfigs, validateConfig, + watchDirConfig, } from "./config/index.js"; // Credentials export * from "./credentials/index.js"; diff --git a/packages/core/tests/config/watcher.test.ts b/packages/core/tests/config/watcher.test.ts new file mode 100644 index 0000000..9388c8a --- /dev/null +++ b/packages/core/tests/config/watcher.test.ts @@ -0,0 +1,50 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { watchDirConfig } from "../../src/config/watcher.js"; + +const TMP = join("/tmp/opencode", "dispatch-watchdir-test"); + +beforeEach(() => { + mkdirSync(TMP, { recursive: true }); +}); + +afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +function wait(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("watchDirConfig", () => { + it("fires onChange (debounced) when <dir>/dispatch.toml changes", async () => { + let calls = 0; + const handle = watchDirConfig(TMP, () => { + calls++; + }); + // Let chokidar finish its initial scan before mutating the file. + await wait(200); + + writeFileSync(join(TMP, "dispatch.toml"), `[permissions]\nread = "allow"\n`, "utf-8"); + // 300ms debounce + chokidar latency. + await wait(700); + + handle.close(); + expect(calls).toBeGreaterThanOrEqual(1); + }); + + it("does not fire after close()", async () => { + let calls = 0; + const handle = watchDirConfig(TMP, () => { + calls++; + }); + await wait(200); + handle.close(); + + writeFileSync(join(TMP, "dispatch.toml"), `[permissions]\nedit = "deny"\n`, "utf-8"); + await wait(700); + + expect(calls).toBe(0); + }); +}); |
