diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 930297baa9..f281506220 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -34,6 +34,7 @@ Instructions to follow when writing Effect. - Use `Effect.gen(function* () { ... })` for composition. - Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. - `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers. +- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4. ## Time @@ -42,3 +43,37 @@ Instructions to follow when writing Effect. ## Errors - In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. + +## Instance-scoped Effect services + +Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap: + +1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`). +2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`. +3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals. +4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`. + +### Instance.bind — ALS context for native callbacks + +`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called. + +**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`. + +**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically. + +```typescript +// Native addon callback — needs Instance.bind +const cb = Instance.bind((err, evts) => { + Bus.publish(MyEvent, { ... }) +}) +nativeAddon.subscribe(dir, cb) +``` + +## Flag → Effect.Config migration + +Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified. + +- Effectful flags return `Config` and are read with `yield*` inside `Effect.gen`. +- The default `ConfigProvider` reads from `process.env`, so env vars keep working. +- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`. +- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect. diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 8e5f4fe78d..bc6a1f5515 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -2,197 +2,155 @@ import { $ } from "bun" import { afterEach, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" +import { ConfigProvider, Effect, Layer, ManagedRuntime } from "effect" import { tmpdir } from "../fixture/fixture" +import { FileWatcher, FileWatcherService } from "../../src/file/watcher" +import { InstanceContext } from "../../src/effect/instances" +import { Instance } from "../../src/project/instance" +import { GlobalBus } from "../../src/bus/global" -process.env.OPENCODE_EXPERIMENTAL_FILEWATCHER = "true" -delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- -async function load() { - const { runPromiseInstance } = await import("../../src/effect/runtime") - const watcher = await import("../../src/file/watcher") - const { GlobalBus } = await import("../../src/bus/global") - const { Instance } = await import("../../src/project/instance") +const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", + }), +) - return { - GlobalBus, - FileWatcher: watcher.FileWatcher, - FileWatcherService: watcher.FileWatcherService, - Instance, - runPromiseInstance, - } -} +type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } -async function start(directory: string) { - const { FileWatcherService, Instance, runPromiseInstance } = await load() - await Instance.provide({ +/** Run `body` with a live FileWatcherService. Runtime is acquired/released via Effect.scoped. */ +function withWatcher(directory: string, body: Effect.Effect) { + return Instance.provide({ directory, - fn: () => runPromiseInstance(FileWatcherService.use((service) => service.init())), + fn: () => + Effect.gen(function* () { + const ctx = Layer.sync(InstanceContext, () => + InstanceContext.of({ directory: Instance.directory, project: Instance.project }), + ) + const layer = Layer.fresh(FileWatcherService.layer).pipe(Layer.provide(ctx), Layer.provide(configLayer)) + const rt = yield* Effect.acquireRelease( + Effect.sync(() => ManagedRuntime.make(layer)), + (rt) => Effect.promise(() => rt.dispose()), + ) + yield* Effect.promise(() => rt.runPromise(FileWatcherService.use((s) => s.init()))) + yield* Effect.sleep("100 millis") + yield* body + }).pipe(Effect.scoped, Effect.runPromise), }) - await Bun.sleep(100) } -async function stop(directory: string) { - const { Instance } = await load() - await Instance.provide({ - directory, - fn: () => Instance.dispose(), - }) - await Bun.sleep(100) -} - -async function nextUpdate( - directory: string, - check: (evt: { file: string; event: "add" | "change" | "unlink" }) => boolean, - run: () => Promise, -) { - const { FileWatcher, GlobalBus } = await load() - - return await new Promise<{ file: string; event: "add" | "change" | "unlink" }>((resolve, reject) => { - const on = (evt: { - directory?: string - payload: { - type: string - properties: { - file: string - event: "add" | "change" | "unlink" - } - } - }) => { +/** Effect that listens on GlobalBus for a matching watcher event, runs `trigger`, and resolves when it arrives. */ +function nextUpdate(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect) { + return Effect.callback((resume) => { + function on(evt: { directory?: string; payload: { type: string; properties: WatcherEvent } }) { if (evt.directory !== directory) return if (evt.payload.type !== FileWatcher.Event.Updated.type) return if (!check(evt.payload.properties)) return - clearTimeout(timeout) GlobalBus.off("event", on) - resolve(evt.payload.properties) + resume(Effect.succeed(evt.payload.properties)) } - - const timeout = setTimeout(() => { - GlobalBus.off("event", on) - reject(new Error("timed out waiting for file watcher event")) - }, 5000) - GlobalBus.on("event", on) - - run().catch((err) => { - clearTimeout(timeout) - GlobalBus.off("event", on) - reject(err) - }) - }) + Effect.runPromise(trigger) + return Effect.sync(() => GlobalBus.off("event", on)) + }).pipe(Effect.timeout("5 seconds")) } -afterEach(async () => { - const { Instance } = await load() - await Instance.disposeAll() -}) +/** Effect that asserts no matching event arrives within `ms`. */ +function noUpdate(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect, ms = 500) { + let seen = false + function on(evt: { directory?: string; payload: { type: string; properties: WatcherEvent } }) { + if (evt.directory !== directory) return + if (evt.payload.type !== FileWatcher.Event.Updated.type) return + if (!check(evt.payload.properties)) return + seen = true + } + return Effect.acquireUseRelease( + Effect.sync(() => GlobalBus.on("event", on)), + () => + Effect.gen(function* () { + yield* trigger + yield* Effect.sleep(`${ms} millis`) + expect(seen).toBe(false) + }), + () => Effect.sync(() => GlobalBus.off("event", on)), + ) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +afterEach(() => Instance.disposeAll()) test("FileWatcherService publishes root create, update, and delete events", async () => { await using tmp = await tmpdir({ git: true }) const file = path.join(tmp.path, "watch.txt") + const dir = tmp.path - await start(tmp.path) + await withWatcher( + dir, + Effect.gen(function* () { + expect( + yield* nextUpdate(dir, (e) => e.file === file && e.event === "add", Effect.promise(() => fs.writeFile(file, "a"))), + ).toEqual({ file, event: "add" }) - await expect( - nextUpdate( - tmp.path, - (evt) => evt.file === file && evt.event === "add", - () => fs.writeFile(file, "a"), - ), - ).resolves.toEqual({ - file, - event: "add", - }) + expect( + yield* nextUpdate(dir, (e) => e.file === file && e.event === "change", Effect.promise(() => fs.writeFile(file, "b"))), + ).toEqual({ file, event: "change" }) - await expect( - nextUpdate( - tmp.path, - (evt) => evt.file === file && evt.event === "change", - () => fs.writeFile(file, "b"), - ), - ).resolves.toEqual({ - file, - event: "change", - }) - - await expect( - nextUpdate( - tmp.path, - (evt) => evt.file === file && evt.event === "unlink", - () => fs.unlink(file), - ), - ).resolves.toEqual({ - file, - event: "unlink", - }) + expect( + yield* nextUpdate(dir, (e) => e.file === file && e.event === "unlink", Effect.promise(() => fs.unlink(file))), + ).toEqual({ file, event: "unlink" }) + }), + ) }) test("FileWatcherService watches non-git roots", async () => { await using tmp = await tmpdir() const file = path.join(tmp.path, "plain.txt") + const dir = tmp.path - await start(tmp.path) - - await expect( - nextUpdate( - tmp.path, - (evt) => evt.file === file && evt.event === "add", - () => fs.writeFile(file, "plain"), - ), - ).resolves.toEqual({ - file, - event: "add", - }) + await withWatcher( + dir, + Effect.gen(function* () { + expect( + yield* nextUpdate(dir, (e) => e.file === file && e.event === "add", Effect.promise(() => fs.writeFile(file, "plain"))), + ).toEqual({ file, event: "add" }) + }), + ) }) test("FileWatcherService cleanup stops publishing events", async () => { await using tmp = await tmpdir({ git: true }) const file = path.join(tmp.path, "after-dispose.txt") - const { FileWatcher, GlobalBus } = await load() - let seen = false - await start(tmp.path) - await stop(tmp.path) + // Start and immediately stop the watcher (withWatcher disposes on exit) + await withWatcher(tmp.path, Effect.void) - const on = (evt: { directory?: string; payload: { type: string; properties: { file: string } } }) => { - if (evt.directory !== tmp.path) return - if (evt.payload.type !== FileWatcher.Event.Updated.type) return - if (evt.payload.properties.file === file) seen = true - } - - GlobalBus.on("event", on) - - try { - await fs.writeFile(file, "gone") - await Bun.sleep(500) - expect(seen).toBe(false) - } finally { - GlobalBus.off("event", on) - } + // Now write a file — no watcher should be listening + await Effect.runPromise( + noUpdate(tmp.path, (e) => e.file === file, Effect.promise(() => fs.writeFile(file, "gone"))), + ) }) test("FileWatcherService ignores non-HEAD git metadata changes", async () => { await using tmp = await tmpdir({ git: true }) - const file = path.join(tmp.path, ".git", "index") + const gitIndex = path.join(tmp.path, ".git", "index") const edit = path.join(tmp.path, "tracked.txt") - const { FileWatcher, GlobalBus } = await load() - let seen = false - await start(tmp.path) - - const on = (evt: { directory?: string; payload: { type: string; properties: { file: string } } }) => { - if (evt.directory !== tmp.path) return - if (evt.payload.type !== FileWatcher.Event.Updated.type) return - if (evt.payload.properties.file === file) seen = true - } - - GlobalBus.on("event", on) - - try { - await fs.writeFile(edit, "a") - await $`git add .`.cwd(tmp.path).quiet().nothrow() - await Bun.sleep(500) - expect(seen).toBe(false) - } finally { - GlobalBus.off("event", on) - } + await withWatcher( + tmp.path, + noUpdate( + tmp.path, + (e) => e.file === gitIndex, + Effect.promise(async () => { + await fs.writeFile(edit, "a") + await $`git add .`.cwd(tmp.path).quiet().nothrow() + }), + ), + ) })