feat(tool-fs): preflight and execute ordered patches

This commit is contained in:
sjw1231 committed 2026-10-14 17:31:15 +08:00
1 parent 27a4010098
commit 9a4e78b469
16 files changed
+1288 -15

No files matched your search

+10 -10
View File
@@ -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
+12 -1
View File
@@ -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.
+12 -1
View File
@@ -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`。
- **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。只有没有扩展名的路径按文件签名识别格式。
+5 -2
View 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:*"
}
}
+122
View File
@@ -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 }
}
+38
View File
@@ -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)
}
+119
View File
@@ -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]
}
+160
View File
@@ -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
}
+90
View File
@@ -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' })
})
+9 -1
View File
@@ -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"
}
]
}
+9
View File
@@ -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