Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
dac6053
add InstanceState effect cache
kitlangton Mar 21, 2026
75bf562
add shared memoized service runner
kitlangton Mar 21, 2026
150cb69
move global facades to shared service runners
kitlangton Mar 21, 2026
44c1328
move format state into InstanceState
kitlangton Mar 21, 2026
310a4c5
move format and question to global runners
kitlangton Mar 21, 2026
2f4e32f
move permission state into InstanceState
kitlangton Mar 21, 2026
a63fd0f
move file time state into InstanceState
kitlangton Mar 21, 2026
733ceb0
move provider auth state into InstanceState
kitlangton Mar 21, 2026
58f7c60
move vcs state into InstanceState
kitlangton Mar 21, 2026
cb6ec3e
move vcs state into InstanceState
kitlangton Mar 21, 2026
360ec2e
move skill state into InstanceState
kitlangton Mar 21, 2026
0bd101f
move file/watcher state into InstanceState, flatten service facades, …
kitlangton Mar 21, 2026
3e28539
flatten skill service facade into skill.ts
kitlangton Mar 21, 2026
4ce1222
flatten format service facade into index.ts
kitlangton Mar 21, 2026
57e456f
flatten snapshot service facade into index.ts
kitlangton Mar 21, 2026
a53db68
flatten permission service facade, rename PermissionNext to Permission
kitlangton Mar 21, 2026
e0b6af8
rename Permission service tag from PermissionNext
kitlangton Mar 21, 2026
0354a5c
remove Layer.fresh from File service
kitlangton Mar 21, 2026
1588d9c
replace dynamic plugin import in provider/auth with static import, fi…
kitlangton Mar 21, 2026
3ba400d
flatten FileTime service facade into time.ts
kitlangton Mar 21, 2026
af4db41
clean up pending questions on dispose
kitlangton Mar 21, 2026
1d26114
clean up pending permissions on dispose
kitlangton Mar 21, 2026
41bbf08
standardize runPromise inside namespace, flatten Account/Truncate fac…
kitlangton Mar 21, 2026
76524db
update effect-migration.md to reflect current architecture
kitlangton Mar 21, 2026
fe991e4
flatten Auth facade using zod() Schema→Zod interop
kitlangton Mar 21, 2026
a692f33
move Auth schema classes inside namespace, use Info.zod for Schema→Zo…
kitlangton Mar 21, 2026
5fb6787
move Question log inside namespace, async Installation facades
kitlangton Mar 21, 2026
b33d6e2
add Effect.fn tracing to ProviderAuth state init, fix cycle checker t…
kitlangton Mar 21, 2026
39ee534
rename instanceState to state in Format and Snapshot for consistency
kitlangton Mar 21, 2026
c07361c
add InstanceState isolation and disposal tests for File service
kitlangton Mar 21, 2026
9454464
parallelize formatter availability checks and cover execution order
kitlangton Mar 21, 2026
c853e6b
add instance cleanup to stateful service tests
kitlangton Mar 21, 2026
a902ff6
add adversarial InstanceState tests: high contention, interleaved dis…
kitlangton Mar 21, 2026
5990015
auto-init file scan on search, reset fiber on failure, async test dis…
kitlangton Mar 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions packages/opencode/script/seed-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config")
const { disposeRuntime } = await import("../src/effect/runtime")
const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project")
Expand Down Expand Up @@ -55,7 +54,6 @@ const seed = async () => {
})
} finally {
await Instance.disposeAll().catch(() => {})
await disposeRuntime().catch(() => {})
}
}

Expand Down
107 changes: 53 additions & 54 deletions packages/opencode/specs/effect-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ 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 `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.

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

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

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

## Service shape

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

```ts
export namespace Foo {
Expand All @@ -28,53 +28,52 @@ export namespace Foo {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
return Service.of({
get: Effect.fn("Foo.get")(function* (id) {
return yield* ...
}),
// For instance-scoped services:
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)

const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})

return Service.of({ get })
}),
)

export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
// Optional: wire dependencies
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))

// Per-service runtime (inside the namespace)
const runPromise = makeRunPromise(Service, defaultLayer)

// Async facade functions
export async function get(id: FooID) {
return runPromise((svc) => svc.get(id))
}
}
```

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
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
- `runPromise` goes inside the namespace (not exported unless tests need it)
- Facade functions are plain `async function` — no `fn()` wrappers
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
- No `Layer.fresh` — InstanceState handles per-directory isolation

Prefer a single namespace whenever possible.
## Schema → Zod interop

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.
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`:

```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") {}
import { zod } from "@/util/effect-zod"

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)))
}
}
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
```

Remove the `Effect` suffix when the boundary split is gone.
See `Auth.ZodInfo` for the canonical example.

## Scheduled Tasks

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

## 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`
Fully migrated (single namespace, InstanceState where needed, flattened facade):

- [x] `Account` — `account/index.ts`
- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
- [x] `File` — `file/index.ts`
- [x] `FileTime` — `file/time.ts`
- [x] `FileWatcher` — `file/watcher.ts`
- [x] `Format` — `format/index.ts`
- [x] `Installation` — `installation/index.ts`
- [x] `Permission` — `permission/index.ts`
- [x] `ProviderAuth` — `provider/auth.ts`
- [x] `Question` — `question/index.ts`
- [x] `Skill` — `skill/index.ts`
- [x] `Snapshot` — `snapshot/index.ts`
- [x] `Truncate` — `tool/truncate.ts`
- [x] `Vcs` — `project/vcs.ts`
- [x] `Discovery` — `skill/discovery.ts`

Still open and likely worth migrating:

- [ ] `Plugin`
- [ ] `ToolRegistry`
- [ ] `Pty`
- [ ] `Worktree`
- [ ] `Installation`
- [ ] `Bus`
- [ ] `Command`
- [ ] `Config`
Expand Down
Loading
Loading