604697f7f8
Replace the legacy Instance.state() lazy-init pattern with the standard Effect service pattern (Interface, Service class, Layer, promise facades). Register Plugin.Service in InstanceServices and add its layer to the instance lookup.
145 lines
4.0 KiB
Markdown
145 lines
4.0 KiB
Markdown
# Effect patterns
|
|
|
|
Practical reference for new and migrated Effect code in `packages/opencode`.
|
|
|
|
## Choose scope
|
|
|
|
Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
|
|
|
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
|
|
|
|
- Shared runtime: config readers, stateless helpers, global clients
|
|
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
|
|
|
|
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
|
|
|
|
## Service shape
|
|
|
|
For a fully migrated module, use the public namespace directly:
|
|
|
|
```ts
|
|
export namespace Foo {
|
|
export interface Interface {
|
|
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
|
|
}
|
|
|
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
|
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
return Service.of({
|
|
get: Effect.fn("Foo.get")(function* (id) {
|
|
return yield* ...
|
|
}),
|
|
})
|
|
}),
|
|
)
|
|
|
|
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
|
|
}
|
|
```
|
|
|
|
Rules:
|
|
|
|
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
|
|
- Export `defaultLayer` only when wiring dependencies is useful
|
|
- Use the direct namespace form once the module is fully migrated
|
|
|
|
## Temporary mixed-mode pattern
|
|
|
|
Prefer a single namespace whenever possible.
|
|
|
|
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
|
|
|
|
```ts
|
|
export namespace FooEffect {
|
|
export interface Interface {
|
|
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
|
|
}
|
|
|
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
|
|
|
export const layer = Layer.effect(...)
|
|
}
|
|
```
|
|
|
|
Then keep the old boundary thin:
|
|
|
|
```ts
|
|
export namespace Foo {
|
|
export function get(id: FooID) {
|
|
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
|
|
}
|
|
}
|
|
```
|
|
|
|
Remove the `Effect` suffix when the boundary split is gone.
|
|
|
|
## Scheduled Tasks
|
|
|
|
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
|
|
|
## Preferred Effect services
|
|
|
|
In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
|
|
|
|
Prefer these first:
|
|
|
|
- `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O
|
|
- `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers
|
|
- `HttpClient.HttpClient` instead of raw `fetch`
|
|
- `Path.Path` instead of mixing path helpers into service code when you already need a path service
|
|
- `Config` for effect-native configuration reads
|
|
- `Clock` / `DateTime` for time reads inside effects
|
|
|
|
## Child processes
|
|
|
|
For child process work in services, yield `ChildProcessSpawner.ChildProcessSpawner` in the layer and use `ChildProcess.make(...)`.
|
|
|
|
Keep shelling-out code inside the service, not in callers.
|
|
|
|
## Shared leaf models
|
|
|
|
Shared schema or model files can stay outside the service namespace when lower layers also depend on them.
|
|
|
|
That is fine for leaf files like `schema.ts`. Keep the service surface in the owning namespace.
|
|
|
|
## Migration checklist
|
|
|
|
Done now:
|
|
|
|
- [x] `AccountEffect` (mixed-mode)
|
|
- [x] `AuthEffect` (mixed-mode)
|
|
- [x] `TruncateEffect` (mixed-mode)
|
|
- [x] `Question`
|
|
- [x] `PermissionNext`
|
|
- [x] `ProviderAuth`
|
|
- [x] `FileWatcher`
|
|
- [x] `FileTime`
|
|
- [x] `Format`
|
|
- [x] `Vcs`
|
|
- [x] `Skill`
|
|
- [x] `Discovery`
|
|
- [x] `File`
|
|
- [x] `Snapshot`
|
|
|
|
Still open and likely worth migrating:
|
|
|
|
- [x] `Plugin`
|
|
- [ ] `ToolRegistry`
|
|
- [ ] `Pty`
|
|
- [ ] `Worktree`
|
|
- [ ] `Installation`
|
|
- [ ] `Bus`
|
|
- [ ] `Command`
|
|
- [ ] `Config`
|
|
- [ ] `Session`
|
|
- [ ] `SessionProcessor`
|
|
- [ ] `SessionPrompt`
|
|
- [ ] `SessionCompaction`
|
|
- [ ] `Provider`
|
|
- [ ] `Project`
|
|
- [ ] `LSP`
|
|
- [ ] `MCP`
|