refactor(format): use ChildProcessSpawner instead of Process.spawn (#19457)
parent
c8909908f5
commit
5cd54ec345
|
|
@ -212,8 +212,81 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||||
|
|
||||||
Still open and likely worth migrating:
|
Still open and likely worth migrating:
|
||||||
|
|
||||||
- [ ] `Session`
|
- [x] `Session` — `session/index.ts`
|
||||||
- [ ] `SessionProcessor`
|
- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433)
|
||||||
- [ ] `SessionPrompt`
|
- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433)
|
||||||
- [ ] `SessionCompaction`
|
- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433)
|
||||||
- [ ] `Provider`
|
- [ ] `Provider` — blocked by AI SDK v6 PR (#18433)
|
||||||
|
|
||||||
|
Other services not yet migrated:
|
||||||
|
|
||||||
|
- [ ] `SessionSummary` — `session/summary.ts`
|
||||||
|
- [ ] `SessionTodo` — `session/todo.ts`
|
||||||
|
- [ ] `SessionRevert` — `session/revert.ts`
|
||||||
|
- [ ] `Instruction` — `session/instruction.ts`
|
||||||
|
- [ ] `ShareNext` — `share/share-next.ts`
|
||||||
|
- [ ] `SyncEvent` — `sync/index.ts`
|
||||||
|
- [ ] `Storage` — `storage/storage.ts`
|
||||||
|
- [ ] `Workspace` — `control-plane/workspace.ts`
|
||||||
|
|
||||||
|
## Tool interface → Effect
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
1. Migrate each tool to return Effects
|
||||||
|
2. Update `Tool.define()` factory to work with Effects
|
||||||
|
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
|
||||||
|
|
||||||
|
Individual tools, ordered by value:
|
||||||
|
|
||||||
|
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
|
||||||
|
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
|
||||||
|
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
|
||||||
|
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
|
||||||
|
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
|
||||||
|
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
|
||||||
|
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
|
||||||
|
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
|
||||||
|
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
|
||||||
|
- [ ] `task.ts` — MEDIUM: task state management
|
||||||
|
- [ ] `glob.ts` — LOW: simple async generator
|
||||||
|
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
|
||||||
|
- [ ] `skill.ts` — LOW: skill tool adapter
|
||||||
|
- [ ] `plan.ts` — LOW: plan file operations
|
||||||
|
|
||||||
|
## Effect service adoption in already-migrated code
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
||||||
|
|
||||||
|
- [ ] `file/index.ts` — 11 calls (the File service itself)
|
||||||
|
- [ ] `config/config.ts` — 7 calls
|
||||||
|
- [ ] `auth/index.ts` — 3 calls
|
||||||
|
- [ ] `skill/index.ts` — 3 calls
|
||||||
|
- [ ] `file/time.ts` — 1 call
|
||||||
|
|
||||||
|
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
||||||
|
|
||||||
|
- [ ] `format/index.ts` — 1 call
|
||||||
|
|
||||||
|
## Filesystem consolidation
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
Similarly, **28 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
|
||||||
|
|
||||||
|
Current raw fs users that will convert during tool migration:
|
||||||
|
- `tool/read.ts` — fs.createReadStream, readline
|
||||||
|
- `tool/apply_patch.ts` — fs/promises
|
||||||
|
- `tool/bash.ts` — fs/promises
|
||||||
|
- `file/ripgrep.ts` — fs/promises
|
||||||
|
- `storage/storage.ts` — fs/promises
|
||||||
|
- `patch/index.ts` — fs, fs/promises
|
||||||
|
|
||||||
|
## Primitives & utilities
|
||||||
|
|
||||||
|
- [ ] `util/lock.ts` — reader-writer lock → Effect Semaphore/Permit
|
||||||
|
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
|
||||||
|
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
|
||||||
|
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
@ -6,7 +8,6 @@ import { mergeDeep } from "remeda"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Process } from "../util/process"
|
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import * as Formatter from "./formatter"
|
import * as Formatter from "./formatter"
|
||||||
|
|
||||||
|
|
@ -36,6 +37,7 @@ export namespace Format {
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
|
||||||
const state = yield* InstanceState.make(
|
const state = yield* InstanceState.make(
|
||||||
Effect.fn("Format.state")(function* (_ctx) {
|
Effect.fn("Format.state")(function* (_ctx) {
|
||||||
|
|
@ -98,38 +100,45 @@ export namespace Format {
|
||||||
return checks.filter((x) => x.enabled).map((x) => x.item)
|
return checks.filter((x) => x.enabled).map((x) => x.item)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function formatFile(filepath: string) {
|
function formatFile(filepath: string) {
|
||||||
log.info("formatting", { file: filepath })
|
return Effect.gen(function* () {
|
||||||
const ext = path.extname(filepath)
|
log.info("formatting", { file: filepath })
|
||||||
|
const ext = path.extname(filepath)
|
||||||
|
|
||||||
for (const item of await getFormatter(ext)) {
|
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
||||||
log.info("running", { command: item.command })
|
log.info("running", { command: item.command })
|
||||||
try {
|
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
|
||||||
const proc = Process.spawn(
|
const code = yield* spawner
|
||||||
item.command.map((x) => x.replace("$FILE", filepath)),
|
.spawn(
|
||||||
{
|
ChildProcess.make(cmd[0]!, cmd.slice(1), {
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
env: { ...process.env, ...item.environment },
|
env: item.environment,
|
||||||
stdout: "ignore",
|
extendEnv: true,
|
||||||
stderr: "ignore",
|
}),
|
||||||
},
|
)
|
||||||
)
|
.pipe(
|
||||||
const exit = await proc.exited
|
Effect.flatMap((handle) => handle.exitCode),
|
||||||
if (exit !== 0) {
|
Effect.scoped,
|
||||||
|
Effect.catch(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.error("failed to format file", {
|
||||||
|
error: "spawn failed",
|
||||||
|
command: item.command,
|
||||||
|
...item.environment,
|
||||||
|
file: filepath,
|
||||||
|
})
|
||||||
|
return ChildProcessSpawner.ExitCode(1)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (code !== 0) {
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
command: item.command,
|
command: item.command,
|
||||||
...item.environment,
|
...item.environment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
log.error("failed to format file", {
|
|
||||||
error,
|
|
||||||
command: item.command,
|
|
||||||
...item.environment,
|
|
||||||
file: filepath,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
|
|
@ -162,14 +171,14 @@ export namespace Format {
|
||||||
|
|
||||||
const file = Effect.fn("Format.file")(function* (filepath: string) {
|
const file = Effect.fn("Format.file")(function* (filepath: string) {
|
||||||
const { formatFile } = yield* InstanceState.get(state)
|
const { formatFile } = yield* InstanceState.get(state)
|
||||||
yield* Effect.promise(() => formatFile(filepath))
|
yield* formatFile(filepath)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ init, status, file })
|
return Service.of({ init, status, file })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
import { Config } from "../../src/config/config"
|
|
||||||
import * as Formatter from "../../src/format/formatter"
|
import * as Formatter from "../../src/format/formatter"
|
||||||
|
|
||||||
const node = NodeChildProcessSpawner.layer.pipe(
|
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
||||||
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const it = testEffect(Layer.mergeAll(Format.layer, node).pipe(Layer.provide(Config.defaultLayer)))
|
|
||||||
|
|
||||||
describe("Format", () => {
|
describe("Format", () => {
|
||||||
it.effect("status() returns built-in formatters when no config overrides", () =>
|
it.effect("status() returns built-in formatters when no config overrides", () =>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue