diff --git a/apps/desktop/src/main/runtime-host-wsl-controller.ts b/apps/desktop/src/main/runtime-host-wsl-controller.ts index a7b92d44dd..3afebc18ac 100644 --- a/apps/desktop/src/main/runtime-host-wsl-controller.ts +++ b/apps/desktop/src/main/runtime-host-wsl-controller.ts @@ -186,6 +186,7 @@ function runtimeHostWslSetupCommand( '--lifecycle', 'on-demand', '--repair-root-after-remount', + '--update-existing', ...(input.projectDirectoryRoots === undefined ? [] : input.projectDirectoryRoots.length === 0 diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index 3375afeebb..2db5fee813 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -456,6 +456,7 @@ describe('managed Runtime Host service', () => { '--client-data-root', '/var/lib/maka-client', '--defer-pairing-commit', + '--update-existing', '--enable-direct-peer', '--coordination-relay', '/dns4/discovery.example/udp/443/quic-v1', @@ -469,6 +470,7 @@ describe('managed Runtime Host service', () => { clientDataRoot: '/var/lib/maka-client', lifecycle: 'supervised', deferPairingCommit: true, + updateExisting: true, directPeer: { coordinationRelays: ['/dns4/discovery.example/udp/443/quic-v1'], }, diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index f47281703a..e3cac48625 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -242,6 +242,55 @@ test('on-demand setup installs one exact deployment without a service backend', 'unsupported_lifecycle_configuration', ); + const replacementIntegrity = `sha512-${Buffer.alloc(64, 9).toString('base64')}`; + const replacementOptions = { ...options, version: '1.2.4' } as const; + const replacementPackage = { + ...overrides, + resolveRegistryCandidate: async () => ({ + kind: 'npm_registry' as const, + version: '1.2.4', + integrity: replacementIntegrity, + }), + }; + const refused: string[] = []; + assert.equal( + await runRuntimeHostSetupCli(replacementOptions, { + ...replacementPackage, + writeOutput: (value) => refused.push(value), + }), + 1, + ); + const refusedReplacement = refused + .map(decodeRuntimeHostSetupFrame) + .find((frame) => frame?.kind === 'error'); + assert.equal( + refusedReplacement?.kind === 'error' ? refusedReplacement.error.code : undefined, + 'version_change_requires_update', + ); + + const replacementOutputs: string[] = []; + assert.equal( + await runRuntimeHostSetupCli( + { ...replacementOptions, updateExisting: true }, + { + ...replacementPackage, + openDeployment: async () => + assert.fail('a changed exact package must be staged before replacement'), + writeOutput: (value) => replacementOutputs.push(value), + }, + ), + 0, + ); + const replaced = JSON.parse( + await readFile(resolveRuntimeHostManagedDeploymentConfigPath(rootId), 'utf8'), + ) as RuntimeHostManagedDeploymentConfig; + assert.equal(replaced.launch.package.version, '1.2.4'); + assert.equal(replaced.launch.package.integrity, replacementIntegrity); + assert.equal( + replacementOutputs.map(decodeRuntimeHostSetupFrame).some((frame) => frame?.kind === 'complete'), + true, + ); + const uninstalled = await manageRuntimeHostManagedLifecycle( rootId, { diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 8ee356dd78..a54e1c0f2a 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -410,6 +410,7 @@ export async function runMakaCli( deferPairingCommit: command.deferPairingCommit, bindPairingToClient: command.bindPairingToClient, ...(command.repairRootAfterRemount ? { repairRootAfterRemount: true } : {}), + updateExisting: command.updateExisting, ...(command.rootPath ? { rootPath: command.rootPath } : {}), ...(command.projectDirectoryRoots ? { projectDirectoryRoots: command.projectDirectoryRoots } diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index cf2f884e1e..33e763be33 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -118,6 +118,7 @@ export type RuntimeHostCliCommand = deferPairingCommit: boolean; bindPairingToClient?: true; repairRootAfterRemount?: true; + updateExisting?: true; clientDataRoot?: string; rootPath?: string; projectDirectoryRoots?: { label: string; path: string }[]; @@ -555,6 +556,7 @@ function parseSetupCommand(argv: string[]): RuntimeHostCliCommand { let deferPairingCommit = false; let bindPairingToClient = false; let repairRootAfterRemount = false; + let updateExisting = false; let clientDataRoot: string | undefined; let enableDirectPeer = false; const coordinationRelays: string[] = []; @@ -603,6 +605,10 @@ function parseSetupCommand(argv: string[]): RuntimeHostCliCommand { if (repairRootAfterRemount) return error('Duplicate --repair-root-after-remount'); repairRootAfterRemount = true; }, + '--update-existing': () => { + if (updateExisting) return error('Duplicate --update-existing'); + updateExisting = true; + }, }, }); if ('kind' in options) return options; @@ -625,6 +631,7 @@ function parseSetupCommand(argv: string[]): RuntimeHostCliCommand { deferPairingCommit, ...(bindPairingToClient ? { bindPairingToClient: true } : {}), ...(repairRootAfterRemount ? { repairRootAfterRemount: true } : {}), + ...(updateExisting ? { updateExisting: true } : {}), ...(clientDataRoot ? { clientDataRoot } : {}), ...(enableDirectPeer ? { directPeer: { coordinationRelays } } : {}), }; diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index d578e13707..55c912172c 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -132,6 +132,7 @@ export interface RuntimeHostSetupCliOptions { readonly deferPairingCommit?: boolean; readonly bindPairingToClient?: boolean; readonly repairRootAfterRemount?: true; + readonly updateExisting?: boolean; readonly rootPath?: string; readonly projectDirectoryRoots?: readonly { readonly label: string; @@ -743,7 +744,8 @@ async function runRuntimeHostOnDemandSetupLocked( const serviceId = capability.rootId; const deploymentRoot = current?.deploymentRoot ?? resolveRuntimeHostManagedDeploymentRoot(serviceId); - if (current && !sameExactPackage(current, candidate)) { + const packageChanged = current !== undefined && !sameExactPackage(current, candidate); + if (current && packageChanged && !options.updateExisting) { throw new RuntimeHostSetupError( 'version_change_requires_update', `Runtime Host ${current.launch.package.version} is already installed; changing its exact package requires the update workflow`, @@ -801,27 +803,30 @@ async function runRuntimeHostOnDemandSetupLocked( await resolvedPackage.use(async (packageRoot) => { let committed = false; const created = !current; + let deployment: Awaited> | undefined; try { emit({ kind: 'progress', phase: 'installing_package' }); - const deployment = current - ? await deps.openDeployment({ - serviceId, - clientDataRoot: options.clientDataRoot, - deploymentRoot, - cliPath: resolveRuntimeHostManagedPackageCliPath( + deployment = + current && !packageChanged + ? await deps.openDeployment({ + serviceId, + clientDataRoot: options.clientDataRoot, deploymentRoot, - candidate.version, - candidate.integrity, - ), - version: candidate.version, - }) - : await deps.prepareDeployment({ - serviceId, - clientDataRoot: options.clientDataRoot, - sourcePackageRoot: packageRoot, - version: candidate.version, - packageIntegrity: candidate.integrity, - }); + cliPath: resolveRuntimeHostManagedPackageCliPath( + deploymentRoot, + candidate.version, + candidate.integrity, + ), + version: candidate.version, + }) + : await deps.prepareDeployment({ + serviceId, + clientDataRoot: options.clientDataRoot, + sourcePackageRoot: packageRoot, + version: candidate.version, + packageIntegrity: candidate.integrity, + ...(current ? { deploymentRoot } : {}), + }); const desiredConfig: RuntimeHostManagedDeploymentConfig = current ? config : { ...config, deploymentRoot: deployment.root }; @@ -839,11 +844,13 @@ async function runRuntimeHostOnDemandSetupLocked( const replacement = await deps.replaceLifecycle({ operation: legacyToMigrate ? 'legacy_migration' - : current - ? isDeepStrictEqual(current.lifecycle, config.lifecycle) - ? 'configure' - : 'lifecycle_change' - : 'install', + : packageChanged + ? 'update' + : current + ? isDeepStrictEqual(current.lifecycle, config.lifecycle) + ? 'configure' + : 'lifecycle_change' + : 'install', ...(current ? { current } : {}), desired: desiredConfig, ...(legacyToMigrate && legacyBackend ? { retirementSupervisor: legacyBackend } : {}), @@ -867,8 +874,13 @@ async function runRuntimeHostOnDemandSetupLocked( (await resolveRuntimeHostManagedDeployment(capability.rootId)).config, ); } catch (error) { - if (created && !committed && canDiscardRuntimeHostLifecycleDesiredArtifacts(error)) { - await removeRuntimeHostManagedDeployment(deploymentRoot, serviceId).catch(() => undefined); + if (!committed && canDiscardRuntimeHostLifecycleDesiredArtifacts(error)) { + if (packageChanged && deployment) await deployment.rollback().catch(() => undefined); + else if (created) { + await removeRuntimeHostManagedDeployment(deploymentRoot, serviceId).catch( + () => undefined, + ); + } } throw error; }