@tiphys/kernel 0.1.0 → 0.2.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/AGENTS.md +56 -4
- package/assurance-modes.yaml +23 -2
- package/dist/bin/tiphys.js +86 -8
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.js +27 -20
- package/dist/src/checks.d.ts +720 -9
- package/dist/src/checks.js +1874 -163
- package/dist/src/cli.js +11 -0
- package/dist/src/commands/brief.js +27 -4
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +229 -0
- package/dist/src/commands/doctor.js +968 -27
- package/dist/src/commands/init.d.ts +3 -3
- package/dist/src/commands/init.js +57 -8
- package/dist/src/commands/lock.d.ts +33 -0
- package/dist/src/commands/lock.js +117 -6
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/pool.js +12 -1
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.js +51 -2
- package/dist/src/commands/status.d.ts +6 -4
- package/dist/src/commands/status.js +6 -4
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.js +10 -2
- package/dist/src/commands/validate.js +70 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +152 -2
- package/dist/src/exec/env.js +146 -2
- package/dist/src/fleet.d.ts +172 -0
- package/dist/src/fleet.js +219 -1
- package/dist/src/gates/citations.js +7 -1
- package/dist/src/gates/coverage.d.ts +113 -22
- package/dist/src/gates/coverage.js +166 -31
- package/dist/src/gates/credentials.d.ts +159 -0
- package/dist/src/gates/credentials.js +221 -2
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/red-witness.js +105 -13
- package/dist/src/gates/run.d.ts +49 -1
- package/dist/src/gates/run.js +83 -5
- package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
- package/dist/src/gates/suite.js +48 -7
- package/dist/src/hooks.d.ts +55 -3
- package/dist/src/hooks.js +69 -6
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/lock.d.ts +82 -4
- package/dist/src/lock.js +314 -22
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/path-identity.d.ts +32 -0
- package/dist/src/path-identity.js +38 -0
- package/dist/src/pool.d.ts +197 -1
- package/dist/src/pool.js +289 -22
- package/dist/src/roles.d.ts +31 -0
- package/dist/src/roles.js +42 -0
- package/dist/src/spawn.d.ts +307 -2
- package/dist/src/spawn.js +690 -19
- package/dist/src/status.d.ts +27 -2
- package/dist/src/status.js +34 -5
- package/dist/src/task.d.ts +295 -55
- package/dist/src/task.js +125 -123
- package/dist/src/teardown.d.ts +7 -0
- package/dist/src/teardown.js +120 -12
- package/dist/src/validate.d.ts +44 -11
- package/dist/src/validate.js +44 -34
- package/dist/src/watcher.js +1 -11
- package/dist/src/witness/run.d.ts +32 -7
- package/dist/src/witness/run.js +76 -30
- package/dist/src/witness/spec.d.ts +168 -0
- package/dist/src/witness/spec.js +240 -18
- package/dist/tsconfig.src.tsbuildinfo +1 -1
- package/gate-registry.yaml +136 -0
- package/gates.manifest.json +63 -1
- package/package.json +18 -3
- package/roles/implementer.md +3 -0
- package/schemas/README.md +1 -0
- package/schemas/assurance-modes.schema.json +1 -1
- package/schemas/charter.schema.json +19 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/verdict.schema.json +9 -3
- package/schemas/write-bypass.schema.json +69 -0
|
@@ -0,0 +1,1444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cutover state and rollback (kernel plan M4, M4-P26; D-19's second limb at
|
|
3
|
+
* delivery/plan/kernel-plan-v1.md:394).
|
|
4
|
+
*
|
|
5
|
+
* WHAT THIS MODULE IS. The freeze point is not one event. It is FIVE
|
|
6
|
+
* independently flippable switches, taken from the list the current delivery
|
|
7
|
+
* process retains at
|
|
8
|
+
* delivery/decisions/DR-0025-controlled-pre-m4-local-pilot.md:45 and read
|
|
9
|
+
* against DR-0036's authority condition. Five switches rather than one event
|
|
10
|
+
* is what makes rollback PARTIAL rather than all-or-nothing, and it is why
|
|
11
|
+
* `credentials-and-refs`, the one switch with owner latency, is separable
|
|
12
|
+
* from the four that are not.
|
|
13
|
+
*
|
|
14
|
+
* THE SPLIT THAT KEEPS THIS HONEST, and it runs through every function here.
|
|
15
|
+
* Rollback of the FILES is cheap: everything under the retirement roots is
|
|
16
|
+
* git-tracked and revertible from history. Rollback of AUTHORITY is the
|
|
17
|
+
* expensive half: the branch-protection ruleset is owner-configured, the
|
|
18
|
+
* orchestrator cannot change it, and
|
|
19
|
+
* delivery/decisions/DR-0036-the-harness-adapter-leads-m4-and-the-kernel-is-the-second-subject.md:15
|
|
20
|
+
* prices the revert at "whatever phases ran under it". Nothing in this module
|
|
21
|
+
* flips authority. It PREPARES the request an owner acts on, and it refuses
|
|
22
|
+
* loudly rather than pretending the preparation is the act.
|
|
23
|
+
*
|
|
24
|
+
* WHAT THIS MODULE DELIBERATELY DOES NOT DO. It does not print
|
|
25
|
+
* `tiphys cutover status`, does not write switches to `kernel`, and does not
|
|
26
|
+
* ship a state schema. Those are M4-P25's acceptance criteria and M4-P25's
|
|
27
|
+
* files. This phase was dispatched ahead of its stated dependency because the
|
|
28
|
+
* conflict pre-pass holds `src/commands/cutover.ts` for exactly one unit at a
|
|
29
|
+
* time, so the state MODEL is created here and the status SURFACE is left
|
|
30
|
+
* where the plan put it. The boundary is recorded in
|
|
31
|
+
* delivery/work-history/m4-p26.md:1 rather than left to be discovered.
|
|
32
|
+
*
|
|
33
|
+
* PLAN CONSTRAINTS. C-1: current state comes from `cutover.json` and from
|
|
34
|
+
* meta.json plus the turn-end file, never from the tail of a log. C-2: no pid,
|
|
35
|
+
* no signal, no /proc and no process probing appears here; a live worktree is
|
|
36
|
+
* a DIRECTORY and an open task is a FILE STATE. C-3: nothing is backgrounded.
|
|
37
|
+
*/
|
|
38
|
+
import { spawnSync } from "node:child_process";
|
|
39
|
+
import { randomBytes } from "node:crypto";
|
|
40
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
|
|
41
|
+
import { dirname, join } from "node:path";
|
|
42
|
+
import { packageRoot } from "./modes.js";
|
|
43
|
+
import { classifyEntry, readRegularFileIfPresent, turnEndPath, } from "./task.js";
|
|
44
|
+
import { validateToLines } from "./validate.js";
|
|
45
|
+
/* -------------------------------------------------------------------- */
|
|
46
|
+
/* The five switches */
|
|
47
|
+
/* -------------------------------------------------------------------- */
|
|
48
|
+
/**
|
|
49
|
+
* The five retained items of DR-0025, in the order section 4.3 of the M4
|
|
50
|
+
* plan tabulates them. This list is CLOSED. A sixth name is a plan revision,
|
|
51
|
+
* not a code edit, because every rollback trigger enumerates it.
|
|
52
|
+
*/
|
|
53
|
+
export const CUTOVER_SWITCHES = [
|
|
54
|
+
"planning-and-scope",
|
|
55
|
+
"review-and-arbitration",
|
|
56
|
+
"credentials-and-refs",
|
|
57
|
+
"salvage-and-recovery",
|
|
58
|
+
"closeout",
|
|
59
|
+
];
|
|
60
|
+
export const SWITCH_STATES = ["current", "kernel"];
|
|
61
|
+
/**
|
|
62
|
+
* The switch with owner latency. Named as a constant because three separate
|
|
63
|
+
* places have to treat it differently and a repeated string literal is how
|
|
64
|
+
* one of them ends up not doing so.
|
|
65
|
+
*/
|
|
66
|
+
export const OWNER_LATENCY_SWITCH = "credentials-and-refs";
|
|
67
|
+
/**
|
|
68
|
+
* `cutover.json` sits at the FLEET ROOT and not under `state/`, because
|
|
69
|
+
* `state/` is gitignored (src/fleet.ts:29) and trigger 2 step 1 identifies a
|
|
70
|
+
* flip with `git log` over this exact path. A switch history that is not
|
|
71
|
+
* committed is not a history.
|
|
72
|
+
*/
|
|
73
|
+
export function cutoverStatePath(fleet) {
|
|
74
|
+
return join(fleet.root, "cutover.json");
|
|
75
|
+
}
|
|
76
|
+
function isSwitchState(value) {
|
|
77
|
+
return typeof value === "string" && SWITCH_STATES.includes(value);
|
|
78
|
+
}
|
|
79
|
+
function nonEmptyString(value) {
|
|
80
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Validate a parsed document into a CutoverState, returning every reason at
|
|
84
|
+
* once rather than the first.
|
|
85
|
+
*
|
|
86
|
+
* FAIL CLOSED ON `restoreTo`. A record missing it, or carrying it as a word
|
|
87
|
+
* outside the two-value vocabulary, is REFUSED rather than defaulted to
|
|
88
|
+
* `current`. Defaulting would make rollback silently guess, and a guess that
|
|
89
|
+
* happens to be right most of the time is the guard that cannot go red.
|
|
90
|
+
*/
|
|
91
|
+
export function validateCutoverDocument(document) {
|
|
92
|
+
const reasons = [];
|
|
93
|
+
if (typeof document !== "object" || document === null || Array.isArray(document)) {
|
|
94
|
+
return ["cutover state is not a JSON object"];
|
|
95
|
+
}
|
|
96
|
+
const switches = document.switches;
|
|
97
|
+
if (typeof switches !== "object" || switches === null || Array.isArray(switches)) {
|
|
98
|
+
return ["cutover state has no switches object"];
|
|
99
|
+
}
|
|
100
|
+
const table = switches;
|
|
101
|
+
for (const name of CUTOVER_SWITCHES) {
|
|
102
|
+
const record = table[name];
|
|
103
|
+
if (typeof record !== "object" || record === null || Array.isArray(record)) {
|
|
104
|
+
reasons.push(`switch ${name} is missing`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const row = record;
|
|
108
|
+
if (!isSwitchState(row["state"])) {
|
|
109
|
+
reasons.push(`switch ${name} has no state of current or kernel`);
|
|
110
|
+
}
|
|
111
|
+
if (!isSwitchState(row["restoreTo"])) {
|
|
112
|
+
reasons.push(`switch ${name} has no restoreTo of current or kernel`);
|
|
113
|
+
}
|
|
114
|
+
for (const field of ["flippedAt", "flippedBy", "reason"]) {
|
|
115
|
+
if (!nonEmptyString(row[field])) {
|
|
116
|
+
reasons.push(`switch ${name} has no ${field}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const unknown = Object.keys(table).filter((key) => !CUTOVER_SWITCHES.includes(key));
|
|
121
|
+
for (const key of unknown) {
|
|
122
|
+
reasons.push(`switch ${key} is not one of the five`);
|
|
123
|
+
}
|
|
124
|
+
return reasons;
|
|
125
|
+
}
|
|
126
|
+
/** Read and validate the fleet's cutover state. */
|
|
127
|
+
export function readCutoverState(fleet) {
|
|
128
|
+
const path = cutoverStatePath(fleet);
|
|
129
|
+
const read = readRegularFileIfPresent(path);
|
|
130
|
+
if (read.kind === "absent") {
|
|
131
|
+
return { kind: "absent" };
|
|
132
|
+
}
|
|
133
|
+
if (read.kind === "refused") {
|
|
134
|
+
return { kind: "refused", reason: read.reason };
|
|
135
|
+
}
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(read.body);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
return { kind: "refused", reason: `${path} is not valid JSON: ${String(error)}` };
|
|
142
|
+
}
|
|
143
|
+
const reasons = validateCutoverDocument(parsed);
|
|
144
|
+
if (reasons.length > 0) {
|
|
145
|
+
return { kind: "refused", reason: `${path}: ${reasons.join("; ")}` };
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
kind: "read",
|
|
149
|
+
state: parsed,
|
|
150
|
+
document: parsed,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export function renderCutoverState(state) {
|
|
154
|
+
return `${JSON.stringify(state, null, 2)}\n`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Put a new switch table into the document the file actually held, leaving
|
|
158
|
+
* every other key exactly as it was read.
|
|
159
|
+
*
|
|
160
|
+
* WHY THIS EXISTS AS A NAMED FUNCTION. `validateCutoverDocument` refuses an
|
|
161
|
+
* unknown SWITCH NAME because the five are a closed list, and that made it
|
|
162
|
+
* look as though the whole document were closed. It is not: M4-P25 owns the
|
|
163
|
+
* schema and may add top-level keys, so a rollback that serialises only what
|
|
164
|
+
* this module's interface names would silently delete them. Refusing them
|
|
165
|
+
* instead would be this phase deciding M4-P25's schema, which is not its to
|
|
166
|
+
* decide, so the document is CARRIED rather than narrowed or refused.
|
|
167
|
+
*/
|
|
168
|
+
export function withSwitches(document, switches) {
|
|
169
|
+
return { ...document, switches };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Publish a whole cutover state ATOMICALLY: serialise everything, write it to
|
|
173
|
+
* a sibling temporary file, fsync that file, then rename over the
|
|
174
|
+
* destination. Nothing partial is ever visible at the destination path,
|
|
175
|
+
* because rename(2) within one directory is atomic.
|
|
176
|
+
*
|
|
177
|
+
* THE MECHANISM THIS EXISTS AGAINST is not "a crash". It is WRITING STATE
|
|
178
|
+
* PER ITEM WHILE ITERATING, which leaves a file that is internally valid and
|
|
179
|
+
* factually wrong: three switches saying `kernel` and two saying `current`
|
|
180
|
+
* describes a process that has no owner for three of its five authorities.
|
|
181
|
+
* Every caller in this module builds the complete next state first and calls
|
|
182
|
+
* this once.
|
|
183
|
+
*/
|
|
184
|
+
export function publishCutoverState(path, state) {
|
|
185
|
+
/* M4-P25 criterion 4, AND THE ORDER IS THE PROPERTY. The shipped schema
|
|
186
|
+
refuses a switch record missing `restoreTo` (or `flippedAt`, `flippedBy`
|
|
187
|
+
or `reason`) BEFORE the temporary file is opened, so a refused write
|
|
188
|
+
leaves the destination byte-identical and, where there was none, leaves
|
|
189
|
+
no file at all. The check lives in the schema rather than here because a
|
|
190
|
+
check implemented only in a command cannot bind a later writer, and the
|
|
191
|
+
later writer is exactly who M4-P26's rollback depends on: `targetFor`
|
|
192
|
+
reads the recorded value rather than reconstructing an intent. */
|
|
193
|
+
const diagnostics = cutoverSchemaDiagnostics(state);
|
|
194
|
+
if (diagnostics.length > 0) {
|
|
195
|
+
throw new Error(`the cutover state was refused by ${CUTOVER_STATE_SCHEMA_FILENAME} and NOTHING was written: ${diagnostics.join("; ")}`);
|
|
196
|
+
}
|
|
197
|
+
const directory = dirname(path);
|
|
198
|
+
mkdirSync(directory, { recursive: true });
|
|
199
|
+
/* The suffix is random, never a pid. C-2 forbids a pid as an identity, and
|
|
200
|
+
a temp-file name is an identity for exactly as long as the rename takes. */
|
|
201
|
+
const temporary = join(directory, `.cutover.${randomBytes(8).toString("hex")}.tmp`);
|
|
202
|
+
const body = renderCutoverState(state);
|
|
203
|
+
let handle;
|
|
204
|
+
try {
|
|
205
|
+
handle = openSync(temporary, "wx");
|
|
206
|
+
writeSync(handle, body);
|
|
207
|
+
fsyncSync(handle);
|
|
208
|
+
closeSync(handle);
|
|
209
|
+
handle = undefined;
|
|
210
|
+
renameSync(temporary, path);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (handle !== undefined) {
|
|
214
|
+
try {
|
|
215
|
+
closeSync(handle);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
/* the write already failed; the close outcome adds nothing */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
rmSync(temporary, { force: true });
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
/* best effort: a leftover temp file is not the destination */
|
|
226
|
+
}
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
export const ROLLBACK_TRIGGERS = [
|
|
231
|
+
"drain-reversal",
|
|
232
|
+
"freeze-point-restore",
|
|
233
|
+
"retirement-unmet",
|
|
234
|
+
];
|
|
235
|
+
/**
|
|
236
|
+
* The target value a trigger moves a switch to.
|
|
237
|
+
*
|
|
238
|
+
* Trigger 1 (drain reversal) hands every authority back to the current
|
|
239
|
+
* process, so the target is `current` for all five: in-flight work has to be
|
|
240
|
+
* handled by the process that started it.
|
|
241
|
+
*
|
|
242
|
+
* Trigger 2 (freeze-point restore) reads each switch's own `restoreTo`. That
|
|
243
|
+
* is the difference between the two, and it is why M4-P25 criterion 4 refuses
|
|
244
|
+
* a write that omits the field.
|
|
245
|
+
*
|
|
246
|
+
* ROLLBACK IS MONOTONE AND THE CLAMP BELOW IS WHAT MAKES THE MODULE HEADER
|
|
247
|
+
* TRUE. `restoreTo` is the value a switch held BEFORE its last flip, and
|
|
248
|
+
* `planRollback` sets it to the value the switch is leaving. So a switch that
|
|
249
|
+
* has already been rolled back to `current` carries `restoreTo: "kernel"`, and
|
|
250
|
+
* returning that value would move the switch FORWARD on the second run of the
|
|
251
|
+
* same command. The failure arm of trigger 1 step 1 exits nonzero after the
|
|
252
|
+
* local write has already happened, and the natural response to that is to run
|
|
253
|
+
* the command again, so the second run is not a hypothetical. A rollback never
|
|
254
|
+
* hands authority to the kernel: `current` is a floor, and a switch already at
|
|
255
|
+
* it is a no-op rather than a flip.
|
|
256
|
+
*/
|
|
257
|
+
export function targetFor(trigger, record) {
|
|
258
|
+
if (trigger === "drain-reversal") {
|
|
259
|
+
return "current";
|
|
260
|
+
}
|
|
261
|
+
if (record.state === "current") {
|
|
262
|
+
return "current";
|
|
263
|
+
}
|
|
264
|
+
return record.restoreTo;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Assemble the complete next state. Throws rather than returning a partial
|
|
268
|
+
* plan, and touches no file at all: publishing is a separate call.
|
|
269
|
+
*/
|
|
270
|
+
export function planRollback(state, trigger, options) {
|
|
271
|
+
const changes = [];
|
|
272
|
+
const nextSwitches = {};
|
|
273
|
+
let index = 0;
|
|
274
|
+
for (const name of CUTOVER_SWITCHES) {
|
|
275
|
+
const record = state.switches[name];
|
|
276
|
+
const to = targetFor(trigger, record);
|
|
277
|
+
const change = { name, from: record.state, to };
|
|
278
|
+
options.onSwitch?.(change, index);
|
|
279
|
+
if (change.from === change.to) {
|
|
280
|
+
/* A SWITCH THIS ROLLBACK DOES NOT MOVE IS NOT REWRITTEN. The record is
|
|
281
|
+
carried through unchanged, `flippedAt`, `flippedBy`, `reason` and
|
|
282
|
+
`restoreTo` included. Stamping the current run over an unmoved switch
|
|
283
|
+
would destroy the one fact freeze-point restore depends on, which is
|
|
284
|
+
what that switch left, and it would do it while reporting zero
|
|
285
|
+
changes: a write whose scope is wider than the sentence describing
|
|
286
|
+
it. */
|
|
287
|
+
nextSwitches[name] = record;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
nextSwitches[name] = {
|
|
291
|
+
/* Spread the record as it was READ. The interface names five fields;
|
|
292
|
+
the file may carry more, and a rollback that rebuilds the record
|
|
293
|
+
from the interface deletes whatever it did not know about. */
|
|
294
|
+
...record,
|
|
295
|
+
state: to,
|
|
296
|
+
flippedAt: options.now,
|
|
297
|
+
flippedBy: options.by,
|
|
298
|
+
reason: options.reason,
|
|
299
|
+
/* The rolled-back switch can be flipped forward again, and the value
|
|
300
|
+
it would return to is the one it is leaving now. Carrying the OLD
|
|
301
|
+
restoreTo forward would make a second rollback restore a state two
|
|
302
|
+
flips old. */
|
|
303
|
+
restoreTo: record.state,
|
|
304
|
+
};
|
|
305
|
+
changes.push(change);
|
|
306
|
+
}
|
|
307
|
+
index += 1;
|
|
308
|
+
}
|
|
309
|
+
return { changes, next: { switches: nextSwitches } };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Plan and publish in one call: validate the whole input, assemble the whole
|
|
313
|
+
* next state, then write once.
|
|
314
|
+
*
|
|
315
|
+
* ORDER IS THE PROPERTY. Every refusal reachable from a bad input happens
|
|
316
|
+
* before `publishCutoverState` is called, so a refusal leaves the file
|
|
317
|
+
* byte-identical. That is asserted directly rather than trusted: see
|
|
318
|
+
* test/cutover.test.ts.
|
|
319
|
+
*/
|
|
320
|
+
export function applyRollback(fleet, trigger, options) {
|
|
321
|
+
const read = readCutoverState(fleet);
|
|
322
|
+
if (read.kind === "absent") {
|
|
323
|
+
return { ok: false, reason: `${cutoverStatePath(fleet)} is absent` };
|
|
324
|
+
}
|
|
325
|
+
if (read.kind === "refused") {
|
|
326
|
+
return { ok: false, reason: read.reason };
|
|
327
|
+
}
|
|
328
|
+
let plan;
|
|
329
|
+
try {
|
|
330
|
+
plan = planRollback(read.state, trigger, options);
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
reason: `rollback assembly failed before any write: ${error instanceof Error ? error.message : String(error)}`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
/* Publish the DOCUMENT with the new switch table in it, not the typed
|
|
340
|
+
view. `read.document` is what the file held; `plan.next.switches` is the
|
|
341
|
+
only part this rollback decided. Publishing `plan.next` alone would
|
|
342
|
+
delete every top-level key this module does not name. */
|
|
343
|
+
publishCutoverState(cutoverStatePath(fleet), withSwitches(read.document, plan.next.switches));
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
reason: `rollback could not be published: ${error instanceof Error ? error.message : String(error)}`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return { ok: true, changes: plan.changes, next: plan.next };
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* WHAT DRAIN COUNTS, and the omission is the decision.
|
|
355
|
+
*
|
|
356
|
+
* In-flight work only: live worktrees and open tasks with no turn-end. Pushed
|
|
357
|
+
* unmerged BRANCHES are deliberately NOT counted (M4-D-15, decided at
|
|
358
|
+
* delivery/plan/kernel-plan-m4.md:3279). This container cannot delete a remote
|
|
359
|
+
* ref and the delete dry run exits 0 either way, and the probes generalised
|
|
360
|
+
* that further: a dry run does not probe push authorization at all
|
|
361
|
+
* (delivery/verification/m4-prototype-probes.md:165). A drain predicate over
|
|
362
|
+
* branches therefore blocks cutover on an owner action with no local
|
|
363
|
+
* pre-check, which is a predicate that can never read clean.
|
|
364
|
+
*
|
|
365
|
+
* C-2 is load-bearing here. A live worktree is a DIRECTORY on disk and an
|
|
366
|
+
* open task is meta.json's status plus the absence of the turn-end file. No
|
|
367
|
+
* process is probed, no pid is read, and nothing asks whether an agent is
|
|
368
|
+
* still breathing.
|
|
369
|
+
*/
|
|
370
|
+
export function inFlightItems(fleet) {
|
|
371
|
+
const items = [];
|
|
372
|
+
/* ABSENT IS UNDECIDABLE HERE AND NOT EMPTY. `loadFleet` refuses a fleet
|
|
373
|
+
whose `worktrees/` or `tasks/` is not a directory (src/fleet.ts:65), so a
|
|
374
|
+
Fleet value that reaches this function had both when it was loaded. One
|
|
375
|
+
that is gone now was removed since, which says nothing about what was in
|
|
376
|
+
it, and reading that as a clean drain is the same fall-through as reading
|
|
377
|
+
an unreadable directory as an empty one. */
|
|
378
|
+
const worktrees = listDirectory(fleet.worktreesDir);
|
|
379
|
+
if (worktrees.kind !== "listed") {
|
|
380
|
+
items.push({
|
|
381
|
+
kind: "unexaminable",
|
|
382
|
+
id: fleet.worktreesDir,
|
|
383
|
+
detail: worktrees.kind === "absent"
|
|
384
|
+
? `${fleet.worktreesDir} is gone, and the fleet had it when it was loaded`
|
|
385
|
+
: worktrees.reason,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
for (const id of worktrees.kind === "listed" ? worktrees.names : []) {
|
|
389
|
+
const path = join(fleet.worktreesDir, id);
|
|
390
|
+
const probe = probeDirectory(path);
|
|
391
|
+
if (probe.kind === "directory") {
|
|
392
|
+
items.push({ kind: "worktree", id, detail: path });
|
|
393
|
+
}
|
|
394
|
+
else if (probe.kind === "unexaminable") {
|
|
395
|
+
items.push({ kind: "unexaminable", id, detail: probe.reason });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const tasks = listDirectory(fleet.tasksDir);
|
|
399
|
+
if (tasks.kind !== "listed") {
|
|
400
|
+
items.push({
|
|
401
|
+
kind: "unexaminable",
|
|
402
|
+
id: fleet.tasksDir,
|
|
403
|
+
detail: tasks.kind === "absent"
|
|
404
|
+
? `${fleet.tasksDir} is gone, and the fleet had it when it was loaded`
|
|
405
|
+
: tasks.reason,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
for (const id of tasks.kind === "listed" ? tasks.names : []) {
|
|
409
|
+
/* A TASK IS A DIRECTORY under tasks/, checked by TYPE and never by name,
|
|
410
|
+
which is the rule src/liveness.ts states for every task reader. This
|
|
411
|
+
loop was the one reader that skipped it, so `tiphys init`'s own
|
|
412
|
+
`tasks/.gitkeep` read as one task in flight and a freshly initialised
|
|
413
|
+
fleet could never report DRAIN clean. Resolved with stat, as the
|
|
414
|
+
worktree loop above does, so a symlink to a task directory is a task. */
|
|
415
|
+
const entry = probeDirectory(join(fleet.tasksDir, id));
|
|
416
|
+
if (entry.kind === "absent" || entry.kind === "other") {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (entry.kind === "unexaminable") {
|
|
420
|
+
items.push({ kind: "unexaminable", id, detail: entry.reason });
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const metaRead = readRegularFileIfPresent(join(fleet.tasksDir, id, "meta.json"));
|
|
424
|
+
if (metaRead.kind === "refused") {
|
|
425
|
+
/* Present and not readable. A meta.json that is a named pipe, a
|
|
426
|
+
directory, or a path this process may not stat says NOTHING about
|
|
427
|
+
whether the task finished, and the T-003 hazard shape is exactly a
|
|
428
|
+
FIFO where a regular file was expected. */
|
|
429
|
+
items.push({ kind: "task", id, detail: `meta.json could not be examined: ${metaRead.reason}` });
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (metaRead.kind === "absent") {
|
|
433
|
+
items.push({ kind: "task", id, detail: "meta.json is absent, so the task has no recorded status" });
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
let status;
|
|
437
|
+
try {
|
|
438
|
+
status = JSON.parse(metaRead.body).status;
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
/* An unparseable meta.json is not evidence that the task finished, so
|
|
442
|
+
it counts as in flight rather than being skipped. */
|
|
443
|
+
items.push({ kind: "task", id, detail: "meta.json is unparseable" });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
/* `closed` is the ONLY positive evidence that a task is finished. The
|
|
447
|
+
previous form tested `status !== "open"`, which made every value that is
|
|
448
|
+
not the word `open` - a missing field, a typo, a number - read as
|
|
449
|
+
finished. The vocabulary is closed (src/task.ts, TaskStatus), so a value
|
|
450
|
+
outside it is undecided and undecided counts. */
|
|
451
|
+
if (status === "closed") {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (status !== "open") {
|
|
455
|
+
items.push({
|
|
456
|
+
kind: "task",
|
|
457
|
+
id,
|
|
458
|
+
detail: `meta.json status ${JSON.stringify(status)} is not one of open or closed`,
|
|
459
|
+
});
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const turnEnd = classifyEntry(turnEndPath(fleet, id));
|
|
463
|
+
/* Symmetrically: a REGULAR turn-end file is the only positive evidence
|
|
464
|
+
that the turn ended. `irregular` and `unexaminable` are undecided, not
|
|
465
|
+
finished, and the previous form counted only `absent` and `dangling`,
|
|
466
|
+
so a turn-end that was a named pipe read as a finished task. */
|
|
467
|
+
if (turnEnd.kind === "regular") {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
items.push({
|
|
471
|
+
kind: "task",
|
|
472
|
+
id,
|
|
473
|
+
detail: turnEnd.kind === "absent" || turnEnd.kind === "dangling"
|
|
474
|
+
? "open with no turn-end"
|
|
475
|
+
: `open and the turn-end could not be examined: ${turnEnd.reason}`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return items.sort((a, b) => `${a.kind}/${a.id}`.localeCompare(`${b.kind}/${b.id}`));
|
|
479
|
+
}
|
|
480
|
+
function probeDirectory(path) {
|
|
481
|
+
let stats;
|
|
482
|
+
try {
|
|
483
|
+
stats = statSync(path);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (error.code === "ENOENT") {
|
|
487
|
+
return { kind: "absent" };
|
|
488
|
+
}
|
|
489
|
+
return { kind: "unexaminable", reason: `${path} could not be examined: ${String(error)}` };
|
|
490
|
+
}
|
|
491
|
+
return stats.isDirectory() ? { kind: "directory" } : { kind: "other" };
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Enumerate a directory, distinguishing "it is not there" from "it could not
|
|
495
|
+
* be enumerated". An empty list and a failed listing are the same value to a
|
|
496
|
+
* caller that returns `[]` for both, and in this module the caller is the
|
|
497
|
+
* drain predicate: a `tasks/` that cannot be read would have reported a clean
|
|
498
|
+
* drain over an unknown number of open tasks.
|
|
499
|
+
*/
|
|
500
|
+
function listDirectory(path) {
|
|
501
|
+
const probe = probeDirectory(path);
|
|
502
|
+
if (probe.kind === "absent") {
|
|
503
|
+
return { kind: "absent" };
|
|
504
|
+
}
|
|
505
|
+
if (probe.kind === "unexaminable") {
|
|
506
|
+
return { kind: "unexaminable", reason: probe.reason };
|
|
507
|
+
}
|
|
508
|
+
if (probe.kind === "other") {
|
|
509
|
+
return {
|
|
510
|
+
kind: "unexaminable",
|
|
511
|
+
reason: `${path} is not a directory, so its contents could not be enumerated`,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
return { kind: "listed", names: readdirSync(path).sort() };
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
return { kind: "unexaminable", reason: `${path} could not be enumerated: ${String(error)}` };
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* The named list of things no local command can see. It is printed with every
|
|
523
|
+
* drain report and is NOT abbreviated when the network is unreachable: a
|
|
524
|
+
* command that silently degrades to a shorter answer is indistinguishable
|
|
525
|
+
* from one reporting a quiet system, which is the shape standing warning 6
|
|
526
|
+
* records for watchers.
|
|
527
|
+
*/
|
|
528
|
+
export const CANNOT_SEE = [
|
|
529
|
+
"open pull requests",
|
|
530
|
+
"CI conclusions",
|
|
531
|
+
"post-merge push runs",
|
|
532
|
+
"whether the pilot's own fleet is drained (DR-0037: the pilot is not this orchestrator's subject)",
|
|
533
|
+
];
|
|
534
|
+
export function runGit(cwd, args) {
|
|
535
|
+
const result = spawnSync("git", args, {
|
|
536
|
+
cwd,
|
|
537
|
+
encoding: "utf8",
|
|
538
|
+
env: {
|
|
539
|
+
...process.env,
|
|
540
|
+
GIT_AUTHOR_NAME: "tiphys",
|
|
541
|
+
GIT_AUTHOR_EMAIL: "tiphys@localhost",
|
|
542
|
+
GIT_COMMITTER_NAME: "tiphys",
|
|
543
|
+
GIT_COMMITTER_EMAIL: "tiphys@localhost",
|
|
544
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
return {
|
|
548
|
+
status: result.status ?? 1,
|
|
549
|
+
stdout: result.stdout ?? "",
|
|
550
|
+
stderr: result.stderr ?? (result.error === undefined ? "" : String(result.error)),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
function nonEmptyLines(text) {
|
|
554
|
+
return text
|
|
555
|
+
.split("\n")
|
|
556
|
+
.map((line) => line.trim())
|
|
557
|
+
.filter((line) => line.length > 0);
|
|
558
|
+
}
|
|
559
|
+
export function syncFleetState(fleetRoot, options) {
|
|
560
|
+
if (options.paths.length === 0) {
|
|
561
|
+
return {
|
|
562
|
+
ok: false,
|
|
563
|
+
reason: "syncFleetState was given no path to stage, and a rollback that stages everything commits work it did not do",
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
const remotes = runGit(fleetRoot, ["remote"]);
|
|
567
|
+
if (remotes.status !== 0) {
|
|
568
|
+
return { ok: false, reason: `git remote failed in ${fleetRoot}: ${remotes.stderr.trim()}` };
|
|
569
|
+
}
|
|
570
|
+
const hasOrigin = remotes.stdout.split("\n").some((line) => line.trim() === "origin");
|
|
571
|
+
/* An index that already holds something is refused rather than absorbed: a
|
|
572
|
+
scoped staging does not unstage what somebody else staged, so committing
|
|
573
|
+
here would carry it under this message. */
|
|
574
|
+
const preStaged = runGit(fleetRoot, ["diff", "--cached", "--name-only"]);
|
|
575
|
+
if (preStaged.status !== 0) {
|
|
576
|
+
return { ok: false, reason: `git diff --cached failed: ${preStaged.stderr.trim()}` };
|
|
577
|
+
}
|
|
578
|
+
const already = nonEmptyLines(preStaged.stdout);
|
|
579
|
+
if (already.length > 0) {
|
|
580
|
+
return {
|
|
581
|
+
ok: false,
|
|
582
|
+
reason: `${fleetRoot} already holds ${String(already.length)} staged path(s) this rollback did not stage, ` +
|
|
583
|
+
`and nothing was committed: ${already.slice(0, 10).join(", ")}`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
const add = runGit(fleetRoot, ["add", "--", ...options.paths]);
|
|
587
|
+
if (add.status !== 0) {
|
|
588
|
+
return { ok: false, reason: `git add failed in ${fleetRoot}: ${add.stderr.trim()}` };
|
|
589
|
+
}
|
|
590
|
+
const staged = runGit(fleetRoot, ["diff", "--cached", "--name-only"]);
|
|
591
|
+
if (staged.status !== 0) {
|
|
592
|
+
return { ok: false, reason: `git diff --cached failed: ${staged.stderr.trim()}` };
|
|
593
|
+
}
|
|
594
|
+
const stagedPaths = nonEmptyLines(staged.stdout);
|
|
595
|
+
/* The scope is VERIFIED and not assumed. A named path that turns out to be a
|
|
596
|
+
directory, or a pathspec the caller did not mean, shows up here. */
|
|
597
|
+
const stray = stagedPaths.filter((path) => !options.paths.some((want) => path === want || path.startsWith(`${want}/`)));
|
|
598
|
+
if (stray.length > 0) {
|
|
599
|
+
return {
|
|
600
|
+
ok: false,
|
|
601
|
+
reason: `staging ${options.paths.join(", ")} also staged ${String(stray.length)} path(s) outside it, ` +
|
|
602
|
+
`and nothing was committed: ${stray.slice(0, 10).join(", ")}`,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
if (stagedPaths.length > 0) {
|
|
606
|
+
const commit = runGit(fleetRoot, ["commit", "-q", "-m", options.message]);
|
|
607
|
+
if (commit.status !== 0) {
|
|
608
|
+
return { ok: false, reason: `git commit failed: ${commit.stderr.trim()}` };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (!hasOrigin) {
|
|
612
|
+
const reason = `${fleetRoot} has no origin remote, so the rollback is committed locally and NOT published`;
|
|
613
|
+
return options.allowNoRemote === true
|
|
614
|
+
? { ok: true, pushed: false, reason, staged: stagedPaths }
|
|
615
|
+
: { ok: false, reason };
|
|
616
|
+
}
|
|
617
|
+
const push = runGit(fleetRoot, ["push", "origin", "HEAD"]);
|
|
618
|
+
if (push.status !== 0) {
|
|
619
|
+
return {
|
|
620
|
+
ok: false,
|
|
621
|
+
reason: `git push failed: ${(push.stderr + push.stdout).trim().split("\n").join(" ")}`,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
const head = runGit(fleetRoot, ["rev-parse", "HEAD"]);
|
|
625
|
+
if (head.status !== 0) {
|
|
626
|
+
return { ok: false, reason: `git rev-parse HEAD failed: ${head.stderr.trim()}` };
|
|
627
|
+
}
|
|
628
|
+
return { ok: true, pushed: true, head: head.stdout.trim(), staged: stagedPaths };
|
|
629
|
+
}
|
|
630
|
+
/* -------------------------------------------------------------------- */
|
|
631
|
+
/* Trigger 2 step 2: restoring the FILES */
|
|
632
|
+
/* -------------------------------------------------------------------- */
|
|
633
|
+
/**
|
|
634
|
+
* Refuse to touch a tree that holds uncommitted work.
|
|
635
|
+
*
|
|
636
|
+
* ANY `git checkout --` in such a tree is destructive, INCLUDING when it names
|
|
637
|
+
* a single path, and especially the path being edited. This repository has
|
|
638
|
+
* paid for that twice; the rule is standing warning 8 in the agent-rules file.
|
|
639
|
+
* The guard is a precondition of the restore and not a warning printed beside
|
|
640
|
+
* it, because a warning is advice and this is the only thing standing between
|
|
641
|
+
* a rollback and four rounds of somebody's uncommitted work.
|
|
642
|
+
*/
|
|
643
|
+
export function refuseIfTreeDirty(repoRoot) {
|
|
644
|
+
const status = runGit(repoRoot, ["status", "--porcelain"]);
|
|
645
|
+
if (status.status !== 0) {
|
|
646
|
+
return { ok: false, reason: `git status failed in ${repoRoot}: ${status.stderr.trim()}` };
|
|
647
|
+
}
|
|
648
|
+
const dirty = status.stdout
|
|
649
|
+
.split("\n")
|
|
650
|
+
.map((line) => line.trim())
|
|
651
|
+
.filter((line) => line.length > 0);
|
|
652
|
+
if (dirty.length > 0) {
|
|
653
|
+
return {
|
|
654
|
+
ok: false,
|
|
655
|
+
reason: `${repoRoot} holds ${dirty.length} uncommitted change(s) and nothing was touched: ` +
|
|
656
|
+
dirty.slice(0, 10).join(", "),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
return { ok: true };
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Restore the named retirement roots from a pre-freeze sha.
|
|
663
|
+
*
|
|
664
|
+
* The dirty check runs FIRST and returns before any version-control
|
|
665
|
+
* invocation that can write. That order is the whole guarantee, and the test
|
|
666
|
+
* asserts the dirty file is byte-identical afterwards rather than asserting
|
|
667
|
+
* the exit code alone.
|
|
668
|
+
*
|
|
669
|
+
* "RESTORE" IS A CLAIM ABOUT THE WHOLE ROOT, AND CHECKING A TREE OUT OVER A
|
|
670
|
+
* PATH IS NOT ONE. `checkout <sha> -- <root>` writes what the sha held and
|
|
671
|
+
* removes NOTHING, so every file added under the root after the freeze
|
|
672
|
+
* survives the restore untouched. The result is a hybrid tree that the caller
|
|
673
|
+
* prints `RESTORED` over: a verdict wider than the operation that produced it.
|
|
674
|
+
* The post-freeze additions are therefore enumerated and removed, and then the
|
|
675
|
+
* root is COMPARED with the sha. The comparison is the verdict; without it the
|
|
676
|
+
* success arm is an assumption, and a success arm that cannot fail is the
|
|
677
|
+
* guard that cannot go red.
|
|
678
|
+
*/
|
|
679
|
+
export function restoreRetirementRoots(repoRoot, sha, roots) {
|
|
680
|
+
if (roots.length === 0) {
|
|
681
|
+
return { ok: false, reason: "no retirement root was named" };
|
|
682
|
+
}
|
|
683
|
+
const guard = refuseIfTreeDirty(repoRoot);
|
|
684
|
+
if (!guard.ok) {
|
|
685
|
+
return { ok: false, reason: guard.reason };
|
|
686
|
+
}
|
|
687
|
+
const resolved = runGit(repoRoot, ["rev-parse", "--verify", `${sha}^{commit}`]);
|
|
688
|
+
if (resolved.status !== 0) {
|
|
689
|
+
return { ok: false, reason: `${sha} does not resolve to a commit in ${repoRoot}` };
|
|
690
|
+
}
|
|
691
|
+
/* Tracked under a root NOW and absent at the freeze. The tree is clean here,
|
|
692
|
+
because refuseIfTreeDirty has already returned, so HEAD is the tree. */
|
|
693
|
+
const added = runGit(repoRoot, [
|
|
694
|
+
"diff",
|
|
695
|
+
"--name-only",
|
|
696
|
+
"--diff-filter=A",
|
|
697
|
+
/* --no-renames IS LOAD-BEARING AND THE VERIFICATION ARM BELOW IS WHAT
|
|
698
|
+
FOUND IT. With rename detection on, a file renamed after the freeze is
|
|
699
|
+
reported as R rather than A, so its NEW name is not enumerated, the
|
|
700
|
+
removal misses it, and the checkout restores the old name beside it. The
|
|
701
|
+
first version of this function had the flag missing; the residue
|
|
702
|
+
comparison turned the hybrid tree into a refusal naming
|
|
703
|
+
`retired/renamed.md` instead of a green. That is the whole reason the
|
|
704
|
+
verdict is measured rather than assumed. */
|
|
705
|
+
"--no-renames",
|
|
706
|
+
sha,
|
|
707
|
+
"HEAD",
|
|
708
|
+
"--",
|
|
709
|
+
...roots,
|
|
710
|
+
]);
|
|
711
|
+
if (added.status !== 0) {
|
|
712
|
+
return {
|
|
713
|
+
ok: false,
|
|
714
|
+
reason: `enumerating the post-freeze additions failed: ${added.stderr.trim()}`,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
const postFreeze = nonEmptyLines(added.stdout);
|
|
718
|
+
const checkout = runGit(repoRoot, ["checkout", sha, "--", ...roots]);
|
|
719
|
+
if (checkout.status !== 0) {
|
|
720
|
+
return { ok: false, reason: `git checkout failed: ${checkout.stderr.trim()}` };
|
|
721
|
+
}
|
|
722
|
+
if (postFreeze.length > 0) {
|
|
723
|
+
const removed = runGit(repoRoot, ["rm", "-q", "-f", "--", ...postFreeze]);
|
|
724
|
+
if (removed.status !== 0) {
|
|
725
|
+
return {
|
|
726
|
+
ok: false,
|
|
727
|
+
reason: `removing the post-freeze additions failed: ${removed.stderr.trim()}`,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
/* THE VERDICT IS MEASURED. Compare the sha with the working tree over the
|
|
732
|
+
same roots; anything printed here is a difference the restore did not
|
|
733
|
+
close, and the caller is told rather than shown a success. */
|
|
734
|
+
const residue = runGit(repoRoot, ["diff", "--name-only", sha, "--", ...roots]);
|
|
735
|
+
if (residue.status !== 0) {
|
|
736
|
+
return {
|
|
737
|
+
ok: false,
|
|
738
|
+
reason: `verifying the restore against ${sha} failed: ${residue.stderr.trim()}`,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
const differing = nonEmptyLines(residue.stdout);
|
|
742
|
+
if (differing.length > 0) {
|
|
743
|
+
return {
|
|
744
|
+
ok: false,
|
|
745
|
+
reason: `${String(differing.length)} path(s) under the retirement roots still differ from ${sha} ` +
|
|
746
|
+
`after the restore, so nothing is reported as restored: ${differing.slice(0, 10).join(", ")}`,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
return { ok: true, roots, removed: postFreeze };
|
|
750
|
+
}
|
|
751
|
+
/* -------------------------------------------------------------------- */
|
|
752
|
+
/* Trigger 2 step 3: the owner request that cannot be rehearsed */
|
|
753
|
+
/* -------------------------------------------------------------------- */
|
|
754
|
+
/**
|
|
755
|
+
* The property that makes trigger 2 step 3 unrehearsable, in one sentence,
|
|
756
|
+
* exported so the document, the command and the rehearsal script all print
|
|
757
|
+
* the SAME sentence and cannot drift apart.
|
|
758
|
+
*/
|
|
759
|
+
export const UNREHEARSABLE_REASON = "the branch-protection ruleset is a single live object on one owner-owned " +
|
|
760
|
+
"repository: there is no second instance to rehearse against, and no dry " +
|
|
761
|
+
"run distinguishes allowed from refused";
|
|
762
|
+
/**
|
|
763
|
+
* Flatten the captured ruleset into the fields the owner request must carry.
|
|
764
|
+
* The shape is fixed here rather than schema-loaded because M4-P25 owns the
|
|
765
|
+
* schema; what this phase needs is the COMPLETENESS check over whatever that
|
|
766
|
+
* capture holds.
|
|
767
|
+
*/
|
|
768
|
+
const REQUIRED_TOP_LEVEL = [
|
|
769
|
+
"capturedAt",
|
|
770
|
+
"repository",
|
|
771
|
+
"rulesetName",
|
|
772
|
+
"rules",
|
|
773
|
+
"credentialGrants",
|
|
774
|
+
];
|
|
775
|
+
function isEmptyValue(value) {
|
|
776
|
+
if (value === undefined || value === null) {
|
|
777
|
+
return true;
|
|
778
|
+
}
|
|
779
|
+
if (typeof value === "string") {
|
|
780
|
+
return value.trim().length === 0;
|
|
781
|
+
}
|
|
782
|
+
if (Array.isArray(value)) {
|
|
783
|
+
return value.length === 0;
|
|
784
|
+
}
|
|
785
|
+
if (typeof value === "object") {
|
|
786
|
+
return Object.keys(value).length === 0;
|
|
787
|
+
}
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Generate the owner request for trigger 2 step 3.
|
|
792
|
+
*
|
|
793
|
+
* TWO STRUCTURALLY DIFFERENT REFUSALS, and the second is the one a field
|
|
794
|
+
* check is green on. An ABSENT key is caught by asking whether the key is
|
|
795
|
+
* there. A key that is PRESENT and EMPTY passes that question and produces a
|
|
796
|
+
* request with a blank where a pre-flip value belongs, which the owner would
|
|
797
|
+
* have to fill in from memory. Both are refused, and the refusal names the
|
|
798
|
+
* field, because an owner request with a hole in it is worse than no request:
|
|
799
|
+
* it looks complete.
|
|
800
|
+
*/
|
|
801
|
+
export function generateRestoreRequest(document) {
|
|
802
|
+
if (typeof document !== "object" || document === null || Array.isArray(document)) {
|
|
803
|
+
return { ok: false, reasons: ["pre-freeze ruleset is not a JSON object"] };
|
|
804
|
+
}
|
|
805
|
+
const capture = document;
|
|
806
|
+
const reasons = [];
|
|
807
|
+
for (const key of REQUIRED_TOP_LEVEL) {
|
|
808
|
+
if (!(key in capture)) {
|
|
809
|
+
reasons.push(`field ${key} is absent from the captured ruleset`);
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (isEmptyValue(capture[key])) {
|
|
813
|
+
reasons.push(`field ${key} is present but empty, so it carries no pre-flip value`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const fields = [];
|
|
817
|
+
for (const [listName, idKey] of [
|
|
818
|
+
["rules", "id"],
|
|
819
|
+
["credentialGrants", "name"],
|
|
820
|
+
]) {
|
|
821
|
+
const list = capture[listName];
|
|
822
|
+
if (!Array.isArray(list)) {
|
|
823
|
+
/* PRESENT, NON-EMPTY AND NOT A LIST. The top-level loop above asks only
|
|
824
|
+
whether the key is there and whether it is empty, and a string such as
|
|
825
|
+
"see the wiki" passes both. Falling through here would generate an
|
|
826
|
+
owner request carrying zero fields from that key while reporting
|
|
827
|
+
success, which is the present-but-useless arm criterion 6 member B
|
|
828
|
+
exists for, one level up from a single field. Absence is already
|
|
829
|
+
reported above, so only presence is reported here. */
|
|
830
|
+
if (listName in capture) {
|
|
831
|
+
reasons.push(`field ${listName} is present but is not a list, so no pre-flip value could be read from it`);
|
|
832
|
+
}
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
list.forEach((row, index) => {
|
|
836
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
|
837
|
+
reasons.push(`${listName}[${index}] is not an object`);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const entry = row;
|
|
841
|
+
const label = nonEmptyString(entry[idKey]) ? entry[idKey] : `[${index}]`;
|
|
842
|
+
if (!nonEmptyString(entry[idKey])) {
|
|
843
|
+
reasons.push(`${listName}[${index}] has no ${idKey}`);
|
|
844
|
+
}
|
|
845
|
+
if (!("preFlipValue" in entry)) {
|
|
846
|
+
reasons.push(`${listName}.${label} has no preFlipValue`);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (isEmptyValue(entry["preFlipValue"])) {
|
|
850
|
+
reasons.push(`${listName}.${label} has a preFlipValue that is present but empty`);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
fields.push({ path: `${listName}.${label}`, value: entry["preFlipValue"] });
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
if (reasons.length > 0) {
|
|
857
|
+
return { ok: false, reasons };
|
|
858
|
+
}
|
|
859
|
+
const lines = [];
|
|
860
|
+
lines.push("OWNER ACTION: restore the pre-freeze branch protection and credential grants");
|
|
861
|
+
lines.push("");
|
|
862
|
+
lines.push(`repository: ${String(capture["repository"])}`);
|
|
863
|
+
lines.push(`ruleset: ${String(capture["rulesetName"])}`);
|
|
864
|
+
lines.push(`captured at: ${String(capture["capturedAt"])}`);
|
|
865
|
+
lines.push("");
|
|
866
|
+
lines.push("Restore each field below to its pre-flip value:");
|
|
867
|
+
for (const field of fields) {
|
|
868
|
+
lines.push(` ${field.path} = ${JSON.stringify(field.value)}`);
|
|
869
|
+
}
|
|
870
|
+
lines.push("");
|
|
871
|
+
lines.push("THIS STEP CANNOT BE REHEARSED. " + UNREHEARSABLE_REASON + ".");
|
|
872
|
+
lines.push("A green rehearsal of this request says the REQUEST is complete. It says " +
|
|
873
|
+
"nothing about whether the change will be accepted.");
|
|
874
|
+
lines.push("");
|
|
875
|
+
lines.push("Request an A-n id from delivery/STATE.md, which is the sole allocator, " +
|
|
876
|
+
"and record this action there before sending it.");
|
|
877
|
+
return { ok: true, text: `${lines.join("\n")}\n`, fields: fields.length };
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Decide one PORT row.
|
|
881
|
+
*
|
|
882
|
+
* `ported` needs BOTH halves and the second is the one that matters. A verdict
|
|
883
|
+
* derived only from the destination file existing is the vacuous version: a
|
|
884
|
+
* file can exist and say nothing. The negative witness was RED under the old
|
|
885
|
+
* rule, so a witness that exits 0 under the new artifact means the new
|
|
886
|
+
* artifact does not catch what the old one caught, which is precisely
|
|
887
|
+
* "WEAKER". Exit 0 from the witness is therefore `unported`, not a pass.
|
|
888
|
+
*
|
|
889
|
+
* `ported` IS REACHED FROM A POSITIVE TEST AND NEVER FROM A FALLTHROUGH, and
|
|
890
|
+
* that is what the first three checks below are. `readRetirementInventory`
|
|
891
|
+
* casts whatever the fixture's `rows` array holds, so a row may be a string, a
|
|
892
|
+
* number, or an object whose `disposition` is misspelt; the earlier form asked
|
|
893
|
+
* only whether the disposition was NOT the word `PORT`, so every one of those
|
|
894
|
+
* returned `ported`, which is a verdict of "this retirement is complete" over
|
|
895
|
+
* a row nobody could read. The vocabulary is closed, so a value outside it is
|
|
896
|
+
* `unported` and names itself.
|
|
897
|
+
*/
|
|
898
|
+
export const DISPOSITIONS = ["PORT", "DELETE", "KEEP"];
|
|
899
|
+
function isDisposition(value) {
|
|
900
|
+
return typeof value === "string" && DISPOSITIONS.includes(value);
|
|
901
|
+
}
|
|
902
|
+
export function evaluatePortRow(row, repoRoot) {
|
|
903
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
|
904
|
+
return {
|
|
905
|
+
id: `(row ${JSON.stringify(row)})`,
|
|
906
|
+
verdict: "unported",
|
|
907
|
+
reason: "inventory row is not an object, so its disposition could not be read",
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
const id = nonEmptyString(row.id) ? row.id : "(row with no id)";
|
|
911
|
+
if (!nonEmptyString(row.id)) {
|
|
912
|
+
return { id, verdict: "unported", reason: "inventory row has no id" };
|
|
913
|
+
}
|
|
914
|
+
if (!isDisposition(row.disposition)) {
|
|
915
|
+
return {
|
|
916
|
+
id,
|
|
917
|
+
verdict: "unported",
|
|
918
|
+
reason: `disposition ${JSON.stringify(row.disposition)} is not one of ${DISPOSITIONS.join(", ")}`,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
if (row.disposition !== "PORT") {
|
|
922
|
+
return { id, verdict: "ported", reason: `disposition ${row.disposition} needs no port` };
|
|
923
|
+
}
|
|
924
|
+
if (!nonEmptyString(row.destination)) {
|
|
925
|
+
return { id, verdict: "unported", reason: "PORT row names no destination" };
|
|
926
|
+
}
|
|
927
|
+
/* A DESTINATION MAY NAME MORE THAN ONE ARTIFACT, and that is the inventory's
|
|
928
|
+
own declared convention rather than a reading invented here: its checker
|
|
929
|
+
splits the field on commas at scripts/check-retirement-inventory.mjs:754.
|
|
930
|
+
Measured 2026-09-18 against the shipped inventory: three rows
|
|
931
|
+
(`next-script:gittry`, `next-script:gitcount`, `next-script:harderrors`)
|
|
932
|
+
carry `roles/investigator.md, checklists/clean-room.yaml`, and treating
|
|
933
|
+
the field as ONE path reported all three as `unported` with a reason about
|
|
934
|
+
a file that does not exist. That is a false finding about the kernel
|
|
935
|
+
produced by a disagreement about a separator, so EVERY named path must
|
|
936
|
+
exist and the first that does not is the one named. Splitting a
|
|
937
|
+
single-path field yields that one path, so nothing else changes. */
|
|
938
|
+
const destinations = row.destination.split(/\s*,\s*/).filter((part) => part.length > 0);
|
|
939
|
+
for (const part of destinations) {
|
|
940
|
+
if (classifyEntry(join(repoRoot, part)).kind !== "regular") {
|
|
941
|
+
return {
|
|
942
|
+
id,
|
|
943
|
+
verdict: "unported",
|
|
944
|
+
reason: `destination ${part} does not exist as a file`,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/* THE COMMAND'S TYPE IS ESTABLISHED BEFORE IT IS DESTRUCTURED OR SPAWNED,
|
|
949
|
+
and `destination` one line up is why this line looks the way it does: that
|
|
950
|
+
field is tested with `nonEmptyString`, this one was tested with `.length`,
|
|
951
|
+
and `.length` is a property read off a value nobody typed. `42` and `{}`
|
|
952
|
+
are not iterable, so the destructuring below threw
|
|
953
|
+
`TypeError: ... is not iterable` out of a function whose interface is a
|
|
954
|
+
PortResult; `null` threw on `.length` before reaching it; and an ARRAY
|
|
955
|
+
holding a non-string threw inside spawnSync on the "file" argument. Four
|
|
956
|
+
throws where a verdict was owed. An inventory row is data supplied by
|
|
957
|
+
another phase's file, so its fields are unknown in the same way a parsed
|
|
958
|
+
document's are, and a throw carries no reason for the refusal. */
|
|
959
|
+
if (!Array.isArray(row.negativeWitness) || row.negativeWitness.length === 0) {
|
|
960
|
+
return {
|
|
961
|
+
id,
|
|
962
|
+
verdict: "unported",
|
|
963
|
+
reason: "PORT row carries no negative-witness command",
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
if (!row.negativeWitness.every((part) => nonEmptyString(part))) {
|
|
967
|
+
return {
|
|
968
|
+
id,
|
|
969
|
+
verdict: "unported",
|
|
970
|
+
reason: "PORT row's negative-witness command is not a list of non-empty strings, so it could not be run",
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
const [program, ...args] = row.negativeWitness;
|
|
974
|
+
const run = spawnSync(program, args, { cwd: repoRoot, encoding: "utf8" });
|
|
975
|
+
if (run.error !== undefined) {
|
|
976
|
+
return {
|
|
977
|
+
id,
|
|
978
|
+
verdict: "unported",
|
|
979
|
+
reason: `negative witness could not be run: ${String(run.error)}`,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
/* A WITNESS THAT DIED IS NOT A WITNESS THAT WAS RED. `status` is null when a
|
|
983
|
+
child is killed by a signal, and null is not 0, so the nonzero arm at the
|
|
984
|
+
bottom used to accept it and report the row as ported. A witness killed by
|
|
985
|
+
the out-of-memory killer or by a harness timeout has demonstrated nothing
|
|
986
|
+
about the new artifact. */
|
|
987
|
+
if (run.signal !== null && run.signal !== undefined) {
|
|
988
|
+
return {
|
|
989
|
+
id,
|
|
990
|
+
verdict: "unported",
|
|
991
|
+
reason: `negative witness was killed by ${run.signal}, which is not evidence that it is red under the new artifact`,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
if (run.status === null) {
|
|
995
|
+
return {
|
|
996
|
+
id,
|
|
997
|
+
verdict: "unported",
|
|
998
|
+
reason: "negative witness reported no exit status, so it did not demonstrate anything",
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
if (run.status === 0) {
|
|
1002
|
+
return {
|
|
1003
|
+
id,
|
|
1004
|
+
verdict: "unported",
|
|
1005
|
+
reason: "negative witness exits 0 under the new artifact, so the destination is WEAKER than the rule it replaced",
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
/* A NONZERO EXIT IS NOT THE SAME THING AS A RED WITNESS, and this is the
|
|
1009
|
+
vacuous red one level below the vacuous green above. `grep` exits 1 when
|
|
1010
|
+
it searched and found nothing, which is the answer that makes the row's
|
|
1011
|
+
claim stand, and 2 when it could not search at all: a missing subject
|
|
1012
|
+
file, an unreadable directory, a bad pattern. Accepting any nonzero
|
|
1013
|
+
status reports a row as PORTED on the strength of an error message, which
|
|
1014
|
+
is a guard that fails open when its own tool fails. The row's own
|
|
1015
|
+
`negative-witness.exit` is what it recorded when it was written, so when
|
|
1016
|
+
the row declares one it is REQUIRED to match. Same rule, same reason, as
|
|
1017
|
+
scripts/check-retirement-inventory.mjs:628 applies to its widening grep.
|
|
1018
|
+
Measured 2026-09-18 over the shipped inventory: all 199 PORT rows record
|
|
1019
|
+
exit 1 and all 199 observed exit 1, so this changes no verdict today and
|
|
1020
|
+
is a guard against the day one of them starts erroring instead. */
|
|
1021
|
+
if (row.expectedWitnessExit !== undefined && run.status !== row.expectedWitnessExit) {
|
|
1022
|
+
return {
|
|
1023
|
+
id,
|
|
1024
|
+
verdict: "unported",
|
|
1025
|
+
reason: `negative witness exits ${String(run.status)} under the new artifact but the row recorded ${String(row.expectedWitnessExit)}, so it did not search and find nothing, it failed`,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
return {
|
|
1029
|
+
id,
|
|
1030
|
+
verdict: "ported",
|
|
1031
|
+
reason: `negative witness exits ${String(run.status)} under the new artifact`,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Read an M4-P23 retirement inventory. The path is a parameter because that
|
|
1036
|
+
* phase runs concurrently with this one and its file does not exist on this
|
|
1037
|
+
* branch; the rehearsal supplies a fixture.
|
|
1038
|
+
*/
|
|
1039
|
+
export function readRetirementInventory(path) {
|
|
1040
|
+
const read = readRegularFileIfPresent(path);
|
|
1041
|
+
if (read.kind === "absent") {
|
|
1042
|
+
return { kind: "absent" };
|
|
1043
|
+
}
|
|
1044
|
+
if (read.kind === "refused") {
|
|
1045
|
+
return { kind: "refused", reason: read.reason };
|
|
1046
|
+
}
|
|
1047
|
+
let parsed;
|
|
1048
|
+
try {
|
|
1049
|
+
parsed = JSON.parse(read.body);
|
|
1050
|
+
}
|
|
1051
|
+
catch (error) {
|
|
1052
|
+
return { kind: "refused", reason: `${path} is not valid JSON: ${String(error)}` };
|
|
1053
|
+
}
|
|
1054
|
+
/* THE TYPE IS ESTABLISHED BEFORE A PROPERTY IS READ OFF IT, and this line is
|
|
1055
|
+
why. `JSON.parse("null")` succeeds and returns null, so the cast-and-read
|
|
1056
|
+
that used to stand here threw a TypeError out of a function whose whole
|
|
1057
|
+
interface is a three-way refusal. A read function that throws has no
|
|
1058
|
+
refusal REASON, so the caller cannot report what was wrong with the file,
|
|
1059
|
+
and the crash is indistinguishable from a defect in the reader. The
|
|
1060
|
+
sibling readers both test the type first (validateCutoverDocument at
|
|
1061
|
+
src/cutover.ts:154 and generateRestoreRequest at src/cutover.ts:1025);
|
|
1062
|
+
this one did not, and the not-covered statement's claim that a malformed
|
|
1063
|
+
inventory is refused was false for exactly that member. */
|
|
1064
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1065
|
+
return { kind: "refused", reason: `${path} is not a JSON object` };
|
|
1066
|
+
}
|
|
1067
|
+
const rows = parsed.rows;
|
|
1068
|
+
if (!Array.isArray(rows)) {
|
|
1069
|
+
return { kind: "refused", reason: `${path} has no rows array` };
|
|
1070
|
+
}
|
|
1071
|
+
return { kind: "read", rows: rows };
|
|
1072
|
+
}
|
|
1073
|
+
/* -------------------------------------------------------------------- */
|
|
1074
|
+
/* M4-P25: the shipped state schema */
|
|
1075
|
+
/* -------------------------------------------------------------------- */
|
|
1076
|
+
/** The shipped schema document's basename, in the package's `schemas/`. */
|
|
1077
|
+
export const CUTOVER_STATE_SCHEMA_FILENAME = "cutover-state.schema.json";
|
|
1078
|
+
/**
|
|
1079
|
+
* Absolute path of the shipped cutover-state schema.
|
|
1080
|
+
*
|
|
1081
|
+
* `packageRoot()` walks UP and TESTS rather than counting `..`, because the
|
|
1082
|
+
* depth differs between running from `src/` and running from `dist/src/`
|
|
1083
|
+
* (src/modes.ts:39 states the same reason for the same walk).
|
|
1084
|
+
*/
|
|
1085
|
+
export function cutoverStateSchemaPath() {
|
|
1086
|
+
return join(packageRoot(), "schemas", CUTOVER_STATE_SCHEMA_FILENAME);
|
|
1087
|
+
}
|
|
1088
|
+
let cachedCutoverSchema;
|
|
1089
|
+
/** The shipped schema document, read once. */
|
|
1090
|
+
export function cutoverStateSchema() {
|
|
1091
|
+
if (cachedCutoverSchema === undefined) {
|
|
1092
|
+
const path = cutoverStateSchemaPath();
|
|
1093
|
+
const read = readRegularFileIfPresent(path);
|
|
1094
|
+
if (read.kind !== "read") {
|
|
1095
|
+
throw new Error(read.kind === "absent"
|
|
1096
|
+
? `${path} is missing from this installation`
|
|
1097
|
+
: read.reason);
|
|
1098
|
+
}
|
|
1099
|
+
cachedCutoverSchema = JSON.parse(read.body);
|
|
1100
|
+
}
|
|
1101
|
+
return cachedCutoverSchema;
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Validate a whole cutover document against the SHIPPED SCHEMA.
|
|
1105
|
+
*
|
|
1106
|
+
* WHY THIS IS NOT `validateCutoverDocument`, and the two are kept apart on
|
|
1107
|
+
* purpose. That function is this module's READ guard and answers "can this
|
|
1108
|
+
* file be interpreted". This one is the WRITE guard and answers "is this
|
|
1109
|
+
* document one the package is prepared to ship", and criterion 4 says the
|
|
1110
|
+
* required-field rule is validated BY THE SCHEMA rather than by a command.
|
|
1111
|
+
* A check implemented only in a command is one the schema cannot enforce for
|
|
1112
|
+
* a later writer, and a later writer is precisely who rollback depends on:
|
|
1113
|
+
* M4-P26's `targetFor` READS the recorded `restoreTo` (src/cutover.ts:352)
|
|
1114
|
+
* rather than remembering an intent, so a record written without one is a
|
|
1115
|
+
* switch that can never be rolled back.
|
|
1116
|
+
*/
|
|
1117
|
+
export function cutoverSchemaDiagnostics(document) {
|
|
1118
|
+
return validateToLines(cutoverStateSchema(), document);
|
|
1119
|
+
}
|
|
1120
|
+
/* -------------------------------------------------------------------- */
|
|
1121
|
+
/* M4-P25 criterion 5: the pre-freeze precondition */
|
|
1122
|
+
/* -------------------------------------------------------------------- */
|
|
1123
|
+
/**
|
|
1124
|
+
* The captured pre-freeze state, relative to the REPOSITORY root. It carries
|
|
1125
|
+
* the branch-protection ruleset as it was before the first flip, so the
|
|
1126
|
+
* restore request M4-P26 generates has an input rather than a memory. T-025
|
|
1127
|
+
* is why: the one step that cannot be rehearsed is the one that failed, and
|
|
1128
|
+
* its INPUT can be captured in advance even when its EXECUTION cannot.
|
|
1129
|
+
*/
|
|
1130
|
+
export const PRE_FREEZE_RULESET_PATH = "delivery/plan/cutover/pre-freeze-ruleset.json";
|
|
1131
|
+
/**
|
|
1132
|
+
* THE CAPTURE TIME IS READ FROM THE DOCUMENT, NOT FROM ITS MTIME, and that is
|
|
1133
|
+
* a correction rather than a preference.
|
|
1134
|
+
*
|
|
1135
|
+
* The criterion says the capture must not be OLDER than the most recent switch
|
|
1136
|
+
* write. An mtime does not survive the journey: `git clone` and `git checkout`
|
|
1137
|
+
* set every working-tree mtime to the moment of the checkout, so a capture
|
|
1138
|
+
* taken weeks before a flip reads as newer than the flip on any fresh clone,
|
|
1139
|
+
* and the guard reports satisfied on exactly the machine a reviewer uses.
|
|
1140
|
+
* Measured, and the measurement is in the work history. So the document
|
|
1141
|
+
* records `captured-at` and the comparison is content to content.
|
|
1142
|
+
*
|
|
1143
|
+
* FAIL CLOSED ON AN UNREADABLE CAPTURE. A document with no `captured-at`, or
|
|
1144
|
+
* one that does not parse as an instant, is `unreadable` and refuses. Treating
|
|
1145
|
+
* it as satisfied would make a malformed capture indistinguishable from a good
|
|
1146
|
+
* one, which is the guard that cannot go red.
|
|
1147
|
+
*/
|
|
1148
|
+
export function preFreezeGuard(repoRoot, state) {
|
|
1149
|
+
const frozen = CUTOVER_SWITCHES.filter((name) => state.switches[name].state === "kernel");
|
|
1150
|
+
if (frozen.length === 0) {
|
|
1151
|
+
return { kind: "not-required" };
|
|
1152
|
+
}
|
|
1153
|
+
const path = join(repoRoot, PRE_FREEZE_RULESET_PATH);
|
|
1154
|
+
const read = readRegularFileIfPresent(path);
|
|
1155
|
+
if (read.kind === "absent") {
|
|
1156
|
+
return {
|
|
1157
|
+
kind: "refused",
|
|
1158
|
+
arm: "absent",
|
|
1159
|
+
reason: `${PRE_FREEZE_RULESET_PATH} is absent, so the pre-freeze branch-protection ruleset was never captured and there is nothing to restore to`,
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
if (read.kind === "refused") {
|
|
1163
|
+
return { kind: "refused", arm: "unreadable", reason: read.reason };
|
|
1164
|
+
}
|
|
1165
|
+
let parsed;
|
|
1166
|
+
try {
|
|
1167
|
+
parsed = JSON.parse(read.body);
|
|
1168
|
+
}
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
return {
|
|
1171
|
+
kind: "refused",
|
|
1172
|
+
arm: "unreadable",
|
|
1173
|
+
reason: `${PRE_FREEZE_RULESET_PATH} is not valid JSON: ${String(error)}`,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1177
|
+
return {
|
|
1178
|
+
kind: "refused",
|
|
1179
|
+
arm: "unreadable",
|
|
1180
|
+
reason: `${PRE_FREEZE_RULESET_PATH} is not a JSON object`,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
const capturedAt = parsed["captured-at"];
|
|
1184
|
+
if (!nonEmptyString(capturedAt)) {
|
|
1185
|
+
return {
|
|
1186
|
+
kind: "refused",
|
|
1187
|
+
arm: "unreadable",
|
|
1188
|
+
reason: `${PRE_FREEZE_RULESET_PATH} records no captured-at, so it cannot be compared with any switch write`,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
const capturedMs = Date.parse(capturedAt);
|
|
1192
|
+
if (Number.isNaN(capturedMs)) {
|
|
1193
|
+
return {
|
|
1194
|
+
kind: "refused",
|
|
1195
|
+
arm: "unreadable",
|
|
1196
|
+
reason: `${PRE_FREEZE_RULESET_PATH} records captured-at ${JSON.stringify(capturedAt)}, which does not parse as an instant`,
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
/* The NEWEST switch write across all five, not only the frozen ones: a
|
|
1200
|
+
switch written back to `current` after the capture is still a write, and
|
|
1201
|
+
the capture has to be at least as new as the whole table. */
|
|
1202
|
+
let newest = Number.NEGATIVE_INFINITY;
|
|
1203
|
+
let newestName = "";
|
|
1204
|
+
for (const name of CUTOVER_SWITCHES) {
|
|
1205
|
+
const at = Date.parse(state.switches[name].flippedAt);
|
|
1206
|
+
if (Number.isNaN(at)) {
|
|
1207
|
+
return {
|
|
1208
|
+
kind: "refused",
|
|
1209
|
+
arm: "unreadable",
|
|
1210
|
+
reason: `switch ${name} records flippedAt ${JSON.stringify(state.switches[name].flippedAt)}, which does not parse as an instant, so the capture could not be compared with it`,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
if (at > newest) {
|
|
1214
|
+
newest = at;
|
|
1215
|
+
newestName = name;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
if (capturedMs < newest) {
|
|
1219
|
+
return {
|
|
1220
|
+
kind: "refused",
|
|
1221
|
+
arm: "stale",
|
|
1222
|
+
reason: `${PRE_FREEZE_RULESET_PATH} was captured at ${capturedAt}, which is older than the most recent switch write (${newestName} at ${state.switches[newestName].flippedAt}), so it does not describe the state before that flip`,
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
return { kind: "satisfied", capturedAt };
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Count the PUSHED, UNMERGED branches, and print them nowhere near the drain
|
|
1229
|
+
* verdict.
|
|
1230
|
+
*
|
|
1231
|
+
* THIS NUMBER DOES NOT FEED THE PREDICATE, AND THAT IS THE DECISION (M4-D-15,
|
|
1232
|
+
* delivery/plan/kernel-plan-m4.md:3279). Remote ref deletion is refused in
|
|
1233
|
+
* this container, and `git push --dry-run` does not probe push authorization
|
|
1234
|
+
* at all, in either direction, for any ref namespace
|
|
1235
|
+
* (delivery/verification/m4-prototype-probes.md:165). A drain defined as "no
|
|
1236
|
+
* unmerged branches" therefore waits forever on an owner action that has no
|
|
1237
|
+
* local pre-check, which is a predicate that can never read clean. It is
|
|
1238
|
+
* reported because an operator wants to see it, and it is reported on its own
|
|
1239
|
+
* line so that no reader can mistake it for a reason the cutover is blocked.
|
|
1240
|
+
*
|
|
1241
|
+
* `unexaminable` is a THIRD answer for the same reason it is one in
|
|
1242
|
+
* `inFlightItems`: a repository with no `origin/<default>` cannot be asked
|
|
1243
|
+
* this question, and printing `0` there would be an answer nobody measured.
|
|
1244
|
+
*/
|
|
1245
|
+
export function unmergedBranchCount(repoRoot, upstream = "origin/main") {
|
|
1246
|
+
const listed = runGit(repoRoot, [
|
|
1247
|
+
"for-each-ref",
|
|
1248
|
+
"--format=%(refname:short)",
|
|
1249
|
+
"--no-merged",
|
|
1250
|
+
upstream,
|
|
1251
|
+
"refs/remotes/origin",
|
|
1252
|
+
]);
|
|
1253
|
+
if (listed.status !== 0) {
|
|
1254
|
+
return {
|
|
1255
|
+
kind: "unexaminable",
|
|
1256
|
+
reason: `git for-each-ref --no-merged ${upstream} exited ${String(listed.status)}: ${singleLineText(listed.stderr)}`,
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
const branches = listed.stdout
|
|
1260
|
+
.split("\n")
|
|
1261
|
+
.map((line) => line.trim())
|
|
1262
|
+
.filter((line) => line.length > 0 && line !== "origin/HEAD");
|
|
1263
|
+
return { kind: "counted", count: branches.length, branches };
|
|
1264
|
+
}
|
|
1265
|
+
function singleLineText(text) {
|
|
1266
|
+
return text.replace(/\s+/g, " ").trim();
|
|
1267
|
+
}
|
|
1268
|
+
/* -------------------------------------------------------------------- */
|
|
1269
|
+
/* M4-P25 criterion 6: the retirement verdict over the real inventory */
|
|
1270
|
+
/* -------------------------------------------------------------------- */
|
|
1271
|
+
/** The M4-P23 inventory, relative to the REPOSITORY root. */
|
|
1272
|
+
export const RETIREMENT_INVENTORY_PATH = "delivery/plan/cutover/retirement-inventory.json";
|
|
1273
|
+
/**
|
|
1274
|
+
* The first tokens a retirement row's command may start a segment with.
|
|
1275
|
+
*
|
|
1276
|
+
* MIRRORED FROM `scripts/check-retirement-inventory.mjs`, DELIBERATELY, AND
|
|
1277
|
+
* THE DRIFT IS ASSERTED BY A TEST rather than by this comment. The script is
|
|
1278
|
+
* this project's own predicate and is KEPT rather than shipped (DR-0029), so
|
|
1279
|
+
* the kernel cannot import it; but the two lists screening the same rows must
|
|
1280
|
+
* not diverge, so `test/cutover.test.ts` reads the script's
|
|
1281
|
+
* `ALLOWED_FIRST_TOKENS` and requires this set to be no wider.
|
|
1282
|
+
*
|
|
1283
|
+
* WHAT THE LIST BUYS AND WHAT IT DOES NOT. Every tool on it is one with no
|
|
1284
|
+
* option for writing a file, so a row cannot modify the tree this command is
|
|
1285
|
+
* auditing. It is a TOOL allowlist, not a sandbox: the child still runs with
|
|
1286
|
+
* this process's privileges and can read anything this process can read.
|
|
1287
|
+
*/
|
|
1288
|
+
export const RETIREMENT_COMMAND_TOKENS = new Set([
|
|
1289
|
+
"grep",
|
|
1290
|
+
"test",
|
|
1291
|
+
"ls",
|
|
1292
|
+
"wc",
|
|
1293
|
+
"comm",
|
|
1294
|
+
"diff",
|
|
1295
|
+
"head",
|
|
1296
|
+
"tail",
|
|
1297
|
+
"cat",
|
|
1298
|
+
]);
|
|
1299
|
+
const RETIREMENT_COMMAND_FORBIDDEN = [
|
|
1300
|
+
{ re: /[<>]/, why: "redirection" },
|
|
1301
|
+
{ re: /\$\(/, why: "command substitution" },
|
|
1302
|
+
{ re: /`/, why: "backtick substitution" },
|
|
1303
|
+
];
|
|
1304
|
+
/**
|
|
1305
|
+
* Screen one inventory command. The rows are DATA FROM A FILE, so the command
|
|
1306
|
+
* is screened before anything spawns it, and the check is on the EXECUTABLE
|
|
1307
|
+
* POSITION of every segment rather than on the whole string: a whole-string
|
|
1308
|
+
* denylist refuses `grep -c 'npm ci' gate-registry.yaml`, which runs no npm at
|
|
1309
|
+
* all and merely searches for those characters.
|
|
1310
|
+
*/
|
|
1311
|
+
export function screenRetirementCommand(command) {
|
|
1312
|
+
if (!nonEmptyString(command)) {
|
|
1313
|
+
return ["negative-witness command is missing or empty"];
|
|
1314
|
+
}
|
|
1315
|
+
const problems = [];
|
|
1316
|
+
for (const forbidden of RETIREMENT_COMMAND_FORBIDDEN) {
|
|
1317
|
+
if (forbidden.re.test(command)) {
|
|
1318
|
+
problems.push(`negative-witness command uses ${forbidden.why}`);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
for (const segment of command.split(/\|\||&&|[|;&\n]/)) {
|
|
1322
|
+
const first = segment.trim().split(/\s+/)[0];
|
|
1323
|
+
if (first === undefined || first === "") {
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
if (!RETIREMENT_COMMAND_TOKENS.has(first)) {
|
|
1327
|
+
problems.push(`negative-witness command segment starts with ${JSON.stringify(first)}, which is not on the allowlist`);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return problems;
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Turn one row of the SHIPPED inventory into the shape `evaluatePortRow`
|
|
1334
|
+
* takes.
|
|
1335
|
+
*
|
|
1336
|
+
* THIS ADAPTER EXISTS BECAUSE THE TWO SHAPES REALLY ARE DIFFERENT, and the
|
|
1337
|
+
* difference is silent in the dangerous direction. `RetirementRow` declares
|
|
1338
|
+
* `negativeWitness` as an argv ARRAY (src/cutover.ts:1128), and the M4-P23
|
|
1339
|
+
* inventory writes `negative-witness` as an OBJECT carrying a SHELL STRING
|
|
1340
|
+
* (delivery/plan/cutover/retirement-inventory.json:1). Handing the shipped
|
|
1341
|
+
* document straight to `evaluatePortRow` therefore returns `unported` with
|
|
1342
|
+
* "PORT row carries no negative-witness command" for every row in it: a
|
|
1343
|
+
* verdict that looks like a finding about the kernel and is a finding about a
|
|
1344
|
+
* key spelling. The adapter is named, tested and refuses rather than
|
|
1345
|
+
* defaulting, so the mismatch cannot come back as a silent all-red.
|
|
1346
|
+
*/
|
|
1347
|
+
export function retirementRowFromDocument(raw) {
|
|
1348
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
1349
|
+
return {
|
|
1350
|
+
kind: "refused",
|
|
1351
|
+
result: {
|
|
1352
|
+
id: `(row ${JSON.stringify(raw)})`,
|
|
1353
|
+
verdict: "unported",
|
|
1354
|
+
reason: "inventory row is not an object, so its disposition could not be read",
|
|
1355
|
+
},
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
const source = raw;
|
|
1359
|
+
const id = nonEmptyString(source["id"]) ? source["id"] : "(row with no id)";
|
|
1360
|
+
const row = {
|
|
1361
|
+
id,
|
|
1362
|
+
disposition: source["disposition"],
|
|
1363
|
+
};
|
|
1364
|
+
if (nonEmptyString(source["destination"])) {
|
|
1365
|
+
row.destination = source["destination"];
|
|
1366
|
+
}
|
|
1367
|
+
const witness = source["negative-witness"];
|
|
1368
|
+
if (source["disposition"] !== "PORT") {
|
|
1369
|
+
return { kind: "row", row };
|
|
1370
|
+
}
|
|
1371
|
+
if (typeof witness !== "object" || witness === null || Array.isArray(witness)) {
|
|
1372
|
+
return {
|
|
1373
|
+
kind: "refused",
|
|
1374
|
+
result: {
|
|
1375
|
+
id,
|
|
1376
|
+
verdict: "unported",
|
|
1377
|
+
reason: "PORT row carries no negative-witness object",
|
|
1378
|
+
},
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
const command = witness["command"];
|
|
1382
|
+
const problems = screenRetirementCommand(command);
|
|
1383
|
+
if (problems.length > 0) {
|
|
1384
|
+
return {
|
|
1385
|
+
kind: "refused",
|
|
1386
|
+
result: { id, verdict: "unported", reason: problems.join("; ") },
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
/* `sh -c` is how the row's own checker runs it
|
|
1390
|
+
(scripts/check-retirement-inventory.mjs:377), so the command that was
|
|
1391
|
+
screened is the command that runs. */
|
|
1392
|
+
row.negativeWitness = ["sh", "-c", command];
|
|
1393
|
+
const recorded = witness["exit"];
|
|
1394
|
+
if (typeof recorded === "number" && Number.isInteger(recorded)) {
|
|
1395
|
+
row.expectedWitnessExit = recorded;
|
|
1396
|
+
}
|
|
1397
|
+
return { kind: "row", row };
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Evaluate every PORT row of the inventory.
|
|
1401
|
+
*
|
|
1402
|
+
* THE VACUOUS VERDICT IS THE ONE THIS GUARDS AGAINST. `ported` is not "the
|
|
1403
|
+
* named kernel artifact exists"; a file can exist and say nothing. Both halves
|
|
1404
|
+
* are required and the second is the one that matters: the row's negative
|
|
1405
|
+
* witness was RED against a subject that does not carry the rule, so a witness
|
|
1406
|
+
* exiting 0 means the probe discriminates nothing and the row is `unported`.
|
|
1407
|
+
* That derivation is M4-P26's `evaluatePortRow` and is REUSED here rather than
|
|
1408
|
+
* reimplemented; this function supplies the reading, the adaptation and the
|
|
1409
|
+
* screen.
|
|
1410
|
+
*/
|
|
1411
|
+
export function evaluateRetirementInventory(inventoryPath, repoRoot) {
|
|
1412
|
+
const read = readRetirementInventory(inventoryPath);
|
|
1413
|
+
if (read.kind === "absent") {
|
|
1414
|
+
return {
|
|
1415
|
+
kind: "refused",
|
|
1416
|
+
reason: `${inventoryPath} is absent, so no retirement criterion could be evaluated`,
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
if (read.kind === "refused") {
|
|
1420
|
+
return { kind: "refused", reason: read.reason };
|
|
1421
|
+
}
|
|
1422
|
+
const results = [];
|
|
1423
|
+
for (const raw of read.rows) {
|
|
1424
|
+
const source = typeof raw === "object" && raw !== null
|
|
1425
|
+
? raw
|
|
1426
|
+
: {};
|
|
1427
|
+
/* Only PORT rows are printed (criterion 6). A KEEP or DELETE row is not a
|
|
1428
|
+
retirement that can be incomplete, and an UNREADABLE disposition is not
|
|
1429
|
+
a KEEP: it goes to `evaluatePortRow`, which names it `unported`. */
|
|
1430
|
+
if (source["disposition"] === "KEEP" || source["disposition"] === "DELETE") {
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
const adapted = retirementRowFromDocument(raw);
|
|
1434
|
+
if (adapted.kind === "refused") {
|
|
1435
|
+
results.push(adapted.result);
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
1438
|
+
results.push(evaluatePortRow(adapted.row, repoRoot));
|
|
1439
|
+
}
|
|
1440
|
+
return {
|
|
1441
|
+
kind: "read",
|
|
1442
|
+
report: { results, unported: results.filter((r) => r.verdict === "unported").length },
|
|
1443
|
+
};
|
|
1444
|
+
}
|