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-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts index 1158c7c458..40583de49e 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 @@ -189,7 +189,10 @@ test('revokes the one Local sharing authority without changing peer connectivity const revoke = handlers.get('local-runtime-host-remote-access:revoke-shared-access'); assert.ok(revoke); - assert.deepEqual(await revoke({} as Electron.IpcMainInvokeEvent), { state: 'on' }); + assert.deepEqual(await revoke({} as Electron.IpcMainInvokeEvent), { + state: 'on', + managedService: true, + }); assert.deepEqual(revoked, [ { principalKind: 'remote_owner', @@ -198,6 +201,33 @@ test('revokes the one Local sharing authority without changing peer connectivity ]); }); +test('keeps the managed service visible when Direct peer support is unavailable', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-without-peer-')); + t.after(() => rm(base, { recursive: true, force: true })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + await mkdir(rootPath, { recursive: true }); + await writeManagedLifecycle(clientDataRoot, rootPath, rootId); + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: false, + manager: () => ({}) as RuntimeHostDesktopManager, + resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + operator: { + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + const snapshot = await service.getSnapshot(); + assert.equal(snapshot.state, 'unsupported'); + assert.equal(snapshot.managedService, true); +}); + test('does not persist recoverable setup authority before Desktop ownership commits', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-ownership-')); t.after(() => rm(base, { recursive: true, force: true })); @@ -275,7 +305,7 @@ test('adopts committed managed authority for every pending receipt without repla clientDataRoot, rootPath, rootId, - directPeerAvailable: true, + directPeerAvailable: false, manager: () => assert.fail('pre-start reconciliation must not require the Local manager'), resolveManagedDeploymentAuthority: async () => ({ kind: 'active', @@ -299,7 +329,7 @@ test('adopts committed managed authority for every pending receipt without repla }); t.after(() => service.close()); - assert.equal(await service.recoverManagedSetup(), true); + assert.equal(await service.recoverBeforeLocalHostStart(), true); assert.deepEqual( JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')), { @@ -359,7 +389,7 @@ test('discards a legacy handoff that belongs to an externally managed Host', asy }); t.after(() => service.close()); - assert.equal(await service.recoverManagedSetup(), false); + assert.equal(await service.recoverBeforeLocalHostStart(), false); await service.recover(); assert.equal(setupCalls, 0); @@ -427,7 +457,7 @@ test('interrupted Local Host setup converges to its exact managed service', asyn }); t.after(() => service.close()); - assert.equal(await service.recoverManagedSetup(), false); + assert.equal(await service.recoverBeforeLocalHostStart(), false); assert.equal(setupCalls, 0); await service.recover(); @@ -462,19 +492,13 @@ test('startup replays the persisted peer intent instead of gating recovery on st allowInterruptActiveTasks: false, })}\n`, ); - let resumed = false; const service = createDesktopLocalRuntimeHostRemoteAccess({ ipcMain: { handle() {}, removeHandler() {} }, clientDataRoot, rootPath, rootId, - directPeerAvailable: true, - manager: () => - ({ - async retireOwnedLocalHost() { - return { kind: 'retired' as const, resume: () => { resumed = true; } }; - }, - }) as unknown as RuntimeHostDesktopManager, + directPeerAvailable: false, + manager: () => assert.fail('pre-start peer recovery must not require the manager'), resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), operator: { async runPeer(input: { @@ -504,8 +528,7 @@ test('startup replays the persisted peer intent instead of gating recovery on st }); t.after(() => service.close()); - await service.recover(); - assert.equal(resumed, true); + assert.equal(await service.recoverBeforeLocalHostStart(), true); assert.equal( JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')) .state, @@ -574,7 +597,7 @@ test('re-enabling a managed peer forwards explicit interruption authority', asyn ); }); -test('startup completes an exact persisted uninstall intent after Desktop interruption', async (t) => { +test('pre-start recovery cleans a committed uninstall before an ephemeral Host can claim the root', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-uninstall-recovery-')); t.after(() => rm(base, { recursive: true, force: true })); const clientDataRoot = join(base, 'client'); @@ -601,11 +624,11 @@ test('startup completes an exact persisted uninstall intent after Desktop interr clientDataRoot, rootPath, rootId, - directPeerAvailable: true, + directPeerAvailable: false, manager: () => ({ - async retireOwnedLocalHost() { - return { kind: 'retired' as const, resume() {} }; + async runManagedLocalHostChange() { + assert.fail('a committed uninstall must not touch a new ephemeral Local Host'); }, }) as unknown as RuntimeHostDesktopManager, resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), @@ -614,15 +637,7 @@ test('startup completes an exact persisted uninstall intent after Desktop interr readonly action: 'retire' | 'uninstall'; readonly retainManagedDeployment?: boolean; }) { - actions.push(input.action); - assert.equal(input.action, 'uninstall'); - assert.equal(input.retainManagedDeployment, true); - return { - kind: 'result' as const, - action: 'uninstall' as const, - retirement: { kind: 'stopped' as const }, - service: { state: 'not_installed' }, - }; + assert.fail(`committed ${input.action} must not be repeated`); }, async cleanupManagedDeployment(input: { readonly finalize?: boolean }) { actions.push('cleanup'); @@ -633,56 +648,72 @@ test('startup completes an exact persisted uninstall intent after Desktop interr }); t.after(() => service.close()); - await service.recover(); - assert.deepEqual(actions, ['uninstall', 'cleanup', 'cleanup']); + const snapshot = await service.getSnapshot(); + assert.equal(snapshot.state, 'unsupported'); + assert.equal(snapshot.managedService, true); + assert.equal(await service.recoverBeforeLocalHostStart(), true); + assert.deepEqual(actions, ['cleanup', 'cleanup']); assert.deepEqual(cleanupPhases, [false, true]); await assert.rejects(readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8'), { code: 'ENOENT', }); }); -test('startup resumes deployment cleanup without repeating a completed uninstall', async (t) => { - const base = await mkdtemp(join(tmpdir(), 'maka-local-cleanup-recovery-')); +test('pre-start recovery settles a canonical uninstall transition through its exact operator', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-uninstall-transition-')); t.after(() => rm(base, { recursive: true, force: true })); const clientDataRoot = join(base, 'client'); const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); await mkdir(rootPath, { recursive: true }); await writeFile( join(clientDataRoot, 'runtime-host-local-service.json'), `${JSON.stringify({ schemaVersion: 1, - state: 'cleanupPending', + state: 'uninstalling', serviceId: 'b'.repeat(64), operatorPath: join(base, 'operator'), rootPath, - rootId: 'a'.repeat(64), + rootId, deploymentId: RECOVERY_DEPLOYMENT_ID, allowInterruptActiveTasks: false, })}\n`, ); - let cleaned = false; + const actions: string[] = []; const service = createDesktopLocalRuntimeHostRemoteAccess({ ipcMain: { handle() {}, removeHandler() {} }, clientDataRoot, rootPath, - rootId: 'a'.repeat(64), - directPeerAvailable: true, - manager: () => ({}) as RuntimeHostDesktopManager, + rootId, + directPeerAvailable: false, + manager: () => assert.fail('pre-start transition recovery must not require the manager'), resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), + resolveManagedDeploymentAuthority: async () => ({ kind: 'transition' }), operator: { - async runService() { - assert.fail('completed uninstall must not be repeated'); + async runService(input: { + readonly action: 'retire' | 'uninstall'; + readonly target: { readonly deploymentId: string }; + }) { + actions.push(input.action); + assert.equal(input.action, 'uninstall'); + assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID); + return { + kind: 'result' as const, + action: 'uninstall' as const, + retirement: { kind: 'stopped' as const }, + service: { state: 'not_installed' }, + }; }, async cleanupManagedDeployment() { - cleaned = true; + actions.push('cleanup'); }, async close() {}, } as unknown as ReturnType, }); t.after(() => service.close()); - await service.recover(); - assert.equal(cleaned, true); + assert.equal(await service.recoverBeforeLocalHostStart(), true); + assert.deepEqual(actions, ['uninstall', 'cleanup', 'cleanup']); await assert.rejects(readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8'), { code: 'ENOENT', }); 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..4c2722b571 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,57 @@ import type { const DEPLOYMENT_ID = '11111111-1111-4111-8111-111111111111'; +test('requires explicit interruption authority before a provider restarts active work', async () => { + const handlers = new Map unknown>(); + const provider = { + profileId: 'local', + accessManagementAvailable: false, + run: async (action: string, allowInterruptActiveTasks: boolean) => { + return action === 'restart' && !allowInterruptActiveTasks + ? { + schemaVersion: 1 as const, + kind: 'error' as const, + action: 'restart' as const, + error: { code: 'active_tasks', message: 'Runtime Host still owns active work' }, + } + : serviceResult(action as DesktopRuntimeHostSshManagementInput['action']); + }, + uninstall: async () => ({ kind: 'uninstalled' as const, retainedStateRoot: '/state' }), + } 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); + const blocked = await run({}, 'local', 'restart'); + const restarted = await run({}, 'local', 'restart', true); + + assert.equal((blocked as { kind: string }).kind, 'error'); + assert.equal((restarted as { kind: string }).kind, 'result'); + assert.throws( + () => run({}, 'local', 'status', 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..4dd1639eab 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,24 @@ 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), + ); + }, +}); const runtimeHostManagement = createDesktopRuntimeHostManagement({ ipcMain, profiles: runtimeHostProfileService, @@ -568,6 +587,7 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ mainWindowController.send("runtime-host-management:progress", progress), runAccessManagement: runtimeHostSshTerminal.runAccessManagement, cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment, + providers: [localRuntimeHostManagement], }); const runtimeHostPeerMeshManagement = createDesktopRuntimeHostPeerMeshManagement({ ipcMain, @@ -782,6 +802,7 @@ registerNotificationsIpc({ }); const sessionCopyOwnerProcessId = randomUUID(); +await localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(); runtimeHostManager = await startRuntimeHostDesktopManager( { rootPath: workspaceRoot, @@ -991,7 +1012,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( isDefault: true, }); }, - recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverManagedSetup(signal), + recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(signal), onFatalError: (error, target) => { if (error instanceof RuntimeHostUpgradeCancelledError) { if (target.profile.kind === "local") app.quit(); diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index e50c03e4de..474a0d872c 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -544,14 +544,14 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { const lifecycle = this.#requireLifecycle( this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id), ); - const quiescence = await lifecycle.quiesce(); + const suspension = await lifecycle.suspend(); try { - if (quiescence.current.hostOwnership !== 'supervised') { + if (suspension.current?.hostOwnership === 'owned_ephemeral') { throw new Error('The Local Runtime Host is not managed by a background service'); } return await change(); } finally { - quiescence.resume(); + suspension.resume(); } }); } 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..933f7bd6e8 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-local-management.ts @@ -0,0 +1,125 @@ +/* + * 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 { LOCAL_RUNTIME_HOST_PROFILE } from '@maka/runtime-host/client'; +import type { RuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator'; +import type { + DesktopLocalRuntimeHostRemoteAccess, + DesktopRuntimeHostLocalManagementTarget, +} 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; +}): DesktopRuntimeHostManagementProvider { + return { + profileId: LOCAL_RUNTIME_HOST_PROFILE.id, + accessManagementAvailable: false, + run: (action, allowInterruptActiveTasks) => { + const execute = (target: DesktopRuntimeHostLocalManagementTarget) => + input.operator.runService({ + operatorPath: target.operatorPath, + action, + target, + ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), + }).then((frame) => requireLocalFrame(frame, action)); + return action === 'status' || action === 'logs' + ? input.remoteAccess.inspectManaged(execute) + : input.remoteAccess.changeManaged(execute); + }, + uninstall: async (allowInterruptActiveTasks) => { + const result = await input.remoteAccess.uninstall({ allowInterruptActiveTasks }); + return { kind: result.kind, retainedStateRoot: input.rootPath }; + }, + update: async (allowInterruptActiveTasks, onProgress) => { + const setupPackage = await input.resolveUpdatePackage(); + return input.remoteAccess.changeManaged( + (target) => + input.operator.runUpdate( + { + setupPackage, + target, + ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), + }, + onProgress, + ).then((frame) => requireLocalFrame(frame, 'update')), + ); + }, + configureProjectDirectories: ( + roots, + expectedConfigFingerprint, + allowInterruptActiveTasks, + ) => + input.remoteAccess.changeManaged( + (target) => + input.operator.runService({ + operatorPath: target.operatorPath, + action: 'configure', + target, + projectDirectoryRoots: roots, + expectedConfigFingerprint, + ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), + }).then((frame) => requireLocalFrame(frame, 'configure')), + ), + updatePolicy: (policy) => + input.remoteAccess.inspectManaged((target) => + input.operator.runUpdatePolicy({ + operatorPath: target.operatorPath, + target, + ...(policy ? { policy } : {}), + }).then((frame) => requireLocalFrame(frame, 'update_policy'))), + reconcileUpdate: (onProgress) => + input.remoteAccess.changeManaged((target) => + input.operator.runUpdateReconciliation( + { operatorPath: target.operatorPath, target }, + onProgress, + ).then((frame) => requireLocalFrame(frame, 'reconcile_update'))), + currentHostEpoch: input.currentHostEpoch, + awaitUpdatedConnection: input.awaitUpdatedConnection, + }; +} + +function requireLocalFrame( + 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< + RuntimeHostServiceManagementFrame, + { readonly kind: 'progress' } + > & { readonly action: Action }; +} 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..275aacde91 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,28 @@ type LocalManagedDeploymentAuthority = | { readonly kind: 'active'; readonly target: LocalServiceTarget } | { readonly kind: 'transition' }; +export interface DesktopRuntimeHostLocalManagementTarget + extends DesktopRuntimeHostLocalServiceTarget { + readonly operatorPath: string; + readonly deploymentId: string; +} + +export interface DesktopLocalRuntimeHostRemoteAccess { + getSnapshot(): Promise; + enable(value: unknown): Promise; + disable(): Promise; + uninstall(value: unknown): Promise<{ readonly kind: 'active_tasks' | 'uninstalled' }>; + inspectManaged( + operation: (target: DesktopRuntimeHostLocalManagementTarget) => Promise, + ): Promise; + changeManaged( + operation: (target: DesktopRuntimeHostLocalManagementTarget) => Promise, + ): Promise; + recoverBeforeLocalHostStart(signal?: AbortSignal): Promise; + recover(): Promise; + close(): Promise; +} + interface LocalServicePeerChanging extends LocalServiceTarget { readonly state: 'peerChanging'; readonly peerEnabled: boolean; @@ -130,11 +152,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(); @@ -184,9 +202,16 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const getSnapshot = (): Promise => serialize(async () => { - if (!supported(input.directPeerAvailable)) return unsupportedSnapshot(); + let managedService = false; try { const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + managedService = lifecycle !== undefined && hasManagedServiceTarget(lifecycle); + if (!supported(input.directPeerAvailable)) { + return { + ...unsupportedSnapshot(), + ...(managedService ? { managedService: true as const } : {}), + }; + } if (!lifecycle) return { state: 'off' }; if (lifecycle.state !== 'managed') { return { @@ -197,6 +222,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { : lifecycle.state === 'peerChanging' ? 'Local Runtime Host remote access is being recovered' : 'Local Runtime Host uninstall is being recovered', + ...(managedService ? { managedService: true } : {}), }; } const sharedAccess = await hasSharedAccess(input.operator, lifecycle); @@ -205,7 +231,11 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ? onSnapshot(sharedAccess) : { state: 'off', managedService: true, ...(sharedAccess ? { sharedAccess: true } : {}) }; } catch (error) { - return { state: 'unavailable', message: errorMessage(error) }; + return { + state: 'unavailable', + message: errorMessage(error), + ...(managedService ? { managedService: true } : {}), + }; } }); @@ -494,10 +524,20 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { throw new Error('Local Runtime Host uninstall request is invalid'); } const allowInterruptActiveTasks = value.allowInterruptActiveTasks; - const managed = requireManaged( - await readLifecycle(lifecyclePath, input.rootPath, input.rootId), - ); + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (lifecycle?.state === 'uninstalling') { + const intent = allowInterruptActiveTasks && !lifecycle.allowInterruptActiveTasks + ? { ...lifecycle, allowInterruptActiveTasks: true } + : lifecycle; + if (intent !== lifecycle) await writeDocument(lifecyclePath, intent); + return finishUninstall(intent); + } + if (lifecycle?.state === 'cleanupPending') { + return finishUninstall(lifecycle); + } + const managed = requireManagementTarget(lifecycle); const intent: LocalServiceUninstalling = { + schemaVersion: 1, ...managed, state: 'uninstalling', allowInterruptActiveTasks, @@ -508,24 +548,22 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const finishPeerChange = async ( intent: LocalServicePeerChanging, + coordination: 'manager' | 'direct' = 'manager', ): Promise< | { readonly kind: 'active_tasks' } | { readonly kind: 'complete'; readonly response: LocalPeerResultFrame } > => { - const changed = await runManagedServiceChange(intent.allowInterruptActiveTasks, () => + const change = () => input.operator.runPeer({ operatorPath: intent.operatorPath, action: intent.peerEnabled ? 'enable' : 'disable', target: intent, coordinationRelays: intent.coordinationRelays, allowInterruptActiveTasks: intent.allowInterruptActiveTasks, - }), - ); - if (changed.kind === 'active_tasks') { - await writeDocument(lifecyclePath, managedLifecycle(intent)); - return changed; - } - const response = changed.value; + }); + const response = coordination === 'manager' + ? await runManagedServiceChange(change) + : await change(); if (response.kind === 'error') { if (response.error.code === 'active_tasks') { await writeDocument(lifecyclePath, managedLifecycle(intent)); @@ -542,29 +580,31 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const finishUninstall = async ( intent: LocalServiceUninstalling, + coordination: 'manager' | 'direct' = 'manager', + signal: AbortSignal = closing.signal, ): Promise<{ readonly kind: 'active_tasks' } | { readonly kind: 'uninstalled' }> => { if (intent.state === 'uninstalling') { - const changed = await runManagedServiceChange(intent.allowInterruptActiveTasks, () => - input.operator.runService({ - operatorPath: intent.operatorPath, - action: 'uninstall', - target: intent, - allowInterruptActiveTasks: intent.allowInterruptActiveTasks, - retainManagedDeployment: true, - }), - ); - if (changed.kind === 'active_tasks') { - await writeDocument(lifecyclePath, managedLifecycle(intent)); - return changed; - } - const response = changed.value; - if (response.kind === 'error') throw new Error(response.error.message); - if (response.action !== 'uninstall') { - throw new Error('Local Runtime Host returned an unrelated service result'); - } - if (response.retirement.kind === 'active_tasks') { - await writeDocument(lifecyclePath, managedLifecycle(intent)); - return { kind: 'active_tasks' }; + const authority = await resolveManagedDeploymentAuthority(intent.rootId); + if (authority) { + const change = () => + input.operator.runService({ + operatorPath: intent.operatorPath, + action: 'uninstall', + target: intent, + allowInterruptActiveTasks: intent.allowInterruptActiveTasks, + retainManagedDeployment: true, + }); + const response = coordination === 'manager' + ? await runManagedServiceChange(change) + : await change(); + if (response.kind === 'error') throw new Error(response.error.message); + if (response.action !== 'uninstall') { + throw new Error('Local Runtime Host returned an unrelated service result'); + } + if (response.retirement.kind === 'active_tasks') { + await writeDocument(lifecyclePath, managedLifecycle(intent)); + return { kind: 'active_tasks' }; + } } intent = { ...intent, state: 'cleanupPending' }; await writeDocument(lifecyclePath, intent); @@ -572,35 +612,21 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { await input.operator.cleanupManagedDeployment({ operatorPath: intent.operatorPath, target: intent, - signal: closing.signal, + signal, }); await input.operator.cleanupManagedDeployment({ operatorPath: intent.operatorPath, target: intent, finalize: true, - signal: closing.signal, + signal, }); await removeDocument(lifecyclePath); return { kind: 'uninstalled' }; }; - const runManagedServiceChange = async ( - allowInterruptActiveTasks: boolean, - change: () => Promise, - ): Promise<{ readonly kind: 'active_tasks' } | { readonly kind: 'complete'; readonly value: T }> => { + const runManagedServiceChange = (change: () => Promise): Promise => { const manager = requireManager(input.manager); - const retirement = await manager.retireOwnedLocalHost( - allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', - ); - if (retirement.kind === 'active_tasks') return { kind: 'active_tasks' }; - if (retirement.kind === 'not_owned') { - return { kind: 'complete', value: await manager.runManagedLocalHostChange(change) }; - } - try { - return { kind: 'complete', value: await change() }; - } finally { - retirement.resume(); - } + return manager.runManagedLocalHostChange(change); }; const channels = [ @@ -609,21 +635,58 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { 'local-runtime-host-remote-access:create-connection-code', 'local-runtime-host-remote-access:revoke-shared-access', 'local-runtime-host-remote-access:disable', - 'local-runtime-host-remote-access:uninstall', ] as const; input.ipcMain.handle(channels[0], getSnapshot); input.ipcMain.handle(channels[1], (_event, value: unknown) => enable(value)); input.ipcMain.handle(channels[2], createConnectionCode); input.ipcMain.handle(channels[3], revokeSharedAccess); input.ipcMain.handle(channels[4], disable); - input.ipcMain.handle(channels[5], (_event, value: unknown) => uninstall(value)); return { - recoverManagedSetup: async (signal) => { - if (!supported(input.directPeerAvailable)) return false; + getSnapshot, + enable, + disable, + uninstall, + inspectManaged: (operation) => + serialize(async () => { + const managed = requireManagementTarget( + await readLifecycle(lifecyclePath, input.rootPath, input.rootId), + ); + return operation(managed); + }), + changeManaged: (operation) => + serialize(async () => { + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (lifecycle?.state === 'uninstalling' || lifecycle?.state === 'cleanupPending') { + throw new Error('Finish uninstalling the Local Runtime Host before changing it'); + } + const managed = requireManagementTarget(lifecycle); + return requireManager(input.manager).runManagedLocalHostChange(() => operation(managed)); + }), + recoverBeforeLocalHostStart: async (signal) => { const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal; operationSignal.throwIfAborted(); const observed = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (observed?.state === 'peerChanging') { + return serialize(async () => { + operationSignal.throwIfAborted(); + const pending = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (pending?.state !== 'peerChanging') return false; + await finishPeerChange(pending, 'direct'); + return true; + }); + } + if (observed?.state === 'uninstalling' || observed?.state === 'cleanupPending') { + return serialize(async () => { + operationSignal.throwIfAborted(); + const pending = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (pending?.state !== 'uninstalling' && pending?.state !== 'cleanupPending') { + return false; + } + await finishUninstall(pending, 'direct', operationSignal); + return true; + }); + } if (observed?.state !== 'setupPending' && observed?.state !== 'handoff') return false; return serialize(async () => { operationSignal.throwIfAborted(); @@ -641,23 +704,9 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { }, 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 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') { - await finishUninstall(lifecycle); - return; - } - if (lifecycle.state === 'cleanupPending') { + if (lifecycle.state === 'uninstalling' || lifecycle.state === 'cleanupPending') { await finishUninstall(lifecycle); return; } @@ -666,6 +715,15 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { if (recovered.kind === 'active_tasks') { throw new Error('Local Runtime Host peer recovery was blocked by active work'); } + return; + } + if (lifecycle.state === 'handoff' || lifecycle.state === 'setupPending') { + const committed = await adoptCommittedSetup(pendingSetup(lifecycle)); + if (committed.kind === 'managed') return; + if (!supported(input.directPeerAvailable)) return; + if (lifecycle.state === 'handoff') await recoverLegacyHandoff(lifecycle); + else await finishSetup(lifecycle, 'recovery'); + return; } }), async close() { @@ -742,7 +800,11 @@ function onSnapshot(sharedAccess: boolean): Extract< DesktopLocalRuntimeHostRemoteAccessSnapshot, { state: 'on' } > { - return { state: 'on', ...(sharedAccess ? { sharedAccess: true } : {}) }; + return { + state: 'on', + managedService: true, + ...(sharedAccess ? { sharedAccess: true } : {}), + }; } function enabledResult( @@ -852,6 +914,15 @@ function requireManaged(lifecycle: LocalServiceLifecycle | undefined): LocalServ return lifecycle; } +function requireManagementTarget( + lifecycle: LocalServiceLifecycle | undefined, +): DesktopRuntimeHostLocalManagementTarget { + if (!lifecycle || !hasManagedServiceTarget(lifecycle)) { + throw new Error('This computer does not have a managed Local Runtime Host'); + } + return lifecycle; +} + function pendingSetup( intent: LocalServiceSetupPending | LocalServiceLegacyHandoff, ): LocalServiceSetupPending { @@ -877,6 +948,13 @@ function managedLifecycle(intent: LocalServiceTarget): LocalServiceManaged { }; } +function hasManagedServiceTarget(lifecycle: LocalServiceLifecycle): lifecycle is + | LocalServiceManaged + | LocalServicePeerChanging + | LocalServiceUninstalling { + return lifecycle.state !== 'handoff' && lifecycle.state !== 'setupPending'; +} + async function readLifecycle( path: string, rootPath: string, 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..67bf941bc5 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-management-provider.ts @@ -0,0 +1,64 @@ +/* + * 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, + RuntimeHostServiceUpdatePhase, +} from '@maka/runtime-host/operator'; +import type { + DesktopRuntimeHostManagementAction, +} from '../preload/bridge-contract.js'; + +export type DesktopRuntimeHostManagementTerminalFrame = Exclude< + RuntimeHostServiceManagementFrame, + { readonly kind: 'progress' } +>; + +export interface DesktopRuntimeHostManagementProvider { + readonly profileId: string; + readonly accessManagementAvailable: boolean; + run( + action: Exclude, + allowInterruptActiveTasks: boolean, + ): Promise; + uninstall( + allowInterruptActiveTasks: boolean, + ): Promise<{ readonly kind: 'active_tasks' | 'uninstalled'; readonly retainedStateRoot: string }>; + update( + allowInterruptActiveTasks: boolean, + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; + configureProjectDirectories( + roots: readonly { readonly label: string; readonly path: string }[], + expectedConfigFingerprint: string, + allowInterruptActiveTasks: boolean, + ): Promise; + updatePolicy( + policy?: RuntimeHostManagedUpdatePolicy, + ): Promise; + reconcileUpdate( + onProgress: (phase: RuntimeHostServiceUpdatePhase) => void, + ): Promise; + currentHostEpoch(): string | undefined; + awaitUpdatedConnection( + previousHostEpoch: string | undefined, + replacementExpected: boolean, + ): Promise; +} diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index 4ae98e5fe4..c1564d67e2 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -57,6 +57,10 @@ import type { DesktopRuntimeHostDevelopmentPeerTarget, DesktopRuntimeHostSetupPackage, } from './runtime-host-setup-package.js'; +import type { + DesktopRuntimeHostManagementProvider, + DesktopRuntimeHostManagementTerminalFrame, +} from './runtime-host-management-provider.js'; const MANAGEMENT_ACTIONS = new Set([ 'status', @@ -129,7 +133,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'); @@ -142,10 +150,28 @@ export function createDesktopRuntimeHostManagement(input: { return managed; }; + const activeTasks = ( + action: DesktopRuntimeHostManagementAction, + ): DesktopRuntimeHostManagementResponse => ({ + schemaVersion: 1, + kind: 'error', + action, + error: { code: 'active_tasks', message: 'Runtime Host still owns active work' }, + }); + + const projectManagementFrame = ( + frame: Exclude, + accessManagementAvailable: boolean, + ): DesktopRuntimeHostManagementResponse => + (frame.kind === 'result' + ? { ...frame, accessManagementAvailable } + : frame) as DesktopRuntimeHostManagementResponse; + const statusRequests = new Map>(); const runManagedAction = async ( profileId: string, managementAction: DesktopRuntimeHostManagementAction, + allowInterruptActiveTasks = false, ): Promise => { const managed = await resolveManagedService(profileId); const { profile, deployment, control } = managed; @@ -183,22 +209,23 @@ export function createDesktopRuntimeHostManagement(input: { websocketPath: profile.transport.websocketPath, } : {}), + ...((managementAction === 'uninstall' || managementAction === 'restart') && + allowInterruptActiveTasks + ? { allowInterruptActiveTasks: true } + : {}), }; if (managementAction !== 'uninstall') { const response = await input.runServiceManagement(managementInput); if (response.action !== managementAction) { throw new Error('Remote Runtime Host returned a different management action'); } - return response.kind === 'result' - ? { - ...response, - action: managementAction, - accessManagementAvailable: - response.operatorCapabilities?.includes( - RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, - ) ?? false, - } - : { ...response, action: managementAction }; + return projectManagementFrame( + response, + response.kind === 'result' && + (response.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + ) ?? false), + ); } let pending = managed; @@ -238,16 +265,44 @@ 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' && + managementAction !== 'restart' && + allowInterruptActiveTasksValue + ) { + throw new Error('Runtime Host interruption authority is not valid for this action'); + } + const provider = providers.get(profileId); + const execute = async (): Promise => { + if (!provider) { + return runManagedAction(profileId, managementAction, allowInterruptActiveTasksValue); + } + if (managementAction === 'uninstall') { + const response = await provider.uninstall(allowInterruptActiveTasksValue); + return response.kind === 'active_tasks' + ? activeTasks(managementAction) + : { kind: 'uninstalled', retainedStateRoot: response.retainedStateRoot }; + } + const frame = requireManagementFrame( + await provider.run(managementAction, allowInterruptActiveTasksValue), + managementAction, + ); + return projectManagementFrame(frame, provider.accessManagementAvailable); + }; + 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); @@ -309,6 +364,23 @@ export function createDesktopRuntimeHostManagement(input: { }; }; + const reconnectManagedTarget = ( + profileId: string, + managed: Awaited>, + previousHostEpoch: string | undefined, + ): (() => Promise) => async () => { + const current = await input.profiles.resolveManagedService(profileId); + if (!current || !sameDesktopRuntimeHostManagedServiceBinding(current, managed)) { + throw new Error('Runtime Host profile changed while its service was updating'); + } + await input.awaitUpdatedConnection( + profileId, + managed.profile.rootId, + previousHostEpoch, + true, + ); + }; + const peerSnapshot = async ( profileId: string, status: RuntimeHostPeerStatus, @@ -480,46 +552,59 @@ export function createDesktopRuntimeHostManagement(input: { if (typeof allowInterruptActiveTasksValue !== 'boolean') { throw new Error('Runtime Host update interruption authority is invalid'); } - const { profileId, managed, transport, expectedTarget } = - await managedMutationTarget(profileIdValue); - const previousHostEpoch = input.currentHostEpoch(profileId); - input.sendProgress({ profileId, phase: 'preparing_cli' }); - const peerTarget = input.setupPackageMode === 'development' - ? await input.resolveSshDevelopmentPeerTarget({ + const profileId = requireProfileId(profileIdValue); + const provider = providers.get(profileId); + let execute: () => Promise; + let reconnect: () => Promise; + if (provider) { + const previousHostEpoch = provider.currentHostEpoch(); + input.sendProgress({ profileId, phase: 'preparing_cli' }); + execute = () => provider.update( + allowInterruptActiveTasksValue, + (phase) => input.sendProgress({ profileId, phase }), + ); + reconnect = () => provider.awaitUpdatedConnection(previousHostEpoch, true); + } else { + const { managed, transport, expectedTarget } = await managedMutationTarget(profileId); + const previousHostEpoch = input.currentHostEpoch(profileId); + input.sendProgress({ profileId, phase: 'preparing_cli' }); + const peerTarget = input.setupPackageMode === 'development' + ? await input.resolveSshDevelopmentPeerTarget({ destination: transport.destination, ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), }) - : 'none'; - const setupPackage = await input.resolveUpdatePackage(peerTarget); - const response = await input.runUpdate( - { - destination: transport.destination, - ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - setupPackage, - expectedTarget, - ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), - }, - (phase) => input.sendProgress({ profileId, phase }), - ); + : 'none'; + const setupPackage = await input.resolveUpdatePackage(peerTarget); + execute = () => input.runUpdate( + { + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + setupPackage, + expectedTarget, + ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), + }, + (phase) => input.sendProgress({ profileId, phase }), + ); + reconnect = reconnectManagedTarget(profileId, managed, previousHostEpoch); + } + const response = requireManagementFrame(await execute(), 'update'); const reconnectError = - response.kind === 'result' && response.update.kind !== 'active_tasks' - ? await reconnectUpdatedTarget( - profileId, - managed, - previousHostEpoch, - response.update.kind !== 'already_current', - ) + response.kind === 'result' && + response.update.kind !== 'active_tasks' && + response.update.kind !== 'already_current' + ? await reconnectChangedTarget(reconnect) : undefined; - return response.kind === 'result' - ? { - ...response, - ...(reconnectError ? { reconnectError } : {}), - accessManagementAvailable: - response.operatorCapabilities?.includes( - RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, - ) ?? false, - } - : response; + const projected = projectManagementFrame( + response, + provider?.accessManagementAvailable ?? + (response.kind === 'result' && + (response.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + ) ?? false)), + ); + return projected.kind === 'result' && reconnectError + ? { ...projected, reconnectError } + : projected; }; const configureProjectDirectories = async ( @@ -538,60 +623,56 @@ export function createDesktopRuntimeHostManagement(input: { if (typeof allowInterruptActiveTasksValue !== 'boolean') { throw new Error('Runtime Host configuration interruption authority is invalid'); } - const { profileId, managed, transport, expectedTarget } = - await managedMutationTarget(profileIdValue); - const previousHostEpoch = input.currentHostEpoch(profileId); - const response = await input.runServiceManagement({ - destination: transport.destination, - ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, - action: 'configure', - expectedTarget, - projectDirectoryRoots: roots, - expectedConfigFingerprint: expectedConfigFingerprintValue, - ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), - }); - if (response.action !== 'configure') { - throw new Error('Remote Runtime Host returned a different management action'); + const profileId = requireProfileId(profileIdValue); + const provider = providers.get(profileId); + let execute: () => Promise; + let reconnect: () => Promise; + if (provider) { + const previousHostEpoch = provider.currentHostEpoch(); + execute = () => provider.configureProjectDirectories( + roots, + expectedConfigFingerprintValue, + allowInterruptActiveTasksValue, + ); + reconnect = () => provider.awaitUpdatedConnection(previousHostEpoch, true); + } else { + const { managed, transport, expectedTarget } = await managedMutationTarget(profileId); + const previousHostEpoch = input.currentHostEpoch(profileId); + execute = () => input.runServiceManagement({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + action: 'configure', + expectedTarget, + projectDirectoryRoots: roots, + expectedConfigFingerprint: expectedConfigFingerprintValue, + ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), + }); + reconnect = reconnectManagedTarget(profileId, managed, previousHostEpoch); } + const response = requireManagementFrame(await execute(), 'configure'); const reconnectError = response.kind === 'result' && response.configuration.kind === 'configured' - ? await reconnectUpdatedTarget( - profileId, - managed, - previousHostEpoch, - true, - ) + ? await reconnectChangedTarget(reconnect) : undefined; - return response.kind === 'result' - ? { - ...response, - ...(reconnectError ? { reconnectError } : {}), - accessManagementAvailable: - response.operatorCapabilities?.includes( - RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, - ) ?? false, - } - : { ...response, action: 'configure' }; + const projected = projectManagementFrame( + response, + provider?.accessManagementAvailable ?? + (response.kind === 'result' && + (response.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + ) ?? false)), + ); + return projected.kind === 'result' && reconnectError + ? { ...projected, reconnectError } + : projected; }; - const reconnectUpdatedTarget = async ( - profileId: string, - managed: Awaited>, - previousHostEpoch: string | undefined, - replacementExpected: boolean, + const reconnectChangedTarget = async ( + reconnect: () => Promise, ): Promise<{ readonly code: string; readonly message: string } | undefined> => { try { - const current = await input.profiles.resolveManagedService(profileId); - if (!current || !sameDesktopRuntimeHostManagedServiceBinding(current, managed)) { - throw new Error('Runtime Host profile changed while its service was updating'); - } - await input.awaitUpdatedConnection( - profileId, - managed.profile.rootId, - previousHostEpoch, - replacementExpected, - ); + await reconnect(); return undefined; } catch (error) { return { @@ -607,18 +688,31 @@ export function createDesktopRuntimeHostManagement(input: { profileIdValue: unknown, policyValue?: unknown, ): Promise => { - const { managed, transport, expectedTarget } = await managedMutationTarget(profileIdValue); const policy = policyValue === undefined ? undefined : requireUpdatePolicy(policyValue); - const common = { - destination: transport.destination, - ...(transport.sshPort === undefined - ? {} - : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, - expectedTarget, - }; + const providerProfileId = requireProfileId(profileIdValue); + const provider = providers.get(providerProfileId); + const execute = provider + ? async (next?: RuntimeHostManagedUpdatePolicy) => + requireManagementFrame(await provider.updatePolicy(next), 'update_policy') + : await (async () => { + const { managed, transport, expectedTarget } = + await managedMutationTarget(profileIdValue); + const common = { + destination: transport.destination, + ...(transport.sshPort === undefined + ? {} + : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + expectedTarget, + }; + return async (next?: RuntimeHostManagedUpdatePolicy) => + input.runUpdatePolicy({ + ...common, + ...(next ? { policy: next } : {}), + }); + })(); if (policy && policy.kind !== 'manual') { - const current = await input.runUpdatePolicy(common); + const current = await execute(); if (current.kind === 'error') throw new Error(current.error.message); if (current.updateSchedulerState === undefined) { throw new Error( @@ -626,10 +720,7 @@ export function createDesktopRuntimeHostManagement(input: { ); } } - const response = await input.runUpdatePolicy({ - ...common, - ...(policy ? { policy } : {}), - }); + const response = await execute(policy); if (response.kind === 'error') throw new Error(response.error.message); return projectUpdatePolicy(response); }; @@ -637,25 +728,35 @@ export function createDesktopRuntimeHostManagement(input: { const reconcileUpdate = async ( profileIdValue: unknown, ): Promise => { - const { profileId, managed, transport, expectedTarget } = - await managedMutationTarget(profileIdValue); - const previousHostEpoch = input.currentHostEpoch(profileId); - const response = await input.runUpdateReconciliation( - { - destination: transport.destination, - ...(transport.sshPort === undefined - ? {} - : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, - expectedTarget, - }, - (phase) => input.sendProgress({ profileId, phase }), - ); + const profileId = requireProfileId(profileIdValue); + const provider = providers.get(profileId); + let execute: () => Promise; + let reconnect: () => Promise; + if (provider) { + const previousHostEpoch = provider.currentHostEpoch(); + execute = () => provider.reconcileUpdate((phase) => + input.sendProgress({ profileId, phase })); + reconnect = () => provider.awaitUpdatedConnection(previousHostEpoch, true); + } else { + const { managed, transport, expectedTarget } = await managedMutationTarget(profileId); + const previousHostEpoch = input.currentHostEpoch(profileId); + execute = () => input.runUpdateReconciliation( + { + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + expectedTarget, + }, + (phase) => input.sendProgress({ profileId, phase }), + ); + reconnect = reconnectManagedTarget(profileId, managed, previousHostEpoch); + } + const response = requireManagementFrame(await execute(), 'reconcile_update'); const reconnectError = response.kind === 'result' && (response.reconciliation.kind === 'updated' || response.reconciliation.kind === 'repaired') - ? await reconnectUpdatedTarget(profileId, managed, previousHostEpoch, true) + ? await reconnectChangedTarget(reconnect) : undefined; return response.kind === 'result' ? { @@ -796,8 +897,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) => @@ -881,6 +989,23 @@ function requireCoordinationRelays(value: unknown): readonly string[] { return value; } +function requireManagementFrame< + Action extends RuntimeHostServiceManagementFrame['action'], +>( + frame: Exclude, + action: Action, +): Exclude & { + readonly action: Action; +} { + if (frame.action !== action) { + throw new Error('Runtime Host returned an unrelated management result'); + } + return frame as Exclude< + RuntimeHostServiceManagementFrame, + { readonly kind: 'progress' } + > & { readonly action: Action }; +} + function projectUpdatePolicy( frame: Extract; revokeSharedAccess(): Promise; disable(): Promise; - uninstall(input: { - readonly allowInterruptActiveTasks: boolean; - }): Promise<{ readonly kind: 'active_tasks' | 'uninstalled' }>; }; runtimeHostSshTerminal: { @@ -707,6 +709,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..af9c764ba4 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1233,9 +1233,6 @@ const makaBridge = { disable() { return ipcRenderer.invoke('local-runtime-host-remote-access:disable'); }, - uninstall(input: { readonly allowInterruptActiveTasks: boolean }) { - return ipcRenderer.invoke('local-runtime-host-remote-access:uninstall', input); - }, }, runtimeHostSshTerminal: { getSnapshot(): Promise { @@ -1295,8 +1292,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..119aa67b69 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; @@ -167,6 +163,8 @@ export type SettingsProjectsCopy = { refresh: string; startService: string; restartService: string; + restartActiveTasksDescription: string; + restartInterrupt: string; repairService: string; updateService: string; updatePolicy: string; @@ -312,10 +310,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 无后备连接。', @@ -460,6 +454,8 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { refresh: '刷新', startService: '启动', restartService: '重启', + restartActiveTasksDescription: '重启会停止当前任务。是否中断这些任务并继续?', + restartInterrupt: '中断任务并重启', repairService: '修复', updateService: '安装配套版本', updatePolicy: '更新策略', @@ -508,7 +504,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { showLogs: '查看日志', noLogs: '没有服务日志', uninstallService: '卸载服务', - uninstallConfirmTitle: '卸载远程 Runtime Host?', + uninstallConfirmTitle: '卸载此 Runtime Host?', uninstallConfirmBody: '这会停止并移除 Maka 管理的服务与程序,但保留 State Root、项目和任务数据。当前 Desktop Profile 不会被删除。', uninstallConfirm: '卸载服务', uninstallRetained: (path: string) => `服务已卸载,数据保留在 ${path}`, @@ -605,10 +601,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.', @@ -753,6 +745,8 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { refresh: 'Refresh', startService: 'Start', restartService: 'Restart', + restartActiveTasksDescription: 'Restarting stops the current tasks. Interrupt them and continue?', + restartInterrupt: 'Interrupt tasks and restart', repairService: 'Repair', updateService: 'Install matching version', updatePolicy: 'Update policy', @@ -804,7 +798,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { showLogs: 'View logs', noLogs: 'No service logs were found', uninstallService: 'Uninstall service', - uninstallConfirmTitle: 'Uninstall the remote Runtime Host?', + uninstallConfirmTitle: 'Uninstall this Runtime Host?', uninstallConfirmBody: 'This stops and removes the Maka-managed service and program, while preserving the State Root, projects, and task data. The Desktop profile is not removed.', uninstallConfirm: 'Uninstall service', uninstallRetained: (path: string) => `Service uninstalled. Data was retained at ${path}`, 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..c8352df3d7 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,8 @@ import { } from './runtime-host-project-directory-editor.js'; type RuntimeHostManagementConfirmation = - | { readonly kind: 'uninstall' } + | { readonly kind: 'uninstall'; readonly allowInterruptActiveTasks: boolean } + | { readonly kind: 'restart' } | { readonly kind: 'update' } | { readonly kind: 'configureDirectories' } | { readonly kind: 'rotate' } @@ -80,8 +80,15 @@ type DirectoryPolicyEdit = { readonly draft: readonly ProjectDirectoryRootDraft[]; readonly conflict?: DirectoryPolicySnapshot; }; +export interface RuntimeHostManagementTarget { + readonly id: string; + readonly name: string; + readonly subtitle?: string; + readonly directPeerManagement: boolean; +} + export function RuntimeHostManagementDialog(props: { - readonly profile: RemoteRuntimeHostProfile | undefined; + readonly target: RuntimeHostManagementTarget | undefined; readonly onClose: () => void; }) { const locale = useUiLocale(); @@ -109,9 +116,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 +140,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 +154,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) { @@ -156,9 +163,9 @@ export function RuntimeHostManagementDialog(props: { } } } - if (shouldLoadUpdatePolicy) { + if (shouldLoadUpdatePolicy && target.directPeerManagement) { 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 +176,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 +188,32 @@ 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; + } + if (action === 'restart' && response.error.code === 'active_tasks') { + setError(undefined); + setConfirmation({ kind: 'restart' }); + return; + } setUpdatePolicy(undefined); setError(response.error.message); toast.error(copy.managementActionFailed, response.error.message); @@ -199,12 +223,14 @@ export function RuntimeHostManagementDialog(props: { setResult(undefined); setUpdatePolicy(undefined); setUninstalledRoot(response.retainedStateRoot); + setConfirmation(undefined); return; } + setConfirmation(undefined); 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 +249,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 +262,7 @@ export function RuntimeHostManagementDialog(props: { } async function configureDirectPeer(enabled: boolean): Promise { - if (!profile) return; + if (!target) return; setLoading(true); setDirectPeerError(undefined); try { @@ -246,7 +272,7 @@ export function RuntimeHostManagementDialog(props: { .filter(Boolean); applyDirectPeer( await window.maka.runtimeHostManagement.configureDirectPeer( - profile.id, + target.id, enabled, relays, automaticRelayDiscovery, @@ -255,7 +281,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 +293,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 +308,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 +316,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 +338,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 +395,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 +455,7 @@ export function RuntimeHostManagementDialog(props: { } async function saveUpdatePolicy(): Promise { - if (!profile) return; + if (!target) return; setLoading(true); setError(undefined); setUpdatePolicyError(undefined); @@ -441,7 +467,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 +480,7 @@ export function RuntimeHostManagementDialog(props: { } async function reconcileUpdate(): Promise { - if (!profile) return; + if (!target) return; setLoading(true); setError(undefined); setReconnectWarning(undefined); @@ -462,7 +488,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 +515,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 +542,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 +583,7 @@ export function RuntimeHostManagementDialog(props: { const updateOutcome = lastUpdateOutcome; return ( { if (!open && !loading) props.onClose(); }} @@ -568,8 +594,8 @@ export function RuntimeHostManagementDialog(props: { { if (!open && !loading) props.onClose(); }} @@ -603,7 +629,9 @@ export function RuntimeHostManagementDialog(props: { ) : null} {confirmation?.kind === 'update' ? ( @@ -613,6 +641,13 @@ export function RuntimeHostManagementDialog(props: { description={copy.updateBlockedBody} /> ) : null} + {confirmation?.kind === 'restart' ? ( + + ) : null} {confirmation?.kind === 'rotate' ? ( ) : null} - {serviceInstalled ? ( + {serviceInstalled && target?.directPeerManagement ? (
@@ -1116,6 +1151,21 @@ export function RuntimeHostManagementDialog(props: { onClick={() => void update(true)} /> + ) : confirmation?.kind === 'restart' ? ( + <> +