From e278bc9a17b42e9b76ca7cd15a1ae52fcd582cc5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:44:43 -0700 Subject: [PATCH] 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 --- config/main-layout-writer-baseline.txt | 1 + config/scripts/pr-e2e-source-routing.mjs | 11 + .../ssh/ssh-host-server-on-connect-wiring.ts | 4 +- src/main/ipc/ssh/ssh.ts | 2 + .../lifecycle/orcad-runtime-conversion.ts | 2 +- .../orcad-source-draft-rename.ts | 104 ++++++++ .../orcad-source-export.ts | 9 + .../orcad-source-release-upgrade.test.ts | 15 +- .../orcad-source-workspace-session.ts | 9 +- .../ssh/orcad-migration-delta-move.test.ts | 4 +- src/main/ssh/orcad-migration-delta-move.ts | 2 +- .../orcad-migration-source-retention.test.ts | 225 +++++++++++++++++- .../ssh/orcad-migration-source-retention.ts | 143 ++++++++++- src/main/ssh/orcad-migration-source.ts | 2 - .../ssh/orcad-migration-ssh-relay-source.ts | 5 +- ...rcad-migration-stuck-host-profiles.test.ts | 56 ++++- .../ssh-host-server-on-connect-test-deps.ts | 2 +- src/main/ssh/ssh-host-server-on-connect.ts | 4 +- tests/e2e/helpers/orcad-convert-host.ts | 7 + tests/e2e/helpers/orcad-managed-workspace.ts | 15 +- tests/e2e/helpers/orcad-upgrade-profile.ts | 5 +- .../ssh-orcad-editor-drafts-migration.spec.ts | 216 +++++++++++++++++ 22 files changed, 798 insertions(+), 45 deletions(-) create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-draft-rename.ts create mode 100644 tests/e2e/ssh-orcad-editor-drafts-migration.spec.ts diff --git a/config/main-layout-writer-baseline.txt b/config/main-layout-writer-baseline.txt index a2558f48639..4166afdb39d 100644 --- a/config/main-layout-writer-baseline.txt +++ b/config/main-layout-writer-baseline.txt @@ -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 diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index d0f0bae07b7..156381499ce 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -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', diff --git a/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.ts b/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.ts index c79d5de4eb8..96fc15bab62 100644 --- a/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.ts +++ b/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.ts @@ -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, diff --git a/src/main/ipc/ssh/ssh.ts b/src/main/ipc/ssh/ssh.ts index 53f581cbddb..6206a11a1e4 100644 --- a/src/main/ipc/ssh/ssh.ts +++ b/src/main/ipc/ssh/ssh.ts @@ -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() diff --git a/src/main/managed-server/lifecycle/orcad-runtime-conversion.ts b/src/main/managed-server/lifecycle/orcad-runtime-conversion.ts index e101fec29cc..944bb817d6e 100644 --- a/src/main/managed-server/lifecycle/orcad-runtime-conversion.ts +++ b/src/main/managed-server/lifecycle/orcad-runtime-conversion.ts @@ -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 { diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-draft-rename.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-draft-rename.ts new file mode 100644 index 00000000000..6bf974e7b20 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-draft-rename.ts @@ -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 + +/** 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 +): 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>() + 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() + 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) + ]) + ) + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts index 55514d3645c..4301a673af2 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts @@ -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) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts index 1b1d7a39b87..f8bbd78594d 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts @@ -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', () => { diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts index f07cb4bf549..648373d427c 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts @@ -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: [], diff --git a/src/main/ssh/orcad-migration-delta-move.test.ts b/src/main/ssh/orcad-migration-delta-move.test.ts index 8c6f12e1934..85d9b221a28 100644 --- a/src/main/ssh/orcad-migration-delta-move.test.ts +++ b/src/main/ssh/orcad-migration-delta-move.test.ts @@ -600,12 +600,12 @@ async function connectAgain(): Promise { 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) } } }) diff --git a/src/main/ssh/orcad-migration-delta-move.ts b/src/main/ssh/orcad-migration-delta-move.ts index f83392a8c52..e949fd1fc71 100644 --- a/src/main/ssh/orcad-migration-delta-move.ts +++ b/src/main/ssh/orcad-migration-delta-move.ts @@ -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 } } diff --git a/src/main/ssh/orcad-migration-source-retention.test.ts b/src/main/ssh/orcad-migration-source-retention.test.ts index 3c31b2f722c..637c9dddf92 100644 --- a/src/main/ssh/orcad-migration-source-retention.test.ts +++ b/src/main/ssh/orcad-migration-source-retention.test.ts @@ -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 { + 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({}) + }) +}) diff --git a/src/main/ssh/orcad-migration-source-retention.ts b/src/main/ssh/orcad-migration-source-retention.ts index b95196a7547..a05153dcfa1 100644 --- a/src/main/ssh/orcad-migration-source-retention.ts +++ b/src/main/ssh/orcad-migration-source-retention.ts @@ -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>() + +/** + * 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 { + 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 { + 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 +): Promise { + 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, + 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)) } diff --git a/src/main/ssh/orcad-migration-source.ts b/src/main/ssh/orcad-migration-source.ts index fb214235095..e1f9a7112b6 100644 --- a/src/main/ssh/orcad-migration-source.ts +++ b/src/main/ssh/orcad-migration-source.ts @@ -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 - /** After a proven commit: the source rows stay, hidden, for a downgraded build. */ - retireAfterCommit: (migrationId: string, now?: () => Date) => OrcadMigrationSourceCutover } export type OrcadMigrationFenceAcquireArgs = { diff --git a/src/main/ssh/orcad-migration-ssh-relay-source.ts b/src/main/ssh/orcad-migration-ssh-relay-source.ts index 22051ad8195..7de480b3f69 100644 --- a/src/main/ssh/orcad-migration-ssh-relay-source.ts +++ b/src/main/ssh/orcad-migration-ssh-relay-source.ts @@ -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) } } diff --git a/src/main/ssh/orcad-migration-stuck-host-profiles.test.ts b/src/main/ssh/orcad-migration-stuck-host-profiles.test.ts index 2e2764f5483..f1ccbcf047e 100644 --- a/src/main/ssh/orcad-migration-stuck-host-profiles.test.ts +++ b/src/main/ssh/orcad-migration-stuck-host-profiles.test.ts @@ -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 () => { diff --git a/src/main/ssh/ssh-host-server-on-connect-test-deps.ts b/src/main/ssh/ssh-host-server-on-connect-test-deps.ts index c67d5b737b2..c90776fa773 100644 --- a/src/main/ssh/ssh-host-server-on-connect-test-deps.ts +++ b/src/main/ssh/ssh-host-server-on-connect-test-deps.ts @@ -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), diff --git a/src/main/ssh/ssh-host-server-on-connect.ts b/src/main/ssh/ssh-host-server-on-connect.ts index 268cbb394e4..a52c957ad46 100644 --- a/src/main/ssh/ssh-host-server-on-connect.ts +++ b/src/main/ssh/ssh-host-server-on-connect.ts @@ -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 /** Marks a commit whose reply outlived the move as finished; its source rows are kept. */ - retainCommittedSource: (target: SshTarget) => void + retainCommittedSource: (target: SshTarget) => Promise /** 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) } diff --git a/tests/e2e/helpers/orcad-convert-host.ts b/tests/e2e/helpers/orcad-convert-host.ts index 800043b4108..82285c0cc6e 100644 --- a/tests/e2e/helpers/orcad-convert-host.ts +++ b/tests/e2e/helpers/orcad-convert-host.ts @@ -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) diff --git a/tests/e2e/helpers/orcad-managed-workspace.ts b/tests/e2e/helpers/orcad-managed-workspace.ts index 9c102a1957b..e1f111e3e7a 100644 --- a/tests/e2e/helpers/orcad-managed-workspace.ts +++ b/tests/e2e/helpers/orcad-managed-workspace.ts @@ -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[3]>, 'draftContent'> /** Runs on the profile-creating launch, before the SSH profile is seeded. */ beforeSeed?: (page: Page) => Promise /** 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) diff --git a/tests/e2e/helpers/orcad-upgrade-profile.ts b/tests/e2e/helpers/orcad-upgrade-profile.ts index 7b18ad716a4..28f9a25bebd 100644 --- a/tests/e2e/helpers/orcad-upgrade-profile.ts +++ b/tests/e2e/helpers/orcad-upgrade-profile.ts @@ -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 }) } ] }, diff --git a/tests/e2e/ssh-orcad-editor-drafts-migration.spec.ts b/tests/e2e/ssh-orcad-editor-drafts-migration.spec.ts new file mode 100644 index 00000000000..fef785df5af --- /dev/null +++ b/tests/e2e/ssh-orcad-editor-drafts-migration.spec.ts @@ -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[] { + 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[] = [] + 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') + }) + }) +}