Skip to content

Commit 38e0dc9

Browse files
authored
Move service state into InstanceState, flatten service facades (anomalyco#18483)
1 parent 40aeaa1 commit 38e0dc9

84 files changed

Lines changed: 4546 additions & 3752 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/opencode/script/seed-e2e.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ const seed = async () => {
1111
const { Instance } = await import("../src/project/instance")
1212
const { InstanceBootstrap } = await import("../src/project/bootstrap")
1313
const { Config } = await import("../src/config/config")
14-
const { disposeRuntime } = await import("../src/effect/runtime")
1514
const { Session } = await import("../src/session")
1615
const { MessageID, PartID } = await import("../src/session/schema")
1716
const { Project } = await import("../src/project/project")
@@ -55,7 +54,6 @@ const seed = async () => {
5554
})
5655
} finally {
5756
await Instance.disposeAll().catch(() => {})
58-
await disposeRuntime().catch(() => {})
5957
}
6058
}
6159

packages/opencode/specs/effect-migration.md

Lines changed: 53 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
44

55
## Choose scope
66

7-
Use the shared runtime for process-wide services with one lifecycle for the whole app.
7+
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
88

9-
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
9+
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
1010

11-
- Shared runtime: config readers, stateless helpers, global clients
12-
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
11+
- Global services (no per-directory state): Account, Auth, Installation, Truncate
12+
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
1313

14-
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
14+
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
1515

1616
## Service shape
1717

18-
For a fully migrated module, use the public namespace directly:
18+
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
1919

2020
```ts
2121
export namespace Foo {
@@ -28,53 +28,52 @@ export namespace Foo {
2828
export const layer = Layer.effect(
2929
Service,
3030
Effect.gen(function* () {
31-
return Service.of({
32-
get: Effect.fn("Foo.get")(function* (id) {
33-
return yield* ...
34-
}),
31+
// For instance-scoped services:
32+
const state = yield* InstanceState.make<State>(
33+
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
34+
)
35+
36+
const get = Effect.fn("Foo.get")(function* (id: FooID) {
37+
const s = yield* InstanceState.get(state)
38+
// ...
3539
})
40+
41+
return Service.of({ get })
3642
}),
3743
)
3844

39-
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
45+
// Optional: wire dependencies
46+
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
47+
48+
// Per-service runtime (inside the namespace)
49+
const runPromise = makeRunPromise(Service, defaultLayer)
50+
51+
// Async facade functions
52+
export async function get(id: FooID) {
53+
return runPromise((svc) => svc.get(id))
54+
}
4055
}
4156
```
4257

4358
Rules:
4459

45-
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
46-
- Export `defaultLayer` only when wiring dependencies is useful
47-
- Use the direct namespace form once the module is fully migrated
48-
49-
## Temporary mixed-mode pattern
60+
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
61+
- `runPromise` goes inside the namespace (not exported unless tests need it)
62+
- Facade functions are plain `async function` — no `fn()` wrappers
63+
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
64+
- No `Layer.fresh` — InstanceState handles per-directory isolation
5065

51-
Prefer a single namespace whenever possible.
66+
## Schema → Zod interop
5267

53-
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.
68+
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
5469

5570
```ts
56-
export namespace FooEffect {
57-
export interface Interface {
58-
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
59-
}
60-
61-
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
71+
import { zod } from "@/util/effect-zod"
6272

63-
export const layer = Layer.effect(...)
64-
}
65-
```
66-
67-
Then keep the old boundary thin:
68-
69-
```ts
70-
export namespace Foo {
71-
export function get(id: FooID) {
72-
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
73-
}
74-
}
73+
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
7574
```
7675

77-
Remove the `Effect` suffix when the boundary split is gone.
76+
See `Auth.ZodInfo` for the canonical example.
7877

7978
## Scheduled Tasks
8079

@@ -107,30 +106,30 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
107106

108107
## Migration checklist
109108

110-
Done now:
111-
112-
- [x] `AccountEffect` (mixed-mode)
113-
- [x] `AuthEffect` (mixed-mode)
114-
- [x] `TruncateEffect` (mixed-mode)
115-
- [x] `Question`
116-
- [x] `PermissionNext`
117-
- [x] `ProviderAuth`
118-
- [x] `FileWatcher`
119-
- [x] `FileTime`
120-
- [x] `Format`
121-
- [x] `Vcs`
122-
- [x] `Skill`
123-
- [x] `Discovery`
124-
- [x] `File`
125-
- [x] `Snapshot`
109+
Fully migrated (single namespace, InstanceState where needed, flattened facade):
110+
111+
- [x] `Account``account/index.ts`
112+
- [x] `Auth``auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
113+
- [x] `File``file/index.ts`
114+
- [x] `FileTime``file/time.ts`
115+
- [x] `FileWatcher``file/watcher.ts`
116+
- [x] `Format``format/index.ts`
117+
- [x] `Installation``installation/index.ts`
118+
- [x] `Permission``permission/index.ts`
119+
- [x] `ProviderAuth``provider/auth.ts`
120+
- [x] `Question``question/index.ts`
121+
- [x] `Skill``skill/index.ts`
122+
- [x] `Snapshot``snapshot/index.ts`
123+
- [x] `Truncate``tool/truncate.ts`
124+
- [x] `Vcs``project/vcs.ts`
125+
- [x] `Discovery``skill/discovery.ts`
126126

127127
Still open and likely worth migrating:
128128

129129
- [ ] `Plugin`
130130
- [ ] `ToolRegistry`
131131
- [ ] `Pty`
132132
- [ ] `Worktree`
133-
- [ ] `Installation`
134133
- [ ] `Bus`
135134
- [ ] `Command`
136135
- [ ] `Config`

0 commit comments

Comments
 (0)