@bli-cockpit/cli 0.1.29 → 0.1.31
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/README.md +7 -1
- package/dist/adapters/attribution-core.js +70 -1
- package/dist/adapters/claude-attribution.js +6 -5
- package/dist/adapters/codex-attribution.js +6 -5
- package/dist/adapters/common.js +2 -5
- package/dist/adapters/raw-evidence.js +29 -8
- package/dist/backfill-lock.js +108 -0
- package/dist/commands/backfill.js +964 -0
- package/dist/commands/local-args.js +59 -1
- package/dist/commands/local.js +305 -8
- package/dist/commands/session-sync.js +22 -14
- package/dist/cursors/backfill-cursor.js +130 -0
- package/dist/evidence-upload-client.js +16 -0
- package/dist/upload.js +114 -25
- package/package.json +2 -2
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
import { containsSecretLikeContent, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
|
|
6
|
+
import { defaultCodexSessionDirs, scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
|
|
7
|
+
import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
|
|
8
|
+
import { acquireBackfillLock } from "../backfill-lock.js";
|
|
9
|
+
import { emptyBackfillCursorState, readBackfillCursor, recordBackfillCursorObservations, writeBackfillCompletionMarker, writeBackfillCursor } from "../cursors/backfill-cursor.js";
|
|
10
|
+
import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext } from "../local-state.js";
|
|
11
|
+
import { discoverGitWorktrees } from "../repo-identity.js";
|
|
12
|
+
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
13
|
+
import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope } from "../upload.js";
|
|
14
|
+
const BACKFILL_UPLOAD_BATCH_SESSIONS = 25;
|
|
15
|
+
const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
|
|
16
|
+
const ALL_BACKFILL_SINCE_MINUTES = 20 * 365 * 24 * 60;
|
|
17
|
+
const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
|
|
18
|
+
const DEFAULT_DISCOVERY_MAX_REPOS = 50;
|
|
19
|
+
const RETRY_COMMAND = "cockpit backfill";
|
|
20
|
+
export async function runBackfillCommand(command, io) {
|
|
21
|
+
if (!command.all && command.sinceDays === undefined) {
|
|
22
|
+
const message = bareBackfillMessage();
|
|
23
|
+
writeLine(io.stderr, message);
|
|
24
|
+
if (command.json) {
|
|
25
|
+
writeLine(io.stdout, JSON.stringify({
|
|
26
|
+
status: "blocked",
|
|
27
|
+
reason: "missing_window",
|
|
28
|
+
retry_command: RETRY_COMMAND,
|
|
29
|
+
}, null, 2));
|
|
30
|
+
}
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
if (command.all && !command.yes && !isInteractiveStdin(io)) {
|
|
34
|
+
const message = "--all requires TTY confirmation; pass --yes for agent runs.";
|
|
35
|
+
writeLine(io.stderr, message);
|
|
36
|
+
if (command.json) {
|
|
37
|
+
writeLine(io.stdout, JSON.stringify({
|
|
38
|
+
status: "blocked",
|
|
39
|
+
reason: "all_requires_confirmation",
|
|
40
|
+
retry_command: RETRY_COMMAND,
|
|
41
|
+
}, null, 2));
|
|
42
|
+
}
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
const result = await runBackfill(command, io);
|
|
46
|
+
if (command.json) {
|
|
47
|
+
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
48
|
+
return result.status === "complete" ? 0 : 1;
|
|
49
|
+
}
|
|
50
|
+
writeHumanBackfillResult(result, io);
|
|
51
|
+
return result.status === "complete" ? 0 : 1;
|
|
52
|
+
}
|
|
53
|
+
export async function runBackfill(command, io) {
|
|
54
|
+
const now = new Date();
|
|
55
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
56
|
+
const config = await readLocalCollectorConfig(paths);
|
|
57
|
+
const sessionFile = await readLocalCollectorSessionFile(paths);
|
|
58
|
+
const session = await readLocalSessionReference(paths);
|
|
59
|
+
if (session.session_state !== "valid") {
|
|
60
|
+
return blockedBackfillResult(command, {
|
|
61
|
+
now,
|
|
62
|
+
dashboardUrl: sessionFile.dashboard_url ?? config.dashboard_url,
|
|
63
|
+
reason: "collector_not_paired",
|
|
64
|
+
cursor: await readBackfillCursor(paths),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const pairedAt = parseRequiredDate(sessionFile.paired_at, "paired_at");
|
|
68
|
+
const dashboardUrl = normalizeDashboardUrl(sessionFile.dashboard_url ?? config.dashboard_url);
|
|
69
|
+
const roots = backfillCollectionRoots(command, config.default_repo_paths);
|
|
70
|
+
const worktrees = await discoverBackfillWorktrees(roots);
|
|
71
|
+
const collectionRoots = normalizeCollectionRoots(roots);
|
|
72
|
+
const sources = selectedSources(command.source);
|
|
73
|
+
const window = backfillWindow(command, now, pairedAt);
|
|
74
|
+
await validateBackfillReachability({
|
|
75
|
+
fetchImpl: io.fetch,
|
|
76
|
+
dashboardUrl,
|
|
77
|
+
dryRun: command.dryRun,
|
|
78
|
+
});
|
|
79
|
+
const cursor = command.dryRun
|
|
80
|
+
? emptyBackfillCursorState()
|
|
81
|
+
: await readBackfillCursor(paths);
|
|
82
|
+
const scan = await scanBackfillSessions({
|
|
83
|
+
command,
|
|
84
|
+
homeDir: command.homeDir ?? os.homedir(),
|
|
85
|
+
worktrees,
|
|
86
|
+
collectionRoots,
|
|
87
|
+
sources,
|
|
88
|
+
window,
|
|
89
|
+
cursor,
|
|
90
|
+
now,
|
|
91
|
+
});
|
|
92
|
+
const guardCounts = await countReadOnlyGuards(scan.candidates);
|
|
93
|
+
const reasonCounts = reasonCountsFor(scan.candidates, guardCounts);
|
|
94
|
+
if (command.all && !command.yes) {
|
|
95
|
+
if (!command.json) {
|
|
96
|
+
writeDryRunSummary(io, {
|
|
97
|
+
candidates: scan.candidates,
|
|
98
|
+
reasonCounts,
|
|
99
|
+
dashboardUrl,
|
|
100
|
+
dryRunOnly: false,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const confirmed = await confirmAllBackfill(io);
|
|
104
|
+
if (!confirmed) {
|
|
105
|
+
return {
|
|
106
|
+
...baseBackfillResult(command, {
|
|
107
|
+
now,
|
|
108
|
+
dashboardUrl,
|
|
109
|
+
sources,
|
|
110
|
+
window,
|
|
111
|
+
cursor,
|
|
112
|
+
scan,
|
|
113
|
+
reasonCounts,
|
|
114
|
+
}),
|
|
115
|
+
status: "blocked",
|
|
116
|
+
counts: {
|
|
117
|
+
...baseBackfillResult(command, {
|
|
118
|
+
now,
|
|
119
|
+
dashboardUrl,
|
|
120
|
+
sources,
|
|
121
|
+
window,
|
|
122
|
+
cursor,
|
|
123
|
+
scan,
|
|
124
|
+
reasonCounts,
|
|
125
|
+
}).counts,
|
|
126
|
+
remaining: scan.candidates.length,
|
|
127
|
+
},
|
|
128
|
+
failure_reason: "confirmation_declined",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (command.dryRun) {
|
|
133
|
+
if (!command.json) {
|
|
134
|
+
writeDryRunSummary(io, {
|
|
135
|
+
candidates: scan.candidates,
|
|
136
|
+
reasonCounts,
|
|
137
|
+
dashboardUrl,
|
|
138
|
+
dryRunOnly: true,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
...baseBackfillResult(command, {
|
|
143
|
+
now,
|
|
144
|
+
dashboardUrl,
|
|
145
|
+
sources,
|
|
146
|
+
window,
|
|
147
|
+
cursor,
|
|
148
|
+
scan,
|
|
149
|
+
reasonCounts,
|
|
150
|
+
}),
|
|
151
|
+
status: scan.candidates.length === 0 ? "blocked" : "complete",
|
|
152
|
+
dry_run: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const lock = await acquireBackfillLock(paths, now);
|
|
156
|
+
if (!lock.acquired) {
|
|
157
|
+
return {
|
|
158
|
+
...baseBackfillResult(command, {
|
|
159
|
+
now,
|
|
160
|
+
dashboardUrl,
|
|
161
|
+
sources,
|
|
162
|
+
window,
|
|
163
|
+
cursor,
|
|
164
|
+
scan,
|
|
165
|
+
reasonCounts,
|
|
166
|
+
}),
|
|
167
|
+
status: "blocked",
|
|
168
|
+
failure_reason: "backfill_already_running",
|
|
169
|
+
blocked_at: {
|
|
170
|
+
what: "backfill lock held",
|
|
171
|
+
batch_index: 0,
|
|
172
|
+
batch_total: 0,
|
|
173
|
+
done: 0,
|
|
174
|
+
total: scan.candidates.length,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const uploadable = uploadableCandidates(scan.candidates);
|
|
180
|
+
const batches = buildBackfillBatches(uploadable);
|
|
181
|
+
const rawEvidenceBudget = {
|
|
182
|
+
remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
|
|
183
|
+
remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
|
|
184
|
+
};
|
|
185
|
+
const syncResults = [];
|
|
186
|
+
let completedBatches = 0;
|
|
187
|
+
let failedBatches = 0;
|
|
188
|
+
let consecutiveFailures = 0;
|
|
189
|
+
let done = 0;
|
|
190
|
+
let failed = 0;
|
|
191
|
+
let deferred = 0;
|
|
192
|
+
let uploadedObjects = 0;
|
|
193
|
+
let uploadedChunks = 0;
|
|
194
|
+
let backfilledSessions = 0;
|
|
195
|
+
let blockedAt;
|
|
196
|
+
let failureReason;
|
|
197
|
+
for (const [index, batch] of batches.entries()) {
|
|
198
|
+
await lock.handle.heartbeat();
|
|
199
|
+
const sync = await syncBackfillBatch({
|
|
200
|
+
command,
|
|
201
|
+
batch,
|
|
202
|
+
worktrees,
|
|
203
|
+
codexAttribution: scan.codexAttribution,
|
|
204
|
+
claudeAttribution: scan.claudeAttribution,
|
|
205
|
+
rawEvidenceBudget,
|
|
206
|
+
fetchImpl: io.fetch,
|
|
207
|
+
});
|
|
208
|
+
syncResults.push(sync);
|
|
209
|
+
done += batch.candidates.length;
|
|
210
|
+
uploadedObjects += sync.raw_evidence_uploaded_object_count;
|
|
211
|
+
uploadedChunks += sync.raw_evidence_uploaded_chunk_count;
|
|
212
|
+
failed += sync.raw_evidence_failed_count;
|
|
213
|
+
deferred +=
|
|
214
|
+
sync.raw_evidence_deferred_byte_budget +
|
|
215
|
+
sync.raw_evidence_deferred_object_budget;
|
|
216
|
+
backfilledSessions += countBackfilledSessions(sync);
|
|
217
|
+
const batchFailed = sync.status !== "uploaded" || sync.raw_evidence_failed_count > 0;
|
|
218
|
+
const batchDeferred = sync.raw_evidence_deferred_byte_budget +
|
|
219
|
+
sync.raw_evidence_deferred_object_budget >
|
|
220
|
+
0;
|
|
221
|
+
if (batchFailed) {
|
|
222
|
+
failedBatches += 1;
|
|
223
|
+
consecutiveFailures += 1;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
consecutiveFailures = 0;
|
|
227
|
+
}
|
|
228
|
+
if (!batchFailed && !batchDeferred) {
|
|
229
|
+
completedBatches += 1;
|
|
230
|
+
recordBackfillCursorObservations(cursor, batch.candidates.map((candidate) => ({
|
|
231
|
+
source: candidate.source,
|
|
232
|
+
state: candidate.state,
|
|
233
|
+
reason: candidate.reason,
|
|
234
|
+
session_file_mtime_ms: candidate.session_file_mtime_ms,
|
|
235
|
+
session_file_mtime: candidate.session_file_mtime,
|
|
236
|
+
})), now);
|
|
237
|
+
await writeBackfillCursor(paths, cursor);
|
|
238
|
+
}
|
|
239
|
+
writeLine(io.stdout, `Uploaded ${done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
|
|
240
|
+
await yieldToEventLoop();
|
|
241
|
+
if (batchDeferred) {
|
|
242
|
+
blockedAt = {
|
|
243
|
+
what: "raw evidence budget exhausted",
|
|
244
|
+
batch_index: index + 1,
|
|
245
|
+
batch_total: batches.length,
|
|
246
|
+
done,
|
|
247
|
+
total: uploadable.length,
|
|
248
|
+
};
|
|
249
|
+
failureReason = "deferred_budget_exhausted";
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
if (consecutiveFailures >= BACKFILL_MAX_CONSECUTIVE_FAILURES) {
|
|
253
|
+
blockedAt = {
|
|
254
|
+
what: "consecutive upload failures",
|
|
255
|
+
batch_index: index + 1,
|
|
256
|
+
batch_total: batches.length,
|
|
257
|
+
done,
|
|
258
|
+
total: uploadable.length,
|
|
259
|
+
};
|
|
260
|
+
failureReason =
|
|
261
|
+
sync.status === "spooled" ? sync.failure_reason : "upload_failed";
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const sessions = buildBackfillSessionReport({
|
|
266
|
+
candidates: scan.candidates,
|
|
267
|
+
syncResults,
|
|
268
|
+
now,
|
|
269
|
+
});
|
|
270
|
+
const report = sessions.length
|
|
271
|
+
? await postCodexSessionReport({
|
|
272
|
+
homeDir: command.homeDir,
|
|
273
|
+
repoRoot: worktrees[0]?.repo_root,
|
|
274
|
+
dashboardUrl,
|
|
275
|
+
sessions,
|
|
276
|
+
fetch: io.fetch,
|
|
277
|
+
now,
|
|
278
|
+
})
|
|
279
|
+
: emptyReport("no_sessions_observed");
|
|
280
|
+
if (sessions.length > 0 && !report.posted && !blockedAt) {
|
|
281
|
+
blockedAt = {
|
|
282
|
+
what: "session report failed",
|
|
283
|
+
batch_index: completedBatches,
|
|
284
|
+
batch_total: batches.length,
|
|
285
|
+
done,
|
|
286
|
+
total: uploadable.length,
|
|
287
|
+
};
|
|
288
|
+
failureReason = report.reason;
|
|
289
|
+
}
|
|
290
|
+
const remaining = Math.max(0, uploadable.length - done);
|
|
291
|
+
const status = blockedAt
|
|
292
|
+
? remaining > 0 || deferred > 0
|
|
293
|
+
? "partial"
|
|
294
|
+
: "blocked"
|
|
295
|
+
: scan.candidates.length === 0
|
|
296
|
+
? "blocked"
|
|
297
|
+
: "complete";
|
|
298
|
+
if (status === "complete" && deferred === 0 && failed === 0) {
|
|
299
|
+
await writeBackfillCompletionMarker(paths, {
|
|
300
|
+
schema_version: "cockpit-backfill-complete.v1",
|
|
301
|
+
completed_at: now.toISOString(),
|
|
302
|
+
cursor,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
...baseBackfillResult(command, {
|
|
307
|
+
now,
|
|
308
|
+
dashboardUrl,
|
|
309
|
+
sources,
|
|
310
|
+
window,
|
|
311
|
+
cursor,
|
|
312
|
+
scan,
|
|
313
|
+
reasonCounts,
|
|
314
|
+
}),
|
|
315
|
+
status,
|
|
316
|
+
counts: {
|
|
317
|
+
...baseBackfillResult(command, {
|
|
318
|
+
now,
|
|
319
|
+
dashboardUrl,
|
|
320
|
+
sources,
|
|
321
|
+
window,
|
|
322
|
+
cursor,
|
|
323
|
+
scan,
|
|
324
|
+
reasonCounts,
|
|
325
|
+
}).counts,
|
|
326
|
+
backfilled: backfilledSessions,
|
|
327
|
+
failed,
|
|
328
|
+
deferred,
|
|
329
|
+
remaining,
|
|
330
|
+
},
|
|
331
|
+
batches: {
|
|
332
|
+
total: batches.length,
|
|
333
|
+
completed: completedBatches,
|
|
334
|
+
failed: failedBatches,
|
|
335
|
+
},
|
|
336
|
+
report,
|
|
337
|
+
server_acknowledged: {
|
|
338
|
+
codex_session_report_recorded_count: report.recorded_count,
|
|
339
|
+
raw_evidence_uploaded_object_count: uploadedObjects,
|
|
340
|
+
raw_evidence_uploaded_chunk_count: uploadedChunks,
|
|
341
|
+
},
|
|
342
|
+
blocked_at: blockedAt,
|
|
343
|
+
failure_reason: failureReason,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await lock.handle.release();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function bareBackfillMessage() {
|
|
351
|
+
return [
|
|
352
|
+
"cockpit backfill needs an explicit window.",
|
|
353
|
+
"Use `cockpit backfill --since-days N` for post-pairing history; the effective start is capped at the collector paired_at timestamp.",
|
|
354
|
+
"Use `cockpit backfill --all` only after reviewing a dry-run; on headless agent runs add `--yes`.",
|
|
355
|
+
].join("\n");
|
|
356
|
+
}
|
|
357
|
+
function backfillCollectionRoots(command, savedRoots) {
|
|
358
|
+
if (command.repoRoot)
|
|
359
|
+
return [path.resolve(command.repoRoot)];
|
|
360
|
+
const roots = normalizeCollectionRoots(savedRoots);
|
|
361
|
+
if (roots.length === 0) {
|
|
362
|
+
throw new Error("No saved collection roots. Run `cockpit onboard --workspace <path>` or pass `cockpit backfill --workspace <path>`.");
|
|
363
|
+
}
|
|
364
|
+
return roots;
|
|
365
|
+
}
|
|
366
|
+
async function discoverBackfillWorktrees(roots) {
|
|
367
|
+
const discovered = [];
|
|
368
|
+
for (const root of roots) {
|
|
369
|
+
discovered.push(...(await discoverGitWorktrees(root, {
|
|
370
|
+
maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH,
|
|
371
|
+
maxWorktrees: DEFAULT_DISCOVERY_MAX_REPOS,
|
|
372
|
+
})));
|
|
373
|
+
}
|
|
374
|
+
const seen = new Set();
|
|
375
|
+
const deduped = [];
|
|
376
|
+
for (const worktree of discovered) {
|
|
377
|
+
const key = worktree.worktree_fingerprint || path.resolve(worktree.repo_root);
|
|
378
|
+
if (seen.has(key))
|
|
379
|
+
continue;
|
|
380
|
+
seen.add(key);
|
|
381
|
+
deduped.push(worktree);
|
|
382
|
+
}
|
|
383
|
+
if (deduped.length === 0) {
|
|
384
|
+
throw new Error("No git repos found in the backfill collection roots.");
|
|
385
|
+
}
|
|
386
|
+
return deduped;
|
|
387
|
+
}
|
|
388
|
+
function selectedSources(source) {
|
|
389
|
+
if (source === "codex")
|
|
390
|
+
return ["codex"];
|
|
391
|
+
if (source === "claude")
|
|
392
|
+
return ["claude_code"];
|
|
393
|
+
return ["codex", "claude_code"];
|
|
394
|
+
}
|
|
395
|
+
function backfillWindow(command, now, pairedAt) {
|
|
396
|
+
if (command.all) {
|
|
397
|
+
return {
|
|
398
|
+
mode: "all",
|
|
399
|
+
since_days: null,
|
|
400
|
+
started_at: new Date(now.getTime() - ALL_BACKFILL_SINCE_MINUTES * 60_000)
|
|
401
|
+
.toISOString(),
|
|
402
|
+
paired_at: pairedAt.toISOString(),
|
|
403
|
+
since_minutes: ALL_BACKFILL_SINCE_MINUTES,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const requestedMs = now.getTime() - (command.sinceDays ?? 1) * 24 * 60 * 60_000;
|
|
407
|
+
const startedAtMs = Math.max(requestedMs, pairedAt.getTime());
|
|
408
|
+
const sinceMinutes = Math.max(1, Math.ceil((now.getTime() - startedAtMs) / 60_000));
|
|
409
|
+
return {
|
|
410
|
+
mode: "since_days",
|
|
411
|
+
since_days: command.sinceDays ?? null,
|
|
412
|
+
started_at: new Date(startedAtMs).toISOString(),
|
|
413
|
+
paired_at: pairedAt.toISOString(),
|
|
414
|
+
since_minutes: sinceMinutes,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
async function validateBackfillReachability(options) {
|
|
418
|
+
if (!options.dryRun)
|
|
419
|
+
return;
|
|
420
|
+
const response = await options.fetchImpl(options.dashboardUrl, {
|
|
421
|
+
method: "HEAD",
|
|
422
|
+
});
|
|
423
|
+
if (response.status >= 500) {
|
|
424
|
+
throw new Error(`Dashboard reachability failed with HTTP ${response.status}.`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async function scanBackfillSessions(options) {
|
|
428
|
+
const scanLimit = options.command.maxFiles ?? 10_000;
|
|
429
|
+
const codexAttribution = options.sources.includes("codex")
|
|
430
|
+
? await scanAndAttributeCodexSessions({
|
|
431
|
+
sessionsDirs: defaultCodexSessionDirs(options.homeDir),
|
|
432
|
+
worktrees: options.worktrees,
|
|
433
|
+
now: options.now,
|
|
434
|
+
sinceMinutes: options.window.since_minutes,
|
|
435
|
+
limit: scanLimit,
|
|
436
|
+
collectionRoots: options.collectionRoots,
|
|
437
|
+
})
|
|
438
|
+
: null;
|
|
439
|
+
const claudeAttribution = options.sources.includes("claude_code")
|
|
440
|
+
? await scanAndAttributeClaudeSessions({
|
|
441
|
+
projectsDir: path.join(options.homeDir, ".claude", "projects"),
|
|
442
|
+
worktrees: options.worktrees,
|
|
443
|
+
now: options.now,
|
|
444
|
+
sinceMinutes: options.window.since_minutes,
|
|
445
|
+
limit: scanLimit,
|
|
446
|
+
collectionRoots: options.collectionRoots,
|
|
447
|
+
})
|
|
448
|
+
: null;
|
|
449
|
+
const candidates = [
|
|
450
|
+
...(codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
|
|
451
|
+
...(claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
|
|
452
|
+
]
|
|
453
|
+
.filter((candidate) => isAfterCursor(candidate, options.cursor))
|
|
454
|
+
.sort((a, b) => b.session_file_mtime_ms - a.session_file_mtime_ms)
|
|
455
|
+
.slice(0, options.command.maxFiles ?? Number.MAX_SAFE_INTEGER);
|
|
456
|
+
return { candidates, codexAttribution, claudeAttribution };
|
|
457
|
+
}
|
|
458
|
+
function normalizeCodexCandidate(result) {
|
|
459
|
+
return {
|
|
460
|
+
source: "codex",
|
|
461
|
+
session_id: result.codex_session_id,
|
|
462
|
+
file_path: result.file_path,
|
|
463
|
+
state: result.state,
|
|
464
|
+
reason: result.reason,
|
|
465
|
+
signals: result.signals,
|
|
466
|
+
attribution_score: result.attribution_score,
|
|
467
|
+
path_score: result.path_score,
|
|
468
|
+
content_hash_sha256: result.content_hash_sha256,
|
|
469
|
+
byte_size: result.byte_size,
|
|
470
|
+
session_file_mtime: result.session_file_mtime,
|
|
471
|
+
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
472
|
+
worktree: result.worktree,
|
|
473
|
+
cwd_basename: result.cwd_basename,
|
|
474
|
+
cwd_hash: result.cwd_hash,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function normalizeClaudeCandidate(result) {
|
|
478
|
+
return {
|
|
479
|
+
source: "claude_code",
|
|
480
|
+
session_id: result.claude_session_id,
|
|
481
|
+
file_path: result.file_path,
|
|
482
|
+
state: result.state,
|
|
483
|
+
reason: result.reason,
|
|
484
|
+
signals: result.signals,
|
|
485
|
+
attribution_score: result.attribution_score,
|
|
486
|
+
path_score: result.path_score,
|
|
487
|
+
content_hash_sha256: result.content_hash_sha256,
|
|
488
|
+
byte_size: result.byte_size,
|
|
489
|
+
session_file_mtime: result.session_file_mtime,
|
|
490
|
+
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
491
|
+
worktree: result.worktree,
|
|
492
|
+
cwd_basename: result.cwd_basename,
|
|
493
|
+
cwd_hash: result.cwd_hash,
|
|
494
|
+
claude: result,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function isAfterCursor(candidate, cursor) {
|
|
498
|
+
const oldest = cursor.sources[candidate.source]?.oldest_mtime_ms_processed;
|
|
499
|
+
return oldest === null || oldest === undefined || candidate.session_file_mtime_ms < oldest;
|
|
500
|
+
}
|
|
501
|
+
async function countReadOnlyGuards(candidates) {
|
|
502
|
+
const counts = new Map();
|
|
503
|
+
for (const candidate of candidates) {
|
|
504
|
+
if (candidate.reason === "repo_not_on_disk")
|
|
505
|
+
increment(counts, "repo_not_on_disk");
|
|
506
|
+
if (candidate.source === "claude_code" && candidate.claude?.main_file_oversized) {
|
|
507
|
+
increment(counts, "file_too_large");
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (candidate.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
511
|
+
increment(counts, "file_too_large");
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (!isUploadableState(candidate.state))
|
|
515
|
+
continue;
|
|
516
|
+
try {
|
|
517
|
+
const raw = await fs.readFile(candidate.file_path, "utf8");
|
|
518
|
+
if (containsSecretLikeContent(raw)) {
|
|
519
|
+
increment(counts, "secret_like_content_guard");
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
increment(counts, "file_read_failed");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return counts;
|
|
527
|
+
}
|
|
528
|
+
function reasonCountsFor(candidates, guardCounts) {
|
|
529
|
+
const counts = new Map();
|
|
530
|
+
for (const candidate of candidates)
|
|
531
|
+
increment(counts, candidate.reason);
|
|
532
|
+
for (const [reason, count] of guardCounts) {
|
|
533
|
+
counts.set(reason, Math.max(counts.get(reason) ?? 0, count));
|
|
534
|
+
}
|
|
535
|
+
for (const required of [
|
|
536
|
+
"secret_like_content_guard",
|
|
537
|
+
"file_too_large",
|
|
538
|
+
"repo_not_on_disk",
|
|
539
|
+
]) {
|
|
540
|
+
counts.set(required, counts.get(required) ?? 0);
|
|
541
|
+
}
|
|
542
|
+
return [...counts.entries()]
|
|
543
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
544
|
+
.map(([reason, count]) => ({
|
|
545
|
+
reason,
|
|
546
|
+
count,
|
|
547
|
+
...reasonClassification(reason),
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
550
|
+
function reasonClassification(reason) {
|
|
551
|
+
if (reason === "secret_like_content_guard" || reason === "secret_redaction_failed") {
|
|
552
|
+
return {
|
|
553
|
+
classification: "permanent",
|
|
554
|
+
note: "secret-guarded permanent by design",
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
if (reason === "file_too_large") {
|
|
558
|
+
return {
|
|
559
|
+
classification: "retryable",
|
|
560
|
+
note: "until the evidence file cap is raised",
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
if (reason === "repo_not_on_disk") {
|
|
564
|
+
return {
|
|
565
|
+
classification: "retryable",
|
|
566
|
+
note: "repo must exist on disk",
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
if (reason.startsWith("deferred_")) {
|
|
570
|
+
return {
|
|
571
|
+
classification: "retryable",
|
|
572
|
+
note: "rerun cockpit backfill to continue",
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
classification: "retryable",
|
|
577
|
+
note: "rerun after fixing source or collector state",
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
function uploadableCandidates(candidates) {
|
|
581
|
+
return candidates.filter((candidate) => isUploadableState(candidate.state) && candidate.worktree);
|
|
582
|
+
}
|
|
583
|
+
function isUploadableState(state) {
|
|
584
|
+
return state === "attributed" || state === "attributed_fallback";
|
|
585
|
+
}
|
|
586
|
+
function buildBackfillBatches(candidates) {
|
|
587
|
+
const byWorktree = new Map();
|
|
588
|
+
for (const candidate of candidates) {
|
|
589
|
+
if (!candidate.worktree)
|
|
590
|
+
continue;
|
|
591
|
+
const key = candidate.worktree.worktree_fingerprint;
|
|
592
|
+
byWorktree.set(key, [...(byWorktree.get(key) ?? []), candidate]);
|
|
593
|
+
}
|
|
594
|
+
const batches = [];
|
|
595
|
+
for (const group of byWorktree.values()) {
|
|
596
|
+
const worktree = group[0]?.worktree;
|
|
597
|
+
if (!worktree)
|
|
598
|
+
continue;
|
|
599
|
+
for (let offset = 0; offset < group.length; offset += BACKFILL_UPLOAD_BATCH_SESSIONS) {
|
|
600
|
+
batches.push({
|
|
601
|
+
worktree,
|
|
602
|
+
candidates: group.slice(offset, offset + BACKFILL_UPLOAD_BATCH_SESSIONS),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return batches;
|
|
607
|
+
}
|
|
608
|
+
async function syncBackfillBatch(options) {
|
|
609
|
+
const syncOptions = {
|
|
610
|
+
homeDir: options.command.homeDir,
|
|
611
|
+
repoRoot: options.batch.worktree.repo_root,
|
|
612
|
+
worktreeInventory: worktreeInventoryForRepo(options.batch.worktree, options.worktrees),
|
|
613
|
+
codexSessionFiles: options.batch.candidates
|
|
614
|
+
.filter((candidate) => candidate.source === "codex")
|
|
615
|
+
.map((candidate) => ({
|
|
616
|
+
local_path: candidate.file_path,
|
|
617
|
+
codex_session_id: candidate.session_id,
|
|
618
|
+
})),
|
|
619
|
+
codexAttributionScan: options.codexAttribution ?? undefined,
|
|
620
|
+
claudeSessionFiles: options.batch.candidates
|
|
621
|
+
.filter((candidate) => candidate.source === "claude_code")
|
|
622
|
+
.map((candidate) => ({
|
|
623
|
+
local_path: candidate.file_path,
|
|
624
|
+
claude_session_id: candidate.session_id,
|
|
625
|
+
main_file_oversized: Boolean(candidate.claude?.main_file_oversized),
|
|
626
|
+
skip_main: false,
|
|
627
|
+
sidecar_files: candidate.claude?.sidecar_files
|
|
628
|
+
.filter((sidecar) => !sidecar.skipped_reason)
|
|
629
|
+
.map((sidecar) => ({ local_path: sidecar.local_path })) ?? [],
|
|
630
|
+
})),
|
|
631
|
+
claudeAttributionScan: options.claudeAttribution ?? undefined,
|
|
632
|
+
rawEvidenceBudget: options.rawEvidenceBudget,
|
|
633
|
+
fetch: options.fetchImpl,
|
|
634
|
+
};
|
|
635
|
+
try {
|
|
636
|
+
return await syncLocalAmbientEnvelope(syncOptions);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
if (error instanceof LocalUploadBlockedError &&
|
|
640
|
+
error.blocker === "missing_context") {
|
|
641
|
+
await startLocalWorkContext({
|
|
642
|
+
homeDir: options.command.homeDir,
|
|
643
|
+
repoRoot: options.batch.worktree.repo_root,
|
|
644
|
+
branch: options.batch.worktree.branch,
|
|
645
|
+
});
|
|
646
|
+
return await syncLocalAmbientEnvelope(syncOptions);
|
|
647
|
+
}
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function buildBackfillSessionReport(options) {
|
|
652
|
+
const uploadByKey = new Map();
|
|
653
|
+
for (const sync of options.syncResults) {
|
|
654
|
+
if (sync.status !== "uploaded")
|
|
655
|
+
continue;
|
|
656
|
+
for (const outcome of sync.raw_evidence_outcomes) {
|
|
657
|
+
if (!outcome.codex_session_id || !outcome.raw_evidence_pointer_id)
|
|
658
|
+
continue;
|
|
659
|
+
const source = outcome.kind === "claude_jsonl"
|
|
660
|
+
? "claude_code"
|
|
661
|
+
: outcome.kind === "codex_jsonl"
|
|
662
|
+
? "codex"
|
|
663
|
+
: null;
|
|
664
|
+
if (!source)
|
|
665
|
+
continue;
|
|
666
|
+
uploadByKey.set(`${source}:${outcome.codex_session_id}`, {
|
|
667
|
+
upload_state: outcome.upload_state,
|
|
668
|
+
raw_evidence_pointer_id: outcome.raw_evidence_pointer_id,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const bestByKey = new Map();
|
|
673
|
+
for (const candidate of options.candidates) {
|
|
674
|
+
const key = `${candidate.source}:${candidate.session_id}`;
|
|
675
|
+
const existing = bestByKey.get(key);
|
|
676
|
+
if (!existing || rank(candidate.state) > rank(existing.state)) {
|
|
677
|
+
bestByKey.set(key, candidate);
|
|
678
|
+
}
|
|
679
|
+
else if (existing &&
|
|
680
|
+
rank(candidate.state) === rank(existing.state) &&
|
|
681
|
+
candidate.session_file_mtime_ms > existing.session_file_mtime_ms) {
|
|
682
|
+
bestByKey.set(key, candidate);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return [...bestByKey.values()].map((candidate) => {
|
|
686
|
+
const upload = uploadByKey.get(`${candidate.source}:${candidate.session_id}`);
|
|
687
|
+
return {
|
|
688
|
+
codex_session_id: candidate.session_id,
|
|
689
|
+
source: candidate.source,
|
|
690
|
+
observed_at: options.now.toISOString(),
|
|
691
|
+
attribution_state: candidate.state,
|
|
692
|
+
attribution_reason: candidate.reason,
|
|
693
|
+
attribution_score: candidate.attribution_score,
|
|
694
|
+
path_score: candidate.path_score,
|
|
695
|
+
signals: candidate.signals,
|
|
696
|
+
...(candidate.content_hash_sha256
|
|
697
|
+
? { session_file_hash_sha256: candidate.content_hash_sha256 }
|
|
698
|
+
: {}),
|
|
699
|
+
session_file_byte_size: candidate.byte_size,
|
|
700
|
+
session_file_mtime: candidate.session_file_mtime,
|
|
701
|
+
...(candidate.worktree
|
|
702
|
+
? {
|
|
703
|
+
repo_fingerprint: candidate.worktree.repo_fingerprint,
|
|
704
|
+
worktree_fingerprint: candidate.worktree.worktree_fingerprint,
|
|
705
|
+
repo_label: candidate.worktree.repo_label,
|
|
706
|
+
branch: candidate.worktree.branch,
|
|
707
|
+
}
|
|
708
|
+
: {}),
|
|
709
|
+
...(candidate.cwd_basename ? { cwd_basename: candidate.cwd_basename } : {}),
|
|
710
|
+
...(candidate.cwd_hash ? { cwd_hash: candidate.cwd_hash } : {}),
|
|
711
|
+
...(upload
|
|
712
|
+
? {
|
|
713
|
+
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
714
|
+
upload_state: upload.upload_state,
|
|
715
|
+
}
|
|
716
|
+
: isUploadableState(candidate.state)
|
|
717
|
+
? { upload_state: "not_uploaded" }
|
|
718
|
+
: {}),
|
|
719
|
+
};
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
function rank(state) {
|
|
723
|
+
switch (state) {
|
|
724
|
+
case "attributed":
|
|
725
|
+
return 5;
|
|
726
|
+
case "attributed_fallback":
|
|
727
|
+
return 4;
|
|
728
|
+
case "ambiguous":
|
|
729
|
+
return 3;
|
|
730
|
+
case "unattributed":
|
|
731
|
+
return 2;
|
|
732
|
+
case "skipped":
|
|
733
|
+
return 1;
|
|
734
|
+
default:
|
|
735
|
+
return 0;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
function countBackfilledSessions(sync) {
|
|
739
|
+
const keys = new Set();
|
|
740
|
+
for (const outcome of sync.raw_evidence_outcomes) {
|
|
741
|
+
if ((outcome.kind === "codex_jsonl" || outcome.kind === "claude_jsonl") &&
|
|
742
|
+
outcome.codex_session_id &&
|
|
743
|
+
(outcome.upload_state === "uploaded" ||
|
|
744
|
+
outcome.upload_state === "reused_existing")) {
|
|
745
|
+
keys.add(`${outcome.kind}:${outcome.codex_session_id}`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return keys.size;
|
|
749
|
+
}
|
|
750
|
+
function worktreeInventoryForRepo(current, worktrees) {
|
|
751
|
+
return worktrees
|
|
752
|
+
.filter((worktree) => worktree.repo_fingerprint
|
|
753
|
+
? worktree.repo_fingerprint === current.repo_fingerprint
|
|
754
|
+
: worktree.repo_label === current.repo_label)
|
|
755
|
+
.map((worktree) => ({
|
|
756
|
+
repo: worktree.repo_root,
|
|
757
|
+
repo_label: worktree.repo_label,
|
|
758
|
+
repo_fingerprint: worktree.repo_fingerprint,
|
|
759
|
+
repo_origin_url: worktree.repo_origin_url ?? undefined,
|
|
760
|
+
head_sha: worktree.head_sha ?? undefined,
|
|
761
|
+
worktree_label: worktree.worktree_label,
|
|
762
|
+
worktree_fingerprint: worktree.worktree_fingerprint,
|
|
763
|
+
worktree_is_primary: worktree.worktree_is_primary,
|
|
764
|
+
branch: worktree.branch,
|
|
765
|
+
}));
|
|
766
|
+
}
|
|
767
|
+
function baseBackfillResult(command, options) {
|
|
768
|
+
const states = countBy(options.scan.candidates, (candidate) => candidate.state);
|
|
769
|
+
const uploadable = uploadableCandidates(options.scan.candidates);
|
|
770
|
+
const perSource = {
|
|
771
|
+
codex: sourceCounts("codex", options.scan),
|
|
772
|
+
claude_code: sourceCounts("claude_code", options.scan),
|
|
773
|
+
};
|
|
774
|
+
return {
|
|
775
|
+
status: "complete",
|
|
776
|
+
dry_run: command.dryRun,
|
|
777
|
+
dashboard_url: options.dashboardUrl,
|
|
778
|
+
verify_url: `${options.dashboardUrl}/my-work`,
|
|
779
|
+
sources: options.sources,
|
|
780
|
+
window: options.window,
|
|
781
|
+
counts: {
|
|
782
|
+
total: options.scan.candidates.length,
|
|
783
|
+
uploadable: uploadable.length,
|
|
784
|
+
backfilled: 0,
|
|
785
|
+
skipped: options.scan.candidates.length - uploadable.length,
|
|
786
|
+
failed: 0,
|
|
787
|
+
deferred: 0,
|
|
788
|
+
remaining: 0,
|
|
789
|
+
states,
|
|
790
|
+
reasons: options.reasonCounts,
|
|
791
|
+
per_source: perSource,
|
|
792
|
+
},
|
|
793
|
+
batches: {
|
|
794
|
+
total: Math.ceil(uploadable.length / BACKFILL_UPLOAD_BATCH_SESSIONS),
|
|
795
|
+
completed: 0,
|
|
796
|
+
failed: 0,
|
|
797
|
+
},
|
|
798
|
+
resume_cursor: options.cursor,
|
|
799
|
+
retry_command: RETRY_COMMAND,
|
|
800
|
+
report: emptyReport("not_posted"),
|
|
801
|
+
server_acknowledged: {
|
|
802
|
+
codex_session_report_recorded_count: 0,
|
|
803
|
+
raw_evidence_uploaded_object_count: 0,
|
|
804
|
+
raw_evidence_uploaded_chunk_count: 0,
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
function blockedBackfillResult(command, options) {
|
|
809
|
+
return {
|
|
810
|
+
status: "blocked",
|
|
811
|
+
dry_run: command.dryRun,
|
|
812
|
+
dashboard_url: options.dashboardUrl,
|
|
813
|
+
verify_url: `${options.dashboardUrl}/my-work`,
|
|
814
|
+
sources: selectedSources(command.source),
|
|
815
|
+
window: {
|
|
816
|
+
mode: command.all ? "all" : "since_days",
|
|
817
|
+
since_days: command.sinceDays ?? null,
|
|
818
|
+
started_at: options.now.toISOString(),
|
|
819
|
+
paired_at: options.now.toISOString(),
|
|
820
|
+
since_minutes: 0,
|
|
821
|
+
},
|
|
822
|
+
counts: {
|
|
823
|
+
total: 0,
|
|
824
|
+
uploadable: 0,
|
|
825
|
+
backfilled: 0,
|
|
826
|
+
skipped: 0,
|
|
827
|
+
failed: 0,
|
|
828
|
+
deferred: 0,
|
|
829
|
+
remaining: 0,
|
|
830
|
+
states: {},
|
|
831
|
+
reasons: [],
|
|
832
|
+
per_source: {
|
|
833
|
+
codex: emptySourceCounts(),
|
|
834
|
+
claude_code: emptySourceCounts(),
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
batches: { total: 0, completed: 0, failed: 0 },
|
|
838
|
+
resume_cursor: options.cursor,
|
|
839
|
+
retry_command: RETRY_COMMAND,
|
|
840
|
+
report: emptyReport("not_posted"),
|
|
841
|
+
server_acknowledged: {
|
|
842
|
+
codex_session_report_recorded_count: 0,
|
|
843
|
+
raw_evidence_uploaded_object_count: 0,
|
|
844
|
+
raw_evidence_uploaded_chunk_count: 0,
|
|
845
|
+
},
|
|
846
|
+
failure_reason: options.reason,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function sourceCounts(source, scan) {
|
|
850
|
+
const candidates = scan.candidates.filter((candidate) => candidate.source === source);
|
|
851
|
+
const scanned = source === "codex"
|
|
852
|
+
? (scan.codexAttribution?.scanned_file_count ?? 0)
|
|
853
|
+
: (scan.claudeAttribution?.scanned_session_count ?? 0);
|
|
854
|
+
return {
|
|
855
|
+
scanned,
|
|
856
|
+
selected: candidates.length,
|
|
857
|
+
uploadable: uploadableCandidates(candidates).length,
|
|
858
|
+
states: countBy(candidates, (candidate) => candidate.state),
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function emptySourceCounts() {
|
|
862
|
+
return { scanned: 0, selected: 0, uploadable: 0, states: {} };
|
|
863
|
+
}
|
|
864
|
+
function emptyReport(reason) {
|
|
865
|
+
return {
|
|
866
|
+
posted: false,
|
|
867
|
+
reason,
|
|
868
|
+
chunk_count: 0,
|
|
869
|
+
recorded_count: 0,
|
|
870
|
+
failed_count: 0,
|
|
871
|
+
chunks: [],
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
function countBy(items, keyOf) {
|
|
875
|
+
const counts = {};
|
|
876
|
+
for (const item of items) {
|
|
877
|
+
const key = keyOf(item);
|
|
878
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
879
|
+
}
|
|
880
|
+
return counts;
|
|
881
|
+
}
|
|
882
|
+
function increment(counts, key) {
|
|
883
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
884
|
+
}
|
|
885
|
+
function writeDryRunSummary(io, options) {
|
|
886
|
+
const uploadable = uploadableCandidates(options.candidates);
|
|
887
|
+
writeLine(io.stdout, `${options.dryRunOnly ? "DRY-RUN" : "Review"}: ${options.candidates.length} session(s), ${uploadable.length} uploadable, ${options.candidates.length - uploadable.length} skipped.`);
|
|
888
|
+
writeReasonTable(io, options.reasonCounts);
|
|
889
|
+
if (options.dryRunOnly) {
|
|
890
|
+
writeLine(io.stdout, "DRY-RUN: wrote nothing (no cursor, marker, report, or upload).");
|
|
891
|
+
}
|
|
892
|
+
writeLine(io.stdout, `Verify after upload: ${options.dashboardUrl}/my-work`);
|
|
893
|
+
}
|
|
894
|
+
function writeHumanBackfillResult(result, io) {
|
|
895
|
+
if (result.status === "complete") {
|
|
896
|
+
writeLine(io.stdout, `PASS: ${result.counts.backfilled} backfilled, ${result.counts.skipped} skipped (table). Verify: ${result.verify_url}`);
|
|
897
|
+
writeReasonTable(io, result.counts.reasons);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (result.status === "partial") {
|
|
901
|
+
const at = result.blocked_at;
|
|
902
|
+
if (at) {
|
|
903
|
+
writeLine(io.stderr, `BLOCKED: ${at.what} at batch ${at.batch_index}/${at.batch_total}, ${at.done}/${at.total} done`);
|
|
904
|
+
}
|
|
905
|
+
writeLine(io.stderr, `Failure: ${result.failure_reason ?? "partial_backfill"}`);
|
|
906
|
+
writeLine(io.stderr, `Retry: ${result.retry_command}`);
|
|
907
|
+
writeLine(io.stderr, `Stopped: ${result.counts.remaining} remaining — rerun cockpit backfill to continue`);
|
|
908
|
+
writeReasonTable(io, result.counts.reasons);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const at = result.blocked_at;
|
|
912
|
+
if (at) {
|
|
913
|
+
writeLine(io.stderr, `BLOCKED: ${at.what} at batch ${at.batch_index}/${at.batch_total}, ${at.done}/${at.total} done`);
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
writeLine(io.stderr, "BLOCKED: backfill could not run.");
|
|
917
|
+
}
|
|
918
|
+
writeLine(io.stderr, `Failure: ${result.failure_reason ?? "no_sessions"}`);
|
|
919
|
+
writeLine(io.stderr, `Retry: ${result.retry_command}`);
|
|
920
|
+
}
|
|
921
|
+
function writeReasonTable(io, reasons) {
|
|
922
|
+
if (reasons.length === 0)
|
|
923
|
+
return;
|
|
924
|
+
writeLine(io.stdout, "Skip/reason table:");
|
|
925
|
+
for (const reason of reasons) {
|
|
926
|
+
writeLine(io.stdout, `- ${reason.reason}: ${reason.count} (${reason.classification}; ${reason.note})`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async function confirmAllBackfill(io) {
|
|
930
|
+
const answer = await readLine(io, "Proceed with --all backfill upload? [y/N] ");
|
|
931
|
+
return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
|
|
932
|
+
}
|
|
933
|
+
async function readLine(io, prompt) {
|
|
934
|
+
io.stdout.write(prompt);
|
|
935
|
+
io.stdin.setEncoding("utf8");
|
|
936
|
+
return new Promise((resolve) => {
|
|
937
|
+
const onData = (chunk) => {
|
|
938
|
+
io.stdin.removeListener("data", onData);
|
|
939
|
+
io.stdin.pause();
|
|
940
|
+
resolve(chunk);
|
|
941
|
+
};
|
|
942
|
+
io.stdin.resume();
|
|
943
|
+
io.stdin.on("data", onData);
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
function isInteractiveStdin(io) {
|
|
947
|
+
return Boolean(io.stdin.isTTY);
|
|
948
|
+
}
|
|
949
|
+
function parseRequiredDate(value, label) {
|
|
950
|
+
const date = new Date(value);
|
|
951
|
+
if (!Number.isFinite(date.getTime())) {
|
|
952
|
+
throw new Error(`Collector session ${label} is invalid.`);
|
|
953
|
+
}
|
|
954
|
+
return date;
|
|
955
|
+
}
|
|
956
|
+
function normalizeDashboardUrl(value) {
|
|
957
|
+
return value.trim().replace(/\/+$/, "");
|
|
958
|
+
}
|
|
959
|
+
function writeLine(stream, text) {
|
|
960
|
+
stream.write(`${text}\n`);
|
|
961
|
+
}
|
|
962
|
+
function yieldToEventLoop() {
|
|
963
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
964
|
+
}
|