Files
rea/src/cli/environmentCommands.ts
T
10f1994fe1 feat(environment): serve an environment manifest as a replay environment (#4975)
Serve retained capture Evidence as a bounded MCP replay environment with discovery.
Preserve default stdio, caller-selected facts and complete source Evidence.

Fix numeric fidelity, argument identity, startup cancellation and bounded discovery,
query/path matching and output metadata. Verify real recorded API data and public
CLI/MCP workflows; preserve explicit gaps for Windows and third-party hosts.

Co-authored-by: Edgar Arakelyan <[email protected]>
Co-authored-by: gensokyo-dev <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-20 03:06:29 +08:00

211 lines
7.0 KiB
TypeScript

import { z } from "incur";
import type { Logger } from "pino";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { parseCliJsonInput } from "../cliJsonInput.js";
import { logCliCommand } from "../cliLogging.js";
import { parseEnvironmentName } from "../config/environmentName.js";
import {
parseMcpHttpEndpoint,
type McpHttpEndpoint,
} from "../config/mcpHttpEndpoint.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { createLogger } from "../logger.js";
import { withCommandCancellation } from "./commandCancellation.js";
import type { CliInstance } from "./types.js";
/** Same envelope label as the other JSON-input commands and `parseCliJsonInput`. */
const cliError = (error: AnalysisError): JsonValue => ({
error: "Application workflow failed",
...projectAnalysisError(error),
});
/** The listener announces its URL at info like `rea mcp --http`; an explicit REA_LOG_LEVEL still wins. */
const listenerLogger = (
environment: Readonly<NodeJS.ProcessEnv>,
logger: Logger,
): Logger =>
environment.REA_LOG_LEVEL === undefined
? createLogger("cli", "info")
: logger;
/** Resolve until the one-shot signal requests shutdown. */
const untilAborted = (signal: AbortSignal): Promise<undefined> =>
new Promise((resolve) => {
if (signal.aborted) resolve(undefined);
else
signal.addEventListener("abort", () => resolve(undefined), {
once: true,
});
});
/** A listener that could not bind or failed after binding, as the command's typed refusal. */
const transportError = (
endpoint: McpHttpEndpoint,
cause: Error,
): AnalysisResourceConstraintError =>
new AnalysisResourceConstraintError(
CLI_COMMANDS.environmentServe,
"transport",
cause.message,
{ host: endpoint.host, port: endpoint.port },
{
cause,
retryable: false,
remediationAction:
"Choose another --port or MCP_PORT, or stop the process using it.",
},
);
interface ServeArguments {
readonly manifest: string;
readonly evidence: string;
readonly host?: string | undefined;
readonly port?: number | undefined;
}
/** Load both Evidence files, bind the listener and run until a signal ends it. */
const serveEnvironmentCommand = async (
input: ServeArguments,
environment: Readonly<NodeJS.ProcessEnv>,
logger: Logger,
signal: AbortSignal,
): Promise<JsonValue> => {
const cancelled = (): JsonValue =>
cliError(new AnalysisCancelledError(CLI_COMMANDS.environmentServe));
if (signal.aborted) return cancelled();
const endpoint = parseMcpHttpEndpoint(environment, {
...(input.host === undefined ? {} : { host: input.host }),
...(input.port === undefined ? {} : { port: input.port }),
});
if (!endpoint.ok) return cliError(endpoint.error);
const environmentName = parseEnvironmentName(environment);
if (!environmentName.ok) return cliError(environmentName.error);
const manifest = await parseCliJsonInput(
input.manifest,
CLI_COMMANDS.environmentServe,
signal,
);
if (!manifest.ok) return manifest.error;
const evidence = await parseCliJsonInput(
input.evidence,
CLI_COMMANDS.environmentServe,
signal,
);
if (!evidence.ok) return evidence.error;
const { loadEnvironmentSource } =
await import("../application/environment/environmentSource.js");
if (signal.aborted) return cancelled();
const source = loadEnvironmentSource(manifest.value, evidence.value);
if (!source.ok) return cliError(source.error);
if (signal.aborted) return cancelled();
// Loaded here so registering the command catalog never loads the HTTP listener.
const { serveEnvironment } =
await import("../server/environment/serveEnvironment.js");
const { StreamableHttpListenError } =
await import("../server/http/streamableHttpListenError.js");
if (signal.aborted) return cancelled();
// A listener that fails after binding ends the command instead of serving nothing.
let failListener: (error: Error) => void = () => undefined;
const listenerFailed = new Promise<Error>((resolve) => {
failListener = resolve;
});
let served: import("../server/environment/serveEnvironment.js").ServedEnvironment;
try {
const started = await serveEnvironment({
source: source.value,
signal,
...endpoint.value,
...(environmentName.value === undefined
? {}
: { environmentName: environmentName.value }),
logger: listenerLogger(environment, logger),
onerror: (error) => failListener(error),
});
if (!started.ok) return cliError(started.error);
served = started.value;
} catch (cause: unknown) {
if (signal.aborted && cause === signal.reason) return cancelled();
if (!(cause instanceof StreamableHttpListenError)) throw cause;
return cliError(transportError(endpoint.value, cause));
}
let failure: Error | undefined;
try {
failure = await Promise.race([untilAborted(signal), listenerFailed]);
} finally {
await served.close();
}
if (failure !== undefined)
return cliError(transportError(endpoint.value, failure));
return {
status: "stopped",
url: served.url,
environment: served.card.name,
tools: served.card.capabilities.tools.length,
evidence_id: source.value.evidenceId,
manifest_evidence_id: source.value.manifestEvidenceId,
};
};
/** Register the long-running agent environment server command. */
export const registerEnvironmentCommands = (
cli: CliInstance,
logger: Logger,
environment: Readonly<NodeJS.ProcessEnv>,
): void => {
cli.command(CLI_COMMANDS.environmentServe, {
description:
"Serve an environment manifest as an agent environment that replays retained capture records over streamable HTTP until SIGINT or SIGTERM",
args: z.object({
manifest: z
.string()
.describe(
"Environment manifest Evidence JSON path (inspect-analysis-view environment-manifest output)",
),
}),
options: z.object({
evidence: z
.string()
.describe(
"Parent capture Evidence JSON path (inspect-web-network-capture output) the manifest cites",
),
host: z
.string()
.min(1)
.optional()
.describe("Listener address; default MCP_HOST, else 127.0.0.1"),
port: z
.number()
.int()
.min(0)
.max(65535)
.optional()
.describe(
"Listener port; default MCP_PORT, else 0 for an ephemeral port",
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.environmentServe, () =>
serveEnvironmentCommand(
{
manifest: args.manifest,
evidence: options.evidence,
host: options.host,
port: options.port,
},
environment,
logger,
signal,
),
),
),
});
};