From 4a006b12104862f043b6d1aa41d0036b5e8abc60 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 9 Jul 2026 21:11:58 -0400 Subject: [PATCH] refactor(core): derive config watches from entries --- packages/core/src/config.ts | 48 ++++++++++++++---------- packages/core/test/config/config.test.ts | 40 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 4533f9b378..e12f7848d6 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -119,6 +119,11 @@ export class Directory extends Schema.Class("Config.Directory")({ path: AbsolutePath, }) {} +export class File extends Schema.Class("Config.File")({ + type: Schema.Literal("file"), + path: AbsolutePath, +}) {} + export class AgentsDirectory extends Schema.Class("Config.AgentsDirectory")({ type: Schema.Literal("agents"), path: AbsolutePath, @@ -129,7 +134,7 @@ export class ClaudeDirectory extends Schema.Class("Config.Claud path: AbsolutePath, }) {} -export type Entry = Document | Directory | AgentsDirectory | ClaudeDirectory +export type Entry = Document | Directory | File | AgentsDirectory | ClaudeDirectory export function latest(entries: readonly Entry[], key: K): Info[K] | undefined { return entries @@ -138,7 +143,7 @@ export function latest(entries: readonly Entry[], key: K): } export interface Interface { - /** Returns location config documents and supplemental directories from lowest to highest priority. */ + /** Returns location config documents and discovery sources from lowest to highest priority. */ readonly entries: () => Effect.Effect } @@ -227,31 +232,36 @@ const layer = Layer.effect( const directPaths = discovered .filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))) .toReversed() - const direct = yield* Effect.forEach(directPaths, loadFile).pipe( + const direct = yield* Effect.forEach(directPaths, (filepath) => + loadFile(filepath).pipe( + Effect.map((config) => [ + ...(config ? [config] : []), + new File({ type: "file", path: AbsolutePath.make(filepath) }), + ]), + ), + ).pipe( Effect.orDie, - Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), + Effect.map((entries) => entries.flat()), ) const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) - return { - entries: [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()], - directories: [...directories, ...claude.map((entry) => entry.path), ...agents.map((entry) => entry.path)], - files: directPaths, - } + return [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] }) const initial = yield* discover() - let configs = initial.entries + let configs = initial const updates = yield* PubSub.unbounded() const subscriptions = new Map>() - const targets = (snapshot: typeof initial) => [ - ...snapshot.directories.map((path) => ({ path, type: "directory" as const })), - ...snapshot.files - .filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file))) - .map((path) => ({ path, type: "file" as const })), - ] - const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) { - const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target])) + const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) { + const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) + const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : [])) + const targets = [ + ...directories.map((path) => ({ path, type: "directory" as const })), + ...files + .filter((file) => !directories.some((directory) => FSUtil.contains(directory, file))) + .map((path) => ({ path, type: "file" as const })), + ] + const next = new Map(targets.map((target) => [JSON.stringify(target), target])) for (const [key, stop] of subscriptions) { if (next.has(key)) continue yield* stop @@ -272,7 +282,7 @@ const layer = Layer.effect( Stream.runForEach((update) => Effect.gen(function* () { const next = yield* discover() - configs = next.entries + configs = next yield* reconcile(next) yield* events.publish(ConfigSchema.Event.Updated, {}) }).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 4df256c86b..e6c0a6b915 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -254,6 +254,43 @@ describe("Config", () => { ), ) + it.live("does not watch ecosystem config roots", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + fs.mkdir(path.join(tmp.path, ".claude", "skills"), { recursive: true }), + fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }), + ]), + ) + const targets: Watcher.WatchInput[] = [] + const watcher = Layer.succeed( + Watcher.Service, + Watcher.Service.of({ + subscribe: (input) => { + targets.push(input) + return Stream.never + }, + }), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + yield* config.entries() + + expect(targets).toEqual([ + { type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }, + ]) + }).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, watcher))) + }), + ), + ), + ) + it.live("loads opencode JSON and JSONC files from lowest to highest priority", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -915,8 +952,11 @@ describe("Config", () => { "global", AbsolutePath.make(global), "root", + AbsolutePath.make(path.join(root, "opencode.json")), "parent", + AbsolutePath.make(path.join(parent, "opencode.jsonc")), "directory", + AbsolutePath.make(path.join(directory, "opencode.json")), "root-dot", AbsolutePath.make(path.join(root, ".opencode")), "directory-dot",