@bli-cockpit/cli 0.2.168 → 0.2.173
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/raw-evidence-collection-state.js +52 -6
- package/dist/adapters/raw-evidence-completeness.js +6 -0
- package/dist/adapters/raw-evidence-git-diff-reader.js +2 -2
- package/dist/adapters/raw-evidence-image-reader.js +5 -2
- package/dist/adapters/raw-evidence-transcript-file.js +2 -2
- package/dist/autostart-opt-out.js +43 -0
- package/dist/autostart-self-heal.js +23 -10
- package/dist/commands/autostart-command.js +10 -0
- package/dist/commands/autostart-heal.js +6 -4
- package/dist/commands/backfill-upload.js +12 -0
- package/dist/commands/doctor-access.js +17 -21
- package/dist/commands/doctor-disk-words.js +20 -0
- package/dist/commands/doctor-mcp.js +27 -0
- package/dist/commands/doctor-pipeline.js +34 -7
- package/dist/commands/doctor-registration.js +9 -3
- package/dist/commands/doctor-report.js +28 -0
- package/dist/commands/doctor.js +20 -5
- package/dist/commands/heartbeat-token.js +14 -0
- package/dist/commands/heartbeat.js +2 -1
- package/dist/commands/local-args-tower-logs.js +53 -0
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-command-shapes-logs.js +6 -0
- package/dist/commands/local-help-commands-tower-ops.js +26 -0
- package/dist/commands/local-help.js +2 -0
- package/dist/commands/local.js +3 -0
- package/dist/commands/logs.js +68 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sync-followups-staging.js +8 -1
- package/dist/commands/sync-heal.js +144 -0
- package/dist/commands/sync.js +9 -1
- package/dist/disk-prune.js +35 -3
- package/dist/disk-retention.js +31 -1
- package/dist/sign-in-action.js +7 -0
- package/dist/staging-gate-receipt.js +131 -0
- package/dist/staging-gate.js +214 -0
- package/dist/state-dir-audit.js +5 -4
- package/dist/state-dir-prune.js +84 -0
- package/dist/sync-tick-stamp.js +22 -0
- package/package.json +4 -4
package/dist/commands/sync.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* collect behind both locks sync-receipt.ts
|
|
12
12
|
* check in with the heartbeat door sync-heartbeat.ts
|
|
13
13
|
* say how the tick went reportTickOutcome / reportTickThrew
|
|
14
|
-
* converge the rest of the machine sync-followups.ts
|
|
14
|
+
* converge the rest of the machine sync-followups.ts, sync-heal.ts
|
|
15
15
|
*
|
|
16
16
|
* The supporting modules each own one thing those steps share:
|
|
17
17
|
*
|
|
@@ -39,13 +39,18 @@ import { createInteractiveExecRunner } from "../process-runner.js";
|
|
|
39
39
|
import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
|
|
40
40
|
import { runMemoryExperienceAfterSync } from "./memory-log.js";
|
|
41
41
|
import { classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
|
|
42
|
+
import { runMachineHealAfterSync } from "./sync-heal.js";
|
|
42
43
|
import { sendSyncHeartbeat } from "./sync-heartbeat.js";
|
|
43
44
|
import { runSyncWithHealthReceipt } from "./sync-receipt.js";
|
|
44
45
|
import { runAutostartSelfHealAfterSync, runMemoryInstallAfterSync, runScheduledSelfUpdateAfterSync, runStagingPruneAfterSync, } from "./sync-followups.js";
|
|
45
46
|
import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
|
|
47
|
+
import { stampSyncTick } from "../sync-tick-stamp.js";
|
|
46
48
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
47
49
|
export async function runSync(command, io) {
|
|
48
50
|
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
51
|
+
// Ticket 12831: the memory prompt hook reads this stamp to tell a scheduler
|
|
52
|
+
// that stopped firing from one that is merely quiet.
|
|
53
|
+
await stampSyncTick(paths);
|
|
49
54
|
// BLI-3553, first thing in the tick: cap the scheduler's own logs. Nothing
|
|
50
55
|
// rotated them before, and two months of a 15-minute tick left 211 MB of
|
|
51
56
|
// sync.log on the reference Mac. Best-effort by construction — a rotation
|
|
@@ -165,5 +170,8 @@ async function reportTickThrew(command, io, dashboardUrl, error, errorCode) {
|
|
|
165
170
|
async function convergeAfterTick(command, io, dashboardUrl) {
|
|
166
171
|
await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
|
|
167
172
|
await runMemoryInstallAfterSync(command, io, dashboardUrl);
|
|
173
|
+
// BLI-12828: the repairs `cockpit doctor` also makes (agent rules, a lapsed
|
|
174
|
+
// sign-in), so nobody has to be told to run it.
|
|
175
|
+
await runMachineHealAfterSync(command, io, dashboardUrl);
|
|
168
176
|
await runMemoryExperienceAfterSync({ ...command, dashboardUrl }, io);
|
|
169
177
|
}
|
package/dist/disk-prune.js
CHANGED
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import fs from "node:fs/promises";
|
|
31
31
|
import path from "node:path";
|
|
32
|
-
import {
|
|
32
|
+
import { measureCollectorBytes, stagingGateLimitsFromEnv, } from "./staging-gate.js";
|
|
33
|
+
import { TERMINAL_RECLAIM_AGE_MS, planStagingRetention, retentionOptionsFromEnv, } from "./disk-retention.js";
|
|
33
34
|
import { RAW_EVIDENCE_DIR, readStagingInventory } from "./disk-usage.js";
|
|
34
35
|
import { describeError } from "./health-detail.js";
|
|
35
36
|
import { writeLastPruneReceipt } from "./prune-receipt.js";
|
|
@@ -62,16 +63,26 @@ export async function runStagingPrune(paths, options = {}) {
|
|
|
62
63
|
export async function planFromDisk(paths, env, options, now) {
|
|
63
64
|
const fromEnv = retentionOptionsFromEnv(env);
|
|
64
65
|
const inventory = await readStagingInventory(paths, now);
|
|
66
|
+
const capBytes = options.capBytes ?? fromEnv.capBytes;
|
|
67
|
+
const limits = stagingGateLimitsFromEnv(env);
|
|
68
|
+
const collectorBytes = await measureCollectorBytes(paths.state_dir).catch(() => 0);
|
|
69
|
+
// BLI-12832: how many terminal bytes must go for the staging gate to reopen.
|
|
70
|
+
// Reclaim down to half of each limit (staging cap, state ceiling) so a
|
|
71
|
+
// typical file fits afterwards, oldest refused copy first.
|
|
72
|
+
const reserve = 0.5;
|
|
73
|
+
const terminalPressureBytes = Math.max(inventory.total_bytes - Math.floor(capBytes * reserve), collectorBytes - Math.floor(limits.ceilingBytes * reserve), 0);
|
|
65
74
|
return planStagingRetention(inventory, {
|
|
66
75
|
retentionMs: options.retentionMs ?? fromEnv.retentionMs,
|
|
67
|
-
capBytes
|
|
76
|
+
capBytes,
|
|
68
77
|
allCommitted: options.allCommitted,
|
|
69
78
|
reclaimTerminal: options.reclaimTerminal,
|
|
79
|
+
terminalAgedMs: options.terminalAgedMs ?? TERMINAL_RECLAIM_AGE_MS,
|
|
80
|
+
terminalPressureBytes: options.terminalPressureBytes ?? terminalPressureBytes,
|
|
70
81
|
});
|
|
71
82
|
}
|
|
72
83
|
async function applyPlan(paths, plan) {
|
|
73
84
|
const root = path.join(paths.state_dir, RAW_EVIDENCE_DIR);
|
|
74
|
-
const tally = { files: 0, bytes: 0, packs: 0, failed: 0, hashes: [] };
|
|
85
|
+
const tally = { files: 0, bytes: 0, packs: 0, failed: 0, hashes: [], terminalFiles: 0, terminalBytes: 0 };
|
|
75
86
|
const removedPackIds = await removeEmptyPacks(root, plan, tally);
|
|
76
87
|
await removeRemainingFiles(root, plan, removedPackIds, tally);
|
|
77
88
|
await forgetStagedObjects(paths, tally.hashes);
|
|
@@ -83,6 +94,8 @@ async function applyPlan(paths, plan) {
|
|
|
83
94
|
deleted_bytes: tally.bytes,
|
|
84
95
|
removed_packs: tally.packs,
|
|
85
96
|
failed_deletions: tally.failed,
|
|
97
|
+
terminal_reclaimed_files: tally.terminalFiles,
|
|
98
|
+
terminal_reclaimed_bytes: tally.terminalBytes,
|
|
86
99
|
};
|
|
87
100
|
}
|
|
88
101
|
/**
|
|
@@ -126,6 +139,17 @@ async function removeRemainingFiles(root, plan, removedPackIds, tally) {
|
|
|
126
139
|
function countRemoved(tally, entry) {
|
|
127
140
|
tally.files += 1;
|
|
128
141
|
tally.bytes += entry.byte_size;
|
|
142
|
+
if (entry.reason.startsWith("terminal")) {
|
|
143
|
+
tally.terminalFiles += 1;
|
|
144
|
+
tally.terminalBytes += entry.byte_size;
|
|
145
|
+
console.error("[collector prune] terminal evidence reclaimed, the refusal receipt stays in the ledger", JSON.stringify({
|
|
146
|
+
reason: entry.reason.split(":")[0],
|
|
147
|
+
terminal_reason: entry.reason.split(":").slice(1).join(":") || null,
|
|
148
|
+
byte_size: entry.byte_size,
|
|
149
|
+
age_ms: entry.age_ms,
|
|
150
|
+
content_hash_prefix: entry.content_hash?.slice(0, 16) ?? null,
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
129
153
|
if (entry.content_hash)
|
|
130
154
|
tally.hashes.push(entry.content_hash);
|
|
131
155
|
}
|
|
@@ -166,6 +190,12 @@ async function forgetStagedObjects(paths, hashes) {
|
|
|
166
190
|
}));
|
|
167
191
|
}
|
|
168
192
|
}
|
|
193
|
+
/** The receipt fragment for refused copies reclaimed this tick, or null. */
|
|
194
|
+
export function terminalReclaimDetail(result) {
|
|
195
|
+
if (!result.terminal_reclaimed_files)
|
|
196
|
+
return null;
|
|
197
|
+
return `terminal_reclaimed ${result.terminal_reclaimed_files} (${result.terminal_reclaimed_bytes ?? 0}B)`;
|
|
198
|
+
}
|
|
169
199
|
function dryRunResult(plan) {
|
|
170
200
|
return {
|
|
171
201
|
status: "ok",
|
|
@@ -229,6 +259,8 @@ export function reportPrune(result, options = { dryRun: false }) {
|
|
|
229
259
|
bytes_scanned: result.bytes_before,
|
|
230
260
|
bytes_after: result.bytes_after,
|
|
231
261
|
failed_deletions: result.failed_deletions,
|
|
262
|
+
terminal_reclaimed_files: result.terminal_reclaimed_files ?? 0,
|
|
263
|
+
terminal_reclaimed_bytes: result.terminal_reclaimed_bytes ?? 0,
|
|
232
264
|
}));
|
|
233
265
|
if (!result.cap_blocked_by_uncommitted)
|
|
234
266
|
return;
|
package/dist/disk-retention.js
CHANGED
|
@@ -8,6 +8,8 @@ export const DEFAULT_STAGING_CAP_BYTES = 256 * 1024 * 1024;
|
|
|
8
8
|
/** Local knobs, read from the environment like `COCKPIT_DISABLE_GC`. */
|
|
9
9
|
export const STAGING_CAP_ENV = "COCKPIT_STAGING_CAP_BYTES";
|
|
10
10
|
export const STAGING_RETENTION_ENV = "COCKPIT_STAGING_RETENTION_HOURS";
|
|
11
|
+
/** A terminal object's staged bytes are reclaimed after a week regardless. */
|
|
12
|
+
export const TERMINAL_RECLAIM_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
11
13
|
/** The two knobs, from the environment, falling back to the defaults above. */
|
|
12
14
|
export function retentionOptionsFromEnv(env) {
|
|
13
15
|
const hours = Number(env[STAGING_RETENTION_ENV]);
|
|
@@ -43,7 +45,10 @@ export function planStagingRetention(inventory, options = {}) {
|
|
|
43
45
|
addToBucket(plan.kept_in_window, object);
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
|
-
const
|
|
48
|
+
const terminalReclaimed = reclaimTerminalOnSchedule(plan, inventory, options);
|
|
49
|
+
const retainedTerminalBytes = options.reclaimTerminal
|
|
50
|
+
? 0
|
|
51
|
+
: Math.max(0, (inventory.terminal_bytes ?? 0) - terminalReclaimed);
|
|
47
52
|
applyByteCap(plan, committedInWindow, capBytes + retainedTerminalBytes, inventory);
|
|
48
53
|
markEmptyPacks(plan, inventory.packs);
|
|
49
54
|
plan.bytes_after =
|
|
@@ -57,6 +62,31 @@ export function planStagingRetention(inventory, options = {}) {
|
|
|
57
62
|
plan.cap_blocked_count = plan.cap_blocked_by_uncommitted ? unvouched : 0;
|
|
58
63
|
return plan;
|
|
59
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* BLI-12832: the scheduled prune's terminal reclaim. Aged objects first (a
|
|
67
|
+
* week), then oldest-first until the gate's pressure is relieved. Returns the
|
|
68
|
+
* bytes planned for deletion.
|
|
69
|
+
*/
|
|
70
|
+
function reclaimTerminalOnSchedule(plan, inventory, options) {
|
|
71
|
+
if (options.reclaimTerminal)
|
|
72
|
+
return 0;
|
|
73
|
+
const terminal = inventory.packs
|
|
74
|
+
.flatMap((pack) => pack.objects)
|
|
75
|
+
.filter((object) => object.state === "terminal")
|
|
76
|
+
.sort((a, b) => (b.disk_age_ms || b.age_ms) - (a.disk_age_ms || a.age_ms));
|
|
77
|
+
let freed = 0;
|
|
78
|
+
let pressure = options.terminalPressureBytes ?? 0;
|
|
79
|
+
for (const object of terminal) {
|
|
80
|
+
const age = object.disk_age_ms || object.age_ms;
|
|
81
|
+
const aged = options.terminalAgedMs !== undefined && age >= options.terminalAgedMs;
|
|
82
|
+
if (!aged && pressure <= 0)
|
|
83
|
+
break;
|
|
84
|
+
pushDeletion(plan, object, `${aged ? "terminal_aged" : "terminal_pressure"}:${object.reason}`);
|
|
85
|
+
freed += object.byte_size;
|
|
86
|
+
pressure -= object.byte_size;
|
|
87
|
+
}
|
|
88
|
+
return freed;
|
|
89
|
+
}
|
|
60
90
|
/**
|
|
61
91
|
* The oldest committed spares go until the total fits, newest kept last. Sorted
|
|
62
92
|
* by the ledger's `committed_at` age rather than a file mtime: mtime moves when
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE sentence a person gets when only a browser can fix their sign-in
|
|
3
|
+
* (BLI-12828). Doctor's `authed` row, doctor's exit-code receipt and the sync
|
|
4
|
+
* tick's once-a-day note all say these exact words, so nobody is told two
|
|
5
|
+
* different things for the same problem.
|
|
6
|
+
*/
|
|
7
|
+
export const SIGN_IN_ACTION = "Run `cockpit login` and approve the sign-in in your browser.";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `staging-gate.json`: what the staging gate last decided, and whether anyone
|
|
3
|
+
* has been told (BLI-12832).
|
|
4
|
+
*
|
|
5
|
+
* The gate itself runs every tick and logs every tick on stderr. The receipt
|
|
6
|
+
* stream and `cockpit doctor` are different: a person reads them, so a pause
|
|
7
|
+
* that lasts three days must be said once when it starts and once when it ends,
|
|
8
|
+
* not 288 times. This file is the memory that makes "once" possible: the state
|
|
9
|
+
* the gate is in now, and the state the last receipt announced.
|
|
10
|
+
*
|
|
11
|
+
* One small JSON object, overwritten each tick, so it is bounded by
|
|
12
|
+
* construction. Metadata only: byte counts and reason labels.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { describeError } from "./health-detail.js";
|
|
17
|
+
import { pruneStateDirCaches } from "./state-dir-prune.js";
|
|
18
|
+
import { readStagingGate, } from "./staging-gate.js";
|
|
19
|
+
export const STAGING_GATE_FILE = "staging-gate.json";
|
|
20
|
+
export const STAGING_GATE_STEP = "staging_gate";
|
|
21
|
+
export async function readStagingGateReceipt(paths) {
|
|
22
|
+
const raw = await fs
|
|
23
|
+
.readFile(path.join(paths.state_dir, STAGING_GATE_FILE), "utf8")
|
|
24
|
+
.catch(() => null);
|
|
25
|
+
if (!raw)
|
|
26
|
+
return null;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
if ((parsed.state !== "open" && parsed.state !== "paused") ||
|
|
30
|
+
typeof parsed.since !== "string" ||
|
|
31
|
+
typeof parsed.announced !== "string") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
state: parsed.state,
|
|
36
|
+
reason: parsed.reason ?? null,
|
|
37
|
+
since: parsed.since,
|
|
38
|
+
evaluated_at: String(parsed.evaluated_at ?? parsed.since),
|
|
39
|
+
announced: parsed.announced,
|
|
40
|
+
staging_bytes: Number(parsed.staging_bytes) || 0,
|
|
41
|
+
collector_bytes: Number(parsed.collector_bytes) || 0,
|
|
42
|
+
free_bytes: typeof parsed.free_bytes === "number" ? parsed.free_bytes : null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function stateKey(snapshot) {
|
|
50
|
+
return snapshot.reason ? `paused:${snapshot.reason}` : "open";
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Reads the gate live (after the prune, so it reflects what the tick just
|
|
54
|
+
* freed), compares it with the last announced state and returns the one
|
|
55
|
+
* receipt event a CHANGE deserves, or null. Never throws.
|
|
56
|
+
*/
|
|
57
|
+
export async function settleStagingGate(paths, env, now, deps = {}) {
|
|
58
|
+
const snapshot = await readStagingGate(paths.state_dir, env, deps);
|
|
59
|
+
const key = stateKey(snapshot);
|
|
60
|
+
const previous = await readStagingGateReceipt(paths);
|
|
61
|
+
const announcedBefore = previous?.announced ?? "open";
|
|
62
|
+
const changed = key !== announcedBefore;
|
|
63
|
+
const sameState = previous && stateKey({ reason: previous.reason }) === key;
|
|
64
|
+
console.error(snapshot.reason
|
|
65
|
+
? "[staging-gate] paused, new evidence is not being copied; sources stay on disk"
|
|
66
|
+
: "[staging-gate] open, new evidence may be copied", JSON.stringify({
|
|
67
|
+
reason: snapshot.reason ?? "open",
|
|
68
|
+
announced: changed ? "now" : "already",
|
|
69
|
+
staging_bytes: snapshot.staging_bytes,
|
|
70
|
+
collector_bytes: snapshot.collector_bytes,
|
|
71
|
+
free_bytes: snapshot.free_bytes,
|
|
72
|
+
floor_bytes: snapshot.floor_bytes,
|
|
73
|
+
cap_bytes: snapshot.cap_bytes,
|
|
74
|
+
ceiling_bytes: snapshot.ceiling_bytes,
|
|
75
|
+
}));
|
|
76
|
+
try {
|
|
77
|
+
const receipt = {
|
|
78
|
+
state: snapshot.reason ? "paused" : "open",
|
|
79
|
+
reason: snapshot.reason,
|
|
80
|
+
since: sameState ? previous.since : now.toISOString(),
|
|
81
|
+
evaluated_at: now.toISOString(),
|
|
82
|
+
announced: key,
|
|
83
|
+
staging_bytes: snapshot.staging_bytes,
|
|
84
|
+
collector_bytes: snapshot.collector_bytes,
|
|
85
|
+
free_bytes: snapshot.free_bytes,
|
|
86
|
+
};
|
|
87
|
+
await fs.mkdir(paths.state_dir, { recursive: true });
|
|
88
|
+
const file = path.join(paths.state_dir, STAGING_GATE_FILE);
|
|
89
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
90
|
+
await fs.writeFile(temp, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
|
|
91
|
+
await fs.rename(temp, file);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
console.error("[staging-gate] could not record the gate receipt", JSON.stringify({ reason: "staging_gate_receipt_write_failed", ...describeError(error) }));
|
|
95
|
+
return { snapshot, event: null };
|
|
96
|
+
}
|
|
97
|
+
return { snapshot, event: changed ? gateEvent(snapshot) : null };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The tick's last disk chore: bound the two disposable caches, then settle the
|
|
101
|
+
* gate. Returns the receipt event a change deserves, or null. Never throws.
|
|
102
|
+
*/
|
|
103
|
+
export async function runGateFollowUp(paths, env, now) {
|
|
104
|
+
try {
|
|
105
|
+
await pruneStateDirCaches(paths, now);
|
|
106
|
+
return (await settleStagingGate(paths, env, now)).event;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
console.error("[staging-gate] the gate follow-up threw, the tick is unaffected", JSON.stringify({ reason: "staging_gate_follow_up_threw", ...describeError(error) }));
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Counts and a reason label only; no path travels in a receipt. */
|
|
114
|
+
function gateEvent(snapshot) {
|
|
115
|
+
const detail = [
|
|
116
|
+
`staging ${snapshot.staging_bytes}B of ${snapshot.cap_bytes}B cap`,
|
|
117
|
+
`collector ${snapshot.collector_bytes}B of ${snapshot.ceiling_bytes}B ceiling`,
|
|
118
|
+
snapshot.free_bytes === null ? null : `free ${snapshot.free_bytes}B`,
|
|
119
|
+
]
|
|
120
|
+
.filter((part) => Boolean(part))
|
|
121
|
+
.join("; ");
|
|
122
|
+
if (snapshot.reason) {
|
|
123
|
+
return {
|
|
124
|
+
step: STAGING_GATE_STEP,
|
|
125
|
+
status: "skipped",
|
|
126
|
+
error_code: `staging_deferred_${snapshot.reason}`,
|
|
127
|
+
error_detail: detail,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return { step: STAGING_GATE_STEP, status: "ok", error_detail: `resumed; ${detail}` };
|
|
131
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write-side gate: Tower never copies new evidence onto a disk that has no
|
|
3
|
+
* room for it (BLI-12832).
|
|
4
|
+
*
|
|
5
|
+
* `disk-retention.ts` governs what the prune may DELETE, and it rightly never
|
|
6
|
+
* deletes evidence the server has not accepted. Nothing on the way IN used to
|
|
7
|
+
* look at the cap or at the volume, so when uploads failed for days every
|
|
8
|
+
* 15-minute tick copied more transcripts into staging without bound: 182 GB on
|
|
9
|
+
* one laptop (ticket 3066), 174 GB on another (ticket 2002).
|
|
10
|
+
*
|
|
11
|
+
* A staged copy duplicates a source file that is still on disk, so refusing to
|
|
12
|
+
* copy loses nothing. The deferred file is simply not collected this tick; no
|
|
13
|
+
* cursor entry is written for it, and the next tick re-reads the source when
|
|
14
|
+
* room has returned.
|
|
15
|
+
*
|
|
16
|
+
* Three reasons close the gate, most urgent first:
|
|
17
|
+
*
|
|
18
|
+
* - `low_disk`: the volume's free space is under max(5 GiB, 5% of the volume).
|
|
19
|
+
* - `state_over_ceiling`: everything the collector writes under its state dir
|
|
20
|
+
* (staging, spool, logs, cursors, state; never the study folders
|
|
21
|
+
* `state-dir-audit.ts` recognises) would pass the hard ceiling, 512 MiB by
|
|
22
|
+
* default.
|
|
23
|
+
* - `staging_at_cap`: staging, committed and uncommitted alike, is at the
|
|
24
|
+
* staging cap (256 MiB by default).
|
|
25
|
+
*
|
|
26
|
+
* One exception, on purpose: when staging is EMPTY the first file is admitted
|
|
27
|
+
* even if it alone is bigger than the cap, because a 334 MB rollout would
|
|
28
|
+
* otherwise never be collected on any machine. The ceiling and the free-space
|
|
29
|
+
* floor still bind it.
|
|
30
|
+
*
|
|
31
|
+
* Metadata only: byte counts and reason labels, never a path.
|
|
32
|
+
*/
|
|
33
|
+
import fs from "node:fs/promises";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { retentionOptionsFromEnv } from "./disk-retention.js";
|
|
36
|
+
import { directoryBytes } from "./disk-usage-files.js";
|
|
37
|
+
import { describeError } from "./health-detail.js";
|
|
38
|
+
import { classifyStateEntry } from "./state-dir-audit.js";
|
|
39
|
+
/** Hard ceiling on everything the collector writes under its state dir. */
|
|
40
|
+
export const DEFAULT_STATE_CEILING_BYTES = 512 * 1024 * 1024;
|
|
41
|
+
export const STATE_CEILING_ENV = "COCKPIT_STATE_CEILING_BYTES";
|
|
42
|
+
/** Overrides the low-disk floor (bytes); unset means max(5 GiB, 5% of volume). */
|
|
43
|
+
export const MIN_FREE_ENV = "COCKPIT_MIN_FREE_BYTES";
|
|
44
|
+
export const LOW_DISK_FLOOR_BYTES = 5 * 1024 * 1024 * 1024;
|
|
45
|
+
export const LOW_DISK_FLOOR_FRACTION = 0.05;
|
|
46
|
+
/**
|
|
47
|
+
* Each pack's `manifest.json` is staged beside the payload and its size grows
|
|
48
|
+
* with the gap ledger, so it is not known when a file is admitted. A tenth of
|
|
49
|
+
* the cap is held back for manifests: payload is admitted only up to 90% of the
|
|
50
|
+
* cap, which keeps the folder, manifests included, at or under the cap.
|
|
51
|
+
*/
|
|
52
|
+
export const STAGING_MANIFEST_RESERVE_FRACTION = 0.1;
|
|
53
|
+
/** The reason label every skip, log line and receipt carries. */
|
|
54
|
+
export const STAGING_DEFERRED = "staging_deferred";
|
|
55
|
+
export function stagingGateLimitsFromEnv(env) {
|
|
56
|
+
const ceiling = Number(env[STATE_CEILING_ENV]);
|
|
57
|
+
const minFree = Number(env[MIN_FREE_ENV]);
|
|
58
|
+
return {
|
|
59
|
+
capBytes: retentionOptionsFromEnv(env).capBytes,
|
|
60
|
+
ceilingBytes: Number.isFinite(ceiling) && ceiling > 0 ? ceiling : DEFAULT_STATE_CEILING_BYTES,
|
|
61
|
+
minFreeBytes: Number.isFinite(minFree) && minFree >= 0 && env[MIN_FREE_ENV] ? minFree : null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Free and total bytes of the volume holding `dir` (or its nearest existing
|
|
66
|
+
* parent: a fresh machine has no state dir yet). `null` when the platform
|
|
67
|
+
* cannot say, which is logged and never closes the gate on its own.
|
|
68
|
+
*/
|
|
69
|
+
export async function readVolumeSpace(dir, statfs = fs.statfs) {
|
|
70
|
+
let target = dir;
|
|
71
|
+
for (let hops = 0; hops < 64; hops += 1) {
|
|
72
|
+
try {
|
|
73
|
+
const stats = await statfs(target);
|
|
74
|
+
const bsize = Number(stats.bsize);
|
|
75
|
+
return {
|
|
76
|
+
free_bytes: Number(stats.bavail) * bsize,
|
|
77
|
+
total_bytes: Number(stats.blocks) * bsize,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const parent = path.dirname(target);
|
|
82
|
+
if (parent === target) {
|
|
83
|
+
console.error("[staging-gate] free space unreadable, low-disk check skipped", JSON.stringify({ reason: "statfs_unavailable", ...describeError(error) }));
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
target = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
export function lowDiskFloorBytes(totalBytes, override) {
|
|
92
|
+
if (override !== null)
|
|
93
|
+
return override;
|
|
94
|
+
return Math.max(LOW_DISK_FLOOR_BYTES, Math.ceil(totalBytes * LOW_DISK_FLOOR_FRACTION));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Everything the collector writes under its state dir, studies excluded: the
|
|
98
|
+
* quantity the ceiling bounds. Cheap: stat-walks only what the audit table
|
|
99
|
+
* counts, never the study folders.
|
|
100
|
+
*/
|
|
101
|
+
export async function measureCollectorBytes(stateDir) {
|
|
102
|
+
const entries = await fs.readdir(stateDir, { withFileTypes: true }).catch(() => []);
|
|
103
|
+
let total = 0;
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (classifyStateEntry(entry.name)?.class === "study")
|
|
106
|
+
continue;
|
|
107
|
+
const full = path.join(stateDir, entry.name);
|
|
108
|
+
total += entry.isDirectory()
|
|
109
|
+
? await directoryBytes(full)
|
|
110
|
+
: await fs.stat(full).then((info) => info.size, () => 0);
|
|
111
|
+
}
|
|
112
|
+
return total;
|
|
113
|
+
}
|
|
114
|
+
export function decideStagingGate(facts) {
|
|
115
|
+
if (facts.free_bytes !== null &&
|
|
116
|
+
facts.floor_bytes !== null &&
|
|
117
|
+
facts.free_bytes < facts.floor_bytes) {
|
|
118
|
+
return "low_disk";
|
|
119
|
+
}
|
|
120
|
+
if (facts.collector_bytes >= facts.ceiling_bytes)
|
|
121
|
+
return "state_over_ceiling";
|
|
122
|
+
if (facts.staging_bytes >= facts.cap_bytes)
|
|
123
|
+
return "staging_at_cap";
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/** One live read of the gate: measure, ask the volume, decide. Never throws. */
|
|
127
|
+
export async function readStagingGate(stateDir, env, deps = {}) {
|
|
128
|
+
const limits = stagingGateLimitsFromEnv(env);
|
|
129
|
+
const [stagingBytes, collectorBytes, volume] = await Promise.all([
|
|
130
|
+
directoryBytes(path.join(stateDir, "raw-evidence")).catch(() => 0),
|
|
131
|
+
measureCollectorBytes(stateDir).catch(() => 0),
|
|
132
|
+
readVolumeSpace(stateDir, deps.statfs),
|
|
133
|
+
]);
|
|
134
|
+
const facts = {
|
|
135
|
+
staging_bytes: stagingBytes,
|
|
136
|
+
collector_bytes: Math.max(collectorBytes, stagingBytes),
|
|
137
|
+
free_bytes: volume?.free_bytes ?? null,
|
|
138
|
+
total_bytes: volume?.total_bytes ?? null,
|
|
139
|
+
floor_bytes: volume ? lowDiskFloorBytes(volume.total_bytes, limits.minFreeBytes) : null,
|
|
140
|
+
cap_bytes: limits.capBytes,
|
|
141
|
+
ceiling_bytes: limits.ceilingBytes,
|
|
142
|
+
};
|
|
143
|
+
return { ...facts, reason: decideStagingGate(facts) };
|
|
144
|
+
}
|
|
145
|
+
/** How many files the gate has refused to copy so far this tick. */
|
|
146
|
+
export function stagingDeferredFiles(allowance) {
|
|
147
|
+
if (!allowance)
|
|
148
|
+
return 0;
|
|
149
|
+
return Object.values(allowance.deferred).reduce((sum, slot) => sum + (slot?.files ?? 0), 0);
|
|
150
|
+
}
|
|
151
|
+
export async function openStagingAllowance(stateDir, env, deps = {}) {
|
|
152
|
+
const snapshot = await readStagingGate(stateDir, env, deps);
|
|
153
|
+
console.error("[staging-gate] evaluated", JSON.stringify({
|
|
154
|
+
reason: snapshot.reason ?? "open",
|
|
155
|
+
staging_bytes: snapshot.staging_bytes,
|
|
156
|
+
collector_bytes: snapshot.collector_bytes,
|
|
157
|
+
free_bytes: snapshot.free_bytes,
|
|
158
|
+
floor_bytes: snapshot.floor_bytes,
|
|
159
|
+
cap_bytes: snapshot.cap_bytes,
|
|
160
|
+
ceiling_bytes: snapshot.ceiling_bytes,
|
|
161
|
+
}));
|
|
162
|
+
return { snapshot, admitted_bytes: 0, deferred: {} };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* May a file of this size be copied into staging now? Returns the reason it may
|
|
166
|
+
* not, or null after charging the allowance. A copy that already exists on disk
|
|
167
|
+
* costs nothing and must be passed as `alreadyStaged` so it is never refused.
|
|
168
|
+
*/
|
|
169
|
+
export function admitToStaging(allowance, byteLength, options = {}) {
|
|
170
|
+
if (options.alreadyStaged)
|
|
171
|
+
return null;
|
|
172
|
+
const { snapshot } = allowance;
|
|
173
|
+
const payloadCap = Math.floor(snapshot.cap_bytes * (1 - STAGING_MANIFEST_RESERVE_FRACTION));
|
|
174
|
+
const spent = allowance.admitted_bytes;
|
|
175
|
+
const stagedNow = snapshot.staging_bytes + spent;
|
|
176
|
+
let reason = null;
|
|
177
|
+
if (snapshot.free_bytes !== null &&
|
|
178
|
+
snapshot.floor_bytes !== null &&
|
|
179
|
+
snapshot.free_bytes - spent - byteLength < snapshot.floor_bytes) {
|
|
180
|
+
reason = "low_disk";
|
|
181
|
+
}
|
|
182
|
+
else if (snapshot.collector_bytes + spent + byteLength > snapshot.ceiling_bytes) {
|
|
183
|
+
reason = "state_over_ceiling";
|
|
184
|
+
}
|
|
185
|
+
else if (stagedNow >= snapshot.cap_bytes ||
|
|
186
|
+
(stagedNow > 0 && stagedNow + byteLength > payloadCap)) {
|
|
187
|
+
reason = "staging_at_cap";
|
|
188
|
+
}
|
|
189
|
+
if (!reason) {
|
|
190
|
+
allowance.admitted_bytes += byteLength;
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
const slot = (allowance.deferred[reason] ??= { files: 0, bytes: 0 });
|
|
194
|
+
slot.files += 1;
|
|
195
|
+
slot.bytes += byteLength;
|
|
196
|
+
// The first refusal of each reason per tick carries the numbers; the rest are
|
|
197
|
+
// counted in `slot` and summed by the follow-up, so a 50-session backlog is
|
|
198
|
+
// one line, not fifty.
|
|
199
|
+
if (slot.files === 1) {
|
|
200
|
+
console.error("[staging-gate] copy deferred, source stays on disk and is re-read when room returns", JSON.stringify({
|
|
201
|
+
event: STAGING_DEFERRED,
|
|
202
|
+
reason,
|
|
203
|
+
kind: options.kind ?? null,
|
|
204
|
+
file_bytes: byteLength,
|
|
205
|
+
staging_bytes: snapshot.staging_bytes,
|
|
206
|
+
collector_bytes: snapshot.collector_bytes,
|
|
207
|
+
free_bytes: snapshot.free_bytes,
|
|
208
|
+
floor_bytes: snapshot.floor_bytes,
|
|
209
|
+
cap_bytes: snapshot.cap_bytes,
|
|
210
|
+
ceiling_bytes: snapshot.ceiling_bytes,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
return reason;
|
|
214
|
+
}
|
package/dist/state-dir-audit.js
CHANGED
|
@@ -34,10 +34,10 @@ export const STUDIES_DIR_SUFFIX = "-studies";
|
|
|
34
34
|
* collector name; none does today, and the test pins that.
|
|
35
35
|
*/
|
|
36
36
|
export const STATE_DIR_RULES = [
|
|
37
|
-
{ name: "raw-evidence", match: "exact", class: "staging", bound: "staged copies go the tick the server commits them; 256 MiB cap; uncommitted evidence is never deleted, only counted" },
|
|
37
|
+
{ name: "raw-evidence", match: "exact", class: "staging", bound: "staged copies go the tick the server commits them; 256 MiB cap; the staging gate stops new copies at the cap, on a low disk or over the 512 MiB ceiling; uncommitted evidence is never deleted, only counted" },
|
|
38
38
|
{ name: "sync.log", match: "prefix", class: "logs", bound: "rotated every tick at 10 MiB, at most 2 archives (20 MB) per stream" },
|
|
39
39
|
{ name: "sync.err.log", match: "prefix", class: "logs", bound: "rotated every tick at 10 MiB, at most 2 archives (20 MB) per stream" },
|
|
40
|
-
{ name: "spool", match: "exact", class: "spool", bound: "upload queue; each entry is deleted once the ingest door accepts it" },
|
|
40
|
+
{ name: "spool", match: "exact", class: "spool", bound: "upload queue; each entry is deleted once the ingest door accepts it; at most 20 pending uploads and 100 outbox entries (40 events each), oldest dropped first" },
|
|
41
41
|
{ name: "cursors", match: "exact", class: "state", bound: "raw-evidence ledger and reconcile answers capped at 5,000 rows each; backfill cursors one small file each" },
|
|
42
42
|
{ name: "raw-evidence-staging.json", match: "exact", class: "state", bound: "staged-object index and delivery attempts capped at 5,000 rows each" },
|
|
43
43
|
{ name: "work-contexts", match: "exact", class: "state", bound: "one small file per worktree ever bound with `cockpit start`; overwritten, not pruned (a few KB each)" },
|
|
@@ -45,14 +45,15 @@ export const STATE_DIR_RULES = [
|
|
|
45
45
|
{ name: "root-work-scan.json", match: "exact", class: "state", bound: "one file, overwritten by each root scan" },
|
|
46
46
|
{ name: "setup-receipt.json", match: "exact", class: "state", bound: "one file, overwritten" },
|
|
47
47
|
{ name: "memory-install-receipt.json", match: "exact", class: "state", bound: "one file, overwritten" },
|
|
48
|
+
{ name: "staging-gate.json", match: "exact", class: "state", bound: "one file, overwritten every tick: the staging gate's state and what was last announced (BLI-12832)" },
|
|
48
49
|
{ name: "last-prune.json", match: "exact", class: "state", bound: "one file, overwritten every tick" },
|
|
49
50
|
{ name: "last-turn-trace.json", match: "exact", class: "state", bound: "one file, overwritten" },
|
|
50
51
|
{ name: "sync.lock", match: "exact", class: "state", bound: "lock file, removed when the tick ends" },
|
|
51
52
|
{ name: "backfill.lock", match: "exact", class: "state", bound: "lock file, removed when the backfill ends" },
|
|
52
53
|
{ name: ".last-", match: "prefix", class: "state", bound: "24-byte throttle markers for self-update, autostart repair and the memory install" },
|
|
53
|
-
{ name: "read-cache", match: "exact", class: "state", bound: "one response file per distinct Tower read
|
|
54
|
+
{ name: "read-cache", match: "exact", class: "state", bound: "one response file per distinct Tower read; the tick deletes files older than 7 days and the oldest past 32 MiB (state-dir-prune.ts)" },
|
|
54
55
|
{ name: "memory-hook-stats.json", match: "exact", class: "state", bound: "fixed-size histograms, overwritten (BLI Memory hooks)" },
|
|
55
|
-
{ name: "memory-hook-samples", match: "exact", class: "state", bound: "hourly content-free timing samples (BLI Memory hooks);
|
|
56
|
+
{ name: "memory-hook-samples", match: "exact", class: "state", bound: "hourly content-free timing samples (BLI Memory hooks); the tick deletes files older than 14 days (state-dir-prune.ts)" },
|
|
56
57
|
{ name: "memory-recall-seen", match: "exact", class: "state", bound: "one file per agent session, deleted after 7 days (BLI Memory hooks)" },
|
|
57
58
|
{ name: "memory-stop-anchors.json", match: "exact", class: "state", bound: "one offset per transcript, overwritten (BLI Memory hooks)" },
|
|
58
59
|
{ name: "memory-daemon-spawn.json", match: "exact", class: "state", bound: "one spawn stamp, overwritten (BLI Memory daemon)" },
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds the two state-dir writers that had no bound (BLI-12832 audit).
|
|
3
|
+
*
|
|
4
|
+
* `read-cache/` keeps one response file per distinct Tower read (the key folds
|
|
5
|
+
* in the request body, so every different `memory search` is its own file) and
|
|
6
|
+
* overwrote only on a repeat of the SAME read. `memory-hook-samples/` keeps
|
|
7
|
+
* hourly timing samples and was "not yet pruned". Both are disposable: a cache
|
|
8
|
+
* miss re-reads from Tower, an old timing sample is history nobody consults.
|
|
9
|
+
* Neither is evidence, so deleting from them can never lose anything.
|
|
10
|
+
*
|
|
11
|
+
* Every other state-dir entry is bounded where it is written (see the table in
|
|
12
|
+
* `state-dir-audit.ts`). Runs on every sync tick, never throws, logs counts.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { describeError } from "./health-detail.js";
|
|
17
|
+
export const READ_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
18
|
+
export const READ_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
export const HOOK_SAMPLES_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
20
|
+
async function listFiles(dir) {
|
|
21
|
+
const out = [];
|
|
22
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const full = path.join(dir, entry.name);
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
out.push(...(await listFiles(full)));
|
|
27
|
+
}
|
|
28
|
+
else if (entry.isFile()) {
|
|
29
|
+
const info = await fs.stat(full).catch(() => null);
|
|
30
|
+
if (info)
|
|
31
|
+
out.push({ file: full, bytes: info.size, mtime_ms: info.mtimeMs });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
async function removeAll(files) {
|
|
37
|
+
let removed = 0;
|
|
38
|
+
let bytes = 0;
|
|
39
|
+
for (const fact of files) {
|
|
40
|
+
const ok = await fs.rm(fact.file, { force: true }).then(() => true, () => false);
|
|
41
|
+
if (!ok)
|
|
42
|
+
continue;
|
|
43
|
+
removed += 1;
|
|
44
|
+
bytes += fact.bytes;
|
|
45
|
+
}
|
|
46
|
+
return { removed, bytes };
|
|
47
|
+
}
|
|
48
|
+
export async function pruneStateDirCaches(paths, now = new Date()) {
|
|
49
|
+
const result = {
|
|
50
|
+
read_cache_removed: 0,
|
|
51
|
+
read_cache_bytes_freed: 0,
|
|
52
|
+
hook_samples_removed: 0,
|
|
53
|
+
hook_samples_bytes_freed: 0,
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
const cache = await listFiles(path.join(paths.state_dir, "read-cache"));
|
|
57
|
+
const fresh = cache.filter((fact) => now.getTime() - fact.mtime_ms <= READ_CACHE_MAX_AGE_MS);
|
|
58
|
+
const doomed = cache.filter((fact) => !fresh.includes(fact));
|
|
59
|
+
let kept = fresh.reduce((sum, fact) => sum + fact.bytes, 0);
|
|
60
|
+
for (const fact of [...fresh].sort((a, b) => a.mtime_ms - b.mtime_ms)) {
|
|
61
|
+
if (kept <= READ_CACHE_MAX_BYTES)
|
|
62
|
+
break;
|
|
63
|
+
doomed.push(fact);
|
|
64
|
+
kept -= fact.bytes;
|
|
65
|
+
}
|
|
66
|
+
const freed = await removeAll(doomed);
|
|
67
|
+
result.read_cache_removed = freed.removed;
|
|
68
|
+
result.read_cache_bytes_freed = freed.bytes;
|
|
69
|
+
const samples = await listFiles(path.join(paths.state_dir, "memory-hook-samples"));
|
|
70
|
+
const stale = samples.filter((fact) => now.getTime() - fact.mtime_ms > HOOK_SAMPLES_MAX_AGE_MS);
|
|
71
|
+
const sampleFreed = await removeAll(stale);
|
|
72
|
+
result.hook_samples_removed = sampleFreed.removed;
|
|
73
|
+
result.hook_samples_bytes_freed = sampleFreed.bytes;
|
|
74
|
+
console.error("[collector prune] state-dir caches bounded", JSON.stringify({
|
|
75
|
+
reason: freed.removed + sampleFreed.removed > 0 ? "caches_trimmed" : "caches_within_bounds",
|
|
76
|
+
...result,
|
|
77
|
+
read_cache_kept_bytes: kept,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
console.error("[collector prune] state-dir cache prune failed, nothing else is affected", JSON.stringify({ reason: "state_dir_cache_prune_failed", ...describeError(error) }));
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|