-
Notifications
You must be signed in to change notification settings - Fork 0
feat(gstack2): unified execution-result contract #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Empty and oversized results are classified internally but that classification is lost before callers see it: Prompt for AI agents |
||
| )); | ||
| 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( | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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({ | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The exported schema is still mutable below its root despite being presented as frozen. A consumer can rewrite nested constraints such as the success Prompt for AI agents |
||||||||||||||||||||||
| $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 }, | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Schema-only consumers can classify whitespace-only evidence as an evidenced success, while the runtime validator rejects it as malformed. Prompt for AI agents |
||||||||||||||||||||||
| 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]) }); | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: JSON rendering can emit a success envelope that violates the published required Prompt for AI agents |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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, | ||||||||||||||||||||||
|
Comment on lines
+64
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Malformed builder input loses the stable error-code contract: Prompt for AI agents
Suggested change
|
||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Browser setup can be incorrectly blocked after a successful Prompt for AI agents
Suggested change
|
||||||
|
|
||||||
| The developer-only fallback is \`node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [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()); | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ] | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: External command output can be corrupted when a UTF-8 character spans two pipe chunks. Decoding each chunk separately turns a valid character such as
éinto replacement characters before it is saved indata.stdout/data.stderr, so consumers no longer receive the command's actual evidence. Buffer the raw bytes and decode once after the stream ends, or use aStringDecoderper stream and flush it on close.Prompt for AI agents