@bli-cockpit/cli 0.2.49 → 0.2.51

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.
Files changed (57) hide show
  1. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  2. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  4. package/dist/adapters/raw-evidence-facts.js +338 -0
  5. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  6. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  7. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  8. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  9. package/dist/adapters/raw-evidence.js +63 -1183
  10. package/dist/commands/backfill-batches.js +34 -0
  11. package/dist/commands/backfill-candidates.js +54 -0
  12. package/dist/commands/backfill-checkpoint.js +101 -0
  13. package/dist/commands/backfill-command-line.js +70 -0
  14. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  15. package/dist/commands/backfill-issues.js +265 -0
  16. package/dist/commands/backfill-output.js +75 -0
  17. package/dist/commands/backfill-plan.js +71 -0
  18. package/dist/commands/backfill-reasons.js +107 -0
  19. package/dist/commands/backfill-report.js +298 -0
  20. package/dist/commands/backfill-result.js +150 -0
  21. package/dist/commands/backfill-scan.js +274 -0
  22. package/dist/commands/backfill-scope.js +114 -0
  23. package/dist/commands/backfill-session-report.js +145 -0
  24. package/dist/commands/backfill-types.js +1 -0
  25. package/dist/commands/backfill-upload.js +212 -0
  26. package/dist/commands/backfill.js +41 -1961
  27. package/dist/commands/doctor.js +57 -0
  28. package/dist/commands/jarvis-trace.js +184 -0
  29. package/dist/commands/jarvis.js +144 -4
  30. package/dist/commands/local-args-collector.js +26 -0
  31. package/dist/commands/local-args-tower.js +21 -0
  32. package/dist/commands/local-args.js +3 -1
  33. package/dist/commands/local-help.js +19 -2
  34. package/dist/commands/local.js +3 -0
  35. package/dist/commands/memory-install-claude.js +294 -0
  36. package/dist/commands/memory-install-codex.js +205 -0
  37. package/dist/commands/memory-install-contract.js +286 -0
  38. package/dist/commands/memory-install-files.js +63 -0
  39. package/dist/commands/memory-install-skills.js +121 -0
  40. package/dist/commands/memory-install-toml.js +265 -0
  41. package/dist/commands/memory-install.js +465 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/sync-followups.js +105 -0
  44. package/dist/commands/sync.js +7 -1
  45. package/dist/local-state-attributed-target.js +75 -0
  46. package/dist/local-state-config.js +147 -0
  47. package/dist/local-state-files.js +59 -0
  48. package/dist/local-state-identity.js +73 -0
  49. package/dist/local-state-pairing.js +263 -0
  50. package/dist/local-state-paths.js +61 -0
  51. package/dist/local-state-session.js +68 -0
  52. package/dist/local-state-status.js +163 -0
  53. package/dist/local-state-work-context.js +190 -0
  54. package/dist/local-state.js +34 -848
  55. package/dist/tower-client.js +3 -2
  56. package/dist/tower-stream.js +57 -3
  57. package/package.json +2 -1
@@ -0,0 +1,150 @@
1
+ import { BACKFILL_UPLOAD_BATCH_SESSIONS, uploadableCandidates, } from "./backfill-batches.js";
2
+ import { backfillRetryCommand } from "./backfill-command-line.js";
3
+ import { selectedSources } from "./backfill-scope.js";
4
+ /**
5
+ * Someone else is already holding a lock this run needs. Reported as blocked
6
+ * with nothing done rather than as a failure — the scan is still valid, and
7
+ * the retry command in it works as soon as the other run finishes.
8
+ */
9
+ export function lockHeldResult(command, scanned, blocker) {
10
+ return {
11
+ ...baseBackfillResult(command, backfillResultBaseArgs(scanned)),
12
+ status: "blocked",
13
+ retry_command: scanned.retryCommand,
14
+ failure_reason: blocker.failureReason,
15
+ blocked_at: {
16
+ what: blocker.what,
17
+ batch_index: 0,
18
+ batch_total: 0,
19
+ done: 0,
20
+ total: scanned.scan.candidates.length,
21
+ },
22
+ };
23
+ }
24
+ /** The `{now, dashboardUrl, sources, window, cursor, scan, reasonCounts}` bag every `baseBackfillResult` call needs. */
25
+ export function backfillResultBaseArgs(ctx) {
26
+ return {
27
+ now: ctx.now,
28
+ dashboardUrl: ctx.dashboardUrl,
29
+ sources: ctx.sources,
30
+ window: ctx.window,
31
+ cursor: ctx.cursor,
32
+ scan: ctx.scan,
33
+ reasonCounts: ctx.reasonCounts,
34
+ };
35
+ }
36
+ export function baseBackfillResult(command, options) {
37
+ const states = countBy(options.scan.candidates, (candidate) => candidate.state);
38
+ const uploadable = uploadableCandidates(options.scan.candidates);
39
+ const perSource = {
40
+ codex: sourceCounts("codex", options.scan),
41
+ claude_code: sourceCounts("claude_code", options.scan),
42
+ };
43
+ return {
44
+ status: "complete",
45
+ dry_run: command.dryRun,
46
+ dashboard_url: options.dashboardUrl,
47
+ verify_url: `${options.dashboardUrl}/my-work`,
48
+ sources: options.sources,
49
+ window: options.window,
50
+ counts: {
51
+ total: options.scan.candidates.length,
52
+ uploadable: uploadable.length,
53
+ backfilled: 0,
54
+ skipped: options.scan.candidates.length - uploadable.length,
55
+ failed: 0,
56
+ deferred: 0,
57
+ remaining: 0,
58
+ states,
59
+ reasons: options.reasonCounts,
60
+ per_source: perSource,
61
+ },
62
+ batches: {
63
+ total: Math.ceil(uploadable.length / BACKFILL_UPLOAD_BATCH_SESSIONS),
64
+ completed: 0,
65
+ failed: 0,
66
+ },
67
+ resume_cursor: options.cursor,
68
+ retry_command: backfillRetryCommand(command),
69
+ report: emptyReport("not_posted"),
70
+ server_acknowledged: {
71
+ codex_session_report_recorded_count: 0,
72
+ raw_evidence_uploaded_object_count: 0,
73
+ raw_evidence_uploaded_chunk_count: 0,
74
+ },
75
+ };
76
+ }
77
+ export function blockedBackfillResult(command, options) {
78
+ return {
79
+ status: "blocked",
80
+ dry_run: command.dryRun,
81
+ dashboard_url: options.dashboardUrl,
82
+ verify_url: `${options.dashboardUrl}/my-work`,
83
+ sources: selectedSources(command.source),
84
+ window: {
85
+ mode: command.all ? "all" : "since_days",
86
+ since_days: command.sinceDays ?? null,
87
+ started_at: options.now.toISOString(),
88
+ paired_at: options.now.toISOString(),
89
+ since_minutes: 0,
90
+ },
91
+ counts: {
92
+ total: 0,
93
+ uploadable: 0,
94
+ backfilled: 0,
95
+ skipped: 0,
96
+ failed: 0,
97
+ deferred: 0,
98
+ remaining: 0,
99
+ states: {},
100
+ reasons: [],
101
+ per_source: {
102
+ codex: emptySourceCounts(),
103
+ claude_code: emptySourceCounts(),
104
+ },
105
+ },
106
+ batches: { total: 0, completed: 0, failed: 0 },
107
+ resume_cursor: options.cursor,
108
+ retry_command: backfillRetryCommand(command),
109
+ report: emptyReport("not_posted"),
110
+ server_acknowledged: {
111
+ codex_session_report_recorded_count: 0,
112
+ raw_evidence_uploaded_object_count: 0,
113
+ raw_evidence_uploaded_chunk_count: 0,
114
+ },
115
+ failure_reason: options.reason,
116
+ };
117
+ }
118
+ function sourceCounts(source, scan) {
119
+ const candidates = scan.candidates.filter((candidate) => candidate.source === source);
120
+ const scanned = source === "codex"
121
+ ? (scan.codexAttribution?.scanned_file_count ?? 0)
122
+ : (scan.claudeAttribution?.scanned_session_count ?? 0);
123
+ return {
124
+ scanned,
125
+ selected: candidates.length,
126
+ uploadable: uploadableCandidates(candidates).length,
127
+ states: countBy(candidates, (candidate) => candidate.state),
128
+ };
129
+ }
130
+ function emptySourceCounts() {
131
+ return { scanned: 0, selected: 0, uploadable: 0, states: {} };
132
+ }
133
+ export function emptyReport(reason) {
134
+ return {
135
+ posted: false,
136
+ reason,
137
+ chunk_count: 0,
138
+ recorded_count: 0,
139
+ failed_count: 0,
140
+ chunks: [],
141
+ };
142
+ }
143
+ function countBy(items, keyOf) {
144
+ const counts = {};
145
+ for (const item of items) {
146
+ const key = keyOf(item);
147
+ counts[key] = (counts[key] ?? 0) + 1;
148
+ }
149
+ return counts;
150
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * SCAN: read both archived session stores and turn them into the candidates
3
+ * this run will consider, then census what could not be accounted for.
4
+ *
5
+ * Two rules shape everything here. A store's own scan limit is a memory guard,
6
+ * not permission to leave history undiscovered, so a capped pass is rescanned
7
+ * in full. And a terminal session that never earned a durable pointer is
8
+ * reopened ahead of newer history, because a `--max-files` cap should be spent
9
+ * on what was missed before what merely arrived.
10
+ */
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
14
+ import { defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
15
+ import { emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, } from "../cursors/backfill-cursor.js";
16
+ import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
17
+ import { getCollectorRuntimePaths } from "../local-state.js";
18
+ import { candidateCursorKey, compareBackfillCandidates, isAfterCursor, } from "./backfill-candidates.js";
19
+ import { addReadOnlyGuardIssues, addRepoDiscoveryIssues, backfillScanIssues, countReadOnlyGuards, oversizedBackfillCandidateKeys, retryableCandidateKeys, sortScanIssuesByPriority, } from "./backfill-issues.js";
20
+ import { reasonCountsFor } from "./backfill-reasons.js";
21
+ import { blockedBackfillResult } from "./backfill-result.js";
22
+ import { resolveBackfillScope } from "./backfill-scope.js";
23
+ /**
24
+ * SCAN: is this machine paired, what roots/sources/window apply, and what did
25
+ * the archived Codex + Claude session stores actually contain. Returns either
26
+ * a terminal "not paired" result or everything PLAN/UPLOAD/REPORT need next.
27
+ */
28
+ export async function scanBackfillRun(command, io, now) {
29
+ const paths = getCollectorRuntimePaths(command.homeDir);
30
+ const scope = await resolveBackfillScope(command, io, now, paths);
31
+ if (scope.kind === "not_paired") {
32
+ return {
33
+ kind: "blocked",
34
+ result: blockedBackfillResult(command, {
35
+ now,
36
+ dashboardUrl: scope.dashboardUrl,
37
+ reason: "collector_not_paired",
38
+ cursor: await readBackfillCursor(paths),
39
+ }),
40
+ };
41
+ }
42
+ const { collectionRoots, sources, worktreeDiscovery } = scope;
43
+ const storedCursor = command.dryRun
44
+ ? emptyBackfillCursorState()
45
+ : await readBackfillCursor(paths);
46
+ const scopedCursor = prepareBackfillCursorForScope(storedCursor, collectionRoots, sources);
47
+ const cursor = scopedCursor.cursor;
48
+ const pointerlessTerminalSessions = await loadPointerlessTerminalSessions(paths, sources);
49
+ const scan = await scanBackfillSessions({
50
+ command,
51
+ homeDir: command.homeDir ?? os.homedir(),
52
+ worktrees: worktreeDiscovery.worktrees,
53
+ collectionRoots,
54
+ sources,
55
+ window: scope.window,
56
+ cursor,
57
+ pointerlessTerminalSessions,
58
+ now,
59
+ });
60
+ const reasonCounts = await auditScannedCandidates(scan, worktreeDiscovery.incomplete_reasons);
61
+ return {
62
+ kind: "scanned",
63
+ now,
64
+ paths,
65
+ dashboardUrl: scope.dashboardUrl,
66
+ sources,
67
+ window: scope.window,
68
+ collectionRoots,
69
+ worktrees: worktreeDiscovery.worktrees,
70
+ retryCommand: scope.retryCommand,
71
+ scopedCursor,
72
+ cursor,
73
+ scan,
74
+ reasonCounts,
75
+ oversizedCandidateKeys: oversizedBackfillCandidateKeys(scan.candidates),
76
+ };
77
+ }
78
+ /**
79
+ * Finishes the scan's ledger before anything acts on it: adds what repo
80
+ * discovery could not enumerate, reads every candidate file once to find the
81
+ * ones this machine cannot actually deliver, and folds all of it into the
82
+ * reason table the operator sees. Mutates `scan` in place — it is the one
83
+ * owner of `issues` and `retryable_candidate_keys` — and returns the table.
84
+ */
85
+ async function auditScannedCandidates(scan, discoveryIncompleteReasons) {
86
+ addRepoDiscoveryIssues(scan.issues, discoveryIncompleteReasons);
87
+ sortScanIssuesByPriority(scan.issues);
88
+ const guards = await countReadOnlyGuards(scan.candidates);
89
+ scan.retryable_candidate_keys = new Set([
90
+ ...scan.retryable_candidate_keys,
91
+ ...guards.retryable_candidate_keys,
92
+ ]);
93
+ addReadOnlyGuardIssues(scan.issues, guards.counts);
94
+ return reasonCountsFor(scan.candidates, guards.counts, scan.issues);
95
+ }
96
+ /**
97
+ * Reads both archived session stores and turns them into the candidates this
98
+ * run will consider: scan each selected store, keep what the cursor has not
99
+ * already resolved, order it, apply the operator's `--max-files` cap, then
100
+ * census what could not be accounted for.
101
+ */
102
+ async function scanBackfillSessions(options) {
103
+ const scanLimit = options.command.maxFiles ?? 10_000;
104
+ const codexSessionDirs = defaultCodexSessionDirs(options.homeDir);
105
+ const claudeProjectsDir = path.join(options.homeDir, ".claude", "projects");
106
+ const codexAttribution = options.sources.includes("codex")
107
+ ? await scanCodexHistory(options, codexSessionDirs, scanLimit)
108
+ : null;
109
+ const claudeAttribution = options.sources.includes("claude_code")
110
+ ? await scanClaudeHistory(options, claudeProjectsDir, scanLimit)
111
+ : null;
112
+ const selection = selectBackfillCandidates({
113
+ codexAttribution,
114
+ claudeAttribution,
115
+ cursor: options.cursor,
116
+ pointerlessTerminalSessions: options.pointerlessTerminalSessions,
117
+ maxFiles: options.command.maxFiles,
118
+ });
119
+ const issues = await backfillScanIssues({
120
+ codexAttribution,
121
+ claudeAttribution,
122
+ codexSessionDirs,
123
+ claudeProjectsDir,
124
+ candidates: selection.candidates,
125
+ omittedCandidateCount: selection.omittedCandidateCount,
126
+ });
127
+ return {
128
+ candidates: selection.candidates,
129
+ codexAttribution,
130
+ claudeAttribution,
131
+ issues,
132
+ retryable_candidate_keys: retryableCandidateKeys(selection.candidates),
133
+ omitted_candidate_count: selection.omittedCandidateCount,
134
+ };
135
+ }
136
+ /**
137
+ * The adapter's own limit is a memory guard, not permission to silently leave
138
+ * history undiscovered. When it fires, re-scan the exact discovered population
139
+ * so a user-facing `--max-files` cap can resume from a truthful ordering.
140
+ */
141
+ async function scanCodexHistory(request, sessionsDirs, scanLimit) {
142
+ const scan = (limit) => scanAndAttributeCodexSessions({
143
+ sessionsDirs,
144
+ worktrees: request.worktrees,
145
+ now: request.now,
146
+ sinceMinutes: request.window.since_minutes,
147
+ limit,
148
+ collectionRoots: request.collectionRoots,
149
+ });
150
+ const firstPass = await scan(scanLimit);
151
+ if (!firstPass.session_limit_applied)
152
+ return firstPass;
153
+ return scan(Math.max(scanLimit + 1, firstPass.discovered_file_count));
154
+ }
155
+ /** Same rule for the Claude store: a capped first pass is rescanned in full. */
156
+ async function scanClaudeHistory(request, projectsDir, scanLimit) {
157
+ const scan = (limit) => scanAndAttributeClaudeSessions({
158
+ projectsDir,
159
+ worktrees: request.worktrees,
160
+ now: request.now,
161
+ sinceMinutes: request.window.since_minutes,
162
+ limit,
163
+ collectionRoots: request.collectionRoots,
164
+ });
165
+ const firstPass = await scan(scanLimit);
166
+ if (!firstPass.session_limit_applied)
167
+ return firstPass;
168
+ return scan(Math.max(scanLimit + 1, firstPass.discovered_session_count));
169
+ }
170
+ /**
171
+ * Which scanned sessions this run will actually work on. A session is in play
172
+ * if the cursor has not passed it, or if it is a terminal session that never
173
+ * earned a pointer and so deserves another attempt; those retries sort first
174
+ * so a `--max-files` cap spends its budget on them before newer history.
175
+ */
176
+ function selectBackfillCandidates(options) {
177
+ const allCandidates = [
178
+ ...(options.codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
179
+ ...(options.claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
180
+ ]
181
+ .filter((candidate) => isAfterCursor(candidate, options.cursor) ||
182
+ isPointerlessTerminalRetry(candidate, options.pointerlessTerminalSessions))
183
+ .sort((a, b) => compareBackfillCandidatesForRetry(a, b, options.pointerlessTerminalSessions));
184
+ const candidates = allCandidates.slice(0, options.maxFiles ?? Number.MAX_SAFE_INTEGER);
185
+ return {
186
+ candidates,
187
+ omittedCandidateCount: allCandidates.length - candidates.length,
188
+ };
189
+ }
190
+ async function loadPointerlessTerminalSessions(paths, sources) {
191
+ const [codexCursor, claudeCursor] = await Promise.all([
192
+ sources.includes("codex")
193
+ ? readRawEvidenceCursor(paths)
194
+ : Promise.resolve(null),
195
+ sources.includes("claude_code")
196
+ ? readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME })
197
+ : Promise.resolve(null),
198
+ ]);
199
+ const terminalIds = (sessions) => new Set(Object.entries(sessions ?? {})
200
+ .filter(([, entry]) => isPointerlessTerminalCursorEntry(entry))
201
+ .map(([sessionId]) => sessionId));
202
+ const result = {
203
+ codex: terminalIds(codexCursor?.sessions),
204
+ claude_code: terminalIds(claudeCursor?.sessions),
205
+ };
206
+ const count = result.codex.size + result.claude_code.size;
207
+ if (count > 0) {
208
+ console.error("[backfill] pointer-less terminal sessions reopened", JSON.stringify({
209
+ count,
210
+ codex_count: result.codex.size,
211
+ claude_count: result.claude_code.size,
212
+ }));
213
+ }
214
+ return result;
215
+ }
216
+ function isPointerlessTerminalCursorEntry(entry) {
217
+ return (!entry.uploaded_object_key &&
218
+ (entry.state === "ambiguous" ||
219
+ entry.state === "unattributed" ||
220
+ entry.state === "skipped"));
221
+ }
222
+ function isPointerlessTerminalRetry(candidate, sessions) {
223
+ return sessions[candidate.source].has(candidate.session_id);
224
+ }
225
+ function compareBackfillCandidatesForRetry(a, b, sessions) {
226
+ const aRetry = isPointerlessTerminalRetry(a, sessions);
227
+ const bRetry = isPointerlessTerminalRetry(b, sessions);
228
+ if (aRetry !== bRetry)
229
+ return aRetry ? -1 : 1;
230
+ if (aRetry && bRetry) {
231
+ return (a.session_file_mtime_ms - b.session_file_mtime_ms ||
232
+ candidateCursorKey(a).localeCompare(candidateCursorKey(b)));
233
+ }
234
+ return compareBackfillCandidates(a, b);
235
+ }
236
+ function normalizeCodexCandidate(result) {
237
+ return {
238
+ source: "codex",
239
+ session_id: result.codex_session_id,
240
+ file_path: result.file_path,
241
+ state: result.state,
242
+ reason: result.reason,
243
+ signals: result.signals,
244
+ attribution_score: result.attribution_score,
245
+ path_score: result.path_score,
246
+ content_hash_sha256: result.content_hash_sha256,
247
+ byte_size: result.byte_size,
248
+ session_file_mtime: result.session_file_mtime,
249
+ session_file_mtime_ms: result.session_file_mtime_ms,
250
+ worktree: result.worktree,
251
+ cwd_basename: result.cwd_basename,
252
+ cwd_hash: result.cwd_hash,
253
+ };
254
+ }
255
+ function normalizeClaudeCandidate(result) {
256
+ return {
257
+ source: "claude_code",
258
+ session_id: result.claude_session_id,
259
+ file_path: result.file_path,
260
+ state: result.state,
261
+ reason: result.reason,
262
+ signals: result.signals,
263
+ attribution_score: result.attribution_score,
264
+ path_score: result.path_score,
265
+ content_hash_sha256: result.content_hash_sha256,
266
+ byte_size: result.byte_size,
267
+ session_file_mtime: result.session_file_mtime,
268
+ session_file_mtime_ms: result.session_file_mtime_ms,
269
+ worktree: result.worktree,
270
+ cwd_basename: result.cwd_basename,
271
+ cwd_hash: result.cwd_hash,
272
+ claude: result,
273
+ };
274
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * What this machine is allowed and able to back fill, before any session is
3
+ * read: is the collector paired, which approved roots and repos does it cover,
4
+ * which stores and how far back was it asked for, and can it reach the
5
+ * dashboard at all. Nothing here opens a transcript.
6
+ */
7
+ import path from "node:path";
8
+ import { readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, } from "../local-state.js";
9
+ import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
10
+ import { normalizeCollectionRoots } from "../root-normalization.js";
11
+ import { backfillDiscoveryRetryCommand } from "./backfill-command-line.js";
12
+ const ALL_BACKFILL_SINCE_MINUTES = 20 * 365 * 24 * 60;
13
+ /**
14
+ * Answers the four questions every later stage assumes: is this collector
15
+ * paired, which approved roots and repos does it cover, which stores and how
16
+ * far back was it asked for, and — on a dry run — can it even reach the
17
+ * dashboard. Nothing here reads a session; a machine that is not paired stops
18
+ * before any history is touched.
19
+ */
20
+ export async function resolveBackfillScope(command, io, now, paths) {
21
+ const config = await readLocalCollectorConfig(paths);
22
+ const sessionFile = await readLocalCollectorSessionFile(paths);
23
+ const session = await readLocalSessionReference(paths);
24
+ if (session.session_state !== "valid") {
25
+ return {
26
+ kind: "not_paired",
27
+ dashboardUrl: sessionFile.dashboard_url ?? config.dashboard_url,
28
+ };
29
+ }
30
+ const pairedAt = parseRequiredDate(sessionFile.paired_at, "paired_at");
31
+ const dashboardUrl = normalizeDashboardUrl(sessionFile.dashboard_url ?? config.dashboard_url);
32
+ const roots = backfillCollectionRoots(command, config.default_repo_paths);
33
+ const collectionRoots = normalizeCollectionRoots(await collectionRootPathAliases(roots));
34
+ const worktreeDiscovery = await discoverBackfillWorktrees(collectionRoots, command);
35
+ const window = backfillWindow(command, now, pairedAt);
36
+ await validateBackfillReachability({
37
+ fetchImpl: io.fetch,
38
+ dashboardUrl,
39
+ dryRun: command.dryRun,
40
+ });
41
+ return {
42
+ kind: "ready",
43
+ dashboardUrl,
44
+ collectionRoots,
45
+ worktreeDiscovery,
46
+ retryCommand: backfillDiscoveryRetryCommand(command, worktreeDiscovery),
47
+ sources: selectedSources(command.source),
48
+ window,
49
+ };
50
+ }
51
+ function backfillCollectionRoots(command, savedRoots) {
52
+ if (command.repoRoot)
53
+ return [path.resolve(command.repoRoot)];
54
+ const roots = normalizeCollectionRoots(savedRoots);
55
+ if (roots.length === 0) {
56
+ throw new Error("No saved collection roots. Run `cockpit onboard --workspace <path>` or pass `cockpit backfill --workspace <path>`.");
57
+ }
58
+ return roots;
59
+ }
60
+ async function discoverBackfillWorktrees(roots, command) {
61
+ return discoverGitWorktreesInRootsWithStatus(roots, {
62
+ maxDepth: command.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
63
+ maxWorktrees: command.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS,
64
+ });
65
+ }
66
+ export function selectedSources(source) {
67
+ if (source === "codex")
68
+ return ["codex"];
69
+ if (source === "claude")
70
+ return ["claude_code"];
71
+ return ["codex", "claude_code"];
72
+ }
73
+ function backfillWindow(command, now, pairedAt) {
74
+ if (command.all) {
75
+ return {
76
+ mode: "all",
77
+ since_days: null,
78
+ started_at: new Date(now.getTime() - ALL_BACKFILL_SINCE_MINUTES * 60_000)
79
+ .toISOString(),
80
+ paired_at: pairedAt.toISOString(),
81
+ since_minutes: ALL_BACKFILL_SINCE_MINUTES,
82
+ };
83
+ }
84
+ const requestedMs = now.getTime() - (command.sinceDays ?? 1) * 24 * 60 * 60_000;
85
+ const startedAtMs = Math.max(requestedMs, pairedAt.getTime());
86
+ const sinceMinutes = Math.max(1, Math.ceil((now.getTime() - startedAtMs) / 60_000));
87
+ return {
88
+ mode: "since_days",
89
+ since_days: command.sinceDays ?? null,
90
+ started_at: new Date(startedAtMs).toISOString(),
91
+ paired_at: pairedAt.toISOString(),
92
+ since_minutes: sinceMinutes,
93
+ };
94
+ }
95
+ async function validateBackfillReachability(options) {
96
+ if (!options.dryRun)
97
+ return;
98
+ const response = await options.fetchImpl(options.dashboardUrl, {
99
+ method: "HEAD",
100
+ });
101
+ if (response.status >= 500) {
102
+ throw new Error(`Dashboard reachability failed with HTTP ${response.status}.`);
103
+ }
104
+ }
105
+ function parseRequiredDate(value, label) {
106
+ const date = new Date(value);
107
+ if (!Number.isFinite(date.getTime())) {
108
+ throw new Error(`Collector session ${label} is invalid.`);
109
+ }
110
+ return date;
111
+ }
112
+ function normalizeDashboardUrl(value) {
113
+ return value.trim().replace(/\/+$/, "");
114
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * One attribution row per session this run saw, uploaded or not — the only
3
+ * record the server ever gets of a session the collector could not upload.
4
+ *
5
+ * Every branch names an outcome: a pointer when one exists, and otherwise WHY
6
+ * there is none (BLI-2107 for an attributed session, BLI-3272 for a refused
7
+ * one). Backfill is the path that revisits old sessions, so a silent branch
8
+ * here would keep rewriting the very NULL/NULL rows those tickets found.
9
+ */
10
+ import { NO_UPLOAD_ATTEMPT_RECORDED, notUploadableAttributionStateReason, } from "@bli-cockpit/telemetry-core";
11
+ import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
12
+ /**
13
+ * One attribution row per session this run saw, uploaded or not — the only
14
+ * record the server ever gets of a session the collector could not upload.
15
+ * Exported for the BLI-3272 regression test; not part of the CLI surface.
16
+ */
17
+ export function buildBackfillSessionReport(options) {
18
+ const uploads = indexMainTranscriptUploads(options.syncResults);
19
+ const bestBySession = bestCandidatePerSession(options.candidates);
20
+ return [...bestBySession.values()].map((candidate) => sessionAttributionRow(candidate, uploads, options.now));
21
+ }
22
+ function indexMainTranscriptUploads(syncResults) {
23
+ const bySourceAndSession = new Map();
24
+ const noUploadReasonBySessionId = new Map();
25
+ for (const sync of syncResults) {
26
+ if (sync.status !== "uploaded")
27
+ continue;
28
+ for (const outcome of sync.raw_evidence_outcomes) {
29
+ if (!outcome.codex_session_id)
30
+ continue;
31
+ const source = outcome.kind === "claude_jsonl"
32
+ ? "claude_code"
33
+ : outcome.kind === "codex_jsonl"
34
+ ? "codex"
35
+ : null;
36
+ if (!source)
37
+ continue;
38
+ if (!outcome.raw_evidence_pointer_id) {
39
+ if (outcome.reason) {
40
+ noUploadReasonBySessionId.set(outcome.codex_session_id, outcome.reason);
41
+ }
42
+ continue;
43
+ }
44
+ bySourceAndSession.set(`${source}:${outcome.codex_session_id}`, {
45
+ upload_state: outcome.upload_state,
46
+ raw_evidence_pointer_id: outcome.raw_evidence_pointer_id,
47
+ reason: outcome.reason,
48
+ });
49
+ }
50
+ }
51
+ return { bySourceAndSession, noUploadReasonBySessionId };
52
+ }
53
+ /**
54
+ * One row per session, not per file: the same session can be scanned from
55
+ * several paths, so the best-attributed sighting wins and the most recent one
56
+ * breaks a tie.
57
+ */
58
+ function bestCandidatePerSession(candidates) {
59
+ const bestByKey = new Map();
60
+ for (const candidate of candidates) {
61
+ const key = `${candidate.source}:${candidate.session_id}`;
62
+ const existing = bestByKey.get(key);
63
+ if (!existing || rank(candidate.state) > rank(existing.state)) {
64
+ bestByKey.set(key, candidate);
65
+ }
66
+ else if (existing &&
67
+ rank(candidate.state) === rank(existing.state) &&
68
+ candidate.session_file_mtime_ms > existing.session_file_mtime_ms) {
69
+ bestByKey.set(key, candidate);
70
+ }
71
+ }
72
+ return bestByKey;
73
+ }
74
+ function sessionAttributionRow(candidate, uploads, now) {
75
+ return {
76
+ codex_session_id: candidate.session_id,
77
+ source: candidate.source,
78
+ observed_at: now.toISOString(),
79
+ attribution_state: candidate.state,
80
+ attribution_reason: candidate.reason,
81
+ attribution_score: candidate.attribution_score,
82
+ path_score: candidate.path_score,
83
+ signals: candidate.signals,
84
+ ...(candidate.content_hash_sha256
85
+ ? { session_file_hash_sha256: candidate.content_hash_sha256 }
86
+ : {}),
87
+ session_file_byte_size: candidate.byte_size,
88
+ session_file_mtime: candidate.session_file_mtime,
89
+ ...(candidate.worktree
90
+ ? {
91
+ repo_fingerprint: candidate.worktree.repo_fingerprint,
92
+ worktree_fingerprint: candidate.worktree.worktree_fingerprint,
93
+ repo_label: candidate.worktree.repo_label,
94
+ branch: candidate.worktree.branch,
95
+ }
96
+ : {}),
97
+ ...(candidate.cwd_basename ? { cwd_basename: candidate.cwd_basename } : {}),
98
+ ...(candidate.cwd_hash ? { cwd_hash: candidate.cwd_hash } : {}),
99
+ ...sessionUploadFields(candidate, uploads),
100
+ };
101
+ }
102
+ /**
103
+ * The upload half of a report row. Every branch names an outcome: a pointer
104
+ * when one exists, and otherwise WHY there is none — BLI-2107 for an
105
+ * attributed session and BLI-3272 for a refused one. Backfill is the path that
106
+ * revisits old sessions, so a silent branch here would keep rewriting the very
107
+ * NULL/NULL rows those tickets found.
108
+ */
109
+ function sessionUploadFields(candidate, uploads) {
110
+ const upload = uploads.bySourceAndSession.get(`${candidate.source}:${candidate.session_id}`);
111
+ if (upload) {
112
+ return {
113
+ raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
114
+ upload_state: upload.upload_state,
115
+ ...(upload.upload_state === "upload_failed"
116
+ ? { upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED }
117
+ : {}),
118
+ };
119
+ }
120
+ const observedReason = uploads.noUploadReasonBySessionId.get(candidate.session_id);
121
+ const wasUploadable = isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null);
122
+ return {
123
+ upload_state: "not_uploaded",
124
+ upload_reason: observedReason ??
125
+ (wasUploadable
126
+ ? NO_UPLOAD_ATTEMPT_RECORDED
127
+ : notUploadableAttributionStateReason(candidate.reason)),
128
+ };
129
+ }
130
+ function rank(state) {
131
+ switch (state) {
132
+ case "attributed":
133
+ return 5;
134
+ case "attributed_fallback":
135
+ return 4;
136
+ case "ambiguous":
137
+ return 3;
138
+ case "unattributed":
139
+ return 2;
140
+ case "skipped":
141
+ return 1;
142
+ default:
143
+ return 0;
144
+ }
145
+ }
@@ -0,0 +1 @@
1
+ export {};