refactor(core): move mutation path onto environment (#41091)
This commit is contained in:
@@ -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<string>,
|
||||
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, Environment.Failed>
|
||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
readonly writeTextPreservingBom: (
|
||||
input: TextWriteInput,
|
||||
) => Effect.Effect<WriteResult, Environment.WrongKind | Environment.Failed>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<string>()
|
||||
|
||||
@@ -51,7 +69,7 @@ const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const environment = yield* Environment.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"})`,
|
||||
|
||||
@@ -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<Patch.Hunk, { readonly type: "add" | "delete" }> & {
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
|
||||
readonly target: Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) })),
|
||||
|
||||
@@ -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: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -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 = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
||||
const activeLocation = Layer.succeed(
|
||||
@@ -106,15 +113,9 @@ const withTool = <A, E, R>(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" },
|
||||
|
||||
@@ -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 = <A, E, R>(
|
||||
directory: string,
|
||||
@@ -141,7 +126,7 @@ const withTool = <A, E, R>(
|
||||
}).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(
|
||||
|
||||
@@ -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 = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
|
||||
const activeLocation = Layer.succeed(
|
||||
@@ -92,7 +95,7 @@ const withTool = <A, E, R>(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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user