You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/20 05:06:05 UTC
(maka) 01/01: feat(desktop): manage remote runtime host services
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/desktop-runtime-host-management
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 6ec884221ce664f83d9f7f5cc10733d3cc63c6a2
Author: Wang <m4...@gmail.com>
AuthorDate: Thu Aug 20 13:05:50 2026 +0800
feat(desktop): manage remote runtime host services
Add an SSH-backed management surface for installed remote Runtime Host services so Desktop can inspect status and logs, recover or restart the service, and uninstall it without conflating service lifecycle with Profile or State Root ownership.
Keep direct connection Profiles usable without claiming a management channel, and use a bounded framed CLI result so machine output cannot expose launch configuration.
Generated-by: Codex
---
.../__tests__/runtime-host-ssh-terminal.test.ts | 45 +++-
apps/desktop/src/main/runtime-host-boot.ts | 12 +-
apps/desktop/src/main/runtime-host-management.ts | 97 ++++++++
apps/desktop/src/main/runtime-host-ssh-terminal.ts | 217 ++++++++++++----
apps/desktop/src/preload/bridge-contract.d.ts | 47 +++-
apps/desktop/src/preload/preload.ts | 14 ++
.../src/renderer/locales/settings-projects-copy.ts | 84 +++++++
.../settings/runtime-host-management-dialog.tsx | 272 +++++++++++++++++++++
.../settings/runtime-host-onboarding-dialog.tsx | 16 +-
.../settings/runtime-host-profiles-section.tsx | 55 ++++-
.../src/renderer/styles/settings/runtime-host.css | 76 ++++++
docs/runtime-host-remote-access.md | 2 +
docs/runtime-host-remote-access.zh-CN.md | 2 +
.../__tests__/runtime-host-service-manager.test.ts | 59 +++++
.../cli/src/__tests__/runtime-host-setup.test.ts | 2 +
packages/cli/src/cli-core.ts | 3 +-
packages/cli/src/runtime-host-cli.ts | 20 +-
.../src/runtime-host-service-management-command.ts | 68 +++++-
packages/cli/src/runtime-host-service-manager.ts | 41 +++-
packages/cli/src/runtime-host-systemd-service.ts | 25 ++
packages/runtime-host/src/client/index.ts | 11 +
.../src/client/service-management-frame.ts | 116 +++++++++
22 files changed, 1212 insertions(+), 72 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
index d1ae12dcc..0f1b0a903 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
@@ -5,6 +5,7 @@ import { join } from 'node:path';
import { test } from 'node:test';
import type { IPty } from 'node-pty';
import {
+ encodeRuntimeHostServiceManagementFrame,
encodeRuntimeHostSetupFrame,
type RuntimeHostSshProcessFactory,
} from '@maka/runtime-host/client';
@@ -137,6 +138,49 @@ test('force-stops a cancelled setup when SSH ignores graceful termination', asyn
await harness.terminal.close();
});
+test('reads a framed service result without projecting it into the SSH terminal', async () => {
+ const harness = createHarness('pending');
+ const management = harness.terminal.runServiceManagement({
+ destination: 'operator@example.com',
+ setupPackage: { kind: 'npm', specifier: 'maka-agent@1.2.3' },
+ principalId: 'desktop:stable-client',
+ action: 'status',
+ });
+ await waitFor(() => harness.pty.hasDataListener());
+ harness.pty.emitData('Password: ');
+ harness.pty.emitData(
+ encodeRuntimeHostServiceManagementFrame({
+ schemaVersion: 1,
+ kind: 'result',
+ action: 'status',
+ service: {
+ manager: 'systemd_user',
+ platform: 'linux',
+ arch: 'x64',
+ osRelease: '6.8.0',
+ installed: true,
+ enabled: true,
+ active: true,
+ state: 'running',
+ pid: 42,
+ lastExitCode: 0,
+ installedVersion: '1.2.3',
+ stateRoot: '/home/operator/.config/Maka/workspaces/default',
+ projectDirectoryRoots: [],
+ },
+ }),
+ );
+ harness.pty.exit(0);
+
+ const result = await management;
+ assert.equal(result.kind, 'result');
+ if (result.kind !== 'result') assert.fail('expected service management result');
+ assert.equal(result.service.installedVersion, '1.2.3');
+ assert.doesNotMatch(JSON.stringify(harness.events), /MAKA_RUNTIME_HOST_SERVICE/u);
+ assert.match(JSON.stringify(harness.events), /Password/u);
+ await harness.terminal.close();
+});
+
test('uploads a development release archive before running the same remote setup', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'maka-runtime-host-development-package-'));
t.after(() => rm(directory, { recursive: true, force: true }));
@@ -184,7 +228,6 @@ test('uploads a development release archive before running the same remote setup
assert.match(remoteCommand, /cd.*\$HOME/u);
assert.match(remoteCommand, /rm -f/u);
assert.match(remoteCommand, /exec \/bin\/sh -c/u);
- assert.match(remoteCommand, /maka_setup_exit/u);
launches[1]?.pty.exit(255);
await assert.rejects(setup, /exited with code 255/u);
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 4d66ecb18..34aaf6137 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -121,6 +121,7 @@ import {
type DesktopRuntimeHostSetupPackage,
} from "./runtime-host-ssh-terminal.js";
import { createDesktopRuntimeHostOnboarding } from "./runtime-host-onboarding.js";
+import { createDesktopRuntimeHostManagement } from "./runtime-host-management.js";
import { registerRuntimeHostOAuthIpc } from "./runtime-host-oauth-ipc-main.js";
import { RuntimeHostOAuthPresentation } from "./runtime-host-oauth-presentation.js";
import { registerRuntimeHostPermissionsIpc } from "./runtime-host-permissions-ipc-main.js";
@@ -304,15 +305,23 @@ const runtimeHostProfileService = createDesktopRuntimeHostProfileService({
runtimeHostManager.setDefaultProfile(profileId);
},
});
+const desktopRuntimeHostSetupPackage = runtimeHostSetupPackage();
const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({
ipcMain,
clientInstanceId: runtimeHostClientInstanceId,
profiles: runtimeHostProfileService,
runSetup: runtimeHostSshTerminal.runSetup,
- setupPackage: runtimeHostSetupPackage(),
+ setupPackage: desktopRuntimeHostSetupPackage,
send: (snapshot) =>
mainWindowController.send("runtime-host-onboarding:changed", snapshot),
});
+const runtimeHostManagement = createDesktopRuntimeHostManagement({
+ ipcMain,
+ clientInstanceId: runtimeHostClientInstanceId,
+ profiles: runtimeHostProfileService,
+ runServiceManagement: runtimeHostSshTerminal.runServiceManagement,
+ setupPackage: desktopRuntimeHostSetupPackage,
+});
function runtimeHostSetupPackage(): DesktopRuntimeHostSetupPackage {
if (!app.isPackaged && process.env.MAKA_RUNTIME_HOST_SETUP_ARCHIVE) {
@@ -1376,6 +1385,7 @@ async function closeRuntimeHostDesktop(): Promise<void> {
settingsBotsIpc?.dispose();
permissionOverlay.dismiss();
const results = await Promise.allSettled([
+ Promise.resolve().then(() => runtimeHostManagement.close()),
runtimeHostManager?.close(),
runtimeHostOnboarding.close(),
runtimeHostSshTerminal.close(),
diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts
new file mode 100644
index 000000000..93d4cc12f
--- /dev/null
+++ b/apps/desktop/src/main/runtime-host-management.ts
@@ -0,0 +1,97 @@
+import type { IpcMain } from 'electron';
+import type { RuntimeHostServiceManagementFrame } from '@maka/runtime-host/client';
+import type {
+ DesktopRuntimeHostManagementAction,
+ DesktopRuntimeHostManagementSnapshot,
+} from '../preload/bridge-contract.js';
+import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js';
+import type {
+ DesktopRuntimeHostSetupPackage,
+ DesktopRuntimeHostSshManagementInput,
+} from './runtime-host-ssh-terminal.js';
+
+const MANAGEMENT_ACTIONS = new Set<DesktopRuntimeHostManagementAction>([
+ 'status',
+ 'start',
+ 'stop',
+ 'restart',
+ 'logs',
+ 'uninstall',
+]);
+
+export function createDesktopRuntimeHostManagement(input: {
+ readonly ipcMain: Pick<IpcMain, 'handle' | 'removeHandler'>;
+ readonly clientInstanceId: string;
+ readonly profiles: Pick<DesktopRuntimeHostProfileService, 'getSnapshot'>;
+ readonly runServiceManagement: (
+ input: DesktopRuntimeHostSshManagementInput,
+ ) => Promise<RuntimeHostServiceManagementFrame>;
+ readonly setupPackage: DesktopRuntimeHostSetupPackage;
+}): { close(): void } {
+ const resolveProfile = async (value: unknown) => {
+ if (typeof value !== 'string' || value.length === 0 || value.length > 128) {
+ throw new Error('Runtime Host profile ID is invalid');
+ }
+ const snapshot = await input.profiles.getSnapshot();
+ const entry = snapshot.entries.find((candidate) => candidate.profile.id === value);
+ if (!entry || entry.profile.kind !== 'remote') {
+ throw new Error('Remote Runtime Host profile was not found');
+ }
+ return entry.profile;
+ };
+
+ const run = async (profileId: unknown, action: unknown) => {
+ if (!MANAGEMENT_ACTIONS.has(action as DesktopRuntimeHostManagementAction)) {
+ throw new Error('Runtime Host service management action is invalid');
+ }
+ const profile = await resolveProfile(profileId);
+ if (profile.transport.kind !== 'ssh') {
+ throw new Error('This Runtime Host profile does not have an SSH management channel');
+ }
+ return input.runServiceManagement({
+ destination: profile.transport.destination,
+ ...(profile.transport.sshPort === undefined ? {} : { sshPort: profile.transport.sshPort }),
+ setupPackage: input.setupPackage,
+ principalId: `desktop:${input.clientInstanceId}`,
+ action: action as DesktopRuntimeHostManagementAction,
+ });
+ };
+
+ const channels = [
+ 'runtime-host-management:getStatus',
+ 'runtime-host-management:run',
+ ] as const;
+ input.ipcMain.handle(channels[0], async (_event, profileId: unknown) => {
+ const profile = await resolveProfile(profileId);
+ if (profile.transport.kind !== 'ssh') {
+ return {
+ kind: 'unavailable',
+ profileId: profile.id,
+ profileName: profile.name,
+ reason: 'ssh_required',
+ } satisfies DesktopRuntimeHostManagementSnapshot;
+ }
+ const response = await run(profile.id, 'status');
+ return response.kind === 'result'
+ ? {
+ kind: 'available',
+ profileId: profile.id,
+ profileName: profile.name,
+ result: response,
+ }
+ : {
+ kind: 'failed',
+ profileId: profile.id,
+ profileName: profile.name,
+ error: response.error,
+ } satisfies DesktopRuntimeHostManagementSnapshot;
+ });
+ input.ipcMain.handle(channels[1], (_event, profileId: unknown, action: unknown) =>
+ run(profileId, action));
+
+ return {
+ close() {
+ for (const channel of channels) input.ipcMain.removeHandler(channel);
+ },
+ };
+}
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index a7f027108..f8ef6ac0e 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -5,9 +5,13 @@ import type { IpcMain } from 'electron';
import type { IPty } from 'node-pty';
import { spawn as spawnPty } from 'node-pty';
import {
+ decodeRuntimeHostServiceManagementFrame,
decodeRuntimeHostSetupFrame,
openRuntimeHostSshTunnel,
+ RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
RUNTIME_HOST_SETUP_FRAME_PREFIX,
+ type RuntimeHostServiceManagementAction,
+ type RuntimeHostServiceManagementFrame,
type RuntimeHostSetupFrame,
type RuntimeHostSshProcess,
type RuntimeHostSshProcessFactory,
@@ -32,8 +36,9 @@ interface ActiveTerminal {
const TERMINAL_REVEAL_DELAY_MS = 500;
const TERMINAL_OUTPUT_MAX = 64 * 1024;
-const SETUP_FRAME_PENDING_MAX = 20 * 1024;
+const FRAME_PENDING_MAX = 128 * 1024;
const SETUP_TIMEOUT_MS = 10 * 60_000;
+const MANAGEMENT_TIMEOUT_MS = 2 * 60_000;
const PROCESS_STOP_GRACE_MS = 2_000;
export interface DesktopRuntimeHostSshSetupInput {
@@ -44,6 +49,15 @@ export interface DesktopRuntimeHostSshSetupInput {
readonly signal?: AbortSignal;
}
+export interface DesktopRuntimeHostSshManagementInput {
+ readonly destination: string;
+ readonly sshPort?: number;
+ readonly setupPackage: DesktopRuntimeHostSetupPackage;
+ readonly principalId: string;
+ readonly action: RuntimeHostServiceManagementAction;
+ readonly signal?: AbortSignal;
+}
+
export type DesktopRuntimeHostSetupPackage =
| { readonly kind: 'npm'; readonly specifier: string }
| { readonly kind: 'development_archive'; readonly path: string };
@@ -68,6 +82,9 @@ export function createDesktopRuntimeHostSshTerminal(input: {
onProgress: (frame: Extract<RuntimeHostSetupFrame, { kind: 'progress' }>) => void,
onComplete?: (frame: RuntimeHostSetupCompleteFrame) => void,
): Promise<RuntimeHostSetupCompleteFrame>;
+ runServiceManagement(
+ input: DesktopRuntimeHostSshManagementInput,
+ ): Promise<RuntimeHostServiceManagementFrame>;
close(): Promise<void>;
} {
let active: ActiveTerminal | undefined;
@@ -88,9 +105,10 @@ export function createDesktopRuntimeHostSshTerminal(input: {
sessionId: terminal.sessionId,
});
}
- function completePresentation(terminal: ActiveTerminal): void {
+ function completePresentation(terminal: ActiveTerminal, releaseProcess = false): void {
if (active !== terminal || terminal.phase !== 'connecting') return;
terminal.phase = 'connected';
+ if (releaseProcess) active = undefined;
presentation = undefined;
revision += 1;
if (terminal.revealTimer !== undefined) {
@@ -269,7 +287,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
if (tunnelInput.interaction !== 'terminal') return openSshTunnel(tunnelInput);
const tunnel = await openSshTunnel(tunnelInput, { spawnProcess });
const terminal = active;
- if (terminal) completePresentation(terminal);
+ if (terminal) completePresentation(terminal, true);
return tunnel;
},
runSetup: async (setupInput, onProgress, onComplete) => {
@@ -294,8 +312,11 @@ export function createDesktopRuntimeHostSshTerminal(input: {
let complete: RuntimeHostSetupCompleteFrame | undefined;
let setupFailure: Error | undefined;
let setupTerminal: ActiveTerminal | undefined;
- const filter = createSetupOutputFilter(
- (frame) => {
+ const filter = createFramedOutputFilter({
+ prefix: RUNTIME_HOST_SETUP_FRAME_PREFIX,
+ decode: decodeRuntimeHostSetupFrame,
+ label: 'Remote Maka setup',
+ onFrame: (frame) => {
if (frame.kind === 'progress') onProgress(frame);
else if (frame.kind === 'complete') {
if (!cancellation.commit()) return;
@@ -304,30 +325,13 @@ export function createDesktopRuntimeHostSshTerminal(input: {
if (setupTerminal) completePresentation(setupTerminal);
} else setupFailure = new Error(frame.error.message);
},
- (error) => {
+ onError: (error) => {
setupFailure = error;
},
- );
+ });
const { process, terminal } = startTerminalProcess(
'ssh',
- [
- '-tt',
- '-o',
- 'BatchMode=no',
- '-o',
- 'ConnectTimeout=15',
- '-o',
- 'ControlMaster=no',
- '-o',
- 'ControlPath=none',
- '-o',
- 'ClearAllForwardings=yes',
- '-o',
- 'RemoteCommand=none',
- ...(sshPort === undefined ? [] : ['-p', String(sshPort)]),
- destination,
- remoteCommand,
- ],
+ sshRemoteCommandArgs(destination, sshPort, remoteCommand),
filter.push,
);
setupTerminal = terminal;
@@ -356,6 +360,74 @@ export function createDesktopRuntimeHostSshTerminal(input: {
cancellation.close();
}
},
+ runServiceManagement: async (managementInput) => {
+ managementInput.signal?.throwIfAborted();
+ const destination = requireSetupDestination(managementInput.destination);
+ const sshPort = managementInput.sshPort === undefined
+ ? undefined
+ : requireSetupPort(managementInput.sshPort);
+ const setupPackage = await prepareSetupPackage(
+ managementInput.setupPackage,
+ destination,
+ sshPort,
+ managementInput.principalId,
+ startTerminalProcess,
+ managementInput.signal,
+ input.processStopGraceMs,
+ dismissPresentation,
+ );
+ let frame: RuntimeHostServiceManagementFrame | undefined;
+ let frameFailure: Error | undefined;
+ let managementTerminal: ActiveTerminal | undefined;
+ const filter = createFramedOutputFilter({
+ prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
+ decode: decodeRuntimeHostServiceManagementFrame,
+ label: 'Remote Runtime Host service management',
+ onFrame: (next) => {
+ if (frame) {
+ frameFailure = new Error(
+ 'Remote Runtime Host service management returned multiple results',
+ );
+ return;
+ }
+ frame = next;
+ if (managementTerminal) completePresentation(managementTerminal);
+ },
+ onError: (error) => {
+ frameFailure = error;
+ },
+ });
+ const { process, terminal } = startTerminalProcess(
+ 'ssh',
+ sshRemoteCommandArgs(
+ destination,
+ sshPort,
+ runtimeHostServiceManagementRemoteCommand(setupPackage, managementInput.action),
+ ),
+ filter.push,
+ true,
+ );
+ managementTerminal = terminal;
+ if (frame) completePresentation(terminal);
+ const result = await waitForTerminalProcess(process, {
+ signal: managementInput.signal,
+ timeoutMs: MANAGEMENT_TIMEOUT_MS,
+ timeoutMessage: 'Remote Runtime Host service management timed out',
+ stopGraceMs: input.processStopGraceMs,
+ onAbort: () => dismissPresentation(terminal),
+ });
+ filter.finish();
+ if (frameFailure) throw frameFailure;
+ if (!frame) {
+ throw new Error(
+ result.code === 0
+ ? 'Remote Runtime Host service management ended without a result'
+ : `Remote Runtime Host service management exited with code ${String(result.code)}`,
+ );
+ }
+ completePresentation(terminal);
+ return frame;
+ },
close: async () => {
for (const channel of channels) input.ipcMain.removeHandler(channel);
const terminal = active;
@@ -394,34 +466,37 @@ function cancellableUntilComplete(signal: AbortSignal | undefined): {
};
}
-function createSetupOutputFilter(
- onFrame: (frame: RuntimeHostSetupFrame) => void,
- onError: (error: Error) => void,
-): { push(data: string): string; finish(): string } {
+function createFramedOutputFilter<Frame>(input: {
+ readonly prefix: string;
+ readonly decode: (line: string) => Frame | undefined;
+ readonly label: string;
+ readonly onFrame: (frame: Frame) => void;
+ readonly onError: (error: Error) => void;
+}): { push(data: string): string; finish(): string } {
let pending = '';
const drain = (finished: boolean): string => {
let visible = '';
while (pending) {
- const marker = pending.indexOf(RUNTIME_HOST_SETUP_FRAME_PREFIX);
+ const marker = pending.indexOf(input.prefix);
if (marker >= 0) {
visible += pending.slice(0, marker);
pending = pending.slice(marker);
const newline = pending.indexOf('\n');
if (newline < 0) {
if (finished) {
- onError(new Error('Remote Maka setup returned an incomplete result'));
+ input.onError(new Error(`${input.label} returned an incomplete result`));
pending = '';
- } else if (pending.length > SETUP_FRAME_PENDING_MAX) {
- onError(new Error('Remote Maka setup returned an oversized result'));
+ } else if (pending.length > FRAME_PENDING_MAX) {
+ input.onError(new Error(`${input.label} returned an oversized result`));
pending = '';
}
break;
}
const line = pending.slice(0, newline + 1);
pending = pending.slice(newline + 1);
- const frame = decodeRuntimeHostSetupFrame(line);
- if (frame) onFrame(frame);
- else onError(new Error('Remote Maka setup returned an invalid result'));
+ const frame = input.decode(line);
+ if (frame) input.onFrame(frame);
+ else input.onError(new Error(`${input.label} returned an invalid result`));
continue;
}
if (finished) {
@@ -429,7 +504,7 @@ function createSetupOutputFilter(
pending = '';
break;
}
- const retained = setupMarkerSuffixLength(pending);
+ const retained = markerSuffixLength(pending, input.prefix);
visible += pending.slice(0, pending.length - retained);
pending = pending.slice(pending.length - retained);
break;
@@ -447,10 +522,10 @@ function createSetupOutputFilter(
};
}
-function setupMarkerSuffixLength(value: string): number {
- const limit = Math.min(value.length, RUNTIME_HOST_SETUP_FRAME_PREFIX.length - 1);
+function markerSuffixLength(value: string, prefix: string): number {
+ const limit = Math.min(value.length, prefix.length - 1);
for (let length = limit; length > 0; length -= 1) {
- if (RUNTIME_HOST_SETUP_FRAME_PREFIX.startsWith(value.slice(-length))) return length;
+ if (prefix.startsWith(value.slice(-length))) return length;
}
return 0;
}
@@ -599,12 +674,7 @@ function runtimeHostSetupRemoteCommand(
if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(principalId)) {
throw new Error('Runtime Host setup principal is invalid');
}
- const setup = [
- 'npx',
- '--yes',
- '--package',
- setupPackage.specifier,
- 'maka',
+ return runtimeHostPackageRemoteCommand(setupPackage, [
'runtime-host',
'setup',
'--principal',
@@ -613,14 +683,65 @@ function runtimeHostSetupRemoteCommand(
'desktop-client',
'--defer-pairing-commit',
'--json',
+ ]);
+}
+
+function runtimeHostServiceManagementRemoteCommand(
+ setupPackage: PreparedSetupPackage,
+ action: RuntimeHostServiceManagementAction,
+): string {
+ return runtimeHostPackageRemoteCommand(setupPackage, [
+ 'runtime-host',
+ 'service',
+ action,
+ '--framed',
+ ]);
+}
+
+function runtimeHostPackageRemoteCommand(
+ setupPackage: PreparedSetupPackage,
+ args: readonly string[],
+): string {
+ const invocation = [
+ 'npx',
+ '--yes',
+ '--package',
+ setupPackage.specifier,
+ 'maka',
+ ...args,
].map(quotePosix).join(' ');
const command = setupPackage.removeAfterSetup
- ? `cd "$HOME" || exit 1; maka_setup_exit=0; ${setup} || maka_setup_exit=$?; rm -f -- ${quotePosix(setupPackage.removeAfterSetup)}; exit "$maka_setup_exit"`
- : `exec ${setup}`;
+ ? `cd "$HOME" || exit 1; maka_command_exit=0; ${invocation} || maka_command_exit=$?; rm -f -- ${quotePosix(setupPackage.removeAfterSetup)}; exit "$maka_command_exit"`
+ : `exec ${invocation}`;
const loginCommand = `exec /bin/sh -c ${quotePosix(command)}`;
return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(loginCommand)}`;
}
+function sshRemoteCommandArgs(
+ destination: string,
+ sshPort: number | undefined,
+ remoteCommand: string,
+): string[] {
+ return [
+ '-tt',
+ '-o',
+ 'BatchMode=no',
+ '-o',
+ 'ConnectTimeout=15',
+ '-o',
+ 'ControlMaster=no',
+ '-o',
+ 'ControlPath=none',
+ '-o',
+ 'ClearAllForwardings=yes',
+ '-o',
+ 'RemoteCommand=none',
+ ...(sshPort === undefined ? [] : ['-p', String(sshPort)]),
+ destination,
+ remoteCommand,
+ ];
+}
+
function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 5fe281dee..9be7a4515 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -84,7 +84,12 @@ import type {
OperationInput,
OperationOutput,
} from '@maka/runtime-host/protocol';
-import type { AgentGraphEpochDirectory, RuntimeHostSetupPhase } from '@maka/runtime-host/client';
+import type {
+ AgentGraphEpochDirectory,
+ RuntimeHostServiceManagementAction,
+ RuntimeHostServiceManagementFrame,
+ RuntimeHostSetupPhase,
+} from '@maka/runtime-host/client';
import type {
RendererRuntimeHostCommandOperation,
RendererRuntimeHostQueryOperation,
@@ -357,6 +362,38 @@ export type DesktopRuntimeHostOnboardingSnapshot =
readonly profileId: string;
};
+export type DesktopRuntimeHostManagementAction = Exclude<
+ RuntimeHostServiceManagementAction,
+ 'install'
+>;
+
+export type DesktopRuntimeHostManagementResult = Extract<
+ RuntimeHostServiceManagementFrame,
+ { kind: 'result' }
+>;
+
+export type DesktopRuntimeHostManagementResponse = RuntimeHostServiceManagementFrame;
+
+export type DesktopRuntimeHostManagementSnapshot =
+ | {
+ readonly kind: 'unavailable';
+ readonly profileId: string;
+ readonly profileName: string;
+ readonly reason: 'ssh_required';
+ }
+ | {
+ readonly kind: 'failed';
+ readonly profileId: string;
+ readonly profileName: string;
+ readonly error: Extract<RuntimeHostServiceManagementFrame, { kind: 'error' }>['error'];
+ }
+ | {
+ readonly kind: 'available';
+ readonly profileId: string;
+ readonly profileName: string;
+ readonly result: DesktopRuntimeHostManagementResult;
+ };
+
export interface DesktopProjectCapabilities {
readonly chooseClientDirectory: boolean;
readonly chooseHostDirectory: boolean;
@@ -453,6 +490,14 @@ export interface MakaBridge {
subscribe(handler: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void): () => void;
};
+ runtimeHostManagement: {
+ getStatus(profileId: string): Promise<DesktopRuntimeHostManagementSnapshot>;
+ run(
+ profileId: string,
+ action: DesktopRuntimeHostManagementAction,
+ ): Promise<DesktopRuntimeHostManagementResponse>;
+ };
+
newTasks: {
getCatalog(): Promise<DesktopNewTaskCatalog>;
subscribeChanges(handler: () => void): () => void;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 0dc0bd3eb..b5b56a7b6 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -24,6 +24,9 @@ import type {
DesktopRuntimeHostSshTerminalSnapshot,
DesktopRuntimeHostOnboardingInput,
DesktopRuntimeHostOnboardingSnapshot,
+ DesktopRuntimeHostManagementAction,
+ DesktopRuntimeHostManagementSnapshot,
+ DesktopRuntimeHostManagementResponse,
DesktopNewTaskCatalog,
DesktopNewTaskHost,
DesktopNewTaskHostRef,
@@ -1089,6 +1092,17 @@ const makaBridge = {
return () => ipcRenderer.off('runtime-host-onboarding:changed', listener);
},
},
+ runtimeHostManagement: {
+ getStatus(profileId: string): Promise<DesktopRuntimeHostManagementSnapshot> {
+ return ipcRenderer.invoke('runtime-host-management:getStatus', profileId);
+ },
+ run(
+ profileId: string,
+ action: DesktopRuntimeHostManagementAction,
+ ): Promise<DesktopRuntimeHostManagementResponse> {
+ return ipcRenderer.invoke('runtime-host-management:run', profileId, action);
+ },
+ },
newTasks: {
getCatalog(): Promise<DesktopNewTaskCatalog> {
return loadNewTaskCatalog();
diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
index 4ddfb39b4..adf626ed4 100644
--- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
@@ -57,6 +57,30 @@ export type SettingsProjectsCopy = {
defaultBadge: string;
defaultDisableHelp: string;
unavailable: string;
+ manage: string;
+ managementTitle(name: string): string;
+ managementUnavailable: string;
+ serviceStatus: string;
+ serviceState: Record<import('../../preload/bridge-contract.js').DesktopRuntimeHostManagementResult['service']['state'], string>;
+ installedVersion: string;
+ operatingSystem: string;
+ processId: string;
+ lastExitCode: string;
+ stateRoot: string;
+ directoryRoots: string;
+ noDirectoryRoots: string;
+ refresh: string;
+ startService: string;
+ restartService: string;
+ repairService: string;
+ showLogs: string;
+ noLogs: string;
+ uninstallService: string;
+ uninstallConfirmTitle: string;
+ uninstallConfirmBody: string;
+ uninstallConfirm: string;
+ uninstallRetained(path: string): string;
+ managementActionFailed: string;
remove: string;
empty: string;
loadFailed: string;
@@ -167,6 +191,36 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
defaultBadge: '默认',
defaultDisableHelp: '先选择另一个默认 Host,才能停用此 Host',
unavailable: '无法连接',
+ manage: '管理',
+ managementTitle: (name: string) => `管理 ${name}`,
+ managementUnavailable: '此连接没有 SSH 管理通道。它仍可正常使用,但需要在 Host 机器上管理服务。',
+ serviceStatus: '服务状态',
+ serviceState: {
+ not_installed: '未安装',
+ stopped: '已停止',
+ starting: '正在启动',
+ running: '运行中',
+ failed: '启动失败',
+ },
+ installedVersion: '版本',
+ operatingSystem: '系统',
+ processId: '进程 ID',
+ lastExitCode: '上次退出码',
+ stateRoot: 'State Root',
+ directoryRoots: '可用目录',
+ noDirectoryRoots: '未配置额外目录',
+ refresh: '刷新',
+ startService: '启动',
+ restartService: '重启',
+ repairService: '修复',
+ showLogs: '查看日志',
+ noLogs: '没有服务日志',
+ uninstallService: '卸载服务',
+ uninstallConfirmTitle: '卸载远程 Runtime Host?',
+ uninstallConfirmBody: '这会停止并移除 Maka 管理的服务与程序,但保留 State Root、项目和任务数据。当前 Desktop Profile 不会被删除。',
+ uninstallConfirm: '卸载服务',
+ uninstallRetained: (path: string) => `服务已卸载,数据保留在 ${path}`,
+ managementActionFailed: '无法管理 Runtime Host 服务',
remove: '移除',
empty: '还没有远程 Host',
loadFailed: '无法读取 Runtime Host profiles',
@@ -275,6 +329,36 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
defaultBadge: 'Default',
defaultDisableHelp: 'Choose another default Host before disabling this Host',
unavailable: 'Unavailable',
+ manage: 'Manage',
+ managementTitle: (name: string) => `Manage ${name}`,
+ managementUnavailable: 'This connection has no SSH management channel. It remains usable, but the service must be managed on the Host machine.',
+ serviceStatus: 'Service status',
+ serviceState: {
+ not_installed: 'Not installed',
+ stopped: 'Stopped',
+ starting: 'Starting',
+ running: 'Running',
+ failed: 'Failed',
+ },
+ installedVersion: 'Version',
+ operatingSystem: 'System',
+ processId: 'Process ID',
+ lastExitCode: 'Last exit code',
+ stateRoot: 'State Root',
+ directoryRoots: 'Available directories',
+ noDirectoryRoots: 'No additional directories configured',
+ refresh: 'Refresh',
+ startService: 'Start',
+ restartService: 'Restart',
+ repairService: 'Repair',
+ showLogs: 'View logs',
+ noLogs: 'No service logs were found',
+ uninstallService: 'Uninstall service',
+ uninstallConfirmTitle: 'Uninstall the remote 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}`,
+ managementActionFailed: 'Unable to manage the Runtime Host service',
remove: 'Remove',
empty: 'No remote Hosts yet',
loadFailed: 'Could not load Runtime Host profiles',
diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
new file mode 100644
index 000000000..96e060876
--- /dev/null
+++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
@@ -0,0 +1,272 @@
+import { useEffect, useState } from 'react';
+import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
+import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
+import { Text } from '@astryxdesign/core/Text';
+import { Banner, Button, Spinner, useToast, useUiLocale } from '@maka/ui';
+import type { RemoteRuntimeHostProfile } from '@maka/runtime-host/client';
+import type {
+ DesktopRuntimeHostManagementAction,
+ DesktopRuntimeHostManagementSnapshot,
+} from '../../preload/bridge-contract.js';
+import { getSettingsProjectsCopy } from '../locales/settings-projects-copy.js';
+import { settingsActionErrorMessage } from './settings-error-copy.js';
+
+export function RuntimeHostManagementDialog(props: {
+ readonly profile: RemoteRuntimeHostProfile | undefined;
+ readonly onClose: () => void;
+ readonly onRepair: (profile: RemoteRuntimeHostProfile) => void;
+}) {
+ const locale = useUiLocale();
+ const copy = getSettingsProjectsCopy(locale).runtimeHost;
+ const toast = useToast();
+ const [snapshot, setSnapshot] = useState<DesktopRuntimeHostManagementSnapshot>();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string>();
+ const [logs, setLogs] = useState<string>();
+ const [confirmingUninstall, setConfirmingUninstall] = useState(false);
+
+ const profile = props.profile;
+ useEffect(() => {
+ if (!profile) return;
+ let disposed = false;
+ setSnapshot(undefined);
+ setError(undefined);
+ setLogs(undefined);
+ setConfirmingUninstall(false);
+ setLoading(true);
+ void window.maka.runtimeHostManagement.getStatus(profile.id).then(
+ (next) => {
+ if (!disposed) setSnapshot(next);
+ },
+ (failure) => {
+ if (!disposed) setError(settingsActionErrorMessage(failure, locale));
+ },
+ ).finally(() => {
+ if (!disposed) setLoading(false);
+ });
+ return () => {
+ disposed = true;
+ };
+ }, [locale, profile]);
+
+ async function run(action: DesktopRuntimeHostManagementAction): Promise<void> {
+ if (!profile) return;
+ setLoading(true);
+ setError(undefined);
+ try {
+ const response = await window.maka.runtimeHostManagement.run(profile.id, action);
+ if (response.kind === 'error') {
+ setError(response.error.message);
+ toast.error(copy.managementActionFailed, response.error.message);
+ return;
+ }
+ const result = response;
+ setSnapshot({
+ kind: 'available',
+ profileId: profile.id,
+ profileName: profile.name,
+ result,
+ });
+ if (action === 'logs') setLogs(result.logs ?? '');
+ } catch (failure) {
+ const message = settingsActionErrorMessage(failure, locale);
+ setError(message);
+ toast.error(copy.managementActionFailed, message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const result = snapshot?.kind === 'available' ? snapshot.result : undefined;
+ const service = result?.service;
+ return (
+ <Dialog
+ isOpen={profile !== undefined}
+ onOpenChange={(open) => {
+ if (!open && !loading) props.onClose();
+ }}
+ purpose="form"
+ width={640}
+ maxHeight="calc(100dvh - 64px)"
+ >
+ <Layout
+ header={(
+ <DialogHeader
+ title={profile ? copy.managementTitle(profile.name) : copy.title}
+ subtitle={profile?.transport.kind === 'ssh' ? profile.transport.destination : undefined}
+ onOpenChange={(open) => {
+ if (!open && !loading) props.onClose();
+ }}
+ />
+ )}
+ content={(
+ <LayoutContent padding={4}>
+ <div className="settingsRuntimeHostManagement">
+ {loading && !snapshot ? (
+ <div className="settingsRuntimeHostSetupProgress" role="status">
+ <Spinner size="sm" />
+ </div>
+ ) : null}
+ {error ? <Banner status="error" title={error} /> : null}
+ {snapshot?.kind === 'failed' ? (
+ <Banner status="error" title={snapshot.error.message} />
+ ) : null}
+ {snapshot?.kind === 'unavailable' ? (
+ <Banner status="info" title={copy.managementUnavailable} />
+ ) : null}
+ {confirmingUninstall ? (
+ <Banner
+ status="warning"
+ title={copy.uninstallConfirmTitle}
+ description={copy.uninstallConfirmBody}
+ />
+ ) : null}
+ {service ? (
+ <>
+ {result?.action === 'uninstall' && result.retainedStateRoot ? (
+ <Banner
+ status="success"
+ title={copy.uninstallRetained(result.retainedStateRoot)}
+ />
+ ) : null}
+ <dl className="settingsRuntimeHostManagementFacts">
+ <Fact label={copy.serviceStatus} value={copy.serviceState[service.state]} />
+ <Fact label={copy.installedVersion} value={service.installedVersion ?? '—'} />
+ <Fact
+ label={copy.operatingSystem}
+ value={`${service.platform} ${service.arch} · ${service.osRelease}`}
+ />
+ <Fact label={copy.processId} value={service.pid?.toString() ?? '—'} />
+ <Fact
+ label={copy.lastExitCode}
+ value={service.lastExitCode?.toString() ?? '—'}
+ />
+ {service.stateRoot ? (
+ <Fact label={copy.stateRoot} value={service.stateRoot} wide />
+ ) : null}
+ </dl>
+ <div className="settingsRuntimeHostManagementDirectoryRoots">
+ <Text type="body" weight="semibold">{copy.directoryRoots}</Text>
+ {service.projectDirectoryRoots.length > 0 ? (
+ <ul className="settingsRuntimeHostManagementRoots">
+ {service.projectDirectoryRoots.map((root) => (
+ <li key={`${root.label}:${root.path}`}>
+ <span>{root.label}</span>
+ <code>{root.path}</code>
+ </li>
+ ))}
+ </ul>
+ ) : (
+ <Text type="supporting" color="secondary">{copy.noDirectoryRoots}</Text>
+ )}
+ </div>
+ {logs !== undefined ? (
+ <pre className="settingsRuntimeHostManagementLogs">
+ {logs || copy.noLogs}
+ </pre>
+ ) : null}
+ </>
+ ) : null}
+ </div>
+ </LayoutContent>
+ )}
+ footer={(
+ <LayoutFooter hasDivider>
+ <div className="settingsRuntimeHostManagementActions">
+ {confirmingUninstall ? (
+ <>
+ <Button
+ variant="secondary"
+ label={copy.cancel}
+ isDisabled={loading}
+ onClick={() => setConfirmingUninstall(false)}
+ />
+ <Button
+ variant="destructive"
+ label={copy.uninstallConfirm}
+ isDisabled={loading}
+ clickAction={async () => {
+ await run('uninstall');
+ setConfirmingUninstall(false);
+ }}
+ />
+ </>
+ ) : (
+ <>
+ <Button
+ variant="secondary"
+ label={copy.setupDone}
+ isDisabled={loading}
+ onClick={props.onClose}
+ />
+ {profile?.transport.kind === 'ssh' ? (
+ <Button
+ variant="secondary"
+ label={copy.repairService}
+ isDisabled={loading}
+ onClick={() => props.onRepair(profile)}
+ />
+ ) : null}
+ {snapshot?.kind === 'available' && profile ? (
+ <>
+ <Button
+ variant="secondary"
+ label={copy.refresh}
+ isDisabled={loading}
+ clickAction={() => run('status')}
+ />
+ {service?.installed ? (
+ <Button
+ variant="secondary"
+ label={copy.showLogs}
+ isDisabled={loading}
+ clickAction={() => run('logs')}
+ />
+ ) : null}
+ {service?.installed && service.active ? (
+ <Button
+ variant="primary"
+ label={copy.restartService}
+ isDisabled={loading}
+ clickAction={() => run('restart')}
+ />
+ ) : service?.installed ? (
+ <Button
+ variant="primary"
+ label={copy.startService}
+ isDisabled={loading}
+ clickAction={() => run('start')}
+ />
+ ) : null}
+ {service?.installed ? (
+ <Button
+ variant="secondary"
+ label={copy.uninstallService}
+ isDisabled={loading}
+ onClick={() => setConfirmingUninstall(true)}
+ />
+ ) : null}
+ </>
+ ) : null}
+ </>
+ )}
+ </div>
+ </LayoutFooter>
+ )}
+ />
+ </Dialog>
+ );
+}
+
+function Fact(props: {
+ readonly label: string;
+ readonly value: string;
+ readonly wide?: boolean;
+}) {
+ return (
+ <div className={props.wide ? 'settingsRuntimeHostManagementFactWide' : undefined}>
+ <dt>{props.label}</dt>
+ <dd>{props.value}</dd>
+ </div>
+ );
+}
diff --git a/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
index 64bf16ed6..5b7a904a0 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
@@ -3,17 +3,22 @@ import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { Text } from '@astryxdesign/core/Text';
import { Banner, Button, FormLayout, Spinner, TextInput, useUiLocale } from '@maka/ui';
-import type { DesktopRuntimeHostOnboardingSnapshot } from '../../preload/bridge-contract.js';
+import type {
+ DesktopRuntimeHostOnboardingInput,
+ DesktopRuntimeHostOnboardingSnapshot,
+} from '../../preload/bridge-contract.js';
import { getSettingsProjectsCopy } from '../locales/settings-projects-copy.js';
export function RuntimeHostOnboardingDialog(props: {
readonly isOpen: boolean;
+ readonly initialInput?: DesktopRuntimeHostOnboardingInput;
readonly onClose: () => void;
readonly onChooseProject: (profileId: string) => void;
}) {
const locale = useUiLocale();
const copy = getSettingsProjectsCopy(locale).runtimeHost;
const revision = useRef(-1);
+ const wasOpen = useRef(false);
const [snapshot, setSnapshot] = useState<DesktopRuntimeHostOnboardingSnapshot>({
kind: 'idle',
revision: 0,
@@ -22,6 +27,15 @@ export function RuntimeHostOnboardingDialog(props: {
const [destination, setDestination] = useState('');
const [sshPort, setSshPort] = useState('');
+ useEffect(() => {
+ if (props.isOpen && !wasOpen.current) {
+ setName(props.initialInput?.name ?? '');
+ setDestination(props.initialInput?.destination ?? '');
+ setSshPort(props.initialInput?.sshPort?.toString() ?? '');
+ }
+ wasOpen.current = props.isOpen;
+ }, [props.initialInput, props.isOpen]);
+
useEffect(() => {
if (!props.isOpen) return;
let disposed = false;
diff --git a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
index 3d905c99a..b2e895371 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
@@ -8,7 +8,10 @@ import {
SegmentedControlItem,
Switch,
} from "@astryxdesign/core";
-import type { RuntimeHostRemoteTransport } from "@maka/runtime-host/client";
+import type {
+ RemoteRuntimeHostProfile,
+ RuntimeHostRemoteTransport,
+} from "@maka/runtime-host/client";
import { isCanonicalRuntimeHostWebSocketPath } from "@maka/runtime-host/protocol";
import {
Badge,
@@ -26,6 +29,8 @@ import { PasswordInput } from "./password-input.js";
import { settingsActionErrorMessage } from "./settings-error-copy.js";
import { SettingsField, SettingsRow, SettingsSection } from "./settings-section.js";
import { RuntimeHostOnboardingDialog } from './runtime-host-onboarding-dialog.js';
+import { RuntimeHostManagementDialog } from './runtime-host-management-dialog.js';
+import type { DesktopRuntimeHostOnboardingInput } from '../../preload/bridge-contract.js';
type RemoteTransportKind = RuntimeHostRemoteTransport["kind"];
@@ -57,6 +62,8 @@ export function RuntimeHostProfilesSection(props: {
>();
const [showAdd, setShowAdd] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
+ const [onboardingInput, setOnboardingInput] = useState<DesktopRuntimeHostOnboardingInput>();
+ const [managedProfile, setManagedProfile] = useState<RemoteRuntimeHostProfile>();
const [switching, setSwitching] = useState(false);
const [draft, setDraft] = useState(createRemoteHostDraft);
@@ -193,7 +200,10 @@ export function RuntimeHostProfilesSection(props: {
size="sm"
label={copy.addComputer}
isDisabled={switching}
- onClick={() => setShowOnboarding(true)}
+ onClick={() => {
+ setOnboardingInput(undefined);
+ setShowOnboarding(true);
+ }}
/>
<Button
variant="secondary"
@@ -322,14 +332,21 @@ export function RuntimeHostProfilesSection(props: {
<MoreMenu
label={copy.moreActions(profile.name)}
size="sm"
- items={[{
- label: copy.remove,
- isDisabled:
- switching ||
- entry.enabled ||
- entry.isDefault,
- onClick: () => void remove(profile.id),
- }]}
+ items={[
+ {
+ label: copy.manage,
+ isDisabled: switching,
+ onClick: () => setManagedProfile(profile),
+ },
+ {
+ label: copy.remove,
+ isDisabled:
+ switching ||
+ entry.enabled ||
+ entry.isDefault,
+ onClick: () => void remove(profile.id),
+ },
+ ]}
/>
</HStack>
}
@@ -341,12 +358,30 @@ export function RuntimeHostProfilesSection(props: {
</SettingsSection>
<RuntimeHostOnboardingDialog
isOpen={showOnboarding}
+ initialInput={onboardingInput}
onClose={() => {
setShowOnboarding(false);
+ setOnboardingInput(undefined);
void reload();
}}
onChooseProject={(profileId) => props.onChooseProject?.(profileId)}
/>
+ <RuntimeHostManagementDialog
+ profile={managedProfile}
+ onClose={() => setManagedProfile(undefined)}
+ onRepair={(profile) => {
+ if (profile.transport.kind !== 'ssh') return;
+ setManagedProfile(undefined);
+ setOnboardingInput({
+ name: profile.name,
+ destination: profile.transport.destination,
+ ...(profile.transport.sshPort === undefined
+ ? {}
+ : { sshPort: profile.transport.sshPort }),
+ });
+ setShowOnboarding(true);
+ }}
+ />
</>
);
}
diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css
index 822a4f5de..a5a6a1523 100644
--- a/apps/desktop/src/renderer/styles/settings/runtime-host.css
+++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css
@@ -69,6 +69,82 @@
min-height: 88px;
}
+.settingsRuntimeHostManagement {
+ display: grid;
+ gap: var(--space-4);
+ min-width: 0;
+}
+
+.settingsRuntimeHostManagementFacts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--space-3) var(--space-5);
+ margin: 0;
+}
+
+.settingsRuntimeHostManagementFacts div {
+ min-width: 0;
+}
+
+.settingsRuntimeHostManagementFacts .settingsRuntimeHostManagementFactWide {
+ grid-column: 1 / -1;
+}
+
+.settingsRuntimeHostManagementFacts dt {
+ color: var(--foreground-secondary);
+ font: var(--maka-text-supporting);
+}
+
+.settingsRuntimeHostManagementFacts dd {
+ overflow-wrap: anywhere;
+ margin: var(--space-1) 0 0;
+ color: var(--foreground-primary);
+ font: var(--maka-text-body);
+}
+
+.settingsRuntimeHostManagementRoots {
+ display: grid;
+ gap: var(--space-2);
+ margin: var(--space-2) 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.settingsRuntimeHostManagementDirectoryRoots {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.settingsRuntimeHostManagementRoots li {
+ display: grid;
+ gap: var(--space-1);
+}
+
+.settingsRuntimeHostManagementRoots code,
+.settingsRuntimeHostManagementLogs {
+ overflow-wrap: anywhere;
+ color: var(--foreground-secondary);
+ font: var(--maka-text-code);
+}
+
+.settingsRuntimeHostManagementLogs {
+ max-height: 240px;
+ overflow: auto;
+ margin: 0;
+ padding: var(--space-3);
+ border: var(--border-width-hairline) solid var(--border-soft);
+ border-radius: var(--radius-container);
+ background: var(--background-secondary);
+ white-space: pre-wrap;
+}
+
+.settingsRuntimeHostManagementActions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: var(--space-2);
+}
+
@media (max-height: 620px) {
.settingsRuntimeHostSshTerminal {
height: 220px;
diff --git a/docs/runtime-host-remote-access.md b/docs/runtime-host-remote-access.md
index ad0e958f4..d514b3d15 100644
--- a/docs/runtime-host-remote-access.md
+++ b/docs/runtime-host-remote-access.md
@@ -130,6 +130,8 @@ Use **Configure manually** for an existing TLS, SSH, or explicitly acknowledged
The credential is stored separately from the Profile. Desktop keeps Local and every enabled remote Host connected independently. Choose one as the default for new Sessions; existing Sessions continue to use their owning Host. A failed remote connection remains visible without interrupting the other Hosts. After connecting, choose a Project registered on that Host; Client-local directory actions remain unavailable.
+For an SSH-managed computer, open its **Manage** action to inspect the installed release, service state, published directory roots, and recent logs, or to start, restart, repair, or uninstall the service. Uninstalling preserves the remote State Root and does not remove the Desktop Profile; removing a Profile does not uninstall the remote service. Manually configured direct connections remain usable but must be managed on the Host machine.
+
## Connect TUI or CLI
Store the target as a shared Profile. Supply the credential through an environment variable only while creating or updating it:
diff --git a/docs/runtime-host-remote-access.zh-CN.md b/docs/runtime-host-remote-access.zh-CN.md
index 6773b395a..df6937d75 100644
--- a/docs/runtime-host-remote-access.zh-CN.md
+++ b/docs/runtime-host-remote-access.zh-CN.md
@@ -124,6 +124,8 @@ Client Profile 还必须单独持久化明文风险确认。Maka 不会把 TLS
Credential 与 Profile 分开存储。Desktop 会让 Local 与每个已启用的 remote Host 独立保持连接,并允许指定一个默认 Host 来创建新 Session;已有 Session 仍使用自己的 Host。Remote connection 失败时仍会显示,但不会中断其他 Host。连接后从该 Host 已注册的 Project 中选择一个;Client 本地目录操作不可用。
+对于通过 SSH 管理的电脑,可从其**管理**操作查看已安装版本、服务状态、可用目录和近期日志,也可以启动、重启、修复或卸载服务。卸载会保留远端 State Root,且不会删除 Desktop Profile;移除 Profile 也不会卸载远端服务。手动配置的 direct connection 仍可正常使用,但需要在 Host 机器上管理服务。
+
## 连接 TUI 或 CLI
把 target 保存为共享 Profile。只在创建或更新 Profile 时通过环境变量提供 credential:
diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
index 31ce62879..16a5f337b 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -13,6 +13,7 @@ import {
import { tmpdir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { describe, it } from 'node:test';
+import { decodeRuntimeHostServiceManagementFrame } from '@maka/runtime-host/client';
import { parseRuntimeHostCommand } from '../runtime-host-cli.js';
import {
removeRuntimeHostManagedDeployment,
@@ -56,6 +57,12 @@ describe('managed Runtime Host service', () => {
websocketPort: 7443,
},
);
+ assert.deepEqual(parseRuntimeHostCommand(['service', 'status', '--framed']), {
+ kind: 'runtime-host-service-manage',
+ action: 'status',
+ json: false,
+ framed: true,
+ });
assert.deepEqual(parseRuntimeHostCommand(['service', 'uninstall', '--json']), {
kind: 'runtime-host-service-manage',
action: 'uninstall',
@@ -350,6 +357,56 @@ describe('managed Runtime Host service', () => {
});
});
+ it('projects a framed service summary without launch configuration', async () => {
+ let output = '';
+ const exitCode = await runManagedRuntimeHostServiceCli(
+ {
+ action: 'status',
+ json: false,
+ framed: true,
+ clientDataRoot: '/config/Maka',
+ defaultRootPath: '/config/Maka/workspaces/default',
+ nodePath: '/usr/bin/node',
+ cliPath: '/opt/maka/cli.js',
+ },
+ {
+ manage: async () => ({
+ schemaVersion: 1,
+ action: 'status',
+ service: {
+ manager: 'systemd_user',
+ installed: true,
+ enabled: true,
+ active: true,
+ state: 'running',
+ pid: 42,
+ lastExitCode: 0,
+ installedVersion: '1.2.3',
+ config: {
+ schemaVersion: 1,
+ rootPath: '/srv/maka',
+ projectDirectoryRoots: [{ label: 'Home', path: '/home/ada' }],
+ websocket: { host: '127.0.0.1', port: 7443, path: '/runtime-host' },
+ launch: { nodePath: '/secret/node', cliPath: '/secret/cli.js' },
+ },
+ },
+ }),
+ createBackend: createUnusedBackend,
+ writeOutput: (value) => {
+ output += value;
+ },
+ },
+ );
+
+ assert.equal(exitCode, 0);
+ const frame = decodeRuntimeHostServiceManagementFrame(output);
+ assert.equal(frame?.kind, 'result');
+ if (frame?.kind !== 'result') assert.fail('Expected a service result frame');
+ assert.equal(frame.service.installedVersion, '1.2.3');
+ assert.equal(frame.service.stateRoot, '/srv/maka');
+ assert.doesNotMatch(JSON.stringify(frame), /secret/u);
+ });
+
it('restores the deployed service when the replacement never becomes ready', async (t) => {
const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-service-rollback-'));
t.after(() => rm(base, { recursive: true, force: true }));
@@ -617,6 +674,7 @@ function createUnusedBackend(): RuntimeHostServiceBackend {
start: unexpected,
stop: unexpected,
restart: unexpected,
+ logs: unexpected,
uninstall: unexpected,
};
}
@@ -645,6 +703,7 @@ function createReadyBackend(): RuntimeHostServiceBackend {
start: async () => undefined,
stop: async () => undefined,
restart: async () => undefined,
+ logs: async () => '',
uninstall: async () => undefined,
};
}
diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts
index 7c129939c..89fbcb5ae 100644
--- a/packages/cli/src/__tests__/runtime-host-setup.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts
@@ -324,6 +324,7 @@ function serviceResult(
state: config ? 'running' : 'not_installed',
pid: config ? 42 : null,
lastExitCode: null,
+ installedVersion: config ? '0.1.0' : null,
config,
},
};
@@ -337,6 +338,7 @@ function unusedBackend(): RuntimeHostServiceBackend {
start: async () => assert.fail('Backend is not expected'),
stop: async () => assert.fail('Backend is not expected'),
restart: async () => assert.fail('Backend is not expected'),
+ logs: async () => assert.fail('Backend is not expected'),
uninstall: async () => assert.fail('Backend is not expected'),
};
}
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index ad2865e67..99bd59b4b 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -103,7 +103,7 @@ function helpText(cliCommand: string): string {
` ${cliCommand} runtime-host serve [options] Run a Runtime Host service`,
` ${cliCommand} runtime-host setup --principal <id> --preset <desktop-client|terminal-client> [options]`,
` ${cliCommand} runtime-host service install [options]`,
- ` ${cliCommand} runtime-host service status|start|stop|restart|uninstall [--json]`,
+ ` ${cliCommand} runtime-host service status|start|stop|restart|logs|uninstall [--json]`,
` ${cliCommand} runtime-host access issue --principal <id> --grant <operation>`,
` ${cliCommand} runtime-host access issue --principal <id> --preset <desktop-client|terminal-client>`,
` ${cliCommand} runtime-host access issue --kind capability-provider --principal <id>`,
@@ -243,6 +243,7 @@ export async function runMakaCli(
return runManagedRuntimeHostServiceCli({
action: command.action,
json: command.json,
+ framed: command.framed ?? false,
clientDataRoot: dataRoots.clientDataRoot,
defaultRootPath: dataRoots.workspaceRoot,
nodePath: process.execPath,
diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts
index c6073fadd..2e040ba9c 100644
--- a/packages/cli/src/runtime-host-cli.ts
+++ b/packages/cli/src/runtime-host-cli.ts
@@ -36,8 +36,9 @@ export type RuntimeHostCliCommand =
}
| {
kind: 'runtime-host-service-manage';
- action: 'install' | 'status' | 'start' | 'stop' | 'restart' | 'uninstall';
+ action: 'install' | 'status' | 'start' | 'stop' | 'restart' | 'logs' | 'uninstall';
json: boolean;
+ framed?: true;
rootPath?: string;
projectDirectoryRoots?: { label: string; path: string }[];
websocketPort?: number;
@@ -151,17 +152,19 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
action !== 'start' &&
action !== 'stop' &&
action !== 'restart' &&
+ action !== 'logs' &&
action !== 'uninstall'
) {
return error(
action
? `Unexpected runtime-host service command: ${action}`
- : 'runtime-host service requires install, status, start, stop, restart, or uninstall',
+ : 'runtime-host service requires install, status, start, stop, restart, logs, or uninstall',
);
}
const options = parseManagedServiceOptions(argv.slice(1), {
allowConfiguration: action === 'install',
+ allowFramed: true,
});
if ('kind' in options) return options;
return {
@@ -173,6 +176,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
interface ManagedServiceOptions {
readonly json: boolean;
+ readonly framed?: true;
readonly rootPath?: string;
readonly projectDirectoryRoots?: { readonly label: string; readonly path: string }[];
readonly websocketPort?: number;
@@ -185,9 +189,11 @@ function parseManagedServiceOptions(
readonly valueOptions?: Readonly<Record<string, (value: string) => RuntimeHostCliError | void>>;
readonly flagOptions?: Readonly<Record<string, () => RuntimeHostCliError | void>>;
readonly allowConfiguration?: boolean;
+ readonly allowFramed?: boolean;
} = {},
): ManagedServiceOptions | RuntimeHostCliError {
let json = false;
+ let framed = false;
let rootPath: string | undefined;
let websocketPort: number | undefined;
let websocketPath: string | undefined;
@@ -195,9 +201,18 @@ function parseManagedServiceOptions(
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--json') {
+ if (json) return error('Duplicate --json');
+ if (framed) return error('--json and --framed cannot be used together');
json = true;
continue;
}
+ if (argument === '--framed') {
+ if (!input.allowFramed) return error(`Unexpected argument: ${argument}`);
+ if (framed) return error('Duplicate --framed');
+ if (json) return error('--json and --framed cannot be used together');
+ framed = true;
+ continue;
+ }
if (Object.hasOwn(input.flagOptions ?? {}, argument ?? '')) {
const optionError = input.flagOptions?.[argument ?? '']?.();
if (optionError) return optionError;
@@ -250,6 +265,7 @@ function parseManagedServiceOptions(
}
return {
json,
+ ...(framed ? { framed: true as const } : {}),
...(rootPath ? { rootPath } : {}),
...(projectDirectoryRoots.length > 0 ? { projectDirectoryRoots } : {}),
...(websocketPort === undefined ? {} : { websocketPort }),
diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts
index e6cd4489d..befa9db7a 100644
--- a/packages/cli/src/runtime-host-service-management-command.ts
+++ b/packages/cli/src/runtime-host-service-management-command.ts
@@ -1,3 +1,12 @@
+import { truncateUtf8 } from '@maka/core/diagnostic-log';
+import { release } from 'node:os';
+import {
+ encodeRuntimeHostServiceManagementFrame,
+ RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES,
+ RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES,
+ type RuntimeHostServiceManagementFrame,
+ type RuntimeHostServiceSummary,
+} from '@maka/runtime-host/client';
import {
manageRuntimeHostService,
resolveRuntimeHostManagedServiceId,
@@ -10,6 +19,7 @@ import { createSystemdUserRuntimeHostService } from './runtime-host-systemd-serv
export interface RuntimeHostServiceManagementCliOptions extends RuntimeHostManagedServiceInput {
readonly json: boolean;
+ readonly framed?: boolean;
}
export interface RuntimeHostServiceManagementCliDeps {
@@ -31,18 +41,38 @@ export async function runManagedRuntimeHostServiceCli(
...overrides,
};
try {
- const { json: _json, ...input } = options;
+ const { json: _json, framed: _framed, ...input } = options;
const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot);
const result = await deps.manage(input, deps.createBackend(serviceId));
- deps.writeOutput(
- options.json ? `${JSON.stringify({ ...result, ok: true })}\n` : formatHumanResult(result),
- );
+ if (options.framed) {
+ deps.writeOutput(encodeRuntimeHostServiceManagementFrame(successFrame(result)));
+ } else {
+ deps.writeOutput(
+ options.json ? `${JSON.stringify({ ...result, ok: true })}\n` : formatHumanResult(result),
+ );
+ }
return 0;
} catch (error) {
const code =
error instanceof RuntimeHostServiceManagerError ? error.code : 'internal_service_error';
const message = error instanceof Error ? error.message : String(error);
- if (options.json) {
+ if (options.framed) {
+ deps.writeOutput(
+ encodeRuntimeHostServiceManagementFrame({
+ schemaVersion: 1,
+ kind: 'error',
+ action: options.action,
+ error: {
+ code:
+ truncateUtf8(code, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES) ||
+ 'internal_service_error',
+ message:
+ truncateUtf8(message, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES) ||
+ 'Runtime Host service operation failed',
+ },
+ }),
+ );
+ } else if (options.json) {
deps.writeOutput(
`${JSON.stringify({ schemaVersion: 1, ok: false, action: options.action, error: { code, message } })}\n`,
);
@@ -69,9 +99,37 @@ function formatHumanResult(result: RuntimeHostManagedServiceResult): string {
if (!service.installed) return 'Runtime Host service is not installed.\n';
return `Runtime Host service is ${service.state} at ${websocketUrl(service)}\n`;
}
+ if (result.action === 'logs') return result.logs || 'No Runtime Host service logs were found.\n';
return `Runtime Host service is ${service.state}.\n`;
}
+function successFrame(result: RuntimeHostManagedServiceResult): RuntimeHostServiceManagementFrame {
+ const config = result.service.config;
+ const service: RuntimeHostServiceSummary = {
+ manager: result.service.manager,
+ platform: process.platform,
+ arch: process.arch,
+ osRelease: release(),
+ installed: result.service.installed,
+ enabled: result.service.enabled,
+ active: result.service.active,
+ state: result.service.state,
+ pid: result.service.pid,
+ lastExitCode: result.service.lastExitCode,
+ installedVersion: result.service.installedVersion,
+ ...(config ? { stateRoot: config.rootPath } : {}),
+ projectDirectoryRoots: [...(config?.projectDirectoryRoots ?? [])],
+ };
+ return {
+ schemaVersion: 1,
+ kind: 'result',
+ action: result.action,
+ service,
+ ...(result.retainedStateRoot ? { retainedStateRoot: result.retainedStateRoot } : {}),
+ ...(result.logs !== undefined ? { logs: result.logs } : {}),
+ };
+}
+
export function createPlatformRuntimeHostServiceBackend(
serviceId: string,
): RuntimeHostServiceBackend {
diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts
index 94201d7fc..2d853470f 100644
--- a/packages/cli/src/runtime-host-service-manager.ts
+++ b/packages/cli/src/runtime-host-service-manager.ts
@@ -3,13 +3,17 @@ import { createServer } from 'node:net';
import { mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
+import { truncateUtf8 } from '@maka/core/diagnostic-log';
import {
isCanonicalRuntimeHostWebSocketPath,
PROJECT_DIRECTORY_MAX_ROOTS,
PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES,
RUNTIME_HOST_PROTOCOL_VERSION,
} from '@maka/runtime-host/protocol';
-import { connectExistingRuntimeHost } from '@maka/runtime-host/client';
+import {
+ connectExistingRuntimeHost,
+ RUNTIME_HOST_SERVICE_LOG_MAX_BYTES,
+} from '@maka/runtime-host/client';
import { withFileUpdateLock } from '@maka/storage/file-update-lock';
import {
isRuntimeHostManagedDeploymentCli,
@@ -65,6 +69,7 @@ export interface RuntimeHostServiceBackend {
start(): Promise<void>;
stop(): Promise<void>;
restart(): Promise<void>;
+ logs(): Promise<string>;
uninstall(): Promise<void>;
}
@@ -74,6 +79,7 @@ export interface RuntimeHostServiceDeployment {
export interface RuntimeHostManagedServiceStatus extends RuntimeHostServiceBackendStatus {
readonly config: RuntimeHostManagedServiceConfig | null;
+ readonly installedVersion: string | null;
}
export type RuntimeHostManagedServiceAction =
@@ -82,6 +88,7 @@ export type RuntimeHostManagedServiceAction =
| 'start'
| 'stop'
| 'restart'
+ | 'logs'
| 'uninstall';
export interface RuntimeHostManagedServiceResult {
@@ -89,6 +96,7 @@ export interface RuntimeHostManagedServiceResult {
readonly action: RuntimeHostManagedServiceAction;
readonly service: RuntimeHostManagedServiceStatus;
readonly retainedStateRoot?: string;
+ readonly logs?: string;
}
export interface RuntimeHostManagedServiceInput {
@@ -224,6 +232,10 @@ async function manageRuntimeHostServiceLocked(
'Runtime Host service is not installed',
);
}
+ if (input.action === 'logs') {
+ const logs = truncateUtf8(await backend.logs(), RUNTIME_HOST_SERVICE_LOG_MAX_BYTES);
+ return result(input.action, await readServiceStatus(configPath, backend), undefined, logs);
+ }
await backend[input.action]();
if (input.action === 'start' || input.action === 'restart') {
try {
@@ -365,7 +377,30 @@ async function readServiceStatus(
readServiceConfig(configPath),
backend.status(),
]);
- return { ...backendStatus, config };
+ return {
+ ...backendStatus,
+ config,
+ installedVersion: config ? await readInstalledVersion(config.launch.cliPath) : null,
+ };
+}
+
+async function readInstalledVersion(cliPath: string): Promise<string | null> {
+ try {
+ const packageRoot = dirname(dirname(await realpath(cliPath)));
+ const manifest: unknown = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'));
+ if (
+ !isRecord(manifest) ||
+ manifest.name !== 'maka-agent' ||
+ typeof manifest.version !== 'string' ||
+ manifest.version.length === 0 ||
+ Buffer.byteLength(manifest.version, 'utf8') > 512
+ ) {
+ return null;
+ }
+ return manifest.version;
+ } catch {
+ return null;
+ }
}
async function readServiceConfig(path: string): Promise<RuntimeHostManagedServiceConfig | null> {
@@ -639,12 +674,14 @@ function result(
action: RuntimeHostManagedServiceAction,
service: RuntimeHostManagedServiceStatus,
retainedStateRoot?: string,
+ logs?: string,
): RuntimeHostManagedServiceResult {
return {
schemaVersion: 1,
action,
service,
...(retainedStateRoot ? { retainedStateRoot } : {}),
+ ...(logs !== undefined ? { logs } : {}),
};
}
diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts
index c92f5b371..567c48e6b 100644
--- a/packages/cli/src/runtime-host-systemd-service.ts
+++ b/packages/cli/src/runtime-host-systemd-service.ts
@@ -34,6 +34,7 @@ export interface SystemdUserServiceOptions {
readonly uid?: number;
readonly runSystemctl?: (args: readonly string[]) => Promise<CommandResult>;
readonly runLoginctl?: (args: readonly string[]) => Promise<CommandResult>;
+ readonly runJournalctl?: (args: readonly string[]) => Promise<CommandResult>;
}
export function createSystemdUserRuntimeHostService(
@@ -49,6 +50,7 @@ export function createSystemdUserRuntimeHostService(
runSystemctl,
};
const runLoginctl = options.runLoginctl ?? defaultRunLoginctl;
+ const runJournalctl = options.runJournalctl ?? defaultRunJournalctl;
const uid = options.uid ?? process.getuid?.();
const readStatus = async (): Promise<RuntimeHostServiceBackendStatus> => {
@@ -90,6 +92,25 @@ export function createSystemdUserRuntimeHostService(
start: () => runLifecycleAction(context, 'start'),
stop: () => runLifecycleAction(context, 'stop'),
restart: () => runLifecycleAction(context, 'restart'),
+ logs: async () => {
+ const result = await runJournalctl([
+ '--user-unit',
+ context.unitName,
+ '--no-pager',
+ '--lines=200',
+ '--output=short-iso',
+ ]).catch((error) => {
+ throw new RuntimeHostServiceManagerError(
+ 'service_manager_unavailable',
+ 'Unable to read Runtime Host service logs',
+ { cause: error },
+ );
+ });
+ if (result.exitCode !== 0) {
+ throw managerError('Reading Runtime Host service logs failed', result);
+ }
+ return result.stdout;
+ },
uninstall: async () => {
const before = await readSystemdStatus(context);
if (before.loadState !== 'not-found') {
@@ -434,6 +455,10 @@ async function defaultRunLoginctl(args: readonly string[]): Promise<CommandResul
return runCommand('loginctl', args);
}
+async function defaultRunJournalctl(args: readonly string[]): Promise<CommandResult> {
+ return runCommand('journalctl', args);
+}
+
async function runCommand(command: string, args: readonly string[]): Promise<CommandResult> {
return new Promise((resolveResult, reject) => {
const child = spawn(command, args, {
diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts
index 3fabf2fcb..079df4db9 100644
--- a/packages/runtime-host/src/client/index.ts
+++ b/packages/runtime-host/src/client/index.ts
@@ -92,6 +92,17 @@ export {
type RuntimeHostSetupFrame,
type RuntimeHostSetupPhase,
} from './setup-frame.js';
+export {
+ RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES,
+ RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES,
+ RUNTIME_HOST_SERVICE_LOG_MAX_BYTES,
+ RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
+ decodeRuntimeHostServiceManagementFrame,
+ encodeRuntimeHostServiceManagementFrame,
+ type RuntimeHostServiceManagementAction,
+ type RuntimeHostServiceManagementFrame,
+ type RuntimeHostServiceSummary,
+} from './service-management-frame.js';
export {
createOAuthPresentationClientProvider,
type OAuthPresentationBackend,
diff --git a/packages/runtime-host/src/client/service-management-frame.ts b/packages/runtime-host/src/client/service-management-frame.ts
new file mode 100644
index 000000000..9b327b269
--- /dev/null
+++ b/packages/runtime-host/src/client/service-management-frame.ts
@@ -0,0 +1,116 @@
+import { z } from 'zod';
+
+export const RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX =
+ 'MAKA_RUNTIME_HOST_SERVICE_MANAGEMENT_V1 ';
+export const RUNTIME_HOST_SERVICE_LOG_MAX_BYTES = 48 * 1024;
+export const RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES = 128;
+export const RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES = 2 * 1024;
+
+const FRAME_MAX_BYTES = 96 * 1024;
+const PATH_MAX_BYTES = 4 * 1024;
+const FIELD_MAX_BYTES = 512;
+const SERVICE_ACTIONS = [
+ 'install',
+ 'status',
+ 'start',
+ 'stop',
+ 'restart',
+ 'logs',
+ 'uninstall',
+] as const;
+const SERVICE_STATES = ['not_installed', 'stopped', 'starting', 'running', 'failed'] as const;
+
+const boundedString = (maxBytes: number) =>
+ z.string().refine((value) => Buffer.byteLength(value, 'utf8') <= maxBytes);
+const boundedNonEmptyString = (maxBytes: number) =>
+ z
+ .string()
+ .min(1)
+ .refine((value) => Buffer.byteLength(value, 'utf8') <= maxBytes);
+
+const SERVICE_SUMMARY_SCHEMA = z
+ .object({
+ manager: z.enum(['systemd_user', 'launch_agent']),
+ platform: boundedNonEmptyString(FIELD_MAX_BYTES),
+ arch: boundedNonEmptyString(FIELD_MAX_BYTES),
+ osRelease: boundedNonEmptyString(FIELD_MAX_BYTES),
+ installed: z.boolean(),
+ enabled: z.boolean(),
+ active: z.boolean(),
+ state: z.enum(SERVICE_STATES),
+ pid: z.number().int().positive().nullable(),
+ lastExitCode: z.number().int().nonnegative().nullable(),
+ installedVersion: boundedString(FIELD_MAX_BYTES).nullable(),
+ stateRoot: boundedString(PATH_MAX_BYTES).optional(),
+ projectDirectoryRoots: z
+ .array(
+ z
+ .object({
+ label: boundedString(FIELD_MAX_BYTES),
+ path: boundedString(PATH_MAX_BYTES),
+ })
+ .strict(),
+ )
+ .max(64),
+ })
+ .strict();
+
+const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.discriminatedUnion('kind', [
+ z
+ .object({
+ schemaVersion: z.literal(1),
+ kind: z.literal('result'),
+ action: z.enum(SERVICE_ACTIONS),
+ service: SERVICE_SUMMARY_SCHEMA,
+ retainedStateRoot: boundedString(PATH_MAX_BYTES).optional(),
+ logs: boundedString(RUNTIME_HOST_SERVICE_LOG_MAX_BYTES).optional(),
+ })
+ .strict(),
+ z
+ .object({
+ schemaVersion: z.literal(1),
+ kind: z.literal('error'),
+ action: z.enum(SERVICE_ACTIONS),
+ error: z
+ .object({
+ code: boundedNonEmptyString(RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES),
+ message: boundedNonEmptyString(RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES),
+ })
+ .strict(),
+ })
+ .strict(),
+]);
+
+export type RuntimeHostServiceManagementAction = (typeof SERVICE_ACTIONS)[number];
+export type RuntimeHostServiceManagementFrame = z.infer<typeof SERVICE_MANAGEMENT_FRAME_SCHEMA>;
+export type RuntimeHostServiceSummary = z.infer<typeof SERVICE_SUMMARY_SCHEMA>;
+
+export function encodeRuntimeHostServiceManagementFrame(
+ frame: RuntimeHostServiceManagementFrame,
+): string {
+ const encoded = Buffer.from(
+ JSON.stringify(SERVICE_MANAGEMENT_FRAME_SCHEMA.parse(frame)),
+ ).toString('base64url');
+ if (Buffer.byteLength(encoded, 'utf8') > FRAME_MAX_BYTES) {
+ throw new RangeError('Runtime Host service management frame exceeds the encoded size limit');
+ }
+ return `${RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX}${encoded}\n`;
+}
+
+export function decodeRuntimeHostServiceManagementFrame(
+ line: string,
+): RuntimeHostServiceManagementFrame | undefined {
+ const marker = line.indexOf(RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX);
+ if (marker === -1) return undefined;
+ try {
+ const encoded = line.slice(marker + RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX.length).trim();
+ if (encoded.length === 0 || Buffer.byteLength(encoded, 'utf8') > FRAME_MAX_BYTES) {
+ return undefined;
+ }
+ const value: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
+ const decoded = SERVICE_MANAGEMENT_FRAME_SCHEMA.safeParse(value);
+ return decoded.success ? decoded.data : undefined;
+ } catch {
+ return undefined;
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org