| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204 |
- export * as FileMutation from "./file-mutation"
- 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.
- */
- export 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
- /**
- * 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.
|