refactor(runtime): collapse speculative portability layers

Remove the one-consumer bounded-read primitive and shared terminal lifecycle controller, make terminal cleanup one awaited provider operation, and reuse one Code Runtime contract suite. Keep only reproduced cancellation and policy fixes; defer unproven replacement, prompt-attribution, and streaming-frame concerns to scoped markers.
This commit is contained in:
Tianyi Cui
2026-07-29 18:26:21 +08:00
parent 4fecc54998
commit c1d550de58
65 changed files with 1265 additions and 948 deletions

View File

@@ -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: f5cf2441adcd62e55c6169ceb766f88382e314f5
README.zh.md: 40f5a83626780bae8f335c80662721bae6cc0b0b
README.md: fe0e5e9dec07fad745d6bea28da9009e517c7d85
README.zh.md: 195f3963328035e9c6c382dd924cd04ee9e8c642

View File

@@ -17,7 +17,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`.
- **`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` / `readTextBounded` / `streamText`** — UTF-8 only. `readText` reads the whole file; `readTextBounded` opens one no-follow, nonblocking handle, verifies it is regular, and retains at most `maxBytes + 1` bytes so growth cannot bypass the cap; `streamText` decodes chunks so a huge file need not be held whole in memory. All 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; protocol consumers such as the LSP host use the stable bounded operation.
- **`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`).
- **`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`).

View File

@@ -17,7 +17,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent智能体的会话 cwd见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。
- **执行世界坐标**`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo``version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token`type` 为 `file`/`directory`/`other``size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。
- **`readText` / `readTextBounded` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`readTextBounded` 打开一个不跟随符号链接的非阻塞句柄,确认其为普通文件,并最多保留 `maxBytes + 1` 字节,使文件增长无法绕过上限;`streamText` 按分片解码,因此超大文件无需整体保存在内存中。三者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑LSP 主机等协议消费方使用稳定的有界操作
- **`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`)。
- **`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`。

View File

@@ -6,7 +6,7 @@
*/
import { randomUUID } from 'node:crypto'
import { constants, createReadStream } from 'node:fs'
import { createReadStream } from 'node:fs'
import { chmod, 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'
@@ -377,69 +377,6 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal):
* @param signal - aborts between handle operations.
* @returns the complete decoded text when it fits.
*/
export async function readWholeTextBounded(
target: LocalTarget,
maxBytes: number,
signal?: AbortSignal,
): Promise<string> {
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
throw new Error('bounded read maxBytes must be a positive safe integer')
}
throwIfAborted(signal, 'read')
let handle: Awaited<ReturnType<typeof open>>
try {
handle = await open(
target.targetKey,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
)
} catch (error: unknown) {
if (isENOENT(error)) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
if (isPermissionError(error)) throw new FsError(`cannot read "${target.displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
throw new FsError(`cannot read "${target.displayPath}" safely: ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
}
try {
throwIfAborted(signal, 'read')
const info = await handle.stat()
if (!info.isFile()) {
throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
if (info.size > maxBytes) {
throw new FsError(
`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`,
'FS_IO_ERROR',
)
}
const chunks: Buffer[] = []
let total = 0
for (;;) {
throwIfAborted(signal, 'read')
// Allocate in fixed internal chunks so a permissive deployment cap does
// not reserve that entire cap for a small file. Once exactly at the
// bound, one final byte detects concurrent growth without retaining it.
const remaining = total === maxBytes ? 1 : Math.min(64 * 1024, maxBytes - total)
const chunk = Buffer.allocUnsafe(remaining)
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total)
if (bytesRead === 0) break
total += bytesRead
if (total > maxBytes) {
throw new FsError(
`cannot read "${target.displayPath}": file grew past the ${maxBytes}-byte limit while reading`,
'FS_IO_ERROR',
)
}
chunks.push(chunk.subarray(0, bytesRead))
}
throwIfAborted(signal, 'read')
const bytes = chunks.length === 1 ? chunks[0] as Buffer : Buffer.concat(chunks, total)
if (bytes.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
}
return decodeUtf8(bytes, 'read', target.displayPath)
} finally {
await handle.close()
}
}
/**
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,

View File

@@ -28,7 +28,6 @@ import {
readForEdit,
readTextForDiff,
readWholeText,
readWholeTextBounded,
resolveLocalTarget,
restoreLineEndings,
streamWholeText,
@@ -126,10 +125,6 @@ export class LocalFileSystem extends FileSystem {
return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
}
override async readTextBounded(target: FsTarget, maxBytes: number, signal?: AbortSignal): Promise<string> {
return readWholeTextBounded({ displayPath: target.displayPath, targetKey: target.targetKey }, maxBytes, signal)
}
override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
}

View File

@@ -218,15 +218,6 @@ describe('readText / streamText', () => {
expect(await fs.readText(await fs.resolve('a.txt'))).toBe('one\ntwo\nthree')
})
it('reads complete text through the stable byte bound', async () => {
await writeFile(join(dir, 'bounded.txt'), '€abc')
const target = await fs.resolve('bounded.txt')
expect(await fs.readTextBounded(target, 6)).toBe('€abc')
await expect(fs.readTextBounded(target, 5)).rejects.toThrow('exceeds the 5-byte limit')
await expect(fs.readTextBounded(target, 0)).rejects.toThrow('positive safe integer')
await expect(fs.readTextBounded(target, 6, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
it('streams the same text', async () => {
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
const target = await fs.resolve('a.txt')

View File

@@ -5,7 +5,7 @@
* policy and lives in `dsh-fs-policy`, so it is not tested here.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -17,7 +17,6 @@ import {
probeNoFollow,
readForEdit,
readWholeText,
readWholeTextBounded,
resolveLocalTarget,
restoreLineEndings,
streamWholeText,
@@ -317,66 +316,6 @@ describe('readWholeText', () => {
})
})
describe('readWholeTextBounded', () => {
it('rejects non-files, binary text, and initial oversize without a full read', async () => {
await expect(readWholeTextBounded(localTarget(dir), 10)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
await writeFile(join(dir, 'large'), '12345')
await expect(readWholeTextBounded(localTarget(join(dir, 'large')), 4)).rejects.toThrow('exceeds the 4-byte limit')
await writeFile(join(dir, 'binary'), Buffer.from([0x61, 0x00, 0x62]))
await expect(readWholeTextBounded(localTarget(join(dir, 'binary')), 3)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
})
it('detects growth past the bound on the same open handle', async () => {
const close = vi.fn(async () => {})
const read = vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
const bytes = position === 0 ? Buffer.from('abc') : Buffer.from('d')
bytes.copy(buffer, offset, 0, Math.min(length, bytes.length))
return { bytesRead: Math.min(length, bytes.length), buffer }
})
vi.resetModules()
vi.doMock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
open: async () => ({
stat: async () => ({ isFile: () => true, size: 3 }),
read,
close,
}),
}
})
try {
const isolated = await import('../src/fsio.ts')
await expect(isolated.readWholeTextBounded(localTarget('/virtual/growing'), 3))
.rejects.toThrow('grew past the 3-byte limit')
expect(close).toHaveBeenCalledOnce()
} finally {
vi.doUnmock('node:fs/promises')
vi.resetModules()
}
})
it('translates permission and generic open failures', async () => {
const failure: { current: Error & { code?: string } } = { current: Object.assign(new Error('denied'), { code: 'EACCES' }) }
vi.resetModules()
vi.doMock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, open: async () => { throw failure.current } }
})
try {
const isolated = await import('../src/fsio.ts')
await expect(isolated.readWholeTextBounded(localTarget('/virtual/denied'), 3))
.rejects.toMatchObject({ code: 'FS_PERMISSION_DENIED' })
failure.current = new Error('open broke')
await expect(isolated.readWholeTextBounded(localTarget('/virtual/broken'), 3))
.rejects.toMatchObject({ code: 'FS_IO_ERROR' })
} finally {
vi.doUnmock('node:fs/promises')
vi.resetModules()
}
})
})
describe('streamWholeText', () => {
it('streams the whole file as decoded text', async () => {
const file = join(dir, 'a.txt')

View File

@@ -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: 67079f795c705ab4c9cfa476e0458be04a48c6c8
README.zh.md: d812a94fab9f4b7e9d15ff78bd1fea3bcc00c0d9
README.md: bf1dd1c1eb65146258cd64e450749845522e7057
README.zh.md: f3fcc0c3794b972233dc418e93bdd80b1cc8570a

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read bounded or streaming text, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
@@ -17,7 +17,7 @@ A future sandboxed, virtual, or remote backend implements this interface and the
## Service API (`ctx.fs`)
A backend subclasses `FileSystem` and implements twelve primitives.
A backend subclasses `FileSystem` and implements eleven primitives.
| Member | Semantics |
|---|---|
@@ -28,8 +28,7 @@ A backend subclasses `FileSystem` and implements twelve primitives.
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
| `lstat(path, opts?, signal?)` | Return `FsPathInfo` metadata without following the final path component when it is a symlink. This is path-shaped so consumers can reject repository-owned symlinks before `resolve` follows them into a target. |
| `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`). |
| `readTextBounded(target, maxBytes, signal?)` | Read one complete regular UTF-8 file through a backend-owned stable operation, rejecting before retaining more than `maxBytes`. Consumers must not emulate this with `stat` then `readText`, which admits growth and replacement races. |
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
| `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. |
| `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. |
@@ -61,6 +60,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
- **Twelve primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **Eleven primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)).
- **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`),定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、有界或流式读取文本、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。
**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`),定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。
本包是四层文件系统栈中的提供方 seam 层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)
@@ -17,7 +17,7 @@
## 服务 API`ctx.fs`
后端继承 `FileSystem` 并实现十个原语。
后端继承 `FileSystem` 并实现十个原语。
| 成员 | 语义 |
|---|---|
@@ -28,8 +28,7 @@
| `stat(target, signal?)` | 返回 `FsInfo` 元数据(`version``type`、可选 `size`);目标不存在时返回 `undefined`。绝不返回内容。 |
| `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库所有的符号链接进入目标前拒绝它。 |
| `readText(target, signal?)` | 把整个普通文本文件读取为一个解码后的字符串。负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝(`FS_NOT_TEXT`)。 |
| `readTextBounded(target, maxBytes, signal?)` | 通过后端自有的稳定操作读取一个完整的普通 UTF-8 文件,在保留超过 `maxBytes` 前拒绝。消费方不得以先 `stat``readText` 模拟此操作,因为那会容许文件增长与替换竞态。 |
| `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责)。 |
| `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`)⇒ 添加防护。 |
| `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 |
@@ -61,6 +60,6 @@
## 已知限制与延期工作
- **契约只支持文本**:后端以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;二进制安全操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。
- **只有十个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层递归、glob、分页和搜索不在范围内见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
- **只有十个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层递归、glob、分页和搜索不在范围内见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
- **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.md))。
- **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。

View File

@@ -1,7 +1,7 @@
/**
* Filesystem provider seam for one execution world. Backends own stable target
* identity, process paths and file URIs, containment, stable bounded text
* reads, decoding, binary rejection, and atomic mutations. Read windows and
* identity, process paths and file URIs, containment, text reads, decoding,
* binary rejection, and atomic mutations. Read windows and
* observed-state policy stay in consumer and policy plugins; `editText`
* remains here so version check, literal match, and rewrite share one critical
* section.
@@ -172,19 +172,6 @@ export abstract class FileSystem extends Service {
*/
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
/**
* Read one regular UTF-8 text file through a backend-owned stable handle,
* rejecting before more than `maxBytes` are retained. The size check and
* bytes read are one operation: a caller must not emulate this with
* {@link stat} followed by {@link readText}, which admits growth and path
* replacement races between the two calls.
* @param target - the resolved target to read.
* @param maxBytes - positive safe-integer byte ceiling.
* @param signal - aborts the open/read operation.
* @returns the complete decoded text when it fits.
*/
abstract readTextBounded(target: FsTarget, maxBytes: number, signal?: AbortSignal): Promise<string>
/**
* Stream the whole regular text file as decoded text chunks (same text
* semantics as {@link readText}, for large files). The backend owns

View File

@@ -46,11 +46,6 @@ class FakeFileSystem extends FileSystem {
if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND')
return content
}
override async readTextBounded(target: FsTarget, maxBytes: number): Promise<string> {
const content = await this.readText(target)
if (Buffer.byteLength(content) > maxBytes) throw new FsError('too large', 'FS_IO_ERROR')
return content
}
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
const content = await this.readText(target)
return (async function* () { yield content })()

View File

@@ -67,11 +67,6 @@ class FakeFs extends FileSystem {
override async readText(target: FsTarget): Promise<string> {
return this.files.get(target.targetKey) ?? ''
}
override async readTextBounded(target: FsTarget, maxBytes: number): Promise<string> {
const content = await this.readText(target)
if (Buffer.byteLength(content) > maxBytes) throw new Error('too large')
return content
}
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
const content = this.files.get(target.targetKey) ?? ''
return (async function* () { yield content })()