From 3175aeb68a74d68aa09f855509568d19279c7e06 Mon Sep 17 00:00:00 2001 From: Wang Date: Sat, 29 Aug 2026 18:07:59 +0800 Subject: [PATCH 1/3] feat(desktop): unify Local Host service management Reuse the managed Runtime Host management surface for the built-in Local Host after remote access promotes it to a supervised service. Preserve Local lifecycle authority while routing service operations through the deployment-bound operator. Generated-by: OpenAI Codex --- .../runtime-host-local-management.test.ts | 119 +++++++ .../runtime-host-local-operator.test.ts | 85 +++++ .../__tests__/runtime-host-management.test.ts | 46 +++ apps/desktop/src/main/runtime-host-boot.ts | 22 ++ .../src/main/runtime-host-local-management.ts | 315 ++++++++++++++++++ .../src/main/runtime-host-local-operator.ts | 213 +++++++++++- .../main/runtime-host-local-remote-access.ts | 44 ++- .../main/runtime-host-management-provider.ts | 54 +++ .../src/main/runtime-host-management.ts | 80 ++++- apps/desktop/src/preload/bridge-contract.d.ts | 1 + apps/desktop/src/preload/preload.ts | 8 +- .../locales/settings-projects-copy.ts | 12 - .../runtime-host-management-dialog.tsx | 118 ++++--- .../runtime-host-profiles-section.tsx | 105 +++--- 14 files changed, 1089 insertions(+), 133 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/runtime-host-local-management.test.ts create mode 100644 apps/desktop/src/main/runtime-host-local-management.ts create mode 100644 apps/desktop/src/main/runtime-host-management-provider.ts diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-management.test.ts new file mode 100644 index 0000000000..596cb38bfa --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-local-management.test.ts @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { RuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator'; +import { createDesktopRuntimeHostLocalManagement } from '../runtime-host-local-management.js'; +import type { DesktopLocalRuntimeHostRemoteAccess } from '../runtime-host-local-remote-access.js'; +import type { createDesktopRuntimeHostLocalOperator } from '../runtime-host-local-operator.js'; + +test('manages the built-in Local profile through its managed-service authority', async () => { + const allowances: (boolean | undefined)[] = []; + const progress: unknown[] = []; + const reconnects: unknown[] = []; + const target = { + serviceId: 'a'.repeat(64), + rootPath: '/Users/ada/Library/Application Support/Maka/workspaces/default', + rootId: 'a'.repeat(64), + deploymentId: '11111111-1111-4111-8111-111111111111', + operatorPath: '/Users/ada/Library/Application Support/Maka/operator', + }; + const remoteAccess = { + manage: async ( + allowInterruptActiveTasks: boolean | undefined, + operation: (value: typeof target) => Promise, + ) => { + allowances.push(allowInterruptActiveTasks); + return { kind: 'complete' as const, value: await operation(target) }; + }, + uninstall: async (value: unknown) => + (value as { allowInterruptActiveTasks: boolean }).allowInterruptActiveTasks + ? { kind: 'uninstalled' as const } + : { kind: 'active_tasks' as const }, + } as unknown as DesktopLocalRuntimeHostRemoteAccess; + const operator = { + runService: async (input: { action: string }) => serviceResult(input.action), + runUpdate: async (_input: unknown, onProgress: (phase: 'staging') => void) => { + onProgress('staging'); + return updateResult(); + }, + } as unknown as ReturnType; + const provider = createDesktopRuntimeHostLocalManagement({ + remoteAccess, + operator, + rootPath: target.rootPath, + resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@0.3.0' }), + currentHostEpoch: () => 'before-update', + awaitUpdatedConnection: async (...args) => { + reconnects.push(args); + }, + sendProgress: (event) => progress.push(event), + }); + + const status = await provider.run('status'); + assert.equal(status.kind, 'result'); + if (status.kind === 'result') assert.equal(status.accessManagementAvailable, false); + + const updated = await provider.update(false); + assert.equal(updated.kind, 'result'); + assert.deepEqual(progress, [ + { profileId: 'local', phase: 'preparing_cli' }, + { profileId: 'local', phase: 'staging' }, + ]); + assert.deepEqual(reconnects, [['before-update', true]]); + + const blocked = await provider.run('uninstall'); + assert.equal(blocked.kind, 'error'); + const uninstalled = await provider.run('uninstall', true); + assert.deepEqual(uninstalled, { kind: 'uninstalled', retainedStateRoot: target.rootPath }); + assert.deepEqual(allowances, [undefined, false]); +}); + +function serviceResult(action: string): RuntimeHostServiceManagementFrame { + return { + schemaVersion: 1, + kind: 'result', + action: action as 'status', + service: serviceSummary('0.2.0'), + }; +} + +function updateResult(): RuntimeHostServiceManagementFrame { + return { + schemaVersion: 1, + kind: 'result', + action: 'update', + service: serviceSummary('0.3.0'), + update: { kind: 'updated', previousVersion: '0.2.0', targetVersion: '0.3.0' }, + }; +} + +function serviceSummary(version: string) { + return { + platform: 'darwin', + arch: 'arm64', + osRelease: '25.6.0', + state: 'running' as const, + pid: 42, + lastExitCode: 0, + installedVersion: version, + projectDirectoryRoots: [], + }; +} 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 3fb3caa39c..b4155e9947 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 @@ -24,7 +24,9 @@ import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import test from 'node:test'; import { + encodeRuntimeHostServiceManagementFrame, encodeRuntimeHostSetupFrame, + RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, } from '@maka/runtime-host/operator'; import { @@ -119,3 +121,86 @@ test('local setup forwards the exact development archive evidence', async (t) => assert.equal(environment?.[RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV], integrity); }); + +test('local update runs the selected package against the exact managed deployment', async (t) => { + let executable: string | undefined; + let args: readonly string[] | undefined; + let environment: NodeJS.ProcessEnv | undefined; + const phases: string[] = []; + const spawnProcess = ((command, commandArgs, options) => { + executable = command; + args = commandArgs; + environment = options?.env; + const child = new EventEmitter() as ReturnType; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + Object.assign(child, { pid: 1234, stdout, stderr, kill: () => true }); + process.nextTick(() => { + stdout.end( + encodeRuntimeHostServiceManagementFrame({ + schemaVersion: 1, + kind: 'progress', + action: 'update', + phase: 'staging', + currentVersion: '0.2.0', + targetVersion: '0.3.0', + }) + + encodeRuntimeHostServiceManagementFrame({ + schemaVersion: 1, + kind: 'result', + action: 'update', + service: { + platform: 'darwin', + arch: 'arm64', + osRelease: '25.6.0', + state: 'running', + pid: 42, + lastExitCode: 0, + installedVersion: '0.3.0', + projectDirectoryRoots: [], + }, + update: { kind: 'updated', previousVersion: '0.2.0', targetVersion: '0.3.0' }, + }), + ); + stderr.end(); + child.emit('close', 0, null); + }); + return child; + }) as typeof spawn; + const operator = createDesktopRuntimeHostLocalOperator({ + environment: { PATH: process.env.PATH }, + spawnProcess, + }); + t.after(() => operator.close()); + const deploymentId = '00000000-0000-4000-8000-000000000001'; + + await operator.runUpdate( + { + setupPackage: { kind: 'npm', specifier: 'maka-agent@0.3.0' }, + target: { + serviceId: 'a'.repeat(64), + rootPath: '/tmp/maka/root', + rootId: 'a'.repeat(64), + deploymentId, + }, + }, + (phase) => phases.push(phase), + ); + + assert.equal(executable, 'npm'); + assert.deepEqual(args, [ + '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), + '--expected-deployment-id', deploymentId, + ]); + assert.equal( + environment?.[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV], + '1', + ); + assert.deepEqual(phases, ['staging']); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index 5e3ee971dd..163964b55f 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -25,6 +25,7 @@ import { type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; import { createDesktopRuntimeHostManagement } from '../runtime-host-management.js'; +import type { DesktopRuntimeHostManagementProvider } from '../runtime-host-management-provider.js'; import type { DesktopRuntimeHostSshAccessInput, DesktopRuntimeHostSshCleanupInput, @@ -36,6 +37,51 @@ import type { const DEPLOYMENT_ID = '11111111-1111-4111-8111-111111111111'; +test('routes built-in Local management through its provider with explicit interruption authority', async () => { + const handlers = new Map unknown>(); + const calls: unknown[] = []; + const provider = { + profileId: 'local', + run: async (action: string, allowInterruptActiveTasks?: boolean) => { + calls.push([action, allowInterruptActiveTasks]); + return action === 'uninstall' + ? { kind: 'uninstalled' as const, retainedStateRoot: '/state' } + : serviceResult(action as DesktopRuntimeHostSshManagementInput['action']); + }, + } as unknown as DesktopRuntimeHostManagementProvider; + createDesktopRuntimeHostManagement({ + ...unusedUpdateDependencies(), + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), + removeHandler: (channel) => handlers.delete(channel), + }, + profiles: { + ...unusedDirectPeerProfileDependencies(), + resolveManagedService: async () => assert.fail('Local must not resolve an SSH service'), + resolveManagedAccess: async () => assert.fail('Local must not resolve SSH access'), + markManagedServiceUninstalling: async () => assert.fail('Local must not mutate SSH state'), + markManagedServiceCleanupPending: async () => assert.fail('Local must not mutate SSH state'), + clearManagedServiceBinding: async () => assert.fail('Local must not mutate SSH state'), + rotateManagedCredential: async () => assert.fail('Local must not mutate SSH state'), + }, + runServiceManagement: async () => assert.fail('Local must not use SSH transport'), + runAccessManagement: async () => assert.fail('Local must not use SSH transport'), + cleanupManagedDeployment: async () => assert.fail('Local must not use SSH transport'), + providers: [provider], + }); + + const run = handlers.get('runtime-host-management:run'); + assert.ok(run); + await run({}, 'local', 'status'); + await run({}, 'local', 'uninstall', true); + + assert.deepEqual(calls, [['status', false], ['uninstall', true]]); + assert.throws( + () => run({}, 'local', 'restart', true), + /authority is not valid for this action/u, + ); +}); + test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => { const handlers = new Map unknown>(); const profile = { diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 8a73c0be66..3661d4d749 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -186,6 +186,7 @@ import { createDesktopRuntimeHostLocalOperator } from './runtime-host-local-oper import { createDesktopLocalRuntimeHostRemoteAccess } from './runtime-host-local-remote-access.js'; import { createDesktopRuntimeHostOnboarding } from "./runtime-host-onboarding.js"; import { createDesktopRuntimeHostManagement } from "./runtime-host-management.js"; +import { createDesktopRuntimeHostLocalManagement } from './runtime-host-local-management.js'; import { createDesktopRuntimeHostPeerMeshManagement } from './runtime-host-peer-mesh-management.js'; import { registerRuntimeHostOAuthIpc } from "./runtime-host-oauth-ipc-main.js"; import { RuntimeHostOAuthPresentation } from "./runtime-host-oauth-presentation.js"; @@ -513,6 +514,26 @@ const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({ send: (snapshot) => mainWindowController.send("runtime-host-onboarding:changed", snapshot), }); +const localRuntimeHostManagement = createDesktopRuntimeHostLocalManagement({ + remoteAccess: localRuntimeHostRemoteAccess, + operator: localRuntimeHostOperator, + rootPath: startupLocalStorageRoot.canonicalPath, + resolveUpdatePackage: () => runtimeHostSetupPackage.resolve( + desktopRuntimeHostDevelopmentPeerTarget(), + ), + currentHostEpoch: () => + runtimeHostManager?.current('local')?.candidate?.client.hostEpoch, + awaitUpdatedConnection: async (previousHostEpoch, replacementExpected) => { + if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable'); + await runtimeHostManager.waitUntilReady( + 'local', + replacementExpected ? previousHostEpoch : undefined, + AbortSignal.timeout(MANAGED_UPDATE_RECONNECT_TIMEOUT_MS), + ); + }, + sendProgress: (progress) => + mainWindowController.send('runtime-host-management:progress', progress), +}); const runtimeHostManagement = createDesktopRuntimeHostManagement({ ipcMain, profiles: runtimeHostProfileService, @@ -568,6 +589,7 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ mainWindowController.send("runtime-host-management:progress", progress), runAccessManagement: runtimeHostSshTerminal.runAccessManagement, cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment, + providers: [localRuntimeHostManagement], }); const runtimeHostPeerMeshManagement = createDesktopRuntimeHostPeerMeshManagement({ ipcMain, diff --git a/apps/desktop/src/main/runtime-host-local-management.ts b/apps/desktop/src/main/runtime-host-local-management.ts new file mode 100644 index 0000000000..14d00ea179 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-local-management.ts @@ -0,0 +1,315 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + RuntimeHostManagedUpdatePolicy, + RuntimeHostServiceManagementFrame, +} from '@maka/runtime-host/operator'; +import type { + DesktopRuntimeHostDirectPeerSnapshot, + DesktopRuntimeHostManagementAction, + DesktopRuntimeHostManagementProgress, + DesktopRuntimeHostManagementResponse, + DesktopRuntimeHostUpdatePolicySnapshot, + DesktopRuntimeHostUpdateReconciliationResponse, +} from '../preload/bridge-contract.js'; +import type { DesktopLocalRuntimeHostRemoteAccess } from './runtime-host-local-remote-access.js'; +import type { createDesktopRuntimeHostLocalOperator } from './runtime-host-local-operator.js'; +import type { DesktopRuntimeHostManagementProvider } from './runtime-host-management-provider.js'; +import type { DesktopRuntimeHostSetupPackage } from './runtime-host-setup-package.js'; + +type LocalOperator = ReturnType; + +export function createDesktopRuntimeHostLocalManagement(input: { + readonly remoteAccess: DesktopLocalRuntimeHostRemoteAccess; + readonly operator: LocalOperator; + readonly rootPath: string; + readonly resolveUpdatePackage: () => + | DesktopRuntimeHostSetupPackage + | Promise; + readonly currentHostEpoch: () => string | undefined; + readonly awaitUpdatedConnection: ( + previousHostEpoch: string | undefined, + replacementExpected: boolean, + ) => Promise; + readonly sendProgress: (progress: DesktopRuntimeHostManagementProgress) => void; +}): DesktopRuntimeHostManagementProvider { + const withAccessFlag = ( + frame: Exclude, + ): DesktopRuntimeHostManagementResponse => { + if ( + frame.action !== 'status' && + frame.action !== 'start' && + frame.action !== 'restart' && + frame.action !== 'logs' && + frame.action !== 'install' && + frame.action !== 'uninstall' && + frame.action !== 'configure' && + frame.action !== 'update' + ) { + throw new Error('Local Runtime Host returned an unrelated management result'); + } + return (frame.kind === 'result' + ? { ...frame, accessManagementAvailable: false } + : frame) as DesktopRuntimeHostManagementResponse; + }; + + const activeTasks = ( + action: DesktopRuntimeHostManagementAction | 'configure' | 'update', + ): DesktopRuntimeHostManagementResponse => ({ + schemaVersion: 1, + kind: 'error', + action, + error: { + code: 'active_tasks', + message: 'Runtime Host still owns active work', + }, + }); + + const runService = async ( + action: Exclude, + ): Promise => { + const changed = await input.remoteAccess.manage( + action === 'status' || action === 'logs' ? undefined : false, + (target) => input.operator.runService({ + operatorPath: target.operatorPath, + action, + target, + }), + ); + if (changed.kind === 'active_tasks') return activeTasks(action); + const frame = requireTerminalFrame(changed.value, action); + return withAccessFlag(frame); + }; + + const reconnect = async ( + response: DesktopRuntimeHostManagementResponse, + previousHostEpoch: string | undefined, + replacementExpected: boolean, + ): Promise => { + if (response.kind !== 'result') return response; + try { + await input.awaitUpdatedConnection(previousHostEpoch, replacementExpected); + return response; + } catch (error) { + return { + ...response, + reconnectError: { + code: 'desktop_reconnect_failed', + message: + 'The Runtime Host change was applied, but Desktop could not reconnect: ' + + (error instanceof Error ? error.message : String(error)), + }, + }; + } + }; + + const updatePolicy = async ( + policy?: RuntimeHostManagedUpdatePolicy, + ): Promise => { + const managed = await input.remoteAccess.manage(undefined, async (target) => { + if (policy && policy.kind !== 'manual') { + const current = requireTerminalFrame( + await input.operator.runUpdatePolicy({ + operatorPath: target.operatorPath, + target, + }), + 'update_policy', + ); + if (current.kind === 'error') throw new Error(current.error.message); + if (current.updateSchedulerState === undefined) { + throw new Error('Update or repair this Runtime Host before enabling automatic updates'); + } + } + return requireTerminalFrame( + await input.operator.runUpdatePolicy({ + operatorPath: target.operatorPath, + target, + ...(policy ? { policy } : {}), + }), + 'update_policy', + ); + }); + if (managed.kind === 'active_tasks') throw new Error('Runtime Host still owns active work'); + if (managed.value.kind === 'error') throw new Error(managed.value.error.message); + return projectUpdatePolicy(managed.value); + }; + + return { + profileId: 'local', + run: async (action, allowInterruptActiveTasks = false) => { + if (action !== 'uninstall') return runService(action); + const result = await input.remoteAccess.uninstall({ allowInterruptActiveTasks }); + return result.kind === 'active_tasks' + ? activeTasks(action) + : { kind: 'uninstalled', retainedStateRoot: input.rootPath }; + }, + update: async (allowInterruptActiveTasks) => { + const previousHostEpoch = input.currentHostEpoch(); + input.sendProgress({ profileId: 'local', phase: 'preparing_cli' }); + const setupPackage = await input.resolveUpdatePackage(); + const changed = await input.remoteAccess.manage( + allowInterruptActiveTasks, + (target) => input.operator.runUpdate( + { + setupPackage, + target, + ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), + }, + (phase) => input.sendProgress({ profileId: 'local', phase }), + ), + ); + if (changed.kind === 'active_tasks') return activeTasks('update'); + const frame = requireTerminalFrame(changed.value, 'update'); + const response = withAccessFlag(frame); + const replacementExpected = + frame.kind === 'result' && + frame.action === 'update' && + frame.update.kind !== 'active_tasks' && + frame.update.kind !== 'already_current'; + return replacementExpected + ? reconnect(response, previousHostEpoch, true) + : response; + }, + configureProjectDirectories: async ( + roots, + expectedConfigFingerprint, + allowInterruptActiveTasks, + ) => { + const previousHostEpoch = input.currentHostEpoch(); + const changed = await input.remoteAccess.manage( + allowInterruptActiveTasks, + (target) => input.operator.runService({ + operatorPath: target.operatorPath, + action: 'configure', + target, + projectDirectoryRoots: roots, + expectedConfigFingerprint, + ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), + }), + ); + if (changed.kind === 'active_tasks') return activeTasks('configure'); + const frame = requireTerminalFrame(changed.value, 'configure'); + const response = withAccessFlag(frame); + return frame.kind === 'result' && + frame.action === 'configure' && + frame.configuration.kind === 'configured' + ? reconnect(response, previousHostEpoch, true) + : response; + }, + getUpdatePolicy: () => updatePolicy(), + setUpdatePolicy: (policy) => updatePolicy(policy), + reconcileUpdate: async (): Promise => { + const previousHostEpoch = input.currentHostEpoch(); + const changed = await input.remoteAccess.manage(false, (target) => + input.operator.runUpdateReconciliation( + { operatorPath: target.operatorPath, target }, + (phase) => input.sendProgress({ profileId: 'local', phase }), + )); + if (changed.kind === 'active_tasks') { + return { + kind: 'error', + error: { code: 'active_tasks', message: 'Runtime Host still owns active work' }, + }; + } + const frame = requireTerminalFrame(changed.value, 'reconcile_update'); + if (frame.kind === 'error') return { kind: 'error', error: frame.error }; + const response: DesktopRuntimeHostUpdateReconciliationResponse = { + kind: 'result', + updatePolicy: projectUpdatePolicy(frame), + reconciliation: frame.reconciliation, + ...(frame.service ? { service: frame.service } : {}), + }; + if ( + frame.reconciliation.kind !== 'updated' && + frame.reconciliation.kind !== 'repaired' + ) { + return response; + } + try { + await input.awaitUpdatedConnection(previousHostEpoch, true); + return response; + } catch (error) { + return { + ...response, + reconnectError: { + code: 'desktop_reconnect_failed', + message: + 'The Runtime Host change was applied, but Desktop could not reconnect: ' + + (error instanceof Error ? error.message : String(error)), + }, + }; + } + }, + getDirectPeer: async (): Promise => { + const snapshot = await input.remoteAccess.getSnapshot(); + return { + state: + snapshot.state === 'on' + ? 'enabled' + : snapshot.state === 'off' && snapshot.managedService + ? 'disabled' + : 'unsupported', + routeHints: [], + coordinationRelays: [], + automaticRelayDiscovery: false, + profilePresent: true, + profileEnabled: false, + clientAvailable: false, + managementAvailable: false, + }; + }, + configureDirectPeer: async () => { + throw new Error('Manage access to this computer from the Remote access controls'); + }, + listCredentials: async () => { + throw new Error('Manage access to this computer from the Remote access controls'); + }, + rotateCredential: async () => { + throw new Error('The Local Runtime Host does not use a remote profile credential'); + }, + revokeCredential: async () => { + throw new Error('Manage access to this computer from the Remote access controls'); + }, + }; +} + +function requireTerminalFrame( + frame: RuntimeHostServiceManagementFrame, + action: Action, +): Exclude & { readonly action: Action } { + if (frame.kind === 'progress' || frame.action !== action) { + throw new Error('Local Runtime Host returned an unrelated management result'); + } + return frame as Exclude & { + readonly action: Action; + }; +} + +function projectUpdatePolicy( + frame: Extract, +): DesktopRuntimeHostUpdatePolicySnapshot { + if (frame.updateSchedulerState === undefined) { + return { ...frame.updatePolicy, schedulingState: 'unsupported' }; + } + return { ...frame.updatePolicy, schedulingState: frame.updateSchedulerState }; +} diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index 5bed92f9c6..e227c2dfe5 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -31,14 +31,19 @@ import { decodeRuntimeHostPeerManagementFrame, decodeRuntimeHostServiceManagementFrame, decodeRuntimeHostSetupFrame, + RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, type RuntimeHostAccessManagementFrame, + type RuntimeHostManagedUpdatePolicy, type RuntimeHostPeerManagementFrame, type RuntimeHostServiceManagementFrame, + type RuntimeHostServiceUpdatePhase, type RuntimeHostSetupFrame, } from '@maka/runtime-host/operator'; import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; @@ -73,6 +78,25 @@ export interface DesktopRuntimeHostLocalSetupCommand { readonly args: readonly string[]; } +export interface DesktopRuntimeHostLocalServiceManagementInput { + readonly operatorPath: string; + readonly action: + | 'status' + | 'start' + | 'restart' + | 'logs' + | 'install' + | 'configure' + | 'retire' + | 'uninstall'; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + readonly expectedConfigFingerprint?: string; + readonly allowInterruptActiveTasks?: boolean; + readonly retainManagedDeployment?: boolean; + readonly signal?: AbortSignal; +} + export function runtimeHostLocalSetupCommand(input: { readonly packageSpecifier: string; readonly clientDataRoot: string; @@ -148,14 +172,32 @@ export function createDesktopRuntimeHostLocalOperator(input: { readonly target: DesktopRuntimeHostLocalServiceTarget; readonly signal?: AbortSignal; }): Promise; - runService(input: { + runService( + input: DesktopRuntimeHostLocalServiceManagementInput, + ): Promise; + runUpdate( + input: { + readonly setupPackage: DesktopRuntimeHostSetupPackage; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly allowInterruptActiveTasks?: boolean; + readonly signal?: AbortSignal; + }, + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; + runUpdatePolicy(input: { readonly operatorPath: string; - readonly action: 'status' | 'retire' | 'uninstall'; readonly target: DesktopRuntimeHostLocalServiceTarget; - readonly allowInterruptActiveTasks?: boolean; - readonly retainManagedDeployment?: boolean; + readonly policy?: RuntimeHostManagedUpdatePolicy; readonly signal?: AbortSignal; }): Promise; + runUpdateReconciliation( + input: { + readonly operatorPath: string; + readonly target: DesktopRuntimeHostLocalServiceTarget; + readonly signal?: AbortSignal; + }, + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; cleanupManagedDeployment(input: { readonly operatorPath: string; readonly target: DesktopRuntimeHostLocalServiceTarget; @@ -272,6 +314,17 @@ export function createDesktopRuntimeHostLocalOperator(input: { args: [ command.action, '--framed', + ...(command.projectDirectoryRoots === undefined + ? [] + : command.projectDirectoryRoots.length === 0 + ? ['--no-project-roots'] + : command.projectDirectoryRoots.flatMap(({ label, path }) => [ + '--project-root-json', + JSON.stringify({ label, path }), + ])), + ...(command.expectedConfigFingerprint + ? ['--expected-config-fingerprint', command.expectedConfigFingerprint] + : []), ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ...(command.retainManagedDeployment ? ['--retain-managed-deployment'] : []), ...managedTargetArgs(command.target), @@ -280,7 +333,10 @@ export function createDesktopRuntimeHostLocalOperator(input: { prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, decode: decodeRuntimeHostServiceManagementFrame, label: 'Local Runtime Host service management', - environment: input.environment ?? process.env, + environment: { + ...(input.environment ?? process.env), + [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1', + }, spawnProcess: input.spawnProcess ?? spawn, timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, terminate, @@ -288,6 +344,108 @@ export function createDesktopRuntimeHostLocalOperator(input: { active, }).then((frame) => requireServiceFrame(frame, command.action)); }, + runUpdate(command, onProgress) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + const deploymentId = command.target.deploymentId; + if (!deploymentId) { + return Promise.reject(new Error('Runtime Host update requires a deployment generation')); + } + const setupPackage = resolveLocalSetupPackage(command.setupPackage); + return runServiceFrameProcess({ + command: { + executable: 'npm', + args: [ + 'exec', + '--yes', + '--package', + setupPackage.specifier, + '--', + 'maka', + 'runtime-host', + 'service', + 'update', + '--framed', + '--managed-root-id', + command.target.rootId, + '--operator-deployment-id', + deploymentId, + ...managedTargetArgs(command.target), + ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), + ], + }, + environment: { + ...(input.environment ?? process.env), + ...(setupPackage.integrity + ? { [RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV]: setupPackage.integrity } + : {}), + [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1', + }, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + action: 'update', + onProgress, + }); + }, + runUpdatePolicy(command) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + const policy = command.policy; + return runServiceFrameProcess({ + command: { + executable: command.operatorPath, + args: [ + 'update-policy', + '--framed', + ...(policy + ? [ + '--target', + policy.kind === 'channel' + ? policy.channel + : policy.kind === 'fixed' + ? policy.version + : 'manual', + ] + : []), + ...managedTargetArgs(command.target), + ], + }, + environment: { + ...(input.environment ?? process.env), + [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]: + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + }, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + action: 'update_policy', + }); + }, + runUpdateReconciliation(command, onProgress) { + if (closed) throw new Error('Local Runtime Host operator is closed'); + return runServiceFrameProcess({ + command: { + executable: command.operatorPath, + args: ['reconcile-update', '--framed', ...managedTargetArgs(command.target)], + }, + environment: { + ...(input.environment ?? process.env), + [RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV]: '1', + [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]: + RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + }, + spawnProcess: input.spawnProcess ?? spawn, + timeoutMs: input.setupTimeoutMs ?? SETUP_TIMEOUT_MS, + terminate, + signal: combinedSignal(command.signal, closing.signal), + active, + action: 'reconcile_update', + onProgress, + }); + }, async cleanupManagedDeployment(command) { if (closed) throw new Error('Local Runtime Host operator is closed'); try { @@ -357,7 +515,7 @@ function requireAccessListFrame( function requireServiceFrame( frame: RuntimeHostServiceManagementFrame, - action: 'status' | 'retire' | 'uninstall', + action: DesktopRuntimeHostLocalServiceManagementInput['action'], ): RuntimeHostServiceManagementFrame { if (frame.action !== action) { throw new Error('Local Runtime Host service management returned an unrelated result'); @@ -365,6 +523,49 @@ function requireServiceFrame( return frame; } +function runServiceFrameProcess(input: { + readonly command: DesktopRuntimeHostLocalSetupCommand; + readonly environment: NodeJS.ProcessEnv; + readonly spawnProcess: typeof spawn; + readonly timeoutMs: number; + readonly terminate: typeof terminateChildProcessTree; + readonly signal?: AbortSignal; + readonly active: Set; + readonly action: RuntimeHostServiceManagementFrame['action']; + readonly onProgress?: (phase: RuntimeHostServiceUpdatePhase) => void; +}): Promise { + let result: RuntimeHostServiceManagementFrame | undefined; + let failure: Error | undefined; + return runFramedProcess({ + ...input, + prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + decode: decodeRuntimeHostServiceManagementFrame, + label: 'Local Runtime Host service management', + onFrame(frame) { + if (frame.action !== input.action) { + failure = new Error('Local Runtime Host service management returned an unrelated result'); + return; + } + if (frame.kind === 'progress') { + if (!input.onProgress) { + failure = new Error('Local Runtime Host service management returned unexpected progress'); + } else { + input.onProgress(frame.phase); + } + return; + } + if (result) { + failure = new Error('Local Runtime Host service management returned multiple results'); + } else { + result = frame; + } + }, + result: () => result, + failure: () => failure, + acceptNonzeroResult: true, + }); +} + function combinedSignal( operation: AbortSignal | undefined, closing: AbortSignal, 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 ab6659d08d..34690ad7cd 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -83,6 +83,30 @@ type LocalManagedDeploymentAuthority = | { readonly kind: 'active'; readonly target: LocalServiceTarget } | { readonly kind: 'transition' }; +export interface DesktopRuntimeHostLocalManagementTarget + extends DesktopRuntimeHostLocalServiceTarget { + readonly operatorPath: string; + readonly deploymentId: string; +} + +export type DesktopRuntimeHostLocalManagementResult = + | { readonly kind: 'active_tasks' } + | { readonly kind: 'complete'; readonly value: T }; + +export interface DesktopLocalRuntimeHostRemoteAccess { + getSnapshot(): Promise; + enable(value: unknown): Promise; + disable(): Promise; + uninstall(value: unknown): Promise<{ readonly kind: 'active_tasks' | 'uninstalled' }>; + manage( + allowInterruptActiveTasks: boolean | undefined, + operation: (target: DesktopRuntimeHostLocalManagementTarget) => Promise, + ): Promise>; + recoverManagedSetup(signal?: AbortSignal): Promise; + recover(): Promise; + close(): Promise; +} + interface LocalServicePeerChanging extends LocalServiceTarget { readonly state: 'peerChanging'; readonly peerEnabled: boolean; @@ -130,11 +154,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { readonly resolveManagedDeploymentAuthority?: ( rootId: string, ) => Promise; -}): { - recoverManagedSetup(signal?: AbortSignal): Promise; - recover(): Promise; - close(): Promise; -} { +}): DesktopLocalRuntimeHostRemoteAccess { const lifecyclePath = join(input.clientDataRoot, LIFECYCLE_FILE); const closing = new AbortController(); let mutation = Promise.resolve(); @@ -619,6 +639,20 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { input.ipcMain.handle(channels[5], (_event, value: unknown) => uninstall(value)); return { + getSnapshot, + enable, + disable, + uninstall, + manage: (allowInterruptActiveTasks, operation) => + serialize(async () => { + const managed = requireManaged( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + if (allowInterruptActiveTasks === undefined) { + return { kind: 'complete', value: await operation(managed) }; + } + return runManagedServiceChange(allowInterruptActiveTasks, () => operation(managed)); + }), recoverManagedSetup: async (signal) => { if (!supported(input.directPeerAvailable)) return false; const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal; diff --git a/apps/desktop/src/main/runtime-host-management-provider.ts b/apps/desktop/src/main/runtime-host-management-provider.ts new file mode 100644 index 0000000000..3d103977d4 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-management-provider.ts @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { RuntimeHostManagedUpdatePolicy } from '@maka/runtime-host/operator'; +import type { + DesktopRuntimeHostAccessSnapshot, + DesktopRuntimeHostDirectPeerSnapshot, + DesktopRuntimeHostManagementAction, + DesktopRuntimeHostManagementResponse, + DesktopRuntimeHostUpdatePolicySnapshot, + DesktopRuntimeHostUpdateReconciliationResponse, +} from '../preload/bridge-contract.js'; + +export interface DesktopRuntimeHostManagementProvider { + readonly profileId: string; + run( + action: DesktopRuntimeHostManagementAction, + allowInterruptActiveTasks?: boolean, + ): Promise; + update(allowInterruptActiveTasks: boolean): Promise; + configureProjectDirectories( + roots: readonly { readonly label: string; readonly path: string }[], + expectedConfigFingerprint: string, + allowInterruptActiveTasks: boolean, + ): Promise; + getUpdatePolicy(): Promise; + setUpdatePolicy(policy: RuntimeHostManagedUpdatePolicy): Promise; + reconcileUpdate(): Promise; + getDirectPeer(): Promise; + configureDirectPeer( + enabled: boolean, + coordinationRelays: readonly string[], + automaticRelayDiscovery: boolean, + ): Promise; + listCredentials(): Promise; + rotateCredential(): Promise; + revokeCredential(credentialId: string): Promise; +} diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index 4ae98e5fe4..f5eb834c21 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -57,6 +57,7 @@ import type { DesktopRuntimeHostDevelopmentPeerTarget, DesktopRuntimeHostSetupPackage, } from './runtime-host-setup-package.js'; +import type { DesktopRuntimeHostManagementProvider } from './runtime-host-management-provider.js'; const MANAGEMENT_ACTIONS = new Set([ 'status', @@ -129,7 +130,11 @@ export function createDesktopRuntimeHostManagement(input: { readonly cleanupManagedDeployment: ( input: DesktopRuntimeHostSshCleanupInput, ) => Promise; + readonly providers?: readonly DesktopRuntimeHostManagementProvider[]; }): { close(): void } { + const providers = new Map( + (input.providers ?? []).map((provider) => [provider.profileId, provider] as const), + ); const requireProfileId = (value: unknown): string => { if (typeof value !== 'string' || value.length === 0 || value.length > 128) { throw new Error('Runtime Host profile ID is invalid'); @@ -146,6 +151,7 @@ export function createDesktopRuntimeHostManagement(input: { const runManagedAction = async ( profileId: string, managementAction: DesktopRuntimeHostManagementAction, + allowInterruptActiveTasks = false, ): Promise => { const managed = await resolveManagedService(profileId); const { profile, deployment, control } = managed; @@ -183,6 +189,9 @@ export function createDesktopRuntimeHostManagement(input: { websocketPath: profile.transport.websocketPath, } : {}), + ...(managementAction === 'uninstall' && allowInterruptActiveTasks + ? { allowInterruptActiveTasks: true } + : {}), }; if (managementAction !== 'uninstall') { const response = await input.runServiceManagement(managementInput); @@ -238,16 +247,27 @@ export function createDesktopRuntimeHostManagement(input: { const run = ( profileIdValue: unknown, action: unknown, + allowInterruptActiveTasksValue: unknown = false, ): Promise => { if (!MANAGEMENT_ACTIONS.has(action as DesktopRuntimeHostManagementAction)) { throw new Error('Runtime Host service management action is invalid'); } const profileId = requireProfileId(profileIdValue); const managementAction = action as DesktopRuntimeHostManagementAction; - if (managementAction !== 'status') return runManagedAction(profileId, managementAction); + if (typeof allowInterruptActiveTasksValue !== 'boolean') { + throw new Error('Runtime Host interruption authority is invalid'); + } + if (managementAction !== 'uninstall' && allowInterruptActiveTasksValue) { + throw new Error('Runtime Host interruption authority is not valid for this action'); + } + const provider = providers.get(profileId); + const execute = () => provider + ? provider.run(managementAction, allowInterruptActiveTasksValue) + : runManagedAction(profileId, managementAction, allowInterruptActiveTasksValue); + if (managementAction !== 'status') return execute(); const existing = statusRequests.get(profileId); if (existing) return existing; - const request = runManagedAction(profileId, managementAction); + const request = execute(); statusRequests.set(profileId, request); const forget = () => { if (statusRequests.get(profileId) === request) statusRequests.delete(profileId); @@ -370,6 +390,9 @@ export function createDesktopRuntimeHostManagement(input: { const getDirectPeer = async ( profileIdValue: unknown, ): Promise => { + const providerProfileId = requireProfileId(profileIdValue); + const provider = providers.get(providerProfileId); + if (provider) return provider.getDirectPeer(); const { profileId, managed, transport, expectedTarget, available } = await peerManagementTarget(profileIdValue); if (!available) return unavailablePeerSnapshot(profileId); @@ -403,6 +426,15 @@ export function createDesktopRuntimeHostManagement(input: { if (typeof automaticRelayDiscoveryValue !== 'boolean') { throw new Error('Runtime Host relay discovery state is invalid'); } + const providerProfileId = requireProfileId(profileIdValue); + const provider = providers.get(providerProfileId); + if (provider) { + return provider.configureDirectPeer( + enabledValue, + coordinationRelays, + automaticRelayDiscoveryValue, + ); + } const { profileId, managed, transport, expectedTarget, available } = await peerManagementTarget(profileIdValue); if (!available) { @@ -480,6 +512,9 @@ export function createDesktopRuntimeHostManagement(input: { if (typeof allowInterruptActiveTasksValue !== 'boolean') { throw new Error('Runtime Host update interruption authority is invalid'); } + const providerProfileId = requireProfileId(profileIdValue); + const provider = providers.get(providerProfileId); + if (provider) return provider.update(allowInterruptActiveTasksValue); const { profileId, managed, transport, expectedTarget } = await managedMutationTarget(profileIdValue); const previousHostEpoch = input.currentHostEpoch(profileId); @@ -538,6 +573,15 @@ export function createDesktopRuntimeHostManagement(input: { if (typeof allowInterruptActiveTasksValue !== 'boolean') { throw new Error('Runtime Host configuration interruption authority is invalid'); } + const providerProfileId = requireProfileId(profileIdValue); + const provider = providers.get(providerProfileId); + if (provider) { + return provider.configureProjectDirectories( + roots, + expectedConfigFingerprintValue, + allowInterruptActiveTasksValue, + ); + } const { profileId, managed, transport, expectedTarget } = await managedMutationTarget(profileIdValue); const previousHostEpoch = input.currentHostEpoch(profileId); @@ -607,8 +651,15 @@ export function createDesktopRuntimeHostManagement(input: { profileIdValue: unknown, policyValue?: unknown, ): Promise => { - const { managed, transport, expectedTarget } = await managedMutationTarget(profileIdValue); const policy = policyValue === undefined ? undefined : requireUpdatePolicy(policyValue); + const providerProfileId = requireProfileId(profileIdValue); + const provider = providers.get(providerProfileId); + if (provider) { + return policy === undefined + ? provider.getUpdatePolicy() + : provider.setUpdatePolicy(policy); + } + const { managed, transport, expectedTarget } = await managedMutationTarget(profileIdValue); const common = { destination: transport.destination, ...(transport.sshPort === undefined @@ -637,6 +688,9 @@ export function createDesktopRuntimeHostManagement(input: { const reconcileUpdate = async ( profileIdValue: unknown, ): Promise => { + const providerProfileId = requireProfileId(profileIdValue); + const provider = providers.get(providerProfileId); + if (provider) return provider.reconcileUpdate(); const { profileId, managed, transport, expectedTarget } = await managedMutationTarget(profileIdValue); const previousHostEpoch = input.currentHostEpoch(profileId); @@ -691,6 +745,9 @@ export function createDesktopRuntimeHostManagement(input: { const listCredentials = async ( profileId: unknown, ): Promise => { + const resolvedProfileId = requireProfileId(profileId); + const provider = providers.get(resolvedProfileId); + if (provider) return provider.listCredentials(); const access = await resolveAccess(profileId); const response = await input.runAccessManagement({ ...access.target, @@ -710,6 +767,9 @@ export function createDesktopRuntimeHostManagement(input: { const rotateCredential = async ( profileId: unknown, ): Promise => { + const resolvedProfileId = requireProfileId(profileId); + const provider = providers.get(resolvedProfileId); + if (provider) return provider.rotateCredential(); const access = await resolveAccess(profileId); if (!access.canRotate) { throw new Error('Enable this Runtime Host before rotating its access credential'); @@ -765,6 +825,9 @@ export function createDesktopRuntimeHostManagement(input: { if (typeof credentialId !== 'string' || credentialId.length === 0 || credentialId.length > 128) { throw new Error('Runtime Host access credential ID is invalid'); } + const resolvedProfileId = requireProfileId(profileId); + const provider = providers.get(resolvedProfileId); + if (provider) return provider.revokeCredential(credentialId); const access = await resolveAccess(profileId); const response = await input.runAccessManagement({ ...access.target, @@ -796,8 +859,15 @@ export function createDesktopRuntimeHostManagement(input: { getDirectPeer: 'runtime-host-management:get-direct-peer', configureDirectPeer: 'runtime-host-management:configure-direct-peer', } as const; - input.ipcMain.handle(channels.run, (_event, profileId: unknown, action: unknown) => - run(profileId, action)); + input.ipcMain.handle( + channels.run, + ( + _event, + profileId: unknown, + action: unknown, + allowInterruptActiveTasks: unknown, + ) => run(profileId, action, allowInterruptActiveTasks), + ); input.ipcMain.handle( channels.update, (_event, profileId: unknown, allowInterruptActiveTasks: unknown) => diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index a639c67fab..410e617829 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -707,6 +707,7 @@ export interface MakaBridge { run( profileId: string, action: DesktopRuntimeHostManagementAction, + allowInterruptActiveTasks?: boolean, ): Promise; update( profileId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 9f59cfad0a..31b0ca1051 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1295,8 +1295,14 @@ const makaBridge = { run( profileId: string, action: DesktopRuntimeHostManagementAction, + allowInterruptActiveTasks = false, ): Promise { - return ipcRenderer.invoke('runtime-host-management:run', profileId, action); + return ipcRenderer.invoke( + 'runtime-host-management:run', + profileId, + action, + allowInterruptActiveTasks, + ); }, update( profileId: string, diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index 93b2e00435..9b9f1d4b13 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -42,10 +42,6 @@ export type SettingsProjectsCopy = { revokeSharedAccessConfirm: string; revokeSharedAccessDescription: string; revokeSharedAccessDone: string; - uninstallLocalService: string; - uninstallLocalServiceConfirm: string; - uninstallLocalServiceDescription: string; - uninstallLocalServiceDone: string; createConnectionCode: string; connectionCodeTitle: string; connectionCodeDescription: string; @@ -312,10 +308,6 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { revokeSharedAccessConfirm: '撤销共享访问?', revokeSharedAccessDescription: '已连接的 Desktop 将断开,尚未使用的连接码也会失效。', revokeSharedAccessDone: '共享访问已撤销', - uninstallLocalService: '移除后台服务', - uninstallLocalServiceConfirm: '移除 Runtime Host 后台服务?', - uninstallLocalServiceDescription: '数据和已授予的共享访问会保留;Local Host 将恢复为仅在 Maka Desktop 运行时启动。', - uninstallLocalServiceDone: '后台服务已移除', createConnectionCode: '新建连接码', connectionCodeTitle: '连接这台电脑', connectionCodeDescription: '连接码将在 15 分钟后过期且只能使用一次。对方将获得 Owner 权限;Direct peer 无后备连接。', @@ -605,10 +597,6 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { revokeSharedAccessConfirm: 'Revoke shared access?', revokeSharedAccessDescription: 'The connected Desktop will be disconnected, and unused connection codes will stop working.', revokeSharedAccessDone: 'Shared access revoked', - uninstallLocalService: 'Remove background service', - uninstallLocalServiceConfirm: 'Remove the Runtime Host background service?', - uninstallLocalServiceDescription: 'Data and granted shared access are retained. The Local Host will return to running only while Maka Desktop is open.', - uninstallLocalServiceDone: 'Background service removed', createConnectionCode: 'New connection code', connectionCodeTitle: 'Connect to this computer', connectionCodeDescription: 'Expires in 15 minutes and can be used once. The other Desktop receives Owner access. Direct peer has no fallback.', diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx index ffa18efe57..9cb2c10967 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -37,7 +37,6 @@ import { } from '@maka/ui'; import { HelpCircle, ICON_SIZE } from '@maka/ui/icons'; import { uiLocaleToIntlLocale, type UiLocale } from '@maka/core/ui-locale'; -import type { RemoteRuntimeHostProfile } from '@maka/runtime-host/client'; import type { DesktopRuntimeHostManagementAction, DesktopRuntimeHostDirectPeerSnapshot, @@ -61,7 +60,7 @@ import { } from './runtime-host-project-directory-editor.js'; type RuntimeHostManagementConfirmation = - | { readonly kind: 'uninstall' } + | { readonly kind: 'uninstall'; readonly allowInterruptActiveTasks: boolean } | { readonly kind: 'update' } | { readonly kind: 'configureDirectories' } | { readonly kind: 'rotate' } @@ -80,8 +79,14 @@ type DirectoryPolicyEdit = { readonly draft: readonly ProjectDirectoryRootDraft[]; readonly conflict?: DirectoryPolicySnapshot; }; +export interface RuntimeHostManagementTarget { + readonly id: string; + readonly name: string; + readonly subtitle?: string; +} + export function RuntimeHostManagementDialog(props: { - readonly profile: RemoteRuntimeHostProfile | undefined; + readonly target: RuntimeHostManagementTarget | undefined; readonly onClose: () => void; }) { const locale = useUiLocale(); @@ -109,9 +114,9 @@ export function RuntimeHostManagementDialog(props: { const nextDirectoryRootId = useRef(1); const logsRef = useRef(null); - const profile = props.profile; + const target = props.target; useEffect(() => { - if (!profile) return; + if (!target) return; let disposed = false; setResult(undefined); setError(undefined); @@ -133,7 +138,7 @@ export function RuntimeHostManagementDialog(props: { void (async () => { let shouldLoadUpdatePolicy = false; try { - const response = await window.maka.runtimeHostManagement.run(profile.id, 'status'); + const response = await window.maka.runtimeHostManagement.run(target.id, 'status'); if (disposed) return; if (response.kind === 'result') { setResult(response); @@ -147,7 +152,7 @@ export function RuntimeHostManagementDialog(props: { } if (shouldLoadUpdatePolicy) { try { - const policy = await window.maka.runtimeHostManagement.getUpdatePolicy(profile.id); + const policy = await window.maka.runtimeHostManagement.getUpdatePolicy(target.id); if (!disposed) applyUpdatePolicy(policy); } catch (failure) { if (!disposed) { @@ -158,7 +163,7 @@ export function RuntimeHostManagementDialog(props: { } if (shouldLoadUpdatePolicy) { try { - const peer = await window.maka.runtimeHostManagement.getDirectPeer(profile.id); + const peer = await window.maka.runtimeHostManagement.getDirectPeer(target.id); if (!disposed) applyDirectPeer(peer); } catch (failure) { if (!disposed) setDirectPeerError(settingsActionErrorMessage(failure, locale)); @@ -169,11 +174,11 @@ export function RuntimeHostManagementDialog(props: { return () => { disposed = true; }; - }, [locale, profile]); + }, [locale, target]); useEffect(() => window.maka.runtimeHostManagement.subscribeProgress((progress) => { - if (progress.profileId === profile?.id) setUpdatePhase(progress.phase); - }), [profile?.id]); + if (progress.profileId === target?.id) setUpdatePhase(progress.phase); + }), [target?.id]); useLayoutEffect(() => { if (result?.action !== 'logs') return; @@ -181,15 +186,27 @@ export function RuntimeHostManagementDialog(props: { if (logs) logs.scrollTop = logs.scrollHeight; }, [result]); - async function run(action: DesktopRuntimeHostManagementAction): Promise { - if (!profile) return; + async function run( + action: DesktopRuntimeHostManagementAction, + allowInterruptActiveTasks = false, + ): Promise { + if (!target) return; setLoading(true); setError(undefined); setReconnectWarning(undefined); setLastUpdateOutcome(undefined); try { - const response = await window.maka.runtimeHostManagement.run(profile.id, action); + const response = await window.maka.runtimeHostManagement.run( + target.id, + action, + allowInterruptActiveTasks, + ); if (response.kind === 'error') { + if (action === 'uninstall' && response.error.code === 'active_tasks') { + setError(undefined); + setConfirmation({ kind: 'uninstall', allowInterruptActiveTasks: true }); + return; + } setUpdatePolicy(undefined); setError(response.error.message); toast.error(copy.managementActionFailed, response.error.message); @@ -199,12 +216,13 @@ export function RuntimeHostManagementDialog(props: { setResult(undefined); setUpdatePolicy(undefined); setUninstalledRoot(response.retainedStateRoot); + setConfirmation(undefined); return; } setResult(response); reconcileDirectoryPolicy(response.service); if (response.service.state === 'not_installed') setUpdatePolicy(undefined); - else if (action !== 'logs') await reloadUpdatePolicy(profile.id); + else if (action !== 'logs') await reloadUpdatePolicy(target.id); } catch (failure) { const message = settingsActionErrorMessage(failure, locale); setUpdatePolicy(undefined); @@ -223,11 +241,11 @@ export function RuntimeHostManagementDialog(props: { } async function reloadDirectPeer(): Promise { - if (!profile) return; + if (!target) return; setLoading(true); setDirectPeerError(undefined); try { - applyDirectPeer(await window.maka.runtimeHostManagement.getDirectPeer(profile.id)); + applyDirectPeer(await window.maka.runtimeHostManagement.getDirectPeer(target.id)); } catch (failure) { setDirectPeerError(settingsActionErrorMessage(failure, locale)); } finally { @@ -236,7 +254,7 @@ export function RuntimeHostManagementDialog(props: { } async function configureDirectPeer(enabled: boolean): Promise { - if (!profile) return; + if (!target) return; setLoading(true); setDirectPeerError(undefined); try { @@ -246,7 +264,7 @@ export function RuntimeHostManagementDialog(props: { .filter(Boolean); applyDirectPeer( await window.maka.runtimeHostManagement.configureDirectPeer( - profile.id, + target.id, enabled, relays, automaticRelayDiscovery, @@ -255,7 +273,7 @@ export function RuntimeHostManagementDialog(props: { } catch (failure) { const message = settingsActionErrorMessage(failure, locale); try { - applyDirectPeer(await window.maka.runtimeHostManagement.getDirectPeer(profile.id)); + applyDirectPeer(await window.maka.runtimeHostManagement.getDirectPeer(target.id)); } catch { // Preserve the last authoritative snapshot when recovery cannot be read. } @@ -267,11 +285,11 @@ export function RuntimeHostManagementDialog(props: { } async function loadAccess(): Promise { - if (!profile) return; + if (!target) return; setLoading(true); setError(undefined); try { - setAccess(await window.maka.runtimeHostManagement.listCredentials(profile.id)); + setAccess(await window.maka.runtimeHostManagement.listCredentials(target.id)); } catch (failure) { const message = settingsActionErrorMessage(failure, locale); setError(message); @@ -282,7 +300,7 @@ export function RuntimeHostManagementDialog(props: { } async function update(allowInterruptActiveTasks: boolean): Promise { - if (!profile) return; + if (!target) return; setLoading(true); setError(undefined); setReconnectWarning(undefined); @@ -290,7 +308,7 @@ export function RuntimeHostManagementDialog(props: { setLastUpdateOutcome(undefined); try { const response = await window.maka.runtimeHostManagement.update( - profile.id, + target.id, allowInterruptActiveTasks, ); if (response.kind === 'error') { @@ -312,7 +330,7 @@ export function RuntimeHostManagementDialog(props: { : undefined, ); if (response.action === 'update' && response.update.kind !== 'active_tasks') { - await reloadUpdatePolicy(profile.id); + await reloadUpdatePolicy(target.id); } } catch (failure) { const message = settingsActionErrorMessage(failure, locale); @@ -369,13 +387,13 @@ export function RuntimeHostManagementDialog(props: { } async function configureDirectories(allowInterruptActiveTasks: boolean): Promise { - if (!profile || !directoryPolicyEdit || directoryPolicyEdit.conflict) return; + if (!target || !directoryPolicyEdit || directoryPolicyEdit.conflict) return; setLoading(true); setError(undefined); setReconnectWarning(undefined); try { const response = await window.maka.runtimeHostManagement.configureProjectDirectories( - profile.id, + target.id, canonicalProjectDirectoryRoots(directoryPolicyEdit.draft), directoryPolicyEdit.baseline.configurationFingerprint, allowInterruptActiveTasks, @@ -429,7 +447,7 @@ export function RuntimeHostManagementDialog(props: { } async function saveUpdatePolicy(): Promise { - if (!profile) return; + if (!target) return; setLoading(true); setError(undefined); setUpdatePolicyError(undefined); @@ -441,7 +459,7 @@ export function RuntimeHostManagementDialog(props: { ? { kind: 'fixed' as const, version: fixedVersion.trim() } : { kind: 'channel' as const, channel: updatePolicyChoice }; applyUpdatePolicy( - await window.maka.runtimeHostManagement.setUpdatePolicy(profile.id, policy), + await window.maka.runtimeHostManagement.setUpdatePolicy(target.id, policy), ); } catch (failure) { const message = settingsActionErrorMessage(failure, locale); @@ -454,7 +472,7 @@ export function RuntimeHostManagementDialog(props: { } async function reconcileUpdate(): Promise { - if (!profile) return; + if (!target) return; setLoading(true); setError(undefined); setReconnectWarning(undefined); @@ -462,7 +480,7 @@ export function RuntimeHostManagementDialog(props: { setUpdatePhase('checking'); setLastUpdateOutcome(undefined); try { - const response = await window.maka.runtimeHostManagement.reconcileUpdate(profile.id); + const response = await window.maka.runtimeHostManagement.reconcileUpdate(target.id); if (response.kind === 'error') { setUpdatePolicy(undefined); setUpdatePolicyError(response.error.message); @@ -489,11 +507,11 @@ export function RuntimeHostManagementDialog(props: { } async function rotateCredential(): Promise { - if (!profile) return; + if (!target) return; setLoading(true); setError(undefined); try { - setAccess(await window.maka.runtimeHostManagement.rotateCredential(profile.id)); + setAccess(await window.maka.runtimeHostManagement.rotateCredential(target.id)); } catch (failure) { const message = settingsActionErrorMessage(failure, locale); setError(message); @@ -516,13 +534,13 @@ export function RuntimeHostManagementDialog(props: { const revokeTarget = confirmation?.kind === 'revoke' ? confirmation.credential : undefined; - if (!profile || !revokeTarget) return; + if (!target || !revokeTarget) return; setLoading(true); setError(undefined); try { setAccess( await window.maka.runtimeHostManagement.revokeCredential( - profile.id, + target.id, revokeTarget.credentialId, ), ); @@ -557,7 +575,7 @@ export function RuntimeHostManagementDialog(props: { const updateOutcome = lastUpdateOutcome; return ( { if (!open && !loading) props.onClose(); }} @@ -568,8 +586,8 @@ export function RuntimeHostManagementDialog(props: { { if (!open && !loading) props.onClose(); }} @@ -603,7 +621,9 @@ export function RuntimeHostManagementDialog(props: { ) : null} {confirmation?.kind === 'update' ? ( @@ -1141,9 +1161,14 @@ export function RuntimeHostManagementDialog(props: { />