diff --git a/README.md b/README.md index e574432..b01f0e6 100644 --- a/README.md +++ b/README.md @@ -158,16 +158,46 @@ push. It then conditionally creates or reuses the Project's singleton GitHub Pub saved ETag in `If-Match`. It sends no request when the Plan changed locally; run `plan push` to validate and accept those bytes first. -For this application route, `If-Match` is explicitly a precondition on the Project's current Foundation Plan head, -not a cache validator for the possibly absent Publication representation. The server route must consume that domain -precondition directly instead of delegating it to generic conditional-response middleware. +For this application route, `If-Match` is a domain precondition, not a cache validator for the possibly absent +Publication representation. A first creation compares it with the Project's current Foundation Plan Head. A +singleton replay compares it with the Publication's retained Head provenance, even if the live Project has since +changed. The server route must apply those semantics directly instead of delegating to generic conditional-response +middleware. A repeated command safely receives the same Publication instead of creating another. If the first mutation's response is lost or invalid, the CLI makes one read-only singleton `GET` to reconcile it and never automatically repeats the mutation. An unresolved request reports `request_outcome_unknown`; running the same command again safely -replays the singleton request. The CLI polls a validated Publication sequentially once per second for at most ten -minutes and rejects changes to its Project, Plan digest, Compilation input digest, Publication, or allocated -repository identity. +replays the singleton request. A `408` or `5xx` start response is ambiguous even with a valid problem, so the CLI +uses the same read-only reconciliation; every other validated rejection is definitive and must not have created or +changed a Publication. + +When `request_outcome_unknown` includes `status` and `response`, a validated problem from the reconciliation `GET` +takes precedence; otherwise they describe the `PUT`. A `status` without `response` may be a success code when the +read returned a structurally invalid or foreign projection. Never infer from this error alone that no mutation +occurred. + +The response's `project` object is immutable Publication provenance, not a projection of the live mutable Project. +Every `201`, `200`, and read-only `GET` response must retain the graph version and Head digest selected when the +Publication was created. The CLI requires that retained Head digest to equal the local Plan bytes and saved ETag, +requires the associated Compilation to agree with the retained Project snapshot, and pins the snapshot for the +rest of polling. This lets a command whose local state still names the original Head safely replay after the live +Project changes elsewhere. A local file/state pair advanced to a newer Head cannot adopt the older Publication. +This release supports one Publication bound to one retained Head for the lifetime of each Project. It cannot +re-point that Publication or publish a later accepted Head, and no same-Project republish path exists yet. + +The CLI polls a validated Publication sequentially once per second for at most ten minutes and rejects changes to +its retained Project provenance, Compilation input, Publication identity, or allocated repository identity. +`repository_unknown` and `publication_unknown` are one-way server-side uncertainty states: polling remains +read-only, and the CLI rejects a regression from either state to the corresponding mutating phase. Either state may +still advance to a later phase or a terminal state when the server has enough evidence. The CLI does not offer a +publication-cancel mutation; it only reports a server-authoritative `cancelled` terminal state and does not infer +that cancellation reversed any remote side effect. + +Any non-success outcome after publication processing began may leave a private GitHub repository. A `current` +projection identifies the last accepted repository when present; `publication_changed` also includes the rejected +next projection. A null or missing repository does not prove that none was created, especially after +`repository_unknown`. Use a safe command replay or direct GitHub inspection to find every repository. The CLI never +deletes one; inspect it before separately deciding whether destructive GitHub deletion is appropriate. On success, stdout contains only the validated `https://github.com/OWNER/REPOSITORY` URL. Repository conflicts and other failed publication phases include the validated terminal state in a handled `publication_failed` error. This @@ -188,7 +218,7 @@ exactly one JSON object to standard error. Agents should branch on its stable `e | `plan push`, `plan status`, `plan compile`, `plan publish` | `local_input_unreadable` | 1 | The required local Plan or private state could not be read; no request was made. | | `plan status`, `plan compile`, `plan publish` | `project_not_pushed` | 1 | Local state is valid but has no pinned remote Project yet; run `plan push` first. | | `plan push`, `plan compile` | `request_outcome_unknown` | 1 | A sent mutation or its response could not be verified. Stop and reconcile instead of retrying it automatically. | -| `plan publish` | `request_outcome_unknown` | 1 | The singleton could not be reconciled after an ambiguous `PUT`; repeating `plan publish` safely replays the same conditional resource request. | +| `plan publish` | `request_outcome_unknown` | 1 | The ambiguous PUT could not be reconciled. No mutation was retried; retry is safe, and a validated problem may be included. | | `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. | @@ -207,11 +237,11 @@ exactly one JSON object to standard error. Agents should branch on its stable `e | `plan compile` | `invalid_output_path` | 2 | The output is not absent beneath an existing real directory; no request was made. | | `plan compile` | `materialization_failed` | 1 | After artifact validation, filesystem state changed or the verified tree could not be written and atomically published. | | `plan publish` | `local_plan_changed` | 1 | Local Plan bytes no longer match the last successful push; push the complete Plan before publishing. | -| `plan publish` | `publication_start_rejected` | 1 | First Draft definitively rejected the conditional singleton request; a validated problem is included. | -| `plan publish` | `publication_status_unavailable` | 1 | The first failed singleton read stopped polling; a validated problem may be included. | +| `plan publish` | `publication_start_rejected` | 1 | First Draft definitively rejected the conditional singleton request; no Publication was created or changed, and a validated problem is included. | +| `plan publish` | `publication_status_unavailable` | 1 | The first failed singleton read stopped polling; rerun `plan publish` to replay the singleton and resume. A validated problem may be included. | | `plan publish` | `invalid_publication_status` | 1 | A status response violated the complete Project, Compilation, Publication, or repository projection contract. | -| `plan publish` | `publication_changed` | 1 | Publication identity, immutable metadata, repository identity, or lifecycle progression changed while polling. | -| `plan publish` | `publication_wait_timed_out` | 1 | The ten-minute deadline ended; `current` contains the last validated status. | +| `plan publish` | `publication_changed` | 1 | Publication identity, metadata, repository, or lifecycle changed. `current` is the pinned projection and `rejected` is the next response; rerun to resume. | +| `plan publish` | `publication_wait_timed_out` | 1 | The ten-minute deadline ended; `current` contains the last validated status. Rerun `plan publish` to resume. | | `plan publish` | `publication_failed` | 1 | The pinned Publication failed or reached `repository_conflict`; `current` contains its validated failure. | | `plan publish` | `publication_cancelled` | 1 | The pinned Publication was cancelled; `current` contains its validated terminal state. | diff --git a/src/cli.js b/src/cli.js index 65e1e4b..c7b70a3 100644 --- a/src/cli.js +++ b/src/cli.js @@ -146,8 +146,9 @@ Options: Environment: FIRSTDRAFT_API_TOKEN Authenticate API requests -The command publishes the exact Plan ETag pinned by the last successful -push, waits up to ten minutes, and prints the private GitHub repository URL. +The command conditionally creates or replays the Project's one Publication. +Each Project can publish one retained Plan Head in this release. The command +waits up to ten minutes and prints the private GitHub repository URL. `; const PLAN_SUBJECT_ID_HELP = `First Draft CLI @@ -256,7 +257,7 @@ const PLAN_PUBLISH_NOT_PUSHED_DETAIL = const PLAN_PUBLISH_LOCAL_PLAN_CHANGED_DETAIL = "The local Foundation Plan has changed since its last successful push. Run 'firstdraft plan push' before publishing."; const PLAN_PUBLISH_REQUEST_OUTCOME_UNKNOWN_DETAIL = - "The publication may have started, but its singleton status could not be reconciled. Run 'firstdraft plan publish' again to safely replay the same conditional request."; + "The publication may have started, but its singleton status could not be verified. No mutation was retried. Running 'firstdraft plan publish' again is safe; if this Project's Publication is retained for a different Plan Head, it cannot be repointed."; const PLAN_PUBLISH_START_REJECTED_DETAIL = "First Draft rejected the publication request."; const PLAN_PUBLISH_STATUS_UNAVAILABLE_DETAIL = @@ -1191,6 +1192,7 @@ async function runPlanPublish({ error: "request_outcome_unknown", detail: PLAN_PUBLISH_REQUEST_OUTCOME_UNKNOWN_DETAIL, ...(typeof error.status === "number" ? { status: error.status } : {}), + ...(error.response ? { response: error.response } : {}), }); return 1; } @@ -1229,6 +1231,7 @@ async function runPlanPublish({ error: "publication_changed", detail: PLAN_PUBLISH_CHANGED_DETAIL, current: error.current, + rejected: error.rejected, }); return 1; } diff --git a/src/commands/plan-publish.js b/src/commands/plan-publish.js index a3cbd4f..df7f542 100644 --- a/src/commands/plan-publish.js +++ b/src/commands/plan-publish.js @@ -100,10 +100,14 @@ export class PublicationLocalStateError extends Error {} export class PublicationLocalPlanChangedError extends Error {} export class PublicationRequestOutcomeUnknownError extends Error { - /** @param {number | undefined} status */ - constructor(status) { + /** + * @param {number | undefined} status + * @param {Record | null} [response] + */ + constructor(status, response = null) { super("The publication request outcome is unknown."); this.status = status; + this.response = response; } } @@ -140,10 +144,14 @@ export class PublicationStatusInvalidError extends Error { } export class PublicationChangedError extends Error { - /** @param {PublicationResponse} current */ - constructor(current) { + /** + * @param {PublicationResponse} current + * @param {PublicationResponse} rejected + */ + constructor(current, rejected) { super("The pinned publication changed while it was being polled."); this.current = current; + this.rejected = rejected; } } @@ -270,9 +278,18 @@ export async function publishPlan({ reconciliationError instanceof PublicationStatusUnavailableError || reconciliationError instanceof PublicationStatusInvalidError ) { - throw new PublicationRequestOutcomeUnknownError( - reconciliationError.status ?? error.status, - ); + let status = reconciliationError.status ?? error.status; + let response = error.response; + if ( + reconciliationError instanceof PublicationStatusUnavailableError && + reconciliationError.response !== null + ) { + status = reconciliationError.status; + response = reconciliationError.response; + } else if (error.response !== null) { + status = error.status; + } + throw new PublicationRequestOutcomeUnknownError(status, response); } throw reconciliationError; @@ -299,7 +316,7 @@ export async function publishPlan({ ), }); if (!samePublication(initial, next) || !validTransition(current, next)) { - throw new PublicationChangedError(next); + throw new PublicationChangedError(current, next); } current = next; } @@ -363,6 +380,9 @@ async function startPublication({ if (response.ok || problem === null) { throw new PublicationRequestOutcomeUnknownError(response.status); } + if (response.status === 408 || response.status >= 500) { + throw new PublicationRequestOutcomeUnknownError(response.status, problem); + } throw new PublicationStartRejectedError(response.status, problem); } @@ -744,6 +764,13 @@ function validPublicationStatusTransition(previous, current) { ); } + if ( + (from === "repository_unknown" && to === "provisioning_repository") || + (from === "publication_unknown" && to === "publishing") + ) { + return false; + } + return publicationStage(to) >= publicationStage(from); } diff --git a/test/plan-publish.test.js b/test/plan-publish.test.js index 38b3c28..a533d18 100644 --- a/test/plan-publish.test.js +++ b/test/plan-publish.test.js @@ -53,8 +53,9 @@ Options: Environment: FIRSTDRAFT_API_TOKEN Authenticate API requests -The command publishes the exact Plan ETag pinned by the last successful -push, waits up to ten minutes, and prints the private GitHub repository URL. +The command conditionally creates or replays the Project's one Publication. +Each Project can publish one retained Plan Head in this release. The command +waits up to ten minutes and prints the private GitHub repository URL. `; test("plan publish sends one conditional singleton PUT and polls sequentially", async (context) => { @@ -159,7 +160,7 @@ test("plan publish sends one conditional singleton PUT and polls sequentially", assert.doesNotMatch(result.stdout, /canary-secret|sha256/); }); -test("a repeated singleton PUT returns its existing successful publication", async (context) => { +test("a repeated singleton PUT accepts provenance matching local Plan state", async (context) => { const cwd = remoteDirectory(context, "https://api.example.test"); /** @type {FetchCall[]} */ const calls = []; @@ -207,6 +208,47 @@ test("an ambiguous PUT is reconciled by one safe singleton GET", async (context) assert.doesNotMatch(result.stdout, /canary-secret/); }); +test("an ambiguous PUT does not adopt a singleton from a different Plan Head", async (context) => { + const cwd = remoteDirectory(context, "https://api.example.test"); + const retainedHead = "a".repeat(64); + /** @type {FetchCall[]} */ + const calls = []; + const result = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch( + [ + async () => { + throw new TypeError("ambiguous publication request"); + }, + jsonResponse( + publicationBody("succeeded", { + project: { + graph_version: 7, + head_source_sha256: retainedHead, + }, + compilation: { + graph_version: 7, + head_source_sha256: retainedHead, + }, + }), + ), + ], + calls, + ), + }); + + assertHandledFailure(result, "request_outcome_unknown"); + assert.equal(JSON.parse(result.stderr).status, 200); + assert.match( + JSON.parse(result.stderr).detail, + /if this Project's Publication is retained for a different Plan Head, it cannot be repointed/, + ); + assert.deepEqual( + calls.map(({ init }) => init?.method), + ["PUT", "GET"], + ); +}); + test("an invalid successful PUT response can reconcile to the exact singleton", async (context) => { const cwd = remoteDirectory(context, "https://api.example.test"); /** @type {FetchCall[]} */ @@ -256,6 +298,10 @@ test("an unresolved ambiguous PUT remains outcome unknown without replaying the assertHandledFailure(result, "request_outcome_unknown"); assert.equal(JSON.parse(result.stderr).status, 404); + assert.equal( + JSON.parse(result.stderr).response.code, + "publication_not_found", + ); assert.deepEqual( calls.map(({ init }) => init?.method), ["PUT", "GET"], @@ -423,6 +469,54 @@ test("validated start rejections are distinct from unknown mutation outcomes", a assert.doesNotMatch(malformed.stderr, /canary-secret/); }); +test("validated timeout and server errors reconcile without replaying the PUT", async (context) => { + for (const status of [408, 503]) { + const cwd = remoteDirectory(context, "https://api.example.test"); + /** @type {FetchCall[]} */ + const calls = []; + const result = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch( + [ + problemResponse(status, "publication_unavailable", "Try later."), + jsonResponse(publicationBody("succeeded")), + ], + calls, + ), + }); + + assert.deepEqual(result, { + status: 0, + stdout: `${REPOSITORY.html_url}\n`, + stderr: "", + }); + assert.deepEqual( + calls.map(({ init }) => init?.method), + ["PUT", "GET"], + ); + } + + const cwd = remoteDirectory(context, "https://api.example.test"); + const unresolved = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch([ + problemResponse(503, "publication_delayed", "Publication is delayed."), + new Response("canary-secret", { status: 500 }), + ]), + }); + + assertHandledFailure(unresolved, "request_outcome_unknown"); + assert.equal(JSON.parse(unresolved.stderr).status, 503); + assert.deepEqual(JSON.parse(unresolved.stderr).response, { + type: "about:blank", + title: "Service Unavailable", + status: 503, + code: "publication_delayed", + detail: "Publication is delayed.", + }); + assert.doesNotMatch(unresolved.stderr, /canary-secret/); +}); + test("polling distinguishes unavailable and invalid status responses", async (context) => { const unavailableCwd = remoteDirectory(context, "https://api.example.test"); const unavailable = await invoke(["plan", "publish"], { @@ -485,6 +579,14 @@ test("polling rejects replacement identities, regressions, and repository mutati }, }), }, + { + initial: publicationBody("repository_unknown"), + changed: publicationBody("provisioning_repository"), + }, + { + initial: publicationBody("publication_unknown"), + changed: publicationBody("publishing"), + }, ]; for (const { initial, changed } of cases) { @@ -499,6 +601,9 @@ test("polling rejects replacement identities, regressions, and repository mutati }); assertHandledFailure(result, "publication_changed"); + const envelope = JSON.parse(result.stderr); + assert.deepEqual(envelope.current, initial); + assert.deepEqual(envelope.rejected, changed); } }); @@ -573,6 +678,13 @@ test("exact response shapes and coherent terminal projections are required", asy publicationBody("succeeded", { project: { head_source_sha256: "f".repeat(64) }, }), + publicationBody("succeeded", { + project: { graph_version: 7, head_source_sha256: "a".repeat(64) }, + compilation: { + graph_version: 7, + head_source_sha256: "a".repeat(64), + }, + }), publicationBody("succeeded", { compilation: { artifact: null }, }), @@ -720,6 +832,7 @@ function problemResponse(status, code, detail) { const titles = { 401: "Unauthorized", 404: "Not Found", + 408: "Request Timeout", 412: "Precondition Failed", 503: "Service Unavailable", };