mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(ssh): a relay-era desktop can join the server another desktop on the same login runs (#28886)
* fix(ssh): a relay-era desktop can join the server another desktop on the same login runs A desktop that still had relay-era projects on a host whose managed server another Orca version ran could never connect: its migration skipped the running-version adoption, so it deferred on a terminal count only a paired server can give. Once the versions matched it failed instead because the server already held the same repository path, added by the other desktop. The migration now pairs with the running server like a fresh desktop does, before any upload. Projects the server already holds at the same host path are linked to the server's project instead of imported twice: the journal is re-cut without them, under the same migration id, only after the server proves it holds nothing of the earlier manifest. A conflict the server will keep refusing now says what conflicts and how to get past it. * fix(ssh): never link a project a bridged relay terminal still runs in The bridge reattaches a relay pane only into its own tab, which a linked project never sends to the server, so linking would leave that terminal running out of sight. Refuse the link instead, naming the project and how to end the terminals. * test(ssh): run the link test on Node's SQLite runtime and read the host's activation record * test(ssh): establish desktop B's profile before seeding its relay-era state * test(ssh): connect desktop B's host explicitly in the shared-login spec * fix(ssh): say what a linked project left behind, and never blame it for an unowned relay terminal A project this desktop moved that the host's server already held is linked to the server's copy, so its tabs and automations from this desktop stay behind. The connect now carries those linked projects and the SSH host card says so, with the automation count, instead of dropping them silently. A bridged relay terminal of unknown or removed project still blocks a link, but its guidance no longer names the linked project as where it runs. Both terminal messages now offer removing the project from this desktop as the way out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
m4air
parent
79589c07c1
commit
6837a4c180
@@ -274,6 +274,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/ssh/orcad-migration-cutover-coordinator.test.ts',
|
||||
'src/main/ssh/orcad-migration-delta-move.test.ts',
|
||||
'src/main/ssh/orcad-migration-delta-snapshot.test.ts',
|
||||
'src/main/ssh/orcad-migration-repository-link.test.ts',
|
||||
'src/main/ssh/orcad-migration-snapshot-resume.test.ts',
|
||||
'src/main/ssh/orcad-migration-source-fence.test.ts',
|
||||
'src/main/ssh/orcad-migration-ssh-relay-source.test.ts',
|
||||
|
||||
@@ -131,7 +131,8 @@ async function doConnect(
|
||||
targetId,
|
||||
server.environmentId,
|
||||
server.update,
|
||||
server.serving
|
||||
server.serving,
|
||||
server.linked
|
||||
)
|
||||
}
|
||||
// This version has no relay to fall back to: a host its managed server doesn't serve fails here.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import type {
|
||||
SshConnectionState,
|
||||
SshManagedServerLinkedProject,
|
||||
SshManagedServerServingNote,
|
||||
SshManagedServerUpdateNote,
|
||||
SshTarget
|
||||
@@ -72,13 +73,15 @@ export function publishManagedServerConnect(
|
||||
targetId: string,
|
||||
environmentId: string,
|
||||
update?: SshManagedServerUpdateNote,
|
||||
serving?: SshManagedServerServingNote
|
||||
serving?: SshManagedServerServingNote,
|
||||
linked?: SshManagedServerLinkedProject[]
|
||||
): SshConnectionState {
|
||||
const managedServer = {
|
||||
kind: 'managed' as const,
|
||||
environmentId,
|
||||
...(update ? { update } : {}),
|
||||
...(serving ? { serving } : {})
|
||||
...(serving ? { serving } : {}),
|
||||
...(linked ? { linked } : {})
|
||||
}
|
||||
setSshHostServerStatus(targetId, managedServer)
|
||||
void restoreManagedHostPortForwards(targetId)
|
||||
|
||||
@@ -13,7 +13,10 @@ import { managedServerUpdateDeps } from '../ssh/managed-server-update-deps'
|
||||
import { ensureOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel'
|
||||
import { verifyOrcadManagedServing } from '../ssh/orcad-managed-serving-verify'
|
||||
import { convertSshTargetToManagedOrcad } from '../ssh/orcad-runtime-conversion'
|
||||
import { orcadMigrationDestinationFor } from '../ssh/orcad-runtime-conversion-wiring'
|
||||
import {
|
||||
orcadMigrationDestinationFor,
|
||||
orcadServerRepositoriesFor
|
||||
} from '../ssh/orcad-runtime-conversion-wiring'
|
||||
import { createManagedOrcadEnvironment } from '../ssh/orcad-runtime-deployment'
|
||||
import type { HostServerOnConnectDeps } from '../ssh/ssh-host-server-on-connect'
|
||||
import {
|
||||
@@ -102,7 +105,8 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne
|
||||
censusHost: (host) =>
|
||||
hostProof ? Promise.resolve(hostProof) : censusHostRelayTerminalsFor(host)(),
|
||||
// Why no releaseDirectSession: a disconnect here would cancel the connect that asked for it.
|
||||
destinationFor: orcadMigrationDestinationFor
|
||||
destinationFor: orcadMigrationDestinationFor,
|
||||
serverRepositoriesFor: orcadServerRepositoriesFor
|
||||
})
|
||||
},
|
||||
abandonDeploy: async (target) => {
|
||||
|
||||
@@ -63,6 +63,7 @@ describe('SSH IPC handlers', () => {
|
||||
'ssh-1',
|
||||
'env-test',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(mockConnectionManager.connect).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { z } from 'zod'
|
||||
import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection'
|
||||
import {
|
||||
parseOrcadMigrationCatalogAbortResult,
|
||||
@@ -151,6 +152,26 @@ export async function stageRemoteOrcadMigrationBlobChunk(
|
||||
return parseOrcadMigrationBlobChunkResult(response.result, request)
|
||||
}
|
||||
|
||||
const ServerRepositoriesSchema = z.object({
|
||||
repos: z.array(z.object({ id: z.string().min(1), path: z.string().min(1) }))
|
||||
})
|
||||
|
||||
/** The projects the destination already holds, as ids and host paths, through the same preflight. */
|
||||
export async function listRemoteOrcadRepositories(
|
||||
pairingCode: string,
|
||||
options: CatalogRequestOptions = {}
|
||||
): Promise<{ id: string; path: string }[]> {
|
||||
const pairing = parsePairingCode(pairingCode)
|
||||
if (!pairing) {
|
||||
throw new Error('orcad_migration_pairing_code_invalid')
|
||||
}
|
||||
const response = await sendSupportedMigrationRequest(pairing, 'repo.list', undefined, options)
|
||||
if (!response.ok) {
|
||||
throw new Error(`orcad_migration_repositories_failed:${response.error.message}`)
|
||||
}
|
||||
return ServerRepositoriesSchema.parse(response.result).repos.map(({ id, path }) => ({ id, path }))
|
||||
}
|
||||
|
||||
async function requestCatalogState(
|
||||
pairingCode: string,
|
||||
operation: Exclude<OrcadCatalogMigrationOperation, 'abort'>,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* A move the host's server refuses because it already holds a different copy of something this
|
||||
* desktop moves. Retrying never clears that, so the connect names what conflicts and the way out.
|
||||
*/
|
||||
const CONFLICT =
|
||||
/orcad_migration_(repository_path|repository_id|project_group_id|folder_workspace_id|dormant_id|dormant_focus|client_state|close_intent_destination|snapshot_destination)_conflict(?::(.+))?/
|
||||
|
||||
const LINKED_PROJECT_TERMINALS = /orcad_migration_linked_project_terminals:(.+)/
|
||||
const UNOWNED_TERMINALS = /orcad_migration_linked_project_unowned_terminals:(.+)/
|
||||
|
||||
/** Null for anything that is not such a conflict, including a transient staged claim. */
|
||||
export function orcadMigrationConflictGuidance(error: unknown): string | null {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const terminals = LINKED_PROJECT_TERMINALS.exec(message)
|
||||
if (terminals) {
|
||||
return (
|
||||
`Terminals an older version of Orca started in ${terminals[1]} are still running, and this ` +
|
||||
'host’s managed server already has that project from another Orca desktop. Close those ' +
|
||||
'terminals in that version of Orca or end them on the host, then reconnect, or remove that ' +
|
||||
'project from this desktop and connect again.'
|
||||
)
|
||||
}
|
||||
const unowned = UNOWNED_TERMINALS.exec(message)
|
||||
if (unowned) {
|
||||
return (
|
||||
'A terminal an older version of Orca started on this host is still running, and Orca can’t ' +
|
||||
'tell which project it belongs to. Close it in that version of Orca or end it on the host, ' +
|
||||
`then reconnect. Or, to use this host’s managed server’s copy of ${unowned[1]}, remove that ` +
|
||||
'project from this desktop and connect again.'
|
||||
)
|
||||
}
|
||||
const match = CONFLICT.exec(message)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const [code, kind, subject] = match
|
||||
switch (kind) {
|
||||
case 'repository_path':
|
||||
return (
|
||||
`This host’s managed server already has a project at ${subject}, which another Orca desktop ` +
|
||||
'added while this one was moving it. Retrying won’t clear it. To use the server’s project, ' +
|
||||
`remove that project from this desktop and connect again. (${code})`
|
||||
)
|
||||
case 'repository_id':
|
||||
return (
|
||||
'This host’s managed server already has one of this desktop’s projects with different ' +
|
||||
'settings. Retrying won’t clear it. To use the server’s copy, remove that project from this ' +
|
||||
`desktop and connect again. (${code})`
|
||||
)
|
||||
default:
|
||||
return (
|
||||
'This host’s managed server already holds different saved state for something this desktop ' +
|
||||
'is moving, so nothing was moved and this desktop keeps its projects. Retrying won’t clear ' +
|
||||
`it. (${code})`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ import { collectOrcadMigrationSourceCatalog } from '../persistence/migrating-orc
|
||||
import { listOrcadMigrationCutoverChainForTarget } from './orcad-migration-cutover-journal'
|
||||
import {
|
||||
createOrcadMigrationManifest,
|
||||
orcadMigrationCatalogIds
|
||||
orcadMigrationCatalogIds,
|
||||
type OrcadMigrationCatalogIds
|
||||
} from './orcad-migration-manifest-export'
|
||||
import type { OrcadMigrationSnapshotSource } from './orcad-migration-snapshot-coordinator'
|
||||
import { collectUntransferredDependentBlockers } from './ssh-target-orcad-dependents'
|
||||
@@ -54,11 +55,29 @@ export function orcadDeltaSourceStore(
|
||||
target: SshTarget,
|
||||
moved: readonly OrcadMigrationSourceCutover[]
|
||||
): OrcadDeltaSourceStore {
|
||||
const movedNow = createOrcadMigrationManifest(store, target, {
|
||||
destinationEnvironmentId: moved.at(-1)?.destinationEnvironmentId,
|
||||
onlyCatalog: orcadMigrationCatalogIds(moved.map((cutover) => cutover.manifest.payload))
|
||||
const ids = orcadMigrationCatalogIds(moved.map((cutover) => cutover.manifest.payload))
|
||||
// A linked project is the server's already, so a delta never moves it either.
|
||||
const linked = moved.flatMap((cutover) => cutover.linkedRepositoryIds ?? [])
|
||||
return orcadSourceStoreWithout(
|
||||
store,
|
||||
target,
|
||||
{ ...ids, repositoryIds: new Set([...ids.repositoryIds, ...linked]) },
|
||||
moved.at(-1)?.destinationEnvironmentId
|
||||
)
|
||||
}
|
||||
|
||||
/** The source minus rows the server already owns, and the dormant state that references them. */
|
||||
export function orcadSourceStoreWithout(
|
||||
store: Store,
|
||||
target: SshTarget,
|
||||
owned: OrcadMigrationCatalogIds,
|
||||
destinationEnvironmentId: string | undefined
|
||||
): OrcadDeltaSourceStore {
|
||||
const ownedNow = createOrcadMigrationManifest(store, target, {
|
||||
destinationEnvironmentId,
|
||||
onlyCatalog: owned
|
||||
})
|
||||
const view = store.createOrcadMigrationDeltaView(movedNow)
|
||||
const view = store.createOrcadMigrationDeltaView(ownedNow)
|
||||
return {
|
||||
...view,
|
||||
getSshTarget: (id) => store.getSshTarget(id),
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
|
||||
import type { OrcadMigrationManifest } from '../../shared/orcad-migration-manifest'
|
||||
import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store'
|
||||
import { listEnvironments } from '../../shared/runtime-environment-store'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness'
|
||||
import { Store } from '../persistence/loading-store/store'
|
||||
import type { OrcadMigrationDestinationCatalog } from './orcad-migration-cutover-coordinator'
|
||||
import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal'
|
||||
import { planOrcadDeltaMove } from './orcad-migration-delta-plan'
|
||||
import {
|
||||
linkedOrcadMigrationProjects,
|
||||
repositoriesHeldByServer
|
||||
} from './orcad-migration-repository-link'
|
||||
import { reconcileManagedOrcadSshTargets, visibleRepos } from './orcad-retained-source'
|
||||
import { SshConnectionStore } from './ssh-connection-store'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state: { targetStore: unknown } = { targetStore: null }
|
||||
return { state, deploy: vi.fn(), ensureTunnel: vi.fn() }
|
||||
})
|
||||
vi.mock('./ssh-target-registry', () => ({
|
||||
getSshConnectionManager: () => ({}),
|
||||
getSshTargetRegistryStore: () => mocks.state.targetStore
|
||||
}))
|
||||
vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy }))
|
||||
vi.mock('./orcad-managed-tunnel', () => ({
|
||||
ensureOrcadManagedTunnel: mocks.ensureTunnel,
|
||||
closeOrcadManagedTunnel: async () => {}
|
||||
}))
|
||||
|
||||
const { convertSshTargetToManagedOrcad } = await import('./orcad-runtime-conversion')
|
||||
|
||||
const TARGET: SshTarget = {
|
||||
id: 'ssh-shared',
|
||||
label: 'Shared box',
|
||||
host: 'box.example.com',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
generation: 2
|
||||
}
|
||||
|
||||
function repo(id: string, path: string, connectionId: string | null = TARGET.id): Repo {
|
||||
return {
|
||||
id,
|
||||
path,
|
||||
displayName: id,
|
||||
badgeColor: '#737373',
|
||||
addedAt: 1,
|
||||
kind: 'git',
|
||||
...(connectionId ? { connectionId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
let userDataPath: string
|
||||
// This desktop's profile, which still holds the host's relay-era projects.
|
||||
let store: Store
|
||||
// The host's managed server, which another desktop on the same login already uses.
|
||||
let server: Store
|
||||
let destination: OrcadMigrationDestinationCatalog & { stage: ReturnType<typeof vi.fn> }
|
||||
|
||||
/** The real server-side import behind the T6-9 calls, as orcad runs it. */
|
||||
function serverDestination(): typeof destination {
|
||||
return {
|
||||
readState: async (manifest: OrcadMigrationManifest) =>
|
||||
server.getOrcadMigrationCatalogState(manifest),
|
||||
stage: vi.fn(async (manifest: OrcadMigrationManifest) =>
|
||||
server.stageOrcadMigrationCatalog(manifest)
|
||||
),
|
||||
commit: async (manifest) => server.commitStagedOrcadMigrationCatalog(manifest),
|
||||
abort: async (manifest) => {
|
||||
const result = server.abortStagedOrcadMigrationCatalog(manifest)
|
||||
return result.aborted ? result : { ...result, durableAbsent: true as const }
|
||||
},
|
||||
stageChunk: async (request) => server.stageOrcadMigrationSnapshotChunk(request)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orcad-repository-link-'))
|
||||
store = createSqliteTestStore(Store, {
|
||||
dataFile: join(userDataPath, 'desktop', 'orca-data.json')
|
||||
})
|
||||
server = createSqliteTestStore(Store, {
|
||||
dataFile: join(userDataPath, 'server', 'orca-data.json')
|
||||
})
|
||||
store.addSshTarget(TARGET)
|
||||
store.addRepo(repo('desktop-repo', '/root/migrepo'))
|
||||
store.addRepo(repo('desktop-only', '/root/tool'))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SshConnectionStore wraps the real test store it is given.
|
||||
mocks.state.targetStore = new SshConnectionStore(store as never)
|
||||
destination = serverDestination()
|
||||
mocks.ensureTunnel.mockResolvedValue(undefined)
|
||||
mocks.deploy.mockImplementation(async (path: string, args: { name: string }) => {
|
||||
const owner = getManagedOrcadFenceEnvironmentId(store.getSshTarget(TARGET.id))
|
||||
if (!owner) {
|
||||
throw new Error('deploy without a fence')
|
||||
}
|
||||
if (!listEnvironments(path).some((entry) => entry.id === owner)) {
|
||||
addManagedOrcadEnvironment(path, {
|
||||
id: owner,
|
||||
name: args.name,
|
||||
pairingCode: encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint: 'ws://127.0.0.1:46768/',
|
||||
deviceToken: 'device-token',
|
||||
publicKeyB64: 'public-key'
|
||||
}),
|
||||
orcadDeployment: {
|
||||
sshTargetId: TARGET.id,
|
||||
sshTargetGeneration: 2,
|
||||
localPort: 46_768,
|
||||
remotePort: 6_768
|
||||
}
|
||||
})
|
||||
}
|
||||
// The running server of another Orca version, adopted as it is.
|
||||
return { outcome: 'already-current', environment: {}, activeVersion: '1.0.0' }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await closeTestStores()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const convert = (link = true, census: 'exited' | 'unverifiable' = 'exited') =>
|
||||
convertSshTargetToManagedOrcad(userDataPath, {
|
||||
sshTargetId: TARGET.id,
|
||||
name: 'Managed',
|
||||
censusHost: async () => ({ verdict: census, count: 0 }),
|
||||
destinationFor: () => destination,
|
||||
...(link ? { serverRepositoriesFor: async () => server.getRepos() } : {}),
|
||||
now: () => new Date('2026-10-14T00:00:00.000Z')
|
||||
})
|
||||
|
||||
const serverRepoIds = () =>
|
||||
server
|
||||
.getRepos()
|
||||
.map((entry) => entry.id)
|
||||
.sort()
|
||||
|
||||
// Desktop A, on the same Unix login, added the same checkout.
|
||||
const otherDesktopAddsSameCheckout = () =>
|
||||
server.addRepo(repo('server-repo', '/root/migrepo', null))
|
||||
|
||||
describe('moving onto a server another desktop already added the same project to', () => {
|
||||
it('is refused by the server when nothing links the project', async () => {
|
||||
otherDesktopAddsSameCheckout()
|
||||
await expect(convert(false)).rejects.toThrow(
|
||||
'orcad_migration_repository_path_conflict:/root/migrepo'
|
||||
)
|
||||
})
|
||||
|
||||
it('links it to the server’s project and moves only what the server lacks', async () => {
|
||||
otherDesktopAddsSameCheckout()
|
||||
await expect(convert()).resolves.toMatchObject({ outcome: 'converted' })
|
||||
|
||||
expect(serverRepoIds()).toEqual(['desktop-only', 'server-repo'])
|
||||
const [journal] = listOrcadMigrationSourceCutovers(userDataPath)
|
||||
expect(journal).toMatchObject({
|
||||
phase: 'destination-committed',
|
||||
linkedRepositoryIds: ['desktop-repo'],
|
||||
sourceRetainedAt: '2026-10-14T00:00:00.000Z'
|
||||
})
|
||||
expect(journal?.manifest.payload.repositories.map((entry) => entry.id)).toEqual([
|
||||
'desktop-only'
|
||||
])
|
||||
// Both rows stay for a downgraded build; this build shows the server's projects instead.
|
||||
expect(
|
||||
store
|
||||
.getRepos()
|
||||
.map((entry) => entry.id)
|
||||
.sort()
|
||||
).toEqual(['desktop-only', 'desktop-repo'])
|
||||
expect(visibleRepos(store, () => userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('tells the connect which projects it linked', async () => {
|
||||
otherDesktopAddsSameCheckout()
|
||||
await expect(convert()).resolves.toMatchObject({
|
||||
outcome: 'converted',
|
||||
linkedProjects: [{ path: '/root/migrepo', automations: 0 }]
|
||||
})
|
||||
})
|
||||
|
||||
it('says nothing was linked when the server held none of the projects', async () => {
|
||||
const result = await convert()
|
||||
expect(result.outcome).toBe('converted')
|
||||
expect(result).not.toHaveProperty('linkedProjects')
|
||||
})
|
||||
|
||||
it('leaves the linked project’s saved state to the server, moving only the rest', async () => {
|
||||
otherDesktopAddsSameCheckout()
|
||||
store.setWorktreeMeta('desktop-repo::/root/migrepo', { comment: 'from this desktop' })
|
||||
store.setWorktreeMeta('desktop-only::/root/tool', { comment: 'moves' })
|
||||
await expect(convert()).resolves.toMatchObject({ outcome: 'converted' })
|
||||
expect(server.getWorktreeMeta('desktop-only::/root/tool')?.comment).toBe('moves')
|
||||
expect(server.getWorktreeMeta('desktop-repo::/root/migrepo')).toBeUndefined()
|
||||
// Kept in this desktop's retained rows for a downgraded build.
|
||||
expect(store.getWorktreeMeta('desktop-repo::/root/migrepo')?.comment).toBe('from this desktop')
|
||||
})
|
||||
|
||||
// COMPAT(legacy-relay-bridge): the bridge reattaches a relay pane only into its own tab.
|
||||
describe('with a relay terminal the bridge still serves', () => {
|
||||
const lease = (worktreeId: string) => ({
|
||||
targetId: TARGET.id,
|
||||
ptyId: 'relay-pty',
|
||||
state: 'detached' as const,
|
||||
tabId: 'tab-1',
|
||||
worktreeId,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
|
||||
it('refuses to link the project the terminal runs in, naming it, and moves nothing', async () => {
|
||||
otherDesktopAddsSameCheckout()
|
||||
vi.spyOn(store, 'getSshRemotePtyLeases').mockReturnValue([
|
||||
lease('desktop-repo::/root/migrepo')
|
||||
])
|
||||
await expect(convert(true, 'unverifiable')).rejects.toThrow(
|
||||
'orcad_migration_linked_project_terminals:/root/migrepo'
|
||||
)
|
||||
expect(serverRepoIds()).toEqual(['server-repo'])
|
||||
expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.linkedRepositoryIds).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no project', undefined],
|
||||
['a project this desktop removed', 'removed-repo::/root/old']
|
||||
])('refuses a terminal in %s without blaming the linked project', async (_case, worktreeId) => {
|
||||
otherDesktopAddsSameCheckout()
|
||||
const { worktreeId: _omit, ...unowned } = lease('')
|
||||
vi.spyOn(store, 'getSshRemotePtyLeases').mockReturnValue([
|
||||
worktreeId ? { ...unowned, worktreeId } : unowned
|
||||
])
|
||||
await expect(convert(true, 'unverifiable')).rejects.toThrow(
|
||||
'orcad_migration_linked_project_unowned_terminals:/root/migrepo'
|
||||
)
|
||||
expect(serverRepoIds()).toEqual(['server-repo'])
|
||||
})
|
||||
|
||||
it('still links when the terminal runs in a project that moves', async () => {
|
||||
otherDesktopAddsSameCheckout()
|
||||
vi.spyOn(store, 'getSshRemotePtyLeases').mockReturnValue([lease('desktop-only::/root/tool')])
|
||||
await expect(convert(true, 'unverifiable')).resolves.toMatchObject({ outcome: 'converted' })
|
||||
expect(serverRepoIds()).toEqual(['desktop-only', 'server-repo'])
|
||||
})
|
||||
})
|
||||
|
||||
it('stays managed across a restart: the linked rows are not an older build’s change', async () => {
|
||||
otherDesktopAddsSameCheckout()
|
||||
await convert()
|
||||
reconcileManagedOrcadSshTargets(userDataPath, store)
|
||||
expect(store.getSshTarget(TARGET.id)?.orcadFence?.sourceChangedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never offers the linked project again in a later delta move', async () => {
|
||||
otherDesktopAddsSameCheckout()
|
||||
await convert()
|
||||
store.addRepo(repo('added-later', '/root/later'))
|
||||
reconcileManagedOrcadSshTargets(userDataPath, store)
|
||||
const target = store.getSshTarget(TARGET.id)!
|
||||
expect(target.orcadFence?.sourceChangedAt).toBeDefined()
|
||||
const plan = planOrcadDeltaMove(userDataPath, store, target)
|
||||
expect(plan.added.map((row) => row.id)).toEqual(['added-later'])
|
||||
})
|
||||
|
||||
it('re-journals a move the server already staged, after proving the stage is gone', async () => {
|
||||
destination.commit = async () => {
|
||||
throw new Error('socket closed')
|
||||
}
|
||||
await expect(convert()).rejects.toThrow('socket closed')
|
||||
const [staged] = listOrcadMigrationSourceCutovers(userDataPath)
|
||||
expect(staged?.phase).toBe('destination-staged')
|
||||
// Desktop A adds the same checkout while this move waits.
|
||||
otherDesktopAddsSameCheckout()
|
||||
destination = serverDestination()
|
||||
|
||||
await expect(convert()).resolves.toMatchObject({
|
||||
outcome: 'converted',
|
||||
migrationId: staged?.migrationId
|
||||
})
|
||||
expect(serverRepoIds()).toEqual(['desktop-only', 'server-repo'])
|
||||
expect(listOrcadMigrationSourceCutovers(userDataPath)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps refusing a source edited after the fence instead of linking over it', async () => {
|
||||
destination.stage.mockImplementationOnce(async () => {
|
||||
throw new Error('socket closed')
|
||||
})
|
||||
await expect(convert()).rejects.toThrow('socket closed')
|
||||
otherDesktopAddsSameCheckout()
|
||||
store.updateRepo('desktop-only', { displayName: 'Renamed' })
|
||||
await expect(convert()).rejects.toThrow('orcad_migration_source_changed')
|
||||
expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.linkedRepositoryIds).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('what a linked project leaves behind', () => {
|
||||
it('counts this desktop’s automations on each linked project only', () => {
|
||||
const runIn = (repoId: string) => ({
|
||||
kind: 'workspace-run' as const,
|
||||
projectId: repoId,
|
||||
hostId: 'local' as const,
|
||||
projectHostSetupId: repoId,
|
||||
repoId,
|
||||
path: '/'
|
||||
})
|
||||
const fake = {
|
||||
getRepos: () => [repo('linked', '/root/app'), repo('moved', '/root/tool')],
|
||||
listAutomations: () => [
|
||||
{ projectId: 'linked' },
|
||||
{ projectId: 'linked', runContext: runIn('moved') },
|
||||
{ projectId: 'moved' },
|
||||
{ projectId: 'other', runContext: runIn('linked') }
|
||||
]
|
||||
}
|
||||
expect(linkedOrcadMigrationProjects(fake, { linkedRepositoryIds: ['linked'] })).toEqual([
|
||||
{ path: '/root/app', automations: 2 }
|
||||
])
|
||||
expect(linkedOrcadMigrationProjects(fake, {})).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('which projects are the same checkout', () => {
|
||||
const incoming = [{ id: 'mine', path: '/root/app' }]
|
||||
|
||||
it('links the same absolute path on the host, spelled with a trailing slash', () => {
|
||||
expect(repositoriesHeldByServer(incoming, [{ id: 'theirs', path: '/root/app/' }])).toEqual([
|
||||
'mine'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a POSIX path that differs only in case apart', () => {
|
||||
expect(repositoriesHeldByServer(incoming, [{ id: 'theirs', path: '/root/App' }])).toEqual([])
|
||||
})
|
||||
|
||||
it('folds case on a Windows host', () => {
|
||||
expect(
|
||||
repositoriesHeldByServer(
|
||||
[{ id: 'mine', path: 'C:\\src\\app' }],
|
||||
[{ id: 'theirs', path: 'c:/src/app' }]
|
||||
)
|
||||
).toEqual(['mine'])
|
||||
})
|
||||
|
||||
it('leaves a project the server holds under the same id to the import', () => {
|
||||
expect(repositoriesHeldByServer(incoming, [{ id: 'mine', path: '/root/app' }])).toEqual([])
|
||||
})
|
||||
|
||||
it('does not link a different path', () => {
|
||||
expect(repositoriesHeldByServer(incoming, [{ id: 'theirs', path: '/root/app2' }])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* A move onto a server another desktop on the same login already uses. A project this desktop moves
|
||||
* that the server already holds at the same host path is the same checkout, so the move links it
|
||||
* to the server's project instead of importing a second row the server refuses. The server's row
|
||||
* and state stay as they are; this desktop's own rows stay in its retained source, as moved ones do.
|
||||
*
|
||||
* Linking rewrites the journal under the same migration id, and only after the server proves it
|
||||
* holds nothing of the earlier manifest, so no row ever lands twice.
|
||||
*/
|
||||
import { getAutomationRunRepoId } from '../../shared/automation-run-identity'
|
||||
import type { Automation } from '../../shared/automations-types'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { SshManagedServerLinkedProject } from '../../shared/ssh-types'
|
||||
import { splitWorktreeId } from '../../shared/worktree/id'
|
||||
import type { Store } from '../persistence'
|
||||
import {
|
||||
abortOrcadMigrationCutover,
|
||||
type OrcadMigrationDestinationCatalog
|
||||
} from './orcad-migration-cutover-coordinator'
|
||||
import { writeOrcadMigrationSourceCutover } from './orcad-migration-cutover-journal'
|
||||
import { orcadSourceStoreWithout, type OrcadDeltaSourceStore } from './orcad-migration-delta-plan'
|
||||
import { createOrcadMigrationManifest } from './orcad-migration-manifest-export'
|
||||
import type { OrcadMigrationSourceCutoverPort } from './orcad-migration-source'
|
||||
import { sshRelayMigrationCutoverPort } from './orcad-migration-ssh-relay-source'
|
||||
import { orcadCatalogFingerprint } from './orcad-retained-source'
|
||||
import type { SshTargetOrcadClaims } from './ssh-target-orcad-claims'
|
||||
|
||||
export type OrcadServerRepository = Pick<Repo, 'id' | 'path'>
|
||||
|
||||
/** Incoming projects the server holds at the same host path under another id. */
|
||||
export function repositoriesHeldByServer(
|
||||
incoming: readonly OrcadServerRepository[],
|
||||
server: readonly OrcadServerRepository[]
|
||||
): string[] {
|
||||
const serverIdsByPath = new Map<string, Set<string>>()
|
||||
for (const repo of server) {
|
||||
const key = normalizeRuntimePathForComparison(repo.path)
|
||||
serverIdsByPath.set(key, (serverIdsByPath.get(key) ?? new Set()).add(repo.id))
|
||||
}
|
||||
return incoming
|
||||
.filter((repo) => {
|
||||
const ids = serverIdsByPath.get(normalizeRuntimePathForComparison(repo.path))
|
||||
// The same id is the same row, which the import already accepts as it is.
|
||||
return ids !== undefined && !ids.has(repo.id)
|
||||
})
|
||||
.map((repo) => repo.id)
|
||||
}
|
||||
|
||||
/** The source the cutover was cut from: the real store less any project it linked. */
|
||||
export function linkedOrcadMigrationSource(
|
||||
store: Store,
|
||||
cutover: Pick<
|
||||
OrcadMigrationSourceCutover,
|
||||
'sshTargetId' | 'destinationEnvironmentId' | 'linkedRepositoryIds'
|
||||
>
|
||||
): (() => OrcadDeltaSourceStore) | undefined {
|
||||
const linked = cutover.linkedRepositoryIds ?? []
|
||||
if (linked.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return () => {
|
||||
const target = store.getSshTarget(cutover.sshTargetId)
|
||||
if (!target) {
|
||||
throw new Error('orcad_migration_source_fence_lost')
|
||||
}
|
||||
return orcadSourceStoreWithout(
|
||||
store,
|
||||
target,
|
||||
{
|
||||
repositoryIds: new Set(linked),
|
||||
folderWorkspaceIds: new Set(),
|
||||
projectGroupIds: new Set()
|
||||
},
|
||||
cutover.destinationEnvironmentId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function linkedOrcadMigrationCutoverPort(args: {
|
||||
userDataPath: string
|
||||
store: Store
|
||||
claims: SshTargetOrcadClaims
|
||||
cutover: OrcadMigrationSourceCutover
|
||||
}): OrcadMigrationSourceCutoverPort {
|
||||
return sshRelayMigrationCutoverPort({
|
||||
userDataPath: args.userDataPath,
|
||||
store: args.store,
|
||||
claims: args.claims,
|
||||
freshSource: linkedOrcadMigrationSource(args.store, args.cutover)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay terminal the bridge still serves reattaches only into its own tab, which a linked
|
||||
* project never sends to the server: linking now would strand it out of sight, still running.
|
||||
*/
|
||||
function assertNoRelayTerminalsIn(
|
||||
store: Pick<Store, 'getSshRemotePtyLeases'>,
|
||||
cutover: OrcadMigrationSourceCutover,
|
||||
linked: readonly string[]
|
||||
): void {
|
||||
const bridged = new Set(cutover.bridgedPtyIds ?? [])
|
||||
const owners = store
|
||||
.getSshRemotePtyLeases(cutover.sshTargetId)
|
||||
.filter((lease) => bridged.has(lease.ptyId) && lease.state !== 'terminated')
|
||||
.map((lease) => (lease.worktreeId ? splitWorktreeId(lease.worktreeId)?.repoId : undefined))
|
||||
if (owners.length === 0) {
|
||||
return
|
||||
}
|
||||
const { repositories, folderWorkspaces } = cutover.manifest.payload
|
||||
const linkedRepos = repositories.filter((repo) => linked.includes(repo.id))
|
||||
const blocked = linkedRepos.find((repo) => owners.includes(repo.id))
|
||||
if (blocked) {
|
||||
throw new Error(`orcad_migration_linked_project_terminals:${blocked.path}`)
|
||||
}
|
||||
const moving = new Set([...repositories, ...folderWorkspaces].map((row) => row.id))
|
||||
// A lease whose project can't be told, or isn't moving, may be one a link would strand.
|
||||
if (owners.some((owner) => owner === undefined || !moving.has(owner))) {
|
||||
throw new Error(
|
||||
`orcad_migration_linked_project_unowned_terminals:${linkedRepos.map((repo) => repo.path).join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What each linked project leaves behind on this desktop, so the connect can say so. */
|
||||
export function linkedOrcadMigrationProjects(
|
||||
store: {
|
||||
getRepos(): OrcadServerRepository[]
|
||||
listAutomations(): Pick<Automation, 'projectId' | 'runContext'>[]
|
||||
},
|
||||
cutover: Pick<OrcadMigrationSourceCutover, 'linkedRepositoryIds'>
|
||||
): SshManagedServerLinkedProject[] {
|
||||
const linked = new Set(cutover.linkedRepositoryIds ?? [])
|
||||
if (linked.size === 0) {
|
||||
return []
|
||||
}
|
||||
const automations = store.listAutomations()
|
||||
return store
|
||||
.getRepos()
|
||||
.filter((repo) => linked.has(repo.id))
|
||||
.map((repo) => ({
|
||||
path: repo.path,
|
||||
automations: automations.filter((entry) => getAutomationRunRepoId(entry) === repo.id).length
|
||||
}))
|
||||
}
|
||||
|
||||
/** Returns the cutover to commit: the same one, or one re-journaled without the linked projects. */
|
||||
export async function linkServerHeldRepositories(args: {
|
||||
userDataPath: string
|
||||
store: Store
|
||||
claims: SshTargetOrcadClaims
|
||||
cutover: OrcadMigrationSourceCutover
|
||||
destination: OrcadMigrationDestinationCatalog
|
||||
serverRepositories: () => Promise<OrcadServerRepository[]>
|
||||
now?: () => Date
|
||||
}): Promise<OrcadMigrationSourceCutover> {
|
||||
const { cutover, userDataPath } = args
|
||||
if (cutover.phase !== 'source-fenced' && cutover.phase !== 'destination-staged') {
|
||||
return cutover
|
||||
}
|
||||
const linked = repositoriesHeldByServer(
|
||||
cutover.manifest.payload.repositories,
|
||||
await args.serverRepositories()
|
||||
)
|
||||
if (linked.length === 0) {
|
||||
return cutover
|
||||
}
|
||||
assertNoRelayTerminalsIn(args.store, cutover, linked)
|
||||
const source = linkedOrcadMigrationCutoverPort(args)
|
||||
// The relinked manifest is cut from the source as fenced, so it must still be that source.
|
||||
source.verifyFence(cutover)
|
||||
const linkedRepositoryIds = [...new Set([...(cutover.linkedRepositoryIds ?? []), ...linked])]
|
||||
const view = linkedOrcadMigrationSource(args.store, { ...cutover, linkedRepositoryIds })?.()
|
||||
const target = args.store.getSshTarget(cutover.sshTargetId)
|
||||
if (!view || !target) {
|
||||
throw new Error('orcad_migration_source_fence_lost')
|
||||
}
|
||||
const manifest = createOrcadMigrationManifest(view, target, {
|
||||
migrationId: cutover.migrationId,
|
||||
destinationEnvironmentId: cutover.destinationEnvironmentId,
|
||||
now: () => new Date(cutover.manifest.createdAt)
|
||||
})
|
||||
const relinked: OrcadMigrationSourceCutover = {
|
||||
...cutover,
|
||||
phase: 'source-fenced',
|
||||
updatedAt: (args.now ?? (() => new Date()))().toISOString(),
|
||||
manifestSha256: manifest.manifestSha256,
|
||||
manifest,
|
||||
linkedRepositoryIds,
|
||||
// The retained rows still hold the linked projects, so the baseline is the whole fenced source.
|
||||
sourceBaselineFingerprint:
|
||||
cutover.sourceBaselineFingerprint ?? orcadCatalogFingerprint(cutover.manifest.payload)
|
||||
}
|
||||
const aborted = await abortOrcadMigrationCutover(
|
||||
{ userDataPath, store: args.store, claims: args.claims, destination: args.destination, source },
|
||||
cutover.migrationId,
|
||||
// Why keep the fence: the move goes on to the same server, now without the linked projects.
|
||||
async () => writeOrcadMigrationSourceCutover(userDataPath, relinked)
|
||||
)
|
||||
if (aborted.outcome !== 'released') {
|
||||
throw new Error(aborted.code)
|
||||
}
|
||||
return relinked
|
||||
}
|
||||
@@ -9,12 +9,14 @@ import type { OrcadMigrationDestinationCatalog } from './orcad-migration-cutover
|
||||
import {
|
||||
abortRemoteOrcadMigrationCatalog,
|
||||
commitRemoteOrcadMigrationCatalog,
|
||||
listRemoteOrcadRepositories,
|
||||
readRemoteOrcadMigrationCatalogState,
|
||||
stageRemoteOrcadMigrationCatalog,
|
||||
stageRemoteOrcadMigrationSnapshotChunk
|
||||
} from './orcad-migration-catalog-client'
|
||||
import { censusHostRelayTerminalsFor } from './ssh-host-relay-census-for-target'
|
||||
import type { OrcadManagedConversionArgs } from './orcad-runtime-conversion'
|
||||
import type { OrcadServerRepository } from './orcad-migration-repository-link'
|
||||
|
||||
/** The T6-9 client against this server, pinned to the runtime it paired with. */
|
||||
export function orcadMigrationDestinationFor(
|
||||
@@ -31,12 +33,24 @@ export function orcadMigrationDestinationFor(
|
||||
}
|
||||
}
|
||||
|
||||
/** The projects this server already holds, read through the same pinned pairing. */
|
||||
export function orcadServerRepositoriesFor(
|
||||
environment: KnownRuntimeEnvironment
|
||||
): Promise<OrcadServerRepository[]> {
|
||||
const pairingCode = encodePairingOffer(getPreferredPairingOffer(environment))
|
||||
return listRemoteOrcadRepositories(
|
||||
pairingCode,
|
||||
environment.runtimeId ? { expectedRuntimeId: environment.runtimeId } : {}
|
||||
)
|
||||
}
|
||||
|
||||
export function conversionCollaborators(): Pick<
|
||||
OrcadManagedConversionArgs,
|
||||
'destinationFor' | 'censusHost' | 'releaseDirectSession'
|
||||
'destinationFor' | 'serverRepositoriesFor' | 'censusHost' | 'releaseDirectSession'
|
||||
> {
|
||||
return {
|
||||
destinationFor: orcadMigrationDestinationFor,
|
||||
serverRepositoriesFor: orcadServerRepositoriesFor,
|
||||
censusHost: (target) => censusHostRelayTerminalsFor(target)(),
|
||||
releaseDirectSession: disconnectRegisteredSshTarget
|
||||
}
|
||||
|
||||
@@ -25,14 +25,21 @@ import {
|
||||
commitOrcadMigrationDestination,
|
||||
type OrcadMigrationDestinationCatalog
|
||||
} from './orcad-migration-cutover-coordinator'
|
||||
import { removeOrcadMigrationJournalsForDestination } from './orcad-migration-cutover-journal'
|
||||
import {
|
||||
findOrcadMigrationSourceCutoverForTarget,
|
||||
removeOrcadMigrationJournalsForDestination
|
||||
} from './orcad-migration-cutover-journal'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import type { CensusHostRelayTerminals } from './orcad-migration-terminal-gate'
|
||||
import { createManagedOrcadEnvironment } from './orcad-runtime-deployment'
|
||||
import { createSshRelayMigrationSource } from './orcad-migration-ssh-relay-source'
|
||||
import { retainOrcadMigrationSource } from './orcad-migration-source-retention'
|
||||
import {
|
||||
createSshRelayMigrationSource,
|
||||
sshRelayMigrationCutoverPort
|
||||
} from './orcad-migration-ssh-relay-source'
|
||||
linkedOrcadMigrationCutoverPort,
|
||||
linkedOrcadMigrationProjects,
|
||||
linkServerHeldRepositories,
|
||||
type OrcadServerRepository
|
||||
} from './orcad-migration-repository-link'
|
||||
|
||||
export type OrcadManagedConversionArgs = {
|
||||
sshTargetId: string
|
||||
@@ -41,6 +48,8 @@ export type OrcadManagedConversionArgs = {
|
||||
censusHost?: ((target: SshTarget) => ReturnType<CensusHostRelayTerminals>) | null
|
||||
/** The destination's T6-9 catalog client, reached through the server's tunnel. */
|
||||
destinationFor: (environment: KnownRuntimeEnvironment) => OrcadMigrationDestinationCatalog
|
||||
/** The server's own projects, so ones this host already holds at the same path link to them. */
|
||||
serverRepositoriesFor?: (environment: KnownRuntimeEnvironment) => Promise<OrcadServerRepository[]>
|
||||
/** Releases the direct SSH session after the terminal check, before the fence. */
|
||||
releaseDirectSession?: (sshTargetId: string) => Promise<void>
|
||||
now?: () => Date
|
||||
@@ -79,30 +88,47 @@ export async function convertSshTargetToManagedOrcad(
|
||||
await ensureOrcadManagedTunnel(userDataPath, environment.id)
|
||||
const { claims, targetStore } = requireManagedOrcadInfrastructure()
|
||||
const store = targetStore.getOrcadMigrationSource()
|
||||
const source = sshRelayMigrationCutoverPort({ userDataPath, store, claims })
|
||||
const committed = await runTargetLifecycle(fenced.sshTargetId, () =>
|
||||
commitOrcadMigrationDestination(
|
||||
const destination = args.destinationFor(marked)
|
||||
const serverRepositoriesFor = args.serverRepositoriesFor
|
||||
let linkedRepositoryIds: string[] | undefined
|
||||
const committed = await runTargetLifecycle(fenced.sshTargetId, async () => {
|
||||
const journaled = requireJournaledCutover(userDataPath, fenced)
|
||||
const cutover = serverRepositoriesFor
|
||||
? await linkServerHeldRepositories({
|
||||
userDataPath,
|
||||
store,
|
||||
claims,
|
||||
cutover: journaled,
|
||||
destination,
|
||||
serverRepositories: () => serverRepositoriesFor(marked),
|
||||
now: args.now
|
||||
})
|
||||
: journaled
|
||||
linkedRepositoryIds = cutover.linkedRepositoryIds
|
||||
return commitOrcadMigrationDestination(
|
||||
{
|
||||
userDataPath,
|
||||
store,
|
||||
claims,
|
||||
source,
|
||||
destination: args.destinationFor(marked),
|
||||
source: linkedOrcadMigrationCutoverPort({ userDataPath, store, claims, cutover }),
|
||||
destination,
|
||||
now: args.now
|
||||
},
|
||||
fenced.migrationId
|
||||
cutover.migrationId
|
||||
)
|
||||
)
|
||||
})
|
||||
if (committed.phase !== 'destination-committed' && committed.phase !== 'source-retired') {
|
||||
throw new Error(`orcad_migration_commit_not_proven:${committed.phase}`)
|
||||
}
|
||||
if (committed.phase === 'destination-committed') {
|
||||
source.retireAfterCommit(committed.migrationId, args.now)
|
||||
retainOrcadMigrationSource(userDataPath, committed.migrationId, args.now)
|
||||
}
|
||||
const linkedProjects = linkedOrcadMigrationProjects(store, { linkedRepositoryIds })
|
||||
return {
|
||||
outcome: 'converted',
|
||||
environment: redactRuntimeEnvironment(marked),
|
||||
migrationId: committed.migrationId
|
||||
migrationId: committed.migrationId,
|
||||
...(linkedProjects.length > 0 ? { linkedProjects } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +210,17 @@ async function fenceOrResume(
|
||||
return refuse(result.verdict, result.code, result.reason)
|
||||
}
|
||||
|
||||
function requireJournaledCutover(
|
||||
userDataPath: string,
|
||||
fenced: OrcadMigrationSourceCutover
|
||||
): OrcadMigrationSourceCutover {
|
||||
const journaled = findOrcadMigrationSourceCutoverForTarget(userDataPath, fenced.sshTargetId)
|
||||
if (journaled?.migrationId !== fenced.migrationId) {
|
||||
throw new Error('orcad_migration_source_fence_lost')
|
||||
}
|
||||
return journaled
|
||||
}
|
||||
|
||||
function isRegistered(userDataPath: string, environmentId: string): boolean {
|
||||
return listEnvironments(userDataPath).some((environment) => environment.id === environmentId)
|
||||
}
|
||||
|
||||
@@ -339,6 +339,43 @@ describe('createManagedOrcadEnvironment', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('pairs a relay migration with the running build too, under its journaled fence', async () => {
|
||||
target = { ...target, orcadFence: { environmentId: 'env-m' }, generation: 9 }
|
||||
writeOrcadMigrationSourceCutover(
|
||||
userDataPath,
|
||||
orcadMigrationCutoverFixture('m-1', 'ssh-1', { generation: 9, environmentId: 'env-m' })
|
||||
)
|
||||
const result = await createManagedOrcadEnvironment(userDataPath, {
|
||||
name: 'Managed',
|
||||
sshTargetId: 'ssh-1',
|
||||
migration: true
|
||||
})
|
||||
|
||||
// Its own deploy would wait on a terminal count only the paired server can give.
|
||||
expect(mocks.deploy).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({ outcome: 'already-current', activeVersion: OTHER })
|
||||
expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual(['env-m'])
|
||||
expect(target.orcadProvisioning).toBeUndefined()
|
||||
})
|
||||
|
||||
it('starts nothing for a migration whose running build is unverifiable', async () => {
|
||||
target = { ...target, orcadFence: { environmentId: 'env-m' }, generation: 9 }
|
||||
writeOrcadMigrationSourceCutover(
|
||||
userDataPath,
|
||||
orcadMigrationCutoverFixture('m-1', 'ssh-1', { generation: 9, environmentId: 'env-m' })
|
||||
)
|
||||
mocks.wake.mockResolvedValueOnce({ outcome: 'unverifiable' })
|
||||
await expect(
|
||||
createManagedOrcadEnvironment(userDataPath, {
|
||||
name: 'Managed',
|
||||
sshTargetId: 'ssh-1',
|
||||
migration: true
|
||||
})
|
||||
).rejects.toThrow('process state is unverifiable')
|
||||
expect(mocks.deploy).not.toHaveBeenCalled()
|
||||
expect(listEnvironments(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('deploys its own build as before when forced', async () => {
|
||||
await createManagedOrcadEnvironment(userDataPath, {
|
||||
name: 'Managed',
|
||||
|
||||
@@ -123,15 +123,15 @@ export async function createManagedOrcadEnvironment(
|
||||
const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, {
|
||||
signal: args.signal
|
||||
})
|
||||
// Before any upload, so a host another version serves is left exactly as it was.
|
||||
const adopted =
|
||||
args.force || args.migration
|
||||
? null
|
||||
: await adoptRunningOrcadVersion(
|
||||
slot,
|
||||
context.activationRecord,
|
||||
readLocalFullVersion(localOrcadDir)
|
||||
)
|
||||
// Before any upload, so a host another version serves is left exactly as it was. A migration
|
||||
// adopts too: only the paired server can count its terminals, so its own deploy would defer.
|
||||
const adopted = args.force
|
||||
? null
|
||||
: await adoptRunningOrcadVersion(
|
||||
slot,
|
||||
context.activationRecord,
|
||||
readLocalFullVersion(localOrcadDir)
|
||||
)
|
||||
const serving: DeployedOrcad | OrcadManagedDeferral =
|
||||
adopted ??
|
||||
(await deployOwnOrcad(context, { ...slot, conn: connection }, localOrcadDir, args))
|
||||
|
||||
@@ -241,6 +241,68 @@ describe('which server an SSH host runs on connect', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('names a conflict the server will keep refusing, and the way out, instead of a retry', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const d = deps({
|
||||
convert: async () => {
|
||||
throw new Error(
|
||||
'orcad_migration_stage_failed:orcad_migration_repository_path_conflict:/root/migrepo'
|
||||
)
|
||||
}
|
||||
})
|
||||
const result = await resolveHostServerOnConnect(target, d)
|
||||
expect(result).toMatchObject({ route: 'unserved', reason: 'refused' })
|
||||
const detail = result.route === 'unserved' ? result.detail : ''
|
||||
expect(detail).toContain('/root/migrepo')
|
||||
expect(detail).toContain('remove that project from this desktop and connect again')
|
||||
expect(d.abandonConversion).toHaveBeenCalledWith(target)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('names relay terminals that hold a linked project, and how to end them', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const d = deps({
|
||||
convert: async () => {
|
||||
throw new Error('orcad_migration_linked_project_terminals:/root/my repo')
|
||||
}
|
||||
})
|
||||
const result = await resolveHostServerOnConnect(target, d)
|
||||
expect(result).toMatchObject({ route: 'unserved', reason: 'refused' })
|
||||
const detail = result.route === 'unserved' ? result.detail : ''
|
||||
expect(detail).toContain('started in /root/my repo are still running')
|
||||
expect(detail).toContain('remove that project from this desktop and connect again')
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('never pins a relay terminal of unknown project on the linked one, and offers the way out', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const d = deps({
|
||||
convert: async () => {
|
||||
throw new Error('orcad_migration_linked_project_unowned_terminals:/root/my repo')
|
||||
}
|
||||
})
|
||||
const result = await resolveHostServerOnConnect(target, d)
|
||||
expect(result).toMatchObject({ route: 'unserved', reason: 'refused' })
|
||||
const detail = result.route === 'unserved' ? result.detail : ''
|
||||
expect(detail).toContain('can’t tell which project it belongs to')
|
||||
expect(detail).not.toContain('started in /root/my repo')
|
||||
expect(detail).toContain('copy of /root/my repo, remove that project from this desktop')
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps a staged claim of another desktop’s move a retry, since it expires', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const d = deps({
|
||||
convert: async () => {
|
||||
throw new Error('orcad_migration_staged_claim_conflict:repository-path:/root/migrepo')
|
||||
}
|
||||
})
|
||||
await expect(resolveHostServerOnConnect(target, d)).resolves.toMatchObject({
|
||||
reason: 'failed'
|
||||
})
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('records why when even the stdio bridge cannot reach the deployed server', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const d = deps({
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from '../../shared/orcad-managed-runtime'
|
||||
import type {
|
||||
SshManagedServerRelayReason,
|
||||
SshManagedServerLinkedProject,
|
||||
SshManagedServerServingNote,
|
||||
SshManagedServerUpdateNote,
|
||||
SshTarget
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
type ManagedServerUpdateDeps
|
||||
} from './managed-server-update-check'
|
||||
import { managedResultAfterUpdate, unservedManagedResult } from './managed-server-serving-gate'
|
||||
import { orcadMigrationConflictGuidance } from './orcad-migration-conflict-guidance'
|
||||
import {
|
||||
classifyOrcadHostUnavailable,
|
||||
ORCAD_TUNNEL_UNAVAILABLE_REASON
|
||||
@@ -48,6 +50,7 @@ export type HostServerOnConnectResult =
|
||||
environmentId: string
|
||||
update?: SshManagedServerUpdateNote
|
||||
serving?: SshManagedServerServingNote
|
||||
linked?: SshManagedServerLinkedProject[]
|
||||
/** Another desktop's update held the host; recheck until it clears. Never published. */
|
||||
fenceHeld?: true
|
||||
}
|
||||
@@ -238,6 +241,10 @@ function setupFailed(
|
||||
console.warn('[ssh] Managed Orca server setup failed; using the relay this session:', error)
|
||||
reportHostServerSetupError(deps.report, target, trace, error)
|
||||
const unavailable = classifyOrcadHostUnavailable(error)
|
||||
const conflict = empty ? null : orcadMigrationConflictGuidance(error)
|
||||
if (conflict) {
|
||||
return unfinished(target, deps, unavailable, empty, 'refused', conflict)
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
return unfinished(target, deps, unavailable, empty, 'failed', detail)
|
||||
}
|
||||
@@ -275,7 +282,11 @@ async function afterConversion(
|
||||
): Promise<HostServerOnConnectResult> {
|
||||
switch (result.outcome) {
|
||||
case 'converted':
|
||||
return { route: 'managed', environmentId: result.environment.id }
|
||||
return {
|
||||
route: 'managed',
|
||||
environmentId: result.environment.id,
|
||||
...(result.linkedProjects ? { linked: result.linkedProjects } : {})
|
||||
}
|
||||
case 'deferred':
|
||||
return deferred(target, deps, result, false)
|
||||
case 'refused':
|
||||
@@ -306,7 +317,7 @@ async function unfinished(
|
||||
deps: HostServerOnConnectDeps,
|
||||
unavailable: string | null,
|
||||
empty: boolean,
|
||||
reason: 'deferred' | 'failed',
|
||||
reason: 'deferred' | 'failed' | 'refused',
|
||||
failure: string
|
||||
): Promise<HostServerOnConnectResult> {
|
||||
try {
|
||||
|
||||
+2
@@ -1805,6 +1805,8 @@
|
||||
}
|
||||
},
|
||||
"sshHostServer": {
|
||||
"linkedProjects_one": "This host’s Orca server already had {{paths}}, so this desktop now uses the server’s copy. This desktop’s tabs and {{count}} automation there were not moved, and worktrees it created there show as external worktrees.",
|
||||
"linkedProjects_other": "This host’s Orca server already had {{paths}}, so this desktop now uses the server’s copy. This desktop’s tabs and {{count}} automations there were not moved, and worktrees it created there show as external worktrees.",
|
||||
"olderTerminals_one": "{{count}} terminal started by an older version of Orca is still running on this host, and it doesn’t stop on its own. Close it in that version of Orca or end it on the host, then reconnect.",
|
||||
"olderTerminals_other": "{{count}} terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect."
|
||||
},
|
||||
|
||||
@@ -12719,7 +12719,11 @@
|
||||
"olderTerminalsUnverifiable": "Orca couldn’t confirm that terminals started by an older version of Orca have exited. Reconnect to try again.",
|
||||
"olderTerminals_one": "{{count}} terminal started by an older version of Orca is still running on this host, and it doesn’t stop on its own. Close it in that version of Orca or end it on the host, then reconnect.",
|
||||
"olderTerminals_other": "{{count}} terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect.",
|
||||
"olderTerminals": "{{count}} terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect."
|
||||
"olderTerminals": "{{count}} terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect.",
|
||||
"linkedProjects_one": "This host’s Orca server already had {{paths}}, so this desktop now uses the server’s copy. This desktop’s tabs and {{count}} automation there were not moved, and worktrees it created there show as external worktrees.",
|
||||
"linkedProjects_other": "This host’s Orca server already had {{paths}}, so this desktop now uses the server’s copy. This desktop’s tabs and {{count}} automations there were not moved, and worktrees it created there show as external worktrees.",
|
||||
"linkedProjects": "This host’s Orca server already had {{paths}}, so this desktop now uses the server’s copy. This desktop’s tabs and {{count}} automations there were not moved, and worktrees it created there show as external worktrees.",
|
||||
"linkedProjectsNoAutomations": "This host’s Orca server already had {{paths}}, so this desktop now uses the server’s copy. This desktop’s tabs there were not moved, and worktrees it created there show as external worktrees."
|
||||
},
|
||||
"ZcodeCliSetupSection": {
|
||||
"title": "Use GLM with ZCode",
|
||||
|
||||
@@ -12536,7 +12536,11 @@
|
||||
"olderTerminalsUnverifiable": "Orca no pudo confirmar que los terminales iniciados por una versión anterior de Orca hayan terminado. Vuelve a conectarte para intentarlo de nuevo.",
|
||||
"olderTerminals_one": "{{count}} terminal iniciado por una versión anterior de Orca sigue ejecutándose en este host y no se detiene solo. Ciérralo en esa versión de Orca o termínalo en el host y vuelve a conectarte.",
|
||||
"olderTerminals_other": "{{count}} terminales iniciados por una versión anterior de Orca siguen ejecutándose en este host y no se detienen solos. Ciérralos en esa versión de Orca o termínalos en el host y vuelve a conectarte.",
|
||||
"olderTerminals": "{{count}} terminales iniciados por una versión anterior de Orca siguen ejecutándose en este host y no se detienen solos. Ciérralos en esa versión de Orca o termínalos en el host y vuelve a conectarte."
|
||||
"olderTerminals": "{{count}} terminales iniciados por una versión anterior de Orca siguen ejecutándose en este host y no se detienen solos. Ciérralos en esa versión de Orca o termínalos en el host y vuelve a conectarte.",
|
||||
"linkedProjects_one": "El servidor Orca de este host ya tenía {{paths}}, así que este escritorio ahora usa la copia del servidor. Las pestañas de este escritorio y {{count}} automatización allí no se movieron, y los worktrees que creó allí aparecen como worktrees externos.",
|
||||
"linkedProjects_other": "El servidor Orca de este host ya tenía {{paths}}, así que este escritorio ahora usa la copia del servidor. Las pestañas de este escritorio y {{count}} automatizaciones allí no se movieron, y los worktrees que creó allí aparecen como worktrees externos.",
|
||||
"linkedProjects": "El servidor Orca de este host ya tenía {{paths}}, así que este escritorio ahora usa la copia del servidor. Las pestañas de este escritorio y {{count}} automatizaciones allí no se movieron, y los worktrees que creó allí aparecen como worktrees externos.",
|
||||
"linkedProjectsNoAutomations": "El servidor Orca de este host ya tenía {{paths}}, así que este escritorio ahora usa la copia del servidor. Las pestañas de este escritorio allí no se movieron, y los worktrees que creó allí aparecen como worktrees externos."
|
||||
},
|
||||
"deltaMove": {
|
||||
"action": "Mover los proyectos nuevos…",
|
||||
|
||||
@@ -12644,7 +12644,11 @@
|
||||
"olderTerminalsUnverifiable": "Orca n’a pas pu confirmer que les terminaux lancés par une version antérieure d’Orca sont terminés. Reconnectez-vous pour réessayer.",
|
||||
"olderTerminals_one": "{{count}} terminal lancé par une version antérieure d’Orca est toujours en cours d’exécution sur cet hôte et ne s’arrête pas de lui-même. Fermez-le dans cette version d’Orca ou arrêtez-le sur l’hôte, puis reconnectez-vous.",
|
||||
"olderTerminals_other": "{{count}} terminaux lancés par une version antérieure d’Orca sont toujours en cours d’exécution sur cet hôte et ne s’arrêtent pas d’eux-mêmes. Fermez-les dans cette version d’Orca ou arrêtez-les sur l’hôte, puis reconnectez-vous.",
|
||||
"olderTerminals": "{{count}} terminaux lancés par une version antérieure d’Orca sont toujours en cours d’exécution sur cet hôte et ne s’arrêtent pas d’eux-mêmes. Fermez-les dans cette version d’Orca ou arrêtez-les sur l’hôte, puis reconnectez-vous."
|
||||
"olderTerminals": "{{count}} terminaux lancés par une version antérieure d’Orca sont toujours en cours d’exécution sur cet hôte et ne s’arrêtent pas d’eux-mêmes. Fermez-les dans cette version d’Orca ou arrêtez-les sur l’hôte, puis reconnectez-vous.",
|
||||
"linkedProjects_one": "Le serveur Orca de cet hôte avait déjà {{paths}} ; ce bureau utilise donc maintenant la copie du serveur. Les onglets de ce bureau et {{count}} automatisation à cet endroit n’ont pas été déplacés, et les worktrees qu’il y a créés apparaissent comme worktrees externes.",
|
||||
"linkedProjects_other": "Le serveur Orca de cet hôte avait déjà {{paths}} ; ce bureau utilise donc maintenant la copie du serveur. Les onglets de ce bureau et {{count}} automatisations à cet endroit n’ont pas été déplacés, et les worktrees qu’il y a créés apparaissent comme worktrees externes.",
|
||||
"linkedProjects": "Le serveur Orca de cet hôte avait déjà {{paths}} ; ce bureau utilise donc maintenant la copie du serveur. Les onglets de ce bureau et {{count}} automatisations à cet endroit n’ont pas été déplacés, et les worktrees qu’il y a créés apparaissent comme worktrees externes.",
|
||||
"linkedProjectsNoAutomations": "Le serveur Orca de cet hôte avait déjà {{paths}} ; ce bureau utilise donc maintenant la copie du serveur. Les onglets de ce bureau à cet endroit n’ont pas été déplacés, et les worktrees qu’il y a créés apparaissent comme worktrees externes."
|
||||
},
|
||||
"deltaMove": {
|
||||
"action": "Déplacer les nouveaux projets…",
|
||||
|
||||
@@ -12536,7 +12536,11 @@
|
||||
"olderTerminalsUnverifiable": "以前のバージョンの Orca で開始されたターミナルが終了したことを確認できませんでした。再接続してもう一度お試しください。",
|
||||
"olderTerminals_one": "以前のバージョンの Orca で開始された {{count}} 個のターミナルがこのホストでまだ実行中で、自動では終了しません。そのバージョンの Orca で閉じるか、ホスト上で終了してから再接続してください。",
|
||||
"olderTerminals_other": "以前のバージョンの Orca で開始された {{count}} 個のターミナルがこのホストでまだ実行中で、自動では終了しません。そのバージョンの Orca で閉じるか、ホスト上で終了してから再接続してください。",
|
||||
"olderTerminals": "以前のバージョンの Orca で開始された {{count}} 個のターミナルがこのホストでまだ実行中で、自動では終了しません。そのバージョンの Orca で閉じるか、ホスト上で終了してから再接続してください。"
|
||||
"olderTerminals": "以前のバージョンの Orca で開始された {{count}} 個のターミナルがこのホストでまだ実行中で、自動では終了しません。そのバージョンの Orca で閉じるか、ホスト上で終了してから再接続してください。",
|
||||
"linkedProjects_one": "このホストの Orca サーバーには {{paths}} が既にあったため、このデスクトップはサーバーのコピーを使用します。このデスクトップのタブと {{count}} 件のオートメーションは移動されず、ここで作成したワークツリーは外部ワークツリーとして表示されます。",
|
||||
"linkedProjects_other": "このホストの Orca サーバーには {{paths}} が既にあったため、このデスクトップはサーバーのコピーを使用します。このデスクトップのタブと {{count}} 件のオートメーションは移動されず、ここで作成したワークツリーは外部ワークツリーとして表示されます。",
|
||||
"linkedProjects": "このホストの Orca サーバーには {{paths}} が既にあったため、このデスクトップはサーバーのコピーを使用します。このデスクトップのタブと {{count}} 件のオートメーションは移動されず、ここで作成したワークツリーは外部ワークツリーとして表示されます。",
|
||||
"linkedProjectsNoAutomations": "このホストの Orca サーバーには {{paths}} が既にあったため、このデスクトップはサーバーのコピーを使用します。このデスクトップのタブは移動されず、ここで作成したワークツリーは外部ワークツリーとして表示されます。"
|
||||
},
|
||||
"deltaMove": {
|
||||
"action": "新規プロジェクトを移動…",
|
||||
|
||||
@@ -12536,7 +12536,11 @@
|
||||
"olderTerminalsUnverifiable": "이전 버전의 Orca에서 시작한 터미널이 종료되었는지 확인할 수 없습니다. 다시 연결하여 다시 시도하세요.",
|
||||
"olderTerminals_one": "이전 버전의 Orca에서 시작한 터미널 {{count}}개가 이 호스트에서 아직 실행 중이며 저절로 종료되지 않습니다. 해당 버전의 Orca에서 닫거나 호스트에서 종료한 후 다시 연결하세요.",
|
||||
"olderTerminals_other": "이전 버전의 Orca에서 시작한 터미널 {{count}}개가 이 호스트에서 아직 실행 중이며 저절로 종료되지 않습니다. 해당 버전의 Orca에서 닫거나 호스트에서 종료한 후 다시 연결하세요.",
|
||||
"olderTerminals": "이전 버전의 Orca에서 시작한 터미널 {{count}}개가 이 호스트에서 아직 실행 중이며 저절로 종료되지 않습니다. 해당 버전의 Orca에서 닫거나 호스트에서 종료한 후 다시 연결하세요."
|
||||
"olderTerminals": "이전 버전의 Orca에서 시작한 터미널 {{count}}개가 이 호스트에서 아직 실행 중이며 저절로 종료되지 않습니다. 해당 버전의 Orca에서 닫거나 호스트에서 종료한 후 다시 연결하세요.",
|
||||
"linkedProjects_one": "이 호스트의 Orca 서버에 {{paths}}이(가) 이미 있어 이 데스크톱은 이제 서버의 사본을 사용합니다. 이 데스크톱의 탭과 자동화 {{count}}개는 이동되지 않았으며, 여기서 만든 워크트리는 외부 워크트리로 표시됩니다.",
|
||||
"linkedProjects_other": "이 호스트의 Orca 서버에 {{paths}}이(가) 이미 있어 이 데스크톱은 이제 서버의 사본을 사용합니다. 이 데스크톱의 탭과 자동화 {{count}}개는 이동되지 않았으며, 여기서 만든 워크트리는 외부 워크트리로 표시됩니다.",
|
||||
"linkedProjects": "이 호스트의 Orca 서버에 {{paths}}이(가) 이미 있어 이 데스크톱은 이제 서버의 사본을 사용합니다. 이 데스크톱의 탭과 자동화 {{count}}개는 이동되지 않았으며, 여기서 만든 워크트리는 외부 워크트리로 표시됩니다.",
|
||||
"linkedProjectsNoAutomations": "이 호스트의 Orca 서버에 {{paths}}이(가) 이미 있어 이 데스크톱은 이제 서버의 사본을 사용합니다. 이 데스크톱의 탭은 이동되지 않았으며, 여기서 만든 워크트리는 외부 워크트리로 표시됩니다."
|
||||
},
|
||||
"deltaMove": {
|
||||
"action": "새 프로젝트 이동…",
|
||||
|
||||
@@ -12550,7 +12550,11 @@
|
||||
"olderTerminalsUnverifiable": "Orca 无法确认由旧版本 Orca 启动的终端是否已退出。请重新连接后重试。",
|
||||
"olderTerminals_one": "由旧版本 Orca 启动的 {{count}} 个终端仍在此主机上运行,且不会自行停止。请在该版本的 Orca 中关闭它们,或在主机上结束它们,然后重新连接。",
|
||||
"olderTerminals_other": "由旧版本 Orca 启动的 {{count}} 个终端仍在此主机上运行,且不会自行停止。请在该版本的 Orca 中关闭它们,或在主机上结束它们,然后重新连接。",
|
||||
"olderTerminals": "由旧版本 Orca 启动的 {{count}} 个终端仍在此主机上运行,且不会自行停止。请在该版本的 Orca 中关闭它们,或在主机上结束它们,然后重新连接。"
|
||||
"olderTerminals": "由旧版本 Orca 启动的 {{count}} 个终端仍在此主机上运行,且不会自行停止。请在该版本的 Orca 中关闭它们,或在主机上结束它们,然后重新连接。",
|
||||
"linkedProjects_one": "此主机的 Orca 服务器已有 {{paths}},因此此桌面现在使用服务器的副本。此桌面在那里的标签页和 {{count}} 个自动化未被迁移,它在那里创建的工作树会显示为外部工作树。",
|
||||
"linkedProjects_other": "此主机的 Orca 服务器已有 {{paths}},因此此桌面现在使用服务器的副本。此桌面在那里的标签页和 {{count}} 个自动化未被迁移,它在那里创建的工作树会显示为外部工作树。",
|
||||
"linkedProjects": "此主机的 Orca 服务器已有 {{paths}},因此此桌面现在使用服务器的副本。此桌面在那里的标签页和 {{count}} 个自动化未被迁移,它在那里创建的工作树会显示为外部工作树。",
|
||||
"linkedProjectsNoAutomations": "此主机的 Orca 服务器已有 {{paths}},因此此桌面现在使用服务器的副本。此桌面在那里的标签页未被迁移,它在那里创建的工作树会显示为外部工作树。"
|
||||
},
|
||||
"deltaMove": {
|
||||
"action": "迁移新项目…",
|
||||
|
||||
@@ -70,6 +70,25 @@ describe('SSH host server status line', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('says which projects the server already had, and what of this desktop’s stayed behind', () => {
|
||||
const linked = (automations: number) =>
|
||||
sshHostServerStatusLine(plain, {
|
||||
managedServer: {
|
||||
kind: 'managed',
|
||||
environmentId: 'e',
|
||||
linked: [
|
||||
{ path: '/root/app', automations },
|
||||
{ path: '/root/tool', automations: 0 }
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(linked(2)).toEqual({
|
||||
tone: 'warning',
|
||||
text: 'This host’s Orca server already had /root/app, /root/tool, so this desktop now uses the server’s copy. This desktop’s tabs and 2 automations there were not moved, and worktrees it created there show as external worktrees.'
|
||||
})
|
||||
expect(linked(0)?.text).not.toContain('automation')
|
||||
})
|
||||
|
||||
it('keeps durable reasons visible without a live state', () => {
|
||||
expect(sshHostServerStatusLine({ orcadFence: { environmentId: 'e' } }, undefined)).toBeNull()
|
||||
expect(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type {
|
||||
SSH_MANAGED_SERVER_PHASES,
|
||||
SshConnectionState,
|
||||
SshManagedServerLinkedProject,
|
||||
SshManagedServerUpdateNote,
|
||||
SshTarget
|
||||
} from '../../../shared/ssh-types'
|
||||
@@ -36,6 +37,9 @@ export function sshHostServerStatusLine(
|
||||
if (status?.kind === 'managed' && status.update) {
|
||||
return managedUpdateLine(status.update)
|
||||
}
|
||||
if (status?.kind === 'managed' && status.linked?.length) {
|
||||
return linkedProjectsLine(status.linked)
|
||||
}
|
||||
// A healthy managed server is the default; the card stays quiet unless something needs attention.
|
||||
if (status?.kind === 'managed' || (!status && target.orcadFence)) {
|
||||
return null
|
||||
@@ -168,6 +172,29 @@ function retryLine(): SshHostServerStatusLine {
|
||||
}
|
||||
}
|
||||
|
||||
/** Projects the server already had: this desktop's own tabs and automations for them stayed behind. */
|
||||
function linkedProjectsLine(
|
||||
linked: readonly SshManagedServerLinkedProject[]
|
||||
): SshHostServerStatusLine {
|
||||
const paths = linked.map((project) => project.path).join(', ')
|
||||
const count = linked.reduce((total, project) => total + project.automations, 0)
|
||||
return {
|
||||
tone: 'warning',
|
||||
text:
|
||||
count > 0
|
||||
? translate(
|
||||
'auto.components.settings.sshHostServer.linkedProjects',
|
||||
'This host’s Orca server already had {{paths}}, so this desktop now uses the server’s copy. This desktop’s tabs and {{count}} automations there were not moved, and worktrees it created there show as external worktrees.',
|
||||
{ paths, count }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.sshHostServer.linkedProjectsNoAutomations',
|
||||
'This host’s Orca server already had {{paths}}, so this desktop now uses the server’s copy. This desktop’s tabs there were not moved, and worktrees it created there show as external worktrees.',
|
||||
{ paths }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function managedUpdateLine(update: SshManagedServerUpdateNote): SshHostServerStatusLine {
|
||||
switch (update.state) {
|
||||
case 'host-newer':
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OrcadTerminalCensus } from './orcad-terminal-census'
|
||||
import type { OrcadMigrationBlocker } from './orcad-migration-preflight'
|
||||
import type { OrcadMigrationSourceCutoverPhase } from './orcad-migration-source-cutover'
|
||||
import type { OrcadDaemonRetirementVerdict } from './orcad-stop-request'
|
||||
import type { SshManagedServerLinkedProject } from './ssh-types'
|
||||
|
||||
export const ORCAD_MANAGED_REMOTE_PORT = 6_768
|
||||
|
||||
@@ -138,6 +139,8 @@ export type OrcadManagedConversionResult =
|
||||
outcome: 'converted'
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
migrationId: string
|
||||
/** Projects the server already held, so this desktop's own state for them did not move. */
|
||||
linkedProjects?: SshManagedServerLinkedProject[]
|
||||
}
|
||||
| OrcadManagedDeferral
|
||||
| OrcadManagedRefusal
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
MAX_ORCAD_MIGRATION_REPOSITORIES,
|
||||
parseOrcadMigrationManifest,
|
||||
type OrcadMigrationManifest
|
||||
} from './orcad-migration-manifest'
|
||||
@@ -47,6 +48,11 @@ const CutoverRecordSchema = z.object({
|
||||
supersedesMigrationId: z.string().min(1).max(128).optional(),
|
||||
// What the retained source must still look like; absent means this manifest's own catalog.
|
||||
sourceBaselineFingerprint: z.string().min(1).max(64).optional(),
|
||||
// Source projects the server already held at the same host path: linked to its rows, not moved.
|
||||
linkedRepositoryIds: z
|
||||
.array(z.string().min(1).max(1_024))
|
||||
.max(MAX_ORCAD_MIGRATION_REPOSITORIES)
|
||||
.optional(),
|
||||
manifest: z.unknown()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/** The managed-server status a retained SSH state carries, admitted field by field. */
|
||||
import type {
|
||||
SshManagedServerLinkedProject,
|
||||
SshManagedServerStatus,
|
||||
SshManagedServerUpdateNote
|
||||
} from './ssh-types'
|
||||
import {
|
||||
SSH_MANAGED_SERVER_PHASES,
|
||||
SSH_MANAGED_SERVER_RELAY_REASONS,
|
||||
SSH_MANAGED_SERVER_UPDATE_STATES
|
||||
} from './ssh-types'
|
||||
import {
|
||||
isNonNegativeSafeInteger,
|
||||
isSshRetainedIdentifier,
|
||||
SSH_CONNECTION_ERROR_MAX_UTF8_BYTES,
|
||||
SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES
|
||||
} from './ssh-retained-payload-limits'
|
||||
import { clampUtf8TextPrefix } from './utf8-byte-limits'
|
||||
|
||||
// Why field-by-field like plainSsh: an unknown kind from a newer host drops alone.
|
||||
export function admitSshManagedServerStatus(value: unknown): {
|
||||
managedServer?: SshManagedServerStatus
|
||||
} {
|
||||
if (!value || typeof value !== 'object' || !('kind' in value)) {
|
||||
return {}
|
||||
}
|
||||
if (value.kind === 'managed' && 'environmentId' in value) {
|
||||
if (!isSshRetainedIdentifier(value.environmentId)) {
|
||||
return {}
|
||||
}
|
||||
const update = admitSshManagedServerUpdateNote('update' in value ? value.update : undefined)
|
||||
const serving =
|
||||
'serving' in value &&
|
||||
value.serving &&
|
||||
typeof value.serving === 'object' &&
|
||||
'state' in value.serving &&
|
||||
value.serving.state === 'unverifiable' &&
|
||||
'detail' in value.serving &&
|
||||
typeof value.serving.detail === 'string'
|
||||
? {
|
||||
serving: {
|
||||
state: 'unverifiable' as const,
|
||||
detail: clampUtf8TextPrefix(value.serving.detail, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES)
|
||||
}
|
||||
}
|
||||
: {}
|
||||
const linked = admitSshManagedServerLinkedProjects('linked' in value ? value.linked : undefined)
|
||||
return {
|
||||
managedServer: {
|
||||
kind: 'managed',
|
||||
environmentId: value.environmentId,
|
||||
...update,
|
||||
...serving,
|
||||
...linked
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value.kind === 'setting-up' && 'phase' in value) {
|
||||
const phase = SSH_MANAGED_SERVER_PHASES.find((entry) => entry === value.phase)
|
||||
return phase ? { managedServer: { kind: 'setting-up', phase } } : {}
|
||||
}
|
||||
if (value.kind !== 'relay' || !('reason' in value)) {
|
||||
return {}
|
||||
}
|
||||
const reason = SSH_MANAGED_SERVER_RELAY_REASONS.find((entry) => entry === value.reason)
|
||||
if (!reason) {
|
||||
return {}
|
||||
}
|
||||
const detail = 'detail' in value && typeof value.detail === 'string' ? value.detail : ''
|
||||
const terminals = 'terminals' in value ? value.terminals : undefined
|
||||
return {
|
||||
managedServer: {
|
||||
kind: 'relay',
|
||||
reason,
|
||||
...(detail
|
||||
? { detail: clampUtf8TextPrefix(detail, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) }
|
||||
: {}),
|
||||
...(isNonNegativeSafeInteger(terminals) ? { terminals } : {}),
|
||||
...('terminalsElsewhere' in value && value.terminalsElsewhere === true
|
||||
? { terminalsElsewhere: true }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SSH_MANAGED_SERVER_LINKED_MAX_ENTRIES = 64
|
||||
|
||||
function admitSshManagedServerLinkedProjects(value: unknown): {
|
||||
linked?: SshManagedServerLinkedProject[]
|
||||
} {
|
||||
if (!Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
const linked = value
|
||||
.slice(0, SSH_MANAGED_SERVER_LINKED_MAX_ENTRIES)
|
||||
.flatMap((entry: unknown): SshManagedServerLinkedProject[] =>
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
'path' in entry &&
|
||||
typeof entry.path === 'string' &&
|
||||
entry.path &&
|
||||
'automations' in entry &&
|
||||
isNonNegativeSafeInteger(entry.automations)
|
||||
? [
|
||||
{
|
||||
path: clampUtf8TextPrefix(entry.path, SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES),
|
||||
automations: entry.automations
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
return linked.length > 0 ? { linked } : {}
|
||||
}
|
||||
|
||||
// Why alone: an unknown note from a newer host drops without hiding that the host is managed.
|
||||
function admitSshManagedServerUpdateNote(value: unknown): { update?: SshManagedServerUpdateNote } {
|
||||
if (!value || typeof value !== 'object' || !('state' in value)) {
|
||||
return {}
|
||||
}
|
||||
const state = SSH_MANAGED_SERVER_UPDATE_STATES.find((entry) => entry === value.state)
|
||||
if (!state) {
|
||||
return {}
|
||||
}
|
||||
const detail = 'detail' in value && typeof value.detail === 'string' ? value.detail : ''
|
||||
return {
|
||||
update: {
|
||||
state,
|
||||
...(detail
|
||||
? { detail: clampUtf8TextPrefix(detail, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,28 @@ describe('SSH retained payload admission', () => {
|
||||
expect(admit({ state: 'from-the-future' })).toEqual({ kind: 'managed', environmentId: 'env-1' })
|
||||
})
|
||||
|
||||
it('keeps the projects a move linked, dropping malformed entries alone', () => {
|
||||
const admit = (linked: unknown) =>
|
||||
admitSshConnectionState(
|
||||
{
|
||||
targetId: 'ssh-a',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
managedServer: { kind: 'managed', environmentId: 'env-1', linked }
|
||||
},
|
||||
'ssh-a'
|
||||
)?.managedServer
|
||||
expect(
|
||||
admit([{ path: '/root/app', automations: 2 }, { path: '/root/x', automations: -1 }, 'bad'])
|
||||
).toEqual({
|
||||
kind: 'managed',
|
||||
environmentId: 'env-1',
|
||||
linked: [{ path: '/root/app', automations: 2 }]
|
||||
})
|
||||
expect(admit('nope')).toEqual({ kind: 'managed', environmentId: 'env-1' })
|
||||
})
|
||||
|
||||
it('caps an unverifiable serving detail like its sibling details', () => {
|
||||
const managedServer = admitSshConnectionState(
|
||||
{
|
||||
|
||||
@@ -3,20 +3,23 @@ import type {
|
||||
EnrichedDetectedPort,
|
||||
SshConnectionState,
|
||||
SshConnectionStatus,
|
||||
SshManagedServerStatus,
|
||||
SshManagedServerUpdateNote,
|
||||
SshPlainSshMode,
|
||||
SshProviderEpoch
|
||||
} from './ssh-types'
|
||||
import { admitSshManagedServerStatus } from './ssh-managed-server-status-admission'
|
||||
import {
|
||||
SSH_MANAGED_SERVER_PHASES,
|
||||
SSH_MANAGED_SERVER_RELAY_REASONS,
|
||||
SSH_MANAGED_SERVER_UPDATE_STATES
|
||||
} from './ssh-types'
|
||||
isNonNegativeSafeInteger,
|
||||
isSshRetainedIdentifier,
|
||||
SSH_CONNECTION_ERROR_MAX_UTF8_BYTES
|
||||
} from './ssh-retained-payload-limits'
|
||||
import { clampUtf8TextPrefix, measureUtf8ByteLength } from './utf8-byte-limits'
|
||||
|
||||
export const SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES = 1024
|
||||
export const SSH_CONNECTION_ERROR_MAX_UTF8_BYTES = 16 * 1024
|
||||
export {
|
||||
isSshRetainedIdentifier,
|
||||
SSH_CONNECTION_ERROR_MAX_UTF8_BYTES,
|
||||
SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES
|
||||
} from './ssh-retained-payload-limits'
|
||||
|
||||
export const SSH_PROVIDER_EPOCH_MAX_UTF8_BYTES = 128
|
||||
const SSH_PLAIN_SSH_REASON_MAX_UTF8_BYTES = 64
|
||||
export const SSH_DETECTED_PORTS_MAX_ENTRIES = 50
|
||||
@@ -35,16 +38,6 @@ const CONNECTION_STATUSES = new Set<SshConnectionStatus>([
|
||||
'error'
|
||||
])
|
||||
|
||||
export function isSshRetainedIdentifier(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
!measureUtf8ByteLength(value, {
|
||||
stopAfterBytes: SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES
|
||||
}).exceededLimit
|
||||
)
|
||||
}
|
||||
|
||||
export function isAdmissibleDirectSshAuthority(value: unknown): value is DirectSshAuthority {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
@@ -108,83 +101,6 @@ export function admitSshConnectionState(
|
||||
}
|
||||
}
|
||||
|
||||
// Why field-by-field like plainSsh: an unknown kind from a newer host drops alone.
|
||||
function admitSshManagedServerStatus(value: unknown): { managedServer?: SshManagedServerStatus } {
|
||||
if (!value || typeof value !== 'object' || !('kind' in value)) {
|
||||
return {}
|
||||
}
|
||||
if (value.kind === 'managed' && 'environmentId' in value) {
|
||||
if (!isSshRetainedIdentifier(value.environmentId)) {
|
||||
return {}
|
||||
}
|
||||
const update = admitSshManagedServerUpdateNote('update' in value ? value.update : undefined)
|
||||
const serving =
|
||||
'serving' in value &&
|
||||
value.serving &&
|
||||
typeof value.serving === 'object' &&
|
||||
'state' in value.serving &&
|
||||
value.serving.state === 'unverifiable' &&
|
||||
'detail' in value.serving &&
|
||||
typeof value.serving.detail === 'string'
|
||||
? {
|
||||
serving: {
|
||||
state: 'unverifiable' as const,
|
||||
detail: clampUtf8TextPrefix(value.serving.detail, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES)
|
||||
}
|
||||
}
|
||||
: {}
|
||||
return {
|
||||
managedServer: { kind: 'managed', environmentId: value.environmentId, ...update, ...serving }
|
||||
}
|
||||
}
|
||||
if (value.kind === 'setting-up' && 'phase' in value) {
|
||||
const phase = SSH_MANAGED_SERVER_PHASES.find((entry) => entry === value.phase)
|
||||
return phase ? { managedServer: { kind: 'setting-up', phase } } : {}
|
||||
}
|
||||
if (value.kind !== 'relay' || !('reason' in value)) {
|
||||
return {}
|
||||
}
|
||||
const reason = SSH_MANAGED_SERVER_RELAY_REASONS.find((entry) => entry === value.reason)
|
||||
if (!reason) {
|
||||
return {}
|
||||
}
|
||||
const detail = 'detail' in value && typeof value.detail === 'string' ? value.detail : ''
|
||||
const terminals = 'terminals' in value ? value.terminals : undefined
|
||||
return {
|
||||
managedServer: {
|
||||
kind: 'relay',
|
||||
reason,
|
||||
...(detail
|
||||
? { detail: clampUtf8TextPrefix(detail, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) }
|
||||
: {}),
|
||||
...(isNonNegativeSafeInteger(terminals) ? { terminals } : {}),
|
||||
...('terminalsElsewhere' in value && value.terminalsElsewhere === true
|
||||
? { terminalsElsewhere: true }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why alone: an unknown note from a newer host drops without hiding that the host is managed.
|
||||
function admitSshManagedServerUpdateNote(value: unknown): { update?: SshManagedServerUpdateNote } {
|
||||
if (!value || typeof value !== 'object' || !('state' in value)) {
|
||||
return {}
|
||||
}
|
||||
const state = SSH_MANAGED_SERVER_UPDATE_STATES.find((entry) => entry === value.state)
|
||||
if (!state) {
|
||||
return {}
|
||||
}
|
||||
const detail = 'detail' in value && typeof value.detail === 'string' ? value.detail : ''
|
||||
return {
|
||||
update: {
|
||||
state,
|
||||
...(detail
|
||||
? { detail: clampUtf8TextPrefix(detail, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why field-by-field: a malformed optional mode must drop alone, never the whole state.
|
||||
function admitSshPlainSshMode(value: unknown): { plainSsh?: SshPlainSshMode } {
|
||||
if (!value || typeof value !== 'object') {
|
||||
@@ -299,10 +215,6 @@ function admitDetectedPort(value: unknown): EnrichedDetectedPort | null {
|
||||
}
|
||||
}
|
||||
|
||||
function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0
|
||||
}
|
||||
|
||||
function isStringWithinLimit(value: unknown, maxBytes: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { measureUtf8ByteLength } from './utf8-byte-limits'
|
||||
|
||||
export const SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES = 1024
|
||||
export const SSH_CONNECTION_ERROR_MAX_UTF8_BYTES = 16 * 1024
|
||||
|
||||
export function isSshRetainedIdentifier(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
!measureUtf8ByteLength(value, {
|
||||
stopAfterBytes: SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES
|
||||
}).exceededLimit
|
||||
)
|
||||
}
|
||||
|
||||
export function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
||||
}
|
||||
@@ -280,6 +280,13 @@ export type SshManagedServerUpdateNote = {
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/** A project the server already held at the same host path: this desktop uses the server's now. */
|
||||
export type SshManagedServerLinkedProject = {
|
||||
path: string
|
||||
/** This desktop's automations on it, which stay behind with its tabs. */
|
||||
automations: number
|
||||
}
|
||||
|
||||
/** A managed server that did not answer and was not proven serving, with why (and orcad.log's tail). */
|
||||
export type SshManagedServerServingNote = { state: 'unverifiable'; detail: string }
|
||||
|
||||
@@ -302,6 +309,8 @@ export type SshManagedServerStatus =
|
||||
update?: SshManagedServerUpdateNote
|
||||
/** Set when the server did not answer and no start brought it up; never a terminal verdict. */
|
||||
serving?: SshManagedServerServingNote
|
||||
/** Set on the connect that moved this host's projects, when some were linked instead. */
|
||||
linked?: SshManagedServerLinkedProject[]
|
||||
}
|
||||
| { kind: 'setting-up'; phase: (typeof SSH_MANAGED_SERVER_PHASES)[number] }
|
||||
/** `detail` names the blocker for a refusal, or why orcad can't run on the host. */
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Two desktops on one Unix login, on a real Docker SSH host:
|
||||
*
|
||||
* Desktop A runs another Orca version's managed server there, has added the host's repository to
|
||||
* it, and leaves a terminal running in it. Desktop B still has that repository from its relay-era
|
||||
* profile. B connects: it pairs with A's server as it runs (no upload, no restart), links its
|
||||
* project to the one A added instead of being refused, moves the rest, and A's terminal keeps
|
||||
* running.
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { appendFileSync, cpSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { z } from 'zod'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { waitForSessionReady } from './helpers/store'
|
||||
import { createRestartSession } from './helpers/orca-restart'
|
||||
import { managedServer, reconnect, serverCall } from './helpers/orcad-convert-flow'
|
||||
import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile'
|
||||
import { startOrcadConvertHost, type OrcadConvertHost } from './helpers/orcad-convert-host'
|
||||
import { callEnvironment } from './helpers/paired-host-terminal'
|
||||
import { findOrcadMigrationSourceCutoverForTarget } from '../../src/main/ssh/orcad-migration-cutover-journal'
|
||||
import { ORCAD_TEMPLATE_MANIFEST_FILENAME } from '../../src/shared/orcad-artifacts'
|
||||
|
||||
const HOST = process.env.ORCA_E2E_ORCAD_CONVERT_HOST
|
||||
const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE
|
||||
test.skip(
|
||||
HOST !== 'docker' || !TEMPLATE,
|
||||
'Set ORCA_E2E_ORCAD_CONVERT_HOST=docker and ORCA_E2E_ORCAD_CONVERT_TEMPLATE'
|
||||
)
|
||||
|
||||
const ServerRepoListSchema = z.object({
|
||||
result: z.object({ repos: z.array(z.object({ path: z.string() })) })
|
||||
})
|
||||
|
||||
// A common template file whose bytes nothing reads at runtime; its digest names the version.
|
||||
const VERSION_SEED_FILE = 'ripgrep/licenses/LICENSE-MIT'
|
||||
|
||||
/** The same build under another content version, as a desktop on another Orca release ships it. */
|
||||
function otherVersionTemplate(source: string, destination: string): string {
|
||||
cpSync(source, destination, { recursive: true })
|
||||
const seed = join(destination, ...VERSION_SEED_FILE.split('/'))
|
||||
appendFileSync(seed, '\n')
|
||||
const manifestPath = join(destination, ORCAD_TEMPLATE_MANIFEST_FILENAME)
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
manifest.commonSha256[VERSION_SEED_FILE] = createHash('sha256')
|
||||
.update(readFileSync(seed))
|
||||
.digest('hex')
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
||||
return destination
|
||||
}
|
||||
|
||||
function hostRecord(host: OrcadConvertHost): { active: string | null } {
|
||||
return JSON.parse(host.exec!('cat /root/.orca-remote/orcad-active.json'))
|
||||
}
|
||||
|
||||
function orcadPids(host: OrcadConvertHost): string {
|
||||
return host.exec!(
|
||||
'for f in /root/.orca-remote/orcad-*/.orcad-pid; do pid=$(cat "$f" 2>/dev/null) && ' +
|
||||
'kill -0 "$pid" 2>/dev/null && echo "$pid"; done; true'
|
||||
).trim()
|
||||
}
|
||||
|
||||
function sleeping(host: OrcadConvertHost, marker: string): boolean {
|
||||
return host.exec!(`pgrep -f "sleep ${marker}" >/dev/null && echo yes || echo no`).trim() === 'yes'
|
||||
}
|
||||
|
||||
async function connectFresh(
|
||||
page: Page,
|
||||
host: OrcadConvertHost
|
||||
): Promise<{ targetId: string; environmentId: string }> {
|
||||
const connected = await page.evaluate(async (input) => {
|
||||
const { target } = await window.api.ssh.addTarget({ target: input })
|
||||
const state = await window.api.ssh.connect({ targetId: target.id })
|
||||
return { targetId: target.id, managedServer: state?.managedServer ?? null }
|
||||
}, host.input)
|
||||
const server = connected.managedServer
|
||||
if (server?.kind !== 'managed') {
|
||||
throw new Error(`expected a managed server, got ${JSON.stringify(server)}`)
|
||||
}
|
||||
return { targetId: connected.targetId, environmentId: server.environmentId }
|
||||
}
|
||||
|
||||
test('a relay-era desktop joins the server another version runs for the same login', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
|
||||
{}, testInfo) => {
|
||||
test.setTimeout(20 * 60_000)
|
||||
const host = startOrcadConvertHost(HOST!, testInfo)
|
||||
const desktopA = createRestartSession(testInfo, {
|
||||
ORCA_ORCAD_TEMPLATE_PATH: otherVersionTemplate(
|
||||
TEMPLATE!,
|
||||
testInfo.outputPath('other-version-template')
|
||||
)
|
||||
})
|
||||
const desktopB = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! })
|
||||
let app: ElectronApplication | null = null
|
||||
try {
|
||||
// 1. Desktop A deploys its version, adds the host's repository and starts a terminal in it.
|
||||
const a = await desktopA.launch()
|
||||
app = a.app
|
||||
await waitForSessionReady(a.page)
|
||||
const serverA = await connectFresh(a.page, host)
|
||||
await callEnvironment(a.page, serverA.environmentId, 'repo.add', { path: host.remoteRepoPath })
|
||||
const marker = String(800_000 + Math.floor(Math.random() * 100_000))
|
||||
await callEnvironment(a.page, serverA.environmentId, 'terminal.create', {
|
||||
worktree: `path:${host.remoteRepoPath}`,
|
||||
command: `exec sleep ${marker}`
|
||||
})
|
||||
await expect.poll(() => sleeping(host, marker), { timeout: 60_000 }).toBe(true)
|
||||
const versionA = hostRecord(host).active
|
||||
const pidA = orcadPids(host)
|
||||
expect(versionA).toBeTruthy()
|
||||
expect(pidA).not.toBe('')
|
||||
await desktopA.close(app)
|
||||
app = null
|
||||
|
||||
// 2. Desktop B's relay-era profile already holds the same repository on the same host.
|
||||
const established = await desktopB.launch()
|
||||
app = established.app
|
||||
await waitForSessionReady(established.page)
|
||||
await desktopB.close(app)
|
||||
app = null
|
||||
const seeded = seedRelayEraProfile(desktopB.userDataDir, host.input, {
|
||||
repoPath: host.remoteRepoPath,
|
||||
folderPath: host.remoteFolderPath
|
||||
})
|
||||
const b = await desktopB.launch()
|
||||
app = b.app
|
||||
await waitForSessionReady(b.page)
|
||||
const connected = await reconnect(b.page, seeded.targetId)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const server = await managedServer(b.page, seeded.targetId)
|
||||
const journal = findOrcadMigrationSourceCutoverForTarget(
|
||||
desktopB.userDataDir,
|
||||
seeded.targetId
|
||||
)
|
||||
return JSON.stringify({ server, phase: journal?.phase ?? null, connected })
|
||||
},
|
||||
{ timeout: 10 * 60_000 }
|
||||
)
|
||||
.toContain('"kind":"managed"')
|
||||
|
||||
// Paired with A's server as it runs: same version, same process, A's terminal untouched.
|
||||
expect(hostRecord(host).active).toBe(versionA)
|
||||
expect(orcadPids(host)).toBe(pidA)
|
||||
expect(sleeping(host, marker)).toBe(true)
|
||||
|
||||
// Linked, not imported twice: the server keeps A's one project and gains B's folder.
|
||||
const journal = findOrcadMigrationSourceCutoverForTarget(desktopB.userDataDir, seeded.targetId)
|
||||
expect(journal).toMatchObject({
|
||||
phase: 'destination-committed',
|
||||
linkedRepositoryIds: [seeded.repoId]
|
||||
})
|
||||
const environments = await b.page.evaluate(() => window.api.runtimeEnvironments.list())
|
||||
const environmentB = environments.find((entry) => {
|
||||
const deployment = entry.orcadDeployment
|
||||
return deployment && 'sshTargetId' in deployment && deployment.sshTargetId === seeded.targetId
|
||||
})
|
||||
expect(environmentB).toBeTruthy()
|
||||
const repos = ServerRepoListSchema.parse(
|
||||
JSON.parse(await serverCall(b.page, environmentB!.id, 'repo.list'))
|
||||
).result.repos
|
||||
expect(repos.filter((repo) => repo.path === host.remoteRepoPath)).toHaveLength(1)
|
||||
expect(await serverCall(b.page, environmentB!.id, 'folderWorkspace.list')).toContain(
|
||||
JSON.stringify(host.remoteFolderPath).slice(1, -1)
|
||||
)
|
||||
await b.page.screenshot({ path: testInfo.outputPath('shared-login-linked.png') })
|
||||
} finally {
|
||||
if (app) {
|
||||
// Either session's close quits whichever app is still running.
|
||||
await desktopB.close(app)
|
||||
}
|
||||
await desktopA.dispose()
|
||||
await desktopB.dispose()
|
||||
host.cleanup()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user