refactor(orcad-migration): drop the host-side editor-drafts transfer; keep the durable-commit gate (#30884)

This commit is contained in:
Neil
2026-10-19 16:05:34 -07:00
committed by GitHub
parent 0a3d203f61
commit 7e75a86caa
17 changed files with 88 additions and 924 deletions
@@ -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<typeof createStore>
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'
}
@@ -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')
})
})
@@ -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<typeof createStore>
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)
})
})
@@ -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<StoreRuntimeState, 'state' | 'terminalScrollbackSnapshotStorage' | 'orcadMigrationBlobRoot'>
@@ -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)
@@ -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,
@@ -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
})
])
)
}
}
@@ -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
}
@@ -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,
@@ -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',
@@ -56,8 +56,7 @@ const receivedDirectoryPath: ReceivedBlobPathResolver = (root, manifest, descrip
const RECEIVED_BLOB_DESTINATIONS: Record<OrcadMigrationBlobKind, ReceivedBlobPathResolver> = {
'terminal-restore-hints': receivedDirectoryPath,
'shell-history': receivedDirectoryPath,
'agent-session-records': receivedDirectoryPath,
'editor-drafts': receivedDirectoryPath
'agent-session-records': receivedDirectoryPath
}
export function getOrcadMigrationReceivedBlobPath(
@@ -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 ?? []
@@ -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(
@@ -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)
})
@@ -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[] {
@@ -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')
})
})
@@ -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
}
@@ -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 = {