feat(tool-fs): preflight and execute ordered patches
This commit is contained in:
1 parent
27a4010098
commit
9a4e78b469
16 files changed
+1288
-15
No files matched your search
@@ -36,17 +36,17 @@
|
||||
en: e30f7038c28e93f8
|
||||
zh: a3868dbd90367b13
|
||||
/deepseek-ai-dsh-tool-fs/understand-the-implementation/design-concept:
|
||||
en: 454b5291db9ba847
|
||||
zh: 9fe7081dfb416c93
|
||||
en: 1f4d7213aa3d0d60
|
||||
zh: aa6573f96c8b5220
|
||||
/deepseek-ai-dsh-tool-fs/understand-the-implementation/source-map:
|
||||
en: e8220ed04480aadc
|
||||
zh: 1cc5f9a505953102
|
||||
en: 8be7e507d38ffc32
|
||||
zh: de2497d7d9579277
|
||||
/deepseek-ai-dsh-tool-fs/understand-the-implementation/per-tool-flow:
|
||||
en: d741e01be9fddb76
|
||||
zh: 7aa1d7ca719a4aac
|
||||
en: b7b3be2cc2e5587d
|
||||
zh: db478d8dedae58cd
|
||||
/deepseek-ai-dsh-tool-fs/understand-the-implementation/observation-and-concurrency:
|
||||
en: 665d437dc35bf05f
|
||||
zh: 8004334d6a3246b4
|
||||
en: cb6e48e724fa17d2
|
||||
zh: e0b6646ddcbdcb6d
|
||||
/deepseek-ai-dsh-tool-fs/further-exploration:
|
||||
en: 485bc55482f576bc
|
||||
zh: fc5fa55b41098bd0
|
||||
@@ -135,8 +135,8 @@
|
||||
en: f5d5f97a153f7c70
|
||||
zh: 3d6bc05b9eadfbc2
|
||||
/deepseek-ai-dsh-tool-fs/known-limitations-and-deferred-work:
|
||||
en: ce7a91d41d2f7597
|
||||
zh: d4a3c905e124fd7f
|
||||
en: b7c956a5f22ed9e3
|
||||
zh: bd68af719e4bf154
|
||||
/deepseek-ai-dsh-tool-fs/known-limitations-and-deferred-work/dev-note:
|
||||
en: 0c4d0c465413d319
|
||||
zh: a20efeb10121b927
|
||||
@@ -85,7 +85,7 @@ This section explains the design decisions behind the tool suite and points at t
|
||||
|
||||
### Design concept
|
||||
|
||||
The tools are the executor; policy is an event gate. The tools inject no policy service and inspect no cache — each mutation asks the single intent slot for its guard through `ctx.waterfall`, and each operation emits `fs/observed` only after it succeeded. Reads do exactly one provider `stat` (type and size routing plus the observed version); mutations do none, because the guard comes from the intent slot and the provider re-checks under its lock.
|
||||
The tools are the executor; policy is an event gate. The tools inject no policy service and inspect no cache — each mutation asks the single intent slot for its guard through `ctx.waterfall`, and each operation emits `fs/observed` only after it succeeded. Reads do exactly one provider `stat` (type and size routing plus the observed version); `write` and `edit` do none, because the guard comes from the intent slot and the provider re-checks under its lock.
|
||||
|
||||
### Source map
|
||||
|
||||
@@ -96,6 +96,10 @@ The tools are the executor; policy is an event gate. The tools inject no policy
|
||||
| [`src/read-image.ts`](src/read-image.ts) | `read_image` executor: route and media-type gates, bounded bytes, attachment save |
|
||||
| [`src/write.ts`](src/write.ts) | `write` executor: intent waterfall, atomic write, observation |
|
||||
| [`src/edit.ts`](src/edit.ts) | `edit` executor: intent waterfall, literal edit, observation |
|
||||
| [`src/patch/index.ts`](src/patch/index.ts), [`types.ts`](src/patch/types.ts) | Internal `executePatch` entry and serializable per-operation reports; not exported from the package entry |
|
||||
| [`src/patch/plan.ts`](src/patch/plan.ts) | Parse and resolve Session-relative paths; preserve ordered operations sharing a canonical target |
|
||||
| [`src/patch/preflight.ts`](src/patch/preflight.ts) | Simulate preceding successes and collect observation, sandbox, type, and hunk failures before any write |
|
||||
| [`src/patch/execute.ts`](src/patch/execute.ts) | Reacquire guards, commit sequentially, record actual effects, and classify uncertain failures |
|
||||
| [`src/read-render.ts`](src/read-render.ts) | Cordis-free windowing and envelope formatting |
|
||||
| [`src/sandbox.ts`](src/sandbox.ts) | Escalation API shared by `write`/`edit`: policy resolution and denial-marker mapping |
|
||||
| [`src/error.ts`](src/error.ts) | Stable model-facing diagnostics for guarded-mutation failures |
|
||||
@@ -104,10 +108,16 @@ The tools are the executor; policy is an event gate. The tools inject no policy
|
||||
|
||||
All four tools share one flow shape: resolve the path with the calling session's cwd, run the applicable gate, perform exactly one provider operation, and emit `fs/observed` only after success. `read` and `read_image` pay one `stat` for type and size routing; `write` and `edit` pay none because their guard comes from the intent slot, and provider failures surface as typed `FsError` results. The per-tool executors live in `src/read.ts`, `src/read-image.ts`, `src/write.ts`, and `src/edit.ts`.
|
||||
|
||||
The internal patch executor accepts a raw patch and an already resolved sandbox policy. It builds an ordered plan, preflights all operations against simulated state, then commits through `writeText`, `patchText`, `deleteFile`, or one `moveFile` call. A failed preflight operation has no simulated effect; any preflight failure prevents every write. Reports retain one entry per operation with `completed`, `failed`, `unknown`, or `skipped` status and known LF text changes. Syntax failures precede the plan. Resolution failures retain the raw patch spelling only for unresolved paths; successful resolutions use provider display paths. Programming errors reject the call. The executor is not registered as a tool.
|
||||
|
||||
### Observation and concurrency
|
||||
|
||||
`fs/observed` fires after the operation succeeded via a plain `ctx.emit`; a listener is contractually a synchronous, side-effect-only recorder, so async or fallible observation does not belong on this event. `read` opts into concurrent scheduling because its only mutation is the synchronous version recorder; recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock, and both mutation tools remain exclusive.
|
||||
|
||||
Patch preflight reads never emit `fs/observed`. The first operation on a target dispatches its intent; later preflight operations use the expected state after preceding successes. Each commit dispatches its intents again to obtain current observation guards. Add/update records present, delete records absent, and move records its destination plus source absence only after successful removal. A partial move is failed with a destination-only change and preserves the source observation.
|
||||
|
||||
After a mutation rejects with `FS_IO_ERROR` or `FS_ABORTED`, the executor compares each affected target’s existence and version with its metadata before the call, using a fresh `stat` without the cancelled signal. Unchanged targets mean a known failure; a changed target or failed verification means `unknown` and records no fabricated observation or change. `affectedPaths` contains only known effects. Execution stops at the first failed or unknown commit, or before an operation whose signal is aborted.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -238,6 +248,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
These limits define when the tool suite is a poor fit or needs special operational care. They are current package constraints, not a general filesystem comparison or a task backlog.
|
||||
|
||||
- **Patch commits do not roll back** — earlier successes and a published move destination remain on disk after later failure. Sandbox preflight is best effort; providers enforce permissions and version guards again. An `unknown` result requires re-reading the affected files before retrying.
|
||||
- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling `dsh-tool-fs-search` package supplies ripgrep-backed `glob` and `grep` rather than extending the filesystem seam.
|
||||
- **`read` handles UTF-8 text files only** — images use the separate `read_image` tool; PDF, audio, and video remain deferred. A directory target is `FS_NOT_REGULAR_FILE`.
|
||||
- **Extension-declared media type** — an extension selects the declared type and the attachment store's magic-byte validation stays authoritative; a correctly formatted image under a wrong extension is refused with the rename remedy rather than sniffed. Only a path with no extension is identified from its file signature.
|
||||
|
||||
@@ -85,7 +85,7 @@ kind: "package-reference"
|
||||
|
||||
### 设计理念
|
||||
|
||||
工具就是执行器;策略是事件门禁。工具不注入策略服务,也不检查任何缓存——每次变更都通过 `ctx.waterfall` 向单一意图槽位请求防护,每个操作只在成功后发出 `fs/observed`。读取恰好执行一次提供方 `stat`(类型与大小路由加观察到的版本);变更一次也不执行,因为防护来自意图槽位,提供方在锁内重新检查。
|
||||
工具就是执行器;策略是事件门禁。工具不注入策略服务,也不检查任何缓存——每次变更都通过 `ctx.waterfall` 向单一意图槽位请求防护,每个操作只在成功后发出 `fs/observed`。读取恰好执行一次提供方 `stat`(类型与大小路由加观察到的版本);`write` 与 `edit` 一次也不执行,因为防护来自意图槽位,提供方在锁内重新检查。
|
||||
|
||||
### 源码地图
|
||||
|
||||
@@ -96,6 +96,10 @@ kind: "package-reference"
|
||||
| [`src/read-image.ts`](src/read-image.ts) | `read_image` 执行器:路由与媒体类型门禁、有界字节、附件保存 |
|
||||
| [`src/write.ts`](src/write.ts) | `write` 执行器:意图 waterfall、原子写入、观察 |
|
||||
| [`src/edit.ts`](src/edit.ts) | `edit` 执行器:意图 waterfall、字面量编辑、观察 |
|
||||
| [`src/patch/index.ts`](src/patch/index.ts)、[`types.ts`](src/patch/types.ts) | 内部 `executePatch` 入口与可序列化的逐操作报告;不从包入口导出 |
|
||||
| [`src/patch/plan.ts`](src/patch/plan.ts) | 解析补丁并按 Session 工作目录解析路径;保留共享规范化目标的有序操作 |
|
||||
| [`src/patch/preflight.ts`](src/patch/preflight.ts) | 模拟前序成功操作,在任何写入前收集观察、沙箱、类型与 hunk 失败 |
|
||||
| [`src/patch/execute.ts`](src/patch/execute.ts) | 重新获取防护、顺序提交、记录实际效果并判断不确定失败 |
|
||||
| [`src/read-render.ts`](src/read-render.ts) | 不依赖 Cordis 的窗口构建与信封格式化 |
|
||||
| [`src/sandbox.ts`](src/sandbox.ts) | `write`/`edit` 共享的升权 API:策略解析与拒绝标记映射 |
|
||||
| [`src/error.ts`](src/error.ts) | 防护变更失败的稳定模型侧诊断 |
|
||||
@@ -104,10 +108,16 @@ kind: "package-reference"
|
||||
|
||||
四个工具共享同一种流程形态:用调用会话的 cwd 解析路径、运行适用的门禁、恰好执行一次提供方操作,并且只在成功后发出 `fs/observed`。`read` 与 `read_image` 为类型与大小路由付出一次 `stat`;`write` 与 `edit` 不执行 stat,因为防护来自意图槽位,提供方失败以类型化 `FsError` 结果呈现。各工具执行器位于 `src/read.ts`、`src/read-image.ts`、`src/write.ts` 与 `src/edit.ts`。
|
||||
|
||||
内部补丁执行器接收原始补丁和已解析的沙箱策略。它建立有序计划,针对模拟状态预检全部操作,再通过 `writeText`、`patchText`、`deleteFile` 或一次 `moveFile` 调用提交。预检失败的操作不产生模拟效果;任何预检失败都会阻止全部写入。报告为每个操作保留 `completed`、`failed`、`unknown` 或 `skipped` 状态及已知的 LF 文本变化。语法失败发生在计划建立前。路径解析失败时,仅未解析的路径保留补丁原始写法;成功解析的路径使用提供方显示路径。编程错误使调用拒绝。执行器尚未注册为工具。
|
||||
|
||||
### 观察与并发
|
||||
|
||||
`fs/observed` 在操作成功之后通过普通 `ctx.emit` 发出;监听器的约定是同步且只有副作用的记录器,因此异步或可能失败的观察不属于该事件。`read` 允许并发调度,因为它唯一改变状态的操作是同步记录版本;稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会安全地失败,两个变更工具仍保持互斥。
|
||||
|
||||
补丁预检读取从不发出 `fs/observed`。对目标的首次操作分发意图;后续预检操作使用前序成功操作后的预期状态。每次提交重新分发意图以取得当前观察防护。新增/更新记录存在,删除记录缺失,移动记录目标存在,且仅在源删除成功后记录源缺失。部分移动报告失败且只包含目标变化,保留源观察。
|
||||
|
||||
变更以 `FS_IO_ERROR` 或 `FS_ABORTED` 拒绝后,执行器使用不携带已取消信号的新 `stat`,将每个受影响目标的存在性与版本和调用前元数据比较。全部未变表示已知失败;任何目标已变或验证失败均为 `unknown`,不伪造观察或变化记录。`affectedPaths` 仅包含已知效果。执行在首个失败或未知提交后停止,或在信号已取消的操作开始前停止。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -238,6 +248,7 @@ Read a file before editing it (the default fs-observation-policy requires it), u
|
||||
|
||||
这些限制说明工具套件何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用文件系统对比或任务积压。
|
||||
|
||||
- **补丁提交不回滚**——后续失败后,先前成功操作和已发布的移动目标仍保留在磁盘上。沙箱预检尽力提前检查;提供方仍会再次执行权限与版本防护。`unknown` 结果要求重读受影响文件后再重试。
|
||||
- **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 `dsh-tool-fs-search` 包则提供基于 ripgrep 的 `glob` 与 `grep`,而不是扩展文件系统 seam。
|
||||
- **`read` 只处理 UTF-8 文本文件**:图像使用独立的 `read_image` 工具;PDF、音频和视频仍延期处理。目录目标为 `FS_NOT_REGULAR_FILE`。
|
||||
- **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。只有没有扩展名的路径按文件签名识别格式。
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
"dependencies": {
|
||||
"diff": "^9.0.0",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"@deepseek-ai/dsh-util-code-language": "workspace:*"
|
||||
"@deepseek-ai/dsh-util-code-language": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs-patch": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:*",
|
||||
@@ -63,6 +65,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:*",
|
||||
"@deepseek-ai/dsh-scope": "workspace:*",
|
||||
"@deepseek-ai/dsh-ptc-runtime": "workspace:*"
|
||||
"@deepseek-ai/dsh-ptc-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/** Sequential provider commits with truthful partial results and uncertainty checks. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import type { FsSandboxController } from '../sandbox.ts'
|
||||
import { destinationIntent, mutationTargets, patchFailure, sourceIntent } from './plan.ts'
|
||||
import type { PatchExecutionReport, PatchOperationOutcome, PlannedOperation } from './types.ts'
|
||||
|
||||
interface BeforeTarget {
|
||||
target: FsTarget
|
||||
info: FsInfo | undefined
|
||||
}
|
||||
|
||||
async function unchanged(ctx: Context, before: readonly BeforeTarget[]): Promise<boolean> {
|
||||
try {
|
||||
for (const entry of before) {
|
||||
// An aborted tool signal must not cancel the independent outcome check.
|
||||
const after = await ctx.fs.stat(entry.target)
|
||||
if (after?.version !== entry.info?.version) return false
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!(error instanceof FsError)) throw error
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a fully admitted plan; the first failure stops later operations without rollback.
|
||||
* @param ctx - provider and observation dispatcher.
|
||||
* @param sandbox - existing model-facing denial mapper.
|
||||
* @param exec - session actor and cancellation signal.
|
||||
* @param policy - request's resolved confinement policy.
|
||||
* @param operations - operations that all passed preflight.
|
||||
* @returns known changes, failed or unknown effects, and skipped successors in patch order.
|
||||
*/
|
||||
export async function commitPatch(
|
||||
ctx: Context, sandbox: FsSandboxController, exec: ToolExecution, policy: SandboxExecutionPolicy | undefined,
|
||||
operations: readonly PlannedOperation[],
|
||||
): Promise<PatchExecutionReport> {
|
||||
const outcomes: PatchOperationOutcome[] = operations.map(operation => ({ status: 'skipped', operation: operation.record }))
|
||||
let cancelled = false
|
||||
for (const [index, operation] of operations.entries()) {
|
||||
if (exec.signal.aborted) { cancelled = true; break }
|
||||
let started = false
|
||||
let before: BeforeTarget[] = []
|
||||
let failurePath = operation.target.displayPath
|
||||
const target = operation.target
|
||||
const record = operation.record
|
||||
let outcome: PatchOperationOutcome
|
||||
try {
|
||||
before = await Promise.all(mutationTargets(operation).map(async target => ({ target, info: await ctx.fs.stat(target, exec.signal) })))
|
||||
switch (operation.kind) {
|
||||
case 'add': {
|
||||
const intent = await destinationIntent(ctx, exec, target)
|
||||
started = true
|
||||
const result = await ctx.fs.writeText(target, operation.content, intent, exec.signal, policy)
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: result.version }, exec)
|
||||
outcome = { status: 'completed', operation: record, changes: [{ path: target.displayPath, before: result.before, after: result.after }] }
|
||||
break
|
||||
}
|
||||
case 'update': {
|
||||
const intent = await sourceIntent(ctx, exec, target, 'edit')
|
||||
started = true
|
||||
const result = await ctx.fs.patchText(target, operation.hunks, intent, exec.signal, policy)
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: result.version }, exec)
|
||||
outcome = { status: 'completed', operation: record, changes: [{ path: target.displayPath, before: result.before, after: result.after }] }
|
||||
break
|
||||
}
|
||||
case 'delete': {
|
||||
const intent = await sourceIntent(ctx, exec, target, 'delete')
|
||||
started = true
|
||||
const result = await ctx.fs.deleteFile(target, intent, exec.signal, policy)
|
||||
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
|
||||
outcome = { status: 'completed', operation: record, changes: [{ path: target.displayPath, before: result.before, after: null }] }
|
||||
break
|
||||
}
|
||||
case 'move': {
|
||||
const expectedSource = await sourceIntent(ctx, exec, target, 'edit')
|
||||
failurePath = operation.destination.displayPath
|
||||
const expectedDestination = await destinationIntent(ctx, exec, operation.destination)
|
||||
failurePath = target.displayPath
|
||||
const sourceBefore = (await ctx.fs.readText(target, exec.signal)).replace(/\r\n/g, '\n')
|
||||
failurePath = operation.destination.displayPath
|
||||
started = true
|
||||
const result = await ctx.fs.moveFile(target, operation.destination, {
|
||||
hunks: operation.hunks,
|
||||
...(expectedSource === undefined ? {} : { expectedSource }),
|
||||
...(expectedDestination === undefined ? {} : { expectedDestination }),
|
||||
}, exec.signal, policy)
|
||||
const changes = [{ path: operation.destination.displayPath, before: result.destination.before, after: result.destination.after }]
|
||||
ctx.emit('fs/observed', operation.destination, { kind: 'present', version: result.destination.version }, exec)
|
||||
if (result.sourceDeleted) {
|
||||
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
|
||||
outcome = { status: 'completed', operation: record, changes: [...changes, { path: target.displayPath, before: sourceBefore, after: null }] }
|
||||
} else {
|
||||
const error = new FsError(result.sourceFailure.message, result.sourceFailure.code)
|
||||
outcome = { status: 'failed', operation: record, failure: patchFailure(sandbox, policy, error, target.displayPath), changes }
|
||||
}
|
||||
break
|
||||
}
|
||||
/* v8 ignore next -- Closed parsed-operation union; assertNever checks exhaustiveness at compile time. */
|
||||
default: assertNever(operation)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof FsError)) throw error
|
||||
const failure = patchFailure(sandbox, policy, error, failurePath)
|
||||
const uncertain = started && (error.code === 'FS_ABORTED' || error.code === 'FS_IO_ERROR') && !await unchanged(ctx, before)
|
||||
outcome = uncertain
|
||||
? { status: 'unknown', operation: record, failure: { ...failure, message: `${failure.message} — re-read the affected files before retrying` } }
|
||||
: { status: 'failed', operation: record, failure, changes: [] }
|
||||
cancelled = exec.signal.aborted
|
||||
}
|
||||
outcomes[index] = outcome
|
||||
if (outcome.status !== 'completed') { cancelled = exec.signal.aborted; break }
|
||||
}
|
||||
const affectedPaths = [...new Set(outcomes.flatMap(outcome => 'changes' in outcome ? outcome.changes.map(change => change.path) : []))]
|
||||
return { phase: 'commit', outcomes, affectedPaths, cancelled }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Internal patch executor; tool registration and persisted result metadata are owned by its caller. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { PatchParseError } from '@deepseek-ai/dsh-fs-patch'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type { FsSandboxController } from '../sandbox.ts'
|
||||
import { commitPatch } from './execute.ts'
|
||||
import { planPatch } from './plan.ts'
|
||||
import { preflightPatch } from './preflight.ts'
|
||||
import type { PatchExecutionReport, PatchExecutionRequest } from './types.ts'
|
||||
|
||||
export type { PatchExecutionReport, PatchExecutionRequest, PatchFailure, PatchFailureCode, PatchFileChange, PatchOperationOutcome, PatchOperationRecord } from './types.ts'
|
||||
|
||||
/**
|
||||
* Preflight a whole patch before sequential guarded mutations; business failures become records.
|
||||
* @param ctx - mounted filesystem and observation-policy events.
|
||||
* @param sandbox - denial mapper shared with existing filesystem tools.
|
||||
* @param exec - session cwd, observation actor, and cancellation signal.
|
||||
* @param request - raw patch and policy already resolved by the caller.
|
||||
* @returns ordered results retaining completed, partial, unknown, and skipped effects.
|
||||
* @throws unexpected programming defects without starting further operations.
|
||||
*/
|
||||
export async function executePatch(
|
||||
ctx: Context, sandbox: FsSandboxController, exec: ToolExecution, request: PatchExecutionRequest,
|
||||
): Promise<PatchExecutionReport> {
|
||||
let plan
|
||||
try { plan = await planPatch(ctx, sandbox, exec, request) } catch (error) {
|
||||
if (!(error instanceof PatchParseError)) throw error
|
||||
return { phase: 'syntax', syntaxFailure: { code: 'PATCH_SYNTAX', message: error.message }, outcomes: [], affectedPaths: [], cancelled: false }
|
||||
}
|
||||
const outcomes = [
|
||||
...plan.failures,
|
||||
...await preflightPatch(ctx, sandbox, exec, request.sandboxPolicy, plan.operations),
|
||||
].sort((left, right) => left.operation.index - right.operation.index)
|
||||
if (outcomes.some(outcome => outcome.status === 'failed')) {
|
||||
return { phase: 'preflight', outcomes, affectedPaths: [], cancelled: exec.signal.aborted }
|
||||
}
|
||||
return commitPatch(ctx, sandbox, exec, request.sandboxPolicy, plan.operations)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/** Patch parsing and target identity shared by preflight and sequential commits. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsTargetKey, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import { parsePatch } from '@deepseek-ai/dsh-fs-patch'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { remediateFsError } from '../error.ts'
|
||||
import type { FsSandboxController } from '../sandbox.ts'
|
||||
import { sessionResolveOptions } from '../session-cwd.ts'
|
||||
import type { PatchExecutionRequest, PatchFailure, PatchOperationRecord, PatchPlan, PlannedOperation } from './types.ts'
|
||||
|
||||
/**
|
||||
* Resolve every operation without dropping repeated aliases or splitting moves.
|
||||
* @param ctx - filesystem service owner.
|
||||
* @param sandbox - filesystem diagnostic mapper.
|
||||
* @param exec - session cwd and execution signal.
|
||||
* @param request - raw patch and resolved per-call policy.
|
||||
* @returns resolved operations sharing one target per canonical key, plus resolution failures.
|
||||
*/
|
||||
export async function planPatch(
|
||||
ctx: Context, sandbox: FsSandboxController, exec: ToolExecution, request: PatchExecutionRequest,
|
||||
): Promise<PatchPlan> {
|
||||
const parsed = parsePatch(request.patch)
|
||||
const targets = new Map<FsTargetKey, FsTarget>()
|
||||
const resolve = async (path: string): Promise<FsTarget> => {
|
||||
const target = await ctx.fs.resolve(path, sessionResolveOptions(exec, request.sandboxPolicy?.workspaceRoot))
|
||||
const prior = targets.get(target.targetKey)
|
||||
if (prior) return prior
|
||||
targets.set(target.targetKey, target)
|
||||
return target
|
||||
}
|
||||
const result: PatchPlan = { operations: [], failures: [] }
|
||||
for (const [index, operation] of parsed.entries()) {
|
||||
const moveTo = operation.kind === 'update' ? operation.moveTo : undefined
|
||||
const record: PatchOperationRecord = {
|
||||
index, kind: moveTo === undefined ? operation.kind : 'move', path: operation.path, patchLine: operation.lineNumber,
|
||||
...(moveTo === undefined ? {} : { destination: moveTo }),
|
||||
}
|
||||
let failurePath = operation.path
|
||||
try {
|
||||
const target = await resolve(operation.path)
|
||||
record.path = target.displayPath
|
||||
const base = { target, rawPath: operation.path, record }
|
||||
switch (operation.kind) {
|
||||
case 'add': result.operations.push({ ...base, kind: 'add', content: operation.content }); break
|
||||
case 'delete': result.operations.push({ ...base, kind: 'delete' }); break
|
||||
case 'update': {
|
||||
if (operation.moveTo === undefined) result.operations.push({ ...base, kind: 'update', hunks: operation.hunks })
|
||||
else {
|
||||
failurePath = operation.moveTo
|
||||
const destination = await resolve(operation.moveTo)
|
||||
record.destination = destination.displayPath
|
||||
result.operations.push({ ...base, kind: 'move', destination, hunks: operation.hunks })
|
||||
}
|
||||
break
|
||||
}
|
||||
/* v8 ignore next -- Closed parsed-operation union; assertNever checks exhaustiveness at compile time. */
|
||||
default: assertNever(operation)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof FsError)) throw error
|
||||
result.failures.push({ status: 'failed', operation: record,
|
||||
failure: patchFailure(sandbox, request.sandboxPolicy, error, failurePath), changes: [] })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a source guard without recording an observation.
|
||||
* @param ctx - intent dispatcher.
|
||||
* @param exec - opaque observation actor.
|
||||
* @param target - resolved source.
|
||||
* @param kind - deletion or edit-family source policy.
|
||||
* @returns the observed version, or undefined for a bare provider.
|
||||
*/
|
||||
export function sourceIntent(ctx: Context, exec: ToolExecution, target: FsTarget, kind: 'delete' | 'edit'): Promise<{ version: FsVersion } | undefined> {
|
||||
return kind === 'delete'
|
||||
? ctx.waterfall('fs/delete-intent', target, exec, () => undefined)
|
||||
: ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a write guard without recording an observation.
|
||||
* @param ctx - intent dispatcher.
|
||||
* @param exec - opaque observation actor.
|
||||
* @param target - resolved write destination.
|
||||
* @returns the observed write intent, or undefined for a bare provider.
|
||||
*/
|
||||
export function destinationIntent(ctx: Context, exec: ToolExecution, target: FsTarget): Promise<FsWriteIntent | undefined> {
|
||||
return ctx.waterfall('fs/write-intent', target, exec, () => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve filesystem codes while applying the existing model-facing error policy.
|
||||
* @param sandbox - shared denial mapper.
|
||||
* @param policy - policy supplied by the request.
|
||||
* @param error - expected filesystem failure.
|
||||
* @param path - display path whose operation failed.
|
||||
* @returns the remediated code and message; unexpected mapper defects reject.
|
||||
*/
|
||||
export function patchFailure(
|
||||
sandbox: FsSandboxController, policy: SandboxExecutionPolicy | undefined, error: FsError, path: string,
|
||||
): PatchFailure {
|
||||
const mapped = remediateFsError(sandbox.mapError(error, policy), path)
|
||||
if (!(mapped instanceof FsError)) throw mapped
|
||||
return { code: mapped.code, message: mapped.message }
|
||||
}
|
||||
|
||||
/**
|
||||
* List all targets the operation can mutate, with destination first for a move.
|
||||
* @param operation - resolved operation.
|
||||
* @returns targets requiring a sandbox check and uncertainty comparison.
|
||||
*/
|
||||
export function mutationTargets(operation: PlannedOperation): FsTarget[] {
|
||||
return operation.kind === 'move' ? [operation.destination, operation.target] : [operation.target]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/** Read-only simulation of patch effects; no internal read establishes model observation. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsInfo, FsTarget, FsTargetKey } from '@deepseek-ai/dsh-fs'
|
||||
import { applyHunks } from '@deepseek-ai/dsh-fs-patch'
|
||||
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import type { FsSandboxController } from '../sandbox.ts'
|
||||
import { sessionResolveOptions } from '../session-cwd.ts'
|
||||
import { destinationIntent, mutationTargets, patchFailure, sourceIntent } from './plan.ts'
|
||||
import type { PatchFailure, PatchOperationOutcome, PlannedOperation } from './types.ts'
|
||||
|
||||
interface SimulatedFile {
|
||||
exists: boolean
|
||||
info: FsInfo | undefined
|
||||
content?: string
|
||||
touched: boolean
|
||||
}
|
||||
|
||||
class Preflight {
|
||||
failurePath = ''
|
||||
private readonly states = new Map<FsTargetKey, SimulatedFile>()
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context, private readonly exec: ToolExecution, private readonly policy: SandboxExecutionPolicy | undefined,
|
||||
) {}
|
||||
|
||||
private async state(target: FsTarget): Promise<SimulatedFile> {
|
||||
const known = this.states.get(target.targetKey)
|
||||
if (known) return known
|
||||
const info = await this.ctx.fs.stat(target, this.exec.signal)
|
||||
const state = { exists: info !== undefined, info, touched: false }
|
||||
this.states.set(target.targetKey, state)
|
||||
return state
|
||||
}
|
||||
|
||||
private async fence(operation: PlannedOperation): Promise<void> {
|
||||
if (this.policy === undefined || this.policy.mode === 'danger-full-access') return
|
||||
if (this.policy.mode === 'read-only') throw new FsError('filesystem mutations are denied', 'FS_SANDBOX_DENIED')
|
||||
const roots = await Promise.all(writableRoots(this.policy).map(root => this.ctx.fs.resolve(root)))
|
||||
for (const target of mutationTargets(operation)) {
|
||||
if (!roots.some(root => this.ctx.fs.contains(root, target))) {
|
||||
throw new FsError(`cannot modify "${target.displayPath}": outside writable roots`, 'FS_SANDBOX_DENIED')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private regular(state: SimulatedFile, target: FsTarget): void {
|
||||
if (state.info !== undefined && state.info.type !== 'file') {
|
||||
throw new FsError(`cannot modify "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
}
|
||||
|
||||
private async source(state: SimulatedFile, target: FsTarget, kind: 'delete' | 'edit'): Promise<void> {
|
||||
const intent = state.touched ? undefined : await sourceIntent(this.ctx, this.exec, target, kind)
|
||||
if (!state.exists) throw new FsError(`cannot ${kind} "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
this.regular(state, target)
|
||||
if (intent !== undefined && intent.version !== state.info?.version) {
|
||||
throw new FsError(`cannot ${kind} "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
}
|
||||
}
|
||||
|
||||
private async destination(state: SimulatedFile, target: FsTarget): Promise<void> {
|
||||
const intent = state.touched ? undefined : await destinationIntent(this.ctx, this.exec, target)
|
||||
this.regular(state, target)
|
||||
if (intent?.kind === 'createIfAbsent' && state.exists) {
|
||||
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
|
||||
}
|
||||
if (intent?.kind === 'replaceIfVersion' && (!state.exists || state.info?.version !== intent.version)) {
|
||||
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
}
|
||||
}
|
||||
|
||||
private async content(state: SimulatedFile, target: FsTarget): Promise<string> {
|
||||
if (state.content === undefined) state.content = (await this.ctx.fs.readText(target, this.exec.signal)).replace(/\r\n/g, '\n')
|
||||
return state.content
|
||||
}
|
||||
|
||||
private publish(target: FsTarget, content?: string): void {
|
||||
this.states.set(target.targetKey, { exists: content !== undefined, info: undefined, touched: true,
|
||||
...(content === undefined ? {} : { content }) })
|
||||
}
|
||||
|
||||
async check(operation: PlannedOperation): Promise<PatchFailure | undefined> {
|
||||
this.failurePath = operation.target.displayPath
|
||||
await this.fence(operation)
|
||||
if (operation.kind === 'move' && operation.target.targetKey === operation.destination.targetKey) {
|
||||
throw new FsError(`cannot move "${operation.target.displayPath}" to itself`, 'FS_SAME_TARGET')
|
||||
}
|
||||
if (operation.kind === 'delete' || operation.kind === 'move') {
|
||||
const info = await this.ctx.fs.lstat(operation.rawPath, sessionResolveOptions(this.exec, this.policy?.workspaceRoot))
|
||||
if (info?.type === 'symlink') return { code: 'PATCH_SYMLINK_SOURCE',
|
||||
message: `cannot ${operation.kind} "${operation.target.displayPath}": path is a symbolic link — operate on its target path instead` }
|
||||
}
|
||||
const state = await this.state(operation.target)
|
||||
switch (operation.kind) {
|
||||
case 'add':
|
||||
await this.destination(state, operation.target)
|
||||
this.publish(operation.target, operation.content)
|
||||
return
|
||||
case 'delete':
|
||||
await this.source(state, operation.target, 'delete')
|
||||
this.publish(operation.target)
|
||||
return
|
||||
case 'update':
|
||||
case 'move': {
|
||||
await this.source(state, operation.target, 'edit')
|
||||
if (operation.kind === 'move') {
|
||||
this.failurePath = operation.destination.displayPath
|
||||
await this.destination(await this.state(operation.destination), operation.destination)
|
||||
this.failurePath = operation.target.displayPath
|
||||
}
|
||||
const result = applyHunks(await this.content(state, operation.target), operation.hunks)
|
||||
if (!result.ok) {
|
||||
const first = result.failure.pattern.slice(0, 1).join('')
|
||||
const characters = Array.from(first)
|
||||
const preview = characters.length > 80 ? `${characters.slice(0, 80).join('')}…` : first
|
||||
return { code: 'FS_PATCH_MISMATCH', hunkLine: result.failure.hunkLineNumber,
|
||||
message: `cannot patch "${operation.target.displayPath}": hunk at patch line ${result.failure.hunkLineNumber} (${result.failure.stage}) does not match — ${preview}` }
|
||||
}
|
||||
if (operation.kind === 'move') {
|
||||
this.publish(operation.destination, result.after)
|
||||
this.publish(operation.target)
|
||||
} else this.publish(operation.target, result.after)
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- Closed parsed-operation union; assertNever checks exhaustiveness at compile time. */
|
||||
default: return assertNever(operation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all independent preflight failures while simulating only successful operations.
|
||||
* @param ctx - filesystem service owner; reads never emit observations.
|
||||
* @param sandbox - model-facing denial mapper.
|
||||
* @param exec - observation actor and cancellation signal.
|
||||
* @param policy - resolved confinement policy.
|
||||
* @param operations - complete resolved operation plan.
|
||||
* @returns failed/skipped entries; any failure forbids the entire commit phase.
|
||||
*/
|
||||
export async function preflightPatch(
|
||||
ctx: Context, sandbox: FsSandboxController, exec: ToolExecution, policy: SandboxExecutionPolicy | undefined,
|
||||
operations: readonly PlannedOperation[],
|
||||
): Promise<PatchOperationOutcome[]> {
|
||||
const preflight = new Preflight(ctx, exec, policy)
|
||||
const outcomes: PatchOperationOutcome[] = []
|
||||
for (const operation of operations) {
|
||||
let failure: PatchFailure | undefined
|
||||
try { failure = await preflight.check(operation) } catch (error) {
|
||||
if (!(error instanceof FsError)) throw error
|
||||
failure = patchFailure(sandbox, policy, error, preflight.failurePath)
|
||||
}
|
||||
outcomes.push(failure === undefined ? { status: 'skipped', operation: operation.record }
|
||||
: { status: 'failed', operation: operation.record, failure, changes: [] })
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Serializable patch outcomes and operation-local plans; not a persisted tool result yet. */
|
||||
import type { FsErrorCode, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import type { PatchHunk } from '@deepseek-ai/dsh-fs-patch'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** One request after the caller resolves its per-call sandbox policy. */
|
||||
export interface PatchExecutionRequest {
|
||||
/** Raw patch text from the model. */
|
||||
patch: string
|
||||
/** Resolved per-call policy; undefined under an unconfined backend. */
|
||||
sandboxPolicy: SandboxExecutionPolicy | undefined
|
||||
}
|
||||
|
||||
/** One file operation; unresolved paths retain their original patch spelling on resolution failure. */
|
||||
export interface PatchOperationRecord {
|
||||
/** Zero-based position in the patch. */
|
||||
index: number
|
||||
/** File effect represented by this operation. */
|
||||
kind: 'add' | 'update' | 'delete' | 'move'
|
||||
/** Source display path (created path for add), or raw patch path if resolution failed. */
|
||||
path: string
|
||||
/** Move destination display path, or raw patch path if it could not be resolved. */
|
||||
destination?: string
|
||||
/** One-based patch line of the operation header. */
|
||||
patchLine: number
|
||||
}
|
||||
|
||||
/** LF text before and after one effect; null means absence or an unavailable provider basis. */
|
||||
export interface PatchFileChange {
|
||||
/** Resolved display path that changed. */
|
||||
path: string
|
||||
/** Previous text, or null for absence or an unavailable basis. */
|
||||
before: string | null
|
||||
/** Published text, or null after deletion. */
|
||||
after: string | null
|
||||
}
|
||||
|
||||
/** Filesystem failures plus syntax and disallowed symlink-source diagnostics. */
|
||||
export type PatchFailureCode = FsErrorCode | 'PATCH_SYNTAX' | 'PATCH_SYMLINK_SOURCE'
|
||||
|
||||
/** Actionable diagnostic ready for model output. */
|
||||
export interface PatchFailure {
|
||||
/** Stable machine-readable reason. */
|
||||
code: PatchFailureCode
|
||||
/** Message after sandbox denial mapping and filesystem remediation. */
|
||||
message: string
|
||||
/** Patch line of an unmatched hunk. */
|
||||
hunkLine?: number
|
||||
}
|
||||
|
||||
/** Each operation retains completed effects even when a move fails halfway. */
|
||||
export type PatchOperationOutcome =
|
||||
| { status: 'completed'; operation: PatchOperationRecord; changes: PatchFileChange[] }
|
||||
| { status: 'failed'; operation: PatchOperationRecord; failure: PatchFailure; changes: PatchFileChange[] }
|
||||
| { status: 'unknown'; operation: PatchOperationRecord; failure: PatchFailure }
|
||||
| { status: 'skipped'; operation: PatchOperationRecord }
|
||||
|
||||
/** Ordered report; preflight failures prevent all writes, commit failures stop later operations. */
|
||||
export interface PatchExecutionReport {
|
||||
/** Stage in which the request completed or stopped. */
|
||||
phase: 'syntax' | 'preflight' | 'commit'
|
||||
/** Syntax failure before any operation plan exists. */
|
||||
syntaxFailure?: PatchFailure
|
||||
/** One entry per operation, in patch order. */
|
||||
outcomes: PatchOperationOutcome[]
|
||||
/** Paths in known changes, deduplicated in first-seen order. */
|
||||
affectedPaths: string[]
|
||||
/** Whether the execution signal stopped the request before completion. */
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
/** Resolved operation with raw source spelling retained for no-follow inspection. */
|
||||
export type PlannedOperation = {
|
||||
record: PatchOperationRecord
|
||||
target: FsTarget
|
||||
rawPath: string
|
||||
} & (
|
||||
| { kind: 'add'; content: string }
|
||||
| { kind: 'update'; hunks: readonly PatchHunk[] }
|
||||
| { kind: 'delete' }
|
||||
| { kind: 'move'; destination: FsTarget; hunks: readonly PatchHunk[] }
|
||||
)
|
||||
|
||||
/** Resolved operations and per-operation path-resolution failures in one parsed patch. */
|
||||
export interface PatchPlan {
|
||||
/** Successfully resolved operations, retaining their original indexes and order. */
|
||||
operations: PlannedOperation[]
|
||||
/** Resolution failures; unresolvable paths retain their raw patch spelling. */
|
||||
failures: Extract<PatchOperationOutcome, { status: 'failed' }>[]
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/** Sequential commits retain known effects and distinguish unchanged from uncertain failures. */
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { add, move, patch, remove, setup, update } from './harness.ts'
|
||||
|
||||
class LostReplyFileSystem extends LocalFileSystem {
|
||||
override async patchText(...args: Parameters<LocalFileSystem['patchText']>): ReturnType<LocalFileSystem['patchText']> {
|
||||
await super.patchText(...args)
|
||||
throw new FsError('reply lost', 'FS_IO_ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
it.each(['delete', 'move'] as const)('retains the first commit when %s removal fails and observes only published changes', async (kind) => {
|
||||
const t = await setup()
|
||||
await t.file('source', 'old\n')
|
||||
const sourceGuard = await t.editIntent('source')
|
||||
t.fs.internals.removeFile = async () => { throw Object.assign(new Error('removal denied'), { code: 'EACCES' }) }
|
||||
const a = t.record('add', 'a')
|
||||
const b = t.record(kind, 'source', 1, 4, kind === 'move' ? 'destination' : undefined)
|
||||
const c = t.record('add', 'c', 2, kind === 'move' ? 9 : 5)
|
||||
const destination = resolve(t.workspace, 'destination')
|
||||
expect(await t.run(patch(add('a'), kind === 'move' ? move('source', 'destination') : remove('source'), add('c')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
||||
{ status: 'failed', operation: b, failure: { code: 'FS_PERMISSION_DENIED', message: `cannot delete "${b.path}": removal denied` },
|
||||
changes: kind === 'move' ? [{ path: destination, before: null, after: 'new\n' }] : [] },
|
||||
{ status: 'skipped', operation: c },
|
||||
], affectedPaths: kind === 'move' ? [a.path, destination] : [a.path], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('new\n')
|
||||
expect(await t.bytes('source')).toBe('old\n')
|
||||
await expect(t.bytes('c')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
expect(await t.editIntent('source')).toEqual(sourceGuard)
|
||||
expect(t.observations.map(event => event.path)).toEqual(kind === 'move' ? [a.path, destination] : [a.path])
|
||||
if (kind === 'move') {
|
||||
expect(await t.bytes('destination')).toBe('new\n')
|
||||
expect(await t.editIntent('destination')).toEqual({ version: await t.version('destination') })
|
||||
}
|
||||
})
|
||||
|
||||
it('stops between operations after cancellation without undoing the first write', async () => {
|
||||
const t = await setup()
|
||||
t.ctx.on('fs/observed', () => { t.controller.abort() })
|
||||
const a = t.record('add', 'a')
|
||||
expect(await t.run(patch(add('a'), add('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
||||
{ status: 'skipped', operation: t.record('add', 'b', 1, 4) },
|
||||
{ status: 'skipped', operation: t.record('add', 'c', 2, 6) },
|
||||
], affectedPaths: [a.path], cancelled: true })
|
||||
expect(await t.bytes('a')).toBe('new\n')
|
||||
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('reports cancellation during staged writing as failed when stat confirms no change', async () => {
|
||||
const t = await setup()
|
||||
let writes = 0
|
||||
t.fs.internals.inspectTemp = () => { if (++writes === 2) t.controller.abort() }
|
||||
const a = t.record('add', 'a')
|
||||
expect(await t.run(patch(add('a'), add('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
||||
{ status: 'failed', operation: t.record('add', 'b', 1, 4), failure: { code: 'FS_ABORTED', message: 'write aborted' }, changes: [] },
|
||||
{ status: 'skipped', operation: t.record('add', 'c', 2, 6) },
|
||||
], affectedPaths: [a.path], cancelled: true })
|
||||
expect(await t.bytes('a')).toBe('new\n')
|
||||
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('does not mark a fully completed patch cancelled when its final observation aborts', async () => {
|
||||
const t = await setup()
|
||||
t.ctx.on('fs/observed', () => { t.controller.abort() })
|
||||
const a = t.record('add', 'a')
|
||||
expect(await t.run(patch(add('a')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
||||
], affectedPaths: [a.path], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('new\n')
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('reports unknown after the provider changes disk and loses its reply without recording an observation', async () => {
|
||||
const t = await setup({ provider: LostReplyFileSystem })
|
||||
await t.file('a', 'old\n')
|
||||
const observed = await t.editIntent('a')
|
||||
expect(await t.run(patch(update('a'), add('b')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'unknown', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'reply lost — re-read the affected files before retrying' } },
|
||||
{ status: 'skipped', operation: t.record('add', 'b', 1, 6) },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('new\n')
|
||||
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual(observed)
|
||||
})
|
||||
|
||||
it.each(['unchanged', 'stat fails', 'stat defect'] as const)('verifies an I/O failure after provider entry: %s', async (scenario) => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const observed = await t.editIntent('a')
|
||||
const defect = new Error('stat defect')
|
||||
vi.spyOn(t.fs, 'patchText').mockImplementation(async () => {
|
||||
if (scenario !== 'unchanged') vi.spyOn(t.fs, 'stat').mockRejectedValue(scenario === 'stat fails' ? new FsError('stat failed', 'FS_IO_ERROR') : defect)
|
||||
throw new FsError('write failed', 'FS_IO_ERROR')
|
||||
})
|
||||
const request = t.run(patch(update('a'), add('b')))
|
||||
if (scenario === 'stat defect') await expect(request).rejects.toBe(defect)
|
||||
else expect(await request).toEqual({ phase: 'commit', outcomes: [
|
||||
scenario === 'unchanged'
|
||||
? { status: 'failed', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'write failed' }, changes: [] }
|
||||
: { status: 'unknown', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'write failed — re-read the affected files before retrying' } },
|
||||
{ status: 'skipped', operation: t.record('add', 'b', 1, 6) },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual(observed)
|
||||
})
|
||||
|
||||
it('checks both move targets when its destination was published but the reply is lost', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const observed = await t.editIntent('a')
|
||||
vi.spyOn(t.fs, 'moveFile').mockImplementation(async (_source, destination) => {
|
||||
await writeFile(destination.targetKey, 'published\n')
|
||||
throw new FsError('move reply lost', 'FS_IO_ERROR')
|
||||
})
|
||||
expect(await t.run(patch(move('a', 'b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'unknown', operation: t.record('move', 'a', 0, 2, 'b'), failure: { code: 'FS_IO_ERROR', message: 'move reply lost — re-read the affected files before retrying' } },
|
||||
{ status: 'skipped', operation: t.record('add', 'c', 1, 7) },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(await t.bytes('b')).toBe('published\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual(observed)
|
||||
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('retains preceding changes when an external edit invalidates the next commit guard', async () => {
|
||||
const t = await setup()
|
||||
await t.file('b', 'old\n')
|
||||
const observed = await t.editIntent('b')
|
||||
const write = t.fs.writeText.bind(t.fs)
|
||||
vi.spyOn(t.fs, 'writeText').mockImplementation(async (...args) => {
|
||||
const result = await write(...args)
|
||||
await writeFile(resolve(t.workspace, 'b'), 'external edit\n')
|
||||
return result
|
||||
})
|
||||
const a = t.record('add', 'a')
|
||||
const b = t.record('update', 'b', 1, 4)
|
||||
expect(await t.run(patch(add('a'), update('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
||||
{ status: 'failed', operation: b, failure: { code: 'FS_STALE_VERSION', message: expect.stringContaining('re-read the file, then retry') as string }, changes: [] },
|
||||
{ status: 'skipped', operation: t.record('add', 'c', 2, 8) },
|
||||
], affectedPaths: [a.path], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('new\n')
|
||||
expect(await t.bytes('b')).toBe('external edit\n')
|
||||
await expect(t.bytes('c')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
expect(await t.editIntent('b')).toEqual(observed)
|
||||
})
|
||||
|
||||
it('propagates programmer defects from mutation and preserves observed state', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const defect = new Error('mutation defect')
|
||||
vi.spyOn(t.fs, 'patchText').mockRejectedValue(defect)
|
||||
await expect(t.run(patch(update('a')))).rejects.toBe(defect)
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Isolated real-provider fixtures for patch reports and observation state. */
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsObservation } from '@deepseek-ai/dsh-fs'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { onTestFinished, vi } from 'vitest'
|
||||
import * as ToolFs from '../../src/index.ts'
|
||||
import { executePatch } from '../../src/patch/index.ts'
|
||||
import type { PatchOperationRecord } from '../../src/patch/types.ts'
|
||||
import { FsSandboxController } from '../../src/sandbox.ts'
|
||||
|
||||
export const patch = (...operations: string[]) => ['*** Begin Patch', ...operations, '*** End Patch'].join('\n')
|
||||
export const add = (path: string, content = 'new') => `*** Add File: ${path}\n+${content}`
|
||||
export const update = (path: string, before = 'old', after = 'new') => `*** Update File: ${path}\n@@\n-${before}\n+${after}`
|
||||
export const remove = (path: string) => `*** Delete File: ${path}`
|
||||
export const move = (source: string, destination: string, before = 'old', after = 'new') =>
|
||||
`*** Update File: ${source}\n*** Move to: ${destination}\n@@\n-${before}\n+${after}`
|
||||
|
||||
export async function setup(options: { policy?: boolean; mode?: SandboxMode; provider?: typeof LocalFileSystem } = {}) {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-patch-executor-')))
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const workspace = join(root, 'workspace')
|
||||
const providerRoot = join(root, 'provider')
|
||||
await mkdir(workspace)
|
||||
await mkdir(providerRoot)
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
onTestFinished(() => { vi.restoreAllMocks() })
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
if (options.mode !== undefined) {
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: options.mode })
|
||||
await ctx.plugin(SandboxedFileSystem, { cwd: providerRoot })
|
||||
} else await ctx.plugin(options.provider ?? LocalFileSystem, { cwd: providerRoot })
|
||||
if (options.policy !== false) await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
const fs = ctx.fs as LocalFileSystem
|
||||
const controller = new AbortController()
|
||||
const exec: ToolExecution = {
|
||||
signal: controller.signal, callId: ToolCallId('patch-test'), rootCallId: ToolCallId('patch-test'),
|
||||
token: Symbol('patch-test') as ToolExecutionToken, name: 'apply_patch', arguments: {},
|
||||
// Only the session header and owner identity are consumed by these services.
|
||||
agent: { session: { header: { cwd: workspace } } } as never,
|
||||
}
|
||||
const sandbox = new FsSandboxController(ctx)
|
||||
const policy = options.mode === undefined ? undefined : { mode: options.mode, workspaceRoot: workspace }
|
||||
const observations: { path: string; observation: FsObservation }[] = []
|
||||
ctx.on('fs/observed', (target, observation) => { observations.push({ path: target.displayPath, observation }) })
|
||||
const target = (name: string) => fs.resolve(name, { cwd: workspace })
|
||||
const file = async (name: string, text: string, observed = true) => {
|
||||
await writeFile(resolve(workspace, name), text)
|
||||
const resolved = await target(name)
|
||||
if (observed) {
|
||||
const info = await fs.stat(resolved)
|
||||
if (!info) throw new Error('Fixture file missing')
|
||||
ctx.emit('fs/observed', resolved, { kind: 'present', version: info.version }, exec)
|
||||
}
|
||||
observations.length = 0
|
||||
return resolved
|
||||
}
|
||||
const absent = async (name: string) => {
|
||||
ctx.emit('fs/observed', await target(name), { kind: 'absent' }, exec)
|
||||
observations.length = 0
|
||||
}
|
||||
const record = (
|
||||
kind: PatchOperationRecord['kind'], name: string, index = 0, patchLine = 2, destination?: string,
|
||||
): PatchOperationRecord => ({
|
||||
kind, path: resolve(workspace, name), index, patchLine,
|
||||
...(destination === undefined ? {} : { destination: resolve(workspace, destination) }),
|
||||
})
|
||||
const run = (text: string) => executePatch(ctx, sandbox, exec, { patch: text, sandboxPolicy: policy })
|
||||
const bytes = (name: string) => readFile(resolve(workspace, name), 'utf8')
|
||||
const editIntent = async (name: string) => ctx.waterfall('fs/edit-intent', await target(name), exec, () => undefined)
|
||||
const version = async (name: string) => (await fs.stat(await target(name)))?.version ?? FsVersion('missing-fixture')
|
||||
return { ctx, fs, workspace, controller, exec, sandbox, policy, observations,
|
||||
target, file, absent, record, run, bytes, editIntent, version }
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/** Whole-patch admission rejects all writes while preserving prior observation state. */
|
||||
import { mkdir, symlink, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsErrorCode } from '@deepseek-ai/dsh-fs'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { add, move, patch, remove, setup, update } from './harness.ts'
|
||||
|
||||
it('reports syntax errors before resolving paths', async () => {
|
||||
const t = await setup()
|
||||
const resolve = vi.spyOn(t.fs, 'resolve')
|
||||
expect(await t.run('invalid')).toEqual({ phase: 'syntax', syntaxFailure: {
|
||||
code: 'PATCH_SYNTAX', message: "Invalid patch at line 1: Expected '*** Begin Patch'",
|
||||
}, outcomes: [], affectedPaths: [], cancelled: false })
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
expect(t.observations).toEqual([])
|
||||
await expect(t.bytes('a')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(t.editIntent('a')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('collects independent unread and confirmed-absent failures and skips valid writes', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n', false)
|
||||
await t.absent('b')
|
||||
const a = t.record('update', 'a')
|
||||
const b = t.record('update', 'b', 1, 6)
|
||||
expect(await t.run(patch(update('a'), update('b'), add('c')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'failed', operation: a, failure: { code: 'FS_NOT_OBSERVED', message: `cannot modify "${a.path}": file has not been read — read the file, then retry` }, changes: [] },
|
||||
{ status: 'failed', operation: b, failure: { code: 'FS_NOT_FOUND', message: `cannot edit "${b.path}": not found` }, changes: [] },
|
||||
{ status: 'skipped', operation: t.record('add', 'c', 2, 10) },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
await expect(t.bytes('c')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(t.observations).toEqual([])
|
||||
await expect(t.editIntent('a')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ pattern: 'missing', marker: '@@', stage: 'oldLines', preview: 'missing' },
|
||||
{ pattern: '🙂'.repeat(81), marker: '@@', stage: 'oldLines', preview: `${'🙂'.repeat(80)}…` },
|
||||
{ pattern: 'old', marker: '@@ absent anchor', stage: 'context', preview: 'absent anchor' },
|
||||
])('reports the hunk line and $stage preview without simulating a failed update', async ({ pattern, marker, stage, preview }) => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const a = t.record('update', 'a')
|
||||
expect(await t.run(patch(`*** Update File: a\n${marker}\n-${pattern}\n+new`, update('a')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'failed', operation: a, failure: { code: 'FS_PATCH_MISMATCH', hunkLine: 3,
|
||||
message: `cannot patch "${a.path}": hunk at patch line 3 (${stage}) does not match — ${preview}` }, changes: [] },
|
||||
{ status: 'skipped', operation: t.record('update', 'a', 1, 6) },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32').each(['delete', 'move'] as const)('rejects a symbolic-link %s source without changing its target', async (kind) => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
await symlink('a', resolve(t.workspace, 'link'))
|
||||
const a = t.record(kind, 'link', 0, 2, kind === 'move' ? 'b' : undefined)
|
||||
expect(await t.run(patch(kind === 'delete' ? remove('link') : move('link', 'b')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'failed', operation: a, failure: { code: 'PATCH_SYMLINK_SOURCE',
|
||||
message: `cannot ${kind} "${a.path}": path is a symbolic link — operate on its target path instead` }, changes: [] },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('link')).toBe('old\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('rejects a move to an alias of its source', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const a = t.record('move', 'a', 0, 2, 'a')
|
||||
expect(await t.run(patch(move('a', './a')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'failed', operation: a, failure: { code: 'FS_SAME_TARGET', message: `cannot move "${a.path}" to itself` }, changes: [] },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it.each(['add', 'move'] as const)('attributes an unread %s destination to its resolved path', async (kind) => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
await t.file('b', 'existing\n', false)
|
||||
const operation = kind === 'add' ? t.record('add', 'b') : t.record('move', 'a', 0, 2, 'b')
|
||||
const b = t.record('add', 'b')
|
||||
expect(await t.run(patch(kind === 'add' ? add('b') : move('a', 'b')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'failed', operation, failure: { code: 'FS_NOT_OBSERVED', message: `cannot modify "${b.path}": file has not been read — read the file, then retry` }, changes: [] },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(await t.bytes('b')).toBe('existing\n')
|
||||
expect(t.observations).toEqual([])
|
||||
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects every operation under read-only policy', async () => {
|
||||
const t = await setup({ mode: 'read-only' })
|
||||
await t.file('a', 'old\n')
|
||||
const records = [t.record('add', 'b'), t.record('update', 'a', 1, 4), t.record('delete', 'a', 2, 8), t.record('move', 'a', 3, 9, 'c')]
|
||||
expect(await t.run(patch(add('b'), update('a'), remove('a'), move('a', 'c')))).toEqual({ phase: 'preflight', outcomes: records.map(operation => ({
|
||||
status: 'failed', operation, failure: { code: 'FS_SANDBOX_DENIED', message: expect.stringContaining('[sandbox: file access denied under read-only mode]') as string }, changes: [],
|
||||
})), affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('prevents all writes when a move destination is outside writable roots', async () => {
|
||||
const t = await setup({ mode: 'workspace-write' })
|
||||
await t.file('a', 'old\n')
|
||||
const outside = resolve(process.cwd(), 'patch-preflight-outside-root')
|
||||
const a = t.record('move', 'a', 1, 4, outside)
|
||||
expect(await t.run(patch(add('b'), move('a', outside)))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'skipped', operation: t.record('add', 'b') },
|
||||
{ status: 'failed', operation: a, failure: { code: 'FS_SANDBOX_DENIED', message: expect.stringContaining('[sandbox: file access denied under workspace-write mode]') as string }, changes: [] },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it.each(['update', 'delete', 'add', 'move'] as const)('rejects non-regular %s targets with bare guards', async (kind) => {
|
||||
const t = await setup({ policy: false })
|
||||
await mkdir(resolve(t.workspace, 'directory'))
|
||||
await t.file('a', 'old\n')
|
||||
const operation = kind === 'move' ? t.record(kind, 'a', 0, 2, 'directory') : t.record(kind, 'directory')
|
||||
const text = kind === 'add' ? add('directory') : kind === 'delete' ? remove('directory') : kind === 'move' ? move('a', 'directory') : update('directory')
|
||||
expect(await t.run(patch(text))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'failed', operation, failure: { code: 'FS_NOT_REGULAR_FILE', message: `cannot modify "${resolve(t.workspace, 'directory')}": not a regular file` }, changes: [] },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect((await t.fs.stat(await t.target('directory')))?.type).toBe('directory')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['update', 'add'] as const)('rejects a stale observed version before %s', async (kind) => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const observed = await t.editIntent('a')
|
||||
await writeFile(resolve(t.workspace, 'a'), 'external edit\n')
|
||||
const a = t.record(kind, 'a')
|
||||
expect(await t.run(patch(kind === 'add' ? add('a') : update('a')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'failed', operation: a, failure: { code: 'FS_STALE_VERSION', message: `cannot ${kind === 'add' ? 'write' : 'edit'} "${a.path}": file changed since it was read — re-read the file, then retry` }, changes: [] },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('external edit\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual(observed)
|
||||
})
|
||||
|
||||
it.each(['FS_NOT_TEXT', 'FS_TOO_LARGE'] satisfies FsErrorCode[])('preserves a preflight read failure %s', async (code) => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
vi.spyOn(t.fs, 'readText').mockRejectedValue(new FsError('cannot read fixture', code))
|
||||
expect(await t.run(patch(update('a')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'failed', operation: t.record('update', 'a'), failure: { code, message: 'cannot read fixture' }, changes: [] },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it.each(['delete', 'move'] as const)('simulates the missing source after %s without performing it', async (kind) => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const first = t.record(kind, 'a', 0, 2, kind === 'move' ? 'b' : undefined)
|
||||
const a = t.record('update', 'a', 1, kind === 'move' ? 7 : 3)
|
||||
expect(await t.run(patch(kind === 'delete' ? remove('a') : move('a', 'b'), update('a')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'skipped', operation: first },
|
||||
{ status: 'failed', operation: a, failure: { code: 'FS_NOT_FOUND', message: `cannot edit "${a.path}": not found` }, changes: [] },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('propagates programming defects from preflight without writes', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const defect = new Error('provider defect')
|
||||
vi.spyOn(t.fs, 'readText').mockRejectedValue(defect)
|
||||
await expect(t.run(patch(update('a')))).rejects.toBe(defect)
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('rethrows a resolution defect instead of labelling it a patch syntax failure', async () => {
|
||||
const t = await setup()
|
||||
const defect = new Error('resolver defect')
|
||||
const spy = vi.spyOn(t.fs, 'resolve').mockRejectedValue(defect)
|
||||
await expect(t.run(patch(add('a')))).rejects.toBe(defect)
|
||||
spy.mockRestore()
|
||||
await expect(t.bytes('a')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(t.observations).toEqual([])
|
||||
await expect(t.editIntent('a')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('propagates an unexpected error mapper result', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n', false)
|
||||
const defect = new Error('mapper defect')
|
||||
vi.spyOn(t.sandbox, 'mapError').mockReturnValue(defect)
|
||||
await expect(t.run(patch(update('a')))).rejects.toBe(defect)
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(t.observations).toEqual([])
|
||||
await expect(t.editIntent('a')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects repeated deletion against simulated absence', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const a = t.record('delete', 'a')
|
||||
expect(await t.run(patch(remove('a'), remove('a')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'skipped', operation: a },
|
||||
{ status: 'failed', operation: t.record('delete', 'a', 1, 3), failure: { code: 'FS_NOT_FOUND', message: `cannot delete "${a.path}": not found` }, changes: [] },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('preserves source simulation after a move destination fails admission', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
await t.file('b', 'unread\n', false)
|
||||
const a = t.record('move', 'a', 0, 2, 'b')
|
||||
expect(await t.run(patch(move('a', 'b'), update('a')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'failed', operation: a, failure: { code: 'FS_NOT_OBSERVED', message: `cannot modify "${a.destination}": file has not been read — read the file, then retry` }, changes: [] },
|
||||
{ status: 'skipped', operation: t.record('update', 'a', 1, 7) },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(await t.bytes('b')).toBe('unread\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/** Resolution failures preserve operation order and use raw spelling only for unresolved paths. */
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { add, move, patch, setup, update } from './harness.ts'
|
||||
|
||||
it('collects path resolution failures while retaining resolved paths and checking remaining operations', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const resolve = t.fs.resolve.bind(t.fs)
|
||||
vi.spyOn(t.fs, 'resolve').mockImplementation(async (path, options) => {
|
||||
if (path === './denied') throw new FsError('cannot resolve denied path', 'FS_PERMISSION_DENIED')
|
||||
return resolve(path, options)
|
||||
})
|
||||
expect(await t.run(patch(update('a'), add('./denied'), add('c')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'skipped', operation: t.record('update', 'a') },
|
||||
{ status: 'failed', operation: { kind: 'add', path: './denied', index: 1, patchLine: 6 },
|
||||
failure: { code: 'FS_PERMISSION_DENIED', message: 'cannot resolve denied path' }, changes: [] },
|
||||
{ status: 'skipped', operation: t.record('add', 'c', 2, 8) },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
await expect(t.bytes('c')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('retains a resolved move source when only its destination cannot be resolved', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const resolve = t.fs.resolve.bind(t.fs)
|
||||
vi.spyOn(t.fs, 'resolve').mockImplementation(async (path, options) => {
|
||||
if (path === './denied') throw new FsError('destination unread', 'FS_NOT_OBSERVED')
|
||||
return resolve(path, options)
|
||||
})
|
||||
expect(await t.run(patch(move('a', './denied'), update('a')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'failed', operation: { ...t.record('move', 'a'), destination: './denied' },
|
||||
failure: { code: 'FS_NOT_OBSERVED', message: 'cannot modify "./denied": file has not been read — read the file, then retry' }, changes: [] },
|
||||
{ status: 'skipped', operation: t.record('update', 'a', 1, 7) },
|
||||
], affectedPaths: [], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('old\n')
|
||||
expect(t.observations).toEqual([])
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('returns cancelled resolution records for a signal aborted before entry', async () => {
|
||||
const t = await setup()
|
||||
t.controller.abort()
|
||||
expect(await t.run(patch(add('a'), move('b', 'c')))).toEqual({ phase: 'preflight', outcomes: [
|
||||
{ status: 'failed', operation: { kind: 'add', path: 'a', index: 0, patchLine: 2 },
|
||||
failure: { code: 'FS_ABORTED', message: 'resolve aborted' }, changes: [] },
|
||||
{ status: 'failed', operation: { kind: 'move', path: 'b', destination: 'c', index: 1, patchLine: 4 },
|
||||
failure: { code: 'FS_ABORTED', message: 'resolve aborted' }, changes: [] },
|
||||
], affectedPaths: [], cancelled: true })
|
||||
await expect(t.bytes('a')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(t.observations).toEqual([])
|
||||
await expect(t.editIntent('a')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
/** Successful simulations and commits agree on bytes and observation guards. */
|
||||
import { symlink } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { add, move, patch, remove, setup, update } from './harness.ts'
|
||||
|
||||
it('applies multiple files and hunks while preserving storage line endings', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\r\ntail\r\n')
|
||||
const text = patch('*** Update File: a\n@@\n-old\n+new\n@@\n-tail\n+end', add('b'))
|
||||
const a = t.record('update', 'a')
|
||||
const b = t.record('add', 'b', 1, 9)
|
||||
expect(await t.run(text)).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: 'old\ntail\n', after: 'new\nend\n' }] },
|
||||
{ status: 'completed', operation: b, changes: [{ path: b.path, before: null, after: 'new\n' }] },
|
||||
], affectedPaths: [a.path, b.path], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('new\r\nend\r\n')
|
||||
expect(await t.bytes('b')).toBe('new\n')
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
expect(await t.editIntent('b')).toEqual({ version: await t.version('b') })
|
||||
})
|
||||
|
||||
it('retains aliased updates and reacquires the version produced by the preceding update', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const write = vi.spyOn(t.fs, 'patchText')
|
||||
const a = t.record('update', './a')
|
||||
const second = t.record('update', 'a', 1, 6)
|
||||
expect(await t.run(patch(update('./a'), update('a', 'new', 'last')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: 'old\n', after: 'new\n' }] },
|
||||
{ status: 'completed', operation: second, changes: [{ path: a.path, before: 'new\n', after: 'last\n' }] },
|
||||
], affectedPaths: [a.path], cancelled: false })
|
||||
expect(write.mock.calls[1]?.[2]).toEqual(t.observations[0]?.observation.kind === 'present' ? { version: t.observations[0].observation.version } : undefined)
|
||||
expect(await t.bytes('a')).toBe('last\n')
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('simulates deletion then recreation at the same target', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
const a = t.record('delete', 'a')
|
||||
const second = t.record('add', 'a', 1, 3)
|
||||
expect(await t.run(patch(remove('a'), add('a')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: 'old\n', after: null }] },
|
||||
{ status: 'completed', operation: second, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
||||
], affectedPaths: [a.path], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('new\n')
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('simulates creation then update before the file exists on disk', async () => {
|
||||
const t = await setup()
|
||||
const a = t.record('add', 'a')
|
||||
const second = t.record('update', 'a', 1, 4)
|
||||
expect(await t.run(patch(add('a'), update('a', 'new', 'last')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
||||
{ status: 'completed', operation: second, changes: [{ path: a.path, before: 'new\n', after: 'last\n' }] },
|
||||
], affectedPaths: [a.path], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('last\n')
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('moves and patches in one provider call then updates the simulated destination', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\r\n')
|
||||
await t.file('b', 'previous\n')
|
||||
const spy = vi.spyOn(t.fs, 'moveFile')
|
||||
const a = t.record('move', 'a', 0, 2, 'b')
|
||||
const b = t.record('update', 'b', 1, 7)
|
||||
expect(await t.run(patch(move('a', 'b'), update('b', 'new', 'last')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [
|
||||
{ path: b.path, before: 'previous\n', after: 'new\n' }, { path: a.path, before: 'old\n', after: null },
|
||||
] },
|
||||
{ status: 'completed', operation: b, changes: [{ path: b.path, before: 'new\n', after: 'last\n' }] },
|
||||
], affectedPaths: [b.path, a.path], cancelled: false })
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(spy.mock.calls[0]?.[2].hunks).toHaveLength(1)
|
||||
await expect(t.bytes('a')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await t.bytes('b')).toBe('last\r\n')
|
||||
await expect(t.editIntent('a')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
expect(await t.editIntent('b')).toEqual({ version: await t.version('b') })
|
||||
})
|
||||
|
||||
it('supports unconditional updates and moves without observation policy', async () => {
|
||||
const t = await setup({ policy: false })
|
||||
await t.file('a', 'old\n', false)
|
||||
const a = t.record('update', 'a')
|
||||
const b = t.record('move', 'a', 1, 6, 'b')
|
||||
expect(await t.run(patch(update('a'), move('a', 'b', 'new', 'last')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: 'old\n', after: 'new\n' }] },
|
||||
{ status: 'completed', operation: b, changes: [
|
||||
{ path: b.destination, before: null, after: 'last\n' }, { path: a.path, before: 'new\n', after: null },
|
||||
] },
|
||||
], affectedPaths: [a.path, b.destination], cancelled: false })
|
||||
expect(await t.bytes('b')).toBe('last\n')
|
||||
await expect(t.bytes('a')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await t.editIntent('b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['workspace-write', 'danger-full-access'] as const)('commits guarded changes under %s policy', async (mode) => {
|
||||
const t = await setup({ mode })
|
||||
const a = t.record('add', 'a')
|
||||
expect(await t.run(patch(add('a')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
||||
], affectedPaths: [a.path], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('new\n')
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('allows an add to overwrite a target already created in the patch', async () => {
|
||||
const t = await setup()
|
||||
const a = t.record('add', 'a')
|
||||
expect(await t.run(patch(add('a'), add('a', 'last')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
||||
{ status: 'completed', operation: t.record('add', 'a', 1, 4), changes: [{ path: a.path, before: 'new\n', after: 'last\n' }] },
|
||||
], affectedPaths: [a.path], cancelled: false })
|
||||
expect(await t.bytes('a')).toBe('last\n')
|
||||
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('updates through a symbolic link and shares its observation with the target', async () => {
|
||||
const t = await setup()
|
||||
await t.file('a', 'old\n')
|
||||
await symlink('a', resolve(t.workspace, 'link'))
|
||||
const link = t.record('update', 'link')
|
||||
expect(await t.run(patch(update('link')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: link, changes: [{ path: link.path, before: 'old\n', after: 'new\n' }] },
|
||||
], affectedPaths: [link.path], cancelled: false })
|
||||
expect(await t.bytes('link')).toBe('new\n')
|
||||
expect(await t.bytes('a')).toBe('new\n')
|
||||
expect(await t.editIntent('link')).toEqual({ version: await t.version('a') })
|
||||
})
|
||||
|
||||
it('deletes a file created earlier in the patch and records confirmed absence', async () => {
|
||||
const t = await setup()
|
||||
const a = t.record('add', 'a')
|
||||
expect(await t.run(patch(add('a'), remove('a')))).toEqual({ phase: 'commit', outcomes: [
|
||||
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
||||
{ status: 'completed', operation: t.record('delete', 'a', 1, 4), changes: [{ path: a.path, before: 'new\n', after: null }] },
|
||||
], affectedPaths: [a.path], cancelled: false })
|
||||
await expect(t.bytes('a')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(t.editIntent('a')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
@@ -4,7 +4,9 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
@@ -44,6 +46,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/code-language"
|
||||
},
|
||||
{
|
||||
"path": "../fs-patch"
|
||||
},
|
||||
{
|
||||
"path": "../../util/values"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+9
@@ -8450,9 +8450,15 @@ importers:
|
||||
|
||||
packages/fs/tool-fs:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-fs-patch':
|
||||
specifier: workspace:*
|
||||
version: link:../fs-patch
|
||||
'@deepseek-ai/dsh-util-code-language':
|
||||
specifier: workspace:*
|
||||
version: link:../../util/code-language
|
||||
'@deepseek-ai/dsh-util-values':
|
||||
specifier: workspace:*
|
||||
version: link:../../util/values
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
@@ -8484,6 +8490,9 @@ importers:
|
||||
'@deepseek-ai/dsh-fs-observation-policy':
|
||||
specifier: workspace:*
|
||||
version: link:../fs-observation-policy
|
||||
'@deepseek-ai/dsh-fs-sandbox':
|
||||
specifier: workspace:*
|
||||
version: link:../fs-sandbox
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:*
|
||||
version: link:../../llm/llm
|
||||
|
||||
Reference in new issue
Block a user