From db31c42e393a32093c9c73d0572769afe4a0fa4a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 7 Aug 2026 11:42:37 -0400 Subject: [PATCH] refactor(core): move mutation path onto environment (#41091) --- packages/core/src/file-mutation.ts | 47 +++++++++++---- packages/core/src/plugin/internal.ts | 3 + packages/core/src/plugin/supervisor.ts | 6 +- packages/core/src/tool/plugin/edit.ts | 35 ++++++----- packages/core/src/tool/plugin/patch.ts | 75 ++++++++++-------------- packages/core/src/tool/plugin/write.ts | 18 +++--- packages/core/test/file-mutation.test.ts | 24 ++++---- packages/core/test/tool-edit.test.ts | 64 ++++++++++---------- packages/core/test/tool-patch.test.ts | 71 +++++++++------------- packages/core/test/tool-write.test.ts | 25 ++++---- packages/util/src/bom.ts | 22 ++++--- 11 files changed, 201 insertions(+), 189 deletions(-) diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index fd443b1ea9..bd220cb68e 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -5,6 +5,8 @@ import { Context, Effect, Layer } from "effect" import { KeyedMutex } from "./effect/keyed-mutex" import { FSUtil } from "@opencode-ai/util/fs-util" import { Bom } from "@opencode-ai/util/bom" +import { Environment } from "./environment" +import type { Files } from "./environment" export interface Target { readonly absolute: string @@ -33,13 +35,29 @@ export interface Interface { readonly withLock: ( targets: ReadonlyArray, ) => (effect: Effect.Effect) => Effect.Effect - readonly write: (input: WriteInput) => Effect.Effect + readonly write: (input: WriteInput) => Effect.Effect /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */ - readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect + readonly writeTextPreservingBom: ( + input: TextWriteInput, + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/FileMutation") {} +export const readText = Effect.fn("FileMutation.readText")(function* (files: Files, target: string) { + return Bom.decodeBytes((yield* files.read(target)).bytes) +}) + +export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* ( + files: Files, + target: string, + bom: boolean, +) { + const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom) + if (synced.bytes) yield* files.write(target, synced.bytes) + return synced.text +}) + /** Share transaction locks across Location graphs that address the same file. */ const transactionLocks = KeyedMutex.makeUnsafe() @@ -51,7 +69,7 @@ const transactionLocks = KeyedMutex.makeUnsafe() const layer = Layer.effect( Service, Effect.gen(function* () { - const fs = yield* FSUtil.Service + const environment = yield* Environment.Service const locks = KeyedMutex.makeUnsafe() const withLock: Interface["withLock"] = (targets) => (effect) => [...new Set(targets.map(FSUtil.resolve))] @@ -72,8 +90,14 @@ const layer = Layer.effect( const write = Effect.fn("FileMutation.write")((input: WriteInput) => withTargetLock(input.target)( Effect.gen(function* () { - const existed = yield* fs.exists(input.target.absolute) - yield* fs.writeWithDirs(input.target.absolute, input.content) + const existed = yield* environment.files.stat(input.target.absolute).pipe( + Effect.as(true), + Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)), + ) + yield* environment.files.write( + input.target.absolute, + typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content, + ) return writeResult(input.target, existed) }), ), @@ -83,12 +107,13 @@ const layer = Layer.effect( withTargetLock(input.target)( Effect.gen(function* () { const next = Bom.split(input.content) - const current = yield* fs - .readFile(input.target.absolute) - .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) - yield* fs.writeWithDirs( + const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe( + Effect.map((result) => result.bytes), + Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)), + ) + yield* environment.files.write( input.target.absolute, - Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom), + new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)), ) return writeResult(input.target, current !== undefined) }), @@ -99,7 +124,7 @@ const layer = Layer.effect( }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) +export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] }) /** * Deferred until the corresponding integrations exist. diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index e99191cb72..a76d6994ab 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -16,6 +16,7 @@ import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigSkillPlugin } from "../config/plugin/skill" import { ConfigWebSearchPlugin } from "../config/plugin/websearch" import { Bus } from "../bus" +import { Environment } from "../environment" import { FileMutation } from "../file-mutation" import { Formatter } from "../formatter" import { Form } from "../form" @@ -70,6 +71,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const config = yield* Config.Service const credential = yield* Credential.Service const bus = yield* Bus.Service + const environment = yield* Environment.Service const mutation = yield* FileMutation.Service const formatter = yield* Formatter.Service const filesystem = yield* FileSystem.Service @@ -102,6 +104,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Config.Service, config), Context.make(Credential.Service, credential), Context.make(Bus.Service, bus), + Context.make(Environment.Service, environment), Context.make(FileMutation.Service, mutation), Context.make(Formatter.Service, formatter), Context.make(FileSystem.Service, filesystem), diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 5ccbec0243..59c9ebd798 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -14,6 +14,7 @@ import { Credential } from "../credential" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { Bus } from "../bus" +import { Environment } from "../environment" import { FileMutation } from "../file-mutation" import { Formatter } from "../formatter" import { FileSystem } from "../filesystem" @@ -282,7 +283,9 @@ const layer = Layer.effect( }) const updates = Stream.merge( config.changes().pipe( - Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))), + Stream.filterEffect((update) => + Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)), + ), Stream.merge(Stream.fromPubSub(configuredChanges)), ), bus.subscribe([Event.Updated, SdkPlugins.Updated]), @@ -320,6 +323,7 @@ export const node = makeLocationNode({ Config.node, Credential.node, Bus.node, + Environment.node, FileMutation.node, Formatter.node, FileSystem.node, diff --git a/packages/core/src/tool/plugin/edit.ts b/packages/core/src/tool/plugin/edit.ts index 6f50ab772a..c7162367aa 100644 --- a/packages/core/src/tool/plugin/edit.ts +++ b/packages/core/src/tool/plugin/edit.ts @@ -12,9 +12,9 @@ import { FileDiff } from "@opencode-ai/schema/file-diff" import { Bom } from "@opencode-ai/util/bom" import { Effect, Schema } from "effect" import path from "path" +import { Environment } from "../../environment" import { FileMutation } from "../../file-mutation" import { Formatter } from "../../formatter" -import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "../../location" import { LocationMutation } from "../../location-mutation" import { Permission } from "../../permission" @@ -111,9 +111,9 @@ export const Plugin = { id: "opencode.tool.edit", effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service - const files = yield* FileMutation.Service + const fileMutation = yield* FileMutation.Service + const environment = yield* Environment.Service const formatter = yield* Formatter.Service - const fs = yield* FSUtil.Service const location = yield* Location.Service const permission = yield* Permission.Service @@ -155,17 +155,16 @@ export const Plugin = { }) } - const info = yield* fs - .stat(target.absolute) - .pipe( - Effect.catchReason("PlatformError", "NotFound", () => - Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })), - ), - ) - if (info.type === "Directory") { - return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }) - } - const original = yield* Bom.readFile(fs, target.absolute) + const original = yield* FileMutation.readText(environment.files, target.absolute).pipe( + Effect.catchTag("Environment.NotFound", () => + Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })), + ), + Effect.catchTag("Environment.WrongKind", (error) => + error.actual === "directory" + ? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })) + : Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })), + ), + ) const source = original.text const ending = source.includes(crlf) ? crlf : "\n" const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending) @@ -207,20 +206,20 @@ export const Plugin = { }) } const replacementBom = replaced.startsWith("\uFEFF") - const result = yield* files.write({ + const result = yield* fileMutation.write({ target, content: Bom.join(replaced, original.bom || replacementBom), }) const bom = original.bom || replacementBom const formatted = (yield* formatter.file(target.absolute)) - ? yield* Bom.syncFile(fs, target.absolute, bom) - : (yield* Bom.readFile(fs, target.absolute)).text + ? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom) + : (yield* FileMutation.readText(environment.files, target.absolute)).text return { files: [fileDiff(result.resource, source, formatted)], replacements, } satisfies Output }).pipe( - files.withLock([path.resolve(location.directory, input.path)]), + fileMutation.withLock([path.resolve(location.directory, input.path)]), Effect.map((output) => ({ output, content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`, diff --git a/packages/core/src/tool/plugin/patch.ts b/packages/core/src/tool/plugin/patch.ts index a69c92918f..9c59d2e116 100644 --- a/packages/core/src/tool/plugin/patch.ts +++ b/packages/core/src/tool/plugin/patch.ts @@ -5,10 +5,10 @@ import { ToolFailure } from "@opencode-ai/ai" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Result, Schema } from "effect" -import { PlatformError } from "effect/PlatformError" import path from "path" import { Bom } from "@opencode-ai/util/bom" import { FSUtil } from "@opencode-ai/util/fs-util" +import { Environment } from "../../environment" import { Formatter } from "../../formatter" import { FileMutation } from "../../file-mutation" import { Location } from "../../location" @@ -45,7 +45,13 @@ export const toModelOutput = (output: Output) => ].join("\n") type Prepared = - | (Extract & { + | (Extract & { + readonly target: Target + readonly content: string + readonly before: string + readonly after: string + }) + | (Extract & { readonly target: Target readonly before: string readonly after: string @@ -70,7 +76,7 @@ interface Target { export const Plugin = { id: "opencode.tool.patch", effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { - const fs = yield* FSUtil.Service + const environment = yield* Environment.Service const mutation = yield* FileMutation.Service const formatter = yield* Formatter.Service const location = yield* Location.Service @@ -90,9 +96,7 @@ export const Plugin = { const lockTargets = Result.isSuccess(parsed) ? parsed.success.flatMap((hunk) => [ path.resolve(location.directory, hunk.path), - ...(hunk.type === "update" && hunk.movePath - ? [path.resolve(location.directory, hunk.movePath)] - : []), + ...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []), ]) : [] const fail = (operation: string, error: unknown) => { @@ -108,7 +112,7 @@ export const Plugin = { id: context.id, } if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" }) - const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe( + const hunks = yield* Effect.fromResult(parsed).pipe( Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })), ) if (hunks.length === 0) { @@ -136,18 +140,19 @@ export const Plugin = { }) } if (hunk.type === "add") { + const content = + hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n` prepared.push({ ...hunk, target, + content, before: "", - after: Bom.split( - hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`, - ).text, + after: Bom.split(content).text, }) return } if (hunk.type === "delete") { - const content = yield* Bom.readFile(fs, target.absolute).pipe( + const content = yield* FileMutation.readText(environment.files, target.absolute).pipe( Effect.mapError( (error) => new ToolFailure({ @@ -162,20 +167,7 @@ export const Plugin = { const original = previous ?? (yield* Effect.gen(function* () { - const stats = yield* fs.stat(target.absolute).pipe( - Effect.mapError( - (error) => - new ToolFailure({ - message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`, - }), - ), - ) - if (stats.type === "Directory") { - return yield* new ToolFailure({ - message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`, - }) - } - const content = yield* Bom.readFile(fs, target.absolute).pipe( + const content = yield* FileMutation.readText(environment.files, target.absolute).pipe( Effect.mapError( (error) => new ToolFailure({ @@ -244,13 +236,8 @@ export const Plugin = { (change) => Effect.gen(function* () { if (change.type === "add") { - yield* fs - .writeWithDirs( - change.target.absolute, - change.contents.endsWith("\n") || change.contents === "" - ? change.contents - : `${change.contents}\n`, - ) + yield* environment.files + .write(change.target.absolute, new TextEncoder().encode(change.content)) .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error))) applied.push({ type: change.type, @@ -260,7 +247,7 @@ export const Plugin = { return } if (change.type === "delete") { - yield* fs + yield* environment.files .remove(change.target.absolute) .pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error))) applied.push({ @@ -272,10 +259,10 @@ export const Plugin = { } if (change.moveTarget) { const moveTarget = change.moveTarget - yield* fs - .writeWithDirs(moveTarget.absolute, change.content) + yield* environment.files + .write(moveTarget.absolute, new TextEncoder().encode(change.content)) .pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error))) - yield* fs + yield* environment.files .remove(change.target.absolute) .pipe( Effect.mapError((error) => @@ -289,8 +276,8 @@ export const Plugin = { }) return } - yield* fs - .writeWithDirs(change.target.absolute, change.content) + yield* environment.files + .write(change.target.absolute, new TextEncoder().encode(change.content)) .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error))) applied.push({ type: change.type, @@ -305,13 +292,13 @@ export const Plugin = { [...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))], (target) => Effect.gen(function* () { - const current = yield* Bom.readFile(fs, target).pipe( + const current = yield* FileMutation.readText(environment.files, target).pipe( Effect.mapError((error) => fail(`Failed to read ${target}`, error)), ) formatted.set( target, (yield* formatter.file(target)) - ? yield* Bom.syncFile(fs, target, current.bom).pipe( + ? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe( Effect.mapError((error) => fail(`Failed to sync ${target}`, error)), ) : current.text, @@ -357,10 +344,10 @@ export const Plugin = { } function errorMessage(error: unknown) { - if (error instanceof PlatformError) { - if (error.reason._tag === "NotFound") return "file does not exist" - return error.reason.description ?? error.reason.message - } + if (error instanceof Environment.NotFound) return "file does not exist" + if (error instanceof Environment.WrongKind) + return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}` + if (error instanceof Environment.Failed) return errorMessage(error.cause) return error instanceof Error ? error.message : String(error) } diff --git a/packages/core/src/tool/plugin/write.ts b/packages/core/src/tool/plugin/write.ts index 667594ab74..bdcf227de7 100644 --- a/packages/core/src/tool/plugin/write.ts +++ b/packages/core/src/tool/plugin/write.ts @@ -10,7 +10,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import { Bom } from "@opencode-ai/util/bom" -import { FSUtil } from "@opencode-ai/util/fs-util" +import { Environment } from "../../environment" import { FileMutation } from "../../file-mutation" import { Formatter } from "../../formatter" import { LocationMutation } from "../../location-mutation" @@ -47,9 +47,9 @@ export const Plugin = { id: "opencode.tool.write", effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service - const files = yield* FileMutation.Service + const fileMutation = yield* FileMutation.Service + const environment = yield* Environment.Service const formatter = yield* Formatter.Service - const fs = yield* FSUtil.Service const permission = yield* Permission.Service yield* ctx.tool @@ -77,8 +77,8 @@ export const Plugin = { agent: context.agent, source, }) - const current = yield* Bom.readFile(fs, target.absolute).pipe( - Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)), + const current = yield* FileMutation.readText(environment.files, target.absolute).pipe( + Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)), ) const next = Bom.split(input.content) const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added") @@ -91,9 +91,11 @@ export const Plugin = { agent: context.agent, source, }) - const result = yield* files.writeTextPreservingBom({ target, content: input.content }) - const bom = (yield* Bom.readFile(fs, target.absolute)).bom - if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom) + const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content }) + const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom + if (yield* formatter.file(target.absolute)) { + yield* FileMutation.syncTextBom(environment.files, target.absolute, bom) + } return result }).pipe( Effect.map((output) => ({ output, content: toModelOutput(output) })), diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index 48f2c3a371..001d74d9d5 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FileMutation } from "@opencode-ai/core/file-mutation" -import { FSUtil } from "@opencode-ai/util/fs-util" +import { Environment } from "@opencode-ai/core/environment" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -13,7 +13,7 @@ import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" -function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) { +function provide(directory: string, environmentLayer = LayerNode.compile(Environment.node)) { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), @@ -21,7 +21,7 @@ function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.n return Effect.provide( AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [ [Location.node, activeLocation], - [FSUtil.node, filesystemLayer], + [Environment.node, environmentLayer], ]), ) } @@ -242,16 +242,16 @@ describe("FileMutation", () => { function instrumentWrites(run: (write: Effect.Effect, target: string) => Effect.Effect) { return Layer.effect( - FSUtil.Service, + Environment.Service, Effect.gen(function* () { - const filesystem = yield* FSUtil.Service - return FSUtil.Service.of({ - ...filesystem, - writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target), - writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target), - writeFileString: (target, content, options) => - run(filesystem.writeFileString(target, content, options), target), + const environment = yield* Environment.Service + return Environment.Service.of({ + ...environment, + files: { + ...environment.files, + write: (target, content) => run(environment.files.write(target, content), target), + }, }) }), - ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) + ).pipe(Layer.provide(LayerNode.compile(Environment.node))) } diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 513ca375a8..ebd9917fb5 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -4,9 +4,9 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Environment } from "@opencode-ai/core/environment" import { FileMutation } from "@opencode-ai/core/file-mutation" import { Formatter } from "@opencode-ai/core/formatter" -import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" import { Permission } from "@opencode-ai/core/permission" @@ -23,7 +23,15 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from " const editToolNode = makeLocationNode({ name: "test/edit-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)), - deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Location.node, Permission.node], + deps: [ + Tool.node, + LocationMutation.node, + FileMutation.node, + Environment.node, + Formatter.node, + Location.node, + Permission.node, + ], }) const sessionID = Session.ID.make("ses_edit_tool_test") @@ -72,29 +80,28 @@ const reset = () => { formatFile = () => Effect.succeed(false) } -const filesystem = Layer.effect( - FSUtil.Service, +const environment = Layer.effect( + Environment.Service, Effect.gen(function* () { - const fs = yield* FSUtil.Service - return FSUtil.Service.of({ - ...fs, - readFile: (target) => - fs - .readFile(target) - .pipe( - Effect.tap((content) => - Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))), + const current = yield* Environment.Service + return Environment.Service.of({ + ...current, + files: { + ...current.files, + read: (target, range) => + current.files + .read(target, range) + .pipe( + Effect.tap((result) => + Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))), + ), ), - ), - writeWithDirs: (target, content, mode) => - Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))), - writeFile: (target, content, options) => - Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFile(target, content, options))), - writeFileString: (target, content, options) => - Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))), + write: (target, content) => + Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))), + }, }) }), -).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) +).pipe(Layer.provide(LayerNode.compile(Environment.node))) const withTool = (directory: string, body: (registry: Tool.Interface) => Effect.Effect) => { const activeLocation = Layer.succeed( @@ -106,15 +113,9 @@ const withTool = (directory: string, body: (registry: Tool.Interface) = }).pipe( Effect.provide( AppNodeBuilder.build( - LayerNode.group([ - Tool.node, - Tool.node, - LocationMutation.node, - FileMutation.node, - editToolNode, - ]), + LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), [ - [FSUtil.node, filesystem], + [Environment.node, environment], [Location.node, activeLocation], [Formatter.node, formatter], [Permission.node, permission], @@ -471,10 +472,7 @@ describe("EditTool", () => { withTool(tmp.path, (registry) => Effect.gen(function* () { expect( - yield* executeTool( - registry, - call({ path: "missing.ts", oldString: "before", newString: "after" }), - ), + yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })), ).toEqual({ status: "error", error: { type: "tool.execution", message: "File not found: missing.ts" }, diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 50d7f3a427..053dce63ba 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -2,9 +2,9 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" import { Effect, Exit, Layer, Schema } from "effect" -import { systemError } from "effect/PlatformError" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Environment } from "@opencode-ai/core/environment" import { FSUtil } from "@opencode-ai/util/fs-util" import { Formatter } from "@opencode-ai/core/formatter" import { FileMutation } from "@opencode-ai/core/file-mutation" @@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from " const patchToolNode = makeLocationNode({ name: "test/patch-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), - deps: [Tool.node, FileMutation.node, Formatter.node, FSUtil.node, Location.node, Permission.node], + deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node], }) const sessionID = Session.ID.make("ses_patch_tool_test") @@ -82,48 +82,33 @@ const reset = () => { formatFile = () => Effect.succeed(false) } -const filesystem = Layer.effect( - FSUtil.Service, +const environment = Layer.effect( + Environment.Service, Effect.gen(function* () { - const fs = yield* FSUtil.Service - return FSUtil.Service.of({ - ...fs, - readFile: (target) => - Effect.sync(() => { - if (!editApproved) readsBeforeEditApproval++ - }).pipe(Effect.andThen(fs.readFile(target))), - remove: (target, options) => { - if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure") - if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) { - return Effect.fail( - systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "remove", - description: "forced remove failure", - pathOrDescriptor: target, - }), - ) - } - return fs.remove(target, options) - }, - writeWithDirs: (target, content, mode) => { - if (failWriteTarget && path.basename(target) === failWriteTarget) { - return Effect.fail( - systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "writeWithDirs", - description: "forced write failure", - pathOrDescriptor: target, - }), - ) - } - return fs.writeWithDirs(target, content, mode) + const current = yield* Environment.Service + return Environment.Service.of({ + ...current, + files: { + ...current.files, + read: (target, range) => + Effect.sync(() => { + if (!editApproved) readsBeforeEditApproval++ + }).pipe(Effect.andThen(current.files.read(target, range))), + remove: (target) => { + if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure") + if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) + return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") })) + return current.files.remove(target) + }, + write: (target, content) => { + if (failWriteTarget && path.basename(target) === failWriteTarget) + return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") })) + return current.files.write(target, content) + }, }, }) }), -).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) +).pipe(Layer.provide(LayerNode.compile(Environment.node))) const withTool = ( directory: string, @@ -141,7 +126,7 @@ const withTool = ( }).pipe( Effect.provide( AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [ - [FSUtil.node, filesystem], + [Environment.node, environment], [Location.node, activeLocation], [Formatter.node, formatter], [Permission.node, permission], @@ -267,9 +252,7 @@ describe("PatchTool", () => { withTempTool((directory, registry) => { const target = path.join(directory, "concurrent.txt") afterEditApproval = () => - assertions.filter((input) => input.action === "edit").length === 1 - ? Effect.sleep("50 millis") - : Effect.void + assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe( Effect.andThen( Effect.all( diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 69d85e63a2..c0225f75f2 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation" import { Formatter } from "@opencode-ai/core/formatter" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { FSUtil } from "@opencode-ai/util/fs-util" +import { Environment } from "@opencode-ai/core/environment" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" import { Permission } from "@opencode-ai/core/permission" @@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from " const writeToolNode = makeLocationNode({ name: "test/write-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)), - deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node], + deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node], }) const sessionID = Session.ID.make("ses_write_tool_test") @@ -68,17 +68,20 @@ const reset = () => { denyAction = undefined } -const filesystem = Layer.effect( - FSUtil.Service, +const environment = Layer.effect( + Environment.Service, Effect.gen(function* () { - const fs = yield* FSUtil.Service - return FSUtil.Service.of({ - ...fs, - writeWithDirs: (target, content, mode) => - Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))), + const current = yield* Environment.Service + return Environment.Service.of({ + ...current, + files: { + ...current.files, + write: (target, content) => + Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))), + }, }) }), -).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) +).pipe(Layer.provide(LayerNode.compile(Environment.node))) const withTool = (directory: string, body: (registry: Tool.Interface) => Effect.Effect) => { const activeLocation = Layer.succeed( @@ -92,7 +95,7 @@ const withTool = (directory: string, body: (registry: Tool.Interface) = AppNodeBuilder.build( LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), [ - [FSUtil.node, filesystem], + [Environment.node, environment], [Location.node, activeLocation], [Formatter.node, formatter], [Permission.node, permission], diff --git a/packages/util/src/bom.ts b/packages/util/src/bom.ts index 776ca118a9..fb0443ec60 100644 --- a/packages/util/src/bom.ts +++ b/packages/util/src/bom.ts @@ -20,17 +20,25 @@ export function has(content: Uint8Array) { return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf } +export function decodeBytes(content: Uint8Array) { + return split(decode(content)) +} + +export function syncBytes(content: Uint8Array, bom: boolean) { + const decoded = decode(content) + const current = split(decoded) + const canonical = join(current.text, bom) + return { text: current.text, bytes: decoded === canonical ? undefined : new TextEncoder().encode(canonical) } +} + export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) { - return split(decode(yield* fs.readFile(filepath))) + return decodeBytes(yield* fs.readFile(filepath)) }) export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filepath: string, bom: boolean) { - const decoded = decode(yield* fs.readFile(filepath)) - const current = split(decoded) - const canonical = join(current.text, bom) - if (decoded === canonical) return current.text - yield* fs.writeWithDirs(filepath, canonical) - return current.text + const synced = syncBytes(yield* fs.readFile(filepath), bom) + if (synced.bytes) yield* fs.writeWithDirs(filepath, synced.bytes) + return synced.text }) function decode(content: Uint8Array) {