fix(fs): address review — rename to dsh-fs-policy, fs/*-intent events, RFC currency, ENOTDIR

Rename per review naming decisions:
- package dsh-file-context → dsh-fs-policy (dir, package name, plugin name,
  tsconfig refs, importers, type-equiv manifest, generated catalog + module-graph)
- events fs/write-expectation → fs/write-intent, fs/edit-expectation → fs/edit-intent
  (fs/observed unchanged); type FsWriteExpectation → FsWriteIntent, "expectation"
  wording → "intent" throughout
- exported FileContextExec → FsPolicyExec

Make the implemented RFCs describe what shipped, not the superseded designs:
the 2026-06-17 capability-seam + tool-schemas RFCs no longer place policy on
ctx.fs or use full/partial-view authorization, and the fsspec RFC's ctx.fileContext
service prose is rewritten to the fs/* event-gate reality (freshness-based auth).
Sharpen docs/rfc/implemented/AGENTS.md: a rename is a fact to fix IN PLACE — the
"new RFC" escape hatch is for macro decision reversals only, not renames.

Code fixes from review:
- fsio.ts resolveLocalTarget/probe translate ENOTDIR (a parent path segment is a
  file) into the structured FsError taxonomy instead of leaking a raw Node error;
  resolve reports FS_NOT_FOUND, probe reports absent. Regression tests proven to
  fail on the unfixed code.
- tool-fs HMR test now asserts prompt sections (not just tool schemas) are
  withdrawn on disposal.
- fs/observed is a plain (unguarded) ctx.emit: correct the fs-policy comment,
  filesystem.md, and tool-fs module doc that wrongly claimed the tool "contains"
  a throwing listener; a throw surfaces as the tool's isError result.
- drop the false "loaded by the default product config" claim (no config wires
  the fs tools yet), the duplicate ctx.bash service-map row, the stale
  FileReadRequest catalog link-map entry, and the fs/fs README EOF blank line;
  correct the dsh-fs package.json description.
This commit is contained in:
Tianyi Cui
2026-07-02 03:12:38 +08:00
parent e4b4eaaf38
commit 30c1863755
42 changed files with 360 additions and 323 deletions

View File

@@ -1,4 +1,4 @@
# RFC: Split the filesystem seam — provider text mutations plus policy `ctx.fileContext`
# RFC: Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin
Status: implemented
@@ -13,7 +13,7 @@ That makes every future backend reimplement model-facing read semantics and obse
This also creates a real UX dead-end: a windowed read records `view: partial`, and partial views cannot authorize `edit`. A model that reads lines 100-150 of a large file therefore cannot edit line 120 unless it first gets a `full` read, which may be impossible for a file past the read cap. Literal edit only needs freshness: the bytes being matched must still be from the version the model read.
The old RFC already deferred a separate `@deepseek-ai/dsh-file-context` package. This RFC builds that layer and keeps `ctx.fs` close to fsspec-style storage primitives (`info`/`cat`/`open`), without turning it into full fsspec.
The old RFC already deferred a separate `@deepseek-ai/dsh-fs-policy` package. This RFC builds that layer and keeps `ctx.fs` close to fsspec-style storage primitives (`info`/`cat`/`open`), without turning it into full fsspec.
## Decision
@@ -21,14 +21,14 @@ Split the stack into four layers:
```text
tool dsh-tool-fs model-facing schemas + read windowing + text rendering; the EXECUTOR (reads/writes/edits via ctx.fs, dispatches the fs/* events)
policy dsh-file-context observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service)
policy dsh-fs-policy observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service)
provider seam dsh-fs ctx.fs: text IO + atomic mutation primitives (optional version guard)
provider dsh-fs-local local implementation of ctx.fs
```
`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It injects `fs` (not a policy service) and reaches `ctx.fs` directly, dispatching the `fs/*` policy events so `dsh-file-context` can gate and record.
`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It is the executor: it injects `fs` (not a policy service) and reaches `ctx.fs` directly, owns read windowing, and dispatches the `fs/*` events so `dsh-fs-policy` can gate and record.
The tool↔policy COUPLING below was reworked by [the file-context event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md): `dsh-file-context` is now a gate PLUGIN that participates through the `fs/*` events (no `ctx.fileContext` service), and read windowing + the fs I/O moved up into `dsh-tool-fs`. The four-layer split, the provider contract, and the freshness *policy* this RFC decided are unchanged. Read the "`ctx.fileContext.read`/`write`/`edit`" method descriptions below as the policy DECISIONS the gate plugin now makes on the `fs/*` events, and the provider's version guard as optional (omit = unconditional bare provider).
This RFC decided the four-layer split, the provider contract, and the freshness policy. The tool↔policy COUPLING was then refined by [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md): `dsh-fs-policy` is a gate PLUGIN that participates through the `fs/*` events rather than a `ctx.fileContext` method service, so the tool is not method-coupled to it and read windowing + the fs I/O live in `dsh-tool-fs`. This document describes that landed event-gate shape; the provider's version guard is optional (omit = unconditional bare provider).
## Provider Contract
@@ -39,7 +39,7 @@ abstract resolve(path: string): Promise<FsTarget>
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise<FsWriteOutcome>
abstract writeText(target: FsTarget, content: string, expected: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
interface FsInfo {
@@ -48,18 +48,18 @@ interface FsInfo {
size?: number
}
type FsWriteExpectation =
type FsWriteIntent =
| { kind: 'createIfAbsent' }
| { kind: 'replaceIfVersion'; version: FsVersion }
```
`stat` returns metadata, not content. `version` is the freshness token; `type` lets the policy reject directories/special files before reading; `size` lets `ctx.fileContext.read` choose `readText` vs `streamText` without probing by failure. `undefined` means absent.
`stat` returns metadata, not content. `version` is the freshness token; `type` lets the executor reject directories/special files before reading; `size` lets the `read` tool choose `readText` vs `streamText` without probing by failure. `undefined` means absent.
`readText` reads the whole regular text file. `streamText` streams the same text semantics for large files. Both provider primitives own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`; the policy layer never handles raw bytes or reimplements cross-chunk decoding. `readText` is the small-file/direct whole-file primitive, while large model-facing reads use `streamText`.
`writeText` is atomic temp-file + rename with an explicit write expectation. `createIfAbsent` creates a missing target and rejects an existing target with `FS_NOT_OBSERVED`; it is the path used when the owner has no prior read. `replaceIfVersion` replaces only when the target exists at the observed version; a missing target or version mismatch throws `FS_STALE_VERSION`.
`editText` is a provider-level guarded text mutation. It first verifies the target still exists at `expected.version`, then reads the current text, applies literal replacement, and writes atomically. The stale check must happen before literal matching so an edit based on an old read reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` from matching against newer content. Keeping this primitive on the provider seam also preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing `ctx.fileContext` to pull the whole file through the policy layer.
`editText` is a provider-level guarded text mutation. When guarded it first verifies the target still exists at `expected.version`, then reads the current text, applies literal replacement, and writes atomically. The stale check must happen before literal matching so an edit based on an old read reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` from matching against newer content. Keeping this primitive on the provider seam preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing the policy layer to pull the whole file through it.
This is a *text-storage* seam, deliberately half a level above byte-level fsspec (`cat`/`open` hand back raw bytes). UTF-8 decoding, binary/NUL rejection, guarded full-file writes, and guarded literal text edits live in the provider so the policy layer never touches raw bytes, reimplements cross-chunk decoding, or separates stale checks from the mutation critical section. Model-facing concepts still stay out of the provider: no line windows, numbered lines, rendered footers, or observed-state store leak down.
@@ -67,23 +67,25 @@ Deleted from `dsh-fs`: `readPage`, `FsExpectation`, `FsView`, `FsStateSource`, `
## Policy Contract
`@deepseek-ai/dsh-file-context` registers concrete service `ctx.fileContext` and injects `fs`. It is a concrete service, not a seam: it owns the read-windowing and write/edit freshness policy that does not belong on the `FileSystem` provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying).
`@deepseek-ai/dsh-fs-policy` is a plugin, not a service: it registers no `ctx.*` key and injects nothing. It owns the write/edit freshness policy and observed-state that do not belong on the `FileSystem` provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying). It contributes that policy through the `fs/*` event gate the executor dispatches. (This RFC originally proposed a concrete `ctx.fileContext` service with `read`/`write`/`edit` methods; [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) refined it into the plugin described here so the tool is never method-coupled to the policy.)
Observed state lives here as `WeakMap<owner, Map<targetKey, { version }>>`. An entry exists iff the owner has read that target through `ctx.fileContext.read`, so its presence *is* the read record — there is no separate `hasRead` flag. The owner is still derived structurally from `{ agent?: { session? } }`, but that shape no longer belongs to `dsh-fs`.
Observed state lives here as `WeakMap<owner, Map<targetKey, FsVersion>>`. An entry exists iff the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence *is* the prior-observation record — there is no separate `hasRead` flag. The owner is derived structurally from the opaque event actor (`{ agent?: { session? } }`), a shape that lives in `dsh-fs-policy`, not `dsh-fs`.
`read(target, request, exec?, signal?)` is the only read path used by the model-facing `read` tool. It stats the target, rejects absent/non-regular targets, chooses `readText` or `streamText` from `FsInfo`, builds the requested line window from text chunks, records `{ version: info.version }`, and returns the structured outcome that the tool renders.
The plugin decides three `fs/*` events:
`write(target, content, exec?, signal?)` uses freshness policy: no recorded read calls `writeText({ kind: 'createIfAbsent' })`, so only new files can be created blindly; a recorded read calls `writeText({ kind: 'replaceIfVersion', version: vObserved })`, so existing files are replaced only if they are unchanged since the read. A successful write refreshes recorded state from the returned outcome or a post-write `stat`.
- `fs/write-intent` — no prior observation ⇒ `{ kind: 'createIfAbsent' }` (only new files can be created blindly); a prior observation ⇒ `{ kind: 'replaceIfVersion', version: vObserved }` (existing files replaced only if unchanged since the observation). Single-slot decision; does not call `next()`.
- `fs/edit-intent` — requires a prior observation by the owner (else `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. It does not implement literal replacement — it authorizes and supplies the version, and the provider's mutation critical section applies the guard, so concurrent edits based on the same observed version remain one-wins/one-stale.
- `fs/observed` — records `{ version }` for this owner+target after a successful read/write/edit. Synchronous, side-effect-only `WeakMap.set`.
`edit(target, edit, exec?, signal?)` requires a recorded read at `vObserved`, then calls `ctx.fs.editText(target, edit, { version: vObserved })` and refreshes recorded state from the returned version. `ctx.fileContext` does not implement literal replacement itself; it authorizes the operation and passes the observed version to the provider. The provider owns the mutation critical section, so concurrent edits based on the same observed version remain one-wins/one-stale rather than being merged or re-applied. If a backend needs a defensive whole-file edit cap, it should surface that as the same filesystem error taxonomy, but large model-facing reads should stream instead of failing just because the file is large.
The plugin does NO filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — the plugin only supplies `vObserved` as the basis.
## Tool Contract
`dsh-tool-fs` keeps the same schemas and prompt surface. `read` still exposes `file_path`, `offset`, and `limit`; `write` and `edit` are unchanged.
`dsh-tool-fs` keeps the same schemas and prompt surface. `read` still exposes `file_path`, `offset`, and `limit`; `write` and `edit` are unchanged. It is the executor: it validates model args, reads/writes/edits through `ctx.fs` directly, owns line windowing and result rendering (`N: text`, footer, `<path>/<content>` envelope), and dispatches the `fs/*` events.
The tool package only validates model args, calls `ctx.fileContext`, and renders results (`N: text`, footer, `<path>/<content>` envelope). The no-bypass rule is part of the contract: a model-facing `read` must call `ctx.fileContext.read`, never `ctx.fs.readText` or `ctx.fs.streamText`, so every successful read records observed-state before rendering.
Each mutation dispatches its intent waterfall with an `undefined` bare-provider default, then calls `ctx.fs`, then emits `fs/observed`: e.g. `write` does `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` → `ctx.fs.writeText(target, content, intent)` → `ctx.emit('fs/observed', …)`. A `read` stats once, reads/streams, builds the window, and emits `fs/observed`. Passing `exec` as the actor lets `dsh-fs-policy` derive the owner without the tool reaching into the policy.
Direct `ctx.fs` calls are still allowed for non-tool consumers. They are explicit escape hatches: a direct `ctx.fs.readText` records no observed-state, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`.
Because the policy is contributed through events with an `undefined` default, `dsh-tool-fs` is not method-coupled to `dsh-fs-policy`: with the plugin absent, every intent waterfall falls through to `undefined` (unconditional bare-provider write/edit) and `fs/observed` has no listener. Loading the plugin back layers the read-before-write/edit policy on.
## Concurrency Boundary
@@ -97,7 +99,7 @@ Cross-process writes are best-effort freshness plus atomic replacement: `mtime:s
This RFC reverses two decisions from [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) and narrows a third:
- Read-before-write/edit policy moves out of `ctx.fs` and into `ctx.fileContext`.
- Read-before-write/edit policy moves out of `ctx.fs` and into the `dsh-fs-policy` plugin (on the `fs/*` event gate).
- Text reads no longer return backend-numbered line records or `full`/`partial` views; authorization is based on version freshness, so a windowed read can authorize edit when the file is unchanged.
- Literal edit no longer sits behind the old `applyEdit` API that mixed backend mutation with seam-owned observation policy. It remains a provider primitive as `editText`, because version guard + literal match + atomic rewrite must stay inside the provider's mutation critical section.
@@ -105,8 +107,8 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import
## Acceptance Criteria
- `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `writeText` uses `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`.
- `dsh-file-context` adds the observed-state + `read`/`write`/`edit` freshness policy and has HMR/disposal coverage. (It does so as a gate PLUGIN on the `fs/*` events with no `ctx.fileContext` service, per [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) — the original service form this RFC proposed was reworked.)
- `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `writeText` uses `FsWriteIntent` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`.
- `dsh-fs-policy` adds the observed-state + `read`/`write`/`edit` freshness policy and has HMR/disposal coverage. (It does so as a gate PLUGIN on the `fs/*` events with no `ctx.fileContext` service, per [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) — the original service form this RFC proposed was reworked.)
- `dsh-tool-fs` reaches the policy decisions and model-facing schemas stay byte-for-byte unchanged; the observation contract (a read records observed-state; a direct `ctx.fs` read does not) is documented and tested. (The tool injects `fs` and dispatches the `fs/*` events rather than injecting a `fileContext` service, per the event-gate RFC.)
- Windowed read authorizing edit is shown to fail on the pre-refit code and pass after the refit. Existing version-CAS behavior is preserved with a regression test; it is not claimed as a pre-refit failure. An edit based on a stale read must report `FS_STALE_VERSION` before attempting literal matching.
- `dsh-fs-local` carries no line, view, or `formatReadBody` logic; it does carry provider-level `editText` logic.
@@ -116,7 +118,7 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import
## Risks
- Adds a fourth fs package and a new service. This is intentional: it is the previously deferred policy layer, not a second abstract backend seam.
- Direct `ctx.fs` use can surprise callers who later use `ctx.fileContext`. The failure is explicit (`FS_NOT_OBSERVED`) and documented.
- Large-file line windowing moves from the backend to `ctx.fileContext.read`; text decoding and binary rejection stay in `ctx.fs.streamText`, so this is relocation of windowing only, not a second text-IO implementation.
- Direct `ctx.fs` use bypasses the policy: a direct `ctx.fs.readText` emits no `fs/observed`, so under the default policy a later `edit` rejects with `FS_NOT_OBSERVED` until the file is read through the `read` tool. The failure is explicit and documented.
- Large-file line windowing moves from the backend to the `read` tool in `dsh-tool-fs`; text decoding and binary rejection stay in `ctx.fs.streamText`, so this is relocation of windowing only, not a second text-IO implementation.
- Keeping `editText` in the provider seam means every backend must implement the literal replacement contract. This is intentional: the operation is not pure storage, but stale guard + literal match + atomic rewrite is the unit that must stay together for correct error attribution and concurrency behavior. The contract should stay narrow and text-only so future backends can implement it natively or by whole-file rewrite.
- Freshness permits full-file `write` after a windowed read. That is weaker than the old view check, but avoids making large files impossible to edit; prompt guidance should still discourage blind full replaces.