feat(gstack2): add unified execution result contract

This commit is contained in:
Sinabina
2026-07-20 16:22:28 -07:00
parent d6ef673e4d
commit 9b5ae4071d
19 changed files with 738 additions and 4 deletions
+47 -4
View File
@@ -39,6 +39,11 @@ import { installManagedRuntime, uninstallManagedRuntime } from "./install.js";
import { assertManagedHome, withRuntimeLifecycleLock } from "./managed-home.js";
import { errorWithCode as cliError } from "./errors.js";
import { RUNTIME_VERSION } from "./index.js";
import {
EXECUTION_RESULT_ERROR_CODES,
executionResult,
renderExecutionResult,
} from "./execution-result.js";
export async function main(argv = process.argv.slice(2), options = {}) {
const env = options.env ?? process.env;
@@ -313,7 +318,7 @@ async function stateCommand({ args, home, cwd, env, stdout, stderr }) {
"EXTERNAL_EFFECT_UNCERTAIN",
);
}
write(stdout, `${JSON.stringify({ status: result.status, effectKey, idempotencyKey: result.idempotencyKey ?? null, result: result.result })}\n`);
write(stdout, renderExecutionResult(result.result, { json: true }));
return 0;
}
if (action === "reconcile-not-applied") {
@@ -345,6 +350,7 @@ async function stateCommand({ args, home, cwd, env, stdout, stderr }) {
}
async function runExternalCommand(command, { cwd, env, stdout, stderr }) {
const maxCapturedBytes = 1024 * 1024;
const [executable, ...args] = command;
return new Promise((resolve, reject) => {
const child = spawn(executable, args, {
@@ -353,12 +359,49 @@ async function runExternalCommand(command, { cwd, env, stdout, stderr }) {
shell: false,
stdio: ["inherit", "pipe", "pipe"],
});
child.stdout?.on("data", (chunk) => write(stdout, chunk));
child.stderr?.on("data", (chunk) => write(stderr, chunk));
const captured = { stdout: "", stderr: "", bytes: 0, truncated: false };
const capture = (key, chunk) => {
const remaining = maxCapturedBytes - captured.bytes;
if (remaining <= 0) { captured.truncated = true; return; }
const buffer = Buffer.from(chunk);
captured[key] += buffer.subarray(0, remaining).toString();
captured.bytes += Math.min(buffer.length, remaining);
if (buffer.length > remaining) captured.truncated = true;
};
child.stdout?.on("data", (chunk) => capture("stdout", chunk));
child.stderr?.on("data", (chunk) => capture("stderr", chunk));
child.once("error", reject);
child.once("close", (code, signal) => {
if (code === 0) {
resolve({ exitCode: 0, executable: path.basename(executable) });
if (captured.truncated) {
reject(cliError(
`External command ${path.basename(executable)} exceeded the ${maxCapturedBytes}-byte result limit; verify its side effect before reconciling it as applied.`,
EXECUTION_RESULT_ERROR_CODES.DEGRADED,
));
return;
}
const evidence = [];
if (captured.stdout.trim()) evidence.push("non-empty stdout");
if (captured.stderr.trim()) evidence.push("non-empty stderr");
const name = path.basename(executable);
if (evidence.length === 0) {
reject(cliError(
`External command ${name} exited 0 but produced no output; verify its side effect before reconciling it as applied.`,
EXECUTION_RESULT_ERROR_CODES.EMPTY,
));
return;
}
resolve(executionResult({
status: "success",
summary: `External command ${name} completed`,
evidence: [`exit code 0`, ...evidence],
data: {
exitCode: 0,
executable: name,
stdout: captured.stdout,
stderr: captured.stderr,
},
}));
return;
}
const error = cliError(
+89
View File
@@ -0,0 +1,89 @@
import { errorWithCode } from "./errors.js";
export const EXECUTION_RESULT_SCHEMA_VERSION = 1;
export const EXECUTION_RESULT_STATUSES = Object.freeze([
"success",
"degraded",
"unsupported",
"failed",
]);
export const EXECUTION_RESULT_ERROR_CODES = Object.freeze({
EMPTY: "EXECUTION_EMPTY",
MALFORMED: "EXECUTION_MALFORMED",
DEGRADED: "EXECUTION_DEGRADED",
UNSUPPORTED: "EXECUTION_UNSUPPORTED",
FAILED: "EXECUTION_FAILED",
});
export const EXECUTION_RESULT_SCHEMA = Object.freeze({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "https://gstack.dev/schemas/execution-result-v1.json",
title: "GStack execution result",
type: "object",
additionalProperties: false,
required: ["schemaVersion", "status", "code", "summary", "evidence", "data"],
properties: {
schemaVersion: { const: EXECUTION_RESULT_SCHEMA_VERSION },
status: { enum: EXECUTION_RESULT_STATUSES },
code: { type: ["string", "null"], pattern: "^[A-Z][A-Z0-9_]{2,63}$" },
summary: { type: "string", minLength: 1 },
evidence: { type: "array", items: { type: "string", minLength: 1 } },
data: {},
},
allOf: [
{ if: { properties: { status: { const: "success" } } }, then: { properties: { code: { type: "null" }, evidence: { minItems: 1 } } } },
{ if: { properties: { status: { enum: ["degraded", "unsupported", "failed"] } } }, then: { properties: { code: { type: "string" } } } },
],
});
/** Validate the host-neutral result before any caller can render it as success. */
export function validateExecutionResult(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return invalid("Execution result must be an object");
const keys = Object.keys(value).sort();
const expected = ["code", "data", "evidence", "schemaVersion", "status", "summary"];
if (JSON.stringify(keys) !== JSON.stringify(expected)) return invalid("Execution result contains missing or unknown fields");
if (value.schemaVersion !== EXECUTION_RESULT_SCHEMA_VERSION) return invalid("Execution result schema version is unsupported");
if (!EXECUTION_RESULT_STATUSES.includes(value.status)) return invalid("Execution result status is unsupported");
if (typeof value.summary !== "string" || value.summary.trim().length === 0) return invalid("Execution result summary is empty");
if (!Array.isArray(value.evidence) || value.evidence.some((item) => typeof item !== "string" || item.trim().length === 0)) {
return invalid("Execution result evidence is malformed");
}
if (value.status === "success") {
if (value.code !== null) return invalid("Successful execution must not carry an error code");
if (value.evidence.length === 0) return invalid("Successful execution requires evidence", EXECUTION_RESULT_ERROR_CODES.EMPTY);
} else if (typeof value.code !== "string" || !/^[A-Z][A-Z0-9_]{2,63}$/.test(value.code)) {
return invalid("Non-success execution requires a stable error code");
}
return Object.freeze({ ...value, evidence: Object.freeze([...value.evidence]) });
}
export function executionResult(input) {
return validateExecutionResult({
schemaVersion: EXECUTION_RESULT_SCHEMA_VERSION,
status: input.status,
code: input.code ?? null,
summary: input.summary,
evidence: input.evidence ?? [],
data: input.data ?? null,
});
}
export function renderExecutionResult(result, options = {}) {
const valid = validateExecutionResult(result);
if (options.json) return `${JSON.stringify(valid, null, 2)}\n`;
const label = valid.status.toUpperCase();
const code = valid.code ? ` [${valid.code}]` : "";
const evidence = valid.evidence.map((item) => `- ${item}`).join("\n");
return `${label}${code}: ${valid.summary}${evidence ? `\nEvidence:\n${evidence}` : ""}\n`;
}
export function assertSuccessfulExecution(result) {
const valid = validateExecutionResult(result);
if (valid.status === "success") return valid;
throw errorWithCode(valid.summary, valid.code);
}
function invalid(message, code = EXECUTION_RESULT_ERROR_CODES.MALFORMED) {
throw errorWithCode(message, code);
}
+1
View File
@@ -15,3 +15,4 @@ export * from "./doctor.js";
export * from "./cleanup.js";
export * from "./upgrade.js";
export * from "./install.js";
export * from "./execution-result.js";