| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- /**
- * Host-filesystem implementation of `ctx.fs`. Realpath-derived target identity makes aliases
- * share stale guards, and writes through a symlink update its target without replacing the link.
- * @module @deepseek-ai/dsh-fs-local
- */
- import { Context } from 'cordis'
- import { resolve } from 'node:path'
- import z from 'schemastery'
- import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
- import type {
- FsDirEntry,
- FsEditOutcome,
- FsEditRequest,
- FsInfo,
- FsPathInfo,
- FsTarget,
- FsWriteIntent,
- FsWriteOutcome,
- } from '@deepseek-ai/dsh-fs'
- import {
- applyLiteralEdit,
- listDirectory,
- normalizeLineEndings,
- probe,
- probeNoFollow,
- readForEdit,
- readTextForDiff,
- readWholeText,
- resolveLocalTarget,
- restoreLineEndings,
- streamWholeText,
- writeFileAtomic,
- } from './fsio.ts'
- import type { FsIoInternals } from './fsio.ts'
- /** Configuration for the local filesystem backend. */
- export interface Config {
- /** Base directory for relative paths. Defaults to `process.cwd()`. */
- cwd?: string
- }
- type ResolvedConfig = Required<Config>
- /**
- * The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
- * (a resolution default, NOT a containment boundary — see the filesystem
- * capability-seam Agent Note); enforce
- * containment with a stricter backend or a `tools/execute` permission plugin.
- */
- export class LocalFileSystem extends FileSystem {
- static Config: z<Config> = z.object({
- cwd: z.string().default(process.cwd()),
- })
- /** Validated config (schemastery applied the defaults before construction). */
- readonly config: ResolvedConfig
- /** Test seam forwarded to fsio (force streaming path, pin temp names). */
- internals: FsIoInternals = {}
- /** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
- * window can't interleave, making concurrent writes/edits deterministically
- * ordered (one wins, the rest see the new version and reject as stale). */
- private locks = new Map<string, Promise<unknown>>()
- constructor(ctx: Context, config: Config) {
- super(ctx)
- this.config = config as ResolvedConfig
- }
- /** Run `op` with exclusive access to `targetKey` (FIFO per key). */
- private async withLock<T>(targetKey: string, op: () => Promise<T>): Promise<T> {
- const prior = this.locks.get(targetKey) ?? Promise.resolve()
- const run = prior.then(op, op)
- // Keep the chain alive but swallow this op's result/throw for the *next* waiter.
- const tail = run.then(() => undefined, () => undefined)
- this.locks.set(targetKey, tail)
- try {
- return await run
- } finally {
- if (this.locks.get(targetKey) === tail) {
- this.locks.delete(targetKey)
- }
- }
- }
- override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
- if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
- const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
- if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
- return { targetKey: local.targetKey, displayPath: local.displayPath }
- }
- override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
- if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
- const info = await probe(target.targetKey)
- if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
- if (!info) return undefined
- return { version: info.version, type: info.type, size: info.size }
- }
- override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
- if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
- if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
- const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path))
- if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
- if (!info) return undefined
- return { version: info.version, type: info.type, size: info.size }
- }
- override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
- return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
- }
- override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
- return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
- }
- override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
- const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
- return entries.map(entry => ({
- name: entry.name,
- type: entry.type,
- target: { targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
- ...(entry.version !== undefined ? { version: entry.version } : {}),
- ...(entry.size !== undefined ? { size: entry.size } : {}),
- }))
- }
- override async writeText(
- target: FsTarget,
- content: string,
- expected?: FsWriteIntent,
- signal?: AbortSignal,
- ): Promise<FsWriteOutcome> {
- return this.withLock(target.targetKey, async () => {
- const existing = await probe(target.targetKey)
- if (existing && existing.type !== 'file') {
- throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
- }
- if (expected?.kind === 'replaceIfVersion') {
- // Stale guard: the file must still exist at the version the owner observed.
- if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')
- if (existing.version !== expected.version) {
- throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
- }
- } else if (expected?.kind === 'createIfAbsent' && existing) {
- // createIfAbsent onto an existing file: a blind overwrite — require a read first.
- throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
- }
- // No expectation means an unconditional but still atomic write.
- // Preserve prior text for contextual diffs; null falls back to a whole-file diff.
- // TODO(overwrite-diff-bound): cap this UI-only pre-read for large files.
- const before = existing ? await readTextForDiff(target.targetKey, signal) : null
- await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals)
- const after = await probe(target.targetKey)
- return {
- operation: existing ? 'update' : 'create',
- version: this.versionAfterWrite(after, target),
- before,
- // LF-normalized to share the diff basis with `before` (also LF): a CRLF
- // overwrite must not read as every line changed. Line-ending restoration
- // is a storage detail the applied-hunk diff ignores.
- after: normalizeLineEndings(content),
- }
- })
- }
- override async editText(
- target: FsTarget,
- edit: FsEditRequest,
- expected?: { version: FsVersion },
- signal?: AbortSignal,
- ): Promise<FsEditOutcome> {
- return this.withLock(target.targetKey, async () => {
- const existing = await probe(target.targetKey)
- // Stale guard before literal matching: an edit based on an old read reports
- // FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content.
- // Missing targets use the same stale code on guarded and unconditional edit paths.
- if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
- if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
- // expected === undefined: unconditional edit of the current content — no
- // version guard. Still inside the per-target lock, so the read→match→write
- // window is serialized and atomic.
- if (expected && existing.version !== expected.version) {
- throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
- }
- const original = await readForEdit(target.targetKey, target.displayPath, signal)
- const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath)
- const content = restoreLineEndings(edited.content, original.lineEndings)
- await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals)
- const after = await probe(target.targetKey)
- return {
- version: this.versionAfterWrite(after, target),
- // The LF-normalized before/after text (the applied-hunk diff basis);
- // line-ending restoration is a storage detail the diff ignores.
- before: original.content,
- after: edited.content,
- }
- })
- }
- /* v8 ignore next 5 -- the post-write probe finding the file absent requires a
- * concurrent unlink between rename and stat; fall back to a sentinel version. */
- private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion {
- if (after) return after.version
- return FsVersion(`missing:${target.targetKey}`)
- }
- }
- export default LocalFileSystem
|