feat(tool-fs): use locked move baselines and cache patch roots

This commit is contained in:
sjw1231 committed 2026-10-14 18:29:15 +08:00
1 parent b96d527022
commit f4a7cdfef5
20 files changed
+79 -35

No files matched your search

+4 -4
View File
@@ -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<FsMoveOutcome>
```
+4 -4
View File
@@ -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<FsMoveOutcome>
```
@@ -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<FsMoveOutcome>',
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',
+2 -2
View File
@@ -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
+2
View File
@@ -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.
-----
<a id="understand-the-implementation"></a>
+2
View File
@@ -58,6 +58,8 @@ kind: "package-reference"
Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用轮询或递归监听。文件使用筛选到目标的父目录监听,因此就绪时也能接收起初缺失文件的创建事件。父目录保持存在时,文件监听覆盖原地写入、原子替换、删除和同路径重建。
移动结果的 `sourceBefore` 是提供方在两端锁内取得的 LF 源文本;完整与部分移动均返回它,调用方据此记录源基线。
-----
<a id="understand-the-implementation"></a>
+3 -2
View File
@@ -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 }
})
}
@@ -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')
+2 -2
View File
@@ -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
+2
View File
@@ -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.
-----
<a id="understand-the-implementation"></a>
+2
View File
@@ -39,6 +39,8 @@ kind: "package-reference"
`watch(target, changed, signal)` 报告单个文件或目录直接子项的失效通知。观察就绪后,它返回调用方必须等待完成的异步关闭函数。signal 取消初始化;不支持监听的提供方直接拒绝,不使用轮询。
移动结果的 `sourceBefore` 是提供方在两端锁内取得的 LF 源文本;完整与部分移动均返回它,调用方据此记录源基线。
-----
<a id="understand-the-implementation"></a>
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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 = [
+1 -4
View File
@@ -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 }
+5 -4
View File
@@ -21,6 +21,7 @@ interface SimulatedFile {
class Preflight {
failurePath = ''
private roots: Promise<FsTarget[]> | undefined
private readonly states = new Map<FsTargetKey, SimulatedFile>()
constructor(
@@ -39,7 +40,7 @@ class Preflight {
private async fence(operation: PlannedOperation): Promise<void> {
if (this.policy === undefined || this.policy.mode === 'danger-full-access') return
if (this.policy.mode === 'read-only') throw new FsError('filesystem mutations are denied', 'FS_SANDBOX_DENIED')
const roots = await Promise.all(writableRoots(this.policy).map(root => this.ctx.fs.resolve(root)))
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<void> {
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<void> {
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)
@@ -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')
})
@@ -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)
})
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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(),
@@ -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' })