@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
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { EX_USAGE } from "../cli.js";
|
|
6
6
|
import { BEACON_FILE, LOCK_FILE, loadFleet, missingLayoutEntries } from "../fleet.js";
|
|
7
7
|
import { judgeBeacon, warnIfWatcherStale } from "../liveness.js";
|
|
8
|
-
import {
|
|
8
|
+
import { expiryHasPassed } from "../lock.js";
|
|
9
|
+
import { poolList, resolveNetworkTimeoutMs } from "../pool.js";
|
|
10
|
+
import { classifyEntry, readRegularFileIfPresent, singleLine } from "../task.js";
|
|
11
|
+
import { sharedLockStatus } from "../exclusion.js";
|
|
9
12
|
import { decodeDocument } from "../validate.js";
|
|
10
13
|
import { MACHINE_IDENTITY_EMAIL, MACHINE_IDENTITY_NAME, } from "./init.js";
|
|
11
14
|
/**
|
|
@@ -15,6 +18,13 @@ import { MACHINE_IDENTITY_EMAIL, MACHINE_IDENTITY_NAME, } from "./init.js";
|
|
|
15
18
|
* and grows at M2/M3 with the gate registry.
|
|
16
19
|
*/
|
|
17
20
|
export const PROFILES = {
|
|
21
|
+
/* THESE THREE PROMOTE NOTHING ABOUT A CHARTER, and M4-P30 left that alone
|
|
22
|
+
while changing `full`. Nothing below `full` resolves roles, checklists or
|
|
23
|
+
retention duties out of a charter, so a fleet that has none is not broken
|
|
24
|
+
in these modes and must keep exiting 0 there; `tiphys init` writes no
|
|
25
|
+
charter, so the first thing a new user does would otherwise fail. A later
|
|
26
|
+
profile that adds a retention condition here reddens a test that walks all
|
|
27
|
+
three. */
|
|
18
28
|
generic: [],
|
|
19
29
|
"local-only": [],
|
|
20
30
|
"direct-pr": ["gh-missing"],
|
|
@@ -22,18 +32,77 @@ export const PROFILES = {
|
|
|
22
32
|
whose charter declares no retention paths is not ready for full mode. The
|
|
23
33
|
generic profile leaves it a WARN, which is the state a fleet legitimately
|
|
24
34
|
sits in before its charter is written.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
M4-P30 PROMOTES `retention-not-applicable` HERE TOO, REVERSING THE LINE
|
|
36
|
+
THAT STOOD ABOVE IT UNTIL THIS PHASE. What that line said was that a fleet
|
|
37
|
+
with NO charter document at all is left a WARN under `full` as well, and
|
|
38
|
+
the reason given was the sentence immediately above: the generic profile
|
|
39
|
+
leaves it a WARN because that is the state a fleet legitimately sits in
|
|
40
|
+
before its charter is written. THAT REASON IS ABOUT THE GENERIC PROFILE.
|
|
41
|
+
Carried into `full` it makes `tiphys doctor --for full` exit 0 on a fleet
|
|
42
|
+
that has no charter, which is a guard that cannot go red against the state
|
|
43
|
+
it exists to detect: the fleet bring-up measured it on the kernel's own
|
|
44
|
+
fleet home, fully configured, and got exit 0 with ZERO FAIL lines and one
|
|
45
|
+
WARN line about retention (delivery/evidence/m4-fleet-bringup/bringup.md:1,
|
|
46
|
+
record `C5.1-arm5-before-charter-emptied`). `full` is the mode whose
|
|
47
|
+
pipeline resolves roles, checklists and retention duties out of a charter,
|
|
48
|
+
so a fleet with no charter is not ready for it, and unlike the branch check
|
|
49
|
+
the remedy IS reachable by the operator: write the charter.
|
|
50
|
+
THE TWO CONDITIONS STAY SEPARATE, which is the half a one-line fix loses.
|
|
51
|
+
`retention-not-applicable` (no charter document) and `retention-undeclared`
|
|
52
|
+
(a charter, or YAML in charter/, that declares no retention paths) keep
|
|
53
|
+
their own ids AND their own detail strings, so promoting both does not make
|
|
54
|
+
"nobody has written a charter yet" and "somebody configured this wrongly"
|
|
55
|
+
print the same sentence. See checkRetention's header. */
|
|
56
|
+
/* M3-P13: `kernel-artifacts-incomplete` is promoted here, so a fleet whose
|
|
57
|
+
installed kernel has lost roles/, schemas/, checklists/ or AGENTS.md is not
|
|
58
|
+
ready for full mode. It is NOT promoted below full, deliberately: the
|
|
59
|
+
commands that resolve those artifacts are full mode's, and promoting
|
|
60
|
+
everywhere is how a check fails a fleet that never needed it. */
|
|
61
|
+
/* M4-P17: `branches-unmerged`, `tasks-open`, `tasks-not-established`,
|
|
62
|
+
`branches-not-established`, `remote-diverged`, `remote-untracked` and
|
|
63
|
+
`remote-not-a-fleet` are NOT promoted here, and the branch one is the
|
|
64
|
+
case the plan argues at length (criterion 5). Deleting a remote ref is
|
|
65
|
+
refused in the container this kernel is built in, and the delete dry run
|
|
66
|
+
exits 0 either way (CLAUDE.md standing warning 14), so a promoted branch
|
|
67
|
+
check would make this profile unpassable on the kernel's own fleet with
|
|
68
|
+
no remedy its operator could reach, and an unpassable check is one that
|
|
69
|
+
gets switched off. The others are states a fleet legitimately sits in
|
|
70
|
+
between a spawn and a teardown, or immediately after a reclaim. A test
|
|
71
|
+
walks every profile and asserts none of them promotes any of these. */
|
|
72
|
+
/* M4-P22: `shared-lock-unreachable` is promoted here and nowhere else. A
|
|
73
|
+
fleet that has DECLARED the shared exclusion register and cannot read it
|
|
74
|
+
refuses every lease operation, every spawn and every teardown, so it is
|
|
75
|
+
not ready for full mode by any reading; and unlike the branch check the
|
|
76
|
+
remedy is reachable by the operator, who either repairs the remote or
|
|
77
|
+
removes the declaration. It is not promoted below `full` because the
|
|
78
|
+
layer is opt-in per fleet home (M4-P21 criterion 1) and a fleet using
|
|
79
|
+
`local-only` has nothing to reach. The other three statuses cannot be
|
|
80
|
+
promoted at all: `not-declared`, `free` and `held` are PASS and carry no
|
|
81
|
+
condition, deliberately, because a register held by another environment
|
|
82
|
+
is the layer WORKING and not a fault of this fleet. */
|
|
83
|
+
full: [
|
|
84
|
+
"gh-missing",
|
|
85
|
+
"remote-missing",
|
|
86
|
+
"retention-undeclared",
|
|
87
|
+
"retention-not-applicable",
|
|
88
|
+
"kernel-artifacts-incomplete",
|
|
89
|
+
"shared-lock-unreachable",
|
|
90
|
+
],
|
|
29
91
|
watch: ["beacon-absent", "beacon-stale"],
|
|
30
92
|
};
|
|
31
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Locate the kernel's own package.json (same walk as src/version.ts).
|
|
95
|
+
*
|
|
96
|
+
* `classifyEntry`, not `existsSync`: this is the second of the three call
|
|
97
|
+
* sites of the mechanism `reading-a-path-whose-type-is-not-established`, and
|
|
98
|
+
* it is the one inside this phase's files-to-touch. The derivation over all
|
|
99
|
+
* three, and the one left open, is published in this phase's work history.
|
|
100
|
+
*/
|
|
32
101
|
function readKernelEnginesNode() {
|
|
33
102
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
34
103
|
for (;;) {
|
|
35
104
|
const candidate = join(dir, "package.json");
|
|
36
|
-
if (
|
|
105
|
+
if (classifyEntry(candidate).kind === "regular") {
|
|
37
106
|
const parsed = JSON.parse(readFileSync(candidate, "utf8"));
|
|
38
107
|
const engines = parsed.engines;
|
|
39
108
|
if (engines === undefined || typeof engines.node !== "string") {
|
|
@@ -143,6 +212,106 @@ function checkLayout(root) {
|
|
|
143
212
|
}
|
|
144
213
|
return { name: "layout", status: "PASS", detail: "all layout entries present" };
|
|
145
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* A git invocation for the reporting paths in this file, with the two
|
|
217
|
+
* properties a REPORT needs and a write does not.
|
|
218
|
+
*
|
|
219
|
+
* `LC_ALL`/`LANG` are pinned so a translated git cannot change what this
|
|
220
|
+
* module reads back, the same reason src/pool.ts:143 pins them.
|
|
221
|
+
* `GIT_TERMINAL_PROMPT=0` turns a credential prompt into an error: doctor is
|
|
222
|
+
* run non-interactively (by a watcher, by an exit test, by CI), and a git that
|
|
223
|
+
* stops to ask for a password is a command that never returns.
|
|
224
|
+
*/
|
|
225
|
+
function runGitHere(root, args, timeoutMs) {
|
|
226
|
+
const result = spawnSync("git", ["-C", root, ...args], {
|
|
227
|
+
encoding: "utf8",
|
|
228
|
+
env: { ...process.env, LC_ALL: "C", LANG: "C", GIT_TERMINAL_PROMPT: "0" },
|
|
229
|
+
...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
|
|
230
|
+
});
|
|
231
|
+
if (result.error !== undefined) {
|
|
232
|
+
const timedOut = timeoutMs !== undefined &&
|
|
233
|
+
result.error.code === "ETIMEDOUT";
|
|
234
|
+
return {
|
|
235
|
+
status: null,
|
|
236
|
+
stdout: "",
|
|
237
|
+
stderr: timedOut
|
|
238
|
+
? `git ${args.join(" ")} did not answer within ${String(timeoutMs)}ms and was killed`
|
|
239
|
+
: String(result.error),
|
|
240
|
+
timedOut,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
status: result.status,
|
|
245
|
+
stdout: result.stdout ?? "",
|
|
246
|
+
stderr: result.stderr ?? "",
|
|
247
|
+
timedOut: false,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/** The first non-empty line of git's own stderr, for a one-line detail. */
|
|
251
|
+
function firstStderrLine(stderr) {
|
|
252
|
+
const line = stderr
|
|
253
|
+
.split("\n")
|
|
254
|
+
.map((entry) => entry.trim())
|
|
255
|
+
.find((entry) => entry !== "");
|
|
256
|
+
return line ?? "no stderr";
|
|
257
|
+
}
|
|
258
|
+
/** git's answer, or undefined when it did not answer. */
|
|
259
|
+
function gitValue(root, args) {
|
|
260
|
+
const run = runGitHere(root, args);
|
|
261
|
+
if (run.status !== 0) {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
const value = run.stdout.trim();
|
|
265
|
+
return value === "" ? undefined : value;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* CHECK remote (M4-P17 criterion 6): FETCH, then COMPARE.
|
|
269
|
+
*
|
|
270
|
+
* WHAT IT USED TO DO, and why that was the H-B shape. It listed the
|
|
271
|
+
* configured remotes and printed `PASS remote configured (origin)` whenever
|
|
272
|
+
* the list was non-empty. That condition is TRUE OF THE DANGEROUS STATE in
|
|
273
|
+
* two separate ways, and both are ordinary rather than exotic: a fleet whose
|
|
274
|
+
* whole history has never been pushed, and a fleet whose remote URL points at
|
|
275
|
+
* something that is not there. Both printed PASS. Nothing the check did could
|
|
276
|
+
* have told them from a healthy fleet, because `git remote` reads a config
|
|
277
|
+
* file and never touches the remote.
|
|
278
|
+
*
|
|
279
|
+
* SO THE FETCH IS THE CHECK. Without it, "is the fleet's work somewhere other
|
|
280
|
+
* than this disk" is answered by reading a string the operator typed. With it,
|
|
281
|
+
* the answer is ahead/behind counts against a ref the remote actually
|
|
282
|
+
* advertised.
|
|
283
|
+
*
|
|
284
|
+
* THE FETCH IS BOUNDED, AND THIS IS A DELIBERATE DEPARTURE FROM THE
|
|
285
|
+
* CLASSIFICATION M4-P19 WROTE (test/pool.test.ts's
|
|
286
|
+
* `pool-network-calls-are-classified`). That classification leaves object
|
|
287
|
+
* transfers (`clone`, `fetch`, `pull`, `push`) unbounded, on the reasoning
|
|
288
|
+
* that a legitimate transfer's duration is set by how much data there is, so a
|
|
289
|
+
* wall-clock bound would kill real work. That reasoning is right where it was
|
|
290
|
+
* written, and it does not reach here: this fetch moves no work an operator
|
|
291
|
+
* asked for, it is a PROBE inside a REPORT. Killing it costs one diagnostic
|
|
292
|
+
* line; not killing it costs the whole diagnosis, because doctor is the
|
|
293
|
+
* command someone runs when a fleet is already misbehaving and a remote that
|
|
294
|
+
* accepts and never answers would hang it with zero output. The bound reads
|
|
295
|
+
* the same `TIPHYS_GIT_NETWORK_TIMEOUT_MS` override as the pool's ref probe,
|
|
296
|
+
* so a witness can shorten it instead of waiting out the shipped twenty
|
|
297
|
+
* seconds; a bound no test can drive is a bound no test will guard.
|
|
298
|
+
*
|
|
299
|
+
* A FETCH THAT DID NOT RUN IS NOT A PASS AND IS NOT A WARN. It is FAIL,
|
|
300
|
+
* naming git's own first stderr line. The alternative a reviewer should look
|
|
301
|
+
* for, and which this code deliberately does not do, is to swallow the failure
|
|
302
|
+
* and fall back to the old non-empty-list test: that is green in exactly the
|
|
303
|
+
* state the check exists to catch, which is the guard-that-cannot-go-red shape.
|
|
304
|
+
* FAIL is also what this command already does everywhere else an input could
|
|
305
|
+
* not be established (an unreadable lease, an undecodable charter), so the
|
|
306
|
+
* three states a reader might confuse (in sync, diverged, could not ask) never
|
|
307
|
+
* print the same word.
|
|
308
|
+
*
|
|
309
|
+
* OUTSIDE A FLEET HOME THERE IS NOTHING TO FETCH FOR. The subject of this
|
|
310
|
+
* check is the FLEET's push target (SC-002), so in a git repository that is
|
|
311
|
+
* not a fleet home it reports that and stops, exactly as CHECK worktrees does.
|
|
312
|
+
* That is a verdict with its reason, not a silent pass, and it means doctor
|
|
313
|
+
* run inside some unrelated checkout never reaches for that checkout's remote.
|
|
314
|
+
*/
|
|
146
315
|
function checkRemote(root) {
|
|
147
316
|
if (!existsSync(join(root, ".git"))) {
|
|
148
317
|
return {
|
|
@@ -152,9 +321,9 @@ function checkRemote(root) {
|
|
|
152
321
|
condition: "remote-missing",
|
|
153
322
|
};
|
|
154
323
|
}
|
|
155
|
-
const
|
|
156
|
-
const remotes =
|
|
157
|
-
?
|
|
324
|
+
const listed = runGitHere(root, ["remote"]);
|
|
325
|
+
const remotes = listed.status === 0
|
|
326
|
+
? listed.stdout.split("\n").filter((line) => line !== "")
|
|
158
327
|
: [];
|
|
159
328
|
if (remotes.length === 0) {
|
|
160
329
|
return {
|
|
@@ -164,10 +333,87 @@ function checkRemote(root) {
|
|
|
164
333
|
condition: "remote-missing",
|
|
165
334
|
};
|
|
166
335
|
}
|
|
336
|
+
try {
|
|
337
|
+
loadFleet(root);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return {
|
|
341
|
+
name: "remote",
|
|
342
|
+
status: "WARN",
|
|
343
|
+
detail: `${root} is a git repository with a remote (${remotes.join(", ")}) but it is not a ` +
|
|
344
|
+
"fleet home, so there is no fleet state to compare against it",
|
|
345
|
+
condition: "remote-not-a-fleet",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const branch = gitValue(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
349
|
+
const remote = (branch === undefined || branch === "HEAD"
|
|
350
|
+
? undefined
|
|
351
|
+
: gitValue(root, ["config", "--get", `branch.${branch}.remote`])) ??
|
|
352
|
+
(remotes.includes("origin") ? "origin" : remotes[0]);
|
|
353
|
+
const fetched = runGitHere(root, ["fetch", "--quiet", remote], resolveNetworkTimeoutMs(process.env["TIPHYS_GIT_NETWORK_TIMEOUT_MS"]));
|
|
354
|
+
if (fetched.status !== 0) {
|
|
355
|
+
return {
|
|
356
|
+
name: "remote",
|
|
357
|
+
status: "FAIL",
|
|
358
|
+
detail: `git fetch ${remote} did not succeed, so this fleet's push target could not be ` +
|
|
359
|
+
`reached and nothing about it is established: ${firstStderrLine(fetched.stderr)}`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
if (branch === undefined || branch === "HEAD") {
|
|
363
|
+
return {
|
|
364
|
+
name: "remote",
|
|
365
|
+
status: "WARN",
|
|
366
|
+
detail: `fetched ${remote}, but HEAD is detached, so there is no tracked remote ` +
|
|
367
|
+
"ref to compare it against",
|
|
368
|
+
condition: "remote-untracked",
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
const tracked = gitValue(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]) ??
|
|
372
|
+
(gitValue(root, ["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${branch}`]) ===
|
|
373
|
+
undefined
|
|
374
|
+
? undefined
|
|
375
|
+
: `${remote}/${branch}`);
|
|
376
|
+
if (tracked === undefined) {
|
|
377
|
+
return {
|
|
378
|
+
name: "remote",
|
|
379
|
+
status: "WARN",
|
|
380
|
+
detail: `fetched ${remote}, which carries no ref for branch ${branch}, so the whole of ` +
|
|
381
|
+
"this branch is unpushed",
|
|
382
|
+
condition: "remote-untracked",
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
const counted = runGitHere(root, ["rev-list", "--left-right", "--count", `${tracked}...HEAD`]);
|
|
386
|
+
if (counted.status !== 0) {
|
|
387
|
+
return {
|
|
388
|
+
name: "remote",
|
|
389
|
+
status: "FAIL",
|
|
390
|
+
detail: `git rev-list could not count HEAD against ${tracked}, so the ahead and behind ` +
|
|
391
|
+
`counts are not established: ${firstStderrLine(counted.stderr)}`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
const fields = counted.stdout.trim().split(/\s+/);
|
|
395
|
+
const behind = Number(fields[0]);
|
|
396
|
+
const ahead = Number(fields[1]);
|
|
397
|
+
if (fields.length !== 2 || !Number.isInteger(behind) || !Number.isInteger(ahead)) {
|
|
398
|
+
return {
|
|
399
|
+
name: "remote",
|
|
400
|
+
status: "FAIL",
|
|
401
|
+
detail: `git rev-list answered ${JSON.stringify(counted.stdout.trim())} for ` +
|
|
402
|
+
`${tracked}...HEAD, which is not two counts`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
if (ahead === 0 && behind === 0) {
|
|
406
|
+
return {
|
|
407
|
+
name: "remote",
|
|
408
|
+
status: "PASS",
|
|
409
|
+
detail: `in sync with ${tracked} (0 ahead, 0 behind) after fetching ${remote}`,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
167
412
|
return {
|
|
168
413
|
name: "remote",
|
|
169
|
-
status: "
|
|
170
|
-
detail:
|
|
414
|
+
status: "WARN",
|
|
415
|
+
detail: `${String(ahead)} unpushed, ${String(behind)} behind ${tracked}`,
|
|
416
|
+
condition: "remote-diverged",
|
|
171
417
|
};
|
|
172
418
|
}
|
|
173
419
|
/**
|
|
@@ -207,19 +453,57 @@ function checkLock(root) {
|
|
|
207
453
|
detail: "lease file is missing holderId or expiresAt",
|
|
208
454
|
};
|
|
209
455
|
}
|
|
210
|
-
|
|
211
|
-
|
|
456
|
+
return lockCheckFor(lease.holderId, lease.expiresAt, Date.now());
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* THE VERDICT OVER A LEASE THAT HAS BEEN READ (M4-P17 criteria 1 and 2).
|
|
460
|
+
*
|
|
461
|
+
* AN EXPIRED LEASE IS A FAIL. Until this phase it was a PASS carrying the
|
|
462
|
+
* word `(expired)` inside the detail, and that is the H-B shape this
|
|
463
|
+
* repository keeps paying for: the check's condition was TRUE OF THE
|
|
464
|
+
* DANGEROUS STATE, so a fleet whose orchestrator died holding the lease
|
|
465
|
+
* reported `CHECK lock PASS` and doctor exited 0. AGENTS.md's resume clause
|
|
466
|
+
* says doctor reports "which leases are expired and who last held them"; a
|
|
467
|
+
* green line with a parenthesis in it is not that report, and an operator
|
|
468
|
+
* scanning for FAIL lines never saw it.
|
|
469
|
+
*
|
|
470
|
+
* The holder id and the expiry are both in the detail because the remedy
|
|
471
|
+
* needs both: WHO to ask before breaking the lease, and WHEN it lapsed.
|
|
472
|
+
*
|
|
473
|
+
* `nowMs` IS A PARAMETER, and that is what makes criterion 2's boundary
|
|
474
|
+
* member testable at all. `Date.now()` cannot be driven to a chosen
|
|
475
|
+
* millisecond from outside the process, so a lease whose expiry is exactly
|
|
476
|
+
* the current instant is unreachable through the CLI: by the time doctor
|
|
477
|
+
* runs, the instant has passed and the case under test is the interior one
|
|
478
|
+
* again. The caller above passes the clock; the witness passes an instant.
|
|
479
|
+
*
|
|
480
|
+
* THE COMPARISON IS NOT MADE HERE. `expiryHasPassed` (src/lock.ts) owns it,
|
|
481
|
+
* so doctor and the lock module cannot return two verdicts about one lease.
|
|
482
|
+
* That is the same rule checkBeacon follows for `judgeBeacon`, and it was
|
|
483
|
+
* written down there after a delta review found doctor carrying its own copy
|
|
484
|
+
* of the beacon comparison and missing a floor the module had.
|
|
485
|
+
*/
|
|
486
|
+
export function lockCheckFor(holderId, expiresAt, nowMs) {
|
|
487
|
+
if (Number.isNaN(Date.parse(expiresAt))) {
|
|
212
488
|
return {
|
|
213
489
|
name: "lock",
|
|
214
490
|
status: "FAIL",
|
|
215
|
-
detail: `lease expiresAt "${
|
|
491
|
+
detail: `lease expiresAt "${expiresAt}" is not a parseable timestamp`,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (expiryHasPassed(expiresAt, nowMs)) {
|
|
495
|
+
return {
|
|
496
|
+
name: "lock",
|
|
497
|
+
status: "FAIL",
|
|
498
|
+
detail: `lease held by ${holderId} EXPIRED at ${expiresAt}; a lease that has ` +
|
|
499
|
+
"lapsed is no longer holding anything, so whatever it was protecting " +
|
|
500
|
+
"is unprotected",
|
|
216
501
|
};
|
|
217
502
|
}
|
|
218
|
-
const expired = expiresMs <= Date.now();
|
|
219
503
|
return {
|
|
220
504
|
name: "lock",
|
|
221
505
|
status: "PASS",
|
|
222
|
-
detail: `lease held by ${
|
|
506
|
+
detail: `lease held by ${holderId}, expires ${expiresAt}`,
|
|
223
507
|
};
|
|
224
508
|
}
|
|
225
509
|
/**
|
|
@@ -357,13 +641,25 @@ function checkIdentity(root) {
|
|
|
357
641
|
* fields are project facts init does not hold. Folding that state into
|
|
358
642
|
* `retention-undeclared` made `tiphys doctor --for full` exit nonzero on every
|
|
359
643
|
* freshly initialized fleet, which is the first thing a new user does. So it
|
|
360
|
-
* gets its own condition, `retention-not-applicable
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
644
|
+
* gets its own condition, `retention-not-applicable`. It is still a WARN and
|
|
645
|
+
* still names its reason, so it never prints the same word as "declared,
|
|
646
|
+
* present and tracked": the plan's hazard row for this check permits exactly
|
|
647
|
+
* "FAIL or not-applicable-with-a-reason, never a silent pass". The SC-011 arm
|
|
648
|
+
* the row is aimed at, a charter that EXISTS and declares no retention paths,
|
|
649
|
+
* keeps `retention-undeclared` and keeps its promotion.
|
|
650
|
+
*
|
|
651
|
+
* WHAT M4-P30 CHANGED, AND WHAT IT DID NOT. Until this phase the paragraph
|
|
652
|
+
* above ended "which the `full` profile does NOT promote", and that is now
|
|
653
|
+
* false: `full` promotes BOTH conditions (see the PROFILES table's own note).
|
|
654
|
+
* What survives unchanged is the separation the paragraph was written to
|
|
655
|
+
* protect. The two states keep separate condition ids and separate detail
|
|
656
|
+
* strings, so a reader of a FAIL line can still tell "nobody has written a
|
|
657
|
+
* charter here yet" from "somebody put YAML in charter/ that is not a
|
|
658
|
+
* charter", and the promotion is what M3-P8 fix round 2 could not have: a
|
|
659
|
+
* state is only promoted where the operator's remedy exists. Below `full`
|
|
660
|
+
* nothing resolves anything out of a charter, so nothing below `full`
|
|
661
|
+
* promotes it and a fresh `tiphys init` fleet still exits 0 under the
|
|
662
|
+
* generic profile.
|
|
367
663
|
*/
|
|
368
664
|
function checkRetention(root) {
|
|
369
665
|
const charterDir = join(root, "charter");
|
|
@@ -456,9 +752,21 @@ function checkRetention(root) {
|
|
|
456
752
|
/* NOT APPLICABLE versus UNDECLARED, and the difference is whether anyone
|
|
457
753
|
has written a charter yet. An empty charter/ is a fleet before
|
|
458
754
|
realization; YAML that is present but carries no `kind: charter` is a
|
|
459
|
-
fleet someone has configured wrongly
|
|
460
|
-
|
|
755
|
+
fleet someone has configured wrongly.
|
|
756
|
+
SINCE M4-P30 THE `full` PROFILE PROMOTES BOTH, AND THE SPLIT IS WHAT
|
|
757
|
+
SURVIVES THAT. It would be one line shorter to return one condition with
|
|
758
|
+
one sentence now that both arms exit nonzero under the same profile, and
|
|
759
|
+
that is the edit this comment exists to refuse: the two states have
|
|
760
|
+
DIFFERENT REMEDIES. The first is an owner writing the charter the fleet
|
|
761
|
+
has been waiting for; the second is somebody removing or fixing a
|
|
762
|
+
document that is in charter/ and is not a charter. A FAIL line that
|
|
763
|
+
cannot tell a reader which of those to do is a diagnosis that has lost
|
|
764
|
+
the thing it was for. The condition ids and the detail strings are both
|
|
765
|
+
kept distinct, and a test asserts it on one fleet with one variable
|
|
766
|
+
changed. */
|
|
461
767
|
if (candidates === 0) {
|
|
768
|
+
/* NO YAML AT ALL in charter/, which is what `tiphys init` leaves. WARN
|
|
769
|
+
below `full`, promoted there. */
|
|
462
770
|
return {
|
|
463
771
|
name: "retention",
|
|
464
772
|
status: "WARN",
|
|
@@ -466,6 +774,9 @@ function checkRetention(root) {
|
|
|
466
774
|
condition: "retention-not-applicable",
|
|
467
775
|
};
|
|
468
776
|
}
|
|
777
|
+
/* YAML IS PRESENT AND NONE OF IT IS A CHARTER. The count is in the detail
|
|
778
|
+
because it is the difference between one stray file and a directory
|
|
779
|
+
somebody filled with the wrong documents. */
|
|
469
780
|
return {
|
|
470
781
|
name: "retention",
|
|
471
782
|
status: "WARN",
|
|
@@ -538,6 +849,631 @@ function isGitIgnored(repository, relative) {
|
|
|
538
849
|
const result = spawnSync("git", ["-C", repository, "check-ignore", "-q", "--", relative], { encoding: "utf8" });
|
|
539
850
|
return result.error === undefined && result.status === 0;
|
|
540
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* THE KERNEL ARTIFACTS THIS CHECK REQUIRES, pinned HERE and never read out of
|
|
854
|
+
* the install being audited.
|
|
855
|
+
*
|
|
856
|
+
* The mechanism index's `checking-a-generated-artifact-against-its-own-generator`
|
|
857
|
+
* row is about a check whose SUBJECT is selected by a value read from the
|
|
858
|
+
* artifact it audits: that check can be silently narrowed by editing the
|
|
859
|
+
* artifact. Reading this list out of the install's own `package.json` files
|
|
860
|
+
* array would be exactly that shape, because an install that dropped `roles/`
|
|
861
|
+
* from both the tree and the files list would report itself complete. So the
|
|
862
|
+
* list is a constant in the source, and `package.json` is consulted only to
|
|
863
|
+
* locate the package root, never to decide what must be in it.
|
|
864
|
+
*
|
|
865
|
+
* A DIRECTORY MUST BE NON-EMPTY, which is decision D-1 of the phase plan. The
|
|
866
|
+
* check's subject is whether the install can resolve a role, a schema or a
|
|
867
|
+
* checklist, and an empty `roles/` resolves none. An `existsSync` on a
|
|
868
|
+
* directory the packer created empty is the vacuous pass hazard H1 names.
|
|
869
|
+
*/
|
|
870
|
+
/**
|
|
871
|
+
* WHAT "RESOLVES" MEANS, PER ARTIFACT, TAKEN FROM THE CONSUMER (fix round 1,
|
|
872
|
+
* clean-room finding CR-001 of the hazard contract).
|
|
873
|
+
*
|
|
874
|
+
* Round 0 tested that a required path was PRESENT and reported success as
|
|
875
|
+
* `carries roles/, schemas/, checklists/ and AGENTS.md`, which is a claim
|
|
876
|
+
* about RESOLVABILITY. Presence is a PROXY for it, and the proxy was reachable
|
|
877
|
+
* in four measured shapes, every one of them PASS with FAIL count zero: a
|
|
878
|
+
* directory holding one unrelated file, a directory holding only a
|
|
879
|
+
* subdirectory, a directory whose members are all zero bytes, and a zero-byte
|
|
880
|
+
* `AGENTS.md`.
|
|
881
|
+
*
|
|
882
|
+
* The suffix below is not invented here. It is the filter the CONSUMING
|
|
883
|
+
* command already applies, so the check cannot claim more than the consumer
|
|
884
|
+
* will deliver:
|
|
885
|
+
*
|
|
886
|
+
* roles/ src/roles.ts:335 `.md`
|
|
887
|
+
* schemas/ src/commands/validate.ts:156 `.schema.json`
|
|
888
|
+
* checklists/ src/checklists.ts:91 `.yaml`
|
|
889
|
+
*
|
|
890
|
+
* WHAT THE PREDICATE DOES NOT COVER, stated rather than left to be found. It
|
|
891
|
+
* asks whether at least ONE member would be selected and carries bytes. It
|
|
892
|
+
* does not PARSE a member, so a `.yaml` that does not decode, a `.schema.json`
|
|
893
|
+
* that is not a schema and a `.md` with no frontmatter all resolve. It does
|
|
894
|
+
* not ask WHICH members are present, so an install carrying one role resolves
|
|
895
|
+
* `roles/` even if the role a brief names is the missing one. Both are
|
|
896
|
+
* deliberate: doctor answers "is this install fit to run", and a per-document
|
|
897
|
+
* decode is the consuming command's own failure, reported by it, with the path
|
|
898
|
+
* it could not use.
|
|
899
|
+
*/
|
|
900
|
+
const REQUIRED_KERNEL_DIRECTORIES = [
|
|
901
|
+
{ name: "roles", suffix: ".md" },
|
|
902
|
+
{ name: "schemas", suffix: ".schema.json" },
|
|
903
|
+
{ name: "checklists", suffix: ".yaml" },
|
|
904
|
+
];
|
|
905
|
+
const REQUIRED_KERNEL_FILES = ["AGENTS.md"];
|
|
906
|
+
/**
|
|
907
|
+
* A path that is a regular file AND carries bytes.
|
|
908
|
+
*
|
|
909
|
+
* `classifyEntry` first, `statSync` second: the type is established before the
|
|
910
|
+
* size is asked for, so a FIFO here is `false` in bounded time rather than a
|
|
911
|
+
* blocked open (mechanism index,
|
|
912
|
+
* `reading-a-path-whose-type-is-not-established`). The `statSync` cannot be
|
|
913
|
+
* folded into `classifyEntry`, which returns a kind and no size; it is a
|
|
914
|
+
* second stat of a path already established as a regular file, never an open.
|
|
915
|
+
*/
|
|
916
|
+
function carriesContent(path) {
|
|
917
|
+
if (classifyEntry(path).kind !== "regular") {
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
try {
|
|
921
|
+
return statSync(path).size > 0;
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* The installed kernel's own package root: the first ancestor of THIS MODULE
|
|
929
|
+
* carrying a `package.json`.
|
|
930
|
+
*
|
|
931
|
+
* **This is deliberately NOT `kernelRoot()` from src/roles.ts, and the reason
|
|
932
|
+
* is the whole point of the check.** That function walks upward looking for a
|
|
933
|
+
* `roles/` directory containing a `.md` file, which is the very artifact this
|
|
934
|
+
* check exists to find missing: against an install with `roles/` removed it
|
|
935
|
+
* walks PAST the install and answers about an ancestor, and where no ancestor
|
|
936
|
+
* carries one it throws. A check built on it reports on the wrong tree or
|
|
937
|
+
* crashes on precisely the state its own criteria describe.
|
|
938
|
+
*
|
|
939
|
+
* Walking for `package.json` does not have that property. `package.json` is
|
|
940
|
+
* the package BOUNDARY rather than a member of the set under test, and it is
|
|
941
|
+
* present in both shipped layouts. Measured on this head: the published
|
|
942
|
+
* package puts this module at `dist/src/commands/doctor.js` with the artifacts
|
|
943
|
+
* three levels up at the package root, and the development checkout puts it at
|
|
944
|
+
* `src/commands/doctor.ts` with the artifacts two levels up, while
|
|
945
|
+
* `dist/package.json` does not exist in the pack listing. So a FIXED DEPTH
|
|
946
|
+
* from `import.meta.url` is wrong in one of the two layouts and the first
|
|
947
|
+
* `package.json` above the module is right in both.
|
|
948
|
+
*
|
|
949
|
+
* Returns the reason rather than throwing, because a guard whose correctness
|
|
950
|
+
* depends on a crash is not a guard (mechanism index,
|
|
951
|
+
* `a-guard-s-own-failure-path`).
|
|
952
|
+
*/
|
|
953
|
+
export function resolveInstalledKernelRoot(from = dirname(fileURLToPath(import.meta.url))) {
|
|
954
|
+
let dir = from;
|
|
955
|
+
for (;;) {
|
|
956
|
+
const candidate = join(dir, "package.json");
|
|
957
|
+
if (classifyEntry(candidate).kind === "regular") {
|
|
958
|
+
return { ok: true, root: dir };
|
|
959
|
+
}
|
|
960
|
+
const parent = dirname(dir);
|
|
961
|
+
if (parent === dir) {
|
|
962
|
+
return {
|
|
963
|
+
ok: false,
|
|
964
|
+
reason: `no package.json above ${from}, so the installed kernel root cannot be resolved`,
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
dir = parent;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* `kernel-artifacts`: the resolved kernel install carries every artifact M3
|
|
972
|
+
* made load-bearing (kernel plan M3 section 4, stage E0.4's designated subject;
|
|
973
|
+
* phase M3-P13).
|
|
974
|
+
*
|
|
975
|
+
* WHAT THIS CHECK IS FOR. The brief composer resolves `roles/`, the validator
|
|
976
|
+
* loads `schemas/`, the checklist command resolves `checklists/`, and
|
|
977
|
+
* `AGENTS.md` is the policy document every role brief points at. Until now an
|
|
978
|
+
* install that lost one of them reported nothing wrong: the loss surfaced later
|
|
979
|
+
* as one command's resolution failure, whose message names the path it could
|
|
980
|
+
* not open rather than the state of the install. doctor is the command whose
|
|
981
|
+
* whole job is answering "is this environment fit to run", and the kernel's own
|
|
982
|
+
* artifacts were the one input none of its checks looked at.
|
|
983
|
+
*
|
|
984
|
+
* EVERY missing artifact is named, not the first (decision D-2): a check that
|
|
985
|
+
* names one sends its reader round the loop once per missing item, and the loop
|
|
986
|
+
* here is a reinstall.
|
|
987
|
+
*
|
|
988
|
+
* The condition is `kernel-artifacts-incomplete`, promoted to FAIL under the
|
|
989
|
+
* `full` profile and left a WARN below it. Below `full` no command that needs
|
|
990
|
+
* these artifacts is necessarily in the pipeline, and promoting everywhere is
|
|
991
|
+
* how a check like this ends up failing a fleet that never needed it.
|
|
992
|
+
*/
|
|
993
|
+
export function checkKernelArtifacts(resolution = resolveInstalledKernelRoot()) {
|
|
994
|
+
if (!resolution.ok) {
|
|
995
|
+
return {
|
|
996
|
+
name: "kernel-artifacts",
|
|
997
|
+
status: "FAIL",
|
|
998
|
+
detail: resolution.reason,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
const root = resolution.root;
|
|
1002
|
+
const missing = [];
|
|
1003
|
+
for (const { name, suffix } of REQUIRED_KERNEL_DIRECTORIES) {
|
|
1004
|
+
const path = join(root, name);
|
|
1005
|
+
let entries;
|
|
1006
|
+
try {
|
|
1007
|
+
entries = readdirSync(path);
|
|
1008
|
+
}
|
|
1009
|
+
catch (error) {
|
|
1010
|
+
const code = error.code;
|
|
1011
|
+
missing.push(code === "ENOENT"
|
|
1012
|
+
? `${name}/ (absent)`
|
|
1013
|
+
: `${name}/ (${code ?? "unreadable"})`);
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
if (entries.length === 0) {
|
|
1017
|
+
missing.push(`${name}/ (present but empty, so it resolves nothing)`);
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
if (!entries.some((entry) => entry.endsWith(suffix) && carriesContent(join(path, entry)))) {
|
|
1021
|
+
missing.push(`${name}/ (present, but no ${suffix} member resolves)`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
for (const name of REQUIRED_KERNEL_FILES) {
|
|
1025
|
+
/* classifyEntry, not existsSync: it lstats the link, stats what it
|
|
1026
|
+
resolves to, and opens only a regular file, so a FIFO at this path is a
|
|
1027
|
+
reported refusal in bounded time rather than a doctor that hangs
|
|
1028
|
+
(mechanism index, `reading-a-path-whose-type-is-not-established`). */
|
|
1029
|
+
const path = join(root, name);
|
|
1030
|
+
const entry = classifyEntry(path);
|
|
1031
|
+
if (entry.kind !== "regular") {
|
|
1032
|
+
missing.push(entry.kind === "absent"
|
|
1033
|
+
? `${name} (absent)`
|
|
1034
|
+
: `${name} (${entry.kind}${entry.kind === "dangling" ? "" : `: ${entry.reason}`})`);
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
/* The FILE member of the class the emptiness reasoning was written for.
|
|
1038
|
+
The plan's words for the directory member, "an install that carries an
|
|
1039
|
+
empty roles/ resolves no role", are true word for word of a zero-byte
|
|
1040
|
+
AGENTS.md: it is the policy document every role brief points at, and an
|
|
1041
|
+
empty one states no policy. */
|
|
1042
|
+
if (!carriesContent(path)) {
|
|
1043
|
+
missing.push(`${name} (present but empty, so it states nothing)`);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
if (missing.length > 0) {
|
|
1047
|
+
return {
|
|
1048
|
+
name: "kernel-artifacts",
|
|
1049
|
+
status: "WARN",
|
|
1050
|
+
detail: `the kernel install at ${root} is missing ${missing.join(", ")}`,
|
|
1051
|
+
condition: "kernel-artifacts-incomplete",
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
return {
|
|
1055
|
+
name: "kernel-artifacts",
|
|
1056
|
+
status: "PASS",
|
|
1057
|
+
detail: `the kernel install at ${root} carries roles/, schemas/, checklists/ and AGENTS.md`,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* CHECK tasks (M4-P17 criterion 3): how many tasks are OPEN, and which.
|
|
1062
|
+
*
|
|
1063
|
+
* THE DEFINITION IS THE PLAN'S AND IT IS DELIBERATELY NARROW. A task is open
|
|
1064
|
+
* when `tasks/<id>/meta.json` is there and `tasks/<id>/turn-end` is not. Two
|
|
1065
|
+
* file existences, both under `tasks/`, and nothing else. No log is read
|
|
1066
|
+
* (constraint C-1: currency never comes off the tail of an append-only
|
|
1067
|
+
* stream), and nothing is probed for being alive (constraint C-2: liveness is
|
|
1068
|
+
* lease freshness, never a process). A test in test/doctor.test.ts greps this
|
|
1069
|
+
* function's own source for the four tokens that would mean either constraint
|
|
1070
|
+
* had been broken, because a violation of either is invisible in the output.
|
|
1071
|
+
*
|
|
1072
|
+
* ESTABLISHED, ABSENT, UNUSABLE, AND THEY NEVER PRINT THE SAME WORD. This is
|
|
1073
|
+
* the one thing this check must not get wrong. The kernel already carries a
|
|
1074
|
+
* live instance of the opposite, tracked at
|
|
1075
|
+
* delivery/verification/tracked-doctor-charter-selection.md:1: the retention
|
|
1076
|
+
* check selects charter documents by a raw `kind` read, so a document whose
|
|
1077
|
+
* `kind` cannot be read is SKIPPED, and skipping is indistinguishable from
|
|
1078
|
+
* absence, so the command reports PASS over a fleet it could not examine. A
|
|
1079
|
+
* fourth check with that shape would be worse than no check.
|
|
1080
|
+
*
|
|
1081
|
+
* So every candidate under `tasks/` lands in exactly one of three buckets and
|
|
1082
|
+
* the third is reported by name:
|
|
1083
|
+
*
|
|
1084
|
+
* - open meta.json is a regular file, turn-end is absent
|
|
1085
|
+
* - closed meta.json is a regular file, turn-end is a regular file
|
|
1086
|
+
* - UNUSABLE anything else: no meta.json at all, a meta.json or a turn-end
|
|
1087
|
+
* that is a directory, a named pipe, a dangling link, or a path
|
|
1088
|
+
* `lstat` itself could not answer about
|
|
1089
|
+
*
|
|
1090
|
+
* An UNUSABLE candidate is a WARN carrying its own condition, never folded
|
|
1091
|
+
* into "closed" and never dropped from the total. A directory under `tasks/`
|
|
1092
|
+
* with no `meta.json` is the common real shape of it, a task half created or
|
|
1093
|
+
* half removed, and the honest report is that the check could not establish
|
|
1094
|
+
* what it is.
|
|
1095
|
+
*
|
|
1096
|
+
* NO PATH HERE IS OPENED. `classifyEntry` (src/task.ts) lstats, stats and
|
|
1097
|
+
* answers a kind; a named pipe at `tasks/<id>/meta.json` is therefore a named
|
|
1098
|
+
* WARN in bounded time rather than a doctor that hangs with no output, which
|
|
1099
|
+
* is the defect CR-520 recorded at the lease path and which this check would
|
|
1100
|
+
* otherwise reintroduce one directory along.
|
|
1101
|
+
*/
|
|
1102
|
+
export function checkTasks(root) {
|
|
1103
|
+
const tasksDir = join(root, "tasks");
|
|
1104
|
+
let entries;
|
|
1105
|
+
try {
|
|
1106
|
+
entries = readdirSync(tasksDir, { withFileTypes: true });
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
/* CHECK layout owns a missing tasks/ and FAILs on it (FLEET_DIRS in
|
|
1110
|
+
src/fleet.ts), so this arm names the condition and leaves the verdict
|
|
1111
|
+
to the check that owns it. */
|
|
1112
|
+
return {
|
|
1113
|
+
name: "tasks",
|
|
1114
|
+
status: "WARN",
|
|
1115
|
+
detail: `tasks/ under ${root} could not be listed (${String(error)}), so no task is established`,
|
|
1116
|
+
condition: "tasks-not-established",
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
const open = [];
|
|
1120
|
+
const unusable = [];
|
|
1121
|
+
let total = 0;
|
|
1122
|
+
for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
1123
|
+
if (entry.isFile()) {
|
|
1124
|
+
/* `tasks/.gitkeep` and anything else a plain file: a task is a
|
|
1125
|
+
DIRECTORY, so this is not a candidate rather than a broken one. */
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
total += 1;
|
|
1129
|
+
if (!entry.isDirectory()) {
|
|
1130
|
+
unusable.push(`${entry.name} (not a directory)`);
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
const metaFile = join(tasksDir, entry.name, "meta.json");
|
|
1134
|
+
const meta = classifyEntry(metaFile);
|
|
1135
|
+
if (meta.kind !== "regular") {
|
|
1136
|
+
unusable.push(`${entry.name} (meta.json ${unestablishedBecause(metaFile, meta)})`);
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
const turnEndFile = join(tasksDir, entry.name, "turn-end");
|
|
1140
|
+
const turnEnd = classifyEntry(turnEndFile);
|
|
1141
|
+
if (turnEnd.kind === "absent") {
|
|
1142
|
+
open.push(entry.name);
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
if (turnEnd.kind !== "regular") {
|
|
1146
|
+
unusable.push(`${entry.name} (turn-end ${unestablishedBecause(turnEndFile, turnEnd)})`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
const counted = `${String(open.length)} open of ${String(total)}` +
|
|
1150
|
+
(open.length === 0 ? "" : ` (${open.join(", ")})`);
|
|
1151
|
+
if (unusable.length > 0) {
|
|
1152
|
+
return {
|
|
1153
|
+
name: "tasks",
|
|
1154
|
+
status: "WARN",
|
|
1155
|
+
detail: `${counted}; ${String(unusable.length)} not established: ${unusable.join(", ")}`,
|
|
1156
|
+
condition: "tasks-not-established",
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
if (open.length > 0) {
|
|
1160
|
+
return { name: "tasks", status: "WARN", detail: counted, condition: "tasks-open" };
|
|
1161
|
+
}
|
|
1162
|
+
return { name: "tasks", status: "PASS", detail: counted };
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Why a path under `tasks/` could not be established, WITHOUT the absolute
|
|
1166
|
+
* path in it.
|
|
1167
|
+
*
|
|
1168
|
+
* `classifyEntry` prefixes its reason with the path it examined, which is
|
|
1169
|
+
* right for a diagnostic naming one file and wrong inside a line that has
|
|
1170
|
+
* already named the task. Stripping it also makes the line reproducible: a
|
|
1171
|
+
* detail carrying a temporary directory differs on every run, so no capture
|
|
1172
|
+
* could record it and no test could compare against one.
|
|
1173
|
+
*/
|
|
1174
|
+
function unestablishedBecause(path, entry) {
|
|
1175
|
+
if (entry.kind === "absent") {
|
|
1176
|
+
return "is absent";
|
|
1177
|
+
}
|
|
1178
|
+
if (entry.kind === "dangling" || entry.kind === "regular") {
|
|
1179
|
+
return entry.kind === "dangling"
|
|
1180
|
+
? "is a link that resolves to nothing"
|
|
1181
|
+
: "is a regular file";
|
|
1182
|
+
}
|
|
1183
|
+
const reason = entry.reason;
|
|
1184
|
+
return reason.startsWith(`${path} `) ? reason.slice(path.length + 1) : reason;
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* CHECK branches (M4-P17 criterion 5): which branches are PUSHED and NOT YET
|
|
1188
|
+
* MERGED. AGENTS.md's resume clause names this as one of the three things
|
|
1189
|
+
* doctor reports after a reclaim, and it was the one with no implementation.
|
|
1190
|
+
*
|
|
1191
|
+
* PUSHED means a remote-tracking ref exists for it, which is the only
|
|
1192
|
+
* evidence available locally that the branch is somewhere other than this
|
|
1193
|
+
* disk. UNMERGED means the trunk does not already contain it.
|
|
1194
|
+
*
|
|
1195
|
+
* IT REPORTS EVERY PUSHED REF RATHER THAN FILTERING TO A NAMING PATTERN, and
|
|
1196
|
+
* that is a decision rather than an omission. At least two branch spellings
|
|
1197
|
+
* are in use across the repositories this kernel runs over: the pool names
|
|
1198
|
+
* task branches `task/<id>` (src/pool.ts:54), and a delivery process running
|
|
1199
|
+
* on this kernel names phase branches with its own harness prefix followed by
|
|
1200
|
+
* a milestone and phase segment. A filter written for either is blind to the
|
|
1201
|
+
* other, and a check that is blind to a branch is worse than one that names a
|
|
1202
|
+
* branch the reader already knew about. A superset prints rows a reader can
|
|
1203
|
+
* skip; a filter that misses a branch prints nothing at all, and nothing is
|
|
1204
|
+
* what a healthy fleet prints too.
|
|
1205
|
+
*
|
|
1206
|
+
* NO BRANCH PREFIX IS SPELLED OUT HERE, and that is not a style choice.
|
|
1207
|
+
* test/schemas.test.ts:800 asserts by name which shipped files carry the
|
|
1208
|
+
* harness-derived branch prefix and exists to stop that set GROWING; writing
|
|
1209
|
+
* the literal prefix into this comment added src/commands/doctor.ts to it and
|
|
1210
|
+
* reddened that test. The spelling belongs in the delivery process that uses
|
|
1211
|
+
* it, not in the kernel that reports over any of them.
|
|
1212
|
+
*
|
|
1213
|
+
* WARN, AND NO PROFILE PROMOTES IT TO FAIL. The reason is measured and is not
|
|
1214
|
+
* a preference. Deleting a remote ref is REFUSED in the container this kernel
|
|
1215
|
+
* is built in, and `git push --dry-run --delete` exits 0 whether deletion is
|
|
1216
|
+
* allowed or not, so the dry run cannot tell an operator whether the remedy is
|
|
1217
|
+
* even available (CLAUDE.md standing warning 14). A promotable branch check
|
|
1218
|
+
* would therefore make `tiphys doctor --for full` unpassable on the kernel's
|
|
1219
|
+
* own fleet, with no action its operator could take, and an unpassable check
|
|
1220
|
+
* is a check that gets switched off. The count is printed; the exit code does
|
|
1221
|
+
* not move. A test walks every profile in PROFILES and asserts that, so a
|
|
1222
|
+
* later profile cannot promote it by accident.
|
|
1223
|
+
*
|
|
1224
|
+
* AND IT NEVER PRINTS PASS FOR A QUESTION IT COULD NOT ASK. If git refuses to
|
|
1225
|
+
* list the refs, or the trunk cannot be resolved, the check says so under its
|
|
1226
|
+
* own condition instead of reporting an empty list as a clean bill of health.
|
|
1227
|
+
* An empty result from a query that failed is indistinguishable from an empty
|
|
1228
|
+
* result from a query that succeeded, which is the third way this repository
|
|
1229
|
+
* has shipped a guard that could not go red.
|
|
1230
|
+
*/
|
|
1231
|
+
export function checkBranches(root) {
|
|
1232
|
+
if (!existsSync(join(root, ".git"))) {
|
|
1233
|
+
return {
|
|
1234
|
+
name: "branches",
|
|
1235
|
+
status: "WARN",
|
|
1236
|
+
detail: "fleet home is not a git repository, so no branch can be reported",
|
|
1237
|
+
condition: "branches-not-established",
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
/* THE FORMAT ASKS FOR THE FULL REFNAME, AND THAT IS THE WHOLE POINT OF THIS
|
|
1241
|
+
LINE. `%(refname:short)` renders refs/remotes/origin/HEAD as `origin`, not
|
|
1242
|
+
as `origin/HEAD`, because git shortens a remote's HEAD to the remote's own
|
|
1243
|
+
name. A filter written as `endsWith("/HEAD")` over the SHORT name is
|
|
1244
|
+
therefore dead on exactly the ref it exists to drop, which is the shape
|
|
1245
|
+
this repository keeps paying for: a guard whose condition does not test
|
|
1246
|
+
the property that matters. The short name is recovered below by stripping
|
|
1247
|
+
the prefix, which is what `:short` does for every ref that is not a HEAD.
|
|
1248
|
+
|
|
1249
|
+
`%(symref)` is the second half and is not redundant. Since git 2.48.0,
|
|
1250
|
+
`git fetch` creates refs/remotes/<name>/HEAD when the remote advertises one
|
|
1251
|
+
and the local side has none: `remote.<name>.followRemoteHEAD` documents
|
|
1252
|
+
`create` as its default. It creates it as a SYMBOLIC ref, so dropping
|
|
1253
|
+
symbolic refs is the direct statement of "an alias is not a branch". A HEAD
|
|
1254
|
+
written as an ordinary ref carries no symref target and is caught by the
|
|
1255
|
+
name test instead; both members occur and each half catches one of them. */
|
|
1256
|
+
const listed = runGitHere(root, [
|
|
1257
|
+
"for-each-ref",
|
|
1258
|
+
"--format=%(refname)%09%(symref)",
|
|
1259
|
+
"refs/remotes",
|
|
1260
|
+
]);
|
|
1261
|
+
if (listed.status !== 0) {
|
|
1262
|
+
return {
|
|
1263
|
+
name: "branches",
|
|
1264
|
+
status: "WARN",
|
|
1265
|
+
detail: "git could not list the remote-tracking refs, so no branch is established: " +
|
|
1266
|
+
firstStderrLine(listed.stderr),
|
|
1267
|
+
condition: "branches-not-established",
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
const REMOTES_PREFIX = "refs/remotes/";
|
|
1271
|
+
const refs = [];
|
|
1272
|
+
for (const row of listed.stdout.split("\n")) {
|
|
1273
|
+
const [refname = "", symref = ""] = row.split("\t");
|
|
1274
|
+
if (!refname.startsWith(REMOTES_PREFIX)) {
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
/* TWO TESTS, TWO STATEMENTS. They are not one condition with an `||`
|
|
1278
|
+
because they are two different properties with two different witnesses,
|
|
1279
|
+
and a witness member that defangs one of them must be distinguishable
|
|
1280
|
+
from one that defangs the other. */
|
|
1281
|
+
if (symref !== "") {
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
if (refname.endsWith("/HEAD")) {
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
refs.push(refname.slice(REMOTES_PREFIX.length));
|
|
1288
|
+
}
|
|
1289
|
+
if (refs.length === 0) {
|
|
1290
|
+
return { name: "branches", status: "PASS", detail: "no pushed branches" };
|
|
1291
|
+
}
|
|
1292
|
+
/* The trunk, in the order the evidence is strongest: what the remote
|
|
1293
|
+
itself advertises as its default, then what this branch tracks, and
|
|
1294
|
+
only then this checkout's own HEAD. */
|
|
1295
|
+
const trunk = gitValue(root, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]) ??
|
|
1296
|
+
gitValue(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]) ??
|
|
1297
|
+
"HEAD";
|
|
1298
|
+
if (gitValue(root, ["rev-parse", "--verify", "--quiet", `${trunk}^{commit}`]) === undefined) {
|
|
1299
|
+
return {
|
|
1300
|
+
name: "branches",
|
|
1301
|
+
status: "WARN",
|
|
1302
|
+
detail: `${trunk} does not resolve to a commit, so no branch can be compared against it`,
|
|
1303
|
+
condition: "branches-not-established",
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
const unmerged = [];
|
|
1307
|
+
for (const ref of refs) {
|
|
1308
|
+
if (ref === trunk) {
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
const ancestor = runGitHere(root, ["merge-base", "--is-ancestor", ref, trunk]);
|
|
1312
|
+
if (ancestor.status === 0) {
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
if (ancestor.status !== 1) {
|
|
1316
|
+
return {
|
|
1317
|
+
name: "branches",
|
|
1318
|
+
status: "WARN",
|
|
1319
|
+
detail: `git merge-base --is-ancestor ${ref} ${trunk} exited ` +
|
|
1320
|
+
`${String(ancestor.status)}, so whether ${ref} is merged is not established: ` +
|
|
1321
|
+
firstStderrLine(ancestor.stderr),
|
|
1322
|
+
condition: "branches-not-established",
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
unmerged.push(ref);
|
|
1326
|
+
}
|
|
1327
|
+
if (unmerged.length === 0) {
|
|
1328
|
+
return {
|
|
1329
|
+
name: "branches",
|
|
1330
|
+
status: "PASS",
|
|
1331
|
+
detail: `${String(refs.length)} pushed branch(es), none unmerged into ${trunk}`,
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
return {
|
|
1335
|
+
name: "branches",
|
|
1336
|
+
status: "WARN",
|
|
1337
|
+
detail: `${String(unmerged.length)} of ${String(refs.length)} pushed branch(es) unmerged ` +
|
|
1338
|
+
`into ${trunk}: ${unmerged.join(", ")}`,
|
|
1339
|
+
condition: "branches-unmerged",
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* CHECK worktrees (M4-P19): every entry in the worktree pool, and whether a
|
|
1344
|
+
* pool record still exists beside it.
|
|
1345
|
+
*
|
|
1346
|
+
* THE STATE THIS REPORTS IS THE POST-RECLAIM ONE. `worktrees/` is
|
|
1347
|
+
* gitignored (src/fleet.ts:28) and `tasks/` is tracked, so a reclaim takes
|
|
1348
|
+
* every worktrees/<id>.pool.json with it and leaves the task records
|
|
1349
|
+
* standing. Before this check, nothing in the kernel said so: `pool list`
|
|
1350
|
+
* enumerated records, and a task whose record was gone was invisible to
|
|
1351
|
+
* every reporting path.
|
|
1352
|
+
*
|
|
1353
|
+
* WARN AND NEVER FAIL, AND NOT PROMOTED BY ANY PROFILE. The condition is
|
|
1354
|
+
* named (`worktree-record-missing`) so a later profile CAN promote it, and
|
|
1355
|
+
* none does, deliberately: a fleet that has just been rehydrated from its
|
|
1356
|
+
* remote is EXPECTED to be in exactly this state, so promoting it would
|
|
1357
|
+
* make `doctor --for full` unpassable on the one fleet the remedy exists
|
|
1358
|
+
* for, and an unpassable check is a check that gets switched off (hazard
|
|
1359
|
+
* H-D). The number is printed; the exit code does not move.
|
|
1360
|
+
*
|
|
1361
|
+
* It reads tasks/<id>/meta.json and git, never a log tail (C-1), and
|
|
1362
|
+
* probes no process (C-2).
|
|
1363
|
+
*/
|
|
1364
|
+
export function checkWorktrees(root) {
|
|
1365
|
+
let fleet;
|
|
1366
|
+
try {
|
|
1367
|
+
fleet = loadFleet(root);
|
|
1368
|
+
}
|
|
1369
|
+
catch {
|
|
1370
|
+
// Not a fleet home. CHECK layout is what reports that, and an
|
|
1371
|
+
// advisory must not be the thing that says so.
|
|
1372
|
+
return {
|
|
1373
|
+
name: "worktrees",
|
|
1374
|
+
status: "WARN",
|
|
1375
|
+
detail: `${root} is not a fleet home, so there is no worktree pool to report`,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
/* THE POOL LISTING IS ASKED FOR, NEVER ASSUMED TO ANSWER (DR-0047 sweep,
|
|
1379
|
+
round 2). `poolList` THROWS on a `tasks/` it cannot list, which is correct
|
|
1380
|
+
and is what round 1 made it do: a pool whose task directory is unreadable
|
|
1381
|
+
is not an empty pool, and reporting "no pool worktrees" over it was the
|
|
1382
|
+
false PASS the review found. What round 1 could not do from its own files
|
|
1383
|
+
is the other half. Uncaught here, the throw escapes `doctor`'s whole run,
|
|
1384
|
+
so a DIAGNOSTIC command ends with one stack-shaped line and reports none of
|
|
1385
|
+
the checks that had nothing wrong with them.
|
|
1386
|
+
|
|
1387
|
+
src/commands/next.ts:334 already catches the same throw and reports it into
|
|
1388
|
+
`unknown`, and the asymmetry was the finding: one consumer of one function
|
|
1389
|
+
degrades and the other aborts. A check that cannot look must say so and let
|
|
1390
|
+
the rest of the run report, which is the same could-not-determine rule
|
|
1391
|
+
`checkSharedLock` states one screen down, and it is FAIL rather than WARN
|
|
1392
|
+
because an unlistable pool is a broken fleet home and not a fleet that
|
|
1393
|
+
never opted in. */
|
|
1394
|
+
let entries;
|
|
1395
|
+
try {
|
|
1396
|
+
entries = poolList(fleet);
|
|
1397
|
+
}
|
|
1398
|
+
catch (error) {
|
|
1399
|
+
return {
|
|
1400
|
+
name: "worktrees",
|
|
1401
|
+
status: "FAIL",
|
|
1402
|
+
detail: `the worktree pool could not be listed, so whether any pool entry lacks a record is ` +
|
|
1403
|
+
`unknown: ${singleLine(String(error))}`,
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
if (entries.length === 0) {
|
|
1407
|
+
return {
|
|
1408
|
+
name: "worktrees",
|
|
1409
|
+
status: "PASS",
|
|
1410
|
+
detail: "no pool worktrees",
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
const withoutRecord = entries.filter((entry) => entry.origin !== "record");
|
|
1414
|
+
if (withoutRecord.length === 0) {
|
|
1415
|
+
return {
|
|
1416
|
+
name: "worktrees",
|
|
1417
|
+
status: "PASS",
|
|
1418
|
+
detail: `${String(entries.length)} pool entr(ies), each with a pool record beside it`,
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
// BY ID, because "1 of 3" tells an operator nothing they can act on.
|
|
1422
|
+
const named = withoutRecord
|
|
1423
|
+
.map((entry) => entry.origin === "reconstructed"
|
|
1424
|
+
? `${entry.taskId} (reconstructed)`
|
|
1425
|
+
: `${entry.taskId} (unreconstructable: ${(entry.unresolved ?? []).join(", ")})`)
|
|
1426
|
+
.join(", ");
|
|
1427
|
+
return {
|
|
1428
|
+
name: "worktrees",
|
|
1429
|
+
status: "WARN",
|
|
1430
|
+
detail: `${String(withoutRecord.length)} of ${String(entries.length)} pool ` +
|
|
1431
|
+
`entr(ies) have no pool record beside them: ${named}`,
|
|
1432
|
+
condition: "worktree-record-missing",
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* CHECK shared-lock (M4-P22 criterion 1): who holds this fleet ACROSS
|
|
1437
|
+
* environments, in exactly one of four statuses.
|
|
1438
|
+
*
|
|
1439
|
+
* CHECK lock above reports the lease on THIS filesystem, which is the only
|
|
1440
|
+
* thing it can report: src/lock.ts:63 states that domain honestly, and
|
|
1441
|
+
* M4-P20 measured two clones of one fleet remote both holding their own
|
|
1442
|
+
* lease at once. This check reports the second layer M4-P21 built, and the
|
|
1443
|
+
* two are separate lines on purpose, because they answer different questions
|
|
1444
|
+
* and an operator reading one of them is entitled to know the other was not
|
|
1445
|
+
* merged into it.
|
|
1446
|
+
*
|
|
1447
|
+
* THE VERDICT IS NOT MADE HERE. `sharedLockStatus` (src/exclusion.ts) owns
|
|
1448
|
+
* it, exactly as `expiryHasPassed` owns CHECK lock's comparison and
|
|
1449
|
+
* `judgeBeacon` owns CHECK beacon's. This check only decides how to present
|
|
1450
|
+
* a status, so doctor and the exclusion layer cannot return two verdicts
|
|
1451
|
+
* about one register.
|
|
1452
|
+
*
|
|
1453
|
+
* THE FOURTH STATUS IS NEVER PASS, which is this check's whole reason for
|
|
1454
|
+
* having four. An unreachable register absorbed into a green line is the
|
|
1455
|
+
* H-C shape: the bundle says fine and the one question that mattered was
|
|
1456
|
+
* never asked. It is a WARN under its own condition so an operator sees it
|
|
1457
|
+
* without doctor exiting nonzero on a fleet that never opted in, and `full`
|
|
1458
|
+
* promotes it (see the PROFILES table).
|
|
1459
|
+
*
|
|
1460
|
+
* IT SPAWNS NOTHING FOR A FLEET THAT HAS NOT OPTED IN. `sharedLockStatus`
|
|
1461
|
+
* reads the fleet home's own package.json first and returns `not-declared`
|
|
1462
|
+
* before any git call, so the cost of this check on every existing fleet is
|
|
1463
|
+
* one file read.
|
|
1464
|
+
*/
|
|
1465
|
+
export function checkSharedLock(root) {
|
|
1466
|
+
const status = sharedLockStatus(root);
|
|
1467
|
+
if (status.token === "unreachable") {
|
|
1468
|
+
return {
|
|
1469
|
+
name: "shared-lock",
|
|
1470
|
+
status: "WARN",
|
|
1471
|
+
detail: status.text,
|
|
1472
|
+
condition: "shared-lock-unreachable",
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
return { name: "shared-lock", status: "PASS", detail: status.text };
|
|
1476
|
+
}
|
|
541
1477
|
export function runChecks(root) {
|
|
542
1478
|
return [
|
|
543
1479
|
checkNode(),
|
|
@@ -546,9 +1482,14 @@ export function runChecks(root) {
|
|
|
546
1482
|
checkLayout(root),
|
|
547
1483
|
checkRemote(root),
|
|
548
1484
|
checkLock(root),
|
|
1485
|
+
checkSharedLock(root),
|
|
549
1486
|
checkBeacon(root),
|
|
550
1487
|
checkIdentity(root),
|
|
551
1488
|
checkRetention(root),
|
|
1489
|
+
checkTasks(root),
|
|
1490
|
+
checkBranches(root),
|
|
1491
|
+
checkWorktrees(root),
|
|
1492
|
+
checkKernelArtifacts(),
|
|
552
1493
|
];
|
|
553
1494
|
}
|
|
554
1495
|
export function cmdDoctor(args) {
|