Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
},
});

Expand All @@ -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'),
},
});

Expand All @@ -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'),
},
});

Expand All @@ -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'),
},
});

Expand All @@ -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'),
},
});

Expand All @@ -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);
Expand All @@ -1175,6 +1175,41 @@ 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 observed = upgradeRequired(false);
const conflict = {
...observed,
registration: { ...observed.registration, lifecycleMode: 'service' as const },
};
const replacement = candidateHarness();
let starts = 0;
let replaced: typeof observed.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, actions) => {
assert.deepEqual(actions, { canReplace: true, canWait: false });
return 'replace';
},
},
resolveLocalHostReplacement: async (registration) => ({
replace: async () => {
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;
Expand All @@ -1183,8 +1218,9 @@ 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, actions) => {
presented = actual;
assert.deepEqual(actions, { canReplace: false, canWait: true });
return 'cancel';
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ test('local update runs the selected package against the exact managed deploymen
rootId: 'a'.repeat(64),
deploymentId,
},
expectedHost: { hostEpoch: 'older-host', pid: 42 },
},
(phase) => phases.push(phase),
);
Expand All @@ -191,8 +192,9 @@ test('local update runs the selected package against the exact managed deploymen
assert.deepEqual(args, [
'exec', '--yes', '--package', 'maka-agent@0.3.0', '--',
'maka', 'runtime-host', 'service', 'update', '--framed',
'--target', '0.3.0',
'--managed-root-id', 'a'.repeat(64),
'--operator-deployment-id', deploymentId,
'--expected-host-json', JSON.stringify({ hostEpoch: 'older-host', pid: 42 }),
'--expected-service-id', 'a'.repeat(64),
'--expected-root-path', '/tmp/maka/root',
'--expected-root-id', 'a'.repeat(64),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ 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';
Expand Down Expand Up @@ -228,6 +234,65 @@ test('keeps the managed service visible when Direct peer support is unavailable'
assert.equal(snapshot.managedService, true);
});

test('replaces a conflicting supervised Host through canonical authority without a receipt', 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 });
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' }),
resolveManagedDeploymentAuthority: async () => ({
kind: 'active',
lifecycleMode: 'supervised',
target: {
schemaVersion: 1,
serviceId: rootId,
rootPath,
rootId,
operatorPath: join(base, 'operator'),
deploymentId: RECOVERY_DEPLOYMENT_ID,
},
}),
operator: {
async runUpdate(input: {
readonly target: { readonly rootId: string; readonly deploymentId?: string };
readonly expectedHost?: { readonly hostEpoch: string; readonly pid: number };
readonly allowInterruptActiveTasks?: boolean;
}) {
assert.equal(input.target.rootId, rootId);
assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID);
assert.deepEqual(input.expectedHost, { hostEpoch: 'older-host', pid: 42 });
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());

const replacement = await service.resolveConflictingHostReplacement(
hostRegistration({ rootId, lifecycleMode: 'service' }),
new AbortController().signal,
);
assert.ok(replacement);
await replacement.replace();
assert.equal(updated, true);
});

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 }));
Expand Down Expand Up @@ -309,6 +374,7 @@ test('adopts committed managed authority for every pending receipt without repla
manager: () => assert.fail('pre-start reconciliation must not require the Local manager'),
resolveManagedDeploymentAuthority: async () => ({
kind: 'active',
lifecycleMode: 'supervised',
target: {
schemaVersion: 1,
serviceId: rootId,
Expand Down Expand Up @@ -738,6 +804,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,11 +461,9 @@ test('runs an exact update package and reports progress before an active-work re
const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? '';
assert.match(remoteCommand, /--package.*maka-agent@1\.3\.0/u);
assert.match(remoteCommand, /runtime-host.*service.*update/u);
assert.match(remoteCommand, /--target.*1\.3\.0/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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildRuntimeHostUpgradeDialogOptions } from '../runtime-host-upgrade-copy.js';
import { buildRuntimeHostUpgradeDialog } from '../runtime-host-upgrade-copy.js';
import { createRuntimeHostUpgradePrompts } from '../runtime-host-upgrade-dialog.js';

const conflict = {
kind: 'upgrade_required',
Expand All @@ -39,8 +40,16 @@ 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 = buildRuntimeHostUpgradeDialog(
conflict,
{ action: 'restart', canWait: true },
'en',
).options;
const zh = buildRuntimeHostUpgradeDialog(
conflict,
{ action: 'restart', canWait: true },
'zh',
).options;
assert.deepEqual(en.buttons, ['Restart Runtime Host', 'Wait', 'Cancel Startup']);
assert.deepEqual(zh.buttons, ['重启 Runtime Host', '等待', '取消启动']);
assert.equal(en.defaultId, 1);
Expand All @@ -49,4 +58,51 @@ 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('maps the non-default replacement choice to the replace decision', async () => {
const prompts = createRuntimeHostUpgradePrompts(
async () => 'en',
async (options) => {
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/);
return { response: 0, checkboxChecked: false };
},
);
assert.equal(
await prompts.nonRestartable(
{
kind: 'upgrade_required',
restartable: false,
registration: { pid: 42 },
} as never,
{ canReplace: true, canWait: 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, { canReplace: true, canWait: false }),
'cancel',
);
});
2 changes: 2 additions & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,8 @@ runtimeHostManager = await startRuntimeHostDesktopManager(
});
},
recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(signal),
resolveLocalHostReplacement: (registration, signal) =>
localRuntimeHostRemoteAccess.resolveConflictingHostReplacement(registration, signal),
onFatalError: (error, target) => {
if (error instanceof RuntimeHostUpgradeCancelledError) {
if (target.profile.kind === "local") app.quit();
Expand Down
Loading
Loading