Skip to content

Commit 5cd54ec

Browse files
authored
refactor(format): use ChildProcessSpawner instead of Process.spawn (#19457)
1 parent c890990 commit 5cd54ec

3 files changed

Lines changed: 119 additions & 41 deletions

File tree

packages/opencode/specs/effect-migration.md

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,81 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
212212

213213
Still open and likely worth migrating:
214214

215-
- [ ] `Session`
216-
- [ ] `SessionProcessor`
217-
- [ ] `SessionPrompt`
218-
- [ ] `SessionCompaction`
219-
- [ ] `Provider`
215+
- [x] `Session``session/index.ts`
216+
- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433)
217+
- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433)
218+
- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433)
219+
- [ ] `Provider` — blocked by AI SDK v6 PR (#18433)
220+
221+
Other services not yet migrated:
222+
223+
- [ ] `SessionSummary``session/summary.ts`
224+
- [ ] `SessionTodo``session/todo.ts`
225+
- [ ] `SessionRevert``session/revert.ts`
226+
- [ ] `Instruction``session/instruction.ts`
227+
- [ ] `ShareNext``share/share-next.ts`
228+
- [ ] `SyncEvent``sync/index.ts`
229+
- [ ] `Storage``storage/storage.ts`
230+
- [ ] `Workspace``control-plane/workspace.ts`
231+
232+
## Tool interface → Effect
233+
234+
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
235+
236+
1. Migrate each tool to return Effects
237+
2. Update `Tool.define()` factory to work with Effects
238+
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
239+
240+
Individual tools, ordered by value:
241+
242+
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
243+
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
244+
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
245+
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
246+
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
247+
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
248+
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
249+
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
250+
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
251+
- [ ] `task.ts` — MEDIUM: task state management
252+
- [ ] `glob.ts` — LOW: simple async generator
253+
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
254+
- [ ] `skill.ts` — LOW: skill tool adapter
255+
- [ ] `plan.ts` — LOW: plan file operations
256+
257+
## Effect service adoption in already-migrated code
258+
259+
Some services are effectified but still use raw `Filesystem.*` or `Process.spawn` instead of the Effect equivalents. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
260+
261+
### `Filesystem.*``AppFileSystem.Service` (yield in layer)
262+
263+
- [ ] `file/index.ts` — 11 calls (the File service itself)
264+
- [ ] `config/config.ts` — 7 calls
265+
- [ ] `auth/index.ts` — 3 calls
266+
- [ ] `skill/index.ts` — 3 calls
267+
- [ ] `file/time.ts` — 1 call
268+
269+
### `Process.spawn``ChildProcessSpawner` (yield in layer)
270+
271+
- [ ] `format/index.ts` — 1 call
272+
273+
## Filesystem consolidation
274+
275+
`util/filesystem.ts` (raw fs wrapper) is used by **64 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) exists but only has **8 consumers**. 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.
276+
277+
Similarly, **28 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
278+
279+
Current raw fs users that will convert during tool migration:
280+
- `tool/read.ts` — fs.createReadStream, readline
281+
- `tool/apply_patch.ts` — fs/promises
282+
- `tool/bash.ts` — fs/promises
283+
- `file/ripgrep.ts` — fs/promises
284+
- `storage/storage.ts` — fs/promises
285+
- `patch/index.ts` — fs, fs/promises
286+
287+
## Primitives & utilities
288+
289+
- [ ] `util/lock.ts` — reader-writer lock → Effect Semaphore/Permit
290+
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
291+
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
292+
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code

packages/opencode/src/format/index.ts

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { Effect, Layer, ServiceMap } from "effect"
2+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
3+
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
24
import { InstanceState } from "@/effect/instance-state"
35
import { makeRuntime } from "@/effect/run-service"
46
import path from "path"
57
import { mergeDeep } from "remeda"
68
import z from "zod"
79
import { Config } from "../config/config"
810
import { Instance } from "../project/instance"
9-
import { Process } from "../util/process"
1011
import { Log } from "../util/log"
1112
import * as Formatter from "./formatter"
1213

@@ -36,6 +37,7 @@ export namespace Format {
3637
Service,
3738
Effect.gen(function* () {
3839
const config = yield* Config.Service
40+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
3941

4042
const state = yield* InstanceState.make(
4143
Effect.fn("Format.state")(function* (_ctx) {
@@ -98,38 +100,45 @@ export namespace Format {
98100
return checks.filter((x) => x.enabled).map((x) => x.item)
99101
}
100102

101-
async function formatFile(filepath: string) {
102-
log.info("formatting", { file: filepath })
103-
const ext = path.extname(filepath)
104-
105-
for (const item of await getFormatter(ext)) {
106-
log.info("running", { command: item.command })
107-
try {
108-
const proc = Process.spawn(
109-
item.command.map((x) => x.replace("$FILE", filepath)),
110-
{
111-
cwd: Instance.directory,
112-
env: { ...process.env, ...item.environment },
113-
stdout: "ignore",
114-
stderr: "ignore",
115-
},
116-
)
117-
const exit = await proc.exited
118-
if (exit !== 0) {
103+
function formatFile(filepath: string) {
104+
return Effect.gen(function* () {
105+
log.info("formatting", { file: filepath })
106+
const ext = path.extname(filepath)
107+
108+
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
109+
log.info("running", { command: item.command })
110+
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
111+
const code = yield* spawner
112+
.spawn(
113+
ChildProcess.make(cmd[0]!, cmd.slice(1), {
114+
cwd: Instance.directory,
115+
env: item.environment,
116+
extendEnv: true,
117+
}),
118+
)
119+
.pipe(
120+
Effect.flatMap((handle) => handle.exitCode),
121+
Effect.scoped,
122+
Effect.catch(() =>
123+
Effect.sync(() => {
124+
log.error("failed to format file", {
125+
error: "spawn failed",
126+
command: item.command,
127+
...item.environment,
128+
file: filepath,
129+
})
130+
return ChildProcessSpawner.ExitCode(1)
131+
}),
132+
),
133+
)
134+
if (code !== 0) {
119135
log.error("failed", {
120136
command: item.command,
121137
...item.environment,
122138
})
123139
}
124-
} catch (error) {
125-
log.error("failed to format file", {
126-
error,
127-
command: item.command,
128-
...item.environment,
129-
file: filepath,
130-
})
131140
}
132-
}
141+
})
133142
}
134143

135144
log.info("init")
@@ -162,14 +171,14 @@ export namespace Format {
162171

163172
const file = Effect.fn("Format.file")(function* (filepath: string) {
164173
const { formatFile } = yield* InstanceState.get(state)
165-
yield* Effect.promise(() => formatFile(filepath))
174+
yield* formatFile(filepath)
166175
})
167176

168177
return Service.of({ init, status, file })
169178
}),
170179
)
171180

172-
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
181+
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer))
173182

174183
const { runPromise } = makeRuntime(Service, defaultLayer)
175184

packages/opencode/test/format/format.test.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
1-
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
1+
import { NodeFileSystem } from "@effect/platform-node"
22
import { describe, expect } from "bun:test"
33
import { Effect, Layer } from "effect"
44
import { provideTmpdirInstance } from "../fixture/fixture"
55
import { testEffect } from "../lib/effect"
6+
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
67
import { Format } from "../../src/format"
7-
import { Config } from "../../src/config/config"
88
import * as Formatter from "../../src/format/formatter"
99

10-
const node = NodeChildProcessSpawner.layer.pipe(
11-
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
12-
)
13-
14-
const it = testEffect(Layer.mergeAll(Format.layer, node).pipe(Layer.provide(Config.defaultLayer)))
10+
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
1511

1612
describe("Format", () => {
1713
it.effect("status() returns built-in formatters when no config overrides", () =>

0 commit comments

Comments
 (0)