From cfe0e9f27d2ef08467d08fdc8fe83643b596d95e Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 15:54:44 +0530 Subject: [PATCH 1/2] feat(engine): a child-status settlement carries the command's own error A command that delegates to a child process settles a failed child with exitWithChildStatus, and in json mode the engine named every such failure CLI.CHILD_PROCESS_FAILED. The command often knows a precise structured error for the failure (Composer builds DEPLOY.ENGINE_FAILED) but had no way to hand it over while still exiting with the child's status, so automation saw only the generic code. exitWithChildStatus now accepts `error`, a CliStructuredError. For a child that exited non-zero, the json envelope carries that error's code, summary, why, where and meta through the same conversion settleErrored uses; its next actions lead the settlement's, and its accompanying diagnostics are reported alongside it. The engine still writes the child's exitCode and signal into error.meta from its own record, over any keys of those names, and the process exit code is still the child's. Unchanged: a child that exited 0, a signal-killed child (still CLI.CHILD_PROCESS_FAILED with no next actions), human and markdown output (the child owned the terminal, so the error is not printed), and every settlement with no error attached. Engine 0.4.1 (`pnpm bump-cli-engine-version patch`): the public surface grew and 0.4.0 is already published. Known failing: `pnpm check:conformance` reports engine-pin-mismatch, because @prisma/composer-cli and @prisma/orm-toolchain peer the engine exactly and still peer 0.4.0. Earlier engine bumps (#222, #224, #260) carried this with transition entries in the conformance exception list; that list is left empty here for a maintainer to decide. Co-Authored-By: Claude Fable 5.1 --- docs/product/output-conventions.md | 22 +- docs/reference/error-reference.md | 2 +- packages/cli-engine/package.json | 2 +- .../cli-engine/src/execution/settlement.ts | 31 +- packages/cli-engine/src/spawn.ts | 25 +- packages/cli-engine/tests/spawn.test.ts | 284 +++++++++++++++++- packages/cli/package.json | 2 +- packages/prisma/package.json | 2 +- pnpm-lock.yaml | 4 +- 9 files changed, 359 insertions(+), 15 deletions(-) diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 9a059653..d678ae2c 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -42,9 +42,25 @@ command: - a non-zero child status is preserved as the process exit code and is represented by `CLI.CHILD_PROCESS_FAILED`, with `exitCode` and `signal` in `error.meta` - -This lets automation consume a command family's structured result without -having to parse the delegated tool's human output. +- a command that knows why its child failed attaches its own structured error + to the settlement (`exitWithChildStatus({ error })`), and the JSON result + carries that error's code, summary, `why`, `where`, and `meta` in place of + `CLI.CHILD_PROCESS_FAILED`. The process exit code is still the child's, and + the engine still writes the child's `exitCode` and `signal` into + `error.meta` from its own record, so those two keys mean the same thing + under every code. The error's own next actions come first, followed by the + settlement's +- a child killed by a signal is always reported as + `CLI.CHILD_PROCESS_FAILED` with no next actions, whatever the command + attached: the user stopped the run, which is not the failure the command's + error describes +- human and markdown output never print the attached error. The child owned + the terminal and has already reported its failure; only the settlement's + next actions follow it + +This lets automation consume a command family's structured result, and branch +on the precise code the command assigned, without having to parse the +delegated tool's human output. ## TTY and Piped Behavior diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 75964ad0..a6ceadc5 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -106,7 +106,7 @@ A `ctx.prompt.browserWait` flow (the command opened a URL and polled for the use ### CLI.CHILD_PROCESS_FAILED -Emitted only as a json-mode error envelope when a command that handed the terminal to a child process (`exitWithChildStatus`) saw that child exit non-zero or die on a signal; the run's exit code is the child's own status verbatim, not the CLI's usual 2. Meta: `exitCode`, `signal`. +Emitted only as a json-mode error envelope when a command that handed the terminal to a child process (`exitWithChildStatus`) saw that child exit non-zero or die on a signal; the run's exit code is the child's own status verbatim, not the CLI's usual 2. A command that attaches its own structured error to that settlement replaces this code for a child that exited non-zero — the envelope carries the command's code, the exit code is still the child's, and `exitCode` and `signal` are still written into `error.meta` — so this code then appears only for a signal-killed child. Meta: `exitCode`, `signal`. ### CLI.COMMAND_MOVED diff --git a/packages/cli-engine/package.json b/packages/cli-engine/package.json index 35b995b5..4ec40fd6 100644 --- a/packages/cli-engine/package.json +++ b/packages/cli-engine/package.json @@ -1,6 +1,6 @@ { "name": "@prisma/cli-engine", - "version": "0.4.0", + "version": "0.4.1", "description": "The execution engine of the unified Prisma CLI.", "type": "module", "exports": { diff --git a/packages/cli-engine/src/execution/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index 2ae47a6f..9f6cbc8d 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -226,6 +226,12 @@ export function settleVerbatimExitCode( * A signal-killed child overrules whatever the handler asked for. The * user stopped the run: it settles 128 + the signal number, with no * envelope and no next actions, because there is nothing to reproduce. + * + * The settlement may carry the command's own structured error. It + * changes what the json envelope says about a failed child — the + * command's code in place of CLI.CHILD_PROCESS_FAILED — and nothing + * else: not the exit code, and not human or markdown output, where the + * child owned the terminal and has already reported its failure. */ export function settleChildStatus( invocation: Invocation, @@ -293,6 +299,29 @@ function settleStructuredChildStatus( }); return; } + const status = { exitCode: child.exitCode, signal: child.signal }; + // The command's own error names the failure only when the child + // failed by itself. A signal-killed child is the user stopping the + // run, which is not the failure that error describes. + const attached = child.signal === null ? settlement.error : undefined; + if (attached !== undefined) { + const error = diagnosticOf(attached); + const actions = [...error.nextActions, ...nextActions]; + emitErrored(invocation, { + ok: false, + commandId: invocation.state.commandId, + // The status is spread last: it is the engine's record of the + // child, and a handler's meta cannot restate it. + error: { + ...error, + nextActions: actions, + meta: { ...error.meta, ...status }, + }, + diagnostics: accompanyingFindings(attached.diagnostics), + nextActions: actions, + }); + return; + } const how = child.signal === null ? `exited with code ${String(child.exitCode ?? "unknown")}` @@ -305,7 +334,7 @@ function settleStructuredChildStatus( severity: "error", summary: `The delegated process ${how}.`, nextActions, - meta: { exitCode: child.exitCode, signal: child.signal }, + meta: status, }, diagnostics: [], nextActions, diff --git a/packages/cli-engine/src/spawn.ts b/packages/cli-engine/src/spawn.ts index bd36f396..945ddfaa 100644 --- a/packages/cli-engine/src/spawn.ts +++ b/packages/cli-engine/src/spawn.ts @@ -3,7 +3,7 @@ * the child-status settlement are built from. The engine never imports * node:child_process — the bin injects an adapter satisfying SpawnChild. */ -import type { NextAction } from "./protocol"; +import type { CliStructuredError, NextAction } from "./protocol"; /** A fully composed child invocation. `env` is the child's COMPLETE * environment: the engine has already merged the invocation @@ -71,11 +71,13 @@ export const CHILD_STATUS: unique symbol = Symbol.for( * command that declares maySpawn. It carries no exit code, because the * code is not the handler's to state: the engine reads it off its own * record of the child. `nextActions` render to stderr before the - * process exits with the child's code. + * process exits with the child's code. `error` is the command's own + * account of why the child failed, and reaches only the json envelope. */ export interface ChildStatusSettlement { readonly [CHILD_STATUS]: true; readonly nextActions: readonly NextAction[]; + readonly error: CliStructuredError | undefined; } export interface ExitWithChildStatusOptions { @@ -84,6 +86,20 @@ export interface ExitWithChildStatusOptions { * a signal-killed child drops these entirely: the user stopped the * run, so there is nothing to reproduce. */ readonly nextActions?: readonly NextAction[]; + /** The structured error the command built for this failure, when it + * knows a more precise one than "the child failed". It names the + * json envelope in place of CLI.CHILD_PROCESS_FAILED: its code, + * summary, why, where and meta are the envelope's, its own next + * actions lead the list ahead of `nextActions`, and its accompanying + * diagnostics are reported alongside it. The engine still writes the + * child's `exitCode` and `signal` into `error.meta` from its own + * record, over any keys of those names, and the process still exits + * with the child's code — an error attached here never settles 2. + * Human and markdown output do not print it: the child owned the + * terminal and has already said what went wrong. A child that exited + * 0 ignores it, and so does a signal-killed one: the user stopped + * the run, which is not the failure this error describes. */ + readonly error?: CliStructuredError; } /** Signal numbers shared by Linux, macOS and the BSDs. Numbers that @@ -124,8 +140,8 @@ export function childExitCode(child: ChildResult): number { * construction error at settlement. * * A signal-killed child overrules everything the caller asked for: it - * settles 128 + the signal number with no `nextActions`, because the - * user stopped the run and there is nothing to reproduce. + * settles 128 + the signal number with no `nextActions` and no `error`, + * because the user stopped the run and there is nothing to reproduce. */ export function exitWithChildStatus( options?: ExitWithChildStatusOptions, @@ -133,6 +149,7 @@ export function exitWithChildStatus( return Object.freeze({ [CHILD_STATUS]: true as const, nextActions: Object.freeze([...(options?.nextActions ?? [])]), + error: options?.error, }); } diff --git a/packages/cli-engine/tests/spawn.test.ts b/packages/cli-engine/tests/spawn.test.ts index 2430a362..87da5a9a 100644 --- a/packages/cli-engine/tests/spawn.test.ts +++ b/packages/cli-engine/tests/spawn.test.ts @@ -11,7 +11,7 @@ import { exitWithChildStatus, type Runtime, } from "@prisma/cli-engine"; -import { ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { createTestCli, mintTestJwt, @@ -1423,6 +1423,288 @@ describe("next actions on a child-status settlement", () => { }); }); +describe("a structured error on a child-status settlement", () => { + /** Composer's shape: the operation knows why the converge failed and + * builds the error for it, and the run must still exit with the + * child's own status. */ + function engineFailed(): CliStructuredError { + return new CliStructuredError( + "DEPLOY.ENGINE_FAILED", + "The deploy engine failed.", + { + why: "alchemy exited before the stack converged.", + where: { path: "/app/.prisma/stack.ts" }, + meta: { + diagnostics: { + stackFilePath: "/app/.prisma/stack.ts", + reproduceCommand: "alchemy deploy ./entry.ts", + }, + // Not the handler's to state: the engine's record wins. + exitCode: 99, + }, + nextActions: [{ kind: "user-choice", label: "Fix the stack file." }], + diagnostics: [ + { + code: "DEPLOY.RESOURCE_FAILED", + severity: "warn", + summary: "The database did not converge.", + nextActions: [], + }, + ], + }, + ); + } + + const reproduce = { + kind: "run-command", + label: "Reproduce the failed converge", + command: "alchemy deploy ./entry.ts", + } as const; + + const deploy = defineCommand({ + help: { summary: "A converge that knows why its child failed" }, + maySpawn: true, + handler: async (_args, ctx) => { + await ctx.spawn({ command: "alchemy" }); + return ok( + exitWithChildStatus({ + nextActions: [reproduce], + error: engineFailed(), + }), + ); + }, + }); + + test("json names the command's error and still exits with the child's code", async () => { + const cli = createTestCli({ + commands: { deploy }, + now: CLOCK, + spawnScript: () => ({ exitCode: 3, signal: null }), + }); + + const result = await cli.run(["deploy", "--json"]); + + expect(result.exitCode).toBe(3); + expect(result.json).toHaveLength(1); + const nextActions = [ + { kind: "user-choice", label: "Fix the stack file." }, + reproduce, + ]; + expect(result.json[0]).toEqual({ + kind: "result", + commandId: "deploy", + timestamp: NOW.toISOString(), + envelope: { + ok: false, + commandId: "deploy", + error: { + code: "DEPLOY.ENGINE_FAILED", + severity: "error", + summary: "The deploy engine failed.", + why: "alchemy exited before the stack converged.", + where: { path: "/app/.prisma/stack.ts" }, + meta: { + diagnostics: { + stackFilePath: "/app/.prisma/stack.ts", + reproduceCommand: "alchemy deploy ./entry.ts", + }, + exitCode: 3, + signal: null, + }, + nextActions, + }, + diagnostics: [ + { + code: "DEPLOY.RESOURCE_FAILED", + severity: "warn", + summary: "The database did not converge.", + nextActions: [], + }, + ], + nextActions, + }, + }); + }); + + test("an error without meta still carries the child's status", async () => { + const bare = defineCommand({ + help: { summary: "A converge whose error has no meta of its own" }, + maySpawn: true, + handler: async (_args, ctx) => { + await ctx.spawn({ command: "alchemy" }); + return ok( + exitWithChildStatus({ + error: new CliStructuredError("DEPLOY.ENGINE_FAILED", "Failed."), + }), + ); + }, + }); + const cli = createTestCli({ + commands: { bare }, + now: CLOCK, + spawnScript: () => ({ exitCode: 1, signal: null }), + }); + + const result = await cli.run(["bare", "--json"]); + + expect(result.exitCode).toBe(1); + expect(result.json.at(-1)).toMatchObject({ + envelope: { + ok: false, + error: { + code: "DEPLOY.ENGINE_FAILED", + meta: { exitCode: 1, signal: null }, + nextActions: [], + }, + diagnostics: [], + nextActions: [], + }, + }); + }); + + test("the settlement summary carries the child's code, not the error's 2", async () => { + const summaries: number[] = []; + const cli = createTestCli({ + commands: { deploy }, + now: CLOCK, + spawnScript: () => ({ exitCode: 7, signal: null }), + }); + + await cli.run(["deploy", "--json"], { + onSettled: (summary) => summaries.push(summary.exitCode), + }); + + expect(summaries).toEqual([7]); + }); + + test("a child that exited 0 settles ok and ignores the error", async () => { + const cli = createTestCli({ + commands: { deploy }, + now: CLOCK, + spawnScript: () => ({ exitCode: 0, signal: null }), + }); + + const result = await cli.run(["deploy", "--json"]); + + expect(result.exitCode).toBe(0); + expect(result.json).toEqual([ + { + kind: "result", + commandId: "deploy", + timestamp: NOW.toISOString(), + envelope: { + ok: true, + commandId: "deploy", + result: null, + exitCode: 0, + diagnostics: [], + nextActions: [reproduce], + }, + }, + ]); + }); + + test("a signal-killed child drops the error with the next actions", async () => { + const cli = createTestCli({ + commands: { deploy }, + now: CLOCK, + spawnScript: () => ({ exitCode: null, signal: "SIGINT" }), + }); + + const result = await cli.run(["deploy", "--json"]); + + // The user stopped the converge: that is not the failure the + // command's error describes, so the engine's own account stands. + expect(result.exitCode).toBe(130); + expect(result.json).toEqual([ + { + kind: "result", + commandId: "deploy", + timestamp: NOW.toISOString(), + envelope: { + ok: false, + commandId: "deploy", + error: { + code: "CLI.CHILD_PROCESS_FAILED", + severity: "error", + summary: "The delegated process was terminated by SIGINT.", + nextActions: [], + meta: { exitCode: null, signal: "SIGINT" }, + }, + diagnostics: [], + nextActions: [], + }, + }, + ]); + }); + + test("human output does not print it: the child already reported the failure", async () => { + const cli = createTestCli({ + commands: { deploy }, + now: CLOCK, + spawnScript: () => ({ exitCode: 3, signal: null }), + }); + + const result = await cli.run(["deploy"], { isTty: { stdout: true } }); + + expect(result.exitCode).toBe(3); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "→ Reproduce the failed converge: alchemy deploy ./entry.ts\n", + ); + }); + + test("markdown output does not print it either", async () => { + const cli = createTestCli({ + commands: { deploy }, + now: CLOCK, + spawnScript: () => ({ exitCode: 3, signal: null }), + }); + + const result = await cli.run(["deploy", "--format", "markdown"], { + isTty: { stdout: true }, + }); + + expect(result.exitCode).toBe(3); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "- Reproduce the failed converge: `alchemy deploy ./entry.ts`\n", + ); + }); + + test("with no error attached the generic envelope is unchanged", async () => { + const cli = createTestCli({ + commands: { converge }, + now: CLOCK, + spawnScript: () => ({ exitCode: 3, signal: null }), + }); + + const result = await cli.run(["converge", "--json"]); + + expect(result.exitCode).toBe(3); + expect(result.json).toEqual([ + { + kind: "result", + commandId: "converge", + timestamp: NOW.toISOString(), + envelope: { + ok: false, + commandId: "converge", + error: { + code: "CLI.CHILD_PROCESS_FAILED", + severity: "error", + summary: "The delegated process exited with code 3.", + nextActions: [], + meta: { exitCode: 3, signal: null }, + }, + diagnostics: [], + nextActions: [], + }, + }, + ]); + }); +}); + describe("unknown terminations are never success", () => { test("an adapter that cannot say how the child ended settles 1", async () => { const cli = createTestCli({ diff --git a/packages/cli/package.json b/packages/cli/package.json index 20c240fe..4881c190 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@manypkg/tools": "^2.1.2", - "@prisma/cli-engine": "workspace:0.4.0", + "@prisma/cli-engine": "workspace:0.4.1", "@prisma/composer-cli": "0.20.0", "@prisma/compute-sdk": "0.42.0", "@prisma/management-api-sdk": "1.69.0", diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 2f69d6a1..1fd64508 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -50,7 +50,7 @@ }, "dependencies": { "@manypkg/tools": "^2.1.2", - "@prisma/cli-engine": "workspace:0.4.0", + "@prisma/cli-engine": "workspace:0.4.1", "@prisma/composer-cli": "0.20.0", "@prisma/compute-sdk": "0.42.0", "@prisma/management-api-sdk": "1.69.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8aadcbc8..de8474fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.4.0 + specifier: workspace:0.4.1 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.20.0 @@ -207,7 +207,7 @@ importers: specifier: ^2.1.2 version: 2.1.2 '@prisma/cli-engine': - specifier: workspace:0.4.0 + specifier: workspace:0.4.1 version: link:../cli-engine '@prisma/composer-cli': specifier: 0.20.0 From c10e3334f1b03280ac9b894b9b902cc1180e77bb Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:30:25 +0530 Subject: [PATCH 2/2] chore(engine): trim the child-status error change to the minimum Shortens the added doc comments in spawn.ts and settlement.ts, restores the neighbouring comments this branch reworded, folds the docs into one bullet and one sentence, and cuts the new tests to three sharing one setup. No behaviour change. Co-Authored-By: Claude Fable 5.1 --- docs/product/output-conventions.md | 25 +- docs/reference/error-reference.md | 2 +- .../cli-engine/src/execution/settlement.ts | 13 +- packages/cli-engine/src/spawn.ts | 23 +- packages/cli-engine/tests/spawn.test.ts | 261 ++---------------- 5 files changed, 39 insertions(+), 285 deletions(-) diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index d678ae2c..2b5168bf 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -42,25 +42,12 @@ command: - a non-zero child status is preserved as the process exit code and is represented by `CLI.CHILD_PROCESS_FAILED`, with `exitCode` and `signal` in `error.meta` -- a command that knows why its child failed attaches its own structured error - to the settlement (`exitWithChildStatus({ error })`), and the JSON result - carries that error's code, summary, `why`, `where`, and `meta` in place of - `CLI.CHILD_PROCESS_FAILED`. The process exit code is still the child's, and - the engine still writes the child's `exitCode` and `signal` into - `error.meta` from its own record, so those two keys mean the same thing - under every code. The error's own next actions come first, followed by the - settlement's -- a child killed by a signal is always reported as - `CLI.CHILD_PROCESS_FAILED` with no next actions, whatever the command - attached: the user stopped the run, which is not the failure the command's - error describes -- human and markdown output never print the attached error. The child owned - the terminal and has already reported its failure; only the settlement's - next actions follow it - -This lets automation consume a command family's structured result, and branch -on the precise code the command assigned, without having to parse the -delegated tool's human output. +- a command may attach its own structured error (`exitWithChildStatus({ error })`); + for a child that exited non-zero the JSON result carries it in place of + `CLI.CHILD_PROCESS_FAILED`, keeping the child's exit code and those two `meta` keys + +This lets automation consume a command family's structured result without +having to parse the delegated tool's human output. ## TTY and Piped Behavior diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index a6ceadc5..a0ed5d89 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -106,7 +106,7 @@ A `ctx.prompt.browserWait` flow (the command opened a URL and polled for the use ### CLI.CHILD_PROCESS_FAILED -Emitted only as a json-mode error envelope when a command that handed the terminal to a child process (`exitWithChildStatus`) saw that child exit non-zero or die on a signal; the run's exit code is the child's own status verbatim, not the CLI's usual 2. A command that attaches its own structured error to that settlement replaces this code for a child that exited non-zero — the envelope carries the command's code, the exit code is still the child's, and `exitCode` and `signal` are still written into `error.meta` — so this code then appears only for a signal-killed child. Meta: `exitCode`, `signal`. +Emitted only as a json-mode error envelope when a command that handed the terminal to a child process (`exitWithChildStatus`) saw that child exit non-zero or die on a signal; the run's exit code is the child's own status verbatim, not the CLI's usual 2. A command that attaches its own error to that settlement replaces this code for a child that exited non-zero. Meta: `exitCode`, `signal`. ### CLI.COMMAND_MOVED diff --git a/packages/cli-engine/src/execution/settlement.ts b/packages/cli-engine/src/execution/settlement.ts index 9f6cbc8d..11dd8205 100644 --- a/packages/cli-engine/src/execution/settlement.ts +++ b/packages/cli-engine/src/execution/settlement.ts @@ -226,12 +226,6 @@ export function settleVerbatimExitCode( * A signal-killed child overrules whatever the handler asked for. The * user stopped the run: it settles 128 + the signal number, with no * envelope and no next actions, because there is nothing to reproduce. - * - * The settlement may carry the command's own structured error. It - * changes what the json envelope says about a failed child — the - * command's code in place of CLI.CHILD_PROCESS_FAILED — and nothing - * else: not the exit code, and not human or markdown output, where the - * child owned the terminal and has already reported its failure. */ export function settleChildStatus( invocation: Invocation, @@ -300,9 +294,7 @@ function settleStructuredChildStatus( return; } const status = { exitCode: child.exitCode, signal: child.signal }; - // The command's own error names the failure only when the child - // failed by itself. A signal-killed child is the user stopping the - // run, which is not the failure that error describes. + // A signal-killed child drops the command's error with its next actions. const attached = child.signal === null ? settlement.error : undefined; if (attached !== undefined) { const error = diagnosticOf(attached); @@ -310,8 +302,7 @@ function settleStructuredChildStatus( emitErrored(invocation, { ok: false, commandId: invocation.state.commandId, - // The status is spread last: it is the engine's record of the - // child, and a handler's meta cannot restate it. + // The engine's record of the child wins over the handler's meta. error: { ...error, nextActions: actions, diff --git a/packages/cli-engine/src/spawn.ts b/packages/cli-engine/src/spawn.ts index 945ddfaa..7ff3f733 100644 --- a/packages/cli-engine/src/spawn.ts +++ b/packages/cli-engine/src/spawn.ts @@ -71,8 +71,7 @@ export const CHILD_STATUS: unique symbol = Symbol.for( * command that declares maySpawn. It carries no exit code, because the * code is not the handler's to state: the engine reads it off its own * record of the child. `nextActions` render to stderr before the - * process exits with the child's code. `error` is the command's own - * account of why the child failed, and reaches only the json envelope. + * process exits with the child's code. */ export interface ChildStatusSettlement { readonly [CHILD_STATUS]: true; @@ -86,19 +85,9 @@ export interface ExitWithChildStatusOptions { * a signal-killed child drops these entirely: the user stopped the * run, so there is nothing to reproduce. */ readonly nextActions?: readonly NextAction[]; - /** The structured error the command built for this failure, when it - * knows a more precise one than "the child failed". It names the - * json envelope in place of CLI.CHILD_PROCESS_FAILED: its code, - * summary, why, where and meta are the envelope's, its own next - * actions lead the list ahead of `nextActions`, and its accompanying - * diagnostics are reported alongside it. The engine still writes the - * child's `exitCode` and `signal` into `error.meta` from its own - * record, over any keys of those names, and the process still exits - * with the child's code — an error attached here never settles 2. - * Human and markdown output do not print it: the child owned the - * terminal and has already said what went wrong. A child that exited - * 0 ignores it, and so does a signal-killed one: the user stopped - * the run, which is not the failure this error describes. */ + /** The command's own structured error for a failed child. It replaces + * CLI.CHILD_PROCESS_FAILED in the json envelope only; the exit code + * stays the child's. Ignored when the child exited 0 or was signalled. */ readonly error?: CliStructuredError; } @@ -140,8 +129,8 @@ export function childExitCode(child: ChildResult): number { * construction error at settlement. * * A signal-killed child overrules everything the caller asked for: it - * settles 128 + the signal number with no `nextActions` and no `error`, - * because the user stopped the run and there is nothing to reproduce. + * settles 128 + the signal number with no `nextActions`, because the + * user stopped the run and there is nothing to reproduce. */ export function exitWithChildStatus( options?: ExitWithChildStatusOptions, diff --git a/packages/cli-engine/tests/spawn.test.ts b/packages/cli-engine/tests/spawn.test.ts index 87da5a9a..61388048 100644 --- a/packages/cli-engine/tests/spawn.test.ts +++ b/packages/cli-engine/tests/spawn.test.ts @@ -1424,43 +1424,6 @@ describe("next actions on a child-status settlement", () => { }); describe("a structured error on a child-status settlement", () => { - /** Composer's shape: the operation knows why the converge failed and - * builds the error for it, and the run must still exit with the - * child's own status. */ - function engineFailed(): CliStructuredError { - return new CliStructuredError( - "DEPLOY.ENGINE_FAILED", - "The deploy engine failed.", - { - why: "alchemy exited before the stack converged.", - where: { path: "/app/.prisma/stack.ts" }, - meta: { - diagnostics: { - stackFilePath: "/app/.prisma/stack.ts", - reproduceCommand: "alchemy deploy ./entry.ts", - }, - // Not the handler's to state: the engine's record wins. - exitCode: 99, - }, - nextActions: [{ kind: "user-choice", label: "Fix the stack file." }], - diagnostics: [ - { - code: "DEPLOY.RESOURCE_FAILED", - severity: "warn", - summary: "The database did not converge.", - nextActions: [], - }, - ], - }, - ); - } - - const reproduce = { - kind: "run-command", - label: "Reproduce the failed converge", - command: "alchemy deploy ./entry.ts", - } as const; - const deploy = defineCommand({ help: { summary: "A converge that knows why its child failed" }, maySpawn: true, @@ -1468,240 +1431,64 @@ describe("a structured error on a child-status settlement", () => { await ctx.spawn({ command: "alchemy" }); return ok( exitWithChildStatus({ - nextActions: [reproduce], - error: engineFailed(), + error: new CliStructuredError("DEPLOY.ENGINE_FAILED", "Failed.", { + meta: { stackFilePath: "/app/stack.ts", exitCode: 99 }, + }), }), ); }, }); - test("json names the command's error and still exits with the child's code", async () => { + async function settle(child: { + readonly exitCode: number | null; + readonly signal: string | null; + }) { const cli = createTestCli({ commands: { deploy }, now: CLOCK, - spawnScript: () => ({ exitCode: 3, signal: null }), + spawnScript: () => child, }); + return cli.run(["deploy", "--json"]); + } - const result = await cli.run(["deploy", "--json"]); + test("json carries the command's error and exits with the child's code", async () => { + const result = await settle({ exitCode: 3, signal: null }); expect(result.exitCode).toBe(3); - expect(result.json).toHaveLength(1); - const nextActions = [ - { kind: "user-choice", label: "Fix the stack file." }, - reproduce, - ]; - expect(result.json[0]).toEqual({ - kind: "result", - commandId: "deploy", - timestamp: NOW.toISOString(), + expect(result.json.at(-1)).toMatchObject({ envelope: { ok: false, - commandId: "deploy", error: { code: "DEPLOY.ENGINE_FAILED", - severity: "error", - summary: "The deploy engine failed.", - why: "alchemy exited before the stack converged.", - where: { path: "/app/.prisma/stack.ts" }, - meta: { - diagnostics: { - stackFilePath: "/app/.prisma/stack.ts", - reproduceCommand: "alchemy deploy ./entry.ts", - }, - exitCode: 3, - signal: null, - }, - nextActions, + summary: "Failed.", + meta: { stackFilePath: "/app/stack.ts", exitCode: 3, signal: null }, }, - diagnostics: [ - { - code: "DEPLOY.RESOURCE_FAILED", - severity: "warn", - summary: "The database did not converge.", - nextActions: [], - }, - ], - nextActions, }, }); }); - test("an error without meta still carries the child's status", async () => { - const bare = defineCommand({ - help: { summary: "A converge whose error has no meta of its own" }, - maySpawn: true, - handler: async (_args, ctx) => { - await ctx.spawn({ command: "alchemy" }); - return ok( - exitWithChildStatus({ - error: new CliStructuredError("DEPLOY.ENGINE_FAILED", "Failed."), - }), - ); - }, - }); - const cli = createTestCli({ - commands: { bare }, - now: CLOCK, - spawnScript: () => ({ exitCode: 1, signal: null }), - }); - - const result = await cli.run(["bare", "--json"]); + test("a signal-killed child is still CLI.CHILD_PROCESS_FAILED", async () => { + const result = await settle({ exitCode: null, signal: "SIGINT" }); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(130); expect(result.json.at(-1)).toMatchObject({ envelope: { ok: false, error: { - code: "DEPLOY.ENGINE_FAILED", - meta: { exitCode: 1, signal: null }, - nextActions: [], + code: "CLI.CHILD_PROCESS_FAILED", + meta: { exitCode: null, signal: "SIGINT" }, }, - diagnostics: [], - nextActions: [], }, }); }); - test("the settlement summary carries the child's code, not the error's 2", async () => { - const summaries: number[] = []; - const cli = createTestCli({ - commands: { deploy }, - now: CLOCK, - spawnScript: () => ({ exitCode: 7, signal: null }), - }); - - await cli.run(["deploy", "--json"], { - onSettled: (summary) => summaries.push(summary.exitCode), - }); - - expect(summaries).toEqual([7]); - }); - - test("a child that exited 0 settles ok and ignores the error", async () => { - const cli = createTestCli({ - commands: { deploy }, - now: CLOCK, - spawnScript: () => ({ exitCode: 0, signal: null }), - }); - - const result = await cli.run(["deploy", "--json"]); + test("a child that exited 0 settles ok", async () => { + const result = await settle({ exitCode: 0, signal: null }); expect(result.exitCode).toBe(0); - expect(result.json).toEqual([ - { - kind: "result", - commandId: "deploy", - timestamp: NOW.toISOString(), - envelope: { - ok: true, - commandId: "deploy", - result: null, - exitCode: 0, - diagnostics: [], - nextActions: [reproduce], - }, - }, - ]); - }); - - test("a signal-killed child drops the error with the next actions", async () => { - const cli = createTestCli({ - commands: { deploy }, - now: CLOCK, - spawnScript: () => ({ exitCode: null, signal: "SIGINT" }), - }); - - const result = await cli.run(["deploy", "--json"]); - - // The user stopped the converge: that is not the failure the - // command's error describes, so the engine's own account stands. - expect(result.exitCode).toBe(130); - expect(result.json).toEqual([ - { - kind: "result", - commandId: "deploy", - timestamp: NOW.toISOString(), - envelope: { - ok: false, - commandId: "deploy", - error: { - code: "CLI.CHILD_PROCESS_FAILED", - severity: "error", - summary: "The delegated process was terminated by SIGINT.", - nextActions: [], - meta: { exitCode: null, signal: "SIGINT" }, - }, - diagnostics: [], - nextActions: [], - }, - }, - ]); - }); - - test("human output does not print it: the child already reported the failure", async () => { - const cli = createTestCli({ - commands: { deploy }, - now: CLOCK, - spawnScript: () => ({ exitCode: 3, signal: null }), - }); - - const result = await cli.run(["deploy"], { isTty: { stdout: true } }); - - expect(result.exitCode).toBe(3); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "→ Reproduce the failed converge: alchemy deploy ./entry.ts\n", - ); - }); - - test("markdown output does not print it either", async () => { - const cli = createTestCli({ - commands: { deploy }, - now: CLOCK, - spawnScript: () => ({ exitCode: 3, signal: null }), - }); - - const result = await cli.run(["deploy", "--format", "markdown"], { - isTty: { stdout: true }, - }); - - expect(result.exitCode).toBe(3); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe( - "- Reproduce the failed converge: `alchemy deploy ./entry.ts`\n", - ); - }); - - test("with no error attached the generic envelope is unchanged", async () => { - const cli = createTestCli({ - commands: { converge }, - now: CLOCK, - spawnScript: () => ({ exitCode: 3, signal: null }), + expect(result.json.at(-1)).toMatchObject({ + envelope: { ok: true, result: null, exitCode: 0 }, }); - - const result = await cli.run(["converge", "--json"]); - - expect(result.exitCode).toBe(3); - expect(result.json).toEqual([ - { - kind: "result", - commandId: "converge", - timestamp: NOW.toISOString(), - envelope: { - ok: false, - commandId: "converge", - error: { - code: "CLI.CHILD_PROCESS_FAILED", - severity: "error", - summary: "The delegated process exited with code 3.", - nextActions: [], - meta: { exitCode: 3, signal: null }, - }, - diagnostics: [], - nextActions: [], - }, - }, - ]); }); });