diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index c83f795b4b..8f246a8efc 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -215,10 +215,10 @@ interface FsMoveSourceFailure { ``` ```ts type-equiv -/** Resolved move outcome: the destination is written, while source removal may have failed. */ +/** Published destination and removal status; sourceBefore is the LF source text captured under the move lock. */ type FsMoveOutcome = - | { destination: FsWriteOutcome; sourceDeleted: true } - | { destination: FsWriteOutcome; sourceDeleted: false; sourceFailure: FsMoveSourceFailure } + | { sourceBefore: string; destination: FsWriteOutcome; sourceDeleted: true } + | { sourceBefore: string; destination: FsWriteOutcome; sourceDeleted: false; sourceFailure: FsMoveSourceFailure } ``` Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`, including a target that appears after the provider's initial probe because publication itself must be no-replace; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit both use the same optional `expected` field. @@ -610,7 +610,7 @@ abstract deleteFile( target: FsTarget, expected?: { version: FsVersion }, signal * @param signal - checked before destination staging; once staging starts, cancellation * does not interrupt the local commit. Remote cancellation can leave the outcome unknown. * @param sandboxPolicy - confinement for both paths; omit to use the provider default. - * @returns destination outcome and source-removal status; failures before publication reject. + * @returns locked LF sourceBefore, destination outcome, and source-removal status; failures before publication reject. */ abstract moveFile( source: FsTarget, destination: FsTarget, options: { hunks?: readonly PatchHunk[] expectedSource?: { version: FsVersion } expectedDestination?: FsWriteIntent }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise ``` diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index b3ad048020..9e984a60c2 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -215,10 +215,10 @@ interface FsMoveSourceFailure { ``` ```ts type-equiv -/** Resolved move outcome: the destination is written, while source removal may have failed. */ +/** Published destination and removal status; sourceBefore is the LF source text captured under the move lock. */ type FsMoveOutcome = - | { destination: FsWriteOutcome; sourceDeleted: true } - | { destination: FsWriteOutcome; sourceDeleted: false; sourceFailure: FsMoveSourceFailure } + | { sourceBefore: string; destination: FsWriteOutcome; sourceDeleted: true } + | { sourceBefore: string; destination: FsWriteOutcome; sourceDeleted: false; sourceFailure: FsMoveSourceFailure } ``` `writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;即使目标在提供方初始探测后才出现,也必须拒绝,因为发布操作本身不得替换。`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 都使用同一个可选的 `expected` 字段。 @@ -610,7 +610,7 @@ abstract deleteFile( target: FsTarget, expected?: { version: FsVersion }, signal * @param signal - checked before destination staging; once staging starts, cancellation * does not interrupt the local commit. Remote cancellation can leave the outcome unknown. * @param sandboxPolicy - confinement for both paths; omit to use the provider default. - * @returns destination outcome and source-removal status; failures before publication reject. + * @returns locked LF sourceBefore, destination outcome, and source-removal status; failures before publication reject. */ abstract moveFile( source: FsTarget, destination: FsTarget, options: { hunks?: readonly PatchHunk[] expectedSource?: { version: FsVersion } expectedDestination?: FsWriteIntent }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise ``` diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6df88b1877..9a2a5fdfee 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1151,7 +1151,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract moveFile( source: FsTarget, destination: FsTarget, options: { hunks?: readonly PatchHunk[] expectedSource?: { version: FsVersion } expectedDestination?: FsWriteIntent }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise', description: 'Publish source text at a destination, optionally patching it, then remove the source. Both paths remain locked in canonical order. FS_SAME_TARGET rejects identical keys; source guards match patchText, destination guards match writeText. No cross-path transaction is promised: destination publication with the source retained is a final outcome. Obtain source/destination guards through fs/edit-intent and fs/write-intent. On resolution, record destination present(destination.version); record source absent only when sourceDeleted is true. Providers do not emit these observations. Resolution follows symlinks: callers rejecting symlink sources must lstat before resolve.', parameters: [{ name: 'source', description: 'resolved regular UTF-8 source; its line-ending style and mode are preserved.' }, { name: 'destination', description: 'resolved destination in the same provider, distinct from source.' }, { name: 'options', description: 'optional hunks and source/destination guards; omitted fields mean no text patch and unconditional source/destination mutations respectively.' }, { name: 'signal', description: 'checked before destination staging; once staging starts, cancellation does not interrupt the local commit. Remote cancellation can leave the outcome unknown.' }, { name: 'sandboxPolicy', description: 'confinement for both paths; omit to use the provider default.' }], - returns: 'destination outcome and source-removal status; failures before publication reject.', + returns: 'locked LF sourceBefore, destination outcome, and source-removal status; failures before publication reject.', }, ], }, @@ -5243,7 +5243,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'FsMoveOutcome', - declaration: 'export type FsMoveOutcome = {\n destination: FsWriteOutcome;\n sourceDeleted: true;\n} | {\n destination: FsWriteOutcome;\n sourceDeleted: false;\n sourceFailure: FsMoveSourceFailure;\n};', + declaration: 'export type FsMoveOutcome = {\n sourceBefore: string;\n destination: FsWriteOutcome;\n sourceDeleted: true;\n} | {\n sourceBefore: string;\n destination: FsWriteOutcome;\n sourceDeleted: false;\n sourceFailure: FsMoveSourceFailure;\n};', }, { name: 'FsMoveSourceFailure', diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index de677ba1ce..31ecbff66d 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/README.i18n.yaml @@ -24,8 +24,8 @@ en: 60678d0c5fbf3c56 zh: 1698a9645c12578b /deepseek-ai-dsh-fs-local/use-this-package/what-you-can-do: - en: 53c80835163c64aa - zh: 962e924ceb8f84d1 + en: 1a58fc2541567a12 + zh: beaea8d99c9d5171 /deepseek-ai-dsh-fs-local/understand-the-implementation: en: 6ffc93e09381bd3d zh: 57c3192c26a080ef diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index a2dc237e30..b8562e5c4c 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -58,6 +58,8 @@ Read, listing, and mutation failures are typed `FsError`s with stable codes — Chokidar observes one file or a directory's direct entries through OS events, without polling or recursive watching. Files use a filtered parent-directory watch, so readiness also covers creation of an initially missing file. File watches cover in-place writes, atomic replacement, deletion, and same-path recreation while the parent directory remains. +Move results include `sourceBefore`, the LF source text captured while both paths are locked. Complete and partial moves return it so consumers can record the source basis. + ----- diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index e1a29ed1aa..0d9c141e67 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -58,6 +58,8 @@ kind: "package-reference" Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用轮询或递归监听。文件使用筛选到目标的父目录监听,因此就绪时也能接收起初缺失文件的创建事件。父目录保持存在时,文件监听覆盖原地写入、原子替换、删除和同路径重建。 +移动结果的 `sourceBefore` 是提供方在两端锁内取得的 LF 源文本;完整与部分移动均返回它,调用方据此记录源基线。 + ----- diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 286af340b0..9c90a2d43e 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -340,9 +340,10 @@ export class LocalFileSystem extends FileSystem { } catch (error) { // removeFile maps I/O failures; unexpected defects must still reject. if (!(error instanceof FsError)) throw error - return { destination: outcome, sourceDeleted: false, sourceFailure: { code: error.code, message: error.message } } + return { sourceBefore: original.content, destination: outcome, sourceDeleted: false, + sourceFailure: { code: error.code, message: error.message } } } - return { destination: outcome, sourceDeleted: true } + return { sourceBefore: original.content, destination: outcome, sourceDeleted: true } }) } diff --git a/packages/fs/fs-local/tests/patch-mutations.spec.ts b/packages/fs/fs-local/tests/patch-mutations.spec.ts index ac9b865e94..61e9d09255 100644 --- a/packages/fs/fs-local/tests/patch-mutations.spec.ts +++ b/packages/fs/fs-local/tests/patch-mutations.spec.ts @@ -119,7 +119,7 @@ describe('moveFile', () => { ? { kind: 'replaceIfVersion' as const, version: await version(destination) } : { kind: 'createIfAbsent' as const } const result = await fs.moveFile(source, destination, { expectedSource: { version: await version(source) }, expectedDestination }) - expect(result).toEqual({ sourceDeleted: true, destination: { + expect(result).toEqual({ sourceBefore: 'old\ntail\n', sourceDeleted: true, destination: { operation: exists ? 'update' : 'create', version: await version(destination), before: exists ? 'destination\n' : null, after: 'old\ntail\n', } }) @@ -167,7 +167,7 @@ describe('moveFile', () => { fs.internals.removeFile = async () => { throw Object.assign(new Error('injected'), { code }) } expect(await fs.moveFile(source, destination, {})).toEqual({ destination: { operation: 'update', version: await version(destination), before: 'previous', after: 'old\n' }, - sourceDeleted: false, + sourceBefore: 'old\n', sourceDeleted: false, sourceFailure: { code: code === 'EACCES' ? 'FS_PERMISSION_DENIED' : 'FS_IO_ERROR', message: `cannot delete "${source.displayPath}": injected` }, }) expect(await bytes('a')).toBe('old\n') diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index 79ac90a6b6..cf260d6886 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/README.i18n.yaml @@ -21,8 +21,8 @@ en: 8e2f904a21ba4dfb zh: 76d9ce7503c84a5c /deepseek-ai-dsh-fs/use-this-package/what-the-service-lets-you-do: - en: 9627062f3013087a - zh: 3dbd1dcbf1403ee7 + en: 6b5f12bf92454cbc + zh: ee75b60c29302062 /deepseek-ai-dsh-fs/understand-the-implementation: en: 7e13d669ecc96d59 zh: b5fe7ae65812d926 diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 4082bd3270..f5ed73018a 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -39,6 +39,8 @@ Through `ctx.fs`, resolve paths, read text or bounded bytes, list direct childre `watch(target, changed, signal)` reports invalidations for one file or a directory's direct entries. It resolves once observation is ready with an asynchronous close function that the caller must await. The signal cancels initialization; unsupported providers reject without polling. +Move results include `sourceBefore`, the LF source text captured while both paths are locked. Complete and partial moves return it so consumers can record the source basis. + ----- diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index 43f015a738..e6bb137679 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -39,6 +39,8 @@ kind: "package-reference" `watch(target, changed, signal)` 报告单个文件或目录直接子项的失效通知。观察就绪后,它返回调用方必须等待完成的异步关闭函数。signal 取消初始化;不支持监听的提供方直接拒绝,不使用轮询。 +移动结果的 `sourceBefore` 是提供方在两端锁内取得的 LF 源文本;完整与部分移动均返回它,调用方据此记录源基线。 + ----- diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index e91aa023a4..2eda70a2bd 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -362,7 +362,7 @@ export abstract class FileSystem extends Service { * @param signal - checked before destination staging; once staging starts, cancellation * does not interrupt the local commit. Remote cancellation can leave the outcome unknown. * @param sandboxPolicy - confinement for both paths; omit to use the provider default. - * @returns destination outcome and source-removal status; failures before publication reject. + * @returns locked LF sourceBefore, destination outcome, and source-removal status; failures before publication reject. */ abstract moveFile( source: FsTarget, diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 51fc1e4eb6..a0ab70ddef 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -181,10 +181,10 @@ export interface FsMoveSourceFailure { message: string } -/** Resolved move outcome: the destination is written, while source removal may have failed. */ +/** Published destination and removal status; sourceBefore is the LF source text captured under the move lock. */ export type FsMoveOutcome = - | { destination: FsWriteOutcome; sourceDeleted: true } - | { destination: FsWriteOutcome; sourceDeleted: false; sourceFailure: FsMoveSourceFailure } + | { sourceBefore: string; destination: FsWriteOutcome; sourceDeleted: true } + | { sourceBefore: string; destination: FsWriteOutcome; sourceDeleted: false; sourceFailure: FsMoveSourceFailure } /** Stable filesystem failure codes shared by providers, consumers, and wire validators. */ export const FS_ERROR_CODES = [ diff --git a/packages/fs/tool-fs/src/patch/execute.ts b/packages/fs/tool-fs/src/patch/execute.ts index 7bc0332f88..4d77e5c62b 100644 --- a/packages/fs/tool-fs/src/patch/execute.ts +++ b/packages/fs/tool-fs/src/patch/execute.ts @@ -82,9 +82,6 @@ export async function commitPatch( 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, @@ -95,7 +92,7 @@ export async function commitPatch( 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 }] } + outcome = { status: 'completed', operation: record, changes: [...changes, { path: target.displayPath, before: result.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 } diff --git a/packages/fs/tool-fs/src/patch/preflight.ts b/packages/fs/tool-fs/src/patch/preflight.ts index 18a5f196f2..1d300cf158 100644 --- a/packages/fs/tool-fs/src/patch/preflight.ts +++ b/packages/fs/tool-fs/src/patch/preflight.ts @@ -21,6 +21,7 @@ interface SimulatedFile { class Preflight { failurePath = '' + private roots: Promise | undefined private readonly states = new Map() constructor( @@ -39,7 +40,7 @@ class Preflight { 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))) + const roots = await (this.roots ??= 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') @@ -53,8 +54,8 @@ class Preflight { } } - private async source(state: SimulatedFile, target: FsTarget, kind: 'delete' | 'edit'): Promise { - const intent = state.touched ? undefined : await sourceIntent(this.ctx, this.exec, target, kind) + private async source(state: SimulatedFile, target: FsTarget, kind: 'delete' | 'edit' | 'move'): Promise { + const intent = state.touched ? undefined : await sourceIntent(this.ctx, this.exec, target, kind === 'delete' ? 'delete' : 'edit') 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) { @@ -106,7 +107,7 @@ class Preflight { return case 'update': case 'move': { - await this.source(state, operation.target, 'edit') + await this.source(state, operation.target, operation.kind === 'move' ? 'move' : 'edit') if (operation.kind === 'move') { this.failurePath = operation.destination.displayPath await this.destination(await this.state(operation.destination), operation.destination) diff --git a/packages/fs/tool-fs/tests/patch/commit.spec.ts b/packages/fs/tool-fs/tests/patch/commit.spec.ts index 6c4dee1ca2..9d20690168 100644 --- a/packages/fs/tool-fs/tests/patch/commit.spec.ts +++ b/packages/fs/tool-fs/tests/patch/commit.spec.ts @@ -171,3 +171,22 @@ it('propagates programmer defects from mutation and preserves observed state', a expect(t.observations).toEqual([]) expect(await t.editIntent('a')).toEqual({ version: await t.version('a') }) }) + + +it('reports the locked move source after an external edit following preflight', async () => { + const t = await setup({ policy: false }) + await t.file('a', 'old\n') + const originalMove = t.fs.moveFile.bind(t.fs) + vi.spyOn(t.fs, 'moveFile').mockImplementation(async (...args) => { + await writeFile(resolve(t.workspace, 'a'), 'old\nexternal\n') + return originalMove(...args) + }) + const reads = vi.spyOn(t.fs, 'readText') + const result = await t.run(patch(move('a', 'b'))) + expect(result.outcomes).toEqual([{ status: 'completed', operation: t.record('move', 'a', 0, 2, 'b'), changes: [ + { path: resolve(t.workspace, 'b'), before: null, after: 'new\nexternal\n' }, + { path: resolve(t.workspace, 'a'), before: 'old\nexternal\n', after: null }, + ] }]) + expect(reads).toHaveBeenCalledTimes(1) + expect(await t.bytes('b')).toBe('new\nexternal\n') +}) diff --git a/packages/fs/tool-fs/tests/patch/preflight.spec.ts b/packages/fs/tool-fs/tests/patch/preflight.spec.ts index 08836f5085..773666cb89 100644 --- a/packages/fs/tool-fs/tests/patch/preflight.spec.ts +++ b/packages/fs/tool-fs/tests/patch/preflight.spec.ts @@ -238,3 +238,21 @@ it('preserves source simulation after a move destination fails admission', async expect(t.observations).toEqual([]) expect(await t.editIntent('a')).toEqual({ version: await t.version('a') }) }) + + +it('names the missing move source operation', async () => { + const t = await setup({ policy: false }) + expect(await t.run(patch(move('missing', 'destination')))).toEqual({ + phase: 'preflight', outcomes: [{ status: 'failed', operation: t.record('move', 'missing', 0, 2, 'destination'), + failure: { code: 'FS_NOT_FOUND', message: `cannot move "${resolve(t.workspace, 'missing')}": not found` }, changes: [] }], + affectedPaths: [], cancelled: false, + }) +}) + +it('resolves writable roots once while admitting multiple operations', async () => { + const t = await setup({ mode: 'workspace-write' }) + const resolve = vi.spyOn(t.fs, 'resolve') + // A later unread source prevents provider commits, isolating admission's root lookups. + await t.run(patch(add('a'), add('b'), update('unread'))) + expect(resolve.mock.calls.filter(([path]) => path === t.workspace)).toHaveLength(1) +}) diff --git a/packages/ssh/fs-ssh/tests/provider.spec.ts b/packages/ssh/fs-ssh/tests/provider.spec.ts index 98c4891484..ce9bdfa6a1 100644 --- a/packages/ssh/fs-ssh/tests/provider.spec.ts +++ b/packages/ssh/fs-ssh/tests/provider.spec.ts @@ -214,7 +214,7 @@ it.each([false, true])('forwards patch/delete/move with explicit policy %s and p const edited = { version: 'v2', before: 'old', after: 'new' } const deleted = { before: 'old' } const destination = { targetKey: FsTargetKey('/remote/work/new.txt'), displayPath: 'new.txt' } - const moved = { destination: { ...edited, operation: 'create' }, sourceDeleted: false, sourceFailure: { code: 'FS_PERMISSION_DENIED', message: 'cannot delete source' } } + const moved = { sourceBefore: 'source before patch\n', destination: { ...edited, operation: 'create' }, sourceDeleted: false, sourceFailure: { code: 'FS_PERMISSION_DENIED', message: 'cannot delete source' } } dispatch.mockResolvedValueOnce(edited).mockResolvedValueOnce(deleted).mockResolvedValueOnce(moved) expect(await fs.patchText(target, hunks, expected, signal, selected)).toEqual(edited) expect(dispatch).toHaveBeenLastCalledWith('fs.patch', { target, hunks, expected, policy: resolved }, signal) @@ -233,7 +233,7 @@ it.each(['FS_PATCH_MISMATCH', 'FS_SAME_TARGET'])('preserves new remote error %s' it.each([ { destination: { operation: 'create', version: 'v', before: null, after: '' }, sourceDeleted: false }, - { destination: { operation: 'create', version: 'v', before: null, after: '' }, sourceDeleted: false, sourceFailure: { code: 'INVALID', message: 'bad' } }, + { destination: { operation: 'create', version: 'v', before: null, after: '' }, sourceDeleted: false, sourceBefore: '', sourceFailure: { code: 'INVALID', message: 'bad' } }, ])('rejects malformed move outcomes', async (outcome) => { const { fs, dispatch } = await setup() dispatch.mockResolvedValue(outcome) diff --git a/packages/ssh/ssh/src/schemas.ts b/packages/ssh/ssh/src/schemas.ts index a45572f11a..5f97f56e17 100644 --- a/packages/ssh/ssh/src/schemas.ts +++ b/packages/ssh/ssh/src/schemas.ts @@ -50,8 +50,8 @@ export const moveOptionsSchema = z.object({ export const deleteResultSchema = z.object({ before: z.string().nullable() }).strict() /** Move completion preserves a published destination even when source removal fails. */ export const moveResultSchema = z.discriminatedUnion('sourceDeleted', [ - z.object({ destination: writeResultSchema, sourceDeleted: z.literal(true) }).strict(), - z.object({ destination: writeResultSchema, sourceDeleted: z.literal(false), sourceFailure: z.object({ + z.object({ sourceBefore: z.string(), destination: writeResultSchema, sourceDeleted: z.literal(true) }).strict(), + z.object({ sourceBefore: z.string(), destination: writeResultSchema, sourceDeleted: z.literal(false), sourceFailure: z.object({ code: z.enum(FS_ERROR_CODES), message: z.string(), }).strict() }).strict(), diff --git a/packages/ssh/ssh/tests/helper-runtime.spec.ts b/packages/ssh/ssh/tests/helper-runtime.spec.ts index a84cea1270..ea1aedc005 100644 --- a/packages/ssh/ssh/tests/helper-runtime.spec.ts +++ b/packages/ssh/ssh/tests/helper-runtime.spec.ts @@ -117,7 +117,7 @@ describe.skipIf(process.platform === 'win32')('guarded patch helper dispatch', ( expect(await readFile(`${test.root}/a`, 'utf8')).toBe('new\r\n') const options = guarded ? { hunks: [{ ...hunks[0], context: 'new', oldLines: [], newLines: ['tail'] }], expectedSource: { version: patched.version }, expectedDestination: { kind: 'createIfAbsent' } } : {} const moved = await test.client.request('fs.move', { source: target, destination, options, policy: policy(test.root) }, moveResultSchema) - expect(moved).toMatchObject({ sourceDeleted: true, destination: { operation: 'create', before: null, after: guarded ? 'new\ntail\n' : 'new\n' } }) + expect(moved).toMatchObject({ sourceBefore: 'new\n', sourceDeleted: true, destination: { operation: 'create', before: null, after: guarded ? 'new\ntail\n' : 'new\n' } }) await expect(readFile(`${test.root}/a`)).rejects.toMatchObject({ code: 'ENOENT' }) expect(await readFile(`${test.root}/b`, 'utf8')).toBe(guarded ? 'new\r\ntail\r\n' : 'new\r\n') expect(await test.client.request('fs.delete', { target: destination, expected: guarded ? { version: moved.destination.version } : undefined, policy: policy(test.root) }, deleteResultSchema)).toEqual({ before: guarded ? 'new\ntail\n' : 'new\n' })