Skip to content

Commit ed03ba2

Browse files
kitlangtonmrsimpson
authored andcommitted
refactor: finish small effect service adoption cleanups (anomalyco#22094)
1 parent fe314f9 commit ed03ba2

8 files changed

Lines changed: 906 additions & 815 deletions

File tree

packages/opencode/specs/effect-migration.md

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,9 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
178178

179179
## Migration checklist
180180

181-
Fully migrated (single namespace, InstanceState where needed, flattened facade):
181+
Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
182+
183+
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in [Destroying the facades](#destroying-the-facades).
182184

183185
- [x] `Account``account/index.ts`
184186
- [x] `Agent``agent/agent.ts`
@@ -221,20 +223,22 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
221223
- [x] `Provider``provider/provider.ts`
222224
- [x] `Storage``storage/storage.ts`
223225
- [x] `ShareNext``share/share-next.ts`
226+
- [x] `SessionTodo``session/todo.ts`
224227

225-
Still open:
228+
Still open at the service-shape level:
226229

227-
- [x] `SessionTodo``session/todo.ts`
228-
- [ ] `SyncEvent``sync/index.ts`
229-
- [ ] `Workspace``control-plane/workspace.ts`
230+
- [ ] `SyncEvent``sync/index.ts` (deferred pending sync with James)
231+
- [ ] `Workspace``control-plane/workspace.ts` (deferred pending sync with James)
230232

231233
## Tool interface → Effect
232234

233-
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is:
235+
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch, and the current tools in `src/tool/*.ts` have been migrated to the Effect-native `Tool.define(...)` shape.
234236

235-
1. Migrate each tool body to return Effects
236-
2. Keep `Tool.define()` inputs Effect-native
237-
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
237+
The remaining work here is follow-on cleanup rather than the top-level tool interface migration:
238+
239+
1. Remove internal `Effect.promise(...)` bridges where practical
240+
2. Keep replacing raw platform helpers with Effect services inside tool bodies
241+
3. Update remaining callers and tests to prefer `yield* info.init()` / `Tool.init(...)` over older Promise-oriented patterns
238242

239243
### Tool migration details
240244

@@ -254,52 +258,49 @@ This keeps migrated tool tests aligned with the production service graph today,
254258

255259
Individual tools, ordered by value:
256260

257-
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
258-
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
259-
- [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
260-
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
261-
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
262-
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
263-
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
264-
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
265-
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
266-
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
267-
- [ ] `task.ts` — MEDIUM: task state management
268-
- [ ] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
269-
- [ ] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
270-
- [ ] `glob.ts` — LOW: simple async generator
271-
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
272-
- [ ] `question.ts` — LOW: prompt wrapper
273-
- [ ] `skill.ts` — LOW: skill tool adapter
274-
- [ ] `todo.ts` — LOW: todo persistence wrapper
275-
- [ ] `invalid.ts` — LOW: invalid-tool fallback
276-
- [ ] `plan.ts` — LOW: plan file operations
261+
- [x] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
262+
- [x] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
263+
- [x] `read.ts` — HIGH: effectful interface migrated; still has raw fs/readline internals tracked below
264+
- [x] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
265+
- [x] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
266+
- [x] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
267+
- [x] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
268+
- [x] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
269+
- [x] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
270+
- [x] `task.ts` — MEDIUM: task state management
271+
- [x] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
272+
- [x] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
273+
- [x] `glob.ts` — LOW: simple async generator
274+
- [x] `lsp.ts` — LOW: dispatch switch over LSP operations
275+
- [x] `question.ts` — LOW: prompt wrapper
276+
- [x] `skill.ts` — LOW: skill tool adapter
277+
- [x] `todo.ts` — LOW: todo persistence wrapper
278+
- [x] `invalid.ts` — LOW: invalid-tool fallback
279+
- [x] `plan.ts` — LOW: plan file operations
280+
281+
`batch.ts` was removed from `src/tool/` and is no longer tracked here.
277282

278283
## Effect service adoption in already-migrated code
279284

280285
Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` in their implementation or helper modules. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
281286

282287
### `Filesystem.*``AppFileSystem.Service` (yield in layer)
283288

284-
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling
285-
- [ ] `config/config.ts` — 5 remaining `Filesystem.*` calls in `installDependencies()`
286-
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
289+
- [x] `config/config.ts``installDependencies()` now uses `AppFileSystem`
290+
- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
287291

288292
### `Process.spawn``ChildProcessSpawner` (yield in layer)
289293

290-
- [ ] `format/formatter.ts`2 remaining `Process.spawn()` checks (`air`, `uv`)
294+
- [x] `format/formatter.ts`direct `Process.spawn()` checks removed (`air`, `uv`)
291295
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
292296

293297
## Filesystem consolidation
294298

295-
`util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
296-
297-
Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
299+
`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep.
298300

299301
Current raw fs users that will convert during tool migration:
300302

301303
- `tool/read.ts` — fs.createReadStream, readline
302-
- `tool/apply_patch.ts` — fs/promises
303304
- `file/ripgrep.ts` — fs/promises
304305
- `patch/index.ts` — fs, fs/promises
305306

@@ -312,7 +313,9 @@ Current raw fs users that will convert during tool migration:
312313

313314
## Destroying the facades
314315

315-
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
316+
This phase is still broadly open. As of 2026-04-11 there are still 31 `makeRuntime(...)` call sites under `src/`, and many service namespaces still export async facade helpers like `export async function read(...) { return runPromise(...) }`.
317+
318+
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
316319

317320
### Process
318321

packages/opencode/src/config/config.ts

Lines changed: 95 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,19 @@ import { Instance, type InstanceContext } from "../project/instance"
2222
import { LSPServer } from "../lsp/server"
2323
import { Installation } from "@/installation"
2424
import { ConfigMarkdown } from "./markdown"
25-
import { constants, existsSync } from "fs"
25+
import { existsSync } from "fs"
2626
import { Bus } from "@/bus"
2727
import { GlobalBus } from "@/bus/global"
2828
import { Event } from "../server/event"
2929
import { Glob } from "../util/glob"
30-
import { iife } from "@/util/iife"
3130
import { Account } from "@/account"
3231
import { isRecord } from "@/util/record"
3332
import { ConfigPaths } from "./paths"
34-
import { Filesystem } from "@/util/filesystem"
3533
import type { ConsoleState } from "./console-state"
3634
import { AppFileSystem } from "@/filesystem"
3735
import { InstanceState } from "@/effect/instance-state"
3836
import { makeRuntime } from "@/effect/run-service"
39-
import { Duration, Effect, Layer, Option, Context } from "effect"
37+
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
4038
import { Flock } from "@/util/flock"
4139
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
4240
import { Npm } from "@/npm"
@@ -140,53 +138,11 @@ export namespace Config {
140138
}
141139

142140
export type InstallInput = {
143-
signal?: AbortSignal
144141
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
145142
}
146143

147-
export async function installDependencies(dir: string, input?: InstallInput) {
148-
if (!(await isWritable(dir))) return
149-
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
150-
signal: input?.signal,
151-
onWait: (tick) =>
152-
input?.waitTick?.({
153-
dir,
154-
attempt: tick.attempt,
155-
delay: tick.delay,
156-
waited: tick.waited,
157-
}),
158-
})
159-
input?.signal?.throwIfAborted()
160-
161-
const pkg = path.join(dir, "package.json")
162-
const target = Installation.isLocal() ? "*" : Installation.VERSION
163-
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
164-
dependencies: {},
165-
}))
166-
json.dependencies = {
167-
...json.dependencies,
168-
"@opencode-ai/plugin": target,
169-
}
170-
await Filesystem.writeJson(pkg, json)
171-
172-
const gitignore = path.join(dir, ".gitignore")
173-
const ignore = await Filesystem.exists(gitignore)
174-
if (!ignore) {
175-
await Filesystem.write(
176-
gitignore,
177-
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
178-
)
179-
}
180-
await Npm.install(dir)
181-
}
182-
183-
async function isWritable(dir: string) {
184-
try {
185-
await fsNode.access(dir, constants.W_OK)
186-
return true
187-
} catch {
188-
return false
189-
}
144+
type Package = {
145+
dependencies?: Record<string, string>
190146
}
191147

192148
function rel(item: string, patterns: string[]) {
@@ -1111,14 +1067,15 @@ export namespace Config {
11111067
type State = {
11121068
config: Info
11131069
directories: string[]
1114-
deps: Promise<void>[]
1070+
deps: Fiber.Fiber<void, never>[]
11151071
consoleState: ConsoleState
11161072
}
11171073

11181074
export interface Interface {
11191075
readonly get: () => Effect.Effect<Info>
11201076
readonly getGlobal: () => Effect.Effect<Info>
11211077
readonly getConsoleState: () => Effect.Effect<ConsoleState>
1078+
readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, AppFileSystem.Error>
11221079
readonly update: (config: Info) => Effect.Effect<void>
11231080
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
11241081
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@@ -1320,6 +1277,74 @@ export namespace Config {
13201277
return yield* cachedGlobal
13211278
})
13221279

1280+
const install = Effect.fnUntraced(function* (dir: string) {
1281+
const pkg = path.join(dir, "package.json")
1282+
const gitignore = path.join(dir, ".gitignore")
1283+
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
1284+
const target = Installation.isLocal() ? "*" : Installation.VERSION
1285+
const json = yield* fs.readJson(pkg).pipe(
1286+
Effect.catch(() => Effect.succeed({} satisfies Package)),
1287+
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
1288+
)
1289+
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
1290+
const hasIgnore = yield* fs.existsSafe(gitignore)
1291+
const hasPkg = yield* fs.existsSafe(plugin)
1292+
1293+
if (!hasDep) {
1294+
yield* fs.writeJson(pkg, {
1295+
...json,
1296+
dependencies: {
1297+
...json.dependencies,
1298+
"@opencode-ai/plugin": target,
1299+
},
1300+
})
1301+
}
1302+
1303+
if (!hasIgnore) {
1304+
yield* fs.writeFileString(
1305+
gitignore,
1306+
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
1307+
)
1308+
}
1309+
1310+
if (hasDep && hasIgnore && hasPkg) return
1311+
1312+
yield* Effect.promise(() => Npm.install(dir))
1313+
})
1314+
1315+
const installDependencies = Effect.fn("Config.installDependencies")(function* (
1316+
dir: string,
1317+
input?: InstallInput,
1318+
) {
1319+
if (
1320+
!(yield* fs.access(dir, { writable: true }).pipe(
1321+
Effect.as(true),
1322+
Effect.orElseSucceed(() => false),
1323+
))
1324+
)
1325+
return
1326+
1327+
const key =
1328+
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
1329+
1330+
yield* Effect.acquireUseRelease(
1331+
Effect.promise((signal) =>
1332+
Flock.acquire(key, {
1333+
signal,
1334+
onWait: (tick) =>
1335+
input?.waitTick?.({
1336+
dir,
1337+
attempt: tick.attempt,
1338+
delay: tick.delay,
1339+
waited: tick.waited,
1340+
}),
1341+
}),
1342+
),
1343+
() => install(dir),
1344+
(lease) => Effect.promise(() => lease.release()),
1345+
)
1346+
})
1347+
13231348
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
13241349
const auth = yield* authSvc.all().pipe(Effect.orDie)
13251350

@@ -1402,7 +1427,7 @@ export namespace Config {
14021427
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
14031428
}
14041429

1405-
const deps: Promise<void>[] = []
1430+
const deps: Fiber.Fiber<void, never>[] = []
14061431

14071432
for (const dir of unique(directories)) {
14081433
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
@@ -1416,12 +1441,18 @@ export namespace Config {
14161441
}
14171442
}
14181443

1419-
const dep = iife(async () => {
1420-
await installDependencies(dir)
1421-
})
1422-
void dep.catch((err) => {
1423-
log.warn("background dependency install failed", { dir, error: err })
1424-
})
1444+
const dep = yield* installDependencies(dir).pipe(
1445+
Effect.exit,
1446+
Effect.tap((exit) =>
1447+
Exit.isFailure(exit)
1448+
? Effect.sync(() => {
1449+
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
1450+
})
1451+
: Effect.void,
1452+
),
1453+
Effect.asVoid,
1454+
Effect.forkScoped,
1455+
)
14251456
deps.push(dep)
14261457

14271458
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
@@ -1558,7 +1589,9 @@ export namespace Config {
15581589
})
15591590

15601591
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
1561-
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
1592+
yield* InstanceState.useEffect(state, (s) =>
1593+
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
1594+
)
15621595
})
15631596

15641597
const update = Effect.fn("Config.update")(function* (config: Info) {
@@ -1613,6 +1646,7 @@ export namespace Config {
16131646
get,
16141647
getGlobal,
16151648
getConsoleState,
1649+
installDependencies,
16161650
update,
16171651
updateGlobal,
16181652
invalidate,
@@ -1642,6 +1676,10 @@ export namespace Config {
16421676
return runPromise((svc) => svc.getConsoleState())
16431677
}
16441678

1679+
export async function installDependencies(dir: string, input?: InstallInput) {
1680+
return runPromise((svc) => svc.installDependencies(dir, input))
1681+
}
1682+
16451683
export async function update(config: Info) {
16461684
return runPromise((svc) => svc.update(config))
16471685
}

0 commit comments

Comments
 (0)