@bli-cockpit/cli 0.2.150 → 0.2.155
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/dist/adapters/local-sources.js +1 -0
- package/dist/adapters/raw-evidence-collection-state.js +1 -0
- package/dist/adapters/raw-evidence-facts.js +1 -0
- package/dist/adapters/raw-evidence-manifest.js +5 -0
- package/dist/adapters/raw-evidence-sanitize.js +82 -20
- package/dist/adapters/raw-evidence-snapshot-supersede.js +82 -0
- package/dist/adapters/raw-evidence-transcript-file.js +10 -0
- package/dist/agent-rules.js +3 -3
- package/dist/commands/backfill-lock-wait.js +49 -0
- package/dist/commands/backfill-outside-roots.js +97 -0
- package/dist/commands/backfill-reasons.js +17 -0
- package/dist/commands/backfill-scan.js +8 -3
- package/dist/commands/backfill-scope.js +14 -1
- package/dist/commands/backfill-session-report.js +3 -0
- package/dist/commands/backfill-upload.js +6 -0
- package/dist/commands/backfill.js +11 -5
- package/dist/commands/clean.js +31 -13
- package/dist/commands/doctor-budget.js +69 -0
- package/dist/commands/doctor-disk-words.js +81 -3
- package/dist/commands/doctor-lock-wait.js +12 -3
- package/dist/commands/doctor-pipeline-verdicts.js +2 -0
- package/dist/commands/doctor-pipeline.js +49 -24
- package/dist/commands/doctor.js +26 -4
- package/dist/commands/heartbeat.js +1 -1
- package/dist/commands/local-arg-values.js +9 -0
- package/dist/commands/local-args-collector-setup.js +4 -2
- package/dist/commands/local-args-collector-work.js +9 -1
- package/dist/commands/local-help-commands.js +11 -8
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/status-disk-line.js +22 -0
- package/dist/commands/status.js +5 -2
- package/dist/commands/sync-followups-staging.js +13 -10
- package/dist/commands/sync.js +7 -4
- package/dist/cursors/raw-evidence-cursor.js +14 -0
- package/dist/disk-prune.js +19 -23
- package/dist/disk-retention.js +12 -9
- package/dist/disk-usage-footprint.js +10 -4
- package/dist/evidence-reconcile-client.js +2 -2
- package/dist/log-rotation.js +31 -10
- package/dist/prune-receipt.js +87 -0
- package/dist/raw-evidence-gc.js +55 -15
- package/dist/state-dir-audit.js +138 -0
- package/dist/upload-envelope-build.js +9 -0
- package/dist/upload-sync.js +10 -22
- package/package.json +5 -5
package/dist/commands/clean.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `cockpit clean` — what this machine is holding, why, and what goes.
|
|
3
3
|
*
|
|
4
|
-
* BLI-3619. The tick prunes on its own
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* BLI-3619 / BLI-11106. The sync tick prunes on its own, every tick, and a
|
|
5
|
+
* staged copy goes the tick its commit is acknowledged; nobody should need
|
|
6
|
+
* this door. It is here for "show me" and for a machine that has been off.
|
|
7
|
+
* It prints the same buckets the retention rule reasons in, one line each with
|
|
8
|
+
* its reason, and only then deletes:
|
|
7
9
|
*
|
|
8
|
-
* committed
|
|
9
|
-
* committed,
|
|
10
|
+
* committed on the server goes
|
|
11
|
+
* committed, inside an opted-in window kept — unless the cap needs it
|
|
12
|
+
* (only when COCKPIT_STAGING_RETENTION_HOURS is set)
|
|
10
13
|
* uncommitted kept, always, and named
|
|
11
14
|
* the ledger cannot say kept, always, and named apart
|
|
12
15
|
*
|
|
13
|
-
* `--dry-run` stops after the printing. `--all-committed` drops
|
|
16
|
+
* `--dry-run` stops after the printing. `--all-committed` drops any opted-in
|
|
14
17
|
* window — every committed spare goes now — and is also the only thing that
|
|
15
18
|
* removes a `manual-study-evidence-vault-*` directory, and then only when every
|
|
16
19
|
* object inside it is committed. Nothing here can delete evidence the upload
|
|
@@ -31,12 +34,13 @@ import crypto from "node:crypto";
|
|
|
31
34
|
import fs from "node:fs/promises";
|
|
32
35
|
import path from "node:path";
|
|
33
36
|
import { writeLine } from "./cli-io.js";
|
|
37
|
+
import { footprintLine } from "./doctor-disk-words.js";
|
|
34
38
|
import { planFromDisk, runStagingPrune } from "../disk-prune.js";
|
|
35
39
|
import { MANUAL_VAULT_PREFIX, readDiskFootprint, } from "../disk-usage.js";
|
|
36
40
|
import { readRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
|
|
37
41
|
import { runEvidenceReconcile, } from "../evidence-reconcile-client.js";
|
|
38
42
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
39
|
-
import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
|
|
43
|
+
import { DEFAULT_LOG_ROTATION_KEEP, orphanedLogArchives, rotateCollectorLogsBestEffort, } from "../log-rotation.js";
|
|
40
44
|
export async function runClean(command, io) {
|
|
41
45
|
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
42
46
|
const now = new Date();
|
|
@@ -53,8 +57,9 @@ export async function runClean(command, io) {
|
|
|
53
57
|
const vaults = command.allCommitted
|
|
54
58
|
? await vaultsSafeToRemove(paths, footprint.vaults)
|
|
55
59
|
: [];
|
|
60
|
+
const logArchives = await orphanedLogArchives(paths);
|
|
56
61
|
if (!command.json) {
|
|
57
|
-
for (const line of cleanLines(footprint, plan, vaults, command)) {
|
|
62
|
+
for (const line of cleanLines(footprint, plan, vaults, command, logArchives)) {
|
|
58
63
|
writeLine(io.stdout, line);
|
|
59
64
|
}
|
|
60
65
|
}
|
|
@@ -67,7 +72,6 @@ export async function runClean(command, io) {
|
|
|
67
72
|
const pruned = await runStagingPrune(paths, {
|
|
68
73
|
env,
|
|
69
74
|
now,
|
|
70
|
-
force: true,
|
|
71
75
|
allCommitted: command.allCommitted,
|
|
72
76
|
reclaimTerminal: true,
|
|
73
77
|
});
|
|
@@ -86,20 +90,32 @@ export async function runClean(command, io) {
|
|
|
86
90
|
return pruned.status === "fail" ? 1 : 0;
|
|
87
91
|
}
|
|
88
92
|
/** The bucket table a person reads before anything is deleted. */
|
|
89
|
-
export function cleanLines(footprint, plan, vaults, command) {
|
|
93
|
+
export function cleanLines(footprint, plan, vaults, command, logArchives = []) {
|
|
90
94
|
const staging = footprint.staging;
|
|
91
95
|
const lines = [
|
|
92
96
|
command.dryRun ? "Tower clean (dry run)" : "Tower clean",
|
|
93
97
|
` staging ${mb(staging.total_bytes)} MB across ${staging.pack_count} pack(s), ${staging.object_count} file(s)`,
|
|
94
98
|
` going ${mb(plan.deleted_bytes + plan.empty_pack_manifest_bytes)} MB, ${plan.delete.length} file(s) — ${command.allCommitted
|
|
95
99
|
? "every committed copy (--all-committed)"
|
|
96
|
-
:
|
|
97
|
-
|
|
100
|
+
: plan.retention_ms > 0
|
|
101
|
+
? `committed and past the opted-in ${Math.round(plan.retention_ms / 3_600_000)} h window`
|
|
102
|
+
: "committed on the server; the source file is still on disk for any retry"}`,
|
|
103
|
+
` kept in window ${mb(plan.kept_in_window.bytes)} MB, ${plan.kept_in_window.count} file(s) — committed, inside an opted-in retry window (none by default)`,
|
|
98
104
|
` kept uncommitted ${mb(plan.kept_uncommitted.bytes)} MB, ${plan.kept_uncommitted.count} file(s) — never delivered; retention will never delete these`,
|
|
99
105
|
` kept unknown ${mb(plan.kept_unknown.bytes)} MB, ${plan.kept_unknown.count} file(s) — the upload ledger no longer reaches back this far and cannot say`,
|
|
100
|
-
` rotated logs ${mb(footprint.logs.total_bytes)} MB (${footprint.logs.archive_count} archive(s))
|
|
106
|
+
` rotated logs ${mb(footprint.logs.total_bytes)} MB (${footprint.logs.archive_count} archive(s))${logArchives.length > 0
|
|
107
|
+
? `; ${logArchives.length} archive(s), ${mb(logArchives.reduce((sum, archive) => sum + archive.byte_size, 0))} MB, sit outside the ${DEFAULT_LOG_ROTATION_KEEP}-archive keep window and go`
|
|
108
|
+
: ""}`,
|
|
101
109
|
` spool ${mb(footprint.spool_bytes)} MB`,
|
|
110
|
+
` state files ${mb(footprint.state.state_bytes)} MB — cursors, indexes and receipts, each bounded`,
|
|
102
111
|
];
|
|
112
|
+
if (footprint.state.other_bytes > 0) {
|
|
113
|
+
lines.push(` unrecognised ${mb(footprint.state.other_bytes)} MB across ${footprint.state.other_entries} entr(ies) — counted, never deleted by clean`);
|
|
114
|
+
}
|
|
115
|
+
if (footprint.state.study_entries > 0) {
|
|
116
|
+
lines.push(` studies ${footprint.state.study_entries} folder(s) an agent left here — not the collector's, not counted, never touched; new ones go in bli-cockpit-studies`);
|
|
117
|
+
}
|
|
118
|
+
lines.push(` ${footprintLine(footprint)}`);
|
|
103
119
|
for (const vault of footprint.vaults) {
|
|
104
120
|
const removable = vaults.some((entry) => entry.name === vault.name);
|
|
105
121
|
lines.push(` ${vault.name} ${mb(vault.byte_size)} MB, ${vault.file_count} file(s) — ${removable
|
|
@@ -158,6 +174,8 @@ function cleanJson(footprint, plan, vaults, pruned, reconciled = null) {
|
|
|
158
174
|
},
|
|
159
175
|
rotated_log_bytes: footprint.logs.total_bytes,
|
|
160
176
|
spool_bytes: footprint.spool_bytes,
|
|
177
|
+
state: footprint.state,
|
|
178
|
+
last_prune: footprint.last_prune,
|
|
161
179
|
vaults: footprint.vaults,
|
|
162
180
|
plan: {
|
|
163
181
|
delete_count: plan.delete.length,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-11101: `cockpit doctor --json` answers within one bounded budget.
|
|
3
|
+
*
|
|
4
|
+
* A person at a terminal gets the long defaults (a 900-second catch-up and a
|
|
5
|
+
* 600-second wait for the collection lock) with progress every 30 seconds. An
|
|
6
|
+
* agent or script calling `--json` gets one answer: on Edward's Mac the long
|
|
7
|
+
* defaults ran past 9.5 minutes behind the 15-minute scheduled sync, and a
|
|
8
|
+
* caller that times out learns nothing. So `--json` without `--lock-wait` or
|
|
9
|
+
* `--backfill-budget` runs every repair under one deadline measured from the
|
|
10
|
+
* start of the run, and a repair that does not fit says so by name instead of
|
|
11
|
+
* queueing. Passing either flag opts back into the long run.
|
|
12
|
+
*/
|
|
13
|
+
export const DOCTOR_JSON_BUDGET_SECONDS = 45;
|
|
14
|
+
export const DEFAULT_LOCK_WAIT_SECONDS = 600;
|
|
15
|
+
export const DEFAULT_BACKFILL_BUDGET_SECONDS = 900;
|
|
16
|
+
/** A repair that execs a whole sync or catch-up chunk is not started with less than this left. */
|
|
17
|
+
export const SLOW_FIX_MIN_SECONDS = {
|
|
18
|
+
"backfill-complete": 10,
|
|
19
|
+
"sync-fresh": 15,
|
|
20
|
+
};
|
|
21
|
+
/** The run deadline in epoch ms, or `null` when the caller gets the long defaults. */
|
|
22
|
+
export function doctorRunDeadline(command, startedMs) {
|
|
23
|
+
if (!command.json)
|
|
24
|
+
return null;
|
|
25
|
+
if (command.lockWaitSeconds !== undefined || command.backfillBudgetSeconds !== undefined)
|
|
26
|
+
return null;
|
|
27
|
+
return startedMs + DOCTOR_JSON_BUDGET_SECONDS * 1000;
|
|
28
|
+
}
|
|
29
|
+
function remainingSeconds(context, now) {
|
|
30
|
+
if (context.deadlineMs === undefined || context.deadlineMs === null)
|
|
31
|
+
return null;
|
|
32
|
+
return Math.max(0, (context.deadlineMs - now) / 1000);
|
|
33
|
+
}
|
|
34
|
+
/** How long a repair may wait for the collection lock. */
|
|
35
|
+
export function doctorLockWaitSeconds(context, now = Date.now()) {
|
|
36
|
+
if (context.command.lockWaitSeconds !== undefined)
|
|
37
|
+
return context.command.lockWaitSeconds;
|
|
38
|
+
const remaining = remainingSeconds(context, now);
|
|
39
|
+
return remaining === null ? DEFAULT_LOCK_WAIT_SECONDS : Math.min(DEFAULT_LOCK_WAIT_SECONDS, remaining);
|
|
40
|
+
}
|
|
41
|
+
/** How long the catch-up repair may run, lock waiting included. */
|
|
42
|
+
export function doctorBackfillBudgetSeconds(context, now = Date.now()) {
|
|
43
|
+
if (context.command.backfillBudgetSeconds !== undefined)
|
|
44
|
+
return context.command.backfillBudgetSeconds;
|
|
45
|
+
const remaining = remainingSeconds(context, now);
|
|
46
|
+
return remaining === null ? DEFAULT_BACKFILL_BUDGET_SECONDS : Math.min(DEFAULT_BACKFILL_BUDGET_SECONDS, remaining);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The row a slow repair returns instead of starting when the run budget cannot
|
|
50
|
+
* hold it; `null` means go ahead. Logged either way the budget decides.
|
|
51
|
+
*/
|
|
52
|
+
export function budgetSpentRow(context, row, now = Date.now()) {
|
|
53
|
+
const floor = SLOW_FIX_MIN_SECONDS[row.id];
|
|
54
|
+
const remaining = remainingSeconds(context, now);
|
|
55
|
+
if (floor === undefined || remaining === null)
|
|
56
|
+
return null;
|
|
57
|
+
if (remaining >= floor) {
|
|
58
|
+
console.error("[doctor] repair fits the json budget", JSON.stringify({ row: row.id, remaining_seconds: Math.floor(remaining), floor_seconds: floor }));
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
console.error("[doctor] repair skipped, json budget spent", JSON.stringify({ reason: "doctor_budget_spent", row: row.id, remaining_seconds: Math.floor(remaining), floor_seconds: floor, budget_seconds: DOCTOR_JSON_BUDGET_SECONDS }));
|
|
62
|
+
return {
|
|
63
|
+
...row,
|
|
64
|
+
status: "needs_fix",
|
|
65
|
+
code: "doctor_budget_spent",
|
|
66
|
+
nextAction: "cockpit doctor",
|
|
67
|
+
message: `\`cockpit doctor --json\` answers within ${DOCTOR_JSON_BUDGET_SECONDS}s and this repair needs at least ${floor}s; ${Math.floor(remaining)}s were left (was: ${row.code}). Run \`cockpit doctor\` to let it finish.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -28,9 +28,85 @@ export function redeliveryLine(result) {
|
|
|
28
28
|
}
|
|
29
29
|
return "the next `cockpit sync` re-offers them";
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Over this, the whole collector footprint is a needs-fix row (BLI-11106).
|
|
33
|
+
* Delete-on-commit plus the 256 MiB staging cap and the log caps put a healthy
|
|
34
|
+
* machine far under it; crossing it means something is holding bytes, and the
|
|
35
|
+
* row names what.
|
|
36
|
+
*/
|
|
37
|
+
export const FOOTPRINT_NEEDS_FIX_BYTES = 512 * 1024 * 1024;
|
|
38
|
+
/**
|
|
39
|
+
* Every bucket the collector owns, biggest first. Study folders an agent left
|
|
40
|
+
* under the state dir are not here: they are not the collector's, and no
|
|
41
|
+
* prune would ever explain them (they belong in `bli-cockpit-studies`).
|
|
42
|
+
*/
|
|
43
|
+
export function footprintBuckets(footprint) {
|
|
44
|
+
const staging = footprint.staging;
|
|
45
|
+
const buckets = [
|
|
46
|
+
{ label: "staging, committed (goes on the next tick)", bytes: staging.committed_bytes },
|
|
47
|
+
{
|
|
48
|
+
label: "staging, uncommitted (evidence the server has not accepted yet)",
|
|
49
|
+
bytes: staging.uncommitted_bytes + staging.unknown_bytes,
|
|
50
|
+
},
|
|
51
|
+
{ label: "staging, refused by the server", bytes: staging.terminal_bytes ?? 0 },
|
|
52
|
+
{ label: "staging, pack manifests", bytes: stagingOverheadBytes(footprint) },
|
|
53
|
+
{ label: "sync logs", bytes: footprint.logs.total_bytes },
|
|
54
|
+
{ label: "collector state files", bytes: footprint.state.state_bytes },
|
|
55
|
+
{ label: "upload spool", bytes: footprint.spool_bytes },
|
|
56
|
+
{ label: "unrecognised files in the state dir", bytes: footprint.state.other_bytes },
|
|
57
|
+
];
|
|
58
|
+
return buckets.filter((bucket) => bucket.bytes > 0).sort((a, b) => b.bytes - a.bytes);
|
|
59
|
+
}
|
|
60
|
+
export function footprintTotalBytes(footprint) {
|
|
61
|
+
return footprintBuckets(footprint).reduce((sum, bucket) => sum + bucket.bytes, 0);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The one footprint line doctor and status both print: staging committed /
|
|
65
|
+
* uncommitted, logs, state, and when the prune last ran and what it freed.
|
|
66
|
+
*/
|
|
67
|
+
export function footprintLine(footprint) {
|
|
68
|
+
const staging = footprint.staging;
|
|
69
|
+
const parts = [
|
|
70
|
+
`footprint ${mib(footprintTotalBytes(footprint))} MB`,
|
|
71
|
+
`staging ${mib(staging.committed_bytes)} MB committed / ${mib(staging.uncommitted_bytes + staging.unknown_bytes)} MB uncommitted`,
|
|
72
|
+
`logs ${mib(footprint.logs.total_bytes)} MB`,
|
|
73
|
+
`state ${mib(footprint.state.state_bytes + footprint.spool_bytes)} MB`,
|
|
74
|
+
];
|
|
75
|
+
if (footprint.state.other_bytes > 0) {
|
|
76
|
+
parts.push(`unrecognised ${mib(footprint.state.other_bytes)} MB`);
|
|
77
|
+
}
|
|
78
|
+
parts.push(lastPrunePhrase(footprint));
|
|
79
|
+
return parts.join(", ");
|
|
80
|
+
}
|
|
81
|
+
/** `last prune: <when>, freed <n> MB`, plus the last time it freed anything. */
|
|
82
|
+
export function lastPrunePhrase(footprint) {
|
|
83
|
+
const last = footprint.last_prune;
|
|
84
|
+
if (!last)
|
|
85
|
+
return "last prune: never";
|
|
86
|
+
const base = `last prune: ${minute(last.at)}, freed ${mib(last.freed_bytes)} MB`;
|
|
87
|
+
if (last.freed_bytes > 0 || !last.last_freed_at)
|
|
88
|
+
return base;
|
|
89
|
+
return `${base} (last freed ${mib(last.last_freed_bytes)} MB at ${minute(last.last_freed_at)})`;
|
|
90
|
+
}
|
|
91
|
+
/** The biggest bucket, named, for the needs-fix sentence; null when empty. */
|
|
92
|
+
export function footprintHolder(footprint) {
|
|
93
|
+
return footprintBuckets(footprint)[0] ?? null;
|
|
94
|
+
}
|
|
95
|
+
function stagingOverheadBytes(footprint) {
|
|
96
|
+
const staging = footprint.staging;
|
|
97
|
+
const payload = staging.committed_bytes +
|
|
98
|
+
staging.uncommitted_bytes +
|
|
99
|
+
staging.unknown_bytes +
|
|
100
|
+
(staging.terminal_bytes ?? 0);
|
|
101
|
+
return Math.max(0, staging.total_bytes - payload);
|
|
102
|
+
}
|
|
103
|
+
function minute(iso) {
|
|
104
|
+
return iso.length >= 16 ? `${iso.slice(0, 16)}Z` : iso;
|
|
105
|
+
}
|
|
31
106
|
export function diskRowMessage(footprint, capBytes) {
|
|
32
107
|
const staging = footprint.staging;
|
|
33
108
|
const parts = [
|
|
109
|
+
footprintLine(footprint),
|
|
34
110
|
`staging ${mib(staging.total_bytes)} MB (${mib(staging.committed_bytes)} committed / ${mib(staging.uncommitted_bytes)} uncommitted / ${mib(staging.unknown_bytes)} unknown) against a ${mib(capBytes)} MB cap`,
|
|
35
111
|
];
|
|
36
112
|
// BLI-3797: "1,916.9 uncommitted" is a number, not a finding. The row now
|
|
@@ -51,9 +127,11 @@ export function diskRowMessage(footprint, capBytes) {
|
|
|
51
127
|
if (staging.unknown_count > 0) {
|
|
52
128
|
parts.push(`${staging.unknown_count} object(s) unknown to this laptop's own ledger — run \`cockpit clean --reconcile\` to ask the server`);
|
|
53
129
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
130
|
+
// BLI-11106: study folders an agent left under the state dir are named as a
|
|
131
|
+
// count, never sized or added to the footprint. They are not the
|
|
132
|
+
// collector's, and new ones belong in the sibling `bli-cockpit-studies`.
|
|
133
|
+
if (footprint.state.study_entries > 0) {
|
|
134
|
+
parts.push(`${footprint.state.study_entries} study folder(s) under the state dir are not counted (new studies go in bli-cockpit-studies beside it)`);
|
|
57
135
|
}
|
|
58
136
|
return parts.join("; ");
|
|
59
137
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
2
2
|
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
3
3
|
import { inspectSyncLock } from "../sync-lock.js";
|
|
4
|
-
|
|
4
|
+
import { doctorLockWaitSeconds } from "./doctor-budget.js";
|
|
5
|
+
const BUSY = new Set(["sync_already_running", "sync_lock_held", "backfill_already_running", "live_sync_paused_during_backfill"]);
|
|
5
6
|
/** Retry the operation itself: its existing exclusive lock acquisition owns the
|
|
6
7
|
* handoff. Merely observing a free lock never grants permission to collect. */
|
|
7
8
|
export async function withDoctorLockWait(context, run, timing = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
|
|
8
9
|
const started = timing.now();
|
|
9
|
-
const limit = (context
|
|
10
|
+
const limit = doctorLockWaitSeconds(context) * 1000;
|
|
10
11
|
let nextProgress = 0;
|
|
11
12
|
let row = await run();
|
|
12
13
|
while (BUSY.has(row.code)) {
|
|
@@ -27,7 +28,15 @@ export async function withDoctorLockWait(context, run, timing = { now: () => Dat
|
|
|
27
28
|
if (result.code === 0 && result.stdout.trim())
|
|
28
29
|
name = result.stdout.trim().split("\n")[0] ?? name;
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
+
// BLI-11101: the scheduled sync holding the lock is not a fault; it is
|
|
32
|
+
// doing the upload this row would prove. Say so by name.
|
|
33
|
+
const syncOwns = !backfill.held && Boolean(sync?.held);
|
|
34
|
+
const code = syncOwns ? "sync_running" : "lock_wait_timeout";
|
|
35
|
+
console.error(`[doctor] ${row.id} lock wait ended`, JSON.stringify({ reason: code, owner_pid: pid ?? null, waited_seconds: Math.round(elapsed / 1000), limit_seconds: Math.round(limit / 1000) }));
|
|
36
|
+
const message = syncOwns
|
|
37
|
+
? `The background sync (pid ${pid ?? "unknown"}, ${name}) is running and holds the collection lock; waited ${Math.round(elapsed / 1000)}s. Run \`cockpit doctor\` after it finishes, or \`cockpit doctor --lock-wait 600\` to wait for it.`
|
|
38
|
+
: `Waited ${Math.round(elapsed / 1000)}s for the collection lock; owner pid ${pid ?? "unknown"}, ${name}. Run \`cockpit doctor --lock-wait 600\`.`;
|
|
39
|
+
return { ...row, status: "needs_fix", code, nextAction: "cockpit doctor --lock-wait 600", message };
|
|
31
40
|
}
|
|
32
41
|
if (elapsed >= nextProgress) {
|
|
33
42
|
const seconds = Math.floor(elapsed / 1000);
|
|
@@ -43,6 +43,8 @@ const COLLECTION_BUSY_REASONS = new Set([
|
|
|
43
43
|
"live_sync_paused_during_backfill",
|
|
44
44
|
// `cockpit backfill` stood aside for another backfill.
|
|
45
45
|
"backfill_already_running",
|
|
46
|
+
// `cockpit backfill` waited for the collection lock and gave up (BLI-11091).
|
|
47
|
+
"sync_lock_held",
|
|
46
48
|
]);
|
|
47
49
|
export function isCollectionBusyReason(reason) {
|
|
48
50
|
return typeof reason === "string" && COLLECTION_BUSY_REASONS.has(reason);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { withDoctorLockWait } from "./doctor-lock-wait.js";
|
|
2
|
-
import
|
|
3
|
-
import path from "node:path";
|
|
2
|
+
import { doctorBackfillBudgetSeconds, doctorLockWaitSeconds } from "./doctor-budget.js";
|
|
4
3
|
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
5
4
|
import { readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
6
5
|
import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
|
|
@@ -11,7 +10,7 @@ import { runEvidenceRedelivery } from "../evidence-redelivery.js";
|
|
|
11
10
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "../local-state.js";
|
|
12
11
|
import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
|
|
13
12
|
import { runBackfillCommand } from "./backfill.js";
|
|
14
|
-
import { diskRowMessage, mib, redeliveryLine } from "./doctor-disk-words.js";
|
|
13
|
+
import { FOOTPRINT_NEEDS_FIX_BYTES, diskRowMessage, footprintHolder, footprintTotalBytes, mib, redeliveryLine, } from "./doctor-disk-words.js";
|
|
15
14
|
import { doctorRoots } from "./doctor-access.js";
|
|
16
15
|
import { backfillCompletionStepState, backfillFixVerdict, jsonField, parseDoctorBackfillJson, parseDoctorSyncJson, syncFailureMessage, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline-verdicts.js";
|
|
17
16
|
import { asRecord, fail, needsFix, ok, skipped } from "./doctor-report.js";
|
|
@@ -31,7 +30,6 @@ import { asRecord, fail, needsFix, ok, skipped } from "./doctor-report.js";
|
|
|
31
30
|
* `doctor-pipeline-verdicts.ts`, and every one of them is re-exported from
|
|
32
31
|
* here so callers keep one address.
|
|
33
32
|
*/
|
|
34
|
-
const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
35
33
|
export async function checkBackfillState(context) {
|
|
36
34
|
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
37
35
|
const roots = await doctorRoots(context);
|
|
@@ -48,7 +46,7 @@ export async function checkBackfillState(context) {
|
|
|
48
46
|
}
|
|
49
47
|
export async function fixBackfillState(context) {
|
|
50
48
|
const started = Date.now();
|
|
51
|
-
const deadline = started + (context
|
|
49
|
+
const deadline = started + doctorBackfillBudgetSeconds(context, started) * 1000;
|
|
52
50
|
let caughtUp = 0;
|
|
53
51
|
let chunks = 0;
|
|
54
52
|
let live = null;
|
|
@@ -79,7 +77,7 @@ export async function fixBackfillState(context) {
|
|
|
79
77
|
// Every chunk reacquires the collector's locks. Bound lock waiting by
|
|
80
78
|
// the overall budget too; never abandon an in-flight cursor write.
|
|
81
79
|
const chunkContext = { ...context, command: { ...context.command,
|
|
82
|
-
lockWaitSeconds: Math.min(context
|
|
80
|
+
lockWaitSeconds: Math.min(doctorLockWaitSeconds(context), (deadline - Date.now()) / 1000),
|
|
83
81
|
} };
|
|
84
82
|
const row = await withDoctorLockWait(chunkContext, async () => {
|
|
85
83
|
if (Date.now() >= deadline)
|
|
@@ -91,6 +89,8 @@ export async function fixBackfillState(context) {
|
|
|
91
89
|
homeDir: context.command.homeDir, repoRoot: context.command.repoRoot,
|
|
92
90
|
all: true, dryRun: false, yes: true, json: true,
|
|
93
91
|
deadlineMs: deadline,
|
|
92
|
+
// Doctor's own `withDoctorLockWait` owns the waiting (BLI-11091).
|
|
93
|
+
syncLockWaitMs: 0,
|
|
94
94
|
onProgress: (progress) => { live = progress; },
|
|
95
95
|
}, capture.io);
|
|
96
96
|
const parsed = parseDoctorBackfillJson(capture.stdout());
|
|
@@ -134,18 +134,14 @@ export async function checkGcState(context) {
|
|
|
134
134
|
if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
|
|
135
135
|
return skipped("gc-checked", "skipped_disabled", "cleanup is switched off");
|
|
136
136
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (info && Date.now() - info.mtimeMs < GC_MIN_INTERVAL_MS) {
|
|
141
|
-
return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
|
|
142
|
-
}
|
|
143
|
-
return needsFix("gc-checked", "due", "cleanup is due");
|
|
137
|
+
// BLI-11106: the GC runs on every sync tick and costs one manifest read per
|
|
138
|
+
// old pack, so there is no "already ran today" to report; doctor runs it too.
|
|
139
|
+
return needsFix("gc-checked", "due", "cleanup runs on every sync; running it now");
|
|
144
140
|
}
|
|
145
141
|
export async function fixGcState(context) {
|
|
146
142
|
const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(context.command.homeDir), context.io.env);
|
|
147
143
|
if (result.skipped) {
|
|
148
|
-
return skipped("gc-checked", "
|
|
144
|
+
return skipped("gc-checked", "skipped_disabled", "cleanup is switched off");
|
|
149
145
|
}
|
|
150
146
|
if (result.removed_dirs === 0) {
|
|
151
147
|
return ok("gc-checked", "nothing_eligible", rawEvidenceGcSummary(result));
|
|
@@ -153,21 +149,47 @@ export async function fixGcState(context) {
|
|
|
153
149
|
return ok("gc-checked", `removed_${result.removed_dirs}`, rawEvidenceGcSummary(result));
|
|
154
150
|
}
|
|
155
151
|
/**
|
|
156
|
-
* BLI-3619: what
|
|
152
|
+
* BLI-3619 / BLI-11106: what Tower is holding on this machine, in one line.
|
|
157
153
|
*
|
|
158
|
-
* Green means the staging tree fits under the cap
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
154
|
+
* Green means the staging tree fits under the cap AND the whole collector
|
|
155
|
+
* footprint (staging, logs, state, spool) is under 512 MB. Either one crossed
|
|
156
|
+
* is `needs_fix`, and the message names the bucket holding the bytes. The fix
|
|
157
|
+
* is the same prune the tick runs; if it is still over afterwards, the message
|
|
158
|
+
* names the one command that goes further. Every number here is metadata —
|
|
159
|
+
* byte counts and file counts, never a path.
|
|
163
160
|
*/
|
|
164
161
|
export async function checkDiskState(context) {
|
|
165
162
|
const footprint = await readDiskFootprint(getCollectorRuntimePaths(context.command.homeDir));
|
|
166
163
|
const { capBytes } = retentionOptionsFromEnv(context.io.env);
|
|
167
164
|
const message = diskRowMessage(footprint, capBytes);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
165
|
+
if (footprint.staging.total_bytes - (footprint.staging.terminal_bytes ?? 0) > capBytes) {
|
|
166
|
+
return needsFix("disk-bounded", "over_staging_cap", message);
|
|
167
|
+
}
|
|
168
|
+
const over = footprintOverSentence(footprint);
|
|
169
|
+
if (over)
|
|
170
|
+
return needsFix("disk-bounded", "footprint_over_512mb", `${over}; ${message}`);
|
|
171
|
+
return ok("disk-bounded", "within_staging_cap", message);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* "812.0 MB held, over the 512 MB line; most of it is sync logs (600.0 MB)",
|
|
175
|
+
* or null when the footprint is under the line. Logged either way, because a
|
|
176
|
+
* row that only speaks when it is red cannot say the check ran.
|
|
177
|
+
*/
|
|
178
|
+
export function footprintOverSentence(footprint) {
|
|
179
|
+
const total = footprintTotalBytes(footprint);
|
|
180
|
+
const holder = footprintHolder(footprint);
|
|
181
|
+
const over = total > FOOTPRINT_NEEDS_FIX_BYTES;
|
|
182
|
+
console.error("[cockpit-doctor] collector footprint", JSON.stringify({
|
|
183
|
+
reason: over ? "footprint_over_512mb" : "footprint_within_512mb",
|
|
184
|
+
total_bytes: total,
|
|
185
|
+
limit_bytes: FOOTPRINT_NEEDS_FIX_BYTES,
|
|
186
|
+
holder: holder?.label ?? null,
|
|
187
|
+
holder_bytes: holder?.bytes ?? 0,
|
|
188
|
+
study_entries_not_counted: footprint.state.study_entries,
|
|
189
|
+
}));
|
|
190
|
+
if (!over)
|
|
191
|
+
return null;
|
|
192
|
+
return `${mib(total)} MB held, over the ${mib(FOOTPRINT_NEEDS_FIX_BYTES)} MB line; most of it is ${holder?.label ?? "unknown"} (${mib(holder?.bytes ?? 0)} MB)`;
|
|
171
193
|
}
|
|
172
194
|
export async function fixDiskState(context) {
|
|
173
195
|
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
@@ -185,7 +207,6 @@ export async function fixDiskState(context) {
|
|
|
185
207
|
const redelivered = redeliveryLine(redelivery);
|
|
186
208
|
const pruned = await runStagingPrune(paths, {
|
|
187
209
|
env: context.io.env,
|
|
188
|
-
force: true,
|
|
189
210
|
});
|
|
190
211
|
if (pruned.status === "fail") {
|
|
191
212
|
return fail("disk-bounded", pruned.reason, "the prune could not run; nothing was deleted");
|
|
@@ -195,6 +216,10 @@ export async function fixDiskState(context) {
|
|
|
195
216
|
}
|
|
196
217
|
const footprint = await readDiskFootprint(paths);
|
|
197
218
|
const message = diskRowMessage(footprint, pruned.cap_bytes);
|
|
219
|
+
const over = pruned.cap_blocked_by_uncommitted ? null : footprintOverSentence(footprint);
|
|
220
|
+
if (over) {
|
|
221
|
+
return needsFix("disk-bounded", "footprint_over_512mb", `freed ${mib(pruned.deleted_bytes)} MB; ${over}; ${message}`);
|
|
222
|
+
}
|
|
198
223
|
if (!pruned.cap_blocked_by_uncommitted) {
|
|
199
224
|
return ok("disk-bounded", `freed_${pruned.deleted_files}`, `freed ${mib(pruned.deleted_bytes)} MB; ${message}`);
|
|
200
225
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -5,6 +5,7 @@ import { withStage } from "../crash-guard.js";
|
|
|
5
5
|
import { checkSingleInstallState, readRootsCoverWork, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
|
|
6
6
|
import { backfillCompletionStepState, backfillFixVerdict, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline.js";
|
|
7
7
|
import { checkMcpAnswersState } from "./doctor-mcp.js";
|
|
8
|
+
import { budgetSpentRow, doctorRunDeadline, DOCTOR_JSON_BUDGET_SECONDS } from "./doctor-budget.js";
|
|
8
9
|
import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
|
|
9
10
|
import { checkDoctorAgentRules, fixDoctorAgentRules, checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
|
|
10
11
|
import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, } from "./doctor-report.js";
|
|
@@ -15,12 +16,19 @@ export async function runDoctor(command, io, hooks, overrides = {}) {
|
|
|
15
16
|
return runDoctorWithDeps(command, io, deps);
|
|
16
17
|
}
|
|
17
18
|
export async function runDoctorWithDeps(command, io, deps) {
|
|
18
|
-
const
|
|
19
|
+
const runStarted = Date.now();
|
|
20
|
+
const deadlineMs = doctorRunDeadline(command, runStarted);
|
|
21
|
+
const context = { command, io, deps, deadlineMs };
|
|
22
|
+
console.error("[doctor] run started", JSON.stringify({ json: command.json, check_only: Boolean(command.dryRun || command.checkOnly), budget_seconds: deadlineMs === null ? null : DOCTOR_JSON_BUDGET_SECONDS }));
|
|
19
23
|
const rows = [];
|
|
20
24
|
const repairs = [];
|
|
21
25
|
const diagnoseOnly = command.dryRun || command.checkOnly;
|
|
22
26
|
const diagnosed = new Map();
|
|
27
|
+
// BLI-11101: every row says how long it took, so the next slow run names
|
|
28
|
+
// the row without a profiler. A killed run still leaves every finished row.
|
|
29
|
+
const checkMs = new Map();
|
|
23
30
|
for (const invariant of doctorInvariants()) {
|
|
31
|
+
const started = Date.now();
|
|
24
32
|
try {
|
|
25
33
|
diagnosed.set(invariant.id, await withStage(`doctor:check:${invariant.id}`, () => invariant.check(context)));
|
|
26
34
|
}
|
|
@@ -28,16 +36,24 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
28
36
|
console.error(`[doctor] ${invariant.id} diagnosis_failed`, JSON.stringify({ reason: "check_threw", ...describeError(error) }));
|
|
29
37
|
diagnosed.set(invariant.id, { id: invariant.id, status: "fail", code: "check_threw", message: "Run `cockpit doctor` to retry this check." });
|
|
30
38
|
}
|
|
39
|
+
checkMs.set(invariant.id, Date.now() - started);
|
|
40
|
+
console.error("[doctor] row checked", JSON.stringify({ row: invariant.id, elapsed_ms: checkMs.get(invariant.id), verdict: diagnosed.get(invariant.id)?.status, reason: diagnosed.get(invariant.id)?.code }));
|
|
31
41
|
}
|
|
32
42
|
for (const invariant of doctorInvariants()) {
|
|
33
43
|
let row;
|
|
44
|
+
const fixStarted = Date.now();
|
|
34
45
|
try {
|
|
35
46
|
row = diagnosed.get(invariant.id);
|
|
36
47
|
const needsCollection = ["backfill-complete", "sync-fresh"].includes(invariant.id);
|
|
37
48
|
const prerequisitesReady = !needsCollection || rows.filter((prior) => prior.id === "authed" || prior.id === "roots-ok").every((prior) => prior.status === "ok");
|
|
38
49
|
const canFix = !diagnoseOnly && prerequisitesReady && invariant.fix &&
|
|
39
50
|
(!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context) || Boolean(command.collectionRoots?.length));
|
|
40
|
-
|
|
51
|
+
const spent = row.status !== "ok" && row.status !== "skipped" && canFix ? budgetSpentRow(context, row) : null;
|
|
52
|
+
if (spent) {
|
|
53
|
+
row = spent;
|
|
54
|
+
repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
|
|
55
|
+
}
|
|
56
|
+
else if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
|
|
41
57
|
row = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, row));
|
|
42
58
|
repairs.push({ step: invariant.id, outcome: row.status === "ok" ? "repaired" : "needs_person", reason: row.code });
|
|
43
59
|
// The replacement process owns the one final receipt, including JSON.
|
|
@@ -57,6 +73,9 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
57
73
|
repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
|
|
58
74
|
}
|
|
59
75
|
console.error(`[doctor] ${invariant.id} ${row.status}`, JSON.stringify({ reason: row.code, repaired: row.fixed ?? false }));
|
|
76
|
+
const fixMs = Date.now() - fixStarted;
|
|
77
|
+
const rowCheckMs = checkMs.get(invariant.id) ?? 0;
|
|
78
|
+
console.error("[doctor] row done", JSON.stringify({ row: invariant.id, elapsed_ms: rowCheckMs + fixMs, check_ms: rowCheckMs, fix_ms: fixMs, verdict: row.status, reason: row.code }));
|
|
60
79
|
rows.push(row);
|
|
61
80
|
}
|
|
62
81
|
// Re-read host state after repairs. Sync is proven by its upload receipts;
|
|
@@ -68,10 +87,11 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
68
87
|
const index = rows.findIndex((row) => row.id === invariant.id);
|
|
69
88
|
if (rows[index]?.status === "fail")
|
|
70
89
|
continue;
|
|
90
|
+
const verifyStarted = Date.now();
|
|
71
91
|
try {
|
|
72
92
|
const checked = await invariant.check(context);
|
|
73
93
|
rows[index] = { ...checked, fixed: rows[index]?.fixed && checked.status === "ok" };
|
|
74
|
-
console.error(`[doctor] ${invariant.id} verified`, JSON.stringify({ reason: checked.code, status: checked.status }));
|
|
94
|
+
console.error(`[doctor] ${invariant.id} verified`, JSON.stringify({ reason: checked.code, status: checked.status, elapsed_ms: Date.now() - verifyStarted }));
|
|
75
95
|
if (checked.status === "needs_fix" || checked.status === "fail") {
|
|
76
96
|
const repair = repairs.find((entry) => entry.step === invariant.id);
|
|
77
97
|
if (repair) {
|
|
@@ -102,8 +122,10 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
102
122
|
rows.push({ id: "memory-registered", status: "needs_fix", code: "receipt_read_threw", message: "Run `cockpit doctor` to read the final setup receipt." });
|
|
103
123
|
}
|
|
104
124
|
}
|
|
125
|
+
const needsPerson = doctorNeedsPerson(rows, receipt).length;
|
|
126
|
+
console.error("[doctor] run done", JSON.stringify({ elapsed_ms: Date.now() - runStarted, rows: rows.length, needs_person: needsPerson, check_only: Boolean(diagnoseOnly) }));
|
|
105
127
|
writeDoctorOutput(command, io, rows, receipt, repairs);
|
|
106
|
-
return
|
|
128
|
+
return needsPerson === 0 ? 0 : 1;
|
|
107
129
|
}
|
|
108
130
|
function doctorInvariants() {
|
|
109
131
|
return [
|
|
@@ -133,7 +133,7 @@ export function buildCollectorHeartbeat(options) {
|
|
|
133
133
|
* that a green, syncing, up-to-date laptop was sitting on 1.8 GB of evidence the
|
|
134
134
|
* server had never received, and a fleet-wide failure nobody can see is the
|
|
135
135
|
* BLI-2528 shape exactly. The walk is bounded by the pack tree the prune keeps
|
|
136
|
-
* under a
|
|
136
|
+
* under a 256 MiB cap, and it is best-effort: a read that fails returns nulls and
|
|
137
137
|
* says so, so the heartbeat omits both fields rather than claiming zero.
|
|
138
138
|
*/
|
|
139
139
|
export async function readHeartbeatStagingFacts(options) {
|
|
@@ -105,6 +105,15 @@ export function optionalPositiveInteger(value, flag) {
|
|
|
105
105
|
}
|
|
106
106
|
return parsed;
|
|
107
107
|
}
|
|
108
|
+
export function optionalNonNegativeInteger(value, flag) {
|
|
109
|
+
if (value === undefined)
|
|
110
|
+
return undefined;
|
|
111
|
+
const parsed = Number(value);
|
|
112
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
113
|
+
throw new Error(`${flag} must be a whole number of 0 or more.`);
|
|
114
|
+
}
|
|
115
|
+
return parsed;
|
|
116
|
+
}
|
|
108
117
|
export function optionalConfidence(value, flag) {
|
|
109
118
|
if (value === undefined)
|
|
110
119
|
return undefined;
|
|
@@ -142,8 +142,10 @@ export function parseDoctorArgs(alias, args) {
|
|
|
142
142
|
return {
|
|
143
143
|
kind: "doctor",
|
|
144
144
|
checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
// Absent stays absent (BLI-11101): `doctor-budget.ts` owns the defaults,
|
|
146
|
+
// and `--json` without either flag runs under one bounded budget.
|
|
147
|
+
backfillBudgetSeconds: optionalPositiveInteger(values.flags.get("--backfill-budget"), "--backfill-budget"),
|
|
148
|
+
lockWaitSeconds: optionalPositiveInteger(values.flags.get("--lock-wait"), "--lock-wait"),
|
|
147
149
|
alias,
|
|
148
150
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
149
151
|
repoRoot: optionalNonEmpty(workRootFlagValue(values)),
|