mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
refactor(orcad-migration): drop the host-side editor-drafts transfer; keep the durable-commit gate (#30884)
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
+54
-52
@@ -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
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
-15
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user