@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.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Writing down what this tick saw: the session report first, then both cursors.
3
+ *
4
+ * The order is the whole reason these live together. The report is queued to
5
+ * the spool BEFORE either cursor moves, so a process that dies in between
6
+ * leaves an exact retry copy behind rather than a cursor that has aged an
7
+ * unreported session out of the live window.
8
+ */
9
+ import { describeError } from "../health-detail.js";
10
+ import { flushPendingCodexSessionReports, queueCodexSessionReport, } from "../upload.js";
11
+ import { CLAUDE_CURSOR_FILENAME, countStaleSessions, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
12
+ import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
13
+ import { liveSyncCursorEntryRequiresRetry } from "./session-sync-attribution.js";
14
+ /**
15
+ * Report this tick's sessions and advance both cursors — in that order, which
16
+ * is the load-bearing part.
17
+ *
18
+ * The report is queued to the spool BEFORE either cursor moves. If the process
19
+ * exits in between, the spool keeps an exact retry copy; if queueing itself
20
+ * fails, the cursors stay at their prior retryable positions instead of aging
21
+ * an unreported session out of the live window.
22
+ */
23
+ export async function reportSessionsAndAdvanceCursors(options) {
24
+ const hadPendingSessionReports = (options.plan.uploadSpool?.pending_session_reports.length ?? 0) > 0;
25
+ const queuedCurrentSessionReport = await queueSessionReportForDelivery({
26
+ homeDir: options.run.homeDir,
27
+ outcomes: options.outcomes,
28
+ sessions: options.sessions,
29
+ now: options.now,
30
+ });
31
+ const staleCounts = await recordBothSourceCursorObservations({
32
+ paths: options.paths,
33
+ plan: options.plan,
34
+ scan: options.scan,
35
+ sessions: options.sessions,
36
+ now: options.now,
37
+ });
38
+ const report = await deliverOrExplainSessionReport({
39
+ hadPendingSessionReports,
40
+ queuedCurrentSessionReport,
41
+ sessionCount: options.sessions.length,
42
+ homeDir: options.run.homeDir,
43
+ fetchImpl: options.run.fetchImpl,
44
+ now: options.now,
45
+ });
46
+ return {
47
+ report,
48
+ reportRequired: options.sessions.length > 0 ||
49
+ hadPendingSessionReports ||
50
+ queuedCurrentSessionReport,
51
+ ...staleCounts,
52
+ };
53
+ }
54
+ /**
55
+ * Persist the metadata-only session report to the spool, before either source
56
+ * cursor advances.
57
+ *
58
+ * If the process exits after this point, the spool keeps an exact retry copy.
59
+ * If queueing itself fails, the cursors remain at their prior retryable
60
+ * positions instead of aging an unreported session out of the live window.
61
+ * Returns whether a report for THIS tick was queued.
62
+ */
63
+ async function queueSessionReportForDelivery(options) {
64
+ const firstUploaded = options.outcomes.find((outcome) => outcome.sync.status === "uploaded");
65
+ if (!firstUploaded || options.sessions.length === 0)
66
+ return false;
67
+ const queued = await queueCodexSessionReport({
68
+ homeDir: options.homeDir,
69
+ dashboardUrl: firstUploaded.sync.dashboard_url,
70
+ generatedAt: options.now.toISOString(),
71
+ workContextId: firstUploaded.sync.work_context_id,
72
+ repoLabel: firstUploaded.worktree.repo_label,
73
+ branch: firstUploaded.worktree.branch,
74
+ repoFingerprint: firstUploaded.worktree.repo_fingerprint,
75
+ repoOriginUrl: firstUploaded.worktree.repo_origin_url,
76
+ worktreeLabel: firstUploaded.worktree.worktree_label,
77
+ worktreeFingerprint: firstUploaded.worktree.worktree_fingerprint,
78
+ worktreeIsPrimary: firstUploaded.worktree.worktree_is_primary,
79
+ sessions: options.sessions,
80
+ });
81
+ return Boolean(queued);
82
+ }
83
+ /**
84
+ * Advance both source cursors to what this tick observed.
85
+ *
86
+ * The sessions cursor is an optimization; a broken local state dir must not
87
+ * turn an already-completed sync into a CLI crash, so each side is best-effort
88
+ * and names its own failure.
89
+ */
90
+ async function recordBothSourceCursorObservations(options) {
91
+ const codexStaleCount = await recordCodexSessionObservationsForSync({
92
+ paths: options.paths,
93
+ codexAttribution: options.scan.codexAttribution,
94
+ sessions: options.sessions,
95
+ now: options.now,
96
+ priorCursor: options.plan.codexCursorBefore,
97
+ codexRetryPending: options.plan.codexRetryPending,
98
+ });
99
+ const claudeStaleCount = await recordClaudeSessionObservationsForSync({
100
+ claudeEnabled: options.plan.claudeEnabled,
101
+ paths: options.paths,
102
+ claudeAttribution: options.scan.claudeAttribution,
103
+ sessions: options.sessions,
104
+ now: options.now,
105
+ claudeCursorBefore: options.plan.claudeCursorBefore,
106
+ claudeRetryPending: options.plan.claudeRetryPending,
107
+ });
108
+ return { codexStaleCount, claudeStaleCount };
109
+ }
110
+ /**
111
+ * Flush the queued session reports — or, when there is nothing queued, say why
112
+ * nothing was posted instead of returning a bare false.
113
+ */
114
+ async function deliverOrExplainSessionReport(options) {
115
+ if (!options.hadPendingSessionReports && !options.queuedCurrentSessionReport) {
116
+ return {
117
+ posted: false,
118
+ reason: options.sessionCount === 0
119
+ ? "no_sessions_observed"
120
+ : "no_successful_sync",
121
+ };
122
+ }
123
+ return flushPendingCodexSessionReports({
124
+ homeDir: options.homeDir,
125
+ fetch: options.fetchImpl,
126
+ now: options.now,
127
+ });
128
+ }
129
+ /**
130
+ * Record this sync's Codex session observations into the Codex cursor and
131
+ * return the stale count. Best-effort by design: a broken local state dir
132
+ * must not turn an already-completed sync into a CLI crash, so a failure here
133
+ * only logs — the stale count simply keeps whatever value it had when the
134
+ * failure happened (0 if the read itself failed).
135
+ *
136
+ * Reads the cursor fresh from disk right before recording rather than reusing
137
+ * `priorCursor`, so a concurrent writer's update is not clobbered. Claude's
138
+ * counterpart does not do this extra read (see
139
+ * `recordClaudeSessionObservationsForSync`) — a real, intentional asymmetry
140
+ * kept as-is rather than forced to match.
141
+ */
142
+ async function recordCodexSessionObservationsForSync(options) {
143
+ let staleCount = 0;
144
+ try {
145
+ const codexCursor = await readRawEvidenceCursor(options.paths);
146
+ staleCount = recordSourceObservations({
147
+ cursor: codexCursor,
148
+ results: options.codexAttribution.results,
149
+ sessions: options.sessions,
150
+ source: "codex",
151
+ sessionIdOf: (result) => result.codex_session_id,
152
+ now: options.now,
153
+ priorCursor: options.priorCursor,
154
+ terminalizeMissingUndurable: options.codexRetryPending &&
155
+ codexAttributionReadFailureCount(options.codexAttribution) === 0,
156
+ });
157
+ codexCursor.updated_at = options.now.toISOString();
158
+ await writeRawEvidenceCursor(options.paths, codexCursor);
159
+ }
160
+ catch (error) {
161
+ // Best-effort: stale counts read 0 and observations re-record next sync —
162
+ // which is fine ONCE. A cursor write that keeps failing means the sessions
163
+ // cursor never advances, every sync re-does the same work, and the only
164
+ // symptom is a stale count that is permanently zero (BLI-3238).
165
+ console.error("[session-sync] Codex session observations were not recorded", JSON.stringify({
166
+ reason: "session_cursor_update_failed",
167
+ source: "codex",
168
+ session_count: options.sessions.length,
169
+ ...describeError(error),
170
+ }));
171
+ }
172
+ return staleCount;
173
+ }
174
+ /**
175
+ * Record this sync's Claude session observations into the Claude cursor and
176
+ * return the stale count — the Claude counterpart to
177
+ * `recordCodexSessionObservationsForSync`. A no-op returning 0 when Claude
178
+ * collection is disabled, matching the source-scan side's own disabled state.
179
+ */
180
+ async function recordClaudeSessionObservationsForSync(options) {
181
+ if (!options.claudeEnabled)
182
+ return 0;
183
+ let staleCount = 0;
184
+ try {
185
+ staleCount = recordSourceObservations({
186
+ cursor: options.claudeCursorBefore,
187
+ results: options.claudeAttribution.results,
188
+ sessions: options.sessions,
189
+ source: "claude_code",
190
+ sessionIdOf: (result) => result.claude_session_id,
191
+ now: options.now,
192
+ priorCursor: options.claudeCursorBefore,
193
+ terminalizeMissingUndurable: options.claudeRetryPending &&
194
+ claudeAttributionReadFailureCount(options.claudeAttribution) === 0,
195
+ });
196
+ options.claudeCursorBefore.updated_at = options.now.toISOString();
197
+ await writeRawEvidenceCursor(options.paths, options.claudeCursorBefore, {
198
+ filename: CLAUDE_CURSOR_FILENAME,
199
+ sessionsOnly: true,
200
+ });
201
+ }
202
+ catch (error) {
203
+ // Best-effort: a broken Claude cursor must not fail the sync. It must
204
+ // still say it is broken — otherwise the Claude half of collection
205
+ // quietly repeats itself forever.
206
+ console.error("[session-sync] Claude session observations were not recorded", JSON.stringify({
207
+ reason: "session_cursor_update_failed",
208
+ source: "claude_code",
209
+ session_count: options.sessions.length,
210
+ ...describeError(error),
211
+ }));
212
+ }
213
+ return staleCount;
214
+ }
215
+ /**
216
+ * Records per-source session observations into its cursor and returns the stale
217
+ * count. Damped/reused Claude sessions carry forward their prior upload
218
+ * timestamp + byte size so the 6h damping window keeps counting from the real
219
+ * last upload (otherwise a slowly-growing file would never re-upload — D21).
220
+ */
221
+ function recordSourceObservations(options) {
222
+ const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
223
+ if (options.terminalizeMissingUndurable) {
224
+ for (const [sessionId, entry] of Object.entries(options.cursor.sessions)) {
225
+ if (seen.has(sessionId) ||
226
+ !liveSyncCursorEntryRequiresRetry(entry)) {
227
+ continue;
228
+ }
229
+ options.cursor.sessions[sessionId] = {
230
+ ...entry,
231
+ state: "skipped",
232
+ reason: "retry_source_missing",
233
+ last_seen_at: options.now.toISOString(),
234
+ };
235
+ }
236
+ }
237
+ const stale = countStaleSessions(options.cursor, seen);
238
+ for (const result of options.results) {
239
+ const sessionId = options.sessionIdOf(result);
240
+ const reported = options.sessions.find((session) => session.source === options.source &&
241
+ session.codex_session_id === sessionId);
242
+ const uploadedThisSync = reported?.upload_state === "uploaded";
243
+ const durableThisSync = reported?.upload_state === "uploaded" ||
244
+ reported?.upload_state === "reused_existing";
245
+ const prior = options.priorCursor?.sessions[sessionId];
246
+ // D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
247
+ // upload-failed for a session that was ALREADY durable must NOT wipe the
248
+ // prior durable state — otherwise damping is forfeited forever and the
249
+ // store row oscillates uploaded -> not_uploaded hourly. Carry the prior
250
+ // durable pointer/timestamp/size forward unless we durably uploaded anew.
251
+ const uploadedObjectKey = durableThisSync
252
+ ? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
253
+ : (prior?.uploaded_object_key ?? null);
254
+ const uploadedAt = uploadedThisSync
255
+ ? options.now.toISOString()
256
+ : (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
257
+ const uploadedByteSize = uploadedThisSync
258
+ ? result.byte_size
259
+ : (prior?.uploaded_byte_size ??
260
+ (durableThisSync ? result.byte_size : null));
261
+ const entry = {
262
+ file_hash_sha256: result.content_hash_sha256,
263
+ file_mtime_ms: result.session_file_mtime_ms,
264
+ byte_size: result.byte_size,
265
+ // Durable byte offset reflects how many bytes are durable remotely (the
266
+ // last uploaded size), not the current file size.
267
+ byte_offset: uploadedByteSize ?? 0,
268
+ state: result.state,
269
+ reason: result.reason,
270
+ worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
271
+ uploaded_object_key: uploadedObjectKey,
272
+ uploaded_at: uploadedAt,
273
+ uploaded_byte_size: uploadedByteSize,
274
+ last_seen_at: options.now.toISOString(),
275
+ };
276
+ recordSessionObservation(options.cursor, sessionId, entry);
277
+ }
278
+ return stale;
279
+ }
@@ -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 {};