From ffb02ca52de7986d835964d49986c4166b58afc7 Mon Sep 17 00:00:00 2001 From: yohnark <213253858+oy-zenprax@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:06:54 +0900 Subject: [PATCH] fix(runtime): separate Runtime CLI authority from init --- README.md | 6 +- docs/local-runtime.md | 14 ++--- scripts/smoke-test.mjs | 42 +++++++++++++- src/cli.test.ts | 83 +++++++++++++++++++++++++- src/cli.ts | 63 ++++++++++++++------ src/init.test.ts | 99 -------------------------------- src/init.ts | 30 +--------- src/local-runtime/index.ts | 1 + src/local-runtime/status.test.ts | 97 +++++++++++++++++++++++++++++++ src/local-runtime/status.ts | 83 ++++++++++++++++++++++++++ src/local-runtime/types.ts | 19 ++++++ 11 files changed, 380 insertions(+), 157 deletions(-) create mode 100644 src/local-runtime/status.test.ts create mode 100644 src/local-runtime/status.ts diff --git a/README.md b/README.md index fe8f7a2a..ad67920d 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,10 @@ clients are welcome via PR. fails closed; Mottainai does not silently fall back to WSL2, TCG, or host-native execution. -The local Runtime is opt-in: plain `mottainai init` only sets up MCP client -registration. Pass `--runtime` to additionally ensure the local Runtime VM +The `mottainai runtime` namespace is the only local Runtime lifecycle +authority: `mottainai runtime ensure` reconciles the local Runtime VM and +`mottainai runtime status` reads its persisted state. `mottainai init` only +sets up MCP client registration and never provisions Runtime (see [docs/local-runtime.md](docs/local-runtime.md)). ## Installation diff --git a/docs/local-runtime.md b/docs/local-runtime.md index 2c13ca30..a6d93b07 100644 --- a/docs/local-runtime.md +++ b/docs/local-runtime.md @@ -1,15 +1,15 @@ # Canonical local Runtime -`mottainai init --runtime` owns one local Runtime profile, -`mottainai-local-runtime-v1`. Ensuring the Runtime is opt-in and separate from -MCP client registration: plain `mottainai init` only sets up the MCP -configuration/clients, so hosts without a hardware accelerator (CI, containers, -sandboxes) can still complete client setup. `--runtime` is required to -additionally ensure the local Runtime. +The `mottainai runtime` namespace is the only local Runtime lifecycle authority +for the `mottainai-local-runtime-v1` profile. Use `mottainai runtime ensure` to +reconcile it and `mottainai runtime status` to read its persisted state. +`mottainai init` only sets up MCP configuration/clients and never provisions the +Runtime, so hosts without a hardware accelerator (CI, containers, sandboxes) +can still complete client setup. The profile is intentionally not a user-selectable provider: QEMU is always the machine substrate, with `KVM` on Linux, `HVF` on macOS, and `WHPX` on Windows. -If the required accelerator is unavailable, `--runtime` fails with an +If the required accelerator is unavailable, `runtime ensure` fails with an actionable diagnostic rather than silently skipping Runtime provisioning. It never selects TCG, WSL/WSL2, a host-native process, or an arbitrary system QEMU installation. diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 1376d1c9..09ef3a55 100644 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -132,9 +132,8 @@ function main() { const configPath = path.join(installDirectory, "mottainai.config.json"); // The packed consumer smoke is intentionally hermetic and must not claim - // host virtualization hardware it does not own. The production init path - // ensures the local Runtime; dry-run validates the released CLI/config - // surface without provisioning a VM in the package harness. + // host virtualization hardware it does not own. `init` only validates the + // released CLI/config surface; Runtime lifecycle belongs to `runtime`. console.log("running init --yes --dry-run --scope project --client none --no-doctor --json..."); const initResult = spawnSync( process.execPath, @@ -167,6 +166,43 @@ function main() { fail(`dry-run init unexpectedly wrote configuration: ${JSON.stringify(initSummary)}`); if (fs.existsSync(configPath)) fail(`dry-run init wrote configuration file at ${configPath}`); + const runtimeStateDirectory = path.join(installDirectory, "runtime-state"); + console.log("running packed runtime ensure --help..."); + const runtimeEnsureHelpResult = spawnSync(process.execPath, [primaryBin, "runtime", "ensure", "--help"], { + cwd: installDirectory, + encoding: "utf8", + timeout: 10_000, + }); + if (runtimeEnsureHelpResult.status !== 0 || !runtimeEnsureHelpResult.stdout.includes("runtime ensure")) + fail( + `runtime ensure help was not callable: ${runtimeEnsureHelpResult.status}\n${runtimeEnsureHelpResult.stdout}\n${runtimeEnsureHelpResult.stderr}`, + ); + + console.log("running packed runtime status --json..."); + const runtimeStatusResult = spawnSync( + process.execPath, + [primaryBin, "runtime", "status", "--json", "--state-directory", runtimeStateDirectory], + { + cwd: installDirectory, + encoding: "utf8", + env: { ...process.env, HOME: installDirectory, USERPROFILE: installDirectory }, + timeout: 10_000, + }, + ); + if (runtimeStatusResult.status !== 0) + fail( + `runtime status exited with status ${runtimeStatusResult.status}:\n${runtimeStatusResult.stdout}\n${runtimeStatusResult.stderr}`, + ); + let runtimeStatus; + try { + runtimeStatus = JSON.parse(runtimeStatusResult.stdout); + } catch { + fail(`runtime status --json did not print valid JSON:\n${runtimeStatusResult.stdout}`); + } + if (runtimeStatus.ok !== true || runtimeStatus.lifecycle !== "absent") + fail(`runtime status did not report an absent Runtime: ${JSON.stringify(runtimeStatus)}`); + if (fs.existsSync(runtimeStateDirectory)) fail("runtime status created the state directory"); + console.log("running packed Mottainai gh-inari companion smoke..."); run(process.execPath, ["scripts/gh-inari-package-smoke.mjs", installedPackageDirectory], { cwd: repoRoot, diff --git a/src/cli.test.ts b/src/cli.test.ts index ce5a1d6d..f33945ae 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -31,6 +31,70 @@ test("early public CLI failure includes bounded runtime identity without stdout } }); +test("public CLI exposes read-only runtime status without creating state", () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-cli-runtime-status-")); + const stateDirectory = path.join(workspace, "runtime-state"); + try { + const result = spawnSync( + process.execPath, + ["--import", "tsx", entryPoint, "runtime", "status", "--json", "--state-directory", stateDirectory], + { + cwd: path.resolve(path.dirname(entryPoint), ".."), + env: { ...process.env, HOME: workspace, USERPROFILE: workspace }, + encoding: "utf8", + }, + ); + assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + machineId: "mottainai-local-runtime-v1", + lifecycle: "absent", + stateDirectory: path.join(path.resolve(stateDirectory), "mottainai-local-runtime-v1"), + stateFile: path.join(path.resolve(stateDirectory), "mottainai-local-runtime-v1", "state.json"), + }); + assert.equal(fs.existsSync(stateDirectory), false); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("top-level init rejects the removed Runtime provisioning option before writing anything", () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-cli-init-runtime-")); + const configPath = path.join(workspace, "mottainai.config.json"); + try { + const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + entryPoint, + "init", + "--yes", + "--workspace", + workspace, + "--config", + configPath, + "--scope", + "project", + "--client", + "none", + "--no-doctor", + "--runtime", + ], + { + cwd: path.resolve(path.dirname(entryPoint), ".."), + env: { ...process.env, HOME: workspace, USERPROFILE: workspace }, + encoding: "utf8", + }, + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /use `mottainai runtime ensure`/); + assert.equal(fs.existsSync(configPath), false); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + test("hooks repair restores an invalid policy through the public CLI", () => { const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-cli-hooks-repair-")); const bin = fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-cli-hooks-bin-")); @@ -46,7 +110,12 @@ test("hooks repair restores an invalid policy through the public CLI", () => { ["--import", "tsx", entryPoint, "hooks", "repair", "--client", "claude", "--workspace", workspace], { cwd: path.resolve(path.dirname(entryPoint), ".."), - env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, HOME: workspace, USERPROFILE: workspace }, + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + HOME: workspace, + USERPROFILE: workspace, + }, encoding: "utf8", }, ); @@ -78,7 +147,11 @@ test("public CLI dispatch projects the workflow authority through a supported cl fs.mkdirSync(path.join(workspace, ".mottainai")); fs.writeFileSync( path.join(workspace, ".mottainai", "workflow.json"), - JSON.stringify({ ...BUILTIN_PRESETS.standard, protectedBranches: ["release/*"], protectedBranchRule: { ...BUILTIN_PRESETS.standard.protectedBranchRule, sourceWrite: "enforce" } }), + JSON.stringify({ + ...BUILTIN_PRESETS.standard, + protectedBranches: ["release/*"], + protectedBranchRule: { ...BUILTIN_PRESETS.standard.protectedBranchRule, sourceWrite: "enforce" }, + }), ); const result = spawnSync( process.execPath, @@ -86,7 +159,11 @@ test("public CLI dispatch projects the workflow authority through a supported cl { cwd: path.resolve(path.dirname(entryPoint), ".."), env: { ...process.env, HOME: workspace, USERPROFILE: workspace }, - input: JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Write", tool_input: { file_path: "tracked.txt" } }), + input: JSON.stringify({ + hook_event_name: "PreToolUse", + tool_name: "Write", + tool_input: { file_path: "tracked.txt" }, + }), encoding: "utf8", }, ); diff --git a/src/cli.ts b/src/cli.ts index 55f86252..4b3b13a2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,7 +10,12 @@ import { localTools } from "./local-tools.js"; import { dispatchClientHook, runManagedHooksCommand } from "./hooks/commands.js"; import type { HookCommandContext } from "./hooks/commands.js"; import { formatInitHuman, runInit } from "./init.js"; -import { createLocalRuntimeProvisioner } from "./local-runtime/index.js"; +import { + createLocalRuntimeProvisioner, + formatLocalRuntimeEnsureHuman, + formatLocalRuntimeStatusHuman, + readLocalRuntimeStatus, +} from "./local-runtime/index.js"; import { createRuntimeDiagnostic, formatRuntimeDiagnosticHuman } from "./runtime-diagnostic.js"; import { runServer } from "./server.js"; import { @@ -56,6 +61,8 @@ import type { CleanupPlan } from "./workflow/domain/cleanup-plan.js"; const USAGE = `usage: mottainai start the MCP stdio server mottainai init [options] initialize a workspace configuration + mottainai runtime ensure [options] reconcile the local Runtime + mottainai runtime status [options] show persisted local Runtime state mottainai serve start the MCP stdio server explicitly mottainai dashboard [options] start the local semantic project viewer (fixture|live) mottainai manager [options] start the local Zellij-backed agent Manager @@ -123,6 +130,10 @@ init options: --no-doctor skip post-initialization diagnostics --latest register the unpinned npm package +runtime options: + --state-directory path local Runtime state root + --json emit one JSON document + policy/task options: --workspace path Git repository root; defaults to the current Git repository's top level --type type explicit branch type for "task start" (required) @@ -136,6 +147,11 @@ hooks options: --mode observe|warn|enforce set the managed rollout mode for install/repair `; +const RUNTIME_USAGE = `usage: + mottainai runtime ensure [--state-directory path] [--json] + mottainai runtime status [--state-directory path] [--json] +`; + function flag(argv: string[], name: string): string | undefined { const index = argv.indexOf(`--${name}`); return index === -1 ? undefined : argv[index + 1]; @@ -479,25 +495,34 @@ export async function runCli(args: string[]): Promise { if (command === "init") { const summary = await runInit({ args: argv, - // The local Runtime is Mottainai-managed hard-isolation infrastructure, - // not part of MCP client registration; ensuring it is opt-in via - // --runtime so `init` still succeeds for MCP-only setup on hosts - // without a hardware accelerator (docs/local-runtime.md). - ...(hasFlag(argv, "runtime") - ? { - localRuntime: createLocalRuntimeProvisioner(), - localRuntimeOptions: { - environment: process.env, - homeDirectory: process.env.HOME ?? process.env.USERPROFILE, - platform: process.platform, - architecture: process.arch, - }, - } - : {}), }); if (hasFlag(argv, "json")) print(summary); else console.log(formatInitHuman(summary)); return summary.ok ? 0 : 1; + } else if (command === "runtime") { + const action = argv[0]; + if (action !== "ensure" && action !== "status") fail(USAGE); + if (hasFlag(argv, "help")) { + console.log(RUNTIME_USAGE); + return 0; + } + const runtimeOptions = { + environment: process.env, + homeDirectory: process.env.HOME ?? process.env.USERPROFILE, + platform: process.platform, + architecture: process.arch, + stateDirectory: requireFlagValue(argv, "state-directory"), + }; + if (action === "status") { + const status = readLocalRuntimeStatus(runtimeOptions); + if (hasFlag(argv, "json")) print(status); + else console.log(formatLocalRuntimeStatusHuman(status)); + return 0; + } + const result = await createLocalRuntimeProvisioner().ensure(runtimeOptions); + if (hasFlag(argv, "json")) print(result); + else console.log(formatLocalRuntimeEnsureHuman(result)); + return result.ok ? 0 : 1; } else if (command === "semantic") { return runSemanticCommand(argv[0], argv.slice(1)); } else if (command === "dashboard") { @@ -886,6 +911,12 @@ export async function runCli(args: string[]): Promise { }); } else if (args[0] === "init" && hasFlag(args, "json")) { print({ ok: false, error: message }); + } else if (args[0] === "runtime" && hasFlag(args, "json")) { + const code = + typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" + ? error.code + : undefined; + print({ ok: false, ...(code === undefined ? {} : { code }), error: message }); } else { console.error( args[0] === "doctor" diff --git a/src/init.test.ts b/src/init.test.ts index ab712405..1e73ed1c 100644 --- a/src/init.test.ts +++ b/src/init.test.ts @@ -9,7 +9,6 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { formatInitHuman, runInit } from "./init.js"; -import type { LocalRuntimeEnsureOptions, LocalRuntimeEnsureResult } from "./local-runtime/types.js"; function temporaryWorkspace(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-init-test-")); @@ -387,104 +386,6 @@ test("init human output points doctor at the generated configuration", async () } }); -function fakeRuntimeResult(overrides: Partial = {}): LocalRuntimeEnsureResult { - return { - ok: true, - machineId: "mottainai-local-runtime-v1", - lifecycle: "ready", - host: "linux-x64", - accelerator: "kvm", - qemu: { - artifactId: "test-qemu", - version: "9.2.2", - buildId: "qemu-9.2.2-mottainai-runtime-v1", - sha256: "a".repeat(64), - executablePath: "/tmp/qemu-system-x86_64", - }, - image: { - imageId: "test-image", - architecture: "x86_64-linux", - buildIdentity: "/nix/store/test-runtime", - diskSha256: "b".repeat(64), - }, - ssh: { host: "127.0.0.1", port: 48321, user: "mottainai-control", hostKey: "ssh-ed25519 AAAA test" }, - qmp: { endpoint: "/tmp/qmp.sock", private: true }, - reused: false, - warnings: [], - ...overrides, - }; -} - -test("init ensures the local Runtime and reports its lifecycle in the summary", async () => { - const workspace = temporaryWorkspace(); - let ensureCalls = 0; - try { - const summary = await runInit({ - args: ["--yes", "--workspace", workspace, "--client", "none", "--no-doctor", "--scope", "project"], - cwd: workspace, - stdinIsTTY: false, - stdoutIsTTY: false, - localRuntime: { - ensure: async (_options?: LocalRuntimeEnsureOptions) => { - ensureCalls += 1; - return fakeRuntimeResult(); - }, - }, - }); - assert.equal(ensureCalls, 1); - assert.equal(summary.runtime?.lifecycle, "ready"); - assert.ok(formatInitHuman(summary).includes("mottainai-local-runtime-v1")); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("init dry-run does not ensure the local Runtime", async () => { - const workspace = temporaryWorkspace(); - let ensureCalls = 0; - try { - const summary = await runInit({ - args: ["--yes", "--workspace", workspace, "--client", "none", "--no-doctor", "--scope", "project", "--dry-run"], - cwd: workspace, - stdinIsTTY: false, - stdoutIsTTY: false, - localRuntime: { - ensure: async () => { - ensureCalls += 1; - return fakeRuntimeResult(); - }, - }, - }); - assert.equal(ensureCalls, 0); - assert.equal(summary.runtime, undefined); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } -}); - -test("init fails when the local Runtime cannot be ensured, without a silent fallback", async () => { - const workspace = temporaryWorkspace(); - try { - await assert.rejects( - runInit({ - args: ["--yes", "--workspace", workspace, "--client", "none", "--no-doctor", "--scope", "project"], - cwd: workspace, - stdinIsTTY: false, - stdoutIsTTY: false, - localRuntime: { - ensure: async () => { - throw new Error("hardware_acceleration_unavailable"); - }, - }, - }), - /hardware_acceleration_unavailable/, - ); - assert.equal(fs.existsSync(path.join(workspace, "mottainai.config.json")), false); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } -}); - test("registration command quotes a configuration path that contains whitespace", async () => { const workspace = temporaryWorkspace(); const spacedWorkspace = path.join(workspace, "path with space"); diff --git a/src/init.ts b/src/init.ts index e8b39481..45ae00d8 100644 --- a/src/init.ts +++ b/src/init.ts @@ -12,11 +12,6 @@ import type { BoundaryOperations } from "./boundary.js"; import { collectDoctorReport, formatDoctorHuman } from "./commands/doctor.js"; import { resolveConfigPath, saveRawConfig } from "./config.js"; import type { DoctorReport } from "./commands/doctor.js"; -import type { LocalRuntimeEnsureOptions, LocalRuntimeEnsureResult } from "./local-runtime/types.js"; - -export interface LocalRuntimeEnsurer { - ensure(options?: LocalRuntimeEnsureOptions): Promise; -} export type InitScope = "personal" | "project"; export type InitClient = "claude" | "codex" | "none"; @@ -29,13 +24,6 @@ export interface InitRunOptions { stdoutIsTTY?: boolean; /** Internal fault-test seam; runtime configuration never supplies this. */ boundaries?: BoundaryOperations; - /** - * CLI supplies the canonical local Runtime provisioner only when the user - * opts in with `--runtime`, so MCP-only setup keeps working on hosts - * without a hardware accelerator; tests can inject a hermetic fake. - */ - localRuntime?: LocalRuntimeEnsurer; - localRuntimeOptions?: LocalRuntimeEnsureOptions; } export interface InitClientResult { @@ -70,7 +58,6 @@ export interface InitSummary { doctor?: DoctorReport; handshake?: InitHandshakeResult; warnings: string[]; - runtime?: LocalRuntimeEnsureResult; } interface InitArguments { @@ -134,6 +121,9 @@ function hasOption(args: string[], name: string): boolean { } function parseArguments(args: string[]): InitArguments { + if (hasOption(args, "runtime")) { + throw new Error("--runtime is not an init option; use `mottainai runtime ensure`"); + } const scopeValue = optionValue(args, "scope"); if (scopeValue !== undefined && scopeValue !== "personal" && scopeValue !== "project") { throw new Error("invalid --scope; expected personal or project"); @@ -725,10 +715,6 @@ export async function runInit(options: InitRunOptions): Promise { const client = parsed.client ?? "none"; const importSource = parsed.importSource ?? "none"; const warnings: string[] = []; - let runtime: LocalRuntimeEnsureResult | undefined; - if (!parsed.dryRun && options.localRuntime !== undefined) { - runtime = await options.localRuntime.ensure(options.localRuntimeOptions); - } const config = baseConfig(configuration, workspace); const registry = config.mcpServers as Record>; const imported = importClientServers(importSource); @@ -810,7 +796,6 @@ export async function runInit(options: InitRunOptions): Promise { clients: clientResults, ...(doctor === undefined ? {} : { doctor }), ...(handshake === undefined ? {} : { handshake }), - ...(runtime === undefined ? {} : { runtime }), warnings, }; } @@ -851,15 +836,6 @@ export function formatInitHuman(summary: InitSummary): string { `MCP handshake: ${handshake.skipped === true ? "skipped" : handshake.ok ? `ok (${handshake.tools ?? 0} tools)` : "failed"}`, ); } - if (summary.runtime !== undefined) { - lines.push( - "", - "Local Runtime", - ` ${summary.runtime.lifecycle}: ${summary.runtime.machineId}`, - ` ${summary.runtime.host}/${summary.runtime.accelerator}`, - ` ${summary.runtime.reused ? "reused" : "created or restarted"}`, - ); - } if (summary.warnings.length > 0) lines.push("", "Warnings", ...summary.warnings.map((warning) => ` ⚠ ${warning}`)); if (summary.clients.some((client) => client.registrationCommand !== "")) { lines.push("", "Registration commands", ...summary.clients.map((client) => ` ${client.registrationCommand}`)); diff --git a/src/local-runtime/index.ts b/src/local-runtime/index.ts index f9aca0ed..32f42ab4 100644 --- a/src/local-runtime/index.ts +++ b/src/local-runtime/index.ts @@ -5,4 +5,5 @@ export * from "./image.js"; export * from "./qmp.js"; export * from "./ssh.js"; export * from "./state.js"; +export * from "./status.js"; export * from "./reconciler.js"; diff --git a/src/local-runtime/status.test.ts b/src/local-runtime/status.test.ts new file mode 100644 index 00000000..95d9f9aa --- /dev/null +++ b/src/local-runtime/status.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + LOCAL_RUNTIME_MACHINE_ID, + LOCAL_RUNTIME_PROFILE, + LOCAL_RUNTIME_STATE_SCHEMA_VERSION, + type LocalRuntimeState, +} from "./types.js"; +import { resolveLocalRuntimePaths, saveLocalRuntimeState } from "./state.js"; +import { readLocalRuntimeStatus } from "./status.js"; + +function temporaryDirectory(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-runtime-status-")); +} + +function persistedState(stateDirectory: string): LocalRuntimeState { + const paths = resolveLocalRuntimePaths(stateDirectory, "linux-x64", "linux"); + return { + schemaVersion: LOCAL_RUNTIME_STATE_SCHEMA_VERSION, + machineId: LOCAL_RUNTIME_MACHINE_ID, + host: "linux-x64", + accelerator: "kvm", + lifecycle: "ready", + qemu: { + artifactId: "test-qemu", + version: "9.2.2", + buildId: "qemu-9.2.2-mottainai-runtime-v1", + sha256: "a".repeat(64), + executablePath: paths.qemuExecutable, + }, + image: { + imageId: "test-image", + architecture: "x86_64-linux", + buildIdentity: "/nix/store/test-runtime", + diskSha256: "b".repeat(64), + }, + paths: { + stateDirectory: paths.stateDirectory, + diskImage: paths.diskImage, + qmpSocket: paths.qmpSocket, + sshPrivateKey: paths.sshPrivateKey, + sshKnownHosts: paths.sshKnownHosts, + }, + ssh: { + host: LOCAL_RUNTIME_PROFILE.sshHost, + port: LOCAL_RUNTIME_PROFILE.sshPort, + user: LOCAL_RUNTIME_PROFILE.sshUser, + hostKey: "ssh-ed25519 AAAA test", + }, + qmp: { endpoint: paths.qmpSocket, private: true }, + createdAt: "2026-08-15T00:00:00.000Z", + updatedAt: "2026-08-15T00:01:00.000Z", + }; +} + +test("runtime status reports absent without creating the state directory", () => { + const root = temporaryDirectory(); + const stateRoot = path.join(root, "state"); + try { + const status = readLocalRuntimeStatus({ stateDirectory: stateRoot }); + assert.equal(status.ok, true); + assert.equal(status.machineId, LOCAL_RUNTIME_MACHINE_ID); + assert.equal(status.lifecycle, "absent"); + assert.equal(status.stateFile, path.join(path.resolve(stateRoot, LOCAL_RUNTIME_MACHINE_ID), "state.json")); + assert.equal(fs.existsSync(stateRoot), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("runtime status projects persisted state without exposing lifecycle secrets or mutating it", () => { + const root = temporaryDirectory(); + const stateRoot = path.join(root, "state"); + const paths = resolveLocalRuntimePaths(stateRoot, "linux-x64", "linux"); + try { + saveLocalRuntimeState(paths.stateFile, persistedState(stateRoot)); + const before = fs.readFileSync(paths.stateFile, "utf8"); + const status = readLocalRuntimeStatus({ stateDirectory: stateRoot }); + const after = fs.readFileSync(paths.stateFile, "utf8"); + + assert.equal(status.lifecycle, "ready"); + assert.equal(status.host, "linux-x64"); + assert.equal(status.accelerator, "kvm"); + assert.equal(status.qemu?.buildId, "qemu-9.2.2-mottainai-runtime-v1"); + assert.equal(status.image?.imageId, "test-image"); + assert.deepEqual(status.ssh, { host: "127.0.0.1", port: 48321, user: "mottainai-control" }); + assert.deepEqual(status.qmp, { private: true }); + assert.equal("hostKey" in (status.ssh ?? {}), false); + assert.equal("endpoint" in (status.qmp ?? {}), false); + assert.equal(after, before); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/local-runtime/status.ts b/src/local-runtime/status.ts new file mode 100644 index 00000000..61ec025c --- /dev/null +++ b/src/local-runtime/status.ts @@ -0,0 +1,83 @@ +import path from "node:path"; +import { defaultRuntimeStateDirectory, loadLocalRuntimeState } from "./state.js"; +import { LOCAL_RUNTIME_MACHINE_ID, type LocalRuntimeEnsureResult, type LocalRuntimeStatus } from "./types.js"; +import type { LocalRuntimeEnsureOptions } from "./types.js"; + +export type LocalRuntimeStatusOptions = Pick< + LocalRuntimeEnsureOptions, + "environment" | "homeDirectory" | "platform" | "stateDirectory" +>; + +function statePaths(options: LocalRuntimeStatusOptions): { stateDirectory: string; stateFile: string } { + const root = + options.stateDirectory ?? + defaultRuntimeStateDirectory(options.platform, options.environment, options.homeDirectory); + const stateDirectory = path.resolve(root, LOCAL_RUNTIME_MACHINE_ID); + return { stateDirectory, stateFile: path.join(stateDirectory, "state.json") }; +} + +/** Read the persisted Runtime projection without probing hardware or creating state. */ +export function readLocalRuntimeStatus(options: LocalRuntimeStatusOptions = {}): LocalRuntimeStatus { + const paths = statePaths(options); + const state = loadLocalRuntimeState(paths.stateFile); + if (state === undefined) { + return { + ok: true, + machineId: LOCAL_RUNTIME_MACHINE_ID, + lifecycle: "absent", + stateDirectory: paths.stateDirectory, + stateFile: paths.stateFile, + }; + } + + return { + ok: true, + machineId: state.machineId, + lifecycle: state.lifecycle, + stateDirectory: paths.stateDirectory, + stateFile: paths.stateFile, + host: state.host, + accelerator: state.accelerator, + qemu: state.qemu, + image: state.image, + ssh: { host: state.ssh.host, port: state.ssh.port, user: state.ssh.user }, + qmp: { private: state.qmp.private }, + ...(state.pid === undefined ? {} : { pid: state.pid }), + ...(state.runtime === undefined ? {} : { runtime: state.runtime }), + createdAt: state.createdAt, + updatedAt: state.updatedAt, + }; +} + +export function formatLocalRuntimeEnsureHuman(result: LocalRuntimeEnsureResult): string { + const lines = [ + "Local Runtime", + ` lifecycle: ${result.lifecycle}`, + ` machine: ${result.machineId}`, + ` host: ${result.host}/${result.accelerator}`, + ` qemu: ${result.qemu.buildId}`, + ` image: ${result.image.imageId}`, + ` ${result.reused ? "reused" : "created or restarted"}`, + ]; + if (result.runtime !== undefined) lines.push(` reconciliation: ${result.runtime.reconciliation}`); + if (result.warnings.length > 0) lines.push("", "Warnings", ...result.warnings.map((warning) => ` ⚠ ${warning}`)); + return lines.join("\n"); +} + +export function formatLocalRuntimeStatusHuman(status: LocalRuntimeStatus): string { + const lines = [ + "Local Runtime", + ` lifecycle: ${status.lifecycle}`, + ` machine: ${status.machineId}`, + ` state: ${status.stateFile}`, + ]; + if (status.host !== undefined && status.accelerator !== undefined) { + lines.push(` host: ${status.host}/${status.accelerator}`); + } + if (status.pid !== undefined) lines.push(` pid: ${status.pid}`); + if (status.qemu !== undefined) lines.push(` qemu: ${status.qemu.buildId}`); + if (status.image !== undefined) lines.push(` image: ${status.image.imageId}`); + if (status.runtime !== undefined) lines.push(` reconciliation: ${status.runtime.reconciliation}`); + if (status.updatedAt !== undefined) lines.push(` updated: ${status.updatedAt}`); + return lines.join("\n"); +} diff --git a/src/local-runtime/types.ts b/src/local-runtime/types.ts index 25fb7583..f96287b0 100644 --- a/src/local-runtime/types.ts +++ b/src/local-runtime/types.ts @@ -164,6 +164,25 @@ export interface LocalRuntimeEnsureResult { readonly warnings: string[]; } +/** Read-only, bounded projection of the persisted local Runtime state. */ +export interface LocalRuntimeStatus { + readonly ok: true; + readonly machineId: typeof LOCAL_RUNTIME_MACHINE_ID; + readonly lifecycle: RuntimeLifecycle; + readonly stateDirectory: string; + readonly stateFile: string; + readonly host?: LocalRuntimeHost; + readonly accelerator?: RuntimeAccelerator; + readonly qemu?: QemuArtifactIdentity; + readonly image?: RuntimeImageIdentity; + readonly ssh?: Pick; + readonly qmp?: Pick; + readonly pid?: number; + readonly runtime?: RuntimeCapabilityResult; + readonly createdAt?: string; + readonly updatedAt?: string; +} + export type LocalRuntimeErrorCode = | "unsupported_host" | "hardware_acceleration_unavailable"