@bli-cockpit/cli 0.2.25 → 0.2.27
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.
|
@@ -15,8 +15,9 @@ const GIT_DIFF_TIMEOUT_MS = 3_000;
|
|
|
15
15
|
export const RAW_EVIDENCE_BUCKET = "ambient-raw-evidence";
|
|
16
16
|
export const RAW_EVIDENCE_RETENTION_MODE = "remote_durable";
|
|
17
17
|
// Per-sync upload budgets enforced at COLLECTION time (D7b). A single marathon
|
|
18
|
-
// transcript can approach the
|
|
19
|
-
// several files without starving the sync; overflow
|
|
18
|
+
// transcript can approach the 500 MiB wire cap (RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES),
|
|
19
|
+
// so 2 GiB leaves room for several files without starving the sync; overflow
|
|
20
|
+
// still defers and converges.
|
|
20
21
|
export const RAW_EVIDENCE_DEFAULT_BYTE_BUDGET = 2 * 1024 * 1024 * 1024;
|
|
21
22
|
export const RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET = 300;
|
|
22
23
|
const CLAUDE_MAX_COLLECT_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
|
package/dist/autostart.js
CHANGED
|
@@ -226,6 +226,22 @@ async function installWindowsTask(options) {
|
|
|
226
226
|
"-File",
|
|
227
227
|
registrationPath,
|
|
228
228
|
]);
|
|
229
|
+
// BLI-2996: before this, a nonzero exit here (schtasks rejected the
|
|
230
|
+
// registration script, or Set-ScheduledTask threw) skipped straight to the
|
|
231
|
+
// return with `verified: null` and never logged anything — the doctor row
|
|
232
|
+
// said "needs repair" every run with no way to tell "the rewrite itself
|
|
233
|
+
// never ran" apart from "it ran and Windows still disagrees" (BLI-2996,
|
|
234
|
+
// Brandon's machine: same silent non-convergence under two different
|
|
235
|
+
// validator messages across CLI versions). Metadata only: exit code and
|
|
236
|
+
// whether the process produced any output, never the stderr/stdout text
|
|
237
|
+
// (it can carry the state directory or a workspace path).
|
|
238
|
+
if (created.code !== 0) {
|
|
239
|
+
console.error("[autostart] windows task repair rewrite failed", JSON.stringify({
|
|
240
|
+
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
241
|
+
exit_code: created.code,
|
|
242
|
+
had_output: Boolean(created.stderr.trim() || created.stdout.trim()),
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
229
245
|
const verified = created.code === 0
|
|
230
246
|
? await windowsTaskStatus({
|
|
231
247
|
...options,
|
|
@@ -238,6 +254,14 @@ async function installWindowsTask(options) {
|
|
|
238
254
|
})
|
|
239
255
|
: null;
|
|
240
256
|
const loaded = verified?.status === "loaded";
|
|
257
|
+
// windowsTaskStatus() above already logs the needs-repair case (with the
|
|
258
|
+
// problem list) when the rewrite ran but the read-back still disagrees; log
|
|
259
|
+
// the converged branch here too, tagged as a repair outcome and by name, so
|
|
260
|
+
// "did the rewrite actually fix it this run" never depends on inferring it
|
|
261
|
+
// from a routine status log emitted for an unrelated reason.
|
|
262
|
+
if (created.code === 0 && loaded) {
|
|
263
|
+
console.error("[autostart] windows task repair converged", JSON.stringify({ task_name: WINDOWS_AUTOSTART_TASK_NAME }));
|
|
264
|
+
}
|
|
241
265
|
return {
|
|
242
266
|
status: "installed",
|
|
243
267
|
label: AUTOSTART_LABEL,
|
|
@@ -549,10 +573,17 @@ function windowsTaskRegistrationProblems(taskXml, expected) {
|
|
|
549
573
|
.replace(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu, "")
|
|
550
574
|
.trim();
|
|
551
575
|
const exactExecBlock = execBlocks.length === 1 && actionRemainder === "" ? execBlocks[0] : "";
|
|
552
|
-
|
|
576
|
+
// Strip a wrapping quote pair defensively: every real capture we have shows
|
|
577
|
+
// Task Scheduler storing <Command> unquoted, but the writer builds the /TR
|
|
578
|
+
// executable segment with the same \"-escaped quoting it uses for the
|
|
579
|
+
// launcher argument (BLI-2598), and there is no verified capture proving
|
|
580
|
+
// schtasks' own TR-splitting heuristic always discards those quotes rather
|
|
581
|
+
// than leaving them as literal text (BLI-2996 canary-gated gap). A stray
|
|
582
|
+
// pair of quotes should never be the reason a correctly-registered task
|
|
583
|
+
// reads as broken.
|
|
584
|
+
const command = stripSurroundingQuotes(exactExecBlock
|
|
553
585
|
?.match(/<Command>\s*([^<]*?)\s*<\/Command>/iu)?.[1]
|
|
554
|
-
?.trim() ??
|
|
555
|
-
"";
|
|
586
|
+
?.trim() ?? "");
|
|
556
587
|
const actionArguments = exactExecBlock
|
|
557
588
|
.match(/<Arguments>\s*([^<]*?)\s*<\/Arguments>/iu)?.[1]
|
|
558
589
|
?.trim() ?? "";
|
|
@@ -654,16 +685,35 @@ function windowsWScriptPath(env = process.env) {
|
|
|
654
685
|
* DESKTOP-G2UO1GK (CLI 0.2.13, 2026-08-12), where the stored value differed
|
|
655
686
|
* from the expected one only by those quotes (BLI-2541).
|
|
656
687
|
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
688
|
+
* BLI-2996: that lesson was only ever applied to the pre-BLI-2677 `-File`
|
|
689
|
+
* action. The wscript launcher pair (`//B "<launcher>"`) has NO verified
|
|
690
|
+
* real-Windows capture — `windowlessWindowsTaskXml()` in autostart.test.ts is
|
|
691
|
+
* SYNTHETIC, built by hand-editing the one real capture we have rather than
|
|
692
|
+
* exported from a machine actually running this action. A repair that rewrites
|
|
693
|
+
* the task correctly but is judged against a guessed shape can fail forever
|
|
694
|
+
* without ever being wrong (Brandon's machine: same non-convergence under two
|
|
695
|
+
* different validator messages across two CLI versions, neither of which ever
|
|
696
|
+
* repaired anything). So this comparison is deliberately structural, not
|
|
697
|
+
* positional: tokenize the way CommandLineToArgvW groups quoted/unquoted
|
|
698
|
+
* arguments, then check flag-token-then-path-token semantically (a real path
|
|
699
|
+
* comparison, case- and quote-insensitive) instead of demanding the exact
|
|
700
|
+
* byte layout we happened to send. Whatever the wscript action's real
|
|
701
|
+
* normalization turns out to be once a canary captures it, this only fails on
|
|
702
|
+
* an actual different flag or different script — not on Windows' own
|
|
703
|
+
* reformatting.
|
|
661
704
|
*/
|
|
662
705
|
function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLauncherPath) {
|
|
663
|
-
|
|
706
|
+
const tokens = splitWindowsArgumentTokens(actionArguments);
|
|
707
|
+
if (tokens.length === 0) {
|
|
708
|
+
return "task action names no sync launcher to run";
|
|
709
|
+
}
|
|
710
|
+
if ((tokens[0] ?? "").toUpperCase() !== expectedFlags.toUpperCase()) {
|
|
664
711
|
return "task action does not run the launcher with the expected batch-mode flag";
|
|
665
712
|
}
|
|
666
|
-
|
|
713
|
+
if (tokens.length > 2) {
|
|
714
|
+
return "task action passes unexpected extra arguments to the sync launcher";
|
|
715
|
+
}
|
|
716
|
+
const launcherArgument = tokens[1];
|
|
667
717
|
if (!launcherArgument) {
|
|
668
718
|
return "task action names no sync launcher to run";
|
|
669
719
|
}
|
|
@@ -672,10 +722,46 @@ function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLa
|
|
|
672
722
|
}
|
|
673
723
|
return null;
|
|
674
724
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
725
|
+
/**
|
|
726
|
+
* Quote-aware whitespace tokenizer for an already-XML-decoded `<Arguments>`
|
|
727
|
+
* value: a `"..."` run is one token (quotes stripped, whitespace inside kept),
|
|
728
|
+
* everything else splits on whitespace. Windows paths never contain a quote
|
|
729
|
+
* character, so this does not need CommandLineToArgvW's backslash-escaping
|
|
730
|
+
* rules — only its quoting rule — to tell "one argument with a space in it"
|
|
731
|
+
* apart from "two arguments".
|
|
732
|
+
*/
|
|
733
|
+
function splitWindowsArgumentTokens(value) {
|
|
734
|
+
const tokens = [];
|
|
735
|
+
let current = "";
|
|
736
|
+
let inQuotes = false;
|
|
737
|
+
let hasToken = false;
|
|
738
|
+
for (const char of value) {
|
|
739
|
+
if (char === '"') {
|
|
740
|
+
inQuotes = !inQuotes;
|
|
741
|
+
hasToken = true;
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (!inQuotes && /\s/u.test(char)) {
|
|
745
|
+
if (hasToken) {
|
|
746
|
+
tokens.push(current);
|
|
747
|
+
current = "";
|
|
748
|
+
hasToken = false;
|
|
749
|
+
}
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
current += char;
|
|
753
|
+
hasToken = true;
|
|
754
|
+
}
|
|
755
|
+
if (hasToken)
|
|
756
|
+
tokens.push(current);
|
|
757
|
+
return tokens;
|
|
758
|
+
}
|
|
759
|
+
/** Removes one wrapping pair of double quotes, if present. Used only to
|
|
760
|
+
* normalize before a path comparison — never to reconstruct a shell-safe
|
|
761
|
+
* value. */
|
|
762
|
+
function stripSurroundingQuotes(value) {
|
|
763
|
+
const match = value.match(/^"([\s\S]*)"$/u);
|
|
764
|
+
return match ? match[1] : value;
|
|
679
765
|
}
|
|
680
766
|
function sameWindowsPath(left, right) {
|
|
681
767
|
if (!left || !right)
|
|
@@ -129,6 +129,23 @@ export async function runBackfill(command, io) {
|
|
|
129
129
|
});
|
|
130
130
|
}
|
|
131
131
|
const reasonCounts = reasonCountsFor(scan.candidates, guards.counts, scan.issues);
|
|
132
|
+
// BLI-2727: a deterministic, labeled oversized skip must never poison
|
|
133
|
+
// completion — it is a permanent, non-retryable fact about the file, not an
|
|
134
|
+
// in-flight problem a rerun can fix. `scan.issues`/`retryable_candidate_keys`
|
|
135
|
+
// still carry it (so it's never silently dropped from reporting); every
|
|
136
|
+
// completion-gating computation below excludes it explicitly instead.
|
|
137
|
+
const oversizedCandidateKeys = oversizedBackfillCandidateKeys(scan.candidates);
|
|
138
|
+
// Two scan issues describe the exact same oversized-main candidates:
|
|
139
|
+
// `backfillScanIssues` pushes the Claude-specific `claude_main_file_too_large`
|
|
140
|
+
// (from `claude?.main_file_oversized`) and `countReadOnlyGuards` separately
|
|
141
|
+
// pushes the source-agnostic `file_too_large` (same predicate as
|
|
142
|
+
// `oversizedBackfillCandidateKeys`, so this list can never drift from it).
|
|
143
|
+
// Both must be excluded from completion-gating together.
|
|
144
|
+
const OVERSIZED_SCAN_ISSUE_REASONS = new Set([
|
|
145
|
+
"file_too_large",
|
|
146
|
+
"claude_main_file_too_large",
|
|
147
|
+
]);
|
|
148
|
+
const blockingScanIssues = (issues) => issues.filter((issue) => !OVERSIZED_SCAN_ISSUE_REASONS.has(issue.reason));
|
|
132
149
|
if (command.all && !command.yes) {
|
|
133
150
|
if (!command.json) {
|
|
134
151
|
writeDryRunSummary(io, {
|
|
@@ -186,11 +203,11 @@ export async function runBackfill(command, io) {
|
|
|
186
203
|
scan,
|
|
187
204
|
reasonCounts,
|
|
188
205
|
}),
|
|
189
|
-
status: scan.issues.length > 0 ? "partial" : "complete",
|
|
206
|
+
status: blockingScanIssues(scan.issues).length > 0 ? "partial" : "complete",
|
|
190
207
|
dry_run: true,
|
|
191
208
|
retry_command: retryCommand,
|
|
192
|
-
...(scan.issues.length > 0
|
|
193
|
-
? { failure_reason: scan.issues[0]?.reason }
|
|
209
|
+
...(blockingScanIssues(scan.issues).length > 0
|
|
210
|
+
? { failure_reason: blockingScanIssues(scan.issues)[0]?.reason }
|
|
194
211
|
: {}),
|
|
195
212
|
};
|
|
196
213
|
}
|
|
@@ -276,7 +293,7 @@ export async function runBackfill(command, io) {
|
|
|
276
293
|
let backfilledSessions = 0;
|
|
277
294
|
const durableCandidateKeys = new Set();
|
|
278
295
|
let blockedAt;
|
|
279
|
-
let failureReason = scan.issues[0]?.reason;
|
|
296
|
+
let failureReason = blockingScanIssues(scan.issues)[0]?.reason;
|
|
280
297
|
for (const [index, batch] of batches.entries()) {
|
|
281
298
|
await lock.handle.heartbeat();
|
|
282
299
|
const sync = await syncBackfillBatch({
|
|
@@ -300,7 +317,12 @@ export async function runBackfill(command, io) {
|
|
|
300
317
|
for (const key of durableInBatch)
|
|
301
318
|
durableCandidateKeys.add(key);
|
|
302
319
|
backfilledSessions = durableCandidateKeys.size;
|
|
303
|
-
|
|
320
|
+
// A candidate whose main is durably oversized-skipped (BLI-2727) is
|
|
321
|
+
// accounted for, not missing — it will never earn a durable pointer
|
|
322
|
+
// under the current cap, and treating it as "still missing" would fail
|
|
323
|
+
// every batch it happens to share with genuinely uploaded siblings.
|
|
324
|
+
const missingDurableMain = batch.candidates.filter((candidate) => !durableInBatch.has(candidateCursorKey(candidate)) &&
|
|
325
|
+
!oversizedCandidateKeys.has(candidateCursorKey(candidate))).length;
|
|
304
326
|
const batchFailed = sync.status !== "uploaded" ||
|
|
305
327
|
countSessionUploadFailures(sync) > 0 ||
|
|
306
328
|
missingDurableMain > 0;
|
|
@@ -414,8 +436,11 @@ export async function runBackfill(command, io) {
|
|
|
414
436
|
const unresolvedUploadableKeys = new Set(uploadable
|
|
415
437
|
.filter((candidate) => !durableCandidateKeys.has(candidateCursorKey(candidate)))
|
|
416
438
|
.map(candidateCursorKey));
|
|
417
|
-
|
|
418
|
-
|
|
439
|
+
// "Blocking" views exclude a deterministic oversized skip (BLI-2727): it is
|
|
440
|
+
// still unresolved (it never gets a durable pointer), but it is a permanent,
|
|
441
|
+
// labeled fact rather than something completion should wait on forever.
|
|
442
|
+
const unresolvedUploadableBlocking = [...unresolvedUploadableKeys].filter((key) => !oversizedCandidateKeys.has(key)).length;
|
|
443
|
+
const unresolvedRetryableBlocking = [...scan.retryable_candidate_keys].filter((key) => !oversizedCandidateKeys.has(key)).length;
|
|
419
444
|
const unresolvedKnownKeys = new Set([
|
|
420
445
|
...unresolvedUploadableKeys,
|
|
421
446
|
...scan.retryable_candidate_keys,
|
|
@@ -429,22 +454,27 @@ export async function runBackfill(command, io) {
|
|
|
429
454
|
// chunks already accepted by the server are retried idempotently when
|
|
430
455
|
// another required chunk lacks an acknowledgement.
|
|
431
456
|
: Math.max(1, sessions.length);
|
|
457
|
+
// `remaining` is a reporting total, not a completion gate: it still counts
|
|
458
|
+
// every unresolved candidate, including oversized skips, so the JSON output
|
|
459
|
+
// never goes silent about them (BLI-2727).
|
|
432
460
|
const remaining = unresolvedKnownKeys.size +
|
|
433
461
|
scan.omitted_candidate_count +
|
|
434
462
|
unseenGlobalFailures +
|
|
435
463
|
reportRetryable;
|
|
436
|
-
failed = Math.max(failed,
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
464
|
+
failed = Math.max(failed, unresolvedUploadableBlocking);
|
|
465
|
+
const blockingIssues = blockingScanIssues(scan.issues);
|
|
466
|
+
const completionBlocked = blockingIssues.length > 0 ||
|
|
467
|
+
unresolvedUploadableBlocking > 0 ||
|
|
468
|
+
unresolvedRetryableBlocking > 0 ||
|
|
440
469
|
deferred > 0 ||
|
|
441
470
|
failed > 0 ||
|
|
442
471
|
!reportAcknowledged;
|
|
443
472
|
if (!failureReason && completionBlocked) {
|
|
444
|
-
const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate))
|
|
473
|
+
const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate)) &&
|
|
474
|
+
!oversizedCandidateKeys.has(candidateCursorKey(candidate)))?.reason;
|
|
445
475
|
failureReason =
|
|
446
|
-
|
|
447
|
-
(
|
|
476
|
+
blockingIssues[0]?.reason ??
|
|
477
|
+
(unresolvedUploadableBlocking > 0
|
|
448
478
|
? "durable_session_pointer_missing"
|
|
449
479
|
: retryableCandidateReason ?? "backfill_incomplete");
|
|
450
480
|
}
|
|
@@ -455,6 +485,7 @@ export async function runBackfill(command, io) {
|
|
|
455
485
|
if (status === "complete" && command.all) {
|
|
456
486
|
recordBackfillScanCoverage(cursor, sources, now, now);
|
|
457
487
|
await writeBackfillCursor(paths, cursor);
|
|
488
|
+
const oversizedCandidates = scan.candidates.filter((candidate) => oversizedCandidateKeys.has(candidateCursorKey(candidate)));
|
|
458
489
|
await writeBackfillCompletionMarker(paths, {
|
|
459
490
|
schema_version: "cockpit-backfill-complete.v2",
|
|
460
491
|
coverage_version: BACKFILL_COVERAGE_VERSION,
|
|
@@ -463,6 +494,15 @@ export async function runBackfill(command, io) {
|
|
|
463
494
|
completed_at: now.toISOString(),
|
|
464
495
|
revalidate_after: new Date(now.getTime() + BACKFILL_COMPLETION_RECHECK_MS).toISOString(),
|
|
465
496
|
cursor,
|
|
497
|
+
...(oversizedCandidates.length > 0
|
|
498
|
+
? {
|
|
499
|
+
oversized_skips: {
|
|
500
|
+
reason: "file_too_large",
|
|
501
|
+
count: oversizedCandidates.length,
|
|
502
|
+
byte_sizes: oversizedCandidates.map((candidate) => candidate.byte_size),
|
|
503
|
+
},
|
|
504
|
+
}
|
|
505
|
+
: {}),
|
|
466
506
|
});
|
|
467
507
|
}
|
|
468
508
|
return {
|
|
@@ -889,6 +929,27 @@ function retryableCandidateKeys(candidates) {
|
|
|
889
929
|
sidecar.skipped_reason === "file_too_large")))
|
|
890
930
|
.map(candidateCursorKey));
|
|
891
931
|
}
|
|
932
|
+
/**
|
|
933
|
+
* A main session file whose only story is "too large to upload under the
|
|
934
|
+
* current cap" (BLI-2727). This mirrors exactly the two branches in
|
|
935
|
+
* `countReadOnlyGuards` that emit the `file_too_large` reason, so a candidate
|
|
936
|
+
* is in this set if and only if it contributed to that scan issue's count —
|
|
937
|
+
* one predicate, no drift between "why the issue fired" and "which candidate
|
|
938
|
+
* caused it". Deterministic and non-retryable: rerunning backfill cannot
|
|
939
|
+
* resolve it (only a larger cap or a smaller file can), so unlike a transient
|
|
940
|
+
* read failure it must never poison completion or a batch's success.
|
|
941
|
+
*/
|
|
942
|
+
function oversizedBackfillCandidateKeys(candidates) {
|
|
943
|
+
const keys = new Set();
|
|
944
|
+
for (const candidate of candidates) {
|
|
945
|
+
if ((candidate.source === "claude_code" &&
|
|
946
|
+
candidate.claude?.main_file_oversized) ||
|
|
947
|
+
candidate.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
948
|
+
keys.add(candidateCursorKey(candidate));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return keys;
|
|
952
|
+
}
|
|
892
953
|
async function countUnreadableClaudeSidecarDirs(candidates) {
|
|
893
954
|
let unreadable = 0;
|
|
894
955
|
for (const candidate of candidates) {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -243,13 +243,36 @@ async function fixAutostartState(context) {
|
|
|
243
243
|
}
|
|
244
244
|
return ok("autostart-alive", "installed", "autostart installed and loaded");
|
|
245
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Pure so it can be unit-tested without touching the real machine's home
|
|
248
|
+
* directory (`getCollectorRuntimePaths()` defaults to `os.homedir()` and
|
|
249
|
+
* doctor never threads `--home` through the backfill steps). Returns `null`
|
|
250
|
+
* when the marker does not cover the roots/sources — the caller falls
|
|
251
|
+
* through to the lock/never-run diagnosis in that case.
|
|
252
|
+
*
|
|
253
|
+
* BLI-2727: a marker whose only outstanding entries are deterministic
|
|
254
|
+
* oversized-file skips is still a completed backfill — it reads green with a
|
|
255
|
+
* named note, never a red `needs_fix`/`fail`, so an unliftable file cap never
|
|
256
|
+
* reads as "backfill never completed" on repeat doctor runs.
|
|
257
|
+
*/
|
|
258
|
+
export function backfillCompletionStepState(marker, roots) {
|
|
259
|
+
if (!backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
const oversized = marker?.oversized_skips;
|
|
263
|
+
if (oversized && oversized.count > 0) {
|
|
264
|
+
return ok("backfill-complete", "complete_with_oversized_skips", `backfill completion covers the current saved roots and both session sources ` +
|
|
265
|
+
`(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"} over the upload cap)`);
|
|
266
|
+
}
|
|
267
|
+
return ok("backfill-complete", "complete", "backfill completion covers the current saved roots and both session sources");
|
|
268
|
+
}
|
|
246
269
|
async function checkBackfillState(context) {
|
|
247
270
|
const paths = getCollectorRuntimePaths();
|
|
248
271
|
const roots = await doctorRoots(context);
|
|
249
272
|
const marker = await readBackfillCompletionMarker(paths);
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
273
|
+
const covered = backfillCompletionStepState(marker, roots);
|
|
274
|
+
if (covered)
|
|
275
|
+
return covered;
|
|
253
276
|
const lock = await inspectBackfillLock(paths);
|
|
254
277
|
if (lock.held) {
|
|
255
278
|
return needsFix("backfill-complete", "backfill_already_running", `backfill completion is not yet proven; another run holds the lock since ${lock.held_since ?? "unknown"}`);
|
|
@@ -268,6 +291,13 @@ async function fixBackfillState(context) {
|
|
|
268
291
|
}, capture.io);
|
|
269
292
|
const output = capture.stdout() + "\n" + capture.stderr();
|
|
270
293
|
if (code === 0) {
|
|
294
|
+
// Re-read the marker this run just wrote instead of hand-rolling a second
|
|
295
|
+
// message: `checkBackfillState`'s pure core already knows how to say
|
|
296
|
+
// "complete" vs "complete_with_oversized_skips" (BLI-2727), and this way
|
|
297
|
+
// the two can never say something different for the same marker.
|
|
298
|
+
const recheck = await checkBackfillState(context);
|
|
299
|
+
if (recheck.status === "ok")
|
|
300
|
+
return recheck;
|
|
271
301
|
return ok("backfill-complete", "completed", "ran `cockpit backfill --all --yes`");
|
|
272
302
|
}
|
|
273
303
|
const reason = jsonField(output, "failure_reason");
|
|
@@ -308,6 +338,66 @@ async function checkSyncState(context) {
|
|
|
308
338
|
// and only turns green from those command receipts.
|
|
309
339
|
return needsFix("sync-fresh", "per_root_verification_required", `fresh upload proof is required for ${roots.length} saved root${roots.length === 1 ? "" : "s"}`);
|
|
310
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* `cockpit sync --json` prints exactly one JSON document to stdout (stderr is
|
|
343
|
+
* for human text; see AGENTS.md logging conventions), so this is a real parse
|
|
344
|
+
* rather than the doctor module's usual regex field-scrape — which cannot
|
|
345
|
+
* disambiguate same-named fields nested under `codex_sessions.codex` vs
|
|
346
|
+
* `codex_sessions.claude` (BLI-2728).
|
|
347
|
+
*/
|
|
348
|
+
function parseDoctorSyncJson(stdout) {
|
|
349
|
+
try {
|
|
350
|
+
const parsed = JSON.parse(stdout.trim());
|
|
351
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* BLI-2728: a tick that only deferred objects past the per-tick raw-evidence
|
|
359
|
+
* object budget (`RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET`, adapters/raw-evidence.ts)
|
|
360
|
+
* is a backlog that is draining, not a failure — `attributedSyncRunStatus`
|
|
361
|
+
* marks the run not-fully-`ok` (so `cockpit sync` exits non-zero and doctor's
|
|
362
|
+
* exec sees `code !== 0`) purely because objects remain queued, with the
|
|
363
|
+
* per-repo upload itself still having succeeded. A genuine failure (auth,
|
|
364
|
+
* server rejection, network, a real upload_failed outcome, an unposted
|
|
365
|
+
* session report) must still read red — this only fires when NOTHING else in
|
|
366
|
+
* the tick's own summary looks wrong. Pure so it is unit-testable without a
|
|
367
|
+
* live exec/fs harness; the remaining-object count is read straight from the
|
|
368
|
+
* tick's own summary, never recomputed.
|
|
369
|
+
*/
|
|
370
|
+
export function syncBacklogDrainingVerdict(parsed) {
|
|
371
|
+
if (!parsed)
|
|
372
|
+
return null;
|
|
373
|
+
const deferredObjects = positiveNumberOrZero(parsed.raw_evidence_deferred_object_budget);
|
|
374
|
+
if (deferredObjects <= 0)
|
|
375
|
+
return null;
|
|
376
|
+
const deferredBytes = positiveNumberOrZero(parsed.raw_evidence_deferred_byte_budget);
|
|
377
|
+
const failedCount = positiveNumberOrZero(parsed.raw_evidence_failed_count);
|
|
378
|
+
const retryReasons = Array.isArray(parsed.raw_evidence_retry_reasons)
|
|
379
|
+
? parsed.raw_evidence_retry_reasons.length
|
|
380
|
+
: 0;
|
|
381
|
+
const sessions = asRecord(parsed.codex_sessions);
|
|
382
|
+
const reportPosted = sessions?.["report_posted"];
|
|
383
|
+
const codexReadFailures = positiveNumberOrZero(asRecord(sessions?.["codex"])?.["read_failures"]);
|
|
384
|
+
const claudeSessions = asRecord(sessions?.["claude"]);
|
|
385
|
+
const claudeReadFailures = positiveNumberOrZero(claudeSessions?.["read_failures"]);
|
|
386
|
+
const claudeSidecarsFailed = positiveNumberOrZero(claudeSessions?.["sidecars_failed"]);
|
|
387
|
+
const onlyDeferredObjectBudget = deferredBytes === 0 &&
|
|
388
|
+
failedCount === 0 &&
|
|
389
|
+
retryReasons === 0 &&
|
|
390
|
+
reportPosted === true &&
|
|
391
|
+
codexReadFailures === 0 &&
|
|
392
|
+
claudeReadFailures === 0 &&
|
|
393
|
+
claudeSidecarsFailed === 0;
|
|
394
|
+
return onlyDeferredObjectBudget ? { remainingObjects: deferredObjects } : null;
|
|
395
|
+
}
|
|
396
|
+
function positiveNumberOrZero(value) {
|
|
397
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
398
|
+
? value
|
|
399
|
+
: 0;
|
|
400
|
+
}
|
|
311
401
|
async function fixSyncState(context) {
|
|
312
402
|
const exec = context.io.exec;
|
|
313
403
|
if (!exec)
|
|
@@ -326,9 +416,16 @@ async function fixSyncState(context) {
|
|
|
326
416
|
args.push("--dashboard-url", context.command.dashboardUrl);
|
|
327
417
|
}
|
|
328
418
|
const result = await exec("cockpit", args);
|
|
419
|
+
const parsed = parseDoctorSyncJson(result.stdout);
|
|
329
420
|
const output = `${result.stdout}\n${result.stderr}`;
|
|
330
421
|
const status = jsonField(output, "status");
|
|
331
422
|
if (result.code !== 0) {
|
|
423
|
+
const draining = syncBacklogDrainingVerdict(parsed);
|
|
424
|
+
if (draining) {
|
|
425
|
+
return needsFix("sync-fresh", "backlog_draining", `${repoRoot}: raw-evidence backlog is still draining (${draining.remainingObjects} ` +
|
|
426
|
+
`object${draining.remainingObjects === 1 ? "" : "s"} deferred this tick); ` +
|
|
427
|
+
"rerun `cockpit sync` to continue");
|
|
428
|
+
}
|
|
332
429
|
return fail("sync-fresh", status ?? "sync_failed", `sync failed for ${repoRoot}`);
|
|
333
430
|
}
|
|
334
431
|
if (status !== "uploaded") {
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.27");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -239,8 +239,26 @@ function parseBackfillCompletionMarker(value) {
|
|
|
239
239
|
completed_at: completedAt,
|
|
240
240
|
revalidate_after: revalidateAfter,
|
|
241
241
|
cursor: parseBackfillCursor(record["cursor"]),
|
|
242
|
+
...(parseOversizedSkips(record["oversized_skips"])
|
|
243
|
+
? { oversized_skips: parseOversizedSkips(record["oversized_skips"]) }
|
|
244
|
+
: {}),
|
|
242
245
|
};
|
|
243
246
|
}
|
|
247
|
+
function parseOversizedSkips(value) {
|
|
248
|
+
if (!value || typeof value !== "object")
|
|
249
|
+
return null;
|
|
250
|
+
const record = value;
|
|
251
|
+
if (record["reason"] !== "file_too_large")
|
|
252
|
+
return null;
|
|
253
|
+
const count = optionalNumber(record["count"]);
|
|
254
|
+
if (count === null || count <= 0)
|
|
255
|
+
return null;
|
|
256
|
+
const rawByteSizes = record["byte_sizes"];
|
|
257
|
+
const byteSizes = Array.isArray(rawByteSizes)
|
|
258
|
+
? rawByteSizes.filter((entry) => typeof entry === "number" && Number.isFinite(entry) && entry >= 0)
|
|
259
|
+
: [];
|
|
260
|
+
return { reason: "file_too_large", count, byte_sizes: byteSizes };
|
|
261
|
+
}
|
|
244
262
|
function normalizeScopeRoot(value) {
|
|
245
263
|
const windowsStyle = path.win32.isAbsolute(value) && !path.posix.isAbsolute(value);
|
|
246
264
|
if (windowsStyle)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.27",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,6 +26,6 @@
|
|
|
26
26
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
29
|
+
"@bli-cockpit/telemetry-core": "0.1.20"
|
|
30
30
|
}
|
|
31
31
|
}
|