@bli-cockpit/cli 0.2.34 → 0.2.36
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/backfill.js +493 -408
- package/dist/commands/local.js +377 -303
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +323 -210
- package/package.json +2 -2
|
@@ -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.36");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -46,6 +46,92 @@ function allLocalHistorySinceMinutes(now) {
|
|
|
46
46
|
// Claude session files while keeping date arithmetic finite and portable.
|
|
47
47
|
return Math.ceil(now.getTime() / 60_000) + 24 * 60;
|
|
48
48
|
}
|
|
49
|
+
/** Whether the spool already recorded a source-scan retry for this source. */
|
|
50
|
+
function sourceScanRetryIsPending(uploadSpool, source) {
|
|
51
|
+
return Boolean(uploadSpool?.pending_source_retries.some((entry) => entry.source === source));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Does this source have a reason to widen its scan window to all local
|
|
55
|
+
* history instead of the normal live-sync window? Used to be two separately
|
|
56
|
+
* written boolean chains, one per source, that happened to agree on shape by
|
|
57
|
+
* hand — asymmetric to read even though the rule is identical for both
|
|
58
|
+
* (BLI-3394): a legacy spooled upload with no recorded source, a scan-retry
|
|
59
|
+
* the spool already remembers, a spooled upload that names this source, or an
|
|
60
|
+
* undurable session this source's own cursor is still carrying.
|
|
61
|
+
*/
|
|
62
|
+
function sourceHasPendingRetries(source, uploadSpool, legacyRetryPending, sourceScanRetryPending, cursor) {
|
|
63
|
+
return (legacyRetryPending ||
|
|
64
|
+
sourceScanRetryPending ||
|
|
65
|
+
Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes(source))) ||
|
|
66
|
+
cursorHasUndurableCollectableSession(cursor));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Scan and attribute Codex sessions for this sync, widening the window to all
|
|
70
|
+
* local history when a retry is pending. Codex has no first-run backfill
|
|
71
|
+
* concept (that is a Claude-only window, see `scanClaudeSessionsForSync`) and
|
|
72
|
+
* is always scanned, unlike Claude which can be disabled entirely.
|
|
73
|
+
*/
|
|
74
|
+
async function scanCodexSessionsForSync(options) {
|
|
75
|
+
const sinceMinutes = options.retryPending
|
|
76
|
+
? options.allHistorySinceMinutes
|
|
77
|
+
: CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES;
|
|
78
|
+
let scan = await scanAndAttributeCodexSessions({
|
|
79
|
+
sessionsDirs: defaultCodexSessionDirs(options.homeDir),
|
|
80
|
+
worktrees: options.worktrees,
|
|
81
|
+
now: options.now,
|
|
82
|
+
sinceMinutes,
|
|
83
|
+
limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
|
|
84
|
+
collectionRoots: options.collectionRoots,
|
|
85
|
+
});
|
|
86
|
+
if (scan.session_limit_applied) {
|
|
87
|
+
// Re-run once, bounded by exactly what the first pass discovered, so a
|
|
88
|
+
// capped scan still returns every session it found instead of silently
|
|
89
|
+
// truncating at the window's default limit.
|
|
90
|
+
scan = await scanAndAttributeCodexSessions({
|
|
91
|
+
sessionsDirs: defaultCodexSessionDirs(options.homeDir),
|
|
92
|
+
worktrees: options.worktrees,
|
|
93
|
+
now: options.now,
|
|
94
|
+
sinceMinutes,
|
|
95
|
+
limit: scan.discovered_file_count,
|
|
96
|
+
collectionRoots: options.collectionRoots,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return scan;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Scan and attribute Claude sessions for this sync — the Claude counterpart to
|
|
103
|
+
* `scanCodexSessionsForSync`. Disabled collection returns an empty scan
|
|
104
|
+
* up front; a first sync (no cursor yet) widens the window to 14 days instead
|
|
105
|
+
* of the normal 24h so it captures retroactive history.
|
|
106
|
+
*/
|
|
107
|
+
async function scanClaudeSessionsForSync(options) {
|
|
108
|
+
if (!options.claudeEnabled)
|
|
109
|
+
return emptyClaudeScan();
|
|
110
|
+
const sinceMinutes = options.retryPending
|
|
111
|
+
? options.allHistorySinceMinutes
|
|
112
|
+
: options.firstRunBackfill
|
|
113
|
+
? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
|
|
114
|
+
: undefined;
|
|
115
|
+
const projectsDir = path.join(options.homeDir, ".claude", "projects");
|
|
116
|
+
let scan = await scanAndAttributeClaudeSessions({
|
|
117
|
+
projectsDir,
|
|
118
|
+
worktrees: options.worktrees,
|
|
119
|
+
now: options.now,
|
|
120
|
+
collectionRoots: options.collectionRoots,
|
|
121
|
+
sinceMinutes,
|
|
122
|
+
});
|
|
123
|
+
if (scan.session_limit_applied) {
|
|
124
|
+
scan = await scanAndAttributeClaudeSessions({
|
|
125
|
+
projectsDir,
|
|
126
|
+
worktrees: options.worktrees,
|
|
127
|
+
now: options.now,
|
|
128
|
+
collectionRoots: options.collectionRoots,
|
|
129
|
+
sinceMinutes,
|
|
130
|
+
limit: scan.discovered_session_count,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return scan;
|
|
134
|
+
}
|
|
49
135
|
function codexAttributionReadFailureCount(scan) {
|
|
50
136
|
return scan.directory_read_failed_count + scan.stat_failed_count;
|
|
51
137
|
}
|
|
@@ -140,71 +226,34 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
140
226
|
readLocalUploadSpoolState(paths).catch(() => null),
|
|
141
227
|
]);
|
|
142
228
|
const legacyRetryPending = Boolean(uploadSpool?.pending_uploads.some((entry) => entry.raw_evidence_file_count > 0 && entry.retry_sources.length === 0));
|
|
143
|
-
const codexSourceRetryPending =
|
|
144
|
-
const claudeSourceRetryPending =
|
|
145
|
-
const codexRetryPending = legacyRetryPending
|
|
146
|
-
codexSourceRetryPending ||
|
|
147
|
-
Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes("codex"))) ||
|
|
148
|
-
cursorHasUndurableCollectableSession(codexCursorBefore);
|
|
229
|
+
const codexSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "codex");
|
|
230
|
+
const claudeSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "claude_code");
|
|
231
|
+
const codexRetryPending = sourceHasPendingRetries("codex", uploadSpool, legacyRetryPending, codexSourceRetryPending, codexCursorBefore);
|
|
149
232
|
const claudeRetryPending = claudeEnabled &&
|
|
150
|
-
(legacyRetryPending
|
|
151
|
-
claudeSourceRetryPending ||
|
|
152
|
-
Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes("claude_code"))) ||
|
|
153
|
-
cursorHasUndurableCollectableSession(claudeCursorBefore));
|
|
233
|
+
sourceHasPendingRetries("claude_code", uploadSpool, legacyRetryPending, claudeSourceRetryPending, claudeCursorBefore);
|
|
154
234
|
const allHistorySinceMinutes = allLocalHistorySinceMinutes(now);
|
|
155
|
-
|
|
156
|
-
|
|
235
|
+
const codexAttribution = await scanCodexSessionsForSync({
|
|
236
|
+
homeDir,
|
|
157
237
|
worktrees: options.worktrees,
|
|
158
238
|
now,
|
|
159
|
-
sinceMinutes: codexRetryPending
|
|
160
|
-
? allHistorySinceMinutes
|
|
161
|
-
: CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
|
|
162
|
-
limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
|
|
163
239
|
collectionRoots,
|
|
240
|
+
retryPending: codexRetryPending,
|
|
241
|
+
allHistorySinceMinutes,
|
|
164
242
|
});
|
|
165
|
-
if (codexAttribution.session_limit_applied) {
|
|
166
|
-
codexAttribution = await scanAndAttributeCodexSessions({
|
|
167
|
-
sessionsDirs: defaultCodexSessionDirs(homeDir),
|
|
168
|
-
worktrees: options.worktrees,
|
|
169
|
-
now,
|
|
170
|
-
sinceMinutes: codexRetryPending
|
|
171
|
-
? allHistorySinceMinutes
|
|
172
|
-
: CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
|
|
173
|
-
limit: codexAttribution.discovered_file_count,
|
|
174
|
-
collectionRoots,
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
243
|
// First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
|
|
178
244
|
// days so the first sync captures retroactive history instead of only 24h.
|
|
179
245
|
const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
|
|
180
246
|
const firstRunBackfill = claudeEnabled && !claudeCursorExists;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
: undefined,
|
|
192
|
-
})
|
|
193
|
-
: emptyClaudeScan();
|
|
194
|
-
if (claudeAttribution.session_limit_applied) {
|
|
195
|
-
claudeAttribution = await scanAndAttributeClaudeSessions({
|
|
196
|
-
projectsDir: path.join(homeDir, ".claude", "projects"),
|
|
197
|
-
worktrees: options.worktrees,
|
|
198
|
-
now,
|
|
199
|
-
collectionRoots,
|
|
200
|
-
sinceMinutes: claudeRetryPending
|
|
201
|
-
? allHistorySinceMinutes
|
|
202
|
-
: firstRunBackfill
|
|
203
|
-
? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
|
|
204
|
-
: undefined,
|
|
205
|
-
limit: claudeAttribution.discovered_session_count,
|
|
206
|
-
});
|
|
207
|
-
}
|
|
247
|
+
const claudeAttribution = await scanClaudeSessionsForSync({
|
|
248
|
+
claudeEnabled,
|
|
249
|
+
homeDir,
|
|
250
|
+
worktrees: options.worktrees,
|
|
251
|
+
now,
|
|
252
|
+
collectionRoots,
|
|
253
|
+
retryPending: claudeRetryPending,
|
|
254
|
+
allHistorySinceMinutes,
|
|
255
|
+
firstRunBackfill,
|
|
256
|
+
});
|
|
208
257
|
await reconcileSourceScanRetry({
|
|
209
258
|
paths,
|
|
210
259
|
source: "codex",
|
|
@@ -359,67 +408,23 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
359
408
|
queuedCurrentSessionReport;
|
|
360
409
|
// The sessions cursor is an optimization; a broken local state dir must not
|
|
361
410
|
// turn already-completed syncs into a CLI crash.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
catch (error) {
|
|
381
|
-
// Best-effort: stale counts read 0 and observations re-record next sync —
|
|
382
|
-
// which is fine ONCE. A cursor write that keeps failing means the sessions
|
|
383
|
-
// cursor never advances, every sync re-does the same work, and the only
|
|
384
|
-
// symptom is a stale count that is permanently zero (BLI-3238).
|
|
385
|
-
console.error("[session-sync] Codex session observations were not recorded", JSON.stringify({
|
|
386
|
-
reason: "session_cursor_update_failed",
|
|
387
|
-
source: "codex",
|
|
388
|
-
session_count: sessions.length,
|
|
389
|
-
...describeError(error),
|
|
390
|
-
}));
|
|
391
|
-
}
|
|
392
|
-
if (claudeEnabled) {
|
|
393
|
-
try {
|
|
394
|
-
claudeStaleCount = recordSourceObservations({
|
|
395
|
-
cursor: claudeCursorBefore,
|
|
396
|
-
results: claudeAttribution.results,
|
|
397
|
-
sessions,
|
|
398
|
-
source: "claude_code",
|
|
399
|
-
sessionIdOf: (result) => result.claude_session_id,
|
|
400
|
-
now,
|
|
401
|
-
priorCursor: claudeCursorBefore,
|
|
402
|
-
terminalizeMissingUndurable: claudeRetryPending &&
|
|
403
|
-
claudeAttributionReadFailureCount(claudeAttribution) === 0,
|
|
404
|
-
});
|
|
405
|
-
claudeCursorBefore.updated_at = now.toISOString();
|
|
406
|
-
await writeRawEvidenceCursor(paths, claudeCursorBefore, {
|
|
407
|
-
filename: CLAUDE_CURSOR_FILENAME,
|
|
408
|
-
sessionsOnly: true,
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
catch (error) {
|
|
412
|
-
// Best-effort: a broken Claude cursor must not fail the sync. It must
|
|
413
|
-
// still say it is broken — otherwise the Claude half of collection
|
|
414
|
-
// quietly repeats itself forever.
|
|
415
|
-
console.error("[session-sync] Claude session observations were not recorded", JSON.stringify({
|
|
416
|
-
reason: "session_cursor_update_failed",
|
|
417
|
-
source: "claude_code",
|
|
418
|
-
session_count: sessions.length,
|
|
419
|
-
...describeError(error),
|
|
420
|
-
}));
|
|
421
|
-
}
|
|
422
|
-
}
|
|
411
|
+
const codexStaleCount = await recordCodexSessionObservationsForSync({
|
|
412
|
+
paths,
|
|
413
|
+
codexAttribution,
|
|
414
|
+
sessions,
|
|
415
|
+
now,
|
|
416
|
+
priorCursor: codexCursorBefore,
|
|
417
|
+
codexRetryPending,
|
|
418
|
+
});
|
|
419
|
+
const claudeStaleCount = await recordClaudeSessionObservationsForSync({
|
|
420
|
+
claudeEnabled,
|
|
421
|
+
paths,
|
|
422
|
+
claudeAttribution,
|
|
423
|
+
sessions,
|
|
424
|
+
now,
|
|
425
|
+
claudeCursorBefore,
|
|
426
|
+
claudeRetryPending,
|
|
427
|
+
});
|
|
423
428
|
const report = hadPendingSessionReports || queuedCurrentSessionReport
|
|
424
429
|
? await flushPendingCodexSessionReports({
|
|
425
430
|
homeDir: options.homeDir,
|
|
@@ -529,17 +534,16 @@ function normalizeClaudeResult(result) {
|
|
|
529
534
|
};
|
|
530
535
|
}
|
|
531
536
|
/**
|
|
532
|
-
*
|
|
533
|
-
* `(source, session_id)
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
* carrying their prior durable pointer.
|
|
537
|
+
* Dedupe every source's attributed results down to the single best result per
|
|
538
|
+
* `(source, session_id)`. Best means highest attribution-state rank, ties
|
|
539
|
+
* broken by the newer file mtime. Codex and Claude sessions that happen to
|
|
540
|
+
* share an id are never collapsed into each other because the key carries the
|
|
541
|
+
* source.
|
|
538
542
|
*/
|
|
539
|
-
|
|
543
|
+
function bestAttributedSessionsByKey(codexResults, claudeResults) {
|
|
540
544
|
const normalized = [
|
|
541
|
-
...
|
|
542
|
-
...
|
|
545
|
+
...codexResults.map(normalizeCodexResult),
|
|
546
|
+
...claudeResults.map(normalizeClaudeResult),
|
|
543
547
|
];
|
|
544
548
|
const bestByKey = new Map();
|
|
545
549
|
for (const result of normalized) {
|
|
@@ -554,20 +558,19 @@ export function buildAgentSessionReport(options) {
|
|
|
554
558
|
bestByKey.set(key, result);
|
|
555
559
|
}
|
|
556
560
|
}
|
|
557
|
-
|
|
558
|
-
|
|
561
|
+
return bestByKey;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Which sessions this sync actually uploaded, and why the rest did not — read
|
|
565
|
+
* ONLY from main-file outcomes (kind `codex_jsonl` / `claude_jsonl`); a
|
|
566
|
+
* sidecar making it must never mark a session uploaded when the main did not
|
|
567
|
+
* (D3).
|
|
568
|
+
*/
|
|
569
|
+
function sessionUploadOutcomesByKey(outcomes) {
|
|
559
570
|
const uploadByKey = new Map();
|
|
560
|
-
// BLI-2107: why a session got no pointer, keyed the same way. The reasons
|
|
561
|
-
// already existed — file_too_large, deferred_byte_budget, the redaction
|
|
562
|
-
// guards — but only as aggregate skip counts in the health report, so no
|
|
563
|
-
// individual session could say what happened to it.
|
|
564
571
|
const noUploadReasonByKey = new Map();
|
|
565
|
-
// A worktree sync that did not finish carries no per-session outcomes, and
|
|
566
|
-
// WorktreeSyncOutcome does not record which sessions it was going to cover.
|
|
567
|
-
// So this pass knows only that it was degraded, not which session each
|
|
568
|
-
// failure belonged to — and says exactly that rather than picking one.
|
|
569
572
|
let anySyncIncomplete = false;
|
|
570
|
-
for (const outcome of
|
|
573
|
+
for (const outcome of outcomes) {
|
|
571
574
|
if (outcome.sync.status !== "uploaded") {
|
|
572
575
|
anySyncIncomplete = true;
|
|
573
576
|
continue;
|
|
@@ -593,84 +596,194 @@ export function buildAgentSessionReport(options) {
|
|
|
593
596
|
uploadByKey.set(key, upload);
|
|
594
597
|
}
|
|
595
598
|
}
|
|
596
|
-
return
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
:
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
599
|
+
return { uploadByKey, noUploadReasonByKey, anySyncIncomplete };
|
|
600
|
+
}
|
|
601
|
+
/** Turn one deduped, best-ranked session result into its report row. */
|
|
602
|
+
function sessionReportEntry(result, context) {
|
|
603
|
+
const key = `${result.source}:${result.session_id}`;
|
|
604
|
+
const upload = context.uploadByKey.get(key);
|
|
605
|
+
// A previously-durable Claude session with no fresh main upload this sync
|
|
606
|
+
// (damped / spooled / budget-deferred) reports reused_existing + its prior
|
|
607
|
+
// pointer rather than not_uploaded, so the store row never flips.
|
|
608
|
+
const priorDurablePointer = result.source === "claude_code"
|
|
609
|
+
? (context.claudePriorDurablePointers.get(result.session_id) ?? null)
|
|
610
|
+
: null;
|
|
611
|
+
return {
|
|
612
|
+
codex_session_id: result.session_id,
|
|
613
|
+
source: result.source,
|
|
614
|
+
observed_at: context.now.toISOString(),
|
|
615
|
+
attribution_state: result.state,
|
|
616
|
+
attribution_reason: result.reason,
|
|
617
|
+
attribution_score: result.attribution_score,
|
|
618
|
+
path_score: result.path_score,
|
|
619
|
+
signals: result.signals,
|
|
620
|
+
...(result.content_hash_sha256
|
|
621
|
+
? { session_file_hash_sha256: result.content_hash_sha256 }
|
|
622
|
+
: {}),
|
|
623
|
+
session_file_byte_size: result.byte_size,
|
|
624
|
+
session_file_mtime: result.session_file_mtime,
|
|
625
|
+
...(result.worktree
|
|
626
|
+
? {
|
|
627
|
+
repo_fingerprint: result.worktree.repo_fingerprint,
|
|
628
|
+
worktree_fingerprint: result.worktree.worktree_fingerprint,
|
|
629
|
+
repo_label: result.worktree.repo_label,
|
|
630
|
+
branch: result.worktree.branch,
|
|
631
|
+
}
|
|
632
|
+
: {}),
|
|
633
|
+
...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
|
|
634
|
+
...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
|
|
635
|
+
...(upload
|
|
636
|
+
? {
|
|
637
|
+
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
638
|
+
upload_state: upload.upload_state,
|
|
639
|
+
// A failed upload names itself; a successful one has nothing to
|
|
640
|
+
// explain.
|
|
641
|
+
...(upload.upload_state === "upload_failed"
|
|
642
|
+
? {
|
|
643
|
+
upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED,
|
|
644
|
+
}
|
|
645
|
+
: {}),
|
|
646
|
+
}
|
|
647
|
+
: priorDurablePointer
|
|
630
648
|
? {
|
|
631
|
-
raw_evidence_pointer_id:
|
|
632
|
-
upload_state:
|
|
633
|
-
// A failed upload names itself; a successful one has nothing to
|
|
634
|
-
// explain.
|
|
635
|
-
...(upload.upload_state === "upload_failed"
|
|
636
|
-
? {
|
|
637
|
-
upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED,
|
|
638
|
-
}
|
|
639
|
-
: {}),
|
|
649
|
+
raw_evidence_pointer_id: priorDurablePointer,
|
|
650
|
+
upload_state: "reused_existing",
|
|
640
651
|
}
|
|
641
|
-
:
|
|
652
|
+
: isRawEvidenceUploadableAttributionState(result.state, result.worktree !== null)
|
|
642
653
|
? {
|
|
643
|
-
|
|
644
|
-
|
|
654
|
+
upload_state: "not_uploaded",
|
|
655
|
+
// BLI-2107: `not_uploaded` used to be the branch of last
|
|
656
|
+
// resort, recording that nothing happened and never why. It
|
|
657
|
+
// now always carries a cause, even when the cause is that we
|
|
658
|
+
// have none — a session labelled NO_UPLOAD_ATTEMPT_RECORDED
|
|
659
|
+
// is a path that still needs instrumenting, and saying so is
|
|
660
|
+
// the point.
|
|
661
|
+
upload_reason: context.noUploadReasonByKey.get(key) ??
|
|
662
|
+
(context.anySyncIncomplete
|
|
663
|
+
? "sync_incomplete_this_pass"
|
|
664
|
+
: NO_UPLOAD_ATTEMPT_RECORDED),
|
|
645
665
|
}
|
|
646
|
-
:
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
666
|
+
: {
|
|
667
|
+
// BLI-3272: the upload policy refused this session's
|
|
668
|
+
// attribution state. That refusal used to spread `{}` here, so
|
|
669
|
+
// the row landed with upload_state NULL and upload_reason NULL
|
|
670
|
+
// — a withhold that named nothing, on 1,256 production
|
|
671
|
+
// sessions. It is a decision like any other and it says so.
|
|
672
|
+
// Any reason the pipeline did record still wins: it is the more
|
|
673
|
+
// specific answer.
|
|
674
|
+
upload_state: "not_uploaded",
|
|
675
|
+
upload_reason: context.noUploadReasonByKey.get(key) ??
|
|
676
|
+
notUploadableAttributionStateReason(result.reason),
|
|
677
|
+
}),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Generalizes the per-session report across sources. Dedupe is per
|
|
682
|
+
* `(source, session_id)` so a Codex session and a Claude session that happen to
|
|
683
|
+
* share an id are never collapsed. Upload state maps ONLY from the main-file
|
|
684
|
+
* outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
|
|
685
|
+
* session's upload state (D3). Damped Claude sessions report `reused_existing`
|
|
686
|
+
* carrying their prior durable pointer.
|
|
687
|
+
*
|
|
688
|
+
* The pass, named: pick the best-ranked result per session
|
|
689
|
+
* (`bestAttributedSessionsByKey`), read back what this sync actually uploaded
|
|
690
|
+
* (`sessionUploadOutcomesByKey`), then render one report row per session
|
|
691
|
+
* (`sessionReportEntry`).
|
|
692
|
+
*/
|
|
693
|
+
export function buildAgentSessionReport(options) {
|
|
694
|
+
const bestByKey = bestAttributedSessionsByKey(options.codexResults, options.claudeResults);
|
|
695
|
+
const uploadOutcomes = sessionUploadOutcomesByKey(options.outcomes);
|
|
696
|
+
return [...bestByKey.values()].map((result) => sessionReportEntry(result, {
|
|
697
|
+
...uploadOutcomes,
|
|
698
|
+
claudePriorDurablePointers: options.claudePriorDurablePointers,
|
|
699
|
+
now: options.now,
|
|
700
|
+
}));
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Record this sync's Codex session observations into the Codex cursor and
|
|
704
|
+
* return the stale count. Best-effort by design: a broken local state dir
|
|
705
|
+
* must not turn an already-completed sync into a CLI crash, so a failure here
|
|
706
|
+
* only logs — the stale count simply keeps whatever value it had when the
|
|
707
|
+
* failure happened (0 if the read itself failed).
|
|
708
|
+
*
|
|
709
|
+
* Reads the cursor fresh from disk right before recording rather than reusing
|
|
710
|
+
* `priorCursor`, so a concurrent writer's update is not clobbered. Claude's
|
|
711
|
+
* counterpart does not do this extra read (see
|
|
712
|
+
* `recordClaudeSessionObservationsForSync`) — a real, intentional asymmetry
|
|
713
|
+
* kept as-is rather than forced to match.
|
|
714
|
+
*/
|
|
715
|
+
async function recordCodexSessionObservationsForSync(options) {
|
|
716
|
+
let staleCount = 0;
|
|
717
|
+
try {
|
|
718
|
+
const codexCursor = await readRawEvidenceCursor(options.paths);
|
|
719
|
+
staleCount = recordSourceObservations({
|
|
720
|
+
cursor: codexCursor,
|
|
721
|
+
results: options.codexAttribution.results,
|
|
722
|
+
sessions: options.sessions,
|
|
723
|
+
source: "codex",
|
|
724
|
+
sessionIdOf: (result) => result.codex_session_id,
|
|
725
|
+
now: options.now,
|
|
726
|
+
priorCursor: options.priorCursor,
|
|
727
|
+
terminalizeMissingUndurable: options.codexRetryPending &&
|
|
728
|
+
codexAttributionReadFailureCount(options.codexAttribution) === 0,
|
|
729
|
+
});
|
|
730
|
+
codexCursor.updated_at = options.now.toISOString();
|
|
731
|
+
await writeRawEvidenceCursor(options.paths, codexCursor);
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
// Best-effort: stale counts read 0 and observations re-record next sync —
|
|
735
|
+
// which is fine ONCE. A cursor write that keeps failing means the sessions
|
|
736
|
+
// cursor never advances, every sync re-does the same work, and the only
|
|
737
|
+
// symptom is a stale count that is permanently zero (BLI-3238).
|
|
738
|
+
console.error("[session-sync] Codex session observations were not recorded", JSON.stringify({
|
|
739
|
+
reason: "session_cursor_update_failed",
|
|
740
|
+
source: "codex",
|
|
741
|
+
session_count: options.sessions.length,
|
|
742
|
+
...describeError(error),
|
|
743
|
+
}));
|
|
744
|
+
}
|
|
745
|
+
return staleCount;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Record this sync's Claude session observations into the Claude cursor and
|
|
749
|
+
* return the stale count — the Claude counterpart to
|
|
750
|
+
* `recordCodexSessionObservationsForSync`. A no-op returning 0 when Claude
|
|
751
|
+
* collection is disabled, matching the source-scan side's own disabled state.
|
|
752
|
+
*/
|
|
753
|
+
async function recordClaudeSessionObservationsForSync(options) {
|
|
754
|
+
if (!options.claudeEnabled)
|
|
755
|
+
return 0;
|
|
756
|
+
let staleCount = 0;
|
|
757
|
+
try {
|
|
758
|
+
staleCount = recordSourceObservations({
|
|
759
|
+
cursor: options.claudeCursorBefore,
|
|
760
|
+
results: options.claudeAttribution.results,
|
|
761
|
+
sessions: options.sessions,
|
|
762
|
+
source: "claude_code",
|
|
763
|
+
sessionIdOf: (result) => result.claude_session_id,
|
|
764
|
+
now: options.now,
|
|
765
|
+
priorCursor: options.claudeCursorBefore,
|
|
766
|
+
terminalizeMissingUndurable: options.claudeRetryPending &&
|
|
767
|
+
claudeAttributionReadFailureCount(options.claudeAttribution) === 0,
|
|
768
|
+
});
|
|
769
|
+
options.claudeCursorBefore.updated_at = options.now.toISOString();
|
|
770
|
+
await writeRawEvidenceCursor(options.paths, options.claudeCursorBefore, {
|
|
771
|
+
filename: CLAUDE_CURSOR_FILENAME,
|
|
772
|
+
sessionsOnly: true,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
// Best-effort: a broken Claude cursor must not fail the sync. It must
|
|
777
|
+
// still say it is broken — otherwise the Claude half of collection
|
|
778
|
+
// quietly repeats itself forever.
|
|
779
|
+
console.error("[session-sync] Claude session observations were not recorded", JSON.stringify({
|
|
780
|
+
reason: "session_cursor_update_failed",
|
|
781
|
+
source: "claude_code",
|
|
782
|
+
session_count: options.sessions.length,
|
|
783
|
+
...describeError(error),
|
|
784
|
+
}));
|
|
785
|
+
}
|
|
786
|
+
return staleCount;
|
|
674
787
|
}
|
|
675
788
|
/**
|
|
676
789
|
* Records per-source session observations into its cursor and returns the stale
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.36",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,6 +27,6 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/telemetry-core": "0.1.25"
|
|
31
31
|
}
|
|
32
32
|
}
|