@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
package/dist/src/cli.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { cmdBrief } from "./commands/brief.js";
|
|
2
2
|
import { cmdChecklist } from "./commands/checklist.js";
|
|
3
|
+
import { cmdCutover } from "./commands/cutover.js";
|
|
3
4
|
import { cmdDoctor } from "./commands/doctor.js";
|
|
4
5
|
import { cmdGates } from "./commands/gates.js";
|
|
5
6
|
import { cmdInit } from "./commands/init.js";
|
|
6
7
|
import { cmdLock } from "./commands/lock.js";
|
|
7
8
|
import { cmdMode } from "./commands/mode.js";
|
|
9
|
+
import { cmdNext } from "./commands/next.js";
|
|
8
10
|
import { cmdPlan } from "./commands/plan.js";
|
|
9
11
|
import { cmdPool } from "./commands/pool.js";
|
|
12
|
+
import { cmdResume } from "./commands/resume.js";
|
|
10
13
|
import { cmdSpawn } from "./commands/spawn.js";
|
|
11
14
|
import { cmdStatus } from "./commands/status.js";
|
|
15
|
+
import { cmdSync } from "./commands/sync.js";
|
|
12
16
|
import { cmdTeardown } from "./commands/teardown.js";
|
|
13
17
|
import { cmdTuition } from "./commands/tuition.js";
|
|
14
18
|
import { cmdValidate } from "./commands/validate.js";
|
|
@@ -23,15 +27,22 @@ const commands = new Map([
|
|
|
23
27
|
["version", printVersion],
|
|
24
28
|
["brief", cmdBrief],
|
|
25
29
|
["checklist", cmdChecklist],
|
|
30
|
+
/* M4-P25. The rollback handlers M4-P26 shipped were reachable only by
|
|
31
|
+
import until this row existed, so the rollback document printed command
|
|
32
|
+
lines that did not run. */
|
|
33
|
+
["cutover", cmdCutover],
|
|
26
34
|
["init", cmdInit],
|
|
27
35
|
["doctor", cmdDoctor],
|
|
28
36
|
["gates", cmdGates],
|
|
29
37
|
["lock", cmdLock],
|
|
30
38
|
["mode", cmdMode],
|
|
39
|
+
["next", cmdNext],
|
|
31
40
|
["plan", cmdPlan],
|
|
32
41
|
["pool", cmdPool],
|
|
42
|
+
["resume", cmdResume],
|
|
33
43
|
["spawn", cmdSpawn],
|
|
34
44
|
["status", cmdStatus],
|
|
45
|
+
["sync", cmdSync],
|
|
35
46
|
["teardown", cmdTeardown],
|
|
36
47
|
["tuition", cmdTuition],
|
|
37
48
|
["validate", cmdValidate],
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { writeFileSync } from "node:fs";
|
|
35
35
|
import { join } from "node:path";
|
|
36
|
-
import { REVIEW_CONTRACTS, REVIEW_CONTRACT_ROLE, ROLE_IDS, clauseRoundTripDiagnostics, expandIncludes, kernelRoot, missingRequiredSections, renderPhase, resolveMandatedReading, selectReviewContract, splitFrontmatter, } from "../roles.js";
|
|
36
|
+
import { REVIEW_CONTRACTS, REVIEW_CONTRACT_ROLE, ROLE_BRIEF_FILES, ROLE_IDS, clauseRoundTripDiagnostics, expandIncludes, kernelRoot, missingRequiredSections, renderPhase, resolveMandatedReading, roleBriefFile, selectReviewContract, splitFrontmatter, } from "../roles.js";
|
|
37
37
|
import { refuseOpenForWrite, readRegularFileIfPresent } from "../task.js";
|
|
38
38
|
import { decodeDocument, formatDiagnostics, readOperatorPath } from "../validate.js";
|
|
39
39
|
/** Exit code for usage errors, per BSD sysexits EX_USAGE. */
|
|
@@ -68,8 +68,25 @@ export function composeBrief(options) {
|
|
|
68
68
|
reason: `unknown role ${options.roleId}; the roles are ${ROLE_IDS.join(", ")}`,
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
/* THE BRIEF'S LOCATION IS DECLARED, NOT COMPUTED FROM THE ID. Five of the
|
|
72
|
+
six roles ship as `roles/<id>.md` and the orchestrator's ships as
|
|
73
|
+
`AGENTS.md` at the package root, so `join(root, "roles", id + ".md")`
|
|
74
|
+
was a path that does not exist for one advertised role. A role the
|
|
75
|
+
vocabulary declares and the package does not place is refused BY NAME
|
|
76
|
+
here, before anything is read, rather than surfacing as a missing file
|
|
77
|
+
the operator reads as a broken install (src/roles.ts:54). Includes are
|
|
78
|
+
still resolved against `roles/`, which is where `$include:` targets such
|
|
79
|
+
as `_shared-dispatch-contract.md` live for every role. */
|
|
80
|
+
const relative = roleBriefFile(options.roleId);
|
|
81
|
+
if (relative === undefined) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: `role ${options.roleId} is declared in the role vocabulary and this ` +
|
|
85
|
+
`package does not say where its brief ships; the placed roles are ` +
|
|
86
|
+
`${Object.keys(ROLE_BRIEF_FILES).join(", ")}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const rolePath = join(options.root, relative);
|
|
73
90
|
const roleRead = readOperatorPath(rolePath);
|
|
74
91
|
if (!roleRead.ok) {
|
|
75
92
|
return { ok: false, reason: `role brief ${rolePath}: ${roleRead.reason}` };
|
|
@@ -94,7 +111,13 @@ export function composeBrief(options) {
|
|
|
94
111
|
if (!resolution.ok) {
|
|
95
112
|
return { ok: false, reason: resolution.reason };
|
|
96
113
|
}
|
|
97
|
-
|
|
114
|
+
/* INCLUDES RESOLVE AGAINST THE BRIEF'S OWN DIRECTORY, not against `roles/`.
|
|
115
|
+
Every brief under `roles/` writes `$include: _shared-dispatch-contract.md`
|
|
116
|
+
and AGENTS.md, which is the orchestrator's brief at the package root,
|
|
117
|
+
writes `$include: roles/_shared-dispatch-contract.md`. Both are correct
|
|
118
|
+
relative to the document they are in, and a fixed `roles/` base turned the
|
|
119
|
+
second into `roles/roles/_shared-dispatch-contract.md`. */
|
|
120
|
+
const expanded = expandIncludes(split.body, dirnameOf(rolePath), rolePath);
|
|
98
121
|
if (!expanded.ok) {
|
|
99
122
|
return { ok: false, reason: expanded.reason };
|
|
100
123
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tiphys cutover status|rollback|restore-files|restore-request` (kernel plan
|
|
3
|
+
* M4, M4-P26 and M4-P25). The executable half of D-19's second limb, plus the
|
|
4
|
+
* freeze-point status surface.
|
|
5
|
+
*
|
|
6
|
+
* REGISTERED IN `src/cli.ts` BY M4-P25. The header that stood here said this
|
|
7
|
+
* module was deliberately unregistered because `src/cli.ts` belonged to
|
|
8
|
+
* M4-P16 for the duration of wave 1 of the M4 conflict pre-pass
|
|
9
|
+
* (delivery/plan/m4-conflict-pre-pass.md:60), and that wave is closed: the
|
|
10
|
+
* `cutover` verb is on this phase's files-to-touch list and is now wired, so
|
|
11
|
+
* the rollback document's command lines resolve.
|
|
12
|
+
*
|
|
13
|
+
* Exit codes:
|
|
14
|
+
* 0 the step completed and its observation is printed
|
|
15
|
+
* 1 the step refused, with one reason line naming what was not done
|
|
16
|
+
* 3 `status` only: the question was answered and the answer is that WORK
|
|
17
|
+
* REMAINS (a switch still reads `current`, drain is not clean, or a
|
|
18
|
+
* retirement row is unported)
|
|
19
|
+
* 64 usage error (BSD sysexits EX_USAGE)
|
|
20
|
+
*
|
|
21
|
+
* A REFUSAL IS 1 AND NOT 64 even when the input file is malformed, because a
|
|
22
|
+
* malformed pre-freeze capture is a well-formed question with a negative
|
|
23
|
+
* answer. A caller that cannot tell those apart cannot script a rollback.
|
|
24
|
+
*
|
|
25
|
+
* 3 IS SEPARATE FROM 1 FOR THE SAME REASON, one level along, and it is the
|
|
26
|
+
* code `tiphys next` already uses for the same meaning
|
|
27
|
+
* (delivery/plan/kernel-plan-m4.md:3234, criterion 1: "distinct from 0 and
|
|
28
|
+
* from 1 so a caller can tell work remains from the command failed"). A
|
|
29
|
+
* cutover that has not completed is not a failure of this command.
|
|
30
|
+
*/
|
|
31
|
+
/** Exit code for usage errors, per BSD sysexits EX_USAGE. */
|
|
32
|
+
export declare const EX_USAGE = 64;
|
|
33
|
+
/** Exit code for "the question was answered and work remains". */
|
|
34
|
+
export declare const EX_WORK_REMAINS = 3;
|
|
35
|
+
export declare function cmdCutover(argv: string[]): number;
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tiphys cutover status|rollback|restore-files|restore-request` (kernel plan
|
|
3
|
+
* M4, M4-P26 and M4-P25). The executable half of D-19's second limb, plus the
|
|
4
|
+
* freeze-point status surface.
|
|
5
|
+
*
|
|
6
|
+
* REGISTERED IN `src/cli.ts` BY M4-P25. The header that stood here said this
|
|
7
|
+
* module was deliberately unregistered because `src/cli.ts` belonged to
|
|
8
|
+
* M4-P16 for the duration of wave 1 of the M4 conflict pre-pass
|
|
9
|
+
* (delivery/plan/m4-conflict-pre-pass.md:60), and that wave is closed: the
|
|
10
|
+
* `cutover` verb is on this phase's files-to-touch list and is now wired, so
|
|
11
|
+
* the rollback document's command lines resolve.
|
|
12
|
+
*
|
|
13
|
+
* Exit codes:
|
|
14
|
+
* 0 the step completed and its observation is printed
|
|
15
|
+
* 1 the step refused, with one reason line naming what was not done
|
|
16
|
+
* 3 `status` only: the question was answered and the answer is that WORK
|
|
17
|
+
* REMAINS (a switch still reads `current`, drain is not clean, or a
|
|
18
|
+
* retirement row is unported)
|
|
19
|
+
* 64 usage error (BSD sysexits EX_USAGE)
|
|
20
|
+
*
|
|
21
|
+
* A REFUSAL IS 1 AND NOT 64 even when the input file is malformed, because a
|
|
22
|
+
* malformed pre-freeze capture is a well-formed question with a negative
|
|
23
|
+
* answer. A caller that cannot tell those apart cannot script a rollback.
|
|
24
|
+
*
|
|
25
|
+
* 3 IS SEPARATE FROM 1 FOR THE SAME REASON, one level along, and it is the
|
|
26
|
+
* code `tiphys next` already uses for the same meaning
|
|
27
|
+
* (delivery/plan/kernel-plan-m4.md:3234, criterion 1: "distinct from 0 and
|
|
28
|
+
* from 1 so a caller can tell work remains from the command failed"). A
|
|
29
|
+
* cutover that has not completed is not a failure of this command.
|
|
30
|
+
*/
|
|
31
|
+
import { writeFileSync } from "node:fs";
|
|
32
|
+
import { join, resolve } from "node:path";
|
|
33
|
+
import { loadFleet } from "../fleet.js";
|
|
34
|
+
import { CANNOT_SEE, CUTOVER_SWITCHES, PRE_FREEZE_RULESET_PATH, RETIREMENT_INVENTORY_PATH, ROLLBACK_TRIGGERS, UNREHEARSABLE_REASON, applyRollback, cutoverStatePath, evaluateRetirementInventory, generateRestoreRequest, inFlightItems, preFreezeGuard, readCutoverState, restoreRetirementRoots, syncFleetState, unmergedBranchCount, } from "../cutover.js";
|
|
35
|
+
import { readRegularFileIfPresent, refuseOpenForWrite } from "../task.js";
|
|
36
|
+
/** Exit code for usage errors, per BSD sysexits EX_USAGE. */
|
|
37
|
+
export const EX_USAGE = 64;
|
|
38
|
+
function usage() {
|
|
39
|
+
return [
|
|
40
|
+
"usage: tiphys cutover status --fleet <dir> [--repo <dir>] [--json]",
|
|
41
|
+
" tiphys cutover status --retirement [--repo <dir>] [--inventory <path>] [--json]",
|
|
42
|
+
" tiphys cutover rollback --trigger <drain-reversal|retirement-unmet> --fleet <dir> [--reason <text>] [--allow-no-remote] [--json]",
|
|
43
|
+
" tiphys cutover restore-files --repo <dir> --from <sha> --root <path> [--root <path>]",
|
|
44
|
+
" tiphys cutover restore-request --ruleset <path> [--out <path>]",
|
|
45
|
+
].join("\n");
|
|
46
|
+
}
|
|
47
|
+
function fail(reason, code) {
|
|
48
|
+
process.stderr.write(`tiphys cutover: ${reason}\n`);
|
|
49
|
+
if (code === EX_USAGE) {
|
|
50
|
+
process.stderr.write(`${usage()}\n`);
|
|
51
|
+
}
|
|
52
|
+
return code;
|
|
53
|
+
}
|
|
54
|
+
const VALUE_ARGS = new Set([
|
|
55
|
+
"--trigger",
|
|
56
|
+
"--fleet",
|
|
57
|
+
"--reason",
|
|
58
|
+
"--repo",
|
|
59
|
+
"--from",
|
|
60
|
+
"--root",
|
|
61
|
+
"--ruleset",
|
|
62
|
+
"--out",
|
|
63
|
+
"--inventory",
|
|
64
|
+
]);
|
|
65
|
+
const FLAG_ARGS = new Set(["--json", "--allow-no-remote", "--retirement"]);
|
|
66
|
+
function parseArgs(argv) {
|
|
67
|
+
const flags = new Set();
|
|
68
|
+
const values = new Map();
|
|
69
|
+
const repeated = new Map();
|
|
70
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
71
|
+
const argument = argv[index];
|
|
72
|
+
if (FLAG_ARGS.has(argument)) {
|
|
73
|
+
flags.add(argument);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (VALUE_ARGS.has(argument)) {
|
|
77
|
+
const value = argv[index + 1];
|
|
78
|
+
if (value === undefined || value.startsWith("--")) {
|
|
79
|
+
return { flags, values, repeated, usageError: `${argument} requires a value` };
|
|
80
|
+
}
|
|
81
|
+
values.set(argument, value);
|
|
82
|
+
const list = repeated.get(argument) ?? [];
|
|
83
|
+
list.push(value);
|
|
84
|
+
repeated.set(argument, list);
|
|
85
|
+
index += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
return { flags, values, repeated, usageError: `unknown argument ${argument}` };
|
|
89
|
+
}
|
|
90
|
+
return { flags, values, repeated };
|
|
91
|
+
}
|
|
92
|
+
function isRollbackTrigger(value) {
|
|
93
|
+
return ROLLBACK_TRIGGERS.includes(value);
|
|
94
|
+
}
|
|
95
|
+
/** Exit code for "the question was answered and work remains". */
|
|
96
|
+
export const EX_WORK_REMAINS = 3;
|
|
97
|
+
/**
|
|
98
|
+
* The five switch lines, ALWAYS FIVE AND ALWAYS IN THE CLOSED ORDER.
|
|
99
|
+
*
|
|
100
|
+
* Iterating `CUTOVER_SWITCHES` rather than the document's own keys is the
|
|
101
|
+
* contract: `Object.keys` would print whatever happened to be in the file, in
|
|
102
|
+
* whatever order it happened to be written, and criterion 1 pins the shape.
|
|
103
|
+
*/
|
|
104
|
+
function switchLines(state) {
|
|
105
|
+
return CUTOVER_SWITCHES.map((name) => `SWITCH ${name} ${state.switches[name].state}`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The state a fleet with no `cutover.json` is in.
|
|
109
|
+
*
|
|
110
|
+
* ABSENT IS NOT UNDECIDABLE HERE, and it is worth saying why this differs from
|
|
111
|
+
* `inFlightItems`, where an absent directory IS undecidable. A fleet that was
|
|
112
|
+
* loaded had its `worktrees/` and `tasks/` when it was loaded, so one that is
|
|
113
|
+
* gone now was removed since and says nothing about what was in it. Nothing
|
|
114
|
+
* creates `cutover.json` at init: its absence is the ORIGINAL state, in which
|
|
115
|
+
* no switch has ever been written and every authority is still the current
|
|
116
|
+
* process's. Reporting five `current` lines and exiting 3 is therefore the
|
|
117
|
+
* measured answer and not a fallthrough, and `flippedAt` is the epoch so that
|
|
118
|
+
* the criterion-5 comparison has an instant to compare against rather than a
|
|
119
|
+
* special case.
|
|
120
|
+
*/
|
|
121
|
+
function unflippedState() {
|
|
122
|
+
const switches = {};
|
|
123
|
+
for (const name of CUTOVER_SWITCHES) {
|
|
124
|
+
switches[name] = {
|
|
125
|
+
state: "current",
|
|
126
|
+
flippedAt: new Date(0).toISOString(),
|
|
127
|
+
flippedBy: "(never written)",
|
|
128
|
+
reason: "cutover.json is absent, so no switch has ever been flipped",
|
|
129
|
+
restoreTo: "current",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { switches };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* `tiphys cutover status --retirement`: one line per PORT row of the M4-P23
|
|
136
|
+
* inventory.
|
|
137
|
+
*
|
|
138
|
+
* THE VACUOUS VERDICT IS WHAT THIS EXISTS AGAINST. `ported` requires the named
|
|
139
|
+
* kernel artifact to exist AND that row's negative witness to be RED under it.
|
|
140
|
+
* A verdict derived from the file existing alone is green and worthless: a
|
|
141
|
+
* file can exist and say nothing, and the negative witness is what turns
|
|
142
|
+
* "verify not weaker" from a phrase into a command.
|
|
143
|
+
*/
|
|
144
|
+
function cmdRetirementStatus(parsed) {
|
|
145
|
+
const repo = resolve(parsed.values.get("--repo") ?? process.cwd());
|
|
146
|
+
const inventory = parsed.values.get("--inventory");
|
|
147
|
+
const inventoryPath = inventory === undefined ? join(repo, RETIREMENT_INVENTORY_PATH) : resolve(inventory);
|
|
148
|
+
const read = evaluateRetirementInventory(inventoryPath, repo);
|
|
149
|
+
if (read.kind === "refused") {
|
|
150
|
+
return fail(read.reason, 1);
|
|
151
|
+
}
|
|
152
|
+
const { results, unported } = read.report;
|
|
153
|
+
const lines = results.map((result) => `PORT ${result.id} ${result.verdict} ${result.reason}`);
|
|
154
|
+
lines.push(unported === 0
|
|
155
|
+
? `RETIREMENT complete ${String(results.length)} PORT row(s)`
|
|
156
|
+
: `RETIREMENT ${String(unported)} of ${String(results.length)} PORT row(s) unported`);
|
|
157
|
+
if (parsed.flags.has("--json")) {
|
|
158
|
+
process.stdout.write(`${JSON.stringify({ inventory: inventoryPath, results, unported }, null, 2)}\n`);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
162
|
+
}
|
|
163
|
+
/* A ZERO-ROW INVENTORY IS NOT A COMPLETE RETIREMENT. `unported === 0` over an
|
|
164
|
+
empty list is the vacuous green one level up from the one the row verdict
|
|
165
|
+
guards, so the row count is required to be positive before this reports
|
|
166
|
+
complete. */
|
|
167
|
+
if (results.length === 0) {
|
|
168
|
+
return fail(`${inventoryPath} holds no PORT rows, so a complete verdict would assert nothing`, 1);
|
|
169
|
+
}
|
|
170
|
+
return unported === 0 ? 0 : EX_WORK_REMAINS;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* `tiphys cutover status`: the five switches, the drain predicate, and the
|
|
174
|
+
* pre-freeze precondition.
|
|
175
|
+
*
|
|
176
|
+
* WHAT MAKES THIS EXIT 0. All five switches read `kernel`, drain is clean, and
|
|
177
|
+
* the pre-freeze capture is present and no older than the most recent switch
|
|
178
|
+
* write. Any other combination is nonzero, and the code says WHICH: 1 when the
|
|
179
|
+
* command refused to answer, 3 when it answered and work remains.
|
|
180
|
+
*
|
|
181
|
+
* THE BRANCH COUNT IS PRINTED AND DOES NOT VOTE (M4-D-15,
|
|
182
|
+
* delivery/plan/kernel-plan-m4.md:3279). It is on its own line, after the
|
|
183
|
+
* DRAIN verdict rather than beside it, because a reader who mistakes it for a
|
|
184
|
+
* blocker has the definition of drain that can never read clean.
|
|
185
|
+
*/
|
|
186
|
+
function cmdStatus(parsed) {
|
|
187
|
+
if (parsed.flags.has("--retirement")) {
|
|
188
|
+
return cmdRetirementStatus(parsed);
|
|
189
|
+
}
|
|
190
|
+
const fleetDir = parsed.values.get("--fleet");
|
|
191
|
+
if (fleetDir === undefined) {
|
|
192
|
+
return fail("--fleet is required", EX_USAGE);
|
|
193
|
+
}
|
|
194
|
+
let fleet;
|
|
195
|
+
try {
|
|
196
|
+
fleet = loadFleet(fleetDir);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
return fail(error instanceof Error ? error.message : String(error), 1);
|
|
200
|
+
}
|
|
201
|
+
const repo = resolve(parsed.values.get("--repo") ?? process.cwd());
|
|
202
|
+
const read = readCutoverState(fleet);
|
|
203
|
+
if (read.kind === "refused") {
|
|
204
|
+
return fail(read.reason, 1);
|
|
205
|
+
}
|
|
206
|
+
const state = read.kind === "absent" ? unflippedState() : read.state;
|
|
207
|
+
const items = inFlightItems(fleet);
|
|
208
|
+
const branches = unmergedBranchCount(fleet.root);
|
|
209
|
+
const guard = preFreezeGuard(repo, state);
|
|
210
|
+
const lines = switchLines(state);
|
|
211
|
+
lines.push(items.length === 0 ? "DRAIN clean" : `DRAIN ${String(items.length)} in flight`);
|
|
212
|
+
lines.push(branches.kind === "counted"
|
|
213
|
+
? `BRANCHES ${String(branches.count)} pushed and unmerged, informational: drain does not count branches (M4-D-15)`
|
|
214
|
+
: `BRANCHES unexaminable ${branches.reason}`);
|
|
215
|
+
for (const item of items) {
|
|
216
|
+
lines.push(`IN-FLIGHT ${item.kind} ${item.id} ${item.detail}`);
|
|
217
|
+
}
|
|
218
|
+
/* ONE REFUSAL LINE PER SWITCH THAT READS `kernel`, not one for the table.
|
|
219
|
+
The criterion says no SWITCH may report `kernel` while the capture is
|
|
220
|
+
absent or stale, so the refusal is attached to each switch that is making
|
|
221
|
+
the claim. A single table-level line would let a reader who greps for a
|
|
222
|
+
switch name see `kernel` and nothing else. */
|
|
223
|
+
const frozen = guard.kind === "refused"
|
|
224
|
+
? CUTOVER_SWITCHES.filter((name) => state.switches[name].state === "kernel")
|
|
225
|
+
: [];
|
|
226
|
+
for (const name of frozen) {
|
|
227
|
+
lines.push(`REFUSED ${name} reports kernel but ${guard.kind === "refused" ? guard.reason : ""}`);
|
|
228
|
+
}
|
|
229
|
+
if (guard.kind === "satisfied") {
|
|
230
|
+
lines.push(`PRE-FREEZE captured ${guard.capturedAt} ${PRE_FREEZE_RULESET_PATH}`);
|
|
231
|
+
}
|
|
232
|
+
else if (guard.kind === "not-required") {
|
|
233
|
+
lines.push(`PRE-FREEZE not-required no switch reads kernel, so there is no freeze to have captured`);
|
|
234
|
+
}
|
|
235
|
+
/* NOT ABBREVIATED WHEN THE ANSWER IS SHORT. A command that prints a shorter
|
|
236
|
+
answer when it has less to say is indistinguishable from one reporting a
|
|
237
|
+
quiet system (standing warning 6). */
|
|
238
|
+
for (const entry of CANNOT_SEE) {
|
|
239
|
+
lines.push(`CANNOT-SEE ${entry}`);
|
|
240
|
+
}
|
|
241
|
+
if (parsed.flags.has("--json")) {
|
|
242
|
+
process.stdout.write(`${JSON.stringify({
|
|
243
|
+
switches: state.switches,
|
|
244
|
+
drain: { clean: items.length === 0, inFlight: items },
|
|
245
|
+
branches,
|
|
246
|
+
preFreeze: guard,
|
|
247
|
+
cannotSee: CANNOT_SEE,
|
|
248
|
+
}, null, 2)}\n`);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
252
|
+
}
|
|
253
|
+
if (guard.kind === "refused") {
|
|
254
|
+
return fail(`${PRE_FREEZE_RULESET_PATH}: ${guard.arm}; ${String(frozen.length)} switch(es) report kernel without a usable pre-freeze capture`, 1);
|
|
255
|
+
}
|
|
256
|
+
const allKernel = CUTOVER_SWITCHES.every((name) => state.switches[name].state === "kernel");
|
|
257
|
+
return allKernel && items.length === 0 ? 0 : EX_WORK_REMAINS;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Trigger 1 and the flip-back half of trigger 3.
|
|
261
|
+
*
|
|
262
|
+
* `freeze-point-restore` is REFUSED here on purpose. Trigger 2 is not one
|
|
263
|
+
* command: its file half is `restore-files`, its authority half is
|
|
264
|
+
* `restore-request` plus an owner, and collapsing them into one verb would
|
|
265
|
+
* let a green exit code stand for a restoration that has not happened. The
|
|
266
|
+
* refusal names the unrehearsable property in the same sentence.
|
|
267
|
+
*/
|
|
268
|
+
function cmdRollback(parsed) {
|
|
269
|
+
const triggerValue = parsed.values.get("--trigger");
|
|
270
|
+
if (triggerValue === undefined) {
|
|
271
|
+
return fail("--trigger is required", EX_USAGE);
|
|
272
|
+
}
|
|
273
|
+
if (!isRollbackTrigger(triggerValue)) {
|
|
274
|
+
return fail(`unknown trigger ${triggerValue}, expected one of ${ROLLBACK_TRIGGERS.join(", ")}`, EX_USAGE);
|
|
275
|
+
}
|
|
276
|
+
if (triggerValue === "freeze-point-restore") {
|
|
277
|
+
return fail("freeze-point-restore is not one command: run restore-files for the file half, then " +
|
|
278
|
+
`restore-request for the authority half, which is an OWNER action because ${UNREHEARSABLE_REASON}`, 1);
|
|
279
|
+
}
|
|
280
|
+
const fleetDir = parsed.values.get("--fleet");
|
|
281
|
+
if (fleetDir === undefined) {
|
|
282
|
+
return fail("--fleet is required", EX_USAGE);
|
|
283
|
+
}
|
|
284
|
+
let fleet;
|
|
285
|
+
try {
|
|
286
|
+
fleet = loadFleet(fleetDir);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
return fail(error instanceof Error ? error.message : String(error), 1);
|
|
290
|
+
}
|
|
291
|
+
/* Step 2 of trigger 1 runs BEFORE step 1's switch write in this command,
|
|
292
|
+
because the in-flight enumeration is the thing that would be lost if the
|
|
293
|
+
rollback crashed, and enumerating costs nothing. The document orders the
|
|
294
|
+
write first; the observation is the same either way and this order cannot
|
|
295
|
+
lose the list. */
|
|
296
|
+
const items = inFlightItems(fleet);
|
|
297
|
+
const before = readCutoverState(fleet);
|
|
298
|
+
if (before.kind !== "read") {
|
|
299
|
+
return fail(before.kind === "absent"
|
|
300
|
+
? `${cutoverStatePath(fleet)} is absent, so there is no cutover to roll back`
|
|
301
|
+
: before.reason, 1);
|
|
302
|
+
}
|
|
303
|
+
const outcome = applyRollback(fleet, triggerValue, {
|
|
304
|
+
now: new Date().toISOString(),
|
|
305
|
+
by: "tiphys cutover rollback",
|
|
306
|
+
reason: parsed.values.get("--reason") ?? `rollback trigger ${triggerValue}`,
|
|
307
|
+
});
|
|
308
|
+
if (!outcome.ok) {
|
|
309
|
+
return fail(outcome.reason, 1);
|
|
310
|
+
}
|
|
311
|
+
const sync = syncFleetState(fleet.root, {
|
|
312
|
+
allowNoRemote: parsed.flags.has("--allow-no-remote"),
|
|
313
|
+
message: `cutover rollback: ${triggerValue}`,
|
|
314
|
+
/* The ONE file this command changed. Trigger 1 fires precisely when
|
|
315
|
+
in-flight work exists, so the fleet is dirty by construction and a
|
|
316
|
+
rollback that staged everything would commit somebody else's half-done
|
|
317
|
+
work under the rollback's message. */
|
|
318
|
+
paths: ["cutover.json"],
|
|
319
|
+
});
|
|
320
|
+
const lines = [];
|
|
321
|
+
/* Iterate the CLOSED list rather than the object's keys: the five names and
|
|
322
|
+
their order are the contract, and `Object.keys` would print whatever
|
|
323
|
+
happened to be in the file. */
|
|
324
|
+
for (const name of CUTOVER_SWITCHES) {
|
|
325
|
+
lines.push(`SWITCH ${name} ${outcome.next.switches[name].state}`);
|
|
326
|
+
}
|
|
327
|
+
lines.push(items.length === 0 ? "DRAIN clean" : `DRAIN ${String(items.length)} in flight`);
|
|
328
|
+
for (const item of items) {
|
|
329
|
+
lines.push(`IN-FLIGHT ${item.kind} ${item.id} ${item.detail}`);
|
|
330
|
+
}
|
|
331
|
+
for (const entry of CANNOT_SEE) {
|
|
332
|
+
lines.push(`CANNOT-SEE ${entry}`);
|
|
333
|
+
}
|
|
334
|
+
if (sync.ok && sync.pushed) {
|
|
335
|
+
lines.push(`SYNC pushed ${sync.head}`);
|
|
336
|
+
}
|
|
337
|
+
else if (sync.ok) {
|
|
338
|
+
lines.push(`SYNC not-pushed ${sync.reason}`);
|
|
339
|
+
}
|
|
340
|
+
if (parsed.flags.has("--json")) {
|
|
341
|
+
process.stdout.write(`${JSON.stringify({
|
|
342
|
+
trigger: triggerValue,
|
|
343
|
+
changes: outcome.changes,
|
|
344
|
+
switches: outcome.next.switches,
|
|
345
|
+
inFlight: items,
|
|
346
|
+
cannotSee: CANNOT_SEE,
|
|
347
|
+
sync,
|
|
348
|
+
}, null, 2)}\n`);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
352
|
+
}
|
|
353
|
+
if (!sync.ok) {
|
|
354
|
+
return fail(sync.reason, 1);
|
|
355
|
+
}
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
/** Trigger 2 step 2: the cheap half, guarded by the dirty-tree refusal. */
|
|
359
|
+
function cmdRestoreFiles(parsed) {
|
|
360
|
+
const repo = parsed.values.get("--repo");
|
|
361
|
+
const from = parsed.values.get("--from");
|
|
362
|
+
const roots = parsed.repeated.get("--root") ?? [];
|
|
363
|
+
if (repo === undefined || from === undefined || roots.length === 0) {
|
|
364
|
+
return fail("--repo, --from and at least one --root are required", EX_USAGE);
|
|
365
|
+
}
|
|
366
|
+
const outcome = restoreRetirementRoots(resolve(repo), from, roots);
|
|
367
|
+
if (!outcome.ok) {
|
|
368
|
+
return fail(outcome.reason, 1);
|
|
369
|
+
}
|
|
370
|
+
/* The removals are printed rather than folded into RESTORED. A restore that
|
|
371
|
+
deleted files is a different event from one that only rewrote them, and a
|
|
372
|
+
reader who cannot tell them apart cannot check the result. */
|
|
373
|
+
for (const path of outcome.removed) {
|
|
374
|
+
process.stdout.write(`REMOVED-AFTER-FREEZE ${path}\n`);
|
|
375
|
+
}
|
|
376
|
+
process.stdout.write(`RESTORED ${from} ${outcome.roots.join(" ")} (${String(outcome.removed.length)} post-freeze addition(s) removed)\n`);
|
|
377
|
+
return 0;
|
|
378
|
+
}
|
|
379
|
+
/** Trigger 2 step 3: prepare the owner request. It does not perform it. */
|
|
380
|
+
function cmdRestoreRequest(parsed) {
|
|
381
|
+
const rulesetPath = parsed.values.get("--ruleset");
|
|
382
|
+
if (rulesetPath === undefined) {
|
|
383
|
+
return fail("--ruleset is required", EX_USAGE);
|
|
384
|
+
}
|
|
385
|
+
const read = readRegularFileIfPresent(resolve(rulesetPath));
|
|
386
|
+
if (read.kind === "absent") {
|
|
387
|
+
return fail(`${rulesetPath} is absent, so no pre-freeze value can be restored`, 1);
|
|
388
|
+
}
|
|
389
|
+
if (read.kind === "refused") {
|
|
390
|
+
return fail(read.reason, 1);
|
|
391
|
+
}
|
|
392
|
+
let parsedDocument;
|
|
393
|
+
try {
|
|
394
|
+
parsedDocument = JSON.parse(read.body);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
return fail(`${rulesetPath} is not valid JSON: ${String(error)}`, 1);
|
|
398
|
+
}
|
|
399
|
+
const outcome = generateRestoreRequest(parsedDocument);
|
|
400
|
+
if (!outcome.ok) {
|
|
401
|
+
for (const reason of outcome.reasons) {
|
|
402
|
+
process.stderr.write(`tiphys cutover: ${reason}\n`);
|
|
403
|
+
}
|
|
404
|
+
process.stderr.write(`tiphys cutover: the owner request was NOT generated, because an incomplete request looks complete\n`);
|
|
405
|
+
return 1;
|
|
406
|
+
}
|
|
407
|
+
const out = parsed.values.get("--out");
|
|
408
|
+
if (out === undefined) {
|
|
409
|
+
process.stdout.write(outcome.text);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
const target = resolve(out);
|
|
413
|
+
/* THE ONE ANSWER TO "may this path be opened" applies to this write too.
|
|
414
|
+
`--out` is an operator-supplied path, and opening a named pipe for
|
|
415
|
+
writing blocks exactly as reading one does (src/task.ts:152). This was
|
|
416
|
+
the only write in the phase that went straight to an unprobed path. */
|
|
417
|
+
const refusal = refuseOpenForWrite(target);
|
|
418
|
+
if (refusal !== undefined) {
|
|
419
|
+
return fail(refusal, 1);
|
|
420
|
+
}
|
|
421
|
+
writeFileSync(target, outcome.text);
|
|
422
|
+
process.stdout.write(`REQUEST ${out} ${String(outcome.fields)} field(s)\n`);
|
|
423
|
+
}
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
export function cmdCutover(argv) {
|
|
427
|
+
const [subcommand, ...rest] = argv;
|
|
428
|
+
if (subcommand === undefined) {
|
|
429
|
+
return fail("a subcommand is required", EX_USAGE);
|
|
430
|
+
}
|
|
431
|
+
const parsed = parseArgs(rest);
|
|
432
|
+
if (parsed.usageError !== undefined) {
|
|
433
|
+
return fail(parsed.usageError, EX_USAGE);
|
|
434
|
+
}
|
|
435
|
+
if (subcommand === "status") {
|
|
436
|
+
return cmdStatus(parsed);
|
|
437
|
+
}
|
|
438
|
+
if (subcommand === "rollback") {
|
|
439
|
+
return cmdRollback(parsed);
|
|
440
|
+
}
|
|
441
|
+
if (subcommand === "restore-files") {
|
|
442
|
+
return cmdRestoreFiles(parsed);
|
|
443
|
+
}
|
|
444
|
+
if (subcommand === "restore-request") {
|
|
445
|
+
return cmdRestoreRequest(parsed);
|
|
446
|
+
}
|
|
447
|
+
return fail(`unknown subcommand ${subcommand}`, EX_USAGE);
|
|
448
|
+
}
|