mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
docs: add Effect migration patterns to AGENTS.md
Document Instance-scoped services, Instance.bind for ALS context in native callbacks, and the Flag → Effect.Config migration pattern.
This commit is contained in:
@@ -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<boolean>` 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.
|
||||
|
||||
@@ -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<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||
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<void>,
|
||||
) {
|
||||
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<void>) {
|
||||
return Effect.callback<WatcherEvent>((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<void>, 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()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user