refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/fs/fs-observation-policy/README.md
|
||||
README.md: a126c7d0e3c806e3ce22eedfb05b21d8fd5aba60
|
||||
README.zh.md: 829893028e388380e33b35ec9d5bf4c9d0a58757
|
||||
@@ -0,0 +1,73 @@
|
||||
# @deepseek-ai/dsh-fs-observation-policy
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **fs-observation-policy plugin**: it records observed presence or absence and adds read-before-edit plus guarded write/edit on top of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` 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 '@deepseek-ai/cordis'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy'
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
// 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/*-intent slots.
|
||||
await ctx.plugin(FsPolicy)
|
||||
```
|
||||
|
||||
## The four-layer split
|
||||
|
||||
| Layer | Package | Role |
|
||||
|---|---|---|
|
||||
| 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-fs-observation-policy` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
|
||||
| provider contract | `@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` |
|
||||
|
||||
## How the gate participates
|
||||
|
||||
Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek-ai/dsh-tool-fs`):
|
||||
|
||||
| Event | This plugin's listener |
|
||||
|---|---|
|
||||
| `fs/write-intent` | Unseen or observed absent → `{ kind: 'createIfAbsent' }`; observed present → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/edit-intent` | Unseen → `FS_NOT_OBSERVED`; observed absent → `FS_NOT_FOUND`; observed present → `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/observed` | Records `{ kind: 'present', version }` or `{ kind: 'absent' }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
|
||||
|
||||
## Observed state is the prior-observation record; freshness is provider CAS
|
||||
|
||||
Observed state is a weak owner-to-target map with three logical states: unseen, confirmed absent, or present at a version. A successful file read or mutation records presence; a metadata miss from `read` or the `str_replace_editor` `view`, `str_replace`, or `insert` command records absence before returning `FS_NOT_FOUND`. The plugin performs no filesystem I/O: it converts that state into a provider guard. Presence supplies the observed version, while absence lets only a `createIfAbsent` write proceed; edit has no version basis and returns `FS_NOT_FOUND`. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions.
|
||||
|
||||
## Single-slot, first-wins
|
||||
|
||||
The `fs/write-intent`/`fs/edit-intent` 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`.
|
||||
|
||||
## 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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Filesystem tool outcome
|
||||
|
||||
#### What the model sees
|
||||
|
||||
This plugin adds no prompt or schema. It rejects an edit without a prior observation with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`; editing a target just observed absent returns `FS_NOT_FOUND`. Guarded mutations whose positive observation is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code. Following the stale remedy on an externally deleted target now records absence: the next guarded write may recreate it with `createIfAbsent`, while the provider atomically preserves any concurrent creator.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero tokens on allowed operations beyond the ordinary tool result. A denial adds the small retained error result and avoids any success payload.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Observed state does not survive a session resume** — persistence of the `WeakMap` record is deferred, so a resumed session must re-read files before guarded writes/edits.
|
||||
- **Actors without an agent session can never satisfy the policy** — their edits throw `FS_NOT_OBSERVED` and their writes always resolve `createIfAbsent`, so a non-agent caller cannot overwrite an existing file through the gate.
|
||||
- **Direct `ctx.fs` reads emit no `fs/observed`** — a file read outside the `read` tool stays unobserved, and a later guarded edit rejects with `FS_NOT_OBSERVED` until the tool reads it.
|
||||
- **Authorization is version freshness, not view completeness** — any windowed read authorizes a full-file overwrite of an unchanged file, deliberately weaker than a full-view rule ([seam-split Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)).
|
||||
@@ -0,0 +1,73 @@
|
||||
# @deepseek-ai/dsh-fs-observation-policy
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**fs-observation-policy 插件**:它记录观测到的存在或缺失状态,并在 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))之上增加编辑前读取和带防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的政策层:不是可替换 seam,而是不应位于 `FileSystem` 提供方基类上的政策。
|
||||
|
||||
```ts
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy'
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
// 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/*-intent slots.
|
||||
await ctx.plugin(FsPolicy)
|
||||
```
|
||||
|
||||
## 四层拆分
|
||||
|
||||
| 层 | 包 | 角色 |
|
||||
|---|---|---|
|
||||
| 工具/执行器 | `@deepseek-ai/dsh-tool-fs` | 面向模型的 schema、读取窗口和文本渲染;通过 `ctx.fs` 读取/写入/编辑,并分派 `fs/*` 事件 |
|
||||
| 策略 | `@deepseek-ai/dsh-fs-observation-policy`(本包) | 通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑(无服务) |
|
||||
| 提供方约定 | `@deepseek-ai/dsh-fs` | `ctx.fs`:文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 事件词汇 |
|
||||
| 提供方 | `@deepseek-ai/dsh-fs-local` | `ctx.fs` 的本地实现 |
|
||||
|
||||
## 门禁的参与方式
|
||||
|
||||
三个 `fs/*` 事件(由 `@deepseek-ai/dsh-fs` 声明,`@deepseek-ai/dsh-tool-fs` 分派):
|
||||
|
||||
| 事件 | 本插件的监听器 |
|
||||
|---|---|
|
||||
| `fs/write-intent` | 未见或已观测为缺失 → `{ kind: 'createIfAbsent' }`;已观测为存在 → `{ kind: 'replaceIfVersion', version: vObserved }`。单 slot 决策;不调用 `next()`。 |
|
||||
| `fs/edit-intent` | 未见 → `FS_NOT_OBSERVED`;已观测为缺失 → `FS_NOT_FOUND`;已观测为存在 → 返回 `{ version: vObserved }` 作为 CAS 基础。单 slot 决策;不调用 `next()`。 |
|
||||
| `fs/observed` | 为该所有者与目标记录 `{ kind: 'present', version }` 或 `{ kind: 'absent' }`。同步、只有副作用的 `WeakMap.set`。 |
|
||||
|
||||
## 已观察状态是先前观察记录;新鲜度由提供方 CAS 保证
|
||||
|
||||
观测状态是一张以所有者为弱键、记录各目标的映射表,具有三种逻辑状态:未见、确认缺失、存在于某个版本。成功读取文件或变更会记录存在;`read` 的元数据未命中,或 `str_replace_editor` 的 `view`、`str_replace`、`insert` 命令发生元数据未命中时,都会在返回 `FS_NOT_FOUND` 前记录缺失。插件不执行文件系统 I/O:它把该状态转换为提供方防护。存在状态提供观测到的版本;缺失状态只允许 `createIfAbsent` 写入继续,edit 因没有版本基准而返回 `FS_NOT_FOUND`。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose(资源释放)时会丢弃状态,并且不会跨会话持久化。
|
||||
|
||||
## 单 slot、先到者胜
|
||||
|
||||
`fs/write-intent`/`fs/edit-intent` slot 只容纳一个决策器;本插件会完整决策,不调用 `next()`。slot 按注册顺序先到者胜;由本插件拥有 slot 只是默认部署约定,不是事件强制的不变式(更早注册或通过 `prepend` 注册的决策器会胜出)。这不是可组合的授权链;分层权限/审计/沙箱拦截属于 `tools/execute`。
|
||||
|
||||
## 不与方法耦合
|
||||
|
||||
由于插件只通过事件影响外部世界,移除它不会在服务注入边界破坏 `@deepseek-ai/dsh-tool-fs`:工具会直接落到裸 `ctx.fs` 提供方(无条件写入/编辑,无已观察状态)。重新加载插件后,策略会重新生效。相比必需的方法服务,这种可平稳增删的性质正是事件门禁的全部目的。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 文件系统工具结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该插件不添加提示词或 schema。没有先前观测时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝编辑;编辑刚被观测为缺失的目标会返回 `FS_NOT_FOUND`。正向观测陈旧时,带防护的变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md) 拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码。外部删除目标后,遵循陈旧恢复指令会记录缺失:下一次带防护的写入可以通过 `createIfAbsent` 重新创建该目标,而提供方会以原子方式保留任何并发创建者写入的文件。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
允许的操作除了普通工具结果外不增加 token。拒绝会添加少量保留的错误结果,并避免产生成功 payload。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **已观察状态无法在会话恢复后保留**:`WeakMap` 记录的持久化工作延期处理,因此恢复的会话必须重新读取文件,才能执行防护写入/编辑。
|
||||
- **没有 agent(智能体)会话的参与者绝无法满足策略**:它们的编辑会抛出 `FS_NOT_OBSERVED`,写入总会解析为 `createIfAbsent`,因此非 agent 调用方无法通过门禁覆盖现有文件。
|
||||
- **直接 `ctx.fs` 读取不会发出 `fs/observed`**:在 `read` 工具之外读取的文件仍未观察;后续防护编辑会以 `FS_NOT_OBSERVED` 拒绝,直到工具读取该文件。
|
||||
- **授权依据是版本新鲜度,而非视图完整性**:任何窗口读取都会授权对未变文件执行全文件覆盖,这有意弱于完整视图规则(见 [seam 拆分 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md))。
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs-observation-policy",
|
||||
"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 API)",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/fs/fs-observation-policy"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Event-only filesystem observation policy; it registers no service. A weak owner/target map
|
||||
* records every authoritative presence/absence observation, single-slot intent listeners derive
|
||||
* guards from that state, and the provider performs the atomic freshness/no-clobber check. Without
|
||||
* this plugin, tools retain the bare provider's unconditional mutation behavior. See the package
|
||||
* README for composition rules.
|
||||
* @module @deepseek-ai/dsh-fs-observation-policy
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsObservationActor } from './types.ts'
|
||||
|
||||
export type { FsObservationActor } from './types.ts'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 prior-observation record; its discriminant keeps
|
||||
* confirmed absence distinct from an unseen target.
|
||||
*/
|
||||
private observed = new WeakMap<object, Map<string, FsObservation>>()
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private owner(actor: object | undefined): object | undefined {
|
||||
// tsgolint treats object as assignable to weak FsObservationActor, while tsc still requires the structural cast for property access.
|
||||
// See the analyzer-divergence consequence in .agents/notes/implemented/process/2026-07-29-oxlint-linter.md.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- The analyzers disagree on this weak type.
|
||||
return (actor as FsObservationActor | undefined)?.agent?.session
|
||||
}
|
||||
|
||||
private get(owner: object, targetKey: string): FsObservation | undefined {
|
||||
return this.observed.get(owner)?.get(targetKey)
|
||||
}
|
||||
|
||||
private set(owner: object, targetKey: string, observation: FsObservation): void {
|
||||
let byTarget = this.observed.get(owner)
|
||||
if (!byTarget) {
|
||||
byTarget = new Map()
|
||||
this.observed.set(owner, byTarget)
|
||||
}
|
||||
byTarget.set(targetKey, observation)
|
||||
}
|
||||
|
||||
/** Drop all recorded state (HMR safety / disposal). */
|
||||
clear(): void {
|
||||
this.observed = new WeakMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the write intent: unseen or confirmed absent ⇒ `createIfAbsent`;
|
||||
* confirmed present ⇒ `replaceIfVersion` at the observed version.
|
||||
*/
|
||||
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
return prior?.kind === 'present'
|
||||
? { kind: 'replaceIfVersion', version: prior.version }
|
||||
: { kind: 'createIfAbsent' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the edit version guard: unseen rejects with `FS_NOT_OBSERVED`,
|
||||
* confirmed absence rejects with `FS_NOT_FOUND`, and presence supplies the
|
||||
* observed version as the CAS basis.
|
||||
*/
|
||||
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
if (!owner || prior === undefined) {
|
||||
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
|
||||
}
|
||||
if (prior.kind === 'absent') {
|
||||
throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
}
|
||||
return { version: prior.version }
|
||||
}
|
||||
|
||||
/** Record an authoritative present or absent observation for this owner and target. */
|
||||
observe(target: FsTarget, observation: FsObservation, actor: object | undefined): void {
|
||||
const owner = this.owner(actor)
|
||||
if (owner) this.set(owner, target.targetKey, observation)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'fs-observation-policy'
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}, 'fs-observation-policy observed-state teardown')
|
||||
|
||||
// fs/write-intent: 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-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))
|
||||
|
||||
// fs/edit-intent: occupy the single decision slot — do not call next().
|
||||
ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))
|
||||
|
||||
// fs/observed must remain synchronous and non-throwing: emit does not await
|
||||
// promises, and successful mutations have already committed. WeakMap.set
|
||||
// satisfies that contract for both presence and absence.
|
||||
ctx.on('fs/observed', (target, observation, actor) => {
|
||||
gate.observe(target, observation, actor)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-fs-observation-policy`.
|
||||
* @module @deepseek-ai/dsh-fs-observation-policy/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-fs-observation-policy'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'fs-observation-policy-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Vocabulary for the fs-observation-policy plugin: the minimal execution-context
|
||||
* fields used to derive an observed-state owner by narrowing the opaque `object`
|
||||
* actor the `fs/*` events carry.
|
||||
*
|
||||
* The provider vocabulary (`FsTarget`, `FsVersion`, write/edit request types) is
|
||||
* re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state
|
||||
* owner structure on top of it.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-observation-policy/types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal structural view of a tool execution the policy plugin needs to derive
|
||||
* an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` contains
|
||||
* these fields, so the tool passes its `exec` straight through as the opaque
|
||||
* `object` actor on the `fs/*` events; this plugin narrows that actor to
|
||||
* `FsObservationActor` 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.
|
||||
*/
|
||||
export interface FsObservationActor {
|
||||
/** The agent on whose behalf the call runs, when there is one. */
|
||||
agent?: {
|
||||
/** The session that owns observed-file state, used as an opaque key. */
|
||||
session?: object
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/** Event-level policy tests; no filesystem provider is needed because the plugin performs no I/O. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsObservation, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy'
|
||||
import type { FsObservationActor } from '@deepseek-ai/dsh-fs-observation-policy'
|
||||
|
||||
function target(path: string): FsTarget {
|
||||
return { targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
const ownerExec = (session: object): FsObservationActor => ({ agent: { session } })
|
||||
const present = (version: string): FsObservation => ({ kind: 'present', version: FsVersion(version) })
|
||||
const absent: FsObservation = { kind: 'absent' }
|
||||
|
||||
/** Dispatch the write-intent waterfall with the bare default thunk. */
|
||||
function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteIntent | undefined> {
|
||||
return ctx.waterfall('fs/write-intent', t, actor, () => undefined)
|
||||
}
|
||||
/** Dispatch the edit-intent waterfall with the bare default thunk. */
|
||||
function editIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> {
|
||||
return ctx.waterfall('fs/edit-intent', t, actor, () => undefined)
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FsPolicy)
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
describe('registration / disposal', () => {
|
||||
it('registers no service API (it is a plugin, not ctx.fsPolicy)', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect((ctx as Context & { fsPolicy?: unknown }).fsPolicy).toBeUndefined()
|
||||
})
|
||||
|
||||
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(FsPolicy)
|
||||
// The listener is live: an unobserved write decides createIfAbsent.
|
||||
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('write-intent decision', () => {
|
||||
it('an unobserved target decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('a no-owner actor decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(await writeIntent(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('an actor with an agent but no session has no owner (createIfAbsent)', async () => {
|
||||
// The middle optional-chain rung: agent present, session undefined ⇒ owner
|
||||
// undefined ⇒ unobservable, so a write can only be a blind create.
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), { agent: {} })).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'), present('v7'), exec)
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
|
||||
})
|
||||
|
||||
it('a target observed absent decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), absent, exec)
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit-intent decision', () => {
|
||||
it('rejects an unread edit with FS_NOT_OBSERVED', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects an edit with no owner (cannot prove prior observation)', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects an edit whose actor has an agent but no session (no owner)', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), { agent: {} })).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('returns the observed version as the CAS basis after an observation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v3'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
|
||||
})
|
||||
|
||||
it('rejects editing a target observed absent with FS_NOT_FOUND', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), absent, exec)
|
||||
await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
|
||||
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({})
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec) // a read
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
})
|
||||
|
||||
it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
// A create records v1; the follow-up edit guards against v1 with no read.
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v1'), exec)
|
||||
expect(await editIntent(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'), present('v2'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
|
||||
})
|
||||
|
||||
it('a no-owner observation records nothing', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), undefined)
|
||||
// Still unobserved for any owner.
|
||||
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('supports present → absent → present transitions for one owner', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
const a = target('a.txt')
|
||||
ctx.emit('fs/observed', a, present('v1'), exec)
|
||||
expect(await writeIntent(ctx, a, exec)).toEqual({ kind: 'replaceIfVersion', version: 'v1' })
|
||||
|
||||
ctx.emit('fs/observed', a, absent, exec)
|
||||
expect(await writeIntent(ctx, a, exec)).toEqual({ kind: 'createIfAbsent' })
|
||||
await expect(editIntent(ctx, a, exec)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
|
||||
ctx.emit('fs/observed', a, present('v2'), exec)
|
||||
expect(await editIntent(ctx, a, exec)).toEqual({ version: 'v2' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-owner isolation', () => {
|
||||
it('owner A observing does not grant owner B edit authority', async () => {
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), a)
|
||||
await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
|
||||
})
|
||||
|
||||
it('each owner records its own observed version independently', async () => {
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), a) // A observed v0
|
||||
// B never observed → createIfAbsent; A still holds v0 → replaceIfVersion.
|
||||
expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
})
|
||||
})
|
||||
|
||||
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 intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => {
|
||||
defaultRan = true
|
||||
return undefined
|
||||
})
|
||||
expect(intent).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(defaultRan).toBe(false)
|
||||
})
|
||||
|
||||
it('a SECOND decider registered AFTER fs-observation-policy is not reached (first-wins short-circuit)', async () => {
|
||||
const { ctx } = await setup()
|
||||
let secondRan = false
|
||||
// Registered after fs-observation-policy, so it dispatches second; fs-observation-policy 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-intent', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec)
|
||||
await editIntent(ctx, target('a.txt'), exec)
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
|
||||
it('a SECOND write-intent decider registered AFTER fs-observation-policy is not reached', async () => {
|
||||
const { ctx } = await setup()
|
||||
let secondRan = false
|
||||
ctx.on('fs/write-intent', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
await writeIntent(ctx, target('a.txt'), ownerExec({}))
|
||||
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(FsPolicy)
|
||||
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
|
||||
await fiber.dispose()
|
||||
|
||||
await ctx.plugin(FsPolicy)
|
||||
// Same owner object, but state was released on disposal.
|
||||
await expect(editIntent(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(FsPolicy)
|
||||
await fiber.dispose()
|
||||
// With no listener, the waterfall falls through to the bare default.
|
||||
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../fs"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user