feat(orcad-migration): unsaved drafts stay on this desktop and are renamed to the server on commit (#30894)

* feat(orcad-migration): unsaved drafts stay on this desktop and are renamed to the server on commit

* fix(orcad-migration): refuse clashing drafts from the rename's own collection; stricter live spec

* fix(orcad-migration): stamp renamed drafts with the server, rename at startup after a cut-off commit, keep draft refusals

* fix(orcad-migration): retain only after a startup rename is durable; startup survives an unreadable journal

* fix(orcad-migration): a cut-off commit is renamed and retained at startup, so the rename runs once

* fix(orcad-migration): one retain per migration at a time; no rollback that could drop newer edits

* test(e2e): check the visible draft in whichever editor a large file opens in

* fix(orcad-migration): startup retains only migrations of hosts this profile owns

* ci(e2e): route the editor-drafts migration spec to the sources it covers
This commit is contained in:
Neil
2026-10-19 22:44:43 -07:00
committed by GitHub
parent d47e3de088
commit e278bc9a17
22 changed files with 798 additions and 45 deletions
+1
View File
@@ -71,5 +71,6 @@
4 src/main/runtime/workspace-session-worktree-id.ts
1 src/main/runtime/workspaces/session-hosts/get-runtime-id.ts
2 src/main/runtime/workspaces/workspace-file-session-targets.ts
1 src/main/ssh/orcad-migration-source-retention.ts
1 src/main/ssh/targets/ssh-target-id-migration.ts
2 src/main/terminal-scrollback-snapshots.ts
+11
View File
@@ -253,6 +253,17 @@ export const PR_E2E_SOURCE_ROUTES = [
file
))
},
{
// Unsaved drafts stay on the desktop and are renamed to the server when a move commits.
id: 'ssh.orcad-editor-drafts-migration',
specs: ['tests/e2e/ssh-orcad-editor-drafts-migration.spec.ts'],
matches: (file) =>
MANAGED_SSH_HARNESS.test(file) ||
(isProductSource(file) &&
/^src\/main\/(?:persistence\/migrating-orcad-catalog\/orcad-source-(?:draft-rename|workspace-session|session-copy-resolution|export)|ssh\/orcad-migration-source-retention|ipc\/ssh\/ssh)\.ts$/.test(
file
))
},
{
// Why: hand-kept routes missed #30130 and #30120; scoped to managed-host code to bound lane cost.
id: 'ssh.orcad-remote-sources',
@@ -55,10 +55,10 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne
await ensureOrcadManagedTunnel(userDataPath, environmentId)
},
ensureServing: (environmentId) => verifyOrcadManagedServing(userDataPath, environmentId),
retainCommittedSource: (target) => {
retainCommittedSource: async (target) => {
const head = findOrcadMigrationSourceCutoverForTarget(userDataPath, target.id)
if (head?.phase === 'destination-committed') {
retainOrcadMigrationSource(userDataPath, head.migrationId)
await retainOrcadMigrationSource(userDataPath, head.migrationId, store)
}
},
hasTemplate: hasOrcadTemplate,
+2
View File
@@ -53,6 +53,7 @@ import { resetSshShutdownDrain } from './ssh-shutdown-drain'
import { registerSshTargetCrudHandlers } from './ssh-target-crud-handlers'
import { targetLifecycleInFlight } from './ssh-target-lifecycle-queue'
import { disposeOrcadManagedTunnels } from '../../managed-server/lifecycle/orcad-managed-tunnel'
import { retainUnretainedOrcadMigrationSources } from '../../ssh/orcad-migration-source-retention'
import { reconcileManagedOrcadSshTargets } from '../../ssh/orcad-retained-source'
import { installOrcadMigrationScrollbackRetention } from '../../ssh/orcad-migration-scrollback-retention-wiring'
import { getAppEnvironment } from '../../../shared/app-environment'
@@ -99,6 +100,7 @@ export function registerSshHandlers(
setSshTargetRegistryStore(new SshConnectionStore(store))
setPersistedStore(store)
reconcileManagedOrcadSshTargets(getAppEnvironment().getPath('userData'), store)
void retainUnretainedOrcadMigrationSources(getAppEnvironment().getPath('userData'), store)
installOrcadMigrationScrollbackRetention(getAppEnvironment().getPath('userData'), store)
installManagedOrcadStartStatus()
@@ -138,7 +138,7 @@ export async function convertSshTargetToManagedOrcad(
throw new Error(`orcad_migration_commit_not_proven:${committed.phase}`)
}
if (committed.phase === 'destination-committed') {
retainOrcadMigrationSource(userDataPath, committed.migrationId, args.now)
await retainOrcadMigrationSource(userDataPath, committed.migrationId, store, args.now)
}
const linkedProjects = linkedOrcadMigrationProjects(store, linked)
return {
@@ -0,0 +1,104 @@
/**
* Unsaved editor drafts never leave this desktop. Its own session holds each one per host
* partition, file path and disk baseline; a committed move renames the record from the SSH host
* to its server, and restore lays it over the file and warns when the baseline no longer matches.
*/
import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type {
PersistedOpenFile,
WorkspaceSessionState
} from '../../../shared/workspace-session-state-types'
import {
createOrcadMigrationSourceScope,
orcadMigrationOwnerMatchesScope,
orcadMigrationPartitionScope,
unqualifyOrcadMigrationOwnerKey
} from './orcad-source-scope'
import { orcadMigrationSessionPartitions } from './orcad-source-session-partitions'
/** Destination worktree key → the drafted files it gains, as the server names that worktree. */
export type OrcadMigrationSourceDrafts = Record<string, PersistedOpenFile[]>
/** Another host's file stays that host's record; the move cannot rename it. */
export function isHostBoundOpenFile(file: PersistedOpenFile): boolean {
return file.externalSshTargetId !== undefined || Boolean(file.runtimeEnvironmentId)
}
export function collectOrcadMigrationSourceDrafts(
state: PersistedState,
manifest: Pick<OrcadMigrationManifest, 'source' | 'payload'>
): OrcadMigrationSourceDrafts {
const sourceScope = createOrcadMigrationSourceScope({
source: manifest.source,
catalog: manifest.payload,
repos: state.repos
})
// The host's own copy first: where both hold one file, it is the one the app reads.
const partitions = orcadMigrationSessionPartitions(state, sourceScope).sort(
([a], [b]) => Number(b === sourceScope.hostId) - Number(a === sourceScope.hostId)
)
const drafts = new Map<string, Map<string, PersistedOpenFile>>()
for (const [partitionId, session] of partitions) {
const scope = orcadMigrationPartitionScope(sourceScope, partitionId)
for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) {
if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) {
continue
}
const key = unqualifyOrcadMigrationOwnerKey(ownerKey)
for (const file of files) {
if (file.dirtyDraftContent === undefined || isHostBoundOpenFile(file)) {
continue
}
const byPath = drafts.get(key) ?? new Map<string, PersistedOpenFile>()
drafts.set(key, byPath)
if (!byPath.has(file.filePath)) {
byPath.set(file.filePath, { ...structuredClone(file), worktreeId: key })
}
}
}
}
return Object.fromEntries([...drafts].map(([key, byPath]) => [key, [...byPath.values()]]))
}
/**
* The destination partition with each draft added, or null when nothing changes. A file the
* destination already lists keeps its own record, so a draft discarded there never comes back.
*/
export function withRenamedOrcadMigrationDrafts(
destination: WorkspaceSessionState,
drafts: OrcadMigrationSourceDrafts,
destinationEnvironmentId: string
): WorkspaceSessionState | null {
const openFilesByWorktree = { ...destination.openFilesByWorktree }
let changed = false
for (const [key, files] of Object.entries(drafts)) {
const held = openFilesByWorktree[key] ?? []
const heldPaths = new Set(held.map((file) => file.filePath))
// Owned by the server now, so reads, saves and its mirrored tab all resolve to it.
const added = files
.filter((file) => !heldPaths.has(file.filePath))
.map((file) => ({ ...file, runtimeEnvironmentId: destinationEnvironmentId }))
if (added.length > 0) {
openFilesByWorktree[key] = [...held, ...added]
changed = true
}
}
return changed ? { ...destination, openFilesByWorktree } : null
}
/** The session the server is sent: tabs without their unsaved contents. */
export function withoutOrcadMigrationDrafts(session: WorkspaceSessionState): WorkspaceSessionState {
if (!session.openFilesByWorktree) {
return session
}
return {
...session,
openFilesByWorktree: Object.fromEntries(
Object.entries(session.openFilesByWorktree).map(([key, files]) => [
key,
files.map(({ dirtyDraftContent: _draft, lastKnownDiskSignature: _base, ...file }) => file)
])
)
}
}
@@ -28,6 +28,10 @@ import {
} from './orcad-source-delta-view'
import { syncOrcadMigrationScrollbackRetention } from './orcad-source-scrollback-retention'
import { createOrcadMigrationLeaveBehindView } from './orcad-source-leave-behind-view'
import {
collectOrcadMigrationSourceDrafts,
type OrcadMigrationSourceDrafts
} from './orcad-source-draft-rename'
type OrcadSourceExportRuntime = Pick<
StoreRuntimeState,
@@ -58,6 +62,11 @@ export class OrcadSourceExportPersistence {
).payload
}
/** This desktop's drafts for what `manifest` moves, keyed as the server names the worktrees. */
collectOrcadMigrationSourceDrafts(manifest: OrcadMigrationManifest): OrcadMigrationSourceDrafts {
return collectOrcadMigrationSourceDrafts(this[orcadSourceExportContext].state, manifest)
}
/** A copy of the source with `moved` subtracted from it, for a delta move. */
createOrcadMigrationDeltaView(moved: OrcadMigrationManifest): OrcadMigrationDeltaView {
return createOrcadMigrationDeltaView(this[orcadSourceExportContext], moved)
@@ -10,6 +10,7 @@ import { createOrcadMigrationManifest } from '../../ssh/orcad-migration-manifest
import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state'
import { collectOrcadMigrationUntransferredDependencyCensus } from './orcad-source-dependency-census'
import { subtractOrcadMigrationSourceDormantState } from './orcad-source-dormant-subtraction'
import { collectOrcadMigrationSourceDrafts } from './orcad-source-draft-rename'
const TARGET = {
id: 'ssh-prod',
@@ -62,6 +63,16 @@ function draft(): PersistedOpenFile {
}
}
/** The tab the server gets, while the draft stays here for the commit to rename. */
function expectDraftKeptHere(state: PersistedState): void {
const manifest = exportState(state)
const { dirtyDraftContent: _draft, ...tab } = draft()
expect(manifest.payload.dormantState?.workspaceSession?.openFilesByWorktree?.[WORKTREE]).toEqual([
tab
])
expect(collectOrcadMigrationSourceDrafts(state, manifest)[WORKTREE]).toEqual([draft()])
}
function sourceState(): PersistedState {
const state = getDefaultPersistedState('/home/user')
state.sshTargets = [TARGET]
@@ -248,7 +259,7 @@ describe('migrating saved state from released SSH clients', () => {
expect(census(state).totalCount).toBe(0)
const session = exportState(state).payload.dormantState?.workspaceSession
expect(session?.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['local'])
expect(session?.openFilesByWorktree?.[WORKTREE]).toEqual([draft()])
expectDraftKeptHere(state)
expect(session?.sleepingAgentSessionsByPaneKey ?? {}).toEqual({})
})
@@ -287,7 +298,7 @@ describe('migrating saved state from released SSH clients', () => {
expect(census(state).totalCount).toBe(0)
const session = exportState(state).payload.dormantState?.workspaceSession
expect(session?.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['host'])
expect(session?.openFilesByWorktree?.[WORKTREE]).toEqual([draft()])
expectDraftKeptHere(state)
})
it('refuses when the host holds a different copy of the file with the local draft', () => {
@@ -35,6 +35,7 @@ import {
resolveCopiesForHost,
type SessionFragment
} from './orcad-source-session-copy-resolution'
import { withoutOrcadMigrationDrafts } from './orcad-source-draft-rename'
import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { normalizeWorkspaceSessionKeyToWorkspaceId } from '../../../shared/workspace-scope'
@@ -240,8 +241,14 @@ function collectWorkspaceSession(
if (duplicates.length > 0) {
return { payload: undefined, snapshots: [], blockedItems: duplicates, leftBehindItems }
}
// Drafts stay on this desktop; the commit renames them to the server (orcad-source-draft-rename).
return merged.session
? { payload: merged.session, snapshots, blockedItems: [], leftBehindItems }
? {
payload: withoutOrcadMigrationDrafts(merged.session),
snapshots,
blockedItems: [],
leftBehindItems
}
: {
payload: undefined,
snapshots: [],
@@ -600,12 +600,12 @@ async function connectAgain(): Promise<void> {
await resolveHostServerOnConnect(target, {
...hostServerDepsStub(),
managedEnvironmentId: () => environmentId,
retainCommittedSource: (host) => {
retainCommittedSource: async (host) => {
const head = listOrcadMigrationSourceCutovers(userDataPath).find(
(journal) => journal.sshTargetId === host.id && !journal.sourceRetainedAt
)
if (head?.phase === 'destination-committed') {
retainOrcadMigrationSource(userDataPath, head.migrationId, now)
await retainOrcadMigrationSource(userDataPath, head.migrationId, store, now)
}
}
})
+1 -1
View File
@@ -177,7 +177,7 @@ async function commitDelta(
throw error
}
}
retainOrcadMigrationSource(userDataPath, cutover.migrationId, now)
await retainOrcadMigrationSource(userDataPath, cutover.migrationId, store, now)
return { outcome: 'moved', migrationId: cutover.migrationId }
}
@@ -3,38 +3,104 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { Repo } from '../../shared/repo-types'
import type { PersistedOpenFile } from '../../shared/workspace-session-state-types'
import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness'
import { Store } from '../persistence/loading-store/store'
import {
listOrcadMigrationSourceCutovers,
writeOrcadMigrationSourceCutover
} from './orcad-migration-cutover-journal'
import { orcadMigrationCutoverFixture } from './orcad-migration-cutover-fixture'
import { listPendingManagedOrcadMigrations } from '../managed-server/lifecycle/orcad-managed-migration-status'
import { retainOrcadMigrationSource } from './orcad-migration-source-retention'
import {
retainUnretainedOrcadMigrationSources,
retainOrcadMigrationSource
} from './orcad-migration-source-retention'
const TARGET_ID = 'ssh-1'
const SOURCE_HOST = `ssh:${TARGET_ID}`
const SERVER_HOST = 'runtime:env-1'
const WORKTREE = 'repo-1::/srv/app'
const REPO: Repo = {
id: 'repo-1',
path: '/srv/app',
displayName: 'app',
badgeColor: '#737373',
addedAt: 1,
kind: 'git',
connectionId: TARGET_ID
}
let userDataPath: string
let store: Store
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orcad-retention-'))
store = createSqliteTestStore(Store, { dataFile: join(userDataPath, 'orca-data.json') })
})
afterEach(() => {
afterEach(async () => {
await closeTestStores()
rmSync(userDataPath, { recursive: true, force: true })
})
function committed(migrationId = 'm-1') {
const cutover = orcadMigrationCutoverFixture(migrationId, TARGET_ID)
return {
...cutover,
phase: 'destination-committed' as const,
manifest: {
...cutover.manifest,
payload: { ...cutover.manifest.payload, repositories: [REPO] }
}
}
}
function draftedFile(filePath: string, draft: string): PersistedOpenFile {
return {
filePath,
relativePath: filePath.slice('/srv/app/'.length),
worktreeId: WORKTREE,
language: 'markdown',
dirtyDraftContent: draft,
lastKnownDiskSignature: 'base-1'
}
}
function saveFiles(hostId: string, files: PersistedOpenFile[]): void {
store.setWorkspaceSession(
{ ...store.getWorkspaceSession(hostId), openFilesByWorktree: { [WORKTREE]: files } },
hostId
)
}
async function restart(): Promise<void> {
await store.flushPendingOrThrowAsync()
await closeTestStores()
store = createSqliteTestStore(Store, { dataFile: join(userDataPath, 'orca-data.json') })
}
const serverFiles = () => store.getWorkspaceSession(SERVER_HOST).openFilesByWorktree?.[WORKTREE]
describe('retaining a committed migration source', () => {
it('marks only a committed cutover, idempotently, and stops counting it as pending', () => {
it('marks only a committed cutover, idempotently, and stops counting it as pending', async () => {
writeOrcadMigrationSourceCutover(userDataPath, orcadMigrationCutoverFixture('m-1'))
expect(() => retainOrcadMigrationSource(userDataPath, 'm-1')).toThrow(
await expect(retainOrcadMigrationSource(userDataPath, 'm-1', store)).rejects.toThrow(
'orcad_migration_retain_before_commit'
)
const committed = {
writeOrcadMigrationSourceCutover(userDataPath, {
...orcadMigrationCutoverFixture('m-1'),
phase: 'destination-committed' as const
}
writeOrcadMigrationSourceCutover(userDataPath, committed)
phase: 'destination-committed'
})
const at = () => new Date('2026-10-03T00:00:00.000Z')
retainOrcadMigrationSource(userDataPath, 'm-1', at)
retainOrcadMigrationSource(userDataPath, 'm-1', () => new Date('2026-12-01T00:00:00.000Z'))
await retainOrcadMigrationSource(userDataPath, 'm-1', store, at)
await retainOrcadMigrationSource(
userDataPath,
'm-1',
store,
() => new Date('2026-12-01T00:00:00.000Z')
)
expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.sourceRetainedAt).toBe(
'2026-10-03T00:00:00.000Z'
)
@@ -55,3 +121,142 @@ describe('retaining a committed migration source', () => {
).not.toThrow()
})
})
describe('renaming unsaved drafts to the server on commit', () => {
beforeEach(() => {
store.addSshTarget({
id: TARGET_ID,
label: 'Prod',
host: 'h',
port: 22,
username: 'u',
generation: 2
})
store.addRepo(REPO)
})
it('moves the host and desktop drafts, with their baseline, and leaves the source as it was', async () => {
saveFiles(SOURCE_HOST, [draftedFile('/srv/app/host.md', 'host draft')])
saveFiles('local', [draftedFile('/srv/app/desktop.md', 'desktop draft')])
const source = JSON.stringify(store.getWorkspaceSession(SOURCE_HOST))
writeOrcadMigrationSourceCutover(userDataPath, committed())
await retainOrcadMigrationSource(userDataPath, 'm-1', store)
expect(serverFiles()).toEqual([
{ ...draftedFile('/srv/app/host.md', 'host draft'), runtimeEnvironmentId: 'env-1' },
{ ...draftedFile('/srv/app/desktop.md', 'desktop draft'), runtimeEnvironmentId: 'env-1' }
])
expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.sourceRetainedAt).toBeDefined()
expect(JSON.stringify(store.getWorkspaceSession(SOURCE_HOST))).toBe(source)
await store.flushPendingOrThrowAsync()
await closeTestStores()
store = createSqliteTestStore(Store, { dataFile: join(userDataPath, 'orca-data.json') })
expect(serverFiles()?.map((file) => file.dirtyDraftContent)).toEqual([
'host draft',
'desktop draft'
])
})
it('keeps what the server partition already holds, and never revives a discarded draft', async () => {
saveFiles(SOURCE_HOST, [
draftedFile('/srv/app/a.md', 'source A'),
draftedFile('/srv/app/b.md', 'source B')
])
saveFiles(SERVER_HOST, [{ ...draftedFile('/srv/app/a.md', 'newer A') }])
writeOrcadMigrationSourceCutover(userDataPath, committed())
await retainOrcadMigrationSource(userDataPath, 'm-1', store)
expect(serverFiles()?.map((file) => file.dirtyDraftContent)).toEqual(['newer A', 'source B'])
saveFiles(SERVER_HOST, [])
await retainOrcadMigrationSource(userDataPath, 'm-1', store)
expect(serverFiles()).toEqual([])
await restart()
await retainUnretainedOrcadMigrationSources(userDataPath, store)
await retainOrcadMigrationSource(userDataPath, 'm-1', store)
expect(serverFiles()).toEqual([])
})
it('renames and retains at startup after a cut-off commit, so a draft closed later stays closed', async () => {
saveFiles(SOURCE_HOST, [draftedFile('/srv/app/a.md', 'source A')])
writeOrcadMigrationSourceCutover(userDataPath, committed())
await restart()
await retainUnretainedOrcadMigrationSources(userDataPath, store)
expect(serverFiles()?.map((file) => file.dirtyDraftContent)).toEqual(['source A'])
expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.sourceRetainedAt).toBeDefined()
// Closed before any connect: neither the connect's retain nor a later start brings it back.
saveFiles(SERVER_HOST, [])
await retainOrcadMigrationSource(userDataPath, 'm-1', store)
await retainUnretainedOrcadMigrationSources(userDataPath, store)
await restart()
await retainUnretainedOrcadMigrationSources(userDataPath, store)
expect(serverFiles()).toEqual([])
})
it('shares one retain between concurrent callers, so a later one never renames again', async () => {
saveFiles(SOURCE_HOST, [draftedFile('/srv/app/a.md', 'source A')])
writeOrcadMigrationSourceCutover(userDataPath, committed())
const first = retainOrcadMigrationSource(userDataPath, 'm-1', store)
expect(retainOrcadMigrationSource(userDataPath, 'm-1', store)).toBe(first)
await first
saveFiles(SERVER_HOST, [])
await retainOrcadMigrationSource(userDataPath, 'm-1', store)
expect(serverFiles()).toEqual([])
})
it("never retains another profile's migration at startup", async () => {
writeOrcadMigrationSourceCutover(userDataPath, {
...orcadMigrationCutoverFixture('m-9', 'ssh-other-profile'),
phase: 'destination-committed'
})
await retainUnretainedOrcadMigrationSources(userDataPath, store)
expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.sourceRetainedAt).toBeUndefined()
})
it('keeps newer typing in the server copy when the connect retains first', async () => {
saveFiles(SOURCE_HOST, [draftedFile('/srv/app/a.md', 'source A')])
saveFiles(SERVER_HOST, [
{ ...draftedFile('/srv/app/a.md', 'typed after'), runtimeEnvironmentId: 'env-1' }
])
writeOrcadMigrationSourceCutover(userDataPath, committed())
await retainOrcadMigrationSource(userDataPath, 'm-1', store)
await retainUnretainedOrcadMigrationSources(userDataPath, store)
expect(serverFiles()?.map((file) => file.dirtyDraftContent)).toEqual(['typed after'])
})
it('makes the startup rename durable before its retained mark', async () => {
saveFiles(SOURCE_HOST, [draftedFile('/srv/app/a.md', 'source A')])
writeOrcadMigrationSourceCutover(userDataPath, committed())
await restart()
await retainUnretainedOrcadMigrationSources(userDataPath, store)
expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.sourceRetainedAt).toBeDefined()
// A crash now: a second reader sees only what reached disk.
const crashed = createSqliteTestStore(Store, { dataFile: join(userDataPath, 'orca-data.json') })
expect(
crashed.getWorkspaceSession(SERVER_HOST).openFilesByWorktree?.[WORKTREE]?.[0]
?.dirtyDraftContent
).toBe('source A')
})
it("leaves another host's file and another project's draft where they are", async () => {
saveFiles(SOURCE_HOST, [
{ ...draftedFile('/srv/app/remote.md', 'other host'), runtimeEnvironmentId: 'env-9' }
])
store.setWorkspaceSession(
{
...store.getWorkspaceSession(SOURCE_HOST),
openFilesByWorktree: {
...store.getWorkspaceSession(SOURCE_HOST).openFilesByWorktree,
'repo-2::/srv/other': [draftedFile('/srv/other/x.md', 'other project')]
}
},
SOURCE_HOST
)
writeOrcadMigrationSourceCutover(userDataPath, committed())
await retainOrcadMigrationSource(userDataPath, 'm-1', store)
expect(store.getWorkspaceSession(SERVER_HOST).openFilesByWorktree ?? {}).toEqual({})
})
})
+133 -10
View File
@@ -3,30 +3,153 @@
* its projects and can reach them over its own relay. New builds hide those rows and serve the host
* from the server. They are never deleted automatically.
*/
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration/orcad-migration-source-cutover'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import type { Store } from '../persistence'
import { withRenamedOrcadMigrationDrafts } from '../persistence/migrating-orcad-catalog/orcad-source-draft-rename'
import {
listOrcadMigrationSourceCutovers,
writeOrcadMigrationSourceCutover
} from './orcad-migration-cutover-journal'
/** Marks a committed migration as finished for this build while its source rows stay. */
export type OrcadMigrationDraftRenameStore = Pick<
Store,
| 'collectOrcadMigrationSourceDrafts'
| 'getWorkspaceSession'
| 'runDurableMutation'
| 'setWorkspaceSession'
>
// One retain per migration at a time: a second caller waits for the first and then finds it done.
const retainsInFlight = new Map<string, Promise<OrcadMigrationSourceCutover>>()
/**
* Marks a committed migration as finished for this build while its source rows stay. Its unsaved
* drafts are renamed to the server first, durably, so the retained mark proves they were.
*/
export function retainOrcadMigrationSource(
userDataPath: string,
migrationId: string,
store: OrcadMigrationDraftRenameStore,
now: () => Date = () => new Date()
): OrcadMigrationSourceCutover {
): Promise<OrcadMigrationSourceCutover> {
const pending = retainsInFlight.get(migrationId)
if (pending) {
return pending
}
const retain = retainOnce(userDataPath, migrationId, store, now).finally(() =>
retainsInFlight.delete(migrationId)
)
retainsInFlight.set(migrationId, retain)
return retain
}
async function retainOnce(
userDataPath: string,
migrationId: string,
store: OrcadMigrationDraftRenameStore,
now: () => Date
): Promise<OrcadMigrationSourceCutover> {
const cutover = readCommittedCutover(userDataPath, migrationId)
if (cutover.sourceRetainedAt) {
return cutover
}
await store.runDurableMutation(() => {
const renamed = renamedDestinationSession(store, cutover)
if (!renamed) {
// A startup rename may still be only in memory; the retained mark must not outrun it.
return { value: undefined, persist: 'if-dirty' }
}
// No rollback: the renamed rows are correct either way, and a retry adds nothing twice.
writeDestinationSession(store, cutover, renamed.next)
return { value: undefined }
})
// Why no baseline here: the source may have changed since the commit (a crash, then an older
// build); only the fence's pre-commit baseline proves what the server holds.
const retained = {
...readCommittedCutover(userDataPath, migrationId),
sourceRetainedAt: now().toISOString()
}
writeOrcadMigrationSourceCutover(userDataPath, retained)
return retained
}
/**
* At startup, for a commit whose retained mark a crash cut off: its drafts are renamed before any
* window reads its sessions, since a renderer that booted without them would overwrite them; then
* the move is retained, so the rename never runs again and a draft closed later stays closed.
*/
export function retainUnretainedOrcadMigrationSources(
userDataPath: string,
store: OrcadMigrationDraftRenameStore & Pick<Store, 'getSshTarget'>
): Promise<void> {
const migrationIds: string[] = []
try {
for (const cutover of listOrcadMigrationSourceCutovers(userDataPath)) {
// Journals are shared across profiles; only the profile that owns the host can rename.
const target = store.getSshTarget(cutover.sshTargetId)
if (
cutover.phase !== 'destination-committed' ||
cutover.sourceRetainedAt ||
target?.generation !== cutover.sshTargetGeneration
) {
continue
}
const renamed = renamedDestinationSession(store, cutover)
if (renamed) {
writeDestinationSession(store, cutover, renamed.next)
}
migrationIds.push(cutover.migrationId)
}
} catch (error) {
// The connect's retain renames them instead; startup must not fail on an unreadable journal.
console.warn('[ssh] Could not rename committed migrations’ unsaved drafts:', error)
}
return Promise.all(
migrationIds.map((migrationId) =>
retainOrcadMigrationSource(userDataPath, migrationId, store).catch((error: unknown) => {
console.warn('[ssh] Could not mark a finished migration retained:', error)
})
)
).then(() => undefined)
}
function readCommittedCutover(userDataPath: string, migrationId: string) {
const cutover = listOrcadMigrationSourceCutovers(userDataPath).find(
(entry) => entry.migrationId === migrationId
)
if (!cutover || cutover.phase !== 'destination-committed') {
throw new Error('orcad_migration_retain_before_commit')
}
if (cutover.sourceRetainedAt) {
return cutover
}
// Why no baseline here: the source may have changed since the commit (a crash, then an older
// build); only the fence's pre-commit baseline proves what the server holds.
const retained = { ...cutover, sourceRetainedAt: now().toISOString() }
writeOrcadMigrationSourceCutover(userDataPath, retained)
return retained
return cutover
}
/** Only files the server partition lacks, so the retain after a startup rename adds nothing. */
function renamedDestinationSession(
store: Pick<
OrcadMigrationDraftRenameStore,
'collectOrcadMigrationSourceDrafts' | 'getWorkspaceSession'
>,
cutover: OrcadMigrationSourceCutover
): { next: WorkspaceSessionState } | null {
const before = store.getWorkspaceSession(
toRuntimeExecutionHostId(cutover.destinationEnvironmentId)
)
const next = withRenamedOrcadMigrationDrafts(
before,
store.collectOrcadMigrationSourceDrafts(cutover.manifest),
cutover.destinationEnvironmentId
)
return next ? { next } : null
}
function writeDestinationSession(
store: Pick<OrcadMigrationDraftRenameStore, 'setWorkspaceSession'>,
cutover: OrcadMigrationSourceCutover,
session: WorkspaceSessionState
): void {
// Baselined writer: drafts are desktop view state no layout command carries (LayoutEditorFile
// omits them), and the tabs themselves reach the server only through the manifest.
store.setWorkspaceSession(session, toRuntimeExecutionHostId(cutover.destinationEnvironmentId))
}
-2
View File
@@ -24,8 +24,6 @@ export type OrcadMigrationSourceCutoverPort = {
verifyFence: (cutover: OrcadMigrationSourceCutover) => void
/** Drops the fence, then the journal; only on proof the destination holds nothing. */
releaseFence: (cutover: OrcadMigrationSourceCutover) => Promise<void>
/** After a proven commit: the source rows stay, hidden, for a downgraded build. */
retireAfterCommit: (migrationId: string, now?: () => Date) => OrcadMigrationSourceCutover
}
export type OrcadMigrationFenceAcquireArgs = {
@@ -15,7 +15,6 @@ import {
releaseOrcadMigrationFence,
resolveOrcadMigrationFence
} from './orcad-migration-source-fence'
import { retainOrcadMigrationSource } from './orcad-migration-source-retention'
import {
assessOrcadMigrationTerminals,
retireProvenExitedLeases,
@@ -43,9 +42,7 @@ export function sshRelayMigrationCutoverPort(
{ userDataPath: deps.userDataPath, store: deps.freshSource?.() ?? deps.store },
cutover
),
releaseFence: (cutover) => releaseOrcadMigrationFence(deps, cutover),
retireAfterCommit: (migrationId, now) =>
retainOrcadMigrationSource(deps.userDataPath, migrationId, now)
releaseFence: (cutover) => releaseOrcadMigrationFence(deps, cutover)
}
}
@@ -395,7 +395,55 @@ describe('"Move anyway"', () => {
})
})
it("carries the desktop copy's drafts when the host's copy of the tabs wins", async () => {
it("moves past an unsaved draft of the host's own file that only this desktop holds", async () => {
const state = savedStateOnly()
state.workspaceSession.openFilesByWorktree = {
[worktreeId]: [
{
filePath: '/home/dev/src/project-0/notes.md',
relativePath: 'notes.md',
worktreeId,
language: 'markdown',
dirtyDraftContent: 'desktop draft'
}
]
}
delete state.workspaceSession.tabsByWorktree[worktreeId]
const loaded = load(state)
confirmed(loaded)
const result = await connectMove(loaded)
expect(result).toMatchObject({ outcome: 'fenced' })
if ('cutover' in result) {
expect(
loaded.store.collectOrcadMigrationSourceDrafts(result.cutover.manifest)[worktreeId]
).toEqual([expect.objectContaining({ dirtyDraftContent: 'desktop draft' })])
}
})
it('still refuses two different unsaved versions of one file, since only one can be renamed', async () => {
const state = savedStateOnly()
const notes = (dirtyDraftContent: string) => ({
filePath: '/home/dev/src/project-0/notes.md',
relativePath: 'notes.md',
worktreeId,
language: 'markdown',
dirtyDraftContent
})
state.workspaceSessionsByHostId![HOST]!.openFilesByWorktree![worktreeId]!.push(notes('host'))
state.workspaceSession.openFilesByWorktree = { [worktreeId]: [notes('desktop')] }
// An unrelated project's draft must not let the clash slip past as a left-behind item.
state.workspaceSessionsByHostId![HOST]!.openFilesByWorktree!['repo-9::/srv/other'] = [
{ ...notes('other project'), worktreeId: 'repo-9::/srv/other', filePath: '/srv/other/x.md' }
]
const loaded = load(state)
confirmed(loaded)
await expect(connectMove(loaded)).resolves.toMatchObject({
outcome: 'refused',
reason: expect.stringContaining('unsaved changes to /home/dev/src/project-0/notes.md')
})
})
it("keeps the desktop copy's drafts here when the host's copy of the tabs wins", async () => {
const state = savedStateOnly()
state.workspaceSession.openFilesByWorktree = {
[worktreeId]: [
@@ -417,9 +465,13 @@ describe('"Move anyway"', () => {
}
const session = result.cutover.manifest.payload.dormantState?.workspaceSession
expect(session?.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['tab-1'])
// The server gets the tab; the commit renames the draft into this desktop's server partition.
expect(session?.openFilesByWorktree?.[worktreeId]).toEqual([
expect.objectContaining({ dirtyDraftContent: 'desktop draft' })
expect.not.objectContaining({ dirtyDraftContent: expect.anything() })
])
expect(
loaded.store.collectOrcadMigrationSourceDrafts(result.cutover.manifest)[worktreeId]
).toEqual([expect.objectContaining({ dirtyDraftContent: 'desktop draft' })])
})
it('moves a pane whose saved history is gone, without that history', async () => {
@@ -12,7 +12,7 @@ export function hostServerDepsStub(
managedEnvironmentId: () => null,
ensureTunnel: vi.fn(async () => undefined),
ensureServing: vi.fn(async () => ({ state: 'serving' as const })),
retainCommittedSource: vi.fn(),
retainCommittedSource: vi.fn(async () => {}),
hasUnfinishedConversion: () => false,
abandonConversion: vi.fn(async () => undefined),
abandonDeploy: vi.fn(async () => undefined),
+2 -2
View File
@@ -88,7 +88,7 @@ export type HostServerOnConnectDeps = {
/** Behind the tunnel: answers, or is started from its activated slot if proven stopped. */
ensureServing: (environmentId: string) => Promise<OrcadManagedServing>
/** Marks a commit whose reply outlived the move as finished; its source rows are kept. */
retainCommittedSource: (target: SshTarget) => void
retainCommittedSource: (target: SshTarget) => Promise<void>
/** False when this build carries no orcad template, so nothing is tried on the host. */
hasTemplate: () => boolean
/** A recorded "orcad can't run here" whose key still matches this build. */
@@ -172,7 +172,7 @@ async function decide(
return unserved
}
try {
deps.retainCommittedSource(target)
await deps.retainCommittedSource(target)
} catch (error) {
console.warn('[ssh] Could not mark a finished migration retained:', error)
}
+7
View File
@@ -29,6 +29,8 @@ export type OrcadConvertHost = {
blockTcpForwarding?: () => void
/** Docker only: runs a shell command on the host as the SSH user and returns its stdout. */
exec?: (command: string) => string
/** Docker only: copies a host path (file or directory) to a local path. */
copyFromHost?: (remotePath: string, localPath: string) => void
/** Docker only: kills every live SSH connection, leaving sshd listening; returns how many. */
dropSshConnections?: () => number
/** Docker only: freezes or thaws the whole host; TCP stays up but nothing answers. */
@@ -66,6 +68,11 @@ export function startOrcadConvertHost(source: string, testInfo: TestInfo): Orcad
cleanup: () => cleanupDockerSshHostTarget(target),
blockTcpForwarding: () => blockDockerSshHostTargetTcpForwarding(target),
exec: (command) => execDockerSshHostTargetCommand(target, command),
copyFromHost: (remotePath, localPath) =>
execFileSync('docker', ['cp', `${target.containerName}:${remotePath}`, localPath], {
stdio: 'ignore',
timeout: 60_000
}),
dropSshConnections: () =>
Number(
execDockerSshHostTargetControlCommand(target, DROP_SSH_CONNECTIONS).split('\n').at(-1)
+11 -4
View File
@@ -63,6 +63,8 @@ export type ManagedProfileOptions = {
repoPath?: string
/** Overrides the host's folder workspace, e.g. to keep /tmp outside every workspace. */
folderPath?: string
/** Passed through to the relay-era seed, e.g. an unsaved editor draft. */
seed?: Pick<NonNullable<Parameters<typeof seedRelayEraProfile>[3]>, 'draftContent'>
/** Runs on the profile-creating launch, before the SSH profile is seeded. */
beforeSeed?: (page: Page) => Promise<void>
/** Runs as soon as the converting launch starts, before the session is ready. */
@@ -82,10 +84,15 @@ export async function launchConvertedProfile(
await options.beforeSeed?.(first.page)
await session.close(first.app)
fixture.app = null
const seeded = seedRelayEraProfile(session.userDataDir, host.input, {
repoPath: options.repoPath ?? host.remoteRepoPath,
folderPath: options.folderPath ?? host.remoteFolderPath
})
const seeded = seedRelayEraProfile(
session.userDataDir,
host.input,
{
repoPath: options.repoPath ?? host.remoteRepoPath,
folderPath: options.folderPath ?? host.remoteFolderPath
},
options.seed
)
const { app, page } = await session.launch()
fixture.app = app
await options.onLaunch?.(page, seeded)
+4 -1
View File
@@ -92,6 +92,8 @@ export function seedRelayEraProfile(
targetId?: string
/** COMPAT(legacy-relay-bridge): the terminal tab still runs this PTY in the host's relay. */
relayPtyId?: string
/** An unsaved buffer on the editor tab, as hot exit persists it. */
draftContent?: string
} = {}
): RelayEraProfile {
const now = Date.now()
@@ -162,7 +164,8 @@ export function seedRelayEraProfile(
relativePath: 'README.md',
worktreeId,
language: 'markdown',
runtimeEnvironmentId: null
runtimeEnvironmentId: null,
...(options.draftContent === undefined ? {} : { dirtyDraftContent: options.draftContent })
}
]
},
@@ -0,0 +1,216 @@
/**
* S10 live check: an unsaved editor draft on a relay-era SSH host, converted to a managed server.
* Drafts stay on the desktop: after the converting connect and a reconnect the editor still holds
* the exact bytes, and the host server's stored profile holds none of them.
*/
import { createHash } from 'node:crypto'
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import {
activateManagedWorkspace,
launchManagedWorkspace,
managedEnvironmentId,
relaunchManagedApp,
skipUnlessManagedDockerHost,
withManagedHost,
type ManagedHostFixture
} from './helpers/orcad-managed-workspace'
import { reconnect } from './helpers/orcad-convert-flow'
import { readPersistedProfileState } from './helpers/persisted-profile-state'
skipUnlessManagedDockerHost()
// A large markdown file opens in the source editor instead of the rich one.
const VISIBLE_EDITOR = '.rich-markdown-editor, .monaco-editor .view-lines'
type EditorVerdict = 'present' | 'dirty-only' | 'absent'
type HostVerdict = { verdict: 'present' | 'mismatch' | 'absent'; where: string[] }
function draftOf(marker: string, bytes: number): string {
const lines: string[] = []
let length = 0
for (let line = 0; length < bytes; line += 1) {
const text = `${marker} unsaved line ${line}\n`
lines.push(text)
length += text.length
}
return lines.join('').slice(0, bytes)
}
function sha256(text: string): string {
return createHash('sha256').update(text, 'utf8').digest('hex')
}
/** Every string in `value` equal to, or carrying, the draft, by JSON path. */
function findDraft(value: unknown, draft: string, marker: string): HostVerdict {
const exact: string[] = []
const partial: string[] = []
const walk = (node: unknown, at: string): void => {
if (typeof node === 'string') {
if (node === draft) {
exact.push(at)
} else if (node.includes(marker)) {
partial.push(`${at} (${node.length} chars)`)
}
return
}
if (Array.isArray(node)) {
node.forEach((child, index) => walk(child, `${at}[${index}]`))
return
}
if (node && typeof node === 'object') {
for (const [key, child] of Object.entries(node)) {
walk(child, at ? `${at}.${key}` : key)
}
}
}
walk(value, '')
if (exact.length > 0) {
return { verdict: 'present', where: exact }
}
return partial.length > 0
? { verdict: 'mismatch', where: partial }
: { verdict: 'absent', where: [] }
}
/** The managed server's committed profile, copied off the host so it is read as stored. */
function readHostProfiles(fixture: ManagedHostFixture): Record<string, unknown>[] {
const { host } = fixture
const databases = host.exec!(
`find / -xdev -name profile-state.db -path '*/profiles/*' 2>/dev/null || true`
)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const states: Record<string, unknown>[] = []
for (const database of databases) {
const profileDir = path.posix.dirname(database)
const profileId = path.posix.basename(profileDir)
const local = mkdtempSync(path.join(tmpdir(), 'orca-s10d-host-'))
try {
const profilesDir = path.join(local, 'profiles')
mkdirSync(profilesDir, { recursive: true })
// The directory carries the WAL beside the database: committed rows may still sit there.
host.copyFromHost!(profileDir, profilesDir)
if (readdirSync(path.join(profilesDir, profileId)).includes('profile-state.db')) {
states.push(readPersistedProfileState(local, profileId))
}
} finally {
rmSync(local, { recursive: true, force: true })
}
}
return states
}
async function readEditorVerdict(
page: Page,
filePath: string,
draft: string
): Promise<{ verdict: EditorVerdict; detail: string }> {
const seen = await page.evaluate(
({ filePath }) => {
const state = window.__store?.getState()
const files = (state?.openFiles ?? []).filter((file) => file.filePath === filePath)
return files.map((file) => ({
id: file.id,
worktreeId: file.worktreeId,
isDirty: file.isDirty,
draft: state?.editorDrafts[file.id] ?? null
}))
},
{ filePath }
)
const detail = JSON.stringify(
seen.map(({ draft: content, ...rest }) => ({
...rest,
draftChars: content?.length ?? null,
draftSha: content === null ? null : sha256(content)
}))
)
if (seen.some((file) => file.draft === draft)) {
return { verdict: 'present', detail }
}
return { verdict: seen.some((file) => file.isDirty) ? 'dirty-only' : 'absent', detail }
}
function record(testInfo: TestInfo, label: string, line: string): void {
console.log(`S10D-LIVE ${label} ${line}`)
testInfo.annotations.push({ type: 'S10D-LIVE', description: `${label} ${line}` })
}
for (const { label, bytes } of [
// Well inside the 768 KiB signed manifest.
{ label: 'inline', bytes: 2 * 1024 },
// Over the 768 KiB manifest budget, which an inline draft used to overflow.
{ label: 'large', bytes: 2 * 1024 * 1024 }
]) {
test(`an unsaved ${label} editor draft survives conversion to a managed server`, async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch.
{}, testInfo) => {
test.setTimeout(14 * 60_000)
await withManagedHost(testInfo, async (fixture) => {
const marker = `S10D_${label.toUpperCase()}_${Date.now()}`
const draft = draftOf(marker, bytes)
const { page, seeded } = await launchManagedWorkspace(fixture, {
seed: { draftContent: draft }
})
// A further reconnect: what the editor sees once the converted host is reached again.
await reconnect(page, seeded.targetId)
// The editor may hydrate the moved tab a moment after the reconnect settles.
let editor = await readEditorVerdict(page, seeded.sessionFilePath, draft)
await expect
.poll(
async () => {
editor = await readEditorVerdict(page, seeded.sessionFilePath, draft)
return editor.verdict
},
{ timeout: 30_000 }
)
.toBe('present')
.catch(() => {})
await expect(page.locator(VISIBLE_EDITOR).first()).toContainText(`${marker} unsaved line 0`, {
timeout: 60_000
})
// A restart reads the draft back from this desktop's own record, now named for the server.
const environmentId = await managedEnvironmentId(page, seeded.targetId)
const restarted = await relaunchManagedApp(fixture)
await activateManagedWorkspace(restarted, seeded.worktreeId, environmentId)
let restored = await readEditorVerdict(restarted, seeded.sessionFilePath, draft)
await expect
.poll(
async () => {
restored = await readEditorVerdict(restarted, seeded.sessionFilePath, draft)
return restored.verdict
},
{ timeout: 30_000 }
)
.toBe('present')
.catch(() => {})
await expect(restarted.locator(VISIBLE_EDITOR).first()).toContainText(
`${marker} unsaved line 0`,
{ timeout: 60_000 }
)
const profiles = readHostProfiles(fixture)
expect(profiles.length, 'no committed server profile was read').toBeGreaterThan(0)
const found = profiles.map((state) => findDraft(state, draft, marker))
const host: HostVerdict = found.find((entry) => entry.verdict !== 'absent') ?? {
verdict: 'absent',
where: []
}
record(
testInfo,
label,
`bytes=${draft.length} sha=${sha256(draft)} editor=${editor.verdict} ` +
`restarted=${restored.verdict} host=${host.verdict} ` +
`hostAt=${JSON.stringify(host.where)} editorFiles=${editor.detail}`
)
await restarted.screenshot({ path: testInfo.outputPath(`editor-draft-${label}.png`) })
// Asserted after the record, so a miss still says where the draft did and did not go.
expect(editor.verdict, `the desktop lost the ${label} draft`).toBe('present')
expect(restored.verdict, `a restart lost the ${label} draft`).toBe('present')
expect(host.verdict, `the ${label} draft left this desktop`).toBe('absent')
})
})
}