Add filesystem capability seam and tools
This commit is contained in:
38
packages/fs/fs/README.md
Normal file
38
packages/fs/fs/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# @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.
|
||||
|
||||
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)):
|
||||
|
||||
| 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` |
|
||||
|
||||
A future sandboxed, virtual, or remote backend implements this interface and the tool schemas don't change.
|
||||
|
||||
## Service API (`ctx.fs`)
|
||||
|
||||
Consumers call the concrete public API; backends implement the four 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. |
|
||||
|
||||
## Read-before-write/edit lives in the seam
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
30
packages/fs/fs/package.json
Normal file
30
packages/fs/fs/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs",
|
||||
"description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service, and the read-before-write/edit file-state contract",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
256
packages/fs/fs/src/index.ts
Normal file
256
packages/fs/fs/src/index.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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
|
||||
* (`@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.
|
||||
*
|
||||
* ## Read-before-write/edit lives here, not in the tools
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { FsError } from './types.ts'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsExecContext,
|
||||
FsExpectation,
|
||||
FsReadOutcome,
|
||||
FsReadRequest,
|
||||
FsTarget,
|
||||
FsVersion,
|
||||
FsWriteOutcome,
|
||||
FileState,
|
||||
} from './types.ts'
|
||||
|
||||
export {
|
||||
FsError,
|
||||
} from './types.ts'
|
||||
export type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsErrorCode,
|
||||
FsExecContext,
|
||||
FsExpectation,
|
||||
FsReadOutcome,
|
||||
FsReadRequest,
|
||||
FsStateSource,
|
||||
FsTarget,
|
||||
FsTextLine,
|
||||
FsVersion,
|
||||
FsView,
|
||||
FsWriteOutcome,
|
||||
FileState,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
fs: FileSystem
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
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
|
||||
* to a stable identity), hence async even though the local backend only
|
||||
* normalizes + realpaths.
|
||||
*/
|
||||
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>
|
||||
|
||||
/**
|
||||
* Create or fully replace a UTF-8 text file, honoring `expected` as the
|
||||
* stale guard / create-vs-update decision.
|
||||
*/
|
||||
abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
|
||||
/**
|
||||
* Apply a literal edit to an existing UTF-8 text file, verifying
|
||||
* `expected.version` as the stale guard. Atomic read-modify-write.
|
||||
*/
|
||||
abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
|
||||
// --- Owner + file-state machinery (shared by all backends) ---
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
export default FileSystem
|
||||
194
packages/fs/fs/src/types.ts
Normal file
194
packages/fs/fs/src/types.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs/types
|
||||
*/
|
||||
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A path resolved by a backend into a stable identity. `resolve()` produces
|
||||
* this; every other operation takes it.
|
||||
*/
|
||||
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
|
||||
/**
|
||||
* Path for model/UI-facing output. May be a local absolute path,
|
||||
* workspace-relative path, or remote URI depending on the backend.
|
||||
*/
|
||||
displayPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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. */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export type FsExpectation =
|
||||
| { kind: 'observed'; version: FsVersion }
|
||||
| { kind: 'partial'; version: FsVersion }
|
||||
| { kind: 'unobserved' }
|
||||
|
||||
/** Outcome of a full-file write. */
|
||||
export interface FsWriteOutcome {
|
||||
/** Whether the write created a new file or replaced an existing one. */
|
||||
operation: 'create' | 'update'
|
||||
/** Opaque version of the file after the write. */
|
||||
version: FsVersion
|
||||
}
|
||||
|
||||
/** A literal-replacement edit request. */
|
||||
export interface FsEditRequest {
|
||||
/** Literal non-empty text to replace. Must match exactly (after line-ending normalization). */
|
||||
oldString: string
|
||||
/** Literal replacement text. An empty string deletes the matched text. */
|
||||
newString: string
|
||||
/** Replace every match instead of requiring exactly one. */
|
||||
replaceAll: boolean
|
||||
}
|
||||
|
||||
/** Outcome of a literal edit. */
|
||||
export interface FsEditOutcome {
|
||||
/** Number of literal replacements applied. */
|
||||
replacements: number
|
||||
/** Whether every match was replaced. */
|
||||
replaceAll: boolean
|
||||
/** Opaque version of the file after the edit. */
|
||||
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`
|
||||
* results so retry/permission/UI layers can branch without parsing messages.
|
||||
*/
|
||||
export type 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'
|
||||
|
||||
/**
|
||||
* Typed filesystem error. Extends {@link HarnessError} so it carries a stable
|
||||
* {@link FsErrorCode} and chains `cause`. `dsh-fs` owns this vocabulary so
|
||||
* backends and the policy layer raise the same codes instead of each inventing
|
||||
* message strings.
|
||||
*/
|
||||
export class FsError extends HarnessError {
|
||||
override readonly code: FsErrorCode
|
||||
|
||||
constructor(message: string, code: FsErrorCode, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
313
packages/fs/fs/tests/service.spec.ts
Normal file
313
packages/fs/fs/tests/service.spec.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { FileSystem, FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsExpectation,
|
||||
FsReadOutcome,
|
||||
FsReadRequest,
|
||||
FsTarget,
|
||||
FsView,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** A fake backend: an in-memory file table, recording every expectation it is handed. */
|
||||
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 }
|
||||
}
|
||||
|
||||
override async readPage(target: FsTarget, request: FsReadRequest): Promise<FsReadOutcome> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
override async createOrReplace(target: FsTarget, content: string, expected: FsExpectation): Promise<FsWriteOutcome> {
|
||||
this.writeExpectations.push(expected)
|
||||
const existed = this.files.has(target.targetKey)
|
||||
this.files.set(target.targetKey, content)
|
||||
return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) }
|
||||
}
|
||||
|
||||
override async applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: string }): Promise<FsEditOutcome> {
|
||||
this.editExpectedVersions.push(expected.version)
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
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' }])
|
||||
})
|
||||
|
||||
it('throws when a second implementation is loaded (duplicate service)', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(ctx.plugin(FakeFileSystem)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('removes the service when the providing fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FakeFileSystem)
|
||||
expect(ctx.fs).toBeDefined()
|
||||
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 () => {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('FsError', () => {
|
||||
it('carries a stable code and HarnessError name', () => {
|
||||
const error = new FsError('nope', 'FS_NOT_FOUND')
|
||||
expect(error.code).toBe('FS_NOT_FOUND')
|
||||
expect(error.name).toBe('FsError')
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
})
|
||||
})
|
||||
13
packages/fs/fs/tsconfig.json
Normal file
13
packages/fs/fs/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../llm/llm" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user