mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 02:36:57 -04:00
208 lines
7.3 KiB
TypeScript
208 lines
7.3 KiB
TypeScript
export * as FileMutation from "./file-mutation"
|
|
|
|
import { makeLocationNode } from "./effect/app-node"
|
|
import { Context, Effect, Layer, Schema } from "effect"
|
|
import { dirname } from "path"
|
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
|
import { FSUtil } from "./fs-util"
|
|
|
|
export interface Target {
|
|
readonly canonical: string
|
|
readonly resource: string
|
|
}
|
|
|
|
export interface WriteInput {
|
|
readonly target: Target
|
|
readonly content: string | Uint8Array
|
|
}
|
|
|
|
export interface TextWriteInput {
|
|
readonly target: Target
|
|
readonly content: string
|
|
}
|
|
|
|
export interface ConditionalWriteInput extends WriteInput {
|
|
readonly expected: Uint8Array
|
|
}
|
|
|
|
export interface RemoveInput {
|
|
readonly target: Target
|
|
}
|
|
|
|
export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()("FileMutation.StaleContentError", {
|
|
path: Schema.String,
|
|
}) {}
|
|
|
|
export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError>()("FileMutation.TargetExistsError", {
|
|
path: Schema.String,
|
|
}) {}
|
|
|
|
export interface WriteResult {
|
|
readonly operation: "write"
|
|
readonly target: string
|
|
readonly resource: string
|
|
readonly existed: boolean
|
|
}
|
|
|
|
export interface RemoveResult {
|
|
readonly operation: "remove"
|
|
readonly target: string
|
|
readonly resource: string
|
|
readonly existed: boolean
|
|
}
|
|
|
|
export interface Interface {
|
|
/** Create without replacing an existing target. */
|
|
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
|
|
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
|
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
|
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
|
/** Commit only if an existing target still has the expected bytes. */
|
|
readonly writeIfUnchanged: (
|
|
input: ConditionalWriteInput,
|
|
) => Effect.Effect<WriteResult, StaleContentError | FSUtil.Error>
|
|
readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, FSUtil.Error>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {}
|
|
|
|
/**
|
|
* Serialize file changes by canonical target. Conditional writes compare and
|
|
* write under the same process-local lock so cooperating OpenCode mutations do
|
|
* not overwrite changes made from the same stale content.
|
|
*/
|
|
const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const fs = yield* FSUtil.Service
|
|
const locks = KeyedMutex.makeUnsafe<string>()
|
|
const withTargetLock =
|
|
(target: Target) =>
|
|
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
|
|
|
|
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
|
operation: "write",
|
|
target: target.canonical,
|
|
resource: target.resource,
|
|
existed,
|
|
})
|
|
|
|
const removeResult = (target: Target, existed: boolean): RemoveResult => ({
|
|
operation: "remove",
|
|
target: target.canonical,
|
|
resource: target.resource,
|
|
existed,
|
|
})
|
|
|
|
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
|
withTargetLock(input.target)(
|
|
Effect.gen(function* () {
|
|
const existed = yield* fs.exists(input.target.canonical)
|
|
yield* fs.writeWithDirs(input.target.canonical, input.content)
|
|
return writeResult(input.target, existed)
|
|
}),
|
|
),
|
|
)
|
|
|
|
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
|
withTargetLock(input.target)(
|
|
Effect.gen(function* () {
|
|
const next = splitBom(input.content)
|
|
const current = yield* fs
|
|
.readFile(input.target.canonical)
|
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
|
yield* fs.writeWithDirs(
|
|
input.target.canonical,
|
|
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
|
|
)
|
|
return writeResult(input.target, current !== undefined)
|
|
}),
|
|
),
|
|
)
|
|
|
|
const create = Effect.fn("FileMutation.create")((input: WriteInput) =>
|
|
withTargetLock(input.target)(
|
|
Effect.gen(function* () {
|
|
const write =
|
|
typeof input.content === "string"
|
|
? fs.writeFileString(input.target.canonical, input.content, { flag: "wx" })
|
|
: fs.writeFile(input.target.canonical, input.content, { flag: "wx" })
|
|
yield* write.pipe(
|
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
|
fs.ensureDir(dirname(input.target.canonical)).pipe(Effect.andThen(write)),
|
|
),
|
|
Effect.catchReason("PlatformError", "AlreadyExists", () =>
|
|
Effect.fail(new TargetExistsError({ path: input.target.canonical })),
|
|
),
|
|
)
|
|
return writeResult(input.target, false)
|
|
}),
|
|
),
|
|
)
|
|
|
|
const writeIfUnchanged = Effect.fn("FileMutation.writeIfUnchanged")((input: ConditionalWriteInput) =>
|
|
withTargetLock(input.target)(
|
|
Effect.gen(function* () {
|
|
const current = yield* fs.readFile(input.target.canonical)
|
|
if (!sameBytes(current, input.expected)) {
|
|
return yield* new StaleContentError({ path: input.target.canonical })
|
|
}
|
|
yield* typeof input.content === "string"
|
|
? fs.writeFileString(input.target.canonical, input.content)
|
|
: fs.writeFile(input.target.canonical, input.content)
|
|
return writeResult(input.target, true)
|
|
}),
|
|
),
|
|
)
|
|
|
|
const remove = Effect.fn("FileMutation.remove")((input: RemoveInput) =>
|
|
withTargetLock(input.target)(
|
|
Effect.gen(function* () {
|
|
const existed = yield* fs.remove(input.target.canonical).pipe(
|
|
Effect.as(true),
|
|
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)),
|
|
)
|
|
return removeResult(input.target, existed)
|
|
}),
|
|
),
|
|
)
|
|
|
|
return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
|
|
}),
|
|
)
|
|
|
|
function splitBom(text: string) {
|
|
const stripped = text.replace(/^\uFEFF+/, "")
|
|
return { bom: stripped.length !== text.length, text: stripped }
|
|
}
|
|
|
|
function joinBom(text: string, bom: boolean) {
|
|
const stripped = splitBom(text).text
|
|
return bom ? `\uFEFF${stripped}` : stripped
|
|
}
|
|
|
|
function hasUtf8Bom(content: Uint8Array) {
|
|
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
|
}
|
|
|
|
function sameBytes(left: Uint8Array, right: Uint8Array) {
|
|
if (left.length !== right.length) return false
|
|
return left.every((byte, index) => byte === right[index])
|
|
}
|
|
|
|
export const locationLayer = layer
|
|
|
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
|
|
|
/**
|
|
* Deferred until the corresponding V2 integrations exist.
|
|
*/
|
|
// TODO: Add formatter integration after V2 formatter runtime exists.
|
|
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
|
|
// TODO: Add snapshots / undo after V2 snapshot design exists.
|
|
// TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists.
|
|
// TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits.
|
|
// Until then, edits are sequential and report partial application.
|
|
// TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement.
|