@bli-cockpit/cli 0.2.52 → 0.2.54

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.
@@ -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 { getCollectorRuntimePaths, } from "../local-state.js";
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
- function cleanJson(footprint, plan, vaults, pruned) {
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,
@@ -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: ["--home", "--dry-run", "--all-committed", "--json"],
607
- valueFlags: ["--home"],
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 or --all-committed.");
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
- "The scheduled sync does the same prune once a day; this is the door for a",
564
- "laptop that is full right now.",
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.52");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.54");
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
+ }