@botiverse/k-carrier 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +24 -0
- package/README.md +62 -0
- package/core/src/artifact/README.md +1 -0
- package/core/src/artifact/collectStream.ts +49 -0
- package/core/src/artifact/download.ts +300 -0
- package/core/src/artifact/errors.ts +24 -0
- package/core/src/artifact/partialPath.ts +18 -0
- package/core/src/artifact/source.ts +59 -0
- package/core/src/artifact/staticManifestSource.ts +185 -0
- package/core/src/artifact/swap.ts +21 -0
- package/core/src/clock.ts +22 -0
- package/core/src/converge/lifecycle.ts +114 -0
- package/core/src/converge/predicates.ts +57 -0
- package/core/src/converge/report.ts +38 -0
- package/core/src/createUpgrader.ts +300 -0
- package/core/src/drive/README.md +1 -0
- package/core/src/index.ts +17 -0
- package/core/src/invariants.ts +243 -0
- package/core/src/lifecycle/hostAdapter.ts +77 -0
- package/core/src/liveness.ts +80 -0
- package/core/src/platform/README.md +1 -0
- package/core/src/platform/index.ts +11 -0
- package/core/src/platform/ops.ts +53 -0
- package/core/src/platform/posix.ts +99 -0
- package/core/src/platform/windows.ts +73 -0
- package/core/src/policy/README.md +1 -0
- package/core/src/progress.ts +54 -0
- package/core/src/provenance/journal.ts +216 -0
- package/core/src/status/report.ts +76 -0
- package/core/src/status/reportStore.ts +67 -0
- package/core/src/txn/effects.ts +40 -0
- package/core/src/txn/engine.ts +275 -0
- package/core/src/txn/fileEffects.ts +129 -0
- package/core/src/txn/hostCallBudget.ts +28 -0
- package/core/src/txn/lock.ts +124 -0
- package/core/src/txn/state.ts +77 -0
- package/core/src/txn/transitions.ts +49 -0
- package/core/src/upgrade/outcome.ts +53 -0
- package/core/src/upgrade/retire.ts +44 -0
- package/core/src/upgrader.ts +124 -0
- package/docs/design-v1.md +246 -0
- package/docs/harness-design.md +170 -0
- package/docs/integration.md +326 -0
- package/docs/prior-art.md +150 -0
- package/docs/test-plan.md +100 -0
- package/harness/README.md +20 -0
- package/harness/src/adapter/probeChecks.ts +93 -0
- package/harness/src/adapter/releaseKnob.ts +65 -0
- package/harness/src/adapter/serviceChecks.ts +260 -0
- package/harness/src/artifact/checks.ts +232 -0
- package/harness/src/artifact/downloadHoles.ts +300 -0
- package/harness/src/artifact/downloadHolesMutations.ts +170 -0
- package/harness/src/artifact/m1.ts +204 -0
- package/harness/src/artifact/m1Resume.ts +141 -0
- package/harness/src/artifact/m3.ts +296 -0
- package/harness/src/artifact/m3Hosts.ts +241 -0
- package/harness/src/artifact/m4.ts +201 -0
- package/harness/src/artifact/m5.ts +239 -0
- package/harness/src/artifact/m6.ts +274 -0
- package/harness/src/artifact/m6Drive.ts +296 -0
- package/harness/src/artifact/m6DriveMutations.ts +70 -0
- package/harness/src/artifact/m6Mutations.ts +136 -0
- package/harness/src/artifact/m6Status.ts +224 -0
- package/harness/src/artifact-factory/checks.ts +68 -0
- package/harness/src/artifact-factory/demo.ts +70 -0
- package/harness/src/artifact-factory/factory.ts +134 -0
- package/harness/src/artifact-factory/run.ts +107 -0
- package/harness/src/blackbox.ts +284 -0
- package/harness/src/cli.ts +281 -0
- package/harness/src/crash/enumerate.ts +66 -0
- package/harness/src/crash/runner.ts +162 -0
- package/harness/src/examples/checks.ts +286 -0
- package/harness/src/fake-host/checks.ts +188 -0
- package/harness/src/fake-host/daemon-entry.ts +39 -0
- package/harness/src/fake-host/daemon.ts +159 -0
- package/harness/src/fake-host/inproc.ts +256 -0
- package/harness/src/fake-server/manifest.ts +68 -0
- package/harness/src/fake-server/server.ts +205 -0
- package/harness/src/fake-server/store.ts +208 -0
- package/harness/src/fixtures/minimal-adapter.ts +108 -0
- package/harness/src/fixtures/service-adapter.ts +187 -0
- package/harness/src/index.ts +11 -0
- package/harness/src/receipt.ts +83 -0
- package/harness/src/runner.ts +268 -0
- package/harness/src/scenario/processScan.ts +55 -0
- package/harness/src/scenario/sandbox.ts +259 -0
- package/harness/src/scenario/virtualClock.ts +56 -0
- package/harness/src/selfverify/selfVerify.ts +136 -0
- package/harness/src/sim/checks.ts +46 -0
- package/harness/src/sim/corpus.ts +36 -0
- package/harness/src/sim/effectRuntime.ts +113 -0
- package/harness/src/sim/error.ts +14 -0
- package/harness/src/sim/prng.ts +29 -0
- package/harness/src/sim/record.ts +76 -0
- package/harness/src/sim/run.ts +148 -0
- package/harness/src/sim/scheduler.ts +72 -0
- package/harness/src/sim/world.ts +284 -0
- package/harness/src/target.ts +86 -0
- package/harness/src/targetCheck.ts +47 -0
- package/harness/src/teeth/artifact.ts +215 -0
- package/harness/src/teeth/artifactDownload.ts +136 -0
- package/harness/src/teeth/artifactFactory.ts +41 -0
- package/harness/src/teeth/artifactM5.ts +68 -0
- package/harness/src/teeth/artifactM6.ts +264 -0
- package/harness/src/teeth/blackbox.ts +22 -0
- package/harness/src/teeth/checks.ts +278 -0
- package/harness/src/teeth/examples.ts +57 -0
- package/harness/src/teeth/fakeHost.ts +120 -0
- package/harness/src/teeth/index.ts +16 -0
- package/harness/src/teeth/m0.ts +135 -0
- package/harness/src/teeth/registry.ts +267 -0
- package/harness/src/teeth/selfCheck.ts +45 -0
- package/harness/src/teeth/sim.ts +61 -0
- package/package.json +43 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M4 consent & notification acceptance checks (the M4 spec).
|
|
3
|
+
*
|
|
4
|
+
* - confirm without approval: policy=confirm holds BEFORE any disk side
|
|
5
|
+
* effect — nothing is staged, not just nothing promoted.
|
|
6
|
+
* - consent binds a SPECIFIC version: the continuation after approval
|
|
7
|
+
* installs exactly the version that was offered; if the publisher moved
|
|
8
|
+
* on, the continuation refuses instead of installing whatever is
|
|
9
|
+
* current now. Consent is to a version, never to "the upgrade".
|
|
10
|
+
* - notify-only: the notification carries the version that is ACTUALLY
|
|
11
|
+
* installable, and nothing is staged.
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { promises as fs } from "node:fs";
|
|
16
|
+
import { type ToothContext } from "../teeth/registry.ts";
|
|
17
|
+
import { FakeServer } from "../fake-server/server.ts";
|
|
18
|
+
import { runCommand } from "../artifact-factory/run.ts";
|
|
19
|
+
import { buildSwapTool, coreUpgraderUrl, readState, serveRelease } from "./m1.ts";
|
|
20
|
+
import { CLI_TOOL_SOURCE } from "../../../examples/swap-tool/source.ts";
|
|
21
|
+
import { ArtifactFactory } from "../artifact-factory/factory.ts";
|
|
22
|
+
import { currentPlatformKey } from "../../../core/src/artifact/staticManifestSource.ts";
|
|
23
|
+
|
|
24
|
+
function swapEnv(ctx: ToothContext, baseUrl: string, servers: FakeServer[], extra: Record<string, string> = {}): Record<string, string> {
|
|
25
|
+
return {
|
|
26
|
+
K_RELEASE_BASE: baseUrl,
|
|
27
|
+
K_STATE_DIR: path.join(ctx.sandboxDir, "state"),
|
|
28
|
+
K_CORE_UPGRADER: coreUpgraderUrl(),
|
|
29
|
+
...extra,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Assert the stateDir has NO transaction side effects at all (not staged). */
|
|
34
|
+
async function assertZeroDiskSideEffects(ctx: ToothContext): Promise<void> {
|
|
35
|
+
const dir = path.join(ctx.sandboxDir, "state");
|
|
36
|
+
let exists = true;
|
|
37
|
+
try {
|
|
38
|
+
await fs.access(dir);
|
|
39
|
+
} catch {
|
|
40
|
+
exists = false;
|
|
41
|
+
}
|
|
42
|
+
if (!exists) return;
|
|
43
|
+
await assert.rejects(fs.access(path.join(dir, "journal.jsonl")), "journal must not exist");
|
|
44
|
+
await assert.rejects(fs.access(path.join(dir, "slots")), "slots must not exist");
|
|
45
|
+
await assert.rejects(fs.access(path.join(dir, "incoming")), "incoming must not exist");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// m4.confirm-no-consent-zero-side-effects
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export async function checkM4ConfirmNoConsentZeroSideEffects(
|
|
53
|
+
ctx: ToothContext,
|
|
54
|
+
opts: { policy?: "confirm" | "auto" } = {},
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const binPath = await buildSwapTool(ctx);
|
|
57
|
+
const target = await serveRelease(ctx, {
|
|
58
|
+
version: "2.0.0",
|
|
59
|
+
behavior: "ok",
|
|
60
|
+
name: "target",
|
|
61
|
+
source: CLI_TOOL_SOURCE,
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
const env = swapEnv(ctx, target.url, [target], { K_POLICY: opts.policy ?? "confirm" });
|
|
65
|
+
const up = await runCommand(binPath, ["self", "upgrade"], { env, timeoutMs: 30000 });
|
|
66
|
+
assert.equal(
|
|
67
|
+
up.code,
|
|
68
|
+
0,
|
|
69
|
+
`confirm-held must exit 0 (${up.stderr.trim()}; auto mutation => RED)`,
|
|
70
|
+
);
|
|
71
|
+
assert.match(up.stdout, /held: policy requires confirmation/, "confirm must hold, not install");
|
|
72
|
+
assert.match(
|
|
73
|
+
up.stderr,
|
|
74
|
+
/notify confirm-request v2\.0\.0/,
|
|
75
|
+
"a confirm-request must name the offered version",
|
|
76
|
+
);
|
|
77
|
+
// the whole point: nothing was staged, not just nothing promoted
|
|
78
|
+
await assertZeroDiskSideEffects(ctx);
|
|
79
|
+
} finally {
|
|
80
|
+
await target.stop();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// m4.consent-binds-version
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export async function checkM4ConsentBindsVersion(
|
|
89
|
+
ctx: ToothContext,
|
|
90
|
+
opts: { serverSwitched?: boolean; expectInstalled?: boolean } = {},
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
const binPath = await buildSwapTool(ctx);
|
|
93
|
+
const target = await serveRelease(ctx, {
|
|
94
|
+
version: "2.0.0",
|
|
95
|
+
behavior: "ok",
|
|
96
|
+
name: "target",
|
|
97
|
+
source: CLI_TOOL_SOURCE,
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
const env = swapEnv(ctx, target.url, [target], { K_POLICY: "confirm" });
|
|
101
|
+
|
|
102
|
+
// 1) offer: confirm-request names 2.0.0
|
|
103
|
+
const offer = await runCommand(binPath, ["self", "upgrade"], { env, timeoutMs: 30000 });
|
|
104
|
+
assert.equal(offer.code, 0, `offer must hold (${offer.stderr.trim()})`);
|
|
105
|
+
assert.match(offer.stdout, /held: policy requires confirmation/);
|
|
106
|
+
assert.match(offer.stderr, /notify confirm-request v2\.0\.0/);
|
|
107
|
+
|
|
108
|
+
// 2) the publisher moves on (or not) before the user answers
|
|
109
|
+
if (opts.serverSwitched) {
|
|
110
|
+
// the SAME server now serves 3.0.0 — the continuation must refuse the
|
|
111
|
+
// approved 2.0.0, not silently install whatever is current
|
|
112
|
+
// 3.0.0 must be a REAL, RUNNABLE release. Publishing garbage bytes here
|
|
113
|
+
// made this tooth pass for the wrong reason: with version binding
|
|
114
|
+
// removed, the continuation DID fetch 3.0.0 -- it just could not run, so
|
|
115
|
+
// the probe failed and nothing was installed, which is exactly what the
|
|
116
|
+
// oracle checks. The tooth then proves "the payload was unrunnable", not
|
|
117
|
+
// "consent is bound to a version". (Found in review by removing the
|
|
118
|
+
// binding and watching this stay green.)
|
|
119
|
+
const switchedFactory = new ArtifactFactory({
|
|
120
|
+
cacheDir: path.join(ctx.sandboxDir, "cache-switched"),
|
|
121
|
+
demoSource: CLI_TOOL_SOURCE,
|
|
122
|
+
});
|
|
123
|
+
await switchedFactory.makeRelease({
|
|
124
|
+
version: "3.0.0",
|
|
125
|
+
behavior: "ok",
|
|
126
|
+
store: target.store,
|
|
127
|
+
platform: currentPlatformKey(),
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
const continueRun = await runCommand(binPath, ["confirm", "upgrade", "2.0.0"], {
|
|
131
|
+
env,
|
|
132
|
+
timeoutMs: 30000,
|
|
133
|
+
});
|
|
134
|
+
const v = await versionOf(binPath);
|
|
135
|
+
const st = await readState(env);
|
|
136
|
+
const nothingInstalled = v === "1.0.0" && st.stableVersion === "0.0.0";
|
|
137
|
+
if (opts.expectInstalled === true) {
|
|
138
|
+
// mutation expectation: the binding was lost and the continuation
|
|
139
|
+
// installed something — the zero-install assertion must go RED
|
|
140
|
+
assert.ok(!nothingInstalled, "the binding must have installed the offered version (expectInstalled => RED)");
|
|
141
|
+
} else {
|
|
142
|
+
assert.ok(nothingInstalled, "the continuation must refuse when the approved version is no longer served");
|
|
143
|
+
assert.match(continueRun.stdout, /held/, "the refusal must be a typed held, not a silent install");
|
|
144
|
+
assert.match(continueRun.stdout, /no longer served|nothing was installed/);
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
} finally {
|
|
148
|
+
// nothing extra to stop: the switched release lives on `target`
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3) green: the continuation installs EXACTLY the offered version
|
|
153
|
+
const cont = await runCommand(binPath, ["confirm", "upgrade", "2.0.0"], {
|
|
154
|
+
env,
|
|
155
|
+
timeoutMs: 30000,
|
|
156
|
+
});
|
|
157
|
+
assert.equal(cont.code, 0, `continuation must exit 0 (${cont.stderr.trim()})`);
|
|
158
|
+
assert.match(cont.stdout, /upgraded to 2\.0\.0/);
|
|
159
|
+
assert.equal(await versionOf(binPath), "2.0.0", "the continuation must install the OFFERED version");
|
|
160
|
+
const st = await readState(env);
|
|
161
|
+
assert.equal(st.stableVersion, "2.0.0", "stable must hold the approved version");
|
|
162
|
+
assert.equal(st.phase, "promoted");
|
|
163
|
+
} finally {
|
|
164
|
+
await target.stop();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function versionOf(binPath: string): Promise<string> {
|
|
169
|
+
const v = await runCommand(binPath, ["--version"], { timeoutMs: 30000 });
|
|
170
|
+
assert.equal(v.code, 0, `version command failed (${v.stderr.trim()})`);
|
|
171
|
+
return v.stdout.trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// m4.notify-only-reports-installable-version
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
export async function checkM4NotifyOnlyReportsInstallableVersion(
|
|
179
|
+
ctx: ToothContext,
|
|
180
|
+
opts: { policy?: "notify-only" | "confirm" } = {},
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
const binPath = await buildSwapTool(ctx);
|
|
183
|
+
const target = await serveRelease(ctx, {
|
|
184
|
+
version: "2.0.0",
|
|
185
|
+
behavior: "ok",
|
|
186
|
+
name: "target",
|
|
187
|
+
source: CLI_TOOL_SOURCE,
|
|
188
|
+
});
|
|
189
|
+
try {
|
|
190
|
+
const env = swapEnv(ctx, target.url, [target], { K_POLICY: opts.policy ?? "notify-only" });
|
|
191
|
+
const up = await runCommand(binPath, ["self", "upgrade"], { env, timeoutMs: 30000 });
|
|
192
|
+
assert.equal(up.code, 0, `notify-only must exit 0 (${up.stderr.trim()}; confirm mutation => RED)`);
|
|
193
|
+
assert.match(up.stdout, /held: policy is notify-only/, "notify-only must hold, not install");
|
|
194
|
+
// the notification must name the version that is ACTUALLY installable
|
|
195
|
+
assert.match(up.stderr, /notify held v2\.0\.0: notify-only/);
|
|
196
|
+
await assertZeroDiskSideEffects(ctx);
|
|
197
|
+
} finally {
|
|
198
|
+
await target.stop();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M5 platform-surface / lifecycle-convergence acceptance checks
|
|
3
|
+
* (test-plan M5 rows: 点名面读回一致;面在 allowlist 才可作证;未注册面被引用
|
|
4
|
+
* ⇒ 拒; fail-closed 退役序; 禁投影).
|
|
5
|
+
*
|
|
6
|
+
* These drive createUpgrader in-process (the library plane — the converge
|
|
7
|
+
* machinery is exactly the kind of internal tooth the black-box plane
|
|
8
|
+
* cannot reach).
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { type ToothContext } from "../teeth/registry.ts";
|
|
13
|
+
import { createUpgrader, type CreateUpgraderOptions } from "../../../core/src/createUpgrader.ts";
|
|
14
|
+
import { staticManifestSource } from "../../../core/src/artifact/staticManifestSource.ts";
|
|
15
|
+
import {
|
|
16
|
+
buildSurfaceAllowlist,
|
|
17
|
+
readAllowlisted,
|
|
18
|
+
evaluateLifecycleConvergence,
|
|
19
|
+
} from "../../../core/src/converge/lifecycle.ts";
|
|
20
|
+
import type { ReadbackSurface } from "../../../core/src/converge/predicates.ts";
|
|
21
|
+
import type { HostAdapter, ProcessEvidence, Slot } from "../../../core/src/lifecycle/hostAdapter.ts";
|
|
22
|
+
import { slotArtifactPath } from "../../../core/src/txn/fileEffects.ts";
|
|
23
|
+
import { FakeServer } from "../fake-server/server.ts";
|
|
24
|
+
import { ArtifactFactory } from "../artifact-factory/factory.ts";
|
|
25
|
+
|
|
26
|
+
function stateDir(ctx: ToothContext): string {
|
|
27
|
+
return path.join(ctx.sandboxDir, "state");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A host whose probe reports the started slot's version (swap-like). */
|
|
31
|
+
function simpleHost(): HostAdapter {
|
|
32
|
+
let version = "0.0.0";
|
|
33
|
+
let startId = "init";
|
|
34
|
+
return {
|
|
35
|
+
async quiesce() {},
|
|
36
|
+
async stop() {},
|
|
37
|
+
async start(slot: Slot) {
|
|
38
|
+
version = slot === "experiment" ? "2.0.0" : "1.0.0";
|
|
39
|
+
startId = `${slot}-${Math.random().toString(36).slice(2)}`;
|
|
40
|
+
},
|
|
41
|
+
async healthProbe(): Promise<ProcessEvidence> {
|
|
42
|
+
return { version, pid: process.pid, startId };
|
|
43
|
+
},
|
|
44
|
+
async resume() {},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeSurface(id: string, value: () => Promise<string>): ReadbackSurface {
|
|
49
|
+
return { id, read: async () => ({ value: await value(), source: id }) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function serveTarget(ctx: ToothContext): Promise<FakeServer> {
|
|
53
|
+
const server = new FakeServer({ storeDir: path.join(ctx.sandboxDir, "store") });
|
|
54
|
+
await server.start();
|
|
55
|
+
const factory = new ArtifactFactory({ cacheDir: path.join(ctx.sandboxDir, "cache") });
|
|
56
|
+
await factory.makeRelease({
|
|
57
|
+
version: "2.0.0",
|
|
58
|
+
behavior: "ok",
|
|
59
|
+
store: server.store,
|
|
60
|
+
platform: (await import("../../../core/src/artifact/staticManifestSource.ts")).currentPlatformKey(),
|
|
61
|
+
});
|
|
62
|
+
return server;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeUpgrader(
|
|
66
|
+
ctx: ToothContext,
|
|
67
|
+
server: FakeServer,
|
|
68
|
+
surfaces: ReadbackSurface[],
|
|
69
|
+
): ReturnType<typeof createUpgrader> {
|
|
70
|
+
const opts: CreateUpgraderOptions = {
|
|
71
|
+
host: simpleHost(),
|
|
72
|
+
source: staticManifestSource({ baseUrl: server.url }),
|
|
73
|
+
policy: "auto",
|
|
74
|
+
notificationSink: async () => {},
|
|
75
|
+
stateDir: stateDir(ctx),
|
|
76
|
+
lifecycleSurfaces: surfaces,
|
|
77
|
+
};
|
|
78
|
+
return createUpgrader(opts);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// m5.lifecycle-surface-allowlist
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export async function checkM5LifecycleSurfaceAllowlist(
|
|
86
|
+
ctx: ToothContext,
|
|
87
|
+
opts: { acceptUnknown?: boolean } = {},
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const allowlist = buildSurfaceAllowlist([
|
|
90
|
+
{ surface: makeSurface("test.autostart", async () => "registered-path"), expectedTarget: "registered-path" },
|
|
91
|
+
]);
|
|
92
|
+
if (opts.acceptUnknown) {
|
|
93
|
+
// mutation: an unknown surface id is accepted as evidence — the
|
|
94
|
+
// refusal assertion must go RED
|
|
95
|
+
assert.ok(
|
|
96
|
+
(await readAllowlisted(allowlist, "not-declared")).value.length >= 0,
|
|
97
|
+
"unregistered surface must be refused (acceptUnknown => RED)",
|
|
98
|
+
);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// an id not on the allowlist is a typed refusal (未注册面被引用 ⇒ 拒)
|
|
102
|
+
await assert.rejects(readAllowlisted(allowlist, "not-declared"), /UNREGISTERED_SURFACE/);
|
|
103
|
+
|
|
104
|
+
// and the evaluator refuses when a declared surface cannot be read
|
|
105
|
+
const broken = await evaluateLifecycleConvergence(
|
|
106
|
+
buildSurfaceAllowlist([
|
|
107
|
+
{
|
|
108
|
+
surface: makeSurface("broken.autostart", async () => {
|
|
109
|
+
throw new Error("surface cannot be read on this machine");
|
|
110
|
+
}),
|
|
111
|
+
expectedTarget: "x",
|
|
112
|
+
},
|
|
113
|
+
]),
|
|
114
|
+
0,
|
|
115
|
+
);
|
|
116
|
+
assert.equal(broken.passed, false, "an unreadable surface cannot vouch for convergence");
|
|
117
|
+
assert.match(broken.source, /broken\.autostart/);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// m5.lifecycle-converged-promotes
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
export async function checkM5LifecycleConvergedPromotes(
|
|
125
|
+
ctx: ToothContext,
|
|
126
|
+
opts: { staleSurface?: boolean } = {},
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const server = await serveTarget(ctx);
|
|
129
|
+
try {
|
|
130
|
+
const dir = stateDir(ctx);
|
|
131
|
+
const target = slotArtifactPath(dir, "experiment");
|
|
132
|
+
const stale = path.join(dir, "slots", "stable", "artifact.bin");
|
|
133
|
+
const surface = opts.staleSurface
|
|
134
|
+
? { id: "test.autostart", read: async () => ({ value: stale, source: "test.autostart" }) }
|
|
135
|
+
: { id: "test.autostart", read: async () => ({ value: target, source: "test.autostart" }) };
|
|
136
|
+
const upgrader = makeUpgrader(ctx, server, [surface]);
|
|
137
|
+
const outcome = await upgrader.upgrade();
|
|
138
|
+
if (opts.staleSurface) {
|
|
139
|
+
// mutation: the surface reads back the OLD path — the predicate must
|
|
140
|
+
// refuse, so the promote assertion goes RED
|
|
141
|
+
assert.equal(
|
|
142
|
+
outcome.result,
|
|
143
|
+
"promoted",
|
|
144
|
+
"convergence requires the surface to read back the new artifact (staleSurface => RED)",
|
|
145
|
+
);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
assert.equal(outcome.result, "promoted", "a converged surface must allow the promote");
|
|
149
|
+
const lifecycle = outcome.report?.hostLifecycleConverged ?? null;
|
|
150
|
+
assert.ok(lifecycle, "this app DID declare a surface, so the report must carry a verdict");
|
|
151
|
+
assert.equal(lifecycle.passed, true, "the report must carry the real converged predicate");
|
|
152
|
+
assert.equal(lifecycle.source, "test.autostart");
|
|
153
|
+
assert.equal(outcome.report?.binaryAtTarget.passed, true);
|
|
154
|
+
} finally {
|
|
155
|
+
await server.stop();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// m5.lifecycle-projection-ban
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export async function checkM5LifecycleProjectionBan(
|
|
164
|
+
ctx: ToothContext,
|
|
165
|
+
opts: { acceptProjection?: boolean } = {},
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
// projection: the surface value is the VERSION string (metadata), not the
|
|
168
|
+
// artifact path — it must never satisfy host_lifecycle_converged
|
|
169
|
+
const projection = makeSurface("test.autostart", async () => "2.0.0");
|
|
170
|
+
const result = await evaluateLifecycleConvergence(
|
|
171
|
+
buildSurfaceAllowlist([
|
|
172
|
+
{ surface: projection, expectedTarget: slotArtifactPath(stateDir(ctx), "experiment") },
|
|
173
|
+
]),
|
|
174
|
+
0,
|
|
175
|
+
);
|
|
176
|
+
if (opts.acceptProjection) {
|
|
177
|
+
// mutation expectation: the projection IS accepted as convergence — the
|
|
178
|
+
// real evaluator refuses it, so this assertion must go RED
|
|
179
|
+
assert.equal(result.passed, true, "metadata must not green the predicate (acceptProjection => RED)");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
assert.equal(result.passed, false, "a version string is not lifecycle evidence (projection ban)");
|
|
183
|
+
assert.match(JSON.stringify(result.detail), /does not reference the installed artifact path/);
|
|
184
|
+
|
|
185
|
+
// end to end: the upgrade with a projecting surface refuses to promote
|
|
186
|
+
const server = await serveTarget(ctx);
|
|
187
|
+
try {
|
|
188
|
+
const upgrader = makeUpgrader(ctx, server, [projection]);
|
|
189
|
+
const outcome = await upgrader.upgrade();
|
|
190
|
+
assert.notEqual(outcome.result, "promoted", "a projecting surface must block the promote");
|
|
191
|
+
} finally {
|
|
192
|
+
await server.stop();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// m5.lifecycle-fail-closed-retirement
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
export async function checkM5LifecycleFailClosedRetirement(
|
|
201
|
+
ctx: ToothContext,
|
|
202
|
+
opts: { retireUnconditionally?: boolean } = {},
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
const server = await serveTarget(ctx);
|
|
205
|
+
try {
|
|
206
|
+
// the surface reads back the OLD path: convergence fails
|
|
207
|
+
const stale = path.join(stateDir(ctx), "slots", "stable", "artifact.bin");
|
|
208
|
+
const surface = { id: "test.autostart", read: async () => ({ value: stale, source: "test.autostart" }) };
|
|
209
|
+
const upgrader = makeUpgrader(ctx, server, [surface]);
|
|
210
|
+
const outcome = await upgrader.upgrade();
|
|
211
|
+
assert.notEqual(outcome.result, "promoted", "the non-converged upgrade must not promote");
|
|
212
|
+
|
|
213
|
+
const retire = await upgrader.retireLegacyManager();
|
|
214
|
+
if (opts.retireUnconditionally) {
|
|
215
|
+
// mutation: retirement ignores convergence — the HOLD assertion goes RED
|
|
216
|
+
assert.equal(retire, "retired", "retirement before convergence must be a HOLD (unconditional => RED)");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
assert.notEqual(retire, "retired", "retirement before convergence is a typed HOLD");
|
|
220
|
+
assert.match((retire as { held: string }).held, /before host_lifecycle_converged passed/);
|
|
221
|
+
|
|
222
|
+
// after a CONVERGED promote, retirement is allowed
|
|
223
|
+
const goodServer = await serveTarget(ctx);
|
|
224
|
+
try {
|
|
225
|
+
const dir = stateDir(ctx);
|
|
226
|
+
const target = slotArtifactPath(dir, "experiment");
|
|
227
|
+
const goodSurface = { id: "test.autostart", read: async () => ({ value: target, source: "test.autostart" }) };
|
|
228
|
+
const up2 = makeUpgrader(ctx, goodServer, [goodSurface]);
|
|
229
|
+
const good = await up2.upgrade();
|
|
230
|
+
assert.equal(good.result, "promoted");
|
|
231
|
+
assert.equal(await up2.retireLegacyManager(), "retired", "post-convergence retirement is allowed");
|
|
232
|
+
} finally {
|
|
233
|
+
await goodServer.stop();
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
await server.stop();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|