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
24 changes: 20 additions & 4 deletions docs/reference/native-compose-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,10 +535,26 @@ A repeated `--recover --stop` after that refusal does not reach the publication
boundary again: the switch is already journaled, so the retried preparation stop
refuses it as a pending publication with the generic `E_LEGACY_ADOPTION_BUSY`
code ("busy or requires explicit interrupted-lock recovery") and no publication
diagnostic; the literal-env message appears only on the first refusal.
`hack doctor` reports the pinned literal override and names
`hack config adopt --recover --rollback` as the next step while the switch is
pending, since `hack down` and plain `--rollback` refuse in that state (HACK-1391).
diagnostic; the literal-env message appears only on the first refusal. Plain
`hack config adopt --recover` would complete the switch, which refuses the literal
override, so whenever `hack config adopt` refuses as busy over a pending switch
its message adds the step that state accepts: `--recover --rollback` when the
bound override holds literal values, otherwise `--recover` to complete or
`--recover --rollback` to restore (HACK-1414).

`hack doctor` reports the pinned literal override and names the first command the
pending state accepts, since `hack down` and plain `--rollback` refuse in every
pending state (HACK-1391, HACK-1414):

- A pending switch: `hack config adopt --recover --rollback`.
- An interrupted preparation stop (before any switch, so `--recover --rollback`
refuses too): `hack config adopt --recover --stop`, which refuses to publish
over the literal override and journals the switch; then
`hack config adopt --recover --rollback`.
- An interrupted retained-container command of an active adoption:
`hack down --recover`, then `hack config adopt --rollback`.

`hack doctor --fix` keeps the pinned file and its note names the same step.

Unmodified v4 clients cannot be universally fenced: they ignore future legacy
versions and may select ancestor Compose inputs. Adoption requires the upgraded
Expand Down
40 changes: 39 additions & 1 deletion src/commands/config-adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type LegacyComposeAdoptedGenerationStore,
openLegacyComposeAdoptedGenerationStore,
} from "../lib/native-compose-adoption-generation.ts";
import { inspectLegacyComposeAdoptionSelectionDetail } from "../lib/native-compose-adoption-marker.ts";
import {
LEGACY_ADOPTION_LITERAL_ENV_OVERRIDE_CODE,
legacyAdoptionEnvOverrideHoldsValues,
Expand Down Expand Up @@ -88,6 +89,43 @@ class LiteralEnvOverrideRefusal extends HackCliError {
}
}

/**
* Next steps appended to `E_LEGACY_ADOPTION_BUSY` when a publication switch
* is journaled. That code's own text names only "explicit interrupted-lock
* recovery", and plain `hack config adopt --recover` completes the switch,
* which refuses a bound literal override, so the step must name the command
* this state accepts (HACK-1414). Fixed text; values omitted.
*/
export const PENDING_SWITCH_BUSY_NEXT_STEP =
"A publication switch is pending. If no other adoption command is running, run 'hack config adopt --recover' to complete it or 'hack config adopt --recover --rollback' to restore the originals.";
export const PENDING_LITERAL_SWITCH_BUSY_NEXT_STEP =
"A publication switch is pending and the bound .hack/.internal/compose.env.override.yml holds literal env values, so completing it refuses. If no other adoption command is running, run 'hack config adopt --recover --rollback' to release it.";

/** The store refusal's fixed message, plus the pending-switch step for BUSY. */
async function adoptionRefusalMessage(opts: {
readonly error: LegacyComposeAdoptedGenerationError;
readonly projectRoot: string;
}): Promise<string> {
if (opts.error.code !== "E_LEGACY_ADOPTION_BUSY") {
return opts.error.message;
}
try {
const detail = await inspectLegacyComposeAdoptionSelectionDetail({
projectRoot: opts.projectRoot,
});
if (detail?.pending !== "switch") {
return opts.error.message;
}
const literal = await legacyAdoptionEnvOverrideHoldsValues({
projectRoot: opts.projectRoot,
});
return `${opts.error.message} ${literal ? PENDING_LITERAL_SWITCH_BUSY_NEXT_STEP : PENDING_SWITCH_BUSY_NEXT_STEP}`;
} catch {
// Unreadable state keeps the store's fixed message alone.
return opts.error.message;
}
}

async function dryRun(
projectRoot: string,
signal: AbortSignal,
Expand Down Expand Up @@ -299,7 +337,7 @@ export const configAdoptCommand = withHandler(spec, async ({ ctx, args }) => {
code: "E_CONFIG_INVALID",
message:
error instanceof LegacyComposeAdoptedGenerationError
? error.message
? await adoptionRefusalMessage({ error, projectRoot })
: "Legacy adoption refused; original data and recovery evidence retained. Values omitted.",
...(diagnostic
? { detail: { legacy_adoption_publication_refusal: diagnostic } }
Expand Down
80 changes: 55 additions & 25 deletions src/lib/compose-env-at-rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
classifyComposeEnvOverrideText,
} from "./compose-managed-env.ts";
import { isRecord } from "./guards.ts";
import { inspectLegacyComposeAdoptionSelection } from "./native-compose-adoption-marker.ts";
import {
inspectLegacyComposeAdoptionSelectionDetail,
type LegacyComposeAdoptionPending,
} from "./native-compose-adoption-marker.ts";
import {
type ReplaceablePrivateFileOptions,
readReplaceablePrivateFile,
Expand Down Expand Up @@ -39,9 +42,9 @@ import {
* such a generation exists (prepared, active or pending), nothing here removes
* the file, because removal would make the adoption unloadable; the supported
* way to drop its values is rollback (`hack config adopt --rollback`, or
* `--recover --rollback` while an adoption operation or switch is pending),
* `hack up`, then adopting again. New adoptions refuse a literal override
* (`hack config adopt`).
* `--recover --rollback` while a switch is pending, after first settling an
* interrupted preparation stop or retained command), `hack up`, then adopting
* again. New adoptions refuse a literal override (`hack config adopt`).
*/
export const COMPOSE_ENV_OVERRIDE_FILENAME = "compose.env.override.yml";

Expand Down Expand Up @@ -338,26 +341,29 @@ export async function composeEnvOverrideAdoptionBound(opts: {

/**
* How a saved adoption binds the override: `bound` as in
* {@link composeEnvOverrideAdoptionBound}, and `pending` when the binding
* adoption has a pending or interrupted operation or switch. A pending
* adoption fences `hack down` and plain `hack config adopt --rollback`, so
* only explicit recovery (`hack config adopt --recover --rollback`) releases
* the file (HACK-1318). Unreadable adoption state is bound and not pending.
* {@link composeEnvOverrideAdoptionBound}, and `pending` naming what a
* pending binding adoption waits on (false when it is not pending). A pending
* adoption fences `hack down` and plain `hack config adopt --rollback`, and
* each pending kind accepts one explicit recovery command first (HACK-1318,
* HACK-1414). Unreadable adoption state is bound and not pending.
*/
export async function composeEnvOverrideAdoptionBinding(opts: {
readonly projectDir: string;
}): Promise<{ readonly bound: boolean; readonly pending: boolean }> {
}): Promise<{
readonly bound: boolean;
readonly pending: false | LegacyComposeAdoptionPending;
}> {
const projectDir = resolve(opts.projectDir);
if (basename(projectDir) !== HACK_PROJECT_DIR_PRIMARY) {
return { bound: false, pending: false };
}
try {
const status = await inspectLegacyComposeAdoptionSelection({
const detail = await inspectLegacyComposeAdoptionSelectionDetail({
projectRoot: dirname(projectDir),
});
return {
bound: status !== null && status !== "rolled-back",
pending: status === "pending",
bound: detail !== null && detail.status !== "rolled-back",
pending: detail?.pending ?? false,
};
} catch {
return { bound: true, pending: false };
Expand Down Expand Up @@ -470,10 +476,11 @@ export type ComposeEnvAtRestReport = {
readonly override: ComposeEnvOverrideAtRest;
/** A saved adoption may pin the override; see {@link composeEnvOverrideAdoptionBound}. */
readonly adoptionBound: boolean;
/** The pinning adoption has a pending or interrupted operation or switch,
* so only `hack config adopt --recover --rollback` releases the file; see
* {@link composeEnvOverrideAdoptionBinding}. False when not bound. */
readonly adoptionPending: boolean;
/** What the pinning adoption's pending or interrupted operation or switch
* waits on, which decides the recovery command that releases the file; see
* {@link composeEnvOverrideAdoptionBinding}. False when not pending or not
* bound. */
readonly adoptionPending: false | LegacyComposeAdoptionPending;
/** Asides left by interrupted removals; each may hold literal values. */
readonly asides: { readonly stale: number; readonly fresh: number };
};
Expand All @@ -497,11 +504,11 @@ export async function inspectComposeEnvAtRest(opts: {
: { stale: [], fresh: [] };
const binding = composeEnvOverrideMayHoldValues(override)
? await composeEnvOverrideAdoptionBinding(opts)
: { bound: false, pending: false };
: ({ bound: false, pending: false } as const);
return {
override,
adoptionBound: binding.bound,
adoptionPending: binding.bound && binding.pending,
adoptionPending: binding.bound ? binding.pending : false,
asides: { stale: asides.stale.length, fresh: asides.fresh.length },
};
}
Expand Down Expand Up @@ -532,20 +539,43 @@ export function composeEnvAtRestRepairs(report: ComposeEnvAtRestReport): {
export const ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP =
"To drop them, stop the adopted containers ('hack down'), run 'hack config adopt --rollback', run 'hack up' so Hack rewrites the file with references only, then adopt again with 'hack config adopt --stop'.";
/**
* The same, while the pinning adoption has a pending or interrupted operation
* or switch: `hack down` and plain `--rollback` refuse in that state, and only
* explicit recovery releases the switch (HACK-1318, HACK-1391).
* The same, while the pinning adoption has a pending switch: `hack down` and
* plain `--rollback` refuse in that state, and only explicit recovery releases
* the switch (HACK-1318, HACK-1391).
*/
export const PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP =
"To drop them, release the pending adoption switch with 'hack config adopt --recover --rollback' (plain rollback and 'hack down' refuse while it is pending), run 'hack up' so Hack rewrites the file with references only, then adopt again with 'hack config adopt --stop'.";
/**
* The same, after an interrupted `hack config adopt --stop` before
* publication: there is no switch yet, so `--recover --rollback` refuses as
* well as plain rollback and `hack down`. Only `--recover --stop` retries the
* stop; publication then refuses the bound literal override and journals the
* switch that `--recover --rollback` releases (HACK-1318, HACK-1414).
*/
export const INTERRUPTED_PREPARATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP =
"To drop them, first retry the interrupted adoption stop with 'hack config adopt --recover --stop' (rollback and 'hack down' refuse until it finishes); it then refuses to publish over this file and leaves the adoption switch pending. Release that switch with 'hack config adopt --recover --rollback', run 'hack up' so Hack rewrites the file with references only, then adopt again with 'hack config adopt --stop'.";
/**
* The same, after an interrupted retained-container command of an active
* adoption: rollback (plain or `--recover`) and plain `hack down` refuse until
* `hack down --recover` stops the originals and settles it (HACK-1414).
*/
export const INTERRUPTED_OPERATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP =
"To drop them, first settle the interrupted adopted command with 'hack down --recover', which stops the adopted containers (rollback and plain 'hack down' refuse until it finishes). Then run 'hack config adopt --rollback', run 'hack up' so Hack rewrites the file with references only, then adopt again with 'hack config adopt --stop'.";

/** The next step for a report whose override a saved adoption pins. */
export function adoptedComposeEnvOverrideNextStep(
report: Pick<ComposeEnvAtRestReport, "adoptionPending">
): string {
return report.adoptionPending
? PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP
: ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP;
switch (report.adoptionPending) {
case "switch":
return PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP;
case "preparation-stop":
return INTERRUPTED_PREPARATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP;
case "retained-operation":
return INTERRUPTED_OPERATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP;
default:
return ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP;
}
}

/**
Expand Down
60 changes: 52 additions & 8 deletions src/lib/native-compose-adoption-marker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,25 +51,56 @@ export class LegacyComposeAdoptionSelectionError extends HackCliError {
function refuse(): never {
throw new LegacyComposeAdoptionSelectionError();
}
/**
* What a `pending` selection waits on, which decides the only command that
* moves it on:
* - `switch`: a journaled publication switch or rollback. `hack config adopt
* --recover` completes it and `--recover --rollback` restores the originals.
* - `preparation-stop`: an interrupted `hack config adopt --stop` before
* publication. Only `hack config adopt --recover --stop` retries it.
* - `retained-operation`: an interrupted retained-container command of an
* active adoption. Only `hack down --recover` settles it.
* A receipt never holds a pending operation and a pending switch together.
*/
export type LegacyComposeAdoptionPending =
| "switch"
| "preparation-stop"
| "retained-operation";

/** A selection status with what a pending one waits on. */
export type LegacyComposeAdoptionSelectionDetail = {
readonly status: LegacyComposeAdoptionSelection;
/** Null unless `status` is `pending`. */
readonly pending: LegacyComposeAdoptionPending | null;
};

function classify(
value: unknown,
checkout: Parameters<typeof parseLegacyComposeAdoptionReceipt>[1]
): LegacyComposeAdoptionSelection {
): LegacyComposeAdoptionSelectionDetail {
const state = parseLegacyComposeAdoptionReceipt(value, checkout);
if (state.pendingOperation !== null) {
return "pending";
// The receipt parser admits a pending operation only over an active
// publication (a retained command) or before one (a preparation stop).
return {
status: "pending",
pending:
state.publication?.phase === "active"
? "retained-operation"
: "preparation-stop",
};
}
if (state.publication === null) {
return "prepared";
return { status: "prepared", pending: null };
}
switch (state.publication.phase) {
case "switching":
case "rolling-back":
return "pending";
return { status: "pending", pending: "switch" };
case "active":
return "active";
return { status: "active", pending: null };
case "rolled-back":
return "rolled-back";
return { status: "rolled-back", pending: null };
default:
refuse();
}
Expand Down Expand Up @@ -99,6 +130,19 @@ function classifyStateRootFailure(error: unknown): StateRootFailure | null {
export async function inspectLegacyComposeAdoptionSelection(opts: {
readonly projectRoot: string;
}): Promise<LegacyComposeAdoptionSelection | null> {
return (
(await inspectLegacyComposeAdoptionSelectionDetail(opts))?.status ?? null
);
}

/**
* {@link inspectLegacyComposeAdoptionSelection} plus what a pending selection
* waits on, for callers that name a recovery command. Same reads, refusals
* and value-free result.
*/
export async function inspectLegacyComposeAdoptionSelectionDetail(opts: {
readonly projectRoot: string;
}): Promise<LegacyComposeAdoptionSelectionDetail | null> {
const directories: HeldDirectory[] = [];
let stateRootFailure: StateRootFailure | null = null;
try {
Expand Down Expand Up @@ -140,7 +184,7 @@ export async function inspectLegacyComposeAdoptionSelection(opts: {
}
const saved = await readPrivate(receiptPath, 64 * 1024),
parsed = parseImportDocument({ text: saved.text, document: "config" });
const status = classify(parsed.value, {
const detail = classify(parsed.value, {
root: { dev: rootDirectory.info.dev, ino: rootDirectory.info.ino },
project: {
dev: projectDirectory.info.dev,
Expand All @@ -154,7 +198,7 @@ export async function inspectLegacyComposeAdoptionSelection(opts: {
if (!sameFile(saved.info, current.info) || saved.text !== current.text) {
refuse();
}
return status;
return detail;
} catch {
if (stateRootFailure !== null) {
throw new LegacyComposeAdoptionSelectionError(stateRootFailure);
Expand Down
Loading
Loading