fix(fs): observe absence before guarded recreation
This commit is contained in:
@@ -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/e2b/fs-e2b/README.md
|
||||
README.md: b346b4c5bc7bc2888ccd3dc210bb90e0f1cc5965
|
||||
README.zh.md: b84b2382c03dc6e422228b19a58aff927e8a5db7
|
||||
README.md: 039ad72a9651a1e8907c2c63b8c83d113aedbb4e
|
||||
README.zh.md: be97bbd92edb528f9560518398a0cb70f36b992b
|
||||
|
||||
@@ -9,8 +9,8 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide
|
||||
- **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute.
|
||||
- **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules.
|
||||
- **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing.
|
||||
- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, preserve an existing file's POSIX mode, and publish the staged file through E2B's same-filesystem atomic rename. The rename response supplies the committed version, so no fallible metadata request follows the commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. Optional create/version guards keep the filesystem seam's observed-state semantics.
|
||||
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before rename. The signal is not forwarded into the rename RPC, so cancellation cannot interrupt the atomic commit; a successful rename is the commit point.
|
||||
- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, and preserve an existing file's POSIX mode. Replacements publish through E2B's same-filesystem atomic rename. A guarded `createIfAbsent` publishes with remote `ln` instead, making the commit atomically no-replace; metadata read from the staged file before that commit is projected to the target path for the returned version, so no fallible metadata request follows either commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process.
|
||||
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before publication. The signal is not forwarded into the rename or guarded-link commit, so cancellation cannot interrupt atomic publication or turn a committed write into a reported failure.
|
||||
|
||||
The provider does not copy, mount, or reconcile the host workspace. Giving it a host path as `cwd` creates a remote directory with the same spelling only.
|
||||
|
||||
@@ -25,7 +25,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No host synchronization** — an empty E2B cwd stays empty until a tool, command, or external process populates it; local files are neither uploaded nor reflected back.
|
||||
- **Mutation coordination is host-process-local** — another harness connection or remote command can race the adapter; version guards detect only metadata changes represented by E2B.
|
||||
- **Mutation coordination is host-process-local** — `createIfAbsent` preserves a remote creator racing publication, but another harness connection or command can still race replacement; version guards detect only metadata changes represented by E2B.
|
||||
- **Reads reopen canonical targets by path** — a concurrent remote path replacement between resolution and stream opening is not fenced by a stable file handle; no observed product defect justifies a provider-specific bounded-read protocol in this POC.
|
||||
- **Whole-file mutation costs remain** — overwrite diffs and literal edits read complete files into host memory, and every operation incurs E2B controller latency.
|
||||
- **The POC targets E2B's default Linux image** — it relies on GNU `realpath`/`base64`/`chmod`, same-filesystem rename, streaming reads, and metadata extended attributes; custom templates are outside this POC.
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;GNU `realpath -mz` 提供规范化目标身份,且不要求最终文件存在;ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam;目录列表会复用已返回的元数据,并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。
|
||||
- **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI,以及由提供方负责的包含关系检查,因此通用进程管理消费方无需解析 E2B 目标 ID,也不会套用宿主路径规则。
|
||||
- **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。
|
||||
- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,保留现有文件的 POSIX mode,并通过 E2B 的同一文件系统原子重命名发布暂存文件。重命名响应会提供已提交的版本,因此提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。可选的创建/版本防护会保留文件系统 seam 的已观察状态语义。
|
||||
- **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在 rename 前立即检查。信号不会传入 rename RPC,因此取消无法中断原子提交;成功 rename 是提交点。
|
||||
- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,并保留现有文件的 POSIX mode。替换操作通过 E2B 的同一文件系统原子重命名发布。带防护的 `createIfAbsent` 改用远程 `ln` 发布,使提交具备原子且不替换的语义;系统会把提交前从暂存文件读取的元数据投影到目标路径,以生成返回的版本,因此任何一类提交点之后都不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。
|
||||
- **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在发布前立即检查。信号不会传入 rename 或防护链接提交,因此取消无法中断原子发布,也不会把已提交的写入报告为失败。
|
||||
|
||||
该提供方不会复制、挂载或协调宿主工作区。把宿主路径用作 `cwd`,只会在远程创建一个拼写相同的目录。
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
## 已知限制与延后工作
|
||||
|
||||
- **不提供宿主同步**:空的 E2B cwd 会一直为空,直到工具、命令或外部进程填充它;本地文件既不会上传,也不会同步回本地。
|
||||
- **变更协调仅限宿主进程内**:另一个 harness 连接或远程命令可能与适配器发生竞态;版本防护只能检测 E2B 元数据所体现的变更。
|
||||
- **变更协调仅限宿主进程内**:`createIfAbsent` 会保留与发布发生竞态的远程创建者所写入的文件,但另一个 harness 连接或命令仍可能与替换操作发生竞态;版本防护只能检测 E2B 元数据所体现的变更。
|
||||
- **读取会按路径重新打开规范化目标**:在解析与打开流之间若并发替换远程路径,该操作没有稳定文件句柄提供围栏;在该 POC 中,没有已观察到的产品缺陷能够证明提供方专用的有界读取协议值得引入。
|
||||
- **仍需承担完整文件变更成本**:覆盖差异和字面量编辑会把完整文件读入宿主内存,每项操作也都会产生 E2B 控制器延迟。
|
||||
- **该 POC 面向 E2B 默认 Linux 镜像**:它依赖 GNU `realpath`/`base64`/`chmod`、同一文件系统内的 rename、流式读取和元数据扩展属性;自定义模板不在该 POC 范围内。
|
||||
|
||||
@@ -332,7 +332,13 @@ export class E2BFileSystem extends FileSystem {
|
||||
}
|
||||
this.checkWriteIntent(existing, expected, target)
|
||||
const before = existing === undefined ? null : await this.readForDiff(target, signal)
|
||||
const version = await this.writeAtomic(target, content, existing, signal)
|
||||
const version = await this.writeAtomic(
|
||||
target,
|
||||
content,
|
||||
existing,
|
||||
expected?.kind === 'createIfAbsent',
|
||||
signal,
|
||||
)
|
||||
return {
|
||||
operation: existing === undefined ? 'create' : 'update',
|
||||
version,
|
||||
@@ -363,7 +369,7 @@ export class E2BFileSystem extends FileSystem {
|
||||
const before = normalizeLineEndings(raw)
|
||||
const after = literalEdit(before, edit, target.displayPath)
|
||||
const storage = restoreLineEndings(after, detectsCrlf(raw))
|
||||
const version = await this.writeAtomic(target, storage, existing, signal)
|
||||
const version = await this.writeAtomic(target, storage, existing, false, signal)
|
||||
return { version, before, after }
|
||||
})
|
||||
}
|
||||
@@ -450,6 +456,7 @@ export class E2BFileSystem extends FileSystem {
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
existing: EntryInfo | undefined,
|
||||
createIfAbsent: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReturnType<typeof FsVersion>> {
|
||||
assertNotAborted(signal, 'write')
|
||||
@@ -476,7 +483,28 @@ export class E2BFileSystem extends FileSystem {
|
||||
commandOpts(signal),
|
||||
)
|
||||
assertNotAborted(signal, 'write')
|
||||
const committed = await sandbox.files.rename(temporary, targetPath)
|
||||
let committed: EntryInfo
|
||||
if (createIfAbsent) {
|
||||
const staged = await sandbox.files.getInfo(temporary, signalOpts(signal))
|
||||
assertNotAborted(signal, 'write')
|
||||
const targetArg = quoteE2BShellArg(targetPath)
|
||||
const publication = await sandbox.commands.run(
|
||||
`if ln -- ${quoteE2BShellArg(temporary)} ${targetArg}; then printf created; elif test -e ${targetArg} || test -L ${targetArg}; then printf exists; else exit 1; fi`,
|
||||
commandOpts(undefined),
|
||||
)
|
||||
if (publication.stdout === 'exists') {
|
||||
throw new FsError(
|
||||
`cannot overwrite existing "${target.displayPath}" without reading it first`,
|
||||
'FS_NOT_OBSERVED',
|
||||
)
|
||||
}
|
||||
if (publication.stdout !== 'created') {
|
||||
throw new Error('guarded create returned an invalid publication result')
|
||||
}
|
||||
committed = { ...staged, name: posix.basename(targetPath), path: targetPath }
|
||||
} else {
|
||||
committed = await sandbox.files.rename(temporary, targetPath)
|
||||
}
|
||||
try {
|
||||
await sandbox.files.remove(stagingDirectory)
|
||||
} catch (_committedStagingCleanupFailure) {
|
||||
|
||||
@@ -37,6 +37,7 @@ class FakeRemote {
|
||||
readonly writes: Array<{ path: string; data: string; metadata?: Record<string, string> }> = []
|
||||
readonly writeParentModes: number[] = []
|
||||
readonly renames: Array<{ from: string; to: string }> = []
|
||||
readonly links: Array<{ from: string; to: string }> = []
|
||||
readonly removals: string[] = []
|
||||
readonly commands: string[] = []
|
||||
streamChunks: Uint8Array[] | undefined
|
||||
@@ -51,6 +52,8 @@ class FakeRemote {
|
||||
nextRemoveError: unknown
|
||||
canonicalOutput: string | undefined
|
||||
abortAfterRename: AbortController | undefined
|
||||
competitorBeforeLink: { path: string; data: string } | undefined
|
||||
guardedLinkOutput: string | undefined
|
||||
disappearOnInfo = new Set<string>()
|
||||
private clock = 1
|
||||
|
||||
@@ -253,6 +256,28 @@ class FakeRemote {
|
||||
}
|
||||
const chmod = /^chmod ([0-7]+) -- '([^']+)'$/.exec(command)
|
||||
if (chmod !== null) this.required(chmod[2]!).mode = Number.parseInt(chmod[1]!, 8)
|
||||
const guardedLink = new RegExp(
|
||||
"^if ln -- '([^']+)' '([^']+)'; then printf created; "
|
||||
+ "elif test -e '[^']+' \\|\\| test -L '[^']+'; then printf exists; else exit 1; fi$",
|
||||
).exec(command)
|
||||
if (guardedLink !== null) {
|
||||
const from = guardedLink[1]!
|
||||
const to = guardedLink[2]!
|
||||
if (this.guardedLinkOutput !== undefined) {
|
||||
const stdout = this.guardedLinkOutput
|
||||
this.guardedLinkOutput = undefined
|
||||
return { exitCode: 0, stdout, stderr: '' }
|
||||
}
|
||||
if (this.competitorBeforeLink?.path === to) {
|
||||
this.file(to, this.competitorBeforeLink.data)
|
||||
this.competitorBeforeLink = undefined
|
||||
}
|
||||
if (this.nodes.has(to)) return { exitCode: 0, stdout: 'exists', stderr: '' }
|
||||
this.nodes.set(to, this.required(from))
|
||||
this.links.push({ from, to })
|
||||
this.abortAfterRename?.abort('after commit')
|
||||
return { exitCode: 0, stdout: 'created', stderr: '' }
|
||||
}
|
||||
const move = /^mv -f -- '([^']+)' '([^']+)'$/.exec(command)
|
||||
if (move !== null) {
|
||||
if (this.nextRenameError !== undefined) {
|
||||
@@ -475,6 +500,7 @@ describe('E2BFileSystem atomic writes and edits', () => {
|
||||
expect(remote.nodes.get('/workspace/new.txt')?.mode).toBe(0o600)
|
||||
expect(remote.nodes.get('/workspace/new.txt')?.metadata?.['dsh-version']).toBeDefined()
|
||||
expect(remote.writeParentModes).toEqual([0o700])
|
||||
expect(remote.links).toHaveLength(1)
|
||||
const stagingDirectory = posix.dirname(remote.writes[0]!.path)
|
||||
expect(posix.dirname(stagingDirectory)).toBe('/workspace')
|
||||
expect(remote.removals).toContain(stagingDirectory)
|
||||
@@ -527,6 +553,33 @@ describe('E2BFileSystem atomic writes and edits', () => {
|
||||
await expectCode(fs.writeText(await fs.resolve('dir'), 'x'), 'FS_NOT_REGULAR_FILE')
|
||||
})
|
||||
|
||||
it('preserves a competitor created after the guarded-create probe', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.competitorBeforeLink = { path: '/workspace/race.txt', data: 'competitor' }
|
||||
const { fs } = await setup(remote)
|
||||
|
||||
await expectCode(
|
||||
fs.writeText(await fs.resolve('race.txt'), 'ours', { kind: 'createIfAbsent' }),
|
||||
'FS_NOT_OBSERVED',
|
||||
)
|
||||
expect(new TextDecoder().decode(remote.nodes.get('/workspace/race.txt')?.data)).toBe('competitor')
|
||||
expect(remote.links).toHaveLength(0)
|
||||
expect(remote.removals).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects an invalid guarded-create publication response before claiming success', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.guardedLinkOutput = 'unexpected'
|
||||
const { fs } = await setup(remote)
|
||||
|
||||
await expectCode(
|
||||
fs.writeText(await fs.resolve('invalid.txt'), 'ours', { kind: 'createIfAbsent' }),
|
||||
'FS_IO_ERROR',
|
||||
)
|
||||
expect(remote.nodes.has('/workspace/invalid.txt')).toBe(false)
|
||||
expect(remote.removals).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not turn an abort observed after a successful move into a failed write', async () => {
|
||||
const remote = new FakeRemote()
|
||||
const controller = new AbortController()
|
||||
@@ -537,6 +590,20 @@ describe('E2BFileSystem atomic writes and edits', () => {
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('does not turn an abort observed after a guarded create into a failed write', async () => {
|
||||
const remote = new FakeRemote()
|
||||
const controller = new AbortController()
|
||||
remote.abortAfterRename = controller
|
||||
const { fs } = await setup(remote)
|
||||
await expect(fs.writeText(
|
||||
await fs.resolve('committed-create'),
|
||||
'yes',
|
||||
{ kind: 'createIfAbsent' },
|
||||
controller.signal,
|
||||
)).resolves.toMatchObject({ operation: 'create' })
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('does not turn post-commit staging cleanup failure into a failed write', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.nextRemoveError = new Error('empty staging cleanup failed')
|
||||
|
||||
@@ -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: ec0cffea1c82ab3bb2485fcb22ac5339d5d2a0d9
|
||||
README.zh.md: 99adba8c8b58f1f0b6f0f5ab8b672eb074b38f31
|
||||
README.md: ac2f271ad9651797e1aceeba49e7a52533405f10
|
||||
README.zh.md: 633d55fe06b2261d9d5b52184987811c87c8f155
|
||||
|
||||
@@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing.
|
||||
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
|
||||
|
||||
The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
|
||||
@@ -39,4 +39,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
- **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.
|
||||
- **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits.
|
||||
- **The per-target mutation lock is in-process only** — a writer in another process is caught only by the optional version guard, never serialized.
|
||||
- **The per-target mutation lock is in-process only** — guarded create still uses an atomic no-replace publication across processes, but replacement writers in another process are caught only when the optional version guard observes their metadata change; they are never serialized.
|
||||
|
||||
@@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。
|
||||
- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。
|
||||
- **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。
|
||||
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED`);`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。
|
||||
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。
|
||||
- **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。
|
||||
|
||||
包根 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。
|
||||
@@ -39,4 +39,4 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **版本 token 依赖文件系统元数据**:它们组合设备、inode、大小、纳秒级 mtime 和纳秒级 ctime;如果存储层在重写时无法更新其中任何一项事实,仍可能绕过陈旧防护。
|
||||
- **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。
|
||||
- **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。
|
||||
- **每目标变更锁仅限进程内**:其他进程中的写入方只会被可选版本防护发现,绝不会被串行化。
|
||||
- **每目标变更锁仅限进程内**:即使跨进程,带防护的创建仍采用原子且不替换的发布方式;但只有当可选版本防护观察到元数据变化时,系统才能发现其他进程中的替换写入方,且绝不会将其串行化。
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Cordis-free local filesystem mechanics. This provider layer returns validated UTF-8 text,
|
||||
* streams large files, and rejects binary data; line windows belong to `dsh-tool-fs`. Writes
|
||||
* stage an exclusive owner-only file in a private sibling directory and atomically rename it.
|
||||
* stage an exclusive owner-only file in a private sibling directory and atomically publish it.
|
||||
* @module @deepseek-ai/dsh-fs-local/fsio
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import { chmod, link, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { BigIntStats, Dirent, Stats } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { TextDecoder } from 'node:util'
|
||||
@@ -20,6 +20,10 @@ function isENOENT(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
|
||||
function isEEXIST(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'EEXIST'
|
||||
}
|
||||
|
||||
/**
|
||||
* A path component that is expected to be a directory is a regular file (e.g.
|
||||
* resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target
|
||||
@@ -85,7 +89,9 @@ export interface FsIoInternals {
|
||||
copyFileDacl?: (source: string, destination: string) => Promise<void>
|
||||
/** Override the Win32 security-preserving replacement boundary. */
|
||||
replaceFile?: (replaced: string, replacement: string) => Promise<void>
|
||||
/** Test hook after the temp file is written/synced but before final chmod+rename. */
|
||||
/** Override the hard-link no-replace publication boundary. */
|
||||
linkFile?: (existingPath: string, newPath: string) => Promise<void>
|
||||
/** Test hook after the temp file is written/synced but before final chmod+publication. */
|
||||
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
|
||||
}
|
||||
|
||||
@@ -426,8 +432,10 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
|
||||
* @param content - the full UTF-8 text to write.
|
||||
* @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
|
||||
* inert as a mode on Windows but identifies replacement security semantics.
|
||||
* @param signal - cancellation checked before the final rename.
|
||||
* @param signal - cancellation checked before final publication.
|
||||
* @param internals - Test hook for pinning temp names and observing the staged file.
|
||||
* @param createIfAbsent - publish with a hard-link no-replace primitive; a
|
||||
* concurrent creator is preserved and rejected with `FS_NOT_OBSERVED`.
|
||||
*/
|
||||
export async function writeFileAtomic(
|
||||
absolutePath: string,
|
||||
@@ -435,6 +443,7 @@ export async function writeFileAtomic(
|
||||
mode: number | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
internals: FsIoInternals = {},
|
||||
createIfAbsent = false,
|
||||
): Promise<void> {
|
||||
throwIfAborted(signal, 'write')
|
||||
const directory = dirname(absolutePath)
|
||||
@@ -448,6 +457,7 @@ export async function writeFileAtomic(
|
||||
const platform = internals.platform ?? process.platform
|
||||
const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32
|
||||
const replaceFile = internals.replaceFile ?? replaceFileWin32
|
||||
const linkFile = internals.linkFile ?? link
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined
|
||||
let stagingCreated = false
|
||||
try {
|
||||
@@ -468,7 +478,18 @@ export async function writeFileAtomic(
|
||||
handle = undefined
|
||||
|
||||
throwIfAborted(signal, 'write')
|
||||
if (platform === 'win32' && mode !== undefined) {
|
||||
if (createIfAbsent) {
|
||||
try {
|
||||
await linkFile(tempPath, absolutePath)
|
||||
} catch (error: unknown) {
|
||||
if (!isEEXIST(error)) throw error
|
||||
throw new FsError(
|
||||
`cannot overwrite existing "${absolutePath}" without reading it first`,
|
||||
'FS_NOT_OBSERVED',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
} else if (platform === 'win32' && mode !== undefined) {
|
||||
try {
|
||||
await replaceFile(absolutePath, tempPath)
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -167,7 +167,14 @@ export class LocalFileSystem extends FileSystem {
|
||||
// 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)
|
||||
await writeFileAtomic(
|
||||
target.targetKey,
|
||||
content,
|
||||
existing?.mode,
|
||||
signal,
|
||||
this.internals,
|
||||
expected?.kind === 'createIfAbsent',
|
||||
)
|
||||
const after = await probe(target.targetKey)
|
||||
return {
|
||||
operation: existing ? 'update' : 'create',
|
||||
|
||||
@@ -298,6 +298,16 @@ describe('writeText', () => {
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('old')
|
||||
})
|
||||
|
||||
it('createIfAbsent preserves a competitor created after the initial probe', async () => {
|
||||
const path = join(dir, 'a.txt')
|
||||
const target = await fs.resolve('a.txt')
|
||||
fs.internals.inspectTemp = async () => { await writeFile(path, 'competitor') }
|
||||
|
||||
await expect(fs.writeText(target, 'ours', { kind: 'createIfAbsent' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await readFile(path, 'utf8')).toBe('competitor')
|
||||
})
|
||||
|
||||
it('replaceIfVersion replaces when the version matches', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
|
||||
@@ -496,6 +496,17 @@ describe('writeFileAtomic — temp-file safety', () => {
|
||||
expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces a non-collision guarded-create publication failure and cleans staging', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
const denied = Object.assign(new Error('link denied'), { code: 'EACCES' })
|
||||
|
||||
await expect(writeFileAtomic(file, 'ours', undefined, undefined, {
|
||||
linkFile: async () => { throw denied },
|
||||
}, true)).rejects.toBe(denied)
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
it.skipIf(!posixModes)('creates new files owner-only by default', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hello', undefined, undefined)
|
||||
|
||||
@@ -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-policy/README.md
|
||||
README.md: bf6486f6f7fe574b576a71b430be91031f7805d2
|
||||
README.zh.md: 2e38d63d8e310c7f22b764d46cbec0c82ffb96cf
|
||||
README.md: 0166690c66f38efe817d8b5779db9a677d22d321
|
||||
README.zh.md: 26e0199e6deb7fc12a860ab01884b70c1b7c9b38
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
|
||||
The **fs-policy plugin**: it records observed presence or absence and adds read-before-edit plus guarded write/edit on top of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
@@ -33,13 +33,13 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek
|
||||
|
||||
| Event | This plugin's listener |
|
||||
|---|---|
|
||||
| `fs/write-intent` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
|
||||
| `fs/write-intent` | Unseen or observed absent → `{ kind: 'createIfAbsent' }`; observed present → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/edit-intent` | Unseen → `FS_NOT_OBSERVED`; observed absent → `FS_NOT_FOUND`; observed present → `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/observed` | Records `{ kind: 'present', version }` or `{ kind: 'absent' }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
|
||||
|
||||
## Observed state is the prior-observation record; freshness is provider CAS
|
||||
|
||||
Observed state is a weak owner-to-target version map updated after every successful read or mutation; presence alone is the prior-observation record. The plugin performs no filesystem I/O: it supplies the observed version to the provider's atomic mutation guard. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions.
|
||||
Observed state is a weak owner-to-target map with three logical states: unseen, confirmed absent, or present at a version. A successful file read or mutation records presence; a `read`/`view` metadata miss records absence before returning `FS_NOT_FOUND`. The plugin performs no filesystem I/O: it converts that state into a provider guard. Presence supplies the observed version, while absence lets only a `createIfAbsent` write proceed; edit has no version basis and returns `FS_NOT_FOUND`. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions.
|
||||
|
||||
## Single-slot, first-wins
|
||||
|
||||
@@ -55,7 +55,7 @@ Because the plugin influences the world only through events, removing it does no
|
||||
|
||||
#### What the model sees
|
||||
|
||||
This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code; observation state is never shown.
|
||||
This plugin adds no prompt or schema. It rejects an edit without a prior observation with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`; editing a target just observed absent returns `FS_NOT_FOUND`. Guarded mutations whose positive observation is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code. Following the stale remedy on an externally deleted target now records absence: the next guarded write may recreate it with `createIfAbsent`, while the provider atomically preserves any concurrent creator.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**fs-policy 插件**:它在 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))之上增加已观察状态、编辑前读取和版本防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的策略层:不是可替换 seam,而是不应位于 `FileSystem` 提供方基类上的策略。
|
||||
**fs-policy 插件**:它记录观测到的存在或缺失状态,并在 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))之上增加编辑前读取和带防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的政策层:不是可替换 seam,而是不应位于 `FileSystem` 提供方基类上的政策。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
@@ -33,13 +33,13 @@ await ctx.plugin(FsPolicy)
|
||||
|
||||
| 事件 | 本插件的监听器 |
|
||||
|---|---|
|
||||
| `fs/write-intent` | 先前未观察 → `{ kind: 'createIfAbsent' }`;先前已观察 → `{ kind: 'replaceIfVersion', version: vObserved }`。单槽决策;不调用 `next()`。 |
|
||||
| `fs/edit-intent` | 要求该所有者先前已观察,否则抛出 `FS_NOT_OBSERVED`;返回 `{ version: vObserved }` 作为 CAS 基础。单槽决策;不调用 `next()`。 |
|
||||
| `fs/observed` | 为该所有者与目标记录 `{ version }`。同步、只有副作用的 `WeakMap.set`。 |
|
||||
| `fs/write-intent` | 未见或已观测为缺失 → `{ kind: 'createIfAbsent' }`;已观测为存在 → `{ kind: 'replaceIfVersion', version: vObserved }`。单槽决策;不调用 `next()`。 |
|
||||
| `fs/edit-intent` | 未见 → `FS_NOT_OBSERVED`;已观测为缺失 → `FS_NOT_FOUND`;已观测为存在 → 返回 `{ version: vObserved }` 作为 CAS 基础。单槽决策;不调用 `next()`。 |
|
||||
| `fs/observed` | 为该所有者与目标记录 `{ kind: 'present', version }` 或 `{ kind: 'absent' }`。同步、只有副作用的 `WeakMap.set`。 |
|
||||
|
||||
## 已观察状态是先前观察记录;新鲜度由提供方 CAS 保证
|
||||
|
||||
已观察状态是一张以所有者为弱键、记录各目标版本的映射表,每次读取或变更成功后都会更新;记录存在本身就是先前观察凭据。插件不执行文件系统 I/O:它把观察到的版本提供给提供方的原子变更防护。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose(资源释放)时会丢弃状态,并且不会跨会话持久化。
|
||||
观测状态是一张以所有者为弱键、记录各目标的映射表,具有三种逻辑状态:未见、确认缺失、存在于某个版本。成功读取文件或变更会记录存在;`read`/`view` 的元数据未命中会在返回 `FS_NOT_FOUND` 前记录缺失。插件不执行文件系统 I/O:它把该状态转换为提供方防护。存在状态提供观测到的版本;缺失状态只允许 `createIfAbsent` 写入继续,edit 因没有版本基准而返回 `FS_NOT_FOUND`。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose(资源释放)时会丢弃状态,并且不会跨会话持久化。
|
||||
|
||||
## 单槽、先到者胜
|
||||
|
||||
@@ -55,7 +55,7 @@ await ctx.plugin(FsPolicy)
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码;观察状态绝不会显示。
|
||||
该插件不添加提示词或 schema。没有先前观测时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝编辑;编辑刚被观测为缺失的目标会返回 `FS_NOT_FOUND`。正向观测陈旧时,带防护的变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码。外部删除目标后,遵循陈旧恢复指令会记录缺失:下一次带防护的写入可以通过 `createIfAbsent` 重新创建该目标,而提供方会以原子方式保留任何并发创建者写入的文件。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Event-only filesystem observation policy; it registers no service. A weak owner/target map
|
||||
* records every successful read or mutation, single-slot intent listeners supply that version,
|
||||
* and the provider performs the atomic freshness check. Without this plugin, tools retain the
|
||||
* bare provider's unconditional mutation behavior. See the package README for composition rules.
|
||||
* records every authoritative presence/absence observation, single-slot intent listeners derive
|
||||
* guards from that state, and the provider performs the atomic freshness/no-clobber check. Without
|
||||
* this plugin, tools retain the bare provider's unconditional mutation behavior. See the package
|
||||
* README for composition rules.
|
||||
* @module @deepseek-ai/dsh-fs-policy
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsPolicyExec } from './types.ts'
|
||||
|
||||
export type { FsPolicyExec } from './types.ts'
|
||||
@@ -21,9 +22,10 @@ class ObservedStateGate {
|
||||
/**
|
||||
* Observed-file state, keyed first by the owner object (weakly held, so a
|
||||
* collected session frees its state), then by {@link FsTarget.targetKey}. An
|
||||
* entry's PRESENCE is the prior-observation record.
|
||||
* entry's presence is the prior-observation record; its discriminant keeps
|
||||
* confirmed absence distinct from an unseen target.
|
||||
*/
|
||||
private observed = new WeakMap<object, Map<string, FsVersion>>()
|
||||
private observed = new WeakMap<object, Map<string, FsObservation>>()
|
||||
|
||||
/**
|
||||
* Derive the observed-state owner from the opaque event actor — normally the
|
||||
@@ -38,17 +40,17 @@ class ObservedStateGate {
|
||||
return (actor as FsPolicyExec | undefined)?.agent?.session
|
||||
}
|
||||
|
||||
private get(owner: object, targetKey: string): FsVersion | undefined {
|
||||
private get(owner: object, targetKey: string): FsObservation | undefined {
|
||||
return this.observed.get(owner)?.get(targetKey)
|
||||
}
|
||||
|
||||
private set(owner: object, targetKey: string, version: FsVersion): void {
|
||||
private set(owner: object, targetKey: string, observation: FsObservation): void {
|
||||
let byTarget = this.observed.get(owner)
|
||||
if (!byTarget) {
|
||||
byTarget = new Map()
|
||||
this.observed.set(owner, byTarget)
|
||||
}
|
||||
byTarget.set(targetKey, version)
|
||||
byTarget.set(targetKey, observation)
|
||||
}
|
||||
|
||||
/** Drop all recorded state (HMR safety / disposal). */
|
||||
@@ -57,33 +59,38 @@ class ObservedStateGate {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the write intent: no prior observation ⇒ `createIfAbsent` (only
|
||||
* new files can be created blindly); a prior observation ⇒ `replaceIfVersion`
|
||||
* at the observed version (existing files replaced only if unchanged).
|
||||
* Decide the write intent: unseen or confirmed absent ⇒ `createIfAbsent`;
|
||||
* confirmed present ⇒ `replaceIfVersion` at the observed version.
|
||||
*/
|
||||
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' }
|
||||
return prior?.kind === 'present'
|
||||
? { kind: 'replaceIfVersion', version: prior.version }
|
||||
: { kind: 'createIfAbsent' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the edit version guard: requires a prior observation by this owner
|
||||
* (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis.
|
||||
* Decide the edit version guard: unseen rejects with `FS_NOT_OBSERVED`,
|
||||
* confirmed absence rejects with `FS_NOT_FOUND`, and presence supplies the
|
||||
* observed version as the CAS basis.
|
||||
*/
|
||||
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
if (!owner || !prior) {
|
||||
if (!owner || prior === undefined) {
|
||||
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
|
||||
}
|
||||
return { version: prior }
|
||||
if (prior.kind === 'absent') {
|
||||
throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
}
|
||||
return { version: prior.version }
|
||||
}
|
||||
|
||||
/** Record a successful read/write/edit: this owner observed this target at this version. */
|
||||
observe(target: FsTarget, version: FsVersion, actor: object | undefined): void {
|
||||
/** Record an authoritative present or absent observation for this owner and target. */
|
||||
observe(target: FsTarget, observation: FsObservation, actor: object | undefined): void {
|
||||
const owner = this.owner(actor)
|
||||
if (owner) this.set(owner, target.targetKey, version)
|
||||
if (owner) this.set(owner, target.targetKey, observation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +121,10 @@ export function apply(ctx: Context): void {
|
||||
// fs/edit-intent: occupy the single decision slot — do not call next().
|
||||
ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))
|
||||
|
||||
// fs/observed must remain synchronous and non-throwing: the mutation already succeeded, and
|
||||
// emit does not await promises. WeakMap.set satisfies that contract.
|
||||
ctx.on('fs/observed', (target, version, actor) => {
|
||||
gate.observe(target, version, actor)
|
||||
// fs/observed must remain synchronous and non-throwing: emit does not await
|
||||
// promises, and successful mutations have already committed. WeakMap.set
|
||||
// satisfies that contract for both presence and absence.
|
||||
ctx.on('fs/observed', (target, observation, actor) => {
|
||||
gate.observe(target, observation, actor)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsObservation, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
@@ -11,6 +11,8 @@ function target(path: string): FsTarget {
|
||||
return { targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })
|
||||
const present = (version: string): FsObservation => ({ kind: 'present', version: FsVersion(version) })
|
||||
const absent: FsObservation = { kind: 'absent' }
|
||||
|
||||
/** Dispatch the write-intent waterfall with the bare default thunk. */
|
||||
function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteIntent | undefined> {
|
||||
@@ -64,9 +66,16 @@ describe('write-intent decision', () => {
|
||||
it('an observed target decides replaceIfVersion at the observed version', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec)
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v7'), exec)
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
|
||||
})
|
||||
|
||||
it('a target observed absent decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), absent, exec)
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit-intent decision', () => {
|
||||
@@ -88,16 +97,23 @@ describe('edit-intent decision', () => {
|
||||
it('returns the observed version as the CAS basis after an observation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec)
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v3'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
|
||||
})
|
||||
|
||||
it('rejects editing a target observed absent with FS_NOT_FOUND', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), absent, exec)
|
||||
await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('observed-state is the prior-observation record', () => {
|
||||
it('a read observation authorizes an in-place write at that version', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec) // a read
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
})
|
||||
|
||||
@@ -105,19 +121,34 @@ describe('observed-state is the prior-observation record', () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
// A create records v1; the follow-up edit guards against v1 with no read.
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec)
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v1'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' })
|
||||
// The edit records v2; a second edit guards against v2.
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec)
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v2'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
|
||||
})
|
||||
|
||||
it('a no-owner observation records nothing', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined)
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), undefined)
|
||||
// Still unobserved for any owner.
|
||||
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('supports present → absent → present transitions for one owner', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
const a = target('a.txt')
|
||||
ctx.emit('fs/observed', a, present('v1'), exec)
|
||||
expect(await writeIntent(ctx, a, exec)).toEqual({ kind: 'replaceIfVersion', version: 'v1' })
|
||||
|
||||
ctx.emit('fs/observed', a, absent, exec)
|
||||
expect(await writeIntent(ctx, a, exec)).toEqual({ kind: 'createIfAbsent' })
|
||||
await expect(editIntent(ctx, a, exec)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
|
||||
ctx.emit('fs/observed', a, present('v2'), exec)
|
||||
expect(await editIntent(ctx, a, exec)).toEqual({ version: 'v2' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-owner isolation', () => {
|
||||
@@ -125,7 +156,7 @@ describe('multi-owner isolation', () => {
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a)
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), a)
|
||||
await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
|
||||
})
|
||||
@@ -134,7 +165,7 @@ describe('multi-owner isolation', () => {
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), a) // A observed v0
|
||||
// B never observed → createIfAbsent; A still holds v0 → replaceIfVersion.
|
||||
expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
@@ -164,7 +195,7 @@ describe('single-slot, first-wins', () => {
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec)
|
||||
await editIntent(ctx, target('a.txt'), exec)
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
@@ -186,7 +217,7 @@ describe('disposal releases recorded state (HMR safety)', () => {
|
||||
const ctx = new Context()
|
||||
const exec = ownerExec({})
|
||||
const fiber = await ctx.plugin(FsPolicy)
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
|
||||
await fiber.dispose()
|
||||
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: c7c978b7530a9b26e178f164dae61ae60f8f321f
|
||||
README.zh.md: 9761fc81bcb160edfc96d42cea45183abcfc087d
|
||||
README.md: 2b10614c8bf3d7e4a26c7f3d90dbc127cfec4336
|
||||
README.zh.md: e0cbf058bb8d6b52e8c8e5a048cc8b3d757e67d0
|
||||
|
||||
@@ -30,14 +30,14 @@ A backend subclasses `FileSystem` and implements eleven primitives.
|
||||
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
|
||||
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here); consumers that need a byte ceiling enforce it while consuming the stream. |
|
||||
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
|
||||
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. |
|
||||
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. `createIfAbsent` must perform a no-replace publication so a creator racing the initial probe is preserved. |
|
||||
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
|
||||
|
||||
The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity.
|
||||
|
||||
## The `fs/*` policy events
|
||||
|
||||
This package declares three events (see the generated region of [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
|
||||
This package declares three events (see the generated region of [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event carrying an `FsObservation` discriminated union: present with a version or confirmed absent. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
|
||||
|
||||
## A provider contract, not the policy layer
|
||||
|
||||
@@ -47,7 +47,7 @@ This package declares three events (see the generated region of [filesystem.md](
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
|
||||
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsObservation` distinguishes `{ kind: 'present', version }` from `{ kind: 'absent' }`, so a policy can separate an unseen target from confirmed absence without performing I/O. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -30,14 +30,14 @@
|
||||
| `readText(target, signal?)` | 把整个普通文本文件读取为一个解码后的字符串。负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝(`FS_NOT_TEXT`)。 |
|
||||
| `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责);需要字节上限的消费方在消费流时执行该上限。 |
|
||||
| `listDir(target, signal?)` | 按稳定名称顺序列出直接子项。返回条目名称、条目类型、解析后的子目标和低成本元数据(若可用则包括 `version`/文件 `size`);绝不读取文件内容。缺失目标抛出 `FS_NOT_FOUND`,非目录抛出 `FS_NOT_DIRECTORY`,权限失败抛出 `FS_PERMISSION_DENIED`,其他后端 I/O 失败抛出 `FS_IO_ERROR`。损坏/消失的子项可以作为无元数据的 `other` 返回;子项权限/I/O 失败会使用相同结构化代码使整个列表失败。 |
|
||||
| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent`(`createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。 |
|
||||
| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent`(`createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。`createIfAbsent` 必须以不替换的方式发布,使初始探测后抢先创建的文件得到保留。 |
|
||||
| `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 |
|
||||
|
||||
无论是否有版本防护,变更都在后端的每目标锁内运行,因此无条件写入/编辑仍是原子的;「无条件」只移除*版本*前置条件,不移除原子性。
|
||||
|
||||
## `fs/*` 政策事件
|
||||
|
||||
本包声明三个事件(见 [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface) 的生成区块),使发出方(`@deepseek-ai/dsh-tool-fs`)和政策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖政策插件。`fs/write-intent` 和 `fs/edit-intent` 是单槽决策 waterfall(监听器完整决策,绝不调用 `next()`);`fs/observed` 是发后即忘的记录事件。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent(智能体)/会话所有者结构。
|
||||
本包声明三个事件(见 [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface) 的生成区块),使发出方(`@deepseek-ai/dsh-tool-fs`)和政策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖政策插件。`fs/write-intent` 和 `fs/edit-intent` 是单槽决策 waterfall(监听器完整决策,绝不调用 `next()`);`fs/observed` 是发后即忘的记录事件,携带 `FsObservation` 可辨识联合:存在并带有版本,或确认缺失。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent(智能体)/会话所有者结构。
|
||||
|
||||
## 提供方约定,不是政策层
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
## 词汇
|
||||
|
||||
`FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`。
|
||||
`FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsObservation` 区分 `{ kind: 'present', version }` 与 `{ kind: 'absent' }`,使策略无需执行 I/O 即可分辨未见目标和确认缺失。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsPathInfo,
|
||||
FsObservation,
|
||||
FsTarget,
|
||||
FsVersion,
|
||||
FsWriteIntent,
|
||||
@@ -33,6 +34,7 @@ export type {
|
||||
FsDirEntry,
|
||||
FsErrorCode,
|
||||
FsInfo,
|
||||
FsObservation,
|
||||
FsPathInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
@@ -63,14 +65,15 @@ declare module 'cordis' {
|
||||
*/
|
||||
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
/**
|
||||
* Record a successful observation. Listeners must be synchronous recorders:
|
||||
* throws fail the tool call and returned promises are not awaited.
|
||||
* @param target - the target that was read/written/edited.
|
||||
* @param version - the version the actor now holds as its observation.
|
||||
* Record an authoritative positive or negative observation. Listeners must
|
||||
* be synchronous recorders: throws fail the tool call and returned promises
|
||||
* are not awaited.
|
||||
* @param target - the target whose presence or absence was observed.
|
||||
* @param observation - present with its version, or confirmed absent.
|
||||
* @param actor - the observing tool-execution context; undefined records nothing useful.
|
||||
* @mode emit
|
||||
*/
|
||||
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
|
||||
'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +201,7 @@ export abstract class FileSystem extends Service {
|
||||
* @param target - the resolved target to write.
|
||||
* @param content - the full new file content.
|
||||
* @param expected - the write intent guarding the write; omit for unconditional.
|
||||
* @param signal - aborts before the atomic rename takes effect.
|
||||
* @param signal - aborts before atomic publication takes effect.
|
||||
* @param sandboxPolicy - the per-call mode and workspace root this write
|
||||
* runs under; a sandboxing backend fences the write by it, the bare backend
|
||||
* ignores it. Omit to leave the backend its own default.
|
||||
@@ -219,7 +222,7 @@ export abstract class FileSystem extends Service {
|
||||
* @param target - the resolved target to edit.
|
||||
* @param edit - the literal search/replace request.
|
||||
* @param expected - the version guard; omit for an unconditional edit.
|
||||
* @param signal - aborts before the atomic rename takes effect.
|
||||
* @param signal - aborts before atomic publication takes effect.
|
||||
* @param sandboxPolicy - the per-call mode and workspace root this edit runs
|
||||
* under; a sandboxing backend fences the edit by it, the bare backend
|
||||
* ignores it. Omit to leave the backend its own default.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { FsTarget, FsVersion } from './types.ts'
|
||||
import type { FsObservation, FsTarget } from './types.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-fs'
|
||||
|
||||
@@ -24,8 +24,17 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
&& eventName !== 'fs/edit-intent'
|
||||
&& eventName !== 'fs/observed') return
|
||||
validateTarget(args[0] as FsTarget, fail)
|
||||
if (eventName === 'fs/observed' && (args[1] as FsVersion).length === 0) {
|
||||
fail('fs/observed version must be non-empty')
|
||||
if (eventName === 'fs/observed') {
|
||||
const observation = args[1] as FsObservation
|
||||
switch (observation.kind) {
|
||||
case 'present':
|
||||
if (observation.version.length === 0) fail('fs/observed present version must be non-empty')
|
||||
break
|
||||
case 'absent':
|
||||
break
|
||||
default:
|
||||
fail('fs/observed kind must be present or absent')
|
||||
}
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
@@ -44,6 +44,15 @@ export function FsVersion(v: string): FsVersion {
|
||||
return v as FsVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* One authoritative observation of a target. A present observation carries the
|
||||
* version used by guarded replacement; an absent observation authorizes only a
|
||||
* guarded create, never an edit.
|
||||
*/
|
||||
export type FsObservation =
|
||||
| { readonly kind: 'present'; readonly version: FsVersion }
|
||||
| { readonly kind: 'absent' }
|
||||
|
||||
/**
|
||||
* A path resolved by a backend into a stable identity. `resolve()` produces
|
||||
* this; every other operation takes it.
|
||||
|
||||
@@ -28,17 +28,28 @@ describe('filesystem invariants', () => {
|
||||
ctx as never, 'fs/edit-intent', target(), undefined,
|
||||
() => Promise.resolve(undefined),
|
||||
)).resolves.toBeUndefined()
|
||||
expect(() => { ctx.emit('fs/observed', target(), FsVersion('v1'), undefined) }).not.toThrow()
|
||||
expect(() => {
|
||||
ctx.emit('fs/observed', target(), { kind: 'present', version: FsVersion('v1') }, undefined)
|
||||
}).not.toThrow()
|
||||
expect(() => { ctx.emit('fs/observed', target(), { kind: 'absent' }, undefined) }).not.toThrow()
|
||||
expect(() => { ctx.emit('tools/change') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects empty target and version identities', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('fs/observed', target(''), FsVersion('v1'), undefined) })
|
||||
expect(() => {
|
||||
ctx.emit('fs/observed', target(''), { kind: 'present', version: FsVersion('v1') }, undefined)
|
||||
})
|
||||
.toThrow(/targetKey must be non-empty/)
|
||||
expect(() => { ctx.emit('fs/observed', target('file:1', ''), FsVersion('v1'), undefined) })
|
||||
expect(() => {
|
||||
ctx.emit('fs/observed', target('file:1', ''), { kind: 'present', version: FsVersion('v1') }, undefined)
|
||||
})
|
||||
.toThrow(/displayPath must be non-empty/)
|
||||
expect(() => { ctx.emit('fs/observed', target(), FsVersion(''), undefined) })
|
||||
.toThrow(/version must be non-empty/)
|
||||
expect(() => {
|
||||
ctx.emit('fs/observed', target(), { kind: 'present', version: FsVersion('') }, undefined)
|
||||
}).toThrow(/present version must be non-empty/)
|
||||
expect(() => {
|
||||
ctx.emit('fs/observed', target(), { kind: 'unknown' } as never, undefined)
|
||||
}).toThrow(/kind must be present or absent/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/tool-fs/README.md
|
||||
README.md: c9375a3ee803db48c547c7553d10f9b5ebde3fd2
|
||||
README.zh.md: 70ff15f2beff2674719f3084594e5cc0c7309f68
|
||||
README.md: 6878584b72c3e799ca8a46ddc6fc2908f9bc3acf
|
||||
README.zh.md: d8e92d96c60d30ec4aa943b77b882517324c4e52
|
||||
|
||||
@@ -108,7 +108,7 @@ Prefix-stable while the visible tool definitions and order are unchanged. Regist
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`.
|
||||
A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`. A missing read still returns `FS_NOT_FOUND`, but it records confirmed absence for the calling session; after an externally deleted file is re-read, a retried `write` can safely recreate it through the provider's no-replace guard.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` (including a missing edit target) gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved.
|
||||
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
成功读取结果精确为 `<path><displayPath></path>`、换行、`<type>file</type>`、换行、`<content>`、形如 `<lineNumber>: <text>` 的编号行、一个空行、一条 footer 和 `</content>`。footer 精确为 `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`、`(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)` 或 `(End of file - total <total> lines)`。长行结尾精确为 `... (line truncated to <max> chars)`。
|
||||
成功读取结果精确为 `<path><displayPath></path>`、换行、`<type>file</type>`、换行、`<content>`、形如 `<lineNumber>: <text>` 的编号行、一个空行、一条 footer 和 `</content>`。footer 精确为 `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`、`(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)` 或 `(End of file - total <total> lines)`。长行结尾精确为 `... (line truncated to <max> chars)`。读取缺失目标仍返回 `FS_NOT_FOUND`,但会为调用会话记录确认缺失;外部删除的文件被重新读取后,重试的 `write` 可以通过提供方的不替换防护安全地重新创建该文件。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -136,7 +136,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION`(包括编辑目标缺失)追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。
|
||||
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;write 则使用带防护的创建。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -137,8 +137,8 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
// model-facing remedy; anything else passes through.
|
||||
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// Record the present observation (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
||||
return {
|
||||
path: target.displayPath,
|
||||
before: outcome.before,
|
||||
|
||||
@@ -138,10 +138,13 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
const input = parseReadArgs(args, caps.limit)
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath))
|
||||
|
||||
// One stat: type check + size routing + the version recorded as observed.
|
||||
// One stat: absence observation OR type check + size routing + present version.
|
||||
// A concurrent write can only make a later guarded mutation fail stale and require reread.
|
||||
const info = await ctx.fs.stat(target, exec.signal)
|
||||
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
if (!info) {
|
||||
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
|
||||
throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
}
|
||||
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
|
||||
// Stream when the file is large OR size is unknown, so a size-less backend
|
||||
@@ -161,10 +164,10 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
lines: window.lines,
|
||||
totalLines: window.totalLines,
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens). The
|
||||
// Record the present observation (a no-op when no policy plugin listens). The
|
||||
// read already succeeded; an fs/observed listener is contractually a
|
||||
// synchronous, side-effect-only recorder.
|
||||
ctx.emit('fs/observed', target, info.version, exec)
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)
|
||||
return outcome
|
||||
},
|
||||
// Result-time display: a `read` card carrying the structured line window a
|
||||
|
||||
@@ -118,8 +118,8 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
// model-facing remedy; anything else passes through.
|
||||
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// Record the present observation (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
||||
return {
|
||||
path: target.displayPath,
|
||||
operation: outcome.operation,
|
||||
|
||||
@@ -234,37 +234,33 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleted observed target (fail-closed corner)', () => {
|
||||
it('a deleted observed file stays un-writable and un-editable in-session: the remedy cannot unblock it', async () => {
|
||||
describe('deleted observed target', () => {
|
||||
it('a failed reread records absence so write can safely recreate the file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
await rm(join(dir, 'a.txt')) // out-of-band deletion
|
||||
|
||||
// Edit of the missing target: stale (the missing-target path shares the
|
||||
// stale code and the re-read remedy).
|
||||
// The original positive observation still protects the first mutation.
|
||||
const edit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
|
||||
expect(edit.isError).toBe(true)
|
||||
expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
|
||||
// Re-reading the missing file FAILS with FS_NOT_FOUND and records no
|
||||
// observation, so the retried edit fails identically: the observed entry
|
||||
// is never cleared for a deleted target.
|
||||
const reread = await call('read', { file_path: 'a.txt' })
|
||||
expect(reread.isError).toBe(true)
|
||||
expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
|
||||
const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
|
||||
expect(retriedEdit.isError).toBe(true)
|
||||
expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
|
||||
// Write cannot recreate it either: the stale observation still forces
|
||||
// replaceIfVersion, which rejects a missing target ("file no longer exists").
|
||||
const write = await call('write', { file_path: 'a.txt', content: 'fresh' })
|
||||
const write = await call('write', { file_path: 'a.txt', content: 'premature' })
|
||||
expect(write.isError).toBe(true)
|
||||
expect(write.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
|
||||
|
||||
// The dead end lifts once the file exists again and is freshly observed.
|
||||
await writeFile(join(dir, 'a.txt'), 'restored')
|
||||
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
// A read-not-found is an authoritative negative observation for this
|
||||
// owner. It still fails as a read, but changes the next write guard.
|
||||
const reread = await call('read', { file_path: 'a.txt' })
|
||||
expect(reread.isError).toBe(true)
|
||||
expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
|
||||
|
||||
// Absence never authorizes edit: there is no content/version to edit.
|
||||
const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
|
||||
expect(retriedEdit.isError).toBe(true)
|
||||
expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
|
||||
|
||||
// The retried write uses createIfAbsent; the provider remains responsible
|
||||
// for rejecting a concurrent creator at publication time.
|
||||
const recovered = await call('write', { file_path: 'a.txt', content: 'fresh' })
|
||||
expect(recovered.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('fresh')
|
||||
@@ -294,6 +290,20 @@ describe('default deployment (with dsh-fs-policy)', () => {
|
||||
expect(statSpy).not.toHaveBeenCalled()
|
||||
statSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('a missing read still stats once and its recovery write stats zero times', async () => {
|
||||
const statSpy = vi.spyOn(ctx.fs, 'stat')
|
||||
const missing = await call('read', { file_path: 'missing.txt' })
|
||||
expect(missing.isError).toBe(true)
|
||||
expect(missing.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
|
||||
expect(statSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
statSpy.mockClear()
|
||||
const created = await call('write', { file_path: 'missing.txt', content: 'fresh' })
|
||||
expect(created.isError).toBe(false)
|
||||
expect(statSpy).not.toHaveBeenCalled()
|
||||
statSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -482,7 +492,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
|
||||
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
|
||||
// Reproduce an older concurrent read winning the observation race.
|
||||
ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } })
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: firstInfo.version }, { agent: { session } })
|
||||
|
||||
const edit = await callOwned('edit', {
|
||||
file_path: 'a.txt',
|
||||
|
||||
@@ -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/tool-str-replace-editor/README.md
|
||||
README.md: 97e9e0ab9ade7c7241c1aac3e2489e055d01ff8f
|
||||
README.zh.md: a71f040f3b1383604fa1f151f797906dd343c49a
|
||||
README.md: 1d6ce6fd801997dd21973fde1ffded84dbee1b5a
|
||||
README.zh.md: 62728659fa8c8173ac42a42610f1a710aec6e386
|
||||
|
||||
@@ -13,7 +13,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w
|
||||
|
||||
## Tool
|
||||
|
||||
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit.
|
||||
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. A missing view records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
## 工具
|
||||
|
||||
schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。
|
||||
schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。查看缺失目标时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace` 或 `insert`。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ async function statExisting(
|
||||
): Promise<FsInfo> {
|
||||
const info = await ctx.fs.stat(target, exec.signal)
|
||||
if (info === undefined) {
|
||||
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
|
||||
throw new FsError(
|
||||
`The path ${target.displayPath} does not exist. Please provide a valid path.`,
|
||||
'FS_NOT_FOUND',
|
||||
@@ -231,7 +232,7 @@ async function viewPath(
|
||||
throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
const content = await ctx.fs.readText(target, exec.signal)
|
||||
ctx.emit('fs/observed', target, info.version, exec)
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)
|
||||
return formatFileView(target.displayPath, content, maxOutputChars, viewRange)
|
||||
}
|
||||
|
||||
@@ -266,7 +267,7 @@ async function createFile(
|
||||
} catch (error: unknown) {
|
||||
throw policy.mapError(error, sandboxPolicy)
|
||||
}
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
||||
return `New file created successfully at: ${target.displayPath}`
|
||||
}
|
||||
|
||||
@@ -317,7 +318,7 @@ async function replaceInFile(
|
||||
} catch (error: unknown) {
|
||||
throw policy.mapError(error, sandboxPolicy)
|
||||
}
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
||||
return `The file ${target.displayPath} has been edited successfully.`
|
||||
}
|
||||
|
||||
@@ -359,7 +360,7 @@ async function insertInFile(
|
||||
} catch (error: unknown) {
|
||||
throw policy.mapError(error, sandboxPolicy)
|
||||
}
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
||||
return `The file ${target.displayPath} has been edited successfully.`
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,35 @@ describe('tool-str-replace-editor', () => {
|
||||
expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n')
|
||||
})
|
||||
|
||||
it('a failed view records absence so create can recover after external deletion', async () => {
|
||||
const { ctx, root, owner } = await setup({}, { fsPolicy: true })
|
||||
const sample = join(root, 'deleted.txt')
|
||||
await writeFile(sample, 'original')
|
||||
expect((await call(ctx, owner, { command: 'view', path: sample })).isError).toBe(false)
|
||||
await rm(sample)
|
||||
|
||||
const missing = await call(ctx, owner, { command: 'view', path: sample })
|
||||
expect(missing.isError).toBe(true)
|
||||
expect(missing.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
|
||||
|
||||
const edit = await call(ctx, owner, {
|
||||
command: 'str_replace',
|
||||
path: sample,
|
||||
old_str: 'original',
|
||||
new_str: 'edited',
|
||||
})
|
||||
expect(edit.isError).toBe(true)
|
||||
expect(edit.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
|
||||
|
||||
const created = await call(ctx, owner, {
|
||||
command: 'create',
|
||||
path: sample,
|
||||
file_text: 'fresh',
|
||||
})
|
||||
expect(created.isError).toBe(false)
|
||||
expect(await readFile(sample, 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('writes replacement text literally', async () => {
|
||||
const { ctx, root, owner } = await setup()
|
||||
const sample = join(root, 'literal.txt')
|
||||
|
||||
@@ -370,11 +370,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsWriteOutcome>',
|
||||
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this write\n * runs under; a sandboxing backend fences the write by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */',
|
||||
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before atomic publication takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this write\n * runs under; a sandboxing backend fences the write by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsEditOutcome>',
|
||||
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this edit runs\n * under; a sandboxing backend fences the edit by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */',
|
||||
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before atomic publication takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this edit runs\n * under; a sandboxing backend fences the edit by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1425,9 +1425,9 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'fs/observed',
|
||||
mode: 'emit',
|
||||
signature: '\'fs/observed\'(target: FsTarget, version: FsVersion, actor: object | undefined): void',
|
||||
jsDoc: '/**\n * Record a successful observation. Listeners must be synchronous recorders:\n * throws fail the tool call and returned promises are not awaited.\n * @param target - the target that was read/written/edited.\n * @param version - the version the actor now holds as its observation.\n * @param actor - the observing tool-execution context; undefined records nothing useful.\n * @mode emit\n */',
|
||||
summary: 'Record a successful observation.',
|
||||
signature: '\'fs/observed\'(target: FsTarget, observation: FsObservation, actor: object | undefined): void',
|
||||
jsDoc: '/**\n * Record an authoritative positive or negative observation. Listeners must\n * be synchronous recorders: throws fail the tool call and returned promises\n * are not awaited.\n * @param target - the target whose presence or absence was observed.\n * @param observation - present with its version, or confirmed absent.\n * @param actor - the observing tool-execution context; undefined records nothing useful.\n * @mode emit\n */',
|
||||
summary: 'Record an authoritative positive or negative observation.',
|
||||
},
|
||||
{
|
||||
name: 'fs/write-intent',
|
||||
|
||||
@@ -136,7 +136,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
ctx.effect(function* () {
|
||||
yield async () => { await provider.dispose() }
|
||||
}, 'skill-local watcher')
|
||||
ctx.on('fs/observed', (target, _version, actor) => {
|
||||
ctx.on('fs/observed', (target, _observation, actor) => {
|
||||
if (mutationToolName(actor) === undefined) return
|
||||
provider.observeHostMutation(target.displayPath)
|
||||
})
|
||||
|
||||
@@ -496,7 +496,7 @@ describe('LocalSkillProvider', () => {
|
||||
ctx.emit(
|
||||
'fs/observed',
|
||||
{ targetKey: path as never, displayPath: path },
|
||||
FsVersion('failed-read'),
|
||||
{ kind: 'present', version: FsVersion('failed-read') },
|
||||
{ name: 'edit' },
|
||||
)
|
||||
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false })
|
||||
@@ -526,7 +526,7 @@ describe('LocalSkillProvider', () => {
|
||||
ctx.emit(
|
||||
'fs/observed',
|
||||
{ targetKey: path as never, displayPath: path },
|
||||
FsVersion('entry-failure'),
|
||||
{ kind: 'present', version: FsVersion('entry-failure') },
|
||||
{ name: 'write' },
|
||||
)
|
||||
}
|
||||
@@ -674,7 +674,7 @@ describe('LocalSkillProvider', () => {
|
||||
ctx.emit(
|
||||
'fs/observed',
|
||||
{ targetKey: displayPath as never, displayPath },
|
||||
FsVersion('observed'),
|
||||
{ kind: 'present', version: FsVersion('observed') },
|
||||
actor,
|
||||
)
|
||||
}
|
||||
@@ -689,7 +689,7 @@ describe('LocalSkillProvider', () => {
|
||||
ctx.emit(
|
||||
'fs/observed',
|
||||
{ targetKey: path as never, displayPath: path },
|
||||
FsVersion('observed'),
|
||||
{ kind: 'present', version: FsVersion('observed') },
|
||||
{ name: 'edit' },
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user