mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa504c7e85 | |||
| 602f5ef465 |
@@ -53,6 +53,10 @@ export interface RemoveResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
/** Serialize a complete read/prepare/write mutation transaction by canonical target. */
|
||||||
|
readonly withLock: (
|
||||||
|
targets: ReadonlyArray<Target>,
|
||||||
|
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||||
/** Create without replacing an existing target. */
|
/** Create without replacing an existing target. */
|
||||||
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
|
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
|
||||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||||
@@ -67,6 +71,9 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||||
|
|
||||||
|
/** Share transaction locks across Location graphs that address the same file. */
|
||||||
|
const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize file changes by canonical target. Conditional writes compare and
|
* Serialize file changes by canonical target. Conditional writes compare and
|
||||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||||
@@ -77,6 +84,10 @@ const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const locks = KeyedMutex.makeUnsafe<string>()
|
const locks = KeyedMutex.makeUnsafe<string>()
|
||||||
|
const withLock: Interface["withLock"] = (targets) => (effect) =>
|
||||||
|
[...new Set(targets.map((target) => target.canonical))]
|
||||||
|
.sort()
|
||||||
|
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
|
||||||
const withTargetLock =
|
const withTargetLock =
|
||||||
(target: Target) =>
|
(target: Target) =>
|
||||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
@@ -169,7 +180,7 @@ const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
|
return Service.of({ withLock, create, write, writeTextPreservingBom, writeIfUnchanged, remove })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import { ToolFailure } from "@opencode-ai/ai"
|
|||||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
import path from "path"
|
||||||
import { FileMutation } from "../../file-mutation"
|
import { FileMutation } from "../../file-mutation"
|
||||||
import { Formatter } from "../../formatter"
|
import { Formatter } from "../../formatter"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
import { Location } from "../../location"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { fileDiff } from "./file-diff"
|
import { fileDiff } from "./file-diff"
|
||||||
@@ -112,6 +114,7 @@ export const Plugin = {
|
|||||||
const files = yield* FileMutation.Service
|
const files = yield* FileMutation.Service
|
||||||
const formatter = yield* Formatter.Service
|
const formatter = yield* Formatter.Service
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
@@ -125,7 +128,9 @@ export const Plugin = {
|
|||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
return Effect.gen(function* () {
|
return files.withLock([
|
||||||
|
{ canonical: FSUtil.resolve(path.resolve(location.directory, input.path)), resource: input.path },
|
||||||
|
])(Effect.gen(function* () {
|
||||||
const permissionSource = {
|
const permissionSource = {
|
||||||
type: "tool" as const,
|
type: "tool" as const,
|
||||||
messageID: context.messageID,
|
messageID: context.messageID,
|
||||||
@@ -219,7 +224,7 @@ export const Plugin = {
|
|||||||
files: [fileDiff(result.resource, source, formatted)],
|
files: [fileDiff(result.resource, source, formatted)],
|
||||||
replacements,
|
replacements,
|
||||||
} satisfies Output
|
} satisfies Output
|
||||||
}).pipe(
|
})).pipe(
|
||||||
Effect.map((output) => ({
|
Effect.map((output) => ({
|
||||||
output,
|
output,
|
||||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
|||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
import { createTwoFilesPatch, diffLines } from "diff"
|
import { createTwoFilesPatch, diffLines } from "diff"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Result, Schema } from "effect"
|
||||||
import { PlatformError } from "effect/PlatformError"
|
import { PlatformError } from "effect/PlatformError"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Formatter } from "../../formatter"
|
import { Formatter } from "../../formatter"
|
||||||
|
import { FileMutation } from "../../file-mutation"
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
import { Patch } from "@opencode-ai/util/patch"
|
import { Patch } from "@opencode-ai/util/patch"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
@@ -70,6 +71,7 @@ export const Plugin = {
|
|||||||
id: "opencode.tool.patch",
|
id: "opencode.tool.patch",
|
||||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
|
const mutation = yield* FileMutation.Service
|
||||||
const formatter = yield* Formatter.Service
|
const formatter = yield* Formatter.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
@@ -85,13 +87,30 @@ export const Plugin = {
|
|||||||
output: Output,
|
output: Output,
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const applied: Array<typeof Applied.Type> = []
|
const applied: Array<typeof Applied.Type> = []
|
||||||
|
const parsed = Patch.parse(input.patchText)
|
||||||
|
const lockTargets = Result.isSuccess(parsed)
|
||||||
|
? parsed.success.flatMap((hunk) => [
|
||||||
|
{
|
||||||
|
...resolveTarget(location, hunk.path),
|
||||||
|
canonical: FSUtil.resolve(path.resolve(location.directory, hunk.path)),
|
||||||
|
},
|
||||||
|
...(hunk.type === "update" && hunk.movePath
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
...resolveTarget(location, hunk.movePath),
|
||||||
|
canonical: FSUtil.resolve(path.resolve(location.directory, hunk.movePath)),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
])
|
||||||
|
: []
|
||||||
const fail = (operation: string, error: unknown) => {
|
const fail = (operation: string, error: unknown) => {
|
||||||
const completed = applied.map((item) => item.resource).join(", ")
|
const completed = applied.map((item) => item.resource).join(", ")
|
||||||
return new ToolFailure({
|
return new ToolFailure({
|
||||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Effect.gen(function* () {
|
return mutation.withLock(lockTargets)(Effect.gen(function* () {
|
||||||
const source = {
|
const source = {
|
||||||
type: "tool" as const,
|
type: "tool" as const,
|
||||||
messageID: context.messageID,
|
messageID: context.messageID,
|
||||||
@@ -322,7 +341,7 @@ export const Plugin = {
|
|||||||
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
||||||
})
|
})
|
||||||
return { applied, files }
|
return { applied, files }
|
||||||
}).pipe(
|
})).pipe(
|
||||||
Effect.map((output) => ({
|
Effect.map((output) => ({
|
||||||
output,
|
output,
|
||||||
content: toModelOutput(output),
|
content: toModelOutput(output),
|
||||||
|
|||||||
@@ -257,6 +257,59 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("shares transaction locks across Location service instances", () =>
|
||||||
|
withTmp((directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
const releaseFirst = yield* Deferred.make<void>()
|
||||||
|
const secondStarted = yield* Deferred.make<void>()
|
||||||
|
const target = { canonical: path.join(directory, "shared.txt"), resource: "shared.txt" }
|
||||||
|
const first = yield* Effect.gen(function* () {
|
||||||
|
const files = yield* FileMutation.Service
|
||||||
|
yield* files.withLock([target])(
|
||||||
|
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||||
|
)
|
||||||
|
}).pipe(provide(directory), Effect.forkChild)
|
||||||
|
yield* Deferred.await(firstStarted)
|
||||||
|
const second = yield* Effect.gen(function* () {
|
||||||
|
const files = yield* FileMutation.Service
|
||||||
|
yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
|
||||||
|
}).pipe(provide(directory), Effect.forkChild)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
||||||
|
|
||||||
|
yield* Deferred.succeed(releaseFirst, undefined)
|
||||||
|
yield* Deferred.await(secondStarted)
|
||||||
|
yield* Fiber.join(first)
|
||||||
|
yield* Fiber.join(second)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("allows transaction locks for distinct canonical targets to proceed independently", () =>
|
||||||
|
withTmp((directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
const releaseFirst = yield* Deferred.make<void>()
|
||||||
|
const secondFinished = yield* Deferred.make<void>()
|
||||||
|
const files = yield* FileMutation.Service
|
||||||
|
const first = yield* files
|
||||||
|
.withLock([{ canonical: path.join(directory, "first.txt"), resource: "first.txt" }])(
|
||||||
|
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||||
|
)
|
||||||
|
.pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(firstStarted)
|
||||||
|
yield* files.withLock([{ canonical: path.join(directory, "second.txt"), resource: "second.txt" }])(
|
||||||
|
Deferred.succeed(secondFinished, undefined),
|
||||||
|
)
|
||||||
|
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
|
||||||
|
|
||||||
|
yield* Deferred.succeed(releaseFirst, undefined)
|
||||||
|
yield* Fiber.join(first)
|
||||||
|
}).pipe(provide(directory)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("allows only one concurrent conditional write based on the same bytes", () =>
|
it.live("allows only one concurrent conditional write based on the same bytes", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
|||||||
const editToolNode = makeLocationNode({
|
const editToolNode = makeLocationNode({
|
||||||
name: "test/edit-tool-plugin",
|
name: "test/edit-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionID = Session.ID.make("ses_edit_tool_test")
|
const sessionID = Session.ID.make("ses_edit_tool_test")
|
||||||
@@ -645,6 +645,43 @@ describe("EditTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("serializes concurrent edit transactions", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => {
|
||||||
|
reset()
|
||||||
|
const target = path.join(tmp.path, "concurrent.txt")
|
||||||
|
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
|
||||||
|
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
withTool(tmp.path, (registry) =>
|
||||||
|
Effect.all(
|
||||||
|
[
|
||||||
|
executeTool(
|
||||||
|
registry,
|
||||||
|
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
|
||||||
|
),
|
||||||
|
executeTool(
|
||||||
|
registry,
|
||||||
|
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.andThen((results) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("applies the edit when content changes after matching", () =>
|
it.live("applies the edit when content changes after matching", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Formatter } from "@opencode-ai/core/formatter"
|
import { Formatter } from "@opencode-ai/core/formatter"
|
||||||
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
@@ -22,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
|||||||
const patchToolNode = makeLocationNode({
|
const patchToolNode = makeLocationNode({
|
||||||
name: "test/patch-tool-plugin",
|
name: "test/patch-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||||
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
deps: [Tool.node, FileMutation.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionID = Session.ID.make("ses_patch_tool_test")
|
const sessionID = Session.ID.make("ses_patch_tool_test")
|
||||||
@@ -139,7 +140,7 @@ const withTool = <A, E, R>(
|
|||||||
return yield* body(yield* Tool.Service)
|
return yield* body(yield* Tool.Service)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
|
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
|
||||||
[FSUtil.node, filesystem],
|
[FSUtil.node, filesystem],
|
||||||
[Location.node, activeLocation],
|
[Location.node, activeLocation],
|
||||||
[Formatter.node, formatter],
|
[Formatter.node, formatter],
|
||||||
@@ -262,6 +263,45 @@ describe("PatchTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("serializes concurrent patch transactions", () =>
|
||||||
|
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
|
||||||
|
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
Effect.all(
|
||||||
|
[
|
||||||
|
executeTool(
|
||||||
|
registry,
|
||||||
|
call(
|
||||||
|
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
|
||||||
|
"call-patch-one",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
executeTool(
|
||||||
|
registry,
|
||||||
|
call(
|
||||||
|
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
|
||||||
|
"call-patch-two",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.andThen((results) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("returns file diffs for final formatted content", () =>
|
it.live("returns file diffs for final formatted content", () =>
|
||||||
withTempTool((directory, registry) => {
|
withTempTool((directory, registry) => {
|
||||||
const target = path.join(directory, "formatted.txt")
|
const target = path.join(directory, "formatted.txt")
|
||||||
|
|||||||
Reference in New Issue
Block a user