refactor(core): make watcher subscription effectful

This commit is contained in:
Dax Raad 2026-07-29 09:49:38 -04:00
parent fc11ed3838
commit bd906d468d
5 changed files with 49 additions and 34 deletions

View file

@ -393,7 +393,8 @@ export const layer = (options?: Options) => Layer.effect(
const key = JSON.stringify(target)
if (watched.has(key)) continue
watched.add(key)
yield* watcher.subscribe(target).pipe(
const stream = yield* watcher.subscribe(target)
yield* stream.pipe(
Stream.runForEach((update) => PubSub.publish(updates, update)),
Effect.forkScoped({ startImmediately: true }),
)

View file

@ -47,13 +47,12 @@ const layer = Layer.effect(
const home = path.resolve(location.directory) === path.resolve(os.homedir())
if (!home && location.vcs) {
yield* watcher
.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
.pipe(Stream.runForEach(publish), Effect.forkScoped)
const updates = yield* watcher.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
if (home) {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
@ -68,9 +67,8 @@ const layer = Layer.effect(
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* watcher
.subscribe({ path: vcs, type: "directory", ignore })
.pipe(Stream.runForEach(publish), Effect.forkScoped)
const updates = yield* watcher.subscribe({ path: vcs, type: "directory", ignore })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(

View file

@ -59,7 +59,7 @@ export interface NativeInterface {
export class Native extends Context.Service<Native, NativeInterface>()("@opencode/Watcher/Native") {}
export interface Interface {
readonly subscribe: (input: WatchInput) => Stream.Stream<Update>
readonly subscribe: (input: WatchInput) => Effect.Effect<Stream.Stream<Update>>
}
export const Options = Schema.Struct({
@ -83,7 +83,7 @@ export const layer = (options?: Options) =>
Service,
Effect.gen(function* () {
if (options?.enabled === false) {
return Service.of({ subscribe: () => Stream.empty })
return Service.of({ subscribe: () => Effect.succeed(Stream.empty) })
}
const native = yield* Native
@ -131,11 +131,19 @@ export const layer = (options?: Options) =>
const subscribe = (input: WatchInput) => {
const target = path.resolve(input.path)
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
return Stream.unwrap(
RcMap.get(watchers, { type: input.type, target, ignore }).pipe(
Effect.map((pubsub) => Stream.fromPubSub(pubsub)),
),
)
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)
}),
)
})
}
return Service.of({ subscribe })

View file

@ -253,7 +253,8 @@ const layer = Layer.effect(
// inside), so don't watch what can't trigger anything.
if (yield* fs.isDir(operation.target)) continue
watched.add(operation.target)
yield* watcher.subscribe({ path: operation.target, type: "file" }).pipe(
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),

View file

@ -30,9 +30,12 @@ describe("Watcher.testLayer", () => {
Effect.gen(function* () {
const watcher = yield* Watcher.Service
const test = yield* Watcher.Test
const received = yield* watcher
.subscribe({ path: "/root", type: "directory" })
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
const updates = yield* watcher.subscribe({ path: "/root", type: "directory" })
const received = yield* updates.pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.yieldNow
yield* test.emit({ type: "update", path: "/root/file.md" })
@ -72,9 +75,10 @@ describe("Watcher lifecycle", () => {
const interrupted = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consumer = yield* watcher
.subscribe({ path: "/pending", type: "directory" })
.pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
const consumer = yield* watcher.subscribe({ path: "/pending", type: "directory" }).pipe(
Effect.flatMap(Stream.runDrain),
Effect.forkScoped({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* Fiber.interrupt(consumer)
expect(yield* Deferred.isDone(interrupted)).toBe(true)
@ -95,9 +99,10 @@ describe("Watcher lifecycle", () => {
return Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consume = () =>
watcher
.subscribe({ path: "/shared", type: "directory" })
.pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
watcher.subscribe({ path: "/shared", type: "directory" }).pipe(
Effect.flatMap(Stream.runDrain),
Effect.forkScoped({ startImmediately: true }),
)
const first = yield* consume()
const second = yield* consume()
yield* Effect.yieldNow
@ -117,9 +122,8 @@ describe("Watcher lifecycle", () => {
return Effect.gen(function* () {
const consumer = yield* Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consumer = yield* watcher
.subscribe({ path: "/active", type: "directory" })
.pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
const updates = yield* watcher.subscribe({ path: "/active", type: "directory" })
const consumer = yield* updates.pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
expect(counts.subscribes).toBe(1)
expect(counts.unsubscribes).toBe(0)
@ -250,9 +254,12 @@ describeWatcher("LocationWatcher", () => {
const watcher = yield* Watcher.Service
const target = path.join(directory, "opencode.json")
const sibling = path.join(directory, "other.json")
const update = yield* watcher
.subscribe({ path: target, type: "file" })
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
const updates = yield* watcher.subscribe({ path: target, type: "file" })
const update = yield* updates.pipe(
Stream.take(1),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* fs.writeFileString(sibling, "sibling")
const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe(
Effect.repeat(Schedule.spaced("10 millis")),