diff --git a/src/main/persistence-orcad-migration-editor-drafts-fixture.ts b/src/main/persistence-orcad-migration-editor-drafts-fixture.ts deleted file mode 100644 index 4e0b8e812e9..00000000000 --- a/src/main/persistence-orcad-migration-editor-drafts-fixture.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { createHash } from 'node:crypto' -import { join } from 'node:path' -import { - ORCAD_MIGRATION_BLOB_CHUNK_BYTES, - type OrcadMigrationBlobDescriptor, - type OrcadMigrationBlobKind -} from '../shared/orcad-migration/orcad-migration-blob-transfer' -import type { OrcadMigrationEditorDraft } from '../shared/orcad-migration/orcad-migration-editor-drafts' -import type { OrcadMigrationManifest } from '../shared/orcad-migration/orcad-migration-manifest' -import type { PersistedOpenFile } from '../shared/workspace-session-state-types' -import { getDefaultWorkspaceSession } from '../shared/constants' -import { getOrcadMigrationReceivedBlobPath } from './persistence/migrating-orcad-catalog/orcad-migration-blob-staging' -import { commitStagedOrcadMigrationCatalogDurably } from './runtime/orcad-migration-catalog-import' -import { - DORMANT_LEAF_ID, - DORMANT_WORKTREE_ID, - dormantState, - manifest -} from './persistence-orcad-migration-catalog-fixture' -import { testState, type createStore } from './persistence-test-harness' - -export type Store = ReturnType - -export const FILE_A = '/srv/repo-1-worktree/src/a.ts' -export const FILE_B = '/srv/repo-1-worktree/src/b.ts' - -export function openFile(filePath: string, dirtyDraftContent?: string): PersistedOpenFile { - return { - filePath, - relativePath: filePath.slice('/srv/repo-1-worktree/'.length), - worktreeId: DORMANT_WORKTREE_ID, - language: 'typescript', - ...(dirtyDraftContent === undefined ? {} : { dirtyDraftContent }) - } -} - -export type EncodedBlob = { bytes: Buffer; descriptor: OrcadMigrationBlobDescriptor } - -function encodeBlob(blobKind: OrcadMigrationBlobKind, key: string, value: unknown): EncodedBlob { - const bytes = Buffer.from(JSON.stringify(value)) - return { - bytes, - descriptor: { - blobKind, - key, - sha256: createHash('sha256').update(bytes).digest('hex'), - byteLength: bytes.length - } - } -} - -export function encodeDrafts(drafts: OrcadMigrationEditorDraft[]): EncodedBlob { - return encodeBlob('editor-drafts', 'drafts', { version: 1, drafts }) -} - -/** A restore hint for the fixture's dormant pane. */ -export const HINTED_PANE = `tab-dormant:${DORMANT_LEAF_ID}` -export function encodeHints(cwd: string): EncodedBlob { - return encodeBlob('terminal-restore-hints', 'migration', { - version: 1, - hints: [{ paneKey: HINTED_PANE, cwd }] - }) -} - -/** A capable desktop's manifest: FILE_A's draft shed into the blob, FILE_B clean. */ -export function declaredManifest( - declared: OrcadMigrationBlobDescriptor | null, - entries = [{ worktreeKey: DORMANT_WORKTREE_ID, filePath: FILE_A }] -): OrcadMigrationManifest { - const dormant = dormantState() - dormant.workspaceSession = { - ...dormant.workspaceSession!, - openFilesByWorktree: { [DORMANT_WORKTREE_ID]: [openFile(FILE_A), openFile(FILE_B)] } - } - if (declared) { - dormant.editorDraftsBlob = { - sha256: declared.sha256, - byteLength: declared.byteLength, - entries - } - } - const base = manifest() - return manifest({ payload: { ...base.payload, dormantState: dormant } }) -} - -export function stageBlob(store: Store, input: OrcadMigrationManifest, blob: EncodedBlob) { - for (let offset = 0; offset < blob.bytes.length; offset += ORCAD_MIGRATION_BLOB_CHUNK_BYTES) { - store.stageOrcadMigrationBlobChunk({ - migrationId: input.migrationId, - manifestSha256: input.manifestSha256, - blob: blob.descriptor, - offset, - bytesBase64: blob.bytes - .subarray(offset, offset + ORCAD_MIGRATION_BLOB_CHUNK_BYTES) - .toString('base64') - }) - } -} - -export function receivedDraftsPath(input: OrcadMigrationManifest): string { - return getOrcadMigrationReceivedBlobPath(join(testState.dir, 'orcad-migration-blobs'), input, { - blobKind: 'editor-drafts', - key: 'drafts' - }) -} - -export function draftOf(store: Store, filePath: string): string | undefined { - return store - .getWorkspaceSession() - .openFilesByWorktree?.[DORMANT_WORKTREE_ID]?.find((file) => file.filePath === filePath) - ?.dirtyDraftContent -} - -export function setDestinationFiles(store: Store, files: PersistedOpenFile[]): void { - store.setWorkspaceSession({ - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { [DORMANT_WORKTREE_ID]: files } - }) -} - -export function commitDurably(store: Store, input: OrcadMigrationManifest) { - return commitStagedOrcadMigrationCatalogDurably({ - store, - manifest: input, - onDurableCommit: () => {} - }) -} - -export const DRAFT_A = { - worktreeKey: DORMANT_WORKTREE_ID, - filePath: FILE_A, - content: 'unsaved A\n' -} diff --git a/src/main/persistence-orcad-migration-editor-drafts.test.ts b/src/main/persistence-orcad-migration-editor-drafts.test.ts deleted file mode 100644 index 9e6b5b3facd..00000000000 --- a/src/main/persistence-orcad-migration-editor-drafts.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - DRAFT_A, - FILE_A, - FILE_B, - commitDurably, - declaredManifest, - draftOf, - encodeDrafts, - openFile, - receivedDraftsPath, - setDestinationFiles, - stageBlob -} from './persistence-orcad-migration-editor-drafts-fixture' -import { DORMANT_WORKTREE_ID } from './persistence-orcad-migration-catalog-fixture' -import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness' - -vi.mock('electron', () => ({ - app: { getPath: () => testState.dir }, - safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), - decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) - } -})) - -vi.mock('./telemetry/client', () => ({ track: vi.fn() })) - -describe('orcad migration editor drafts blob', () => { - beforeEach(() => { - testState.dir = mkdtempSync(join(tmpdir(), 'orca-migration-drafts-')) - }) - - afterEach(() => { - rmSync(testState.dir, { recursive: true, force: true }) - }) - - it('applies an 8 MiB draft byte-identical inside the commit and releases the blob once durable', async () => { - const content = `${'x'.repeat(8 * 1024 * 1024 - 7)}é\n\u0000end` - const blob = encodeDrafts([{ ...DRAFT_A, content }]) - const input = declaredManifest(blob.descriptor) - const store = createStore() - store.stageOrcadMigrationCatalog(input) - stageBlob(store, input, blob) - - expect((await commitDurably(store, input)).state).toBe('committed') - expect(draftOf(store, FILE_A)).toBe(content) - expect(draftOf(store, FILE_B)).toBeUndefined() - expect(existsSync(receivedDraftsPath(input))).toBe(false) - expect(draftOf(createStore(), FILE_A)).toBe(content) - }) - - it('refuses a declared blob that never arrived, whether or not the sender names its blobs', () => { - const blob = encodeDrafts([DRAFT_A]) - const input = declaredManifest(blob.descriptor) - const store = createStore() - store.stageOrcadMigrationCatalog(input) - - expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( - 'orcad_migration_editor_drafts_missing' - ) - expect(() => store.commitStagedOrcadMigrationCatalog(input, { expectedBlobs: [] })).toThrow( - 'orcad_migration_editor_drafts_missing' - ) - expect(store.getOrcadMigrationCatalogState(input).state).toBe('staged') - expect(store.getRepos()).toEqual([]) - }) - - it('refuses a drafts blob the manifest does not declare', () => { - const blob = encodeDrafts([DRAFT_A]) - const input = declaredManifest(null) - const store = createStore() - store.stageOrcadMigrationCatalog(input) - stageBlob(store, input, blob) - - expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( - 'orcad_migration_editor_drafts_undeclared' - ) - expect(store.getRepos()).toEqual([]) - }) - - it('refuses a draft for an entry the manifest did not shed', () => { - const blob = encodeDrafts([ - DRAFT_A, - { worktreeKey: DORMANT_WORKTREE_ID, filePath: FILE_B, content: 'extra' } - ]) - const input = declaredManifest(blob.descriptor) - const store = createStore() - store.stageOrcadMigrationCatalog(input) - stageBlob(store, input, blob) - - expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( - 'orcad_migration_editor_drafts_set_mismatch' - ) - expect(store.getOrcadMigrationCatalogState(input)).toMatchObject({ - state: 'staged', - blobUploads: [{ ...blob.descriptor, receivedBytes: blob.descriptor.byteLength }] - }) - }) - - it('refuses bytes whose digest differs from the signed declaration', () => { - const declared = encodeDrafts([DRAFT_A]) - const sent = encodeDrafts([{ ...DRAFT_A, content: 'tampered' }]) - const input = declaredManifest(declared.descriptor) - const store = createStore() - store.stageOrcadMigrationCatalog(input) - stageBlob(store, input, sent) - - expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( - 'orcad_migration_editor_drafts_digest_mismatch' - ) - expect(store.getRepos()).toEqual([]) - }) - - it('accepts a destination entry already holding the identical draft', () => { - const blob = encodeDrafts([DRAFT_A]) - const input = declaredManifest(blob.descriptor) - const store = createStore() - setDestinationFiles(store, [openFile(FILE_A, DRAFT_A.content), openFile(FILE_B)]) - store.stageOrcadMigrationCatalog(input) - stageBlob(store, input, blob) - - expect(store.commitStagedOrcadMigrationCatalog(input).state).toBe('committed') - expect(draftOf(store, FILE_A)).toBe(DRAFT_A.content) - }) - - it('stages past a competing destination draft but refuses it at commit, keeping staging', () => { - const blob = encodeDrafts([DRAFT_A]) - const input = declaredManifest(blob.descriptor) - const store = createStore() - setDestinationFiles(store, [openFile(FILE_A, 'destination edit'), openFile(FILE_B)]) - store.stageOrcadMigrationCatalog(input) - stageBlob(store, input, blob) - - expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( - `orcad_migration_dormant_id_conflict:workspace_session:openFilesByWorktree:${DORMANT_WORKTREE_ID}` - ) - expect(draftOf(store, FILE_A)).toBe('destination edit') - expect(store.getOrcadMigrationCatalogState(input)).toMatchObject({ - state: 'staged', - blobUploads: [{ ...blob.descriptor, receivedBytes: blob.descriptor.byteLength }] - }) - expect(existsSync(receivedDraftsPath(input))).toBe(false) - }) - - it('refuses a destination draft edited between stage and commit', () => { - const blob = encodeDrafts([DRAFT_A]) - const input = declaredManifest(blob.descriptor) - const store = createStore() - setDestinationFiles(store, [openFile(FILE_A, DRAFT_A.content), openFile(FILE_B)]) - store.stageOrcadMigrationCatalog(input) - stageBlob(store, input, blob) - setDestinationFiles(store, [openFile(FILE_A, 'edited after staging'), openFile(FILE_B)]) - - expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( - `orcad_migration_dormant_id_conflict:workspace_session:openFilesByWorktree:${DORMANT_WORKTREE_ID}` - ) - expect(store.getOrcadMigrationCatalogState(input).state).toBe('staged') - }) - - it('re-applies the received drafts on a retry after the first commit was lost', async () => { - const blob = encodeDrafts([DRAFT_A]) - const input = declaredManifest(blob.descriptor) - const store = createStore() - store.stageOrcadMigrationCatalog(input) - stageBlob(store, input, blob) - await store.flushPendingOrThrowAsync() - const stagedDiskState = readDataFile() - store.commitStagedOrcadMigrationCatalog(input) - writeDataFile(stagedDiskState) - - // Staging is gone: the first commit moved the blob, so only the received bytes remain. - const restarted = createStore() - expect(restarted.getOrcadMigrationCatalogState(input)).toMatchObject({ state: 'staged' }) - expect(restarted.getOrcadMigrationCatalogState(input)).not.toHaveProperty('blobUploads') - expect(draftOf(restarted, FILE_A)).toBeUndefined() - expect((await commitDurably(restarted, input)).state).toBe('committed') - expect(draftOf(restarted, FILE_A)).toBe(DRAFT_A.content) - expect(existsSync(receivedDraftsPath(input))).toBe(false) - }) - - it('refuses a received-only retry whose bytes were damaged', async () => { - const blob = encodeDrafts([DRAFT_A]) - const input = declaredManifest(blob.descriptor) - const store = createStore() - store.stageOrcadMigrationCatalog(input) - stageBlob(store, input, blob) - await store.flushPendingOrThrowAsync() - const stagedDiskState = readDataFile() - store.commitStagedOrcadMigrationCatalog(input) - writeDataFile(stagedDiskState) - writeFileSync(receivedDraftsPath(input), Buffer.from('damaged')) - - const restarted = createStore() - expect(() => restarted.commitStagedOrcadMigrationCatalog(input)).toThrow( - 'orcad_migration_editor_drafts_digest_mismatch' - ) - expect(restarted.getOrcadMigrationCatalogState(input).state).toBe('staged') - }) -}) diff --git a/src/main/persistence-orcad-migration-editor-drafts-durability.test.ts b/src/main/persistence-orcad-migration-received-blob-durability.test.ts similarity index 65% rename from src/main/persistence-orcad-migration-editor-drafts-durability.test.ts rename to src/main/persistence-orcad-migration-received-blob-durability.test.ts index fa4707698c9..4a6468c7da3 100644 --- a/src/main/persistence-orcad-migration-editor-drafts-durability.test.ts +++ b/src/main/persistence-orcad-migration-received-blob-durability.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { + ORCAD_MIGRATION_BLOB_CHUNK_BYTES, + type OrcadMigrationBlobDescriptor +} from '../shared/orcad-migration/orcad-migration-blob-transfer' import type { OrcadMigrationManifest } from '../shared/orcad-migration/orcad-migration-manifest' import { getOrcadMigrationReceivedBlobPath } from './persistence/migrating-orcad-catalog/orcad-migration-blob-staging' import { @@ -9,20 +14,7 @@ import { getOrcadMigrationCatalogState } from './runtime/orcad-migration-catalog-import' import { - DRAFT_A, - FILE_A, - HINTED_PANE, - commitDurably, - declaredManifest, - draftOf, - encodeDrafts, - encodeHints, - openFile, - receivedDraftsPath, - stageBlob, - type Store -} from './persistence-orcad-migration-editor-drafts-fixture' -import { + DORMANT_LEAF_ID, DORMANT_WORKTREE_ID, dormantState, manifest @@ -40,8 +32,24 @@ vi.mock('electron', () => ({ vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +type Store = ReturnType + +const HINTED_PANE = `tab-dormant:${DORMANT_LEAF_ID}` const noLiveShells = (): boolean => false +function encodeHints(cwd: string): { bytes: Buffer; descriptor: OrcadMigrationBlobDescriptor } { + const bytes = Buffer.from(JSON.stringify({ version: 1, hints: [{ paneKey: HINTED_PANE, cwd }] })) + return { + bytes, + descriptor: { + blobKind: 'terminal-restore-hints', + key: 'migration', + sha256: createHash('sha256').update(bytes).digest('hex'), + byteLength: bytes.length + } + } +} + function receivedHintsPath(input: OrcadMigrationManifest): string { return getOrcadMigrationReceivedBlobPath(join(testState.dir, 'orcad-migration-blobs'), input, { blobKind: 'terminal-restore-hints', @@ -53,16 +61,33 @@ function hint(store: Store): string | null { return store.peekOrcadMigrationTerminalRestoreHint(HINTED_PANE, DORMANT_WORKTREE_ID) } -/** Drafts plus restore hints staged and durable; returns the disk state before any commit. */ -async function stageDraftsAndHints(store: Store) { - const drafts = encodeDrafts([DRAFT_A]) +function commitDurably(store: Store, input: OrcadMigrationManifest) { + return commitStagedOrcadMigrationCatalogDurably({ + store, + manifest: input, + onDurableCommit: () => {} + }) +} + +/** Restore hints staged and durable; returns the disk state before any commit. */ +async function stageHints(store: Store) { const hints = encodeHints('/home/me/src') - const input = declaredManifest(drafts.descriptor) + const base = manifest() + const input = manifest({ payload: { ...base.payload, dormantState: dormantState() } }) store.stageOrcadMigrationCatalog(input) - stageBlob(store, input, drafts) - stageBlob(store, input, hints) + for (let offset = 0; offset < hints.bytes.length; offset += ORCAD_MIGRATION_BLOB_CHUNK_BYTES) { + store.stageOrcadMigrationBlobChunk({ + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + blob: hints.descriptor, + offset, + bytesBase64: hints.bytes + .subarray(offset, offset + ORCAD_MIGRATION_BLOB_CHUNK_BYTES) + .toString('base64') + }) + } await store.flushPendingOrThrowAsync() - return { input, expectedBlobs: [drafts.descriptor, hints.descriptor], stagedDisk: readDataFile() } + return { input, expectedBlobs: [hints.descriptor], stagedDisk: readDataFile() } } async function commitWithFailedFlush(store: Store, input: OrcadMigrationManifest) { @@ -72,7 +97,7 @@ async function commitWithFailedFlush(store: Store, input: OrcadMigrationManifest describe('orcad migration received blobs wait for a durable commit', () => { beforeEach(() => { - testState.dir = mkdtempSync(join(tmpdir(), 'orca-migration-drafts-durable-')) + testState.dir = mkdtempSync(join(tmpdir(), 'orca-migration-blobs-durable-')) }) afterEach(() => { @@ -80,14 +105,13 @@ describe('orcad migration received blobs wait for a durable commit', () => { rmSync(testState.dir, { recursive: true, force: true }) }) - it('keeps every received blob through a failed flush and a state query, for the retry after restart', async () => { + it('keeps the received blob through a failed flush and a state query, for the retry after restart', async () => { const store = createStore() - const { input, expectedBlobs, stagedDisk } = await stageDraftsAndHints(store) + const { input, expectedBlobs, stagedDisk } = await stageHints(store) await commitWithFailedFlush(store, input) // The in-memory receipt answers "committed", but nothing it received may be consumed yet. expect(getOrcadMigrationCatalogState({ store, manifest: input }).state).toBe('committed') - expect(existsSync(receivedDraftsPath(input))).toBe(true) expect(existsSync(receivedHintsPath(input))).toBe(true) expect(hint(store)).toBeNull() @@ -101,28 +125,25 @@ describe('orcad migration received blobs wait for a durable commit', () => { onDurableCommit: () => {} }) expect(result.state).toBe('committed') - expect(draftOf(restarted, FILE_A)).toBe(DRAFT_A.content) expect(hint(restarted)).toBe('/home/me/src') - expect(existsSync(receivedDraftsPath(input))).toBe(false) expect(existsSync(receivedHintsPath(input))).toBe(false) }) it('never consumes an in-memory-only commit in the post-attach pass', async () => { const store = createStore() - const { input } = await stageDraftsAndHints(store) + const { input } = await stageHints(store) await commitWithFailedFlush(store, input) store.installPendingOrcadMigrationReceivedBlobs({ worktreeHasLiveShells: noLiveShells }) store.installDeferredOrcadMigrationShellHistory(DORMANT_WORKTREE_ID, { worktreeHasLiveShells: noLiveShells }) - expect(existsSync(receivedDraftsPath(input))).toBe(true) expect(existsSync(receivedHintsPath(input))).toBe(true) }) - it('releases the blobs when a same-process retry flushes the commit', async () => { + it('installs the blob when a same-process retry flushes the commit', async () => { const store = createStore() - const { input, expectedBlobs } = await stageDraftsAndHints(store) + const { input, expectedBlobs } = await stageHints(store) await commitWithFailedFlush(store, input) const retried = await commitStagedOrcadMigrationCatalogDurably({ @@ -132,39 +153,20 @@ describe('orcad migration received blobs wait for a durable commit', () => { onDurableCommit: () => {} }) expect(retried.state).toBe('committed') - expect(existsSync(receivedDraftsPath(input))).toBe(false) expect(existsSync(receivedHintsPath(input))).toBe(false) expect(hint(store)).toBe('/home/me/src') - expect(draftOf(createStore(), FILE_A)).toBe(DRAFT_A.content) }) it('treats any successful write of the commit as proof, and a load from disk as proof', async () => { const store = createStore() - const { input } = await stageDraftsAndHints(store) + const { input } = await stageHints(store) store.commitStagedOrcadMigrationCatalog(input) expect(getOrcadMigrationCatalogState({ store, manifest: input }).state).toBe('committed') - expect(existsSync(receivedDraftsPath(input))).toBe(true) + expect(existsSync(receivedHintsPath(input))).toBe(true) await store.flushPendingOrThrowAsync() const restarted = createStore() - expect(existsSync(receivedDraftsPath(input))).toBe(false) expect(existsSync(receivedHintsPath(input))).toBe(false) - expect(draftOf(restarted, FILE_A)).toBe(DRAFT_A.content) expect(hint(restarted)).toBe('/home/me/src') }) - - it('commits an older desktop inline draft unchanged', async () => { - const dormant = dormantState() - dormant.workspaceSession = { - ...dormant.workspaceSession!, - openFilesByWorktree: { [DORMANT_WORKTREE_ID]: [openFile(FILE_A, DRAFT_A.content)] } - } - const base = manifest() - const input = manifest({ payload: { ...base.payload, dormantState: dormant } }) - const store = createStore() - store.stageOrcadMigrationCatalog(input) - - expect((await commitDurably(store, input)).state).toBe('committed') - expect(draftOf(createStore(), FILE_A)).toBe(DRAFT_A.content) - }) }) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts index dbbdb34ad94..270e1c0b7b0 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts @@ -52,7 +52,6 @@ import { stageOrcadMigrationBlobChunk } from './orcad-migration-blob-staging' import { assertOrcadMigrationBlobContentsValid } from './orcad-migration-blob-installers' -import { readDeclaredOrcadMigrationEditorDrafts } from './orcad-destination-editor-drafts' type OrcadCatalogImportRuntime = OrcadMigrationCommitGenerationRuntime & Pick @@ -160,7 +159,7 @@ export class OrcadCatalogImportPersistence { if (current.state !== 'staged') { throw new Error('orcad_migration_catalog_not_staged') } - let prepared = prepareOrcadMigrationCatalog(manifest, context.runtime.state) + const prepared = prepareOrcadMigrationCatalog(manifest, context.runtime.state) const blobRoot = context.runtime.orcadMigrationBlobRoot assertOrcadMigrationSnapshotsReady(manifest, context.runtime.terminalScrollbackSnapshotStorage) if (options.expectedBlobs) { @@ -168,11 +167,6 @@ export class OrcadCatalogImportPersistence { } assertOrcadMigrationBlobsReady(manifest, blobRoot) assertOrcadMigrationBlobContentsValid(manifest, blobRoot) - // Before any blob moves, so a refused draft or conflict leaves staging as it was. - const editorDrafts = readDeclaredOrcadMigrationEditorDrafts(manifest, blobRoot) - if (editorDrafts) { - prepared = prepareOrcadMigrationCatalog(manifest, context.runtime.state, editorDrafts) - } commitOrcadMigrationSnapshots(manifest, context.runtime.terminalScrollbackSnapshotStorage) commitOrcadMigrationBlobs(manifest, blobRoot) const receipt = commitPreparedCatalog(context, manifest, prepared, options.now) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts index 8e5fbabb518..f70f9815afb 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts @@ -4,7 +4,6 @@ import { type OrcadMigrationImportReceipt, type OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest' -import type { OrcadMigrationEditorDraft } from '../../../shared/orcad-migration/orcad-migration-editor-drafts' import type { ProjectGroup } from '../../../shared/project-group-types' import type { Repo } from '../../../shared/repo-types' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' @@ -49,8 +48,7 @@ export function assertSameOrcadMigrationManifest( // Dormant only: no live PTY reaches the destination, so the incoming session is compared as is. export function prepareOrcadMigrationCatalog( manifest: OrcadMigrationManifest, - state: StoreRuntimeState['state'], - editorDrafts?: readonly OrcadMigrationEditorDraft[] + state: StoreRuntimeState['state'] ): PreparedOrcadMigrationCatalog { assertOrcadMigrationStagedCatalogClaims(manifest, state.orcadMigrationStagedCatalogs ?? []) const repositories = manifest.payload.repositories.map(toOrcadDestinationRepository) @@ -79,7 +77,7 @@ export function prepareOrcadMigrationCatalog( folderWorkspaces, existingProjectGroups: state.projectGroups }) - const dormantState = prepareOrcadMigrationDormantState(manifest, state, editorDrafts) + const dormantState = prepareOrcadMigrationDormantState(manifest, state) return { repositories, projectGroups, diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-editor-drafts.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-editor-drafts.ts deleted file mode 100644 index f62ca126dc2..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-destination-editor-drafts.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Destination side of the `editor-drafts` blob. The drafts are applied inside the catalog commit, - * never after it: the commit verifies the bytes against the signed declaration, writes them into - * the incoming session, and runs the full session conflict check on that hydrated copy. - */ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { - sameOrcadMigrationBlob, - type OrcadMigrationBlobDescriptor -} from '../../../shared/orcad-migration/orcad-migration-blob-transfer' -import { - ORCAD_MIGRATION_EDITOR_DRAFTS_KEY, - orcadMigrationEditorDraftIdentity, - parseOrcadMigrationEditorDraftsBytes, - type OrcadMigrationEditorDraft, - type OrcadMigrationEditorDraftEntry -} from '../../../shared/orcad-migration/orcad-migration-editor-drafts' -import type { - OrcadMigrationDormantStatePayload, - OrcadMigrationManifest -} from '../../../shared/orcad-migration/orcad-migration-manifest' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { - getOrcadMigrationReceivedBlobPath, - listOrcadMigrationReceivedBlobNames, - stagedOrcadMigrationBlobFiles -} from './orcad-migration-blob-staging' -import { - assertNoOrcadMigrationSessionConflicts, - prepareOrcadMigrationWorkspaceSession, - type PreparedOrcadMigrationWorkspaceSession -} from './orcad-destination-workspace-session' - -/** - * The declared drafts, read from the staged bytes on a first commit or the received bytes on a - * retry. `expectedBlobs` is optional on the wire, so this refuses a declared-but-absent blob itself. - */ -export function readDeclaredOrcadMigrationEditorDrafts( - manifest: OrcadMigrationManifest, - root: string -): OrcadMigrationEditorDraft[] | undefined { - const declaration = manifest.payload.dormantState?.editorDraftsBlob - const staged = stagedOrcadMigrationBlobFiles(manifest, root).filter( - (file) => file.descriptor.blobKind === 'editor-drafts' - ) - const received = listOrcadMigrationReceivedBlobNames(root, manifest, 'editor-drafts') - if (!declaration) { - if (staged.length > 0 || received.length > 0) { - throw new Error('orcad_migration_editor_drafts_undeclared') - } - return undefined - } - const descriptor: OrcadMigrationBlobDescriptor = { - blobKind: 'editor-drafts', - key: ORCAD_MIGRATION_EDITOR_DRAFTS_KEY, - sha256: declaration.sha256, - byteLength: declaration.byteLength - } - if (staged.some((file) => !sameOrcadMigrationBlob(file.descriptor, descriptor))) { - throw new Error('orcad_migration_editor_drafts_digest_mismatch') - } - const path = staged[0]?.path ?? getOrcadMigrationReceivedBlobPath(root, manifest, descriptor) - let bytes: Buffer - try { - bytes = readFileSync(path) - } catch { - throw new Error('orcad_migration_editor_drafts_missing') - } - // Hash what is parsed, so nothing can change between the check and the read. - if ( - bytes.length !== declaration.byteLength || - createHash('sha256').update(bytes).digest('hex') !== declaration.sha256 - ) { - throw new Error('orcad_migration_editor_drafts_digest_mismatch') - } - return parseOrcadMigrationEditorDraftsBytes(bytes, declaration) -} - -/** - * Staging has no draft bytes yet, so declared entries are compared without their draft on both - * sides; the commit reruns the full check on the hydrated session and replaces this result. - */ -export function prepareOrcadMigrationSessionWithDrafts( - payload: OrcadMigrationDormantStatePayload, - state: PersistedState, - drafts: readonly OrcadMigrationEditorDraft[] | undefined -): PreparedOrcadMigrationWorkspaceSession { - const declaration = payload.editorDraftsBlob - const incoming = payload.workspaceSession - if (!declaration || !incoming) { - return prepareOrcadMigrationWorkspaceSession(incoming, state) - } - if (drafts) { - return prepareOrcadMigrationWorkspaceSession(withEditorDrafts(incoming, drafts), state) - } - assertNoOrcadMigrationSessionConflicts( - withoutEditorDrafts(state.workspaceSession, declaration.entries), - incoming - ) - return { incoming, merged: undefined, editorDraftsPending: true } -} - -/** A detached copy of `session` with each draft written into its one declared editor entry. */ -function withEditorDrafts( - session: WorkspaceSessionState, - drafts: readonly OrcadMigrationEditorDraft[] -): WorkspaceSessionState { - const hydrated = structuredClone(session) - for (const draft of drafts) { - const file = hydrated.openFilesByWorktree?.[draft.worktreeKey]?.find( - (entry) => entry.filePath === draft.filePath - ) - if (!file) { - throw new Error('orcad_migration_editor_drafts_declaration_scope_invalid') - } - file.dirtyDraftContent = draft.content - } - return hydrated -} - -function withoutEditorDrafts( - session: WorkspaceSessionState, - entries: readonly OrcadMigrationEditorDraftEntry[] -): WorkspaceSessionState { - const declared = new Set(entries.map(orcadMigrationEditorDraftIdentity)) - const openFiles = session.openFilesByWorktree - if (!openFiles) { - return session - } - return { - ...session, - openFilesByWorktree: Object.fromEntries( - Object.entries(openFiles).map(([worktreeKey, files]) => [ - worktreeKey, - files.map((file) => { - if ( - !declared.has( - orcadMigrationEditorDraftIdentity({ worktreeKey, filePath: file.filePath }) - ) - ) { - return file - } - const { dirtyDraftContent: _draft, ...rest } = file - return rest - }) - ]) - ) - } -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts index c87b408b0ed..ca2d8fcaea7 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts @@ -32,8 +32,6 @@ const MERGED_SESSION_RECORD_FIELDS = [ export type PreparedOrcadMigrationWorkspaceSession = { incoming: WorkspaceSessionState | undefined merged: WorkspaceSessionState | undefined - /** Checked at staging without the declared drafts; never applied, or they would be lost. */ - editorDraftsPending?: true } export function prepareOrcadMigrationWorkspaceSession( @@ -43,22 +41,15 @@ export function prepareOrcadMigrationWorkspaceSession( if (!incoming) { return { incoming: undefined, merged: undefined } } - assertNoOrcadMigrationSessionConflicts(state.workspaceSession, incoming) + assertNoSessionRecordConflicts(state.workspaceSession, incoming) + assertNoSessionFocusConflicts(state.workspaceSession, incoming) + assertNoSessionEntityConflicts(state.workspaceSession, incoming) return { incoming, merged: mergeWorkspaceSessions(state.workspaceSession, incoming) } } -export function assertNoOrcadMigrationSessionConflicts( - existing: WorkspaceSessionState, - incoming: WorkspaceSessionState -): void { - assertNoSessionRecordConflicts(existing, incoming) - assertNoSessionFocusConflicts(existing, incoming) - assertNoSessionEntityConflicts(existing, incoming) -} - function assertNoSessionFocusConflicts( existing: WorkspaceSessionState, incoming: WorkspaceSessionState @@ -82,9 +73,6 @@ export function applyPreparedOrcadMigrationWorkspaceSession( prepared: PreparedOrcadMigrationWorkspaceSession, state: PersistedState ): void { - if (prepared.editorDraftsPending) { - throw new Error('orcad_migration_editor_drafts_not_applied') - } if (prepared.merged) { state.workspaceSession = prepared.merged } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts b/src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts index 60bf49ec442..62f5749c101 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts @@ -16,10 +16,9 @@ import { import { recordRetirementNamespaceRegistry } from '../../worktree-retirement-namespace' import { applyPreparedOrcadMigrationWorkspaceSession, + prepareOrcadMigrationWorkspaceSession, type PreparedOrcadMigrationWorkspaceSession } from './orcad-destination-workspace-session' -import { prepareOrcadMigrationSessionWithDrafts } from './orcad-destination-editor-drafts' -import type { OrcadMigrationEditorDraft } from '../../../shared/orcad-migration/orcad-migration-editor-drafts' import { applyPreparedOrcadMigrationAutomationState, prepareOrcadMigrationAutomationState, @@ -47,11 +46,9 @@ export type PreparedOrcadMigrationDormantState = { clientState: PreparedOrcadMigrationClientState } -/** `editorDrafts`: the verified declared drafts at commit; absent while staging. */ export function prepareOrcadMigrationDormantState( manifest: OrcadMigrationManifest, - state: PersistedState, - editorDrafts?: readonly OrcadMigrationEditorDraft[] + state: PersistedState ): PreparedOrcadMigrationDormantState { const payload = manifest.payload.dormantState if (!payload) { @@ -119,7 +116,7 @@ export function prepareOrcadMigrationDormantState( entry.registry ) })), - workspaceSession: prepareOrcadMigrationSessionWithDrafts(payload, state, editorDrafts), + workspaceSession: prepareOrcadMigrationWorkspaceSession(payload.workspaceSession, state), automationState: prepareOrcadMigrationAutomationState( payload.automations, payload.automationRuns, diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts index 808418aae4e..32c771cdb29 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts @@ -3,11 +3,10 @@ * commits, and an installer after the commit is durable that moves the received blob into live * state and deletes it. Kinds without an installer are not advertised, so none arrive. */ -import { existsSync, readFileSync, rmSync } from 'node:fs' +import { readFileSync, rmSync } from 'node:fs' import { dirname } from 'node:path' import type { OrcadMigrationBlobKind } from '../../../shared/orcad-migration/orcad-migration-blob-transfer' import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest' -import { ORCAD_MIGRATION_EDITOR_DRAFTS_KEY } from '../../../shared/orcad-migration/orcad-migration-editor-drafts' import { ORCAD_MIGRATION_TERMINAL_RESTORE_HINTS_KEY, parseOrcadMigrationTerminalRestoreHintsText, @@ -102,11 +101,7 @@ export type OrcadMigrationBlobInstall = { export function installOrcadMigrationReceivedBlobs(install: OrcadMigrationBlobInstall): void { let firstError: unknown = null // One kind's failure must not hold back the others; each keeps its own blobs for the retry. - for (const installKind of [ - installTerminalRestoreHints, - installShellHistory, - releaseEditorDrafts - ]) { + for (const installKind of [installTerminalRestoreHints, installShellHistory]) { try { installKind(install) } catch (error) { @@ -133,19 +128,6 @@ function installShellHistory(install: OrcadMigrationBlobInstall): void { }) } -// The drafts landed in the committed session; this file only served a commit retry. -function releaseEditorDrafts(install: OrcadMigrationBlobInstall): void { - const path = getOrcadMigrationReceivedBlobPath(install.root, install.migration, { - blobKind: 'editor-drafts', - key: ORCAD_MIGRATION_EDITOR_DRAFTS_KEY - }) - if (!existsSync(path)) { - return - } - rmSync(path, { force: true }) - syncDirectoryDurablySync(dirname(path)) -} - function installTerminalRestoreHints(install: OrcadMigrationBlobInstall): void { const path = getOrcadMigrationReceivedBlobPath(install.root, install.migration, { blobKind: 'terminal-restore-hints', diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts index 29086435854..c6cbbd49718 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts @@ -56,8 +56,7 @@ const receivedDirectoryPath: ReceivedBlobPathResolver = (root, manifest, descrip const RECEIVED_BLOB_DESTINATIONS: Record = { 'terminal-restore-hints': receivedDirectoryPath, 'shell-history': receivedDirectoryPath, - 'agent-session-records': receivedDirectoryPath, - 'editor-drafts': receivedDirectoryPath + 'agent-session-records': receivedDirectoryPath } export function getOrcadMigrationReceivedBlobPath( diff --git a/src/shared/orcad-migration-dormant-state-validation.ts b/src/shared/orcad-migration-dormant-state-validation.ts index 775cd504067..af054457c46 100644 --- a/src/shared/orcad-migration-dormant-state-validation.ts +++ b/src/shared/orcad-migration-dormant-state-validation.ts @@ -18,10 +18,6 @@ import { assertOrcadMigrationClientStateReferences, parseOrcadMigrationClientState } from './orcad-migration/orcad-migration-client-state' -import { - assertOrcadMigrationEditorDraftsDeclarationReferences, - parseOrcadMigrationEditorDraftsDeclaration -} from './orcad-migration/orcad-migration-editor-drafts' import { assertUnique, boundedArray, @@ -91,12 +87,7 @@ export function parseOrcadMigrationDormantState(value: unknown): OrcadMigrationD : { automationRuns: parseOrcadMigrationDormantAutomationRuns(record.automationRuns) }), ...(record.clientState === undefined ? {} - : { clientState: parseOrcadMigrationClientState(record.clientState) }), - ...(record.editorDraftsBlob === undefined - ? {} - : { - editorDraftsBlob: parseOrcadMigrationEditorDraftsDeclaration(record.editorDraftsBlob) - }) + : { clientState: parseOrcadMigrationClientState(record.clientState) }) } assertUnique(payload.worktreeMeta, (entry) => entry.worktreeId, 'worktree_meta') assertUnique(payload.worktreeMeta, (entry) => entry.sourceKey, 'worktree_meta_source') @@ -175,12 +166,6 @@ export function assertOrcadMigrationDormantStateReferences(args: { repositoryIds: args.repositoryIds }) } - if (args.dormantState.editorDraftsBlob) { - assertOrcadMigrationEditorDraftsDeclarationReferences( - args.dormantState.workspaceSession, - args.dormantState.editorDraftsBlob - ) - } assertOrcadMigrationScrollbackReferences( args.dormantState.workspaceSession, args.dormantState.terminalScrollbackSnapshots ?? [] diff --git a/src/shared/orcad-migration-manifest-contract.test.ts b/src/shared/orcad-migration-manifest-contract.test.ts index 9bd09f893e0..000332b6854 100644 --- a/src/shared/orcad-migration-manifest-contract.test.ts +++ b/src/shared/orcad-migration-manifest-contract.test.ts @@ -114,6 +114,16 @@ describe('orcad migration manifest contract', () => { ).toThrow('orcad_migration_dormant_workspace_lineage_scope_invalid') }) + // The field a build that shed drafts into a blob would send; no released desktop ever did. + it('accepts and drops the retired editorDraftsBlob declaration', () => { + const dormantState = { + ...dormantLineage('folder:folder-1', 'worktree:repo-1::/srv/repo-1'), + editorDraftsBlob: { sha256: digest, byteLength: 4, entries: [] } + } + const parsed = parseOrcadMigrationManifest(manifest({ dormantState })) + expect(parsed.payload.dormantState).not.toHaveProperty('editorDraftsBlob') + }) + it('rejects a malformed workspace key rather than treating it as a worktree id', () => { expect(() => parseOrcadMigrationManifest( diff --git a/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts b/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts index c55a9eabd7c..51bc9c2ca4b 100644 --- a/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts +++ b/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts @@ -124,8 +124,7 @@ describe('orcad migration blob contract', () => { expect(advertisedOrcadMigrationBlobCapabilities()).toEqual([ ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, 'orcad.migration-blob.terminal-restore-hints.v1', - 'orcad.migration-blob.shell-history.v1', - 'orcad.migration-blob.editor-drafts.v1' + 'orcad.migration-blob.shell-history.v1' ]) // A kind with no installer stays unadvertised, so no desktop sends it here. expect(advertisedOrcadMigrationBlobCapabilities()).not.toContain( @@ -133,19 +132,15 @@ describe('orcad migration blob contract', () => { ) }) - it('accepts only the one drafts key, up to 64 MiB', () => { - const drafts = (key: string, byteLength: number) => ({ - blobKind: 'editor-drafts', - key, - sha256: 'b'.repeat(64), - byteLength - }) + // Drafts stay on each desktop; a chunk from a build that once shed them is refused, not stored. + it('refuses the retired editor-drafts kind', () => { expect( - OrcadMigrationBlobDescriptorSchema.safeParse(drafts('drafts', 64 * 1024 * 1024)).success - ).toBe(true) - expect(OrcadMigrationBlobDescriptorSchema.safeParse(drafts('drafts-2', 4)).success).toBe(false) - expect( - OrcadMigrationBlobDescriptorSchema.safeParse(drafts('drafts', 64 * 1024 * 1024 + 1)).success + OrcadMigrationBlobDescriptorSchema.safeParse({ + blobKind: 'editor-drafts', + key: 'drafts', + sha256: 'b'.repeat(64), + byteLength: 4 + }).success ).toBe(false) }) diff --git a/src/shared/orcad-migration/orcad-migration-blob-transfer.ts b/src/shared/orcad-migration/orcad-migration-blob-transfer.ts index f32e325be26..a098939c081 100644 --- a/src/shared/orcad-migration/orcad-migration-blob-transfer.ts +++ b/src/shared/orcad-migration/orcad-migration-blob-transfer.ts @@ -19,8 +19,7 @@ import { ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY } from '../orcad-runtim export const ORCAD_MIGRATION_BLOB_KINDS = [ 'terminal-restore-hints', 'shell-history', - 'agent-session-records', - 'editor-drafts' + 'agent-session-records' ] as const export type OrcadMigrationBlobKind = (typeof ORCAD_MIGRATION_BLOB_KINDS)[number] @@ -55,12 +54,6 @@ export const ORCAD_MIGRATION_BLOB_KIND_CONTRACTS: Record< key: /^[a-z0-9][a-z0-9_-]{0,127}$/, maxBytes: 64 * MIB, capability: 'orcad.migration-blob.agent-session-records.v1' - }, - // Every shed unsaved editor buffer of the migration in one document, named by the manifest. - 'editor-drafts': { - key: /^drafts$/, - maxBytes: 64 * MIB, - capability: 'orcad.migration-blob.editor-drafts.v1' } } @@ -266,8 +259,7 @@ export function parseOrcadMigrationBlobChunkResult( /** Kinds this build's destination installs after commit; each is added with its installer. */ export const ORCAD_MIGRATION_INSTALLABLE_BLOB_KINDS: readonly OrcadMigrationBlobKind[] = [ 'terminal-restore-hints', - 'shell-history', - 'editor-drafts' + 'shell-history' ] export function advertisedOrcadMigrationBlobCapabilities(): string[] { diff --git a/src/shared/orcad-migration/orcad-migration-editor-drafts.test.ts b/src/shared/orcad-migration/orcad-migration-editor-drafts.test.ts deleted file mode 100644 index db5bd38878d..00000000000 --- a/src/shared/orcad-migration/orcad-migration-editor-drafts.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../constants' -import type { PersistedOpenFile } from '../workspace-session-state-types' -import { - parseOrcadMigrationEditorDraftsBytes, - type OrcadMigrationEditorDraftsDeclaration -} from './orcad-migration-editor-drafts' -import { serializeOrcadMigrationValue } from './orcad-migration-manifest' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest -} from './orcad-migration-manifest-validation' - -const WORKTREE = 'repo-1::/srv/repo-1-wt' -const FILE_A = '/srv/repo-1-wt/a.ts' -const FILE_B = '/srv/repo-1-wt/b.ts' - -function file(filePath: string, dirtyDraftContent?: string): PersistedOpenFile { - return { - filePath, - relativePath: filePath.slice('/srv/repo-1-wt/'.length), - worktreeId: WORKTREE, - language: 'typescript', - ...(dirtyDraftContent === undefined ? {} : { dirtyDraftContent }) - } -} - -const declaration: OrcadMigrationEditorDraftsDeclaration = { - sha256: 'c'.repeat(64), - byteLength: 120, - entries: [{ worktreeKey: WORKTREE, filePath: FILE_A }] -} - -function rawManifest(editorDraftsBlob: unknown, files = [file(FILE_A), file(FILE_B, 'inline')]) { - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-drafts', - createdAt: '2026-10-01T12:00:00.000Z', - source: { sshTargetId: 'target', sshTargetGeneration: 3, targetLabel: 'Host' }, - payload: { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1 - } - ], - projectGroups: [], - folderWorkspaces: [], - dormantState: { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [], - workspaceSession: { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { [WORKTREE]: files } - }, - ...(editorDraftsBlob === undefined ? {} : { editorDraftsBlob }) - } - }, - manifestSha256: 'a'.repeat(64) - } -} - -describe('orcad migration editor drafts', () => { - it('keeps the signed declaration in its golden wire shape', () => { - const parsed = parseOrcadMigrationManifest(rawManifest(declaration)) - expect( - serializeOrcadMigrationValue(parsed.payload.dormantState?.editorDraftsBlob) - ).toMatchInlineSnapshot( - `"{"byteLength":120,"entries":[{"filePath":"/srv/repo-1-wt/a.ts","worktreeKey":"repo-1::/srv/repo-1-wt"}],"sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}"` - ) - }) - - it('stays optional: a manifest without it parses as before', () => { - const parsed = parseOrcadMigrationManifest(rawManifest(undefined)) - expect(parsed.payload.dormantState).not.toHaveProperty('editorDraftsBlob') - }) - - it('names only shed editor entries, each exactly once', () => { - const scope = 'orcad_migration_editor_drafts_declaration_scope_invalid' - const entry = (filePath: string, worktreeKey = WORKTREE) => ({ - ...declaration, - entries: [{ worktreeKey, filePath }] - }) - // An entry that still carries its draft inline. - expect(() => parseOrcadMigrationManifest(rawManifest(entry(FILE_B)))).toThrow(scope) - expect(() => parseOrcadMigrationManifest(rawManifest(entry('/srv/repo-1-wt/none.ts')))).toThrow( - scope - ) - expect(() => - parseOrcadMigrationManifest(rawManifest(entry(FILE_A, 'repo-1::/srv/other'))) - ).toThrow(scope) - expect(() => - parseOrcadMigrationManifest(rawManifest(declaration, [file(FILE_A), file(FILE_A)])) - ).toThrow(scope) - expect(() => - parseOrcadMigrationManifest( - rawManifest({ ...declaration, entries: [...declaration.entries, ...declaration.entries] }) - ) - ).toThrow('orcad_migration_dormant_editor_drafts_entry_duplicate') - }) - - it('refuses a malformed declaration', () => { - const invalid = 'orcad_migration_editor_drafts_declaration_invalid' - for (const value of [ - { ...declaration, entries: [] }, - { ...declaration, sha256: 'not-a-digest' }, - { ...declaration, byteLength: 0 }, - { ...declaration, byteLength: 64 * 1024 * 1024 + 1 }, - { ...declaration, entries: [{ worktreeKey: WORKTREE, filePath: '' }] } - ]) { - expect(() => parseOrcadMigrationManifest(rawManifest(value))).toThrow(invalid) - } - }) - - it('maps the blob one-to-one onto the declared entries', () => { - const two: OrcadMigrationEditorDraftsDeclaration = { - ...declaration, - entries: [ - { worktreeKey: WORKTREE, filePath: FILE_A }, - { worktreeKey: WORKTREE, filePath: FILE_B } - ] - } - const bytes = (drafts: unknown[], version = 1) => - Buffer.from(JSON.stringify({ version, drafts })) - const a = { worktreeKey: WORKTREE, filePath: FILE_A, content: '' } - const b = { worktreeKey: WORKTREE, filePath: FILE_B, content: 'b' } - - expect(parseOrcadMigrationEditorDraftsBytes(bytes([b, a]), two)).toEqual([b, a]) - expect(() => parseOrcadMigrationEditorDraftsBytes(bytes([a]), two)).toThrow( - 'orcad_migration_editor_drafts_set_mismatch' - ) - expect(() => parseOrcadMigrationEditorDraftsBytes(bytes([a, b]), declaration)).toThrow( - 'orcad_migration_editor_drafts_set_mismatch' - ) - expect(() => parseOrcadMigrationEditorDraftsBytes(bytes([a, a]), two)).toThrow( - 'orcad_migration_dormant_editor_drafts_entry_duplicate' - ) - expect(() => parseOrcadMigrationEditorDraftsBytes(bytes([a], 2), declaration)).toThrow( - 'orcad_migration_editor_drafts_invalid' - ) - expect(() => - parseOrcadMigrationEditorDraftsBytes(Buffer.from([0xff, 0xfe]), declaration) - ).toThrow('orcad_migration_editor_drafts_not_json') - }) -}) diff --git a/src/shared/orcad-migration/orcad-migration-editor-drafts.ts b/src/shared/orcad-migration/orcad-migration-editor-drafts.ts deleted file mode 100644 index c829a85007f..00000000000 --- a/src/shared/orcad-migration/orcad-migration-editor-drafts.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * The `editor-drafts` blob: unsaved editor buffers shed from the signed manifest so it stays under - * its size cap. The manifest declares the blob's digest and exactly which editor entries it fills - * (`dormantState.editorDraftsBlob`); those entries carry no inline `dirtyDraftContent`. - */ -import { z } from 'zod' -import type { WorkspaceSessionState } from '../workspace-session-state-types' -import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from './orcad-migration-blob-transfer' -import { - MAX_ORCAD_MIGRATION_DORMANT_ROWS, - assertUnique, - requiredRecord, - requiredString -} from './orcad-migration-dormant-value-validation' -import { OrcadMigrationDigestSchema } from './orcad-migration-scrollback' - -export const ORCAD_MIGRATION_EDITOR_DRAFTS_VERSION = 1 -export const ORCAD_MIGRATION_EDITOR_DRAFTS_KEY = 'drafts' -export const MAX_ORCAD_MIGRATION_EDITOR_DRAFTS_BYTES = - ORCAD_MIGRATION_BLOB_KIND_CONTRACTS['editor-drafts'].maxBytes - -/** One `openFilesByWorktree[worktreeKey]` entry, by its `filePath`. */ -export type OrcadMigrationEditorDraftEntry = { worktreeKey: string; filePath: string } - -export type OrcadMigrationEditorDraftsDeclaration = { - sha256: string - byteLength: number - entries: OrcadMigrationEditorDraftEntry[] -} - -export type OrcadMigrationEditorDraft = OrcadMigrationEditorDraftEntry & { content: string } - -export type OrcadMigrationEditorDraftsDocument = { - version: typeof ORCAD_MIGRATION_EDITOR_DRAFTS_VERSION - drafts: OrcadMigrationEditorDraft[] -} - -export function orcadMigrationEditorDraftIdentity(entry: OrcadMigrationEditorDraftEntry): string { - return `${entry.worktreeKey}\0${entry.filePath}` -} - -export function parseOrcadMigrationEditorDraftsDeclaration( - value: unknown -): OrcadMigrationEditorDraftsDeclaration { - const record = requiredRecord(value, 'orcad_migration_editor_drafts_declaration_invalid') - const sha256 = OrcadMigrationDigestSchema.safeParse(record.sha256) - if ( - !sha256.success || - !Number.isSafeInteger(record.byteLength) || - Number(record.byteLength) < 1 || - Number(record.byteLength) > MAX_ORCAD_MIGRATION_EDITOR_DRAFTS_BYTES || - !Array.isArray(record.entries) || - record.entries.length === 0 || - record.entries.length > MAX_ORCAD_MIGRATION_DORMANT_ROWS - ) { - throw new Error('orcad_migration_editor_drafts_declaration_invalid') - } - const entries = record.entries.map((entry) => { - const item = requiredRecord(entry, 'orcad_migration_editor_drafts_declaration_invalid') - return { - worktreeKey: requiredString( - item.worktreeKey, - 'orcad_migration_editor_drafts_declaration_invalid' - ), - filePath: requiredString(item.filePath, 'orcad_migration_editor_drafts_declaration_invalid') - } - }) - assertUnique(entries, orcadMigrationEditorDraftIdentity, 'editor_drafts_entry') - return { sha256: sha256.data, byteLength: Number(record.byteLength), entries } -} - -/** Each declared entry names exactly one session editor entry, and that entry has no inline draft. */ -export function assertOrcadMigrationEditorDraftsDeclarationReferences( - session: WorkspaceSessionState | undefined, - declaration: OrcadMigrationEditorDraftsDeclaration -): void { - for (const entry of declaration.entries) { - const matches = (session?.openFilesByWorktree?.[entry.worktreeKey] ?? []).filter( - (file) => file.filePath === entry.filePath - ) - if (matches.length !== 1 || matches[0].dirtyDraftContent !== undefined) { - throw new Error('orcad_migration_editor_drafts_declaration_scope_invalid') - } - } -} - -// Not strict: a same-version sender may add optional fields this build ignores. -const EditorDraftsDocumentSchema = z.object({ - version: z.literal(ORCAD_MIGRATION_EDITOR_DRAFTS_VERSION), - drafts: z - .array( - z.object({ - worktreeKey: z.string().min(1), - filePath: z.string().min(1), - content: z.string() - }) - ) - .max(MAX_ORCAD_MIGRATION_DORMANT_ROWS) -}) - -const UTF8 = new TextDecoder('utf-8', { fatal: true }) - -/** The blob's bytes, held to an exact one-to-one match with the declared entries. */ -export function parseOrcadMigrationEditorDraftsBytes( - bytes: Uint8Array, - declaration: OrcadMigrationEditorDraftsDeclaration -): OrcadMigrationEditorDraft[] { - let value: unknown - try { - value = JSON.parse(UTF8.decode(bytes)) - } catch { - throw new Error('orcad_migration_editor_drafts_not_json') - } - const parsed = EditorDraftsDocumentSchema.safeParse(value) - if (!parsed.success) { - throw new Error('orcad_migration_editor_drafts_invalid') - } - const drafts = parsed.data.drafts.map((draft) => ({ - worktreeKey: draft.worktreeKey, - filePath: draft.filePath, - content: draft.content - })) - assertUnique(drafts, orcadMigrationEditorDraftIdentity, 'editor_drafts_entry') - const declared = new Set(declaration.entries.map(orcadMigrationEditorDraftIdentity)) - if ( - drafts.length !== declared.size || - drafts.some((draft) => !declared.has(orcadMigrationEditorDraftIdentity(draft))) - ) { - throw new Error('orcad_migration_editor_drafts_set_mismatch') - } - return drafts -} diff --git a/src/shared/orcad-migration/orcad-migration-manifest.ts b/src/shared/orcad-migration/orcad-migration-manifest.ts index 11d57fcaebe..0de03f92e1a 100644 --- a/src/shared/orcad-migration/orcad-migration-manifest.ts +++ b/src/shared/orcad-migration/orcad-migration-manifest.ts @@ -14,7 +14,6 @@ import type { OrcadMigrationTerminalScrollbackSnapshot } from './orcad-migration-scrollback' import type { OrcadMigrationBlobUploadState } from './orcad-migration-blob-transfer' -import type { OrcadMigrationEditorDraftsDeclaration } from './orcad-migration-editor-drafts' import { isRecord } from './orcad-migration-manifest-fields' @@ -93,11 +92,6 @@ export type OrcadMigrationDormantStatePayload = { automationRuns?: AutomationRun[] /** Client-owned durable intent projected to the destination authority. */ clientState?: OrcadMigrationClientStatePayload - /** - * The `editor-drafts` blob holding the named entries' unsaved buffers. Only sent to a host that - * advertises that kind: an older parser drops this field and would commit without the drafts. - */ - editorDraftsBlob?: OrcadMigrationEditorDraftsDeclaration } export type OrcadMigrationManifest = {