mirror of
https://github.com/morluto/rea.git
synced 2026-11-04 22:06:10 +00:00
Add rea environment-export <manifest.json> --evidence <capture.json> <dir> [--package-tarball <path>] [--name <override>]: it reads and authenticates the manifest and capture pair once and writes an agent environment bundle folder (README with provenance and a rights note, envs/<name> with a Dockerfile that pins this exact rea version or installs a given tarball, the manifest and the Evidence as byte-identical copies, a Python verifier that checks replay through the MCP endpoint, and a smoke task) that the agentenv framework runs with agent-env run. Refuses a non-empty directory and a mismatched pair before writing. Adds the real-agentenv hosted lane, which packs the checkout, exports a bundle from the source-owned capture fixture and runs it on the runner's Docker with a pinned agentenv-framework, wired into the CI planner and its guard tests. Refs morluto/rea#4933 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
299 lines
9.9 KiB
TypeScript
299 lines
9.9 KiB
TypeScript
import { resolve } from "node:path";
|
|
|
|
import { z } from "incur";
|
|
import type { Logger } from "pino";
|
|
|
|
import { ENVIRONMENT_EXPORT_OPERATION } from "../application/environment/environmentBundle.js";
|
|
import {
|
|
exportEnvironmentBundle,
|
|
readEnvironmentExportInputs,
|
|
type EnvironmentExportResult,
|
|
} from "../application/environment/environmentExport.js";
|
|
import {
|
|
ENVIRONMENT_SERVE_OPERATION,
|
|
loadEnvironmentSource,
|
|
} from "../application/environment/environmentSource.js";
|
|
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 { 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(
|
|
ENVIRONMENT_SERVE_OPERATION,
|
|
"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 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,
|
|
ENVIRONMENT_SERVE_OPERATION,
|
|
);
|
|
if (!manifest.ok) return manifest.error;
|
|
const evidence = await parseCliJsonInput(
|
|
input.evidence,
|
|
ENVIRONMENT_SERVE_OPERATION,
|
|
);
|
|
if (!evidence.ok) return evidence.error;
|
|
const source = loadEnvironmentSource(manifest.value, evidence.value);
|
|
if (!source.ok) return cliError(source.error);
|
|
// 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");
|
|
// 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: Awaited<ReturnType<typeof serveEnvironment>>;
|
|
try {
|
|
served = await serveEnvironment({
|
|
source: source.value,
|
|
...endpoint.value,
|
|
...(environmentName.value === undefined
|
|
? {}
|
|
: { environmentName: environmentName.value }),
|
|
logger: listenerLogger(environment, logger),
|
|
onerror: (error) => failListener(error),
|
|
});
|
|
} catch (cause: unknown) {
|
|
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,
|
|
};
|
|
};
|
|
|
|
interface ExportArguments {
|
|
readonly manifest: string;
|
|
readonly evidence: string;
|
|
readonly directory: string;
|
|
readonly packageTarball?: string | undefined;
|
|
readonly name?: string | undefined;
|
|
}
|
|
|
|
/** The written files with sizes and the pinned package source, as the command prints them. */
|
|
const exportDocument = (result: EnvironmentExportResult): JsonValue => ({
|
|
status: result.status,
|
|
directory: result.directory,
|
|
name: result.name,
|
|
tool_count: result.tool_count,
|
|
evidence_id: result.evidence_id,
|
|
manifest_evidence_id: result.manifest_evidence_id,
|
|
package_source: result.package_source,
|
|
files: result.files.map(({ path, bytes }) => ({ path, bytes })),
|
|
});
|
|
|
|
/** Read each file once, authenticate the pair as the serve command does, then write the bundle from the same bytes. */
|
|
const exportEnvironmentCommand = async (
|
|
input: ExportArguments,
|
|
): Promise<JsonValue> => {
|
|
const inputs = await readEnvironmentExportInputs(
|
|
input.manifest,
|
|
input.evidence,
|
|
);
|
|
if (!inputs.ok) return cliError(inputs.error);
|
|
const source = loadEnvironmentSource(
|
|
inputs.value.manifest.value,
|
|
inputs.value.evidence.value,
|
|
ENVIRONMENT_EXPORT_OPERATION,
|
|
);
|
|
if (!source.ok) return cliError(source.error);
|
|
const exported = await exportEnvironmentBundle({
|
|
...inputs.value,
|
|
source: source.value,
|
|
directory: resolve(input.directory),
|
|
name: input.name,
|
|
packageTarball:
|
|
input.packageTarball === undefined
|
|
? undefined
|
|
: resolve(input.packageTarball),
|
|
});
|
|
return exported.ok
|
|
? exportDocument(exported.value)
|
|
: cliError(exported.error);
|
|
};
|
|
|
|
const manifestArgument = z
|
|
.string()
|
|
.describe(
|
|
"Environment manifest Evidence JSON path (inspect-analysis-view environment-manifest output)",
|
|
);
|
|
const evidenceOption = z
|
|
.string()
|
|
.describe(
|
|
"Parent capture Evidence JSON path (inspect-web-network-capture output) the manifest cites",
|
|
);
|
|
|
|
/** Register the one-shot bundle export command. */
|
|
const registerExportCommand = (cli: CliInstance, logger: Logger): void => {
|
|
cli.command(CLI_COMMANDS.environmentExport, {
|
|
description:
|
|
"Export an environment manifest and its capture Evidence as an agent environment bundle folder the agentenv framework runs with agent-env run",
|
|
args: z.object({
|
|
manifest: manifestArgument,
|
|
directory: z
|
|
.string()
|
|
.describe(
|
|
"Bundle directory to create; must be absent or empty, nothing is overwritten",
|
|
),
|
|
}),
|
|
options: z.object({
|
|
evidence: evidenceOption,
|
|
packageTarball: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Packed rea-agents tarball (npm pack) copied into the env folder and installed by the Dockerfile instead of the registry package pinned to this version",
|
|
),
|
|
name: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Env folder and env id (kebab-case); default the manifest's name",
|
|
),
|
|
}),
|
|
run: ({ args, options }) =>
|
|
logCliCommand(logger, CLI_COMMANDS.environmentExport, () =>
|
|
exportEnvironmentCommand({
|
|
manifest: args.manifest,
|
|
evidence: options.evidence,
|
|
directory: args.directory,
|
|
packageTarball: options.packageTarball,
|
|
name: options.name,
|
|
}),
|
|
),
|
|
});
|
|
};
|
|
|
|
/** Register the long-running agent environment server command and the bundle export. */
|
|
export const registerEnvironmentCommands = (
|
|
cli: CliInstance,
|
|
logger: Logger,
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
): void => {
|
|
registerExportCommand(cli, logger);
|
|
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: manifestArgument }),
|
|
options: z.object({
|
|
evidence: evidenceOption,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
});
|
|
};
|