You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/29 14:50:37 UTC
(maka) 01/01: fix(desktop): replace conflicting local runtime host
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch fix/runtime-host-conflict-stop
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 679e52c923dc0e7c3bf8ae14d81355b0fbaab2bc
Author: Wang <m4...@gmail.com>
AuthorDate: Sat Aug 29 22:49:22 2026 +0800
fix(desktop): replace conflicting local runtime host
Generated-by: OpenAI Codex
---
.../__tests__/runtime-host-desktop-manager.test.ts | 43 +++++-
.../__tests__/runtime-host-local-operator.test.ts | 1 -
.../runtime-host-local-remote-access.test.ts | 122 ++++++++++++++-
.../__tests__/runtime-host-ssh-terminal.test.ts | 5 +-
.../__tests__/runtime-host-upgrade-dialog.test.ts | 62 +++++++-
apps/desktop/src/main/runtime-host-boot.ts | 2 +
.../src/main/runtime-host-desktop-manager.ts | 35 ++++-
.../src/main/runtime-host-local-operator.ts | 5 +-
.../src/main/runtime-host-local-remote-access.ts | 172 ++++++++++++++++++++-
apps/desktop/src/main/runtime-host-ssh-terminal.ts | 5 +-
apps/desktop/src/main/runtime-host-upgrade-copy.ts | 48 ++++--
.../src/main/runtime-host-upgrade-dialog.ts | 20 ++-
packages/cli/src/cli-core.ts | 5 +
.../cli/src/runtime-host-lifecycle-transaction.ts | 55 +++++--
packages/cli/src/runtime-host-update-command.ts | 17 +-
15 files changed, 530 insertions(+), 67 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
index cbc687b668..c7fbce5547 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
@@ -1056,7 +1056,7 @@ test('restarts an idle generation-aware Host without prompting', async () => {
},
upgradePrompts: {
restartable: async () => assert.fail('idle Host must not prompt before restart'),
- waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'),
+ nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'),
},
});
@@ -1077,7 +1077,7 @@ test('prompts before restarting a generation-aware Host with active work', async
prompts += 1;
return 'restart';
},
- waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'),
+ nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'),
},
});
@@ -1097,7 +1097,7 @@ test('prompts before restarting a generation-aware Host with a residency', async
prompts += 1;
return 'restart';
},
- waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'),
+ nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'),
},
});
@@ -1117,7 +1117,7 @@ test('prompts before restarting a generation-aware Host with connections', async
prompts += 1;
return 'restart';
},
- waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'),
+ nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'),
},
});
@@ -1137,7 +1137,7 @@ test('prompts when a restartable Host has no activity snapshot', async () => {
prompts += 1;
return 'restart';
},
- waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'),
+ nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'),
},
});
@@ -1160,7 +1160,7 @@ test('waits passively for a Host that cannot be taken over', async () => {
},
upgradePrompts: {
restartable: async () => assert.fail('wait-only conflict used restart prompt'),
- waitOnly: async () => 'wait',
+ nonRestartable: async () => 'wait',
},
waitForHostRetirement: async (registration) => {
assert.equal(registration.hostEpoch, conflict.registration.hostEpoch);
@@ -1175,6 +1175,35 @@ test('waits passively for a Host that cannot be taken over', async () => {
await owner.close();
});
+test('replaces a non-restartable Local Host through the supplied authority and retries', async () => {
+ const conflict = upgradeRequired(false);
+ const replacement = candidateHarness();
+ let starts = 0;
+ let replaced: typeof conflict.registration | undefined;
+ const owner = await startRuntimeHostDesktopManager(
+ {} as DesktopRuntimeHostCandidateStartInput,
+ {
+ startCandidate: async () => {
+ starts += 1;
+ return starts === 1 ? conflict : ready(replacement.candidate);
+ },
+ upgradePrompts: {
+ restartable: async () => assert.fail('non-restartable conflict used restart prompt'),
+ nonRestartable: async (_conflict, canReplace) => {
+ assert.equal(canReplace, true);
+ return 'replace';
+ },
+ },
+ replaceLocalHost: async (registration) => {
+ replaced = registration;
+ },
+ },
+ );
+ assert.equal(starts, 2);
+ assert.equal(replaced?.hostEpoch, conflict.registration.hostEpoch);
+ await owner.close();
+});
+
test('lets the user cancel startup when an incompatible Host owns the root', async () => {
const conflict = incompatibleHost('blocked_by_residency');
let presented: DesktopRuntimeHostCandidateStartResult | undefined;
@@ -1183,7 +1212,7 @@ test('lets the user cancel startup when an incompatible Host owns the root', asy
startCandidate: async () => conflict,
upgradePrompts: {
restartable: async () => assert.fail('incompatible Host used restart prompt'),
- waitOnly: async (actual) => {
+ nonRestartable: async (actual) => {
presented = actual;
return 'cancel';
},
diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts
index b4155e9947..b8ef7de8b8 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts
@@ -192,7 +192,6 @@ test('local update runs the selected package against the exact managed deploymen
'exec', '--yes', '--package', 'maka-agent@0.3.0', '--',
'maka', 'runtime-host', 'service', 'update', '--framed',
'--managed-root-id', 'a'.repeat(64),
- '--operator-deployment-id', deploymentId,
'--expected-service-id', 'a'.repeat(64),
'--expected-root-path', '/tmp/maka/root',
'--expected-root-id', 'a'.repeat(64),
diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts
index 40583de49e..b6c3fbc3a2 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts
@@ -23,10 +23,19 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { decodeRuntimeHostOwnerConnectionCode } from '@maka/runtime-host/client';
+import {
+ INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+ RUNTIME_HOST_COMPATIBILITY_EPOCH,
+ RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
+ type HostRegistration,
+} from '@maka/runtime-host/protocol';
import type { RuntimeHostDesktopManager } from '../runtime-host-desktop-manager.js';
const RECOVERY_DEPLOYMENT_ID = '33333333-3333-4333-8333-333333333333';
-import { createDesktopLocalRuntimeHostRemoteAccess } from '../runtime-host-local-remote-access.js';
+import {
+ createDesktopLocalRuntimeHostRemoteAccess,
+ stopConflictingEphemeralRuntimeHost,
+} from '../runtime-host-local-remote-access.js';
import type { createDesktopRuntimeHostLocalOperator } from '../runtime-host-local-operator.js';
test('enabling remote access hands the same root to one managed service before Desktop resumes', async (t) => {
@@ -228,6 +237,96 @@ test('keeps the managed service visible when Direct peer support is unavailable'
assert.equal(snapshot.managedService, true);
});
+test('replaces a conflicting supervised Host through its exact service authority', async (t) => {
+ const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-conflict-'));
+ t.after(() => rm(base, { recursive: true, force: true }));
+ const clientDataRoot = join(base, 'client');
+ const rootPath = join(clientDataRoot, 'workspaces', 'default');
+ const rootId = 'a'.repeat(64);
+ await mkdir(rootPath, { recursive: true });
+ await writeManagedLifecycle(clientDataRoot, rootPath, rootId);
+ let updated = false;
+ const service = createDesktopLocalRuntimeHostRemoteAccess({
+ ipcMain: { handle() {}, removeHandler() {} },
+ clientDataRoot,
+ rootPath,
+ rootId,
+ directPeerAvailable: true,
+ manager: () => undefined,
+ resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }),
+ operator: {
+ async runUpdate(input: {
+ readonly target: { readonly rootId: string; readonly deploymentId?: string };
+ readonly allowInterruptActiveTasks?: boolean;
+ }) {
+ assert.equal(input.target.rootId, rootId);
+ assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID);
+ assert.equal(input.allowInterruptActiveTasks, true);
+ updated = true;
+ return {
+ kind: 'result' as const,
+ action: 'update' as const,
+ update: { kind: 'updated', previousVersion: '0.2.0', targetVersion: '0.2.0' },
+ } as never;
+ },
+ async close() {},
+ } as unknown as ReturnType<typeof createDesktopRuntimeHostLocalOperator>,
+ });
+ t.after(() => service.close());
+
+ await service.replaceConflictingHost(
+ hostRegistration({ rootId, lifecycleMode: 'service' }),
+ new AbortController().signal,
+ );
+ assert.equal(updated, true);
+});
+
+test('stops only the revalidated ephemeral Host identity', async () => {
+ const registration = hostRegistration({ lifecycleMode: 'ephemeral' });
+ let alive = true;
+ const signaled: number[] = [];
+ await stopConflictingEphemeralRuntimeHost(
+ {
+ rootPath: '/workspace',
+ registration,
+ signal: new AbortController().signal,
+ },
+ {
+ connectExisting: async () => ({
+ kind: 'upgrade_required',
+ registration,
+ restartable: false,
+ }) as never,
+ signalProcess(pid) {
+ signaled.push(pid);
+ alive = false;
+ },
+ isProcessAlive: () => alive,
+ },
+ );
+ assert.deepEqual(signaled, [registration.pid]);
+
+ await assert.rejects(
+ stopConflictingEphemeralRuntimeHost(
+ {
+ rootPath: '/workspace',
+ registration,
+ signal: new AbortController().signal,
+ },
+ {
+ connectExisting: async () => ({
+ kind: 'upgrade_required',
+ registration: { ...registration, hostEpoch: 'replacement' },
+ restartable: false,
+ }) as never,
+ signalProcess: () => assert.fail('a changed Host identity must not be signaled'),
+ isProcessAlive: () => true,
+ },
+ ),
+ /could not verify that the process still owns this workspace/u,
+ );
+});
+
test('does not persist recoverable setup authority before Desktop ownership commits', async (t) => {
const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-ownership-'));
t.after(() => rm(base, { recursive: true, force: true }));
@@ -738,6 +837,27 @@ async function writeManagedLifecycle(
);
}
+function hostRegistration(
+ overrides: Partial<Pick<HostRegistration, 'rootId' | 'lifecycleMode'>> = {},
+): HostRegistration {
+ return {
+ kind: 'maka-runtime-host',
+ schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
+ rootId: 'a'.repeat(64),
+ hostEpoch: 'older-host',
+ endpoint: '/tmp/runtime-host.sock',
+ protocolMin: 0,
+ protocolMax: 0,
+ compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
+ compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+ compositionRevision: 'legacy',
+ state: 'ready',
+ pid: 42,
+ createdAt: '2026-08-29T00:00:00.000Z',
+ ...overrides,
+ };
+}
+
function sharedCredential(credentialId: string, status: 'active' | 'pending') {
return {
credentialId,
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 a7b183a614..30696f5461 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
@@ -462,10 +462,7 @@ test('runs an exact update package and reports progress before an active-work re
assert.match(remoteCommand, /--package.*maka-agent@1\.3\.0/u);
assert.match(remoteCommand, /runtime-host.*service.*update/u);
assert.match(remoteCommand, /--managed-root-id.*a{64}/u);
- assert.match(
- remoteCommand,
- /--operator-deployment-id.*00000000-0000-4000-8000-000000000001/u,
- );
+ assert.doesNotMatch(remoteCommand, /--operator-deployment-id/u);
assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u);
harness.pty.emitData('Password: ');
harness.pty.emitData(
diff --git a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts
index 3f515e2c9b..d7e6bb8037 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts
@@ -20,6 +20,7 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildRuntimeHostUpgradeDialogOptions } from '../runtime-host-upgrade-copy.js';
+import { createRuntimeHostUpgradePrompts } from '../runtime-host-upgrade-dialog.js';
const conflict = {
kind: 'upgrade_required',
@@ -39,8 +40,8 @@ const conflict = {
} as never;
test('localizes upgrade activity without changing decision indexes', () => {
- const en = buildRuntimeHostUpgradeDialogOptions(conflict, true, 'en');
- const zh = buildRuntimeHostUpgradeDialogOptions(conflict, true, 'zh');
+ const en = buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', 'en');
+ const zh = buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', 'zh');
assert.deepEqual(en.buttons, ['Restart Runtime Host', 'Wait', 'Cancel Startup']);
assert.deepEqual(zh.buttons, ['重启 Runtime Host', '等待', '取消启动']);
assert.equal(en.defaultId, 1);
@@ -49,4 +50,61 @@ test('localizes upgrade activity without changing decision indexes', () => {
assert.match(zh.detail ?? '', /每日回顾: 1/);
assert.match(en.detail ?? '', /Scheduled Task: 2/);
assert.match(zh.detail ?? '', /计划任务: 2/);
+ assert.match(en.detail ?? '', /Process ID \(PID\):/);
+});
+
+test('offers a non-default replacement action for a non-restartable Local Host', () => {
+ const options = buildRuntimeHostUpgradeDialogOptions(
+ {
+ kind: 'upgrade_required',
+ restartable: false,
+ registration: { pid: 42 },
+ } as never,
+ 'replace',
+ 'en',
+ );
+ assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Wait', 'Cancel Startup']);
+ assert.equal(options.defaultId, 1);
+ assert.equal(options.cancelId, 2);
+ assert.match(options.detail ?? '', /Maka will stop this Host/);
+});
+
+test('maps the native replacement button to the replace decision', async () => {
+ const prompts = createRuntimeHostUpgradePrompts(
+ async () => 'en',
+ async (options) => {
+ assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Wait', 'Cancel Startup']);
+ return { response: 0, checkboxChecked: false };
+ },
+ );
+ assert.equal(
+ await prompts.nonRestartable(
+ {
+ kind: 'upgrade_required',
+ restartable: false,
+ registration: { pid: 42 },
+ } as never,
+ true,
+ ),
+ 'replace',
+ );
+});
+
+test('does not offer passive waiting for a supervised Host', async () => {
+ const conflict = {
+ kind: 'upgrade_required',
+ restartable: false,
+ registration: { pid: 42, lifecycleMode: 'service' },
+ } as never;
+ const prompts = createRuntimeHostUpgradePrompts(
+ async () => 'en',
+ async (options) => {
+ assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Cancel Startup']);
+ assert.equal(options.defaultId, 1);
+ assert.equal(options.cancelId, 1);
+ assert.doesNotMatch(options.detail ?? '', /If you wait/u);
+ return { response: 1, checkboxChecked: false };
+ },
+ );
+ assert.equal(await prompts.nonRestartable(conflict, true), 'cancel');
});
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 4dd1639eab..9c9cf0a7b3 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -1013,6 +1013,8 @@ runtimeHostManager = await startRuntimeHostDesktopManager(
});
},
recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(signal),
+ replaceLocalHost: (registration, signal) =>
+ localRuntimeHostRemoteAccess.replaceConflictingHost(registration, signal),
onFatalError: (error, target) => {
if (error instanceof RuntimeHostUpgradeCancelledError) {
if (target.profile.kind === "local") app.quit();
diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts
index 474a0d872c..7c38d06c06 100644
--- a/apps/desktop/src/main/runtime-host-desktop-manager.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts
@@ -136,7 +136,7 @@ export class DesktopLocalHostRetirementError extends Error {
}
export type RuntimeHostRestartDecision = 'restart' | 'wait' | 'cancel';
-export type RuntimeHostWaitDecision = 'wait' | 'cancel';
+export type RuntimeHostNonRestartableDecision = 'replace' | 'wait' | 'cancel';
export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconnectError {
constructor() {
@@ -170,7 +170,10 @@ export interface RuntimeHostUpgradePrompts {
restartable(
conflict: RuntimeHostRestartableConflict,
): Promise<RuntimeHostRestartDecision>;
- waitOnly(conflict: RuntimeHostWaitConflict): Promise<RuntimeHostWaitDecision>;
+ nonRestartable(
+ conflict: RuntimeHostWaitConflict,
+ canReplace: boolean,
+ ): Promise<RuntimeHostNonRestartableDecision>;
}
interface DesktopRuntimeHostTargetGeneration {
@@ -210,6 +213,10 @@ export async function startRuntimeHostDesktopManager(
registration: HostRegistration,
signal: AbortSignal,
) => Promise<void>;
+ replaceLocalHost?: (
+ registration: HostRegistration,
+ signal: AbortSignal,
+ ) => Promise<void>;
recoverLocalHost?: (signal: AbortSignal) => Promise<boolean>;
reconnectBackoff?: RuntimeHostReconnectBackoff;
pairingFinalizationTimeoutMs?: number;
@@ -226,6 +233,7 @@ export async function startRuntimeHostDesktopManager(
options.upgradePrompts,
options.waitForHostExit ?? waitForProcessExit,
options.waitForHostRetirement ?? waitForProcessRetirement,
+ options.replaceLocalHost,
options.recoverLocalHost,
options.reconnectBackoff,
options.pairingFinalizationTimeoutMs ?? DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS,
@@ -266,6 +274,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
registration: HostRegistration,
signal: AbortSignal,
) => Promise<void>,
+ private readonly replaceLocalHost:
+ | ((registration: HostRegistration, signal: AbortSignal) => Promise<void>)
+ | undefined,
private readonly recoverLocalHost:
| ((signal: AbortSignal) => Promise<boolean>)
| undefined,
@@ -853,8 +864,19 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
result.kind === 'incompatible' ||
(result.kind === 'upgrade_required' && !result.restartable)
) {
- const decision = await this.#resolveWaitOnly(result);
+ const canReplace = !target.input.profileTarget && this.replaceLocalHost !== undefined;
+ const decision = await this.#resolveNonRestartable(result, canReplace);
if (decision === 'cancel') throw new RuntimeHostUpgradeCancelledError();
+ if (decision === 'replace') {
+ if (!canReplace || !this.replaceLocalHost) {
+ throw new RuntimeHostPermanentReconnectError(
+ 'This Runtime Host cannot be replaced from the current target',
+ );
+ }
+ await this.replaceLocalHost(result.registration, signal);
+ takeoverHostEpoch = undefined;
+ continue;
+ }
takeoverHostEpoch = undefined;
await this.waitForHostRetirement(result.registration, signal);
continue;
@@ -871,8 +893,11 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
return this.#missingUpgradePrompt();
}
- #resolveWaitOnly(conflict: RuntimeHostWaitConflict): Promise<RuntimeHostWaitDecision> {
- if (this.upgradePrompts) return this.upgradePrompts.waitOnly(conflict);
+ #resolveNonRestartable(
+ conflict: RuntimeHostWaitConflict,
+ canReplace: boolean,
+ ): Promise<RuntimeHostNonRestartableDecision> {
+ if (this.upgradePrompts) return this.upgradePrompts.nonRestartable(conflict, canReplace);
return this.#missingUpgradePrompt();
}
diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts
index e227c2dfe5..e8c6e1f582 100644
--- a/apps/desktop/src/main/runtime-host-local-operator.ts
+++ b/apps/desktop/src/main/runtime-host-local-operator.ts
@@ -346,8 +346,7 @@ export function createDesktopRuntimeHostLocalOperator(input: {
},
runUpdate(command, onProgress) {
if (closed) throw new Error('Local Runtime Host operator is closed');
- const deploymentId = command.target.deploymentId;
- if (!deploymentId) {
+ if (!command.target.deploymentId) {
return Promise.reject(new Error('Runtime Host update requires a deployment generation'));
}
const setupPackage = resolveLocalSetupPackage(command.setupPackage);
@@ -367,8 +366,6 @@ export function createDesktopRuntimeHostLocalOperator(input: {
'--framed',
'--managed-root-id',
command.target.rootId,
- '--operator-deployment-id',
- deploymentId,
...managedTargetArgs(command.target),
...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
],
diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts
index 275aacde91..d763e5b001 100644
--- a/apps/desktop/src/main/runtime-host-local-remote-access.ts
+++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts
@@ -23,11 +23,17 @@ import { hostname } from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';
import type { IpcMain } from 'electron';
import {
+ connectExistingRuntimeHost,
consumeAccessCredentialDelivery,
encodeRuntimeHostOwnerConnectionCode,
} from '@maka/runtime-host/client';
import { resolveRuntimeHostManagedDeploymentAuthority } from '@maka/runtime-host/operator';
-import { REMOTE_OWNER_OPERATION_GRANTS } from '@maka/runtime-host/protocol';
+import {
+ INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+ REMOTE_OWNER_OPERATION_GRANTS,
+ RUNTIME_HOST_PROTOCOL_VERSION,
+ type HostRegistration,
+} from '@maka/runtime-host/protocol';
import type {
DesktopLocalRuntimeHostRemoteAccessEnableResult,
DesktopLocalRuntimeHostRemoteAccessSnapshot,
@@ -100,6 +106,7 @@ export interface DesktopLocalRuntimeHostRemoteAccess {
changeManaged<T>(
operation: (target: DesktopRuntimeHostLocalManagementTarget) => Promise<T>,
): Promise<T>;
+ replaceConflictingHost(registration: HostRegistration, signal: AbortSignal): Promise<void>;
recoverBeforeLocalHostStart(signal?: AbortSignal): Promise<boolean>;
recover(): Promise<void>;
close(): Promise<void>;
@@ -152,6 +159,10 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
readonly resolveManagedDeploymentAuthority?: (
rootId: string,
) => Promise<LocalManagedDeploymentAuthority | undefined>;
+ readonly replaceEphemeralHost?: (
+ registration: HostRegistration,
+ signal: AbortSignal,
+ ) => Promise<void>;
}): DesktopLocalRuntimeHostRemoteAccess {
const lifecyclePath = join(input.clientDataRoot, LIFECYCLE_FILE);
const closing = new AbortController();
@@ -663,6 +674,50 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
const managed = requireManagementTarget(lifecycle);
return requireManager(input.manager).runManagedLocalHostChange(() => operation(managed));
}),
+ replaceConflictingHost: (registration, signal) =>
+ serialize(async () => {
+ signal.throwIfAborted();
+ if (registration.rootId !== input.rootId) {
+ throw conflictReplacementError(registration.pid, 'the workspace identity changed');
+ }
+ if (registration.lifecycleMode !== 'service') {
+ await (input.replaceEphemeralHost ?? ((expected, operationSignal) =>
+ stopConflictingEphemeralRuntimeHost({
+ rootPath: input.rootPath,
+ registration: expected,
+ signal: operationSignal,
+ })))(registration, signal);
+ return;
+ }
+ const managed = requireManagementTarget(
+ await readLifecycle(lifecyclePath, input.rootPath, input.rootId),
+ );
+ const setupPackage = await input.resolveSetupPackage(signal);
+ const frame = await input.operator.runUpdate(
+ {
+ setupPackage,
+ target: managed,
+ allowInterruptActiveTasks: true,
+ signal,
+ },
+ () => undefined,
+ );
+ if (frame.kind === 'error') {
+ throw conflictReplacementError(registration.pid, frame.error.message);
+ }
+ if (frame.kind === 'progress' || frame.action !== 'update') {
+ throw conflictReplacementError(
+ registration.pid,
+ 'the managed service returned an unrelated result',
+ );
+ }
+ if (frame.update.kind === 'active_tasks') {
+ throw conflictReplacementError(
+ registration.pid,
+ 'the managed service refused to interrupt active work',
+ );
+ }
+ }),
recoverBeforeLocalHostStart: async (signal) => {
const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal;
operationSignal.throwIfAborted();
@@ -735,6 +790,121 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
};
}
+export async function stopConflictingEphemeralRuntimeHost(
+ input: {
+ readonly rootPath: string;
+ readonly registration: HostRegistration;
+ readonly signal: AbortSignal;
+ },
+ dependencies: {
+ readonly connectExisting?: typeof connectExistingRuntimeHost;
+ readonly signalProcess?: (pid: number) => void;
+ readonly isProcessAlive?: (pid: number) => boolean;
+ readonly wait?: (ms: number, signal: AbortSignal) => Promise<void>;
+ } = {},
+): Promise<void> {
+ if (input.registration.lifecycleMode === 'service') {
+ throw conflictReplacementError(
+ input.registration.pid,
+ 'a system-supervised Host must be replaced through its service operator',
+ );
+ }
+ input.signal.throwIfAborted();
+ const current = await (dependencies.connectExisting ?? connectExistingRuntimeHost)({
+ rootPath: input.rootPath,
+ protocol: {
+ min: RUNTIME_HOST_PROTOCOL_VERSION,
+ max: RUNTIME_HOST_PROTOCOL_VERSION,
+ },
+ compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+ });
+ if (current.kind === 'connected') {
+ await current.connection.close();
+ return;
+ }
+ const currentRegistration = 'registration' in current ? current.registration : undefined;
+ const isAlive = dependencies.isProcessAlive ?? processIsAlive;
+ if (!sameHostRegistration(currentRegistration, input.registration)) {
+ if (!isAlive(input.registration.pid)) return;
+ throw conflictReplacementError(
+ input.registration.pid,
+ 'Maka could not verify that the process still owns this workspace',
+ );
+ }
+ if (current.kind === 'unavailable') {
+ throw conflictReplacementError(
+ input.registration.pid,
+ 'Maka could not verify the Runtime Host endpoint',
+ );
+ }
+ input.signal.throwIfAborted();
+ try {
+ (dependencies.signalProcess ?? signalRuntimeHostProcess)(input.registration.pid);
+ } catch (error) {
+ if (!isMissingProcessError(error)) throw error;
+ return;
+ }
+ const wait = dependencies.wait ?? waitForAbortableDelay;
+ const deadline = Date.now() + 10_000;
+ while (isAlive(input.registration.pid)) {
+ if (Date.now() >= deadline) {
+ throw conflictReplacementError(
+ input.registration.pid,
+ 'the process did not exit after Maka requested a graceful stop',
+ );
+ }
+ await wait(100, input.signal);
+ }
+}
+
+function sameHostRegistration(
+ current: HostRegistration | undefined,
+ expected: HostRegistration,
+): boolean {
+ return current?.rootId === expected.rootId &&
+ current.hostEpoch === expected.hostEpoch &&
+ current.pid === expected.pid &&
+ current.endpoint === expected.endpoint;
+}
+
+function signalRuntimeHostProcess(pid: number): void {
+ process.kill(pid, 'SIGTERM');
+}
+
+function processIsAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ return !isMissingProcessError(error);
+ }
+}
+
+function isMissingProcessError(error: unknown): boolean {
+ return error instanceof Error &&
+ 'code' in error &&
+ (error as NodeJS.ErrnoException).code === 'ESRCH';
+}
+
+function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise<void> {
+ signal.throwIfAborted();
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ signal.removeEventListener('abort', onAbort);
+ resolve();
+ }, ms);
+ const onAbort = () => {
+ clearTimeout(timer);
+ reject(signal.reason);
+ };
+ signal.addEventListener('abort', onAbort, { once: true });
+ });
+}
+
+function conflictReplacementError(pid: number, reason: string): Error {
+ return new Error(`Maka could not replace Runtime Host process ${pid}: ${reason}`);
+}
+
function supported(directPeerAvailable: boolean): boolean {
return directPeerAvailable && (process.platform === 'darwin' || process.platform === 'linux');
}
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index 8222ce8e9d..8698dcf9c9 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -1244,8 +1244,7 @@ function runtimeHostUpdateRemoteCommand(
setupPackage: PreparedSetupPackage,
input: DesktopRuntimeHostSshUpdateInput,
): string {
- const deploymentId = input.expectedTarget.deploymentId;
- if (!deploymentId) {
+ if (!input.expectedTarget.deploymentId) {
throw new Error('Runtime Host update requires a deployment generation');
}
return runtimeHostPackageRemoteCommand(
@@ -1257,8 +1256,6 @@ function runtimeHostUpdateRemoteCommand(
'--framed',
'--managed-root-id',
input.expectedTarget.rootId,
- '--operator-deployment-id',
- deploymentId,
...managedServiceTargetArgs(input.expectedTarget),
...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
],
diff --git a/apps/desktop/src/main/runtime-host-upgrade-copy.ts b/apps/desktop/src/main/runtime-host-upgrade-copy.ts
index 4a4a46c99a..c00195729b 100644
--- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts
+++ b/apps/desktop/src/main/runtime-host-upgrade-copy.ts
@@ -36,30 +36,42 @@ type ActivityKey =
export function buildRuntimeHostUpgradeDialogOptions(
conflict: Conflict,
- canRestart: boolean,
+ action: 'restart' | 'replace' | undefined,
locale: UiLocale,
): MessageBoxOptions {
const activity = conflict.handshake?.activity;
const hasWork =
(activity?.activeOperations ?? 0) > 0 || (activity?.residencies.length ?? 0) > 0;
const copy = UPGRADE_COPY[locale];
- const buttons = canRestart ? [copy.restart, copy.wait, copy.cancel] : [copy.wait, copy.cancel];
+ const canWait = conflict.registration.lifecycleMode !== 'service';
+ const buttons = action
+ ? canWait
+ ? [action === 'restart' ? copy.restart : copy.replace, copy.wait, copy.cancel]
+ : [action === 'restart' ? copy.restart : copy.replace, copy.cancel]
+ : canWait
+ ? [copy.wait, copy.cancel]
+ : [copy.cancel];
return {
type: 'warning',
title: copy.title,
message: copy.message,
- detail: formatActivity(conflict, locale),
+ detail: formatActivity(conflict, action, locale),
buttons,
- defaultId: hasWork || !canRestart ? (canRestart ? 1 : 0) : 0,
- cancelId: canRestart ? 2 : 1,
+ defaultId: action === 'restart' && !hasWork ? 0 : action ? 1 : 0,
+ cancelId: action ? (canWait ? 2 : 1) : canWait ? 1 : 0,
noLink: true,
};
}
-function formatActivity(conflict: Conflict, locale: UiLocale): string {
+function formatActivity(
+ conflict: Conflict,
+ action: 'restart' | 'replace' | undefined,
+ locale: UiLocale,
+): string {
const activity = conflict.handshake?.activity;
const copy = UPGRADE_COPY[locale];
const lines: string[] = [];
+ lines.push(copy.processId(conflict.registration.pid));
if (activity) {
const minutes = Math.max(1, Math.round(activity.processUptimeSeconds / 60));
lines.push(copy.uptime(minutes));
@@ -69,9 +81,12 @@ function formatActivity(conflict: Conflict, locale: UiLocale): string {
lines.push(`${copy.activity[activityKey(residency.label)]}: ${residency.count}`);
}
} else lines.push(copy.unknownActivity);
- lines.push('', copy.restartWarning);
- if (conflict.kind !== 'upgrade_required' || !conflict.restartable) lines.push(copy.exitOwner);
- lines.push(copy.waitExplanation);
+ lines.push('', action === 'replace' ? copy.replaceWarning : copy.restartWarning);
+ if (action === 'replace') lines.push(copy.replaceExplanation);
+ else if (conflict.kind !== 'upgrade_required' || !conflict.restartable) {
+ lines.push(copy.exitOwner(conflict.registration.pid));
+ }
+ if (conflict.registration.lifecycleMode !== 'service') lines.push(copy.waitExplanation);
return lines.join('\n');
}
@@ -93,15 +108,21 @@ const UPGRADE_COPY = {
title: 'Older Runtime Host is running',
message: 'Another Runtime Host process still owns this workspace.',
restart: 'Restart Runtime Host',
+ replace: 'Stop Host and Continue',
wait: 'Wait',
cancel: 'Cancel Startup',
uptime: (n: number) => `Running for about ${n} ${n === 1 ? 'minute' : 'minutes'}`,
connections: (n: number) => `${n} other client(s) are still connected`,
operations: (n: number) => `${n} operation(s) are running`,
unknownActivity: 'This Host version cannot report its background activity.',
+ processId: (pid: number) => `Process ID (PID): ${pid}`,
restartWarning:
'Restarting preserves durable state, but it can interrupt in-flight external work.',
- exitOwner: 'Exit the process that owns this Host to replace it safely.',
+ replaceWarning:
+ 'Stopping preserves durable state, but it can interrupt in-flight external work.',
+ replaceExplanation: 'Maka will stop this Host, replace it safely, and continue startup.',
+ exitOwner: (pid: number) =>
+ `End process ${pid} with your system process manager to replace this Host safely.`,
waitExplanation: 'If you wait, Maka will continue automatically when this Host exits.',
activity: {
goal: 'Goal', scheduledTask: 'Scheduled Task', dailyReview: 'Daily Review',
@@ -113,14 +134,19 @@ const UPGRADE_COPY = {
title: '旧版 Runtime Host 正在运行',
message: '另一个 Runtime Host 进程仍占用此工作区。',
restart: '重启 Runtime Host',
+ replace: '停止 Host 并继续',
wait: '等待',
cancel: '取消启动',
uptime: (n: number) => `已运行约 ${n} 分钟`,
connections: (n: number) => `仍有 ${n} 个其他客户端连接`,
operations: (n: number) => `有 ${n} 个操作正在运行`,
unknownActivity: '此 Host 版本无法报告后台活动。',
+ processId: (pid: number) => `进程 ID (PID):${pid}`,
restartWarning: '重启会保留持久化状态,但可能中断正在进行的外部工作。',
- exitOwner: '请退出当前占用此 Host 的进程,以便安全替换。',
+ replaceWarning: '停止 Host 会保留持久化状态,但可能中断正在进行的外部工作。',
+ replaceExplanation: 'Maka 将停止并安全替换此 Host,然后继续启动。',
+ exitOwner: (pid: number) =>
+ `请使用系统进程管理工具结束进程 ${pid},以便安全替换此 Host。`,
waitExplanation: '若选择等待,当前 Host 退出后 Maka 将自动继续。',
activity: {
goal: '目标', scheduledTask: '计划任务', dailyReview: '每日回顾', execution: '活动执行',
diff --git a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts
index b051eabc9e..68e08bf3ec 100644
--- a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts
+++ b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts
@@ -22,7 +22,7 @@ import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron';
import type {
RuntimeHostRestartDecision,
RuntimeHostUpgradePrompts,
- RuntimeHostWaitDecision,
+ RuntimeHostNonRestartableDecision,
} from './runtime-host-desktop-manager.js';
import { buildRuntimeHostUpgradeDialogOptions } from './runtime-host-upgrade-copy.js';
@@ -36,21 +36,29 @@ export function createRuntimeHostUpgradePrompts(
return {
restartable: async (conflict): Promise<RuntimeHostRestartDecision> => {
const locale = await resolveLocale();
+ const canWait = conflict.registration.lifecycleMode !== 'service';
const { response } = await showDialog(
- buildRuntimeHostUpgradeDialogOptions(conflict, true, locale),
+ buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', locale),
locale,
);
if (response === 0) return 'restart';
- if (response === 1) return 'wait';
+ if (canWait && response === 1) return 'wait';
return 'cancel';
},
- waitOnly: async (conflict): Promise<RuntimeHostWaitDecision> => {
+ nonRestartable: async (
+ conflict,
+ canReplace,
+ ): Promise<RuntimeHostNonRestartableDecision> => {
const locale = await resolveLocale();
+ const canWait = conflict.registration.lifecycleMode !== 'service';
const { response } = await showDialog(
- buildRuntimeHostUpgradeDialogOptions(conflict, false, locale),
+ buildRuntimeHostUpgradeDialogOptions(conflict, canReplace ? 'replace' : undefined, locale),
locale,
);
- return response === 0 ? 'wait' : 'cancel';
+ if (!canReplace) return canWait && response === 0 ? 'wait' : 'cancel';
+ if (response === 0) return 'replace';
+ if (canWait && response === 1) return 'wait';
+ return 'cancel';
},
};
}
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index 0dd8e4c268..085ecc51d6 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -510,6 +510,10 @@ export async function runMakaCli(
case 'runtime-host-service-update': {
const { runManagedRuntimeHostSelectedUpdateCli, runManagedRuntimeHostUpdateCli } =
await import('./runtime-host-update-command.js');
+ const { RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV } = await import(
+ '@maka/runtime-host/operator'
+ );
+ const sourcePackageIntegrity = process.env[RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV];
const serviceDataRoots = command.clientDataRoot
? deriveMakaDataRoots(command.clientDataRoot)
: dataRoots;
@@ -534,6 +538,7 @@ export async function runMakaCli(
clientDataRoot: serviceDataRoots.clientDataRoot,
defaultRootPath: serviceDataRoots.workspaceRoot,
sourcePackageRoot: fileURLToPath(new URL('..', import.meta.url)),
+ ...(sourcePackageIntegrity ? { sourcePackageIntegrity } : {}),
version,
expectedTarget: command.expectedTarget,
...(command.managedRootId ? { managedRootId: command.managedRootId } : {}),
diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts
index 69802ed8f1..aa9d48e42f 100644
--- a/packages/cli/src/runtime-host-lifecycle-transaction.ts
+++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts
@@ -338,6 +338,13 @@ export async function retireRuntimeHostLifecycleOwner(input: {
},
});
if (connected.kind !== 'connected') {
+ if (input.allowInterruptActiveTasks && input.supervisor) {
+ const status = await input.supervisor.status();
+ if (status.active && status.pid !== null) {
+ await input.supervisor.retire();
+ return waitForRuntimeHostLifecycleOwner(capability, input.timeoutMs ?? 45_000);
+ }
+ }
throw new RuntimeHostLifecycleTransactionError(
'transition_failed',
`Runtime Host cannot prepare for retirement: ${connected.kind}`,
@@ -370,7 +377,14 @@ export async function retireRuntimeHostLifecycleOwner(input: {
} finally {
await connected.connection.close().catch(() => undefined);
}
- const deadline = Date.now() + (input.timeoutMs ?? 45_000);
+ return waitForRuntimeHostLifecycleOwner(capability, input.timeoutMs ?? 45_000);
+}
+
+async function waitForRuntimeHostLifecycleOwner(
+ capability: Awaited<ReturnType<typeof resolveExistingStorageRoot>>,
+ timeoutMs: number,
+): Promise<Extract<RuntimeHostLifecycleRetirement, { readonly kind: 'retired' }>> {
+ const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const owner = await tryAcquireStateRootOwner(capability);
if (owner) return { kind: 'retired', owner };
@@ -646,23 +660,9 @@ export async function verifyRuntimeHostLifecycleReady(
timeoutMs = 45_000,
): Promise<void> {
const canonical = decodeRuntimeHostManagedDeploymentConfig(config);
- await deps.verifyOperator(canonical);
+ await verifyRuntimeHostLifecycleProjection(canonical, deps);
if (canonical.lifecycle.mode !== 'supervised') return;
const provider = deps.resolveProvider(canonical.lifecycle.provider);
- const supervisorDefinition = runtimeHostSupervisorDefinition(canonical);
- await provider.supervisor.verify(supervisorDefinition);
- if (canonical.reconciliation.trigger === 'scheduled') {
- await provider.reconciliationTrigger.verify(
- runtimeHostReconciliationTriggerDefinition(canonical),
- );
- const trigger = await provider.reconciliationTrigger.status();
- if (!trigger.installed || !trigger.active) {
- throw new RuntimeHostLifecycleTransactionError(
- 'transition_failed',
- 'Runtime Host reconciliation scheduling is not active',
- );
- }
- }
const deadline = Date.now() + timeoutMs;
let lastFailure: unknown = new Error('Runtime Host is not ready');
while (Date.now() < deadline) {
@@ -703,6 +703,29 @@ export async function verifyRuntimeHostLifecycleReady(
);
}
+/** Verifies the durable operator and supervisor projection without requiring a compatible Host. */
+export async function verifyRuntimeHostLifecycleProjection(
+ config: RuntimeHostManagedDeploymentConfig,
+ deps: RuntimeHostLifecycleTransactionDeps,
+): Promise<void> {
+ const canonical = decodeRuntimeHostManagedDeploymentConfig(config);
+ await deps.verifyOperator(canonical);
+ if (canonical.lifecycle.mode !== 'supervised') return;
+ const provider = deps.resolveProvider(canonical.lifecycle.provider);
+ await provider.supervisor.verify(runtimeHostSupervisorDefinition(canonical));
+ if (canonical.reconciliation.trigger !== 'scheduled') return;
+ await provider.reconciliationTrigger.verify(
+ runtimeHostReconciliationTriggerDefinition(canonical),
+ );
+ const trigger = await provider.reconciliationTrigger.status();
+ if (!trigger.installed || !trigger.active) {
+ throw new RuntimeHostLifecycleTransactionError(
+ 'transition_failed',
+ 'Runtime Host reconciliation scheduling is not active',
+ );
+ }
+}
+
export function runtimeHostSupervisorDefinition(
config: RuntimeHostManagedDeploymentConfig,
): RuntimeHostProviderDefinition {
diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts
index f720426afd..a920aa0709 100644
--- a/packages/cli/src/runtime-host-update-command.ts
+++ b/packages/cli/src/runtime-host-update-command.ts
@@ -79,6 +79,7 @@ import {
canDiscardRuntimeHostLifecycleDesiredArtifacts,
replaceRuntimeHostLifecycle,
resolveRecoverableRuntimeHostManagedDeployment,
+ verifyRuntimeHostLifecycleProjection,
type RuntimeHostLifecycleTransactionDeps,
} from './runtime-host-lifecycle-transaction.js';
import { manageRuntimeHostManagedLifecycle } from './runtime-host-managed-lifecycle-manager.js';
@@ -92,6 +93,7 @@ export interface RuntimeHostUpdateCliOptions {
readonly clientDataRoot: string;
readonly defaultRootPath: string;
readonly sourcePackageRoot: string;
+ readonly sourcePackageIntegrity?: string;
readonly version: string;
readonly expectedTarget: RuntimeHostManagedServiceTarget;
readonly managedRootId?: string;
@@ -248,7 +250,7 @@ export async function runManagedRuntimeHostUpdateCli(
const targetCliPath = resolveRuntimeHostManagedPackageCliPath(
deploymentRoot,
options.version,
- options.registrySelection?.integrity,
+ options.sourcePackageIntegrity ?? options.registrySelection?.integrity,
);
const expectedCurrent = options.registrySelection?.current;
const selectedDeploymentStillCurrent =
@@ -328,8 +330,11 @@ export async function runManagedRuntimeHostUpdateCli(
clientDataRoot: options.clientDataRoot,
sourcePackageRoot: options.sourcePackageRoot,
version: options.version,
- ...(options.registrySelection
- ? { packageIntegrity: options.registrySelection.integrity }
+ ...((options.sourcePackageIntegrity ?? options.registrySelection?.integrity)
+ ? {
+ packageIntegrity:
+ options.sourcePackageIntegrity ?? options.registrySelection?.integrity,
+ }
: {}),
}),
);
@@ -579,7 +584,7 @@ async function runCanonicalRuntimeHostUpdate(
const recovered = await resolveRecoverableRuntimeHostManagedDeployment(
options.managedRootId,
lifecycleDeps,
- { expectedTarget: options.expectedTarget, ensureAvailable: true },
+ { expectedTarget: options.expectedTarget },
);
if (recovered.kind === 'absent') {
throw new RuntimeHostServiceManagerError(
@@ -588,6 +593,7 @@ async function runCanonicalRuntimeHostUpdate(
);
}
const current = recovered.config;
+ await verifyRuntimeHostLifecycleProjection(current, lifecycleDeps);
assertRuntimeHostManagedOperatorConfig(
current,
options.operatorDeploymentId,
@@ -606,6 +612,7 @@ async function runCanonicalRuntimeHostUpdate(
{ resolveProvider: resolveRuntimeHostLifecycleProvider },
);
const targetIntegrity =
+ options.sourcePackageIntegrity ??
options.registrySelection?.integrity ??
(options.version === current.launch.package.version
? current.launch.package.integrity
@@ -613,7 +620,7 @@ async function runCanonicalRuntimeHostUpdate(
if (!targetIntegrity) {
throw new RuntimeHostServiceManagerError(
'invalid_launch',
- 'An exact registry package identity is required for a managed update',
+ 'An exact package identity is required for a managed update',
);
}
const expectedCurrent = options.registrySelection?.current;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org