refactor(fs): split filesystem seam into provider ctx.fs + policy ctx.fileContext

Implements the split-the-filesystem-seam RFC. ctx.fs shrinks to a text-storage
provider seam (resolve/stat/readText/streamText/writeText/editText with branded
FsTargetKey/FsVersion and an explicit FsWriteExpectation); the new
dsh-file-context package owns the model-facing policy (read windowing,
observed-state, write/edit freshness) as the concrete ctx.fileContext service.

Authorization is now freshness-based rather than full/partial view: a windowed
read records the file version and authorizes a later edit when the file is
unchanged, removing the dead-end where reading lines 100-150 of a large file
could not edit line 120. editText stays a provider primitive so version guard +
literal match + atomic rewrite remain one critical section, and the stale check
runs before matching so a stale edit reports FS_STALE_VERSION. tool-fs injects
fileContext, never reaching around to ctx.fs (the no-bypass contract).
This commit is contained in:
Dudu-0223
2026-06-26 17:23:18 +08:00
parent c7a197fb5f
commit ef37ce3b9d
42 changed files with 1899 additions and 1466 deletions

View File

@@ -1,38 +1,37 @@
# @deepseek-ai/dsh-fs
The **filesystem seam**: an abstract `FileSystem` service (`ctx.fs`) defining WHAT a filesystem backend does — resolve paths, read bounded text pages, create/replace files, apply literal edits — without saying HOW.
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the text-storage primitives a backend provides — resolve a path, stat metadata, read/stream text, write atomically, and apply a guarded literal edit — without saying HOW.
This package is one third of the filesystem capability, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) and [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md)):
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 RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), and [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)):
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-fs` (this) | the interface: abstract service + vocabulary types + read-before-write/edit policy |
| `@deepseek-ai/dsh-fs-local` | an implementation: the host filesystem |
| `@deepseek-ai/dsh-tool-fs` | the model-facing `read`/`write`/`edit` tool schemas over `ctx.fs` |
| Layer | Package | Role |
|---|---|---|
| tool | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + text rendering |
| policy | `@deepseek-ai/dsh-file-context` | `ctx.fileContext`: observed-state, read windowing, write/edit freshness |
| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + guarded mutation primitives |
| provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation |
A future sandboxed, virtual, or remote backend implements this interface and the tool schemas don't change.
A future sandboxed, virtual, or remote backend implements this interface and the policy/tool layers don't change.
## Service API (`ctx.fs`)
Consumers call the concrete public API; backends implement the four primitives.
A backend subclasses `FileSystem` and implements six primitives.
| Member | Kind | Semantics |
|---|---|---|
| `resolve(path)` | primitive | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
| `readPage(target, request, signal?)` | primitive | Read a bounded UTF-8 text page. Returns line-numbered content, `totalLines`, an opaque `version`, and a `view` (`full` only when the page covered the whole file). |
| `createOrReplace(target, content, expected, signal?)` | primitive | Create/replace a file honoring the `FsExpectation` stale guard. |
| `applyEdit(target, edit, expected, signal?)` | primitive | Atomic literal read-modify-write, verifying the expected version. `oldString` must be non-empty. |
| `read(target, request, exec?, signal?)` | public | Calls `readPage`, then records observed state for the derived owner. |
| `write(target, content, exec?, signal?)` | public | Builds the `FsExpectation` from recorded state, calls `createOrReplace`, refreshes state to `full`. Updating an existing file needs a prior `full` read; a create does not. |
| `edit(target, edit, exec?, signal?)` | public | Requires a prior `full` read by this owner (else `FS_NOT_OBSERVED` / `FS_PARTIAL_OBSERVATION`), rejects empty `oldString`, calls `applyEdit`, refreshes state. |
| `owner(exec?)` | helper | Derives the file-state owner (`exec.agent.session`) — `undefined` when there is none. |
| Member | Semantics |
|---|---|
| `resolve(path)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
| `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). |
| `writeText(target, content, expected, signal?)` | Atomic create/replace honoring the `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`). |
| `editText(target, edit, expected, signal?)` | Version-guarded literal edit. Verifies `expected.version` BEFORE matching, then applies the replacement and writes atomically — one mutation critical section. |
## Read-before-write/edit lives in the seam
## A provider seam, not the policy layer
Write/edit safety depends on backend-defined target identity and version tokens, so `ctx.fs` — not the tool layer — records what each owner has observed (keyed by an opaque owner object, normally the agent session, then by `targetKey`) and enforces the policy. The base class owns owner derivation, the file-state store, and *which* `FsExpectation` to hand the backend; the backend owns version comparison and I/O. Only a `full` view authorizes write/edit; a `partial` view (paged/truncated read) records context but does not.
`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the version-guarded literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state — those model-facing read-windowing and read-before-write/edit policies live one layer up in `ctx.fileContext` ([`@deepseek-ai/dsh-file-context`](../file-context)), so a sandboxed/remote backend inherits no model-facing observation policy.
State is held in a `WeakMap` keyed by the owner object and dropped on disposal (HMR safety). Persistence across sessions is deferred — a resumed session must read files again before write/edit.
`editText` stays on this seam (not composed in the policy layer from a read plus a write) because version guard + literal match + atomic rewrite must stay inside one critical section for correct error attribution and one-wins/one-stale concurrency, and a remote backend may implement it as a native compare-and-edit.
## Vocabulary
`FsTarget` / `FsVersion` are opaque — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_PARTIAL_OBSERVATION`, `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 RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteExpectation` is the explicit 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`). Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `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.

View File

@@ -20,10 +20,12 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -1,62 +1,62 @@
/**
* The filesystem seam (`ctx.fs`): an abstract service defining WHAT a
* filesystem backend does — resolve paths into stable targets, read bounded
* text pages, create/replace files, and apply literal edits — without saying
* HOW. Implementations subclass {@link FileSystem} and register themselves as
* the `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the
* first. Future implementations swap in sandboxed, remote, virtual, or
* project-scoped backends without touching the tool schemas that consume them
* The filesystem provider seam (`ctx.fs`): an abstract service defining the
* text-storage primitives a backend provides — resolve a path into a stable
* target, stat its metadata, read/stream its text, write it atomically with an
* explicit expectation, and apply a guarded literal edit — without saying HOW.
* Implementations subclass {@link FileSystem} and register themselves as the
* `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the first.
* Future implementations swap in sandboxed, remote, virtual, or project-scoped
* backends without touching the model-facing tool schemas
* (`@deepseek-ai/dsh-tool-fs`).
*
* The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See
* the capability-seam RFC for why a swappable capability is three packages.
* The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See the
* capability-seam RFC for why a swappable capability is three (here four)
* packages.
*
* ## Read-before-write/edit lives here, not in the tools
* ## This is a provider seam, not the policy layer
*
* Write/edit safety depends on backend-defined target identity and version
* tokens, so the seam — not the consumer — records what each owner has observed
* and enforces the policy. The base class owns owner derivation, the file-state
* store, and the decision of *which* {@link FsExpectation} to hand a backend;
* the backend owns version comparison and the actual I/O. A consumer passes its
* execution context through {@link read}/{@link write}/{@link edit} and never
* touches the cache, owner key, or version tokens.
* `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns
* UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the
* version-guarded literal-edit critical section — but NOT line windows,
* numbered lines, rendered footers, or observed-state. Those model-facing
* read-windowing and read-before-write/edit policies live one layer up in the
* concrete `ctx.fileContext` service (`@deepseek-ai/dsh-file-context`), so a
* sandboxed/remote backend inherits no model-facing observation policy it has
* no business carrying.
*
* `editText` stays on this seam (not composed in the policy layer from a read
* plus a write) because version guard + literal match + atomic rewrite must
* stay inside one mutation critical section for correct error attribution and
* one-wins/one-stale concurrency, and a remote backend may implement it as a
* native compare-and-edit.
*
* @module @deepseek-ai/dsh-fs
*/
import { Context, Service } from 'cordis'
import { FsError } from './types.ts'
import type {
FsEditOutcome,
FsEditRequest,
FsExecContext,
FsExpectation,
FsReadOutcome,
FsReadRequest,
FsInfo,
FsTarget,
FsVersion,
FsWriteExpectation,
FsWriteOutcome,
FileState,
} from './types.ts'
export {
FsError,
FsTargetKey,
FsVersion,
} from './types.ts'
export type {
FsEditOutcome,
FsEditRequest,
FsErrorCode,
FsExecContext,
FsExpectation,
FsReadOutcome,
FsReadRequest,
FsStateSource,
FsInfo,
FsTarget,
FsTextLine,
FsVersion,
FsView,
FsWriteExpectation,
FsWriteOutcome,
FileState,
} from './types.ts'
declare module 'cordis' {
@@ -66,50 +66,32 @@ declare module 'cordis' {
}
/**
* Abstract filesystem service. Subclass, implement the four backend primitives
* ({@link resolve}, {@link readPage}, {@link createOrReplace},
* {@link applyEdit}), and load the subclass as a plugin — it registers as
* `ctx.fs` (one implementation per context; loading a second throws, cordis'
* standard duplicate-service behavior).
*
* Consumers call the concrete public API ({@link read}/{@link write}/
* {@link edit}), which derives the file-state owner, enforces the
* read-before-write/edit policy, and refreshes recorded state — then delegates
* the actual I/O to the backend primitives.
* Abstract filesystem provider service. Subclass, implement the six text-storage
* primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one
* implementation per context; loading a second throws, cordis' standard
* duplicate-service behavior).
*
* Semantics every backend must honor:
* - {@link resolve} returns a stable {@link FsTarget}; the same underlying file
* reached by different input paths must yield the same `targetKey` so stale
* guards and file-state lookup agree across paths (e.g. through symlinks).
* - {@link readPage} returns line-numbered UTF-8 content with a `version` and a
* `view` (`full` only when the page covered the whole file).
* - {@link createOrReplace} honors the {@link FsExpectation}: `observed`
* rejects with `FS_STALE_VERSION` if the file changed since `version`;
* `partial` rejects existing targets because the owner saw only a
* non-editable view; `unobserved` creates iff the target is absent and
* otherwise rejects.
* - {@link applyEdit} verifies the expected version (stale guard) and is atomic
* (read-modify-write must not interleave with a concurrent edit).
* guards and target lookup agree across paths (e.g. through symlinks).
* - {@link stat} returns {@link FsInfo} metadata (never content) or `undefined`
* when the target is absent.
* - {@link readText}/{@link streamText} read the whole regular text file (the
* stream for large files); both own regular-file checks, UTF-8 decoding,
* binary/NUL rejection, and `FS_NOT_TEXT`.
* - {@link writeText} is atomic temp-file + rename honoring the
* {@link FsWriteExpectation}.
* - {@link editText} verifies `expected.version` BEFORE literal matching (so a
* stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/
* `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement
* and writes atomically — all inside one mutation critical section.
*/
export abstract class FileSystem extends Service {
/**
* Observed-file state, keyed first by the owner object (weakly held, so a
* collected session frees its state), then by {@link FsTarget.targetKey}.
*/
private fileStates = new WeakMap<object, Map<string, FileState>>()
constructor(ctx: Context) {
super(ctx, 'fs')
ctx.effect(() => () => {
// Drop all recorded state on disposal so a reloaded backend starts clean
// (HMR safety). The WeakMap itself would be GC'd, but replacing it makes
// the release observable and immediate for tests.
this.fileStates = new WeakMap()
}, 'fs file-state teardown')
}
// --- Backend primitives (subclass implements; all backend I/O lives here) ---
/**
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May
* perform I/O (a remote/sandboxed backend may need a round-trip to map a path
@@ -118,139 +100,32 @@ export abstract class FileSystem extends Service {
*/
abstract resolve(path: string): Promise<FsTarget>
/** Read a bounded UTF-8 text page from a target. */
abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise<FsReadOutcome>
/** Return target metadata, or `undefined` when the target does not exist. */
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
/** Read the whole regular text file as a single decoded string. */
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
/**
* Create or fully replace a UTF-8 text file, honoring `expected` as the
* stale guard / create-vs-update decision.
* Stream the whole regular text file as decoded text chunks (same text
* semantics as {@link readText}, for large files). The backend owns
* cross-chunk UTF-8 decoding and binary rejection so the policy layer never
* touches raw bytes.
*/
abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise<FsWriteOutcome>
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
/**
* Apply a literal edit to an existing UTF-8 text file, verifying
* `expected.version` as the stale guard. Atomic read-modify-write.
* Create or fully replace a UTF-8 text file atomically, honoring `expected`
* as the create-vs-replace decision and stale guard.
*/
abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
// --- Owner + file-state machinery (shared by all backends) ---
abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise<FsWriteOutcome>
/**
* Derive the file-state owner from an execution context — normally the active
* agent session. Returns `undefined` when no owner can be derived (e.g. a
* direct tool call with no agent); such calls read freely but cannot satisfy
* the write/edit prior-observation policy.
* Apply a literal edit to an existing UTF-8 text file. Verifies
* `expected.version` as the stale guard BEFORE literal matching, then applies
* the replacement and writes atomically — one mutation critical section.
*/
owner(exec?: FsExecContext): object | undefined {
return exec?.agent?.session
}
/** Look up recorded state for an owner+target, if any. */
protected getState(owner: object, targetKey: string): FileState | undefined {
return this.fileStates.get(owner)?.get(targetKey)
}
/** Record (or replace) one owner's observed state for a target. */
protected recordState(owner: object, state: FileState): void {
let byTarget = this.fileStates.get(owner)
if (!byTarget) {
byTarget = new Map()
this.fileStates.set(owner, byTarget)
}
byTarget.set(state.targetKey, state)
}
// --- Concrete public API (orchestration; consumers call these) ---
/**
* Read a bounded text page and, when an owner is derivable, record the
* observed state (a `full` view authorizes later write/edit; a `partial` view
* does not).
*/
async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise<FsReadOutcome> {
const outcome = await this.readPage(target, request, signal)
const owner = this.owner(exec)
if (owner) {
this.recordState(owner, {
targetKey: target.targetKey,
displayPath: target.displayPath,
version: outcome.version,
view: outcome.view,
updatedAt: this.now(),
source: 'read',
})
}
return outcome
}
/**
* Create or fully replace a file. Updating an existing file requires a `full`
* prior observation by this owner; a create (no prior state, target absent)
* does not. After a successful write the recorded state refreshes to `full`
* at the new version so a follow-up modification needs no re-read.
*/
async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise<FsWriteOutcome> {
const owner = this.owner(exec)
const prior = owner ? this.getState(owner, target.targetKey) : undefined
const expected: FsExpectation = prior
? prior.view === 'full'
? { kind: 'observed', version: prior.version }
: { kind: 'partial', version: prior.version }
: { kind: 'unobserved' }
const outcome = await this.createOrReplace(target, content, expected, signal)
if (owner) {
this.recordState(owner, {
targetKey: target.targetKey,
displayPath: target.displayPath,
version: outcome.version,
view: 'full',
updatedAt: this.now(),
source: 'write',
})
}
return outcome
}
/**
* Apply a literal edit. Always requires a `full` prior observation by this
* owner. No owner or absent state rejects with `FS_NOT_OBSERVED`; a partial
* view rejects with `FS_PARTIAL_OBSERVATION`; an empty `oldString` rejects
* before backend I/O. There is no "create via edit". Refreshes recorded
* state to `full` at the new version on success.
*/
async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise<FsEditOutcome> {
if (edit.oldString.length === 0) {
throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND')
}
const owner = this.owner(exec)
const prior = owner ? this.getState(owner, target.targetKey) : undefined
if (!owner || !prior) {
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
}
if (prior.view !== 'full') {
throw new FsError(`edit requires a full read of "${target.displayPath}" first`, 'FS_PARTIAL_OBSERVATION')
}
const outcome = await this.applyEdit(target, edit, { version: prior.version }, signal)
this.recordState(owner, {
targetKey: target.targetKey,
displayPath: target.displayPath,
version: outcome.version,
view: 'full',
updatedAt: this.now(),
source: 'edit',
})
return outcome
}
/**
* Wall-clock now (ms). A protected seam so tests can use deterministic
* timestamps; production uses `Date.now()`.
*/
protected now(): number {
return Date.now()
}
abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
}
export default FileSystem

View File

@@ -1,34 +1,49 @@
/**
* Vocabulary for the filesystem capability seam (`ctx.fs`): the request/outcome
* shapes backends produce and consumers format, the opaque target/version
* identities, the per-owner file-state record, and the typed error taxonomy.
* Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque
* target/version identities, the metadata `stat` returns, the write-expectation
* and outcome shapes, the literal-edit request/outcome, and the typed error
* taxonomy.
*
* These types are shared by every backend (`@deepseek-ai/dsh-fs-local` and
* future sandboxed/remote backends) and by the model-facing consumer
* (`@deepseek-ai/dsh-tool-fs`). They deliberately avoid host-path assumptions:
* `targetKey` and `version` are opaque tokens, and `displayPath` is the only
* field a consumer may show.
* future sandboxed/remote backends) and by the policy layer
* (`@deepseek-ai/dsh-file-context`). They are deliberately a *text-storage*
* vocabulary half a level above byte-level fsspec: `readText`/`streamText` hand
* back decoded text, never raw bytes. Host-path assumptions stay out — `targetKey`
* and `version` are opaque branded tokens, and `displayPath` is the only field a
* consumer may show.
*
* Model-facing concepts (line windows, numbered lines, observed-state) do NOT
* live here; they belong to the policy layer (`ctx.fileContext`).
*
* @module @deepseek-ai/dsh-fs/types
*/
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Branded } from '@deepseek-ai/dsh-brand'
/**
* Minimal structural view of a tool execution the filesystem seam needs to
* derive a file-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution`
* satisfies this shape, so the consumer passes its `exec` straight through
* without `dsh-fs` importing `dsh-tools`, `dsh-agent`, or `dsh-session`.
*
* The owner is `agent.session` when present. It is treated as an opaque object
* identity (a `WeakMap` key); `dsh-fs` never reads any of its fields.
* Opaque key for stale guards and target lookup. The local backend uses a
* realpath-like string; a remote backend might use a workspace URI or file id.
* Consumers MUST NOT parse it or assume it is a local absolute path.
*/
export interface FsExecContext {
/** The agent on whose behalf the call runs, when there is one. */
agent?: {
/** The session that owns observed-file state, used as an opaque key. */
session?: object
}
export type FsTargetKey = Branded<'FsTargetKey'>
/** Brand a string as an {@link FsTargetKey}. */
export function FsTargetKey(key: string): FsTargetKey {
return key as FsTargetKey
}
/**
* Opaque file-version token — the freshness token a write/edit guards against.
* The local backend derives it from mtime+size; a remote backend might use a
* revision id. The policy layer records it for stale checks; consumers may
* display related metadata but MUST NOT interpret this token.
*/
export type FsVersion = Branded<'FsVersion'>
/** Brand a string as an {@link FsVersion}. */
export function FsVersion(v: string): FsVersion {
return v as FsVersion
}
/**
@@ -38,12 +53,8 @@ export interface FsExecContext {
export interface FsTarget {
/** The original model/plugin-supplied path, for diagnostics only. */
inputPath: string
/**
* Opaque key for stale guards and file-state lookup. The local backend uses
* a realpath-like string; a remote backend might use a workspace URI or file
* id. Consumers MUST NOT parse it or assume it is a local absolute path.
*/
targetKey: string
/** Opaque key for stale guards and target lookup. */
targetKey: FsTargetKey
/**
* Path for model/UI-facing output. May be a local absolute path,
* workspace-relative path, or remote URI depending on the backend.
@@ -52,64 +63,30 @@ export interface FsTarget {
}
/**
* Opaque file-version token. The local backend derives it from mtime+size; a
* remote backend might use a revision id. `ctx.fs` records it for stale checks;
* consumers may display related metadata but MUST NOT interpret this token.
* Metadata about a target — what {@link FileSystem.stat} returns. Lets the
* policy layer reject directories/special files before reading and choose
* `readText` vs `streamText` from `size` without probing by failure. `version`
* is the freshness token. `undefined` from `stat` means the target is absent.
*/
export type FsVersion = string
/** Resolved read window. The consumer applies its defaults/caps before calling. */
export interface FsReadRequest {
/** 1-based first line to return. */
offset: number
/** Maximum number of lines to return. */
limit: number
}
/** One line returned from a text file. */
export interface FsTextLine {
/** 1-based line number in the file. */
number: number
/** Line text without its trailing newline. */
text: string
}
/** Whether a recorded/returned view covers the whole file or only part of it. */
export type FsView = 'full' | 'partial'
/** Outcome of a bounded text read. */
export interface FsReadOutcome {
/** 1-based first line requested. */
offset: number
/** Maximum number of lines requested. */
limit: number
/** Returned lines, already numbered. */
lines: FsTextLine[]
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
totalLines: number
/** Whether selected output hit the byte cap before EOF or the requested limit. */
truncatedByBytes?: true
/** Opaque version of the file at read time. */
export interface FsInfo {
/** Opaque freshness token of the target right now. */
version: FsVersion
/**
* Whether this read saw the whole file (`full`) or only part of it
* (`partial`). Only a `full` view authorizes a later write/edit.
*/
view: FsView
/** Whether the target is a regular file, a directory, or something else. */
type: 'file' | 'directory' | 'other'
/** Byte size of a regular file, when the backend can report it. */
size?: number
}
/**
* The read-before-write decision the base service hands to a backend for a
* full-file write. `observed` means the owner has a `full` view recorded at
* `version` (the backend rejects if the file has since changed); `partial`
* means the owner saw only a non-editable view of that target; `unobserved`
* means there is no prior view (the backend may create iff the target is
* absent, else rejects as not observed).
* The explicit intent of a {@link FileSystem.writeText} call. `createIfAbsent`
* creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`
* (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 a
* version mismatch throws `FS_STALE_VERSION`.
*/
export type FsExpectation =
| { kind: 'observed'; version: FsVersion }
| { kind: 'partial'; version: FsVersion }
| { kind: 'unobserved' }
export type FsWriteExpectation =
| { kind: 'createIfAbsent' }
| { kind: 'replaceIfVersion'; version: FsVersion }
/** Outcome of a full-file write. */
export interface FsWriteOutcome {
@@ -139,29 +116,6 @@ export interface FsEditOutcome {
version: FsVersion
}
/** Source that last touched a recorded {@link FileState}. */
export type FsStateSource = 'read' | 'write' | 'edit'
/**
* What an owner has observed about one target. Keyed (inside the service) first
* by the owner object, then by {@link FsTarget.targetKey}. Only a `full` view
* authorizes write/edit.
*/
export interface FileState {
/** Backend target identity this state describes. */
targetKey: string
/** Display path captured when the state was recorded. */
displayPath: string
/** Opaque version the owner last saw. */
version: FsVersion
/** Whether the owner saw the whole file or only part of it. */
view: FsView
/** Wall-clock time the state was last updated (ms since epoch). */
updatedAt: number
/** Operation that produced this state. */
source: FsStateSource
}
/**
* Stable, machine-routable codes for filesystem failures. Carried on
* {@link FsError}; the tool registry surfaces `{ name, code }` on `isError`
@@ -173,7 +127,6 @@ export type FsErrorCode =
| 'FS_NOT_REGULAR_FILE'
| 'FS_STALE_VERSION'
| 'FS_NOT_OBSERVED'
| 'FS_PARTIAL_OBSERVATION'
| 'FS_AMBIGUOUS_EDIT'
| 'FS_EDIT_NOT_FOUND'
| 'FS_ABORTED'

View File

@@ -1,98 +1,69 @@
/**
* Tests for the filesystem service seam itself: registration/disposal, owner
* derivation, and the read-before-write/edit policy the base class enforces
* (which `FsExpectation` it hands the backend, multi-owner isolation, and
* state refresh) — all exercised through a fake in-memory backend that records
* the expectations it received.
* Tests for the filesystem provider seam itself: registration, duplicate-service
* behavior, disposal, and the branded id factories. The provider primitives and
* policy live in `dsh-fs-local` and `dsh-file-context`; this seam owns only the
* abstract service contract, so a minimal fake backend exercises it.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FileSystem, FsError } from '@deepseek-ai/dsh-fs'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsEditOutcome,
FsEditRequest,
FsExpectation,
FsReadOutcome,
FsReadRequest,
FsInfo,
FsTarget,
FsView,
FsWriteExpectation,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
/** A fake backend: an in-memory file table, recording every expectation it is handed. */
/** A minimal in-memory fake implementing the six provider primitives. */
class FakeFileSystem extends FileSystem {
files = new Map<string, string>()
versions = new Map<string, number>()
/** View the next `readPage` should report (tests flip this for partial reads). */
nextReadView: FsView = 'full'
/** Expectations handed to `createOrReplace`, in call order. */
writeExpectations: FsExpectation[] = []
/** Versions handed to `applyEdit`, in call order. */
editExpectedVersions: string[] = []
private bump(key: string): string {
const next = (this.versions.get(key) ?? 0) + 1
this.versions.set(key, next)
return `v${next}`
}
override async resolve(path: string): Promise<FsTarget> {
return { inputPath: path, targetKey: path, displayPath: path }
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
}
override async readPage(target: FsTarget, request: FsReadRequest): Promise<FsReadOutcome> {
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
const content = this.files.get(target.targetKey)
if (content === undefined) return undefined
return { version: FsVersion('v1'), type: 'file', size: content.length }
}
override async readText(target: FsTarget): Promise<string> {
const content = this.files.get(target.targetKey)
if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND')
const allLines = content.split('\n')
const lines = allLines
.slice(request.offset - 1, request.offset - 1 + request.limit)
.map((text, i) => ({ number: request.offset + i, text }))
return {
offset: request.offset,
limit: request.limit,
lines,
totalLines: allLines.length,
version: `v${this.versions.get(target.targetKey) ?? 0}`,
view: this.nextReadView,
}
return content
}
override async createOrReplace(target: FsTarget, content: string, expected: FsExpectation): Promise<FsWriteOutcome> {
this.writeExpectations.push(expected)
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
const content = await this.readText(target)
return (async function* () { yield content })()
}
override async writeText(target: FsTarget, content: string, _expected: FsWriteExpectation): Promise<FsWriteOutcome> {
const existed = this.files.has(target.targetKey)
this.files.set(target.targetKey, content)
return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) }
return { operation: existed ? 'update' : 'create', version: FsVersion('v2') }
}
override async applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: string }): Promise<FsEditOutcome> {
this.editExpectedVersions.push(expected.version)
override async editText(target: FsTarget, edit: FsEditRequest): Promise<FsEditOutcome> {
const content = this.files.get(target.targetKey) ?? ''
this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString))
return { replacements: 1, replaceAll: edit.replaceAll, version: this.bump(target.targetKey) }
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') }
}
}
async function setup() {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
return { ctx, fs }
}
const READ_ALL: FsReadRequest = { offset: 1, limit: 2000 }
const ownerExec = (session: object) => ({ agent: { session } })
describe('FileSystem service seam', () => {
it('registers as ctx.fs and serves the API', async () => {
const { fs } = await setup()
describe('FileSystem provider seam', () => {
it('registers as ctx.fs and serves the primitives', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
fs.files.set('a.txt', 'hi')
const outcome = await fs.read(await fs.resolve('a.txt'), READ_ALL)
expect(outcome.lines).toEqual([{ number: 1, text: 'hi' }])
const target = await fs.resolve('a.txt')
expect((await fs.stat(target))?.type).toBe('file')
expect(await fs.readText(target)).toBe('hi')
})
it('throws when a second implementation is loaded (duplicate service)', async () => {
const { ctx } = await setup()
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
await expect(ctx.plugin(FakeFileSystem)).rejects.toThrow()
})
@@ -103,203 +74,30 @@ describe('FileSystem service seam', () => {
await fiber.dispose()
expect(ctx.fs).toBeUndefined()
})
})
describe('owner derivation', () => {
it('derives the owner from exec.agent.session', async () => {
const { fs } = await setup()
const session = {}
expect(fs.owner(ownerExec(session))).toBe(session)
})
it('returns undefined with no exec, no agent, or no session', async () => {
const { fs } = await setup()
expect(fs.owner()).toBeUndefined()
expect(fs.owner({})).toBeUndefined()
expect(fs.owner({ agent: {} })).toBeUndefined()
})
})
describe('read records observed state', () => {
it('a full read authorizes a later in-place write (observed expectation)', async () => {
const { fs } = await setup()
const exec = ownerExec({})
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await fs.read(target, READ_ALL, exec)
await fs.write(target, 'goodbye', exec)
expect(fs.writeExpectations).toEqual([{ kind: 'observed', version: 'v0' }])
})
it('a partial read does NOT authorize a write (passes a partial expectation)', async () => {
const { fs } = await setup()
const exec = ownerExec({})
fs.files.set('a.txt', 'hello')
fs.nextReadView = 'partial'
const target = await fs.resolve('a.txt')
await fs.read(target, { offset: 1, limit: 1 }, exec)
await fs.write(target, 'goodbye', exec)
expect(fs.writeExpectations).toEqual([{ kind: 'partial', version: 'v0' }])
})
it('skips recording when there is no owner', async () => {
const { fs } = await setup()
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await fs.read(target, READ_ALL) // no exec
await fs.write(target, 'goodbye') // no exec → cannot be observed
expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }])
})
})
describe('write policy', () => {
it('a create (no prior state) is unobserved', async () => {
const { fs } = await setup()
const exec = ownerExec({})
const target = await fs.resolve('new.txt')
const outcome = await fs.write(target, 'fresh', exec)
expect(outcome.operation).toBe('create')
expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }])
})
it('refreshes state to full after a write, so a follow-up edit needs no re-read', async () => {
const { fs } = await setup()
const exec = ownerExec({})
const target = await fs.resolve('a.txt')
await fs.write(target, 'one', exec) // create → state now full at v1
await fs.edit(target, { oldString: 'one', newString: 'two', replaceAll: false }, exec)
expect(fs.editExpectedVersions).toEqual(['v1'])
})
})
describe('edit policy', () => {
it('rejects with FS_NOT_OBSERVED when the file was never read', async () => {
const { fs } = await setup()
const exec = ownerExec({})
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await expect(
fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec),
).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('rejects with FS_PARTIAL_OBSERVATION when only a partial view was recorded', async () => {
const { fs } = await setup()
const exec = ownerExec({})
fs.files.set('a.txt', 'hello')
fs.nextReadView = 'partial'
const target = await fs.resolve('a.txt')
await fs.read(target, { offset: 1, limit: 1 }, exec)
await expect(
fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec),
).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' })
})
it('rejects an empty oldString before calling the backend primitive', async () => {
const { fs } = await setup()
const exec = ownerExec({})
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await fs.read(target, READ_ALL, exec)
await expect(
fs.edit(target, { oldString: '', newString: 'bye', replaceAll: false }, exec),
).rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
expect(fs.editExpectedVersions).toEqual([])
})
it('rejects when there is no owner (cannot prove prior observation)', async () => {
const { fs } = await setup()
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await expect(
fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }),
).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('proceeds after a full read, passing the recorded version as the stale guard', async () => {
const { fs } = await setup()
const exec = ownerExec({})
fs.files.set('a.txt', 'hello')
fs.versions.set('a.txt', 7) // distinguishable version
const target = await fs.resolve('a.txt')
await fs.read(target, READ_ALL, exec)
await fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)
expect(fs.editExpectedVersions).toEqual(['v7'])
})
})
describe('multi-owner isolation', () => {
it('owner A reading does not grant owner B edit authority', async () => {
const { fs } = await setup()
const a = ownerExec({})
const b = ownerExec({})
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await fs.read(target, READ_ALL, a)
// B never read it → B's edit must be rejected.
await expect(
fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b),
).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
// A still may edit.
await expect(
fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a),
).resolves.toMatchObject({ replacements: 1 })
})
it('each owner records its own observed version independently', async () => {
const { fs } = await setup()
const a = ownerExec({})
const b = ownerExec({})
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await fs.read(target, READ_ALL, a) // A sees v0
await fs.write(target, 'mid', b) // B writes unobserved → file now v1
await fs.write(target, 'late', a) // A still holds its v0 observation
expect(fs.writeExpectations).toEqual([
{ kind: 'unobserved' },
{ kind: 'observed', version: 'v0' },
])
})
})
describe('disposal releases recorded state', () => {
it('a fresh provider after disposal starts with no inherited state', async () => {
it('streamText yields the same text readText returns', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(FakeFileSystem)
const fs1 = ctx.fs as FakeFileSystem
const exec = ownerExec({})
fs1.files.set('a.txt', 'hello')
await fs1.read(await fs1.resolve('a.txt'), READ_ALL, exec)
await fiber.dispose()
await ctx.plugin(FakeFileSystem)
const fs2 = ctx.fs as FakeFileSystem
fs2.files.set('a.txt', 'hello')
const target = await fs2.resolve('a.txt')
// Reusing the same exec/owner object: state must NOT carry over.
await expect(
fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec),
).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
const fs = ctx.fs as FakeFileSystem
fs.files.set('a.txt', 'one\ntwo')
const target = await fs.resolve('a.txt')
let streamed = ''
for await (const chunk of await fs.streamText(target)) streamed += chunk
expect(streamed).toBe(await fs.readText(target))
})
it('stat returns undefined for an absent target', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
})
})
describe('branded id factories', () => {
it('FsTargetKey and FsVersion brand a string at compile time (identity at runtime)', () => {
expect(FsTargetKey('k')).toBe('k')
expect(FsVersion('v')).toBe('v')
})
})

View File

@@ -8,6 +8,7 @@
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../util/brand" },
{ "path": "../../llm/llm" }
]
}