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 /packages/core | |
| 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
Diffstat (limited to 'packages/core')
| -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 |
4 files changed, 110 insertions, 1 deletions
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); + }); +}); |
