@bli-cockpit/cli 0.2.52 → 0.2.53
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/commands/clean.js +58 -5
- package/dist/commands/doctor.js +7 -2
- package/dist/commands/local-args-collector.js +12 -3
- package/dist/commands/local-help.js +12 -4
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-attribution.js +55 -0
- package/dist/commands/session-sync-failures.js +140 -0
- package/dist/commands/session-sync-health.js +102 -0
- package/dist/commands/session-sync-plan.js +81 -0
- package/dist/commands/session-sync-record.js +279 -0
- package/dist/commands/session-sync-scan.js +209 -0
- package/dist/commands/session-sync-types.js +12 -0
- package/dist/commands/session-sync-upload.js +215 -0
- package/dist/commands/session-sync.js +44 -987
- package/dist/commands/sync-followups.js +47 -2
- package/dist/cursors/raw-evidence-reconcile-cursor.js +132 -0
- package/dist/disk-usage.js +55 -0
- package/dist/evidence-reconcile-client.js +224 -0
- package/package.json +3 -3
package/dist/commands/clean.js
CHANGED
|
@@ -16,6 +16,16 @@
|
|
|
16
16
|
* object inside it is committed. Nothing here can delete evidence the upload
|
|
17
17
|
* ledger did not vouch for; that line is in `disk-retention.ts` and this
|
|
18
18
|
* command has no way to cross it.
|
|
19
|
+
*
|
|
20
|
+
* `--reconcile` (also under `--dry-run`) runs FIRST, before the footprint is
|
|
21
|
+
* even read: it asks the dashboard's own upload ledger about every `unknown`
|
|
22
|
+
* hash and writes the answer into `cursors/raw-evidence-reconcile.json`, so
|
|
23
|
+
* the footprint and plan below already see a server-confirmed `committed`
|
|
24
|
+
* object as committed and a server-confirmed absence as an ordinary,
|
|
25
|
+
* never-deleted `uncommitted` object rather than `unknown`. A reconcile
|
|
26
|
+
* failure is its own printed line and never blocks the rest of the command —
|
|
27
|
+
* the usual buckets print and the usual prune runs on whatever the local
|
|
28
|
+
* ledger already knew.
|
|
19
29
|
*/
|
|
20
30
|
import crypto from "node:crypto";
|
|
21
31
|
import fs from "node:fs/promises";
|
|
@@ -24,12 +34,20 @@ import { writeLine } from "./cli-io.js";
|
|
|
24
34
|
import { planFromDisk, runStagingPrune } from "../disk-prune.js";
|
|
25
35
|
import { MANUAL_VAULT_PREFIX, readDiskFootprint, } from "../disk-usage.js";
|
|
26
36
|
import { readRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
|
|
27
|
-
import {
|
|
37
|
+
import { runEvidenceReconcile, } from "../evidence-reconcile-client.js";
|
|
38
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
28
39
|
import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
|
|
29
40
|
export async function runClean(command, io) {
|
|
30
41
|
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
31
42
|
const now = new Date();
|
|
32
43
|
const env = io.env ?? process.env;
|
|
44
|
+
const reconciled = command.reconcile
|
|
45
|
+
? await reconcileUnknownPacks(command, paths, io, now)
|
|
46
|
+
: null;
|
|
47
|
+
if (reconciled && !command.json) {
|
|
48
|
+
for (const line of reconcileLines(reconciled))
|
|
49
|
+
writeLine(io.stdout, line);
|
|
50
|
+
}
|
|
33
51
|
const footprint = await readDiskFootprint(paths, now);
|
|
34
52
|
const plan = await planFromDisk(paths, env, { allCommitted: command.allCommitted }, now);
|
|
35
53
|
const vaults = command.allCommitted
|
|
@@ -41,8 +59,9 @@ export async function runClean(command, io) {
|
|
|
41
59
|
}
|
|
42
60
|
}
|
|
43
61
|
if (command.dryRun) {
|
|
44
|
-
if (command.json)
|
|
45
|
-
writeLine(io.stdout, cleanJson(footprint, plan, vaults, null));
|
|
62
|
+
if (command.json) {
|
|
63
|
+
writeLine(io.stdout, cleanJson(footprint, plan, vaults, null, reconciled));
|
|
64
|
+
}
|
|
46
65
|
return 0;
|
|
47
66
|
}
|
|
48
67
|
const pruned = await runStagingPrune(paths, {
|
|
@@ -56,7 +75,7 @@ export async function runClean(command, io) {
|
|
|
56
75
|
await rotateCollectorLogsBestEffort(paths);
|
|
57
76
|
const removedVaults = await removeVaults(paths.state_dir, vaults, io);
|
|
58
77
|
if (command.json) {
|
|
59
|
-
writeLine(io.stdout, cleanJson(footprint, plan, vaults, pruned));
|
|
78
|
+
writeLine(io.stdout, cleanJson(footprint, plan, vaults, pruned, reconciled));
|
|
60
79
|
return pruned.status === "fail" ? 1 : 0;
|
|
61
80
|
}
|
|
62
81
|
writeLine(io.stdout, `Freed ${mb(pruned.deleted_bytes)} MB across ${pruned.deleted_files} file(s) and ${pruned.removed_packs} pack(s)${removedVaults > 0 ? `, plus ${removedVaults} evidence vault(s)` : ""}.`);
|
|
@@ -91,8 +110,42 @@ export function cleanLines(footprint, plan, vaults, command) {
|
|
|
91
110
|
}
|
|
92
111
|
return lines;
|
|
93
112
|
}
|
|
94
|
-
|
|
113
|
+
/**
|
|
114
|
+
* `--reconcile`: ask the dashboard about every hash this laptop can no longer
|
|
115
|
+
* classify, before the footprint below is even read. Never throws — a server
|
|
116
|
+
* failure is `reconciled.status === "fail"` and the rest of `clean` runs
|
|
117
|
+
* exactly as if `--reconcile` had not been passed.
|
|
118
|
+
*/
|
|
119
|
+
async function reconcileUnknownPacks(command, paths, io, now) {
|
|
120
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
121
|
+
const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
|
|
122
|
+
return runEvidenceReconcile({
|
|
123
|
+
homeDir: command.homeDir,
|
|
124
|
+
dashboardUrl,
|
|
125
|
+
now,
|
|
126
|
+
fetch: io.fetch,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function reconcileLines(reconciled) {
|
|
130
|
+
if (reconciled.reason === "nothing_unknown") {
|
|
131
|
+
return ["Reconcile: nothing unknown to ask the server about."];
|
|
132
|
+
}
|
|
133
|
+
if (reconciled.status === "fail") {
|
|
134
|
+
return [
|
|
135
|
+
`Reconcile unavailable (${reconciled.reason}). Nothing was deleted based on this run's local memory alone; the usual buckets below are unaffected.`,
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
const lines = [
|
|
139
|
+
`Reconcile: asked ${reconciled.asked} hash(es) across ${reconciled.batches}/${reconciled.total_batches} batch(es) — ${reconciled.committed} committed, ${reconciled.not_committed} not committed, ${reconciled.unknown_to_server} unknown to the server.`,
|
|
140
|
+
];
|
|
141
|
+
if (reconciled.failed_batches > 0) {
|
|
142
|
+
lines.push(` ${reconciled.failed_batches} batch(es) could not be asked this run; those hashes stay unknown until the next --reconcile.`);
|
|
143
|
+
}
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
function cleanJson(footprint, plan, vaults, pruned, reconciled = null) {
|
|
95
147
|
return JSON.stringify({
|
|
148
|
+
reconciled,
|
|
96
149
|
staging: {
|
|
97
150
|
pack_count: footprint.staging.pack_count,
|
|
98
151
|
object_count: footprint.staging.object_count,
|
package/dist/commands/doctor.js
CHANGED
|
@@ -506,9 +506,14 @@ function diskRowMessage(footprint, capBytes) {
|
|
|
506
506
|
const staging = footprint.staging;
|
|
507
507
|
const parts = [
|
|
508
508
|
`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`,
|
|
509
|
-
`logs ${mib(footprint.logs.total_bytes)} MB`,
|
|
510
|
-
`spool ${mib(footprint.spool_bytes)} MB`,
|
|
511
509
|
];
|
|
510
|
+
// BLI-3619's second half: "unknown" means the local ledger's own capped
|
|
511
|
+
// memory cannot say, never that delivery failed — and the one command that
|
|
512
|
+
// actually answers it is named right here, not left for a person to find.
|
|
513
|
+
if (staging.unknown_count > 0) {
|
|
514
|
+
parts.push(`${staging.unknown_count} object(s) unknown to this laptop's own ledger — run \`cockpit clean --reconcile\` to ask the server`);
|
|
515
|
+
}
|
|
516
|
+
parts.push(`logs ${mib(footprint.logs.total_bytes)} MB`, `spool ${mib(footprint.spool_bytes)} MB`);
|
|
512
517
|
for (const vault of footprint.vaults) {
|
|
513
518
|
parts.push(`${vault.name} ${mib(vault.byte_size)} MB (a one-off; \`cockpit clean --all-committed\` removes it only if every file in it is accepted)`);
|
|
514
519
|
}
|
|
@@ -603,17 +603,26 @@ export function parseMemoryArgs(args) {
|
|
|
603
603
|
*/
|
|
604
604
|
export function parseCleanArgs(args) {
|
|
605
605
|
const values = parseNamedArgs(args, {
|
|
606
|
-
allowedFlags: [
|
|
607
|
-
|
|
606
|
+
allowedFlags: [
|
|
607
|
+
"--home",
|
|
608
|
+
"--dashboard-url",
|
|
609
|
+
"--dry-run",
|
|
610
|
+
"--all-committed",
|
|
611
|
+
"--reconcile",
|
|
612
|
+
"--json",
|
|
613
|
+
],
|
|
614
|
+
valueFlags: ["--home", "--dashboard-url"],
|
|
608
615
|
});
|
|
609
616
|
if (values.positionals.length > 0) {
|
|
610
|
-
throw new Error("clean takes no arguments; use --dry-run
|
|
617
|
+
throw new Error("clean takes no arguments; use --dry-run, --all-committed, or --reconcile.");
|
|
611
618
|
}
|
|
612
619
|
return {
|
|
613
620
|
kind: "clean",
|
|
614
621
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
622
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
615
623
|
dryRun: values.booleans.has("--dry-run"),
|
|
616
624
|
allCommitted: values.booleans.has("--all-committed"),
|
|
625
|
+
reconcile: values.booleans.has("--reconcile"),
|
|
617
626
|
json: values.booleans.has("--json"),
|
|
618
627
|
};
|
|
619
628
|
}
|
|
@@ -77,7 +77,7 @@ export function localCommandHelp(command) {
|
|
|
77
77
|
" cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
|
|
78
78
|
" cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
|
|
79
79
|
" cockpit memory [install|status] [--dashboard-url <url>] [--dry-run] [--json]",
|
|
80
|
-
" cockpit clean [--dry-run] [--all-committed] [--json]",
|
|
80
|
+
" cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
|
|
81
81
|
" cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
|
|
82
82
|
"",
|
|
83
83
|
`Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
|
|
@@ -551,7 +551,7 @@ function localSubcommandHelp(command) {
|
|
|
551
551
|
[
|
|
552
552
|
"clean",
|
|
553
553
|
[
|
|
554
|
-
"Usage: cockpit clean [--dry-run] [--all-committed] [--json]",
|
|
554
|
+
"Usage: cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
|
|
555
555
|
"",
|
|
556
556
|
"Reclaims this machine's disk. Prints what it would delete and why, per bucket,",
|
|
557
557
|
"then deletes: staged evidence Tower has already accepted goes after a 48-hour",
|
|
@@ -560,8 +560,16 @@ function localSubcommandHelp(command) {
|
|
|
560
560
|
"--dry-run stops after the printing. --all-committed drops the retry window,",
|
|
561
561
|
"so every already-accepted copy goes now, and is the only thing that removes a",
|
|
562
562
|
"manual-study-evidence-vault folder (and only when every file in it is accepted).",
|
|
563
|
-
"
|
|
564
|
-
"
|
|
563
|
+
"--reconcile asks Tower's own upload ledger about every object this machine's",
|
|
564
|
+
"local memory can no longer classify (\"unknown\") before the buckets print — a",
|
|
565
|
+
"confirmed-committed pack prunes under the ordinary rule this same run; a pack",
|
|
566
|
+
"the server has never seen becomes an ordinary, never-deleted \"uncommitted\"",
|
|
567
|
+
"object instead of \"unknown\". A server failure never blocks the rest of clean",
|
|
568
|
+
"and is printed on its own line. Combine with --dry-run to ask and print",
|
|
569
|
+
"without deleting.",
|
|
570
|
+
"The scheduled sync does the same prune once a day (reconciling one batch first",
|
|
571
|
+
"when there is anything unknown); this is the door for a laptop that is full",
|
|
572
|
+
"right now.",
|
|
565
573
|
],
|
|
566
574
|
],
|
|
567
575
|
[
|
|
@@ -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.53");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sessions live sync may collect, and which worktrees it collects them
|
|
3
|
+
* through.
|
|
4
|
+
*
|
|
5
|
+
* One place answers all four questions the rest of the pass asks about an
|
|
6
|
+
* attribution result: is this state collectable at all, does this result belong
|
|
7
|
+
* to the worktree currently syncing, which worktrees are targets this tick, and
|
|
8
|
+
* does a cursor entry still owe a retry. Live sync is reason-ALLOWLISTED where
|
|
9
|
+
* historical backfill is not, so these answers are deliberately narrower than
|
|
10
|
+
* the shared policy in `raw-evidence-attribution-policy.ts` and must not be
|
|
11
|
+
* re-derived anywhere else.
|
|
12
|
+
*/
|
|
13
|
+
import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
|
|
14
|
+
/**
|
|
15
|
+
* Live sync remains reason-allowlisted even though historical backfill accepts
|
|
16
|
+
* every deterministic fallback state.
|
|
17
|
+
*/
|
|
18
|
+
export function isLiveSyncCollectableAttributionState(state, reason, hasApprovedWorkspace = false) {
|
|
19
|
+
return isLiveRawEvidenceSyncAttribution(state, reason, hasApprovedWorkspace);
|
|
20
|
+
}
|
|
21
|
+
export function liveSyncCursorEntryRequiresRetry(entry) {
|
|
22
|
+
return (!entry.uploaded_object_key &&
|
|
23
|
+
(isLiveSyncCollectableAttributionState(entry.state, entry.reason, Boolean(entry.worktree_fingerprint)) || entry.reason === "repo_not_on_disk"));
|
|
24
|
+
}
|
|
25
|
+
export function matchesLiveSyncWorktree(result, target) {
|
|
26
|
+
return (isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null) &&
|
|
27
|
+
result.worktree !== null &&
|
|
28
|
+
liveSyncTargetKey(result.worktree) === liveSyncTargetKey(target));
|
|
29
|
+
}
|
|
30
|
+
export function liveSyncTargetKey(worktree) {
|
|
31
|
+
return `${worktree.repo_fingerprint}:${worktree.worktree_fingerprint}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Folder and deleted-repo fallbacks can intentionally synthesize a worktree
|
|
35
|
+
* identity that is not present in git discovery. Include those identities as
|
|
36
|
+
* sync targets so a wrapper-root session is not observed and then stranded.
|
|
37
|
+
*/
|
|
38
|
+
export function liveSyncTargetWorktrees(discovered, results) {
|
|
39
|
+
const targets = [...discovered];
|
|
40
|
+
const seen = new Set(discovered.map(liveSyncTargetKey));
|
|
41
|
+
for (const result of results) {
|
|
42
|
+
const targetKey = result.worktree
|
|
43
|
+
? liveSyncTargetKey(result.worktree)
|
|
44
|
+
: null;
|
|
45
|
+
if (!isLiveSyncCollectableAttributionState(result.state, result.reason, result.worktree !== null) ||
|
|
46
|
+
!result.worktree ||
|
|
47
|
+
!targetKey ||
|
|
48
|
+
seen.has(targetKey)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
seen.add(targetKey);
|
|
52
|
+
targets.push(result.worktree);
|
|
53
|
+
}
|
|
54
|
+
return targets;
|
|
55
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE place a sync writes down that something failed, and what it is called.
|
|
3
|
+
*
|
|
4
|
+
* Every reason label this pass can produce is written here and nowhere else, so
|
|
5
|
+
* the closed registry in `sync-health-class.ts` can be checked against a single
|
|
6
|
+
* file: `sync-health-class.test.ts` reads this source and refuses a label with
|
|
7
|
+
* no declared class (BLI-3551). Two consequences bind anyone editing this file:
|
|
8
|
+
* the ledger's methods keep the names `add` and `fail`, because that test reads
|
|
9
|
+
* those exact call shapes, and a new label goes in the registry first.
|
|
10
|
+
*
|
|
11
|
+
* The recorders below are the decision table itself — worktree delivery, source
|
|
12
|
+
* scan, the unposted session report, and the sentinel for a gate that fired
|
|
13
|
+
* without saying why.
|
|
14
|
+
*/
|
|
15
|
+
import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
|
|
16
|
+
import { sourceScanFailureReason } from "./session-sync-scan.js";
|
|
17
|
+
/**
|
|
18
|
+
* The label used when a sync fails and nothing on the way there said why.
|
|
19
|
+
*
|
|
20
|
+
* A deliberate sentinel rather than a fallback to `sync_failed`: it means the
|
|
21
|
+
* gate is real but its reason is unrecorded, which is a bug in this file, and
|
|
22
|
+
* it should be visible as one instead of blending into the generic bucket.
|
|
23
|
+
*/
|
|
24
|
+
export const SYNC_FAILED_WITHOUT_REASON = "sync_failed_reason_not_recorded";
|
|
25
|
+
export function createSyncFailureLedger() {
|
|
26
|
+
const recordsByRenderedReason = new Map();
|
|
27
|
+
const add = (record) => {
|
|
28
|
+
if (!recordsByRenderedReason.has(record.rendered)) {
|
|
29
|
+
recordsByRenderedReason.set(record.rendered, record);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
add,
|
|
34
|
+
fail(condition, label, rendered = label) {
|
|
35
|
+
if (condition)
|
|
36
|
+
add({ label, rendered });
|
|
37
|
+
},
|
|
38
|
+
isEmpty: () => recordsByRenderedReason.size === 0,
|
|
39
|
+
sortedRecords: () => [...recordsByRenderedReason.values()].sort((a, b) => a.rendered.localeCompare(b.rendered)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Every worktree that did not finish, and every raw-evidence gap it reported.
|
|
44
|
+
*
|
|
45
|
+
* The spooled reason is the most specific thing anyone has, so it leads, and it
|
|
46
|
+
* names the worktree it belongs to — a fleet failure is usually one repo, and
|
|
47
|
+
* "which one" is the first question asked.
|
|
48
|
+
*/
|
|
49
|
+
export function recordWorktreeDeliveryFailures(ledger, outcomes) {
|
|
50
|
+
for (const { worktree, sync } of outcomes) {
|
|
51
|
+
if (sync.status !== "uploaded") {
|
|
52
|
+
ledger.add(sync.status === "spooled" && sync.failure_reason
|
|
53
|
+
? {
|
|
54
|
+
label: sync.failure_class,
|
|
55
|
+
rendered: `${worktree.worktree_label}:${sync.failure_reason}`,
|
|
56
|
+
http_status: sync.failure_http_status,
|
|
57
|
+
}
|
|
58
|
+
: {
|
|
59
|
+
label: "upload_not_completed",
|
|
60
|
+
rendered: `${worktree.worktree_label}:upload_${sync.status}`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
for (const reason of sync.raw_evidence_failure_reasons ?? []) {
|
|
64
|
+
ledger.add({
|
|
65
|
+
label: "raw_evidence_upload_failed",
|
|
66
|
+
rendered: `raw_evidence:${reason}`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
for (const reason of sync.raw_evidence_retry_reasons ?? []) {
|
|
70
|
+
ledger.add({
|
|
71
|
+
label: "raw_evidence_retry_required",
|
|
72
|
+
rendered: `raw_evidence_retry:${reason}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
ledger.fail(sync.raw_evidence_deferred_byte_budget > 0, "deferred_byte_budget");
|
|
76
|
+
ledger.fail(sync.raw_evidence_deferred_object_budget > 0, "deferred_object_budget");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* What the scan itself got wrong: a window that hit its cap, sessions that
|
|
81
|
+
* could not be read, or a session store that could not be read at all.
|
|
82
|
+
*
|
|
83
|
+
* BLI-3551: the scan's RETRY reason and the scan's FAILURE reason are two
|
|
84
|
+
* different questions, and answering both with one function is what put
|
|
85
|
+
* `claude_scan:repo_not_on_disk` on every tick of three machines. A repo that
|
|
86
|
+
* is not on disk is a label on the session (the attribution umbrella finding:
|
|
87
|
+
* nothing was deleted, the transcript simply names a path git no longer
|
|
88
|
+
* knows). It still widens the next scan window; it is not a failed sync.
|
|
89
|
+
*/
|
|
90
|
+
export function recordSourceScanFailures(ledger, scan) {
|
|
91
|
+
const { codexAttribution, claudeAttribution } = scan;
|
|
92
|
+
ledger.fail(codexAttribution.session_limit_applied, "codex_session_limit_applied");
|
|
93
|
+
ledger.fail(claudeAttribution.session_limit_applied, "claude_session_limit_applied");
|
|
94
|
+
ledger.fail(codexAttributionReadFailureCount(codexAttribution) > 0, "codex_session_read_failed");
|
|
95
|
+
ledger.fail(claudeAttributionReadFailureCount(claudeAttribution) > 0, "claude_session_read_failed");
|
|
96
|
+
const codexScanFailure = sourceScanFailureReason("codex", codexAttribution);
|
|
97
|
+
if (codexScanFailure) {
|
|
98
|
+
ledger.add({
|
|
99
|
+
label: "codex_scan_read_failed",
|
|
100
|
+
rendered: `codex_scan:${codexScanFailure}`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const claudeScanFailure = sourceScanFailureReason("claude_code", claudeAttribution);
|
|
104
|
+
if (claudeScanFailure) {
|
|
105
|
+
ledger.add({
|
|
106
|
+
label: "claude_scan_read_failed",
|
|
107
|
+
rendered: `claude_scan:${claudeScanFailure}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A report that was owed and did not go out — unless the reason it did not go
|
|
113
|
+
* out is that there was nothing inside the approved roots to report.
|
|
114
|
+
*
|
|
115
|
+
* BLI-3551: a tick that observed only sessions from outside the operator's
|
|
116
|
+
* approved roots has nothing to post, and that is the consent boundary
|
|
117
|
+
* working — not a failure. It used to fail as
|
|
118
|
+
* `session_report_unposted:no_successful_sync`, whose word "session" then
|
|
119
|
+
* classified as `auth_failed`; one machine reported a broken credential 377
|
|
120
|
+
* times in 38 hours while its token had eleven weeks left. The withhold
|
|
121
|
+
* decision itself is untouched (adapters/attribution-core.ts) — only what it
|
|
122
|
+
* is CALLED.
|
|
123
|
+
*/
|
|
124
|
+
export function recordUnpostedSessionReportFailure(ledger, options) {
|
|
125
|
+
const report = options.delivery.report;
|
|
126
|
+
ledger.fail(options.delivery.reportRequired &&
|
|
127
|
+
!report.posted &&
|
|
128
|
+
!options.explainedByNothingInRoot, "session_report_unposted", `session_report_unposted:${report.reason ?? "unknown"}`);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A gate that fired without recording a reason still has to say so. Reached
|
|
132
|
+
* only when `ok` is false and the ledger is empty, which is a bug in this
|
|
133
|
+
* family rather than a condition of the machine.
|
|
134
|
+
*/
|
|
135
|
+
export function recordUnexplainedFailure(ledger) {
|
|
136
|
+
ledger.add({
|
|
137
|
+
label: SYNC_FAILED_WITHOUT_REASON,
|
|
138
|
+
rendered: SYNC_FAILED_WITHOUT_REASON,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The verdict on one tick — and the one true thing said about a tick that did
|
|
3
|
+
* not fail.
|
|
4
|
+
*
|
|
5
|
+
* This used to be a single boolean chain: correct, and completely mute. A
|
|
6
|
+
* failed sync exited 1 saying only `sync_failed`, which named no cause and
|
|
7
|
+
* supported no repair (BLI-2526). The conditions are unchanged; they are now
|
|
8
|
+
* asked in order, each answered by a recorder in `session-sync-failures.ts`
|
|
9
|
+
* that writes the closed-registry LABEL the health receipt is classified by.
|
|
10
|
+
* Nothing here parses a rendered sentence back apart.
|
|
11
|
+
*/
|
|
12
|
+
import { createSyncFailureLedger, recordSourceScanFailures, recordUnexplainedFailure, recordUnpostedSessionReportFailure, recordWorktreeDeliveryFailures, } from "./session-sync-failures.js";
|
|
13
|
+
/**
|
|
14
|
+
* Decide whether this tick failed, and record every condition that decided it.
|
|
15
|
+
*
|
|
16
|
+
* The order below is the decision table: what delivery got wrong, what the scan
|
|
17
|
+
* got wrong, whether the report was owed, and only then whether the run is ok.
|
|
18
|
+
*/
|
|
19
|
+
export function decideSyncHealth(options) {
|
|
20
|
+
const { outcomes } = options.worktreePass;
|
|
21
|
+
const ledger = createSyncFailureLedger();
|
|
22
|
+
recordWorktreeDeliveryFailures(ledger, outcomes);
|
|
23
|
+
recordSourceScanFailures(ledger, options.scan);
|
|
24
|
+
const sessionsOutsideRoot = options.sessions.filter((session) => OUTSIDE_APPROVED_ROOT_REASONS.has(session.attribution_reason)).length;
|
|
25
|
+
const nothingInRoot = nothingInRootCount({
|
|
26
|
+
sessionCount: options.sessions.length,
|
|
27
|
+
outsideRootCount: sessionsOutsideRoot,
|
|
28
|
+
outcomes,
|
|
29
|
+
reportPosted: options.delivery.report.posted,
|
|
30
|
+
reportReason: options.delivery.report.reason,
|
|
31
|
+
});
|
|
32
|
+
recordUnpostedSessionReportFailure(ledger, {
|
|
33
|
+
delivery: options.delivery,
|
|
34
|
+
explainedByNothingInRoot: nothingInRoot !== null,
|
|
35
|
+
});
|
|
36
|
+
// `everyWorktreeUploaded` may already be false; the delivery recorder above
|
|
37
|
+
// re-derives that from the same outcomes, so the two agree by construction.
|
|
38
|
+
const ok = options.worktreePass.everyWorktreeUploaded && ledger.isEmpty();
|
|
39
|
+
if (!ok && ledger.isEmpty()) {
|
|
40
|
+
recordUnexplainedFailure(ledger);
|
|
41
|
+
}
|
|
42
|
+
const notice = ok && nothingInRoot !== null ? `nothing_in_root:${nothingInRoot}` : null;
|
|
43
|
+
if (nothingInRoot !== null && notice) {
|
|
44
|
+
announceNothingInRoot(nothingInRoot, options.collectionRootCount);
|
|
45
|
+
}
|
|
46
|
+
const records = ledger.sortedRecords();
|
|
47
|
+
return {
|
|
48
|
+
ok,
|
|
49
|
+
failure_reasons: records.map((record) => record.rendered),
|
|
50
|
+
failure_records: records,
|
|
51
|
+
notice,
|
|
52
|
+
sessions_outside_root: sessionsOutsideRoot,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Reasons attribution gives when a session's working directory is not inside
|
|
57
|
+
* any approved collection root.
|
|
58
|
+
*
|
|
59
|
+
* Exact labels, not a pattern — the same discipline the classifier now follows.
|
|
60
|
+
* `attribution-core.ts` writes both of these and nothing else means
|
|
61
|
+
* "outside the boundary".
|
|
62
|
+
*/
|
|
63
|
+
const OUTSIDE_APPROVED_ROOT_REASONS = new Set([
|
|
64
|
+
"cwd_outside_scanned_worktrees",
|
|
65
|
+
"no_matching_worktree_signals",
|
|
66
|
+
]);
|
|
67
|
+
/**
|
|
68
|
+
* How many observed sessions were outside the approved roots, when that
|
|
69
|
+
* accounts for ALL of them and nothing else went wrong — otherwise `null`.
|
|
70
|
+
*
|
|
71
|
+
* Deliberately narrow. It requires that no worktree was synced at all (so no
|
|
72
|
+
* upload could have succeeded or failed), that every session observed this tick
|
|
73
|
+
* names an outside-the-root reason, and that the unposted report is the
|
|
74
|
+
* `no_successful_sync` shape rather than a spooled report that failed to flush.
|
|
75
|
+
* Anything else keeps its failure.
|
|
76
|
+
*/
|
|
77
|
+
export function nothingInRootCount(options) {
|
|
78
|
+
if (options.reportPosted)
|
|
79
|
+
return null;
|
|
80
|
+
if (options.reportReason !== "no_successful_sync")
|
|
81
|
+
return null;
|
|
82
|
+
if (options.outcomes.length > 0)
|
|
83
|
+
return null;
|
|
84
|
+
if (options.sessionCount === 0)
|
|
85
|
+
return null;
|
|
86
|
+
return options.outsideRootCount === options.sessionCount
|
|
87
|
+
? options.outsideRootCount
|
|
88
|
+
: null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The success branch says something too: this is the receipt that proves a
|
|
92
|
+
* quiet machine is a working machine, and the count is what tells a coach that
|
|
93
|
+
* someone is working entirely outside the approved boundary.
|
|
94
|
+
*/
|
|
95
|
+
function announceNothingInRoot(sessionsOutsideRoot, collectionRootCount) {
|
|
96
|
+
console.error("[session-sync] nothing to collect inside the approved roots", JSON.stringify({
|
|
97
|
+
reason: "nothing_in_root",
|
|
98
|
+
sessions_outside_root: sessionsOutsideRoot,
|
|
99
|
+
collection_root_count: collectionRootCount,
|
|
100
|
+
next_action: "widen the approved roots (an operator decision) if this machine should be collecting here",
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this tick is bound by before it reads a single session file.
|
|
3
|
+
*
|
|
4
|
+
* Every read here degrades on purpose: a missing or corrupt local file becomes
|
|
5
|
+
* an empty cursor or a null spool, because a collector that cannot read its own
|
|
6
|
+
* bookkeeping must still collect. The one decision this step makes that changes
|
|
7
|
+
* what gets collected is the retry width — whether a source reopens all local
|
|
8
|
+
* history instead of its normal live window.
|
|
9
|
+
*/
|
|
10
|
+
import { readLocalCollectorConfig, } from "../local-state.js";
|
|
11
|
+
import { CLAUDE_CURSOR_FILENAME, emptyRawEvidenceCursorState, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
12
|
+
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
13
|
+
import { readLocalUploadSpoolState, } from "../spool/local-spool.js";
|
|
14
|
+
import { liveSyncCursorEntryRequiresRetry } from "./session-sync-attribution.js";
|
|
15
|
+
/**
|
|
16
|
+
* Read the local state this sync is bound by — config, both cursors, the upload
|
|
17
|
+
* spool — and turn it into the decisions the rest of the pass reads.
|
|
18
|
+
*
|
|
19
|
+
* Every read here degrades on purpose: a missing or corrupt local file becomes
|
|
20
|
+
* an empty cursor or a null spool, because a collector that cannot read its own
|
|
21
|
+
* bookkeeping must still collect.
|
|
22
|
+
*/
|
|
23
|
+
export async function planSyncFromLocalState(options) {
|
|
24
|
+
const config = await readLocalCollectorConfig(options.paths).catch(() => null);
|
|
25
|
+
const claudeEnabled = config?.collect_claude_jsonl !== false;
|
|
26
|
+
const collectionRoots = normalizeCollectionRoots(options.approvedCollectionRoots ?? config?.default_repo_paths ?? []);
|
|
27
|
+
const [codexCursorBefore, claudeCursorBefore, uploadSpool] = await Promise.all([
|
|
28
|
+
readRawEvidenceCursor(options.paths).catch(() => emptyRawEvidenceCursorState()),
|
|
29
|
+
claudeEnabled
|
|
30
|
+
? readRawEvidenceCursor(options.paths, {
|
|
31
|
+
filename: CLAUDE_CURSOR_FILENAME,
|
|
32
|
+
}).catch(() => emptyRawEvidenceCursorState())
|
|
33
|
+
: Promise.resolve(emptyRawEvidenceCursorState()),
|
|
34
|
+
readLocalUploadSpoolState(options.paths).catch(() => null),
|
|
35
|
+
]);
|
|
36
|
+
// A spooled upload from a CLI old enough not to have recorded its source
|
|
37
|
+
// could have come from either one, so both sources own it until it clears.
|
|
38
|
+
const legacyRetryPending = Boolean(uploadSpool?.pending_uploads.some((entry) => entry.raw_evidence_file_count > 0 && entry.retry_sources.length === 0));
|
|
39
|
+
const codexSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "codex");
|
|
40
|
+
const claudeSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "claude_code");
|
|
41
|
+
return {
|
|
42
|
+
claudeEnabled,
|
|
43
|
+
collectionRoots,
|
|
44
|
+
codexCursorBefore,
|
|
45
|
+
claudeCursorBefore,
|
|
46
|
+
uploadSpool,
|
|
47
|
+
codexSourceRetryPending,
|
|
48
|
+
claudeSourceRetryPending,
|
|
49
|
+
codexRetryPending: sourceHasPendingRetries("codex", uploadSpool, legacyRetryPending, codexSourceRetryPending, codexCursorBefore),
|
|
50
|
+
claudeRetryPending: claudeEnabled &&
|
|
51
|
+
sourceHasPendingRetries("claude_code", uploadSpool, legacyRetryPending, claudeSourceRetryPending, claudeCursorBefore),
|
|
52
|
+
allHistorySinceMinutes: allLocalHistorySinceMinutes(options.now),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function allLocalHistorySinceMinutes(now) {
|
|
56
|
+
// A cutoff just before the Unix epoch is effectively unbounded for Codex and
|
|
57
|
+
// Claude session files while keeping date arithmetic finite and portable.
|
|
58
|
+
return Math.ceil(now.getTime() / 60_000) + 24 * 60;
|
|
59
|
+
}
|
|
60
|
+
/** Whether the spool already recorded a source-scan retry for this source. */
|
|
61
|
+
function sourceScanRetryIsPending(uploadSpool, source) {
|
|
62
|
+
return Boolean(uploadSpool?.pending_source_retries.some((entry) => entry.source === source));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Does this source have a reason to widen its scan window to all local
|
|
66
|
+
* history instead of the normal live-sync window? Used to be two separately
|
|
67
|
+
* written boolean chains, one per source, that happened to agree on shape by
|
|
68
|
+
* hand — asymmetric to read even though the rule is identical for both
|
|
69
|
+
* (BLI-3394): a legacy spooled upload with no recorded source, a scan-retry
|
|
70
|
+
* the spool already remembers, a spooled upload that names this source, or an
|
|
71
|
+
* undurable session this source's own cursor is still carrying.
|
|
72
|
+
*/
|
|
73
|
+
function sourceHasPendingRetries(source, uploadSpool, legacyRetryPending, sourceScanRetryPending, cursor) {
|
|
74
|
+
return (legacyRetryPending ||
|
|
75
|
+
sourceScanRetryPending ||
|
|
76
|
+
Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes(source))) ||
|
|
77
|
+
cursorHasUndurableCollectableSession(cursor));
|
|
78
|
+
}
|
|
79
|
+
function cursorHasUndurableCollectableSession(cursor) {
|
|
80
|
+
return Object.values(cursor.sessions).some((entry) => liveSyncCursorEntryRequiresRetry(entry));
|
|
81
|
+
}
|