diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index b82385efd2..e7d234745f 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -425,6 +425,9 @@ test('publishes update progress and waits for the managed profile to reconnect', runUpdatePolicy: async () => assert.fail('update policy is not expected'), runUpdateReconciliation: async () => assert.fail('update reconciliation is not expected'), + setupPackageMode: 'published', + resolveSshDevelopmentPeerTarget: async () => + assert.fail('published update must not inspect the development target'), resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.3.0' }), currentHostEpoch: () => 'host-before-update', awaitUpdatedConnection: async (...args) => { @@ -1175,6 +1178,9 @@ function unusedUpdateDependencies() { runPeerManagement: async (): Promise => assert.fail('direct peer management is not expected'), directPeerClientAvailable: false, + setupPackageMode: 'published' as const, + resolveSshDevelopmentPeerTarget: async (): Promise => + assert.fail('published update must not inspect the development target'), resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.2.3' } as const), currentHostEpoch: () => undefined, awaitUpdatedConnection: async () => undefined, diff --git a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts index 2eae7b7d41..c40b096d21 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts @@ -94,6 +94,7 @@ test('persists a verified on-demand SSH profile without endpoint or credential p test('onboards WSL as a credential-free environment profile', async () => { let saved: DesktopRuntimeHostProfileAddInput | undefined; + const peerTargets: string[] = []; const harness = createHarness({ profiles: { addAndEnable: async (input) => { @@ -108,6 +109,10 @@ test('onboards WSL as a credential-free environment profile', async () => { operatorPath: '/home/operator/.local/share/maka/operator', }; }, + resolveSetupPackage: (peerTarget) => { + peerTargets.push(peerTarget); + return { kind: 'npm', specifier: 'maka-agent@0.2.0' }; + }, }); const result = await harness.invoke('runtime-host-onboarding:start', { @@ -125,6 +130,7 @@ test('onboards WSL as a credential-free environment profile', async () => { rootId: 'a'.repeat(64), operatorPath: '/home/operator/.local/share/maka/operator', }); + assert.deepEqual(peerTargets, ['none']); await harness.onboarding.close(); }); @@ -294,6 +300,9 @@ function createHarness(overrides: HarnessOverrides = {}) { addAndEnableVerified: async () => assert.fail('profile must not be saved'), ...profiles, }, + setupPackageMode: 'published', + resolveSshDevelopmentPeerTarget: async () => + assert.fail('published setup must not inspect the development target'), resolveSetupPackage: () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), runSetup: async () => assert.fail('SSH must not start'), runWslSetup: async () => assert.fail('WSL must not start'), diff --git a/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts b/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts index c5e9ecec46..6827082f20 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts @@ -23,17 +23,19 @@ import { join, resolve } from 'node:path'; import { test } from 'node:test'; import { createRuntimeHostSetupPackageResolver } from '../runtime-host-setup-package.js'; -test('development setup lazily builds one local CLI archive unless explicitly overridden', async () => { +test('development setup lazily caches CLI archives by peer target unless overridden', async () => { const repoRoot = resolve('/workspace'); const archive = join(repoRoot, 'packages', 'cli', 'release', 'maka-agent-dev.tgz'); let builds = 0; let closes = 0; + const targets: string[] = []; const resolvePackage = createRuntimeHostSetupPackageResolver({ isPackaged: false, appPath: join(repoRoot, 'apps', 'desktop'), environment: {}, - startDevelopmentArchiveBuild: (resolvedRoot) => { + startDevelopmentArchiveBuild: (resolvedRoot, target) => { builds += 1; + targets.push(target); assert.equal(resolvedRoot, repoRoot); return { result: Promise.resolve(archive), @@ -44,7 +46,11 @@ test('development setup lazily builds one local CLI archive unless explicitly ov }, }); - assert.deepEqual(await Promise.all([resolvePackage.resolve(), resolvePackage.resolve()]), [ + assert.equal(resolvePackage.mode, 'development'); + assert.deepEqual(await Promise.all([ + resolvePackage.resolve('linux-x64'), + resolvePackage.resolve('linux-x64'), + ]), [ { kind: 'development_archive', path: archive, @@ -54,7 +60,9 @@ test('development setup lazily builds one local CLI archive unless explicitly ov path: archive, }, ]); - assert.equal(builds, 1); + await resolvePackage.resolve('none'); + assert.equal(builds, 2); + assert.deepEqual(targets, ['linux-x64', 'none']); const override = join(tmpdir(), 'explicit.tgz'); const resolveOverride = createRuntimeHostSetupPackageResolver({ @@ -63,12 +71,12 @@ test('development setup lazily builds one local CLI archive unless explicitly ov environment: { MAKA_RUNTIME_HOST_SETUP_ARCHIVE: override }, startDevelopmentArchiveBuild: () => assert.fail('override must bypass the local build'), }); - assert.deepEqual(await resolveOverride.resolve(), { + assert.deepEqual(await resolveOverride.resolve('none'), { kind: 'development_archive', path: override, }); await Promise.all([resolvePackage.close(), resolveOverride.close()]); - assert.equal(closes, 1); + assert.equal(closes, 2); }); test('cancelling the last waiter closes its build before a new setup starts', async () => { @@ -107,10 +115,10 @@ test('cancelling the last waiter closes its build before a new setup starts', as }, }); - const first = resolver.resolve(cancelled.signal); + const first = resolver.resolve('linux-x64', cancelled.signal); cancelled.abort(new Error('setup cancelled')); await closeStarted; - const second = resolver.resolve(); + const second = resolver.resolve('linux-x64'); await Promise.resolve(); assert.equal(builds, 1); diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index 3bbc6c5649..c5a918c24f 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -33,7 +33,37 @@ import { RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, RUNTIME_HOST_SETUP_FRAME_PREFIX, } from '@maka/runtime-host/operator'; -import { createDesktopRuntimeHostSshTerminal } from '../runtime-host-ssh-terminal.js'; +import { + createDesktopRuntimeHostSshTerminal, + runtimeHostDevelopmentPeerTargetFromUname, +} from '../runtime-host-ssh-terminal.js'; + +test('maps supported SSH uname identities to development peer targets', () => { + assert.equal(runtimeHostDevelopmentPeerTargetFromUname('Linux', 'x86_64'), 'linux-x64'); + assert.equal(runtimeHostDevelopmentPeerTargetFromUname('Linux', 'aarch64'), 'linux-arm64'); + assert.equal(runtimeHostDevelopmentPeerTargetFromUname('Darwin', 'arm64'), 'darwin-arm64'); + assert.throws( + () => runtimeHostDevelopmentPeerTargetFromUname('Linux', 'riscv64'), + /not available/u, + ); +}); + +test('detects the development peer target through the bounded SSH preflight', async () => { + const harness = createHarness('pending'); + const detection = harness.terminal.resolveDevelopmentPeerTarget({ + destination: 'operator@example.com', + }); + await waitFor(() => harness.pty.hasDataListener()); + const command = harness.launchArgs[0]?.at(-1) ?? ''; + const marker = command.match(/__MAKA_RUNTIME_HOST_TARGET_[0-9a-f]+__/u)?.[0]; + assert.ok(marker); + harness.pty.emitData(`${marker}Linux:x86_64\r\n`); + harness.pty.exit(0); + + assert.equal(await detection, 'linux-x64'); + assert.doesNotMatch(JSON.stringify(harness.events), /MAKA_RUNTIME_HOST_TARGET/u); + await harness.terminal.close(); +}); test('keeps a connecting SSH prompt observable across renderer presentation changes', async () => { const harness = createHarness('pending'); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 0bbce55dba..5476d7e02f 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -172,7 +172,10 @@ import { createDesktopRuntimeHostSshTerminal, } from "./runtime-host-ssh-terminal.js"; import { runDesktopRuntimeHostWslSetup } from './runtime-host-wsl-controller.js'; -import { createRuntimeHostSetupPackageResolver } from "./runtime-host-setup-package.js"; +import { + createRuntimeHostSetupPackageResolver, + desktopRuntimeHostDevelopmentPeerTarget, +} from "./runtime-host-setup-package.js"; import { configureDesktopRuntimeHostPeerClient } from './runtime-host-peer-client.js'; import { createDesktopRuntimeHostLocalOperator } from './runtime-host-local-operator.js'; import { createDesktopLocalRuntimeHostRemoteAccess } from './runtime-host-local-remote-access.js'; @@ -399,7 +402,10 @@ const localRuntimeHostRemoteAccess = createDesktopLocalRuntimeHostRemoteAccess({ rootId: startupLocalStorageRoot.rootId, directPeerAvailable: runtimeHostDirectPeerAvailable, manager: () => runtimeHostManager, - resolveSetupPackage: runtimeHostSetupPackage.resolve, + resolveSetupPackage: (signal) => runtimeHostSetupPackage.resolve( + desktopRuntimeHostDevelopmentPeerTarget(), + signal, + ), operator: localRuntimeHostOperator, }); const native = assembleDesktopNativeCapabilities({ @@ -474,6 +480,8 @@ const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({ runSetup: runtimeHostSshTerminal.runSetup, runWslSetup: runDesktopRuntimeHostWslSetup, listWslDistributions: listRuntimeHostWslDistributions, + setupPackageMode: runtimeHostSetupPackage.mode, + resolveSshDevelopmentPeerTarget: runtimeHostSshTerminal.resolveDevelopmentPeerTarget, resolveSetupPackage: runtimeHostSetupPackage.resolve, send: (snapshot) => mainWindowController.send("runtime-host-onboarding:changed", snapshot), @@ -487,6 +495,8 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ runUpdate: runtimeHostSshTerminal.runUpdate, runUpdatePolicy: runtimeHostSshTerminal.runUpdatePolicy, runUpdateReconciliation: runtimeHostSshTerminal.runUpdateReconciliation, + setupPackageMode: runtimeHostSetupPackage.mode, + resolveSshDevelopmentPeerTarget: runtimeHostSshTerminal.resolveDevelopmentPeerTarget, resolveUpdatePackage: runtimeHostSetupPackage.resolve, currentHostEpoch: (profileId) => runtimeHostManager?.current(profileId)?.candidate?.client.hostEpoch, diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index 3b82eb0b9f..c1e82c737f 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -54,6 +54,7 @@ import type { RuntimeHostServiceUpdateReconciliationTerminalFrame, RuntimeHostServiceUpdateTerminalFrame, } from './runtime-host-ssh-terminal.js'; +import type { DesktopRuntimeHostDevelopmentPeerTarget } from './runtime-host-setup-package.js'; const MANAGEMENT_ACTIONS = new Set([ 'status', @@ -104,7 +105,15 @@ export function createDesktopRuntimeHostManagement(input: { input: DesktopRuntimeHostSshUpdateReconciliationInput, onProgress: (phase: DesktopRuntimeHostManagementProgress['phase']) => void, ) => Promise; - readonly resolveUpdatePackage: () => + readonly setupPackageMode: 'published' | 'development'; + readonly resolveSshDevelopmentPeerTarget: (input: { + readonly destination: string; + readonly sshPort?: number; + readonly signal?: AbortSignal; + }) => Promise>; + readonly resolveUpdatePackage: ( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + ) => | DesktopRuntimeHostSetupPackage | Promise; readonly currentHostEpoch: (profileId: string) => string | undefined; @@ -466,7 +475,13 @@ export function createDesktopRuntimeHostManagement(input: { await managedMutationTarget(profileIdValue); const previousHostEpoch = input.currentHostEpoch(profileId); input.sendProgress({ profileId, phase: 'preparing_cli' }); - const setupPackage = await input.resolveUpdatePackage(); + const peerTarget = input.setupPackageMode === 'development' + ? await input.resolveSshDevelopmentPeerTarget({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + }) + : 'none'; + const setupPackage = await input.resolveUpdatePackage(peerTarget); const response = await input.runUpdate( { destination: transport.destination, diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts index 1c9674b8ec..fd39a42760 100644 --- a/apps/desktop/src/main/runtime-host-onboarding.ts +++ b/apps/desktop/src/main/runtime-host-onboarding.ts @@ -33,6 +33,7 @@ import type { DesktopRuntimeHostSshSetupInput, } from './runtime-host-ssh-terminal.js'; import type { DesktopRuntimeHostWslSetupInput } from './runtime-host-wsl-controller.js'; +import type { DesktopRuntimeHostDevelopmentPeerTarget } from './runtime-host-setup-package.js'; import { requireProjectDirectoryRoots } from '../shared/runtime-host-project-directory-policy.js'; type OnboardingState = DesktopRuntimeHostOnboardingSnapshot extends infer Snapshot @@ -65,7 +66,14 @@ export function createDesktopRuntimeHostOnboarding(input: { ) => Promise<{ readonly rootId: string; readonly operatorPath: string }>; readonly listWslDistributions: () => Promise; readonly send: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void; + readonly setupPackageMode: 'published' | 'development'; + readonly resolveSshDevelopmentPeerTarget: (input: { + readonly destination: string; + readonly sshPort?: number; + readonly signal?: AbortSignal; + }) => Promise>; readonly resolveSetupPackage: ( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, signal?: AbortSignal, ) => DesktopRuntimeHostSetupPackage | Promise; }): { close(): Promise } { @@ -113,8 +121,14 @@ export function createDesktopRuntimeHostOnboarding(input: { signal: AbortSignal, ): Promise => { try { - const setupPackage = await input.resolveSetupPackage(signal); - if (request.kind === 'wsl') return await runWsl(request, setupPackage, signal); + if (request.kind === 'wsl') { + const setupPackage = await input.resolveSetupPackage('none', signal); + return await runWsl(request, setupPackage, signal); + } + const peerTarget = input.setupPackageMode === 'development' + ? await resolveSshDevelopmentPeerTarget(request, signal) + : 'none'; + const setupPackage = await input.resolveSetupPackage(peerTarget, signal); const lifecycle = setupPackage.kind === 'npm' ? 'on_demand' : 'supervised'; signal.throwIfAborted(); publish({ kind: 'running', phase: 'connecting_ssh' }); @@ -203,6 +217,20 @@ export function createDesktopRuntimeHostOnboarding(input: { } }; + const resolveSshDevelopmentPeerTarget = async ( + request: Extract, + signal: AbortSignal, + ): Promise> => { + publish({ kind: 'running', phase: 'connecting_ssh' }); + const target = await input.resolveSshDevelopmentPeerTarget({ + destination: request.destination, + ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), + signal, + }); + publish({ kind: 'running', phase: 'preparing_cli' }); + return target; + }; + const runWsl = async ( request: Extract, setupPackage: DesktopRuntimeHostSetupPackage, diff --git a/apps/desktop/src/main/runtime-host-setup-package.ts b/apps/desktop/src/main/runtime-host-setup-package.ts index 7a9ca7f10c..5a182ace53 100644 --- a/apps/desktop/src/main/runtime-host-setup-package.ts +++ b/apps/desktop/src/main/runtime-host-setup-package.ts @@ -36,33 +36,68 @@ interface DevelopmentArchiveBuild { close(): Promise; } +interface DevelopmentBuildState { + readonly task: DevelopmentArchiveBuild; + readonly result: Promise; + waiters: number; + settled: boolean; + closing?: Promise; +} + +export type DesktopRuntimeHostDevelopmentPeerTarget = + | 'none' + | 'darwin-arm64' + | 'linux-arm64' + | 'linux-x64' + | 'win32-x64'; + export interface RuntimeHostSetupPackageResolver { - resolve(signal?: AbortSignal): Promise; + readonly mode: 'published' | 'development'; + resolve( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + signal?: AbortSignal, + ): Promise; close(): Promise; } +export function desktopRuntimeHostDevelopmentPeerTarget( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): Exclude { + const target = `${platform}-${arch}`; + if ( + target !== 'darwin-arm64' && + target !== 'linux-arm64' && + target !== 'linux-x64' && + target !== 'win32-x64' + ) { + throw new Error(`Direct peer is not available on ${target}`); + } + return target; +} + export function createRuntimeHostSetupPackageResolver(input: { readonly isPackaged: boolean; readonly appPath: string; readonly environment: NodeJS.ProcessEnv; - readonly startDevelopmentArchiveBuild?: (repoRoot: string) => DevelopmentArchiveBuild; + readonly startDevelopmentArchiveBuild?: ( + repoRoot: string, + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + ) => DevelopmentArchiveBuild; }): RuntimeHostSetupPackageResolver { let closed = false; - let developmentBuild: - | { - readonly task: DevelopmentArchiveBuild; - readonly result: Promise; - waiters: number; - settled: boolean; - closing?: Promise; - } - | undefined; + const developmentBuilds = new Map< + DesktopRuntimeHostDevelopmentPeerTarget, + DevelopmentBuildState + >(); - const startBuild = () => { + const startBuild = ( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + ): DevelopmentBuildState => { const repoRoot = resolve(input.appPath, '..', '..'); - const task = input.startDevelopmentArchiveBuild?.(repoRoot) ?? - startDevelopmentArchiveBuild(repoRoot, input.environment); - const build = { + const task = input.startDevelopmentArchiveBuild?.(repoRoot, peerTarget) ?? + startDevelopmentArchiveBuild(repoRoot, input.environment, peerTarget); + const build: DevelopmentBuildState = { task, result: task.result.then((path) => ({ kind: 'development_archive' as const, @@ -71,62 +106,71 @@ export function createRuntimeHostSetupPackageResolver(input: { waiters: 0, settled: false, }; - developmentBuild = build; + developmentBuilds.set(peerTarget, build); void build.result.then( () => { build.settled = true; }, () => { build.settled = true; - if (developmentBuild === build) developmentBuild = undefined; + if (developmentBuilds.get(peerTarget) === build) { + developmentBuilds.delete(peerTarget); + } }, ); return build; }; - const stopBuild = async (build: NonNullable) => { + const stopBuild = async ( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + build: DevelopmentBuildState, + ) => { build.closing ??= build.task.close().finally(() => { - if (developmentBuild === build) developmentBuild = undefined; + if (developmentBuilds.get(peerTarget) === build) developmentBuilds.delete(peerTarget); }); await build.closing; await build.result.catch(() => undefined); }; - const acquireBuild = async (signal?: AbortSignal) => { + const acquireBuild = async ( + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, + signal?: AbortSignal, + ) => { while (true) { if (closed) throw new Error('Runtime Host setup package resolver is closed'); - const build = developmentBuild; - if (!build) return startBuild(); + const build = developmentBuilds.get(peerTarget); + if (!build) return startBuild(peerTarget); if (!build.closing) return build; await waitForPackage(build.closing, signal); } }; return { - async resolve(signal) { + mode: input.isPackaged ? 'published' : 'development', + async resolve(peerTarget, signal) { if (closed) throw new Error('Runtime Host setup package resolver is closed'); if (input.isPackaged) return packagedSetupPackage(input.appPath); const override = input.environment[DEVELOPMENT_ARCHIVE_ENV]; if (override) return { kind: 'development_archive', path: override }; - const build = await acquireBuild(signal); + const build = await acquireBuild(peerTarget, signal); build.waiters += 1; try { return await waitForPackage(build.result, signal); } finally { build.waiters -= 1; if (signal?.aborted && build.waiters === 0 && !build.settled) { - await stopBuild(build); + await stopBuild(peerTarget, build); } } }, async close() { if (closed) return; closed = true; - const build = developmentBuild; - developmentBuild = undefined; - if (build) await stopBuild(build); + const builds = [...developmentBuilds.entries()]; + developmentBuilds.clear(); + await Promise.all(builds.map(([peerTarget, build]) => stopBuild(peerTarget, build))); }, }; } @@ -144,6 +188,7 @@ function packagedSetupPackage(appPath: string): DesktopRuntimeHostSetupPackage { function startDevelopmentArchiveBuild( repoRoot: string, environment: NodeJS.ProcessEnv, + peerTarget: DesktopRuntimeHostDevelopmentPeerTarget, ): DevelopmentArchiveBuild { const script = join(repoRoot, 'scripts', 'release-cli-package.mjs'); const nodeExecutable = environment.npm_node_execpath?.trim() || 'node'; @@ -153,7 +198,11 @@ function startDevelopmentArchiveBuild( const child = spawn(nodeExecutable, [script, '--development'], { cwd: repoRoot, detached: process.platform !== 'win32', - env: { ...environment, MAKA_CLI_DEVELOPMENT_OUTPUT_ROOT: outputRoot }, + env: { + ...environment, + MAKA_CLI_DEVELOPMENT_OUTPUT_ROOT: outputRoot, + MAKA_CLI_DEVELOPMENT_PEER_TARGET: peerTarget, + }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 1b2d82e21d..97b133c390 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -67,6 +67,7 @@ import type { DesktopRuntimeHostSshTerminalSnapshot, } from '../preload/bridge-contract.js'; import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; +import type { DesktopRuntimeHostDevelopmentPeerTarget } from './runtime-host-setup-package.js'; interface ActiveTerminal { readonly sessionId: string; @@ -105,6 +106,12 @@ export interface DesktopRuntimeHostSshSetupInput { readonly signal?: AbortSignal; } +export interface DesktopRuntimeHostSshTargetInput { + readonly destination: string; + readonly sshPort?: number; + readonly signal?: AbortSignal; +} + export interface DesktopRuntimeHostSshManagementInput { readonly destination: string; readonly sshPort?: number; @@ -236,6 +243,9 @@ export function createDesktopRuntimeHostSshTerminal(input: { input: RuntimeHostSshOperatorActivationInput, ): Promise; openSshTunnel(input: RuntimeHostSshTunnelInput): Promise; + resolveDevelopmentPeerTarget( + input: DesktopRuntimeHostSshTargetInput, + ): Promise>; runSetup( input: DesktopRuntimeHostSshSetupInput, onProgress: (frame: Extract) => void, @@ -603,6 +613,66 @@ export function createDesktopRuntimeHostSshTerminal(input: { } return tunnel; }, + resolveDevelopmentPeerTarget: async (targetInput) => { + if (closed) throw new Error('Runtime Host SSH terminal is closed'); + targetInput.signal?.throwIfAborted(); + const destination = normalizeRuntimeHostSshDestination(targetInput.destination); + const sshPort = targetInput.sshPort === undefined + ? undefined + : requireSetupPort(targetInput.sshPort); + const marker = `__MAKA_RUNTIME_HOST_TARGET_${randomUUID().replaceAll('-', '')}__`; + const remoteCommand = `printf '${marker}%s:%s\\n' "$(uname -s)" "$(uname -m)"`; + let target: Exclude | undefined; + let failure: Error | undefined; + const filter = createRuntimeHostFramedOutputFilter({ + prefix: marker, + pendingMaxBytes: 256, + decode: (line) => line.slice(marker.length).replaceAll('\r', '').trimEnd(), + label: 'Remote Runtime Host target detection', + onFrame: (identity) => { + if (target) { + failure = new Error('Remote Runtime Host target detection returned multiple results'); + return; + } + const [system, machine, ...extra] = identity.split(':'); + if (!system || !machine || extra.length > 0) { + failure = new Error('Remote Runtime Host target detection returned an invalid result'); + return; + } + try { + target = runtimeHostDevelopmentPeerTargetFromUname(system, machine); + } catch (error) { + failure = error instanceof Error ? error : new Error(String(error)); + } + }, + onError: (error) => { + failure = error; + }, + }); + const { process, terminal } = startTerminalProcess( + 'ssh', + sshRemoteCommandArgs(destination, sshPort, remoteCommand), + filter.push, + true, + ); + const wait = await waitForTerminalProcess(process, { + signal: targetInput.signal, + timeoutMs: input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS, + stopGraceMs: input.processStopGraceMs, + onAbort: () => dismissPresentation(terminal), + }, input.terminateProcessTree); + if (wait.timedOut) throw new Error('Remote Runtime Host target detection timed out'); + if (wait.exit.code !== 0) { + throw new Error( + `Remote Runtime Host target detection exited with code ${String(wait.exit.code)}`, + ); + } + filter.finish(); + if (failure) throw failure; + completePresentation(terminal); + if (!target) throw new Error('Remote Runtime Host target detection returned no result'); + return target; + }, runSetup: async (setupInput, onProgress, onComplete) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); setupInput.signal?.throwIfAborted(); @@ -842,6 +912,21 @@ export function createDesktopRuntimeHostSshTerminal(input: { }; } +export function runtimeHostDevelopmentPeerTargetFromUname( + system: string, + machine: string, +): Exclude { + const normalizedMachine = machine.toLowerCase(); + if (system === 'Darwin' && normalizedMachine === 'arm64') return 'darwin-arm64'; + if (system === 'Linux') { + if (normalizedMachine === 'x86_64') return 'linux-x64'; + if (normalizedMachine === 'aarch64' || normalizedMachine === 'arm64') { + return 'linux-arm64'; + } + } + throw new Error(`Direct peer is not available on ${system}/${machine}`); +} + function cancellableUntilComplete(signal: AbortSignal | undefined): { readonly signal: AbortSignal; commit(): boolean; diff --git a/native/runtime-host-peer/build.mjs b/native/runtime-host-peer/build.mjs index b103cfe154..9c9d77ffe7 100644 --- a/native/runtime-host-peer/build.mjs +++ b/native/runtime-host-peer/build.mjs @@ -53,6 +53,13 @@ const targetPlatform = cargoTarget ? 'darwin' : 'linux' : process.platform; +const targetArch = cargoTarget + ? cargoTarget.startsWith('aarch64-') + ? 'arm64' + : cargoTarget.startsWith('x86_64-') + ? 'x64' + : undefined + : process.arch; const library = targetPlatform === 'win32' @@ -67,7 +74,11 @@ if (targetPlatform === 'darwin' && process.platform === 'darwin') { ...process.env, }); await run('strip', ['-x', destination], root, { ...process.env }); -} else if (targetPlatform === 'linux' && process.platform === 'linux') { +} else if ( + targetPlatform === 'linux' && + process.platform === 'linux' && + targetArch === process.arch +) { await run('strip', ['--strip-unneeded', destination], root, { ...process.env }); } if ((await readFile(destination)).includes(Buffer.from(root))) { diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index 2a6a12a223..da67bac7e9 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -58,6 +58,9 @@ const releaseRoot = join(cliSource, 'release'); const artifactRoot = developmentBuild ? createDevelopmentArtifactRoot() : releaseRoot; const stageRoot = join(artifactRoot, 'package'); const peerPrebuildTargets = ['darwin-arm64', 'linux-arm64', 'linux-x64', 'win32-x64']; +const privatePeerTarget = developmentBuild + ? resolveDevelopmentPeerTarget() + : `${process.platform}-${process.arch}`; const unsupportedArguments = process.argv .slice(2) .filter((argument) => !['--allow-dirty', '--development'].includes(argument)); @@ -157,7 +160,7 @@ function packageCli(publishable) { entryCount: pack.entryCount, }); const tarballPath = join(artifactRoot, pack.filename); - validatePackedFiles(pack.files, expectedDependencyManifests); + validatePackedFiles(pack.files, expectedDependencyManifests, publishable); const sha256 = digestFile(tarballPath); writeFileSync(`${tarballPath}.sha256`, `${sha256} ${pack.filename}\n`, 'utf8'); writeFileSync( @@ -542,16 +545,15 @@ function copyReleaseDocuments() { function copyRuntimeHostPeerPrebuilds(publishable) { let sourceRoot = process.env.MAKA_RUNTIME_HOST_PEER_PREBUILDS?.trim(); let generatedRoot; - let targets = peerPrebuildTargets; + const targets = publishable + ? peerPrebuildTargets + : privatePeerTarget === 'none' + ? [] + : [privatePeerTarget]; + if (targets.length === 0) return; if (!sourceRoot && !publishable) { - execFileSync(process.execPath, [join(repoRoot, 'native/runtime-host-peer/build.mjs')], { - cwd: repoRoot, - stdio: 'inherit', - }); - const target = `${process.platform}-${process.arch}`; - if (!peerPrebuildTargets.includes(target)) { - throw new Error(`Direct peer is not supported on ${target}`); - } + const [target] = targets; + buildDevelopmentPeerAddon(target); sourceRoot = generatedRoot = mkdtempSync(join(tmpdir(), 'maka-runtime-host-peer-prebuilds-')); const targetRoot = join(sourceRoot, target); mkdirSync(targetRoot, { recursive: true, mode: 0o755 }); @@ -559,7 +561,6 @@ function copyRuntimeHostPeerPrebuilds(publishable) { join(repoRoot, 'native/runtime-host-peer/target/release/maka_runtime_host_peer.node'), join(targetRoot, 'maka_runtime_host_peer.node'), ); - targets = [target]; } if (!sourceRoot) { throw new Error('MAKA_RUNTIME_HOST_PEER_PREBUILDS must contain all release platform addons'); @@ -580,6 +581,60 @@ function copyRuntimeHostPeerPrebuilds(publishable) { } } +function resolveDevelopmentPeerTarget() { + const configured = process.env.MAKA_CLI_DEVELOPMENT_PEER_TARGET?.trim(); + const target = configured || `${process.platform}-${process.arch}`; + if (target !== 'none' && !peerPrebuildTargets.includes(target)) { + throw new Error( + `MAKA_CLI_DEVELOPMENT_PEER_TARGET must be none or a supported target; found ${target}`, + ); + } + return target; +} + +function buildDevelopmentPeerAddon(target) { + const hostTarget = `${process.platform}-${process.arch}`; + const buildScript = join(repoRoot, 'native/runtime-host-peer/build.mjs'); + if (target === hostTarget) { + execFileSync(process.execPath, [buildScript], { cwd: repoRoot, stdio: 'inherit' }); + return; + } + const rustTarget = { + 'linux-arm64': 'aarch64-unknown-linux-gnu.2.28', + 'linux-x64': 'x86_64-unknown-linux-gnu.2.28', + }[target]; + if (!rustTarget) { + throw new Error( + `Cannot build the ${target} direct-peer addon from ${hostTarget}; run Desktop on that target or provide MAKA_RUNTIME_HOST_PEER_PREBUILDS`, + ); + } + requireDevelopmentCommand( + 'zig', + ['version'], + `Cross-compiling the ${target} direct-peer addon requires Zig on PATH (CI uses 0.16.x)`, + ); + requireDevelopmentCommand( + 'cargo-zigbuild', + ['--version'], + `Cross-compiling the ${target} direct-peer addon requires cargo-zigbuild (cargo install cargo-zigbuild --version 0.23.2 --locked)`, + ); + execFileSync(process.execPath, [buildScript], { + cwd: repoRoot, + env: { + ...process.env, + MAKA_RUNTIME_HOST_PEER_CARGO_SUBCOMMAND: 'zigbuild', + MAKA_RUNTIME_HOST_PEER_CARGO_TARGET: rustTarget, + }, + stdio: 'inherit', + }); +} + +function requireDevelopmentCommand(command, args, message) { + const result = spawnSync(command, args, { cwd: repoRoot, encoding: 'utf8' }); + if (result.status === 0) return; + throw new Error(`${message}; install it before setting up this development Runtime Host`); +} + function writeReleaseManifest(cli, publishable) { const source = readJson(join(cliSource, 'package.json')); const root = readJson(join(repoRoot, 'package.json')); @@ -690,9 +745,9 @@ function validateStaging(publishable) { (target) => `native/runtime-host-peer/prebuilds/${target}/maka_runtime_host_peer.node`, ), ); - } else { + } else if (privatePeerTarget !== 'none') { required.push( - `native/runtime-host-peer/prebuilds/${process.platform}-${process.arch}/maka_runtime_host_peer.node`, + `native/runtime-host-peer/prebuilds/${privatePeerTarget}/maka_runtime_host_peer.node`, ); } for (const path of required) { @@ -745,7 +800,7 @@ function validateStaging(publishable) { } } -function validatePackedFiles(files, expectedDependencyManifests) { +function validatePackedFiles(files, expectedDependencyManifests, publishable) { const paths = files.map((file) => file.path); for (const file of files) { const { path } = file; @@ -780,7 +835,7 @@ function validatePackedFiles(files, expectedDependencyManifests) { 'node_modules/@maka/runtime/dist/workers/filesystem-worker.js', 'node_modules/@maka/runtime-host/dist/execution-candidate-main.js', 'packages/eval/harbor/relay_agent.py', - 'native/runtime-host-peer/prebuilds/', + ...(publishable || privatePeerTarget !== 'none' ? ['native/runtime-host-peer/prebuilds/'] : []), ]; for (const suffix of requiredPacked) { if (