Files
deepseek-harness/packages/fs/tool-fs/tests/patch/commit.spec.ts
T

193 lines
11 KiB
TypeScript

/** Sequential commits retain known effects and distinguish unchanged from uncertain failures. */
import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { FsError } from '@deepseek-ai/dsh-fs'
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import { expect, it, vi } from 'vitest'
import { add, move, patch, remove, setup, update } from './harness.ts'
class LostReplyFileSystem extends LocalFileSystem {
override async patchText(...args: Parameters<LocalFileSystem['patchText']>): ReturnType<LocalFileSystem['patchText']> {
await super.patchText(...args)
throw new FsError('reply lost', 'FS_IO_ERROR')
}
}
it.each(['delete', 'move'] as const)('retains the first commit when %s removal fails and observes only published changes', async (kind) => {
const t = await setup()
await t.file('source', 'old\n')
const sourceGuard = await t.editIntent('source')
t.fs.internals.removeFile = async () => { throw Object.assign(new Error('removal denied'), { code: 'EACCES' }) }
const a = t.record('add', 'a')
const b = t.record(kind, 'source', 1, 4, kind === 'move' ? 'destination' : undefined)
const c = t.record('add', 'c', 2, kind === 'move' ? 9 : 5)
const destination = resolve(t.workspace, 'destination')
expect(await t.run(patch(add('a'), kind === 'move' ? move('source', 'destination') : remove('source'), add('c')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
{ status: 'failed', operation: b, failure: { code: 'FS_PERMISSION_DENIED', message: `cannot delete "${b.path}": removal denied` },
changes: kind === 'move' ? [{ path: destination, before: null, after: 'new\n' }] : [] },
{ status: 'skipped', operation: c },
], affectedPaths: kind === 'move' ? [a.path, destination] : [a.path], cancelled: false })
expect(await t.bytes('a')).toBe('new\n')
expect(await t.bytes('source')).toBe('old\n')
await expect(t.bytes('c')).rejects.toMatchObject({ code: 'ENOENT' })
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
expect(await t.editIntent('source')).toEqual(sourceGuard)
expect(t.observations.map(event => event.path)).toEqual(kind === 'move' ? [a.path, destination] : [a.path])
if (kind === 'move') {
expect(await t.bytes('destination')).toBe('new\n')
expect(await t.editIntent('destination')).toEqual({ version: await t.version('destination') })
}
})
it('stops between operations after cancellation without undoing the first write', async () => {
const t = await setup()
t.ctx.on('fs/observed', () => { t.controller.abort() })
const a = t.record('add', 'a')
expect(await t.run(patch(add('a'), add('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
{ status: 'skipped', operation: t.record('add', 'b', 1, 4) },
{ status: 'skipped', operation: t.record('add', 'c', 2, 6) },
], affectedPaths: [a.path], cancelled: true })
expect(await t.bytes('a')).toBe('new\n')
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('reports cancellation during staged writing as failed when stat confirms no change', async () => {
const t = await setup()
let writes = 0
t.fs.internals.inspectTemp = () => { if (++writes === 2) t.controller.abort() }
const a = t.record('add', 'a')
expect(await t.run(patch(add('a'), add('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
{ status: 'failed', operation: t.record('add', 'b', 1, 4), failure: { code: 'FS_ABORTED', message: 'write aborted' }, changes: [] },
{ status: 'skipped', operation: t.record('add', 'c', 2, 6) },
], affectedPaths: [a.path], cancelled: true })
expect(await t.bytes('a')).toBe('new\n')
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('does not mark a fully completed patch cancelled when its final observation aborts', async () => {
const t = await setup()
t.ctx.on('fs/observed', () => { t.controller.abort() })
const a = t.record('add', 'a')
expect(await t.run(patch(add('a')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
], affectedPaths: [a.path], cancelled: false })
expect(await t.bytes('a')).toBe('new\n')
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
})
it('reports unknown after the provider changes disk and loses its reply without recording an observation', async () => {
const t = await setup({ provider: LostReplyFileSystem })
await t.file('a', 'old\n')
const observed = await t.editIntent('a')
expect(await t.run(patch(update('a'), add('b')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'unknown', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'reply lost — re-read the affected files before retrying' } },
{ status: 'skipped', operation: t.record('add', 'b', 1, 6) },
], affectedPaths: [], cancelled: false })
expect(await t.bytes('a')).toBe('new\n')
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
expect(t.observations).toEqual([])
expect(await t.editIntent('a')).toEqual(observed)
})
it.each(['unchanged', 'stat fails', 'stat defect'] as const)('verifies an I/O failure after provider entry: %s', async (scenario) => {
const t = await setup()
await t.file('a', 'old\n')
const observed = await t.editIntent('a')
const defect = new Error('stat defect')
vi.spyOn(t.fs, 'patchText').mockImplementation(async () => {
if (scenario !== 'unchanged') vi.spyOn(t.fs, 'stat').mockRejectedValue(scenario === 'stat fails' ? new FsError('stat failed', 'FS_IO_ERROR') : defect)
throw new FsError('write failed', 'FS_IO_ERROR')
})
const request = t.run(patch(update('a'), add('b')))
if (scenario === 'stat defect') await expect(request).rejects.toBe(defect)
else expect(await request).toEqual({ phase: 'commit', outcomes: [
scenario === 'unchanged'
? { status: 'failed', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'write failed' }, changes: [] }
: { status: 'unknown', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'write failed — re-read the affected files before retrying' } },
{ status: 'skipped', operation: t.record('add', 'b', 1, 6) },
], affectedPaths: [], cancelled: false })
expect(await t.bytes('a')).toBe('old\n')
expect(t.observations).toEqual([])
expect(await t.editIntent('a')).toEqual(observed)
})
it('checks both move targets when its destination was published but the reply is lost', async () => {
const t = await setup()
await t.file('a', 'old\n')
const observed = await t.editIntent('a')
vi.spyOn(t.fs, 'moveFile').mockImplementation(async (_source, destination) => {
await writeFile(destination.targetKey, 'published\n')
throw new FsError('move reply lost', 'FS_IO_ERROR')
})
expect(await t.run(patch(move('a', 'b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'unknown', operation: t.record('move', 'a', 0, 2, 'b'), failure: { code: 'FS_IO_ERROR', message: 'move reply lost — re-read the affected files before retrying' } },
{ status: 'skipped', operation: t.record('add', 'c', 1, 7) },
], affectedPaths: [], cancelled: false })
expect(await t.bytes('a')).toBe('old\n')
expect(await t.bytes('b')).toBe('published\n')
expect(t.observations).toEqual([])
expect(await t.editIntent('a')).toEqual(observed)
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('retains preceding changes when an external edit invalidates the next commit guard', async () => {
const t = await setup()
await t.file('b', 'old\n')
const observed = await t.editIntent('b')
const write = t.fs.writeText.bind(t.fs)
vi.spyOn(t.fs, 'writeText').mockImplementation(async (...args) => {
const result = await write(...args)
await writeFile(resolve(t.workspace, 'b'), 'external edit\n')
return result
})
const a = t.record('add', 'a')
const b = t.record('update', 'b', 1, 4)
expect(await t.run(patch(add('a'), update('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
{ status: 'failed', operation: b, failure: { code: 'FS_STALE_VERSION', message: expect.stringContaining('re-read the file, then retry') as string }, changes: [] },
{ status: 'skipped', operation: t.record('add', 'c', 2, 8) },
], affectedPaths: [a.path], cancelled: false })
expect(await t.bytes('a')).toBe('new\n')
expect(await t.bytes('b')).toBe('external edit\n')
await expect(t.bytes('c')).rejects.toMatchObject({ code: 'ENOENT' })
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
expect(await t.editIntent('b')).toEqual(observed)
})
it('propagates programmer defects from mutation and preserves observed state', async () => {
const t = await setup()
await t.file('a', 'old\n')
const defect = new Error('mutation defect')
vi.spyOn(t.fs, 'patchText').mockRejectedValue(defect)
await expect(t.run(patch(update('a')))).rejects.toBe(defect)
expect(await t.bytes('a')).toBe('old\n')
expect(t.observations).toEqual([])
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
})
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')
})