diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 2857385fc4dc..b947519671e4 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -4,7 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import path from "path" import { isDeepStrictEqual } from "node:util" import { type ParseError, parse } from "jsonc-parser" -import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect" +import { Context, Effect, FiberMap, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect" import { AgentsDirectory, ClaudeDirectory, @@ -23,6 +23,8 @@ import { Location } from "./location.js" import { AbsolutePath } from "./schema.js" import { ConfigVariable } from "./config/variable.js" import { ConfigNormalize } from "./config/normalize.js" +import { ConfigDiscovery } from "./config/discovery.js" +import { ConfigWatch } from "./config/watch.js" import { WellKnown } from "./wellknown.js" export function latest(entries: readonly Entry[], key: K): Info[K] | undefined { @@ -83,15 +85,12 @@ export const layer = (options?: Options) => Service, Effect.gen(function* () { const fs = yield* FSUtil.Service - const global = yield* Global.Service const location = yield* Location.Service const watcher = yield* Watcher.Service const bus = yield* Bus.Service const credentials = yield* Credential.Service const wellknown = yield* WellKnown.Service - const names = ["opencode.json", "opencode.jsonc"] const reloadLock = Semaphore.makeUnsafe(1) - const fileTargets = new Set() const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) { @@ -177,90 +176,21 @@ export const layer = (options?: Options) => const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { return [ - ...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( + ...(yield* Effect.forEach(ConfigDiscovery.names, (file) => loadFile(path.join(directory, file))).pipe( Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), )), new Directory({ type: "directory", path: directory }), ] }) - const discover = Effect.fn("Config.discover")(function* () { - const globalDirectory = AbsolutePath.make(global.config) - const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents")) - const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude")) - // Global roots and the walk are compared by canonical path: the same - // directory reached under two spellings (a symlinked checkout, macOS - // /var vs /private/var, OPENCODE_CONFIG_DIR inside the project) must - // classify identically or it enters discovery twice. - const globalRoots = yield* Effect.forEach( - [globalDirectory, globalClaudeDirectory, globalAgentsDirectory], - (item) => fs.resolve(item), - ) - const locationIsGlobal = (yield* fs.resolve(location.directory)) === globalRoots[0] - const discovered = - locationIsGlobal || options?.project === false - ? [] - : yield* fs - .up({ - targets: [".opencode", ".claude", ".agents", ...names.toReversed()], - start: location.directory, - }) - .pipe( - Effect.flatMap((items) => - Effect.forEach(items, (item) => - fs.resolve(item).pipe(Effect.map((resolved) => ({ item, resolved }))), - ), - ), - Effect.orDie, - ) - - const globalEnabled = options?.global !== false - // A walked path that resolves into a global root is global config - // however the walk reached it (home above the project, or a location - // beneath the global config dir), so global: false excludes it - // uniformly — classified once here, not per consumer below. With - // global enabled, the roots themselves and the global config files are - // already loaded below, so the walk must not add them a second time. - const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name))) - const visible = discovered - .filter(({ resolved }) => - globalEnabled - ? !globalRoots.includes(resolved) && !globalFiles.includes(resolved) - : !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)), - ) - .map(({ item }) => item) - // We load certain files from a few other folders in the ecosystem - const claude = [ - ...new Set([ - ...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []), - ...visible.filter((item) => path.basename(item) === ".claude").toReversed(), - ]), - ].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })) - const agents = [ - ...new Set([ - ...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []), - ...visible.filter((item) => path.basename(item) === ".agents").toReversed(), - ]), - ].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })) - - const projectDirectories = visible - .filter((item) => path.basename(item) === ".opencode") - .toReversed() - .map((directory) => AbsolutePath.make(directory)) - const directPaths = visible - .filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))) - .toReversed() - fileTargets.clear() - directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath))) - const direct = yield* Effect.forEach(directPaths, (filepath) => loadFile(filepath)).pipe( + const load = Effect.fn("Config.load")(function* (sources: ConfigDiscovery.Sources) { + const direct = yield* Effect.forEach(sources.direct, (filepath) => loadFile(filepath)).pipe( Effect.orDie, Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)), ) - const file = options?.file - if (file) fileTargets.add(AbsolutePath.make(path.resolve(file))) - const explicit = file - ? yield* loadFile(path.resolve(file)).pipe( + const explicit = sources.explicit + ? yield* loadFile(sources.explicit).pipe( Effect.map((config) => (config ? [config] : [])), Effect.orDie, ) @@ -281,15 +211,18 @@ export const layer = (options?: Options) => // Global entries sit below explicit and direct files; project // directories rank above them. - const globalSupplementary = globalEnabled ? yield* loadDirectory(globalDirectory).pipe(Effect.orDie) : [] - const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe( + const globalSupplementary = sources.global ? yield* loadDirectory(sources.global).pipe(Effect.orDie) : [] + const projectSupplementary = yield* Effect.forEach( + sources.project.filter((root) => root.present), + (root) => loadDirectory(root.path), + ).pipe( Effect.orDie, Effect.map((entries) => entries.flat()), ) return [ ...(yield* loadWellknown().pipe(Effect.orDie)), - ...claude, - ...agents, + ...sources.claude.map((path) => new ClaudeDirectory({ type: "claude", path })), + ...sources.agents.map((path) => new AgentsDirectory({ type: "agents", path })), ...globalSupplementary, ...explicit, ...direct, @@ -298,44 +231,35 @@ export const layer = (options?: Options) => ] }) - const initial = yield* discover() - let configs = initial + const initial = yield* ConfigDiscovery.discover(options) + let configs = yield* load(initial) const updates = yield* PubSub.unbounded() - // Vendored trees inside config roots (a plugin's node_modules, a nested - // .git) produce event blizzards that can never change discovery output. - const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"] - // Watch-once: roots leave discovery only by deletion, so a stale watch is - // inert, bounded, and dies with this layer — and keeping a deleted root's - // watch alive is exactly what makes its recreation observable. - const watched = new Set() - 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 === "document" && entry.path ? [entry.path] : [])), - ...fileTargets, - ] - const targets = [ - ...directories.map((path) => ({ path, type: "directory" as const, ignore })), - ...files - .filter((file) => !directories.some((directory) => FSUtil.contains(directory, file))) - .map((path) => ({ path, type: "file" as const })), - ] - for (const target of targets) { - const key = JSON.stringify(target) - if (watched.has(key)) continue - watched.add(key) - const stream = yield* watcher.subscribe(target) - yield* stream.pipe( - Stream.runForEach((update) => PubSub.publish(updates, update)), - Effect.forkScoped({ startImmediately: true }), - ) + const reloads = yield* PubSub.sliding(1) + // Readiness rescans recover writes made before a watch attached. + const requestReload = PubSub.publish(reloads, undefined).pipe(Effect.asVoid) + const watched = yield* FiberMap.make() + const reconcile = Effect.fn("Config.reconcileWatches")(function* (sources: ConfigDiscovery.Sources) { + const plan = ConfigWatch.plan(sources) + for (const key of Array.from(watched, ([key]) => key)) { + if (!plan.has(key)) yield* FiberMap.remove(watched, key) + } + for (const [key, target] of plan) { + yield* watcher + .subscribe(target, requestReload) + .pipe( + Effect.flatMap( + Stream.runForEach((update) => PubSub.publish(updates, update).pipe(Effect.andThen(requestReload))), + ), + FiberMap.run(watched, key, { onlyIfMissing: true, startImmediately: true }), + ) } }) const reload = Effect.fn("Config.reload")( function* () { - const next = yield* discover() - yield* reconcile(next) + const sources = yield* ConfigDiscovery.discover(options) + const next = yield* load(sources) + yield* reconcile(sources) if (isDeepStrictEqual(configs, next)) return configs = next yield* bus.publish(Event.Updated, {}) @@ -343,12 +267,12 @@ export const layer = (options?: Options) => (effect) => reloadLock.withPermit(effect), ) - yield* Stream.fromPubSub(updates).pipe( + // Subscribe eagerly so synchronous watch readiness isn't dropped. + const pendingReloads = yield* PubSub.subscribe(reloads) + yield* Stream.fromSubscription(pendingReloads).pipe( Stream.debounce("100 millis"), - Stream.runForEach((update) => - reload().pipe( - Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })), - ), + Stream.runForEach(() => + reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { cause }))), ), Effect.forkScoped({ startImmediately: true }), ) @@ -389,7 +313,7 @@ export const layer = (options?: Options) => Effect.forever, Effect.forkScoped({ startImmediately: true }), ) - yield* reconcile(initial) + yield* reloadLock.withPermit(reconcile(initial)) return Service.of({ entries: Effect.fnUntraced(function* () { diff --git a/packages/core/src/config/discovery.ts b/packages/core/src/config/discovery.ts new file mode 100644 index 000000000000..ad21f5d7da5c --- /dev/null +++ b/packages/core/src/config/discovery.ts @@ -0,0 +1,85 @@ +export * as ConfigDiscovery from "./discovery.js" + +import path from "path" +import { Effect } from "effect" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Global } from "@opencode-ai/util/global" +import { Location } from "../location.js" +import { AbsolutePath } from "../schema.js" +import type { Options } from "../config.js" + +export const names = ["opencode.json", "opencode.jsonc"] + +/** Eligible sources in priority order, including paths that may appear later. */ +export interface Sources { + readonly global?: AbsolutePath + readonly explicit?: AbsolutePath + readonly direct: readonly AbsolutePath[] + readonly project: readonly { readonly path: AbsolutePath; readonly present: boolean }[] + readonly claude: readonly AbsolutePath[] + readonly agents: readonly AbsolutePath[] +} + +export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options?: Options) { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const location = yield* Location.Service + const globalDirectory = AbsolutePath.make(global.config) + const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents")) + const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude")) + const globalRoots = yield* Effect.forEach([globalDirectory, globalClaudeDirectory, globalAgentsDirectory], (item) => + fs.resolve(item), + ) + const directories = + (yield* fs.resolve(location.directory)) === globalRoots[0] || options?.project === false + ? [] + : yield* fs.up({ targets: ["."], start: location.directory }).pipe(Effect.orDie) + const discovered = yield* Effect.forEach(directories, (directory) => + Effect.gen(function* () { + // Resolve the parent too: missing children must honor symlinked global roots. + const parent = yield* fs.resolve(directory) + const ecosystem = yield* Effect.filter([".claude", ".agents"], (name) => fs.exists(path.join(directory, name))) + return yield* Effect.forEach([...ecosystem, ".opencode", ...names.toReversed()], (name) => + fs + .resolve(path.join(parent, name)) + .pipe(Effect.map((resolved) => ({ item: AbsolutePath.make(path.join(directory, name)), resolved }))), + ) + }), + ).pipe( + Effect.map((items) => items.flat()), + Effect.orDie, + ) + + const globalEnabled = options?.global !== false + const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name))) + // Global sources must not re-enter through the project walk. + const visible = discovered + .filter(({ resolved }) => + globalEnabled + ? !globalRoots.includes(resolved) && !globalFiles.includes(resolved) + : !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)), + ) + .map(({ item }) => item) + + return { + global: globalEnabled ? globalDirectory : undefined, + explicit: options?.file ? AbsolutePath.make(path.resolve(options.file)) : undefined, + direct: visible.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))).toReversed(), + project: yield* Effect.forEach( + visible.filter((item) => path.basename(item) === ".opencode").toReversed(), + (directory) => fs.isDir(directory).pipe(Effect.map((present) => ({ path: directory, present }))), + ), + claude: [ + ...new Set([ + ...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []), + ...visible.filter((item) => path.basename(item) === ".claude").toReversed(), + ]), + ], + agents: [ + ...new Set([ + ...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []), + ...visible.filter((item) => path.basename(item) === ".agents").toReversed(), + ]), + ], + } satisfies Sources +}) diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index df4c178bdfbf..88004d5b80ef 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -33,7 +33,7 @@ export const Plugin = define({ const changes = yield* PubSub.sliding(1) const lock = Semaphore.makeUnsafe(1) - const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) { + const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: "file" | "directory") { const target = path.resolve(directory) const updates = yield* watcher.subscribe({ path: target, type }) yield* FiberMap.run( diff --git a/packages/core/src/config/watch.ts b/packages/core/src/config/watch.ts new file mode 100644 index 000000000000..9a99e175171f --- /dev/null +++ b/packages/core/src/config/watch.ts @@ -0,0 +1,37 @@ +export * as ConfigWatch from "./watch.js" + +import path from "path" +import { FSUtil } from "@opencode-ai/util/fs-util" +import type { Watcher } from "../filesystem/watcher.js" +import type { ConfigDiscovery } from "./discovery.js" + +export function plan(sources: ConfigDiscovery.Sources) { + const directories = [ + ...(sources.global ? [sources.global] : []), + ...sources.project.filter((root) => root.present).map((root) => root.path), + ] + const files = [ + ...sources.direct, + ...sources.project.map((root) => root.path), + ...(sources.explicit ? [sources.explicit] : []), + ] + // Keep a parent watch for each root so deletion/recreation is observable. + const parents = Map.groupBy( + files.filter((file) => !directories.some((directory) => file !== directory && FSUtil.contains(directory, file))), + (file) => path.dirname(file), + ) + return new Map( + [ + ...directories.map((path) => ({ + path, + type: "directory" as const, + ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"], + })), + ...Array.from(parents, ([parent, files]) => ({ + path: parent, + type: "entries" as const, + names: [...new Set(files.map((file) => path.basename(file)))].toSorted(), + })), + ].map((target) => [JSON.stringify(target), target satisfies Watcher.WatchInput]), + ) +} diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 468adefbaa25..b1ef3fefaaea 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -34,6 +34,7 @@ export type Update = ParcelWatcher.Event export type WatchInput = | { readonly path: string; readonly type: "file" } + | { readonly path: string; readonly type: "entries"; readonly names: readonly string[] } | { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] } export type Subscription = { @@ -42,25 +43,26 @@ export type Subscription = { readonly backend?: string } +type Target = { + readonly target: string + readonly ignore: readonly string[] +} & ( + | { readonly type: "entries"; readonly names: readonly string[] } + | { readonly type: "file" | "directory"; readonly names?: readonly string[] } +) + export interface NativeInterface { - /** Starts one OS-level watch, reporting events through `publish` until unsubscribed. */ - readonly subscribe: (input: { - readonly type: WatchInput["type"] - readonly target: string - readonly ignore: readonly string[] - readonly publish: (update: Update) => void - }) => Effect.Effect + readonly subscribe: ( + input: Target & { readonly publish: (update: Update) => void }, + ) => Effect.Effect } -/** - * The OS-level watch implementation behind the Watcher service. The default - * layer uses `node:fs.watch` for files and `@parcel/watcher` for directories; - * tests provide implementations they can control. - */ +/** Uses fs.watch for immediate entries and Parcel for recursive directories. */ export class Native extends Context.Service()("@opencode/Watcher/Native") {} export interface Interface { - readonly subscribe: (input: WatchInput) => Effect.Effect> + /** onReady runs after native acquisition and listener registration, when the stream is consumed. */ + readonly subscribe: (input: WatchInput, onReady?: Effect.Effect) => Effect.Effect> } export const Options = Schema.Struct({ @@ -89,16 +91,13 @@ export const layer = (options?: Options) => const native = yield* Native // Keys compare structurally (effect Equal), so equivalent watches share one entry. - type Key = { readonly type: WatchInput["type"]; readonly target: string; readonly ignore: readonly string[] } const watchers = yield* RcMap.make({ - lookup: (key: Key) => + lookup: (key: Target) => Effect.gen(function* () { const pubsub = yield* Effect.acquireRelease(PubSub.unbounded(), (pubsub) => PubSub.shutdown(pubsub)) const subscription = yield* Effect.acquireRelease( native.subscribe({ - type: key.type, - target: key.target, - ignore: key.ignore, + ...key, publish: (update) => PubSub.publishUnsafe(pubsub, update), }), (subscription) => @@ -127,34 +126,31 @@ export const layer = (options?: Options) => }), }) - const subscribe = (input: WatchInput) => { + const subscribe = Effect.fnUntraced(function* (input: WatchInput, onReady: Effect.Effect = Effect.void) { const target = path.resolve(input.path) const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted() - return Effect.gen(function* () { - yield* Effect.logInfo("watcher subscribe", { - path: target, - type: input.type, - ignores: ignore.length, - }) - return Stream.unwrap( - Effect.gen(function* () { - const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore }) - return Stream.fromPubSub(pubsub) - }), - ) + const names = [...new Set(input.type === "entries" ? input.names : [])].toSorted() + yield* Effect.logInfo("watcher subscribe", { + path: target, + type: input.type, + ignores: ignore.length, }) - } + return Stream.unwrap( + Effect.gen(function* () { + const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore, names }) + const subscription = yield* PubSub.subscribe(pubsub) + if (yield* PubSub.isShutdown(pubsub)) return Stream.empty + yield* onReady + return Stream.fromSubscription(subscription) + }), + ) + }) return Service.of({ subscribe }) }), ) -/** - * Watcher for tests: the real lifecycle over an in-memory Native that records - * acquired watches and routes emitted updates the way the OS watches would: a - * file watch receives updates for its own path, a directory watch receives - * updates for paths inside it that no ignore entry covers. - */ +/** Real subscription lifecycle with in-memory, path-filtered event delivery. */ export const testLayer = Layer.effectContext( Effect.gen(function* () { const subscriptions: WatchInput[] = [] @@ -165,21 +161,22 @@ export const testLayer = Layer.effectContext( subscriptions.push( input.type === "file" ? { path: input.target, type: "file" } - : input.ignore.length > 0 - ? { path: input.target, type: "directory", ignore: input.ignore } - : { path: input.target, type: "directory" }, + : input.type === "entries" + ? { path: input.target, type: "entries", names: input.names } + : input.ignore.length > 0 + ? { path: input.target, type: "directory", ignore: input.ignore } + : { path: input.target, type: "directory" }, ) // Ignore entries resolve against the target like the parcel wrapper's // literal paths. Glob entries resolve to paths nothing lives under, so // they are inert here rather than compiled the way parcel compiles them. const ignored = input.ignore.map((entry) => path.resolve(input.target, entry)) - active.set( - input.publish, - input.type === "file" - ? (target) => target === input.target - : (target) => - FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target)), - ) + active.set(input.publish, (target) => { + if (input.type === "file") return target === input.target + if (input.type === "entries") + return path.dirname(target) === input.target && input.names.includes(path.basename(target)) + return FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target)) + }) return { unsubscribe: () => { active.delete(input.publish) @@ -208,18 +205,19 @@ export const nativeLayer = Layer.succeed( Native, Native.of({ subscribe: (input) => { - if (input.type === "file") { + if (input.type === "file" || input.type === "entries") { return Effect.sync(() => { - const directory = path.dirname(input.target) + const directory = input.type === "file" ? path.dirname(input.target) : input.target + const names = new Set(input.type === "file" ? [path.basename(input.target)] : input.names) const subscription = watch(directory, { recursive: false }, (_event, file) => { - if (file && path.resolve(directory, file.toString()) !== input.target) return - input.publish({ path: input.target, type: "update" } satisfies Update) + if (file && !names.has(file)) return + for (const name of file ? [file] : names) { + input.publish({ path: path.join(directory, name), type: "update" }) + } }) - if ("on" in subscription && typeof subscription.on === "function") { - subscription.on("error", (error: unknown) => - Effect.runFork(Effect.logError("watcher callback failed", { path: input.target, error })), - ) - } + subscription.on("error", (error: unknown) => + Effect.runFork(Effect.logError("watcher callback failed", { path: directory, error })), + ) return { unsubscribe: () => Promise.resolve(subscription.close()), backend: "node" } }) } diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 8ba8e2d07c08..88c37be03870 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -165,7 +165,16 @@ describe("Config", () => { const entries = yield* config.entries() expect(entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))).toEqual([global]) expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual(["global"]) - expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global]) + expect( + (yield* watcher.subscriptions()) + .filter((subscription) => subscription.type === "directory") + .map((subscription) => subscription.path), + ).toEqual([global]) + expect( + (yield* watcher.subscriptions()).filter((subscription) => + subscription.path.includes(`${path.sep}.opencode${path.sep}`), + ), + ).toEqual([]) }) return Effect.promise(async () => { await fs.mkdir(global, { recursive: true }) @@ -230,6 +239,8 @@ describe("Config", () => { Effect.gen(function* () { const config = yield* Config.Service expect(Config.latest(yield* config.entries(), "shell")).toBe("global") + const watcher = yield* Watcher.Test + expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global]) }).pipe( Effect.provide( testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, { @@ -243,17 +254,19 @@ describe("Config", () => { ), ) - it.live("reloads external config and publishes directory updates", () => + it.live("reloads file substitutions when their source changes", () => Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( Effect.flatMap((tmp) => Effect.gen(function* () { const global = path.join(tmp.path, "global") const project = path.join(tmp.path, "project") const file = path.join(global, "opencode.json") + const source = path.join(global, "shell.txt") yield* Effect.promise(async () => { await fs.mkdir(global, { recursive: true }) await fs.mkdir(project, { recursive: true }) - await fs.writeFile(file, JSON.stringify({ shell: "first" })) + await fs.writeFile(source, "first") + await fs.writeFile(file, JSON.stringify({ shell: "{file:shell.txt}" })) }) return yield* Effect.gen(function* () { const config = yield* Config.Service @@ -264,9 +277,8 @@ describe("Config", () => { .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.sleep("10 millis") - yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") }) - yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" }))) - yield* watcher.emit({ type: "update", path: file }) + yield* Effect.promise(() => fs.writeFile(source, "second")) + yield* watcher.emit({ type: "update", path: source }) expect(yield* Fiber.join(changed)).toHaveLength(1) expect(Config.latest(yield* config.entries(), "shell")).toBe("second") @@ -276,6 +288,35 @@ describe("Config", () => { ), ) + it.live("excludes missing files under symlinked global roots when global is disabled", () => + Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const link = path.join(tmp.path, "link") + const project = path.join(link, "plugins", "demo") + return Effect.promise(async () => { + await fs.mkdir(path.join(global, "plugins", "demo"), { recursive: true }) + await fs.symlink(global, link, process.platform === "win32" ? "junction" : undefined) + }).pipe( + Effect.andThen( + Effect.gen(function* () { + const watcher = yield* Watcher.Test + const subscriptions = yield* watcher.subscriptions() + expect(subscriptions.length).toBeGreaterThan(0) + expect( + subscriptions.filter((item) => inFixture(global, item.path) || inFixture(link, item.path)), + ).toEqual([]) + }).pipe( + Effect.provide( + testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }), + ), + ), + ), + ) + }), + ), + ) + it.live("exposes filesystem updates under config roots through changes", () => Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( Effect.flatMap((tmp) => @@ -892,6 +933,7 @@ describe("Config", () => { path: AbsolutePath.make(path.join(tmp.path, "global")), ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"], }, + { type: "entries", path: tmp.path, names: [".opencode", "opencode.json", "opencode.jsonc"] }, ]) }).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer))) }), @@ -1447,8 +1489,9 @@ describe("Config", () => { expect(documents.map((document) => document.info.$schema)).toEqual(["base"]) expect(yield* watcher.subscriptions()).toContainEqual({ - path: path.join(tmp.path, "opencode.jsonc"), - type: "file", + path: tmp.path, + type: "entries", + names: [".opencode", "opencode.json", "opencode.jsonc"], }) }).pipe(Effect.provide(testLayer(tmp.path))) }), diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts index dda3cd834c6f..32bbac5c0367 100644 --- a/packages/core/test/config/reload.test.ts +++ b/packages/core/test/config/reload.test.ts @@ -1,4 +1,6 @@ import path from "path" +import fs from "fs/promises" +import { writeFileSync } from "node:fs" import { describe, expect } from "bun:test" import { Document, Event, Info } from "@opencode-ai/schema/config" import { Agent } from "@opencode-ai/core/agent" @@ -20,12 +22,19 @@ import { Reference } from "@opencode-ai/core/reference" import { Skill } from "@opencode-ai/core/skill" import { ShellSelect } from "@opencode-ai/core/shell/select" import { Global } from "@opencode-ai/util/global" +import { Location } from "@opencode-ai/core/location" +import { Credential } from "@opencode-ai/core/credential" +import { WellKnown } from "@opencode-ai/core/wellknown" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { AppProcess } from "@opencode-ai/util/process" -import { Effect, Layer, Schema } from "effect" +import { Deferred, Effect, Layer, Schema } from "effect" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "../plugin/fixture" +import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes" +import { location } from "../fixture/location" +import { tmpdir } from "../fixture/tmpdir" const it = testEffect( Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))), @@ -34,6 +43,144 @@ const decode = Schema.decodeUnknownSync(Info) const document = path.join(import.meta.dir, "opencode.json") describe("config plugin reloads", () => { + it.live("retains readiness signalled synchronously during initial config startup", () => + Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( + Effect.flatMap((tmp) => { + const native = Watcher.Native.of({ + subscribe: (input) => + Effect.sync(() => { + if (input.type === "entries" && input.target === tmp.path) { + // No event is emitted: only synchronous readiness can trigger the reload. + writeFileSync(path.join(tmp.path, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } })) + } + return { unsubscribe: () => Promise.resolve() } + }), + }) + return Effect.gen(function* () { + const plugins = yield* Plugin.Service + const references = yield* Reference.Service + const host = yield* PluginHost.make(plugins) + yield* ConfigReferencePlugin.Plugin.effect(host) + yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs")))) + expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(tmp.path, "docs"))) + }).pipe(Effect.provide(liveConfig(tmp.path, native))) + }), + ), + ) + + it.live("loads the first config written while a new directory watch is starting", () => + Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, ".opencode") + const parent = yield* Deferred.make<(update: Watcher.Update) => void>() + const starting = yield* Deferred.make() + const release = yield* Deferred.make() + const native = Watcher.Native.of({ + subscribe: (input) => + Effect.gen(function* () { + if (input.type === "entries" && input.target === tmp.path) { + yield* Deferred.succeed(parent, input.publish) + } + if (input.type === "directory" && input.target === root) { + yield* Deferred.succeed(starting, undefined) + yield* Deferred.await(release) + } + return { unsubscribe: () => Promise.resolve() } + }), + }) + return yield* Effect.gen(function* () { + const plugins = yield* Plugin.Service + const references = yield* Reference.Service + const host = yield* PluginHost.make(plugins) + yield* ConfigReferencePlugin.Plugin.effect(host) + const publish = yield* Deferred.await(parent) + yield* Effect.promise(() => fs.mkdir(root)) + publish({ path: root, type: "create" }) + yield* Deferred.await(starting).pipe(Effect.timeout("2 seconds")) + // No file event: the recursive native watch has not been acquired yet. + yield* Effect.promise(() => + fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } })), + ) + yield* Deferred.succeed(release, undefined) + yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs")))) + expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(root, "docs"))) + }).pipe(Effect.provide(liveConfig(tmp.path, native))) + }), + ), + ), + ) + + for (const input of [ + { file: "opencode.json", empty: false }, + { file: "../opencode.jsonc", empty: false }, + { file: ".opencode/opencode.json", empty: false }, + { file: "../.opencode/opencode.jsonc", empty: true }, + ]) { + it.live(`loads references when ${input.file} is first created and keeps watching it`, () => + Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const target = path.resolve(project, input.file) + yield* Effect.promise(() => fs.mkdir(project)) + return yield* Effect.gen(function* () { + const plugins = yield* Plugin.Service + const references = yield* Reference.Service + const host = yield* PluginHost.make(plugins) + yield* ConfigReferencePlugin.Plugin.effect(host) + expect(yield* references.list()).toEqual([]) + + if (input.empty) { + yield* Effect.promise(() => fs.mkdir(path.dirname(target))) + const config = yield* Config.Service + yield* waitUntil( + config + .entries() + .pipe( + Effect.map((entries) => + entries.some((entry) => entry.type === "directory" && entry.path === path.dirname(target)), + ), + ), + ) + } + yield* Effect.promise(async () => { + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } })) + }) + yield* waitUntil( + references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))), + ) + expect((yield* references.list())[0]?.path).toBe( + AbsolutePath.make(path.join(path.dirname(target), "docs")), + ) + yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } }))) + yield* waitUntil( + references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")), + ) + + yield* Effect.promise(() => + fs.rm(input.file.includes(".opencode/") ? path.dirname(target) : target, { recursive: true }), + ) + yield* waitUntil(references.list().pipe(Effect.map((items) => items.length === 0))) + yield* Effect.promise(async () => { + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } })) + }) + yield* waitUntil( + references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "docs")), + ) + yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } }))) + yield* waitUntil( + references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")), + ) + }).pipe(Effect.provide(liveConfig(project))) + }), + ), + ), + ) + } + it.effect("preserves reference precedence and insertion order across documents", () => Effect.gen(function* () { const plugins = yield* Plugin.Service @@ -123,6 +270,23 @@ describe("config plugin reloads", () => { ) }) +function liveConfig(directory: string, native?: Watcher.NativeInterface) { + return AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node, Reference.node, Global.node, Location.node]), [ + Config.node.replace(Config.configured({ global: false })), + Location.node.replace( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ), + Global.node.replace( + Global.layerWith({ config: path.join(directory, "global"), home: path.join(directory, "home") }), + ), + Credential.node.replace(emptyCredentialNode), + WellKnown.node.replace(emptyWellknownNode), + ...(native + ? [Watcher.node.replace(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))] + : []), + ]) +} + function config(name: string) { return new Document({ type: "document", diff --git a/packages/core/test/config/watch.test.ts b/packages/core/test/config/watch.test.ts new file mode 100644 index 000000000000..b3834520d8c5 --- /dev/null +++ b/packages/core/test/config/watch.test.ts @@ -0,0 +1,42 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import type { ConfigDiscovery } from "@opencode-ai/core/config/discovery" +import { ConfigWatch } from "@opencode-ai/core/config/watch" +import { AbsolutePath } from "@opencode-ai/core/schema" + +const project = path.resolve("watch-plan-project") +const root = AbsolutePath.make(path.join(project, ".opencode")) +const sources: ConfigDiscovery.Sources = { + direct: ["opencode.json", "opencode.jsonc"].map((name) => AbsolutePath.make(path.join(project, name))), + project: [{ path: root, present: false }], + claude: [AbsolutePath.make(path.join(project, ".claude"))], + agents: [AbsolutePath.make(path.join(project, ".agents"))], +} + +describe("ConfigWatch.plan", () => { + test("groups missing candidates and keeps parent watches when roots appear", () => { + const missing = ConfigWatch.plan(sources) + expect(Array.from(missing.values())).toEqual([ + { path: project, type: "entries", names: [".opencode", "opencode.json", "opencode.jsonc"] }, + ]) + const present = ConfigWatch.plan({ ...sources, project: [{ path: root, present: true }] }) + expect(Array.from(present.values())).toEqual([ + { path: root, type: "directory", ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"] }, + ...missing.values(), + ]) + }) + + test("adds exact watches for explicit files only when not already covered", () => { + expect(ConfigWatch.plan({ ...sources, explicit: sources.direct[0] })).toEqual(ConfigWatch.plan(sources)) + const present = { ...sources, project: [{ path: root, present: true }] } + expect(ConfigWatch.plan({ ...present, explicit: AbsolutePath.make(path.join(root, "custom.json")) })).toEqual( + ConfigWatch.plan(present), + ) + const directory = path.resolve("watch-plan-external") + expect( + Array.from( + ConfigWatch.plan({ ...sources, explicit: AbsolutePath.make(path.join(directory, "custom.json")) }).values(), + ), + ).toContainEqual({ path: directory, type: "entries", names: ["custom.json"] }) + }) +}) diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 7b73020eddaa..4df4b1dad458 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -52,18 +52,121 @@ function countingNative() { } describe("Watcher lifecycle", () => { + it.effect("signals readiness after acquisition and buffers updates published by the ready callback", () => + Effect.gen(function* () { + const publish = yield* Deferred.make<(update: Watcher.Update) => void>() + const acquired = yield* Deferred.make() + const counts = { ready: 0, closed: 0 } + yield* Effect.gen(function* () { + const watcher = yield* Watcher.Service + const consumer = yield* watcher + .subscribe( + { path: "/shared", type: "entries", names: ["opencode.json"] }, + Effect.gen(function* () { + counts.ready++ + const notify = yield* Deferred.await(publish) + notify({ path: "/shared/opencode.json", type: "create" }) + }), + ) + .pipe(Effect.flatMap(Stream.runHead), Effect.forkScoped({ startImmediately: true })) + yield* Deferred.await(publish) + expect(counts.ready).toBe(0) + yield* Deferred.succeed(acquired, undefined) + expect(Option.getOrUndefined(yield* Fiber.join(consumer))).toEqual({ + path: "/shared/opencode.json", + type: "create", + }) + expect(counts).toEqual({ ready: 1, closed: 1 }) + }).pipe( + withNative({ + subscribe: (input) => + Deferred.succeed(publish, input.publish).pipe( + Effect.andThen(Deferred.await(acquired)), + Effect.as({ + unsubscribe: async () => { + counts.closed++ + }, + }), + ), + }), + ) + }), + ) + + it.effect("does not signal readiness for an unavailable native watch", () => { + const counts = { ready: 0 } + return Effect.gen(function* () { + const watcher = yield* Watcher.Service + const stream = yield* watcher.subscribe( + { path: "/unavailable", type: "directory" }, + Effect.sync(() => { + counts.ready++ + }), + ) + yield* Stream.runDrain(stream) + expect(counts.ready).toBe(0) + }).pipe(withNative({ subscribe: () => Effect.undefined })) + }) + + it.live("watches only named immediate entries, including missing directories", () => + Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const native = yield* Watcher.Native + const events: Watcher.Update[] = [] + yield* Effect.acquireRelease( + native.subscribe({ + type: "entries", + target: tmp.path, + names: ["opencode.json", ".opencode"], + ignore: [], + publish: (update) => events.push(update), + }), + (subscription) => Effect.promise(() => subscription?.unsubscribe() ?? Promise.resolve()), + ) + yield* Effect.promise(async () => { + await fs.mkdir(path.join(tmp.path, "nested")) + await fs.writeFile(path.join(tmp.path, "nested", "opencode.json"), "ignored") + await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "ignored") + await fs.writeFile(path.join(tmp.path, "opencode.json"), "first") + }) + yield* Effect.sync(() => events.length).pipe( + Effect.filterOrFail((count) => count > 0), + Effect.retry(Schedule.spaced("10 millis")), + Effect.timeout("1 second"), + ) + expect(events.every((update) => update.path === path.join(tmp.path, "opencode.json"))).toBe(true) + yield* Effect.sleep("10 millis") + yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".opencode"))) + yield* Effect.sync(() => events.some((update) => update.path === path.join(tmp.path, ".opencode"))).pipe( + Effect.filterOrFail(Boolean), + Effect.retry(Schedule.spaced("10 millis")), + Effect.timeout("1 second"), + ) + }).pipe(Effect.provide(Watcher.nativeLayer)), + ), + ), + ) + it.effect("interrupting a consumer interrupts a pending acquisition", () => Effect.gen(function* () { const started = yield* Deferred.make() const interrupted = yield* Deferred.make() + const counts = { ready: 0 } yield* Effect.gen(function* () { const watcher = yield* Watcher.Service const consumer = yield* watcher - .subscribe({ path: "/pending", type: "directory" }) + .subscribe( + { path: "/pending", type: "directory" }, + Effect.sync(() => { + counts.ready++ + }), + ) .pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true })) yield* Deferred.await(started) yield* Fiber.interrupt(consumer) expect(yield* Deferred.isDone(interrupted)).toBe(true) + expect(counts.ready).toBe(0) }).pipe( withNative({ subscribe: () => @@ -76,16 +179,16 @@ describe("Watcher lifecycle", () => { }), ) - it.effect("shares one subscription and releases exactly once after the final consumer", () => { + it.effect("shares equivalent entry sets and releases exactly once after the final consumer", () => { const { native, counts } = countingNative() return Effect.gen(function* () { const watcher = yield* Watcher.Service - const consume = () => + const consume = (names: string[]) => watcher - .subscribe({ path: "/shared", type: "directory" }) + .subscribe({ path: "/shared", type: "entries", names }) .pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true })) - const first = yield* consume() - const second = yield* consume() + const first = yield* consume(["opencode.json", ".opencode", "opencode.json"]) + const second = yield* consume([".opencode", "opencode.json"]) yield* Effect.yieldNow expect(counts.subscribes).toBe(1) @@ -482,17 +585,35 @@ describeNative("LocationWatcher", () => { }) it.live("publishes .hg/branch events", () => - withTmp( - (directory) => + Effect.gen(function* () { + const started = yield* Deferred.make() + const watcher = Layer.effect( + Watcher.Service, Effect.gen(function* () { - const fs = yield* FSUtil.Service - const branch = path.join(directory, ".hg", "branch") - yield* ready(branch) - expect( - yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")), - ).toMatchObject({ file: branch }) + const service = yield* Watcher.Service + return Watcher.Service.of({ + subscribe: (input, onReady) => + service.subscribe( + input, + Deferred.succeed(started, input.path).pipe(Effect.andThen(onReady ?? Effect.void)), + ), + }) }), - { vcs: "hg" }, - ), + ).pipe(Layer.provide(AppNodeBuilder.build(Watcher.node))) + return yield* withTmp( + (directory) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const branch = path.join(directory, ".hg", "branch") + // Use the actual acquisition barrier, not a probe write whose event + // callback can race the next write in Bun's filesystem watcher. + expect(yield* Deferred.await(started)).toBe(branch) + expect( + yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")), + ).toMatchObject({ file: branch }) + }), + { vcs: "hg", watcher }, + ) + }), ) })