index.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. /**
  2. * Host-filesystem implementation of `ctx.fs`. Realpath-derived target identity makes aliases
  3. * share stale guards, and writes through a symlink update its target without replacing the link.
  4. * @module @deepseek-ai/dsh-fs-local
  5. */
  6. import { Context } from 'cordis'
  7. import { resolve } from 'node:path'
  8. import z from 'schemastery'
  9. import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
  10. import type {
  11. FsDirEntry,
  12. FsEditOutcome,
  13. FsEditRequest,
  14. FsInfo,
  15. FsPathInfo,
  16. FsTarget,
  17. FsWriteIntent,
  18. FsWriteOutcome,
  19. } from '@deepseek-ai/dsh-fs'
  20. import {
  21. applyLiteralEdit,
  22. listDirectory,
  23. normalizeLineEndings,
  24. probe,
  25. probeNoFollow,
  26. readForEdit,
  27. readTextForDiff,
  28. readWholeText,
  29. resolveLocalTarget,
  30. restoreLineEndings,
  31. streamWholeText,
  32. writeFileAtomic,
  33. } from './fsio.ts'
  34. import type { FsIoInternals } from './fsio.ts'
  35. /** Configuration for the local filesystem backend. */
  36. export interface Config {
  37. /** Base directory for relative paths. Defaults to `process.cwd()`. */
  38. cwd?: string
  39. }
  40. type ResolvedConfig = Required<Config>
  41. /**
  42. * The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
  43. * (a resolution default, NOT a containment boundary — see the filesystem
  44. * capability-seam Agent Note); enforce
  45. * containment with a stricter backend or a `tools/execute` permission plugin.
  46. */
  47. export class LocalFileSystem extends FileSystem {
  48. static Config: z<Config> = z.object({
  49. cwd: z.string().default(process.cwd()),
  50. })
  51. /** Validated config (schemastery applied the defaults before construction). */
  52. readonly config: ResolvedConfig
  53. /** Test seam forwarded to fsio (force streaming path, pin temp names). */
  54. internals: FsIoInternals = {}
  55. /** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
  56. * window can't interleave, making concurrent writes/edits deterministically
  57. * ordered (one wins, the rest see the new version and reject as stale). */
  58. private locks = new Map<string, Promise<unknown>>()
  59. constructor(ctx: Context, config: Config) {
  60. super(ctx)
  61. this.config = config as ResolvedConfig
  62. }
  63. /** Run `op` with exclusive access to `targetKey` (FIFO per key). */
  64. private async withLock<T>(targetKey: string, op: () => Promise<T>): Promise<T> {
  65. const prior = this.locks.get(targetKey) ?? Promise.resolve()
  66. const run = prior.then(op, op)
  67. // Keep the chain alive but swallow this op's result/throw for the *next* waiter.
  68. const tail = run.then(() => undefined, () => undefined)
  69. this.locks.set(targetKey, tail)
  70. try {
  71. return await run
  72. } finally {
  73. if (this.locks.get(targetKey) === tail) {
  74. this.locks.delete(targetKey)
  75. }
  76. }
  77. }
  78. override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
  79. if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
  80. const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
  81. if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
  82. return { targetKey: local.targetKey, displayPath: local.displayPath }
  83. }
  84. override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
  85. if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
  86. const info = await probe(target.targetKey)
  87. if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
  88. if (!info) return undefined
  89. return { version: info.version, type: info.type, size: info.size }
  90. }
  91. override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
  92. if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
  93. if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
  94. const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path))
  95. if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
  96. if (!info) return undefined
  97. return { version: info.version, type: info.type, size: info.size }
  98. }
  99. override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
  100. return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
  101. }
  102. override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
  103. return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
  104. }
  105. override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
  106. const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
  107. return entries.map(entry => ({
  108. name: entry.name,
  109. type: entry.type,
  110. target: { targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
  111. ...(entry.version !== undefined ? { version: entry.version } : {}),
  112. ...(entry.size !== undefined ? { size: entry.size } : {}),
  113. }))
  114. }
  115. override async writeText(
  116. target: FsTarget,
  117. content: string,
  118. expected?: FsWriteIntent,
  119. signal?: AbortSignal,
  120. ): Promise<FsWriteOutcome> {
  121. return this.withLock(target.targetKey, async () => {
  122. const existing = await probe(target.targetKey)
  123. if (existing && existing.type !== 'file') {
  124. throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
  125. }
  126. if (expected?.kind === 'replaceIfVersion') {
  127. // Stale guard: the file must still exist at the version the owner observed.
  128. if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')
  129. if (existing.version !== expected.version) {
  130. throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
  131. }
  132. } else if (expected?.kind === 'createIfAbsent' && existing) {
  133. // createIfAbsent onto an existing file: a blind overwrite — require a read first.
  134. throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
  135. }
  136. // No expectation means an unconditional but still atomic write.
  137. // Preserve prior text for contextual diffs; null falls back to a whole-file diff.
  138. // TODO(overwrite-diff-bound): cap this UI-only pre-read for large files.
  139. const before = existing ? await readTextForDiff(target.targetKey, signal) : null
  140. await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals)
  141. const after = await probe(target.targetKey)
  142. return {
  143. operation: existing ? 'update' : 'create',
  144. version: this.versionAfterWrite(after, target),
  145. before,
  146. // LF-normalized to share the diff basis with `before` (also LF): a CRLF
  147. // overwrite must not read as every line changed. Line-ending restoration
  148. // is a storage detail the applied-hunk diff ignores.
  149. after: normalizeLineEndings(content),
  150. }
  151. })
  152. }
  153. override async editText(
  154. target: FsTarget,
  155. edit: FsEditRequest,
  156. expected?: { version: FsVersion },
  157. signal?: AbortSignal,
  158. ): Promise<FsEditOutcome> {
  159. return this.withLock(target.targetKey, async () => {
  160. const existing = await probe(target.targetKey)
  161. // Stale guard before literal matching: an edit based on an old read reports
  162. // FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content.
  163. // Missing targets use the same stale code on guarded and unconditional edit paths.
  164. if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
  165. if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
  166. // expected === undefined: unconditional edit of the current content — no
  167. // version guard. Still inside the per-target lock, so the read→match→write
  168. // window is serialized and atomic.
  169. if (expected && existing.version !== expected.version) {
  170. throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
  171. }
  172. const original = await readForEdit(target.targetKey, target.displayPath, signal)
  173. const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath)
  174. const content = restoreLineEndings(edited.content, original.lineEndings)
  175. await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals)
  176. const after = await probe(target.targetKey)
  177. return {
  178. version: this.versionAfterWrite(after, target),
  179. // The LF-normalized before/after text (the applied-hunk diff basis);
  180. // line-ending restoration is a storage detail the diff ignores.
  181. before: original.content,
  182. after: edited.content,
  183. }
  184. })
  185. }
  186. /* v8 ignore next 5 -- the post-write probe finding the file absent requires a
  187. * concurrent unlink between rename and stat; fall back to a sentinel version. */
  188. private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion {
  189. if (after) return after.version
  190. return FsVersion(`missing:${target.targetKey}`)
  191. }
  192. }
  193. export default LocalFileSystem