diff --git a/README.md b/README.md index 68aaef1..fcf2dcc 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ built for agents that author and review Foundation Plans with their users. The package is not released yet. This repository contains the auditable command shell, local Foundation Plan -initialization, subject identity generation, and conditional whole-document push; release behavior will arrive in -reviewed increments. +initialization, subject identity generation, conditional whole-document push, and whole-graph analysis status +polling; release behavior will arrive in reviewed increments. ## Requirements @@ -63,27 +63,55 @@ later override must match it. If a failure happens after sending the request, the CLI reports that the outcome may be ambiguous and leaves local state unchanged. It never constructs an ETag from the Plan digest or trusts an ETag from a response it could not -fully verify. Until First Draft has a read or reconciliation endpoint, an accepted request whose response cannot be -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 failure from `plan init`, `plan subject-id`, or `plan push` writes exactly one JSON object to standard -error. Agents should branch on its stable `error` value, not on the human-readable `detail`: - -| Commands | `error` | Exit | Meaning | -| ------------------------------------------- | ----------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `plan init`, `plan subject-id`, `plan push` | `invalid_arguments` | 2 | The command syntax is invalid; nothing was written and no request was made. | -| `plan init` | `local_initialization_failed` | 1 | Local initialization failed. The directory may be incomplete; existing files were not overwritten. | -| `plan push` | `invalid_configuration` | 2 | The configured API origin is invalid or conflicts with pinned local state; no request was made. | -| `plan push` | `local_input_unreadable` | 1 | The local Plan or state could not be read; no request was made. | -| `plan push` | `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. | -| `plan push` | `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. | -| `plan push` | `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`. | +fully verify. Until First Draft has a Foundation Plan head reconciliation endpoint, an accepted request whose +response cannot be 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. + +## Read analysis status + +After a successful push: + +```sh +firstdraft plan status +firstdraft plan status --wait +``` + +Without `--wait`, the command makes one `GET` and prints the current analysis as one JSON object. With `--wait`, it +polls sequentially once per second for at most two minutes and stops at `valid`, `issues_found`, `analysis_failed`, +or `superseded`. Every validated analysis status is a successful read with exit 0; agents should branch on the +`analysis.status` value and inspect `analysis.diagnostics` rather than treating a completed analysis with issues as +a transport failure. + +Status reads require the API origin pinned by a successful push. They never select an origin from the current +environment, expose the private ETag, follow redirects, or modify local state. Each request has a bounded timeout, +every response is byte-bounded and fully validated, and polling will not silently switch to a replacement analysis. +The wait repeats only validated `processing` responses and stops on its first failed read. A network failure is safe +to retry a bounded number of times because the command sends only `GET` requests. If `status_unavailable` persists, +inspect the API origin pinned in `.firstdraft/state.json`; an invalid server response instead requires reconciling the +CLI and server contract. + +Every handled failure from `plan init`, `plan subject-id`, `plan push`, or `plan status` writes exactly one JSON +object to standard error. Agents should branch on its stable `error` value, not on the human-readable `detail`: + +| Commands | `error` | Exit | Meaning | +| ---------------------------------------------------------- | ----------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `plan init`, `plan subject-id`, `plan push`, `plan status` | `invalid_arguments` | 2 | The command syntax is invalid; nothing was written and no request was made. | +| `plan init` | `local_initialization_failed` | 1 | Local initialization failed. The directory may be incomplete; existing files were not overwritten. | +| `plan push` | `invalid_configuration` | 2 | The configured API origin is invalid or conflicts with pinned local state; no request was made. | +| `plan push`, `plan status` | `local_input_unreadable` | 1 | The required local Plan or private state could not be read; no request was made. | +| `plan status` | `project_not_pushed` | 1 | Local state is valid but has no pinned remote Project yet; run `plan push` first. | +| `plan push` | `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. | +| `plan status` | `status_unavailable` | 1 | The network request or response stream failed. The object includes `status` when headers were received; retry the GET a bounded number of times. | +| `plan status` | `invalid_server_response` | 1 | First Draft returned a response that does not satisfy the status contract. The object includes `status`; retrying unchanged will not repair the mismatch. | +| `plan push`, `plan status` | `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. | +| `plan status --wait` | `analysis_changed` | 1 | A different current analysis appeared while polling. `current` contains its validated state; start a fresh wait to follow it explicitly. | +| `plan status --wait` | `wait_timed_out` | 1 | The two-minute wait ended while processing continued. `current` contains the last validated state; another wait is safe. | +| `plan push` | `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`. | Handled failure output never includes command arguments, local Plan bytes, runtime paths, raw filesystem or 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. +errors, or unvalidated response bodies. Optional fields inside a validated rejection 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. Root-level and `plan` command-group usage failures remain human-readable text on standard error with exit 2. A failure before a subcommand can begin, such as an unavailable working directory, also remains uncaught, as do diff --git a/scripts/check-pack.js b/scripts/check-pack.js index 3e4da25..864add2 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -25,10 +25,13 @@ if (result.status !== 0) { "README.md", "bin/firstdraft.js", "package.json", + "src/api-response.js", "src/cli.js", "src/commands/plan-init.js", "src/commands/plan-push.js", + "src/commands/plan-status.js", "src/file-system.js", + "src/plan-state.js", "src/uuid-v7.js", "src/version.js", ]); diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 7bde4ec..692d414 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -152,6 +152,22 @@ try { detail: "Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery.", }); + + const invalidStatus = spawnPackedCli( + ["plan", "status", "--canary-secret-option"], + installationDirectory, + ); + assertHandledFailure(invalidStatus, 2, { + error: "invalid_arguments", + detail: "Invalid arguments. Run 'firstdraft plan status --help' for usage.", + }); + + const localStatus = spawnPackedCli(["plan", "status"], installationDirectory); + assertHandledFailure(localStatus, 1, { + error: "local_input_unreadable", + detail: + "Could not read valid local First Draft state. No network request was made. Run 'firstdraft plan init' if this directory is not initialized; otherwise repair the private state before retrying.", + }); } finally { rmSync(temporaryDirectory, { recursive: true, force: true }); } diff --git a/src/api-response.js b/src/api-response.js new file mode 100644 index 0000000..636e7b5 --- /dev/null +++ b/src/api-response.js @@ -0,0 +1,152 @@ +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + +export class FirstDraftNetworkError 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 FirstDraftProtocolError extends Error { + /** @param {string} message @param {{status: number}} options */ + constructor(message, options) { + super(message); + this.status = options.status; + } +} + +/** + * @param {typeof globalThis.fetch} fetchFunction + * @param {URL} endpoint + * @param {RequestInit} request + */ +export async function sendRequest(fetchFunction, endpoint, request) { + try { + return await fetchFunction(endpoint, request); + } catch (error) { + if (!(error instanceof Error)) throw error; + + throw new FirstDraftNetworkError("The First Draft request failed.", { + cause: error, + }); + } +} + +/** @param {Response} response */ +export async function readResponseBody(response) { + const bytes = await readResponseBytes(response); + + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + + return null; + } + + try { + return JSON.parse(text); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + + return null; + } +} + +/** @param {Response} response */ +export function responseMediaType(response) { + return ( + response.headers + .get("content-type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase() ?? "" + ); +} + +/** @param {Response} response @param {unknown} body */ +export function isProblemBody(response, body) { + return ( + responseMediaType(response) === "application/problem+json" && + isRecord(body) && + (body.type === undefined || body.type === "about:blank") && + typeof body.title === "string" && + body.status === response.status && + typeof body.code === "string" && + typeof body.detail === "string" + ); +} + +/** + * @param {unknown} value + * @returns {value is Record & {severity: "error" | "warning"}} + */ +export function isDiagnostic(value) { + return ( + isRecord(value) && + typeof value.code === "string" && + (value.severity === "error" || value.severity === "warning") && + typeof value.message === "string" + ); +} + +/** @param {Response} response */ +async function readResponseBytes(response) { + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength !== null && + /^\d+$/.test(declaredLength) && + Number(declaredLength) > MAX_RESPONSE_BYTES + ) { + if (response.body !== null) { + await response.body.cancel().catch(() => undefined); + } + throw new FirstDraftProtocolError( + "The First Draft response is too large.", + { status: response.status }, + ); + } + + if (response.body === null) return Buffer.alloc(0); + + const reader = response.body.getReader(); + const chunks = []; + let byteLength = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + byteLength += value.byteLength; + if (byteLength > MAX_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined); + throw new FirstDraftProtocolError( + "The First Draft response is too large.", + { status: response.status }, + ); + } + chunks.push(Buffer.from(value)); + } + } catch (error) { + if (error instanceof FirstDraftProtocolError) throw error; + if (!(error instanceof Error)) throw error; + + throw new FirstDraftNetworkError("The First Draft response failed.", { + cause: error, + status: response.status, + }); + } + + return Buffer.concat(chunks, byteLength); +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/cli.js b/src/cli.js index ab00af8..90e97cd 100644 --- a/src/cli.js +++ b/src/cli.js @@ -9,6 +9,12 @@ import { PlanPushStateWriteError, pushPlan, } from "./commands/plan-push.js"; +import { + PlanStatusChangedError, + PlanStatusNotPushedError, + PlanStatusTimeoutError, + readPlanStatus, +} from "./commands/plan-status.js"; import { isFileSystemError } from "./file-system.js"; import { generateUuidV7 } from "./uuid-v7.js"; import { VERSION } from "./version.js"; @@ -36,6 +42,7 @@ Commands: init Create a local empty Foundation Plan subject-id Generate a UUIDv7 for a new Plan subject push Send the local Foundation Plan to First Draft + status Read the current whole-graph analysis status Options: -h, --help Show help @@ -56,6 +63,19 @@ The first successful push saves its API origin in .firstdraft/state.json. Later pushes reject a different origin. `; +const PLAN_STATUS_HELP = `First Draft CLI + +Usage: + firstdraft plan status [--wait] + +Options: + --wait Poll until the current analysis reaches a terminal status + -h, --help Show help + +The command uses only the API origin pinned by a successful plan push. +Without --wait, it makes exactly one status request. +`; + const PLAN_SUBJECT_ID_HELP = `First Draft CLI Usage: @@ -101,6 +121,22 @@ const PLAN_PUSH_LOCAL_INPUT_UNREADABLE_DETAIL = 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_STATUS_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft plan status --help' for usage."; +const PLAN_STATUS_LOCAL_INPUT_UNREADABLE_DETAIL = + "Could not read valid local First Draft state. No network request was made. Run 'firstdraft plan init' if this directory is not initialized; otherwise repair the private state before retrying."; +const PLAN_STATUS_NOT_PUSHED_DETAIL = + "The local Foundation Plan has not been pushed successfully. Run 'firstdraft plan push' before requesting analysis status."; +const PLAN_STATUS_UNAVAILABLE_DETAIL = + "Could not verify the current analysis status. Retry this read-only request a bounded number of times; if it keeps failing, inspect the API origin pinned in .firstdraft/state.json."; +const PLAN_STATUS_INVALID_RESPONSE_DETAIL = + "First Draft returned an invalid analysis response. Retrying the unchanged request will not repair this protocol mismatch."; +const PLAN_STATUS_SERVER_REJECTED_DETAIL = + "First Draft rejected the analysis status request."; +const PLAN_STATUS_CHANGED_DETAIL = + "The current analysis changed while waiting. Run 'firstdraft plan status --wait' again to follow the latest analysis."; +const PLAN_STATUS_TIMEOUT_DETAIL = + "The current analysis is still processing after the bounded wait. Run 'firstdraft plan status --wait' again to continue waiting."; const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft plan subject-id --help' for usage."; @@ -122,7 +158,9 @@ const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = * @property {typeof globalThis.fetch} [fetchFunction] * @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem] * @property {() => string} [createTemporaryId] - * @property {() => AbortSignal} [createRequestSignal] + * @property {(timeoutMs?: number) => AbortSignal} [createRequestSignal] + * @property {(delayMs: number) => Promise} [planStatusSleep] + * @property {() => number} [planStatusNow] * @property {string} [apiUrl] */ @@ -138,7 +176,9 @@ const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = * @property {typeof globalThis.fetch} [fetchFunction] * @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem] * @property {() => string} [createTemporaryId] - * @property {() => AbortSignal} [createRequestSignal] + * @property {(timeoutMs?: number) => AbortSignal} [createRequestSignal] + * @property {(delayMs: number) => Promise} [planStatusSleep] + * @property {() => number} [planStatusNow] * @property {string} [apiUrl] */ @@ -160,6 +200,8 @@ export async function run({ planPushFileSystem, createTemporaryId, createRequestSignal, + planStatusSleep, + planStatusNow, apiUrl = process.env.FIRSTDRAFT_API_URL, }) { if (argv[0] === "plan") { @@ -176,6 +218,8 @@ export async function run({ planPushFileSystem, createTemporaryId, createRequestSignal, + planStatusSleep, + planStatusNow, apiUrl, }); } @@ -240,6 +284,8 @@ async function runPlan({ planPushFileSystem, createTemporaryId, createRequestSignal, + planStatusSleep, + planStatusNow, apiUrl, }) { if (argv[0] === "init") { @@ -267,6 +313,20 @@ async function runPlan({ }); } + if (argv[0] === "status") { + return runPlanStatus({ + argv: argv.slice(1), + stdout, + stderr, + cwd: cwd ?? getCwd(), + fetchFunction, + planPushFileSystem, + createRequestSignal, + planStatusSleep, + planStatusNow, + }); + } + if (argv[0] === "subject-id") { return runPlanSubjectId({ argv: argv.slice(1), @@ -453,6 +513,137 @@ async function runPlanPush({ return 0; } +/** + * @param {Pick} options + */ +async function runPlanStatus({ + argv, + stdout, + stderr, + cwd, + fetchFunction, + planPushFileSystem, + createRequestSignal, + planStatusSleep, + planStatusNow, +}) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { + help: { type: "boolean", short: "h" }, + wait: { type: "boolean" }, + }, + allowPositionals: false, + strict: true, + tokens: true, + }), + ); + + if (!parsed || repeatedValueOption(parsed.tokens)) { + writeJson(stderr, { + error: "invalid_arguments", + detail: PLAN_STATUS_INVALID_ARGUMENTS_DETAIL, + }); + return 2; + } + + if (parsed.values.help) { + stdout.write(PLAN_STATUS_HELP); + return 0; + } + + let result; + try { + result = await readPlanStatus({ + cwd, + wait: parsed.values.wait, + fetchFunction, + fileSystem: planPushFileSystem, + createRequestSignal, + sleep: planStatusSleep, + now: planStatusNow, + }); + } catch (error) { + if (error instanceof PlanPushLocalError) { + writeJson(stderr, { + error: "local_input_unreadable", + detail: PLAN_STATUS_LOCAL_INPUT_UNREADABLE_DETAIL, + }); + return 1; + } + + if (error instanceof PlanStatusNotPushedError) { + writeJson(stderr, { + error: "project_not_pushed", + detail: PLAN_STATUS_NOT_PUSHED_DETAIL, + }); + return 1; + } + + if (error instanceof PlanStatusChangedError) { + writeJson(stderr, { + error: "analysis_changed", + detail: PLAN_STATUS_CHANGED_DETAIL, + current: error.current, + }); + return 1; + } + + if (error instanceof PlanStatusTimeoutError) { + writeJson(stderr, { + error: "wait_timed_out", + detail: PLAN_STATUS_TIMEOUT_DETAIL, + current: error.current, + }); + return 1; + } + + if (error instanceof PlanPushNetworkError) { + writeJson(stderr, { + error: "status_unavailable", + detail: PLAN_STATUS_UNAVAILABLE_DETAIL, + ...(typeof error.status === "number" ? { status: error.status } : {}), + }); + return 1; + } + + if (error instanceof PlanPushProtocolError) { + writeJson(stderr, { + error: "invalid_server_response", + detail: PLAN_STATUS_INVALID_RESPONSE_DETAIL, + status: error.status, + }); + return 1; + } + + throw error; + } + + if ("responseKind" in result) { + if (result.responseKind === null) { + writeJson(stderr, { + error: "invalid_server_response", + detail: PLAN_STATUS_INVALID_RESPONSE_DETAIL, + status: result.status, + }); + return 1; + } + + const response = safeRejectedResponse(result.responseKind, result.body); + writeJson(stderr, { + error: "server_rejected", + detail: PLAN_STATUS_SERVER_REJECTED_DETAIL, + status: result.status, + ...(response ? { response } : {}), + }); + return 1; + } + + writeJson(stdout, result.body); + return 0; +} + /** * @param {Pick} options */ diff --git a/src/commands/plan-push.js b/src/commands/plan-push.js index 073394e..d8bfd70 100644 --- a/src/commands/plan-push.js +++ b/src/commands/plan-push.js @@ -2,21 +2,40 @@ import { createHash, randomUUID } from "node:crypto"; import { lstatSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { + FirstDraftProtocolError, + isDiagnostic, + isProblemBody, + readResponseBody, + responseMediaType, + sendRequest, +} from "../api-response.js"; import { isFileSystemError } from "../file-system.js"; +import { + MAX_STATE_BYTES, + PlanStateConfigurationError, + isStrongEtag, + normalizeApiUrl, + readLocalFile, + readPlanState, +} from "../plan-state.js"; + +export { + PlanStateConfigurationError as PlanPushConfigurationError, + PlanStateLocalError as PlanPushLocalError, + normalizeApiUrl, +} from "../plan-state.js"; +export { + FirstDraftNetworkError as PlanPushNetworkError, + FirstDraftProtocolError as PlanPushProtocolError, +} from "../api-response.js"; export const DEFAULT_API_URL = "https://firstdraft.com"; const FOUNDATION_PLAN_MEDIA_TYPE = "application/vnd.firstdraft.foundation-plan+json"; -const STATE_FORMAT = "firstdraft.cli-state/1"; const MAX_PLAN_BYTES = 1024 * 1024; -const MAX_STATE_BYTES = 4096; -const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; -const MAX_ETAG_BYTES = 1024; const REQUEST_TIMEOUT_MS = 30_000; -const PROJECT_ID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; -const STRONG_ETAG_PATTERN = /^"(?:[\x21\x23-\x7e\x80-\xff])*"$/; /** * @typedef {object} PlanPushFileSystem @@ -34,28 +53,6 @@ const DEFAULT_FILE_SYSTEM = { writeFileSync, }; -export class PlanPushConfigurationError extends Error {} -export class PlanPushLocalError 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 { /** * @param {{format: string, project_id: string, api_url: string, foundation_plan_etag: string}} recoveryState @@ -106,13 +103,11 @@ export async function pushPlan({ createTemporaryId = randomUUID, createRequestSignal = () => AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) { + const state = readPlanState({ cwd, fileSystem }); const directory = path.join(cwd, ".firstdraft"); - assertLocalDirectory(directory, fileSystem); const planPath = path.join(directory, "foundation-plan.json"); const statePath = path.join(directory, "state.json"); const planSource = readLocalFile(planPath, MAX_PLAN_BYTES, fileSystem); - const stateSource = readLocalFile(statePath, MAX_STATE_BYTES, fileSystem); - const state = loadState(stateSource); const origin = resolveApiUrl(apiUrl, state.api_url); const endpoint = new URL( `/v1/projects/${state.project_id}/foundation-plan`, @@ -141,7 +136,7 @@ export async function pushPlan({ (responseMediaType(response) !== "application/json" || !isDiagnosticBody(body, sourceSha256)) ) { - throw new PlanPushProtocolError( + throw new FirstDraftProtocolError( "First Draft returned invalid diagnostics.", { status: response.status }, ); @@ -149,7 +144,7 @@ export async function pushPlan({ if (response.status !== 200 && response.status !== 201) { if (response.ok) { - throw new PlanPushProtocolError( + throw new FirstDraftProtocolError( "First Draft returned an unexpected success status.", { status: response.status }, ); @@ -173,7 +168,7 @@ export async function pushPlan({ !isStrongEtag(etag) || !isAcceptedBody(body, state.project_id, sourceSha256) ) { - throw new PlanPushProtocolError( + throw new FirstDraftProtocolError( "First Draft returned an invalid success response.", { status: response.status }, ); @@ -205,7 +200,7 @@ function resolveApiUrl(configured, stored) { configuredOrigin !== undefined && stored !== configuredOrigin ) { - throw new PlanPushConfigurationError( + throw new PlanStateConfigurationError( "The configured API URL does not match local state.", ); } @@ -213,242 +208,6 @@ function resolveApiUrl(configured, stored) { return stored ?? configuredOrigin ?? DEFAULT_API_URL; } -/** @param {string} value */ -export function normalizeApiUrl(value) { - let url; - - try { - url = new URL(value); - } catch (error) { - if (!(error instanceof TypeError)) throw error; - - throw new PlanPushConfigurationError("The API URL is invalid.", { - cause: error, - }); - } - - const loopbackHttp = - url.protocol === "http:" && - (url.hostname === "127.0.0.1" || - url.hostname === "localhost" || - url.hostname === "[::1]"); - if ( - (url.protocol !== "https:" && !loopbackHttp) || - url.username !== "" || - url.password !== "" || - url.pathname !== "/" || - url.search !== "" || - url.hash !== "" - ) { - throw new PlanPushConfigurationError("The API URL is invalid."); - } - - return url.origin; -} - -/** @param {string} directory @param {PlanPushFileSystem} fileSystem */ -function assertLocalDirectory(directory, fileSystem) { - try { - if (!fileSystem.lstatSync(directory).isDirectory()) { - throw new PlanPushLocalError( - "The local First Draft directory is invalid.", - ); - } - } catch (error) { - if (error instanceof PlanPushLocalError) throw error; - if (!isFileSystemError(error)) throw error; - - throw new PlanPushLocalError( - "The local First Draft directory could not be read.", - { cause: error }, - ); - } -} - -/** - * @param {string} filePath - * @param {number} maximumBytes - * @param {PlanPushFileSystem} fileSystem - */ -function readLocalFile(filePath, maximumBytes, fileSystem) { - try { - const stat = fileSystem.lstatSync(filePath); - if (!stat.isFile() || stat.size > maximumBytes) { - throw new PlanPushLocalError("A local First Draft file is invalid."); - } - - const source = fileSystem.readFileSync(filePath); - if (!Buffer.isBuffer(source) || source.byteLength > maximumBytes) { - throw new PlanPushLocalError("A local First Draft file is invalid."); - } - - return source; - } catch (error) { - if (error instanceof PlanPushLocalError) throw error; - if (!isFileSystemError(error)) throw error; - - throw new PlanPushLocalError( - "A local First Draft file could not be read.", - { - cause: error, - }, - ); - } -} - -/** @param {Buffer} source */ -function loadState(source) { - let state; - - try { - state = JSON.parse( - new TextDecoder("utf-8", { fatal: true }).decode(source), - ); - } catch (error) { - if (!(error instanceof SyntaxError || error instanceof TypeError)) { - throw error; - } - - throw new PlanPushLocalError("Local First Draft state is invalid.", { - cause: error, - }); - } - - if (!isRecord(state)) { - throw new PlanPushLocalError("Local First Draft state is invalid."); - } - - const keys = Object.keys(state).sort(); - const hasRemoteState = - state.api_url !== undefined || state.foundation_plan_etag !== undefined; - const expectedKeys = hasRemoteState - ? ["api_url", "format", "foundation_plan_etag", "project_id"] - : ["format", "project_id"]; - let storedApiUrl; - - if (typeof state.api_url === "string") { - try { - storedApiUrl = normalizeApiUrl(state.api_url); - } catch (error) { - if (!(error instanceof PlanPushConfigurationError)) throw error; - - throw new PlanPushLocalError("Local First Draft state is invalid.", { - cause: error, - }); - } - } - - if ( - !arraysEqual(keys, expectedKeys) || - state.format !== STATE_FORMAT || - typeof state.project_id !== "string" || - !PROJECT_ID_PATTERN.test(state.project_id) || - (hasRemoteState && storedApiUrl !== state.api_url) || - (state.foundation_plan_etag !== undefined && - !isStrongEtag(state.foundation_plan_etag)) - ) { - throw new PlanPushLocalError("Local First Draft state is invalid."); - } - - return { - format: state.format, - project_id: state.project_id, - ...(storedApiUrl ? { api_url: storedApiUrl } : {}), - ...(typeof state.foundation_plan_etag === "string" - ? { foundation_plan_etag: state.foundation_plan_etag } - : {}), - }; -} - -/** - * @param {typeof globalThis.fetch} fetchFunction - * @param {URL} endpoint - * @param {RequestInit} request - */ -async function sendRequest(fetchFunction, endpoint, request) { - try { - return await fetchFunction(endpoint, request); - } catch (error) { - if (!(error instanceof Error)) throw error; - - throw new PlanPushNetworkError("The First Draft request failed.", { - cause: error, - }); - } -} - -/** @param {Response} response */ -async function readResponseBody(response) { - const bytes = await readResponseBytes(response); - - let text; - try { - text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch (error) { - if (!(error instanceof TypeError)) throw error; - - return null; - } - - try { - return JSON.parse(text); - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - - return null; - } -} - -/** @param {Response} response */ -async function readResponseBytes(response) { - const declaredLength = response.headers.get("content-length"); - if ( - declaredLength !== null && - /^\d+$/.test(declaredLength) && - Number(declaredLength) > MAX_RESPONSE_BYTES - ) { - if (response.body !== null) { - await response.body.cancel().catch(() => undefined); - } - throw new PlanPushProtocolError("The First Draft response is too large.", { - status: response.status, - }); - } - - if (response.body === null) return Buffer.alloc(0); - - const reader = response.body.getReader(); - const chunks = []; - let byteLength = 0; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - byteLength += value.byteLength; - if (byteLength > MAX_RESPONSE_BYTES) { - await reader.cancel().catch(() => undefined); - throw new PlanPushProtocolError( - "The First Draft response is too large.", - { status: response.status }, - ); - } - chunks.push(Buffer.from(value)); - } - } catch (error) { - if (error instanceof PlanPushProtocolError) throw error; - if (!(error instanceof Error)) throw error; - - throw new PlanPushNetworkError("The First Draft response failed.", { - cause: error, - status: response.status, - }); - } - - return Buffer.concat(chunks, byteLength); -} - /** * @param {object} options * @param {string} options.statePath @@ -495,17 +254,6 @@ function saveState({ } } -/** @param {Response} response */ -function responseMediaType(response) { - return ( - response.headers - .get("content-type") - ?.split(";", 1)[0] - ?.trim() - .toLowerCase() ?? "" - ); -} - /** * @param {unknown} body * @param {string} projectId @@ -543,55 +291,12 @@ function isDiagnosticBody(body, sourceSha256) { ); } -/** @param {Response} response @param {unknown} body */ -function isProblemBody(response, body) { - return ( - responseMediaType(response) === "application/problem+json" && - isRecord(body) && - (body.type === undefined || body.type === "about:blank") && - typeof body.title === "string" && - body.status === response.status && - typeof body.code === "string" && - typeof body.detail === "string" - ); -} - /** @param {unknown} value */ function isWarningDiagnostic(value) { return isDiagnostic(value) && value.severity === "warning"; } -/** - * @param {unknown} value - * @returns {value is Record & {severity: "error" | "warning"}} - */ -function isDiagnostic(value) { - return ( - isRecord(value) && - typeof value.code === "string" && - (value.severity === "error" || value.severity === "warning") && - typeof value.message === "string" - ); -} - /** @param {unknown} value @returns {value is Record} */ function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } - -/** @param {unknown} value @returns {value is string} */ -function isStrongEtag(value) { - return ( - typeof value === "string" && - Buffer.byteLength(value) <= MAX_ETAG_BYTES && - STRONG_ETAG_PATTERN.test(value) - ); -} - -/** @param {string[]} left @param {string[]} right */ -function arraysEqual(left, right) { - return ( - left.length === right.length && - left.every((value, index) => value === right[index]) - ); -} diff --git a/src/commands/plan-status.js b/src/commands/plan-status.js new file mode 100644 index 0000000..4a23633 --- /dev/null +++ b/src/commands/plan-status.js @@ -0,0 +1,469 @@ +import { lstatSync, readFileSync } from "node:fs"; + +import { + FirstDraftNetworkError, + FirstDraftProtocolError, + isProblemBody, + readResponseBody, + responseMediaType, + sendRequest, +} from "../api-response.js"; +import { isUuidV7, readPlanState } from "../plan-state.js"; + +const REQUEST_TIMEOUT_MS = 30_000; +const WAIT_TIMEOUT_MS = 120_000; +const POLL_INTERVAL_MS = 1_000; +const TERMINAL_STATUSES = new Set([ + "valid", + "issues_found", + "analysis_failed", + "superseded", +]); +const ALL_STATUSES = new Set(["processing", ...TERMINAL_STATUSES]); +const RESPONSE_KEYS = ["analysis", "project"]; +const PROJECT_KEYS = ["graph_version", "id"]; +const ANALYSIS_KEYS = [ + "analyzer_release", + "completed_at", + "diagnostics", + "graph_version", + "id", + "started_at", + "status", +]; +const DIAGNOSTIC_KEYS = [ + "code", + "location", + "message", + "related_locations", + "severity", + "subject", + "suggestions", +]; + +/** + * @typedef {object} PlanStatusFileSystem + * @property {typeof lstatSync} lstatSync + * @property {typeof readFileSync} readFileSync + */ + +/** @type {PlanStatusFileSystem} */ +const DEFAULT_FILE_SYSTEM = { lstatSync, readFileSync }; + +export class PlanStatusNotPushedError extends Error {} + +export class PlanStatusChangedError extends Error { + /** @param {AnalysisResponse} current */ + constructor(current) { + super("The current analysis changed while it was being polled."); + this.current = current; + } +} + +export class PlanStatusTimeoutError extends Error { + /** @param {AnalysisResponse} current */ + constructor(current) { + super("The current analysis did not finish before the wait deadline."); + this.current = current; + } +} + +/** + * @typedef {object} PlanStatusOptions + * @property {string} cwd + * @property {boolean} [wait] + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {PlanStatusFileSystem} [fileSystem] + * @property {(timeoutMs: number) => AbortSignal} [createRequestSignal] + * @property {(delayMs: number) => Promise} [sleep] + * @property {() => number} [now] + */ + +/** + * @typedef {object} AnalysisResponse + * @property {{id: string, graph_version: number}} project + * @property {{id: string, graph_version: number, analyzer_release: string, status: string, diagnostics: Record[], started_at: string | null, completed_at: string | null}} analysis + */ + +/** @typedef {{source_pointer: string} | {line: number, column: number}} SourceLocation */ +/** @typedef {{kind: string, readable_path: string, subject_uuid?: string}} DiagnosticSubject */ +/** @typedef {{code: string, severity: "error" | "warning", message: string, location: SourceLocation, subject: DiagnosticSubject | null, related_locations: SourceLocation[], suggestions: string[]}} StructuredDiagnostic */ + +/** + * @typedef {object} StatusSuccess + * @property {200} status + * @property {AnalysisResponse} body + */ + +/** + * @typedef {object} StatusRejection + * @property {number} status + * @property {"problem" | null} responseKind + * @property {Record | null} body + */ + +/** + * @param {PlanStatusOptions} options + * @returns {Promise} + */ +export async function readPlanStatus({ + cwd, + wait = false, + fetchFunction = globalThis.fetch, + fileSystem = DEFAULT_FILE_SYSTEM, + createRequestSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), + sleep = sleepFor, + now = Date.now, +}) { + const state = readPlanState({ cwd, fileSystem }); + if (state.api_url === undefined) { + throw new PlanStatusNotPushedError( + "The local Foundation Plan has not been pushed.", + ); + } + + const endpoint = new URL( + `/v1/projects/${state.project_id}/analysis`, + state.api_url, + ); + const deadline = wait ? now() + WAIT_TIMEOUT_MS : null; + /** @type {AnalysisResponse | null} */ + let first = null; + /** @type {AnalysisResponse | null} */ + let current = null; + + while (true) { + if (deadline !== null && current !== null && now() >= deadline) { + throw new PlanStatusTimeoutError(current); + } + + const requestTimeout = + deadline === null + ? REQUEST_TIMEOUT_MS + : Math.max(1, Math.min(REQUEST_TIMEOUT_MS, deadline - now())); + let response; + let body; + try { + response = await sendRequest(fetchFunction, endpoint, { + method: "GET", + headers: { Accept: "application/json, application/problem+json" }, + redirect: "error", + signal: createRequestSignal(requestTimeout), + }); + body = await readResponseBody(response); + } catch (error) { + if ( + error instanceof FirstDraftNetworkError && + deadline !== null && + current !== null && + now() >= deadline + ) { + throw new PlanStatusTimeoutError(current); + } + + throw error; + } + + if (response.status !== 200) { + if (response.ok) { + throw new FirstDraftProtocolError( + "First Draft returned an unexpected success status.", + { status: response.status }, + ); + } + + if (isProblemBody(response, body)) { + return { + status: response.status, + responseKind: "problem", + body, + }; + } + + return { + status: response.status, + responseKind: null, + body: null, + }; + } + + const parsed = parseAnalysisResponse(body, state.project_id); + if (responseMediaType(response) !== "application/json" || parsed === null) { + throw new FirstDraftProtocolError( + "First Draft returned an invalid analysis response.", + { status: response.status }, + ); + } + + current = parsed; + first ??= parsed; + if ( + current.analysis.id !== first.analysis.id || + current.analysis.graph_version !== first.analysis.graph_version + ) { + throw new PlanStatusChangedError(current); + } + + if (!wait || TERMINAL_STATUSES.has(current.analysis.status)) { + return { status: 200, body: current }; + } + + const remaining = deadline === null ? 0 : deadline - now(); + if (remaining <= 0) throw new PlanStatusTimeoutError(current); + await sleep(Math.min(POLL_INTERVAL_MS, remaining)); + } +} + +/** @param {number} delayMs */ +function sleepFor(delayMs) { + return new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); +} + +/** + * @param {unknown} value + * @param {string} projectId + * @returns {AnalysisResponse | null} + */ +function parseAnalysisResponse(value, projectId) { + if (!hasRequiredKeys(value, RESPONSE_KEYS)) return null; + + const project = value.project; + const analysis = value.analysis; + if ( + !hasRequiredKeys(project, PROJECT_KEYS) || + !hasRequiredKeys(analysis, ANALYSIS_KEYS) || + project.id !== projectId || + !isGraphVersion(project.graph_version) || + !isUuidV7(analysis.id) || + !isGraphVersion(analysis.graph_version) || + analysis.graph_version !== project.graph_version || + typeof analysis.analyzer_release !== "string" || + Buffer.byteLength(analysis.analyzer_release) === 0 || + Buffer.byteLength(analysis.analyzer_release) > 256 || + typeof analysis.status !== "string" || + !ALL_STATUSES.has(analysis.status) || + !Array.isArray(analysis.diagnostics) || + !analysis.diagnostics.every(isStructuredDiagnostic) || + !isNullableTimestamp(analysis.started_at) || + !isNullableTimestamp(analysis.completed_at) + ) { + return null; + } + + const terminal = TERMINAL_STATUSES.has(analysis.status); + if ( + (terminal && analysis.completed_at === null) || + (!terminal && analysis.completed_at !== null) || + (analysis.started_at !== null && + analysis.completed_at !== null && + timestampMilliseconds(analysis.completed_at) < + timestampMilliseconds(analysis.started_at)) || + (analysis.status === "valid" && + analysis.diagnostics.some( + (diagnostic) => diagnostic.severity === "error", + )) || + (analysis.status === "issues_found" && + !analysis.diagnostics.some( + (diagnostic) => diagnostic.severity === "error", + )) + ) { + return null; + } + + return { + project: { + id: project.id, + graph_version: project.graph_version, + }, + analysis: { + id: analysis.id, + graph_version: analysis.graph_version, + analyzer_release: analysis.analyzer_release, + status: analysis.status, + diagnostics: analysis.diagnostics.map(copyDiagnostic), + started_at: analysis.started_at, + completed_at: analysis.completed_at, + }, + }; +} + +/** @param {unknown} value @returns {value is StructuredDiagnostic} */ +function isStructuredDiagnostic(value) { + if ( + !hasRequiredKeys(value, DIAGNOSTIC_KEYS) || + typeof value.code !== "string" || + (value.severity !== "error" && value.severity !== "warning") || + typeof value.message !== "string" || + !isSourceLocation(value.location) || + !isDiagnosticSubject(value.subject) || + !Array.isArray(value.related_locations) || + !value.related_locations.every(isSourceLocation) || + !Array.isArray(value.suggestions) || + !value.suggestions.every((suggestion) => typeof suggestion === "string") + ) { + return false; + } + + return true; +} + +/** @param {unknown} value @returns {value is SourceLocation} */ +function isSourceLocation(value) { + if (!isRecord(value)) return false; + + const pointerLocation = hasOwn(value, "source_pointer"); + const coordinateLocation = hasOwn(value, "line") || hasOwn(value, "column"); + if (pointerLocation === coordinateLocation) return false; + + if (pointerLocation) { + return isJsonPointer(value.source_pointer); + } + + return ( + hasOwn(value, "line") && + hasOwn(value, "column") && + Number.isSafeInteger(value.line) && + Number(value.line) > 0 && + Number.isSafeInteger(value.column) && + Number(value.column) > 0 + ); +} + +/** @param {unknown} value @returns {value is DiagnosticSubject | null} */ +function isDiagnosticSubject(value) { + if (value === null) return true; + if (!isRecord(value)) return false; + + return ( + hasOwn(value, "kind") && + hasOwn(value, "readable_path") && + typeof value.kind === "string" && + typeof value.readable_path === "string" && + (value.subject_uuid === undefined || isUuidV7(value.subject_uuid)) + ); +} + +/** @param {unknown} value */ +function isJsonPointer(value) { + return ( + typeof value === "string" && + (value === "" || value.startsWith("/")) && + !/~(?:[^01]|$)/.test(value) + ); +} + +/** @param {unknown} value @returns {value is number} */ +function isGraphVersion(value) { + return Number.isSafeInteger(value) && Number(value) >= 1; +} + +/** @param {unknown} value @returns {value is string | null} */ +function isNullableTimestamp(value) { + return value === null || timestampParts(value) !== null; +} + +/** @param {string} value */ +function timestampMilliseconds(value) { + const parts = timestampParts(value); + if (parts === null) return Number.NaN; + + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + + return Date.parse(value.replace(/:60(?=[.,Zz+-])/, ":59")) + 1_000; +} + +/** @param {unknown} value */ +function timestampParts(value) { + if (typeof value !== "string") return null; + + const match = + /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/.exec( + value, + ); + if (!match) return null; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[8] === undefined ? 0 : Number(match[8]); + const offsetMinute = match[9] === undefined ? 0 : Number(match[9]); + if ( + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth(year, month) || + hour > 23 || + minute > 59 || + second > 60 || + (second === 60 && (hour !== 23 || minute !== 59)) || + offsetHour > 23 || + offsetMinute > 59 + ) { + return null; + } + + return { year, month, day, hour, minute, second }; +} + +/** @param {number} year @param {number} month */ +function daysInMonth(year, month) { + if (month === 2) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; + } + + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +/** @param {StructuredDiagnostic} diagnostic */ +function copyDiagnostic(diagnostic) { + return { + code: diagnostic.code, + severity: diagnostic.severity, + message: diagnostic.message, + location: copyLocation(diagnostic.location), + subject: + diagnostic.subject === null + ? null + : { + kind: diagnostic.subject.kind, + readable_path: diagnostic.subject.readable_path, + ...(diagnostic.subject.subject_uuid === undefined + ? {} + : { subject_uuid: diagnostic.subject.subject_uuid }), + }, + related_locations: diagnostic.related_locations.map(copyLocation), + suggestions: [...diagnostic.suggestions], + }; +} + +/** @param {SourceLocation} location */ +function copyLocation(location) { + return "source_pointer" in location + ? { source_pointer: location.source_pointer } + : { line: location.line, column: location.column }; +} + +/** + * @param {unknown} value + * @param {string[]} keys + * @returns {value is Record} + */ +function hasRequiredKeys(value, keys) { + return isRecord(value) && keys.every((key) => hasOwn(value, key)); +} + +/** @param {Record} value @param {string} key */ +function hasOwn(value, key) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/plan-state.js b/src/plan-state.js new file mode 100644 index 0000000..bb365f8 --- /dev/null +++ b/src/plan-state.js @@ -0,0 +1,214 @@ +import { lstatSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { isFileSystemError } from "./file-system.js"; + +const STATE_FORMAT = "firstdraft.cli-state/1"; +export const MAX_STATE_BYTES = 4096; +const MAX_ETAG_BYTES = 1024; +const PROJECT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const STRONG_ETAG_PATTERN = /^"(?:[\x21\x23-\x7e\x80-\xff])*"$/; + +/** + * @typedef {object} PlanStateFileSystem + * @property {typeof lstatSync} lstatSync + * @property {typeof readFileSync} readFileSync + */ + +/** @type {PlanStateFileSystem} */ +const DEFAULT_FILE_SYSTEM = { lstatSync, readFileSync }; + +export class PlanStateConfigurationError extends Error {} +export class PlanStateLocalError extends Error {} + +/** + * @param {object} options + * @param {string} options.cwd + * @param {PlanStateFileSystem} [options.fileSystem] + */ +export function readPlanState({ cwd, fileSystem = DEFAULT_FILE_SYSTEM }) { + const directory = path.join(cwd, ".firstdraft"); + assertLocalDirectory(directory, fileSystem); + const source = readLocalFile( + path.join(directory, "state.json"), + MAX_STATE_BYTES, + fileSystem, + ); + + return parsePlanState(source); +} + +/** @param {string} value */ +export function normalizeApiUrl(value) { + let url; + + try { + url = new URL(value); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + + throw new PlanStateConfigurationError("The API URL is invalid.", { + cause: error, + }); + } + + const loopbackHttp = + url.protocol === "http:" && + (url.hostname === "127.0.0.1" || + url.hostname === "localhost" || + url.hostname === "[::1]"); + if ( + (url.protocol !== "https:" && !loopbackHttp) || + url.username !== "" || + url.password !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" + ) { + throw new PlanStateConfigurationError("The API URL is invalid."); + } + + return url.origin; +} + +/** @param {unknown} value @returns {value is string} */ +export function isUuidV7(value) { + return typeof value === "string" && PROJECT_ID_PATTERN.test(value); +} + +/** + * @param {string} filePath + * @param {number} maximumBytes + * @param {PlanStateFileSystem} fileSystem + */ +export function readLocalFile(filePath, maximumBytes, fileSystem) { + try { + const stat = fileSystem.lstatSync(filePath); + if (!stat.isFile() || stat.size > maximumBytes) { + throw new PlanStateLocalError("A local First Draft file is invalid."); + } + + const source = fileSystem.readFileSync(filePath); + if (!Buffer.isBuffer(source) || source.byteLength > maximumBytes) { + throw new PlanStateLocalError("A local First Draft file is invalid."); + } + + return source; + } catch (error) { + if (error instanceof PlanStateLocalError) throw error; + if (!isFileSystemError(error)) throw error; + + throw new PlanStateLocalError( + "A local First Draft file could not be read.", + { cause: error }, + ); + } +} + +/** + * @param {string} directory + * @param {PlanStateFileSystem} fileSystem + */ +function assertLocalDirectory(directory, fileSystem) { + try { + if (!fileSystem.lstatSync(directory).isDirectory()) { + throw new PlanStateLocalError( + "The local First Draft directory is invalid.", + ); + } + } catch (error) { + if (error instanceof PlanStateLocalError) throw error; + if (!isFileSystemError(error)) throw error; + + throw new PlanStateLocalError( + "The local First Draft directory could not be read.", + { cause: error }, + ); + } +} + +/** @param {Buffer} source */ +function parsePlanState(source) { + let state; + + try { + state = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(source), + ); + } catch (error) { + if (!(error instanceof SyntaxError || error instanceof TypeError)) { + throw error; + } + + throw new PlanStateLocalError("Local First Draft state is invalid.", { + cause: error, + }); + } + + if (!isRecord(state)) { + throw new PlanStateLocalError("Local First Draft state is invalid."); + } + + const keys = Object.keys(state).sort(); + const hasRemoteState = + state.api_url !== undefined || state.foundation_plan_etag !== undefined; + const expectedKeys = hasRemoteState + ? ["api_url", "format", "foundation_plan_etag", "project_id"] + : ["format", "project_id"]; + let storedApiUrl; + + if (typeof state.api_url === "string") { + try { + storedApiUrl = normalizeApiUrl(state.api_url); + } catch (error) { + if (!(error instanceof PlanStateConfigurationError)) throw error; + + throw new PlanStateLocalError("Local First Draft state is invalid.", { + cause: error, + }); + } + } + + if ( + !arraysEqual(keys, expectedKeys) || + state.format !== STATE_FORMAT || + !isUuidV7(state.project_id) || + (hasRemoteState && storedApiUrl !== state.api_url) || + (state.foundation_plan_etag !== undefined && + !isStrongEtag(state.foundation_plan_etag)) + ) { + throw new PlanStateLocalError("Local First Draft state is invalid."); + } + + return { + format: state.format, + project_id: state.project_id, + ...(storedApiUrl ? { api_url: storedApiUrl } : {}), + ...(typeof state.foundation_plan_etag === "string" + ? { foundation_plan_etag: state.foundation_plan_etag } + : {}), + }; +} + +/** @param {unknown} value @returns {value is string} */ +export function isStrongEtag(value) { + return ( + typeof value === "string" && + Buffer.byteLength(value) <= MAX_ETAG_BYTES && + STRONG_ETAG_PATTERN.test(value) + ); +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** @param {string[]} left @param {string[]} right */ +function arraysEqual(left, right) { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} diff --git a/test/plan-init.test.js b/test/plan-init.test.js index 9ed1306..91a6572 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -29,6 +29,7 @@ Commands: init Create a local empty Foundation Plan subject-id Generate a UUIDv7 for a new Plan subject push Send the local Foundation Plan to First Draft + status Read the current whole-graph analysis status Options: -h, --help Show help diff --git a/test/plan-status.test.js b/test/plan-status.test.js new file mode 100644 index 0000000..47690c5 --- /dev/null +++ b/test/plan-status.test.js @@ -0,0 +1,1143 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { + mkdtempSync, + mkdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { run } from "../src/cli.js"; + +/** @typedef {{input: string | URL | Request, init: RequestInit | undefined}} FetchCall */ + +const PROJECT_ID = "01900000-0000-7000-8000-000000000301"; +const OTHER_PROJECT_ID = "01900000-0000-7000-8000-000000000302"; +const ANALYSIS_ID = "01900000-0000-7000-8000-000000000401"; +const OTHER_ANALYSIS_ID = "01900000-0000-7000-8000-000000000402"; +const SUBJECT_ID = "01900000-0000-7000-8000-000000000501"; +const API_URL = "https://api.example.test"; +const ETAG = '"opaque:plan-validator"'; +const STARTED_AT = "2026-07-30T12:00:00.123Z"; +const COMPLETED_AT = "2026-07-30T12:00:01.456Z"; +const PLAN_STATUS_HELP = `First Draft CLI + +Usage: + firstdraft plan status [--wait] + +Options: + --wait Poll until the current analysis reaches a terminal status + -h, --help Show help + +The command uses only the API origin pinned by a successful plan push. +Without --wait, it makes exactly one status request. +`; +const INVALID_ARGUMENTS = jsonOutput({ + error: "invalid_arguments", + detail: "Invalid arguments. Run 'firstdraft plan status --help' for usage.", +}); +const LOCAL_INPUT_UNREADABLE = jsonOutput({ + error: "local_input_unreadable", + detail: + "Could not read valid local First Draft state. No network request was made. Run 'firstdraft plan init' if this directory is not initialized; otherwise repair the private state before retrying.", +}); +const PROJECT_NOT_PUSHED = jsonOutput({ + error: "project_not_pushed", + detail: + "The local Foundation Plan has not been pushed successfully. Run 'firstdraft plan push' before requesting analysis status.", +}); + +test("plan status help has no local or network prerequisites", async () => { + const inaccessible = () => { + throw new Error("help must not access dependencies"); + }; + + for (const argv of [ + ["plan", "status", "--help"], + ["plan", "status", "-h"], + ["plan", "status", "--help", "--help"], + ["plan", "status", "--wait", "--help"], + ]) { + assert.deepEqual( + await invoke(argv, { + fetchFunction: inaccessible, + planPushFileSystem: inaccessibleFileSystem(), + }), + { status: 0, stdout: PLAN_STATUS_HELP, stderr: "" }, + ); + } +}); + +test("plan status validates arguments before local or network access", async () => { + for (const argv of [ + ["plan", "status", "canary-secret-positional"], + ["plan", "status", "--canary-secret-option"], + ["plan", "status", "--wait", "--wait"], + ]) { + const result = await invoke(argv, { + fetchFunction: inaccessibleFetch(), + planPushFileSystem: inaccessibleFileSystem(), + }); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: INVALID_ARGUMENTS, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("plan status makes one bounded GET to only the pinned origin", async (context) => { + const cwd = remoteDirectory(context); + /** @type {FetchCall[]} */ + const calls = []; + const signal = new AbortController().signal; + /** @type {number[]} */ + const requestTimeouts = []; + const body = analysisBody("processing"); + const result = await invoke(["plan", "status"], { + cwd, + apiUrl: "https://canary-secret.example", + fetchFunction: recordingFetch([jsonResponse(body)], calls), + createRequestSignal: (timeoutMs) => { + if (timeoutMs === undefined) throw new Error("missing request timeout"); + requestTimeouts.push(timeoutMs); + return signal; + }, + }); + + assert.deepEqual(result, { + status: 0, + stdout: jsonOutput(body), + stderr: "", + }); + assert.deepEqual(requestTimeouts, [30_000]); + assert.equal(calls.length, 1); + const [call] = calls; + assert(call); + assert.equal( + String(call.input), + `${API_URL}/v1/projects/${PROJECT_ID}/analysis`, + ); + assert.equal(call.init?.method, "GET"); + assert.equal(call.init?.redirect, "error"); + assert.equal(call.init?.signal, signal); + assert.deepEqual( + new Headers(call.init?.headers), + new Headers({ + Accept: "application/json, application/problem+json", + }), + ); + assert.equal(call.init?.body, undefined); + assert.doesNotMatch(result.stdout, /api\.example|opaque|canary-secret/); +}); + +test("additive response fields are ignored rather than leaked or rejected", async (context) => { + const cwd = remoteDirectory(context); + const warning = diagnostic("warning"); + const expected = analysisBody("valid", { + analysis: { diagnostics: [warning] }, + }); + const extended = { + ...expected, + canary: "canary-secret-envelope", + project: { + ...expected.project, + canary: "canary-secret-project", + }, + analysis: { + ...expected.analysis, + canary: "canary-secret-analysis", + diagnostics: [ + { + ...warning, + canary: "canary-secret-diagnostic", + location: { + ...warning.location, + canary: "canary-secret-location", + }, + subject: { + ...warning.subject, + canary: "canary-secret-subject", + }, + related_locations: [ + { + ...warning.related_locations[0], + canary: "canary-secret-related-location", + }, + ], + }, + ], + }, + }; + const result = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch([jsonResponse(extended)], []), + }); + + assert.deepEqual(result, { + status: 0, + stdout: jsonOutput(expected), + stderr: "", + }); + assert.doesNotMatch(result.stdout, /canary-secret/); +}); + +test("diagnostics preserve each supported optional subject shape", async (context) => { + for (const subject of [ + null, + { kind: "application", readable_path: "application" }, + ]) { + const cwd = remoteDirectory(context); + const body = analysisBody("valid", { + analysis: { diagnostics: [diagnostic("warning", { subject })] }, + }); + const result = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch([jsonResponse(body)], []), + }); + + assert.deepEqual(result, { + status: 0, + stdout: jsonOutput(body), + stderr: "", + }); + } +}); + +test("all validated analysis states are command results rather than transport failures", async (context) => { + for (const status of [ + "processing", + "valid", + "issues_found", + "analysis_failed", + "superseded", + ]) { + const cwd = remoteDirectory(context); + const body = analysisBody(status); + const result = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch([jsonResponse(body)], []), + }); + + assert.deepEqual(result, { + status: 0, + stdout: jsonOutput(body), + stderr: "", + }); + } +}); + +test("an initialized but unpushed Plan explains the required recovery", async (context) => { + const cwd = localDirectory(context, { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + }); + + const result = await invoke(["plan", "status"], { + cwd, + apiUrl: "https://canary-secret.example", + fetchFunction: inaccessibleFetch(), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PROJECT_NOT_PUSHED, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); +}); + +test("invalid private state never selects a network destination", async (context) => { + const cases = [ + Buffer.from("not json"), + Buffer.from([0xff]), + stateSource({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: "http://canary-secret.example", + foundation_plan_etag: ETAG, + }), + stateSource({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + }), + stateSource({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + foundation_plan_etag: ETAG, + canary: "canary-secret-extra-key", + }), + Buffer.alloc(4097, 0x20), + ]; + + for (const source of cases) { + const cwd = temporaryDirectory(context); + mkdirSync(path.join(cwd, ".firstdraft")); + writeFileSync(path.join(cwd, ".firstdraft", "state.json"), source); + const result = await invoke(["plan", "status"], { + cwd, + fetchFunction: inaccessibleFetch(), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: LOCAL_INPUT_UNREADABLE, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("missing paths and state symlinks are rejected without following them", async (context) => { + const missing = temporaryDirectory(context); + assert.deepEqual( + await invoke(["plan", "status"], { + cwd: missing, + fetchFunction: inaccessibleFetch(), + }), + { status: 1, stdout: "", stderr: LOCAL_INPUT_UNREADABLE }, + ); + + const target = remoteDirectory(context); + const symlinked = temporaryDirectory(context); + mkdirSync(path.join(symlinked, ".firstdraft")); + symlinkSync( + path.join(target, ".firstdraft", "state.json"), + path.join(symlinked, ".firstdraft", "state.json"), + ); + assert.deepEqual( + await invoke(["plan", "status"], { + cwd: symlinked, + fetchFunction: inaccessibleFetch(), + }), + { status: 1, stdout: "", stderr: LOCAL_INPUT_UNREADABLE }, + ); +}); + +test("wait polls sequentially until the same analysis becomes valid", async (context) => { + const cwd = remoteDirectory(context); + const processing = analysisBody("processing"); + const started = analysisBody("processing", { + analysis: { started_at: STARTED_AT }, + }); + const valid = analysisBody("valid"); + /** @type {FetchCall[]} */ + const calls = []; + /** @type {string[]} */ + const events = []; + const responses = [processing, started, valid].map((body) => + jsonResponse(body), + ); + let active = false; + const result = await invoke(["plan", "status", "--wait"], { + cwd, + fetchFunction: async (input, init) => { + assert.equal(active, false, "poll requests must not overlap"); + active = true; + events.push("fetch"); + calls.push({ input, init }); + const response = responses.shift(); + assert(response); + active = false; + return response; + }, + planStatusSleep: async (delayMs) => { + assert.equal(active, false); + assert.equal(delayMs, 1_000); + events.push("sleep"); + }, + }); + + assert.deepEqual(result, { + status: 0, + stdout: jsonOutput(valid), + stderr: "", + }); + assert.deepEqual(events, ["fetch", "sleep", "fetch", "sleep", "fetch"]); + assert.equal(calls.length, 3); + assert(calls.every((call) => String(call.input).startsWith(API_URL))); +}); + +test("wait stops on each terminal analysis status without another poll", async (context) => { + for (const status of [ + "valid", + "issues_found", + "analysis_failed", + "superseded", + ]) { + const cwd = remoteDirectory(context); + const terminal = analysisBody(status); + let sleeps = 0; + const result = await invoke(["plan", "status", "--wait"], { + cwd, + fetchFunction: recordingFetch( + [jsonResponse(analysisBody("processing")), jsonResponse(terminal)], + [], + ), + planStatusSleep: async () => { + sleeps += 1; + }, + }); + + assert.deepEqual(result, { + status: 0, + stdout: jsonOutput(terminal), + stderr: "", + }); + assert.equal(sleeps, 1); + } +}); + +test("wait stops on its first failed read instead of repairing it speculatively", async (context) => { + const processing = analysisBody("processing"); + + { + const cwd = remoteDirectory(context); + let requests = 0; + let sleeps = 0; + const network = await invoke(["plan", "status", "--wait"], { + cwd, + fetchFunction: async () => { + requests += 1; + if (requests === 1) return jsonResponse(processing); + + throw new Error("canary-secret transient failure"); + }, + planStatusSleep: async () => { + sleeps += 1; + }, + }); + + assert.equal(JSON.parse(network.stderr).error, "status_unavailable"); + assert.equal(requests, 2); + assert.equal(sleeps, 1); + assert.doesNotMatch(network.stderr, /canary-secret/); + } + + { + const cwd = remoteDirectory(context); + const problem = { + type: "about:blank", + title: "Service Unavailable", + status: 503, + code: "service_unavailable", + detail: "Analysis status is temporarily unavailable.", + }; + let sleeps = 0; + const rejected = await invoke(["plan", "status", "--wait"], { + cwd, + fetchFunction: recordingFetch( + [jsonResponse(processing), problemResponse(problem, 503)], + [], + ), + planStatusSleep: async () => { + sleeps += 1; + }, + }); + + assert.deepEqual(JSON.parse(rejected.stderr), { + error: "server_rejected", + detail: "First Draft rejected the analysis status request.", + status: 503, + response: problem, + }); + assert.equal(sleeps, 1); + } +}); + +test("wait does not follow a replacement analysis silently", async (context) => { + const cwd = remoteDirectory(context); + const first = analysisBody("processing"); + const replacement = analysisBody("valid", { + project: { graph_version: 2 }, + analysis: { id: OTHER_ANALYSIS_ID, graph_version: 2 }, + }); + const result = await invoke(["plan", "status", "--wait"], { + cwd, + fetchFunction: recordingFetch( + [jsonResponse(first), jsonResponse(replacement)], + [], + ), + planStatusSleep: async () => undefined, + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: jsonOutput({ + error: "analysis_changed", + detail: + "The current analysis changed while waiting. Run 'firstdraft plan status --wait' again to follow the latest analysis.", + current: replacement, + }), + }); +}); + +test("wait has a fixed overall deadline and reports the last verified state", async (context) => { + const cwd = remoteDirectory(context); + const processing = analysisBody("processing"); + let clock = 0; + let requests = 0; + let sleeps = 0; + /** @type {number[]} */ + const requestTimeouts = []; + const result = await invoke(["plan", "status", "--wait"], { + cwd, + fetchFunction: async () => { + requests += 1; + return jsonResponse(processing); + }, + createRequestSignal: (timeoutMs) => { + if (timeoutMs === undefined) throw new Error("missing request timeout"); + requestTimeouts.push(timeoutMs); + return new AbortController().signal; + }, + planStatusNow: () => clock, + planStatusSleep: async (delayMs) => { + sleeps += 1; + clock += delayMs; + }, + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: jsonOutput({ + error: "wait_timed_out", + detail: + "The current analysis is still processing after the bounded wait. Run 'firstdraft plan status --wait' again to continue waiting.", + current: processing, + }), + }); + assert.equal(requests, 120); + assert.equal(sleeps, 120); + assert.equal(clock, 120_000); + assert.equal(requestTimeouts.at(0), 30_000); + assert.equal(requestTimeouts.at(-1), 1_000); + assert(requestTimeouts.every((timeout) => timeout >= 1 && timeout <= 30_000)); +}); + +test("a response body failure at the wait deadline reports the last verified state", async (context) => { + const cwd = remoteDirectory(context); + const processing = analysisBody("processing"); + let clock = 0; + const failingBody = new ReadableStream({ + pull(controller) { + clock = 120_000; + controller.error(new Error("canary-secret body deadline")); + }, + }); + const result = await invoke(["plan", "status", "--wait"], { + cwd, + fetchFunction: recordingFetch( + [ + jsonResponse(processing), + new Response(failingBody, { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ], + [], + ), + createRequestSignal: () => new AbortController().signal, + planStatusNow: () => clock, + planStatusSleep: async (delayMs) => { + clock += delayMs; + }, + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: jsonOutput({ + error: "wait_timed_out", + detail: + "The current analysis is still processing after the bounded wait. Run 'firstdraft plan status --wait' again to continue waiting.", + current: processing, + }), + }); + assert.doesNotMatch(result.stderr, /canary-secret/); +}); + +test("validated problem responses are whitelisted for agent recovery", async (context) => { + const cwd = remoteDirectory(context); + const problem = { + type: "about:blank", + title: "Not Found", + status: 404, + code: "analysis_not_found", + detail: "This Project does not have a current analysis run.", + canary: "canary-secret-problem-field", + }; + const result = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch([problemResponse(problem, 404)], []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: jsonOutput({ + error: "server_rejected", + detail: "First Draft rejected the analysis status request.", + status: 404, + response: { + type: "about:blank", + title: problem.title, + status: 404, + code: problem.code, + detail: problem.detail, + }, + }), + }); + assert.doesNotMatch(result.stderr, /canary-secret/); +}); + +test("invalid HTTP responses are non-retryable and never expose their body", async (context) => { + for (const response of [ + new Response("canary-secret-json", { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + new Response("canary-secret-problem", { + status: 404, + headers: { "Content-Type": "application/problem+json" }, + }), + new Response(null, { status: 204 }), + new Response("canary-secret-redirect", { + status: 302, + headers: { Location: "https://canary-secret.example" }, + }), + ]) { + const cwd = remoteDirectory(context); + const result = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch([response], []), + }); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).error, "invalid_server_response"); + assert.equal(JSON.parse(result.stderr).status, response.status); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("network, redirect, and response-stream failures advise bounded retries", async (context) => { + const cwd = remoteDirectory(context); + const calls = []; + const network = await invoke(["plan", "status"], { + cwd, + fetchFunction: async (input, init) => { + calls.push({ input, init }); + assert.equal(init?.redirect, "error"); + throw new TypeError("canary-secret redirect or network failure"); + }, + }); + assert.deepEqual(JSON.parse(network.stderr), { + error: "status_unavailable", + detail: + "Could not verify the current analysis status. Retry this read-only request a bounded number of times; if it keeps failing, inspect the API origin pinned in .firstdraft/state.json.", + }); + assert.doesNotMatch(network.stderr, /canary-secret/); + assert.equal(calls.length, 1); + + const stream = new ReadableStream({ + pull(controller) { + controller.error(new Error("canary-secret stream failure")); + }, + }); + const streamed = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch( + [ + new Response(stream, { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ], + [], + ), + }); + assert.equal(JSON.parse(streamed.stderr).error, "status_unavailable"); + assert.equal(JSON.parse(streamed.stderr).status, 200); + assert.doesNotMatch(streamed.stderr, /canary-secret/); +}); + +test("success responses are rejected unless every contract field is valid", async (context) => { + const valid = analysisBody("valid"); + const cases = [ + { name: "wrong media type", response: textResponse(JSON.stringify(valid)) }, + { name: "invalid UTF-8", response: byteResponse(Uint8Array.of(0xff)) }, + { name: "non-object", body: [] }, + { + name: "wrong Project", + body: analysisBody("valid", { project: { id: OTHER_PROJECT_ID } }), + }, + { + name: "nonpositive Project generation", + body: analysisBody("valid", { + project: { graph_version: 0 }, + analysis: { graph_version: 0 }, + }), + }, + { + name: "mismatched generation", + body: analysisBody("valid", { analysis: { graph_version: 2 } }), + }, + { + name: "UUIDv4 analysis identity", + body: analysisBody("valid", { + analysis: { id: "550e8400-e29b-41d4-a716-446655440000" }, + }), + }, + { + name: "empty analyzer release", + body: analysisBody("valid", { analysis: { analyzer_release: "" } }), + }, + { + name: "unknown status", + body: analysisBody("valid", { analysis: { status: "done" } }), + }, + { + name: "non-array diagnostics", + body: analysisBody("valid", { analysis: { diagnostics: {} } }), + }, + { + name: "incomplete diagnostic", + body: analysisBody("valid", { + analysis: { diagnostics: [{ code: "incomplete" }] }, + }), + }, + { + name: "invalid pointer escape", + body: analysisBody("valid", { + analysis: { + diagnostics: [ + diagnostic("warning", { + location: { source_pointer: "/bad~2pointer" }, + }), + ], + }, + }), + }, + { + name: "invalid coordinate", + body: analysisBody("valid", { + analysis: { + diagnostics: [ + diagnostic("warning", { location: { line: 0, column: 1 } }), + ], + }, + }), + }, + { + name: "UUIDv4 diagnostic subject", + body: analysisBody("valid", { + analysis: { + diagnostics: [ + diagnostic("warning", { + subject: { + kind: "entity", + readable_path: "movie", + subject_uuid: "550e8400-e29b-41d4-a716-446655440000", + }, + }), + ], + }, + }), + }, + { + name: "impossible timestamp", + body: analysisBody("valid", { + analysis: { completed_at: "2026-02-30T12:00:01Z" }, + }), + }, + { + name: "processing completion timestamp", + body: analysisBody("processing", { + analysis: { completed_at: COMPLETED_AT }, + }), + }, + { + name: "terminal without completion timestamp", + body: analysisBody("valid", { analysis: { completed_at: null } }), + }, + { + name: "completion before start", + body: analysisBody("valid", { + analysis: { + started_at: COMPLETED_AT, + completed_at: STARTED_AT, + }, + }), + }, + { + name: "valid analysis with an error", + body: analysisBody("valid", { + analysis: { diagnostics: [diagnostic("error")] }, + }), + }, + { + name: "issues status without an error", + body: analysisBody("issues_found", { + analysis: { diagnostics: [diagnostic("warning")] }, + }), + }, + ]; + + for (const candidate of cases) { + const cwd = remoteDirectory(context); + const response = + candidate.response ?? + jsonResponse(candidate.body, 200, { + "X-Canary": "canary-secret-response", + }); + const result = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch([response], []), + }); + + assert.equal(result.status, 1, candidate.name); + assert.equal(result.stdout, "", candidate.name); + assert.deepEqual( + JSON.parse(result.stderr), + { + error: "invalid_server_response", + detail: + "First Draft returned an invalid analysis response. Retrying the unchanged request will not repair this protocol mismatch.", + status: 200, + }, + candidate.name, + ); + assert.doesNotMatch(result.stderr, /canary-secret/, candidate.name); + } +}); + +test("valid timestamps include offsets, lowercase RFC 3339 markers, and leap seconds", async (context) => { + const cwd = remoteDirectory(context); + const body = analysisBody("valid", { + analysis: { + started_at: "2016-12-31t23:59:60z", + completed_at: "2017-01-01T01:00:00+01:00", + }, + }); + const result = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch([jsonResponse(body)], []), + }); + + assert.deepEqual(result, { + status: 0, + stdout: jsonOutput(body), + stderr: "", + }); +}); + +test("declared and streamed response sizes are bounded", async (context) => { + const cwd = remoteDirectory(context); + let declaredCancelled = false; + const declaredStream = new ReadableStream({ + cancel() { + declaredCancelled = true; + }, + }); + const declared = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch( + [ + new Response(declaredStream, { + status: 200, + headers: { + "Content-Type": "application/json", + "Content-Length": String(2 * 1024 * 1024 + 1), + }, + }), + ], + [], + ), + }); + assert.equal(JSON.parse(declared.stderr).error, "invalid_server_response"); + assert.equal(declaredCancelled, true); + + let streamedCancelled = false; + const streamedBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(2 * 1024 * 1024 + 1)); + }, + cancel() { + streamedCancelled = true; + }, + }); + const streamed = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch( + [ + new Response(streamedBody, { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ], + [], + ), + }); + assert.equal(JSON.parse(streamed.stderr).error, "invalid_server_response"); + assert.equal(streamedCancelled, true); +}); + +test("the packaged executable polls a real local analysis endpoint", async (context) => { + let requestMethod; + /** @type {import("node:http").IncomingHttpHeaders | undefined} */ + let requestHeaders; + const body = analysisBody("valid"); + const server = createServer((request, response) => { + requestMethod = request.method; + requestHeaders = request.headers; + const source = JSON.stringify(body); + response.writeHead(200, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(source), + }); + response.end(source); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + context.after(() => server.close()); + const address = server.address(); + assert(address && typeof address !== "string"); + const cwd = localDirectory( + context, + remoteState(`http://127.0.0.1:${address.port}`), + ); + const executable = fileURLToPath( + new URL("../bin/firstdraft.js", import.meta.url), + ); + const child = spawn( + process.execPath, + [executable, "plan", "status", "--wait"], + { + cwd, + env: { + ...process.env, + FIRSTDRAFT_API_URL: "https://canary-secret.example", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + const [status] = await once(child, "close"); + + assert.equal(status, 0); + assert.equal(stderr, ""); + assert.deepEqual(JSON.parse(stdout), body); + assert.equal(requestMethod, "GET"); + assert.equal( + requestHeaders?.accept, + "application/json, application/problem+json", + ); + assert.doesNotMatch(stdout, /canary-secret/); +}); + +/** + * @param {readonly string[]} argv + * @param {Partial} [overrides] + */ +async function invoke(argv, overrides = {}) { + let stdout = ""; + let stderr = ""; + const status = await run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + ...overrides, + }); + + return { status, stdout, stderr }; +} + +/** @param {import("node:test").TestContext} context */ +function remoteDirectory(context) { + return localDirectory(context, remoteState(API_URL)); +} + +/** @param {string} apiUrl */ +function remoteState(apiUrl) { + return { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: apiUrl, + foundation_plan_etag: ETAG, + }; +} + +/** + * @param {import("node:test").TestContext} context + * @param {Record} state + */ +function localDirectory(context, state) { + const cwd = temporaryDirectory(context); + mkdirSync(path.join(cwd, ".firstdraft")); + writeFileSync( + path.join(cwd, ".firstdraft", "state.json"), + stateSource(state), + { + mode: 0o600, + }, + ); + return cwd; +} + +/** + * @param {string} status + * @param {{project?: Record, analysis?: Record}} [overrides] + */ +function analysisBody(status, overrides = {}) { + const terminal = status !== "processing"; + const diagnostics = status === "issues_found" ? [diagnostic("error")] : []; + return { + project: { + id: PROJECT_ID, + graph_version: 1, + ...overrides.project, + }, + analysis: { + id: ANALYSIS_ID, + graph_version: 1, + analyzer_release: "scalar-rails/1", + status, + diagnostics, + started_at: terminal ? STARTED_AT : null, + completed_at: terminal ? COMPLETED_AT : null, + ...overrides.analysis, + }, + }; +} + +/** + * @param {"error" | "warning"} severity + * @param {Record} [overrides] + */ +function diagnostic(severity, overrides = {}) { + return { + code: "foundation_plan.analysis.example", + severity, + message: "The Plan has an example diagnostic.", + location: { source_pointer: "/application/entities/0" }, + subject: { + kind: "entity", + readable_path: "movie", + subject_uuid: SUBJECT_ID, + }, + related_locations: [{ line: 1, column: 2 }], + suggestions: ["Choose a supported value."], + ...overrides, + }; +} + +/** + * @param {Response[]} responses + * @param {FetchCall[]} calls + * @returns {typeof globalThis.fetch} + */ +function recordingFetch(responses, calls) { + return async (input, init) => { + calls.push({ input, init }); + const response = responses.shift(); + assert(response, "unexpected extra request"); + return response; + }; +} + +/** @returns {typeof globalThis.fetch} */ +function inaccessibleFetch() { + return async () => { + throw new Error("network must not run"); + }; +} + +/** @returns {import("../src/commands/plan-push.js").PlanPushFileSystem} */ +function inaccessibleFileSystem() { + return { + lstatSync() { + throw new Error("filesystem must not run"); + }, + readFileSync() { + throw new Error("filesystem must not run"); + }, + renameSync() { + throw new Error("filesystem must not run"); + }, + writeFileSync() { + throw new Error("filesystem must not run"); + }, + }; +} + +/** + * @param {unknown} body + * @param {number} [status] + * @param {Record} [headers] + */ +function jsonResponse(body, status = 200, headers = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +} + +/** @param {unknown} body @param {number} status */ +function problemResponse(body, status) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/problem+json" }, + }); +} + +/** @param {string} body */ +function textResponse(body) { + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); +} + +/** @param {Uint8Array} body */ +function byteResponse(body) { + return new Response(body, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +/** @param {Record} state */ +function stateSource(state) { + return Buffer.from(`${JSON.stringify(state, null, 2)}\n`); +} + +/** @param {unknown} value */ +function jsonOutput(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +/** @param {import("node:test").TestContext} context */ +function temporaryDirectory(context) { + const directory = mkdtempSync(path.join(tmpdir(), "firstdraft-plan-status-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +}