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()("FileMutation.StaleContentError", { path: Schema.String, }) {} export class TargetExistsError extends Schema.TaggedErrorClass()("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 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 /** Commit only if an existing target still has the expected bytes. */ readonly writeIfUnchanged: ( input: ConditionalWriteInput, ) => Effect.Effect readonly remove: (input: RemoveInput) => Effect.Effect } export class Service extends Context.Service()("@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() const withTargetLock = (target: Target) => (effect: Effect.Effect) => 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.