refactor(fs): make dsh-file-context an event-gate plugin, not a method service

Invert the tool↔policy control flow per the file-context event-gate RFC.
dsh-tool-fs becomes the executor — it reads/writes/edits through ctx.fs
directly, owns read windowing, and dispatches fs/write-expectation /
fs/edit-expectation (single-slot waterfalls) plus a contained fs/observed
emit. dsh-file-context drops its ctx.fileContext service and becomes a pure
event-gate plugin (observed-state + read-before-edit + version-guarded
write/edit, decided on those events). The provider's version guard becomes
optional so ctx.fs alone is a complete unconstrained text-storage seam:
removing the policy plugin gracefully loses the policy instead of breaking
the tool at a service-injection boundary.
This commit is contained in:
Dudu-0223
2026-06-28 13:49:02 +08:00
parent d612ebaef1
commit 90dceea0e4
35 changed files with 1229 additions and 793 deletions
+5 -5
View File
@@ -6,17 +6,17 @@ The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepse
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
// ctx.fs is now the local backend; load @deepseek-ai/dsh-file-context for policy
// and @deepseek-ai/dsh-tool-fs to expose read/write/edit to the model.
// ctx.fs is now the local backend; load @deepseek-ai/dsh-file-context for the
// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit.
```
## Behavior
- **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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.
- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The policy layer (`ctx.fileContext`) decides which to call by size and owns the line windowing.
- **`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`. Honors the `FsWriteExpectation`: `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. Verifies the expected version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content), 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`).
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. 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`) decides which to call by size and owns the line windowing.
- **`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`. 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`).
## `cwd` is not a sandbox
+13 -5
View File
@@ -120,7 +120,7 @@ export class LocalFileSystem extends FileSystem {
override async writeText(
target: FsTarget,
content: string,
expected: FsWriteExpectation,
expected?: FsWriteExpectation,
signal?: AbortSignal,
): Promise<FsWriteOutcome> {
return this.withLock(target.targetKey, async () => {
@@ -129,16 +129,19 @@ export class LocalFileSystem extends FileSystem {
throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
if (expected.kind === 'replaceIfVersion') {
if (expected?.kind === 'replaceIfVersion') {
// Stale guard: the file must still exist at the version the owner observed.
if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')
if (existing.version !== expected.version) {
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
}
} else if (existing) {
} else if (expected?.kind === 'createIfAbsent' && existing) {
// createIfAbsent onto an existing file: a blind overwrite — require a read first.
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
}
// expected === undefined: unconditional create-or-overwrite (the bare
// provider) — no version guard, no read-first requirement. Still atomic
// (the per-target lock is unconditional), so the write is never torn.
await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals)
const after = await probe(target.targetKey)
@@ -152,16 +155,21 @@ export class LocalFileSystem extends FileSystem {
override async editText(
target: FsTarget,
edit: FsEditRequest,
expected: { version: FsVersion },
expected?: { version: FsVersion },
signal?: AbortSignal,
): Promise<FsEditOutcome> {
return this.withLock(target.targetKey, async () => {
const existing = await probe(target.targetKey)
// Stale guard BEFORE literal matching: an edit based on an old read reports
// FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content.
// A missing target reports FS_STALE_VERSION on BOTH paths (guarded and
// unconditional) — one "cannot edit this target now" code.
if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
if (existing.version !== expected.version) {
// expected === undefined: unconditional edit of the current content — no
// version guard. Still inside the per-target lock, so the read→match→write
// window is serialized and atomic.
if (expected && existing.version !== expected.version) {
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
}
@@ -144,6 +144,26 @@ describe('writeText', () => {
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('unconditionally creates a new file with no expectation (bare provider)', async () => {
const target = await fs.resolve('new.txt')
const outcome = await fs.writeText(target, 'fresh')
expect(outcome.operation).toBe('create')
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
})
it('unconditionally OVERWRITES an existing file with no expectation (bare provider)', async () => {
await writeFile(join(dir, 'a.txt'), 'old')
const target = await fs.resolve('a.txt')
const outcome = await fs.writeText(target, 'clobbered')
expect(outcome.operation).toBe('update')
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
})
it('rejects writing onto a directory even with no expectation', async () => {
const target = await fs.resolve('.')
await expect(fs.writeText(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('releases per-target mutation locks after success and failure', async () => {
const target = await fs.resolve('a.txt')
await fs.writeText(target, 'created', { kind: 'createIfAbsent' })
@@ -173,6 +193,28 @@ describe('editText', () => {
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
})
it('unconditionally edits the current content with no expectation (bare provider)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const target = await fs.resolve('a.txt')
// No version guard: any current content is edited, regardless of version.
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false })
expect(outcome.replacements).toBe(1)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
})
it('reports a missing target as FS_STALE_VERSION even with no expectation (bare provider)', async () => {
const target = await fs.resolve('missing.txt')
await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }))
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
})
it('still reports literal-match codes with no expectation (FS_EDIT_NOT_FOUND, unrelated to freshness)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const target = await fs.resolve('a.txt')
await expect(fs.editText(target, { oldString: 'absent', newString: 'x', replaceAll: false }))
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
})
it('rejects a deleted target as stale (before matching)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello')
const target = await fs.resolve('a.txt')