From 80fb2ea83174f2f8eead20d02086234a31b90fa6 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Sun, 4 Oct 2026 18:54:16 -0500 Subject: [PATCH] Add compilation cancel for stuck Compilations A Project allows one active Compilation. When one never finishes, for example because its Render One-Off died, every later plan push and plan compile fails with 409 compilation_active. The Service has a cancel route, and its compilation_active detail tells the caller to cancel, but the CLI could not call it. Recovery needed someone calling the API with the user's token, or a worker shell. compilation cancel finds the Project and origin in local state, as compilation status does, sends one POST with no precondition, and prints the cancelled Compilation. The Service answers a repeated cancel with the same result and never changes a succeeded or failed Compilation, so an unconfirmed outcome is reported as compilation_cancel_unavailable, which is safe to repeat, rather than as request_outcome_unknown. A validated refusal keeps the Service problem under compilation_cancel_rejected, the way a rejected Compilation start does. The new command takes the version to 0.8.2. --- CHANGELOG.md | 20 ++ README.md | 2 +- docs/commands.md | 50 +++- docs/errors.md | 36 ++- package-lock.json | 4 +- package.json | 2 +- release/compatibility.json | 2 +- scripts/smoke-package.js | 37 +++ src/api-response.js | 4 + src/cli.js | 164 +++++++++++- src/commands/compilation.js | 100 +++++++ test/api-environments.test.js | 4 +- test/cli.test.js | 2 +- test/compilation-cancel.test.js | 451 ++++++++++++++++++++++++++++++++ 14 files changed, 859 insertions(+), 19 deletions(-) create mode 100644 test/compilation-cancel.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 721d179..a5dcf80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ carry no release status. The `v` tag and npm show whether a version is Entries start with the first version prepared after this file was added, and earlier versions have none. The repository's [tags](https://github.com/firstdraft/cli/tags) identify the source of each published version. +## 0.8.2 + +Adds `firstdraft compilation cancel `, so an agent can recover a Compilation that will not finish +without an operator calling the Service for it. + +- A Project allows one active Compilation. While a stuck one stays queued or running, `plan push` and `plan compile` + fail with First Draft's `409 compilation_active` problem, whose `detail` names that Compilation. Check it with + `compilation status`, cancel it, then rerun `plan compile`. +- The command makes one `POST` to the Service's cancel route for the local Project and prints the cancelled + Compilation in the same shape as `compilation status`. Repeating it prints the same result. First Draft refuses to + cancel a succeeded or failed Compilation and leaves it unchanged. +- New `error` values: `compilation_cancel_rejected`, which carries the Service problem in `response` + (`compilation_not_cancellable`, `compilation_not_found`, or `project_not_found`), and + `compilation_cancel_unavailable`, after which cancelling again is safe. A response that violates the Compilation + contract reports `invalid_compilation_status`. +- `firstdraft --help` describes the `compilation` group as "Inspect, download, and cancel Compilations". + +Existing callers need no change. The cancel route is part of the Service's API `0.7.x` contract, so the accepted +API-contract range is unchanged. + ## 0.8.1 Adds `firstdraft login` and `firstdraft logout`, so a token no longer has to be copied from `/api-tokens` into the diff --git a/README.md b/README.md index f585b45..53c4835 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ requires `--github`. - conditional whole-document push and conflict reporting; - analysis polling and complete GapSet output; - direct Compile-and-materialize and private publish orchestration; -- retained Compilation inspection and artifact download; +- retained Compilation inspection, cancellation, and artifact download; - terminal output, exit status, and recovery contracts; - the dependency-free npm package; and - package provenance and publication. diff --git a/docs/commands.md b/docs/commands.md index 333401e..4a171b9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,8 +5,8 @@ group's `--help` for concise executable syntax. See [Errors and recovery](errors The current `0.8.x` source line contains the auditable command shell, browser and device login, local Foundation Plan initialization, local application-key and UUID generation, conditional whole-document push, whole-graph analysis status polling, direct -Compile-and-materialize and private publish orchestration, and retained-Compilation inspection. CLI `0.8.x` -requires the service's `0.7.x` API contract. The +Compile-and-materialize and private publish orchestration, and retained-Compilation inspection and cancellation. +CLI `0.8.x` requires the service's `0.7.x` API contract. The [changelog](https://github.com/firstdraft/cli/blob/main/CHANGELOG.md) describes each version. ## Command map @@ -24,6 +24,7 @@ requires the service's `0.7.x` API contract. The | `firstdraft plan compile --github` | Yes | Push and analyze, then publish to private GitHub | | `firstdraft compilation status` | Yes | Inspect a retained Compilation by ID | | `firstdraft compilation download` | Yes | Verify and materialize a successful retained Compilation | +| `firstdraft compilation cancel` | Yes | Cancel a queued or running Compilation by ID | ## Select an environment and authenticate @@ -113,8 +114,8 @@ the existing Project's private state to redirect it. `FIRSTDRAFT_API_URL` remains available for an initial custom HTTPS origin or loopback HTTP development server, and it also selects the origin for `login` and `logout`, for example `FIRSTDRAFT_API_URL=http://127.0.0.1:3000 firstdraft login`. `--staging` together with a different URL is an error; the equivalent normalized staging URL is -allowed. Later pushes and compilation reject an override that differs from the pin. Read-only status and retained -download commands use the pin and ignore `FIRSTDRAFT_API_URL` unless checking its conflict with an explicit +allowed. Later pushes and compilation reject an override that differs from the pin. Status, retained download, and +cancel commands use the pin and ignore `FIRSTDRAFT_API_URL` unless checking its conflict with an explicit `--staging`. Every remote command selects credentials from its effective origin. First it uses the environment: the exact @@ -464,6 +465,38 @@ POSIX, directories use mode `0755` and files use artifact-declared `0644` or `07 contents, and digests without claiming POSIX mode bits. The declared and streamed artifact envelope is bounded at 128 MiB. +## Cancel a stuck Compilation + +A Project allows one active Compilation. While it is queued or running, First Draft refuses to replace the Plan or +start another Compilation with `409 compilation_active`, so `plan push` and `plan compile` fail. If that Compilation +will not finish, for example because its worker process died, cancel it from the Project's directory: + +```sh +firstdraft compilation cancel 01900000-0000-7000-8000-000000000001 +``` + +The `compilation_active` problem names the active Compilation's ID in its `response.detail`, and a `plan compile` +failure after a validated start carries it as `current.compilation.id`. Read the Compilation with +`compilation status` first. Cancelling one that is still making progress throws away its work, and the next +`plan compile` starts over. + +The command validates the UUID before network access and makes one `POST` to the origin pinned for the Project, with +that origin's credential. It sends no Plan bytes or precondition header and changes no local state. Success prints +the validated cancelled Compilation in the same shape as `compilation status`, with `compilation.status` set to +`cancelled`. First Draft clears the Project's active Compilation, so the next `plan push` or `plan compile` can +proceed. A worker that finishes later cannot attach an artifact to a cancelled Compilation; stopping that worker is +best effort. + +Cancel is idempotent for queued, running, and cancelled Compilations, so repeating it prints the same cancelled +Compilation. First Draft never changes a succeeded or failed Compilation: it answers +`409 compilation_not_cancellable`, which the CLI reports as `compilation_cancel_rejected`. Because a repeat is +safe, an outcome the CLI cannot confirm is `compilation_cancel_unavailable` rather than `request_outcome_unknown`. +See [stuck Compilation recovery](errors.md#stuck-compilation-recovery). + +If the Compilation was started by `plan compile --github`, Cancel also cancels the Project's Publication. The +Publication is a Project singleton in this release, so `plan compile --github` cannot start another one for that +Project. + ## Service endpoints The CLI calls these Service API routes. The `/v1` routes go to the origin pinned for the Project and send the @@ -479,6 +512,7 @@ timeout. Each request takes its method and path from one | `GET` | `/v1/projects/{project_id}/analysis` | Read the Head's current analysis | | `POST` | `/v1/projects/{project_id}/compilations` | Start a Compilation of the Head | | `GET` | `/v1/projects/{project_id}/compilations/{compilation_id}` | Read one retained Compilation | +| `POST` | `/v1/projects/{project_id}/compilations/{compilation_id}/cancel` | Cancel that Compilation | | `GET` | `/v1/projects/{project_id}/compilations/{compilation_id}/artifact` | Download that Compilation's artifact | | `PUT` | `/v1/projects/{project_id}/github-publication` | Start or rejoin the Publication | | `GET` | `/v1/projects/{project_id}/github-publication` | Poll or reconcile the Publication | @@ -489,12 +523,13 @@ timeout. Each request takes its method and path from one `plan push` sends the Plan `PUT`, and `plan status` reads the analysis. `plan compile` does both. It then starts a Compilation, polls it, and downloads its artifact. With `--github`, it starts and polls the Publication instead. `compilation status` reads one retained Compilation. `compilation download` reads it and downloads its artifact. +`compilation cancel` cancels it. `login` exchanges its authorization code at `/oauth/token`. With `--interactive`, it starts at `/oauth/device_authorization` and then polls `/oauth/token`. `logout` calls `/oauth/revoke`. The browser, not the CLI, opens `/oauth/authorize` and `/device`, so they have no rows. -The first push sends `If-None-Match: *`. Later pushes, the Compilation `POST`, and the Publication `PUT` send the -saved Head ETag in `If-Match`. The Service also has a Compilation cancel route, which the CLI does not call. +The first push sends `If-None-Match: *`. Later pushes, the Compilation start `POST`, and the Publication `PUT` send +the saved Head ETag in `If-Match`. The cancel `POST` sends no precondition header. The `/v1` routes belong to the API-contract range that `release/compatibility.json` accepts. The CLI does not read the Service's `FirstDraft-API-Contract` response header. Instead, the Service's release compatibility check compares the @@ -550,8 +585,7 @@ Use this checklist when a change adds a command or subcommand. Each step names t 5. **Document it.** - In this page, add a row to the [command map](#command-map) and a section for the command. Add a row to [Service endpoints](#service-endpoints) for each new route, and name the command in the paragraph below the - table. If the command calls a route that section says the CLI does not call, such as Compilation cancel, - rewrite that sentence. + table. - In [errors.md](errors.md#error-index), add an Error index row for each new `error` value. Add recovery guidance when retrying the command is safe in a different way than for the existing commands. - When the Skill will call the command, update the Skill's CLI references and contract checks in diff --git a/docs/errors.md b/docs/errors.md index 0db1ed8..887dff4 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -20,9 +20,9 @@ remain human-readable text on standard error with exit 2. Unexpected programming `invalid_configuration` stops before a request when `--staging` conflicts with `FIRSTDRAFT_API_URL` or a Project's saved origin. Push and Compile also reject any URL override that differs from that pin. Unset the conflicting override or use a separate initialized project directory for the other environment; do not redirect existing -private Project state. Status and retained download commands continue using their pin. +private Project state. Status, retained download, and cancel commands continue using their pin. -`authentication_required` means the selected environment's token is missing or rejected. Staging uses +`authentication_required` means the selected environment's token is missing or rejected with `401`. Staging uses `FIRSTDRAFT_STAGING_API_TOKEN`, including old staging Projects with no flag. Production and custom origins use `FIRSTDRAFT_API_TOKEN`. When that variable is unset, the token saved by `firstdraft login` for the exact origin is used. The CLI never substitutes one environment's credential for another's. Run `firstdraft login` for the same @@ -130,10 +130,37 @@ operation is safe to remove. After successful adoption, run retained status and After `compilation_wait_timed_out`, retained work may still continue. Use `firstdraft compilation status ` for one read-only status check; do not rerun -`plan compile`. `compilation_failed`, `compilation_cancelled`, and `compilation_changed` already carry the +`plan compile`. If it stays `queued` or `running`, follow [stuck Compilation recovery](#stuck-compilation-recovery). +`compilation_failed`, `compilation_cancelled`, and `compilation_changed` already carry the validated `current` projection appropriate to their stopping boundary. Authentication recovery may refresh the credential, but it must continue from the retained ID rather than starting another Compilation. +## Stuck Compilation recovery + +A Project allows one active Compilation. While one is queued or running, `plan push` and `plan compile` report +`server_rejected` with First Draft's `409 compilation_active` problem in `response`, because the Plan cannot be +replaced. `plan compile` can also meet it at the Compilation start, as `compilation_start_rejected`. Either problem's +`response.detail` names the active Compilation's ID. + +1. Read that Compilation once with `firstdraft compilation status `. One that has reached `succeeded`, `failed`, + or `cancelled` no longer blocks; rerun `plan compile`. +2. If it is still `queued` or `running` well after a Compilation normally finishes, for example after a + `plan compile` wait timed out, cancel it with `firstdraft compilation cancel `. Cancelling throws away any + work it is still doing. +3. After the cancel succeeds, rerun `plan compile`. + +Cancel never changes a succeeded or failed Compilation, and repeating it is safe: + +- `compilation_cancel_rejected` means First Draft refused and nothing was cancelled; `response.code` says why. + `409 compilation_not_cancellable` means the Compilation already succeeded or failed, so it no longer blocks; check + it with `compilation status`. `404 compilation_not_found` or `404 project_not_found` means First Draft found no + such Compilation for this Project and account. Check the ID, the directory, and that the environment and login + match the ones that started it. +- `compilation_cancel_unavailable` means a network failure, timeout, or server error left the outcome unconfirmed. + Rerun `compilation cancel`, or check the result with `compilation status`. +- `invalid_compilation_status` means the response violated the Compilation contract, so the cancellation may or may + not have happened. Check with `compilation status` rather than repeating the unchanged request. + ## Publication recovery The Publication is a Project singleton. If its initial conditional `PUT` is ambiguous, the CLI attempts one @@ -178,6 +205,9 @@ stopped without following the replacement. | `compilation status`, `compilation download` | `compilation_status_unavailable`, `invalid_compilation_status` | 1 | The retained status could not be read or violated its exact contract. | | `compilation status --wait` | `compilation_changed`, `compilation_wait_timed_out` | 1 | Retained identity/provenance changed or the wait ended. | | `compilation download` | `compilation_not_succeeded` | 1 | Status was not `succeeded`; no artifact request was made. | +| `compilation cancel` | `compilation_cancel_rejected` | 1 | Nothing was cancelled; `response.code` says why ([recovery](#stuck-compilation-recovery)). | +| `compilation cancel` | `compilation_cancel_unavailable` | 1 | The cancellation could not be confirmed; repeating it is safe. | +| `compilation cancel` | `invalid_compilation_status` | 1 | The response violated the Compilation contract; check status before repeating. | | Download commands | `artifact_unavailable`, `invalid_artifact` | 1 | Artifact transport or integrity validation failed; direct Compile post-start errors include `current`. | | Download commands | `invalid_output_path` | 2 | The absent destination or root-adoption preconditions failed; `reason` identifies the stable refusal. | | Download commands | `materialization_failed` | 1 | The output changed or its transaction failed; `reason` identifies incomplete rollback when applicable. | diff --git a/package-lock.json b/package-lock.json index 913e882..43fb8c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@firstdraft.com/cli", - "version": "0.8.1", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@firstdraft.com/cli", - "version": "0.8.1", + "version": "0.8.2", "license": "MIT", "bin": { "firstdraft": "bin/firstdraft.js" diff --git a/package.json b/package.json index 55166f6..fa5bbd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@firstdraft.com/cli", - "version": "0.8.1", + "version": "0.8.2", "description": "Command-line interface for First Draft", "license": "MIT", "type": "module", diff --git a/release/compatibility.json b/release/compatibility.json index 87502c2..97a50fd 100644 --- a/release/compatibility.json +++ b/release/compatibility.json @@ -1,7 +1,7 @@ { "format": "firstdraft.release-compatibility/1", "component": "cli", - "version": "0.8.1", + "version": "0.8.2", "requires": { "api_contract": [">= 0.7.0", "< 0.8.0"], "foundation_plan_formats": ["firstdraft.foundation-plan.sketch/0.23"] diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 3ef124e..16b2f2e 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -263,6 +263,16 @@ try { "Invalid arguments. Run 'firstdraft compilation status --help' for usage.", }); + const invalidCancel = spawnPackedCli( + ["compilation", "cancel", "not-a-uuid"], + installationDirectory, + ); + assertHandledFailure(invalidCancel, 2, { + error: "invalid_arguments", + detail: + "Invalid arguments. Run 'firstdraft compilation cancel --help' for usage.", + }); + const invalidLogin = spawnPackedCli( ["login", "--canary-secret-option"], installationDirectory, @@ -520,6 +530,14 @@ async function exercisePackedCompilation(projectDirectory) { completed_at: "2026-07-30T12:00:02.000000Z", }, }; + const cancelled = { + project: compilation.project, + compilation: { + ...compilation.compilation, + status: "cancelled", + artifact: null, + }, + }; const analysis = { project: { id: projectId, graph_version: 1 }, analysis: { @@ -590,6 +608,7 @@ async function exercisePackedCompilation(projectDirectory) { publication: 0, compilationStarts: 0, status: false, + cancel: false, artifact: false, }; const server = createServer(async (request, response) => { @@ -652,6 +671,13 @@ async function exercisePackedCompilation(projectDirectory) { respondJson(response, 200, compilation); return; } + if (request.method === "POST" && request.url === `${statusPath}/cancel`) { + assert.equal(request.headers["if-match"], undefined); + assert.equal(requestBody.byteLength, 0); + seen.cancel = true; + respondJson(response, 200, cancelled); + return; + } if (request.method === "GET" && request.url === artifactPath) { seen.artifact = true; response.writeHead(200, { @@ -738,6 +764,16 @@ First Draft: Application compiled. stderr: "", }); + const cancel = await spawnPackedCliAsync( + ["compilation", "cancel", compilationId], + projectDirectory, + ); + assert.deepEqual(cancel, { + status: 0, + stdout: `${JSON.stringify(cancelled, null, 2)}\n`, + stderr: "", + }); + const output = path.join(projectDirectory, "generated"); const downloaded = await spawnPackedCliAsync( ["compilation", "download", compilationId, "--output", output], @@ -852,6 +888,7 @@ First Draft: Application compiled. publication: 1, compilationStarts: expectedCompilationStarts, status: true, + cancel: true, artifact: true, }); } finally { diff --git a/src/api-response.js b/src/api-response.js index b995b49..ebdfb4d 100644 --- a/src/api-response.js +++ b/src/api-response.js @@ -43,6 +43,10 @@ export const SERVICE_ROUTES = { method: "GET", path: "/v1/projects/{project_id}/compilations/{compilation_id}", }, + cancelCompilation: { + method: "POST", + path: "/v1/projects/{project_id}/compilations/{compilation_id}/cancel", + }, downloadArtifact: { method: "GET", path: "/v1/projects/{project_id}/compilations/{compilation_id}/artifact", diff --git a/src/cli.js b/src/cli.js index 0aa8025..2eed6e3 100644 --- a/src/cli.js +++ b/src/cli.js @@ -17,6 +17,8 @@ import { CompilationArtifactInvalidError, CompilationArtifactResponseInvalidError, CompilationArtifactUnavailableError, + CompilationCancelRejectedError, + CompilationCancelUnavailableError, CompilationCancelledError, CompilationChangedError, CompilationFailedError, @@ -32,6 +34,7 @@ import { CompilationStatusInvalidError, CompilationStatusUnavailableError, CompilationTimeoutError, + cancelCompilation, downloadCompilation, readCompilation, } from "./commands/compilation.js"; @@ -94,7 +97,7 @@ Usage: firstdraft [options] Commands: - compilation Inspect and download Compilations + compilation Inspect, download, and cancel Compilations generate Generate local values login Log in to First Draft and save a token logout Revoke and remove the saved token @@ -230,6 +233,7 @@ Usage: Commands: status Read one retained Compilation download Download one successful Compilation artifact + cancel Cancel one queued or running Compilation Options: -h, --help Show help @@ -274,6 +278,25 @@ materializes it into an absent output path or adopts the current directory while preserving existing material under .firstdraft/design. It never starts work. `; +const COMPILATION_CANCEL_HELP = `First Draft CLI + +Usage: + firstdraft compilation cancel + +Options: + --staging Use staging; reject a different saved origin + -h, --help Show help + +Environment: + FIRSTDRAFT_API_TOKEN Authenticate production or custom API origins + FIRSTDRAFT_STAGING_API_TOKEN Authenticate staging.firstdraft.com + +The command asks First Draft once to cancel a queued or running Compilation of +the local Project and prints the cancelled Compilation. Cancelling it again +prints the same result; a succeeded or failed Compilation is not changed. +Once cancelled, it no longer blocks plan push or plan compile. +`; + const PLAN_INIT_HELP = `First Draft CLI Usage: @@ -446,6 +469,8 @@ const COMPILATION_STATUS_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft compilation status --help' for usage."; const COMPILATION_DOWNLOAD_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft compilation download --help' for usage."; +const COMPILATION_CANCEL_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft compilation cancel --help' for usage."; const COMPILATION_LOCAL_INPUT_UNREADABLE_DETAIL = "Could not read valid local First Draft state. No network request was made."; const COMPILATION_NOT_PUSHED_DETAIL = @@ -460,6 +485,12 @@ const COMPILATION_TIMEOUT_DETAIL = "The retained Compilation is still processing after the bounded ten-minute wait."; const COMPILATION_NOT_SUCCEEDED_DETAIL = "The requested Compilation has not succeeded, so no artifact was downloaded."; +const COMPILATION_CANCEL_REJECTED_DETAIL = + "First Draft rejected the cancellation, so nothing was cancelled. response.code compilation_not_cancellable means the Compilation already succeeded or failed; compilation_not_found or project_not_found means First Draft found no such Compilation for this Project and account."; +const COMPILATION_CANCEL_UNAVAILABLE_DETAIL = + "Could not confirm the cancellation. Cancelling again is safe: rerun 'firstdraft compilation cancel', or check the Compilation with 'firstdraft compilation status'."; +const COMPILATION_CANCEL_INVALID_DETAIL = + "First Draft returned an invalid response to the cancellation, so its outcome is unconfirmed. Check the Compilation with 'firstdraft compilation status'; retrying unchanged will not repair this protocol mismatch."; const COMPILATION_ARTIFACT_UNAVAILABLE_DETAIL = "Could not download the requested Compilation artifact. No files were materialized."; const COMPILATION_ARTIFACT_INVALID_DETAIL = @@ -939,6 +970,23 @@ async function runCompilation({ }); } + if (argv[0] === "cancel") { + return runCompilationCancel({ + argv: argv.slice(1), + stdout, + stderr, + cwd: cwd ?? getCwd(), + fetchFunction, + planPushFileSystem, + createRequestSignal, + apiUrl, + apiToken, + stagingApiToken, + staging, + credentials, + }); + } + const parsed = parseArguments(() => parseArgs({ args: [...argv], @@ -1204,6 +1252,120 @@ async function runCompilationDownload({ } } +/** + * @param {Pick} options + */ +async function runCompilationCancel({ + argv, + stdout, + stderr, + cwd, + fetchFunction, + planPushFileSystem, + createRequestSignal, + apiUrl, + apiToken, + stagingApiToken, + staging, + credentials, +}) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { + staging: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + strict: true, + tokens: true, + }), + ); + + if (!parsed || repeatedValueOption(parsed.tokens)) { + writeCompilationInvalidArguments( + stderr, + COMPILATION_CANCEL_INVALID_ARGUMENTS_DETAIL, + ); + return 2; + } + + if (parsed.values.help) { + stdout.write(COMPILATION_CANCEL_HELP); + return 0; + } + + const [compilationId] = parsed.positionals; + if (parsed.positionals.length !== 1 || !isUuidV7(compilationId)) { + writeCompilationInvalidArguments( + stderr, + COMPILATION_CANCEL_INVALID_ARGUMENTS_DETAIL, + ); + return 2; + } + + try { + const authentication = authenticateApiCommand({ + fetchFunction, + apiUrl, + apiToken, + stagingApiToken, + staging: staging || parsed.values.staging, + readStoredTokens: storedTokenReader(credentials), + }); + if (authentication === null) { + writeAuthenticationRequired(stderr); + return 1; + } + const result = await cancelCompilation({ + cwd, + compilationId, + fetchFunction: authentication.fetchFunction, + fileSystem: planPushFileSystem, + createRequestSignal, + }); + writeJson(stdout, result); + return 0; + } catch (error) { + if (error instanceof CompilationCancelRejectedError) { + if (isAuthenticationProblem(error.status, error.response)) { + writeAuthenticationRequired(stderr, error.status, error.response); + return 1; + } + writeJson(stderr, { + error: "compilation_cancel_rejected", + detail: COMPILATION_CANCEL_REJECTED_DETAIL, + status: error.status, + response: error.response, + }); + return 1; + } + + if (error instanceof CompilationCancelUnavailableError) { + writeJson(stderr, { + error: "compilation_cancel_unavailable", + detail: COMPILATION_CANCEL_UNAVAILABLE_DETAIL, + ...(typeof error.status === "number" ? { status: error.status } : {}), + ...(error.response ? { response: error.response } : {}), + }); + return 1; + } + + if (error instanceof CompilationStatusInvalidError) { + writeJson(stderr, { + error: "invalid_compilation_status", + detail: COMPILATION_CANCEL_INVALID_DETAIL, + status: error.status, + }); + return 1; + } + + const status = writeCompilationReadError(stderr, error, false); + if (status !== null) return status; + throw error; + } +} + /** @param {Writer} writer @param {string} detail */ function writeCompilationInvalidArguments(writer, detail) { writeJson(writer, { error: "invalid_arguments", detail }); diff --git a/src/commands/compilation.js b/src/commands/compilation.js index 9b35f29..3bf4226 100644 --- a/src/commands/compilation.js +++ b/src/commands/compilation.js @@ -131,6 +131,30 @@ export class CompilationStatusInvalidError extends Error { } } +export class CompilationCancelRejectedError extends Error { + /** + * @param {number} status + * @param {Record} response + */ + constructor(status, response) { + super("First Draft rejected the cancellation."); + this.status = status; + this.response = response; + } +} + +export class CompilationCancelUnavailableError extends Error { + /** + * @param {number | undefined} status + * @param {Record | null} [response] + */ + constructor(status, response = null) { + super("The cancellation could not be confirmed."); + this.status = status; + this.response = response; + } +} + export class CompilationChangedError extends Error { /** @param {CompilationResponse} current */ constructor(current) { @@ -361,6 +385,82 @@ export async function downloadCompilation({ } } +/** + * @typedef {object} CompilationCancelOptions + * @property {string} cwd + * @property {string} compilationId + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {import("../plan-state.js").PlanStateFileSystem} [fileSystem] + * @property {(timeoutMs: number) => AbortSignal} [createRequestSignal] + */ + +/** + * Cancel one queued or running Compilation of the local Project. The Service + * answers a repeated cancel with the same cancelled Compilation and refuses a + * succeeded or failed one without changing it, so an unconfirmed outcome is + * safe to repeat rather than an ambiguous mutation. + * + * @param {CompilationCancelOptions} options + * @returns {Promise} + */ +export async function cancelCompilation({ + cwd, + compilationId, + fetchFunction = globalThis.fetch, + fileSystem, + createRequestSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), +}) { + const { apiUrl, projectId } = readContext({ cwd, compilationId, fileSystem }); + const endpoint = serviceEndpoint(SERVICE_ROUTES.cancelCompilation, apiUrl, { + project_id: projectId, + compilation_id: compilationId, + }); + let response; + let body; + try { + response = await sendRequest(fetchFunction, endpoint, { + headers: { Accept: "application/json, application/problem+json" }, + redirect: "error", + signal: createRequestSignal(REQUEST_TIMEOUT_MS), + }); + body = await readResponseBody(response); + } catch (error) { + if (error instanceof FirstDraftNetworkError) { + throw new CompilationCancelUnavailableError(error.status); + } + // An oversized error body leaves the outcome as unconfirmed as any other + // failed response; only an oversized success breaks the contract. + if (error instanceof FirstDraftProtocolError) { + throw error.status >= 200 && error.status < 300 + ? new CompilationStatusInvalidError(error.status) + : new CompilationCancelUnavailableError(error.status); + } + + throw error; + } + + if (response.status !== 200) { + if (response.ok) throw new CompilationStatusInvalidError(response.status); + + const problem = safeProblem(response, body); + if (problem !== null && response.status !== 408 && response.status < 500) { + throw new CompilationCancelRejectedError(response.status, problem); + } + throw new CompilationCancelUnavailableError(response.status, problem); + } + + const parsed = parseCompilationResponse(body, projectId, compilationId); + if ( + responseMediaType(response) !== "application/json" || + parsed === null || + parsed.compilation.status !== "cancelled" + ) { + throw new CompilationStatusInvalidError(response.status); + } + + return parsed; +} + /** * @typedef {object} CurrentCompilationIdentity * @property {string} projectId diff --git a/test/api-environments.test.js b/test/api-environments.test.js index 47bf3c9..12285fe 100644 --- a/test/api-environments.test.js +++ b/test/api-environments.test.js @@ -23,6 +23,7 @@ const REMOTE_COMMANDS = [ ["plan", "compile", "--github"], ["compilation", "status", COMPILATION_ID], ["compilation", "download", COMPILATION_ID, "--output", "application"], + ["compilation", "cancel", COMPILATION_ID], ]; test("production and staging credentials follow the pinned origin on every remote command", async (context) => { @@ -156,11 +157,12 @@ test("staging rejects a conflicting URL or pinned origin before any request", as } }); -test("retained reads ignore an initial URL override when selecting staging credentials", async (context) => { +test("status and retained Compilation commands ignore an initial URL override when selecting staging credentials", async (context) => { for (const argv of [ REMOTE_COMMANDS[1], REMOTE_COMMANDS[4], REMOTE_COMMANDS[5], + REMOTE_COMMANDS[6], ]) { assert(argv); let requests = 0; diff --git a/test/cli.test.js b/test/cli.test.js index dd45689..80d9063 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -15,7 +15,7 @@ Usage: firstdraft [options] Commands: - compilation Inspect and download Compilations + compilation Inspect, download, and cancel Compilations generate Generate local values login Log in to First Draft and save a token logout Revoke and remove the saved token diff --git a/test/compilation-cancel.test.js b/test/compilation-cancel.test.js new file mode 100644 index 0000000..fdcf74f --- /dev/null +++ b/test/compilation-cancel.test.js @@ -0,0 +1,451 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { run } from "../src/cli.js"; +import { RAILS_TARGET_PROFILE } from "../src/compilation-artifact.js"; + +const PROJECT_ID = "01900000-0000-7000-8000-000000004001"; +const COMPILATION_ID = "01900000-0000-7000-8000-000000004002"; +const ANALYSIS_ID = "01900000-0000-7000-8000-000000004004"; +const API_TOKEN = `fd_canary${"c".repeat(37)}`; +const API_URL = "https://api.example.test"; +const CREATED_AT = "2026-10-04T12:00:00.000000Z"; +const STARTED_AT = "2026-10-04T12:00:01.000000Z"; +const COMPLETED_AT = "2026-10-04T12:30:00.000000Z"; +const STATUS_PATH = `/v1/projects/${PROJECT_ID}/compilations/${COMPILATION_ID}`; +const CANCEL_PATH = `${STATUS_PATH}/cancel`; +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + +const COMPILATION_CANCEL_HELP = `First Draft CLI + +Usage: + firstdraft compilation cancel + +Options: + --staging Use staging; reject a different saved origin + -h, --help Show help + +Environment: + FIRSTDRAFT_API_TOKEN Authenticate production or custom API origins + FIRSTDRAFT_STAGING_API_TOKEN Authenticate staging.firstdraft.com + +The command asks First Draft once to cancel a queued or running Compilation of +the local Project and prints the cancelled Compilation. Cancelling it again +prints the same result; a succeeded or failed Compilation is not changed. +Once cancelled, it no longer blocks plan push or plan compile. +`; + +test("compilation cancel help is printed on stdout and listed in its group", async () => { + for (const argv of [ + ["compilation", "cancel", "--help"], + ["compilation", "cancel", "-h"], + ]) { + const result = await invoke(argv, { cwd: process.cwd() }); + assert.deepEqual(result, { + status: 0, + stdout: COMPILATION_CANCEL_HELP, + stderr: "", + }); + } + + const group = await invoke(["compilation", "--help"], { + cwd: process.cwd(), + }); + assert.equal(group.status, 0); + assert.match( + group.stdout, + /^ {2}cancel {4}Cancel one queued or running Compilation$/m, + ); +}); + +test("compilation cancel makes one POST and prints the cancelled Compilation", async (context) => { + for (const startedAt of [null, STARTED_AT]) { + const cwd = projectDirectory(context); + /** @type {FetchCall[]} */ + const calls = []; + const body = cancelledBody({ started_at: startedAt }); + const result = await invoke(["compilation", "cancel", COMPILATION_ID], { + cwd, + fetchFunction: sequenceFetch([jsonResponse(body)], calls), + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), body); + assert.equal(result.stderr, ""); + assert.equal(calls.length, 1); + assert.equal(String(calls[0]?.input), `${API_URL}${CANCEL_PATH}`); + assert.equal(calls[0]?.init?.method, "POST"); + assert.equal(calls[0]?.init?.body, undefined); + assert.equal(calls[0]?.init?.redirect, "error"); + const headers = new Headers(calls[0]?.init?.headers); + assert.equal(headers.get("authorization"), `Bearer ${API_TOKEN}`); + assert.equal( + headers.get("accept"), + "application/json, application/problem+json", + ); + assert.equal(headers.get("if-match"), null); + assertNoSecrets(result); + } +}); + +test("a rejected cancellation reports the validated Service problem", async (context) => { + for (const problem of [ + { + status: 409, + code: "compilation_not_cancellable", + detail: "A succeeded Compilation cannot be cancelled.", + }, + { + status: 404, + code: "compilation_not_found", + detail: "This Project does not contain that Compilation.", + }, + { + status: 404, + code: "project_not_found", + detail: "This Project does not exist.", + }, + ]) { + const result = await invoke(["compilation", "cancel", COMPILATION_ID], { + cwd: projectDirectory(context), + fetchFunction: sequenceFetch([problemResponse(problem)]), + }); + + assertHandledFailure(result, "compilation_cancel_rejected"); + const envelope = JSON.parse(result.stderr); + assert.equal(envelope.status, problem.status); + assert.deepEqual(envelope.response, { + type: "about:blank", + title: problem.status === 409 ? "Conflict" : "Not Found", + ...problem, + }); + assert.match(envelope.detail, /nothing was cancelled/); + assertNoSecrets(result); + } +}); + +test("an unconfirmed cancellation is reported as safe to repeat", async (context) => { + const cases = [ + { + response: async () => { + throw new TypeError("canary network failure"); + }, + status: undefined, + problem: false, + }, + { + response: async () => + problemResponse({ + status: 503, + code: "service_unavailable", + detail: "Try again later.", + }), + status: 503, + problem: true, + }, + { + response: async () => + problemResponse({ + status: 408, + code: "request_timeout", + detail: "The request timed out.", + }), + status: 408, + problem: true, + }, + { + response: async () => + new Response("canary upstream failure", { + status: 502, + headers: { "Content-Type": "text/html" }, + }), + status: 502, + problem: false, + }, + { + response: async () => + new Response(JSON.stringify({ error: "canary" }), { + status: 409, + headers: { "Content-Type": "application/json" }, + }), + status: 409, + problem: false, + }, + ]; + for (const { response, status, problem } of cases) { + const result = await invoke(["compilation", "cancel", COMPILATION_ID], { + cwd: projectDirectory(context), + fetchFunction: response, + }); + + assertHandledFailure(result, "compilation_cancel_unavailable"); + const envelope = JSON.parse(result.stderr); + assert.equal(envelope.status, status); + assert.equal("response" in envelope, problem); + assert.match(envelope.detail, /Cancelling again is safe/); + assertNoSecrets(result); + } +}); + +test("an oversized response is unconfirmed unless it claims success", async (context) => { + for (const { status, error } of [ + { status: 503, error: "compilation_cancel_unavailable" }, + { status: 409, error: "compilation_cancel_unavailable" }, + { status: 200, error: "invalid_compilation_status" }, + ]) { + for (const declared of [true, false]) { + let bodyCancelled = false; + const body = new ReadableStream({ + start(controller) { + if (!declared) { + controller.enqueue({ byteLength: MAX_RESPONSE_BYTES + 1 }); + } + }, + cancel() { + bodyCancelled = true; + }, + }); + const result = await invoke(["compilation", "cancel", COMPILATION_ID], { + cwd: projectDirectory(context), + fetchFunction: sequenceFetch([ + new Response(body, { + status, + headers: { + "Content-Type": "application/problem+json", + ...(declared + ? { "Content-Length": String(MAX_RESPONSE_BYTES + 1) } + : {}), + }, + }), + ]), + }); + + assertHandledFailure(result, error); + assert.equal(JSON.parse(result.stderr).status, status); + assert.equal(bodyCancelled, true); + } + } +}); + +test("a cancellation response must be the exact cancelled Compilation", async (context) => { + for (const response of [ + jsonResponse(cancelledBody({ status: "running", completed_at: null })), + jsonResponse({ ...cancelledBody(), canary: true }), + jsonResponse(cancelledBody({ id: "01900000-0000-7000-8000-000000004099" })), + new Response(JSON.stringify(cancelledBody()), { + status: 200, + headers: { "Content-Type": "text/plain" }, + }), + new Response(null, { status: 204 }), + new Response("canary", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ]) { + const result = await invoke(["compilation", "cancel", COMPILATION_ID], { + cwd: projectDirectory(context), + fetchFunction: sequenceFetch([response]), + }); + + assertHandledFailure(result, "invalid_compilation_status"); + assert.match(JSON.parse(result.stderr).detail, /outcome is unconfirmed/); + assertNoSecrets(result); + } +}); + +test("compilation cancel authentication problems are stable", async (context) => { + const missing = await invoke(["compilation", "cancel", COMPILATION_ID], { + cwd: projectDirectory(context), + apiToken: undefined, + fetchFunction: async () => { + throw new Error("network must remain inaccessible"); + }, + }); + assertHandledFailure(missing, "authentication_required"); + assert.equal(JSON.parse(missing.stderr).status, undefined); + + const rejected = await invoke(["compilation", "cancel", COMPILATION_ID], { + cwd: projectDirectory(context), + fetchFunction: sequenceFetch([ + problemResponse({ + status: 401, + code: "authentication_required", + detail: "Provide a valid First Draft API token.", + }), + ]), + }); + assertHandledFailure(rejected, "authentication_required"); + assert.equal(JSON.parse(rejected.stderr).status, 401); + assert.equal( + JSON.parse(rejected.stderr).response.code, + "authentication_required", + ); + assertNoSecrets(rejected); +}); + +test("compilation cancel syntax and local state fail before network access", async (context) => { + const inaccessible = async () => { + throw new Error("network must remain inaccessible"); + }; + for (const argv of [ + ["compilation", "cancel"], + ["compilation", "cancel", "not-a-uuid"], + ["compilation", "cancel", "0190ABCD-0000-7000-8000-000000004002"], + ["compilation", "cancel", COMPILATION_ID, COMPILATION_ID], + ["compilation", "cancel", COMPILATION_ID, "--wait"], + ["compilation", "cancel", COMPILATION_ID, "--staging", "--staging"], + ]) { + const result = await invoke(argv, { + cwd: projectDirectory(context), + fetchFunction: inaccessible, + }); + assertHandledFailure(result, "invalid_arguments", 2); + assert.equal( + JSON.parse(result.stderr).detail, + "Invalid arguments. Run 'firstdraft compilation cancel --help' for usage.", + ); + } + + const uninitialized = mkdtempSync(path.join(tmpdir(), "firstdraft-cancel-")); + context.after(() => rmSync(uninitialized, { recursive: true, force: true })); + const unreadable = await invoke(["compilation", "cancel", COMPILATION_ID], { + cwd: uninitialized, + fetchFunction: inaccessible, + }); + assertHandledFailure(unreadable, "local_input_unreadable"); + + const unpushed = await invoke(["compilation", "cancel", COMPILATION_ID], { + cwd: projectDirectory(context, { pushed: false }), + fetchFunction: inaccessible, + }); + assertHandledFailure(unpushed, "project_not_pushed"); +}); + +/** @param {Record} [changes] */ +function cancelledBody(changes = {}) { + return { + project: { id: PROJECT_ID, graph_version: 3 }, + compilation: { + id: COMPILATION_ID, + analysis_run_id: ANALYSIS_ID, + graph_version: 3, + head_source_sha256: "1".repeat(64), + status: "cancelled", + compiler_release: + "foundation-plan-rails/compiler-application-2026-10-03-accounts-without-verification", + target: { id: "rails", profile: RAILS_TARGET_PROFILE }, + status_path: STATUS_PATH, + cancel_path: CANCEL_PATH, + artifact: null, + failure: null, + created_at: CREATED_AT, + started_at: STARTED_AT, + completed_at: COMPLETED_AT, + ...changes, + }, + }; +} + +/** @param {{status: number, code: string, detail: string}} problem */ +function problemResponse({ status, code, detail }) { + const titles = new Map([ + [401, "Unauthorized"], + [404, "Not Found"], + [408, "Request Timeout"], + [409, "Conflict"], + [503, "Service Unavailable"], + ]); + return new Response( + JSON.stringify({ + type: "about:blank", + title: titles.get(status), + status, + code, + detail, + }), + { status, headers: { "Content-Type": "application/problem+json" } }, + ); +} + +/** + * @param {import("node:test").TestContext} context + * @param {{pushed?: boolean}} [options] + */ +function projectDirectory(context, { pushed = true } = {}) { + const cwd = mkdtempSync(path.join(tmpdir(), "firstdraft-cancel-")); + context.after(() => rmSync(cwd, { recursive: true, force: true })); + mkdirSync(path.join(cwd, ".firstdraft")); + writeFileSync( + path.join(cwd, ".firstdraft", "state.json"), + `${JSON.stringify( + { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + ...(pushed + ? { + api_url: API_URL, + foundation_plan_etag: `"sha256:${"9".repeat(64)}"`, + } + : {}), + }, + null, + 2, + )}\n`, + { mode: 0o600 }, + ); + return cwd; +} + +/** @param {readonly string[]} argv @param {Record} [options] */ +async function invoke(argv, options = {}) { + let stdout = ""; + let stderr = ""; + const status = await run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + apiToken: API_TOKEN, + env: { XDG_CONFIG_HOME: "/nonexistent/firstdraft-test-config" }, + fetchFunction: async () => assert.fail("No network request was expected"), + ...options, + }); + return { status, stdout, stderr }; +} + +/** @param {{status: number, stdout: string, stderr: string}} result @param {string} error @param {number} [status] */ +function assertHandledFailure(result, error, status = 1) { + assert.equal(result.status, status); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).error, error); +} + +/** @param {{stdout: string, stderr: string}} result */ +function assertNoSecrets(result) { + assert.doesNotMatch(result.stdout + result.stderr, /canary/); +} + +/** @typedef {{input: string | URL | Request, init: RequestInit | undefined}} FetchCall */ + +/** @param {Response[]} responses @param {FetchCall[]} [calls] */ +function sequenceFetch(responses, calls = []) { + return async ( + /** @type {string | URL | Request} */ input, + /** @type {RequestInit | undefined} */ init, + ) => { + calls.push({ input, init }); + const response = responses.shift(); + assert(response, "unexpected request"); + return response; + }; +} + +/** @param {unknown} body @param {number} [status] */ +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +}