mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
* feat(wsl): Add WSL distro host UI, host row status and fake-transport e2e (WSL-via-orcad C7b) Experimental toggle (Windows only), an Add WSL distro choice beside SSH host and remote server, the WSL host's own row in host pickers with its liveness state, a Connect action that starts a stopped distro only on request, a WSL hosts list in Settings, and the renderer report of which WSL hosts an open project or terminal needs. * feat(wsl): show WSL hosts as turned off while the experiment is off, and confirm their removal With Experimental → WSL distros as hosts off, a WSL host keeps its row so its projects never seem to vanish, says it is turned off, and offers Turn on (opening that setting) instead of Connect; Settings lists it the same way with Connect disabled. Removing a WSL host now asks first in the dialog SSH hosts use, with WSL copy. The e2e covers both. * fix(wsl): skip the WSL host status bridge when the preload lacks it * fix(wsl): show when this build cannot run WSL hosts instead of failing an add The UI reads main's template capability once. Without it, the add dialog says this build can't run WSL hosts and disables Add, existing hosts' rows and Settings say the same with Connect disabled, and the Experimental toggle notes it. The e2e covers a fake build without the files. * fix(wsl): re-read the WSL distro list when the add dialog's window returns, and on Refresh The add dialog read the registered distros once, so one imported while it was open never appeared. It now re-reads when the window regains focus or becomes visible, and from an explicit Refresh button, never on a timer. The selection is derived from the current list, so a refresh cannot leave a stale pick. The e2e imports a distro mid-dialog. * fix(wsl): list WSL hosts only as WSL hosts, not among the generic remote servers Settings → Remote Orca Servers, the server-update checks and the status bar's server list now leave out environments whose deployment record is a WSL host (by kind, never by name). The WSL hosts section reads them from the store itself. * fix(wsl): say a WSL host's removal ends its terminals, and report what it left running The confirmation now says removing ends the host's open terminals as well as Orca's server, and a removal main could not complete in the distro (a terminal or the daemon kept running) is reported instead of passing silently. * fix(wsl): ship the removal copy that says a WSL host's terminals end The build resolves copy from the English catalog, which still held the old text under the reused key, so the new inline default never reached the dialog. The copy gets its own key, the stale entry is dropped from both catalogs, and a test reads the shipped catalog. * fix(wsl): leave a removed host's workspace, keep its removal warning up, and prove removal in e2e After removal the active workspace is left when it lived on that host, so nothing keeps calling a host that no longer exists, and a warning about what still runs in the distro stays until dismissed. The e2e now checks that removal ends the host's terminals on the server standing in for orcad, shows no warning, leaves the workspace, and starts nothing for the host afterwards. * test(wsl): read the shipped removal copy and the fake's connect log without reflection * fix(wsl): say a WSL host is turned off in its terminal instead of 'Reconnecting' With the experiment off (or without server files) nothing retries, so a WSL host's terminal now says so, with Turn on, rather than claiming Orca is reconnecting. * test(wsl): reproduce project-removal survivors and orphan terminals in e2e The headless server standing in for orcad now carries a project whose terminal must end when the project is removed (and leave the host's own catalog), and an orphan terminal whose project the host already forgot, which removing the host must still end. Both checks fail without the fixes. * fix(wsl): drop the IPC wrapper from file-explorer and editor load errors; offer Turn on when WSL hosts are off The file explorer showed "Error invoking remote method 'runtimeEnvironments:call': Error: ..." for a WSL workspace whose host was turned off. Explorer read errors now go through the shared IPC-error unwrapping, and when the workspace's WSL host is turned off the empty pane shows the same turned-off copy and Turn on button as the terminal banner instead of a read error and a Retry that cannot work. The editor's file-load error view drops the same wrapper at render time (stored text is unchanged for retry classifiers). * test(wsl): open the internal gate for the WSL host e2e, and prove a closed gate shows nothing The fake-transport e2e launches its desktop with ORCA_INTERNAL_WSL_HOSTS=1. A new case leaves the gate closed with a WSL host already saved and the Experimental setting on: no WSL host API, list entry, host row, Add WSL distro action, Experimental toggle or Settings section appears, the fake never starts a distro, and the saved host stays on disk. * test(wsl): count only live shells when proving a removed project's terminals ended On Linux CI the hub keeps a pty-less tab record for the removed worktree after terminal.stop ended its shell, so counting every terminal.list row never reached zero.
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import { z } from 'zod'
|
|
import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing'
|
|
import {
|
|
pairedRuntimeConnectionFromOffer,
|
|
type PairedRuntimeConnection
|
|
} from './paired-runtime-connection'
|
|
import { classifyRemotePairingHostname } from './remote-pairing-address'
|
|
import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record'
|
|
import { OrcadWslDeploymentLinkSchema } from './wsl-host-deployment-link'
|
|
|
|
export const RuntimeAccessEndpointSchema = z.object({
|
|
id: z.string().min(1),
|
|
kind: z.literal('websocket'),
|
|
label: z.string().min(1),
|
|
endpoint: z.string().min(1),
|
|
deviceToken: z.string().min(1),
|
|
publicKeyB64: z.string().min(1)
|
|
})
|
|
|
|
export const PublicRuntimeAccessEndpointSchema = RuntimeAccessEndpointSchema.omit({
|
|
deviceToken: true,
|
|
publicKeyB64: true
|
|
})
|
|
|
|
export type PublicRuntimeAccessEndpoint = z.infer<typeof PublicRuntimeAccessEndpointSchema>
|
|
|
|
export const RuntimeEnvironmentSourceSchema = z.enum(['manual', 'ephemeral-vm'])
|
|
export type RuntimeEnvironmentSource = z.infer<typeof RuntimeEnvironmentSourceSchema>
|
|
|
|
export const RuntimeSshTunnelLinkSchema = z.object({
|
|
sshTargetId: z.string().min(1),
|
|
sshTargetGeneration: z.number().int().positive(),
|
|
localPort: z.number().int().min(1).max(65_535),
|
|
remotePort: z.number().int().min(1).max(65_535)
|
|
})
|
|
|
|
export type RuntimeSshTunnelLink = z.infer<typeof RuntimeSshTunnelLinkSchema>
|
|
|
|
/** A server Orca deployed and owns over SSH; unlike sshAccess, it grants lifecycle ownership. */
|
|
export const OrcadSshDeploymentLinkSchema = RuntimeSshTunnelLinkSchema.extend({
|
|
// Absent on disk: the sidecar persists SSH links without it, so older builds read them unchanged.
|
|
kind: z.literal('ssh').optional()
|
|
})
|
|
export type OrcadSshDeploymentLink = z.infer<typeof OrcadSshDeploymentLinkSchema>
|
|
|
|
/** A server Orca deployed and owns; the WSL kind is persisted only in wsl-host-deployment-store. */
|
|
export const OrcadDeploymentLinkSchema = z.union([
|
|
OrcadSshDeploymentLinkSchema,
|
|
OrcadWslDeploymentLinkSchema
|
|
])
|
|
export type OrcadDeploymentLink = z.infer<typeof OrcadDeploymentLinkSchema>
|
|
|
|
/** The managed deployment Orca owns over SSH; undefined for none or a WSL one. */
|
|
export function getOrcadSshDeployment(environment: {
|
|
orcadDeployment?: OrcadDeploymentLink | null
|
|
}): OrcadSshDeploymentLink | undefined {
|
|
const deployment = environment.orcadDeployment
|
|
return deployment && deployment.kind !== 'wsl' ? deployment : undefined
|
|
}
|
|
|
|
export const RuntimeSshAccessLinkSchema = RuntimeSshTunnelLinkSchema.extend({
|
|
requestId: z.string().min(1).optional(),
|
|
targetFingerprint: z.string().min(1).optional(),
|
|
endpointId: z.string().min(1),
|
|
previousPreferredEndpointId: z.string().min(1)
|
|
})
|
|
|
|
export type RuntimeSshAccessLink = z.infer<typeof RuntimeSshAccessLinkSchema>
|
|
|
|
export const RuntimeSshAccessOperationSchema = RuntimeSshTunnelLinkSchema.omit({ localPort: true })
|
|
.extend({
|
|
requestId: z.string().min(1),
|
|
operation: z.enum(['link', 'unlink']),
|
|
targetFingerprint: z.string().min(1).optional()
|
|
})
|
|
.refine((intent) => intent.operation !== 'link' || !!intent.targetFingerprint, {
|
|
message: 'Link intent requires a target fingerprint.'
|
|
})
|
|
|
|
export type RuntimeSshAccessOperation = z.infer<typeof RuntimeSshAccessOperationSchema>
|
|
|
|
/** The fields shipped builds read and rewrite in orca-environments.json. */
|
|
export const PersistedRuntimeEnvironmentSchema = z.object({
|
|
id: z.string().min(1),
|
|
name: z.string().min(1),
|
|
createdAt: z.number().finite(),
|
|
updatedAt: z.number().finite(),
|
|
pairingRevision: z.number().finite().optional(),
|
|
pairedDeviceId: z.string().min(1).optional(),
|
|
lastUsedAt: z.number().finite().nullable(),
|
|
runtimeId: z.string().min(1).nullable(),
|
|
source: RuntimeEnvironmentSourceSchema.optional(),
|
|
connectionDependency: z.literal('ssh-tunnel').optional(),
|
|
endpoints: z.array(RuntimeAccessEndpointSchema).min(1),
|
|
preferredEndpointId: z.string().min(1)
|
|
})
|
|
|
|
export type PersistedRuntimeEnvironment = z.infer<typeof PersistedRuntimeEnvironmentSchema>
|
|
|
|
/**
|
|
* A persisted environment with its sidecar state overlaid (runtime-environment-sidecar). These
|
|
* fields never enter orca-environments.json: shipped builds strip unknown keys when they rewrite it.
|
|
*/
|
|
export const KnownRuntimeEnvironmentSchema = PersistedRuntimeEnvironmentSchema.extend({
|
|
sshAccess: RuntimeSshAccessLinkSchema.optional(),
|
|
pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(),
|
|
orcadDeployment: OrcadDeploymentLinkSchema.optional(),
|
|
/** When a migration into this managed server began committing; older snapshots predate it. */
|
|
orcadMigratedAt: z.string().datetime().optional(),
|
|
reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional()
|
|
})
|
|
.refine(
|
|
({ pendingSshAccessOperation, sshAccess, orcadDeployment, connectionDependency }) =>
|
|
!pendingSshAccessOperation || (!sshAccess && !orcadDeployment && !connectionDependency),
|
|
{
|
|
message: 'Pending SSH access operations cannot coexist with active SSH access.',
|
|
path: ['pendingSshAccessOperation']
|
|
}
|
|
)
|
|
.refine(
|
|
(environment) => {
|
|
const access = environment.sshAccess
|
|
return (
|
|
!access ||
|
|
(environment.connectionDependency === 'ssh-tunnel' &&
|
|
environment.preferredEndpointId === access.endpointId &&
|
|
access.previousPreferredEndpointId !== access.endpointId &&
|
|
environment.endpoints.some(
|
|
(endpoint) => endpoint.id === access.previousPreferredEndpointId
|
|
) &&
|
|
getPreferredLoopbackRuntimePort(environment) === access.localPort)
|
|
)
|
|
},
|
|
{
|
|
message:
|
|
'Runtime SSH access must preserve its previous endpoint and prefer its loopback endpoint.',
|
|
path: ['sshAccess']
|
|
}
|
|
)
|
|
.refine(
|
|
(environment) => {
|
|
const deployment = environment.orcadDeployment
|
|
// Why no WSL dependency value: older builds reject any other value in orca-environments.json;
|
|
// a WSL link's kind, kept in its own file, is the bridge dependency.
|
|
const dependency = deployment?.kind === 'wsl' ? undefined : 'ssh-tunnel'
|
|
return (
|
|
!deployment ||
|
|
(!environment.sshAccess &&
|
|
environment.connectionDependency === dependency &&
|
|
getPreferredLoopbackRuntimePort(environment) === deployment.localPort)
|
|
)
|
|
},
|
|
{
|
|
message:
|
|
'A managed deployment must prefer its own tunnel and exclude independent SSH access.',
|
|
path: ['orcadDeployment']
|
|
}
|
|
)
|
|
|
|
export type KnownRuntimeEnvironment = z.infer<typeof KnownRuntimeEnvironmentSchema>
|
|
|
|
export type PublicKnownRuntimeEnvironment = Omit<KnownRuntimeEnvironment, 'endpoints'> & {
|
|
endpoints: PublicRuntimeAccessEndpoint[]
|
|
/** Digest of the host's persisted E2EE public key, which its pairing handshake proves. */
|
|
hostKeyFingerprint?: string
|
|
}
|
|
|
|
export function redactRuntimeEnvironment(
|
|
environment: KnownRuntimeEnvironment
|
|
): PublicKnownRuntimeEnvironment {
|
|
return {
|
|
...environment,
|
|
endpoints: environment.endpoints.map(
|
|
({ deviceToken: _deviceToken, publicKeyB64: _key, ...rest }) => rest
|
|
)
|
|
}
|
|
}
|
|
|
|
// Why version 1 and the persisted schema: shipped builds accept only this shape, so every
|
|
// T5 field lives in the sidecar and a downgrade rewrite cannot lose or reject it.
|
|
export const RuntimeEnvironmentStoreSchema = z.object({
|
|
version: z.literal(1),
|
|
environments: z.array(PersistedRuntimeEnvironmentSchema)
|
|
})
|
|
|
|
export type RuntimeEnvironmentStore = z.infer<typeof RuntimeEnvironmentStoreSchema>
|
|
|
|
export function createEnvironmentFromPairingOffer(args: {
|
|
id: string
|
|
name: string
|
|
now: number
|
|
offer: PairingOffer
|
|
runtimeId?: string | null
|
|
source?: RuntimeEnvironmentSource
|
|
connectionDependency?: 'ssh-tunnel'
|
|
}): KnownRuntimeEnvironment {
|
|
const endpointId = `ws-${args.id}`
|
|
return KnownRuntimeEnvironmentSchema.parse({
|
|
id: args.id,
|
|
name: args.name,
|
|
createdAt: args.now,
|
|
updatedAt: args.now,
|
|
pairingRevision: args.now,
|
|
...(args.offer.pairedDeviceId ? { pairedDeviceId: args.offer.pairedDeviceId } : {}),
|
|
lastUsedAt: null,
|
|
runtimeId: args.runtimeId ?? null,
|
|
...(args.source ? { source: args.source } : {}),
|
|
...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}),
|
|
endpoints: [
|
|
{
|
|
id: endpointId,
|
|
kind: 'websocket',
|
|
label: 'WebSocket',
|
|
endpoint: args.offer.endpoint,
|
|
deviceToken: args.offer.deviceToken,
|
|
publicKeyB64: args.offer.publicKeyB64
|
|
}
|
|
],
|
|
preferredEndpointId: endpointId
|
|
})
|
|
}
|
|
|
|
export function isEphemeralVmRuntimeEnvironment(
|
|
environment: Pick<PublicKnownRuntimeEnvironment, 'source'>
|
|
): boolean {
|
|
return environment.source === 'ephemeral-vm'
|
|
}
|
|
|
|
export function isUserManagedRuntimeEnvironment(
|
|
environment: Pick<PublicKnownRuntimeEnvironment, 'source'>
|
|
): boolean {
|
|
return !isEphemeralVmRuntimeEnvironment(environment)
|
|
}
|
|
|
|
/** A WSL distro added as a host; keyed by its deployment record's kind, never by its name. */
|
|
export function isWslHostRuntimeEnvironment(
|
|
environment: Pick<PublicKnownRuntimeEnvironment, 'orcadDeployment'>
|
|
): boolean {
|
|
return environment.orcadDeployment?.kind === 'wsl'
|
|
}
|
|
|
|
/**
|
|
* A server the generic remote-server surfaces list (Settings, status bar, server updates):
|
|
* neither a recipe VM nor a WSL host, which each have their own place.
|
|
*/
|
|
export function isListedRemoteServer(
|
|
environment: Pick<PublicKnownRuntimeEnvironment, 'source' | 'orcadDeployment'>
|
|
): boolean {
|
|
return isUserManagedRuntimeEnvironment(environment) && !isWslHostRuntimeEnvironment(environment)
|
|
}
|
|
|
|
type PreferredEndpointSource = Pick<
|
|
KnownRuntimeEnvironment,
|
|
'name' | 'endpoints' | 'preferredEndpointId' | 'pairedDeviceId'
|
|
>
|
|
|
|
function preferredEndpoint(environment: PreferredEndpointSource) {
|
|
const endpoint =
|
|
environment.endpoints.find((entry) => entry.id === environment.preferredEndpointId) ??
|
|
environment.endpoints[0]
|
|
if (!endpoint) {
|
|
throw new Error(`Environment ${environment.name} has no access endpoints`)
|
|
}
|
|
return endpoint
|
|
}
|
|
|
|
export function getPreferredPairedRuntimeConnection(
|
|
environment: PreferredEndpointSource
|
|
): PairedRuntimeConnection {
|
|
const endpoint = preferredEndpoint(environment)
|
|
return pairedRuntimeConnectionFromOffer({
|
|
endpoint: endpoint.endpoint,
|
|
deviceToken: endpoint.deviceToken,
|
|
publicKeyB64: endpoint.publicKeyB64,
|
|
pairedDeviceId: environment.pairedDeviceId
|
|
})
|
|
}
|
|
|
|
export function getPreferredPairingOffer(environment: PreferredEndpointSource): PairingOffer {
|
|
const endpoint = preferredEndpoint(environment)
|
|
return {
|
|
v: PAIRING_OFFER_VERSION,
|
|
endpoint: endpoint.endpoint,
|
|
deviceToken: endpoint.deviceToken,
|
|
publicKeyB64: endpoint.publicKeyB64,
|
|
...(environment.pairedDeviceId ? { pairedDeviceId: environment.pairedDeviceId } : {})
|
|
}
|
|
}
|
|
|
|
/** The SSH tunnel a server is reached through; only orcadDeployment also grants lifecycle ownership. */
|
|
export function getRuntimeSshAccess(
|
|
environment: Pick<KnownRuntimeEnvironment, 'orcadDeployment' | 'sshAccess'>
|
|
): RuntimeSshTunnelLink | undefined {
|
|
return getOrcadSshDeployment(environment) ?? environment.sshAccess
|
|
}
|
|
|
|
export function getPreferredLoopbackRuntimePort(environment: {
|
|
endpoints: { id: string; endpoint: string }[]
|
|
preferredEndpointId: string
|
|
}): number | null {
|
|
const endpoint = environment.endpoints.find(
|
|
(entry) => entry.id === environment.preferredEndpointId
|
|
)
|
|
if (!endpoint) {
|
|
return null
|
|
}
|
|
try {
|
|
const url = new URL(endpoint.endpoint)
|
|
const port = Number(url.port)
|
|
return (url.protocol === 'ws:' || url.protocol === 'wss:') &&
|
|
classifyRemotePairingHostname(url.hostname) === 'loopback' &&
|
|
Number.isInteger(port) &&
|
|
port >= 1 &&
|
|
port <= 65_535
|
|
? port
|
|
: null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|