diff --git a/README.md b/README.md index e70790b..9fb2b29 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,23 @@ fully verify. Until First Draft has a read or reconciliation endpoint, an accept verified may require manual recovery. If a verified response cannot replace local state, preserve the printed recovery state; an adjacent `.tmp` file may contain the same private recovery copy. +Every handled `plan push` failure writes exactly one JSON object to standard error. Agents should branch on its +stable `error` value, not on the human-readable `detail`: + +| `error` | Exit | Meaning | +| ------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `invalid_arguments` | 2 | The command syntax is invalid; no request was made. | +| `invalid_configuration` | 2 | The configured API origin is invalid or conflicts with pinned local state; no request was made. | +| `local_input_unreadable` | 1 | The local Plan or state could not be read; no request was made. | +| `request_outcome_unknown` | 1 | A sent request or its response could not be verified. Stop and reconcile before pushing again. The object includes `status` when one was received. | +| `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. | +| `local_state_not_saved` | 1 | The server accepted the Plan, but local state replacement failed. This is the only error that includes private `recovery_state`. | + +Failure output never includes command arguments, local Plan bytes, raw network errors, or unvalidated response +bodies. Optional fields inside a validated diagnostic are omitted when absent or when the CLI cannot validate their +complete shape. Exit status remains a broad shell-level class; the `error` value is the machine-readable recovery +contract. + ## Trust model - The published CLI will run the reviewed JavaScript source directly, without generated or bundled code. diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 5d2d548..fb6634b 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -16,6 +16,13 @@ const npmCli = requiredEnvironmentVariable("npm_execpath"); const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")); const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "firstdraft-cli-")); const installationDirectory = path.join(temporaryDirectory, "installation"); +const packedExecutable = path.join( + installationDirectory, + "node_modules", + "firstdraft", + "bin", + "firstdraft.js", +); try { const packResult = runNpm([ @@ -67,6 +74,25 @@ try { /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\n$/, ); assert.equal(subjectId.stderr, ""); + + const invalidPush = spawnPackedCli( + ["plan", "push", "--canary-secret-option"], + installationDirectory, + ); + assert.equal(invalidPush.status, 2); + assert.deepEqual(JSON.parse(invalidPush.stderr), { + error: "invalid_arguments", + detail: "Invalid arguments. Run 'firstdraft plan push --help' for usage.", + }); + assert.doesNotMatch(invalidPush.stderr, /canary-secret/); + + const localPush = spawnPackedCli(["plan", "push"], installationDirectory); + assert.equal(localPush.status, 1); + assert.deepEqual(JSON.parse(localPush.stderr), { + error: "local_input_unreadable", + detail: + "Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery.", + }); } finally { rmSync(temporaryDirectory, { recursive: true, force: true }); } @@ -76,10 +102,7 @@ try { * @param {string} [cwd] */ function runNpm(arguments_, cwd = process.cwd()) { - const result = spawnSync(process.execPath, [npmCli, ...arguments_], { - cwd, - encoding: "utf8", - }); + const result = spawnNpm(arguments_, cwd); assert.equal( result.status, @@ -90,6 +113,28 @@ function runNpm(arguments_, cwd = process.cwd()) { return result; } +/** + * @param {string[]} arguments_ + * @param {string} [cwd] + */ +function spawnNpm(arguments_, cwd = process.cwd()) { + return spawnSync(process.execPath, [npmCli, ...arguments_], { + cwd, + encoding: "utf8", + }); +} + +/** + * @param {string[]} arguments_ + * @param {string} [cwd] + */ +function spawnPackedCli(arguments_, cwd = process.cwd()) { + return spawnSync(process.execPath, [packedExecutable, ...arguments_], { + cwd, + encoding: "utf8", + }); +} + /** @param {string} name */ function requiredEnvironmentVariable(name) { const value = process.env[name]; diff --git a/src/cli.js b/src/cli.js index dc74945..76bc4b7 100644 --- a/src/cli.js +++ b/src/cli.js @@ -92,16 +92,15 @@ const PLAN_INIT_USAGE_ERROR = const PLAN_INIT_ERROR = "Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.\n"; const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n"; -const PLAN_PUSH_USAGE_ERROR = - "Invalid arguments.\nRun 'firstdraft plan push --help' for usage.\n"; -const PLAN_PUSH_CONFIGURATION_ERROR = - "Invalid First Draft API configuration.\nRun 'firstdraft plan push --help' for usage.\n"; -const PLAN_PUSH_LOCAL_ERROR = - "Could not read the local First Draft Plan or state. No network request was made.\n"; -const PLAN_PUSH_NETWORK_ERROR = - "Could not complete the First Draft request. The Plan may have been accepted; local state was not changed.\n"; -const PLAN_PUSH_PROTOCOL_ERROR = - "First Draft returned an unexpected response. The Plan may have been accepted; local state was not changed.\n"; +const PLAN_PUSH_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft plan push --help' for usage."; +const PLAN_PUSH_INVALID_CONFIGURATION_DETAIL = + "Invalid First Draft API configuration. Run 'firstdraft plan push --help' for usage."; +const PLAN_PUSH_LOCAL_INPUT_UNREADABLE_DETAIL = + "Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery."; +const PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL = + "The Plan may have been accepted, but the response could not be verified. Stop and reconcile before pushing again; local state was not changed."; +const PLAN_PUSH_SERVER_REJECTED_DETAIL = "First Draft rejected the Plan."; const PLAN_SUBJECT_ID_USAGE_ERROR = "Invalid arguments.\nRun 'firstdraft plan subject-id --help' for usage.\n"; @@ -357,7 +356,10 @@ async function runPlanPush({ ); if (!parsed || repeatedValueOption(parsed.tokens)) { - stderr.write(PLAN_PUSH_USAGE_ERROR); + writeJson(stderr, { + error: "invalid_arguments", + detail: PLAN_PUSH_INVALID_ARGUMENTS_DETAIL, + }); return 2; } @@ -378,22 +380,30 @@ async function runPlanPush({ }); } catch (error) { if (error instanceof PlanPushConfigurationError) { - stderr.write(PLAN_PUSH_CONFIGURATION_ERROR); + writeJson(stderr, { + error: "invalid_configuration", + detail: PLAN_PUSH_INVALID_CONFIGURATION_DETAIL, + }); return 2; } if (error instanceof PlanPushLocalError) { - stderr.write(PLAN_PUSH_LOCAL_ERROR); - return 1; - } - - if (error instanceof PlanPushNetworkError) { - stderr.write(PLAN_PUSH_NETWORK_ERROR); + writeJson(stderr, { + error: "local_input_unreadable", + detail: PLAN_PUSH_LOCAL_INPUT_UNREADABLE_DETAIL, + }); return 1; } - if (error instanceof PlanPushProtocolError) { - stderr.write(PLAN_PUSH_PROTOCOL_ERROR); + if ( + error instanceof PlanPushNetworkError || + error instanceof PlanPushProtocolError + ) { + writeJson(stderr, { + error: "request_outcome_unknown", + detail: PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL, + ...(typeof error.status === "number" ? { status: error.status } : {}), + }); return 1; } @@ -411,11 +421,22 @@ async function runPlanPush({ } if (!("etag" in result)) { - if (result.body === null) { - stderr.write(`First Draft rejected the Plan (HTTP ${result.status}).\n`); - } else { - writeJson(stderr, result.body); + if (result.responseKind === null) { + writeJson(stderr, { + error: "request_outcome_unknown", + detail: PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL, + status: result.status, + }); + return 1; } + + const response = safeRejectedResponse(result.responseKind, result.body); + writeJson(stderr, { + error: "server_rejected", + detail: PLAN_PUSH_SERVER_REJECTED_DETAIL, + status: result.status, + ...(response ? { response } : {}), + }); return 1; } @@ -578,3 +599,120 @@ function isParseArgsError(error) { function writeJson(writer, value) { writer.write(`${JSON.stringify(value, null, 2)}\n`); } + +/** + * @param {"diagnostics" | "problem" | null} responseKind + * @param {Record | null} body + */ +function safeRejectedResponse(responseKind, body) { + if ( + responseKind === "diagnostics" && + body && + Array.isArray(body.diagnostics) + ) { + return { + source_sha256: body.source_sha256, + diagnostics: body.diagnostics.map(safeDiagnostic), + }; + } + + if (responseKind === "problem" && body) { + return { + ...(body.type === "about:blank" ? { type: body.type } : {}), + title: body.title, + status: body.status, + code: body.code, + detail: body.detail, + }; + } + + return null; +} + +/** @param {unknown} value */ +function safeDiagnostic(value) { + if (!isRecord(value)) return {}; + + const location = safeSourceLocation(value.location); + const subject = safeDiagnosticSubject(value.subject); + const relatedLocations = safeSourceLocations(value.related_locations); + const suggestions = safeSuggestions(value.suggestions); + + return { + ...(typeof value.code === "string" ? { code: value.code } : {}), + ...(value.severity === "error" || value.severity === "warning" + ? { severity: value.severity } + : {}), + ...(typeof value.message === "string" ? { message: value.message } : {}), + ...(location ? { location } : {}), + ...(value.subject === null ? { subject: null } : {}), + ...(subject ? { subject } : {}), + ...(relatedLocations ? { related_locations: relatedLocations } : {}), + ...(suggestions ? { suggestions } : {}), + }; +} + +/** @param {unknown} value */ +function safeSourceLocations(value) { + if (!Array.isArray(value)) return null; + + const locations = value.map(safeSourceLocation); + return locations.every(Boolean) ? locations : null; +} + +/** @param {unknown} value */ +function safeSuggestions(value) { + if ( + !Array.isArray(value) || + !value.every((suggestion) => typeof suggestion === "string") + ) { + return null; + } + + return value; +} + +/** @param {unknown} value */ +function safeSourceLocation(value) { + if (!isRecord(value)) return null; + + if (typeof value.source_pointer === "string") { + return { source_pointer: value.source_pointer }; + } + + if ( + Number.isSafeInteger(value.line) && + Number(value.line) > 0 && + Number.isSafeInteger(value.column) && + Number(value.column) > 0 + ) { + return { line: value.line, column: value.column }; + } + + return null; +} + +/** @param {unknown} value */ +function safeDiagnosticSubject(value) { + if ( + !isRecord(value) || + typeof value.kind !== "string" || + typeof value.readable_path !== "string" || + (value.subject_uuid !== undefined && typeof value.subject_uuid !== "string") + ) { + return null; + } + + return { + kind: value.kind, + readable_path: value.readable_path, + ...(typeof value.subject_uuid === "string" + ? { subject_uuid: value.subject_uuid } + : {}), + }; +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/commands/plan-push.js b/src/commands/plan-push.js index c02ffee..073394e 100644 --- a/src/commands/plan-push.js +++ b/src/commands/plan-push.js @@ -36,8 +36,25 @@ const DEFAULT_FILE_SYSTEM = { export class PlanPushConfigurationError extends Error {} export class PlanPushLocalError extends Error {} -export class PlanPushNetworkError extends Error {} -export class PlanPushProtocolError extends Error {} + +export class PlanPushNetworkError extends Error { + /** + * @param {string} message + * @param {{cause?: Error, status?: number}} [options] + */ + constructor(message, options = {}) { + super(message, { cause: options.cause }); + this.status = options.status; + } +} + +export class PlanPushProtocolError extends Error { + /** @param {string} message @param {{status: number}} options */ + constructor(message, options) { + super(message); + this.status = options.status; + } +} export class PlanPushStateWriteError extends Error { /** @@ -73,7 +90,8 @@ export class PlanPushStateWriteError extends Error { /** * @typedef {object} RejectedPushResult * @property {number} status - * @property {unknown} body + * @property {"diagnostics" | "problem" | null} responseKind + * @property {Record | null} body */ /** @@ -125,6 +143,7 @@ export async function pushPlan({ ) { throw new PlanPushProtocolError( "First Draft returned invalid diagnostics.", + { status: response.status }, ); } @@ -132,13 +151,18 @@ export async function pushPlan({ if (response.ok) { throw new PlanPushProtocolError( "First Draft returned an unexpected success status.", + { status: response.status }, ); } - return { - status: response.status, - body: - response.status === 422 || isProblemBody(response, body) ? body : null, - }; + if (response.status === 422) { + return { status: response.status, responseKind: "diagnostics", body }; + } + + if (isProblemBody(response, body)) { + return { status: response.status, responseKind: "problem", body }; + } + + return { status: response.status, responseKind: null, body: null }; } const etag = response.headers.get("etag"); @@ -151,6 +175,7 @@ export async function pushPlan({ ) { throw new PlanPushProtocolError( "First Draft returned an invalid success response.", + { status: response.status }, ); } @@ -385,7 +410,9 @@ async function readResponseBytes(response) { if (response.body !== null) { await response.body.cancel().catch(() => undefined); } - throw new PlanPushProtocolError("The First Draft response is too large."); + throw new PlanPushProtocolError("The First Draft response is too large.", { + status: response.status, + }); } if (response.body === null) return Buffer.alloc(0); @@ -404,6 +431,7 @@ async function readResponseBytes(response) { await reader.cancel().catch(() => undefined); throw new PlanPushProtocolError( "The First Draft response is too large.", + { status: response.status }, ); } chunks.push(Buffer.from(value)); @@ -414,6 +442,7 @@ async function readResponseBytes(response) { throw new PlanPushNetworkError("The First Draft response failed.", { cause: error, + status: response.status, }); } diff --git a/test/plan-push.test.js b/test/plan-push.test.js index beac60c..857a551 100644 --- a/test/plan-push.test.js +++ b/test/plan-push.test.js @@ -46,14 +46,21 @@ Environment: The first successful push saves its API origin in .firstdraft/state.json. Later pushes reject a different origin. `; -const PLAN_PUSH_CONFIGURATION_ERROR = - "Invalid First Draft API configuration.\nRun 'firstdraft plan push --help' for usage.\n"; -const PLAN_PUSH_LOCAL_ERROR = - "Could not read the local First Draft Plan or state. No network request was made.\n"; -const PLAN_PUSH_NETWORK_ERROR = - "Could not complete the First Draft request. The Plan may have been accepted; local state was not changed.\n"; -const PLAN_PUSH_PROTOCOL_ERROR = - "First Draft returned an unexpected response. The Plan may have been accepted; local state was not changed.\n"; +const PLAN_PUSH_INVALID_ARGUMENTS_ERROR = jsonOutput({ + error: "invalid_arguments", + detail: "Invalid arguments. Run 'firstdraft plan push --help' for usage.", +}); +const PLAN_PUSH_CONFIGURATION_ERROR = jsonOutput({ + error: "invalid_configuration", + detail: + "Invalid First Draft API configuration. Run 'firstdraft plan push --help' for usage.", +}); +const PLAN_PUSH_LOCAL_ERROR = jsonOutput({ + error: "local_input_unreadable", + detail: + "Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery.", +}); +const PLAN_PUSH_OUTCOME_UNKNOWN_ERROR = requestOutcomeUnknownOutput(); test("plan push help has no local or network prerequisites", async () => { const inaccessible = () => { @@ -275,6 +282,7 @@ test("usage errors happen before local or network access", async () => { assert.equal(result.status, 2); assert.equal(result.stdout, ""); + assert.equal(result.stderr, PLAN_PUSH_INVALID_ARGUMENTS_ERROR); assert.doesNotMatch(result.stderr, /canary-secret/); } }); @@ -335,12 +343,107 @@ test("HTTP diagnostics and problems leave local state byte-for-byte unchanged", assert.deepEqual(result, { status: 1, stdout: "", - stderr: `${JSON.stringify(responseBody, null, 2)}\n`, + stderr: serverRejectedOutput(status, responseBody), }); assert.deepEqual(stateSource(cwd), before); } }); +test("server rejection envelopes expose only validated response fields", async (context) => { + const cwd = await initializedDirectory(context); + const sourceSha256 = sha256(planSource(cwd)); + const diagnostic = { + code: "foundation_plan.example", + severity: "error", + message: "The Plan needs a supported value.", + location: { + source_pointer: "/application/entities/0", + canary: "canary-secret-location", + }, + subject: { + kind: "entity", + readable_path: "movie", + subject_uuid: PROJECT_ID, + canary: "canary-secret-subject", + }, + related_locations: [ + { line: 1, column: 2, canary: "canary-secret-related" }, + ], + suggestions: ["Choose a supported value.", 7], + canary: "canary-secret-diagnostic", + }; + const body = { + source_sha256: sourceSha256, + diagnostics: [diagnostic], + canary: "canary-secret-response", + }; + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(jsonResponse(body, 422), []), + }); + + assert.deepEqual(JSON.parse(result.stderr), { + error: "server_rejected", + detail: "First Draft rejected the Plan.", + status: 422, + response: { + source_sha256: sourceSha256, + diagnostics: [ + { + code: diagnostic.code, + severity: diagnostic.severity, + message: diagnostic.message, + location: { source_pointer: "/application/entities/0" }, + subject: { + kind: "entity", + readable_path: "movie", + subject_uuid: PROJECT_ID, + }, + related_locations: [{ line: 1, column: 2 }], + }, + ], + }, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + + const problem = { + type: "about:blank", + title: "Precondition Failed", + status: 412, + code: "precondition_failed", + detail: "The Foundation Plan has changed.", + source_sha256: "canary-secret-unverified-digest", + diagnostics: [ + { + code: "canary-secret-code", + severity: "error", + message: "canary-secret-message", + }, + ], + canary: "canary-secret-problem", + }; + const problemResult = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(problemResponse(problem, 412), []), + }); + + assert.deepEqual(JSON.parse(problemResult.stderr), { + error: "server_rejected", + detail: "First Draft rejected the Plan.", + status: 412, + response: { + type: "about:blank", + title: problem.title, + status: 412, + code: problem.code, + detail: problem.detail, + }, + }); + assert.doesNotMatch(problemResult.stderr, /canary-secret/); +}); + test("a stale update preserves the prior ETag and exact local state", async (context) => { const cwd = await initializedDirectory(context); await successfulInitialPush(cwd); @@ -369,7 +472,7 @@ test("a stale update preserves the prior ETag and exact local state", async (con assert.deepEqual(result, { status: 1, stdout: "", - stderr: `${JSON.stringify(problem, null, 2)}\n`, + stderr: serverRejectedOutput(412, problem), }); assert.deepEqual(stateSource(cwd), before); assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); @@ -391,7 +494,7 @@ test("an update rejects a create status before changing local state", async (con assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(201), }); assert.deepEqual(stateSource(cwd), before); assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); @@ -429,7 +532,7 @@ test("422 diagnostics must identify the exact submitted bytes", async (context) assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(422), }); assert.deepEqual(stateSource(cwd), before); } @@ -456,12 +559,12 @@ test("422 diagnostics must identify the exact submitted bytes", async (context) assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(422), }); assert.deepEqual(stateSource(cwd), before); }); -test("non-JSON HTTP failures are reported without echoing their body", async (context) => { +test("unverified HTTP failures have an ambiguous outcome without echoing their body", async (context) => { const cwd = await initializedDirectory(context); const before = stateSource(cwd); const result = await invoke(["plan", "push"], { @@ -476,7 +579,7 @@ test("non-JSON HTTP failures are reported without echoing their body", async (co assert.deepEqual(result, { status: 1, stdout: "", - stderr: "First Draft rejected the Plan (HTTP 503).\n", + stderr: requestOutcomeUnknownOutput(503), }); assert.doesNotMatch(result.stderr, /canary-secret/); assert.deepEqual(stateSource(cwd), before); @@ -496,7 +599,33 @@ test("transport failures disclose the ambiguous outcome without leaking errors", assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_NETWORK_ERROR, + stderr: PLAN_PUSH_OUTCOME_UNKNOWN_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); +}); + +test("response stream failures retain the received status without leaking errors", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const response = new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error("canary-secret-stream-detail")); + }, + }), + { status: 201 }, + ); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: requestOutcomeUnknownOutput(201), }); assert.doesNotMatch(result.stderr, /canary-secret/); assert.deepEqual(stateSource(cwd), before); @@ -541,16 +670,17 @@ test("success responses are bound to the request before state changes", async (c for (const makeResponse of sourceMutators) { const cwd = await initializedDirectory(context); const before = stateSource(cwd); + const response = makeResponse(planSource(cwd)); const result = await invoke(["plan", "push"], { cwd, apiUrl: API_URL, - fetchFunction: recordingFetch(makeResponse(planSource(cwd)), []), + fetchFunction: recordingFetch(response, []), }); assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(response.status), }); assert.doesNotMatch(result.stderr, /canary-secret/); assert.deepEqual(stateSource(cwd), before); @@ -755,7 +885,12 @@ test("the Plan is never parsed or reserialized locally", async (context) => { assert(Buffer.isBuffer(call.init?.body)); assert.deepEqual(call.init.body, invalidUtf8); assert.equal(result.status, 1); - assert.deepEqual(JSON.parse(result.stderr), diagnostic); + assert.deepEqual(JSON.parse(result.stderr), { + error: "server_rejected", + detail: "First Draft rejected the Plan.", + status: 422, + response: diagnostic, + }); }); test("failed atomic state replacements report the accepted ETag", async (context) => { @@ -876,7 +1011,7 @@ test("oversized success responses stop before local state changes", async (conte assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(201), }); assert.deepEqual(stateSource(cwd), before); assert.equal(cancelled, true); @@ -911,7 +1046,7 @@ test("streamed oversized responses are cancelled at the byte cap", async (contex assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(201), }); assert.deepEqual(stateSource(cwd), before); assert.equal(cancelled, true); @@ -1131,6 +1266,31 @@ function stateJson(state) { return Buffer.from(`${JSON.stringify(state, null, 2)}\n`); } +/** @param {unknown} value */ +function jsonOutput(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +/** @param {number} status @param {unknown} [response] */ +function serverRejectedOutput(status, response) { + return jsonOutput({ + error: "server_rejected", + detail: "First Draft rejected the Plan.", + status, + ...(response === undefined ? {} : { response }), + }); +} + +/** @param {number} [status] */ +function requestOutcomeUnknownOutput(status) { + return jsonOutput({ + error: "request_outcome_unknown", + detail: + "The Plan may have been accepted, but the response could not be verified. Stop and reconcile before pushing again; local state was not changed.", + ...(status === undefined ? {} : { status }), + }); +} + /** @param {import("node:test").TestContext} context */ function temporaryDirectory(context) { const directory = mkdtempSync(path.join(tmpdir(), "firstdraft-plan-push-"));