Skip to content

Commit 9c6f1ed

Browse files
authored
refactor(effect): yield services instead of promise facades (anomalyco#19325)
1 parent ef7d1f7 commit 9c6f1ed

19 files changed

Lines changed: 284 additions & 251 deletions

File tree

packages/opencode/specs/effect-migration.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
88

99
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
1010

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
11+
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
12+
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
1313

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

@@ -181,36 +181,39 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
181181
Fully migrated (single namespace, InstanceState where needed, flattened facade):
182182

183183
- [x] `Account``account/index.ts`
184+
- [x] `Agent``agent/agent.ts`
185+
- [x] `AppFileSystem``filesystem/index.ts`
184186
- [x] `Auth``auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
187+
- [x] `Bus``bus/index.ts`
188+
- [x] `Command``command/index.ts`
189+
- [x] `Config``config/config.ts`
190+
- [x] `Discovery``skill/discovery.ts` (dependency-only layer, no standalone runtime)
185191
- [x] `File``file/index.ts`
186192
- [x] `FileTime``file/time.ts`
187193
- [x] `FileWatcher``file/watcher.ts`
188194
- [x] `Format``format/index.ts`
189195
- [x] `Installation``installation/index.ts`
196+
- [x] `LSP``lsp/index.ts`
197+
- [x] `MCP``mcp/index.ts`
198+
- [x] `McpAuth``mcp/auth.ts`
190199
- [x] `Permission``permission/index.ts`
200+
- [x] `Plugin``plugin/index.ts`
201+
- [x] `Project``project/project.ts`
191202
- [x] `ProviderAuth``provider/auth.ts`
203+
- [x] `Pty``pty/index.ts`
192204
- [x] `Question``question/index.ts`
205+
- [x] `SessionStatus``session/status.ts`
193206
- [x] `Skill``skill/index.ts`
194207
- [x] `Snapshot``snapshot/index.ts`
208+
- [x] `ToolRegistry``tool/registry.ts`
195209
- [x] `Truncate``tool/truncate.ts`
196210
- [x] `Vcs``project/vcs.ts`
197-
- [x] `Discovery``skill/discovery.ts`
198-
- [x] `SessionStatus`
211+
- [x] `Worktree``worktree/index.ts`
199212

200213
Still open and likely worth migrating:
201214

202-
- [x] `Plugin`
203-
- [x] `ToolRegistry`
204-
- [ ] `Pty`
205-
- [x] `Worktree`
206-
- [x] `Bus`
207-
- [x] `Command`
208-
- [x] `Config`
209215
- [ ] `Session`
210216
- [ ] `SessionProcessor`
211217
- [ ] `SessionPrompt`
212218
- [ ] `SessionCompaction`
213219
- [ ] `Provider`
214-
- [x] `Project`
215-
- [x] `LSP`
216-
- [x] `MCP`

packages/opencode/src/agent/agent.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,14 @@ export namespace Agent {
7272
export const layer = Layer.effect(
7373
Service,
7474
Effect.gen(function* () {
75-
const config = () => Effect.promise(() => Config.get())
75+
const config = yield* Config.Service
7676
const auth = yield* Auth.Service
77+
const skill = yield* Skill.Service
7778

7879
const state = yield* InstanceState.make<State>(
7980
Effect.fn("Agent.state")(function* (ctx) {
80-
const cfg = yield* config()
81-
const skillDirs = yield* Effect.promise(() => Skill.dirs())
81+
const cfg = yield* config.get()
82+
const skillDirs = yield* skill.dirs()
8283
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
8384

8485
const defaults = Permission.fromConfig({
@@ -281,7 +282,7 @@ export namespace Agent {
281282
})
282283

283284
const list = Effect.fnUntraced(function* () {
284-
const cfg = yield* config()
285+
const cfg = yield* config.get()
285286
return pipe(
286287
agents,
287288
values(),
@@ -293,7 +294,7 @@ export namespace Agent {
293294
})
294295

295296
const defaultAgent = Effect.fnUntraced(function* () {
296-
const c = yield* config()
297+
const c = yield* config.get()
297298
if (c.default_agent) {
298299
const agent = agents[c.default_agent]
299300
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
@@ -328,7 +329,7 @@ export namespace Agent {
328329
description: string
329330
model?: { providerID: ProviderID; modelID: ModelID }
330331
}) {
331-
const cfg = yield* config()
332+
const cfg = yield* config.get()
332333
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
333334
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
334335
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
@@ -391,7 +392,11 @@ export namespace Agent {
391392
}),
392393
)
393394

394-
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
395+
export const defaultLayer = layer.pipe(
396+
Layer.provide(Auth.layer),
397+
Layer.provide(Config.defaultLayer),
398+
Layer.provide(Skill.defaultLayer),
399+
)
395400

396401
const { runPromise } = makeRuntime(Service, defaultLayer)
397402

packages/opencode/src/command/index.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,12 @@ export namespace Command {
7575
export const layer = Layer.effect(
7676
Service,
7777
Effect.gen(function* () {
78+
const config = yield* Config.Service
79+
const mcp = yield* MCP.Service
80+
const skill = yield* Skill.Service
81+
7882
const init = Effect.fn("Command.state")(function* (ctx) {
79-
const cfg = yield* Effect.promise(() => Config.get())
83+
const cfg = yield* config.get()
8084
const commands: Record<string, Info> = {}
8185

8286
commands[Default.INIT] = {
@@ -114,7 +118,7 @@ export namespace Command {
114118
}
115119
}
116120

117-
for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
121+
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
118122
commands[name] = {
119123
name,
120124
source: "mcp",
@@ -139,14 +143,14 @@ export namespace Command {
139143
}
140144
}
141145

142-
for (const skill of yield* Effect.promise(() => Skill.all())) {
143-
if (commands[skill.name]) continue
144-
commands[skill.name] = {
145-
name: skill.name,
146-
description: skill.description,
146+
for (const item of yield* skill.all()) {
147+
if (commands[item.name]) continue
148+
commands[item.name] = {
149+
name: item.name,
150+
description: item.description,
147151
source: "skill",
148152
get template() {
149-
return skill.content
153+
return item.content
150154
},
151155
hints: [],
152156
}
@@ -173,7 +177,13 @@ export namespace Command {
173177
}),
174178
)
175179

176-
const { runPromise } = makeRuntime(Service, layer)
180+
export const defaultLayer = layer.pipe(
181+
Layer.provide(Config.defaultLayer),
182+
Layer.provide(MCP.defaultLayer),
183+
Layer.provide(Skill.defaultLayer),
184+
)
185+
186+
const { runPromise } = makeRuntime(Service, defaultLayer)
177187

178188
export async function get(name: string) {
179189
return runPromise((svc) => svc.get(name))

packages/opencode/src/config/config.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { Lock } from "@/util/lock"
4040
import { AppFileSystem } from "@/filesystem"
4141
import { InstanceState } from "@/effect/instance-state"
4242
import { makeRuntime } from "@/effect/run-service"
43-
import { Duration, Effect, Layer, ServiceMap } from "effect"
43+
import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
4444

4545
export namespace Config {
4646
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -1136,10 +1136,12 @@ export namespace Config {
11361136
}),
11371137
)
11381138

1139-
export const layer: Layer.Layer<Service, never, AppFileSystem.Service> = Layer.effect(
1139+
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> = Layer.effect(
11401140
Service,
11411141
Effect.gen(function* () {
11421142
const fs = yield* AppFileSystem.Service
1143+
const authSvc = yield* Auth.Service
1144+
const accountSvc = yield* Account.Service
11431145

11441146
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
11451147
return yield* fs.readFileString(filepath).pipe(
@@ -1256,7 +1258,7 @@ export namespace Config {
12561258
})
12571259

12581260
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
1259-
const auth = yield* Effect.promise(() => Auth.all())
1261+
const auth = yield* authSvc.all().pipe(Effect.orDie)
12601262

12611263
let result: Info = {}
12621264
for (const [key, value] of Object.entries(auth)) {
@@ -1344,17 +1346,20 @@ export namespace Config {
13441346
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
13451347
}
13461348

1347-
const active = yield* Effect.promise(() => Account.active())
1349+
const active = Option.getOrUndefined(yield* accountSvc.active().pipe(Effect.orDie))
13481350
if (active?.active_org_id) {
13491351
yield* Effect.gen(function* () {
1350-
const [config, token] = yield* Effect.promise(() =>
1351-
Promise.all([Account.config(active.id, active.active_org_id!), Account.token(active.id)]),
1352+
const [configOpt, tokenOpt] = yield* Effect.all(
1353+
[accountSvc.config(active.id, active.active_org_id!), accountSvc.token(active.id)],
1354+
{ concurrency: 2 },
13521355
)
1356+
const token = Option.getOrUndefined(tokenOpt)
13531357
if (token) {
13541358
process.env["OPENCODE_CONSOLE_TOKEN"] = token
13551359
Env.set("OPENCODE_CONSOLE_TOKEN", token)
13561360
}
13571361

1362+
const config = Option.getOrUndefined(configOpt)
13581363
if (config) {
13591364
result = mergeConfigConcatArrays(
13601365
result,
@@ -1365,7 +1370,7 @@ export namespace Config {
13651370
)
13661371
}
13671372
}).pipe(
1368-
Effect.catchDefect((err) => {
1373+
Effect.catch((err) => {
13691374
log.debug("failed to fetch remote account config", {
13701375
error: err instanceof Error ? err.message : String(err),
13711376
})
@@ -1502,7 +1507,11 @@ export namespace Config {
15021507
}),
15031508
)
15041509

1505-
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
1510+
export const defaultLayer = layer.pipe(
1511+
Layer.provide(AppFileSystem.defaultLayer),
1512+
Layer.provide(Auth.layer),
1513+
Layer.provide(Account.defaultLayer),
1514+
)
15061515

15071516
const { runPromise } = makeRuntime(Service, defaultLayer)
15081517

packages/opencode/src/effect/cross-spawn-spawner.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type * as Arr from "effect/Array"
2-
import { NodeSink, NodeStream } from "@effect/platform-node"
2+
import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node"
3+
import * as NodePath from "@effect/platform-node/NodePath"
34
import * as Deferred from "effect/Deferred"
45
import * as Effect from "effect/Effect"
56
import * as Exit from "effect/Exit"
@@ -474,3 +475,5 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
474475
ChildProcessSpawner,
475476
make,
476477
)
478+
479+
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))

packages/opencode/src/file/watcher.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export namespace FileWatcher {
7070
export const layer = Layer.effect(
7171
Service,
7272
Effect.gen(function* () {
73+
const config = yield* Config.Service
74+
7375
const state = yield* InstanceState.make(
7476
Effect.fn("FileWatcher.state")(
7577
function* () {
@@ -117,7 +119,7 @@ export namespace FileWatcher {
117119
)
118120
}
119121

120-
const cfg = yield* Effect.promise(() => Config.get())
122+
const cfg = yield* config.get()
121123
const cfgIgnores = cfg.watcher?.ignore ?? []
122124

123125
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
@@ -159,7 +161,9 @@ export namespace FileWatcher {
159161
}),
160162
)
161163

162-
const { runPromise } = makeRuntime(Service, layer)
164+
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
165+
166+
const { runPromise } = makeRuntime(Service, defaultLayer)
163167

164168
export function init() {
165169
return runPromise((svc) => svc.init())

packages/opencode/src/format/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@ export namespace Format {
3535
export const layer = Layer.effect(
3636
Service,
3737
Effect.gen(function* () {
38+
const config = yield* Config.Service
39+
3840
const state = yield* InstanceState.make(
3941
Effect.fn("Format.state")(function* (_ctx) {
4042
const enabled: Record<string, boolean> = {}
4143
const formatters: Record<string, Formatter.Info> = {}
4244

43-
const cfg = yield* Effect.promise(() => Config.get())
45+
const cfg = yield* config.get()
4446

4547
if (cfg.formatter !== false) {
4648
for (const item of Object.values(Formatter)) {
@@ -167,7 +169,9 @@ export namespace Format {
167169
}),
168170
)
169171

170-
const { runPromise } = makeRuntime(Service, layer)
172+
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
173+
174+
const { runPromise } = makeRuntime(Service, defaultLayer)
171175

172176
export async function init() {
173177
return runPromise((s) => s.init())

packages/opencode/src/installation/index.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { NodeFileSystem, NodePath } from "@effect/platform-node"
21
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
32
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
43
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -341,9 +340,7 @@ export namespace Installation {
341340

342341
export const defaultLayer = layer.pipe(
343342
Layer.provide(FetchHttpClient.layer),
344-
Layer.provide(CrossSpawnSpawner.layer),
345-
Layer.provide(NodeFileSystem.layer),
346-
Layer.provide(NodePath.layer),
343+
Layer.provide(CrossSpawnSpawner.defaultLayer),
347344
)
348345

349346
const { runPromise } = makeRuntime(Service, defaultLayer)

packages/opencode/src/lsp/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,11 @@ export namespace LSP {
161161
export const layer = Layer.effect(
162162
Service,
163163
Effect.gen(function* () {
164+
const config = yield* Config.Service
165+
164166
const state = yield* InstanceState.make<State>(
165167
Effect.fn("LSP.state")(function* () {
166-
const cfg = yield* Effect.promise(() => Config.get())
168+
const cfg = yield* config.get()
167169

168170
const servers: Record<string, LSPServer.Info> = {}
169171

@@ -504,7 +506,9 @@ export namespace LSP {
504506
}),
505507
)
506508

507-
const { runPromise } = makeRuntime(Service, layer)
509+
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
510+
511+
const { runPromise } = makeRuntime(Service, defaultLayer)
508512

509513
export const init = async () => runPromise((svc) => svc.init())
510514

0 commit comments

Comments
 (0)