diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 02ce9fc46c..1a4ca47419 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md -README.md: 6584fade3517637726f9bae4e7ca692ffffcca01 -README.zh.md: cc1ab560a071a92b624a1580fa82902a2da08d99 +README.md: 9c005443eb064a03b3050c9ae82295c794315239 +README.zh.md: 3adc249afe68b4cd8b56c68101baafec47ffb981 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 6584fade35..9c005443eb 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -54,7 +54,7 @@ Read any regular UTF-8 text file whole or as a stream, read raw bytes up to a ca Read, listing, and mutation failures are typed `FsError`s with stable codes — `FS_NOT_FOUND`, `FS_NOT_TEXT` (binary content), `FS_STALE_VERSION` (changed since observation), `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` (no unique literal match), and others — so callers branch on the code, never on message text. A missing target on an edit reports `FS_STALE_VERSION` whether or not the version guard is supplied. -Chokidar observes one file or a directory's direct entries through OS events, without polling or recursive watching. File watches cover in-place writes, atomic replacement, deletion, and same-path recreation while the parent directory remains. +Chokidar observes one file or a directory's direct entries through OS events, without polling or recursive watching. Files use a filtered parent-directory watch, so readiness also covers creation of an initially missing file. File watches cover in-place writes, atomic replacement, deletion, and same-path recreation while the parent directory remains. ----- @@ -129,7 +129,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. These limits define when the local backend is a poor fit or needs special operational care. They are current package constraints, not a general filesystem comparison or a task backlog. - **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. -- **Initially missing targets** — Chokidar can report readiness before the parent-directory watch is attached; creation immediately after readiness can be missed. - **Linux parent-directory recreation** — restoring observation after a parent directory is deleted and recreated is deferred; same-path file recreation while its parent remains is supported. - **Version tokens depend on filesystem metadata** — they combine device, inode, size, nanosecond mtime, and nanosecond ctime; a storage layer that cannot update any of those facts for a rewrite can still defeat the stale guard. - **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index cc1ab560a0..3adc249afe 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" 读取、列出与变更操作的失败是携带稳定错误码的类型化 `FsError`——`FS_NOT_FOUND`、`FS_NOT_TEXT`(二进制内容)、`FS_STALE_VERSION`(自观察以来已变化)、`FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`(无唯一字面量匹配)等——因此调用方依据错误码分支,绝不解析消息文本。编辑遇到缺失目标时,无论是否提供版本防护,都报告 `FS_STALE_VERSION`。 -Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用轮询或递归监听。父目录保持存在时,文件监听覆盖原地写入、原子替换、删除和同路径重建。 +Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用轮询或递归监听。文件使用筛选到目标的父目录监听,因此就绪时也能接收起初缺失文件的创建事件。父目录保持存在时,文件监听覆盖原地写入、原子替换、删除和同路径重建。 ----- @@ -129,7 +129,6 @@ Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用 这些限制说明本地后端何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用文件系统对比或任务积压。 - **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束。 -- **初始缺失目标**:Chokidar 可能在父目录监听建立前报告就绪;紧随就绪通知的创建可能漏报。 - **Linux 父目录重建**:父目录删除并重建后的监听恢复仍延期;父目录保持存在时,同路径文件重建仍受支持。 - **版本 token 依赖文件系统元数据**:它们组合设备、inode、大小、纳秒级 mtime 与纳秒级 ctime;如果存储层在重写时无法更新其中任何一项事实,仍可能绕过陈旧防护。 - **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。 diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 8ae48790a7..17033e512b 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import { constants as bufferConstants } from 'node:buffer' import { once } from 'node:events' import { watch } from 'chokidar' -import { isAbsolute, relative, resolve, sep } from 'node:path' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' import z from '@deepseek-ai/schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' @@ -68,8 +68,17 @@ const MAX_DIFF_BASIS_BYTES = Math.min( export class LocalFileSystem extends FileSystem { override async watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> { signal.throwIfAborted() - const watcher = watch(this.processPath(target), { ignoreInitial: true, depth: 0 }) - watcher.on('all', () => { changed() }) + const path = resolve(this.processPath(target)) + const directory = (await this.stat(target, signal))?.type === 'directory' + signal.throwIfAborted() + const root = directory ? path : dirname(path) + const watcher = watch(root, { + ignoreInitial: true, depth: 0, + ignored: entry => !directory && resolve(entry) !== root && resolve(entry) !== path, + }) + watcher.on('all', (_event, entry) => { + if (directory || resolve(entry) === path) changed() + }) watcher.on('error', (error) => { changed(error instanceof Error ? error : new Error(String(error))) }) try { await once(watcher, 'ready', { signal }) diff --git a/packages/fs/fs-local/tests/watch.spec.ts b/packages/fs/fs-local/tests/watch.spec.ts index 4217d16c83..dae8a3daa4 100644 --- a/packages/fs/fs-local/tests/watch.spec.ts +++ b/packages/fs/fs-local/tests/watch.spec.ts @@ -1,6 +1,7 @@ /** Watch initialization and asynchronous release at the Chokidar adapter. */ import { Context } from '@deepseek-ai/cordis' -import { FsTargetKey } from '@deepseek-ai/dsh-fs' +import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import { dirname, join, resolve } from 'node:path' import * as chokidar from 'chokidar' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { LocalFileSystem } from '../src/index.ts' @@ -12,32 +13,48 @@ vi.mock('chokidar', async (importOriginal) => { afterEach(() => { vi.restoreAllMocks() }) -async function setup() { +async function setup(kind: 'file' | 'directory' | 'missing' = 'file') { const watcher = new chokidar.FSWatcher() onTestFinished(() => watcher.close()) - const watch = vi.mocked(chokidar.watch).mockReset().mockReturnValue(watcher) + const started = Promise.withResolvers() + const watch = vi.mocked(chokidar.watch).mockReset().mockImplementation(() => { + started.resolve() + return watcher + }) const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(LocalFileSystem) - const target = { targetKey: FsTargetKey('/workspace/file.txt'), displayPath: 'file.txt' } + const path = resolve('/workspace/file.txt') + const target = { targetKey: FsTargetKey(path), displayPath: 'file.txt' } + const stat = vi.spyOn(ctx.fs, 'stat').mockResolvedValue(kind === 'missing' + ? undefined : { type: kind, version: FsVersion('v1'), size: 1 }) const changed = vi.fn<(error?: Error) => void>() const controller = new AbortController() onTestFinished(() => { controller.abort() }) - return { watcher, watch, fs: ctx.fs, target, changed, controller } + return { watcher, watch, fs: ctx.fs, target, changed, controller, path, stat, started: started.promise } } describe('local filesystem watch', () => { - it('waits for ready, forwards invalidation, and awaits watcher closure', async () => { - const h = await setup() + it.each(['file', 'missing'] as const)('waits for the parent watch of a %s target and awaits closure', async (kind) => { + const h = await setup(kind) const pending = h.fs.watch(h.target, h.changed, h.controller.signal) - expect(h.watch).toHaveBeenCalledExactlyOnceWith('/workspace/file.txt', { ignoreInitial: true, depth: 0 }) + await h.started + expect(h.watch).toHaveBeenCalledExactlyOnceWith(dirname(h.path), { ignoreInitial: true, depth: 0, ignored: expect.any(Function) }) + const ignored = h.watch.mock.calls[0]![1]!.ignored + if (typeof ignored !== 'function') throw new Error('Expected a target filter') + expect(ignored(dirname(h.path))).toBe(false) + expect(ignored(h.path)).toBe(false) + expect(ignored(join(dirname(h.path), 'unrelated.txt'))).toBe(true) let ready = false void pending.then(() => { ready = true }) await Promise.resolve(undefined) expect(ready).toBe(false) h.watcher.emit('ready') const close = await pending - h.watcher.emit('all', 'change', '/workspace/file.txt') + h.watcher.emit('all', 'addDir', dirname(h.path)) + h.watcher.emit('all', 'change', join(dirname(h.path), 'unrelated.txt')) + expect(h.changed).not.toHaveBeenCalled() + h.watcher.emit('all', 'change', h.path) expect(h.changed).toHaveBeenCalledExactlyOnceWith() const closed = Promise.withResolvers() onTestFinished(() => { closed.resolve(undefined) }) @@ -53,6 +70,33 @@ describe('local filesystem watch', () => { expect(h.watcher.closed).toBe(true) }) + it('watches a directory itself without filtering its direct entries', async () => { + const h = await setup('directory') + const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + await h.started + expect(h.watch.mock.calls[0]![0]).toBe(h.path) + const ignored = h.watch.mock.calls[0]![1]!.ignored + if (typeof ignored !== 'function') throw new Error('Expected a target filter') + expect(ignored(join(h.path, 'child.txt'))).toBe(false) + h.watcher.emit('ready') + const close = await pending + h.watcher.emit('all', 'change', join(h.path, 'child.txt')) + expect(h.changed).toHaveBeenCalledExactlyOnceWith() + await close() + }) + + it('does not acquire a watcher when cancelled during metadata lookup', async () => { + const h = await setup() + const metadata = Promise.withResolvers() + h.stat.mockReturnValueOnce(metadata.promise) + const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + const rejected = expect(pending).rejects.toThrow() + h.controller.abort() + metadata.resolve(undefined) + await rejected + expect(h.watch).not.toHaveBeenCalled() + }) + it('rejects an already-cancelled initialization without acquiring a watcher', async () => { const h = await setup() h.controller.abort() @@ -64,6 +108,7 @@ describe('local filesystem watch', () => { const h = await setup() const pending = h.fs.watch(h.target, h.changed, h.controller.signal) const rejected = expect(pending).rejects.toThrow() + await h.started h.controller.abort() await rejected expect(h.watcher.closed).toBe(true) @@ -75,6 +120,7 @@ describe('local filesystem watch', () => { const error = new Error('watch failed') const pending = h.fs.watch(h.target, h.changed, h.controller.signal) const rejected = expect(pending).rejects.toBe(error) + await h.started h.watcher.emit('error', error) await rejected expect(h.changed).toHaveBeenCalledExactlyOnceWith(error) @@ -84,6 +130,7 @@ describe('local filesystem watch', () => { it('normalizes watcher errors after initialization', async () => { const h = await setup() const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + await h.started h.watcher.emit('ready') const close = await pending const error = new Error('watch failed')