193 lines
11 KiB
TypeScript
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')
|
|
})
|