file-mutation.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. export * as FileMutation from "./file-mutation"
  2. import { Context, Effect, Layer, Schema } from "effect"
  3. import { dirname } from "path"
  4. import { KeyedMutex } from "./effect/keyed-mutex"
  5. import { FSUtil } from "./fs-util"
  6. export interface Target {
  7. readonly canonical: string
  8. readonly resource: string
  9. }
  10. export interface WriteInput {
  11. readonly target: Target
  12. readonly content: string | Uint8Array
  13. }
  14. export interface TextWriteInput {
  15. readonly target: Target
  16. readonly content: string
  17. }
  18. export interface ConditionalWriteInput extends WriteInput {
  19. readonly expected: Uint8Array
  20. }
  21. export interface RemoveInput {
  22. readonly target: Target
  23. }
  24. export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()("FileMutation.StaleContentError", {
  25. path: Schema.String,
  26. }) {}
  27. export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError>()("FileMutation.TargetExistsError", {
  28. path: Schema.String,
  29. }) {}
  30. export interface WriteResult {
  31. readonly operation: "write"
  32. readonly target: string
  33. readonly resource: string
  34. readonly existed: boolean
  35. }
  36. export interface RemoveResult {
  37. readonly operation: "remove"
  38. readonly target: string
  39. readonly resource: string
  40. readonly existed: boolean
  41. }
  42. export interface Interface {
  43. /** Create without replacing an existing target. */
  44. readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
  45. readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
  46. /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
  47. readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
  48. /** Commit only if an existing target still has the expected bytes. */
  49. readonly writeIfUnchanged: (
  50. input: ConditionalWriteInput,
  51. ) => Effect.Effect<WriteResult, StaleContentError | FSUtil.Error>
  52. readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, FSUtil.Error>
  53. }
  54. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {}
  55. /**
  56. * Serialize file changes by canonical target. Conditional writes compare and
  57. * write under the same process-local lock so cooperating OpenCode mutations do
  58. * not overwrite changes made from the same stale content.
  59. */
  60. export const layer = Layer.effect(
  61. Service,
  62. Effect.gen(function* () {
  63. const fs = yield* FSUtil.Service
  64. const locks = KeyedMutex.makeUnsafe<string>()
  65. const withTargetLock =
  66. (target: Target) =>
  67. <A, E, R>(effect: Effect.Effect<A, E, R>) =>
  68. locks.withLock(target.canonical)(Effect.uninterruptible(effect))
  69. const writeResult = (target: Target, existed: boolean): WriteResult => ({
  70. operation: "write",
  71. target: target.canonical,
  72. resource: target.resource,
  73. existed,
  74. })
  75. const removeResult = (target: Target, existed: boolean): RemoveResult => ({
  76. operation: "remove",
  77. target: target.canonical,
  78. resource: target.resource,
  79. existed,
  80. })
  81. const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
  82. withTargetLock(input.target)(
  83. Effect.gen(function* () {
  84. const existed = yield* fs.exists(input.target.canonical)
  85. yield* fs.writeWithDirs(input.target.canonical, input.content)
  86. return writeResult(input.target, existed)
  87. }),
  88. ),
  89. )
  90. const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
  91. withTargetLock(input.target)(
  92. Effect.gen(function* () {
  93. const next = splitBom(input.content)
  94. const current = yield* fs
  95. .readFile(input.target.canonical)
  96. .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
  97. yield* fs.writeWithDirs(
  98. input.target.canonical,
  99. joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
  100. )
  101. return writeResult(input.target, current !== undefined)
  102. }),
  103. ),
  104. )
  105. const create = Effect.fn("FileMutation.create")((input: WriteInput) =>
  106. withTargetLock(input.target)(
  107. Effect.gen(function* () {
  108. const write =
  109. typeof input.content === "string"
  110. ? fs.writeFileString(input.target.canonical, input.content, { flag: "wx" })
  111. : fs.writeFile(input.target.canonical, input.content, { flag: "wx" })
  112. yield* write.pipe(
  113. Effect.catchReason("PlatformError", "NotFound", () =>
  114. fs.ensureDir(dirname(input.target.canonical)).pipe(Effect.andThen(write)),
  115. ),
  116. Effect.catchReason("PlatformError", "AlreadyExists", () =>
  117. Effect.fail(new TargetExistsError({ path: input.target.canonical })),
  118. ),
  119. )
  120. return writeResult(input.target, false)
  121. }),
  122. ),
  123. )
  124. const writeIfUnchanged = Effect.fn("FileMutation.writeIfUnchanged")((input: ConditionalWriteInput) =>
  125. withTargetLock(input.target)(
  126. Effect.gen(function* () {
  127. const current = yield* fs.readFile(input.target.canonical)
  128. if (!sameBytes(current, input.expected)) {
  129. return yield* new StaleContentError({ path: input.target.canonical })
  130. }
  131. yield* typeof input.content === "string"
  132. ? fs.writeFileString(input.target.canonical, input.content)
  133. : fs.writeFile(input.target.canonical, input.content)
  134. return writeResult(input.target, true)
  135. }),
  136. ),
  137. )
  138. const remove = Effect.fn("FileMutation.remove")((input: RemoveInput) =>
  139. withTargetLock(input.target)(
  140. Effect.gen(function* () {
  141. const existed = yield* fs.remove(input.target.canonical).pipe(
  142. Effect.as(true),
  143. Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)),
  144. )
  145. return removeResult(input.target, existed)
  146. }),
  147. ),
  148. )
  149. return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
  150. }),
  151. )
  152. function splitBom(text: string) {
  153. const stripped = text.replace(/^\uFEFF+/, "")
  154. return { bom: stripped.length !== text.length, text: stripped }
  155. }
  156. function joinBom(text: string, bom: boolean) {
  157. const stripped = splitBom(text).text
  158. return bom ? `\uFEFF${stripped}` : stripped
  159. }
  160. function hasUtf8Bom(content: Uint8Array) {
  161. return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
  162. }
  163. function sameBytes(left: Uint8Array, right: Uint8Array) {
  164. if (left.length !== right.length) return false
  165. return left.every((byte, index) => byte === right[index])
  166. }
  167. export const locationLayer = layer
  168. /**
  169. * Deferred until the corresponding V2 integrations exist.
  170. */
  171. // TODO: Add formatter integration after V2 formatter runtime exists.
  172. // TODO: Publish watcher/file-edit events after V2 watcher integration exists.
  173. // TODO: Add snapshots / undo after V2 snapshot design exists.
  174. // TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists.
  175. // TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits.
  176. // Until then, edits are sequential and report partial application.
  177. // TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement.