feat(fs): add directory listing seam
This commit is contained in:
@@ -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`. |
|
||||
| `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
|
||||
@@ -130,8 +147,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 existed = this.files.has(target.targetKey)
|
||||
this.files.set(target.targetKey, content)
|
||||
@@ -86,6 +99,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)
|
||||
|
||||
Reference in New Issue
Block a user