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
18 changes: 15 additions & 3 deletions src/lib/native-compose-adoption-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ import {
} from "./projects-registry.ts";
import { run } from "./shell.ts";

/**
* Fixed, value-free messages an adopted command ends with when it cancels or
* rewraps an unexpected refusal as `E_CONFIG_INVALID`. Diagnostics map these
* exact strings to closed tokens, so they import them instead of copying them
* (HACK-1432).
*/
export const LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES = Object.freeze({
refused:
"Adopted Compose ownership or execution refused; original data is retained. Values omitted.",
cancelled:
"Adopted Compose operation cancelled; pending ownership is retained. Values omitted.",
});

function unsupported(): never {
throw new HackCliError({
code: "E_NATIVE_PROJECT_UNSUPPORTED",
Expand All @@ -34,8 +47,7 @@ function cancelled(signal: AbortSignal) {
if (signal.aborted) {
throw new HackCliError({
code: "E_CONFIG_INVALID",
message:
"Adopted Compose operation cancelled; pending ownership is retained. Values omitted.",
message: LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES.cancelled,
});
}
}
Expand Down Expand Up @@ -371,7 +383,7 @@ export async function tryLegacyComposeAdoptedCommand(
message:
error instanceof LegacyComposeAdoptedGenerationError
? error.message
: "Adopted Compose ownership or execution refused; original data is retained. Values omitted.",
: LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES.refused,
...(legacyComposeOrderedRefusal(error)
? {
detail: {
Expand Down
11 changes: 3 additions & 8 deletions tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isRecord } from "../../../src/lib/guards.ts";
import { LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES } from "../../../src/lib/native-compose-adoption-command.ts";
import type { LegacyComposeOrderedRefusal } from "../../../src/lib/native-compose-adoption-diagnostics.ts";
import { LegacyComposeAdoptedGenerationError } from "../../../src/lib/native-compose-adoption-generation.ts";
import { LEGACY_ADOPTION_SELECTION_MESSAGES } from "../../../src/lib/native-compose-adoption-marker.ts";
Expand Down Expand Up @@ -82,14 +83,8 @@ const REFUSALS: readonly (readonly [string, string])[] = [
([token, code]) =>
[token, new LegacyComposeAdoptedGenerationError(code).message] as const
),
[
"adopted-refused",
"Adopted Compose ownership or execution refused; original data is retained. Values omitted.",
],
[
"adopted-cancelled",
"Adopted Compose operation cancelled; pending ownership is retained. Values omitted.",
],
["adopted-refused", LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES.refused],
["adopted-cancelled", LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES.cancelled],
["marker-adoption", LEGACY_ADOPTION_SELECTION_MESSAGES.adoption],
["marker-permission", LEGACY_ADOPTION_SELECTION_MESSAGES.permission],
// The marker's input-access message is the shared project-input guard's.
Expand Down
121 changes: 95 additions & 26 deletions tests/native-compose-adoption-binding.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
import {
afterEach,
beforeEach,
expect,
setDefaultTimeout,
spyOn,
test,
} from "bun:test";
import {
chmod,
mkdir,
Expand Down Expand Up @@ -27,6 +34,32 @@ import { acquireNativeConfigImportInputs } from "../src/lib/native-config-import
import { mapLegacyNativeStorageAdoption } from "../src/lib/native-config-import-plan.ts";
import { restoreEnv } from "./helpers/env.ts";

/**
* The product's default deadline for one aggregate Docker probe
* (`createNativeComposeProbe`). Each acquisition and each freshness check here
* owns one such probe unless a test names its own `timeoutMs`.
*/
const PROBE_DEADLINE_MS = 15_000;
/**
* Every fake Docker query here is a cold Bun process, and one acquisition runs
* a dozen or more. Bun's 5 s default then budgets host process start-up rather
* than the product, and an expired test keeps running against the next test's
* shared root (HACK-1432, as HACK-1367 found for the ownership tests). The
* heaviest test here owns about eight default-deadline probes, so a slow host
* ends in the product's own closed refusal before this budget expires.
*/
const FAKE_DOCKER_TEST_TIMEOUT_MS = 8 * PROBE_DEADLINE_MS;
setDefaultTimeout(FAKE_DOCKER_TEST_TIMEOUT_MS);
/**
* A placeholder acquisition repeats bridge scans under one probe deadline
* (two extra scans with two owned bridges). The default refused one under
* heavy host load (HACK-1432), so the placeholder tests name a wider deadline
* for their clean baseline and their placeholder acquisition alike.
*/
const PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS = 3 * PROBE_DEADLINE_MS;
/** A clean acquire and resolve, then a placeholder acquire and resolve. */
const PLACEHOLDER_RESCAN_TEST_TIMEOUT_MS =
4 * PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS;
const CANARY = "synthetic-private-adoption-canary";
const ID = "a".repeat(64);
const NETWORK = "b".repeat(64);
Expand Down Expand Up @@ -504,7 +537,9 @@ test("moving job snapshot counterexample retains identity and admits a fresh com
maximumRetryCount: 0,
}));
}
// The maintained job fixture uses the same 20s operation / 30s case bounds.
// The maintained job fixture uses the same 20s operation bound. The case
// bound is the file's fake-Docker budget, so a slow host ends in the
// operation's own refusal rather than an unconfirmed snapshot lifetime.
const deadline = Date.now() + 20_000;
await executeLegacyComposeRetainedPlan({
plan,
Expand Down Expand Up @@ -612,7 +647,7 @@ test("moving job snapshot counterexample retains identity and admits a fresh com
} finally {
snapshotCaseActive = false;
}
}, 30_000);
});

test.each([
"foreign",
Expand All @@ -629,7 +664,7 @@ test.each([
: "E_LEGACY_COMPOSE_BINDING_IDENTITY"
);
expect(await Bun.file(join(root, "mutation")).exists()).toBe(false);
}, 20_000);
});

test("moving snapshot guard exhausts the original shared deadline", async () => {
fixture.mode = "snapshot-guard-unstable";
Expand All @@ -650,7 +685,7 @@ test("moving snapshot guard exhausts the original shared deadline", async () =>
).length
).toBeGreaterThan(2);
expect(await Bun.file(join(root, "mutation")).exists()).toBe(false);
}, 10_000);
});

function networkInspections(observed: readonly string[][]): number {
return observed.filter(
Expand All @@ -666,37 +701,53 @@ test.each([
] as const)(
"a sandbox-less endpoint placeholder on bridge read %s is re-observed, never admitted or refused (HACK-1421)",
async (ordinal, extra) => {
const clean = await acquireLegacyComposeAdoptionBinding({ projectRoot });
const clean = await acquireLegacyComposeAdoptionBinding({
projectRoot,
timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS,
});
const original = await clean.resolveBinding({ projectRoot });
const baseline = networkInspections(await commands());
await rm(join(root, "commands"));
fixture.mode = `snapshot-placeholder-${ordinal}`;
await writeFile(join(root, "snapshot-network-ordinal"), "0");
await save();
const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot });
const acquired = await acquireLegacyComposeAdoptionBinding({
projectRoot,
timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS,
});
expect(await acquired.resolveBinding({ projectRoot })).toEqual(original);
expect(networkInspections(await commands())).toBe(baseline + extra);
expect(JSON.stringify(original)).not.toContain("ep-");
expect(await Bun.file(join(root, "mutation")).exists()).toBe(false);
},
60_000
PLACEHOLDER_RESCAN_TEST_TIMEOUT_MS
);

test("a placeholder on one of two owned bridges is re-observed with both bridges (HACK-1421)", async () => {
await twoBridges();
const clean = await acquireLegacyComposeAdoptionBinding({ projectRoot });
const original = await clean.resolveBinding({ projectRoot });
const baseline = networkInspections(await commands());
await rm(join(root, "commands"));
// Reads 1-2 fix identity; read 3 is the first bridge of the accepted loop scan.
fixture.mode = "snapshot-placeholder-3";
await writeFile(join(root, "snapshot-network-ordinal"), "0");
await save();
const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot });
expect(await acquired.resolveBinding({ projectRoot })).toEqual(original);
expect(networkInspections(await commands())).toBe(baseline + 2);
expect(await Bun.file(join(root, "mutation")).exists()).toBe(false);
}, 60_000);
test(
"a placeholder on one of two owned bridges is re-observed with both bridges (HACK-1421)",
async () => {
await twoBridges();
const clean = await acquireLegacyComposeAdoptionBinding({
projectRoot,
timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS,
});
const original = await clean.resolveBinding({ projectRoot });
const baseline = networkInspections(await commands());
await rm(join(root, "commands"));
// Reads 1-2 fix identity; read 3 is the first bridge of the accepted loop scan.
fixture.mode = "snapshot-placeholder-3";
await writeFile(join(root, "snapshot-network-ordinal"), "0");
await save();
const acquired = await acquireLegacyComposeAdoptionBinding({
projectRoot,
timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS,
});
expect(await acquired.resolveBinding({ projectRoot })).toEqual(original);
expect(networkInspections(await commands())).toBe(baseline + 2);
expect(await Bun.file(join(root, "mutation")).exists()).toBe(false);
},
PLACEHOLDER_RESCAN_TEST_TIMEOUT_MS
);

test("a persistent endpoint placeholder exhausts the original shared deadline", async () => {
fixture.mode = "snapshot-placeholder-always";
Expand All @@ -709,7 +760,7 @@ test("a persistent endpoint placeholder exhausts the original shared deadline",
expect(Date.now() - started).toBeLessThan(9000);
// Identity read plus at least two discarded loop reads before the deadline.
expect(networkInspections(await commands())).toBeGreaterThan(2);
}, 20_000);
});

test("a malformed placeholder-shaped bridge member still refuses identity without retry", async () => {
fixture.mode = "snapshot-placeholder-always-malformed";
Expand All @@ -719,7 +770,25 @@ test("a malformed placeholder-shaped bridge member still refuses identity withou
"E_LEGACY_COMPOSE_BINDING_IDENTITY"
);
expect(networkInspections(await commands())).toBe(1);
}, 10_000);
});

// A duplicate `.Containers` key is never one member or one transient
// placeholder: the first bridge read refuses identity without a retry.
test.each([
["container ID", () => [ID, ID]],
[
"endpoint placeholder",
() => [ID, `ep-${"d".repeat(64)}`, `ep-${"d".repeat(64)}`],
],
] as const)("a duplicate bridge member key (%s) refuses identity without retry", async (_kind, members) => {
network().containers = members();
await save();
await refusal(
acquireLegacyComposeAdoptionBinding({ projectRoot }),
"E_LEGACY_COMPOSE_BINDING_IDENTITY"
);
expect(networkInspections(await commands())).toBe(1);
});

test("moving snapshot guard rejects a selected ID on its undeclared bridge before retry", async () => {
await twoBridges();
Expand All @@ -742,7 +811,7 @@ test("moving snapshot guard rejects a selected ID on its undeclared bridge befor
)
).toHaveLength(2);
expect(await Bun.file(join(root, "mutation")).exists()).toBe(false);
}, 20_000);
});

test("one authored bridge binds original physical ID, internal policy and exact live aliases", async () => {
await customBridge();
Expand Down
36 changes: 27 additions & 9 deletions tests/native-compose-adoption-job-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
HackCliError,
renderCliResult,
} from "../src/lib/cli-result.ts";
import { LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES } from "../src/lib/native-compose-adoption-command.ts";
import {
legacyComposeOrderedError,
legacyComposeOrderedRefusal,
Expand Down Expand Up @@ -277,15 +278,6 @@ test("completed-job CLI diagnostics name a fixed refusal message by closed token
)
).toBe(`refusal=${token}`);
}
expect(
refusal(
new HackCliError({
code: "E_CONFIG_INVALID",
message:
"Adopted Compose ownership or execution refused; original data is retained. Values omitted.",
})
)
).toBe("refusal=adopted-refused");
expect(refusal(new LegacyComposeAdoptionSelectionError())).toBe(
"refusal=marker-adoption"
);
Expand All @@ -312,3 +304,29 @@ test("completed-job CLI diagnostics name a fixed refusal message by closed token
expect(messages.join("\n")).not.toContain("ENOTDIR");
expect(messages.join("\n")).not.toContain("values omitted");
});

// The adopted command's own fixed messages are imported, never copied, so a
// wording change cannot silently turn either token into `other` (HACK-1432).
test.each([
["refused", "adopted-refused"],
["cancelled", "adopted-cancelled"],
] as const)("completed-job CLI diagnostics name the adopted command's %s message by its own token", (kind, token) => {
const messages: string[] = [];
const diagnostic = diagnostics(messages);
diagnostic.cliOutcome({
exitCode: 1,
timedOut: false,
stdout: renderCliResult({
result: errorResultFromUnknown({
error: new HackCliError({
code: "E_CONFIG_INVALID",
message: LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES[kind],
}),
}),
}),
});
expect(messages).toEqual([
`start-operation=restart worktree=alpha stage=cli-result exit=nonzero timed-out=no code=E_CONFIG_INVALID ordered-stage=unavailable ordered-reason=unavailable refusal=${token}`,
]);
expect(messages.join("\n")).not.toContain("Values omitted");
});
Loading