Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |
Expand All @@ -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. |

Expand Down
9 changes: 6 additions & 3 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1229,6 +1231,7 @@ async function runPlanPublish({
error: "publication_changed",
detail: PLAN_PUBLISH_CHANGED_DETAIL,
current: error.current,
rejected: error.rejected,
});
return 1;
}
Expand Down
43 changes: 35 additions & 8 deletions src/commands/plan-publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null} [response]
*/
constructor(status, response = null) {
super("The publication request outcome is unknown.");
this.status = status;
this.response = response;
}
}

Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}

Expand Down
Loading