Merge pull request #134 from deepseek-harness/codex/fs-directory-listing
feat(fs): add directory listing seam
This commit is contained in:
@@ -209,7 +209,7 @@ Single-slot decision: produce the optional version guard for the next FileSystem
|
||||
|
||||
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md)
|
||||
|
||||
Source: [`packages/fs/fs/src/index.ts:117`](../../packages/fs/fs/src/index.ts)
|
||||
Source: [`packages/fs/fs/src/index.ts:119`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
#### `fs/observed` — emit
|
||||
|
||||
@@ -221,7 +221,7 @@ Record that an actor observed a target at a version, after a successful read/wri
|
||||
|
||||
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md)
|
||||
|
||||
Source: [`packages/fs/fs/src/index.ts:129`](../../packages/fs/fs/src/index.ts)
|
||||
Source: [`packages/fs/fs/src/index.ts:131`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
#### `fs/write-intent` — waterfall
|
||||
|
||||
@@ -233,7 +233,7 @@ Single-slot decision: produce the write intent for the next FileSystem.writeText
|
||||
|
||||
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md)
|
||||
|
||||
Source: [`packages/fs/fs/src/index.ts:105`](../../packages/fs/fs/src/index.ts)
|
||||
Source: [`packages/fs/fs/src/index.ts:107`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
### `llm/*`
|
||||
|
||||
@@ -445,13 +445,14 @@ Source: [`packages/compact/compact/src/index.ts:63`](../../packages/compact/comp
|
||||
|
||||
### `ctx.fs` — `FileSystem` (abstract seam)
|
||||
|
||||
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).
|
||||
Abstract filesystem provider service. Subclass, implement the seven 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:
|
||||
|
||||
- resolve returns a stable FsTarget; the same underlying file reached by different input paths must yield the same `targetKey` so stale guards and target lookup agree across paths (e.g. through symlinks).
|
||||
- stat returns FsInfo metadata (never content) or `undefined` when the target is absent.
|
||||
- readText/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`.
|
||||
- listDir returns direct children of a directory in stable name order with resolved child targets and cheap metadata only. It never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`.
|
||||
- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteIntent to guard the write.
|
||||
- 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. `expected` is OPTIONAL: omit it for an unconditional edit of the current content (a missing target still reports `FS_STALE_VERSION`).
|
||||
|
||||
@@ -460,13 +461,14 @@ abstract resolve(path: string, opts?: { cwd?: 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 listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
|
||||
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>
|
||||
```
|
||||
|
||||
Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md)
|
||||
|
||||
Source: [`packages/fs/fs/src/index.ts:158`](../../packages/fs/fs/src/index.ts)
|
||||
Source: [`packages/fs/fs/src/index.ts:165`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
### `ctx.llm` — `LlmService`
|
||||
|
||||
|
||||
@@ -38,6 +38,18 @@ interface FsInfo {
|
||||
}
|
||||
```
|
||||
|
||||
`listDir` returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so `size` is only for regular files and `version` is metadata-derived. Broken or disappeared children may be returned as `other` without metadata; permission or backend I/O failures while listing or resolving child metadata fail the whole listing with `FS_PERMISSION_DENIED` or `FS_IO_ERROR`.
|
||||
|
||||
```ts type-equiv
|
||||
interface FsDirEntry {
|
||||
name: string
|
||||
type: 'file' | 'directory' | 'other'
|
||||
target: FsTarget
|
||||
version?: FsVersion
|
||||
size?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Write and edit guards (provider seam)
|
||||
|
||||
Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape.
|
||||
@@ -121,8 +133,11 @@ Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`Harn
|
||||
```ts type-equiv
|
||||
type FsErrorCode =
|
||||
| 'FS_NOT_FOUND'
|
||||
| 'FS_NOT_DIRECTORY'
|
||||
| 'FS_NOT_TEXT'
|
||||
| 'FS_NOT_REGULAR_FILE'
|
||||
| 'FS_PERMISSION_DENIED'
|
||||
| 'FS_IO_ERROR'
|
||||
| 'FS_STALE_VERSION'
|
||||
| 'FS_NOT_OBSERVED'
|
||||
| 'FS_AMBIGUOUS_EDIT'
|
||||
@@ -130,8 +145,8 @@ type FsErrorCode =
|
||||
| 'FS_ABORTED'
|
||||
```
|
||||
|
||||
`FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one (or an edit hit a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`.
|
||||
`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one (or an edit hit a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`.
|
||||
|
||||
## The service and the plugin
|
||||
|
||||
`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam).
|
||||
`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam).
|
||||
|
||||
@@ -128,6 +128,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [Resolve filesystem paths against the caller's session cwd](implemented/architecture/2026-07-02-fs-per-session-cwd.md) | 2026-07-02 |
|
||||
| [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 |
|
||||
| [Result-time applied-hunk diffs for file mutations](implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md) | 2026-07-02 |
|
||||
| [Add direct directory listing to the filesystem seam](implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md) | 2026-07-03 |
|
||||
|
||||
### Process
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ The read-before-write/edit and observed-state policy is a fourth package, `@deep
|
||||
|
||||
The first backend is deliberately local-only: `dsh-fs-local` implements `ctx.fs` against the host filesystem. Future sibling backends can provide sandboxed, remote, virtual, or project-scoped filesystems behind the same interface.
|
||||
|
||||
The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-facing `read`, `write`, and `edit` tools for UTF-8 text files. Future consumers can add directory listing, search/glob, binary-safe operations, file watching, or higher-level project operations without changing the local backend package, as long as the needed capability exists on `ctx.fs`.
|
||||
The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-facing `read`, `write`, and `edit` tools for UTF-8 text files. Future consumers can add directory listing, search/glob, binary-safe operations, file watching, or higher-level project operations without changing the local backend package, as long as the needed capability exists on `ctx.fs`. Direct directory listing was later added by [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md).
|
||||
|
||||
Filesystem permissions and sandboxing are not implied by this split. The local backend resolves relative paths from its configured base directory, but containment policy is a separate decision: either a stricter `ctx.fs` implementation enforces it, or a permission/sandbox plugin wraps `tools/execute` and vetoes calls before they reach the consumer.
|
||||
|
||||
@@ -60,6 +60,7 @@ The root `tool-fs` plugin registers the full filesystem tool suite (`read`, `wri
|
||||
The exact TypeScript signatures are implementation details for the PR, but the interface must cover four semantic operations:
|
||||
|
||||
- Resolve a model/plugin-supplied path into a backend-defined target.
|
||||
- Stat target metadata without reading file contents.
|
||||
- Read a bounded UTF-8 text page from a target.
|
||||
- Create or replace a UTF-8 text file.
|
||||
- Edit an existing UTF-8 text file by literal replacement.
|
||||
@@ -92,7 +93,7 @@ Literal edit is a provider primitive (`editText`), not composed in `tool-fs` fro
|
||||
|
||||
The policy plugin, not `ctx.fs`, gates on prior observation: an `edit` requires a prior observation by the owner (else `FS_NOT_OBSERVED`), and the recorded version is passed to `editText` as the CAS basis. With the policy plugin absent, `ctx.fs` alone is a complete unconstrained seam (unconditional write/edit); the tool is never method-coupled to the policy.
|
||||
|
||||
Filesystem contract failures are thrown as `FsError extends HarnessError`, and the tool registry converts them into `isError` tool results with structured `{ name, code }` metadata. `dsh-fs` owns this vocabulary rather than each tool inventing messages. The codes are `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, and `FS_ABORTED`. (An earlier draft included `FS_PARTIAL_OBSERVATION`; freshness-based authorization has no partial/full distinction, so it was dropped.)
|
||||
Filesystem contract failures are thrown as `FsError extends HarnessError`, and the tool registry converts them into `isError` tool results with structured `{ name, code }` metadata. `dsh-fs` owns this vocabulary rather than each tool inventing messages. The codes are `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, and `FS_ABORTED`. (An earlier draft included `FS_PARTIAL_OBSERVATION`; freshness-based authorization has no partial/full distinction, so it was dropped. Directory-listing-specific codes were added later by [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md).)
|
||||
|
||||
## Tool consumer behavior
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Add direct directory listing to the filesystem seam
|
||||
|
||||
## Status
|
||||
|
||||
Implemented.
|
||||
|
||||
## Context
|
||||
|
||||
`@deepseek-ai/dsh-fs` is the provider seam for filesystem access, with local and future non-local backends behind the same `ctx.fs` contract. Before this change it could resolve paths, stat targets, read text, stream text, write text, and edit text. That was enough for model-facing file tools, but not for non-model-facing consumers that need to enumerate directories without importing `node:fs`.
|
||||
|
||||
The immediate pressure came from skill loading: reading an individual `SKILL.md` can already go through `ctx.get('fs')`, but discovering which skill roots contain `<name>/SKILL.md` or `<name>.md` still needs directory enumeration. Adding directory listing only in `dsh-skill` would either keep a direct Node dependency there or invent a one-off local helper outside the filesystem provider stack.
|
||||
|
||||
This branch deliberately lands the provider capability first and does not add a model-facing `ls`/`list` tool or change skill discovery. The follow-up consumer can validate UX and prompt shape separately, while this PR establishes the backend seam and local implementation.
|
||||
|
||||
## Decision
|
||||
|
||||
Add `FileSystem.listDir(target, signal?)` to `@deepseek-ai/dsh-fs`.
|
||||
|
||||
`listDir` lists one directory level only. It returns direct children in stable name order and includes:
|
||||
|
||||
- `name`: the child basename.
|
||||
- `type`: `file`, `directory`, or `other`.
|
||||
- `target`: the resolved child `FsTarget`.
|
||||
- `version`: cheap metadata when available.
|
||||
- `size`: regular-file size when available.
|
||||
|
||||
It never reads file contents. Recursive traversal, globbing, pagination, search, file watching, and model-facing rendering are intentionally out of scope.
|
||||
|
||||
The local backend implements this through `readdir({ withFileTypes: true })`, `resolveLocalTarget`, and metadata `stat`/`realpath` probes. The result order is deterministic (`name.localeCompare`) to keep prompt/listing output stable for future consumers and improve prefix-cache reuse.
|
||||
|
||||
Broken or disappeared children may be represented as `type: 'other'` without `version`/`size`; they do not abort the whole listing. Permission or backend I/O failures while listing the directory or resolving/probing child metadata fail the whole listing with structured `FsError` codes:
|
||||
|
||||
- `FS_NOT_FOUND` for missing targets.
|
||||
- `FS_NOT_DIRECTORY` for existing non-directory targets.
|
||||
- `FS_PERMISSION_DENIED` for permission failures.
|
||||
- `FS_IO_ERROR` for other backend I/O failures.
|
||||
- `FS_ABORTED` for aborted calls.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
**Add a model-facing list tool now.** Rejected for this PR. The immediate request is the provider seam, and the user explicitly asked not to change skill loading or other upper layers in this branch. A model-facing tool needs prompt/schema/rendering decisions that should be reviewed separately.
|
||||
|
||||
**Keep directory enumeration in each consumer.** Rejected. That would bind product packages such as `dsh-skill` to Node/local filesystem behavior and bypass policy/remote/sandboxed backends.
|
||||
|
||||
**Make `listDir` recursive or glob-shaped.** Rejected for now. Skill-root discovery only needs direct children, and a simple direct listing is the smallest backend contract future consumers can safely compose.
|
||||
|
||||
**Skip children that fail metadata resolution.** Rejected. The API promises resolved child targets, so permission/IO failures while resolving a child are contract failures. Broken or disappeared children are the exception because they can still be represented without claiming a live resolved file.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every filesystem backend must now implement one additional provider primitive. That is deliberate foundation work while the harness is still unreleased, but it does mean future sandboxed/remote backends need to define equivalent direct-child listing behavior.
|
||||
|
||||
The capability remains provider-facing. Until a consumer lands, ACP/model sessions will still need existing tools such as `bash` for directory listing. The absence of a model-facing `listdir` tool is expected, not a wiring failure.
|
||||
@@ -115,6 +115,10 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import
|
||||
- Docs and generated artifacts are updated: `docs/architecture.md`, `packages/README.md`, fs package READMEs, `docs/core-data-structures/filesystem.md`, affected `type-equiv` blocks and `scripts/type-equiv.manifest.json`, Cordis catalog, module graph, and doc references.
|
||||
- Gates stay green: normal `doc-sync`, `pnpm run knip`, and `pnpm run test:coverage` with 100% per-file coverage.
|
||||
|
||||
## Later extension
|
||||
|
||||
The seam was later extended with direct directory listing by [Add direct directory listing to the filesystem seam](../architecture/2026-07-03-filesystem-directory-listing-seam.md). That follow-up is tracked separately so this RFC's acceptance criteria continue to describe the fsspec-style refit that originally shipped.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-fs-local
|
||||
|
||||
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the six `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
|
||||
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the seven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
|
||||
|
||||
```ts ignore-check
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
@@ -15,6 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
|
||||
- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
|
||||
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { Dirent, Stats } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { TextDecoder } from 'node:util'
|
||||
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
@@ -55,6 +55,10 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
function isPermissionError(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && (error.code === 'EACCES' || error.code === 'EPERM')
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined, verb: string): void {
|
||||
if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED')
|
||||
}
|
||||
@@ -112,6 +116,15 @@ export interface PathInfo {
|
||||
size: number
|
||||
}
|
||||
|
||||
/** One local directory child with a resolved target and cheap metadata. */
|
||||
export interface LocalDirEntry {
|
||||
name: string
|
||||
type: 'file' | 'directory' | 'other'
|
||||
target: LocalTarget
|
||||
version?: FsVersion
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path to its absolute display path and realpath identity. Relative
|
||||
* paths are based on `cwd`. When the file itself does not yet exist, the
|
||||
@@ -172,6 +185,68 @@ export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Directory listing ---
|
||||
|
||||
function listingIoError(displayPath: string, error: unknown): FsError {
|
||||
/* v8 ignore next -- defensive pass-through for races where a child resolver has already produced a structured FsError. */
|
||||
if (error instanceof FsError) return error
|
||||
/* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */
|
||||
if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
|
||||
if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
|
||||
return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
|
||||
}
|
||||
|
||||
async function resolveListedChildTarget(parent: LocalTarget, name: string): Promise<LocalTarget> {
|
||||
const identity = await resolveLocalTarget(parent.targetKey, name)
|
||||
return { displayPath: join(parent.displayPath, name), targetKey: identity.targetKey }
|
||||
}
|
||||
|
||||
/**
|
||||
* List direct children of a directory in stable name order. Each child includes
|
||||
* a resolved target plus stat metadata when still available; file contents are
|
||||
* never read.
|
||||
*/
|
||||
export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]> {
|
||||
throwIfAborted(signal, 'list')
|
||||
let info: PathInfo | null
|
||||
try {
|
||||
info = await probe(target.targetKey)
|
||||
} catch (error: unknown) {
|
||||
throw listingIoError(target.displayPath, error)
|
||||
}
|
||||
if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await readdir(target.targetKey, { withFileTypes: true, encoding: 'utf8' })
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */
|
||||
throw listingIoError(target.displayPath, error)
|
||||
}
|
||||
throwIfAborted(signal, 'list')
|
||||
|
||||
const result: LocalDirEntry[] = []
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
throwIfAborted(signal, 'list')
|
||||
try {
|
||||
const childTarget = await resolveListedChildTarget(target, entry.name)
|
||||
const childInfo = await probe(childTarget.targetKey)
|
||||
result.push({
|
||||
name: entry.name,
|
||||
type: childInfo?.type ?? 'other',
|
||||
target: childTarget,
|
||||
...(childInfo ? { version: childInfo.version } : {}),
|
||||
...(childInfo?.type === 'file' ? { size: childInfo.size } : {}),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
throw listingIoError(join(target.displayPath, entry.name), error)
|
||||
}
|
||||
throwIfAborted(signal, 'list')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- Reading ---
|
||||
|
||||
function notTextError(verb: 'read' | 'edit', displayPath: string): FsError {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Local-filesystem implementation of the `ctx.fs` provider seam.
|
||||
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the six
|
||||
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven
|
||||
* text-storage primitives with the host filesystem via
|
||||
* {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses
|
||||
* `realpath`, so the stable `targetKey` is the real file identity (two input
|
||||
@@ -17,6 +17,7 @@ import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
@@ -26,6 +27,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
normalizeLineEndings,
|
||||
probe,
|
||||
readForEdit,
|
||||
@@ -41,6 +43,7 @@ import type { FsIoInternals } from './fsio.ts'
|
||||
export {
|
||||
STREAM_MIN_SIZE,
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
probe,
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
@@ -50,7 +53,7 @@ export {
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from './fsio.ts'
|
||||
export type { FsIoInternals, LineEndings, LocalTarget, PathInfo } from './fsio.ts'
|
||||
export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts'
|
||||
|
||||
/** Configuration for the local filesystem backend. */
|
||||
export interface Config {
|
||||
@@ -120,6 +123,17 @@ export class LocalFileSystem extends FileSystem {
|
||||
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
|
||||
}
|
||||
|
||||
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
|
||||
const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
|
||||
return entries.map(entry => ({
|
||||
name: entry.name,
|
||||
type: entry.type,
|
||||
target: { inputPath: entry.name, targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
|
||||
...(entry.version !== undefined ? { version: entry.version } : {}),
|
||||
...(entry.size !== undefined ? { size: entry.size } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
override async writeText(
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
@@ -118,6 +118,56 @@ describe('readText / streamText', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDir', () => {
|
||||
it('lists files and directories in stable name order with resolved child targets', async () => {
|
||||
await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true })
|
||||
await writeFile(join(dir, 'skills', 'zeta.md'), 'zeta')
|
||||
await writeFile(join(dir, 'skills', 'alpha.md'), 'alpha')
|
||||
await symlink(join(dir, 'skills', 'missing-target'), join(dir, 'skills', 'broken-link'))
|
||||
|
||||
const entries = await fs.listDir(await fs.resolve('skills'))
|
||||
expect(entries.map(entry => [entry.name, entry.type])).toEqual([
|
||||
['alpha.md', 'file'],
|
||||
['broken-link', 'other'],
|
||||
['dir-skill', 'directory'],
|
||||
['zeta.md', 'file'],
|
||||
])
|
||||
expect(entries.map(entry => entry.target.displayPath)).toEqual([
|
||||
join(dir, 'skills', 'alpha.md'),
|
||||
join(dir, 'skills', 'broken-link'),
|
||||
join(dir, 'skills', 'dir-skill'),
|
||||
join(dir, 'skills', 'zeta.md'),
|
||||
])
|
||||
expect(entries.map(entry => entry.target.inputPath)).toEqual([
|
||||
'alpha.md',
|
||||
'broken-link',
|
||||
'dir-skill',
|
||||
'zeta.md',
|
||||
])
|
||||
const materializedEntries = entries.filter(entry => entry.version !== undefined)
|
||||
expect(materializedEntries.map(entry => entry.target.targetKey))
|
||||
.toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath))))
|
||||
expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5)
|
||||
expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string')
|
||||
expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined()
|
||||
expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a missing directory as FS_NOT_FOUND', async () => {
|
||||
await expect(fs.listDir(await fs.resolve('missing'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('reports a file target as FS_NOT_DIRECTORY', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'text')
|
||||
await expect(fs.listDir(await fs.resolve('a.txt'))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
await mkdir(join(dir, 'skills'), { recursive: true })
|
||||
await expect(fs.listDir(await fs.resolve('skills'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeText', () => {
|
||||
it('createIfAbsent creates a new file', async () => {
|
||||
const target = await fs.resolve('new.txt')
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
|
||||
import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
probe,
|
||||
readForEdit,
|
||||
readWholeText,
|
||||
@@ -145,6 +146,110 @@ describe('probe', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDirectory', () => {
|
||||
it('lists direct children in stable order without reading content', async () => {
|
||||
const root = join(dir, 'skills')
|
||||
await mkdir(join(root, 'dir-skill'), { recursive: true })
|
||||
await writeFile(join(root, 'zeta.md'), 'zeta')
|
||||
await writeFile(join(root, 'alpha.md'), 'alpha')
|
||||
await symlink(join(root, 'missing-target'), join(root, 'broken-link'))
|
||||
|
||||
const entries = await listDirectory(localTarget(root))
|
||||
expect(entries.map(entry => [entry.name, entry.type])).toEqual([
|
||||
['alpha.md', 'file'],
|
||||
['broken-link', 'other'],
|
||||
['dir-skill', 'directory'],
|
||||
['zeta.md', 'file'],
|
||||
])
|
||||
expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5)
|
||||
expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string')
|
||||
expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined()
|
||||
expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives child target keys from the listed parent identity', async () => {
|
||||
const realOne = join(dir, 'real-one')
|
||||
const realTwo = join(dir, 'real-two')
|
||||
const link = join(dir, 'link')
|
||||
await mkdir(realOne)
|
||||
await mkdir(realTwo)
|
||||
await writeFile(join(realOne, 'same.txt'), 'one')
|
||||
await writeFile(join(realTwo, 'same.txt'), 'different two')
|
||||
await symlink(realOne, link)
|
||||
const target = await resolveLocalTarget(dir, 'link')
|
||||
|
||||
await unlink(link)
|
||||
await symlink(realTwo, link)
|
||||
|
||||
const entries = await listDirectory(target)
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatchObject({
|
||||
name: 'same.txt',
|
||||
target: {
|
||||
displayPath: join(link, 'same.txt'),
|
||||
targetKey: await realpath(join(realOne, 'same.txt')),
|
||||
},
|
||||
size: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing, non-directory, and aborted listing requests', async () => {
|
||||
await expect(listDirectory(localTarget(join(dir, 'missing')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
await expect(listDirectory(localTarget(file))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' })
|
||||
await expect(listDirectory(localTarget(dir), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('translates directory permission failures into FS_PERMISSION_DENIED', async () => {
|
||||
const root = join(dir, 'restricted')
|
||||
await mkdir(root)
|
||||
await chmod(root, 0o000)
|
||||
try {
|
||||
const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught)
|
||||
// Root-like environments may still be able to list mode-000 directories.
|
||||
if (error === undefined) return
|
||||
expect(error).toBeInstanceOf(FsError)
|
||||
expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' })
|
||||
} finally {
|
||||
await chmod(root, 0o700)
|
||||
}
|
||||
})
|
||||
|
||||
it('translates preflight metadata IO failures into FS_IO_ERROR', async () => {
|
||||
const loop = join(dir, 'loop')
|
||||
await symlink(loop, loop)
|
||||
await expect(listDirectory(localTarget(loop))).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
|
||||
})
|
||||
|
||||
it('translates child resolution failures into structured listing errors', async () => {
|
||||
const root = join(dir, 'listed')
|
||||
await mkdir(root)
|
||||
const loop = join(root, 'loop')
|
||||
await symlink(loop, loop)
|
||||
await expect(listDirectory(localTarget(root))).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
|
||||
})
|
||||
|
||||
it('translates child permission failures into FS_PERMISSION_DENIED', async () => {
|
||||
const root = join(dir, 'listed')
|
||||
const protectedRoot = join(dir, 'protected')
|
||||
const secret = join(protectedRoot, 'secret')
|
||||
await mkdir(root)
|
||||
await mkdir(secret, { recursive: true })
|
||||
await symlink(secret, join(root, 'secret-link'))
|
||||
await chmod(protectedRoot, 0o000)
|
||||
try {
|
||||
const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught)
|
||||
// Root-like environments may still resolve through mode-000 directories.
|
||||
if (error === undefined) return
|
||||
expect(error).toBeInstanceOf(FsError)
|
||||
expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' })
|
||||
} finally {
|
||||
await chmod(protectedRoot, 0o700)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('readWholeText', () => {
|
||||
it('reads a small file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-fs
|
||||
|
||||
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 literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
|
||||
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
|
||||
|
||||
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam 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), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
|
||||
|
||||
@@ -15,7 +15,7 @@ A future sandboxed, virtual, or remote backend implements this interface and the
|
||||
|
||||
## Service API (`ctx.fs`)
|
||||
|
||||
A backend subclasses `FileSystem` and implements six primitives.
|
||||
A backend subclasses `FileSystem` and implements seven primitives.
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
@@ -23,6 +23,7 @@ A backend subclasses `FileSystem` and implements six primitives.
|
||||
| `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). |
|
||||
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
|
||||
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. |
|
||||
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
|
||||
|
||||
@@ -40,4 +41,4 @@ This package declares three events (see the generated [catalog](../../../docs/co
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`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. `FsWriteIntent` is the explicit GUARDED 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`); omitting it from `writeText` is the third, unconditional state. 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.
|
||||
`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. `FsWriteIntent` is the explicit GUARDED 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`); omitting it from `writeText` is the third, unconditional state. 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_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `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.
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
@@ -76,6 +77,7 @@ export {
|
||||
export type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsDirEntry,
|
||||
FsErrorCode,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
@@ -131,7 +133,7 @@ declare module 'cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract filesystem provider service. Subclass, implement the six text-storage
|
||||
* Abstract filesystem provider service. Subclass, implement the seven 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).
|
||||
@@ -145,6 +147,11 @@ declare module 'cordis' {
|
||||
* - {@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 listDir} returns direct children of a directory in stable name order
|
||||
* with resolved child targets and cheap metadata only. It never reads file
|
||||
* contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw
|
||||
* `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and
|
||||
* other backend I/O failures throw `FS_IO_ERROR`.
|
||||
* - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL:
|
||||
* omit it for an unconditional create-or-overwrite (the bare-provider default),
|
||||
* or supply a {@link FsWriteIntent} to guard the write.
|
||||
@@ -190,6 +197,12 @@ export abstract class FileSystem extends Service {
|
||||
*/
|
||||
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
|
||||
|
||||
/**
|
||||
* List direct children of a directory in stable name order. Returns resolved
|
||||
* child targets plus cheap metadata only; never reads file contents.
|
||||
*/
|
||||
abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
|
||||
|
||||
/**
|
||||
* Create or fully replace a UTF-8 text file atomically. `expected` is the
|
||||
* create-vs-replace decision and stale guard when supplied; OMITTING it is an
|
||||
|
||||
@@ -78,6 +78,23 @@ export interface FsInfo {
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One direct child returned by {@link FileSystem.listDir}. Listing returns
|
||||
* metadata and resolved targets only; it must not read file contents.
|
||||
*/
|
||||
export interface FsDirEntry {
|
||||
/** Basename of the child inside the listed directory. */
|
||||
name: string
|
||||
/** Whether the child is a regular file, a directory, or something else. */
|
||||
type: 'file' | 'directory' | 'other'
|
||||
/** Resolved child target for follow-up operations. */
|
||||
target: FsTarget
|
||||
/** Opaque freshness token when the backend can report metadata cheaply. */
|
||||
version?: FsVersion
|
||||
/** Byte size of a regular file, when the backend can report it. */
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The explicit intent of a guarded {@link FileSystem.writeText} call.
|
||||
* `createIfAbsent` creates a missing target and rejects an existing one with
|
||||
@@ -148,8 +165,11 @@ export interface FsEditOutcome {
|
||||
*/
|
||||
export type FsErrorCode =
|
||||
| 'FS_NOT_FOUND'
|
||||
| 'FS_NOT_DIRECTORY'
|
||||
| 'FS_NOT_TEXT'
|
||||
| 'FS_NOT_REGULAR_FILE'
|
||||
| 'FS_PERMISSION_DENIED'
|
||||
| 'FS_IO_ERROR'
|
||||
| 'FS_STALE_VERSION'
|
||||
| 'FS_NOT_OBSERVED'
|
||||
| 'FS_AMBIGUOUS_EDIT'
|
||||
|
||||
@@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
@@ -17,7 +18,7 @@ import type {
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** A minimal in-memory fake implementing the six provider primitives. */
|
||||
/** A minimal in-memory fake implementing the seven provider primitives. */
|
||||
class FakeFileSystem extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
|
||||
@@ -38,6 +39,18 @@ class FakeFileSystem extends FileSystem {
|
||||
const content = await this.readText(target)
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
|
||||
if (target.targetKey !== 'skills') throw new FsError(`not a directory: ${target.displayPath}`, 'FS_NOT_DIRECTORY')
|
||||
return [
|
||||
{
|
||||
name: 'alpha.md',
|
||||
type: 'file',
|
||||
target: { inputPath: 'skills/alpha.md', targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' },
|
||||
size: 2,
|
||||
version: FsVersion('v1'),
|
||||
},
|
||||
]
|
||||
}
|
||||
override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise<FsWriteOutcome> {
|
||||
const before = this.files.get(target.targetKey) ?? null
|
||||
this.files.set(target.targetKey, content)
|
||||
@@ -87,6 +100,20 @@ describe('FileSystem provider seam', () => {
|
||||
expect(streamed).toBe(await fs.readText(target))
|
||||
})
|
||||
|
||||
it('listDir returns child entry targets without reading file content', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
const entries = await fs.listDir(await fs.resolve('skills'))
|
||||
expect(entries).toEqual([{
|
||||
name: 'alpha.md',
|
||||
type: 'file',
|
||||
target: { inputPath: 'skills/alpha.md', targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' },
|
||||
size: 2,
|
||||
version: 'v1',
|
||||
}])
|
||||
})
|
||||
|
||||
it('stat returns undefined for an absent target', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
|
||||
@@ -90,4 +90,3 @@ export function diffsFromMeta(meta: unknown): FileDiff[] | undefined {
|
||||
if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return undefined
|
||||
return diffs
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
@@ -54,6 +55,9 @@ class FakeFs extends FileSystem {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
|
||||
return []
|
||||
}
|
||||
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
|
||||
this.throwIfArmed()
|
||||
this.writeIntents.push(expected)
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" },
|
||||
|
||||
Reference in New Issue
Block a user