mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Autosave relies on the guarded save instead of pause flags; identical saves don't rewrite; non-UTF-8 files aren't autosaved (#31768)
* Autosave relies on the guarded write instead of pausing restored or moved tabs Delete the pendingDiskBaselineVerification and pendingLiveDiskVerification pause flags. Every editor save is already a guarded write; a refusal raises the changed-on-disk banner, which is what stops autosave until an explicit save. A guarded write now succeeds when disk changed but already holds exactly the text being saved (local/host compare is byte-exact; client-side fallbacks compare decoded text and refuse when it holds U+FFFD), so a restored draft that matches disk goes clean instead of showing a banner. The restore scan only raises the banner early for tabs that arrive dirty; a watch resync no longer re-checks dirty tabs. Co-Authored-By: Claude <noreply@anthropic.com> * fix(editor): skip guarded saves when disk is already identical Return a compatible optional no-op result from hosts and client fallbacks, clean the tab, and clear its provisional self-write stamp without rewriting identical bytes. Co-Authored-By: Claude <noreply@anthropic.com> * fix(editor): refuse lossy automatic saves, keep identical-skip on older hosts, flag dirty tabs on resync - Autosave and pre-restart saves refuse when disk text can't be decoded losslessly, so a baseline that decoded to U+FFFD can't authorize overwriting different bytes. - New files.write.identical-noop.v1 capability; for hosts with only the v1 guarded write the client compares first, skips identical saves, then delegates to the host's guarded write. - Watch resync compares dirty tabs against disk and shows the changed-on-disk banner. - Local guarded writes check for identical content before the write-only regular-file probe. Co-Authored-By: Claude <noreply@anthropic.com> * fix(editor): tell non-UTF-8 files apart from disk changes, and quiet false resync banners - Local guarded writes judge losslessness by bytes, so a valid UTF-8 file containing U+FFFD autosaves again. A file that really isn't UTF-8 is refused with its own reason, 'not-utf8' (older clients read it as 'changed'), which pauses autosave for that file with one notice until an explicit save converts it. - A watch resync whose disk read fails leaves dirty tabs alone; the guarded save still catches a real conflict. - Resync and restored-tab checks treat disk holding the tab's own just-written save as an echo. - On hosts with only the v1 guarded write, only automatic saves read the file first; explicit saves go straight to the host's guard. Co-Authored-By: Claude <noreply@anthropic.com> * fix(editor): lift the non-UTF-8 autosave pause when its edits end, and report disk changes first - The pause is released once no dirty tab of the file remains (close, reload from disk, discard), so a reopened or reloaded file's next autosave asks the host again and notifies. - Client-side precondition checks compare the signature before looking for U+FFFD, so an external rewrite shows the changed-on-disk banner rather than the non-UTF-8 notice. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,6 @@ import { isENOENT } from '../filesystem-path-containment'
|
||||
import { registerFilesystemMutationHandlers } from '../filesystem-mutations'
|
||||
import { registerAgentSessionAttachmentUploadHandlers } from '../agent-session-attachment-upload-ipc'
|
||||
import type { FilesystemHandlerContext } from './filesystem-handler-context'
|
||||
import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read'
|
||||
import type {
|
||||
FileWriteDiskPrecondition,
|
||||
FileWriteResult
|
||||
@@ -55,7 +54,6 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await assertLocalWriteTargetIsRegularFile(filePath)
|
||||
return writeLocalTextFileWithDiskPrecondition(filePath, args.content, args.diskPrecondition)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature'
|
||||
import { NOT_A_REGULAR_FILE_MESSAGE } from './local-regular-file-read'
|
||||
import { registerFilesystemWriteHandlers } from './filesystem-write-handlers'
|
||||
import type { FilesystemHandlerContext } from './filesystem-handler-context'
|
||||
|
||||
const handlers = vi.hoisted(() => new Map<string, (_event: unknown, args: unknown) => unknown>())
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (name: string, handler: (_event: unknown, args: unknown) => unknown) =>
|
||||
handlers.set(name, handler)
|
||||
},
|
||||
shell: {}
|
||||
}))
|
||||
vi.mock('../local-file-access-resolution', () => ({
|
||||
resolveLocalWriteRequestPath: async (path: string) => path
|
||||
}))
|
||||
vi.mock('../filesystem-mutations', () => ({ registerFilesystemMutationHandlers: vi.fn() }))
|
||||
vi.mock('../agent-session-attachment-upload-ipc', () => ({
|
||||
registerAgentSessionAttachmentUploadHandlers: vi.fn()
|
||||
}))
|
||||
|
||||
let root = ''
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-identical-ipc-'))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization is stubbed, and no other context member is used by this handler.
|
||||
registerFilesystemWriteHandlers({ store: {} } as FilesystemHandlerContext)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
handlers.clear()
|
||||
})
|
||||
|
||||
it('accepts identical content on a readable, non-writable file through IPC', async () => {
|
||||
const filePath = join(root, 'read-only.txt')
|
||||
await writeFile(filePath, 'edited')
|
||||
await chmod(filePath, 0o444)
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('fs:writeFile')!(null, {
|
||||
filePath,
|
||||
content: 'edited',
|
||||
diskPrecondition: { expectedSignature: getDiskBaselineSignature('original') }
|
||||
})
|
||||
).resolves.toEqual({ ok: true, unchanged: true })
|
||||
expect(await readFile(filePath, 'utf8')).toBe('edited')
|
||||
} finally {
|
||||
await chmod(filePath, 0o644)
|
||||
}
|
||||
})
|
||||
|
||||
it
|
||||
.skipIf(process.platform === 'win32')
|
||||
.each([undefined, { expectedSignature: getDiskBaselineSignature('') }])(
|
||||
'still refuses non-regular files through IPC (%j)',
|
||||
async (diskPrecondition) => {
|
||||
await expect(
|
||||
handlers.get('fs:writeFile')!(null, { filePath: '/dev/null', content: '', diskPrecondition })
|
||||
).rejects.toThrow(NOT_A_REGULAR_FILE_MESSAGE)
|
||||
}
|
||||
)
|
||||
@@ -1,4 +1,14 @@
|
||||
import { link, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import {
|
||||
chmod,
|
||||
link,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -7,7 +17,8 @@ import type { RuntimeStore } from '../../runtime/runtime-store-contract'
|
||||
import { createLocalFilesystemProvider } from '../../providers/local-filesystem-provider'
|
||||
import {
|
||||
checkLocalDiskWritePrecondition,
|
||||
diskSignatureOfUtf8
|
||||
diskSignatureOfUtf8,
|
||||
writeLocalTextFileWithDiskPrecondition
|
||||
} from './local-disk-write-precondition'
|
||||
import type * as LocalRegularFileRead from './local-regular-file-read'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
@@ -51,14 +62,14 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
return zeroInodes.enabled ? Object.assign(stats, { ino: 0n }) : stats
|
||||
},
|
||||
// Why: holds one save between its passed check and its write, where a competing save must wait.
|
||||
writeFile: async (...args: Parameters<typeof actual.writeFile>) => {
|
||||
writeFile: vi.fn(async (...args: Parameters<typeof actual.writeFile>) => {
|
||||
const gate = typeof args[1] === 'string' ? writeGates.get(args[1]) : undefined
|
||||
if (gate) {
|
||||
gate.entered()
|
||||
await gate.release
|
||||
}
|
||||
return actual.writeFile(...args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
vi.mock('./local-regular-file-read', async (importOriginal) => {
|
||||
@@ -104,20 +115,80 @@ describe('checkLocalDiskWritePrecondition', () => {
|
||||
await writeFile(filePath, content)
|
||||
|
||||
await expect(
|
||||
checkLocalDiskWritePrecondition(filePath, {
|
||||
expectedSignature: getDiskBaselineSignature(content)
|
||||
})
|
||||
checkLocalDiskWritePrecondition(
|
||||
filePath,
|
||||
{
|
||||
expectedSignature: getDiskBaselineSignature(content)
|
||||
},
|
||||
'my save'
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it.each([2, 1024 * 1024 + 2])(
|
||||
'refuses lossy automatic baseline matches (%i bytes)',
|
||||
async (size) => {
|
||||
const filePath = join(base, 'invalid.ts')
|
||||
const old = Buffer.alloc(size, 0x61)
|
||||
old[size - 1] = 0xfe
|
||||
const newer = Buffer.from(old)
|
||||
newer[size - 1] = 0xff
|
||||
await writeFile(filePath, newer)
|
||||
const decoded = old.toString('utf8')
|
||||
const expectedSignature = getDiskBaselineSignature(decoded)
|
||||
await expect(
|
||||
writeLocalTextFileWithDiskPrecondition(filePath, decoded, {
|
||||
expectedSignature,
|
||||
requireLosslessUtf8: true
|
||||
})
|
||||
).resolves.toEqual({ ok: false, diskConflict: 'not-utf8' })
|
||||
expect(await readFile(filePath)).toEqual(newer)
|
||||
await expect(
|
||||
writeLocalTextFileWithDiskPrecondition(filePath, decoded, { expectedSignature })
|
||||
).resolves.toEqual({ ok: true })
|
||||
expect(await readFile(filePath)).toEqual(Buffer.from(decoded))
|
||||
}
|
||||
)
|
||||
|
||||
it.each([0, 1024 * 1024 + 2])(
|
||||
'lets an automatic save edit valid UTF-8 that contains U+FFFD (%i padding)',
|
||||
async (padding) => {
|
||||
const filePath = join(base, 'replacement-char.ts')
|
||||
const original = `${'a'.repeat(padding)}\uFFFD`
|
||||
await writeFile(filePath, original)
|
||||
await expect(
|
||||
writeLocalTextFileWithDiskPrecondition(filePath, `${original}!`, {
|
||||
expectedSignature: getDiskBaselineSignature(original),
|
||||
requireLosslessUtf8: true
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
expect(await readFile(filePath, 'utf8')).toBe(`${original}!`)
|
||||
}
|
||||
)
|
||||
|
||||
it('reports an automatic save over a changed non-UTF-8 file as changed', async () => {
|
||||
const filePath = join(base, 'latin1.ts')
|
||||
await writeFile(filePath, Buffer.from([0x63, 0x61, 0x66, 0xe9]))
|
||||
await expect(
|
||||
writeLocalTextFileWithDiskPrecondition(filePath, 'x', {
|
||||
expectedSignature: getDiskBaselineSignature('other'),
|
||||
requireLosslessUtf8: true
|
||||
})
|
||||
).resolves.toEqual({ ok: false, diskConflict: 'changed' })
|
||||
})
|
||||
|
||||
it('reports a same-size external rewrite as changed', async () => {
|
||||
const filePath = join(base, 'a.ts')
|
||||
await writeFile(filePath, 'agent')
|
||||
|
||||
await expect(
|
||||
checkLocalDiskWritePrecondition(filePath, {
|
||||
expectedSignature: getDiskBaselineSignature('human')
|
||||
})
|
||||
checkLocalDiskWritePrecondition(
|
||||
filePath,
|
||||
{
|
||||
expectedSignature: getDiskBaselineSignature('human')
|
||||
},
|
||||
'my save'
|
||||
)
|
||||
).resolves.toBe('changed')
|
||||
})
|
||||
|
||||
@@ -126,9 +197,13 @@ describe('checkLocalDiskWritePrecondition', () => {
|
||||
await writeFile(filePath, 'x'.repeat(100))
|
||||
|
||||
await expect(
|
||||
checkLocalDiskWritePrecondition(filePath, {
|
||||
expectedSignature: getDiskBaselineSignature('short')
|
||||
})
|
||||
checkLocalDiskWritePrecondition(
|
||||
filePath,
|
||||
{
|
||||
expectedSignature: getDiskBaselineSignature('short')
|
||||
},
|
||||
'my save'
|
||||
)
|
||||
).resolves.toBe('changed')
|
||||
})
|
||||
|
||||
@@ -138,9 +213,13 @@ describe('checkLocalDiskWritePrecondition', () => {
|
||||
fileRead.understateSizeBy = 10
|
||||
|
||||
await expect(
|
||||
checkLocalDiskWritePrecondition(filePath, {
|
||||
expectedSignature: getDiskBaselineSignature('ab')
|
||||
})
|
||||
checkLocalDiskWritePrecondition(
|
||||
filePath,
|
||||
{
|
||||
expectedSignature: getDiskBaselineSignature('ab')
|
||||
},
|
||||
'my save'
|
||||
)
|
||||
).resolves.toBe('changed')
|
||||
})
|
||||
|
||||
@@ -148,15 +227,57 @@ describe('checkLocalDiskWritePrecondition', () => {
|
||||
const filePath = join(base, 'gone.ts')
|
||||
const expectedSignature = getDiskBaselineSignature('was here')
|
||||
|
||||
await expect(checkLocalDiskWritePrecondition(filePath, { expectedSignature })).resolves.toBe(
|
||||
'missing'
|
||||
)
|
||||
await expect(
|
||||
checkLocalDiskWritePrecondition(filePath, { expectedSignature, allowMissing: true })
|
||||
checkLocalDiskWritePrecondition(filePath, { expectedSignature }, 'my save')
|
||||
).resolves.toBe('missing')
|
||||
await expect(
|
||||
checkLocalDiskWritePrecondition(
|
||||
filePath,
|
||||
{ expectedSignature, allowMissing: true },
|
||||
'my save'
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a changed disk that already holds the save', () => {
|
||||
const baseline = { expectedSignature: getDiskBaselineSignature('original') }
|
||||
|
||||
it.each([
|
||||
['the same length as the baseline', 'origin2'],
|
||||
['a length the baseline cannot decode to', 'x'.repeat(100)],
|
||||
['multi-byte text', 'café ☕ 𝄞\r\n']
|
||||
])('passes when disk holds exactly the bytes being saved (%s)', async (_label, content) => {
|
||||
const filePath = join(base, 'a.ts')
|
||||
await writeFile(filePath, content)
|
||||
|
||||
await expect(checkLocalDiskWritePrecondition(filePath, baseline, content)).resolves.toBe(
|
||||
'identical'
|
||||
)
|
||||
})
|
||||
|
||||
it('compares bytes, so a BOM or a line-ending difference still refuses', async () => {
|
||||
const filePath = join(base, 'a.ts')
|
||||
await writeFile(filePath, '\uFEFFmine\n')
|
||||
await expect(checkLocalDiskWritePrecondition(filePath, baseline, 'mine\n')).resolves.toBe(
|
||||
'changed'
|
||||
)
|
||||
await writeFile(filePath, 'mine\r\n')
|
||||
await expect(checkLocalDiskWritePrecondition(filePath, baseline, 'mine\n')).resolves.toBe(
|
||||
'changed'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats invalid UTF-8 on disk as different from its decoded text', async () => {
|
||||
const filePath = join(base, 'a.ts')
|
||||
await writeFile(filePath, Buffer.from([0x61, 0xff]))
|
||||
|
||||
await expect(checkLocalDiskWritePrecondition(filePath, baseline, 'a\uFFFD')).resolves.toBe(
|
||||
'changed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sliced disk hashing', () => {
|
||||
it('matches the whole-file signature across slice boundaries and invalid bytes', async () => {
|
||||
const slice = 1024 * 1024
|
||||
@@ -196,6 +317,45 @@ describe('local provider guarded write', () => {
|
||||
})
|
||||
).resolves.toEqual({ ok: false, diskConflict: 'changed' })
|
||||
expect(await readFile(filePath, 'utf8')).toBe('edited')
|
||||
|
||||
await expect(
|
||||
provider().writeFileWithDiskPrecondition(filePath, 'edited', {
|
||||
expectedSignature: getDiskBaselineSignature('original')
|
||||
})
|
||||
).resolves.toEqual({ ok: true, unchanged: true })
|
||||
})
|
||||
|
||||
it.each(['original', 'edited'])(
|
||||
'does not write identical bytes against baseline %s',
|
||||
async (baseline) => {
|
||||
const filePath = join(base, 'identical.ts')
|
||||
await writeFile(filePath, 'edited')
|
||||
vi.mocked(writeFile).mockClear()
|
||||
|
||||
await expect(
|
||||
provider().writeFileWithDiskPrecondition(filePath, 'edited', {
|
||||
expectedSignature: getDiskBaselineSignature(baseline)
|
||||
})
|
||||
).resolves.toEqual({ ok: true, unchanged: true })
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('accepts identical content on a read-only file without opening it for write', async () => {
|
||||
const filePath = join(base, 'read-only.ts')
|
||||
await writeFile(filePath, 'edited')
|
||||
await chmod(filePath, 0o444)
|
||||
vi.mocked(writeFile).mockClear()
|
||||
try {
|
||||
await expect(
|
||||
provider().writeFileWithDiskPrecondition(filePath, 'edited', {
|
||||
expectedSignature: getDiskBaselineSignature('original')
|
||||
})
|
||||
).resolves.toEqual({ ok: true, unchanged: true })
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await chmod(filePath, 0o644)
|
||||
}
|
||||
})
|
||||
|
||||
it('lets only one of two concurrent saves from the same baseline write', async () => {
|
||||
@@ -441,10 +601,14 @@ describe('local provider guarded write', () => {
|
||||
)
|
||||
|
||||
await expect(
|
||||
checkLocalDiskWritePrecondition(filePath, {
|
||||
expectedSignature: getDiskBaselineSignature('original'),
|
||||
allowMissing: true
|
||||
})
|
||||
checkLocalDiskWritePrecondition(
|
||||
filePath,
|
||||
{
|
||||
expectedSignature: getDiskBaselineSignature('original'),
|
||||
allowMissing: true
|
||||
},
|
||||
'my save'
|
||||
)
|
||||
).rejects.toThrow('EACCES')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isUtf8 } from 'node:buffer'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import {
|
||||
DiskBaselineSignatureBuilder,
|
||||
@@ -8,7 +9,8 @@ import {
|
||||
import {
|
||||
compareDiskWritePrecondition,
|
||||
FILE_WRITE_OK,
|
||||
type FileWriteDiskConflict,
|
||||
FILE_WRITE_UNCHANGED,
|
||||
type FileWriteDiskComparison,
|
||||
type FileWriteDiskPrecondition,
|
||||
type FileWriteResult
|
||||
} from '../../../shared/file-write-disk-precondition'
|
||||
@@ -17,6 +19,7 @@ import { basename, dirname, join, normalize, resolve } from 'node:path'
|
||||
import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue'
|
||||
import { serializedParentDirectoryKey } from '../../destination-serialized-local-rename'
|
||||
import {
|
||||
assertLocalWriteTargetIsRegularFile,
|
||||
FileTooLargeError,
|
||||
openLocalRegularFile,
|
||||
readLocalFileBounded
|
||||
@@ -41,36 +44,51 @@ export async function diskSignatureOfUtf8(buffer: Buffer): Promise<string> {
|
||||
/** Reads the file the writer is about to replace and reports why it no longer matches the baseline. */
|
||||
export async function checkLocalDiskWritePrecondition(
|
||||
filePath: string,
|
||||
precondition: FileWriteDiskPrecondition
|
||||
): Promise<FileWriteDiskConflict | null> {
|
||||
precondition: FileWriteDiskPrecondition,
|
||||
content: string
|
||||
): Promise<FileWriteDiskComparison> {
|
||||
let opened: Awaited<ReturnType<typeof openLocalRegularFile>>
|
||||
try {
|
||||
opened = await openLocalRegularFile(filePath)
|
||||
} catch (error) {
|
||||
// Why the code alone: the message quotes the path, which can spell any error.
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
return compareDiskWritePrecondition(precondition, null)
|
||||
return compareDiskWritePrecondition(precondition, null, content)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { handle, stats } = opened
|
||||
try {
|
||||
const written = Buffer.from(content, 'utf8')
|
||||
const expectedLength = diskBaselineSignatureLength(precondition.expectedSignature)
|
||||
// Why: the signature pins the decoded length, so a size it can't decode to skips reading a huge file.
|
||||
if (expectedLength === null || !utf8ByteSizeCanDecodeToLength(stats.size, expectedLength)) {
|
||||
// Why: the signature pins the decoded length, so a size neither it nor the save can match skips reading a huge file.
|
||||
const baselineFits =
|
||||
expectedLength !== null && utf8ByteSizeCanDecodeToLength(stats.size, expectedLength)
|
||||
if (!baselineFits && stats.size !== written.length) {
|
||||
return 'changed'
|
||||
}
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = await readLocalFileBounded(handle, expectedLength * 3, stats.size)
|
||||
buffer = await readLocalFileBounded(
|
||||
handle,
|
||||
Math.max(baselineFits ? expectedLength * 3 : 0, written.length),
|
||||
stats.size
|
||||
)
|
||||
} catch (error) {
|
||||
// Why: grown past what the baseline could decode from since the size check, so it changed.
|
||||
// Why: grown past what the baseline or the save could match since the size check, so it changed.
|
||||
if (error instanceof FileTooLargeError) {
|
||||
return 'changed'
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return (await diskSignatureOfUtf8(buffer)) === precondition.expectedSignature ? null : 'changed'
|
||||
if (buffer.equals(written)) {
|
||||
return 'identical'
|
||||
}
|
||||
if (!baselineFits || (await diskSignatureOfUtf8(buffer)) !== precondition.expectedSignature) {
|
||||
return 'changed'
|
||||
}
|
||||
// Why bytes: a decoded U+FFFD may be a real character or replaced bytes; only the bytes tell.
|
||||
return precondition.requireLosslessUtf8 && !isUtf8(buffer) ? 'not-utf8' : null
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
@@ -147,14 +165,19 @@ export async function writeLocalTextFileWithDiskPrecondition(
|
||||
): Promise<FileWriteResult> {
|
||||
// Why: an unguarded write has nothing to compare, so it skips the locks a stuck guarded save holds.
|
||||
if (!precondition) {
|
||||
await assertLocalWriteTargetIsRegularFile(filePath)
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
return FILE_WRITE_OK
|
||||
}
|
||||
const compareAndWrite = async (): Promise<FileWriteResult> => {
|
||||
const diskConflict = await checkLocalDiskWritePrecondition(filePath, precondition)
|
||||
const diskConflict = await checkLocalDiskWritePrecondition(filePath, precondition, content)
|
||||
if (diskConflict === 'identical') {
|
||||
return FILE_WRITE_UNCHANGED
|
||||
}
|
||||
if (diskConflict) {
|
||||
return { ok: false, diskConflict }
|
||||
}
|
||||
await assertLocalWriteTargetIsRegularFile(filePath)
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
return FILE_WRITE_OK
|
||||
}
|
||||
|
||||
@@ -58,7 +58,11 @@ function makeHost(disk: string, draft?: string, saved: Partial<PersistedOpenFile
|
||||
content: string,
|
||||
precondition: { expectedSignature: string }
|
||||
) => {
|
||||
if (compareDiskWritePrecondition(precondition, state.disk)) {
|
||||
const comparison = compareDiskWritePrecondition(precondition, state.disk, content)
|
||||
if (comparison === 'identical') {
|
||||
return { ok: true as const, unchanged: true }
|
||||
}
|
||||
if (comparison) {
|
||||
return { ok: false as const, diskConflict: 'changed' as const }
|
||||
}
|
||||
state.disk = content
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
diskConflictFromReadFailure,
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IF_UNCHANGED_METHOD,
|
||||
hostEnforcesPrecondition,
|
||||
FILE_WRITE_UNCHANGED,
|
||||
parseFileWriteResult,
|
||||
type FileWriteDiskConflict,
|
||||
type FileWriteDiskComparison,
|
||||
type FileWriteDiskPrecondition,
|
||||
type FileWriteResult
|
||||
} from '../../shared/file-write-disk-precondition'
|
||||
@@ -223,35 +225,42 @@ async function writeFileOnEnvironment(
|
||||
// Why `local`: the server writes only a workspace it runs itself, so the write never hops on.
|
||||
expectedExecutionHostId: 'local'
|
||||
}
|
||||
if (capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY)) {
|
||||
if (hostEnforcesPrecondition(capabilities, diskPrecondition)) {
|
||||
return parseFileWriteResult(
|
||||
await pinned(FILE_WRITE_IF_UNCHANGED_METHOD, { ...params, diskPrecondition })
|
||||
)
|
||||
}
|
||||
const diskConflict = await diskConflictOnServer(pinned, params, diskPrecondition)
|
||||
if (diskConflict === 'identical') {
|
||||
return FILE_WRITE_UNCHANGED
|
||||
}
|
||||
if (diskConflict) {
|
||||
return { ok: false, diskConflict }
|
||||
}
|
||||
return parseFileWriteResult(await pinned('files.write', params))
|
||||
return parseFileWriteResult(
|
||||
capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY)
|
||||
? await pinned(FILE_WRITE_IF_UNCHANGED_METHOD, { ...params, diskPrecondition })
|
||||
: await pinned('files.write', params)
|
||||
)
|
||||
}
|
||||
|
||||
/** For a server that would write unguarded: a read that cannot prove the disk fails the save. */
|
||||
async function diskConflictOnServer(
|
||||
pinned: (method: string, params: unknown) => Promise<unknown>,
|
||||
address: { worktree: string; relativePath: string },
|
||||
write: { worktree: string; relativePath: string; content: string },
|
||||
precondition: FileWriteDiskPrecondition
|
||||
): Promise<FileWriteDiskConflict | null> {
|
||||
): Promise<FileWriteDiskComparison> {
|
||||
let read: unknown
|
||||
try {
|
||||
read = await pinned('files.read', {
|
||||
worktree: address.worktree,
|
||||
relativePath: address.relativePath
|
||||
worktree: write.worktree,
|
||||
relativePath: write.relativePath
|
||||
})
|
||||
} catch (error) {
|
||||
return diskConflictFromReadFailure(precondition, error)
|
||||
}
|
||||
if (!isRecord(read) || typeof read.content !== 'string' || read.truncated === true) {
|
||||
return compareDiskWritePrecondition(precondition, undefined)
|
||||
return compareDiskWritePrecondition(precondition, undefined, write.content)
|
||||
}
|
||||
return compareDiskWritePrecondition(precondition, read.content)
|
||||
return compareDiskWritePrecondition(precondition, read.content, write.content)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-err
|
||||
import { FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import {
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IF_UNCHANGED_METHOD
|
||||
} from '../../shared/file-write-disk-precondition'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
@@ -96,7 +97,8 @@ async function runtimeOnHost(
|
||||
|
||||
const CURRENT_SERVER = [
|
||||
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY
|
||||
]
|
||||
/** A server from before #30605: it advertises no disk check and serves no `files.writeIfUnchanged`. */
|
||||
const PRE_DISK_CHECK_SERVER = [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY]
|
||||
@@ -271,6 +273,42 @@ describe("a phone's Markdown tab on a paired server's workspace", () => {
|
||||
expect(await readFile(readme, 'utf8')).toBe('# Changed on the server\n')
|
||||
})
|
||||
|
||||
it('saves on a server too old to check the disk when its changed disk already holds the text', async () => {
|
||||
const { desktop, call, serverCalls } = await desktopOnServer('local', PRE_DISK_CHECK_SERVER)
|
||||
const forward = call.getMockImplementation()!
|
||||
call.mockImplementation(async (environmentId, method, params, ...rest) => {
|
||||
if (method === 'status.get') {
|
||||
await writeFile(readme, '# Phone\n')
|
||||
}
|
||||
return forward(environmentId, method, params, ...rest)
|
||||
})
|
||||
|
||||
await expect(
|
||||
desktop.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
readme,
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Phone\n'
|
||||
)
|
||||
).resolves.toMatchObject({ content: '# Phone\n', isDirty: false })
|
||||
expect(serverCalls).toEqual(['files.read', 'status.get', 'files.read'])
|
||||
})
|
||||
|
||||
it('sends a phone save straight to a v1 server guard, without reading first', async () => {
|
||||
const { desktop, serverCalls } = await desktopOnServer('local', [
|
||||
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY
|
||||
])
|
||||
await desktop.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
readme,
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Phone\n'
|
||||
)
|
||||
expect(serverCalls).toEqual(['files.read', 'status.get', 'files.writeIfUnchanged'])
|
||||
expect(await readFile(readme, 'utf8')).toBe('# Phone\n')
|
||||
})
|
||||
|
||||
it('refuses a save whose server was re-paired after its first read, writing nothing', async () => {
|
||||
const { desktop, call, serverCalls, pairing } = await desktopOnServer()
|
||||
const forward = call.getMockImplementation()!
|
||||
|
||||
@@ -17,7 +17,11 @@ describe('files.writeIfUnchanged', () => {
|
||||
writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: false, diskConflict: 'changed' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
const diskPrecondition = { expectedSignature: 'a-b-1', allowMissing: true }
|
||||
const diskPrecondition = {
|
||||
expectedSignature: 'a-b-1',
|
||||
allowMissing: true,
|
||||
requireLosslessUtf8: true
|
||||
}
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.writeIfUnchanged', {
|
||||
|
||||
@@ -8,9 +8,12 @@ import { attachEditorAutosaveController } from './editor-autosave-controller'
|
||||
import { __clearSelfWriteRegistryForTests, recordSelfWrite } from './editor-self-write-registry'
|
||||
import {
|
||||
createEditorStore,
|
||||
createFakeEditorDisk,
|
||||
stubEditorWindow,
|
||||
stubEditorWindowWithDisk,
|
||||
withLoadedBaselines
|
||||
} from './editor-autosave-controller-test-fixture'
|
||||
import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getConnectionIdForFile: vi.fn()
|
||||
@@ -144,33 +147,48 @@ describe('editor autosave changed-on-disk conflict flow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('suspends autosave while a restored tab awaits disk baseline verification', async () => {
|
||||
const writeFile = stubEditorWindow()
|
||||
const store = createEditorStore()
|
||||
openDirtyFile(store, 'restored draft')
|
||||
// Why: mimic hydration — the scan has not yet compared disk against the
|
||||
// persisted baseline, so autosave must hold off (a slow remote read must
|
||||
// not lose a race to this timer).
|
||||
store.getState().setPendingDiskBaselineVerification('/repo/file.ts', true)
|
||||
|
||||
const cleanup = attachEditorAutosaveController(withLoadedBaselines(store))
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
|
||||
store.getState().clearPendingDiskBaselineVerification('/repo/file.ts')
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/repo/file.ts',
|
||||
content: 'restored draft',
|
||||
connectionId: undefined,
|
||||
expectedExecutionHostId: 'local'
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
cleanup()
|
||||
describe('a restored draft autosaves through the guarded write alone', () => {
|
||||
// Why: hydration restores the draft with the persisted baseline; nothing pauses its autosave.
|
||||
function restore(disk: string) {
|
||||
const fakeDisk = stubEditorWindowWithDisk(createFakeEditorDisk({ '/repo/file.ts': disk }))
|
||||
const store = createEditorStore()
|
||||
openDirtyFile(store, 'restored draft')
|
||||
store.getState().setLastKnownDiskSignature('/repo/file.ts', getDiskBaselineSignature('base'))
|
||||
return { fakeDisk, store, cleanup: attachEditorAutosaveController(store) }
|
||||
}
|
||||
|
||||
it.each([
|
||||
['disk still holds the baseline', 'base'],
|
||||
['disk already holds the draft', 'restored draft']
|
||||
])('lands and goes clean without a banner when %s', async (_label, disk) => {
|
||||
const { fakeDisk, store, cleanup } = restore(disk)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(fakeDisk.files.get('/repo/file.ts')).toBe('restored draft')
|
||||
expect(store.getState().openFiles[0]).toMatchObject({ isDirty: false })
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('is refused with the banner, keeps the draft and stops autosaving when disk changed', async () => {
|
||||
const { fakeDisk, store, cleanup } = restore('agent write')
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(fakeDisk.files.get('/repo/file.ts')).toBe('agent write')
|
||||
expect(store.getState().openFiles[0]).toMatchObject({
|
||||
isDirty: true,
|
||||
externalMutation: 'changed'
|
||||
})
|
||||
expect(store.getState().editorDrafts['/repo/file.ts']).toBe('restored draft')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(fakeDisk.fs.writeFile).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it("clears the changed-on-disk mark after the banner's Overwrite", async () => {
|
||||
|
||||
@@ -39,8 +39,11 @@ export function createFakeEditorDisk(initialFiles: Record<string, string> = {}):
|
||||
diskPrecondition?: FileWriteDiskPrecondition
|
||||
}) => {
|
||||
const diskConflict = diskPrecondition
|
||||
? compareDiskWritePrecondition(diskPrecondition, files.get(filePath) ?? null)
|
||||
? compareDiskWritePrecondition(diskPrecondition, files.get(filePath) ?? null, content)
|
||||
: null
|
||||
if (diskConflict === 'identical') {
|
||||
return { ok: true, unchanged: true }
|
||||
}
|
||||
if (diskConflict) {
|
||||
return { ok: false, diskConflict }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MIN_EDITOR_AUTO_SAVE_DELAY_MS
|
||||
} from '../../../../shared/constants'
|
||||
import { clampNumber } from '@/lib/terminal-theme'
|
||||
import { isAutosavePausedForNonUtf8File } from './editor-non-utf8-autosave-pause'
|
||||
|
||||
export const ORCA_EDITOR_QUIESCE_FILE_SAVES_EVENT = 'orca:editor-quiesce-file-saves'
|
||||
export const ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT = 'orca:editor-external-file-change'
|
||||
@@ -106,24 +107,19 @@ export function canAutoSaveOpenFile(file: OpenFile): boolean {
|
||||
return file.mode === 'edit' || (file.mode === 'diff' && file.diffSource === 'unstaged')
|
||||
}
|
||||
|
||||
// Why: autosave must not resolve a changed-on-disk conflict by overwriting
|
||||
// the newer external content, nor write over a restored tab whose disk
|
||||
// baseline is still unverified (the conflict may simply not be marked YET).
|
||||
// Why: autosave must not resolve a changed-on-disk conflict by overwriting the newer external
|
||||
// content. An unmarked conflict needs no pause: every save is guarded, and a refusal sets the mark.
|
||||
// One predicate so the save-queue gate and the timer scheduler cannot drift.
|
||||
export function isAutosaveSuspendedForFile(
|
||||
file: Pick<
|
||||
OpenFile,
|
||||
| 'externalMutation'
|
||||
| 'pendingDiskBaselineVerification'
|
||||
| 'pendingLiveDiskVerification'
|
||||
| 'pendingOwnerMigration'
|
||||
'externalMutation' | 'pendingOwnerMigration' | 'filePath' | 'runtimeEnvironmentId'
|
||||
>
|
||||
): boolean {
|
||||
return (
|
||||
file.externalMutation === 'changed' ||
|
||||
file.pendingDiskBaselineVerification === true ||
|
||||
file.pendingLiveDiskVerification === true ||
|
||||
file.pendingOwnerMigration === true
|
||||
file.pendingOwnerMigration === true ||
|
||||
isAutosavePausedForNonUtf8File(file)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { basename } from '@/lib/path'
|
||||
import { normalizeAbsolutePathForComparison } from '@/components/right-sidebar/file-explorer-paths'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
|
||||
type PausedFile = Pick<OpenFile, 'filePath' | 'runtimeEnvironmentId'>
|
||||
|
||||
// Why: autosave must not convert a non-UTF-8 file unasked; an explicit save consents and lifts the pause.
|
||||
const pausedFiles = new Set<string>()
|
||||
|
||||
function pauseKey(file: PausedFile): string {
|
||||
return `${file.runtimeEnvironmentId?.trim() || 'client'}::${normalizeAbsolutePathForComparison(file.filePath)}`
|
||||
}
|
||||
|
||||
export function isAutosavePausedForNonUtf8File(file: PausedFile): boolean {
|
||||
return pausedFiles.size > 0 && pausedFiles.has(pauseKey(file))
|
||||
}
|
||||
|
||||
/** Tells the user once per pause, so a refused autosave never repeats as a loop of notices. */
|
||||
export function pauseAutosaveForNonUtf8File(file: PausedFile): void {
|
||||
const key = pauseKey(file)
|
||||
if (pausedFiles.has(key)) {
|
||||
return
|
||||
}
|
||||
pausedFiles.add(key)
|
||||
toast.warning(
|
||||
translate(
|
||||
'auto.components.editor.editor.save.notUtf8.notice',
|
||||
"Autosave is paused for {{name}} because it isn't UTF-8 text. Save it yourself to convert it to UTF-8.",
|
||||
{ name: basename(file.filePath) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function resumeAutosaveForNonUtf8File(file: PausedFile): void {
|
||||
pausedFiles.delete(pauseKey(file))
|
||||
}
|
||||
|
||||
/** Why: a close, reload or discard ends the edits the pause held back, so the next edit asks the host again. */
|
||||
export function releaseNonUtf8PausesWithoutDirtyTab(
|
||||
openFiles: readonly (PausedFile & Pick<OpenFile, 'isDirty'>)[]
|
||||
): void {
|
||||
if (pausedFiles.size === 0) {
|
||||
return
|
||||
}
|
||||
const dirty = new Set(openFiles.filter((file) => file.isDirty).map(pauseKey))
|
||||
for (const key of pausedFiles) {
|
||||
if (!dirty.has(key)) {
|
||||
pausedFiles.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function __clearNonUtf8AutosavePausesForTests(): void {
|
||||
pausedFiles.clear()
|
||||
}
|
||||
+4
-93
@@ -23,9 +23,7 @@ vi.mock('@/lib/file-owner-runtime-target', () => {
|
||||
import { createTestStore } from '@/store/slices/store-test-helpers'
|
||||
import {
|
||||
attachRestoredTabConflictScan,
|
||||
getRestoredTabReverificationRequestCountForTest,
|
||||
MAX_VERIFY_ATTEMPTS,
|
||||
requestRestoredTabReverification
|
||||
MAX_VERIFY_ATTEMPTS
|
||||
} from './editor-restored-tab-conflict-scan'
|
||||
import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature'
|
||||
|
||||
@@ -59,64 +57,9 @@ afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reads once more when a new check is requested while a read is in flight', async () => {
|
||||
const replies: ((content: string) => void)[] = []
|
||||
mocks.readRuntimeFileContent.mockImplementation(
|
||||
() => new Promise((resolve) => replies.push((content) => resolve({ content, isBinary: false })))
|
||||
)
|
||||
store.getState().setPendingDiskBaselineVerification(fileId, true)
|
||||
detach = attachRestoredTabConflictScan(store)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(replies).toHaveLength(1)
|
||||
|
||||
// A second catch-up lands mid-read, after an external write.
|
||||
requestRestoredTabReverification(fileId)
|
||||
requestRestoredTabReverification(fileId)
|
||||
replies[0]('v1')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(tab().pendingDiskBaselineVerification).toBe(true)
|
||||
// Coalesced: two requests mid-read still cost one more read.
|
||||
expect(replies).toHaveLength(2)
|
||||
|
||||
replies[1]('v2 external write')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(tab().externalMutation).toBe('changed')
|
||||
expect(tab().pendingDiskBaselineVerification).toBeFalsy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ disk: 'v1', banner: undefined },
|
||||
{ disk: 'v2 external write', banner: 'changed' }
|
||||
])('waits out overlapping checks, then judges a fresh read ($disk)', async ({ disk, banner }) => {
|
||||
const replies: ((content: string) => void)[] = []
|
||||
mocks.readRuntimeFileContent.mockImplementation(
|
||||
() => new Promise((resolve) => replies.push((content) => resolve({ content, isBinary: false })))
|
||||
)
|
||||
store.getState().setPendingDiskBaselineVerification(fileId, true)
|
||||
detach = attachRestoredTabConflictScan(store)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(replies).toHaveLength(1)
|
||||
|
||||
// Every read is slower than the re-verify interval, so a newer check always lands first.
|
||||
for (let i = 0; i < MAX_VERIFY_ATTEMPTS + 5; i += 1) {
|
||||
requestRestoredTabReverification(fileId)
|
||||
replies[i]('v1')
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(replies).toHaveLength(i + 2)
|
||||
}
|
||||
expect(tab().pendingDiskBaselineVerification).toBe(true)
|
||||
expect(tab().externalMutation).toBeUndefined()
|
||||
|
||||
replies.at(-1)!(disk)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(tab().pendingDiskBaselineVerification).toBeFalsy()
|
||||
expect(tab().externalMutation).toBe(banner)
|
||||
})
|
||||
|
||||
it('marks a dirty runtime-owned tab deleted when its host says the file is gone', async () => {
|
||||
mocks.readRuntimeFileContent.mockRejectedValue(new Error('read failed'))
|
||||
mocks.runtimePathsExist.mockResolvedValue([{ exists: false }])
|
||||
store.getState().setPendingDiskBaselineVerification(fileId, true)
|
||||
detach = attachRestoredTabConflictScan(store)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -127,47 +70,15 @@ it('marks a dirty runtime-owned tab deleted when its host says the file is gone'
|
||||
{ codedAbsenceOnly: true }
|
||||
)
|
||||
expect(tab().externalMutation).toBe('deleted')
|
||||
expect(tab().pendingDiskBaselineVerification).toBeFalsy()
|
||||
})
|
||||
|
||||
it('stops retrying and shows the conflict banner when the disk can never be checked', async () => {
|
||||
it('stops retrying without a banner when the disk can never be checked', async () => {
|
||||
mocks.readRuntimeFileContent.mockRejectedValue(new Error('host unreachable'))
|
||||
mocks.runtimePathsExist.mockResolvedValue([{ error: 'unverifiable' }])
|
||||
store.getState().setPendingDiskBaselineVerification(fileId, true)
|
||||
detach = attachRestoredTabConflictScan(store)
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
||||
|
||||
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(MAX_VERIFY_ATTEMPTS)
|
||||
expect(tab().pendingDiskBaselineVerification).toBeFalsy()
|
||||
expect(tab().externalMutation).toBe('changed')
|
||||
})
|
||||
|
||||
it('does not let an absence answer from before a newer check resolve that check', async () => {
|
||||
let answerProbe: (rows: unknown) => void = () => {}
|
||||
mocks.readRuntimeFileContent.mockRejectedValueOnce(new Error('read failed'))
|
||||
mocks.runtimePathsExist.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (answerProbe = resolve))
|
||||
)
|
||||
store.getState().setPendingDiskBaselineVerification(fileId, true)
|
||||
detach = attachRestoredTabConflictScan(store)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// The file comes back and a new catch-up asks again while the old probe is pending.
|
||||
requestRestoredTabReverification(fileId)
|
||||
mocks.readRuntimeFileContent.mockResolvedValueOnce({ content: 'v2 recreated', isBinary: false })
|
||||
answerProbe([{ exists: false }])
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(tab().externalMutation).toBe('changed')
|
||||
expect(tab().pendingDiskBaselineVerification).toBeFalsy()
|
||||
})
|
||||
|
||||
it("forgets a tab's check requests once the tab closes", async () => {
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false })
|
||||
detach = attachRestoredTabConflictScan(store)
|
||||
requestRestoredTabReverification(fileId)
|
||||
expect(getRestoredTabReverificationRequestCountForTest()).toBeGreaterThan(0)
|
||||
|
||||
store.getState().closeFile(fileId)
|
||||
expect(getRestoredTabReverificationRequestCountForTest()).toBe(0)
|
||||
// Why no banner: the guarded save refuses newer disk on its own.
|
||||
expect(tab().externalMutation).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import type { AppState } from '@/store'
|
||||
import { attachRestoredTabConflictScan } from './editor-restored-tab-conflict-scan'
|
||||
import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature'
|
||||
import { __clearSelfWriteRegistryForTests, recordSelfWrite } from './editor-self-write-registry'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readRuntimeFileContent: vi.fn(),
|
||||
@@ -39,8 +40,6 @@ function openRestoredDirtyTab(
|
||||
store.getState().setEditorDraft(fileId, 'restored draft')
|
||||
store.getState().markFileDirty(fileId, true)
|
||||
store.getState().setLastKnownDiskSignature(fileId, getDiskBaselineSignature(baselineContent))
|
||||
// Why: hydration flags restored dirty tabs for verification; tests mimic it.
|
||||
store.getState().setPendingDiskBaselineVerification(fileId, true)
|
||||
}
|
||||
|
||||
describe('attachRestoredTabConflictScan', () => {
|
||||
@@ -94,6 +93,22 @@ describe('attachRestoredTabConflictScan', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a tab unmarked when disk holds its own just-written save', async () => {
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({ content: 'saved earlier', isBinary: false })
|
||||
const store = createTestStore()
|
||||
openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline')
|
||||
recordSelfWrite('/repo/file.ts', 'saved earlier')
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined()
|
||||
} finally {
|
||||
detach()
|
||||
__clearSelfWriteRegistryForTests()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not read files for clean tabs or tabs without a baseline', async () => {
|
||||
const store = createTestStore()
|
||||
store.getState().openFile({
|
||||
@@ -141,37 +156,24 @@ describe('attachRestoredTabConflictScan', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the pending-verification flag on both verification outcomes', async () => {
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({
|
||||
content: 'original baseline',
|
||||
isBinary: false
|
||||
})
|
||||
it('leaves a restored dirty tab unmarked when disk already holds its draft', async () => {
|
||||
// Why: its next guarded save lands (disk already equals it) and the tab goes clean.
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({ content: 'restored draft', isBinary: false })
|
||||
const store = createTestStore()
|
||||
openRestoredDirtyTab(store, '/repo/match.ts', 'original baseline')
|
||||
openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline')
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
// Why: the flag suspends autosave — leaving it set after a clean
|
||||
// verification would strand the tab's autosave forever.
|
||||
expect(store.getState().openFiles[0]?.pendingDiskBaselineVerification).toBeUndefined()
|
||||
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({
|
||||
content: 'agent rewrote this offline',
|
||||
isBinary: false
|
||||
})
|
||||
openRestoredDirtyTab(store, '/repo/mismatch.ts', 'original baseline')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
const mismatchTab = store.getState().openFiles.find((f) => f.id === '/repo/mismatch.ts')
|
||||
expect(mismatchTab?.externalMutation).toBe('changed')
|
||||
expect(mismatchTab?.pendingDiskBaselineVerification).toBeUndefined()
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined()
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not re-verify live dirty tabs that were never flagged at hydration', async () => {
|
||||
it('does not verify a tab dirtied after the scan attached', async () => {
|
||||
const store = createTestStore()
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/live.ts',
|
||||
relativePath: 'live.ts',
|
||||
@@ -183,7 +185,6 @@ describe('attachRestoredTabConflictScan', () => {
|
||||
store.getState().markFileDirty('/repo/live.ts', true)
|
||||
store.getState().setLastKnownDiskSignature('/repo/live.ts', getDiskBaselineSignature('base'))
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
// Why: in-session drift is the live watcher's job; re-reading here would
|
||||
@@ -195,9 +196,6 @@ describe('attachRestoredTabConflictScan', () => {
|
||||
})
|
||||
|
||||
it('resolves verification with a deleted mark when the file is definitively gone', async () => {
|
||||
// Why: a file deleted while the app was closed can never verify — without
|
||||
// a terminal state the retry loop keeps the tab's autosave silently
|
||||
// suspended for the whole session.
|
||||
mocks.readRuntimeFileContent.mockRejectedValue(new Error('ENOENT'))
|
||||
mocks.pathExists.mockResolvedValue(false)
|
||||
const store = createTestStore()
|
||||
@@ -207,7 +205,6 @@ describe('attachRestoredTabConflictScan', () => {
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
const tab = store.getState().openFiles[0]
|
||||
expect(tab?.pendingDiskBaselineVerification).toBeUndefined()
|
||||
expect(tab?.externalMutation).toBe('deleted')
|
||||
// Why: the verification is resolved — no further retries may fire.
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
@@ -217,10 +214,8 @@ describe('attachRestoredTabConflictScan', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps retrying with the suspension intact when the existence probe fails', async () => {
|
||||
// Why: a transport that is down cannot disprove existence — lifting the
|
||||
// suspension unverified would reopen the clobber window the moment the
|
||||
// transport recovers.
|
||||
it('keeps retrying when the existence probe fails', async () => {
|
||||
// Why: a transport that is down cannot disprove existence.
|
||||
mocks.readRuntimeFileContent.mockRejectedValue(new Error('connection not ready'))
|
||||
mocks.pathExists.mockRejectedValue(new Error('connection not ready'))
|
||||
const store = createTestStore()
|
||||
@@ -231,7 +226,6 @@ describe('attachRestoredTabConflictScan', () => {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await vi.advanceTimersByTimeAsync(2_100)
|
||||
const tab = store.getState().openFiles[0]
|
||||
expect(tab?.pendingDiskBaselineVerification).toBe(true)
|
||||
expect(tab?.externalMutation).toBeUndefined()
|
||||
expect(mocks.readRuntimeFileContent.mock.calls.length).toBeGreaterThan(1)
|
||||
} finally {
|
||||
@@ -263,7 +257,6 @@ describe('attachRestoredTabConflictScan', () => {
|
||||
expect(mocks.readRuntimeFileContent).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ filePath: '/Users/me/notes.txt', access: { kind: 'user-file' } })
|
||||
)
|
||||
expect(store.getState().openFiles[0]?.pendingDiskBaselineVerification).toBeUndefined()
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
@@ -304,7 +297,6 @@ describe('attachRestoredTabConflictScan', () => {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled()
|
||||
expect(mocks.pathExists).not.toHaveBeenCalled()
|
||||
expect(store.getState().openFiles[0]?.pendingDiskBaselineVerification).toBe(true)
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
@@ -339,9 +331,6 @@ describe('attachRestoredTabConflictScan', () => {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
}
|
||||
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(6)
|
||||
for (const file of store.getState().openFiles) {
|
||||
expect(file.pendingDiskBaselineVerification).toBeUndefined()
|
||||
}
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
@@ -413,7 +402,6 @@ describe('attachRestoredTabConflictScan', () => {
|
||||
|
||||
const tab = store.getState().openFiles.find((f) => f.id === '/repo/file-3.ts')
|
||||
expect(tab?.externalMutation).toBeUndefined()
|
||||
expect(tab?.pendingDiskBaselineVerification).toBeUndefined()
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Why: a restored dirty tab's changed-on-disk mark doesn't survive restarts (issue #7265), so re-derive the conflict from disk — else a resumed autosave silently overwrites newer content; autosave stays suspended (pendingDiskBaselineVerification) until verification resolves.
|
||||
// Why: a restored dirty tab's changed-on-disk mark doesn't survive restarts (issue #7265), so re-derive it from disk to show the banner early; the guarded save is what keeps autosave off newer content.
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { AppState } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
@@ -13,54 +13,32 @@ import { canAutoSaveOpenFile } from './editor-autosave'
|
||||
import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature'
|
||||
import { markFileChangedOnDisk } from './editor-changed-on-disk-mark'
|
||||
import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch'
|
||||
import { getRecentSelfWrite } from './editor-self-write-registry'
|
||||
|
||||
type AppStoreApi = Pick<StoreApi<AppState>, 'getState' | 'subscribe'>
|
||||
|
||||
// Retry fast then slow: reads fail while SSH/runtime transport is still coming up; giving up would strand autosave suspension.
|
||||
// Retry fast then slow: reads fail while SSH/runtime transport is still coming up.
|
||||
const VERIFY_RETRY_MS = 2_000
|
||||
const VERIFY_SLOW_RETRY_MS = 15_000
|
||||
const VERIFY_FAST_ATTEMPTS = 30
|
||||
// Cap concurrent reads: many restored dirty tabs would otherwise fire N concurrent 15s RPCs competing with startup connection recovery.
|
||||
const MAX_CONCURRENT_VERIFY_READS = 3
|
||||
// Why: an unreachable host must not suspend autosave forever; give up with the conflict banner instead.
|
||||
export const MAX_VERIFY_ATTEMPTS = VERIFY_FAST_ATTEMPTS + 10
|
||||
|
||||
// Why: a disk check requested while one is in flight must not be answered by the older read.
|
||||
// A global sequence, so dropping a closed tab's entry can't let a reopened tab's request reuse an old read's number.
|
||||
const verifyRequestGenerationByFileId = new Map<string, number>()
|
||||
let verifyRequestGenerationSeq = 0
|
||||
|
||||
/** Asks for a fresh disk check of a tab; a read already in flight is followed by one more. */
|
||||
export function requestRestoredTabReverification(fileId: string): void {
|
||||
verifyRequestGenerationSeq += 1
|
||||
verifyRequestGenerationByFileId.set(fileId, verifyRequestGenerationSeq)
|
||||
}
|
||||
|
||||
export function getRestoredTabReverificationRequestCountForTest(): number {
|
||||
return verifyRequestGenerationByFileId.size
|
||||
}
|
||||
|
||||
function forgetClosedTabReverificationRequests(openFiles: readonly OpenFile[]): void {
|
||||
if (verifyRequestGenerationByFileId.size === 0) {
|
||||
return
|
||||
}
|
||||
const openIds = new Set(openFiles.map((file) => file.id))
|
||||
for (const fileId of verifyRequestGenerationByFileId.keys()) {
|
||||
if (!openIds.has(fileId)) {
|
||||
verifyRequestGenerationByFileId.delete(fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyRequestGeneration(fileId: string): number {
|
||||
return verifyRequestGenerationByFileId.get(fileId) ?? 0
|
||||
function needsDiskCheck(file: OpenFile): boolean {
|
||||
return (
|
||||
file.isDirty &&
|
||||
file.lastKnownDiskSignature !== undefined &&
|
||||
file.externalMutation !== 'changed' &&
|
||||
canAutoSaveOpenFile(file)
|
||||
)
|
||||
}
|
||||
|
||||
export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
|
||||
// Dedupes queued + in-flight verifications; the store's pending flag is the durable "needs verification" signal.
|
||||
// Why: only a tab that arrives dirty (a restore, or a dirty tab's rekey) is checked; edits made here start in sync.
|
||||
const seenFileIds = new Set<string>()
|
||||
const inFlightFileIds = new Set<string>()
|
||||
const attemptsByFileId = new Map<string, number>()
|
||||
const supersededReadsByFileId = new Map<string, number>()
|
||||
const retryTimers = new Set<ReturnType<typeof setTimeout>>()
|
||||
// Queue ids, not OpenFile snapshots: a snapshot can go stale (close/reopen or same-path save) while it waits for a slot.
|
||||
const verifyQueue: string[] = []
|
||||
@@ -125,30 +103,19 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a newer check was requested mid-read; this answer may predate it, so keep the flag and read once more.
|
||||
const rereadIfSuperseded = (fileId: string, requestGeneration: number): boolean => {
|
||||
if (verifyRequestGeneration(fileId) === requestGeneration) {
|
||||
return false
|
||||
}
|
||||
// Why back off, not cap: a superseded answer may predate the newer check, so it never resolves; churn only delays the fresh read.
|
||||
const superseded = (supersededReadsByFileId.get(fileId) ?? 0) + 1
|
||||
supersededReadsByFileId.set(fileId, superseded)
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
retryTimers.delete(timer)
|
||||
inFlightFileIds.delete(fileId)
|
||||
scan()
|
||||
},
|
||||
Math.min((superseded - 1) * VERIFY_RETRY_MS, VERIFY_SLOW_RETRY_MS)
|
||||
)
|
||||
retryTimers.add(timer)
|
||||
return true
|
||||
// Why: a save (or reload) since the read re-baselined the tab, so the read proves nothing about it.
|
||||
const unchangedSinceRead = (file: OpenFile): OpenFile | null => {
|
||||
const liveFile = store.getState().openFiles.find((f) => f.id === file.id)
|
||||
return liveFile &&
|
||||
needsDiskCheck(liveFile) &&
|
||||
liveFile.lastKnownDiskSignature === file.lastKnownDiskSignature
|
||||
? liveFile
|
||||
: null
|
||||
}
|
||||
|
||||
const verify = async (file: OpenFile): Promise<void> => {
|
||||
// ids are file paths: a stray leftover marker would skip a reopened same-path tab, so only a scheduled retry keeps it set.
|
||||
let retryScheduled = false
|
||||
const requestGeneration = verifyRequestGeneration(file.id)
|
||||
try {
|
||||
const state = store.getState()
|
||||
const result = await readRuntimeFileContent({
|
||||
@@ -160,56 +127,31 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
|
||||
expectedExternalSshTargetId: file.externalSshTargetId,
|
||||
access: editorTabFileAccess(state, file)
|
||||
})
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const liveFile = store.getState().openFiles.find((f) => f.id === file.id)
|
||||
if (!liveFile) {
|
||||
return
|
||||
}
|
||||
if (rereadIfSuperseded(file.id, requestGeneration)) {
|
||||
retryScheduled = true
|
||||
return
|
||||
}
|
||||
attemptsByFileId.delete(file.id)
|
||||
supersededReadsByFileId.delete(file.id)
|
||||
// Verification resolved: lift autosave suspension regardless of outcome; wasPending flags a save that already re-baselined.
|
||||
const wasPending = liveFile.pendingDiskBaselineVerification === true
|
||||
store.getState().clearPendingDiskBaselineVerification(file.id)
|
||||
const liveFile = disposed ? null : unchangedSinceRead(file)
|
||||
if (
|
||||
!wasPending ||
|
||||
!liveFile ||
|
||||
result.isBinary ||
|
||||
!liveFile.isDirty ||
|
||||
liveFile.externalMutation === 'changed'
|
||||
getDiskBaselineSignature(result.content) === file.lastKnownDiskSignature ||
|
||||
// Why: disk already holds the draft, so the next save goes through clean.
|
||||
result.content === store.getState().editorDrafts[file.id] ||
|
||||
// Why: the tab's own save can land before it rebaselines.
|
||||
getRecentSelfWrite(file.filePath, file.runtimeEnvironmentId)?.content === result.content
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (getDiskBaselineSignature(result.content) !== file.lastKnownDiskSignature) {
|
||||
markFileChangedOnDisk(store.getState(), liveFile, {
|
||||
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined,
|
||||
origin: 'restore'
|
||||
})
|
||||
}
|
||||
markFileChangedOnDisk(store.getState(), liveFile, {
|
||||
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined,
|
||||
origin: 'restore'
|
||||
})
|
||||
} catch {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (await probeFileMissing(file)) {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (rereadIfSuperseded(file.id, requestGeneration)) {
|
||||
retryScheduled = true
|
||||
return
|
||||
}
|
||||
// Definitive not-found = resolved: no newer disk content for a save to clobber, so mark deleted instead of retrying forever.
|
||||
const liveFile = store.getState().openFiles.find((f) => f.id === file.id)
|
||||
if (!liveFile) {
|
||||
return
|
||||
}
|
||||
const wasPending = liveFile.pendingDiskBaselineVerification === true
|
||||
store.getState().clearPendingDiskBaselineVerification(file.id)
|
||||
if (wasPending && liveFile.isDirty && liveFile.externalMutation !== 'changed') {
|
||||
attemptsByFileId.delete(file.id)
|
||||
const liveFile = disposed ? null : unchangedSinceRead(file)
|
||||
if (liveFile && liveFile.externalMutation !== 'deleted') {
|
||||
store.getState().setExternalMutation(file.id, 'deleted')
|
||||
}
|
||||
return
|
||||
@@ -219,16 +161,17 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
|
||||
}
|
||||
const attempts = (attemptsByFileId.get(file.id) ?? 0) + 1
|
||||
attemptsByFileId.set(file.id, attempts)
|
||||
// Why stop silently: the banner is only early warning; the guarded save still refuses newer disk.
|
||||
if (attempts >= MAX_VERIFY_ATTEMPTS) {
|
||||
giveUp(file)
|
||||
attemptsByFileId.delete(file.id)
|
||||
return
|
||||
}
|
||||
retryScheduled = true
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
retryTimers.delete(timer)
|
||||
inFlightFileIds.delete(file.id)
|
||||
scan()
|
||||
verifyQueue.push(file.id)
|
||||
pumpVerifyQueue()
|
||||
},
|
||||
attempts < VERIFY_FAST_ATTEMPTS ? VERIFY_RETRY_MS : VERIFY_SLOW_RETRY_MS
|
||||
)
|
||||
@@ -240,37 +183,14 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: still unverifiable after every retry; resume nothing silently — the banner makes the user choose.
|
||||
const giveUp = (file: OpenFile): void => {
|
||||
attemptsByFileId.delete(file.id)
|
||||
supersededReadsByFileId.delete(file.id)
|
||||
const liveFile = store.getState().openFiles.find((f) => f.id === file.id)
|
||||
if (!liveFile?.pendingDiskBaselineVerification) {
|
||||
return
|
||||
}
|
||||
store.getState().clearPendingDiskBaselineVerification(file.id)
|
||||
if (liveFile.isDirty && liveFile.externalMutation !== 'changed') {
|
||||
markFileChangedOnDisk(store.getState(), liveFile, {
|
||||
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined,
|
||||
origin: 'restore'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const pumpVerifyQueue = (): void => {
|
||||
while (!disposed && activeVerifyReads < MAX_CONCURRENT_VERIFY_READS && verifyQueue.length > 0) {
|
||||
const fileId = verifyQueue.shift()!
|
||||
// Re-read live file: a queued id may now be a reopened/saved same-path tab; re-validate before a disk read, and skipping frees the dedupe marker so a later scan can re-queue it.
|
||||
// Re-read live file: a queued id may now be a reopened/saved same-path tab; skipping frees the dedupe marker.
|
||||
const file = store.getState().openFiles.find((f) => f.id === fileId)
|
||||
if (
|
||||
!file ||
|
||||
!file.pendingDiskBaselineVerification ||
|
||||
!file.isDirty ||
|
||||
!file.lastKnownDiskSignature ||
|
||||
file.externalMutation === 'changed' ||
|
||||
!canAutoSaveOpenFile(file)
|
||||
) {
|
||||
if (!file || !needsDiskCheck(file)) {
|
||||
inFlightFileIds.delete(fileId)
|
||||
attemptsByFileId.delete(fileId)
|
||||
continue
|
||||
}
|
||||
activeVerifyReads += 1
|
||||
@@ -287,19 +207,22 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
for (const file of store.getState().openFiles) {
|
||||
if (
|
||||
!file.pendingDiskBaselineVerification ||
|
||||
!file.isDirty ||
|
||||
!file.lastKnownDiskSignature ||
|
||||
file.externalMutation === 'changed' ||
|
||||
!canAutoSaveOpenFile(file) ||
|
||||
inFlightFileIds.has(file.id)
|
||||
) {
|
||||
const openFiles = store.getState().openFiles
|
||||
const openIds = new Set(openFiles.map((file) => file.id))
|
||||
for (const fileId of seenFileIds) {
|
||||
if (!openIds.has(fileId)) {
|
||||
seenFileIds.delete(fileId)
|
||||
}
|
||||
}
|
||||
for (const file of openFiles) {
|
||||
if (seenFileIds.has(file.id)) {
|
||||
continue
|
||||
}
|
||||
inFlightFileIds.add(file.id)
|
||||
verifyQueue.push(file.id)
|
||||
seenFileIds.add(file.id)
|
||||
if (needsDiskCheck(file) && !inFlightFileIds.has(file.id)) {
|
||||
inFlightFileIds.add(file.id)
|
||||
verifyQueue.push(file.id)
|
||||
}
|
||||
}
|
||||
pumpVerifyQueue()
|
||||
}
|
||||
@@ -311,7 +234,6 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
|
||||
return
|
||||
}
|
||||
previousOpenFiles = nextOpenFiles
|
||||
forgetClosedTabReverificationRequests(nextOpenFiles)
|
||||
scan()
|
||||
})
|
||||
scan()
|
||||
|
||||
@@ -251,7 +251,7 @@ describe('guarded editor saves under races', () => {
|
||||
gate.resolve()
|
||||
await saves
|
||||
|
||||
// Each save is guarded against the advanced baseline and rewrites identical bytes.
|
||||
// The second guarded save completes without rewriting the identical bytes.
|
||||
expect(disk.writeFile).toHaveBeenCalledTimes(2)
|
||||
expect(disk.files.get(GUARDED_PATH)).toBe('X')
|
||||
expect([...shown.values()]).toEqual(['X', 'X'])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { requestEditorFileSave } from './editor-autosave'
|
||||
import { attachEditorAutosaveController } from './editor-autosave-controller'
|
||||
import { __clearSelfWriteRegistryForTests } from './editor-self-write-registry'
|
||||
import { __clearSelfWriteRegistryForTests, hasRecentSelfWrite } from './editor-self-write-registry'
|
||||
import { createEditorStore } from './editor-autosave-controller-test-fixture'
|
||||
import {
|
||||
keepTabEditsOverExternalChange,
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
getDiskBaselineSignature,
|
||||
getWrittenContentDiskSignature
|
||||
} from '../../../../shared/disk-baseline-signature'
|
||||
import { FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY } from '../../../../shared/file-write-disk-precondition'
|
||||
import {
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/file-write-disk-precondition'
|
||||
import { GUARDED_PATH, openGuardedFile, stubGuardedDisk } from './editor-save-guarded-disk-fixture'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponse,
|
||||
@@ -68,6 +71,29 @@ describe('editor saves guarded by the disk precondition', () => {
|
||||
__clearSelfWriteRegistryForTests()
|
||||
})
|
||||
|
||||
it('cleans a tab whose disk already holds the edit, without retaining a self-write stamp', async () => {
|
||||
const disk = stubGuardedDisk({ [PATH]: 'user edit' })
|
||||
const store = createEditorStore()
|
||||
openFile(store, { baseline: 'original', draft: 'user edit' })
|
||||
const cleanup = attachEditorAutosaveController(store)
|
||||
try {
|
||||
await requestEditorFileSave({ fileId: PATH })
|
||||
|
||||
await expect(disk.writeFile.mock.results[0]?.value).resolves.toEqual({
|
||||
ok: true,
|
||||
unchanged: true
|
||||
})
|
||||
expect(store.getState().openFiles[0]).toMatchObject({
|
||||
isDirty: false,
|
||||
lastKnownDiskSignature: getWrittenContentDiskSignature('user edit')
|
||||
})
|
||||
expect(store.getState().editorDrafts[PATH]).toBeUndefined()
|
||||
expect(hasRecentSelfWrite(PATH)).toBe(false)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('blocks an autosave over an unnoticed agent write and raises the banner', async () => {
|
||||
const disk = stubGuardedDisk({ [PATH]: 'original' })
|
||||
const store = createEditorStore()
|
||||
@@ -84,7 +110,8 @@ describe('editor saves guarded by the disk precondition', () => {
|
||||
content: 'user edit',
|
||||
diskPrecondition: {
|
||||
expectedSignature: getDiskBaselineSignature('original'),
|
||||
allowMissing: false
|
||||
allowMissing: false,
|
||||
requireLosslessUtf8: true
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -152,9 +179,12 @@ describe('editor saves guarded by the disk precondition', () => {
|
||||
store.getState().markFileDirty(PATH, true)
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
// Why: this host only sees decoded text, so a U+FFFD on disk pauses autosave conservatively.
|
||||
expect(disk.writeFile).toHaveBeenCalledTimes(2)
|
||||
expect(disk.files.get(PATH)).toBe('second edit')
|
||||
expect(disk.files.get(PATH)).toBe('a�b')
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined()
|
||||
await requestEditorFileSave({ fileId: PATH })
|
||||
expect(disk.files.get(PATH)).toBe('second edit')
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
@@ -489,7 +519,9 @@ describe('editor saves through a remote host without the guarded write (client c
|
||||
throw new Error('fixture status must succeed')
|
||||
}
|
||||
const capabilities = (status.result.capabilities ?? []).filter(
|
||||
(capability) => capability !== FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY
|
||||
(capability) =>
|
||||
capability !== FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY &&
|
||||
capability !== FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY
|
||||
)
|
||||
const olderStatus = { ...status, result: { ...status.result, capabilities } }
|
||||
const meta = { _meta: { runtimeId: 'runtime-env-1' } }
|
||||
|
||||
@@ -71,7 +71,7 @@ export function editorModelSiblingTabs(state: AppState, file: OpenFile): OpenFil
|
||||
* write gets the writer's compatibility fallback: the client reads and compares, then writes.
|
||||
*/
|
||||
export type EditorSavePrecondition =
|
||||
| { kind: 'guarded'; signature: string; allowMissing: boolean }
|
||||
| { kind: 'guarded'; signature: string; allowMissing: boolean; requireLosslessUtf8?: boolean }
|
||||
| { kind: 'explicit-overwrite' }
|
||||
| { kind: 'blocked'; reason: 'no-baseline' }
|
||||
|
||||
@@ -128,7 +128,12 @@ export function editorSavePrecondition(
|
||||
return { kind: 'blocked', reason: 'no-baseline' }
|
||||
}
|
||||
// Why: autosave must not resurrect a file deleted behind the editor's back; explicit saves may.
|
||||
return { kind: 'guarded', signature: baseline, allowMissing: trigger !== 'autosave' }
|
||||
return {
|
||||
kind: 'guarded',
|
||||
signature: baseline,
|
||||
allowMissing: trigger !== 'autosave',
|
||||
requireLosslessUtf8: trigger === 'autosave' || trigger === 'restart'
|
||||
}
|
||||
}
|
||||
|
||||
/** The only file write editor save code makes. */
|
||||
@@ -146,7 +151,11 @@ export function writeEditorSave(
|
||||
content,
|
||||
access,
|
||||
write.kind === 'guarded'
|
||||
? { expectedSignature: write.signature, allowMissing: write.allowMissing }
|
||||
? {
|
||||
expectedSignature: write.signature,
|
||||
allowMissing: write.allowMissing,
|
||||
...(write.requireLosslessUtf8 ? { requireLosslessUtf8: true } : {})
|
||||
}
|
||||
: undefined,
|
||||
beforeSend
|
||||
)
|
||||
|
||||
@@ -28,8 +28,12 @@ export function stubGuardedDisk(
|
||||
if (diskPrecondition) {
|
||||
const diskConflict = compareDiskWritePrecondition(
|
||||
diskPrecondition,
|
||||
files.get(filePath) ?? null
|
||||
files.get(filePath) ?? null,
|
||||
content
|
||||
)
|
||||
if (diskConflict === 'identical') {
|
||||
return { ok: true, unchanged: true }
|
||||
}
|
||||
if (diskConflict) {
|
||||
return { ok: false, diskConflict }
|
||||
}
|
||||
|
||||
@@ -58,8 +58,15 @@ function pasteIntoGuardedTab(pasted: string) {
|
||||
const files = new Map([[PATH, 'base']])
|
||||
const writeFile = vi.fn(async (args: GuardedWriteArgs) => {
|
||||
const conflict = args.diskPrecondition
|
||||
? compareDiskWritePrecondition(args.diskPrecondition, files.get(args.filePath) ?? null)
|
||||
? compareDiskWritePrecondition(
|
||||
args.diskPrecondition,
|
||||
files.get(args.filePath) ?? null,
|
||||
args.content
|
||||
)
|
||||
: null
|
||||
if (conflict === 'identical') {
|
||||
return { ok: true, unchanged: true }
|
||||
}
|
||||
if (conflict) {
|
||||
return { ok: false, diskConflict: conflict }
|
||||
}
|
||||
@@ -118,7 +125,11 @@ it('holds autosave while a chunked paste is in flight, then saves the pasted tex
|
||||
expect(files.get(PATH)).toBe(`${pasted}base`)
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
diskPrecondition: { expectedSignature: getDiskBaselineSignature('base'), allowMissing: false }
|
||||
diskPrecondition: {
|
||||
expectedSignature: getDiskBaselineSignature('base'),
|
||||
allowMissing: false,
|
||||
requireLosslessUtf8: true
|
||||
}
|
||||
})
|
||||
)
|
||||
queue.dispose()
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import { createEditorStore } from './editor-autosave-controller-test-fixture'
|
||||
import { createEditorSaveQueue } from './editor-save-queue'
|
||||
import { GUARDED_PATH, openGuardedFile, stubGuardedDisk } from './editor-save-guarded-disk-fixture'
|
||||
import { isAutosaveSuspendedForFile } from './editor-autosave'
|
||||
import { __clearNonUtf8AutosavePausesForTests } from './editor-non-utf8-autosave-pause'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { warning: vi.fn() } }))
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.mocked(toast.warning).mockClear()
|
||||
__clearNonUtf8AutosavePausesForTests()
|
||||
})
|
||||
|
||||
it.each(['autosave', 'restart', 'user', 'overwrite'] as const)(
|
||||
'requires a lossless baseline only for automatic writes (%s)',
|
||||
async (trigger) => {
|
||||
const decoded = Buffer.from([0x61, 0xfe]).toString('utf8')
|
||||
const newer = Buffer.from([0x61, 0xff]).toString('utf8')
|
||||
const disk = stubGuardedDisk({ [GUARDED_PATH]: newer })
|
||||
const store = createEditorStore()
|
||||
openGuardedFile(store, { baseline: decoded, draft: decoded })
|
||||
const queue = createEditorSaveQueue(store)
|
||||
try {
|
||||
const automatic = trigger === 'autosave' || trigger === 'restart'
|
||||
expect(await queue.queueSave(store.getState().openFiles[0], decoded, trigger)).toBe(
|
||||
automatic ? 'failed' : 'written'
|
||||
)
|
||||
expect(store.getState().openFiles[0]?.isDirty).toBe(automatic)
|
||||
// Why: a non-UTF-8 refusal is not a changed-on-disk conflict.
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined()
|
||||
expect(isAutosaveSuspendedForFile(store.getState().openFiles[0]!)).toBe(automatic)
|
||||
expect(toast.warning).toHaveBeenCalledTimes(automatic ? 1 : 0)
|
||||
expect(disk.files.get(GUARDED_PATH)).toBe(decoded)
|
||||
} finally {
|
||||
queue.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('pauses autosave once for a non-UTF-8 file until an explicit save converts it', async () => {
|
||||
const latin1 = Buffer.from([0x63, 0x61, 0x66, 0xe9]).toString('utf8')
|
||||
const disk = stubGuardedDisk({ [GUARDED_PATH]: latin1 })
|
||||
const store = createEditorStore()
|
||||
openGuardedFile(store, { baseline: latin1, draft: `${latin1}!` })
|
||||
const queue = createEditorSaveQueue(store)
|
||||
try {
|
||||
const file = (): NonNullable<ReturnType<typeof store.getState>['openFiles'][0]> =>
|
||||
store.getState().openFiles[0]!
|
||||
expect(await queue.queueSave(file(), `${latin1}!`, 'autosave')).toBe('failed')
|
||||
// A second automatic attempt is gated before it reaches the host, with no second notice.
|
||||
expect(await queue.queueSave(file(), `${latin1}!`, 'autosave')).toBe('failed')
|
||||
expect(disk.writeFile).toHaveBeenCalledTimes(1)
|
||||
expect(toast.warning).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(toast.warning).mock.calls[0]?.[0]).toContain("isn't UTF-8 text")
|
||||
|
||||
expect(await queue.queueSave(file(), `${latin1}!`, 'user')).toBe('written')
|
||||
expect(disk.files.get(GUARDED_PATH)).toBe(`${latin1}!`)
|
||||
expect(isAutosaveSuspendedForFile(file())).toBe(false)
|
||||
} finally {
|
||||
queue.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['close', 'reload'] as const)(
|
||||
'lifts the pause on %s, so the next edit is asked about again',
|
||||
async (ending) => {
|
||||
const latin1 = Buffer.from([0x63, 0x61, 0x66, 0xe9]).toString('utf8')
|
||||
const disk = stubGuardedDisk({ [GUARDED_PATH]: latin1 })
|
||||
const store = createEditorStore()
|
||||
openGuardedFile(store, { baseline: latin1, draft: `${latin1}!` })
|
||||
const queue = createEditorSaveQueue(store)
|
||||
try {
|
||||
expect(await queue.queueSave(store.getState().openFiles[0]!, `${latin1}!`, 'autosave')).toBe(
|
||||
'failed'
|
||||
)
|
||||
if (ending === 'close') {
|
||||
// Why: the minimal fixture store lacks the layout state closeFile walks.
|
||||
store.setState({ openFiles: [], editorBuffers: {}, editorDrafts: {} })
|
||||
} else {
|
||||
store.getState().markFileDirty(GUARDED_PATH, false)
|
||||
}
|
||||
queue.syncAutoSave()
|
||||
openGuardedFile(store, { baseline: latin1, draft: `${latin1}?` })
|
||||
queue.syncAutoSave()
|
||||
expect(isAutosaveSuspendedForFile(store.getState().openFiles[0]!)).toBe(false)
|
||||
expect(await queue.queueSave(store.getState().openFiles[0]!, `${latin1}?`, 'autosave')).toBe(
|
||||
'failed'
|
||||
)
|
||||
expect(disk.writeFile).toHaveBeenCalledTimes(2)
|
||||
expect(toast.warning).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
queue.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -23,6 +23,7 @@ import { trackExternalChangeConflictAction } from './editor-external-change-tele
|
||||
import { editorTabFileAccess } from '@/lib/local-file-access'
|
||||
import { isEditorLargePasteInFlight } from './editor-large-paste-in-flight'
|
||||
import {
|
||||
markEditorSaveRefusal,
|
||||
rebaselineAfterEditorWrite,
|
||||
setModelExternalMutation,
|
||||
waitForModelBaselineRestamps
|
||||
@@ -40,6 +41,7 @@ import { editorSaveWriterView } from './editor-save-writer-view'
|
||||
import { createEditorSaveSlots, type EditorSaveRequest } from './editor-save-slots'
|
||||
import { EditorSaveDiscardedError, editorDiscardEpoch } from './editor-discard-epoch'
|
||||
import { selectWorktreesByRepo } from '@/store/workspace-record-selectors'
|
||||
import { releaseNonUtf8PausesWithoutDirtyTab } from './editor-non-utf8-autosave-pause'
|
||||
|
||||
export type AppStoreApi = Pick<StoreApi<AppState>, 'getState' | 'subscribe'>
|
||||
|
||||
@@ -175,15 +177,13 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue {
|
||||
}
|
||||
if (!writeResult.ok) {
|
||||
clearSelfWrite(liveFile.filePath, liveFile.runtimeEnvironmentId)
|
||||
// Why: the banner (or deleted tombstone) explains the refusal and pauses autosave.
|
||||
setModelExternalMutation(
|
||||
store.getState(),
|
||||
fileId,
|
||||
writeResult.diskConflict === 'missing' ? 'deleted' : 'changed'
|
||||
)
|
||||
return 'blocked-stale'
|
||||
markEditorSaveRefusal(store.getState(), liveFile, writeResult.diskConflict)
|
||||
return writeResult.diskConflict === 'not-utf8' ? 'failed' : 'blocked-stale'
|
||||
}
|
||||
|
||||
if (writeResult.unchanged) {
|
||||
clearSelfWrite(liveFile.filePath, liveFile.runtimeEnvironmentId)
|
||||
}
|
||||
rebaselineAfterEditorWrite(store.getState(), fileId, contentToSave)
|
||||
const nextState = store.getState()
|
||||
const currentDraft = nextState.editorDrafts[fileId]
|
||||
@@ -194,8 +194,6 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue {
|
||||
if (!stillDirty) {
|
||||
nextState.clearEditorDraft(fileId)
|
||||
}
|
||||
// Why: disk now holds contentToSave, so a pending restore verification is moot.
|
||||
nextState.clearPendingDiskBaselineVerification(fileId)
|
||||
// Why: the write made disk match the buffer, so clear any now-stale changed-on-disk conflict.
|
||||
const savedFile = nextState.openFiles.find((openFile) => openFile.id === fileId)
|
||||
if (savedFile?.externalMutation === 'changed') {
|
||||
@@ -259,6 +257,7 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue {
|
||||
const syncAutoSave = (): void => {
|
||||
const state = store.getState()
|
||||
holds.flagOverdue(state)
|
||||
releaseNonUtf8PausesWithoutDirtyTab(state.openFiles)
|
||||
const openFilesByKey = new Map(
|
||||
state.openFiles.map((file) => [editorBufferIdOfView(state, file.id), file])
|
||||
)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { AppState } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { getWrittenContentDiskSignature } from '../../../../shared/disk-baseline-signature'
|
||||
import type { FileWriteDiskConflict } from '../../../../shared/file-write-disk-precondition'
|
||||
import { notifyEditorFileCacheStale } from './editor-autosave'
|
||||
import {
|
||||
pauseAutosaveForNonUtf8File,
|
||||
resumeAutosaveForNonUtf8File
|
||||
} from './editor-non-utf8-autosave-pause'
|
||||
import {
|
||||
editorModelSiblingTabs,
|
||||
noteEditorBaselineWrite,
|
||||
@@ -21,6 +26,19 @@ export function setModelExternalMutation(
|
||||
}
|
||||
}
|
||||
|
||||
/** The banner, deleted tombstone or non-UTF-8 notice explains a refused save and pauses autosave. */
|
||||
export function markEditorSaveRefusal(
|
||||
state: AppState,
|
||||
file: OpenFile,
|
||||
diskConflict: FileWriteDiskConflict
|
||||
): void {
|
||||
if (diskConflict === 'not-utf8') {
|
||||
pauseAutosaveForNonUtf8File(file)
|
||||
return
|
||||
}
|
||||
setModelExternalMutation(state, file.id, diskConflict === 'missing' ? 'deleted' : 'changed')
|
||||
}
|
||||
|
||||
/** A Keep in any tab of this text restamps the baseline the save uses, so wait for all of them. */
|
||||
export function waitForModelBaselineRestamps(state: AppState, file: OpenFile): Promise<unknown> {
|
||||
const tab = state.openFiles.find((openFile) => openFile.id === file.id)
|
||||
@@ -37,6 +55,10 @@ export function rebaselineAfterEditorWrite(state: AppState, fileId: string, cont
|
||||
const moved = setEditorDiskBaseline(state, fileId, getWrittenContentDiskSignature(content))
|
||||
noteEditorBaselineWrite(fileId)
|
||||
const file = state.openFiles.find((openFile) => openFile.id === fileId)
|
||||
if (file) {
|
||||
// Why: disk now holds this UTF-8 write, so a non-UTF-8 pause no longer applies.
|
||||
resumeAutosaveForNonUtf8File(file)
|
||||
}
|
||||
// Why: while any tab still has edits beyond this write, the model holds them; a sibling must
|
||||
// show that, not disk. The saving tab's own written draft is about to be settled.
|
||||
const draft = state.editorDrafts[fileId]
|
||||
|
||||
@@ -115,7 +115,6 @@ describe('a clean tab of a dirty shared model', () => {
|
||||
|
||||
it('after a restart, a file changed while closed banners every tab of the model', async () => {
|
||||
const { store, a, b, tab } = openSharedTabsWithDirtySibling()
|
||||
store.getState().setPendingDiskBaselineVerification(b.id, true)
|
||||
mocks.readRuntimeFileContent.mockResolvedValue({ content: 'E', isBinary: false })
|
||||
|
||||
const detach = attachRestoredTabConflictScan(store)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { createEditorStore } from '@/components/editor/editor-autosave-controller-test-fixture'
|
||||
import { openGuardedFile, GUARDED_PATH } from '@/components/editor/editor-save-guarded-disk-fixture'
|
||||
import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture'
|
||||
import {
|
||||
__clearSelfWriteRegistryForTests,
|
||||
recordSelfWrite
|
||||
} from '@/components/editor/editor-self-write-registry'
|
||||
|
||||
const storeRef = vi.hoisted((): { current: ReturnType<typeof createEditorStore> | null } => ({
|
||||
current: null
|
||||
}))
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => storeRef.current?.getState() } }))
|
||||
|
||||
import { reconcileEditorTabsAfterOverflow } from './editor-external-watch-overflow'
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
__clearSelfWriteRegistryForTests()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it.each(['original', 'external rewrite'])(
|
||||
'compares a dirty tab after an unwatched period with autosave off (%s)',
|
||||
async (content) => {
|
||||
const store = createEditorStore()
|
||||
storeRef.current = store
|
||||
store.setState({ settings: createGlobalSettingsFixture({ editorAutoSave: false }) })
|
||||
openGuardedFile(store, { baseline: 'original', draft: 'my edits' })
|
||||
let finishRead: () => void = () => undefined
|
||||
const readFile = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishRead = () => resolve({ content, isBinary: false })
|
||||
})
|
||||
)
|
||||
const writeFile = vi.fn()
|
||||
vi.stubGlobal('window', { api: { fs: { readFile, writeFile } } })
|
||||
|
||||
reconcileEditorTabsAfterOverflow({ worktreeId: 'wt-1', worktreePath: '/repo' }, true)
|
||||
expect(readFile).toHaveBeenCalled()
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined()
|
||||
store.getState().setEditorDraft(GUARDED_PATH, 'still typing')
|
||||
finishRead()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBe(
|
||||
content === 'original' ? undefined : 'changed'
|
||||
)
|
||||
expect(store.getState().openFiles[0]?.isDirty).toBe(true)
|
||||
expect(store.getState().editorDrafts[GUARDED_PATH]).toBe('still typing')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves a dirty tab unmarked when its resync read fails', async () => {
|
||||
const store = createEditorStore()
|
||||
storeRef.current = store
|
||||
openGuardedFile(store, { baseline: 'original', draft: 'my edits' })
|
||||
const readFile = vi.fn(() => Promise.reject(new Error('connection lost')))
|
||||
vi.stubGlobal('window', { api: { fs: { readFile } } })
|
||||
|
||||
reconcileEditorTabsAfterOverflow({ worktreeId: 'wt-1', worktreePath: '/repo' }, true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(readFile).toHaveBeenCalled()
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined()
|
||||
})
|
||||
|
||||
it("treats the tab's own just-written save as an echo, not an external change", async () => {
|
||||
const store = createEditorStore()
|
||||
storeRef.current = store
|
||||
openGuardedFile(store, { baseline: 'original', draft: 'my edits' })
|
||||
recordSelfWrite(GUARDED_PATH, 'saved edits')
|
||||
vi.stubGlobal('window', {
|
||||
api: { fs: { readFile: vi.fn(async () => ({ content: 'saved edits', isBinary: false })) } }
|
||||
})
|
||||
|
||||
reconcileEditorTabsAfterOverflow({ worktreeId: 'wt-1', worktreePath: '/repo' }, true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(store.getState().openFiles[0]?.externalMutation).toBeUndefined()
|
||||
})
|
||||
@@ -162,7 +162,7 @@ export function scheduleEditorChangedOnDiskMark(
|
||||
})
|
||||
}
|
||||
|
||||
// Per-file generation so a newer echo-verify read supersedes an older one — overlapping reads can't clear each other's autosave gate or apply a stale verdict.
|
||||
// Per-file generation so a newer echo-verify read supersedes an older one — an overlapping read can't apply a stale verdict.
|
||||
const liveMoveVerifyGeneration = new Map<string, number>()
|
||||
let liveMoveVerifyCounter = 0
|
||||
|
||||
@@ -174,10 +174,10 @@ type LiveMoveVerifyCandidate = {
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
// Fails closed: anything but a proven baseline match surfaces the conflict, so a real external write is never swallowed.
|
||||
// Fails closed: `false` (anything but a proven match) surfaces the conflict; `null` (unread, on a resync) proves nothing.
|
||||
function resolveLiveMoveVerification(
|
||||
candidate: LiveMoveVerifyCandidate,
|
||||
diskSignature: string | null,
|
||||
matchesDisk: boolean | null,
|
||||
connectionId: string | undefined,
|
||||
consumeProvenance: boolean
|
||||
): void {
|
||||
@@ -187,7 +187,6 @@ function resolveLiveMoveVerification(
|
||||
}
|
||||
liveMoveVerifyGeneration.delete(fileId)
|
||||
const state = useAppStore.getState()
|
||||
state.setPendingLiveDiskVerification(fileId, false)
|
||||
const file = state.openFiles.find((candidateFile) => candidateFile.id === fileId)
|
||||
if (
|
||||
!file ||
|
||||
@@ -202,8 +201,7 @@ function resolveLiveMoveVerification(
|
||||
if (consumeProvenance) {
|
||||
state.clearSelfMoveEcho(fileId)
|
||||
}
|
||||
const isMoveEcho = baseline !== undefined && diskSignature === baseline
|
||||
if (!isMoveEcho) {
|
||||
if (matchesDisk === false) {
|
||||
markFileChangedOnDisk(state, file, { connectionId, origin: 'live' })
|
||||
}
|
||||
}
|
||||
@@ -228,11 +226,12 @@ export function verifyLatchedEditorMoveDestinations(
|
||||
)
|
||||
}
|
||||
|
||||
// Autosave is suspended synchronously first so a write landing mid-read can't be overwritten before verification settles.
|
||||
/** `resync`: a watch reconnect check, where a failed read leaves the tab to its guarded save. */
|
||||
export function scheduleEditorSelfMoveEchoVerification(
|
||||
target: EditorExternalWatchTarget,
|
||||
fileIds: string[],
|
||||
consumeProvenance: boolean
|
||||
consumeProvenance: boolean,
|
||||
resync = false
|
||||
): void {
|
||||
if (fileIds.length === 0) {
|
||||
return
|
||||
@@ -245,7 +244,6 @@ export function scheduleEditorSelfMoveEchoVerification(
|
||||
}
|
||||
const generation = ++liveMoveVerifyCounter
|
||||
liveMoveVerifyGeneration.set(fileId, generation)
|
||||
state.setPendingLiveDiskVerification(fileId, true)
|
||||
const candidate: LiveMoveVerifyCandidate = {
|
||||
fileId,
|
||||
baseline: file.lastKnownDiskSignature,
|
||||
@@ -262,16 +260,22 @@ export function scheduleEditorSelfMoveEchoVerification(
|
||||
expectedExternalSshTargetId: file.externalSshTargetId
|
||||
})
|
||||
.then((result) => {
|
||||
const diskSignature = result.isBinary ? null : getDiskBaselineSignature(result.content)
|
||||
const matchesDisk =
|
||||
!result.isBinary &&
|
||||
((candidate.baseline !== undefined &&
|
||||
getDiskBaselineSignature(result.content) === candidate.baseline) ||
|
||||
// Why: the tab's own save can land before it rebaselines; that is not an external change.
|
||||
getRecentSelfWrite(file.filePath, file.runtimeEnvironmentId)?.content ===
|
||||
result.content)
|
||||
resolveLiveMoveVerification(candidate, matchesDisk, target.connectionId, consumeProvenance)
|
||||
})
|
||||
.catch(() =>
|
||||
resolveLiveMoveVerification(
|
||||
candidate,
|
||||
diskSignature,
|
||||
resync ? null : false,
|
||||
target.connectionId,
|
||||
consumeProvenance
|
||||
)
|
||||
})
|
||||
.catch(() =>
|
||||
resolveLiveMoveVerification(candidate, null, target.connectionId, consumeProvenance)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const harness = vi.hoisted(() => {
|
||||
state,
|
||||
runtimePathsExist: vi.fn(),
|
||||
scheduleReload: vi.fn(),
|
||||
compareDirty: vi.fn(),
|
||||
forgetCombinedBodies: vi.fn()
|
||||
}
|
||||
})
|
||||
@@ -18,7 +19,8 @@ vi.mock('@/lib/file-owner-runtime-target', () => ({
|
||||
getRuntimeTargetForFileOwner: () => ({ kind: 'local' })
|
||||
}))
|
||||
vi.mock('./editor-external-watch-disk-verification', () => ({
|
||||
scheduleDebouncedEditorExternalReload: harness.scheduleReload
|
||||
scheduleDebouncedEditorExternalReload: harness.scheduleReload,
|
||||
scheduleEditorSelfMoveEchoVerification: harness.compareDirty
|
||||
}))
|
||||
vi.mock('@/components/editor/combined-diff/remember-view/combined-diff-view-cache', () => ({
|
||||
forgetCombinedDiffViewBodiesForWorktree: harness.forgetCombinedBodies
|
||||
@@ -32,7 +34,6 @@ import {
|
||||
} from './editor-external-watch-overflow'
|
||||
|
||||
const setExternalMutation = vi.fn()
|
||||
const setPendingDiskBaselineVerification = vi.fn()
|
||||
|
||||
function tab(id: string, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
@@ -47,7 +48,7 @@ function tab(id: string, extra: Record<string, unknown> = {}): Record<string, un
|
||||
}
|
||||
|
||||
function setTabs(...openFiles: Record<string, unknown>[]): void {
|
||||
harness.state = { openFiles, setExternalMutation, setPendingDiskBaselineVerification }
|
||||
harness.state = { openFiles, setExternalMutation }
|
||||
}
|
||||
|
||||
const target = { worktreeId: 'wt-1', worktreePath: '/repo' }
|
||||
@@ -110,16 +111,22 @@ describe('reconcileEditorTabsAfterOverflow', () => {
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('a.ts', 'deleted')
|
||||
})
|
||||
|
||||
it('hands dirty tabs to the disk check instead of reloading them', () => {
|
||||
it('compares dirty tabs asynchronously without reloading them', () => {
|
||||
setTabs(tab('dirty.ts', { isDirty: true, lastKnownDiskSignature: 'sig' }))
|
||||
reconcileEditorTabsAfterOverflow(target, true)
|
||||
|
||||
expect(setPendingDiskBaselineVerification).toHaveBeenCalledWith('dirty.ts', true)
|
||||
expect(harness.compareDirty).toHaveBeenCalledWith(
|
||||
{ ...target, connectionId: undefined, runtimeEnvironmentId: null },
|
||||
['dirty.ts'],
|
||||
false,
|
||||
true
|
||||
)
|
||||
expect(setExternalMutation).not.toHaveBeenCalled()
|
||||
expect(harness.runtimePathsExist).not.toHaveBeenCalled()
|
||||
expect(harness.scheduleReload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a storm overflow reloads clean tabs without probing, pausing autosave or dropping combined bodies', () => {
|
||||
it('a storm overflow reloads clean tabs without probing or dropping combined bodies', () => {
|
||||
setTabs(
|
||||
tab('a.md', { externalMutation: 'deleted' }),
|
||||
tab('dirty.ts', { isDirty: true, lastKnownDiskSignature: 'sig' })
|
||||
@@ -129,8 +136,8 @@ describe('reconcileEditorTabsAfterOverflow', () => {
|
||||
expect(harness.scheduleReload.mock.calls).toEqual([[notification('a.md')]])
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('a.md', null)
|
||||
expect(harness.runtimePathsExist).not.toHaveBeenCalled()
|
||||
expect(setPendingDiskBaselineVerification).not.toHaveBeenCalled()
|
||||
expect(harness.forgetCombinedBodies).not.toHaveBeenCalled()
|
||||
expect(harness.compareDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a storm overflow supersedes a pending resync probe', async () => {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { EditorExternalWatchBatchPathIndex } from '@/components/editor/editor-external-watch-path-index'
|
||||
import {
|
||||
canAutoSaveOpenFile,
|
||||
isExternalReloadableEditorTab
|
||||
} from '@/components/editor/editor-autosave'
|
||||
import { isExternalReloadableEditorTab } from '@/components/editor/editor-autosave'
|
||||
import { forgetCombinedDiffViewBodiesForWorktree } from '@/components/editor/combined-diff/remember-view/combined-diff-view-cache'
|
||||
import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target'
|
||||
import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch'
|
||||
@@ -12,7 +9,6 @@ import {
|
||||
PATH_EXISTENCE_BATCH_MAX,
|
||||
type PathExistenceResult
|
||||
} from '../../../shared/path-existence-batch'
|
||||
import { requestRestoredTabReverification } from '@/components/editor/editor-restored-tab-conflict-scan'
|
||||
import {
|
||||
getLocalWindowsWslAliasOption,
|
||||
getOpenFileRuntimeOwner,
|
||||
@@ -20,6 +16,7 @@ import {
|
||||
} from './editor-external-watch-targets'
|
||||
import {
|
||||
scheduleDebouncedEditorExternalReload,
|
||||
scheduleEditorSelfMoveEchoVerification,
|
||||
type EditorExternalWatchNotification
|
||||
} from './editor-external-watch-disk-verification'
|
||||
|
||||
@@ -80,8 +77,8 @@ type EditTabProbe = {
|
||||
|
||||
/**
|
||||
* An overflow names no paths, so every clean tab of the workspace reloads.
|
||||
* A resync (the watch was down) also probes clean edit tabs for deletion and gives dirty tabs the restored-tab
|
||||
* disk check; an event-storm overflow skips both so it never pauses autosave or fans out per-tab probes.
|
||||
* A resync (the watch was down) also probes clean edit tabs for deletion; an event-storm overflow
|
||||
* skips that so it never fans out per-tab probes.
|
||||
*/
|
||||
export function reconcileEditorTabsAfterOverflow(target: OverflowTarget, resync: boolean): void {
|
||||
if (resync) {
|
||||
@@ -98,19 +95,19 @@ export function reconcileEditorTabsAfterOverflow(target: OverflowTarget, resync:
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// Why: compare dirty tabs asynchronously; autosave stays free to run its own guarded check.
|
||||
if (file.isDirty) {
|
||||
if (!resync) {
|
||||
continue
|
||||
}
|
||||
// Why: a dirty tab is never reloaded; the restored-tab scan shows the changed-on-disk banner if disk moved.
|
||||
// Mirrors the scan's eligibility so it always resolves the flag.
|
||||
if (
|
||||
file.lastKnownDiskSignature &&
|
||||
file.externalMutation !== 'changed' &&
|
||||
canAutoSaveOpenFile(file)
|
||||
) {
|
||||
requestRestoredTabReverification(file.id)
|
||||
state.setPendingDiskBaselineVerification(file.id, true)
|
||||
if (resync) {
|
||||
scheduleEditorSelfMoveEchoVerification(
|
||||
{
|
||||
...target,
|
||||
connectionId: target.connectionId,
|
||||
runtimeEnvironmentId: target.runtimeEnvironmentId ?? null
|
||||
},
|
||||
[file.id],
|
||||
false,
|
||||
true
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const harness = vi.hoisted(() => {
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), {
|
||||
getState: () => ({ openFiles: [], setPendingDiskBaselineVerification: vi.fn() })
|
||||
getState: () => ({ openFiles: [] })
|
||||
})
|
||||
}))
|
||||
vi.mock('./editor-external-watch-targets', () => ({
|
||||
|
||||
@@ -147,7 +147,6 @@ describe('self-move source suppression (in-flight)', () => {
|
||||
|
||||
describe('self-move echo verification (content identity)', () => {
|
||||
const setExternalMutation = vi.fn()
|
||||
const setPendingLiveDiskVerification = vi.fn()
|
||||
const clearSelfMoveEcho = vi.fn()
|
||||
|
||||
const BASELINE_CONTENT = 'the file on disk\n'
|
||||
@@ -175,7 +174,6 @@ describe('self-move echo verification (content identity)', () => {
|
||||
...createTestStore().getState(),
|
||||
openFiles: [file],
|
||||
setExternalMutation,
|
||||
setPendingLiveDiskVerification,
|
||||
clearSelfMoveEcho
|
||||
})
|
||||
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file])
|
||||
@@ -205,17 +203,15 @@ describe('self-move echo verification (content identity)', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('suspends autosave synchronously and suppresses when disk matches the baseline', async () => {
|
||||
it('suppresses when disk matches the baseline', async () => {
|
||||
mockState(movedDirtyTab())
|
||||
stubDiskRead({ isBinary: false, content: BASELINE_CONTENT })
|
||||
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
|
||||
|
||||
handleFsChanged(payloadFor())
|
||||
expect(setPendingLiveDiskVerification).toHaveBeenCalledWith('file-notes', true)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-notes', 'changed')
|
||||
expect(setPendingLiveDiskVerification).toHaveBeenCalledWith('file-notes', false)
|
||||
expect(clearSelfMoveEcho).toHaveBeenCalledWith('file-notes')
|
||||
dispose()
|
||||
})
|
||||
@@ -253,7 +249,6 @@ describe('self-move echo verification (content identity)', () => {
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('file-notes', 'changed')
|
||||
expect(setPendingLiveDiskVerification).toHaveBeenCalledWith('file-notes', false)
|
||||
dispose()
|
||||
})
|
||||
|
||||
@@ -284,7 +279,6 @@ describe('self-move echo verification (content identity)', () => {
|
||||
})
|
||||
|
||||
describe('verifyLatchedMoveDestinations (proactive, cross-worktree path)', () => {
|
||||
const setPendingLiveDiskVerification = vi.fn()
|
||||
const clearSelfMoveEcho = vi.fn()
|
||||
const setExternalMutation = vi.fn()
|
||||
|
||||
@@ -316,7 +310,6 @@ describe('verifyLatchedMoveDestinations (proactive, cross-worktree path)', () =>
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles: [floatingTab],
|
||||
setPendingLiveDiskVerification,
|
||||
clearSelfMoveEcho,
|
||||
setExternalMutation
|
||||
})
|
||||
@@ -332,9 +325,8 @@ describe('verifyLatchedMoveDestinations (proactive, cross-worktree path)', () =>
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(readFile).toHaveBeenCalledWith(expect.objectContaining({ filePath: FLOATING_FILE_PATH }))
|
||||
// Disk == baseline -> echo -> suppress + release the gate, no false banner.
|
||||
// Disk == baseline -> echo -> suppress, no false banner.
|
||||
expect(setExternalMutation).not.toHaveBeenCalledWith('file-x', 'changed')
|
||||
expect(setPendingLiveDiskVerification).toHaveBeenCalledWith('file-x', false)
|
||||
// Safety net does NOT consume provenance (a later watcher event still needs it).
|
||||
expect(clearSelfMoveEcho).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -30,8 +30,7 @@ vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), {
|
||||
getState: () => ({
|
||||
openFiles: harness.openFiles,
|
||||
setExternalMutation: harness.setExternalMutation,
|
||||
setPendingDiskBaselineVerification: vi.fn()
|
||||
setExternalMutation: harness.setExternalMutation
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -89,7 +89,6 @@ describe('switching back to a workspace that was not watched', () => {
|
||||
let previousApi: unknown
|
||||
let detachScan: () => void
|
||||
let settleWatch: () => void
|
||||
let emitIpc: (payload: unknown) => void
|
||||
let pathExists: Mock<(args: { filePath: string }) => Promise<boolean>>
|
||||
let reloads: string[]
|
||||
let fileChangeEvents: number
|
||||
@@ -126,6 +125,8 @@ describe('switching back to a workspace that was not watched', () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
store = createTestStore()
|
||||
harness.holder.store = store
|
||||
// Why before the edits: a tab dirtied in this session is not a restored draft.
|
||||
detachScan = attachRestoredTabConflictScan(store)
|
||||
openTab(CLEAN)
|
||||
openTab(DIRTY)
|
||||
store.getState().setEditorDraft(DIRTY, 'unsaved edit')
|
||||
@@ -141,10 +142,7 @@ describe('switching back to a workspace that was not watched', () => {
|
||||
})
|
||||
),
|
||||
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
onFsChanged: vi.fn((listener: (payload: unknown) => void) => {
|
||||
emitIpc = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
onFsChanged: vi.fn(() => vi.fn()),
|
||||
pathExists,
|
||||
pathsExist: vi.fn(async ({ filePaths }: { filePaths: string[] }) =>
|
||||
Promise.all(
|
||||
@@ -157,7 +155,6 @@ describe('switching back to a workspace that was not watched', () => {
|
||||
fileChangeEvents = 0
|
||||
window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, onReload)
|
||||
window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, onFileChange)
|
||||
detachScan = attachRestoredTabConflictScan(store)
|
||||
container = document.body.appendChild(document.createElement('div'))
|
||||
root = createRoot(container)
|
||||
})
|
||||
@@ -180,55 +177,17 @@ describe('switching back to a workspace that was not watched', () => {
|
||||
await act(async () => vi.advanceTimersByTimeAsync(200))
|
||||
}
|
||||
|
||||
it('reloads clean tabs, banners a dirty tab changed on disk, and refreshes git status/Files once', async () => {
|
||||
it('reloads clean tabs, flags a changed dirty tab without pausing it, and refreshes git status/Files once', async () => {
|
||||
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v2 agent write', isBinary: false })
|
||||
await switchBack()
|
||||
|
||||
expect(reloads).toEqual(['clean.ts'])
|
||||
expect(file(DIRTY).externalMutation).toBe('changed')
|
||||
expect(file(DIRTY).isDirty).toBe(true)
|
||||
expect(harness.readRuntimeFileContent).toHaveBeenCalledTimes(1)
|
||||
expect(file(DIRTY)).toMatchObject({ isDirty: true, externalMutation: 'changed' })
|
||||
expect(file(CLEAN).externalMutation).toBeFalsy()
|
||||
expect(fileChangeEvents).toBe(1)
|
||||
})
|
||||
|
||||
it('checks dirty tabs only once the watch is back, so a write in between still gets the banner', async () => {
|
||||
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false })
|
||||
await render([active], [background])
|
||||
await render([active, background], [])
|
||||
await act(async () => vi.advanceTimersByTimeAsync(200))
|
||||
expect(harness.readRuntimeFileContent).not.toHaveBeenCalled()
|
||||
|
||||
// An agent writes after the switch but before the watch is installed.
|
||||
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v2 late write', isBinary: false })
|
||||
await act(async () => settleWatch())
|
||||
await act(async () => vi.advanceTimersByTimeAsync(200))
|
||||
|
||||
expect(file(DIRTY).externalMutation).toBe('changed')
|
||||
})
|
||||
|
||||
it('checks dirty tabs again when a late install (capacity, SSH retry) lands', async () => {
|
||||
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false })
|
||||
// The request returns while the watcher waits for capacity; the catch-up finds nothing yet.
|
||||
await switchBack()
|
||||
expect(file(DIRTY).externalMutation).toBeFalsy()
|
||||
|
||||
harness.readRuntimeFileContent.mockResolvedValue({
|
||||
content: 'v2 before install',
|
||||
isBinary: false
|
||||
})
|
||||
// Main announces the late install with the same overflow it sends after a reconnect.
|
||||
await act(async () =>
|
||||
emitIpc({
|
||||
worktreePath: '/background',
|
||||
events: [{ kind: 'overflow', absolutePath: '/background' }],
|
||||
resync: true
|
||||
})
|
||||
)
|
||||
await act(async () => vi.advanceTimersByTimeAsync(200))
|
||||
|
||||
expect(file(DIRTY).externalMutation).toBe('changed')
|
||||
})
|
||||
|
||||
it('marks a clean tab deleted and keeps it when its file was deleted while unwatched', async () => {
|
||||
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false })
|
||||
pathExists.mockImplementation(async ({ filePath }: { filePath: string }) => filePath !== CLEAN)
|
||||
@@ -283,8 +242,9 @@ describe('switching back to a workspace that was not watched', () => {
|
||||
harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false })
|
||||
await switchBack()
|
||||
|
||||
expect(harness.readRuntimeFileContent).toHaveBeenCalledTimes(1)
|
||||
expect(file(DIRTY).externalMutation).toBeFalsy()
|
||||
expect(file(DIRTY).pendingDiskBaselineVerification).toBeFalsy()
|
||||
expect(isAutosaveSuspendedForFile(file(DIRTY))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not re-read tabs of a workspace that was never dropped', async () => {
|
||||
@@ -295,7 +255,6 @@ describe('switching back to a workspace that was not watched', () => {
|
||||
|
||||
expect(reloads).toEqual([])
|
||||
expect(harness.readRuntimeFileContent).not.toHaveBeenCalled()
|
||||
expect(file(DIRTY).pendingDiskBaselineVerification).toBeFalsy()
|
||||
})
|
||||
|
||||
it('never holds autosave on a tab edited before the watch is back', async () => {
|
||||
@@ -306,7 +265,6 @@ describe('switching back to a workspace that was not watched', () => {
|
||||
store.getState().markFileDirty(CLEAN, true)
|
||||
await act(async () => {})
|
||||
|
||||
expect(file(CLEAN).pendingDiskBaselineVerification).toBeFalsy()
|
||||
expect(isAutosaveSuspendedForFile(file(CLEAN))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16680,6 +16680,9 @@
|
||||
},
|
||||
"sectionStale": {
|
||||
"notice": "This file changed on disk since this section loaded."
|
||||
},
|
||||
"notUtf8": {
|
||||
"notice": "Autosave is paused for {{name}} because it isn't UTF-8 text. Save it yourself to convert it to UTF-8."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -98,8 +98,7 @@ describe('executeOpenEditorPathMove', () => {
|
||||
expect(moved.isDirty).toBe(true)
|
||||
expect(moved.lastKnownDiskSignature).toBe('sig-a')
|
||||
expect(useAppStore.getState().editorDrafts['/repo/sub/a.md']).toBe('unsaved work')
|
||||
// Dirty destination gets the content-verify gate + provenance.
|
||||
expect(moved.pendingLiveDiskVerification).toBe(true)
|
||||
// Dirty destination gets the echo provenance.
|
||||
expect(moved.pendingSelfMoveEcho?.targetPath).toBe('/repo/sub/a.md')
|
||||
// The in-flight transaction is settled (no leak).
|
||||
expect(__activeEditorPathMoveCountForTests()).toBe(0)
|
||||
@@ -200,8 +199,7 @@ describe('executeOpenEditorPathMove', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
const moved = useAppStore.getState().openFiles[0]!
|
||||
// Disk matched the carried baseline: gate released, no false conflict banner.
|
||||
expect(moved.pendingLiveDiskVerification).toBeFalsy()
|
||||
// Disk matched the carried baseline: no false conflict banner.
|
||||
expect(moved.externalMutation).toBeUndefined()
|
||||
expect(moved.isDirty).toBe(true)
|
||||
// Safety-net verify LEAVES the provenance so a destination watcher event
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../../shared/protocol-version'
|
||||
import { FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY } from '../../../shared/file-write-disk-precondition'
|
||||
import {
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY
|
||||
} from '../../../shared/file-write-disk-precondition'
|
||||
import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature'
|
||||
import { advanceRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status-connection-generation'
|
||||
import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision'
|
||||
@@ -33,6 +36,7 @@ function rpcOk(result: unknown) {
|
||||
|
||||
function serveHost(options: {
|
||||
enforcesPrecondition: boolean
|
||||
identicalNoop?: boolean
|
||||
diskContent?: string
|
||||
readError?: string
|
||||
readErrorCode?: string
|
||||
@@ -50,7 +54,12 @@ function serveHost(options: {
|
||||
capabilities: [
|
||||
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
|
||||
...(options.enforcesPrecondition
|
||||
? [FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY]
|
||||
? [
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
...(options.identicalNoop === false
|
||||
? []
|
||||
: [FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY])
|
||||
]
|
||||
: [])
|
||||
]
|
||||
})
|
||||
@@ -196,6 +205,73 @@ describe('writeRuntimeFile disk precondition', () => {
|
||||
expect(writeCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips writing through an older host whose changed disk already holds the text', async () => {
|
||||
serveHost({ enforcesPrecondition: false, diskContent: 'edited' })
|
||||
runtimeEnvironmentCall.mockResolvedValue(rpcOk({ ok: true }))
|
||||
|
||||
const result = await writeRuntimeFile(
|
||||
remoteContext,
|
||||
'/remote/repo/file.ts',
|
||||
'edited',
|
||||
undefined,
|
||||
precondition
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true, unchanged: true })
|
||||
expect(writeCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(['original', 'edited'])(
|
||||
'skips identical content on a v1 guarded host for an automatic save (baseline %s)',
|
||||
async (baseline) => {
|
||||
serveHost({ enforcesPrecondition: true, identicalNoop: false, diskContent: 'edited' })
|
||||
expect(
|
||||
await writeRuntimeFile(remoteContext, '/remote/repo/file.ts', 'edited', undefined, {
|
||||
expectedSignature: getDiskBaselineSignature(baseline),
|
||||
requireLosslessUtf8: true
|
||||
})
|
||||
).toEqual({ ok: true, unchanged: true })
|
||||
expect(writeCalls()).toHaveLength(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the v1 host guard for non-identical automatic saves after comparing locally', async () => {
|
||||
serveHost({ enforcesPrecondition: true, identicalNoop: false, diskContent: 'original' })
|
||||
runtimeEnvironmentCall.mockResolvedValue(rpcOk({ ok: false, diskConflict: 'changed' }))
|
||||
expect(
|
||||
await writeRuntimeFile(remoteContext, '/remote/repo/file.ts', 'edited', undefined, {
|
||||
...precondition,
|
||||
requireLosslessUtf8: true
|
||||
})
|
||||
).toEqual({ ok: false, diskConflict: 'changed' })
|
||||
expect(writeCalls()[0]?.method).toBe('files.writeIfUnchanged')
|
||||
})
|
||||
|
||||
it('sends an explicit save straight to a v1 guarded host without reading first', async () => {
|
||||
let reads = 0
|
||||
serveHost({
|
||||
enforcesPrecondition: true,
|
||||
identicalNoop: false,
|
||||
diskContent: 'edited',
|
||||
onRead: () => (reads += 1)
|
||||
})
|
||||
runtimeEnvironmentCall.mockResolvedValue(rpcOk({ ok: true }))
|
||||
await writeRuntimeFile(remoteContext, '/remote/repo/file.ts', 'edited', undefined, precondition)
|
||||
expect(reads).toBe(0)
|
||||
expect(writeCalls()[0]?.method).toBe('files.writeIfUnchanged')
|
||||
})
|
||||
|
||||
it('refuses an ambiguous automatic baseline before sending to a v1 host', async () => {
|
||||
serveHost({ enforcesPrecondition: true, identicalNoop: false, diskContent: 'a�' })
|
||||
expect(
|
||||
await writeRuntimeFile(remoteContext, '/remote/repo/file.ts', 'a�', undefined, {
|
||||
expectedSignature: getDiskBaselineSignature('a�'),
|
||||
requireLosslessUtf8: true
|
||||
})
|
||||
).toEqual({ ok: false, diskConflict: 'not-utf8' })
|
||||
expect(writeCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports a confirmed-missing file to an autosave through an older host', async () => {
|
||||
serveHost({ enforcesPrecondition: false })
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
diskConflictFromReadFailure,
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IF_UNCHANGED_METHOD,
|
||||
hostEnforcesPrecondition,
|
||||
FILE_WRITE_OK,
|
||||
FILE_WRITE_UNCHANGED,
|
||||
parseFileWriteResult,
|
||||
type FileWriteDiskConflict,
|
||||
type FileWriteDiskComparison,
|
||||
type FileWriteDiskPrecondition,
|
||||
type FileWriteResult
|
||||
} from '../../../shared/file-write-disk-precondition'
|
||||
@@ -102,7 +104,7 @@ export async function writeRemoteFileIfUnchanged(
|
||||
context.target,
|
||||
'files.write',
|
||||
async (capabilities) => {
|
||||
if (capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY)) {
|
||||
if (hostEnforcesPrecondition(capabilities, precondition)) {
|
||||
return {
|
||||
method: FILE_WRITE_IF_UNCHANGED_METHOD,
|
||||
params: { ...params, diskPrecondition: precondition }
|
||||
@@ -111,9 +113,21 @@ export async function writeRemoteFileIfUnchanged(
|
||||
const diskConflict = await checkDiskPreconditionFromClient(
|
||||
context,
|
||||
filePath,
|
||||
precondition
|
||||
precondition,
|
||||
content
|
||||
)
|
||||
return diskConflict ? { result: { ok: false, diskConflict } } : { params }
|
||||
if (diskConflict === 'identical') {
|
||||
return { result: FILE_WRITE_UNCHANGED }
|
||||
}
|
||||
if (diskConflict) {
|
||||
return { result: { ok: false, diskConflict } }
|
||||
}
|
||||
return capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY)
|
||||
? {
|
||||
method: FILE_WRITE_IF_UNCHANGED_METHOD,
|
||||
params: { ...params, diskPrecondition: precondition }
|
||||
}
|
||||
: { params }
|
||||
},
|
||||
15_000,
|
||||
pairingRevision,
|
||||
@@ -148,13 +162,18 @@ export async function writeRemoteFileIfUnchanged(
|
||||
async function checkDiskPreconditionFromClient(
|
||||
context: RemoteFileContext,
|
||||
filePath: string,
|
||||
precondition: FileWriteDiskPrecondition
|
||||
): Promise<FileWriteDiskConflict | null> {
|
||||
precondition: FileWriteDiskPrecondition,
|
||||
content: string
|
||||
): Promise<FileWriteDiskComparison> {
|
||||
let current: { content: string; isBinary: boolean }
|
||||
try {
|
||||
current = await readRemoteFile(context, filePath)
|
||||
} catch (error) {
|
||||
return diskConflictFromReadFailure(precondition, error)
|
||||
}
|
||||
return compareDiskWritePrecondition(precondition, current.isBinary ? undefined : current.content)
|
||||
return compareDiskWritePrecondition(
|
||||
precondition,
|
||||
current.isBinary ? undefined : current.content,
|
||||
content
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,8 +68,6 @@ describe('editor buffer records', () => {
|
||||
isDirty: false,
|
||||
lastKnownDiskSignature: undefined,
|
||||
externalMutation: undefined,
|
||||
pendingDiskBaselineVerification: undefined,
|
||||
pendingLiveDiskVerification: undefined,
|
||||
pendingOwnerMigration: undefined
|
||||
})
|
||||
|
||||
@@ -97,18 +95,6 @@ describe('editor buffer records', () => {
|
||||
'lastKnownDiskSignature',
|
||||
'sig-1'
|
||||
],
|
||||
[
|
||||
'setPendingDiskBaselineVerification',
|
||||
(id: string) => useAppStore.getState().setPendingDiskBaselineVerification(id, true),
|
||||
'pendingDiskBaselineVerification',
|
||||
true
|
||||
],
|
||||
[
|
||||
'setPendingLiveDiskVerification',
|
||||
(id: string) => useAppStore.getState().setPendingLiveDiskVerification(id, true),
|
||||
'pendingLiveDiskVerification',
|
||||
true
|
||||
],
|
||||
[
|
||||
'setRestoredEditorOwnerMigrationPending',
|
||||
(id: string) => useAppStore.getState().setRestoredEditorOwnerMigrationPending(id, true),
|
||||
@@ -127,19 +113,7 @@ describe('editor buffer records', () => {
|
||||
expect(fileOf(id)?.[field]).toBe(value)
|
||||
})
|
||||
|
||||
it('clears the restored-baseline gate on the record and its mirror', () => {
|
||||
const id = openEditTab()
|
||||
useAppStore.getState().setPendingDiskBaselineVerification(id, true)
|
||||
|
||||
useAppStore.getState().clearPendingDiskBaselineVerification(id)
|
||||
|
||||
expect(useAppStore.getState().editorBuffers[id]?.pendingDiskBaselineVerification).toBe(
|
||||
undefined
|
||||
)
|
||||
expect(fileOf(id)?.pendingDiskBaselineVerification).toBe(undefined)
|
||||
})
|
||||
|
||||
it('re-keys the record with a self-move and gates the moved dirty tab on both copies', async () => {
|
||||
it('re-keys the record with a self-move', async () => {
|
||||
const main = await drawTabsFromMain(useAppStore)
|
||||
const id = openEditTab()
|
||||
await main.settle()
|
||||
@@ -168,10 +142,8 @@ describe('editor buffer records', () => {
|
||||
expect(Object.keys(records)).toEqual(['/repo/b.md'])
|
||||
expect(records['/repo/b.md']).toMatchObject({
|
||||
isDirty: true,
|
||||
lastKnownDiskSignature: 'sig-a',
|
||||
pendingLiveDiskVerification: true
|
||||
lastKnownDiskSignature: 'sig-a'
|
||||
})
|
||||
expect(fileOf('/repo/b.md')?.pendingLiveDiskVerification).toBe(true)
|
||||
})
|
||||
|
||||
it('creates hydrated records from the session, not from a replaced same-id tab', () => {
|
||||
@@ -203,8 +175,7 @@ describe('editor buffer records', () => {
|
||||
|
||||
expect(useAppStore.getState().editorBuffers[id]).toMatchObject({
|
||||
isDirty: true,
|
||||
lastKnownDiskSignature: 'sig-saved',
|
||||
pendingDiskBaselineVerification: true
|
||||
lastKnownDiskSignature: 'sig-saved'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -452,14 +452,10 @@ describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('open files renamed on disk, layout-ow
|
||||
expect.objectContaining({
|
||||
id: '/repo-a/lib/a.ts',
|
||||
isDirty: true,
|
||||
pendingLiveDiskVerification: true,
|
||||
pendingSelfMoveEcho: { operationId: 'move-1', targetPath: '/repo-a/lib/a.ts' }
|
||||
})
|
||||
])
|
||||
expect(state.editorDrafts).toEqual({ '/repo-a/lib/a.ts': 'unsaved' })
|
||||
expect(state.editorBuffers['/repo-a/lib/a.ts']).toMatchObject({
|
||||
pendingLiveDiskVerification: true
|
||||
})
|
||||
})
|
||||
|
||||
it('changes nothing when main refuses the rename', async () => {
|
||||
|
||||
@@ -139,7 +139,6 @@ describe('read-only editor tabs (AI Vault View Log)', () => {
|
||||
expect(restored).toEqual(
|
||||
expect.objectContaining({ readOnly: true, liveTail: true, isDirty: false, mode: 'edit' })
|
||||
)
|
||||
expect(restored?.pendingDiskBaselineVerification).toBeUndefined()
|
||||
expect(store.getState().editorDrafts[LOG_PATH]).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('rekeyOpenFilesForPathChange', () => {
|
||||
expect(moved.filePath).toBe('/repo/named.md')
|
||||
})
|
||||
|
||||
it('installs the move-echo gate + provenance on a dirty destination when moveOperationId is set', async () => {
|
||||
it('installs the move-echo provenance on a dirty destination when moveOperationId is set', async () => {
|
||||
seedEditTab()
|
||||
const oldId = useAppStore.getState().openFiles[0]!.id
|
||||
const newPath = '/repo/sub/a.md'
|
||||
@@ -233,16 +233,13 @@ describe('rekeyOpenFilesForPathChange', () => {
|
||||
})
|
||||
|
||||
const moved = useAppStore.getState().openFiles[0]!
|
||||
// Autosave is gated synchronously by the same commit that re-homes the tab.
|
||||
expect(moved.pendingLiveDiskVerification).toBe(true)
|
||||
expect(moved.pendingSelfMoveEcho).toEqual({ operationId: 'op-42', targetPath: newPath })
|
||||
})
|
||||
|
||||
it('does not gate a tab already showing the changed-on-disk banner', async () => {
|
||||
it('does not verify a tab already showing the changed-on-disk banner', async () => {
|
||||
seedEditTab()
|
||||
const oldId = useAppStore.getState().openFiles[0]!.id
|
||||
// A dirty tab that already conflicts with disk: autosave is suspended by the
|
||||
// banner; gating it would strand the gate (verification skips a 'changed' tab).
|
||||
// A dirty tab that already conflicts with disk keeps its banner.
|
||||
useAppStore.getState().setExternalMutation(oldId, 'changed')
|
||||
|
||||
await rekey({
|
||||
@@ -252,7 +249,6 @@ describe('rekeyOpenFilesForPathChange', () => {
|
||||
|
||||
const moved = useAppStore.getState().openFiles[0]!
|
||||
expect(moved.externalMutation).toBe('changed')
|
||||
expect(moved.pendingLiveDiskVerification).toBeUndefined()
|
||||
expect(moved.pendingSelfMoveEcho).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -272,7 +268,7 @@ describe('rekeyOpenFilesForPathChange', () => {
|
||||
expect(reveal.filePath).toBe('/repo/sub/a.md')
|
||||
})
|
||||
|
||||
it('does not gate a clean destination', async () => {
|
||||
it('does not verify a clean destination', async () => {
|
||||
seedEditTab()
|
||||
const oldId = useAppStore.getState().openFiles[0]!.id
|
||||
useAppStore.getState().markFileDirty(oldId, false)
|
||||
@@ -283,7 +279,6 @@ describe('rekeyOpenFilesForPathChange', () => {
|
||||
})
|
||||
|
||||
const moved = useAppStore.getState().openFiles[0]!
|
||||
expect(moved.pendingLiveDiskVerification).toBeUndefined()
|
||||
expect(moved.pendingSelfMoveEcho).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -113,13 +113,6 @@ export function buildHydratedEditorSessionState(
|
||||
...(isReadOnly ? { readOnly: true } : {}),
|
||||
...(isReadOnly && pf.liveTail === true ? { liveTail: true } : {}),
|
||||
lastKnownDiskSignature: isReadOnly ? undefined : pf.lastKnownDiskSignature,
|
||||
// Why: suspend autosave until the conflict scan verifies disk vs baseline, else a slow remote read clobbers an offline write.
|
||||
pendingDiskBaselineVerification:
|
||||
!isReadOnly &&
|
||||
pf.dirtyDraftContent !== undefined &&
|
||||
pf.lastKnownDiskSignature !== undefined
|
||||
? true
|
||||
: undefined,
|
||||
mode: 'edit'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ export function createOpenFileMutations(
|
||||
| 'markFileDirty'
|
||||
| 'setExternalMutation'
|
||||
| 'setLastKnownDiskSignature'
|
||||
| 'clearPendingDiskBaselineVerification'
|
||||
| 'setPendingDiskBaselineVerification'
|
||||
| 'setPendingLiveDiskVerification'
|
||||
| 'clearSelfMoveEcho'
|
||||
| 'clearUntitled'
|
||||
> {
|
||||
@@ -146,19 +143,6 @@ export function createOpenFileMutations(
|
||||
setLastKnownDiskSignature: (fileId, signature) =>
|
||||
set((s) => updateEditorBuffer(s, fileId, { lastKnownDiskSignature: signature })),
|
||||
|
||||
clearPendingDiskBaselineVerification: (fileId) =>
|
||||
get().setPendingDiskBaselineVerification(fileId, false),
|
||||
|
||||
setPendingDiskBaselineVerification: (fileId, value) =>
|
||||
set((s) =>
|
||||
updateEditorBuffer(s, fileId, { pendingDiskBaselineVerification: value || undefined })
|
||||
),
|
||||
|
||||
setPendingLiveDiskVerification: (fileId, value) =>
|
||||
set((s) =>
|
||||
updateEditorBuffer(s, fileId, { pendingLiveDiskVerification: value || undefined })
|
||||
),
|
||||
|
||||
clearSelfMoveEcho: (fileId) =>
|
||||
set((s) => {
|
||||
const file = s.openFiles.find((f) => f.id === fileId)
|
||||
|
||||
@@ -44,23 +44,19 @@ export function rekeyOpenFilesPatch(
|
||||
}
|
||||
}
|
||||
|
||||
const echoGatedIds = new Set<string>()
|
||||
const nextOpenFiles = s.openFiles.map((f) => {
|
||||
const rekey = rekeyByOldId.get(f.id)
|
||||
if (!rekey) {
|
||||
return f
|
||||
}
|
||||
// Spread the whole OpenFile so fields this action doesn't know about survive; change only the path-derived ones.
|
||||
// Gate atomically here so autosave is suspended before any echo can be verified (only a dirty autosave-capable tab can be clobbered).
|
||||
const gatesEcho =
|
||||
// Only a dirty autosave-capable tab needs its destination echo verified.
|
||||
const verifiesEcho =
|
||||
moveOperationId !== undefined &&
|
||||
f.isDirty &&
|
||||
// A 'changed' tab is already autosave-suspended via externalMutation; gating it would strand the gate (verification skips a 'changed' tab), so leave the banner as terminal.
|
||||
// A 'changed' tab already shows the banner, which verification would only repeat.
|
||||
f.externalMutation !== 'changed' &&
|
||||
(f.mode === 'edit' || (f.mode === 'diff' && f.diffSource === 'unstaged'))
|
||||
if (gatesEcho) {
|
||||
echoGatedIds.add(f.id)
|
||||
}
|
||||
return {
|
||||
...f,
|
||||
id: rekey.newFileId,
|
||||
@@ -75,9 +71,8 @@ export function rekeyOpenFilesPatch(
|
||||
...(rekey.consumeUntitled
|
||||
? { isUntitled: undefined, deleteUntouchedOnClose: undefined }
|
||||
: {}),
|
||||
...(gatesEcho
|
||||
...(verifiesEcho
|
||||
? {
|
||||
pendingLiveDiskVerification: true,
|
||||
pendingSelfMoveEcho: {
|
||||
operationId: moveOperationId,
|
||||
targetPath: rekey.newFilePath
|
||||
@@ -119,9 +114,7 @@ export function rekeyOpenFilesPatch(
|
||||
ok: true,
|
||||
patch: {
|
||||
openFiles: nextOpenFiles,
|
||||
editorBuffers: rekeyEditorBuffers(s.editorBuffers, s.openFiles, migrations, (f) =>
|
||||
echoGatedIds.has(f.id) ? { pendingLiveDiskVerification: true } : undefined
|
||||
),
|
||||
editorBuffers: rekeyEditorBuffers(s.editorBuffers, s.openFiles, migrations),
|
||||
editorDrafts: rekeyFileIdRecord(s.editorDrafts, migrations),
|
||||
editorCursorLine: rekeyFileIdRecord(s.editorCursorLine, migrations),
|
||||
markdownViewMode: rekeyFileIdRecord(s.markdownViewMode, migrations),
|
||||
|
||||
@@ -11,12 +11,7 @@ import {
|
||||
*/
|
||||
export type EditorBufferRecord = Pick<
|
||||
OpenFile,
|
||||
| 'isDirty'
|
||||
| 'lastKnownDiskSignature'
|
||||
| 'externalMutation'
|
||||
| 'pendingDiskBaselineVerification'
|
||||
| 'pendingLiveDiskVerification'
|
||||
| 'pendingOwnerMigration'
|
||||
'isDirty' | 'lastKnownDiskSignature' | 'externalMutation' | 'pendingOwnerMigration'
|
||||
>
|
||||
|
||||
export type EditorBufferRecords = Record<string, EditorBufferRecord>
|
||||
@@ -31,8 +26,6 @@ export const EDITOR_BUFFER_FIELDS = [
|
||||
'isDirty',
|
||||
'lastKnownDiskSignature',
|
||||
'externalMutation',
|
||||
'pendingDiskBaselineVerification',
|
||||
'pendingLiveDiskVerification',
|
||||
'pendingOwnerMigration'
|
||||
] as const satisfies readonly (keyof EditorBufferRecord)[]
|
||||
|
||||
@@ -41,8 +34,6 @@ export function editorBufferRecordFromFile(file: OpenFile): EditorBufferRecord {
|
||||
isDirty: file.isDirty,
|
||||
lastKnownDiskSignature: file.lastKnownDiskSignature,
|
||||
externalMutation: file.externalMutation,
|
||||
pendingDiskBaselineVerification: file.pendingDiskBaselineVerification,
|
||||
pendingLiveDiskVerification: file.pendingLiveDiskVerification,
|
||||
pendingOwnerMigration: file.pendingOwnerMigration
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +108,6 @@ export type EditorFilesSlice = {
|
||||
setExternalMutation: (fileId: string, mutation: 'deleted' | 'renamed' | 'changed' | null) => void
|
||||
/** `undefined` un-records a baseline a failed operation stamped provisionally. */
|
||||
setLastKnownDiskSignature: (fileId: string, signature: string | undefined) => void
|
||||
clearPendingDiskBaselineVerification: (fileId: string) => void
|
||||
setPendingDiskBaselineVerification: (fileId: string, value: boolean) => void
|
||||
setPendingLiveDiskVerification: (fileId: string, value: boolean) => void
|
||||
clearSelfMoveEcho: (fileId: string) => void
|
||||
/** Atomically retargets open editor sessions across an Orca-owned move — one commit-only update migrating every path-derived id + all id-keyed state, no close/reopen. Returns collision/stale without mutating. */
|
||||
rekeyOpenFilesForPathChange: (args: {
|
||||
|
||||
@@ -73,10 +73,6 @@ export type OpenFile = {
|
||||
externalMutation?: 'deleted' | 'renamed' | 'changed'
|
||||
/** Signature of the disk content this tab's edits are based on; persisted so a restore detects a changed-on-disk conflict before autosave clobbers an agent write. */
|
||||
lastKnownDiskSignature?: string
|
||||
/** Why: gates autosave for restored dirty tabs until the conflict scan compares disk vs baseline, else a slow SSH read loses the race. Not persisted. */
|
||||
pendingDiskBaselineVerification?: boolean
|
||||
/** Why: gates autosave during a live self-move echo's disk verification; separate flag from the restored scan's so the two can't clear each other's gate. Not persisted. */
|
||||
pendingLiveDiskVerification?: boolean
|
||||
/** Blocks saves while a restored tab's filesystem authority is being replaced. */
|
||||
pendingOwnerMigration?: boolean
|
||||
/** Why: routes an Orca-owned move's destination-watcher echo into content verification. On the tab so it survives the atomic rekey; operationId supersedes a stale verification on re-move. Not persisted. */
|
||||
|
||||
@@ -77,8 +77,7 @@ function openRestoredSource(): string {
|
||||
worktreeId: SOURCE,
|
||||
runtimeEnvironmentId: null,
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
pendingDiskBaselineVerification: true
|
||||
mode: 'edit'
|
||||
},
|
||||
{ suppressActiveRuntimeFallback: true }
|
||||
)
|
||||
@@ -172,8 +171,7 @@ describe('restored editor owner reparent', () => {
|
||||
relativePath: 'docs/readme.md',
|
||||
worktreeId: TARGET,
|
||||
runtimeEnvironmentId: null,
|
||||
isDirty: true,
|
||||
pendingDiskBaselineVerification: true
|
||||
isDirty: true
|
||||
})
|
||||
expect(moved.pendingOwnerMigration).toBeUndefined()
|
||||
expect(moved.operationProvenance?.generation.route).toEqual({
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
diskConflictFromReadFailure,
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IF_UNCHANGED_METHOD,
|
||||
hostEnforcesPrecondition,
|
||||
FILE_WRITE_UNCHANGED,
|
||||
parseFileWriteResult,
|
||||
type FileWriteDiskConflict,
|
||||
type FileWriteDiskComparison,
|
||||
type FileWriteDiskPrecondition,
|
||||
type FileWriteResult
|
||||
} from '../../../shared/file-write-disk-precondition'
|
||||
@@ -135,8 +137,9 @@ async function readWholeTextFile(
|
||||
async function checkWebDiskPreconditionFromClient(
|
||||
session: WebFileMutationSession,
|
||||
file: ResolvedWebRuntimeFile,
|
||||
precondition: FileWriteDiskPrecondition
|
||||
): Promise<FileWriteDiskConflict | null> {
|
||||
precondition: FileWriteDiskPrecondition,
|
||||
content: string
|
||||
): Promise<FileWriteDiskComparison> {
|
||||
let preview: unknown
|
||||
try {
|
||||
preview = await session.callRuntimeResult('files.readPreview', {
|
||||
@@ -148,7 +151,7 @@ async function checkWebDiskPreconditionFromClient(
|
||||
if (error instanceof Error && error.message === 'file_too_large') {
|
||||
// Why undefined on failure: a host that can't page it keeps the earlier answer, 'changed'.
|
||||
const text = await readWholeTextFile(session, file).catch(() => undefined)
|
||||
return compareDiskWritePrecondition(precondition, text)
|
||||
return compareDiskWritePrecondition(precondition, text, content)
|
||||
}
|
||||
return diskConflictFromReadFailure(precondition, error)
|
||||
}
|
||||
@@ -160,7 +163,7 @@ async function checkWebDiskPreconditionFromClient(
|
||||
typeof preview.content === 'string'
|
||||
? preview.content
|
||||
: undefined
|
||||
return compareDiskWritePrecondition(precondition, text)
|
||||
return compareDiskWritePrecondition(precondition, text, content)
|
||||
}
|
||||
|
||||
export function createWebFileMutationMethods(
|
||||
@@ -205,7 +208,7 @@ export function createWebFileMutationMethods(
|
||||
if (!diskPrecondition) {
|
||||
return { method: 'files.write', params }
|
||||
}
|
||||
if (capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY)) {
|
||||
if (hostEnforcesPrecondition(capabilities, diskPrecondition)) {
|
||||
return {
|
||||
method: FILE_WRITE_IF_UNCHANGED_METHOD,
|
||||
params: { ...params, diskPrecondition }
|
||||
@@ -214,9 +217,18 @@ export function createWebFileMutationMethods(
|
||||
const diskConflict = await checkWebDiskPreconditionFromClient(
|
||||
session,
|
||||
file,
|
||||
diskPrecondition
|
||||
diskPrecondition,
|
||||
content
|
||||
)
|
||||
return diskConflict ? { ok: false, diskConflict } : { method: 'files.write', params }
|
||||
if (diskConflict === 'identical') {
|
||||
return FILE_WRITE_UNCHANGED
|
||||
}
|
||||
if (diskConflict) {
|
||||
return { ok: false, diskConflict }
|
||||
}
|
||||
return capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY)
|
||||
? { method: FILE_WRITE_IF_UNCHANGED_METHOD, params: { ...params, diskPrecondition } }
|
||||
: { method: 'files.write', params }
|
||||
},
|
||||
diskPrecondition !== undefined
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature'
|
||||
import { FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY } from '../../../shared/file-write-disk-precondition'
|
||||
import {
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY
|
||||
} from '../../../shared/file-write-disk-precondition'
|
||||
import { createWebFileMutationMethods } from './web-file-mutation-methods'
|
||||
|
||||
const precondition = { expectedSignature: getDiskBaselineSignature('original') }
|
||||
@@ -37,7 +40,7 @@ function methodsFor(
|
||||
describe('paired web client save precondition', () => {
|
||||
it('lets a server that enforces the precondition compare at write time', async () => {
|
||||
const { methods, callRuntimeResult, calledMethods } = methodsFor(
|
||||
[FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY],
|
||||
[FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY, FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY],
|
||||
'original'
|
||||
)
|
||||
|
||||
@@ -64,6 +67,19 @@ describe('paired web client save precondition', () => {
|
||||
expect(calledMethods()).toEqual(['files.readPreview'])
|
||||
})
|
||||
|
||||
it('skips writing through an older server whose changed disk already holds the text', async () => {
|
||||
const { methods, calledMethods } = methodsFor([], 'edited')
|
||||
|
||||
const result = await methods.writeFile({
|
||||
filePath: '/hub/repo/readme.md',
|
||||
content: 'edited',
|
||||
diskPrecondition: precondition
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, unchanged: true })
|
||||
expect(calledMethods()).toEqual(['files.readPreview'])
|
||||
})
|
||||
|
||||
it('writes through an older server without the field once the disk matches', async () => {
|
||||
const { methods, callRuntimeResult, calledMethods } = methodsFor([], 'original')
|
||||
|
||||
@@ -78,6 +94,71 @@ describe('paired web client save precondition', () => {
|
||||
expect(callRuntimeResult.mock.calls[1]?.[1]).not.toHaveProperty('diskPrecondition')
|
||||
})
|
||||
|
||||
it.each(['original', 'edited'])(
|
||||
'skips identical content on a v1 guarded server for an automatic save (baseline %s)',
|
||||
async (baseline) => {
|
||||
const { methods, calledMethods } = methodsFor(
|
||||
[FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY],
|
||||
'edited'
|
||||
)
|
||||
expect(
|
||||
await methods.writeFile({
|
||||
filePath: '/hub/repo/readme.md',
|
||||
content: 'edited',
|
||||
diskPrecondition: {
|
||||
expectedSignature: getDiskBaselineSignature(baseline),
|
||||
requireLosslessUtf8: true
|
||||
}
|
||||
})
|
||||
).toEqual({ ok: true, unchanged: true })
|
||||
expect(calledMethods()).toEqual(['files.readPreview'])
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the v1 server guard for non-identical automatic saves', async () => {
|
||||
const { methods, calledMethods } = methodsFor(
|
||||
[FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY],
|
||||
'original'
|
||||
)
|
||||
await methods.writeFile({
|
||||
filePath: '/hub/repo/readme.md',
|
||||
content: 'edited',
|
||||
diskPrecondition: { ...precondition, requireLosslessUtf8: true }
|
||||
})
|
||||
expect(calledMethods()).toEqual(['files.readPreview', 'files.writeIfUnchanged'])
|
||||
})
|
||||
|
||||
it('sends an explicit save straight to a v1 guarded server without reading first', async () => {
|
||||
const { methods, calledMethods } = methodsFor(
|
||||
[FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY],
|
||||
'original'
|
||||
)
|
||||
await methods.writeFile({
|
||||
filePath: '/hub/repo/readme.md',
|
||||
content: 'edited',
|
||||
diskPrecondition: precondition
|
||||
})
|
||||
expect(calledMethods()).toEqual(['files.writeIfUnchanged'])
|
||||
})
|
||||
|
||||
it('refuses an ambiguous automatic baseline before sending to a v1 server', async () => {
|
||||
const { methods, calledMethods } = methodsFor(
|
||||
[FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY],
|
||||
'a�'
|
||||
)
|
||||
expect(
|
||||
await methods.writeFile({
|
||||
filePath: '/hub/repo/readme.md',
|
||||
content: 'a�',
|
||||
diskPrecondition: {
|
||||
expectedSignature: getDiskBaselineSignature('a�'),
|
||||
requireLosslessUtf8: true
|
||||
}
|
||||
})
|
||||
).toEqual({ ok: false, diskConflict: 'not-utf8' })
|
||||
expect(calledMethods()).toEqual(['files.readPreview'])
|
||||
})
|
||||
|
||||
it('reports a confirmed-missing file to an autosave through an older server', async () => {
|
||||
const { methods, calledMethods } = methodsFor([], null)
|
||||
|
||||
|
||||
@@ -13,16 +13,60 @@ import {
|
||||
describe('disk write precondition', () => {
|
||||
it('compares decoded disk text against the baseline signature', () => {
|
||||
const precondition = { expectedSignature: getDiskBaselineSignature('base') }
|
||||
expect(compareDiskWritePrecondition(precondition, 'base')).toBeNull()
|
||||
expect(compareDiskWritePrecondition(precondition, 'newer')).toBe('changed')
|
||||
expect(compareDiskWritePrecondition(precondition, undefined)).toBe('changed')
|
||||
expect(compareDiskWritePrecondition(precondition, null)).toBe('missing')
|
||||
expect(compareDiskWritePrecondition({ ...precondition, allowMissing: true }, null)).toBeNull()
|
||||
expect(compareDiskWritePrecondition(precondition, 'base', 'mine')).toBeNull()
|
||||
expect(compareDiskWritePrecondition(precondition, 'newer', 'mine')).toBe('changed')
|
||||
expect(compareDiskWritePrecondition(precondition, undefined, 'mine')).toBe('changed')
|
||||
expect(compareDiskWritePrecondition(precondition, null, 'mine')).toBe('missing')
|
||||
expect(
|
||||
compareDiskWritePrecondition({ ...precondition, allowMissing: true }, null, 'mine')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a changed disk that already holds exactly the text being saved', () => {
|
||||
const precondition = { expectedSignature: getDiskBaselineSignature('base') }
|
||||
expect(compareDiskWritePrecondition(precondition, 'mine', 'mine')).toBe('identical')
|
||||
expect(compareDiskWritePrecondition(precondition, 'base', 'base')).toBe('identical')
|
||||
expect(compareDiskWritePrecondition(precondition, 'mine\r\n', 'mine\n')).toBe('changed')
|
||||
expect(compareDiskWritePrecondition(precondition, undefined, 'mine')).toBe('changed')
|
||||
// A replacement character may stand for bytes the decode dropped, so it proves nothing.
|
||||
expect(compareDiskWritePrecondition(precondition, 'a�', 'a�')).toBe('changed')
|
||||
})
|
||||
|
||||
it('refuses ambiguous automatic baseline matches but preserves explicit saves', () => {
|
||||
const decoded = Buffer.from([0x61, 0xfe]).toString('utf8')
|
||||
const current = Buffer.from([0x61, 0xff]).toString('utf8')
|
||||
const precondition = { expectedSignature: getDiskBaselineSignature(decoded) }
|
||||
expect(
|
||||
compareDiskWritePrecondition({ ...precondition, requireLosslessUtf8: true }, current, decoded)
|
||||
).toBe('not-utf8')
|
||||
expect(compareDiskWritePrecondition(precondition, current, decoded)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an external rewrite as changed before checking for U+FFFD', () => {
|
||||
expect(
|
||||
compareDiskWritePrecondition(
|
||||
{ expectedSignature: getDiskBaselineSignature('original'), requireLosslessUtf8: true },
|
||||
'rewritten �',
|
||||
'mine'
|
||||
)
|
||||
).toBe('changed')
|
||||
})
|
||||
|
||||
it('treats anything but an explicit refusal from a host as a completed write', () => {
|
||||
expect(parseFileWriteResult({ ok: false, diskConflict: 'not-utf8' })).toEqual({
|
||||
ok: false,
|
||||
diskConflict: 'not-utf8'
|
||||
})
|
||||
expect(parseFileWriteResult({ ok: false, diskConflict: 'later' })).toEqual({
|
||||
ok: false,
|
||||
diskConflict: 'changed'
|
||||
})
|
||||
expect(parseFileWriteResult(undefined)).toEqual({ ok: true })
|
||||
expect(parseFileWriteResult({ ok: true })).toEqual({ ok: true })
|
||||
expect(parseFileWriteResult({ ok: true, unchanged: true })).toEqual({
|
||||
ok: true,
|
||||
unchanged: true
|
||||
})
|
||||
expect(parseFileWriteResult({ ok: false, diskConflict: 'missing' })).toEqual({
|
||||
ok: false,
|
||||
diskConflict: 'missing'
|
||||
|
||||
@@ -4,42 +4,86 @@ import { getDiskBaselineSignature, getWrittenContentDiskSignature } from './disk
|
||||
export const FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY =
|
||||
'files.write.disk-precondition.v1' as const
|
||||
|
||||
// Why separate: v1 hosts can rewrite identical text and cannot enforce lossless automatic saves.
|
||||
export const FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY = 'files.write.identical-noop.v1' as const
|
||||
|
||||
export const FILE_WRITE_IF_UNCHANGED_METHOD = 'files.writeIfUnchanged'
|
||||
|
||||
/**
|
||||
* Whether the host's guarded write can be sent as is. A v1 host can't check bytes or skip identical
|
||||
* text, so only automatic saves (which must refuse non-UTF-8 files) pay for a client-side read first.
|
||||
*/
|
||||
export function hostEnforcesPrecondition(
|
||||
capabilities: readonly unknown[],
|
||||
precondition: FileWriteDiskPrecondition
|
||||
): boolean {
|
||||
return (
|
||||
capabilities.includes(FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY) ||
|
||||
(capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY) &&
|
||||
precondition.requireLosslessUtf8 !== true)
|
||||
)
|
||||
}
|
||||
|
||||
/** Refuse the write unless disk still holds the content this signature was taken over. */
|
||||
export type FileWriteDiskPrecondition = {
|
||||
expectedSignature: string
|
||||
/** A missing file is written (recreated) instead of refused. */
|
||||
allowMissing?: boolean
|
||||
/** Automatic saves refuse a file that isn't valid UTF-8 rather than convert it unasked. */
|
||||
requireLosslessUtf8?: boolean
|
||||
}
|
||||
|
||||
export type FileWriteDiskConflict = 'changed' | 'missing'
|
||||
/** `not-utf8`: an automatic save met bytes it can't round-trip; older clients read it as `changed`. */
|
||||
export type FileWriteDiskConflict = 'changed' | 'missing' | 'not-utf8'
|
||||
|
||||
export type FileWriteResult = { ok: true } | { ok: false; diskConflict: FileWriteDiskConflict }
|
||||
export type FileWriteDiskComparison = FileWriteDiskConflict | 'identical' | null
|
||||
|
||||
export type FileWriteResult =
|
||||
| { ok: true; unchanged?: boolean }
|
||||
| { ok: false; diskConflict: FileWriteDiskConflict }
|
||||
|
||||
export const FILE_WRITE_OK: FileWriteResult = { ok: true }
|
||||
export const FILE_WRITE_UNCHANGED: FileWriteResult = { ok: true, unchanged: true }
|
||||
|
||||
/** `current` is the decoded disk text, `null` when missing, `undefined` when not comparable text. */
|
||||
/** `current` is the decoded disk text, `null` when missing, `undefined` when not comparable text; `content` is the text being saved. */
|
||||
export function compareDiskWritePrecondition(
|
||||
precondition: FileWriteDiskPrecondition,
|
||||
current: string | null | undefined
|
||||
): FileWriteDiskConflict | null {
|
||||
current: string | null | undefined,
|
||||
content: string
|
||||
): FileWriteDiskComparison {
|
||||
if (current === null) {
|
||||
return precondition.allowMissing === true ? null : 'missing'
|
||||
}
|
||||
if (current === undefined) {
|
||||
return 'changed'
|
||||
}
|
||||
return getDiskBaselineSignature(current) === precondition.expectedSignature ? null : 'changed'
|
||||
// Why: U+FFFD may stand for bytes a decode dropped, so decoded equality proves nothing.
|
||||
if (current === content && !current.includes('\uFFFD')) {
|
||||
return 'identical'
|
||||
}
|
||||
if (getDiskBaselineSignature(current) !== precondition.expectedSignature) {
|
||||
return 'changed'
|
||||
}
|
||||
// Why: decoded text can't tell a real U+FFFD from replaced bytes, so a reader of text stays conservative.
|
||||
return precondition.requireLosslessUtf8 && current.includes('\uFFFD') ? 'not-utf8' : null
|
||||
}
|
||||
|
||||
/** Why: old hosts answer `{ ok: true }` or nothing; only an explicit `ok: false` is a refusal. */
|
||||
export function parseFileWriteResult(value: unknown): FileWriteResult {
|
||||
if (typeof value !== 'object' || value === null || !('ok' in value) || value.ok !== false) {
|
||||
if (typeof value !== 'object' || value === null || !('ok' in value)) {
|
||||
return FILE_WRITE_OK
|
||||
}
|
||||
if (value.ok !== false) {
|
||||
return value.ok === true && 'unchanged' in value && value.unchanged === true
|
||||
? FILE_WRITE_UNCHANGED
|
||||
: FILE_WRITE_OK
|
||||
}
|
||||
const diskConflict = 'diskConflict' in value ? value.diskConflict : undefined
|
||||
return { ok: false, diskConflict: diskConflict === 'missing' ? 'missing' : 'changed' }
|
||||
return {
|
||||
ok: false,
|
||||
diskConflict:
|
||||
diskConflict === 'missing' || diskConflict === 'not-utf8' ? diskConflict : 'changed'
|
||||
}
|
||||
}
|
||||
|
||||
/** Only a not-found answer proves the file is gone; a lost transport proves nothing. */
|
||||
@@ -79,7 +123,7 @@ export function diskConflictFromReadFailure(
|
||||
error: unknown
|
||||
): FileWriteDiskConflict | null {
|
||||
if (isConfirmedFileNotFoundError(error)) {
|
||||
return compareDiskWritePrecondition(precondition, null)
|
||||
return precondition.allowMissing === true ? null : 'missing'
|
||||
}
|
||||
// Why: the baseline was read whole, so content too big to read now cannot match it.
|
||||
if (isFileTooLargeToReadError(error)) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY } from './file-write-disk-precondition'
|
||||
import {
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY
|
||||
} from './file-write-disk-precondition'
|
||||
|
||||
// Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC.
|
||||
export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const
|
||||
@@ -12,5 +15,6 @@ export const FILES_RUNTIME_CAPABILITIES = [
|
||||
'files.resolve-terminal-path.cross-workspace.v1',
|
||||
FILE_DOC_PREVIEW_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY,
|
||||
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
@@ -32,7 +32,8 @@ export const FileWrite = FileMutationOpen.extend({
|
||||
export const FileWriteIfUnchanged = FileWrite.extend({
|
||||
diskPrecondition: z.object({
|
||||
expectedSignature: z.string().min(1).max(64),
|
||||
allowMissing: z.boolean().optional()
|
||||
allowMissing: z.boolean().optional(),
|
||||
requireLosslessUtf8: z.boolean().optional()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user