refactor(fs): make dsh-file-context an event-gate plugin, not a method service
Invert the tool↔policy control flow per the file-context event-gate RFC. dsh-tool-fs becomes the executor — it reads/writes/edits through ctx.fs directly, owns read windowing, and dispatches fs/write-expectation / fs/edit-expectation (single-slot waterfalls) plus a contained fs/observed emit. dsh-file-context drops its ctx.fileContext service and becomes a pure event-gate plugin (observed-state + read-before-edit + version-guarded write/edit, decided on those events). The provider's version guard becomes optional so ctx.fs alone is a complete unconstrained text-storage seam: removing the policy plugin gracefully loses the policy instead of breaking the tool at a service-injection boundary.
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
# @deepseek-ai/dsh-file-context
|
||||
|
||||
The **file-context policy layer**: a concrete `ctx.fileContext` service that owns model-facing read windowing and write/edit freshness on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the policy third of the filesystem stack — it is **not** a swappable seam, but the deferred policy layer that does not belong on the `FileSystem` provider base class.
|
||||
The **file-context policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fileContext` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import FileContext from '@deepseek-ai/dsh-file-context'
|
||||
import * as FileContext from '@deepseek-ai/dsh-file-context'
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
// A ctx.fs provider must already be loaded (e.g. @deepseek-ai/dsh-fs-local);
|
||||
// FileContext injects `fs` and registers ctx.fileContext. Load
|
||||
// @deepseek-ai/dsh-tool-fs afterwards to expose read/write/edit to the model.
|
||||
// No service to inject — this plugin only registers the three fs/* listeners.
|
||||
// Load it alongside a ctx.fs provider (e.g. @deepseek-ai/dsh-fs-local) and the
|
||||
// @deepseek-ai/dsh-tool-fs tools; the tools dispatch the fs/* events this plugin
|
||||
// decides. Order does not matter for resolution (no inject), but the policy
|
||||
// listener should be the first decider registered for the fs/*-expectation slots.
|
||||
await ctx.plugin(FileContext)
|
||||
```
|
||||
|
||||
@@ -18,26 +20,29 @@ await ctx.plugin(FileContext)
|
||||
|
||||
| Layer | Package | Role |
|
||||
|---|---|---|
|
||||
| tool | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + text rendering |
|
||||
| policy | `@deepseek-ai/dsh-file-context` (this) | `ctx.fileContext`: observed-state, read windowing, write/edit freshness |
|
||||
| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + guarded mutation primitives |
|
||||
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
|
||||
| policy | `@deepseek-ai/dsh-file-context` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
|
||||
| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
|
||||
| provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` |
|
||||
|
||||
## Service API (`ctx.fileContext`)
|
||||
## How the gate participates
|
||||
|
||||
| Member | Semantics |
|
||||
Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek-ai/dsh-tool-fs`):
|
||||
|
||||
| Event | This plugin's listener |
|
||||
|---|---|
|
||||
| `read(target, request, exec?, signal?)` | Stats the target, rejects absent/non-regular targets, chooses `readText`/`streamText` by size, builds the requested line window, records the version, and returns the `FileReadOutcome` the tool renders. |
|
||||
| `write(target, content, exec?, signal?)` | No recorded read → `writeText({ kind: 'createIfAbsent' })` (only new files create blindly); a recorded read → `writeText({ kind: 'replaceIfVersion', version })`. Refreshes recorded state on success. |
|
||||
| `edit(target, edit, exec?, signal?)` | Requires a recorded read by this owner (else `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the stale guard and refreshes recorded state. |
|
||||
| `owner(exec?)` | Derives the observed-state owner (`exec.agent.session`) — `undefined` when there is none. |
|
||||
| `fs/write-expectation` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/edit-expectation` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
|
||||
|
||||
## Observed state is the read record, freshness is the authorization
|
||||
## Observed state is the prior-observation record; freshness is provider CAS
|
||||
|
||||
Observed state is a `WeakMap<owner, Map<targetKey, { version }>>`. An entry exists **iff** the owner has read that target through `read`, so its presence *is* the read record — there is no `hasRead` flag and no `full`/`partial` view. Authorization is based on version freshness only: a windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged (the provider's stale guard enforces it). State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred.
|
||||
Observed state is a `WeakMap<owner, Map<targetKey, FsVersion>>`. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no `hasRead` flag and no `full`/`partial` view. This plugin does **no** filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — this plugin only supplies `vObserved` as the basis. A windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged. State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred.
|
||||
|
||||
## The no-bypass contract
|
||||
## Single-slot, first-wins
|
||||
|
||||
A model-facing read MUST go through `ctx.fileContext.read`, never `ctx.fs.readText`/`streamText`, so every successful read records observed state before the tool renders. Direct `ctx.fs` calls remain an explicit escape hatch for non-tool consumers: a direct `ctx.fs.readText` records nothing, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`.
|
||||
The `fs/write-expectation`/`fs/edit-expectation` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`.
|
||||
|
||||
The line-windowing mechanics live in `src/window.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the service wiring and policy.
|
||||
## No method coupling
|
||||
|
||||
Because the plugin influences the world only through events, removing it does not break `@deepseek-ai/dsh-tool-fs` at a service-injection boundary: the tool falls through to the bare `ctx.fs` provider (unconditional write/edit, no observed-state). Loading it back layers the policy on. That graceful add/remove is the whole point of the event gate over a mandatory method service.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-file-context",
|
||||
"description": "File-context policy layer (ctx.fileContext) for the DeepSeek Harness — read windowing and write/edit freshness over the ctx.fs provider seam",
|
||||
"description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service surface)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,193 +1,159 @@
|
||||
/**
|
||||
* The file-context policy layer (`ctx.fileContext`): a concrete service that
|
||||
* owns model-facing read windowing and write/edit freshness on top of the
|
||||
* `ctx.fs` provider seam. It is NOT a swappable seam — it is the previously
|
||||
* deferred policy layer that does not belong on the `FileSystem` provider base
|
||||
* class (where a sandboxed/remote backend would otherwise inherit model-facing
|
||||
* observation policy it has no business carrying).
|
||||
* The file-context policy PLUGIN: observed-state, read-before-edit, and
|
||||
* "write/edit must be based on the version you read" — added on top of the
|
||||
* `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method
|
||||
* service. This plugin registers NO `ctx.fileContext` service and exposes no
|
||||
* `read`/`write`/`edit`/`resolve` methods; it influences the world only by
|
||||
* deciding the `fs/write-expectation`/`fs/edit-expectation` waterfalls and
|
||||
* recording on `fs/observed`. That is what keeps `@deepseek-ai/dsh-tool-fs`
|
||||
* (the executor) free of any method coupling to the policy layer — removing
|
||||
* this plugin gracefully loses the policy and leaves the unconstrained bare
|
||||
* provider, rather than breaking the tool at a service-injection boundary.
|
||||
*
|
||||
* ## Observed state IS the read record
|
||||
* ## Observed state IS the prior-observation record
|
||||
*
|
||||
* Observed state lives here as `WeakMap<owner, Map<targetKey, { version }>>`. An
|
||||
* entry exists iff the owner has read that target through {@link read}, so its
|
||||
* presence *is* the read record — there is no separate `hasRead` flag. The owner
|
||||
* is derived structurally from `{ agent?: { session? } }` and held weakly, so a
|
||||
* collected session frees its state; disposal drops everything (HMR safety).
|
||||
* State lives here as `WeakMap<owner, Map<targetKey, { version }>>`. An entry
|
||||
* exists iff the owner has read, written, OR edited that target (every success
|
||||
* emits `fs/observed`), so its presence means "this owner has observed this
|
||||
* target at this version". This is what lets a create-then-edit or
|
||||
* edit-then-edit sequence work without an intervening re-read: the mutation
|
||||
* refreshes the recorded version to its own result. The owner is derived
|
||||
* structurally from `{ agent?: { session? } }` and held weakly, so a collected
|
||||
* session frees its state; disposal drops everything (HMR safety).
|
||||
*
|
||||
* ## Freshness, not full/partial views
|
||||
* ## Freshness via provider CAS, not stat
|
||||
*
|
||||
* Authorization is based on version freshness only. A windowed read records the
|
||||
* file's version, and any later write/edit at that version is authorized — a
|
||||
* model that read lines 100-150 of a large file can still edit line 120 as long
|
||||
* as the file is unchanged. There is no `full`/`partial` distinction: the bytes
|
||||
* the edit matches must merely come from the version the model read, which the
|
||||
* provider's stale guard enforces.
|
||||
* This plugin does NO filesystem I/O. "Have you observed this file?" is a
|
||||
* `WeakMap` lookup (no record ⇒ `FS_NOT_OBSERVED`). "Is the version you read
|
||||
* still current?" is decided INSIDE `ctx.fs.editText`/`writeText`, in the same
|
||||
* atomic lock that performs the mutation — this plugin only supplies the
|
||||
* observed version as the CAS basis. Stat-ing and comparing here would open a
|
||||
* TOCTOU gap the provider lock has to back up anyway, so it is deliberately
|
||||
* avoided.
|
||||
*
|
||||
* ## The no-bypass contract
|
||||
* ## Single-slot, first-wins
|
||||
*
|
||||
* A model-facing read MUST go through {@link read} (never `ctx.fs.readText`/
|
||||
* `streamText` directly), so every successful read records observed state before
|
||||
* the tool renders. Direct `ctx.fs` calls are allowed for non-tool consumers but
|
||||
* record nothing, so a later {@link edit} rejects with `FS_NOT_OBSERVED` until
|
||||
* the file is read through `ctx.fileContext`.
|
||||
* The `fs/write-expectation`/`fs/edit-expectation` listeners do NOT call
|
||||
* `next()`: each fully decides its single slot. The slot is first-wins by
|
||||
* registration order — this plugin owning it is the default-deployment
|
||||
* convention, not an event-enforced invariant (a decider registered before /
|
||||
* `prepend`ed would win instead). This is not a composable authorization chain;
|
||||
* layered permission/audit/sandbox interception belongs on `tools/execute`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-file-context
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsVersion, FsEditRequest, FsEditOutcome, FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import { buildWindow } from './window.ts'
|
||||
import type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts'
|
||||
import type { FsTarget, FsVersion, FsWriteExpectation } from '@deepseek-ai/dsh-fs'
|
||||
import type { FileContextExec } from './types.ts'
|
||||
|
||||
export type { FileTextLine, ReadWindow, WindowResult } from './window.ts'
|
||||
export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow } from './window.ts'
|
||||
export type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts'
|
||||
|
||||
/** Files at or above this size stream; smaller files read whole into memory. */
|
||||
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
fileContext: FileContext
|
||||
}
|
||||
}
|
||||
|
||||
/** What an owner has observed about one target: just the version it last saw. */
|
||||
interface ObservedState {
|
||||
version: FsVersion
|
||||
}
|
||||
export type { FileContextExec } from './types.ts'
|
||||
|
||||
/**
|
||||
* The file-context policy service. Injects `fs`, registers as `ctx.fileContext`,
|
||||
* and is the only read/write/edit path the model-facing tools use.
|
||||
* Per-context observed-file state and the three `fs/*` decisions over it. One
|
||||
* instance is created per `apply()` so disposal can drop all state for HMR.
|
||||
*/
|
||||
export class FileContext extends Service {
|
||||
static inject = ['fs']
|
||||
|
||||
class ObservedStateGate {
|
||||
/**
|
||||
* Observed-file state, keyed first by the owner object (weakly held, so a
|
||||
* collected session frees its state), then by {@link FsTarget.targetKey}. An
|
||||
* entry's PRESENCE is the read record.
|
||||
* entry's PRESENCE is the prior-observation record.
|
||||
*/
|
||||
private observed = new WeakMap<object, Map<string, ObservedState>>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'fileContext')
|
||||
ctx.effect(() => () => {
|
||||
// Drop all recorded state on disposal so a reloaded service starts clean
|
||||
// (HMR safety). The WeakMap itself would be GC'd, but replacing it makes
|
||||
// the release observable and immediate for tests.
|
||||
this.observed = new WeakMap()
|
||||
}, 'fileContext observed-state teardown')
|
||||
}
|
||||
private observed = new WeakMap<object, Map<string, FsVersion>>()
|
||||
|
||||
/**
|
||||
* Derive the observed-state owner from an execution context — normally the
|
||||
* Derive the observed-state owner from the opaque event actor — normally the
|
||||
* active agent session. `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?: FileContextExec): object | undefined {
|
||||
return exec?.agent?.session
|
||||
private owner(actor: object | undefined): object | undefined {
|
||||
return (actor as FileContextExec | undefined)?.agent?.session
|
||||
}
|
||||
|
||||
private getObserved(owner: object, targetKey: string): ObservedState | undefined {
|
||||
private get(owner: object, targetKey: string): FsVersion | undefined {
|
||||
return this.observed.get(owner)?.get(targetKey)
|
||||
}
|
||||
|
||||
private record(owner: object, targetKey: string, version: FsVersion): void {
|
||||
private set(owner: object, targetKey: string, version: FsVersion): void {
|
||||
let byTarget = this.observed.get(owner)
|
||||
if (!byTarget) {
|
||||
byTarget = new Map()
|
||||
this.observed.set(owner, byTarget)
|
||||
}
|
||||
byTarget.set(targetKey, { version })
|
||||
byTarget.set(targetKey, version)
|
||||
}
|
||||
|
||||
/** Drop all recorded state (HMR safety / disposal). */
|
||||
clear(): void {
|
||||
this.observed = new WeakMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path into a stable {@link FsTarget}, delegating to the provider.
|
||||
* Exposed here so the model-facing tools never need to inject `ctx.fs`
|
||||
* directly — they resolve and then read/write/edit entirely through
|
||||
* `ctx.fileContext`.
|
||||
* Decide the write expectation: no prior observation ⇒ `createIfAbsent` (only
|
||||
* new files can be created blindly); a prior observation ⇒ `replaceIfVersion`
|
||||
* at the observed version (existing files replaced only if unchanged).
|
||||
*/
|
||||
async resolve(path: string): Promise<FsTarget> {
|
||||
return this.ctx.fs.resolve(path)
|
||||
writeExpectation(target: FsTarget, actor: object | undefined): FsWriteExpectation {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a bounded line window from a target. Stats first (rejecting an absent
|
||||
* target with `FS_NOT_FOUND` and a non-regular one with `FS_NOT_REGULAR_FILE`),
|
||||
* chooses `readText` vs `streamText` by size — streaming when the size is
|
||||
* large OR unknown so a size-less backend never buffers an arbitrarily large
|
||||
* file — builds the window, then records the version observed AFTER the read
|
||||
* so the recorded freshness token corresponds to the bytes actually returned
|
||||
* (a writer racing between the routing stat and the read can't make a
|
||||
* follow-up edit spuriously stale against a pre-read version).
|
||||
* Decide the edit version guard: requires a prior observation by this owner
|
||||
* (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis.
|
||||
*/
|
||||
async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise<FileReadOutcome> {
|
||||
const info = await this.ctx.fs.stat(target, signal)
|
||||
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
|
||||
const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE
|
||||
? await this.ctx.fs.streamText(target, signal)
|
||||
: [await this.ctx.fs.readText(target, signal)]
|
||||
const window = await buildWindow(chunks, request, target.displayPath)
|
||||
|
||||
// The version that matches the bytes just read: a stat taken after the read
|
||||
// (falling back to the routing stat if the file vanished in the interim).
|
||||
const after = await this.ctx.fs.stat(target, signal)
|
||||
const version = after?.version ?? info.version
|
||||
|
||||
const owner = this.owner(exec)
|
||||
if (owner) this.record(owner, target.targetKey, version)
|
||||
return {
|
||||
offset: request.offset,
|
||||
limit: request.limit,
|
||||
lines: window.lines,
|
||||
totalLines: window.totalLines,
|
||||
version,
|
||||
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or fully replace a file. With no recorded read, writes
|
||||
* `createIfAbsent` (only new files can be created blindly); with a recorded
|
||||
* read, writes `replaceIfVersion` at the observed version (existing files are
|
||||
* replaced only if unchanged since the read). Refreshes recorded state from
|
||||
* the returned version on success.
|
||||
*/
|
||||
async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: AbortSignal): Promise<FsWriteOutcome> {
|
||||
const owner = this.owner(exec)
|
||||
const prior = owner ? this.getObserved(owner, target.targetKey) : undefined
|
||||
const outcome = await this.ctx.fs.writeText(
|
||||
target,
|
||||
content,
|
||||
prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' },
|
||||
signal,
|
||||
)
|
||||
if (owner) this.record(owner, target.targetKey, outcome.version)
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a literal edit. Requires a recorded read by this owner (else
|
||||
* `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the
|
||||
* stale guard and refreshes recorded state from the returned version. The
|
||||
* provider owns the mutation critical section and the literal match.
|
||||
*/
|
||||
async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise<FsEditOutcome> {
|
||||
const owner = this.owner(exec)
|
||||
const prior = owner ? this.getObserved(owner, target.targetKey) : undefined
|
||||
editExpectation(target: FsTarget, actor: object | undefined): { version: FsVersion } {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
if (!owner || !prior) {
|
||||
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
|
||||
}
|
||||
const outcome = await this.ctx.fs.editText(target, edit, { version: prior.version }, signal)
|
||||
this.record(owner, target.targetKey, outcome.version)
|
||||
return outcome
|
||||
return { version: prior }
|
||||
}
|
||||
|
||||
/** Record a successful read/write/edit: this owner observed this target at this version. */
|
||||
observe(target: FsTarget, version: FsVersion, actor: object | undefined): void {
|
||||
const owner = this.owner(actor)
|
||||
if (owner) this.set(owner, target.targetKey, version)
|
||||
}
|
||||
}
|
||||
|
||||
export default FileContext
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'file-context'
|
||||
|
||||
/**
|
||||
* Register the three `fs/*` listeners. No `inject` — this plugin reads no
|
||||
* services; it operates only on its own `WeakMap`. The waterfalls are unbound
|
||||
* (the tool dispatches them with no `this`), so the listeners take the raw
|
||||
* `(target, actor, next)` arguments.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const gate = new ObservedStateGate()
|
||||
|
||||
ctx.effect(() => () => {
|
||||
// Drop all recorded state on disposal so a reloaded plugin starts clean
|
||||
// (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the
|
||||
// release observable and immediate for tests.
|
||||
gate.clear()
|
||||
}, 'file-context observed-state teardown')
|
||||
|
||||
// fs/write-expectation: occupy the single decision slot — do NOT call next().
|
||||
// Deferred through Promise.resolve().then so the declared Promise return type
|
||||
// holds (a throw rejects, never escapes synchronously through the waterfall).
|
||||
ctx.on('fs/write-expectation', (target, actor) => Promise.resolve().then(() => gate.writeExpectation(target, actor)))
|
||||
|
||||
// fs/edit-expectation: occupy the single decision slot — do NOT call next().
|
||||
// Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise
|
||||
// the edit tool's `await ctx.waterfall(...)` surfaces as its isError result.
|
||||
ctx.on('fs/edit-expectation', (target, actor) => Promise.resolve().then(() => gate.editExpectation(target, actor)))
|
||||
|
||||
// fs/observed: synchronous, side-effect-only WeakMap write (cannot throw under
|
||||
// normal operation); the tool contains any throw so a record bug never fails
|
||||
// the already-completed mutation.
|
||||
ctx.on('fs/observed', (target, version, actor) => {
|
||||
gate.observe(target, version, actor)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
/**
|
||||
* Vocabulary for the file-context policy layer (`ctx.fileContext`): the
|
||||
* minimal execution-context shape used to derive an observed-state owner, the
|
||||
* resolved read window, and the structured read outcome the model-facing `read`
|
||||
* tool renders.
|
||||
* Vocabulary for the file-context policy plugin: the minimal execution-context
|
||||
* shape used to derive an observed-state owner by narrowing the opaque `object`
|
||||
* actor the `fs/*` events carry.
|
||||
*
|
||||
* The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is
|
||||
* re-used from `@deepseek-ai/dsh-fs` — this package owns only the model-facing
|
||||
* read-windowing and observation policy on top of it.
|
||||
* re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state
|
||||
* owner structure on top of it.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-file-context/types
|
||||
*/
|
||||
|
||||
import type { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FileTextLine } from './window.ts'
|
||||
|
||||
/**
|
||||
* Minimal structural view of a tool execution the policy layer needs to derive
|
||||
* Minimal structural view of a tool execution the policy plugin needs to derive
|
||||
* an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies
|
||||
* this shape, so the consumer passes its `exec` straight through without
|
||||
* `dsh-file-context` importing `dsh-tools`, `dsh-agent`, or `dsh-session`.
|
||||
* this shape, so the tool passes its `exec` straight through as the opaque
|
||||
* `object` actor on the `fs/*` events; this plugin narrows that actor to this
|
||||
* shape without 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); this package never reads any of its fields.
|
||||
@@ -30,27 +27,3 @@ export interface FileContextExec {
|
||||
session?: object
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved read window. The consumer applies its defaults/caps before calling. */
|
||||
export interface FileReadRequest {
|
||||
/** 1-based first line to return. */
|
||||
offset: number
|
||||
/** Maximum number of lines to return. */
|
||||
limit: number
|
||||
}
|
||||
|
||||
/** Outcome of a bounded text read — what the model-facing `read` tool renders. */
|
||||
export interface FileReadOutcome {
|
||||
/** 1-based first line requested. */
|
||||
offset: number
|
||||
/** Maximum number of lines requested. */
|
||||
limit: number
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** 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
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Cordis-free line-windowing for `@deepseek-ai/dsh-file-context`. Relocated
|
||||
* from the local backend: turning a file's decoded text into a bounded,
|
||||
* line-numbered window (offset/limit, byte cap, per-line truncation) is
|
||||
* model-facing READ POLICY, not a storage primitive, so it lives in the policy
|
||||
* layer rather than in every `ctx.fs` backend.
|
||||
*
|
||||
* The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text
|
||||
* (UTF-8 validated, binary rejected); this module only scans that text for
|
||||
* newlines and builds the requested window. A capped line buffer means a
|
||||
* newline-free giant line can never balloon memory even when streamed.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-file-context/window
|
||||
*/
|
||||
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Maximum characters returned for a single line. */
|
||||
export const READ_MAX_LINE_LENGTH = 2000
|
||||
|
||||
/** Maximum bytes returned for selected file lines. */
|
||||
export const READ_MAX_BYTES = 50 * 1024
|
||||
|
||||
const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
|
||||
const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1
|
||||
|
||||
/** Resolved read window. The consumer applies its defaults/caps before calling. */
|
||||
export interface ReadWindow {
|
||||
/** 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 FileTextLine {
|
||||
/** 1-based line number in the file. */
|
||||
number: number
|
||||
/** Line text without its trailing newline. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** The windowed result this module builds from a file's decoded text. */
|
||||
export interface WindowResult {
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** 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: boolean
|
||||
}
|
||||
|
||||
interface WindowAccumulator {
|
||||
lines: FileTextLine[]
|
||||
totalLines: number
|
||||
outputBytes: number
|
||||
truncatedByBytes: boolean
|
||||
done: boolean
|
||||
}
|
||||
|
||||
function newAccumulator(): WindowAccumulator {
|
||||
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
|
||||
}
|
||||
|
||||
function truncateLine(line: string): string {
|
||||
return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line
|
||||
}
|
||||
|
||||
function lineByteSize(line: string, currentLineCount: number): number {
|
||||
return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
|
||||
acc.totalLines += 1
|
||||
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
|
||||
|
||||
const text = truncateLine(rawLine)
|
||||
const bytes = lineByteSize(text, acc.lines.length)
|
||||
if (acc.outputBytes + bytes > READ_MAX_BYTES) {
|
||||
acc.truncatedByBytes = true
|
||||
acc.done = true
|
||||
return
|
||||
}
|
||||
acc.outputBytes += bytes
|
||||
acc.lines.push({ number: acc.totalLines, text })
|
||||
}
|
||||
|
||||
function stripCarriageReturn(line: string): string {
|
||||
return line.endsWith('\r') ? line.slice(0, -1) : line
|
||||
}
|
||||
|
||||
function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string): WindowResult {
|
||||
if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) {
|
||||
throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND')
|
||||
}
|
||||
return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bounded, line-numbered window from a file's decoded text chunks.
|
||||
* Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an
|
||||
* `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
|
||||
* path serves both. Scans for newlines with a capped line buffer (a newline-free
|
||||
* giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}),
|
||||
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
|
||||
*/
|
||||
export async function buildWindow(
|
||||
chunks: AsyncIterable<string> | Iterable<string>,
|
||||
request: ReadWindow,
|
||||
displayPath: string,
|
||||
): Promise<WindowResult> {
|
||||
const acc = newAccumulator()
|
||||
let lineBuffer = ''
|
||||
|
||||
function appendToLineBuffer(segment: string): void {
|
||||
if (lineBuffer.length >= LINE_BUFFER_CAP) return
|
||||
lineBuffer += segment
|
||||
if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP)
|
||||
}
|
||||
|
||||
function flushLine(): void {
|
||||
consumeLine(acc, stripCarriageReturn(lineBuffer), request)
|
||||
lineBuffer = ''
|
||||
}
|
||||
|
||||
for await (const chunk of chunks) {
|
||||
let startPos = 0
|
||||
let newlinePos: number
|
||||
while ((newlinePos = chunk.indexOf('\n', startPos)) !== -1) {
|
||||
appendToLineBuffer(chunk.slice(startPos, newlinePos))
|
||||
flushLine()
|
||||
startPos = newlinePos + 1
|
||||
if (acc.done) return finish(acc, request, displayPath)
|
||||
}
|
||||
appendToLineBuffer(chunk.slice(startPos))
|
||||
}
|
||||
if (lineBuffer.length > 0) flushLine()
|
||||
return finish(acc, request, displayPath)
|
||||
}
|
||||
@@ -1,349 +1,192 @@
|
||||
/**
|
||||
* Tests for the file-context policy layer: registration/disposal/HMR, owner
|
||||
* derivation, observed-state-as-read-record, read windowing over a fake
|
||||
* provider, freshness-based write/edit authorization (including the key
|
||||
* windowed-read-authorizes-edit behavior), the read→streamText size routing,
|
||||
* and multi-owner isolation. The provider is a fake `ctx.fs` recording the
|
||||
* expectations it was handed.
|
||||
* Tests for the file-context policy PLUGIN: it registers no service, only the
|
||||
* three `fs/*` listeners. We dispatch those events directly (the unbound
|
||||
* waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the
|
||||
* decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread
|
||||
* edit, observed-state-as-prior-observation (read/write/edit all record),
|
||||
* multi-owner isolation, single-slot first-wins, and disposal/HMR release.
|
||||
*
|
||||
* No `ctx.fs` provider is needed — the plugin does no filesystem I/O; it only
|
||||
* decides expectations and records versions on its own WeakMap.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteExpectation,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import FileContext, { STREAM_MIN_SIZE } from '@deepseek-ai/dsh-file-context'
|
||||
import type { FileContextExec, FileReadRequest } from '@deepseek-ai/dsh-file-context'
|
||||
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsWriteExpectation } from '@deepseek-ai/dsh-fs'
|
||||
import * as FileContext from '@deepseek-ai/dsh-file-context'
|
||||
import type { FileContextExec } from '@deepseek-ai/dsh-file-context'
|
||||
|
||||
/** A fake provider: in-memory files, recording every expectation/version it is handed. */
|
||||
class FakeFs extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
versions = new Map<string, number>()
|
||||
/** Size to report from stat (lets a test push read onto the streaming path). */
|
||||
reportSize?: number
|
||||
/** When true, stat omits `size` entirely (a size-less backend). */
|
||||
omitSize = false
|
||||
/** Whether streamText was used for the last read (vs readText). */
|
||||
lastReadStreamed = false
|
||||
writeExpectations: FsWriteExpectation[] = []
|
||||
editExpectedVersions: string[] = []
|
||||
function target(path: string): FsTarget {
|
||||
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
const ownerExec = (session: object): FileContextExec => ({ agent: { session } })
|
||||
|
||||
private ver(key: string): FsVersion {
|
||||
return FsVersion(`v${this.versions.get(key) ?? 0}`)
|
||||
}
|
||||
private bump(key: string): FsVersion {
|
||||
const next = (this.versions.get(key) ?? 0) + 1
|
||||
this.versions.set(key, next)
|
||||
return FsVersion(`v${next}`)
|
||||
}
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) return undefined
|
||||
return { version: this.ver(target.targetKey), type: 'file', ...this.omitSize ? {} : { size: this.reportSize ?? content.length } }
|
||||
}
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
this.lastReadStreamed = false
|
||||
return this.files.get(target.targetKey) ?? ''
|
||||
}
|
||||
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
this.lastReadStreamed = true
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
override async writeText(target: FsTarget, content: string, expected: FsWriteExpectation): 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 editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }): 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) }
|
||||
}
|
||||
/** Dispatch the write-expectation waterfall with the bare default thunk. */
|
||||
function writeExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteExpectation | undefined> {
|
||||
return ctx.waterfall('fs/write-expectation', t, actor, () => undefined)
|
||||
}
|
||||
/** Dispatch the edit-expectation waterfall with the bare default thunk. */
|
||||
function editExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> {
|
||||
return ctx.waterfall('fs/edit-expectation', t, actor, () => undefined)
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(FileContext)
|
||||
const fs = ctx.fs as FakeFs
|
||||
const fileContext = ctx.fileContext
|
||||
return { ctx, fs, fileContext }
|
||||
const fiber = await ctx.plugin(FileContext)
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
const READ_ALL: FileReadRequest = { offset: 1, limit: 2000 }
|
||||
const ownerExec = (session: object): FileContextExec => ({ agent: { session } })
|
||||
|
||||
describe('registration / disposal', () => {
|
||||
it('registers as ctx.fileContext and injects fs', async () => {
|
||||
const { fileContext } = await setup()
|
||||
expect(fileContext).toBeDefined()
|
||||
it('registers no service surface (it is a plugin, not ctx.fileContext)', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect((ctx as Context & { fileContext?: unknown }).fileContext).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stays pending until ctx.fs exists', async () => {
|
||||
it('mounts with no inject (reads no services)', async () => {
|
||||
// It mounts immediately even with nothing else in the context.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FileContext) // no fs provider
|
||||
expect(ctx.fileContext).toBeUndefined()
|
||||
})
|
||||
|
||||
it('withdraws ctx.fileContext when its fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFs)
|
||||
const fiber = await ctx.plugin(FileContext)
|
||||
expect(ctx.fileContext).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(ctx.fileContext).toBeUndefined()
|
||||
await ctx.plugin(FileContext)
|
||||
// The listener is live: an unobserved write decides createIfAbsent.
|
||||
expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('owner derivation', () => {
|
||||
it('derives the owner from exec.agent.session', async () => {
|
||||
const { fileContext } = await setup()
|
||||
const session = {}
|
||||
expect(fileContext.owner(ownerExec(session))).toBe(session)
|
||||
describe('write-expectation decision', () => {
|
||||
it('an unobserved target decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('returns undefined with no exec, no agent, or no session', async () => {
|
||||
const { fileContext } = await setup()
|
||||
expect(fileContext.owner()).toBeUndefined()
|
||||
expect(fileContext.owner({})).toBeUndefined()
|
||||
expect(fileContext.owner({ agent: {} })).toBeUndefined()
|
||||
it('a no-owner actor decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(await writeExpectation(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('an observed target decides replaceIfVersion at the observed version', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec)
|
||||
expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('read', () => {
|
||||
it('returns a windowed outcome and rejects an absent target', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
fs.files.set('a.txt', 'one\ntwo')
|
||||
const outcome = await fileContext.read(await fs.resolve('a.txt'), READ_ALL)
|
||||
expect(outcome.lines).toEqual([{ number: 1, text: 'one' }, { number: 2, text: 'two' }])
|
||||
expect(outcome.version).toBe('v0')
|
||||
|
||||
await expect(fileContext.read(await fs.resolve('missing.txt'), READ_ALL))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
describe('edit-expectation decision', () => {
|
||||
it('rejects an unread edit with FS_NOT_OBSERVED', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects a non-regular target', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
fs.files.set('d', '')
|
||||
const target = await fs.resolve('d')
|
||||
// Force stat to report a directory.
|
||||
fs.stat = async () => ({ version: FsVersion('v0'), type: 'directory' })
|
||||
await expect(fileContext.read(target, READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
it('rejects an edit with no owner (cannot prove prior observation)', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editExpectation(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('reads small files whole and large files via streamText', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
fs.files.set('a.txt', 'one\ntwo')
|
||||
|
||||
await fileContext.read(await fs.resolve('a.txt'), READ_ALL)
|
||||
expect(fs.lastReadStreamed).toBe(false)
|
||||
|
||||
fs.reportSize = STREAM_MIN_SIZE
|
||||
await fileContext.read(await fs.resolve('a.txt'), READ_ALL)
|
||||
expect(fs.lastReadStreamed).toBe(true)
|
||||
})
|
||||
|
||||
it('streams when the backend reports no size (never buffers a size-less file)', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
fs.files.set('a.txt', 'one\ntwo')
|
||||
fs.omitSize = true
|
||||
await fileContext.read(await fs.resolve('a.txt'), READ_ALL)
|
||||
expect(fs.lastReadStreamed).toBe(true)
|
||||
})
|
||||
|
||||
it('records the version observed after the read, not the routing stat', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
it('returns the observed version as the CAS basis after an observation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
fs.files.set('a.txt', 'hello')
|
||||
fs.versions.set('a.txt', 1)
|
||||
const target = await fs.resolve('a.txt')
|
||||
// A writer bumps the version after the routing stat but before the post-read stat.
|
||||
const realReadText = fs.readText.bind(fs)
|
||||
fs.readText = async (t) => {
|
||||
const text = await realReadText(t)
|
||||
fs.versions.set('a.txt', 5) // file changed during the read
|
||||
return text
|
||||
}
|
||||
const outcome = await fileContext.read(target, READ_ALL, exec)
|
||||
expect(outcome.version).toBe('v5')
|
||||
// The recorded (post-read) version authorizes an edit without going stale.
|
||||
await fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)
|
||||
expect(fs.editExpectedVersions).toEqual(['v5'])
|
||||
})
|
||||
|
||||
it('falls back to the routing-stat version if the file vanishes after the read', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
fs.files.set('a.txt', 'hello')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const realReadText = fs.readText.bind(fs)
|
||||
fs.readText = async (t) => {
|
||||
const text = await realReadText(t)
|
||||
fs.files.delete('a.txt') // vanishes → post-read stat returns undefined
|
||||
return text
|
||||
}
|
||||
const outcome = await fileContext.read(target, READ_ALL)
|
||||
expect(outcome.version).toBe('v0') // the routing-stat version
|
||||
})
|
||||
|
||||
it('surfaces truncatedByBytes when the window hits the byte cap', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
fs.files.set('big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
|
||||
const outcome = await fileContext.read(await fs.resolve('big.txt'), READ_ALL)
|
||||
expect(outcome.truncatedByBytes).toBe(true)
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec)
|
||||
expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('observed-state is the read record', () => {
|
||||
it('a read authorizes a later in-place write at the observed version', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
describe('observed-state is the prior-observation record', () => {
|
||||
it('a read observation authorizes an in-place write at that version', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
fs.files.set('a.txt', 'hello')
|
||||
const target = await fs.resolve('a.txt')
|
||||
|
||||
await fileContext.read(target, READ_ALL, exec)
|
||||
await fileContext.write(target, 'goodbye', exec)
|
||||
|
||||
expect(fs.writeExpectations).toEqual([{ kind: 'replaceIfVersion', version: 'v0' }])
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read
|
||||
expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
})
|
||||
|
||||
it('a windowed (partial) read still authorizes edit — freshness, not full/partial', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
fs.files.set('a.txt', 'one\ntwo\nthree\nfour')
|
||||
const target = await fs.resolve('a.txt')
|
||||
|
||||
// Read only lines 2-3 — a partial window.
|
||||
const outcome = await fileContext.read(target, { offset: 2, limit: 2 }, exec)
|
||||
expect(outcome.lines.map(l => l.number)).toEqual([2, 3])
|
||||
|
||||
// Edit is authorized anyway: the file is unchanged since the read.
|
||||
await fileContext.edit(target, { oldString: 'one', newString: 'X', replaceAll: false }, exec)
|
||||
expect(fs.editExpectedVersions).toEqual(['v0'])
|
||||
// A create records v1; the follow-up edit guards against v1 with no read.
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec)
|
||||
expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' })
|
||||
// The edit records v2; a second edit guards against v2.
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec)
|
||||
expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
|
||||
})
|
||||
|
||||
it('skips recording when there is no owner, so write is createIfAbsent', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
fs.files.set('a.txt', 'hello')
|
||||
const target = await fs.resolve('a.txt')
|
||||
|
||||
await fileContext.read(target, READ_ALL) // no exec
|
||||
// No recorded read → createIfAbsent → the provider rejects an existing target.
|
||||
fs.writeText = async () => { throw new FsError('exists', 'FS_NOT_OBSERVED') }
|
||||
await expect(fileContext.write(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('write policy', () => {
|
||||
it('a create (no prior read) uses createIfAbsent', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
const exec = ownerExec({})
|
||||
const target = await fs.resolve('new.txt')
|
||||
const outcome = await fileContext.write(target, 'fresh', exec)
|
||||
expect(outcome.operation).toBe('create')
|
||||
expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }])
|
||||
})
|
||||
|
||||
it('refreshes state after a write, so a follow-up edit needs no re-read', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
const exec = ownerExec({})
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fileContext.write(target, 'one', exec) // create → state now at v1
|
||||
await fileContext.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, fileContext } = await setup()
|
||||
const exec = ownerExec({})
|
||||
fs.files.set('a.txt', 'hello')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects when there is no owner (cannot prove prior observation)', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
fs.files.set('a.txt', 'hello')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('passes the recorded version as the stale guard after a read', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
const exec = ownerExec({})
|
||||
fs.files.set('a.txt', 'hello')
|
||||
fs.versions.set('a.txt', 7)
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fileContext.read(target, READ_ALL, exec)
|
||||
await fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)
|
||||
expect(fs.editExpectedVersions).toEqual(['v7'])
|
||||
it('a no-owner observation records nothing', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined)
|
||||
// Still unobserved for any owner.
|
||||
await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-owner isolation', () => {
|
||||
it('owner A reading does not grant owner B edit authority', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
it('owner A observing does not grant owner B edit authority', async () => {
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
fs.files.set('a.txt', 'hello')
|
||||
const target = await fs.resolve('a.txt')
|
||||
|
||||
await fileContext.read(target, READ_ALL, a)
|
||||
await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a))
|
||||
.resolves.toMatchObject({ replacements: 1 })
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a)
|
||||
await expect(editExpectation(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await editExpectation(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
|
||||
})
|
||||
|
||||
it('each owner records its own observed version independently', async () => {
|
||||
const { fs, fileContext } = await setup()
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
fs.files.set('a.txt', 'hello')
|
||||
const target = await fs.resolve('a.txt')
|
||||
|
||||
await fileContext.read(target, READ_ALL, a) // A sees v0
|
||||
await fileContext.write(target, 'mid', b) // B has no read → createIfAbsent
|
||||
await fileContext.write(target, 'late', a) // A still holds its v0 observation
|
||||
|
||||
expect(fs.writeExpectations).toEqual([
|
||||
{ kind: 'createIfAbsent' },
|
||||
{ kind: 'replaceIfVersion', version: 'v0' },
|
||||
])
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0
|
||||
// B never observed → createIfAbsent; A still holds v0 → replaceIfVersion.
|
||||
expect(await writeExpectation(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(await writeExpectation(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal releases recorded state', () => {
|
||||
it('a fresh service after disposal starts with no inherited state', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFs)
|
||||
const fs = ctx.fs as FakeFs
|
||||
const fiber = await ctx.plugin(FileContext)
|
||||
describe('single-slot, first-wins', () => {
|
||||
it('fully decides the slot without calling next() (the bare default is unreached)', async () => {
|
||||
const { ctx } = await setup()
|
||||
let defaultRan = false
|
||||
const expectation = await ctx.waterfall('fs/write-expectation', target('a.txt'), ownerExec({}), () => {
|
||||
defaultRan = true
|
||||
return undefined
|
||||
})
|
||||
expect(expectation).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(defaultRan).toBe(false)
|
||||
})
|
||||
|
||||
it('a SECOND decider registered AFTER file-context is not reached (first-wins short-circuit)', async () => {
|
||||
const { ctx } = await setup()
|
||||
let secondRan = false
|
||||
// Registered after file-context, so it dispatches second; file-context does
|
||||
// not call next(), so this never runs. (A decider registered BEFORE — or with
|
||||
// prepend — would instead win: first-wins is by convention, not enforced.)
|
||||
ctx.on('fs/edit-expectation', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
const exec = ownerExec({})
|
||||
fs.files.set('a.txt', 'hello')
|
||||
await ctx.fileContext.read(await fs.resolve('a.txt'), READ_ALL, exec)
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
|
||||
await editExpectation(ctx, target('a.txt'), exec)
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal releases recorded state (HMR safety)', () => {
|
||||
it('a fresh plugin after disposal starts with no inherited state', async () => {
|
||||
const ctx = new Context()
|
||||
const exec = ownerExec({})
|
||||
const fiber = await ctx.plugin(FileContext)
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
|
||||
expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
|
||||
await fiber.dispose()
|
||||
|
||||
await ctx.plugin(FileContext)
|
||||
const target = await fs.resolve('a.txt')
|
||||
// Same owner object, but state was released on disposal.
|
||||
await expect(ctx.fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
await expect(editExpectation(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('no listeners remain after disposal (the gate no longer decides)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FileContext)
|
||||
await fiber.dispose()
|
||||
// With no listener, the waterfall falls through to the bare default.
|
||||
expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Cordis-free tests for the line-windowing module: offset/limit windows, byte
|
||||
* caps, per-line truncation, CRLF stripping, offset-past-EOF rejection, and the
|
||||
* capped line buffer for newline-free giant lines — all over an async-iterable
|
||||
* of decoded text chunks (so one code path serves whole-file and streamed reads).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-file-context'
|
||||
import type { ReadWindow } from '@deepseek-ai/dsh-file-context'
|
||||
|
||||
const READ_ALL: ReadWindow = { offset: 1, limit: 2000 }
|
||||
|
||||
/** Yield `text` as one chunk (whole-file read shape). */
|
||||
async function* whole(text: string): AsyncIterable<string> {
|
||||
yield text
|
||||
}
|
||||
|
||||
/** Yield `text` split into fixed-size chunks (streamed read shape). */
|
||||
async function* chunked(text: string, size: number): AsyncIterable<string> {
|
||||
for (let i = 0; i < text.length; i += size) yield text.slice(i, i + size)
|
||||
}
|
||||
|
||||
describe('buildWindow', () => {
|
||||
it('numbers lines and reports total for a whole-file read', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\nthree'), READ_ALL, 'f')
|
||||
expect(result.lines).toEqual([
|
||||
{ number: 1, text: 'one' },
|
||||
{ number: 2, text: 'two' },
|
||||
{ number: 3, text: 'three' },
|
||||
])
|
||||
expect(result.totalLines).toBe(3)
|
||||
expect(result.truncatedByBytes).toBe(false)
|
||||
})
|
||||
|
||||
it('applies offset/limit', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2 }, 'f')
|
||||
expect(result.lines.map(l => l.number)).toEqual([2, 3])
|
||||
expect(result.totalLines).toBe(4)
|
||||
})
|
||||
|
||||
it('strips CRLF', async () => {
|
||||
const result = await buildWindow(whole('one\r\ntwo\r\n'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('truncates an over-long line', async () => {
|
||||
const result = await buildWindow(whole('x'.repeat(3000)), READ_ALL, 'f')
|
||||
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
|
||||
})
|
||||
|
||||
it('caps output bytes and reports truncatedByBytes', async () => {
|
||||
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
|
||||
const result = await buildWindow(whole(big), READ_ALL, 'f')
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
it('reads an empty file at offset 1 as zero lines', async () => {
|
||||
const result = await buildWindow(whole(''), READ_ALL, 'f')
|
||||
expect(result.lines).toEqual([])
|
||||
expect(result.totalLines).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects an offset past EOF', async () => {
|
||||
await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1 }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('flushes a final line with no trailing newline', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('handles a trailing newline (no dangling empty line)', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\n'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
describe('chunked input (streamed read shape)', () => {
|
||||
it('windows identically when text arrives in small chunks', async () => {
|
||||
const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1 }, 'f')
|
||||
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
|
||||
expect(result.totalLines).toBe(3)
|
||||
})
|
||||
|
||||
it('caps a newline-free giant line split across chunks without unbounded buffering', async () => {
|
||||
const result = await buildWindow(chunked('z'.repeat(5000), 256), READ_ALL, 'f')
|
||||
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
|
||||
})
|
||||
|
||||
it('caps output bytes mid-stream', async () => {
|
||||
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
|
||||
const result = await buildWindow(chunked(big, 512), READ_ALL, 'f')
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
it('flushes a final newline-terminated line across a chunk boundary', async () => {
|
||||
const result = await buildWindow(chunked('one\ntwo\n', 3), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user