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 d2b9accf06..cbc687b668 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 @@ -23,6 +23,7 @@ import type { BotIncomingMessage } from '@maka/runtime/bots'; import { RuntimeHostOperationError, RuntimeHostRequestInterruptedError, + type RuntimeHostSpawnedProcess, } from '@maka/runtime-host/client'; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, @@ -270,6 +271,33 @@ test('does not retire the local Host twice when an update handoff triggers quit' await owner.close(); }); +test('does not block quit after a retired Local Host hands off to an unavailable supervisor', async () => { + const current = candidateHarness({ disconnectOnPrepare: true }); + let starts = 0; + let reportFatal!: (error: Error) => void; + const fatalReported = new Promise((resolve) => { + reportFatal = resolve; + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => { + starts += 1; + return starts === 1 ? ready(current.candidate) : incompatibleHost('wait_for_idle_exit'); + }, + waitForHostExit: async () => undefined, + onFatalError: reportFatal, + }); + + const handoff = await owner.retireOwnedLocalHost('interrupt_active_work'); + assert.equal(handoff.kind, 'retired'); + if (handoff.kind === 'retired') handoff.resume(); + await fatalReported; + + assert.deepEqual(await owner.retireOwnedLocalHost('interrupt_active_work'), { + kind: 'not_owned', + }); + await owner.close(); +}); + test('coalesces concurrent retirement intents onto one exact Host request', async () => { const current = candidateHarness({ disconnectOnPrepare: true }); let releaseExitWait!: () => void; @@ -894,8 +922,55 @@ test('keeps reconnecting through transient startup failures until the Desktop ad await owner.close(); }); +test('reconciles interrupted managed setup after a Local discovery result', async () => { + const managed = candidateHarness({ ownership: 'supervised' }); + const events: string[] = []; + let starts = 0; + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => { + starts += 1; + events.push(`discover:${starts}`); + return starts === 1 + ? { kind: 'failed', reason: 'managed_root_requires_operator' } + : ready(managed.candidate); + }, + recoverLocalHost: async () => { + events.push('reconcile'); + return true; + }, + }); + + assert.deepEqual(events, ['discover:1', 'reconcile', 'discover:2']); + assert.equal(owner.current('local')?.candidate?.hostOwnership, 'supervised'); + await owner.close(); +}); + +test('reconciles interrupted managed setup after Local discovery throws', async () => { + const managed = candidateHarness({ ownership: 'supervised' }); + const events: string[] = []; + let starts = 0; + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => { + starts += 1; + events.push(`discover:${starts}`); + if (starts === 1) throw new Error('managed deployment transition is in progress'); + return ready(managed.candidate); + }, + recoverLocalHost: async () => { + events.push('reconcile'); + return true; + }, + }); + + assert.deepEqual(events, ['discover:1', 'reconcile', 'discover:2']); + assert.equal(owner.current('local')?.candidate?.hostOwnership, 'supervised'); + await owner.close(); +}); + test('stops reconnecting when the replacement Host is incompatible', async () => { - const first = candidateHarness(); + const first = candidateHarness({ + ownedProcess: { pid: 42, exited: new Promise(() => undefined) }, + }); let reportFatal!: (error: Error) => void; const fatalReported = new Promise((resolve) => { reportFatal = resolve; @@ -911,6 +986,62 @@ test('stops reconnecting when the replacement Host is incompatible', async () => await first.candidate.close(); const fatal = await fatalReported; assert.match(fatal.message, /older Runtime Host/); + await assert.rejects( + owner.retireOwnedLocalHost('interrupt_active_work'), + (error: unknown) => + error instanceof DesktopLocalHostRetirementError && + error.facts.pid === 42 && + error.cause === fatal, + ); + await owner.close(); +}); + +test('does not retain manual-stop authority after the owned Host process exits', async () => { + const first = candidateHarness({ + ownedProcess: { + pid: 42, + exited: Promise.resolve({ code: 0, signal: null, stderr: '', stderrTruncated: false }), + }, + }); + let reportFatal!: (error: Error) => void; + const fatalReported = new Promise((resolve) => { + reportFatal = resolve; + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => + first.closeCalls === 0 + ? ready(first.candidate) + : incompatibleHost('wait_for_idle_exit'), + onFatalError: reportFatal, + }); + + await first.candidate.close(); + await fatalReported; + assert.deepEqual(await owner.retireOwnedLocalHost('interrupt_active_work'), { + kind: 'not_owned', + }); + await owner.close(); +}); + +test('does not block quit after a supervised Local Host becomes permanently unavailable', async () => { + const first = candidateHarness({ ownership: 'supervised' }); + let reportFatal!: (error: Error) => void; + const fatalReported = new Promise((resolve) => { + reportFatal = resolve; + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => + first.closeCalls === 0 + ? ready(first.candidate) + : incompatibleHost('wait_for_idle_exit'), + onFatalError: reportFatal, + }); + + await first.candidate.close(); + await fatalReported; + assert.deepEqual(await owner.retireOwnedLocalHost('interrupt_active_work'), { + kind: 'not_owned', + }); await owner.close(); }); @@ -1160,6 +1291,7 @@ function candidateHarness( disconnectOnPrepare?: boolean; activeTasks?: boolean | 'always'; ownership?: 'owned_ephemeral' | 'supervised' | 'external'; + ownedProcess?: RuntimeHostSpawnedProcess; hostId?: string; hostEpoch?: string; finalizeFailures?: Error[]; @@ -1184,6 +1316,7 @@ function candidateHarness( closed, hostOwnership: options.ownership ?? 'owned_ephemeral', hostPid: 42, + ...(options.ownedProcess ? { ownedProcess: options.ownedProcess } : {}), client: { hostId: options.hostId ?? 'test-host', hostEpoch: options.hostEpoch ?? 'test-host-epoch', 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 1fbc1844b5..1158c7c458 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 @@ -198,7 +198,175 @@ test('revokes the one Local sharing authority without changing peer connectivity ]); }); -test('an interrupted Local Host handoff converges to its exact managed service', async (t) => { +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 })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + const lifecyclePath = join(clientDataRoot, 'runtime-host-local-service.json'); + await mkdir(rootPath, { recursive: true }); + const handlers = new Map[1]>(); + let ownershipChecked = false; + let setupCalls = 0; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { + handle: (channel, handler) => { handlers.set(channel, handler); }, + removeHandler: (channel) => { handlers.delete(channel); }, + }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => + ({ + async retireOwnedLocalHost() { + ownershipChecked = true; + await assert.rejects(readFile(lifecyclePath, 'utf8'), { code: 'ENOENT' }); + return { kind: 'not_owned' as const }; + }, + }) as unknown as RuntimeHostDesktopManager, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runSetup() { + setupCalls += 1; + throw new Error('setup must not run for an externally managed Host'); + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + const enable = handlers.get('local-runtime-host-remote-access:enable'); + assert.ok(enable); + const enabling = enable({} as Electron.IpcMainInvokeEvent, { + allowInterruptActiveTasks: false, + coordinationRelays: [], + }); + await assert.rejects(enabling, /already managed outside this Desktop/u); + assert.equal(ownershipChecked, true); + await assert.rejects(readFile(lifecyclePath, 'utf8'), { code: 'ENOENT' }); + assert.equal(setupCalls, 0); +}); + +test('adopts committed managed authority for every pending receipt without replaying setup', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-prestart-')); + t.after(() => rm(base, { recursive: true, force: true })); + for (const state of ['handoff', 'setupPending'] as const) { + const clientDataRoot = join(base, state); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + const deploymentId = '22222222-2222-4222-8222-222222222222'; + const operatorPath = join(base, 'installed', 'operator'); + await mkdir(rootPath, { recursive: true }); + await writeFile( + join(clientDataRoot, 'runtime-host-local-service.json'), + `${JSON.stringify({ + schemaVersion: 1, + state, + rootPath, + rootId, + coordinationRelays: [], + allowInterruptActiveTasks: true, + })}\n`, + ); + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => assert.fail('pre-start reconciliation must not require the Local manager'), + resolveManagedDeploymentAuthority: async () => ({ + kind: 'active', + target: { + schemaVersion: 1, + serviceId: rootId, + operatorPath, + rootPath, + rootId, + deploymentId, + }, + }), + resolveSetupPackage: async () => + assert.fail('committed authority must not resolve a package'), + operator: { + async runSetup() { + assert.fail('committed authority must not replay setup'); + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + assert.equal(await service.recoverManagedSetup(), true); + assert.deepEqual( + JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')), + { + schemaVersion: 1, + state: 'managed', + serviceId: rootId, + operatorPath, + rootPath, + rootId, + deploymentId, + }, + ); + } +}); + +test('discards a legacy handoff that belongs to an externally managed Host', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-legacy-external-')); + t.after(() => rm(base, { recursive: true, force: true })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + const lifecyclePath = join(clientDataRoot, 'runtime-host-local-service.json'); + await mkdir(rootPath, { recursive: true }); + await writeFile( + lifecyclePath, + `${JSON.stringify({ + schemaVersion: 1, + state: 'handoff', + rootPath, + rootId, + coordinationRelays: [], + allowInterruptActiveTasks: false, + })}\n`, + ); + let setupCalls = 0; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => + ({ + async retireOwnedLocalHost() { + return { kind: 'not_owned' as const }; + }, + }) as unknown as RuntimeHostDesktopManager, + resolveManagedDeploymentAuthority: async () => undefined, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async runSetup() { + setupCalls += 1; + throw new Error('setup must not replace an externally managed Host'); + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + assert.equal(await service.recoverManagedSetup(), false); + await service.recover(); + + assert.equal(setupCalls, 0); + await assert.rejects(readFile(lifecyclePath, 'utf8'), { code: 'ENOENT' }); +}); + +test('interrupted Local Host setup converges to its exact managed service', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-recovery-')); t.after(() => rm(base, { recursive: true, force: true })); const clientDataRoot = join(base, 'client'); @@ -209,7 +377,7 @@ test('an interrupted Local Host handoff converges to its exact managed service', join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ schemaVersion: 1, - state: 'handoff', + state: 'setupPending', rootPath, rootId, coordinationRelays: [], @@ -217,6 +385,7 @@ test('an interrupted Local Host handoff converges to its exact managed service', })}\n`, ); let setupCalls = 0; + let setupQuiesced = false; const service = createDesktopLocalRuntimeHostRemoteAccess({ ipcMain: { handle() {}, removeHandler() {} }, clientDataRoot, @@ -229,7 +398,12 @@ test('an interrupted Local Host handoff converges to its exact managed service', assert.equal(mode, 'interrupt_active_work'); return { kind: 'not_owned' as const }; }, + async runManagedLocalHostChange(change: () => Promise) { + setupQuiesced = true; + return change(); + }, }) as unknown as RuntimeHostDesktopManager, + resolveManagedDeploymentAuthority: async () => undefined, resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), operator: { async runSetup() { @@ -253,9 +427,12 @@ test('an interrupted Local Host handoff converges to its exact managed service', }); t.after(() => service.close()); + assert.equal(await service.recoverManagedSetup(), false); + assert.equal(setupCalls, 0); await service.recover(); assert.equal(setupCalls, 1); + assert.equal(setupQuiesced, true); assert.equal( JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')) .state, diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 0913bbee60..8a73c0be66 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -991,6 +991,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( isDefault: true, }); }, + recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverManagedSetup(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-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 3ecce8214c..03ce20e79e 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -201,6 +201,7 @@ export interface DesktopRuntimeHostCandidate { readonly closed: Promise; readonly hostOwnership: DesktopRuntimeHostOwnership; readonly hostPid?: number; + readonly ownedProcess?: RuntimeHostSpawnedProcess; stopSession(sessionId: string): Promise; close(): Promise; } @@ -211,6 +212,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { readonly closed: Promise; readonly hostOwnership: DesktopRuntimeHostOwnership; readonly hostPid: number | undefined; + readonly ownedProcess: RuntimeHostSpawnedProcess | undefined; readonly #client: DesktopRuntimeHostClient; readonly #observer: RuntimeHostSessionObserver; readonly #ipc: ScopedIpcMain; @@ -237,6 +239,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { connectionClosed: Promise; hostOwnership: DesktopRuntimeHostOwnership; hostPid?: number; + ownedProcess?: RuntimeHostSpawnedProcess; hasRegisteredCapabilities: () => boolean; stopSession: (sessionId: string) => Promise; }) { @@ -255,6 +258,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { this.botIncoming = input.botIncoming; this.hostOwnership = input.hostOwnership; this.hostPid = input.hostPid; + this.ownedProcess = input.ownedProcess; this.closed = input.connectionClosed.then(() => this.close()); } @@ -323,6 +327,7 @@ export async function startDesktopRuntimeHostCandidate( : 'supervised', "local", connection.registration.pid, + connection.spawnedProcess, ), }; } catch (error) { @@ -432,6 +437,7 @@ export async function createDesktopRuntimeHostCandidate( hostOwnership: DesktopRuntimeHostOwnership, targetKind: DesktopRuntimeHostTargetPolicy["kind"], hostPid?: number, + ownedProcess?: RuntimeHostSpawnedProcess, ): Promise { const target: DesktopRuntimeHostTargetPolicy = { kind: targetKind, @@ -760,6 +766,7 @@ export async function createDesktopRuntimeHostCandidate( connectionClosed: connection.closed, hostOwnership, ...(hostPid === undefined ? {} : { hostPid }), + ...(ownedProcess === undefined ? {} : { ownedProcess }), hasRegisteredCapabilities: () => capabilitiesRegistered, stopSession, }); diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index c03c6f6af9..e50c03e4de 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -41,6 +41,7 @@ import { type DesktopRuntimeHostCandidate, type DesktopRuntimeHostCandidateStartInput, type DesktopRuntimeHostCandidateStartResult, + type DesktopRuntimeHostOwnership, } from './runtime-host-desktop-candidate.js'; import { RuntimeHostReconnectingIpcMain } from './runtime-host-reconnecting-ipc-main.js'; import { RuntimeHostSessionObservationRegistry } from './runtime-host-session-observation-registry.js'; @@ -181,9 +182,20 @@ interface DesktopRuntimeHostTargetGeneration { hostId?: string; lifecycle?: RuntimeHostReconnectLifecycle; unsubscribeLifecycle?: () => void; + lastCandidate?: { + readonly hostId: string; + readonly hostEpoch: string; + readonly ownership: DesktopRuntimeHostOwnership; + readonly ownedProcess?: DesktopOwnedProcessEvidence; + }; valid: boolean; } +interface DesktopOwnedProcessEvidence { + readonly pid: number; + state: 'running' | 'exited' | 'unknown'; +} + export async function startRuntimeHostDesktopManager( input: DesktopRuntimeHostCandidateStartInput, options: { @@ -198,6 +210,7 @@ export async function startRuntimeHostDesktopManager( registration: HostRegistration, signal: AbortSignal, ) => Promise; + recoverLocalHost?: (signal: AbortSignal) => Promise; reconnectBackoff?: RuntimeHostReconnectBackoff; pairingFinalizationTimeoutMs?: number; onTargetStateChanged?: (state: RuntimeHostDesktopTargetState) => void; @@ -213,6 +226,7 @@ export async function startRuntimeHostDesktopManager( options.upgradePrompts, options.waitForHostExit ?? waitForProcessExit, options.waitForHostRetirement ?? waitForProcessRetirement, + options.recoverLocalHost, options.reconnectBackoff, options.pairingFinalizationTimeoutMs ?? DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS, options.onTargetStateChanged, @@ -252,6 +266,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { registration: HostRegistration, signal: AbortSignal, ) => Promise, + private readonly recoverLocalHost: + | ((signal: AbortSignal) => Promise) + | undefined, private readonly reconnectBackoff: RuntimeHostReconnectBackoff | undefined, private readonly pairingFinalizationTimeoutMs: number, private readonly onTargetStateChanged: @@ -576,10 +593,20 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { async #retireOwnedLocalHost( mode: RuntimeHostRetirementMode, ): Promise { - const lifecycle = this.#requireLifecycle( - this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id), - ); - const quiescence = await lifecycle.quiesce(); + const target = this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id); + const lifecycle = this.#requireLifecycle(target); + const unavailable = this.#unavailableLocalHostRetirement(target); + if (unavailable) return unavailable; + let quiescence: Awaited< + ReturnType['quiesce']> + >; + try { + quiescence = await lifecycle.quiesce(); + } catch (error) { + const terminal = this.#unavailableLocalHostRetirement(target, error); + if (terminal) return terminal; + throw error; + } let hostPid = quiescence.current.hostPid; let launchBarrierPaused = false; const resume = () => { @@ -610,6 +637,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return result; } await this.waitForHostExit(result.pid); + target.lastCandidate = undefined; return this.#completeLocalHostRetirement(resume); } catch (error) { resume(); @@ -626,6 +654,28 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } } + #unavailableLocalHostRetirement( + target: DesktopRuntimeHostTargetGeneration, + cause: unknown = target.state.readiness === 'unavailable' ? target.state.error : undefined, + ): DesktopLocalHostRetirement | undefined { + if (target.state.readiness !== 'unavailable') return undefined; + const last = target.lastCandidate; + if (!last || last.ownership !== 'owned_ephemeral') return { kind: 'not_owned' }; + if (last.ownedProcess?.state === 'exited') return { kind: 'not_owned' }; + throw new DesktopLocalHostRetirementError( + { + hostId: last.hostId, + hostEpoch: last.hostEpoch, + lifecycleMode: 'ephemeral', + rootPath: this.#baseInput.rootPath, + ...(last.ownedProcess?.state === 'running' + ? { pid: last.ownedProcess.pid } + : {}), + }, + { cause: cause instanceof Error ? cause : new Error(String(cause)) }, + ); + } + #completeLocalHostRetirement( resume: () => void, ): Extract { @@ -719,30 +769,64 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { sshInteraction: RuntimeHostSshInteraction | undefined, ): Promise { let takeoverHostEpoch: string | undefined; + let localRecoveryAttempted = false; const inheritedExit = target.input.onExit; + const tryRecoverLocalHost = async (): Promise => { + if (target.input.profileTarget || localRecoveryAttempted || !this.recoverLocalHost) { + return false; + } + localRecoveryAttempted = true; + return this.recoverLocalHost(signal); + }; while (true) { - const result = await this.startCandidate( - { - ...target.input, - onExit: (details) => this.#reportCandidateExit(inheritedExit, details), - ...(target.input.profileTarget - ? { - profileTarget: { - ...target.input.profileTarget, - ...(sshInteraction === undefined ? {} : { sshInteraction }), - }, - } - : {}), - ipcMain: this.#ipcMain.createTarget(target.epoch), - isTargetActive: () => this.#ipcMain.isActive(target.epoch), - isTargetValid: () => target.valid, - signal, - ...(takeoverHostEpoch === undefined ? {} : { takeoverHostEpoch }), - }, - target.observations, - ); + let result: DesktopRuntimeHostCandidateStartResult; + try { + result = await this.startCandidate( + { + ...target.input, + onExit: (details) => this.#reportCandidateExit(inheritedExit, details), + ...(target.input.profileTarget + ? { + profileTarget: { + ...target.input.profileTarget, + ...(sshInteraction === undefined ? {} : { sshInteraction }), + }, + } + : {}), + ipcMain: this.#ipcMain.createTarget(target.epoch), + isTargetActive: () => this.#ipcMain.isActive(target.epoch), + isTargetValid: () => target.valid, + signal, + ...(takeoverHostEpoch === undefined ? {} : { takeoverHostEpoch }), + }, + target.observations, + ); + } catch (error) { + signal.throwIfAborted(); + if (await tryRecoverLocalHost()) continue; + throw error; + } if (result.kind === 'ready') { target.hostId = result.candidate.client.hostId; + const previous = target.lastCandidate; + const retainedOwnedProcess = + previous?.hostId === result.candidate.client.hostId && + previous.hostEpoch === result.candidate.client.hostEpoch && + previous.ownership === 'owned_ephemeral' && + result.candidate.hostOwnership === 'owned_ephemeral' && + previous.ownedProcess?.pid === result.candidate.hostPid + ? previous.ownedProcess + : undefined; + target.lastCandidate = { + hostId: result.candidate.client.hostId, + hostEpoch: result.candidate.client.hostEpoch, + ownership: result.candidate.hostOwnership, + ...(result.candidate.ownedProcess + ? { ownedProcess: trackOwnedProcess(result.candidate.ownedProcess) } + : retainedOwnedProcess + ? { ownedProcess: retainedOwnedProcess } + : {}), + }; return result.candidate; } if (result.kind === 'upgrade_required' && result.restartable) { @@ -775,6 +859,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { await this.waitForHostRetirement(result.registration, signal); continue; } + if (await tryRecoverLocalHost()) continue; throw runtimeHostStartupError(result.reason, result.diagnostic); } } @@ -975,6 +1060,21 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } } +function trackOwnedProcess( + process: NonNullable, +): DesktopOwnedProcessEvidence { + const evidence: DesktopOwnedProcessEvidence = { pid: process.pid, state: 'running' }; + void process.exited.then( + () => { + evidence.state = 'exited'; + }, + () => { + evidence.state = 'unknown'; + }, + ); + return evidence; +} + function pairingFinalizeRetry(error: unknown): boolean { // Finalization is idempotent for the current credential, so both a known // non-dispatch and an unknown outcome converge on the replacement connection. 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 d19b3803bd..ab6659d08d 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -26,6 +26,7 @@ import { 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 type { DesktopLocalRuntimeHostRemoteAccessEnableResult, @@ -54,7 +55,18 @@ interface LocalServiceTarget extends DesktopRuntimeHostLocalServiceTarget { readonly deploymentId: string; } -interface LocalServiceHandoff { +interface LocalServiceSetupPending { + readonly schemaVersion: 1; + /** Persisted only after the Desktop-owned Host has retired. */ + readonly state: 'setupPending'; + readonly rootPath: string; + readonly rootId: string; + readonly coordinationRelays: readonly string[]; + readonly allowInterruptActiveTasks: boolean; +} + +/** Schema-v1 setup intent written by Desktop releases before ownership was established. */ +interface LocalServiceLegacyHandoff { readonly schemaVersion: 1; readonly state: 'handoff'; readonly rootPath: string; @@ -67,6 +79,10 @@ interface LocalServiceManaged extends LocalServiceTarget { readonly state: 'managed'; } +type LocalManagedDeploymentAuthority = + | { readonly kind: 'active'; readonly target: LocalServiceTarget } + | { readonly kind: 'transition' }; + interface LocalServicePeerChanging extends LocalServiceTarget { readonly state: 'peerChanging'; readonly peerEnabled: boolean; @@ -80,7 +96,8 @@ interface LocalServiceUninstalling extends LocalServiceTarget { } type LocalServiceLifecycle = - | LocalServiceHandoff + | LocalServiceLegacyHandoff + | LocalServiceSetupPending | LocalServiceManaged | LocalServicePeerChanging | LocalServiceUninstalling; @@ -110,7 +127,14 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { signal?: AbortSignal, ) => DesktopRuntimeHostSetupPackage | Promise; readonly operator: DesktopRuntimeHostLocalOperator; -}): { recover(): Promise; close(): Promise } { + readonly resolveManagedDeploymentAuthority?: ( + rootId: string, + ) => Promise; +}): { + recoverManagedSetup(signal?: AbortSignal): Promise; + recover(): Promise; + close(): Promise; +} { const lifecyclePath = join(input.clientDataRoot, LIFECYCLE_FILE); const closing = new AbortController(); let mutation = Promise.resolve(); @@ -122,6 +146,41 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); return result; }; + const resolveManagedDeploymentAuthority = + input.resolveManagedDeploymentAuthority ?? + (async (rootId: string): Promise => { + const authority = await resolveRuntimeHostManagedDeploymentAuthority(rootId); + if (!authority) return undefined; + if (authority.record.state !== 'active') return { kind: 'transition' }; + return { + kind: 'active', + target: requireServiceTarget( + { + schemaVersion: 1, + serviceId: authority.record.root.id, + operatorPath: join(authority.record.deploymentRoot, 'operator'), + rootPath: authority.record.root.path, + rootId: authority.record.root.id, + deploymentId: authority.record.deploymentId, + }, + input.rootPath, + ), + }; + }); + + const adoptCommittedSetup = async ( + setup: LocalServiceSetupPending, + ): Promise< + | { readonly kind: 'absent' | 'transition' } + | { readonly kind: 'managed'; readonly managed: LocalServiceManaged } + > => { + const authority = await resolveManagedDeploymentAuthority(setup.rootId); + if (!authority) return { kind: 'absent' }; + if (authority.kind === 'transition') return authority; + const managed = managedLifecycle(authority.target); + await writeDocument(lifecyclePath, managed); + return { kind: 'managed', managed }; + }; const getSnapshot = (): Promise => serialize(async () => { @@ -133,7 +192,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { return { state: 'unavailable', message: - lifecycle.state === 'handoff' + lifecycle.state === 'setupPending' || lifecycle.state === 'handoff' ? 'Local Runtime Host setup is being recovered' : lifecycle.state === 'peerChanging' ? 'Local Runtime Host remote access is being recovered' @@ -170,10 +229,23 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { lifecycle = managedLifecycle(lifecycle); } if (lifecycle?.state === 'handoff') { - const recovered = await finishHandoff(lifecycle, true); + const recovered = await recoverLegacyHandoff(lifecycle); if (recovered.kind === 'active_tasks') return recovered; + if (recovered.kind === 'external') { + throw new Error('The Local Runtime Host is already managed outside this Desktop'); + } lifecycle = recovered.managed; } + if (lifecycle?.state === 'setupPending') { + const committed = await adoptCommittedSetup(lifecycle); + if (committed.kind === 'managed') { + lifecycle = committed.managed; + } else { + const recovered = await finishSetup(lifecycle, 'recovery'); + if (recovered.kind === 'active_tasks') return recovered; + lifecycle = recovered.managed; + } + } if (lifecycle?.state === 'managed') { const manager = requireManager(input.manager); const previousHostEpoch = manager.current('local')?.candidate?.client.hostEpoch; @@ -199,16 +271,15 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); } - const handoff: LocalServiceHandoff = { + const setup: LocalServiceSetupPending = { schemaVersion: 1, - state: 'handoff', + state: 'setupPending', rootPath: input.rootPath, rootId: input.rootId, coordinationRelays: request.coordinationRelays, allowInterruptActiveTasks: request.allowInterruptActiveTasks, }; - await writeDocument(lifecyclePath, handoff); - const completed = await finishHandoff(handoff, false); + const completed = await finishSetup(setup, 'request'); if (completed.kind === 'active_tasks') return completed; return enabledResult( encodeRuntimeHostOwnerConnectionCode({ @@ -220,9 +291,9 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); }); - const finishHandoff = async ( - handoff: LocalServiceHandoff, - allowAlreadyManaged: boolean, + const finishSetup = async ( + setup: LocalServiceSetupPending, + origin: 'request' | 'recovery', ): Promise< | { readonly kind: 'active_tasks' } | { @@ -235,38 +306,60 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const setupPackage = await input.resolveSetupPackage(closing.signal); const manager = requireManager(input.manager); const retirement = await manager.retireOwnedLocalHost( - handoff.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + setup.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', ); if (retirement.kind === 'active_tasks') { - if (!allowAlreadyManaged) await removeDocument(lifecyclePath); return { kind: 'active_tasks' }; } - if (retirement.kind === 'not_owned' && !allowAlreadyManaged) { - await removeDocument(lifecyclePath); + if (retirement.kind === 'not_owned' && origin === 'request') { throw new Error('The Local Runtime Host is already managed outside this Desktop'); } + try { + if (origin === 'request') await writeDocument(lifecyclePath, setup); + const reconcile = () => reconcileSetup(setup, setupPackage); + // Recovery may find that the operator already owns the root. Its setup + // can still restart that Host, so keep Desktop reconnect quiesced across + // the entire reconciliation just like every other managed-service change. + return retirement.kind === 'not_owned' + ? await manager.runManagedLocalHostChange(reconcile) + : await reconcile(); + } finally { + if (retirement.kind === 'retired') retirement.resume(); + } + }; + + const reconcileSetup = async ( + setup: LocalServiceSetupPending, + setupPackage: DesktopRuntimeHostSetupPackage, + signal: AbortSignal = closing.signal, + ): Promise<{ + readonly kind: 'complete'; + readonly managed: LocalServiceManaged; + readonly peer: LocalPeerDescriptor; + readonly credential: string; + }> => { let target: LocalServiceTarget | undefined; try { const complete = await input.operator.runSetup( { setupPackage, clientDataRoot: input.clientDataRoot, - rootPath: handoff.rootPath, + rootPath: setup.rootPath, principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, - coordinationRelays: handoff.coordinationRelays, + coordinationRelays: setup.coordinationRelays, expectedTarget: { - serviceId: handoff.rootId, - rootPath: handoff.rootPath, - rootId: handoff.rootId, + serviceId: setup.rootId, + rootPath: setup.rootPath, + rootId: setup.rootId, }, - signal: closing.signal, + signal, }, () => undefined, ); if ( - complete.serviceId !== handoff.rootId || - complete.rootPath !== handoff.rootPath || - complete.rootId !== handoff.rootId || + complete.serviceId !== setup.rootId || + complete.rootPath !== setup.rootPath || + complete.rootId !== setup.rootId || !DEPLOYMENT_ID_PATTERN.test(complete.deploymentId) || !complete.directPeer ) { @@ -281,13 +374,10 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { rootId: complete.rootId, deploymentId: complete.deploymentId, }, - handoff.rootPath, + setup.rootPath, ); const peer = requireEnabledPeer({ state: 'enabled', ...complete.directPeer }); - const managed: LocalServiceManaged = { - ...target, - state: 'managed', - }; + const managed: LocalServiceManaged = { ...target, state: 'managed' }; await writeDocument(lifecyclePath, managed); return { kind: 'complete', managed, peer, credential: complete.credential }; } catch (error) { @@ -302,6 +392,49 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); } throw error; + } + }; + + const recoverLegacyHandoff = async ( + legacy: LocalServiceLegacyHandoff, + ): Promise< + | { readonly kind: 'active_tasks' } + | { readonly kind: 'external' } + | { readonly kind: 'complete'; readonly managed: LocalServiceManaged } + > => { + const pending = pendingSetup(legacy); + const authority = await adoptCommittedSetup(pending); + if (authority.kind === 'managed') { + return { kind: 'complete', managed: authority.managed }; + } + if (authority.kind === 'transition') { + await writeDocument(lifecyclePath, pending); + return finishSetup(pending, 'recovery'); + } + + const manager = requireManager(input.manager); + const retirement = await manager.retireOwnedLocalHost( + legacy.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + ); + if (retirement.kind === 'active_tasks') return { kind: 'active_tasks' }; + if (retirement.kind === 'not_owned') { + const raced = await adoptCommittedSetup(pending); + if (raced.kind === 'managed') { + return { kind: 'complete', managed: raced.managed }; + } + if (raced.kind === 'absent') { + await removeDocument(lifecyclePath); + return { kind: 'external' }; + } + } + + try { + await writeDocument(lifecyclePath, pending); + const setupPackage = await input.resolveSetupPackage(closing.signal); + const reconcile = () => reconcileSetup(pending, setupPackage); + return retirement.kind === 'not_owned' + ? await manager.runManagedLocalHostChange(reconcile) + : await reconcile(); } finally { if (retirement.kind === 'retired') retirement.resume(); } @@ -486,13 +619,38 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { input.ipcMain.handle(channels[5], (_event, value: unknown) => uninstall(value)); return { + recoverManagedSetup: async (signal) => { + if (!supported(input.directPeerAvailable)) return false; + const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal; + operationSignal.throwIfAborted(); + const observed = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (observed?.state !== 'setupPending' && observed?.state !== 'handoff') return false; + return serialize(async () => { + operationSignal.throwIfAborted(); + const pending = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (pending?.state !== 'setupPending' && pending?.state !== 'handoff') return false; + const committed = pendingSetup(pending); + const authority = await adoptCommittedSetup(committed); + if (authority.kind === 'absent') return false; + if (authority.kind === 'managed') return true; + if (pending.state === 'handoff') await writeDocument(lifecyclePath, committed); + const setupPackage = await input.resolveSetupPackage(operationSignal); + await reconcileSetup(committed, setupPackage, operationSignal); + return true; + }); + }, recover: () => serialize(async () => { if (!supported(input.directPeerAvailable)) return; const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); if (!lifecycle) return; if (lifecycle.state === 'handoff') { - await finishHandoff(lifecycle, true); + await recoverLegacyHandoff(lifecycle); + return; + } + if (lifecycle.state === 'setupPending') { + const committed = await adoptCommittedSetup(lifecycle); + if (committed.kind !== 'managed') await finishSetup(lifecycle, 'recovery'); return; } if (lifecycle.state === 'uninstalling') { @@ -694,6 +852,19 @@ function requireManaged(lifecycle: LocalServiceLifecycle | undefined): LocalServ return lifecycle; } +function pendingSetup( + intent: LocalServiceSetupPending | LocalServiceLegacyHandoff, +): LocalServiceSetupPending { + return { + schemaVersion: 1, + state: 'setupPending', + rootPath: intent.rootPath, + rootId: intent.rootId, + coordinationRelays: intent.coordinationRelays, + allowInterruptActiveTasks: intent.allowInterruptActiveTasks, + }; +} + function managedLifecycle(intent: LocalServiceTarget): LocalServiceManaged { return { schemaVersion: 1, @@ -726,7 +897,7 @@ async function readLifecycle( ) { throw new Error('Local Runtime Host service lifecycle is invalid'); } - if (value.state === 'handoff') { + if (value.state === 'setupPending' || value.state === 'handoff') { assertExactKeys(value, [ 'schemaVersion', 'state', @@ -738,11 +909,11 @@ async function readLifecycle( if ( typeof value.allowInterruptActiveTasks !== 'boolean' ) { - throw new Error('Local Runtime Host handoff intent is invalid'); + throw new Error('Local Runtime Host setup intent is invalid'); } return { schemaVersion: 1, - state: 'handoff', + state: value.state, rootPath, rootId, coordinationRelays: requireAddresses(value.coordinationRelays), diff --git a/native/runtime-host-peer/Cargo.toml b/native/runtime-host-peer/Cargo.toml index 01e367934e..35c4f35a36 100644 --- a/native/runtime-host-peer/Cargo.toml +++ b/native/runtime-host-peer/Cargo.toml @@ -49,3 +49,7 @@ tokio = { version = "1.53", features = ["fs", "io-util", "rt-multi-thread", "syn [build-dependencies] napi-build = "2.4" + +[profile.release] +# build.mjs strips the copied addon once; Cargo stripping every intermediate artifact is redundant. +strip = "none" diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 6c6f1f4e82..0854089df8 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -59,6 +59,7 @@ import { resolveRuntimeHostManagedDeploymentRoot, } from '../runtime-host-managed-deployment.js'; import { runRuntimeHostSetupCli } from '../runtime-host-setup-command.js'; +import { RuntimeHostAccessUnavailableError } from '../runtime-host-access-command.js'; import { manageRuntimeHostManagedLifecycle } from '../runtime-host-managed-lifecycle-manager.js'; import { resolveRuntimeHostLifecycleProvider, @@ -86,6 +87,7 @@ test('on-demand setup installs one exact deployment without a service backend', const outputs: string[] = []; let rootId = ''; let projectedOperatorDeploymentRoot = ''; + let pairingAttempts = 0; t.after(async () => { if (previousDataHome === undefined) delete process.env.XDG_DATA_HOME; else process.env.XDG_DATA_HOME = previousDataHome; @@ -166,16 +168,20 @@ test('on-demand setup installs one exact deployment without a service backend', projectedOperatorDeploymentRoot = desired?.deploymentRoot ?? ''; }, verifyOperator: async () => undefined, - replaceCredential: async () => ({ - rootId, - credential: 'secret-token', - credentialId: 'credential-1', - principalKind: 'remote_owner' as const, - principalId: 'desktop:client-1', - operationGrants: [] as const, - canPublishClientCapabilities: false, - canUseHostPaths: false, - }), + replaceCredential: async () => { + pairingAttempts += 1; + if (pairingAttempts === 1) throw new RuntimeHostAccessUnavailableError('unavailable'); + return { + rootId, + credential: 'secret-token', + credentialId: 'credential-1', + principalKind: 'remote_owner' as const, + principalId: 'desktop:client-1', + operationGrants: [] as const, + canPublishClientCapabilities: false, + canUseHostPaths: false, + }; + }, verifyCredential: async ({ endpoint, rootId: expectedRootId }) => { assert.equal(endpoint, 'ws://127.0.0.1:43210/runtime-host'); assert.equal(expectedRootId, rootId); @@ -183,6 +189,7 @@ test('on-demand setup installs one exact deployment without a service backend', writeOutput: (value) => outputs.push(value), } satisfies NonNullable[1]>; assert.equal(await runRuntimeHostSetupCli(options, overrides), 0); + assert.equal(pairingAttempts, 2); const complete = outputs .map(decodeRuntimeHostSetupFrame) .find((frame) => frame?.kind === 'complete'); diff --git a/packages/cli/src/runtime-host-access-command.ts b/packages/cli/src/runtime-host-access-command.ts index 40f4bd2079..47311f16af 100644 --- a/packages/cli/src/runtime-host-access-command.ts +++ b/packages/cli/src/runtime-host-access-command.ts @@ -45,6 +45,16 @@ const PROTOCOL = { max: RUNTIME_HOST_PROTOCOL_VERSION, } as const; +export class RuntimeHostAccessUnavailableError extends Error { + constructor( + readonly reason: string, + options?: ErrorOptions, + ) { + super(`Runtime Host service is not available (${reason})`, options); + this.name = 'RuntimeHostAccessUnavailableError'; + } +} + export interface RuntimeHostAccessIssueOptions { readonly rootPath: string; readonly expectedRootId?: string; @@ -350,12 +360,13 @@ export async function revokeRuntimeHostAccessCredential( } async function connectLocalOwner(rootPath: string, expectedRootId?: string) { - const result = await connectExistingRuntimeHost({ - rootPath, - protocol: PROTOCOL, - }); + const result = await connectExistingRuntimeHost({ rootPath, protocol: PROTOCOL }).catch( + (error: unknown) => { + throw new RuntimeHostAccessUnavailableError('connection_failed', { cause: error }); + }, + ); if (result.kind !== 'connected') { - throw new Error(`Runtime Host service is not available (${result.kind})`); + throw new RuntimeHostAccessUnavailableError(result.kind); } if (expectedRootId && result.connection.rootId !== expectedRootId) { await result.connection.close(); diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index 275213356a..5a933d9383 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -22,6 +22,7 @@ import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { generalizedErrorMessage } from '@maka/core/redaction'; import { activateRuntimeHostManagedDeployment, connectRemoteRuntimeHost, @@ -47,6 +48,7 @@ import { prepareRuntimeHostAccessCredential, replaceRuntimeHostAccessCredential, revokeRuntimeHostAccessCredential, + RuntimeHostAccessUnavailableError, type RuntimeHostAccessPreset, } from './runtime-host-access-command.js'; import { @@ -118,6 +120,8 @@ import { import { activateRuntimeHostManagedDeploymentWithReconciliation } from './runtime-host-activation-command.js'; const SETUP_LOCK_TIMEOUT_MS = 5 * 60_000; +const PAIRING_AVAILABILITY_TIMEOUT_MS = 10_000; +const PAIRING_AVAILABILITY_POLL_MS = 100; export interface RuntimeHostSetupCliOptions { readonly json: boolean; @@ -1075,20 +1079,35 @@ async function pairAndVerifyRuntimeHostSetup( const pairCredential = options.deferPairingCommit ? deps.prepareCredential : deps.replaceCredential; - paired = await pairCredential({ + const credentialInput = { rootPath: target.rootPath, - principalKind: 'remote_owner', + principalKind: 'remote_owner' as const, principalId: options.principalId, operationGrants: [], canPublishClientCapabilities: false, canUseHostPaths: false, preset: options.preset, ...(options.bindPairingToClient ? { bindClientInstance: true } : {}), - }); + }; + const deadline = Date.now() + PAIRING_AVAILABILITY_TIMEOUT_MS; + while (true) { + try { + paired = await pairCredential(credentialInput); + break; + } catch (error) { + if (!(error instanceof RuntimeHostAccessUnavailableError) || Date.now() >= deadline) { + throw error; + } + await new Promise((resolveWait) => + setTimeout(resolveWait, Math.min(PAIRING_AVAILABILITY_POLL_MS, deadline - Date.now())), + ); + } + } } catch (error) { + const reason = generalizedErrorMessage(error, 'Runtime Host access service is unavailable'); throw new RuntimeHostSetupError( 'pairing_failed', - 'Runtime Host could not pair the requested Client identity', + `Runtime Host could not pair the requested Client identity: ${reason}`, { cause: error }, ); } diff --git a/packages/runtime-host/src/__tests__/peer-mesh.test.ts b/packages/runtime-host/src/__tests__/peer-mesh.test.ts index 78c598fe6c..8ee98365f2 100644 --- a/packages/runtime-host/src/__tests__/peer-mesh.test.ts +++ b/packages/runtime-host/src/__tests__/peer-mesh.test.ts @@ -233,7 +233,7 @@ test('closed Mesh records do not permanently consume membership capacity', async if (index === 0) assert.equal(await hasActivePeerMeshMembership(root, 'peer-a'), false); } assert.equal((await node.create()).roster.roster.closed, false); - assert.equal(node.status().length, 16); + assert.equal(node.status().length, 1); } finally { await node.close(); await peer.close(); diff --git a/packages/runtime-host/src/peer-mesh/node.ts b/packages/runtime-host/src/peer-mesh/node.ts index edbbdcbe53..4aa43b614a 100644 --- a/packages/runtime-host/src/peer-mesh/node.ts +++ b/packages/runtime-host/src/peer-mesh/node.ts @@ -233,10 +233,7 @@ class PeerMeshNodeImpl implements PeerMeshNode { return Object.freeze( this.#store .read() - .meshes.filter( - (state) => - state.role === 'authority' || state.roster.roster.members.includes(identity.peerId), - ) + .meshes.filter((state) => isActiveMembership(state, identity.peerId)) .map((state) => peerMeshStatus(state, identity, this.#store.read().routes, this.#now())), ); }