@bli-cockpit/cli 0.2.51 → 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.
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Reading both session stores, and settling what the reading owes.
3
+ *
4
+ * Codex and Claude are scanned once per tick each, under a window this step
5
+ * chooses: the normal live window, the 14-day first-run backfill, or all local
6
+ * history when a retry is pending. It also holds the BLI-3551 split that a
7
+ * scan's RETRY reason and a scan's FAILURE reason are two different claims —
8
+ * `repo_not_on_disk` widens the next window and never fails a sync.
9
+ */
10
+ import path from "node:path";
11
+ import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
12
+ import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
13
+ import { CLAUDE_CURSOR_FILENAME } from "../cursors/raw-evidence-cursor.js";
14
+ import { clearSourceRetryFailure, recordSourceRetryFailure, } from "../spool/local-spool.js";
15
+ import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
16
+ const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
17
+ /**
18
+ * Scan and attribute both session sources, then settle each source's scan-retry
19
+ * bookkeeping against what the scan actually found.
20
+ *
21
+ * Codex first, Claude second, and Claude's first-run window is decided between
22
+ * them because it depends on whether the Claude cursor file exists yet.
23
+ */
24
+ export async function scanAndAttributeBothSources(options) {
25
+ const { plan } = options;
26
+ const codexAttribution = await scanCodexSessionsForSync({
27
+ homeDir: options.homeDir,
28
+ worktrees: options.worktrees,
29
+ now: options.now,
30
+ collectionRoots: plan.collectionRoots,
31
+ retryPending: plan.codexRetryPending,
32
+ allHistorySinceMinutes: plan.allHistorySinceMinutes,
33
+ });
34
+ // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
35
+ // days so the first sync captures retroactive history instead of only 24h.
36
+ const claudeCursorExists = await fileExists(path.join(options.paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
37
+ const firstRunBackfill = plan.claudeEnabled && !claudeCursorExists;
38
+ const claudeAttribution = await scanClaudeSessionsForSync({
39
+ claudeEnabled: plan.claudeEnabled,
40
+ homeDir: options.homeDir,
41
+ worktrees: options.worktrees,
42
+ now: options.now,
43
+ collectionRoots: plan.collectionRoots,
44
+ retryPending: plan.claudeRetryPending,
45
+ allHistorySinceMinutes: plan.allHistorySinceMinutes,
46
+ firstRunBackfill,
47
+ });
48
+ await reconcileSourceScanRetry({
49
+ paths: options.paths,
50
+ source: "codex",
51
+ attemptedAt: options.now.toISOString(),
52
+ pendingBefore: plan.codexSourceRetryPending,
53
+ reason: sourceScanRetryReason("codex", codexAttribution),
54
+ });
55
+ if (plan.claudeEnabled) {
56
+ await reconcileSourceScanRetry({
57
+ paths: options.paths,
58
+ source: "claude_code",
59
+ attemptedAt: options.now.toISOString(),
60
+ pendingBefore: plan.claudeSourceRetryPending,
61
+ reason: sourceScanRetryReason("claude_code", claudeAttribution),
62
+ });
63
+ }
64
+ return { codexAttribution, claudeAttribution, firstRunBackfill };
65
+ }
66
+ /**
67
+ * Scan and attribute Codex sessions for this sync, widening the window to all
68
+ * local history when a retry is pending. Codex has no first-run backfill
69
+ * concept (that is a Claude-only window, see `scanClaudeSessionsForSync`) and
70
+ * is always scanned, unlike Claude which can be disabled entirely.
71
+ */
72
+ async function scanCodexSessionsForSync(options) {
73
+ const sinceMinutes = options.retryPending
74
+ ? options.allHistorySinceMinutes
75
+ : CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES;
76
+ let scan = await scanAndAttributeCodexSessions({
77
+ sessionsDirs: defaultCodexSessionDirs(options.homeDir),
78
+ worktrees: options.worktrees,
79
+ now: options.now,
80
+ sinceMinutes,
81
+ limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
82
+ collectionRoots: options.collectionRoots,
83
+ });
84
+ if (scan.session_limit_applied) {
85
+ // Re-run once, bounded by exactly what the first pass discovered, so a
86
+ // capped scan still returns every session it found instead of silently
87
+ // truncating at the window's default limit.
88
+ scan = await scanAndAttributeCodexSessions({
89
+ sessionsDirs: defaultCodexSessionDirs(options.homeDir),
90
+ worktrees: options.worktrees,
91
+ now: options.now,
92
+ sinceMinutes,
93
+ limit: scan.discovered_file_count,
94
+ collectionRoots: options.collectionRoots,
95
+ });
96
+ }
97
+ return scan;
98
+ }
99
+ /**
100
+ * Scan and attribute Claude sessions for this sync — the Claude counterpart to
101
+ * `scanCodexSessionsForSync`. Disabled collection returns an empty scan
102
+ * up front; a first sync (no cursor yet) widens the window to 14 days instead
103
+ * of the normal 24h so it captures retroactive history.
104
+ */
105
+ async function scanClaudeSessionsForSync(options) {
106
+ if (!options.claudeEnabled)
107
+ return emptyClaudeScan();
108
+ const sinceMinutes = options.retryPending
109
+ ? options.allHistorySinceMinutes
110
+ : options.firstRunBackfill
111
+ ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
112
+ : undefined;
113
+ const projectsDir = path.join(options.homeDir, ".claude", "projects");
114
+ let scan = await scanAndAttributeClaudeSessions({
115
+ projectsDir,
116
+ worktrees: options.worktrees,
117
+ now: options.now,
118
+ collectionRoots: options.collectionRoots,
119
+ sinceMinutes,
120
+ });
121
+ if (scan.session_limit_applied) {
122
+ scan = await scanAndAttributeClaudeSessions({
123
+ projectsDir,
124
+ worktrees: options.worktrees,
125
+ now: options.now,
126
+ collectionRoots: options.collectionRoots,
127
+ sinceMinutes,
128
+ limit: scan.discovered_session_count,
129
+ });
130
+ }
131
+ return scan;
132
+ }
133
+ export function sourceScanRetryReason(source, scan) {
134
+ const reasons = new Set();
135
+ const failure = sourceScanFailureReason(source, scan);
136
+ if (failure)
137
+ reasons.add(failure);
138
+ // Kept HERE and nowhere else (BLI-3551): a repo that is not on disk is a
139
+ // reason to widen the next scan window, because the transcript fallback can
140
+ // still attribute it. It is not a reason to call this sync failed — see
141
+ // `sourceScanFailureReason`.
142
+ if (scan.results.some((result) => result.reason === "repo_not_on_disk")) {
143
+ reasons.add("repo_not_on_disk");
144
+ }
145
+ return reasons.size > 0 ? [...reasons].sort().join(",") : null;
146
+ }
147
+ /**
148
+ * The part of the scan outcome that is a genuine FAILURE: the session store
149
+ * itself could not be read, so sessions that exist were not seen.
150
+ *
151
+ * Split from {@link sourceScanRetryReason} in BLI-3551. The two used to be one
152
+ * function, so `repo_not_on_disk` — a label the attribution umbrella finding
153
+ * already established is not a defect (nothing was deleted; the transcript
154
+ * names a path git no longer tracks) — failed the sync on every tick for three
155
+ * operators. A retry hint and a failure are different claims.
156
+ */
157
+ export function sourceScanFailureReason(source, scan) {
158
+ const readFailureCount = source === "codex"
159
+ ? codexAttributionReadFailureCount(scan)
160
+ : claudeAttributionReadFailureCount(scan);
161
+ return readFailureCount > 0 ? `${source}_session_store_read_failed` : null;
162
+ }
163
+ async function reconcileSourceScanRetry(options) {
164
+ if (options.reason) {
165
+ await recordSourceRetryFailure(options.paths, {
166
+ source: options.source,
167
+ reason: options.reason,
168
+ attemptedAt: options.attemptedAt,
169
+ });
170
+ return;
171
+ }
172
+ if (options.pendingBefore) {
173
+ await clearSourceRetryFailure(options.paths, options.source, options.attemptedAt);
174
+ }
175
+ }
176
+ function emptyClaudeScan() {
177
+ return {
178
+ results: [],
179
+ discovered_session_count: 0,
180
+ scanned_session_count: 0,
181
+ since_minutes: 0,
182
+ session_limit: 0,
183
+ session_limit_applied: false,
184
+ max_file_bytes: 0,
185
+ max_sidecar_files: 0,
186
+ max_line_buffer_bytes: 0,
187
+ project_dirs_skipped: 0,
188
+ project_dir_read_failed_count: 0,
189
+ session_stat_failed_count: 0,
190
+ sidecar_dir_read_failed_count: 0,
191
+ sidecar_stat_failed_count: 0,
192
+ disabled_reason: "claude_collection_disabled_by_config",
193
+ counts: {
194
+ attributed: 0,
195
+ attributed_fallback: 0,
196
+ ambiguous: 0,
197
+ unattributed: 0,
198
+ skipped: 0,
199
+ mains_oversized: 0,
200
+ oversized_lines_skipped: 0,
201
+ sessions_schema_drift: 0,
202
+ sidecars_capped: 0,
203
+ },
204
+ };
205
+ }
206
+ async function fileExists(filePath) {
207
+ const { stat } = await import("node:fs/promises");
208
+ return stat(filePath).then(() => true, () => false);
209
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The shapes one attributed sync pass hands from step to step.
3
+ *
4
+ * They live together, away from any step, because each one is a contract
5
+ * BETWEEN two steps: the plan the scan reads, the scan the upload reads, the
6
+ * upload the recorder reads. A step that owned its own output type would make
7
+ * the reader open the producer to learn what the consumer receives.
8
+ *
9
+ * Every name here is re-exported from `./session-sync.js`, which is the address
10
+ * callers already know.
11
+ */
12
+ export {};
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Sending this tick's attributed transcripts, one worktree at a time.
3
+ *
4
+ * The whole loop shares ONE raw-evidence budget (D7b), so a parent-folder sync
5
+ * over many worktrees honours a single byte/object cap rather than N times it,
6
+ * and the Claude growth damper lives here because it is the only step that can
7
+ * see both what the cursor already made durable and what is about to be sent.
8
+ */
9
+ import { startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from "../local-state.js";
10
+ import { LocalUploadBlockedError, syncLocalAmbientEnvelope, } from "../upload.js";
11
+ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET, } from "../adapters/raw-evidence.js";
12
+ import { liveSyncTargetKey, liveSyncTargetWorktrees, matchesLiveSyncWorktree, } from "./session-sync-attribution.js";
13
+ const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
14
+ const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
15
+ /**
16
+ * Sync every worktree this tick is responsible for, in order, under one shared
17
+ * raw-evidence budget.
18
+ *
19
+ * The budget is per-sync (D7b), not per-worktree: a parent-folder sync over
20
+ * many worktrees honors a single byte/object cap rather than N times it.
21
+ */
22
+ export async function syncEveryTargetWorktree(options) {
23
+ const { run, scan } = options;
24
+ const syncWorktrees = liveSyncTargetWorktrees(run.worktrees, [
25
+ ...scan.codexAttribution.results,
26
+ ...scan.claudeAttribution.results,
27
+ ]);
28
+ const discoveredWorktreeKeys = new Set(run.worktrees.map(liveSyncTargetKey));
29
+ // sessionId -> prior durable pointer for damped sessions (drives the
30
+ // growth_damped count + the skip_main decision).
31
+ const dampedClaudePointers = new Map();
32
+ const rawEvidenceBudget = {
33
+ remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
34
+ remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
35
+ };
36
+ const outcomes = [];
37
+ let everyWorktreeUploaded = true;
38
+ for (const worktree of syncWorktrees) {
39
+ const outcome = await syncOneWorktree({
40
+ run,
41
+ now: options.now,
42
+ plan: options.plan,
43
+ scan,
44
+ worktree,
45
+ syncWorktrees,
46
+ rawEvidenceBudget,
47
+ // A target git discovery never produced is one the fallback attribution
48
+ // synthesized, and it syncs through its own attributed work context.
49
+ isDiscoveredWorktree: discoveredWorktreeKeys.has(liveSyncTargetKey(worktree)),
50
+ recordDampedClaudeSession: (sessionId, priorPointer) => {
51
+ dampedClaudePointers.set(sessionId, priorPointer);
52
+ },
53
+ });
54
+ everyWorktreeUploaded =
55
+ everyWorktreeUploaded && outcome.sync.status === "uploaded";
56
+ outcomes.push(outcome);
57
+ }
58
+ return {
59
+ outcomes,
60
+ everyWorktreeUploaded,
61
+ claudePriorDurablePointers: claudeDurablePointersFromCursor(options.plan.claudeCursorBefore),
62
+ growthDampedSessionCount: dampedClaudePointers.size,
63
+ };
64
+ }
65
+ /** Every Claude session the cursor already holds a durable pointer for. */
66
+ function claudeDurablePointersFromCursor(claudeCursorBefore) {
67
+ const pointersBySessionId = new Map();
68
+ for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
69
+ if (entry.uploaded_object_key) {
70
+ pointersBySessionId.set(sessionId, entry.uploaded_object_key);
71
+ }
72
+ }
73
+ return pointersBySessionId;
74
+ }
75
+ /**
76
+ * Sync one worktree's attributed transcripts, starting a work context first
77
+ * when this target needs one.
78
+ *
79
+ * A newly cloned repo has no work context yet. Capture is permissive and ticket
80
+ * binding comes later, so a `missing_context` refusal starts general ambient
81
+ * capture for that repo and retries, instead of blocking every other repo's
82
+ * sync until someone runs `cockpit start` by hand.
83
+ */
84
+ async function syncOneWorktree(options) {
85
+ const { run, worktree } = options;
86
+ const attributedSyntheticTarget = options.isDiscoveredWorktree
87
+ ? null
88
+ : worktree;
89
+ const contextOptions = {
90
+ homeDir: run.homeDir,
91
+ repoRoot: worktree.repo_root,
92
+ activeTicketId: run.activeTicketId,
93
+ operatorId: run.operatorId,
94
+ sessionId: run.sessionId,
95
+ ...(attributedSyntheticTarget ? {} : { branch: run.branch }),
96
+ };
97
+ const ensureContext = () => attributedSyntheticTarget
98
+ ? startLocalWorkContextForAttributedTarget(contextOptions, attributedSyntheticTarget)
99
+ : startLocalWorkContext(contextOptions);
100
+ let context = null;
101
+ if (run.startContexts || attributedSyntheticTarget) {
102
+ context = await ensureContext();
103
+ }
104
+ const syncOptions = ambientEnvelopeForWorktree(options);
105
+ let sync;
106
+ try {
107
+ sync = await syncLocalAmbientEnvelope(syncOptions);
108
+ }
109
+ catch (error) {
110
+ if (error instanceof LocalUploadBlockedError &&
111
+ error.blocker === "missing_context") {
112
+ context = await ensureContext();
113
+ sync = await syncLocalAmbientEnvelope(syncOptions);
114
+ }
115
+ else {
116
+ throw error;
117
+ }
118
+ }
119
+ return { worktree, context, sync };
120
+ }
121
+ /**
122
+ * Assemble what one worktree is about to upload: its sibling worktree
123
+ * inventory, its Codex mains, its Claude mains and sidecars, and its share of
124
+ * the sync-wide raw-evidence budget.
125
+ */
126
+ function ambientEnvelopeForWorktree(options) {
127
+ const { run, worktree, scan } = options;
128
+ const claudeSessionFiles = claudeSessionFilesForWorktree({
129
+ worktree,
130
+ claudeResults: scan.claudeAttribution.results,
131
+ claudeCursorBefore: options.plan.claudeCursorBefore,
132
+ now: options.now,
133
+ recordDampedClaudeSession: options.recordDampedClaudeSession,
134
+ });
135
+ return {
136
+ homeDir: run.homeDir,
137
+ repoRoot: worktree.repo_root,
138
+ dashboardUrl: run.dashboardUrl,
139
+ worktreeInventory: worktreeInventoryForRepo(worktree, options.syncWorktrees),
140
+ codexSessionFiles: scan.codexAttribution.results
141
+ .filter((result) => matchesLiveSyncWorktree(result, worktree))
142
+ .map((result) => ({
143
+ local_path: result.file_path,
144
+ codex_session_id: result.codex_session_id,
145
+ })),
146
+ codexAttributionScan: scan.codexAttribution,
147
+ claudeSessionFiles,
148
+ claudeAttributionScan: scan.claudeAttribution,
149
+ rawEvidenceBudget: options.rawEvidenceBudget,
150
+ fetch: run.fetchImpl,
151
+ };
152
+ }
153
+ /**
154
+ * This worktree's Claude mains and sidecars, with the growth damper applied.
155
+ *
156
+ * A damped main is still reported — `skip_main` means "do not re-upload the
157
+ * body this tick", not "forget this session" — and its prior durable pointer is
158
+ * recorded so the session report can say `reused_existing` instead of flipping
159
+ * the row to not_uploaded.
160
+ */
161
+ function claudeSessionFilesForWorktree(options) {
162
+ return options.claudeResults
163
+ .filter((result) => matchesLiveSyncWorktree(result, options.worktree))
164
+ .map((result) => {
165
+ const damped = !result.main_file_oversized &&
166
+ shouldDampClaudeMain(result, options.claudeCursorBefore, options.now);
167
+ if (damped) {
168
+ options.recordDampedClaudeSession(result.claude_session_id, options.claudeCursorBefore.sessions[result.claude_session_id]
169
+ ?.uploaded_object_key ?? null);
170
+ }
171
+ return {
172
+ local_path: result.file_path,
173
+ claude_session_id: result.claude_session_id,
174
+ main_file_oversized: result.main_file_oversized,
175
+ skip_main: damped,
176
+ sidecar_files: result.sidecar_files
177
+ .filter((sidecar) => !sidecar.skipped_reason)
178
+ .map((sidecar) => ({ local_path: sidecar.local_path })),
179
+ };
180
+ });
181
+ }
182
+ function shouldDampClaudeMain(result, cursor, now) {
183
+ const entry = cursor.sessions[result.claude_session_id];
184
+ if (!entry ||
185
+ !entry.uploaded_object_key ||
186
+ !entry.uploaded_at ||
187
+ entry.uploaded_byte_size == null) {
188
+ return false;
189
+ }
190
+ const grew = result.byte_size > entry.uploaded_byte_size;
191
+ if (!grew)
192
+ return false; // unchanged content reuses via the object cursor
193
+ const growth = result.byte_size - entry.uploaded_byte_size;
194
+ const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
195
+ return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
196
+ Number.isFinite(ageMs) &&
197
+ ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
198
+ }
199
+ function worktreeInventoryForRepo(current, worktrees) {
200
+ return worktrees
201
+ .filter((worktree) => worktree.repo_fingerprint
202
+ ? worktree.repo_fingerprint === current.repo_fingerprint
203
+ : worktree.repo_label === current.repo_label)
204
+ .map((worktree) => ({
205
+ repo: worktree.repo_root,
206
+ repo_label: worktree.repo_label,
207
+ repo_fingerprint: worktree.repo_fingerprint,
208
+ repo_origin_url: worktree.repo_origin_url ?? undefined,
209
+ head_sha: worktree.head_sha ?? undefined,
210
+ worktree_label: worktree.worktree_label,
211
+ worktree_fingerprint: worktree.worktree_fingerprint,
212
+ worktree_is_primary: worktree.worktree_is_primary,
213
+ branch: worktree.branch,
214
+ }));
215
+ }