mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user