diff --git a/docs/cli.md b/docs/cli.md index 6a0ee13cb..562f75097 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -129,6 +129,8 @@ Historical remote guides are excluded from the supported documentation. - `--json` on `hack up`/`down`/`restart`/`doctor` emits a `{ok, data | error: {code, message}}` envelope with stable `E_*` error codes. `--json` on `hack up` implies `--detach`. +- Known Hack errors and closed native runtime refusals print their message only, never a stack + trace; set `HACK_DEBUG=1` to add the stack. Unexpected exceptions always print their stack. - `--no-interactive` (or `HACK_NO_INTERACTIVE=1`) is a global flag: commands never prompt — they apply documented defaults or fail fast with `E_INTERACTIVE_REQUIRED`. - `NO_COLOR` (or `HACK_NO_COLOR`) disables colored/decorated output. diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index e68bb6171..b7bb3ae49 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -614,7 +614,13 @@ A project with no admitted mapping reports not started. `hack ps` keeps containe observations separate from the last graph receipt. For a ready foreground graph, it also checks the live owner: `owner_unconfirmed` means the owner could not be authenticated, while `runtime_degraded` means its runtime check -failed. Native `exec` and `run` refresh admitted host dependencies once before +failed. A stopped candidate runtime is an ordinary state for `ps`: with a mapping +present it exits 0 and reports `runtime_not_running` (no phase, no service rows), +and text output prints "Native runtime is not running; no services are running." +`hack ps --json` prints the standard `{ok: true, data}` envelope; `data` holds +`backend: "native"`, `status` (`not_started`, `runtime_not_running`, `observed`, +`owner_unconfirmed` or `runtime_degraded`), `run`, `phase` and `items`. Bounded +logs refuse a stopped runtime with `E_DOCKER_UNAVAILABLE` and exit 1. Native `exec` and `run` refresh admitted host dependencies once before command selection and managed environment preparation. They proceed only with fresh ownership and runtime proof, even if containers still appear running. Unconfirmed ownership or an incomplete refresh requires inspection and explicit diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index 75e6ba512..0109bb878 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -95,7 +95,11 @@ Only fresh runtime observations supply state and health. Stored readiness and ph do not prove that a service is still live. A project with no saved attempt reports `not_started`; a retained startup intent -without a Ready mapping reports `pending` with no confirmed service rows. A dead +without a Ready mapping reports `pending` with no confirmed service rows. When a +Ready generation is saved but the candidate runtime is stopped (native code +`runtime_not_running`), `ps` exits 0 and reports `runtime_not_running` with the +saved `data.run`, a null `data.phase` and no items; text output prints +"Native runtime is not running; no services are running." A dead owner, uncertain recovery, changed selection, or malformed or mismatched reply refuses with unconfirmed status. The reader holds and rechecks project, private directories, saved record identities and the selected executable across its single @@ -259,7 +263,9 @@ guest checks, within the existing bounded control reply. Cancellation or incompl child/output settlement withholds the result; it never stops the foreground owner or retries a query. Logs may contain application values and are returned only to the requesting caller; -they are never journaled or included in refusal diagnostics. Offline controls do +they are never journaled or included in refusal diagnostics. A stopped candidate +runtime refuses with `E_DOCKER_UNAVAILABLE` ("Native runtime is not running; no +service logs are available.") and exit 1. Offline controls do not qualify a live provider or application log behavior. ## Finite service exec diff --git a/scripts/accept-native-frontend.py b/scripts/accept-native-frontend.py index 9d87998fd..cddeff0cb 100644 --- a/scripts/accept-native-frontend.py +++ b/scripts/accept-native-frontend.py @@ -295,6 +295,8 @@ def up(self, label, previous_container=None, command="up"): ps = self.cli(f"{label}-ps", "ps", "--path", str(self.project), "--branch", self.branch, "--json", timeout=20) except Failure: ps = {} + # `ps --json` prints the standard {ok, data} envelope; only its data is a native status. + ps = ps.get("data") if isinstance(ps, dict) and ps.get("ok") is True else None ps = ps if isinstance(ps, dict) else {} items = ps.get("items") or [] if (ps.get("status") == "observed" and ps.get("phase") == "ready-observed" diff --git a/src/backends/native-authored-project-logs.ts b/src/backends/native-authored-project-logs.ts index 920ee2edc..84fe961f9 100644 --- a/src/backends/native-authored-project-logs.ts +++ b/src/backends/native-authored-project-logs.ts @@ -15,6 +15,7 @@ import { } from "./native-authored-runtime-binary.ts"; import { invokeNativeRuntime, + isNativeRuntimeNotRunning, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; @@ -133,7 +134,11 @@ export async function nativeAuthoredProjectLogs(input: { ) { return refused(); } - const result = await withStatus(scope, async (saved) => { + // The held saved-read owner turns any error raised inside it into its own + // refusal, so a stopped runtime leaves it as a value and is rethrown after. + const result = await withStatus< + { readonly logs: NativeAuthoredLogs } | { readonly stopped: Error } + >(scope, async (saved) => { active(); const receipt = requireReadyServiceReceipt(saved, scope, service) ?? refused(); @@ -146,32 +151,45 @@ export async function nativeAuthoredProjectLogs(input: { active(); }; await check(); - const value = await invoke({ - runtime, - cwd: scope.projectRoot, - args: [ - "graph", - "native", - "logs", - "--run-id", - receipt.review.provenance.run, - "--service", - service, - "--tail", - String(tail), - "--json", - ], - timeoutMs: 15_000, - signal, - boundNativeAuthoredLogsDrain: true, - }); + let value: unknown; + try { + value = await invoke({ + runtime, + cwd: scope.projectRoot, + args: [ + "graph", + "native", + "logs", + "--run-id", + receipt.review.provenance.run, + "--service", + service, + "--tail", + String(tail), + "--json", + ], + timeoutMs: 15_000, + signal, + boundNativeAuthoredLogsDrain: true, + }); + } catch (error: unknown) { + if (!(isNativeRuntimeNotRunning(error) && error instanceof Error)) { + throw error; + } + // Reported only while the same saved selection and executable hold. + await check(); + return { stopped: error }; + } await check(); const logs = parseNativeAuthoredLogs(value, receipt, service); await check(); - return logs; + return { logs }; } ); }); active(); - return result; + if ("stopped" in result) { + throw result.stopped; + } + return result.logs; } diff --git a/src/backends/native-authored-project-observe.ts b/src/backends/native-authored-project-observe.ts index af47d919a..036132a32 100644 --- a/src/backends/native-authored-project-observe.ts +++ b/src/backends/native-authored-project-observe.ts @@ -7,6 +7,7 @@ import { import { withPinnedRuntimeBinary } from "./native-authored-runtime-binary.ts"; import { invokeNativeRuntime, + isNativeRuntimeNotRunning, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; @@ -29,7 +30,15 @@ type Item = { }; export type NativeAuthoredProjectStatus = { readonly backend: "native"; - readonly status: "not_started" | "pending" | "observed"; + /** + * `runtime_not_running`: a Ready generation is saved but the candidate + * runtime is stopped. Nothing was observed: `phase` is null, `items` empty. + */ + readonly status: + | "not_started" + | "pending" + | "runtime_not_running" + | "observed"; readonly run: string | null; readonly phase: string | null; readonly items: readonly Item[]; @@ -83,23 +92,40 @@ export async function nativeAuthoredProjectPs(input: { active(signal); }; await check(); - const value = await invoke({ - runtime, - cwd: scope.projectRoot, - args: [ - "graph", - "native", - "control", - "--run-id", - receipt.review.provenance.run, - "--action", - "status", - "--json", - ], - timeoutMs: 15_000, - signal, - boundNativeStatusDrain: true, - }); + let value: unknown; + try { + value = await invoke({ + runtime, + cwd: scope.projectRoot, + args: [ + "graph", + "native", + "control", + "--run-id", + receipt.review.provenance.run, + "--action", + "status", + "--json", + ], + timeoutMs: 15_000, + signal, + boundNativeStatusDrain: true, + }); + } catch (error: unknown) { + if (!isNativeRuntimeNotRunning(error)) { + throw error; + } + // A stopped runtime is a status, not a refusal; the same saved + // selection and executable must still hold. + await check(); + return { + backend: "native", + status: "runtime_not_running", + run: receipt.review.provenance.run, + phase: null, + items: [], + }; + } await check(); const current = parseNativeAuthoredControl(value, receipt, "status"); const items: Item[] = Object.entries(current.receipt.readiness).map( diff --git a/src/backends/native-project-observe.ts b/src/backends/native-project-observe.ts index a64c45146..60aee6564 100644 --- a/src/backends/native-project-observe.ts +++ b/src/backends/native-project-observe.ts @@ -1,3 +1,4 @@ +import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { loadNativeProjectRun, @@ -6,6 +7,7 @@ import { } from "./native-project-run.ts"; import { invokeNativeRuntime, + isNativeRuntimeNotRunning, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; @@ -25,10 +27,36 @@ type Options = { readonly invoke?: typeof invokeNativeRuntime; }; function refused(): Error { - return new Error( + return new ClosedCliError( "Native graph observation refused: owned mapping or current graph identity is unavailable or changed." ); } +/** Closed refusal for logs, which have nothing to read from a stopped runtime. */ +function runtimeNotRunning(): HackCliError { + return new HackCliError({ + code: "E_DOCKER_UNAVAILABLE", + message: + "Native runtime is not running; no service logs are available. Start the native runtime and retry.", + }); +} +/** + * Native ps result. `runtime_not_running` means the project has an owned run + * mapping but the candidate runtime is stopped: nothing was observed, so + * `phase` is null and `items` is empty. + */ +export type NativeProjectPsStatus = + | "not_started" + | "runtime_not_running" + | "observed" + | "owner_unconfirmed" + | "runtime_degraded"; +export type NativeProjectPsResult = { + readonly backend: "native"; + readonly status: NativeProjectPsStatus; + readonly run: string | null; + readonly phase: string | null; + readonly items: Item[]; +}; function inspected( value: unknown, expected: NativeProjectRun @@ -111,19 +139,39 @@ async function inspect(opts: Options, run: NativeProjectRun) { run ); } -/** Historical receipt phase is separated from current per-service observations. */ -export async function nativeProjectPs(opts: Options) { +/** + * Historical receipt phase is separated from current per-service observations. + * A stopped candidate runtime is an ordinary state for this read: it reports + * `runtime_not_running` instead of failing. Every other refusal still throws. + */ +export async function nativeProjectPs( + opts: Options +): Promise { const run = await loadNativeProjectRun(opts.scope); if (!run) { return { - backend: "native" as const, - status: "not_started" as const, + backend: "native", + status: "not_started", run: null, phase: null, - items: [] as Item[], + items: [], + }; + } + let snapshot: Awaited>; + try { + snapshot = await inspect(opts, run); + } catch (error: unknown) { + if (!isNativeRuntimeNotRunning(error)) { + throw error; + } + return { + backend: "native", + status: "runtime_not_running", + run: run.run, + phase: null, + items: [], }; } - const snapshot = await inspect(opts, run); let status: "observed" | "owner_unconfirmed" | "runtime_degraded" = "observed"; if (snapshot.foreground && snapshot.phase === "ready-observed") { @@ -153,7 +201,7 @@ export async function nativeProjectPs(opts: Options) { } } return { - backend: "native" as const, + backend: "native", status, run: run.run, items: snapshot.items, @@ -176,17 +224,22 @@ export async function nativeProjectLogs( opts.tail < 1 || opts.tail > 1000 ) { - throw new Error( + throw new ClosedCliError( "Native logs require one service, --no-follow and --tail 1..1000." ); } const run = await loadNativeProjectRun(opts.scope); if (!run) { - throw new Error( + throw new ClosedCliError( "Native project has not been started; no owned graph mapping exists." ); } - const before = await inspect(opts, run); + let before: Awaited>; + try { + before = await inspect(opts, run); + } catch (error: unknown) { + throw isNativeRuntimeNotRunning(error) ? runtimeNotRunning() : error; + } const service = before.items.find((item) => item.service === opts.service); if (!service?.container) { throw refused(); diff --git a/src/backends/native-runtime-client.ts b/src/backends/native-runtime-client.ts index 8a0179741..6b5e78c9e 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -1,4 +1,5 @@ import { isAbsolute } from "node:path"; +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { nativeAuthoredExecOptions } from "./native-authored-exec-options.ts"; import { @@ -18,8 +19,11 @@ interface NativeFailure { readonly stopFailures?: readonly NativeStopFailure[]; } -/** Structured native codes remain safe to inspect without exposing subprocess diagnostics. */ -export class NativeRuntimeRequestError extends Error { +/** + * Structured native codes remain safe to inspect without exposing subprocess + * diagnostics. The message is closed, so the CLI prints it without a stack. + */ +export class NativeRuntimeRequestError extends ClosedCliError { readonly nativeCode: string | undefined; readonly nativeCauseCode: string | undefined; readonly nativeStopFailures: readonly NativeStopFailure[] | undefined; @@ -37,6 +41,22 @@ export class NativeRuntimeRequestError extends Error { } } +/** Native code for a read against a candidate runtime that is not running. */ +export const NATIVE_RUNTIME_NOT_RUNNING = "runtime_not_running"; + +/** + * True when a native request was refused because the candidate runtime is not + * running. The runtime reports this before it connects to the guest or reads + * graph state, so nothing was observed or changed. A read-only observation may + * report the stopped runtime as its result instead of failing. + */ +export function isNativeRuntimeNotRunning(error: unknown): boolean { + return ( + error instanceof NativeRuntimeRequestError && + error.nativeCode === NATIVE_RUNTIME_NOT_RUNNING + ); +} + export interface NativeRuntimeSelection { readonly binary: string; readonly home: string; diff --git a/src/cli/run.ts b/src/cli/run.ts index 8c181a7fa..cf2d4eaa2 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -1,5 +1,6 @@ import { cancel } from "@clack/prompts"; import { + ClosedCliError, emitCliResult, errorResult, errorResultFromUnknown, @@ -138,6 +139,10 @@ function resolveSensitiveOutputError( * stays a single parseable envelope for every error class (usage errors * included — they emit E_USAGE and skip the printed help); the human-readable * message still reaches stderr. + * + * Without `--json`, known errors ({@link HackCliError}, {@link ClosedCliError}) + * print their closed message only, plus the stack when `HACK_DEBUG=1`. Any + * other exception is unexpected and always prints its stack. */ export async function handleRunCliError(opts: { readonly error: unknown; @@ -165,9 +170,19 @@ export async function handleRunCliError(opts: { if (error instanceof HackCliError) { logger.error({ message: `${error.code}: ${error.message}` }); + logDebugStack(error); return 1; } + if (error instanceof ClosedCliError) { + // A known refusal: its message is the whole report. A stack would only + // repeat it with internal frames. + logger.error({ message: error.message }); + logDebugStack(error); + return 1; + } + + // Unexpected exceptions keep their stack: it is the only lead to the bug. const message = error instanceof Error ? error.message : "Unknown error"; cancel(message); if (error instanceof Error && error.stack) { @@ -176,6 +191,13 @@ export async function handleRunCliError(opts: { return 1; } +/** Known errors show their stack only on request (`HACK_DEBUG=1`). */ +function logDebugStack(error: Error): void { + if (isTruthyEnv(process.env.HACK_DEBUG) && error.stack) { + logger.error({ message: error.stack }); + } +} + /** * One-line stderr warning when an experimental (Beta-group) command is * invoked. Suppressed under `--json` output and when the user acknowledged diff --git a/src/commands/project.ts b/src/commands/project.ts index 2d38f7707..073ce8ee2 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -46,6 +46,7 @@ import { import { adoptNativeLifecycleCleanup } from "../backends/native-project-lifecycle.ts"; import { parseNativeAllowedHosts } from "../backends/native-project-network.ts"; import { + type NativeProjectPsResult, nativeProjectLogs, nativeProjectPs, requireComposeOperationAvailable, @@ -7227,15 +7228,25 @@ async function maybeManageProjectLogsAfterDown(opts: { }); } +/** + * Native ps output. `--json` prints the standard `{ok: true, data}` envelope + * whose `data` is the native ps result, matching authored native ps. + */ async function displayNativePs( - result: Awaited>, + result: NativeProjectPsResult, json: boolean ): Promise { if (json) { - process.stdout.write(`${JSON.stringify(result)}\n`); + emitCliResult({ result: okResult({ data: result }) }); return; } - if (result.status === "owner_unconfirmed") { + if (result.status === "not_started") { + logger.info({ message: "Native project has not been started." }); + } else if (result.status === "runtime_not_running") { + logger.info({ + message: "Native runtime is not running; no services are running.", + }); + } else if (result.status === "owner_unconfirmed") { logger.warn({ message: "Native graph owner is unconfirmed. Service rows are observations, not usable readiness; explicit retaining recovery is required.", diff --git a/src/lib/cli-result.ts b/src/lib/cli-result.ts index 799d88cbd..c64043178 100644 --- a/src/lib/cli-result.ts +++ b/src/lib/cli-result.ts @@ -131,6 +131,17 @@ export class HackCliError extends Error { } } +/** + * Error whose message is a complete, value-free sentence for the user, such + * as a refusal at the native runtime boundary. It carries no stable `--json` + * code of its own (the envelope still reports E_UNEXPECTED). + * + * Without `--json`, the top-level CLI handler prints only the message; the + * stack trace is a debugging aid shown only with `HACK_DEBUG=1`. Unexpected + * exceptions are not closed errors and keep printing their stack. + */ +export class ClosedCliError extends Error {} + /** * Convert any thrown value into an error envelope, preserving structured * codes from {@link HackCliError} and falling back to E_UNEXPECTED. diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 6e0597465..0187256bb 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -27,7 +27,10 @@ import { } from "../backends/native-authored-project-start.ts"; import { resolveNativeAuthoredExecStaleEnvWarning } from "../backends/native-exec-stale-env.ts"; import { parseNativeHttpsSelection } from "../backends/native-project-start.ts"; -import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; +import { + isNativeRuntimeNotRunning, + resolveNativeRuntimeSelection, +} from "../backends/native-runtime-client.ts"; import { CliUsageError } from "../cli/command.ts"; import { display } from "../ui/display.ts"; import { logger } from "../ui/logger.ts"; @@ -111,10 +114,17 @@ async function runAuthoredLogs(input: { process.stderr.write(result.stderr); } return 0; - } catch { + } catch (error: unknown) { if (controller.signal.aborted) { return 130; } + if (isNativeRuntimeNotRunning(error)) { + throw new HackCliError({ + code: "E_DOCKER_UNAVAILABLE", + message: + "Native runtime is not running; no service logs are available. Start the native runtime and retry.", + }); + } throw new HackCliError({ code: "E_LIFECYCLE_FAILED", message: @@ -281,6 +291,16 @@ function commandMode(options: NativeComposeCommandOptions): Mode { return FOREGROUND_MODES[options.operation] ?? unsupported(); } +const STATUS_NOTES: Readonly< + Record, string> +> = { + not_started: "Native project has not been started.", + pending: + "Native project startup is pending; no current service status is confirmed.", + runtime_not_running: + "Native runtime is not running; no services are running.", +}; + async function showStatus(options: { readonly result: NativeAuthoredProjectStatus; readonly json?: boolean; @@ -291,12 +311,7 @@ async function showStatus(options: { return; } if (result.status !== "observed") { - logger.info({ - message: - result.status === "pending" - ? "Native project startup is pending; no current service status is confirmed." - : "Native project has not been started.", - }); + logger.info({ message: STATUS_NOTES[result.status] }); } await display.table({ columns: ["NATIVE SERVICE", "CONTAINER", "STATE", "HEALTH"], diff --git a/tests/cli-closed-error-output.test.ts b/tests/cli-closed-error-output.test.ts new file mode 100644 index 000000000..07b0ed038 --- /dev/null +++ b/tests/cli-closed-error-output.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { handleRunCliError } from "../src/cli/run.ts"; +import { ClosedCliError, HackCliError } from "../src/lib/cli-result.ts"; +import { setLoggerBackendOverride } from "../src/ui/logger.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +/** + * The top-level handler prints a known error's closed message only; its stack + * is shown with HACK_DEBUG=1. Unexpected exceptions keep their stack. + */ + +const STACK_FRAME = /^\s+at /m; +let output = ""; +let restoreWrites: () => void = () => undefined; +const savedDebug = process.env.HACK_DEBUG; + +beforeEach(() => { + output = ""; + Reflect.deleteProperty(process.env, "HACK_DEBUG"); + setLoggerBackendOverride({ backend: "console" }); + const stdout = process.stdout.write.bind(process.stdout); + const stderr = process.stderr.write.bind(process.stderr); + const capture = (chunk: string | Uint8Array): boolean => { + output += + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); + return true; + }; + process.stdout.write = capture as typeof process.stdout.write; + process.stderr.write = capture as typeof process.stderr.write; + restoreWrites = () => { + process.stdout.write = stdout; + process.stderr.write = stderr; + }; +}); + +afterEach(() => { + restoreWrites(); + setLoggerBackendOverride({ backend: null }); + restoreEnv("HACK_DEBUG", savedDebug); +}); + +function nativeRefusal(): NativeRuntimeRequestError { + return new NativeRuntimeRequestError({ + message: + "Native runtime request failed (graph_journal_incomplete); inspect owned state before retrying.", + nativeCode: "graph_journal_incomplete", + }); +} + +test("a closed native refusal prints its message once and no stack", async () => { + const error = nativeRefusal(); + expect(error).toBeInstanceOf(ClosedCliError); + expect(await handleRunCliError({ error, jsonRequested: false })).toBe(1); + expect(output).toBe(`ERROR: ${error.message}\n`); + expect(output).not.toMatch(STACK_FRAME); +}); + +test("HACK_DEBUG=1 adds the stack to closed and coded errors", async () => { + process.env.HACK_DEBUG = "1"; + expect( + await handleRunCliError({ error: nativeRefusal(), jsonRequested: false }) + ).toBe(1); + expect(output).toMatch(STACK_FRAME); + output = ""; + const coded = new HackCliError({ + code: "E_LIFECYCLE_FAILED", + message: "Closed lifecycle refusal.", + }); + expect(await handleRunCliError({ error: coded, jsonRequested: false })).toBe( + 1 + ); + expect(output).toContain( + "ERROR: E_LIFECYCLE_FAILED: Closed lifecycle refusal." + ); + expect(output).toMatch(STACK_FRAME); +}); + +test("a coded error prints no stack without HACK_DEBUG", async () => { + const coded = new HackCliError({ + code: "E_LIFECYCLE_FAILED", + message: "Closed lifecycle refusal.", + }); + expect(await handleRunCliError({ error: coded, jsonRequested: false })).toBe( + 1 + ); + expect(output).toBe("ERROR: E_LIFECYCLE_FAILED: Closed lifecycle refusal.\n"); +}); + +test("an unexpected exception keeps its stack", async () => { + const error = new TypeError("unexpected internal failure"); + expect(await handleRunCliError({ error, jsonRequested: false })).toBe(1); + expect(output).toContain("unexpected internal failure"); + expect(output).toMatch(STACK_FRAME); +}); + +test("--json keeps one envelope for a closed refusal", async () => { + const error = nativeRefusal(); + expect(await handleRunCliError({ error, jsonRequested: true })).toBe(1); + expect(JSON.parse(output)).toEqual({ + ok: false, + error: { code: "E_UNEXPECTED", message: error.message }, + }); +}); diff --git a/tests/native-authored-project-logs.test.ts b/tests/native-authored-project-logs.test.ts index 060fc2db3..fa660abc8 100644 --- a/tests/native-authored-project-logs.test.ts +++ b/tests/native-authored-project-logs.test.ts @@ -21,10 +21,14 @@ import { saveNativeAuthoredProjectRun, withNativeAuthoredProjectStatus, } from "../src/backends/native-authored-project-run.ts"; -import { invokeNativeRuntime } from "../src/backends/native-runtime-client.ts"; +import { + invokeNativeRuntime, + NativeRuntimeRequestError, +} from "../src/backends/native-runtime-client.ts"; import { tryNativeAuthoredCommand } from "../src/lib/native-authored-command.ts"; import type { NativeComposeCommandOptions } from "../src/lib/native-compose-command.ts"; import { captureCompletedJobFixtureCommand } from "./e2e/scenarios/native-compose-adoption-job-worktrees.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; const roots: string[] = []; let uncertain = false; @@ -364,6 +368,41 @@ test("logs drain domain refuses mutations, follow, private input and noncanonica ).rejects.toThrow("budget"); }); }); +test("a stopped runtime leaves the held read as its own closed error, only while the selection holds", async () => { + return owned(async () => { + const f = await fixture(); + const stopped = () => + new NativeRuntimeRequestError({ + message: "stopped", + nativeCode: "runtime_not_running", + }); + let calls = 0; + const outcome = nativeAuthoredProjectLogs({ + ...f, + service: "web", + invoke: () => { + calls++; + return Promise.reject(stopped()); + }, + }); + await expect(outcome).rejects.toBeInstanceOf(NativeRuntimeRequestError); + await expect(outcome).rejects.toMatchObject({ + nativeCode: "runtime_not_running", + }); + expect(calls).toBe(1); + // An executable that changed during the request refuses instead. + await expect( + nativeAuthoredProjectLogs({ + ...f, + service: "web", + invoke: async () => { + await Bun.write(f.runtime.binary, "changed"); + throw stopped(); + }, + }) + ).rejects.toThrow("changed"); + }); +}); test("logs cancellation settles its read child and returns no captured bytes", async () => { return owned(async () => { const f = await fixture(); @@ -596,3 +635,84 @@ macTest.each([ }), 20_000 ); + +macTest.each([false, true])( + "source CLI authored logs --no-follow reports a stopped runtime as E_DOCKER_UNAVAILABLE (json=%s)", + (json) => + owned(async () => { + const f = await fixture(); + await Bun.write( + join(f.scope.projectDir, "hack.project.json"), + JSON.stringify({ + version: 1, + services: { web: { image: "synthetic:test" } }, + }) + ); + const attempts = join(f.runtime.home, "log-attempts"); + const expected = [ + "--candidate-root", + f.runtime.home, + ...f.args.map((value) => (value === "17" ? "200" : value)), + ]; + // The candidate's reply to any read while its runtime is stopped. + await Bun.write( + f.runtime.binary, + `#!${process.execPath}\nimport {appendFileSync} from 'node:fs';\nif(JSON.stringify(process.argv.slice(2))!==${JSON.stringify(JSON.stringify(expected))})process.exit(97);appendFileSync(${JSON.stringify(attempts)},'logs\\n');process.stderr.write(${JSON.stringify(JSON.stringify({ code: "runtime_not_running" }))});process.exit(1);\n` + ); + const result = await captureCompletedJobFixtureCommand({ + argv: [ + process.execPath, + "--no-env-file", + resolve(import.meta.dir, "../index.ts"), + "--path", + f.scope.projectRoot, + "logs", + "web", + "--no-follow", + ...(json ? ["--json"] : []), + ], + cwd: f.scope.projectRoot, + env: privateCliEnv({ + root: f.scope.projectRoot, + env: { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: f.runtime.binary, + HACK_NATIVE_HOME: f.runtime.home, + HACK_CONFIG_COMPILER_BINARY: "/must-not-compile", + HACK_LOGGER: "console", + HACK_EXECUTION_MODE: "non_interactive", + }, + }), + captures: join(f.scope.projectRoot, "captures"), + timeoutMs: 15_000, + onUnconfirmed: () => { + uncertain = true; + }, + }).catch((error: unknown) => { + uncertain = true; + throw error; + }); + expect({ + exitCode: result.exitCode, + output: result.combined, + }).toMatchObject({ exitCode: 1 }); + // The authored read reached the stopped runtime exactly once. + expect(await Bun.file(attempts).text()).toBe("logs\n"); + expect(result.combined).not.toMatch(/^\s+at /m); + expect(result.combined).not.toContain("E_LIFECYCLE_FAILED"); + if (json) { + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { code: "E_DOCKER_UNAVAILABLE" }, + }); + } else { + expect(result.stdout).toBe(""); + expect(result.stderr.split("\n").filter(Boolean)).toEqual([ + expect.stringContaining( + "E_DOCKER_UNAVAILABLE: Native runtime is not running; no service logs are available. Start the native runtime and retry." + ), + ]); + } + }), + 20_000 +); diff --git a/tests/native-authored-project-observe.test.ts b/tests/native-authored-project-observe.test.ts index 9ad937094..44fca3e58 100644 --- a/tests/native-authored-project-observe.test.ts +++ b/tests/native-authored-project-observe.test.ts @@ -19,7 +19,9 @@ import { withNativeAuthoredProjectAdmission, withNativeAuthoredProjectStatus, } from "../src/backends/native-authored-project-run.ts"; +import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; import { captureCompletedJobFixtureCommand } from "./e2e/scenarios/native-compose-adoption-job-worktrees.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; let active = 0, unknown = false; @@ -47,7 +49,11 @@ async function owned(body: () => Promise) { active--; } } -async function fixture(saved = true, branch: string | null = null) { +async function fixture( + saved = true, + branch: string | null = null, + nativeCode?: string +) { const projectRoot = await realpath( await mkdtemp(join(tmpdir(), "native-authored-ps-")) ); @@ -133,7 +139,7 @@ async function fixture(saved = true, branch: string | null = null) { const attempts = join(nativeHome, "status-attempts"); await Bun.write( runtime.binary, - `#!${process.execPath}\nimport { appendFileSync } from 'node:fs';\nconst expected=${JSON.stringify(["--candidate-root", nativeHome, "graph", "native", "control", "--run-id", provenance.run, "--action", "status", "--json"])};if(JSON.stringify(process.argv.slice(2))!==JSON.stringify(expected))process.exit(97);appendFileSync(${JSON.stringify(attempts)},'status\\n');console.log(${JSON.stringify(JSON.stringify(reply))});\n` + `#!${process.execPath}\nimport { appendFileSync } from 'node:fs';\nconst expected=${JSON.stringify(["--candidate-root", nativeHome, "graph", "native", "control", "--run-id", provenance.run, "--action", "status", "--json"])};if(JSON.stringify(process.argv.slice(2))!==JSON.stringify(expected))process.exit(97);appendFileSync(${JSON.stringify(attempts)},'status\\n');${nativeCode === undefined ? `console.log(${JSON.stringify(JSON.stringify(reply))});` : `process.stderr.write(${JSON.stringify(JSON.stringify({ code: nativeCode }))});process.exit(1);`}\n` ); await chmod(runtime.binary, 0o700); const mapping = join( @@ -475,7 +481,164 @@ test("dead owner refuses and cancellation waits for the owned request", () => expect(settled).toBe(true); })); +test("a stopped runtime is a status for a ready generation; other refusals still refuse", () => + owned(async () => { + const f = await fixture(); + let calls = 0; + const stopped = await nativeAuthoredProjectPs({ + ...f, + invoke: async () => { + calls++; + throw new NativeRuntimeRequestError({ + message: "stopped", + nativeCode: "runtime_not_running", + }); + }, + }); + expect(stopped).toEqual({ + backend: "native", + status: "runtime_not_running", + run: f.receipt.review.provenance.run, + phase: null, + items: [], + }); + expect(calls).toBe(1); + await expect( + nativeAuthoredProjectPs({ + ...f, + invoke: () => + Promise.reject( + new NativeRuntimeRequestError({ + message: "protocol", + nativeCode: "engine_protocol", + }) + ), + }) + ).rejects.toThrow(); + // Cancellation still wins over a stopped-runtime reply. + const controller = new AbortController(); + await expect( + nativeAuthoredProjectPs({ + ...f, + signal: controller.signal, + invoke: () => { + controller.abort(); + return Promise.reject( + new NativeRuntimeRequestError({ + message: "stopped", + nativeCode: "runtime_not_running", + }) + ); + }, + }) + ).rejects.toThrow(); + })); + +test.each([ + "rebirth", + "content", + "binary", + "recovery", +])("a stopped-runtime reply still refuses %s drift across its awaited request", (fault) => + owned(async () => { + const f = await fixture(); + let calls = 0; + const outcome = nativeAuthoredProjectPs({ + ...f, + invoke: async () => { + calls++; + if (fault === "rebirth") { + await rename(f.mapping, `${f.mapping}.original`); + await Bun.write( + f.mapping, + await Bun.file(`${f.mapping}.original`).text() + ); + await chmod(f.mapping, 0o600); + } + if (fault === "content") { + await Bun.write(f.mapping, "{}"); + } + if (fault === "binary") { + await Bun.write(f.runtime.binary, "changed"); + } + if (fault === "recovery") { + await Bun.write(`${f.mapping}.recovery.json`, "{}"); + } + // The runtime reports itself stopped only after the saved selection + // or executable changed; the recheck must refuse, not report a status. + throw new NativeRuntimeRequestError({ + message: "stopped", + nativeCode: "runtime_not_running", + }); + }, + }); + await expect(outcome).rejects.toThrow("changed"); + await expect(outcome).rejects.not.toBeInstanceOf(NativeRuntimeRequestError); + expect(calls).toBe(1); + })); + const macTest = process.platform === "darwin" ? test : test.skip; + +macTest.each([true, false])( + "source CLI native authored ps reports a stopped runtime (json=%s)", + (json) => + owned(async () => { + const f = await fixture(true, null, "runtime_not_running"); + const result = await captureCompletedJobFixtureCommand({ + argv: [ + process.execPath, + resolve(import.meta.dir, "../index.ts"), + "--path", + f.scope.projectRoot, + "ps", + ...(json ? ["--json"] : []), + ], + cwd: f.scope.projectRoot, + env: privateCliEnv({ + root: f.scope.projectRoot, + env: { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: f.runtime.binary, + HACK_NATIVE_HOME: f.runtime.home, + HACK_CONFIG_COMPILER_BINARY: "/must-not-compile", + HACK_LOGGER: "console", + HACK_EXECUTION_MODE: "non_interactive", + }, + }), + captures: join(f.scope.projectRoot, "captures"), + timeoutMs: 15_000, + onUnconfirmed: () => { + unknown = true; + }, + }).catch((error: unknown) => { + unknown = true; + throw error; + }); + expect({ + exitCode: result.exitCode, + output: result.combined, + }).toMatchObject({ exitCode: 0 }); + expect(await Bun.file(f.attempts).text()).toBe("status\n"); + expect(result.combined).not.toMatch(/^\s+at /m); + if (json) { + expect(JSON.parse(result.stdout)).toEqual({ + ok: true, + data: { + backend: "native", + status: "runtime_not_running", + run: f.receipt.review.provenance.run, + phase: null, + items: [], + }, + }); + } else { + expect(result.combined).toContain( + "Native runtime is not running; no services are running." + ); + } + }), + 20_000 +); macTest.each([ { json: true, profile: undefined }, { json: false, profile: undefined }, diff --git a/tests/native-project-observe.test.ts b/tests/native-project-observe.test.ts index 684a1c54a..ea852312c 100644 --- a/tests/native-project-observe.test.ts +++ b/tests/native-project-observe.test.ts @@ -7,6 +7,8 @@ import { nativeProjectPs, } from "../src/backends/native-project-observe.ts"; import { saveNativeProjectRun } from "../src/backends/native-project-run.ts"; +import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; const roots: string[] = []; afterEach(async () => { @@ -272,10 +274,15 @@ test("source CLI native ps reports not-started with Docker unavailable", async ( new Response(child.stderr).text(), ]); expect(code).toBe(0); - expect(JSON.parse(stdout)).toMatchObject({ - backend: "native", - status: "not_started", - items: [], + expect(JSON.parse(stdout)).toEqual({ + ok: true, + data: { + backend: "native", + status: "not_started", + run: null, + phase: null, + items: [], + }, }); } finally { clearTimeout(timer); @@ -433,3 +440,70 @@ test("absent native selector preserves legacy command availability", async () => expect(() => requireComposeOperationAvailable(operation, {})).not.toThrow(); } }); + +function nativeRefusal(nativeCode: string) { + return new NativeRuntimeRequestError({ + message: `Native runtime request failed (${nativeCode}); inspect owned state before retrying.`, + nativeCode, + }); +} +test("ps reports a stopped runtime for an owned mapping instead of failing", async () => { + const opts = await fixture(); + const calls: string[] = []; + const invoke = async (request: { args: readonly string[] }) => { + calls.push(request.args.join(" ")); + throw nativeRefusal("runtime_not_running"); + }; + expect(await nativeProjectPs({ ...opts, invoke })).toEqual({ + backend: "native", + status: "runtime_not_running", + run: run.run, + phase: null, + items: [], + }); + // One inspect; no owner-status or other follow-up request. + expect(calls).toEqual([`graph inspect --run-id ${run.run} --json`]); +}); +test("ps still fails on every other native refusal", async () => { + const opts = await fixture(); + for (const code of ["engine_protocol", "graph_journal_incomplete"]) { + await expect( + nativeProjectPs({ + ...opts, + invoke: () => Promise.reject(nativeRefusal(code)), + }) + ).rejects.toThrow(`(${code})`); + } + // An unstructured failure is not a stopped runtime either. + await expect( + nativeProjectPs({ + ...opts, + invoke: () => Promise.reject(new Error("runtime_not_running")), + }) + ).rejects.toThrow("runtime_not_running"); +}); +test("logs refuse a stopped runtime with a closed, coded error", async () => { + const opts = await fixture(); + const error = await nativeProjectLogs({ + ...opts, + invoke: () => Promise.reject(nativeRefusal("runtime_not_running")), + service: "web", + follow: false, + tail: 20, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(HackCliError); + expect(error).toMatchObject({ + code: "E_DOCKER_UNAVAILABLE", + message: + "Native runtime is not running; no service logs are available. Start the native runtime and retry.", + }); + await expect( + nativeProjectLogs({ + ...opts, + invoke: () => Promise.reject(nativeRefusal("engine_protocol")), + service: "web", + follow: false, + tail: 20, + }) + ).rejects.toThrow("(engine_protocol)"); +}); diff --git a/tests/native-runtime-not-running-cli.test.ts b/tests/native-runtime-not-running-cli.test.ts new file mode 100644 index 000000000..6602bf9bd --- /dev/null +++ b/tests/native-runtime-not-running-cli.test.ts @@ -0,0 +1,218 @@ +import { afterEach, expect, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { saveNativeProjectRun } from "../src/backends/native-project-run.ts"; +import { privateCliEnv, privateCliHome } from "./helpers/private-cli-env.ts"; + +/** + * A stopped native runtime is an ordinary state for read-only commands. The + * fake runtime binary answers every request the way the candidate does when + * its runtime is stopped: a closed `{"code": ...}` on stderr and exit 1. + */ + +const roots: string[] = []; +const entrypoint = resolve("index.ts"); +const run = { + run: "a".repeat(32), + owner: "b".repeat(32), + namespace: "c".repeat(64), + planId: "d".repeat(64), + profiles: [], + effectiveEnvName: null, + aws: null, +}; +/** A JavaScript stack frame as Bun prints it. */ +const STACK_FRAME = /^\s+at /m; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function fixture(opts: { readonly nativeCode: string }) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-not-running-cli-")) + ); + roots.push(root); + const project = join(root, "project"); + const projectDir = join(project, ".hack"); + const nativeHome = join(root, "native-home"); + await mkdir(projectDir, { recursive: true }); + await mkdir(nativeHome, { mode: 0o700 }); + await writeFile( + join(projectDir, "docker-compose.yml"), + JSON.stringify({ + services: { web: { image: "docker.io/library/busybox:1.37" } }, + }) + ); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ + name: "not-running-fixture", + dev_host: "not-running-fixture.hack.local", + }) + ); + const binary = join(root, "native-runtime"); + await writeFile( + binary, + `#!/bin/sh +printf '%s\\n' "$*" >> "$HOME/native-calls" +printf '%s' '{"code":"${opts.nativeCode}"}' >&2 +exit 1 +`, + { mode: 0o700 } + ); + await saveNativeProjectRun({ + projectRoot: project, + projectDir, + nativeHome, + branch: "alpha", + run, + }); + const env = (extra: Record = {}) => + privateCliEnv({ + root, + env: { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: binary, + HACK_NATIVE_HOME: nativeHome, + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + ...extra, + }, + }); + return { root, project, env }; +} + +async function cli(opts: { + readonly fixture: Awaited>; + readonly args: readonly string[]; + readonly env?: Record; +}) { + const child = Bun.spawn( + [ + process.execPath, + entrypoint, + ...opts.args, + "--path", + opts.fixture.project, + "--branch", + "alpha", + ], + { + cwd: opts.fixture.root, + env: opts.fixture.env(opts.env), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + } + ); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, code }; +} + +async function nativeCalls(root: string): Promise { + const text = await readFile( + join(privateCliHome(root), "native-calls"), + "utf8" + ).catch(() => ""); + return text.split("\n").filter(Boolean); +} + +test("native ps reports a stopped runtime as a status, not a failure", async () => { + const f = await fixture({ nativeCode: "runtime_not_running" }); + const result = await cli({ fixture: f, args: ["ps"] }); + const output = result.stdout + result.stderr; + expect(result.code).toBe(0); + expect(output).toContain( + "Native runtime is not running; no services are running." + ); + expect(output).toContain("NATIVE SERVICE"); + expect(output).not.toContain("Native runtime request failed"); + expect(output).not.toMatch(STACK_FRAME); + // The read reached the runtime exactly once (graph inspect for the mapping). + expect(await nativeCalls(f.root)).toEqual([ + `--candidate-root ${join(f.root, "native-home")} graph inspect --run-id ${run.run} --json`, + ]); +}); + +test("native ps --json returns the ok envelope with runtime_not_running", async () => { + const f = await fixture({ nativeCode: "runtime_not_running" }); + const result = await cli({ fixture: f, args: ["ps", "--json"] }); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + ok: true, + data: { + backend: "native", + status: "runtime_not_running", + run: run.run, + phase: null, + items: [], + }, + }); + expect(result.stderr).not.toMatch(STACK_FRAME); +}); + +test("native logs --no-follow refuses a stopped runtime with a closed message", async () => { + const f = await fixture({ nativeCode: "runtime_not_running" }); + const human = await cli({ fixture: f, args: ["logs", "web", "--no-follow"] }); + expect(human.code).toBe(1); + expect(human.stdout + human.stderr).toContain( + "E_DOCKER_UNAVAILABLE: Native runtime is not running; no service logs are available." + ); + expect(human.stdout + human.stderr).not.toMatch(STACK_FRAME); + + const json = await cli({ + fixture: f, + args: ["logs", "web", "--no-follow", "--json"], + }); + expect(json.code).toBe(1); + expect(JSON.parse(json.stdout)).toMatchObject({ + ok: false, + error: { code: "E_DOCKER_UNAVAILABLE" }, + }); + expect(json.stderr).not.toMatch(STACK_FRAME); +}); + +test("other native refusals still fail ps, with the closed message and no stack", async () => { + const f = await fixture({ nativeCode: "graph_journal_incomplete" }); + const result = await cli({ fixture: f, args: ["ps"] }); + const output = result.stdout + result.stderr; + expect(result.code).toBe(1); + expect(output).toContain( + "Native runtime request failed (graph_journal_incomplete); inspect owned state before retrying." + ); + // Printed once: not repeated as the head of a stack trace. + expect(output.split("Native runtime request failed").length - 1).toBe(1); + expect(output).not.toMatch(STACK_FRAME); + + const json = await cli({ fixture: f, args: ["ps", "--json"] }); + expect(json.code).toBe(1); + expect(JSON.parse(json.stdout)).toMatchObject({ ok: false }); + expect(json.stderr).not.toMatch(STACK_FRAME); +}); + +test("HACK_DEBUG=1 adds the stack to a closed native refusal", async () => { + const f = await fixture({ nativeCode: "graph_journal_incomplete" }); + const result = await cli({ + fixture: f, + args: ["ps"], + env: { HACK_DEBUG: "1" }, + }); + expect(result.code).toBe(1); + expect(result.stdout + result.stderr).toMatch(STACK_FRAME); +}); diff --git a/tests/python/test_native_frontend_acceptance.py b/tests/python/test_native_frontend_acceptance.py index 2963fa3db..540d38c7e 100644 --- a/tests/python/test_native_frontend_acceptance.py +++ b/tests/python/test_native_frontend_acceptance.py @@ -139,9 +139,9 @@ def reply(body, code=0): fcntl.flock(lock, fcntl.LOCK_UN) time.sleep(10) if state.get("foreground"): - reply({"status": "observed", "phase": "ready-observed", "backend": "native", "run": state["run"], - "items": [{"container": f"c{state['container']}"}]}) - reply({"status": "stopped"}) + reply({"ok": True, "data": {"status": "observed", "phase": "ready-observed", "backend": "native", + "run": state["run"], "items": [{"container": f"c{state['container']}"}]}}) + reply({"ok": True, "data": {"status": "runtime_not_running", "backend": "native", "items": []}}) if argv[0] == "exec": command = argv[argv.index("--") + 1:] if command[-1] == "/data/marker":