file-mutation.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  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. import { LocationMutation } from "./location-mutation"
  7. export interface WriteInput {
  8. readonly plan: LocationMutation.Plan
  9. readonly content: string | Uint8Array
  10. }
  11. export interface TextWriteInput {
  12. readonly plan: LocationMutation.Plan
  13. readonly content: string
  14. }
  15. export interface ConditionalWriteInput extends WriteInput {
  16. readonly expected: Uint8Array
  17. }
  18. export interface RemoveInput {
  19. readonly plan: LocationMutation.Plan
  20. }
  21. export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()("FileMutation.StaleContentError", {
  22. path: Schema.String,
  23. }) {}
  24. export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError>()("FileMutation.TargetExistsError", {
  25. path: Schema.String,
  26. }) {}
  27. export interface WriteResult {
  28. readonly operation: "write"
  29. /** Canonical target actually passed to the filesystem mutation. */
  30. readonly target: string
  31. /** Permission resource captured during planning. */
  32. readonly resource: string
  33. readonly existed: boolean
  34. }
  35. export interface RemoveResult {
  36. readonly operation: "remove"
  37. /** Canonical target actually passed to the filesystem mutation. */
  38. readonly target: string
  39. /** Permission resource captured during planning. */
  40. readonly resource: string
  41. readonly existed: boolean
  42. }
  43. export interface Interface {
  44. /** Create only while the planned target remains absent. */
  45. readonly create: (
  46. input: WriteInput,
  47. ) => Effect.Effect<WriteResult, TargetExistsError | LocationMutation.RevalidationError | FSUtil.Error>
  48. /** Write after immediately revalidating the planned target. */
  49. readonly write: (input: WriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
  50. /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
  51. readonly writeTextPreservingBom: (
  52. input: TextWriteInput,
  53. ) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
  54. /** Commit only if an existing target still has the expected bytes. */
  55. readonly writeIfUnchanged: (
  56. input: ConditionalWriteInput,
  57. ) => Effect.Effect<WriteResult, StaleContentError | LocationMutation.RevalidationError | FSUtil.Error>
  58. /** Remove after immediately revalidating the planned target. */
  59. readonly remove: (
  60. input: RemoveInput,
  61. ) => Effect.Effect<RemoveResult, LocationMutation.RevalidationError | FSUtil.Error>
  62. }
  63. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {}
  64. /**
  65. * Commit planned file changes.
  66. *
  67. * resolve(path) -> approve -> lock target -> revalidate(plan) -> mutate
  68. *
  69. * The caller approves the plan first. This service locks the canonical target,
  70. * revalidates the plan immediately before the filesystem operation, then mutates.
  71. *
  72. * `writeIfUnchanged` compares and writes while holding the same in-memory lock,
  73. * so cooperating calls in this process cannot overwrite from the same stale
  74. * content. Locks apply only within this service layer and only to identical
  75. * canonical targets.
  76. *
  77. * Revalidation reduces the race window but is not atomic with the next
  78. * path-based filesystem operation. A hostile local process can still race it.
  79. *
  80. * TODO: Use descriptor-relative no-follow operations where supported to close
  81. * the final race.
  82. */
  83. export const layer = Layer.effect(
  84. Service,
  85. Effect.gen(function* () {
  86. const fs = yield* FSUtil.Service
  87. const mutation = yield* LocationMutation.Service
  88. const locks = KeyedMutex.makeUnsafe<string>()
  89. const withTargetLock =
  90. (target: string) =>
  91. <A, E, R>(effect: Effect.Effect<A, E, R>) =>
  92. locks.withLock(target)(Effect.uninterruptible(effect))
  93. const withValidatedTarget =
  94. (plan: LocationMutation.Plan) =>
  95. <A, E, R>(commit: (target: LocationMutation.Target) => Effect.Effect<A, E, R>) =>
  96. withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit)))
  97. const writeResult = (target: LocationMutation.Target, existed = target.exists): WriteResult => ({
  98. operation: "write",
  99. target: target.canonical,
  100. resource: target.resource,
  101. existed,
  102. })
  103. const removeResult = (target: LocationMutation.Target): RemoveResult => ({
  104. operation: "remove",
  105. target: target.canonical,
  106. resource: target.resource,
  107. existed: target.exists,
  108. })
  109. const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
  110. withValidatedTarget(input.plan)((target) =>
  111. Effect.gen(function* () {
  112. yield* fs.writeWithDirs(target.canonical, input.content)
  113. return writeResult(target)
  114. }),
  115. ),
  116. )
  117. const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
  118. withValidatedTarget(input.plan)((target) =>
  119. Effect.gen(function* () {
  120. const next = splitBom(input.content)
  121. const preserveBom = target.exists && hasUtf8Bom(yield* fs.readFile(target.canonical))
  122. yield* fs.writeWithDirs(target.canonical, joinBom(next.text, preserveBom || next.bom))
  123. return writeResult(target)
  124. }),
  125. ),
  126. )
  127. const create = Effect.fn("FileMutation.create")((input: WriteInput) =>
  128. withValidatedTarget(input.plan)((target) =>
  129. Effect.gen(function* () {
  130. if (target.exists) return yield* new TargetExistsError({ path: target.canonical })
  131. yield* fs.ensureDir(dirname(target.canonical))
  132. if (typeof input.content === "string")
  133. yield* fs.writeFileString(target.canonical, input.content, { flag: "wx" })
  134. else yield* fs.writeFile(target.canonical, input.content, { flag: "wx" })
  135. return writeResult(target, false)
  136. }),
  137. ),
  138. )
  139. const writeIfUnchanged = Effect.fn("FileMutation.writeIfUnchanged")((input: ConditionalWriteInput) =>
  140. withValidatedTarget(input.plan)((target) =>
  141. Effect.gen(function* () {
  142. const current = yield* fs.readFile(target.canonical)
  143. if (!sameBytes(current, input.expected)) return yield* new StaleContentError({ path: target.canonical })
  144. yield* fs.writeWithDirs(target.canonical, input.content)
  145. return writeResult(target)
  146. }),
  147. ),
  148. )
  149. const remove = Effect.fn("FileMutation.remove")((input: RemoveInput) =>
  150. withValidatedTarget(input.plan)((target) =>
  151. Effect.gen(function* () {
  152. yield* fs.remove(target.canonical)
  153. return removeResult(target)
  154. }),
  155. ),
  156. )
  157. return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
  158. }),
  159. )
  160. function splitBom(text: string) {
  161. const stripped = text.replace(/^\uFEFF+/, "")
  162. return { bom: stripped.length !== text.length, text: stripped }
  163. }
  164. function joinBom(text: string, bom: boolean) {
  165. const stripped = splitBom(text).text
  166. return bom ? `\uFEFF${stripped}` : stripped
  167. }
  168. function hasUtf8Bom(content: Uint8Array) {
  169. return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
  170. }
  171. function sameBytes(left: Uint8Array, right: Uint8Array) {
  172. if (left.length !== right.length) return false
  173. return left.every((byte, index) => byte === right[index])
  174. }
  175. export const locationLayer = layer
  176. /**
  177. * Deferred until the corresponding V2 integrations exist.
  178. */
  179. // TODO: Add formatter integration after V2 formatter runtime exists.
  180. // TODO: Publish watcher/file-edit events after V2 watcher integration exists.
  181. // TODO: Add snapshots / undo after V2 snapshot design exists.
  182. // TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists.
  183. // TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits.
  184. // Until then, edits are sequential and report partial application.
  185. // TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement.