From ec89fa609917fc23d52628c5086bcd5a353154a8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:46:41 -0700 Subject: [PATCH] feat(orcad-migration): host applies a signed editor-drafts blob inside the commit (S10-H) (#30665) * feat(orcad-migration): host applies a signed editor-drafts blob inside the commit * fix(orcad-migration): consume received blobs only after the commit is on disk --- ...migration-editor-drafts-durability.test.ts | 170 +++++++++++++++ ...e-orcad-migration-editor-drafts-fixture.ts | 133 ++++++++++++ ...ence-orcad-migration-editor-drafts.test.ts | 203 ++++++++++++++++++ .../loading-store/store-runtime-state.ts | 5 + .../orcad-catalog-import.ts | 33 +-- .../orcad-catalog-receipt-ledger.ts | 45 ++++ .../orcad-catalog-records.ts | 6 +- .../orcad-destination-editor-drafts.ts | 151 +++++++++++++ .../orcad-destination-workspace-session.ts | 18 +- .../orcad-dormant-state-records.ts | 9 +- ...rcad-migration-blob-install-persistence.ts | 17 +- .../orcad-migration-blob-installers.ts | 22 +- .../orcad-migration-blob-staging.ts | 3 +- ...rcad-migration-dormant-state-validation.ts | 17 +- .../orcad-migration-blob-transfer.test.ts | 24 ++- .../orcad-migration-blob-transfer.ts | 12 +- .../orcad-migration-editor-drafts.test.ts | 153 +++++++++++++ .../orcad-migration-editor-drafts.ts | 132 ++++++++++++ .../orcad-migration-manifest.ts | 6 + 19 files changed, 1126 insertions(+), 33 deletions(-) create mode 100644 src/main/persistence-orcad-migration-editor-drafts-durability.test.ts create mode 100644 src/main/persistence-orcad-migration-editor-drafts-fixture.ts create mode 100644 src/main/persistence-orcad-migration-editor-drafts.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-destination-editor-drafts.ts create mode 100644 src/shared/orcad-migration/orcad-migration-editor-drafts.test.ts create mode 100644 src/shared/orcad-migration/orcad-migration-editor-drafts.ts diff --git a/src/main/persistence-orcad-migration-editor-drafts-durability.test.ts b/src/main/persistence-orcad-migration-editor-drafts-durability.test.ts new file mode 100644 index 00000000000..fa4707698c9 --- /dev/null +++ b/src/main/persistence-orcad-migration-editor-drafts-durability.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { OrcadMigrationManifest } from '../shared/orcad-migration/orcad-migration-manifest' +import { getOrcadMigrationReceivedBlobPath } from './persistence/migrating-orcad-catalog/orcad-migration-blob-staging' +import { + commitStagedOrcadMigrationCatalogDurably, + 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_WORKTREE_ID, + dormantState, + manifest +} 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() })) + +const noLiveShells = (): boolean => false + +function receivedHintsPath(input: OrcadMigrationManifest): string { + return getOrcadMigrationReceivedBlobPath(join(testState.dir, 'orcad-migration-blobs'), input, { + blobKind: 'terminal-restore-hints', + key: 'migration' + }) +} + +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]) + const hints = encodeHints('/home/me/src') + const input = declaredManifest(drafts.descriptor) + store.stageOrcadMigrationCatalog(input) + stageBlob(store, input, drafts) + stageBlob(store, input, hints) + await store.flushPendingOrThrowAsync() + return { input, expectedBlobs: [drafts.descriptor, hints.descriptor], stagedDisk: readDataFile() } +} + +async function commitWithFailedFlush(store: Store, input: OrcadMigrationManifest) { + vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValueOnce(new Error('disk full')) + await expect(commitDurably(store, input)).rejects.toThrow('disk full') +} + +describe('orcad migration received blobs wait for a durable commit', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-migration-drafts-durable-')) + }) + + afterEach(() => { + vi.restoreAllMocks() + 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 () => { + const store = createStore() + const { input, expectedBlobs, stagedDisk } = await stageDraftsAndHints(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() + + writeDataFile(stagedDisk) + const restarted = createStore() + expect(restarted.getOrcadMigrationCatalogState(input).state).toBe('staged') + const result = await commitStagedOrcadMigrationCatalogDurably({ + store: restarted, + manifest: input, + expectedBlobs, + 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) + 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 () => { + const store = createStore() + const { input, expectedBlobs } = await stageDraftsAndHints(store) + await commitWithFailedFlush(store, input) + + const retried = await commitStagedOrcadMigrationCatalogDurably({ + store, + manifest: input, + expectedBlobs, + 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) + store.commitStagedOrcadMigrationCatalog(input) + expect(getOrcadMigrationCatalogState({ store, manifest: input }).state).toBe('committed') + expect(existsSync(receivedDraftsPath(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-orcad-migration-editor-drafts-fixture.ts b/src/main/persistence-orcad-migration-editor-drafts-fixture.ts new file mode 100644 index 00000000000..4e0b8e812e9 --- /dev/null +++ b/src/main/persistence-orcad-migration-editor-drafts-fixture.ts @@ -0,0 +1,133 @@ +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 new file mode 100644 index 00000000000..9e6b5b3facd --- /dev/null +++ b/src/main/persistence-orcad-migration-editor-drafts.test.ts @@ -0,0 +1,203 @@ +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/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index c11d28d4178..6cddd3d6564 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -74,6 +74,11 @@ export class StoreRuntimeState { quitFlushPromise: Promise | null = null lastWrittenStateHash: string | null = null lastDurableWriteGeneration = -1 + /** + * The write generation each migration committed by this process needs on disk before its + * received blobs may be consumed; a receipt missing here was read from disk. + */ + readonly orcadMigrationCommitWriteGenerations = new Map() firstPendingSaveAt: number | null = null /** Known dirty domains, or null when a caller requires a complete-document fallback. */ dirtyProfileStateDomains: Set | null = new Set() 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 9b116791d95..dbbdb34ad94 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts @@ -30,7 +30,9 @@ import { expireOrcadMigrationStages, findOrcadMigrationImportReceipt, isLiveOrcadMigrationStage, - recordOrcadMigrationImportReceipt + recordOrcadMigrationCommitWriteGeneration, + recordOrcadMigrationImportReceipt, + type OrcadMigrationCommitGenerationRuntime } from './orcad-catalog-receipt-ledger' import { abortOrcadMigrationSnapshots, @@ -50,11 +52,10 @@ import { stageOrcadMigrationBlobChunk } from './orcad-migration-blob-staging' import { assertOrcadMigrationBlobContentsValid } from './orcad-migration-blob-installers' +import { readDeclaredOrcadMigrationEditorDrafts } from './orcad-destination-editor-drafts' -type OrcadCatalogImportRuntime = Pick< - StoreRuntimeState, - 'state' | 'terminalScrollbackSnapshotStorage' | 'orcadMigrationBlobRoot' -> +type OrcadCatalogImportRuntime = OrcadMigrationCommitGenerationRuntime & + Pick const orcadCatalogImportContext = Symbol('OrcadCatalogImportPersistence') type OrcadCatalogImportContext = { @@ -159,21 +160,25 @@ export class OrcadCatalogImportPersistence { if (current.state !== 'staged') { throw new Error('orcad_migration_catalog_not_staged') } - const prepared = prepareOrcadMigrationCatalog(manifest, context.runtime.state) + let prepared = prepareOrcadMigrationCatalog(manifest, context.runtime.state) + const blobRoot = context.runtime.orcadMigrationBlobRoot assertOrcadMigrationSnapshotsReady(manifest, context.runtime.terminalScrollbackSnapshotStorage) if (options.expectedBlobs) { - assertOrcadMigrationBlobSetMatches( - manifest, - context.runtime.orcadMigrationBlobRoot, - options.expectedBlobs - ) + assertOrcadMigrationBlobSetMatches(manifest, blobRoot, options.expectedBlobs) + } + 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) } - assertOrcadMigrationBlobsReady(manifest, context.runtime.orcadMigrationBlobRoot) - assertOrcadMigrationBlobContentsValid(manifest, context.runtime.orcadMigrationBlobRoot) commitOrcadMigrationSnapshots(manifest, context.runtime.terminalScrollbackSnapshotStorage) - commitOrcadMigrationBlobs(manifest, context.runtime.orcadMigrationBlobRoot) + commitOrcadMigrationBlobs(manifest, blobRoot) const receipt = commitPreparedCatalog(context, manifest, prepared, options.now) + const generationBefore = context.runtime.writeGeneration scheduleSave(context.scheduling) + recordOrcadMigrationCommitWriteGeneration(context.runtime, manifest, generationBefore) return committedCatalogState(receipt) } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-receipt-ledger.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-receipt-ledger.ts index dc0cbbb3401..ed70f316d8d 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-receipt-ledger.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-receipt-ledger.ts @@ -10,6 +10,11 @@ import type { } from '../../../shared/orcad-migration/orcad-migration-manifest' import { MAX_ORCAD_MIGRATION_EVICTED_RECEIPTS } from '../../../shared/orcad-migration/orcad-migration-evicted-receipts' import type { PersistedState } from '../../../shared/persisted-state-types' +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' +import { + orcadMigrationStagingKey, + type OrcadMigrationRequestIdentity +} from './orcad-migration-staged-file' // A client resumes a stage within minutes; a week only covers a host left offline meanwhile. export const ORCAD_MIGRATION_STAGE_TTL_MS = 7 * 24 * 60 * 60 * 1000 @@ -76,3 +81,43 @@ export function expireOrcadMigrationStages(state: PersistedState, now: number): state.orcadMigrationStagedCatalogs = live return true } + +export type OrcadMigrationCommitGenerationRuntime = Pick< + StoreRuntimeState, + 'writeGeneration' | 'lastDurableWriteGeneration' | 'orcadMigrationCommitWriteGenerations' +> + +/** Call right after the commit's `scheduleSave`; `generationBefore` is read just before it. */ +export function recordOrcadMigrationCommitWriteGeneration( + runtime: OrcadMigrationCommitGenerationRuntime, + migration: OrcadMigrationRequestIdentity, + generationBefore: number +): void { + // A save suppressed by quit or maintenance never bumps the generation, so no in-process write + // can prove this commit; only a restart that reads it from disk does. + runtime.orcadMigrationCommitWriteGenerations.set( + orcadMigrationStagingKey(migration), + runtime.writeGeneration > generationBefore ? runtime.writeGeneration : Number.POSITIVE_INFINITY + ) +} + +/** + * Whether the receipt is on disk. A catalog-state query can see an in-memory receipt whose flush + * failed; consuming its received blobs then would leave a retry after restart without them. + */ +export function isOrcadMigrationCommitDurable( + runtime: OrcadMigrationCommitGenerationRuntime, + migration: OrcadMigrationRequestIdentity +): boolean { + const key = orcadMigrationStagingKey(migration) + const required = runtime.orcadMigrationCommitWriteGenerations.get(key) + if (required === undefined) { + return true + } + if (runtime.lastDurableWriteGeneration < required) { + return false + } + // Durable stays durable, so the entry is no longer needed. + runtime.orcadMigrationCommitWriteGenerations.delete(key) + return true +} 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 f70f9815afb..8e5fbabb518 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts @@ -4,6 +4,7 @@ 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' @@ -48,7 +49,8 @@ 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'] + state: StoreRuntimeState['state'], + editorDrafts?: readonly OrcadMigrationEditorDraft[] ): PreparedOrcadMigrationCatalog { assertOrcadMigrationStagedCatalogClaims(manifest, state.orcadMigrationStagedCatalogs ?? []) const repositories = manifest.payload.repositories.map(toOrcadDestinationRepository) @@ -77,7 +79,7 @@ export function prepareOrcadMigrationCatalog( folderWorkspaces, existingProjectGroups: state.projectGroups }) - const dormantState = prepareOrcadMigrationDormantState(manifest, state) + const dormantState = prepareOrcadMigrationDormantState(manifest, state, editorDrafts) 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 new file mode 100644 index 00000000000..f62ca126dc2 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-editor-drafts.ts @@ -0,0 +1,151 @@ +/** + * 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 92727ebecce..611a3997438 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,6 +32,8 @@ 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( @@ -41,15 +43,22 @@ export function prepareOrcadMigrationWorkspaceSession( if (!incoming) { return { incoming: undefined, merged: undefined } } - assertNoSessionRecordConflicts(state.workspaceSession, incoming) - assertNoSessionFocusConflicts(state.workspaceSession, incoming) - assertNoSessionEntityConflicts(state.workspaceSession, incoming) + assertNoOrcadMigrationSessionConflicts(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 @@ -73,6 +82,9 @@ 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 62f5749c101..60bf49ec442 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,9 +16,10 @@ 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, @@ -46,9 +47,11 @@ export type PreparedOrcadMigrationDormantState = { clientState: PreparedOrcadMigrationClientState } +/** `editorDrafts`: the verified declared drafts at commit; absent while staging. */ export function prepareOrcadMigrationDormantState( manifest: OrcadMigrationManifest, - state: PersistedState + state: PersistedState, + editorDrafts?: readonly OrcadMigrationEditorDraft[] ): PreparedOrcadMigrationDormantState { const payload = manifest.payload.dormantState if (!payload) { @@ -116,7 +119,7 @@ export function prepareOrcadMigrationDormantState( entry.registry ) })), - workspaceSession: prepareOrcadMigrationWorkspaceSession(payload.workspaceSession, state), + workspaceSession: prepareOrcadMigrationSessionWithDrafts(payload, state, editorDrafts), automationState: prepareOrcadMigrationAutomationState( payload.automations, payload.automationRuns, diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts index cba07a969eb..aad338b98d0 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts @@ -4,7 +4,10 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import { parsePaneKey } from '../../../shared/stable-pane-id' import { getHistoryRoot } from '../../terminal-history-paths' import type { StoreRuntimeState } from '../loading-store/store-runtime-state' -import { findOrcadMigrationImportReceipt } from './orcad-catalog-receipt-ledger' +import { + findOrcadMigrationImportReceipt, + isOrcadMigrationCommitDurable +} from './orcad-catalog-receipt-ledger' import { installOrcadMigrationReceivedBlobs } from './orcad-migration-blob-installers' import { listOrcadMigrationReceivedBlobNames } from './orcad-migration-blob-staging' import type { OrcadMigrationRequestIdentity } from './orcad-migration-staged-file' @@ -16,6 +19,9 @@ type OrcadMigrationBlobInstallRuntime = Pick< | 'orcadTerminalRestoreHints' | 'paneShellPath' | 'deferredShellHistoryWorktreeIds' + | 'writeGeneration' + | 'lastDurableWriteGeneration' + | 'orcadMigrationCommitWriteGenerations' > type ClockOptions = { now?: () => Date } @@ -38,9 +44,9 @@ export class OrcadMigrationBlobInstallPersistence { } /** - * Run only once the commit is durable: installing first and then losing the commit would leave - * live hints or history for a catalog this host never received. A no-op before commit and on - * repeats. + * Installs only once the commit is durable: installing first and then losing the commit would + * leave live state for a catalog this host never received, and consume the received blobs a + * retry after restart needs. A no-op before then and on repeats. */ installOrcadMigrationReceivedBlobs( manifest: OrcadMigrationManifest, @@ -127,6 +133,9 @@ function installCommitted( migration: OrcadMigrationRequestIdentity, options: InstallOptions ): void { + if (!isOrcadMigrationCommitDurable(runtime, migration)) { + return + } installOrcadMigrationReceivedBlobs({ migration, root: runtime.orcadMigrationBlobRoot, 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 32c771cdb29..808418aae4e 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,10 +3,11 @@ * 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 { readFileSync, rmSync } from 'node:fs' +import { existsSync, 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, @@ -101,7 +102,11 @@ 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]) { + for (const installKind of [ + installTerminalRestoreHints, + installShellHistory, + releaseEditorDrafts + ]) { try { installKind(install) } catch (error) { @@ -128,6 +133,19 @@ 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 c6cbbd49718..29086435854 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,7 +56,8 @@ const receivedDirectoryPath: ReceivedBlobPathResolver = (root, manifest, descrip const RECEIVED_BLOB_DESTINATIONS: Record = { 'terminal-restore-hints': receivedDirectoryPath, 'shell-history': receivedDirectoryPath, - 'agent-session-records': receivedDirectoryPath + 'agent-session-records': receivedDirectoryPath, + 'editor-drafts': 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 af054457c46..775cd504067 100644 --- a/src/shared/orcad-migration-dormant-state-validation.ts +++ b/src/shared/orcad-migration-dormant-state-validation.ts @@ -18,6 +18,10 @@ import { assertOrcadMigrationClientStateReferences, parseOrcadMigrationClientState } from './orcad-migration/orcad-migration-client-state' +import { + assertOrcadMigrationEditorDraftsDeclarationReferences, + parseOrcadMigrationEditorDraftsDeclaration +} from './orcad-migration/orcad-migration-editor-drafts' import { assertUnique, boundedArray, @@ -87,7 +91,12 @@ export function parseOrcadMigrationDormantState(value: unknown): OrcadMigrationD : { automationRuns: parseOrcadMigrationDormantAutomationRuns(record.automationRuns) }), ...(record.clientState === undefined ? {} - : { clientState: parseOrcadMigrationClientState(record.clientState) }) + : { clientState: parseOrcadMigrationClientState(record.clientState) }), + ...(record.editorDraftsBlob === undefined + ? {} + : { + editorDraftsBlob: parseOrcadMigrationEditorDraftsDeclaration(record.editorDraftsBlob) + }) } assertUnique(payload.worktreeMeta, (entry) => entry.worktreeId, 'worktree_meta') assertUnique(payload.worktreeMeta, (entry) => entry.sourceKey, 'worktree_meta_source') @@ -166,6 +175,12 @@ 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/orcad-migration-blob-transfer.test.ts b/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts index 92f50bb709a..c55a9eabd7c 100644 --- a/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts +++ b/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts @@ -3,6 +3,7 @@ import { MAX_ORCAD_MIGRATION_BLOB_TOTAL_BYTES, ORCAD_MIGRATION_BLOB_KIND_CONTRACTS, OrcadMigrationBlobChunkRequestSchema, + OrcadMigrationBlobDescriptorSchema, advertisedOrcadMigrationBlobCapabilities, assertOrcadMigrationBlobSet, findUnsupportedMigrationBlobKind, @@ -123,8 +124,29 @@ 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.shell-history.v1', + 'orcad.migration-blob.editor-drafts.v1' ]) + // A kind with no installer stays unadvertised, so no desktop sends it here. + expect(advertisedOrcadMigrationBlobCapabilities()).not.toContain( + 'orcad.migration-blob.agent-session-records.v1' + ) + }) + + 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 + }) + 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 + ).toBe(false) }) it('keeps unknown request fields out and checks the acknowledged length', () => { diff --git a/src/shared/orcad-migration/orcad-migration-blob-transfer.ts b/src/shared/orcad-migration/orcad-migration-blob-transfer.ts index a098939c081..f32e325be26 100644 --- a/src/shared/orcad-migration/orcad-migration-blob-transfer.ts +++ b/src/shared/orcad-migration/orcad-migration-blob-transfer.ts @@ -19,7 +19,8 @@ import { ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY } from '../orcad-runtim export const ORCAD_MIGRATION_BLOB_KINDS = [ 'terminal-restore-hints', 'shell-history', - 'agent-session-records' + 'agent-session-records', + 'editor-drafts' ] as const export type OrcadMigrationBlobKind = (typeof ORCAD_MIGRATION_BLOB_KINDS)[number] @@ -54,6 +55,12 @@ 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' } } @@ -259,7 +266,8 @@ 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' + 'shell-history', + 'editor-drafts' ] 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 new file mode 100644 index 00000000000..db5bd38878d --- /dev/null +++ b/src/shared/orcad-migration/orcad-migration-editor-drafts.test.ts @@ -0,0 +1,153 @@ +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 new file mode 100644 index 00000000000..c829a85007f --- /dev/null +++ b/src/shared/orcad-migration/orcad-migration-editor-drafts.ts @@ -0,0 +1,132 @@ +/** + * 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 0de03f92e1a..11d57fcaebe 100644 --- a/src/shared/orcad-migration/orcad-migration-manifest.ts +++ b/src/shared/orcad-migration/orcad-migration-manifest.ts @@ -14,6 +14,7 @@ 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' @@ -92,6 +93,11 @@ 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 = {