From 2938cef109fb0691250e9520f103dcf7765f5f33 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 13:14:25 -0700 Subject: [PATCH] feat(gstack2): add unified execution result contract --- runtime/cli.js | 51 ++++++++++- runtime/execution-result.js | 89 +++++++++++++++++++ runtime/index.js | 1 + scripts/gstack2/generate-skill-tree.ts | 4 + skills/debug/references/RUNTIME.md | 2 + .../support/execution-result-contract.json | 88 ++++++++++++++++++ skills/design/references/RUNTIME.md | 2 + .../support/execution-result-contract.json | 88 ++++++++++++++++++ skills/plan/references/RUNTIME.md | 2 + .../support/execution-result-contract.json | 88 ++++++++++++++++++ skills/qa/references/RUNTIME.md | 2 + .../support/execution-result-contract.json | 88 ++++++++++++++++++ skills/review/references/RUNTIME.md | 2 + .../support/execution-result-contract.json | 88 ++++++++++++++++++ skills/ship/references/RUNTIME.md | 2 + .../support/execution-result-contract.json | 88 ++++++++++++++++++ test/gstack2-runtime-effect-cli.test.ts | 19 ++++ test/gstack2-runtime-execution-result.test.ts | 35 ++++++++ test/gstack2-skill-ux.test.ts | 3 + 19 files changed, 738 insertions(+), 4 deletions(-) create mode 100644 runtime/execution-result.js create mode 100644 skills/debug/references/support/execution-result-contract.json create mode 100644 skills/design/references/support/execution-result-contract.json create mode 100644 skills/plan/references/support/execution-result-contract.json create mode 100644 skills/qa/references/support/execution-result-contract.json create mode 100644 skills/review/references/support/execution-result-contract.json create mode 100644 skills/ship/references/support/execution-result-contract.json create mode 100644 test/gstack2-runtime-execution-result.test.ts diff --git a/runtime/cli.js b/runtime/cli.js index 2eea76259a..73cd8ec545 100644 --- a/runtime/cli.js +++ b/runtime/cli.js @@ -41,6 +41,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; @@ -391,7 +396,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") { @@ -423,6 +428,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, { @@ -431,12 +437,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( diff --git a/runtime/execution-result.js b/runtime/execution-result.js new file mode 100644 index 0000000000..dd92128809 --- /dev/null +++ b/runtime/execution-result.js @@ -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); +} diff --git a/runtime/index.js b/runtime/index.js index 7dd98939d1..8e5179867f 100644 --- a/runtime/index.js +++ b/runtime/index.js @@ -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"; diff --git a/scripts/gstack2/generate-skill-tree.ts b/scripts/gstack2/generate-skill-tree.ts index 5bfebf6ec9..2af4ca22be 100644 --- a/scripts/gstack2/generate-skill-tree.ts +++ b/scripts/gstack2/generate-skill-tree.ts @@ -4,6 +4,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { BUG_FIX_OVERLAYS, overlaysForSource } from './bug-fix-overlays'; import { renderBrowserProviderContract } from './browser-provider-contract'; +import { EXECUTION_RESULT_SCHEMA } from '../../runtime/execution-result.js'; import { contractFor, DISPATCHERS, SOURCE_ASSIGNMENTS } from './assignments'; import { SCENARIOS } from './scenarios'; import { runDeterministicSemanticParity } from './semantic-parity'; @@ -514,6 +515,8 @@ Some retained helpers are shell scripts. \`gstack doctor\` verifies Bash and, on The package/runtime compatibility tuple is \`schemaVersion=1\`, \`runtimeVersion=2.0.0\`, and \`skillApi=2.0\`; the machine-readable copy is \`references/support/runtime-contract.json\`. An incompatible active runtime is unavailable, not permission to upgrade it. +Every optional-runtime tool result must satisfy \`references/support/execution-result-contract.json\` before it is presented as success. Success requires non-empty evidence. Empty or malformed output and explicit degraded, unsupported, or failed statuses remain non-success with stable codes; human renderings must preserve that status and code. + The developer-only fallback is \`node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes\`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. `; } @@ -576,6 +579,7 @@ function writeSharedContracts(): void { write(path.join(ROOT, 'skills', tree, 'references', 'support', 'browser-choice.mjs'), browserChoice); write(path.join(ROOT, 'skills', tree, 'references', 'support', 'browser-provider-smoke.mjs'), browserSmoke); writeJson(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-contract.json'), RUNTIME_SKILL_CONTRACT); + writeJson(path.join(ROOT, 'skills', tree, 'references', 'support', 'execution-result-contract.json'), EXECUTION_RESULT_SCHEMA); } write(path.join(ROOT, 'skills', 'qa', 'references', 'SYSTEM-FUNCTIONAL.md'), systemFunctionalContract()); write(path.join(ROOT, 'skills', 'ship', 'references', 'EXTERNAL-EFFECTS.md'), externalEffectsContract()); diff --git a/skills/debug/references/RUNTIME.md b/skills/debug/references/RUNTIME.md index 10a231dade..d10576b0b1 100644 --- a/skills/debug/references/RUNTIME.md +++ b/skills/debug/references/RUNTIME.md @@ -23,4 +23,6 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. +Every optional-runtime tool result must satisfy `references/support/execution-result-contract.json` before it is presented as success. Success requires non-empty evidence. Empty or malformed output and explicit degraded, unsupported, or failed statuses remain non-success with stable codes; human renderings must preserve that status and code. + The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/debug/references/support/execution-result-contract.json b/skills/debug/references/support/execution-result-contract.json new file mode 100644 index 0000000000..2e6a1d0256 --- /dev/null +++ b/skills/debug/references/support/execution-result-contract.json @@ -0,0 +1,88 @@ +{ + "$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": 1 + }, + "status": { + "enum": [ + "success", + "degraded", + "unsupported", + "failed" + ] + }, + "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" + } + } + } + } + ] +} diff --git a/skills/design/references/RUNTIME.md b/skills/design/references/RUNTIME.md index 10a231dade..d10576b0b1 100644 --- a/skills/design/references/RUNTIME.md +++ b/skills/design/references/RUNTIME.md @@ -23,4 +23,6 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. +Every optional-runtime tool result must satisfy `references/support/execution-result-contract.json` before it is presented as success. Success requires non-empty evidence. Empty or malformed output and explicit degraded, unsupported, or failed statuses remain non-success with stable codes; human renderings must preserve that status and code. + The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/design/references/support/execution-result-contract.json b/skills/design/references/support/execution-result-contract.json new file mode 100644 index 0000000000..2e6a1d0256 --- /dev/null +++ b/skills/design/references/support/execution-result-contract.json @@ -0,0 +1,88 @@ +{ + "$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": 1 + }, + "status": { + "enum": [ + "success", + "degraded", + "unsupported", + "failed" + ] + }, + "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" + } + } + } + } + ] +} diff --git a/skills/plan/references/RUNTIME.md b/skills/plan/references/RUNTIME.md index 10a231dade..d10576b0b1 100644 --- a/skills/plan/references/RUNTIME.md +++ b/skills/plan/references/RUNTIME.md @@ -23,4 +23,6 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. +Every optional-runtime tool result must satisfy `references/support/execution-result-contract.json` before it is presented as success. Success requires non-empty evidence. Empty or malformed output and explicit degraded, unsupported, or failed statuses remain non-success with stable codes; human renderings must preserve that status and code. + The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/plan/references/support/execution-result-contract.json b/skills/plan/references/support/execution-result-contract.json new file mode 100644 index 0000000000..2e6a1d0256 --- /dev/null +++ b/skills/plan/references/support/execution-result-contract.json @@ -0,0 +1,88 @@ +{ + "$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": 1 + }, + "status": { + "enum": [ + "success", + "degraded", + "unsupported", + "failed" + ] + }, + "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" + } + } + } + } + ] +} diff --git a/skills/qa/references/RUNTIME.md b/skills/qa/references/RUNTIME.md index 10a231dade..d10576b0b1 100644 --- a/skills/qa/references/RUNTIME.md +++ b/skills/qa/references/RUNTIME.md @@ -23,4 +23,6 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. +Every optional-runtime tool result must satisfy `references/support/execution-result-contract.json` before it is presented as success. Success requires non-empty evidence. Empty or malformed output and explicit degraded, unsupported, or failed statuses remain non-success with stable codes; human renderings must preserve that status and code. + The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/qa/references/support/execution-result-contract.json b/skills/qa/references/support/execution-result-contract.json new file mode 100644 index 0000000000..2e6a1d0256 --- /dev/null +++ b/skills/qa/references/support/execution-result-contract.json @@ -0,0 +1,88 @@ +{ + "$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": 1 + }, + "status": { + "enum": [ + "success", + "degraded", + "unsupported", + "failed" + ] + }, + "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" + } + } + } + } + ] +} diff --git a/skills/review/references/RUNTIME.md b/skills/review/references/RUNTIME.md index 10a231dade..d10576b0b1 100644 --- a/skills/review/references/RUNTIME.md +++ b/skills/review/references/RUNTIME.md @@ -23,4 +23,6 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. +Every optional-runtime tool result must satisfy `references/support/execution-result-contract.json` before it is presented as success. Success requires non-empty evidence. Empty or malformed output and explicit degraded, unsupported, or failed statuses remain non-success with stable codes; human renderings must preserve that status and code. + The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/review/references/support/execution-result-contract.json b/skills/review/references/support/execution-result-contract.json new file mode 100644 index 0000000000..2e6a1d0256 --- /dev/null +++ b/skills/review/references/support/execution-result-contract.json @@ -0,0 +1,88 @@ +{ + "$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": 1 + }, + "status": { + "enum": [ + "success", + "degraded", + "unsupported", + "failed" + ] + }, + "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" + } + } + } + } + ] +} diff --git a/skills/ship/references/RUNTIME.md b/skills/ship/references/RUNTIME.md index 10a231dade..d10576b0b1 100644 --- a/skills/ship/references/RUNTIME.md +++ b/skills/ship/references/RUNTIME.md @@ -23,4 +23,6 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. +Every optional-runtime tool result must satisfy `references/support/execution-result-contract.json` before it is presented as success. Success requires non-empty evidence. Empty or malformed output and explicit degraded, unsupported, or failed statuses remain non-success with stable codes; human renderings must preserve that status and code. + The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/ship/references/support/execution-result-contract.json b/skills/ship/references/support/execution-result-contract.json new file mode 100644 index 0000000000..2e6a1d0256 --- /dev/null +++ b/skills/ship/references/support/execution-result-contract.json @@ -0,0 +1,88 @@ +{ + "$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": 1 + }, + "status": { + "enum": [ + "success", + "degraded", + "unsupported", + "failed" + ] + }, + "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" + } + } + } + } + ] +} diff --git a/test/gstack2-runtime-effect-cli.test.ts b/test/gstack2-runtime-effect-cli.test.ts index 53d2457fb9..b1f8f4b3cb 100644 --- a/test/gstack2-runtime-effect-cli.test.ts +++ b/test/gstack2-runtime-effect-cli.test.ts @@ -95,4 +95,23 @@ describe('gstack state external-effect CLI', () => { { cwd, env, stdout: out, stderr: err }, )).toBe(2); }); + + test('an empty exit-zero tool result is uncertain, never successful', async () => { + const { cwd, env } = await fixture(); + const out = sink(); + const err = sink(); + await main(['state', 'begin', 'ship', '--run-id', 'run_empty'], { cwd, env, stdout: out, stderr: err }); + const silent = path.join(cwd, 'silent.sh'); + await fs.writeFile(silent, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + const code = await main([ + 'state', 'effect', 'run_empty', 'silent.tool', '--', silent, + ], { cwd, env, stdout: out, stderr: err }); + expect(code).toBe(1); + expect(err.value()).toContain('may have occurred'); + expect(out.value()).not.toContain('"status":"success"'); + expect(await main([ + 'state', 'effect', 'run_empty', 'silent.tool', '--', silent, + ], { cwd, env, stdout: out, stderr: err })).toBe(1); + expect(err.value()).toContain('was already claimed'); + }); }); diff --git a/test/gstack2-runtime-execution-result.test.ts b/test/gstack2-runtime-execution-result.test.ts new file mode 100644 index 0000000000..dd36ff7ec3 --- /dev/null +++ b/test/gstack2-runtime-execution-result.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { + EXECUTION_RESULT_ERROR_CODES, + executionResult, + renderExecutionResult, + validateExecutionResult, +} from "../runtime/execution-result.js"; + +describe("GStack execution-result contract", () => { + test("accepts evidenced success and preserves it in both renderings", () => { + const result = executionResult({ status: "success", summary: "Probe completed", evidence: ["exit code 0"], data: { count: 1 } }); + expect(JSON.parse(renderExecutionResult(result, { json: true }))).toEqual(result); + expect(renderExecutionResult(result)).toContain("SUCCESS: Probe completed"); + }); + + test("rejects empty and malformed values instead of inferring success", () => { + expect(() => validateExecutionResult(null)).toThrow(); + expect(() => executionResult({ status: "success", summary: "Done", evidence: [], data: null })) + .toThrow("requires evidence"); + try { + executionResult({ status: "success", summary: "Done", evidence: [], data: null }); + } catch (error: any) { + expect(error.code).toBe(EXECUTION_RESULT_ERROR_CODES.EMPTY); + } + }); + + test.each([ + ["degraded", "EXECUTION_DEGRADED"], + ["unsupported", "EXECUTION_UNSUPPORTED"], + ["failed", "EXECUTION_FAILED"], + ])("keeps %s explicitly non-success", (status, code) => { + const result = executionResult({ status, code, summary: `${status} result`, evidence: [], data: null }); + expect(renderExecutionResult(result)).toStartWith(`${status.toUpperCase()} [${code}]`); + }); +}); diff --git a/test/gstack2-skill-ux.test.ts b/test/gstack2-skill-ux.test.ts index f137f7fa0b..4621479364 100644 --- a/test/gstack2-skill-ux.test.ts +++ b/test/gstack2-skill-ux.test.ts @@ -63,9 +63,12 @@ describe('GStack 2 canonical skill UX', () => { const bootstrap = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs')); const browserChoice = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'browser-choice.mjs')); const contract = JSON.parse(fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-contract.json'), 'utf8')); + const resultContract = JSON.parse(fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'execution-result-contract.json'), 'utf8')); expect(bootstrap, tree).toEqual(source); expect(browserChoice, tree).toEqual(fs.readFileSync(path.join(ROOT, 'runtime', 'browser-choice.mjs'))); expect(contract, tree).toEqual({ schemaVersion: 1, runtimeVersion: '2.0.0', skillApi: '2.0' }); + expect(resultContract.properties.status.enum, tree).toEqual(['success', 'degraded', 'unsupported', 'failed']); + expect(resultContract.allOf[0].then.properties.evidence.minItems, tree).toBe(1); expect(runtime, tree).toContain('preview --capability '); expect(runtime, tree).toContain('It never downloads components or mutates runtime state.'); expect(runtime, tree).toContain('options --capability ');