From 9a4e78b469ab32d7b62fdfdabf8f059e25b9831b Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Wed, 14 Oct 2026 17:31:15 +0800 Subject: [PATCH] feat(tool-fs): preflight and execute ordered patches --- packages/fs/tool-fs/README.i18n.yaml | 20 +- packages/fs/tool-fs/README.md | 13 +- packages/fs/tool-fs/README.zh.md | 13 +- packages/fs/tool-fs/package.json | 7 +- packages/fs/tool-fs/src/patch/execute.ts | 122 +++++++++ packages/fs/tool-fs/src/patch/index.ts | 38 +++ packages/fs/tool-fs/src/patch/plan.ts | 119 +++++++++ packages/fs/tool-fs/src/patch/preflight.ts | 160 ++++++++++++ packages/fs/tool-fs/src/patch/types.ts | 90 +++++++ .../fs/tool-fs/tests/patch/commit.spec.ts | 173 +++++++++++++ packages/fs/tool-fs/tests/patch/harness.ts | 90 +++++++ .../fs/tool-fs/tests/patch/preflight.spec.ts | 240 ++++++++++++++++++ .../fs/tool-fs/tests/patch/resolution.spec.ts | 56 ++++ .../fs/tool-fs/tests/patch/success.spec.ts | 143 +++++++++++ packages/fs/tool-fs/tsconfig.json | 10 +- pnpm-lock.yaml | 9 + 16 files changed, 1288 insertions(+), 15 deletions(-) create mode 100644 packages/fs/tool-fs/src/patch/execute.ts create mode 100644 packages/fs/tool-fs/src/patch/index.ts create mode 100644 packages/fs/tool-fs/src/patch/plan.ts create mode 100644 packages/fs/tool-fs/src/patch/preflight.ts create mode 100644 packages/fs/tool-fs/src/patch/types.ts create mode 100644 packages/fs/tool-fs/tests/patch/commit.spec.ts create mode 100644 packages/fs/tool-fs/tests/patch/harness.ts create mode 100644 packages/fs/tool-fs/tests/patch/preflight.spec.ts create mode 100644 packages/fs/tool-fs/tests/patch/resolution.spec.ts create mode 100644 packages/fs/tool-fs/tests/patch/success.spec.ts diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 90d4a216dd..25fd05b32a 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -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 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index a6e889f1d6..1c4393a3cb 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -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. + ----- @@ -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. diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 5e56478d92..2ae45b9a3d 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -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` 仅包含已知效果。执行在首个失败或未知提交后停止,或在信号已取消的操作开始前停止。 + ----- @@ -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`。 - **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。只有没有扩展名的路径按文件签名识别格式。 diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 3b535e2f75..71df8efdf3 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -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:*" } } diff --git a/packages/fs/tool-fs/src/patch/execute.ts b/packages/fs/tool-fs/src/patch/execute.ts new file mode 100644 index 0000000000..7bc0332f88 --- /dev/null +++ b/packages/fs/tool-fs/src/patch/execute.ts @@ -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 { + 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 { + 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 } +} diff --git a/packages/fs/tool-fs/src/patch/index.ts b/packages/fs/tool-fs/src/patch/index.ts new file mode 100644 index 0000000000..f27ddbe2e4 --- /dev/null +++ b/packages/fs/tool-fs/src/patch/index.ts @@ -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 { + 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) +} diff --git a/packages/fs/tool-fs/src/patch/plan.ts b/packages/fs/tool-fs/src/patch/plan.ts new file mode 100644 index 0000000000..6ef32dd112 --- /dev/null +++ b/packages/fs/tool-fs/src/patch/plan.ts @@ -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 { + const parsed = parsePatch(request.patch) + const targets = new Map() + const resolve = async (path: string): Promise => { + 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 { + 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] +} diff --git a/packages/fs/tool-fs/src/patch/preflight.ts b/packages/fs/tool-fs/src/patch/preflight.ts new file mode 100644 index 0000000000..18a5f196f2 --- /dev/null +++ b/packages/fs/tool-fs/src/patch/preflight.ts @@ -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() + + constructor( + private readonly ctx: Context, private readonly exec: ToolExecution, private readonly policy: SandboxExecutionPolicy | undefined, + ) {} + + private async state(target: FsTarget): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/packages/fs/tool-fs/src/patch/types.ts b/packages/fs/tool-fs/src/patch/types.ts new file mode 100644 index 0000000000..af4af1d76b --- /dev/null +++ b/packages/fs/tool-fs/src/patch/types.ts @@ -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[] +} diff --git a/packages/fs/tool-fs/tests/patch/commit.spec.ts b/packages/fs/tool-fs/tests/patch/commit.spec.ts new file mode 100644 index 0000000000..6c4dee1ca2 --- /dev/null +++ b/packages/fs/tool-fs/tests/patch/commit.spec.ts @@ -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): ReturnType { + 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') }) +}) diff --git a/packages/fs/tool-fs/tests/patch/harness.ts b/packages/fs/tool-fs/tests/patch/harness.ts new file mode 100644 index 0000000000..fcb12e2078 --- /dev/null +++ b/packages/fs/tool-fs/tests/patch/harness.ts @@ -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 } +} diff --git a/packages/fs/tool-fs/tests/patch/preflight.spec.ts b/packages/fs/tool-fs/tests/patch/preflight.spec.ts new file mode 100644 index 0000000000..08836f5085 --- /dev/null +++ b/packages/fs/tool-fs/tests/patch/preflight.spec.ts @@ -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') }) +}) diff --git a/packages/fs/tool-fs/tests/patch/resolution.spec.ts b/packages/fs/tool-fs/tests/patch/resolution.spec.ts new file mode 100644 index 0000000000..a1cfea1d32 --- /dev/null +++ b/packages/fs/tool-fs/tests/patch/resolution.spec.ts @@ -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' }) +}) diff --git a/packages/fs/tool-fs/tests/patch/success.spec.ts b/packages/fs/tool-fs/tests/patch/success.spec.ts new file mode 100644 index 0000000000..71892d5fb2 --- /dev/null +++ b/packages/fs/tool-fs/tests/patch/success.spec.ts @@ -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' }) +}) diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index ca6e6e073a..0299edf069 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -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" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bf3141476..9536d1dfaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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