@bli-cockpit/cli 0.2.47 → 0.2.49
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/adapters/raw-evidence-attribution-gaps.js +133 -0
- package/dist/adapters/raw-evidence.js +360 -349
- package/dist/autostart-contract.js +79 -0
- package/dist/autostart-darwin-plist.js +265 -0
- package/dist/autostart-darwin.js +171 -0
- package/dist/autostart-windows-scripts.js +310 -0
- package/dist/autostart-windows-task-xml.js +260 -0
- package/dist/autostart-windows.js +237 -0
- package/dist/autostart-xml.js +23 -0
- package/dist/autostart.js +35 -1148
- package/dist/commands/agent-rules-command.js +55 -0
- package/dist/commands/agent-session-report.js +290 -0
- package/dist/commands/analyze.js +131 -0
- package/dist/commands/autostart-command.js +105 -0
- package/dist/commands/backfill.js +824 -551
- package/dist/commands/cli-io.js +13 -0
- package/dist/commands/heartbeat.js +18 -0
- package/dist/commands/install-receipts.js +34 -0
- package/dist/commands/jarvis.js +179 -3
- package/dist/commands/local-arg-values.js +169 -0
- package/dist/commands/local-args-collector.js +578 -0
- package/dist/commands/local-args-tower.js +870 -0
- package/dist/commands/local-args.js +8 -1549
- package/dist/commands/local-help.js +11 -3
- package/dist/commands/local.js +18 -1786
- package/dist/commands/login.js +53 -0
- package/dist/commands/logout.js +66 -0
- package/dist/commands/onboard-receipts.js +66 -0
- package/dist/commands/onboard-report.js +274 -0
- package/dist/commands/onboard.js +449 -0
- package/dist/commands/ops-render.js +36 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/serve.js +13 -0
- package/dist/commands/session-sync.js +513 -534
- package/dist/commands/settings-render.js +28 -0
- package/dist/commands/settings.js +66 -2
- package/dist/commands/start.js +47 -0
- package/dist/commands/sync-followups.js +203 -0
- package/dist/commands/sync.js +381 -0
- package/dist/dev-build.js +186 -0
- package/dist/tower-stream.js +20 -4
- package/package.json +2 -2
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit backfill` — collect the archived Codex and Claude history that the
|
|
3
|
+
* every-15-minutes sync was never running to see.
|
|
4
|
+
*
|
|
5
|
+
* Read `runBackfill` below as the table of contents. It is four stages, in
|
|
6
|
+
* order, and every function in this file belongs to exactly one of them:
|
|
7
|
+
*
|
|
8
|
+
* SCAN resolveBackfillScope paired? which roots, stores and window
|
|
9
|
+
* scanBackfillSessions read both stores into candidates
|
|
10
|
+
* auditScannedCandidates census what could not be accounted for
|
|
11
|
+
* PLAN planBackfillRun confirm `--all`, or answer a `--dry-run`
|
|
12
|
+
* UPLOAD uploadBackfillBatches sync in batches under the shared lock
|
|
13
|
+
* REPORT reportBackfillOutcome post, checkpoint, decide, assemble
|
|
14
|
+
*
|
|
15
|
+
* Two rules run through all four and explain most of the apparent complexity.
|
|
16
|
+
* First, a session may never be silently dropped: everything the scan could
|
|
17
|
+
* not account for lands on the issue ledger and everything a person reads
|
|
18
|
+
* names its own reason. Second, the cursor is the only irreversible thing here
|
|
19
|
+
* — it advances solely through a contiguous prefix of sessions that both
|
|
20
|
+
* earned a durable pointer and were acknowledged by the server, so a
|
|
21
|
+
* misjudged "resolved" loses that history for good.
|
|
22
|
+
*/
|
|
1
23
|
import { NO_UPLOAD_ATTEMPT_RECORDED, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, notUploadableAttributionStateReason } from "@bli-cockpit/telemetry-core";
|
|
2
24
|
import crypto from "node:crypto";
|
|
3
25
|
import fs from "node:fs/promises";
|
|
@@ -75,19 +97,10 @@ export async function runBackfill(command, io) {
|
|
|
75
97
|
const { paths } = scanned;
|
|
76
98
|
const lock = await acquireBackfillLock(paths, now);
|
|
77
99
|
if (!lock.acquired) {
|
|
78
|
-
return {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
failure_reason: "backfill_already_running",
|
|
83
|
-
blocked_at: {
|
|
84
|
-
what: "backfill lock held",
|
|
85
|
-
batch_index: 0,
|
|
86
|
-
batch_total: 0,
|
|
87
|
-
done: 0,
|
|
88
|
-
total: scanned.scan.candidates.length,
|
|
89
|
-
},
|
|
90
|
-
};
|
|
100
|
+
return lockHeldResult(command, scanned, {
|
|
101
|
+
failureReason: "backfill_already_running",
|
|
102
|
+
what: "backfill lock held",
|
|
103
|
+
});
|
|
91
104
|
}
|
|
92
105
|
let collectionLock;
|
|
93
106
|
try {
|
|
@@ -99,19 +112,10 @@ export async function runBackfill(command, io) {
|
|
|
99
112
|
}
|
|
100
113
|
if (!collectionLock.acquired) {
|
|
101
114
|
await lock.handle.release();
|
|
102
|
-
return {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
failure_reason: "sync_already_running",
|
|
107
|
-
blocked_at: {
|
|
108
|
-
what: "sync collection lock held",
|
|
109
|
-
batch_index: 0,
|
|
110
|
-
batch_total: 0,
|
|
111
|
-
done: 0,
|
|
112
|
-
total: scanned.scan.candidates.length,
|
|
113
|
-
},
|
|
114
|
-
};
|
|
115
|
+
return lockHeldResult(command, scanned, {
|
|
116
|
+
failureReason: "sync_already_running",
|
|
117
|
+
what: "sync collection lock held",
|
|
118
|
+
});
|
|
115
119
|
}
|
|
116
120
|
try {
|
|
117
121
|
// Backfill and scheduled/manual sync share upload spool and raw-evidence
|
|
@@ -129,6 +133,26 @@ export async function runBackfill(command, io) {
|
|
|
129
133
|
await lock.handle.release();
|
|
130
134
|
}
|
|
131
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Someone else is already holding a lock this run needs. Reported as blocked
|
|
138
|
+
* with nothing done rather than as a failure — the scan is still valid, and
|
|
139
|
+
* the retry command in it works as soon as the other run finishes.
|
|
140
|
+
*/
|
|
141
|
+
function lockHeldResult(command, scanned, blocker) {
|
|
142
|
+
return {
|
|
143
|
+
...baseBackfillResult(command, backfillResultBaseArgs(scanned)),
|
|
144
|
+
status: "blocked",
|
|
145
|
+
retry_command: scanned.retryCommand,
|
|
146
|
+
failure_reason: blocker.failureReason,
|
|
147
|
+
blocked_at: {
|
|
148
|
+
what: blocker.what,
|
|
149
|
+
batch_index: 0,
|
|
150
|
+
batch_total: 0,
|
|
151
|
+
done: 0,
|
|
152
|
+
total: scanned.scan.candidates.length,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
132
156
|
/** The `{now, dashboardUrl, sources, window, cursor, scan, reasonCounts}` bag every `baseBackfillResult` call needs. */
|
|
133
157
|
function backfillResultBaseArgs(ctx) {
|
|
134
158
|
return {
|
|
@@ -148,34 +172,19 @@ function backfillResultBaseArgs(ctx) {
|
|
|
148
172
|
*/
|
|
149
173
|
async function scanBackfillRun(command, io, now) {
|
|
150
174
|
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
const session = await readLocalSessionReference(paths);
|
|
154
|
-
if (session.session_state !== "valid") {
|
|
175
|
+
const scope = await resolveBackfillScope(command, io, now, paths);
|
|
176
|
+
if (scope.kind === "not_paired") {
|
|
155
177
|
return {
|
|
156
178
|
kind: "blocked",
|
|
157
179
|
result: blockedBackfillResult(command, {
|
|
158
180
|
now,
|
|
159
|
-
dashboardUrl:
|
|
181
|
+
dashboardUrl: scope.dashboardUrl,
|
|
160
182
|
reason: "collector_not_paired",
|
|
161
183
|
cursor: await readBackfillCursor(paths),
|
|
162
184
|
}),
|
|
163
185
|
};
|
|
164
186
|
}
|
|
165
|
-
const
|
|
166
|
-
const dashboardUrl = normalizeDashboardUrl(sessionFile.dashboard_url ?? config.dashboard_url);
|
|
167
|
-
const roots = backfillCollectionRoots(command, config.default_repo_paths);
|
|
168
|
-
const collectionRoots = normalizeCollectionRoots(await collectionRootPathAliases(roots));
|
|
169
|
-
const worktreeDiscovery = await discoverBackfillWorktrees(collectionRoots, command);
|
|
170
|
-
const retryCommand = backfillDiscoveryRetryCommand(command, worktreeDiscovery);
|
|
171
|
-
const worktrees = worktreeDiscovery.worktrees;
|
|
172
|
-
const sources = selectedSources(command.source);
|
|
173
|
-
const window = backfillWindow(command, now, pairedAt);
|
|
174
|
-
await validateBackfillReachability({
|
|
175
|
-
fetchImpl: io.fetch,
|
|
176
|
-
dashboardUrl,
|
|
177
|
-
dryRun: command.dryRun,
|
|
178
|
-
});
|
|
187
|
+
const { collectionRoots, sources, worktreeDiscovery } = scope;
|
|
179
188
|
const storedCursor = command.dryRun
|
|
180
189
|
? emptyBackfillCursorState()
|
|
181
190
|
: await readBackfillCursor(paths);
|
|
@@ -185,43 +194,88 @@ async function scanBackfillRun(command, io, now) {
|
|
|
185
194
|
const scan = await scanBackfillSessions({
|
|
186
195
|
command,
|
|
187
196
|
homeDir: command.homeDir ?? os.homedir(),
|
|
188
|
-
worktrees,
|
|
197
|
+
worktrees: worktreeDiscovery.worktrees,
|
|
189
198
|
collectionRoots,
|
|
190
199
|
sources,
|
|
191
|
-
window,
|
|
200
|
+
window: scope.window,
|
|
192
201
|
cursor,
|
|
193
202
|
pointerlessTerminalSessions,
|
|
194
203
|
now,
|
|
195
204
|
});
|
|
196
|
-
|
|
197
|
-
// (see "Scan issue ledger" further down) instead of a bare push/sort.
|
|
198
|
-
addRepoDiscoveryIssues(scan.issues, worktreeDiscovery.incomplete_reasons);
|
|
199
|
-
sortScanIssuesByPriority(scan.issues);
|
|
200
|
-
const guards = await countReadOnlyGuards(scan.candidates);
|
|
201
|
-
scan.retryable_candidate_keys = new Set([
|
|
202
|
-
...scan.retryable_candidate_keys,
|
|
203
|
-
...guards.retryable_candidate_keys,
|
|
204
|
-
]);
|
|
205
|
-
addReadOnlyGuardIssues(scan.issues, guards.counts);
|
|
206
|
-
const reasonCounts = reasonCountsFor(scan.candidates, guards.counts, scan.issues);
|
|
207
|
-
const oversizedCandidateKeys = oversizedBackfillCandidateKeys(scan.candidates);
|
|
205
|
+
const reasonCounts = await auditScannedCandidates(scan, worktreeDiscovery.incomplete_reasons);
|
|
208
206
|
return {
|
|
209
207
|
kind: "scanned",
|
|
210
208
|
now,
|
|
211
209
|
paths,
|
|
212
|
-
dashboardUrl,
|
|
210
|
+
dashboardUrl: scope.dashboardUrl,
|
|
213
211
|
sources,
|
|
214
|
-
window,
|
|
212
|
+
window: scope.window,
|
|
215
213
|
collectionRoots,
|
|
216
|
-
worktrees,
|
|
217
|
-
retryCommand,
|
|
214
|
+
worktrees: worktreeDiscovery.worktrees,
|
|
215
|
+
retryCommand: scope.retryCommand,
|
|
218
216
|
scopedCursor,
|
|
219
217
|
cursor,
|
|
220
218
|
scan,
|
|
221
219
|
reasonCounts,
|
|
222
|
-
oversizedCandidateKeys,
|
|
220
|
+
oversizedCandidateKeys: oversizedBackfillCandidateKeys(scan.candidates),
|
|
223
221
|
};
|
|
224
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Answers the four questions every later stage assumes: is this collector
|
|
225
|
+
* paired, which approved roots and repos does it cover, which stores and how
|
|
226
|
+
* far back was it asked for, and — on a dry run — can it even reach the
|
|
227
|
+
* dashboard. Nothing here reads a session; a machine that is not paired stops
|
|
228
|
+
* before any history is touched.
|
|
229
|
+
*/
|
|
230
|
+
async function resolveBackfillScope(command, io, now, paths) {
|
|
231
|
+
const config = await readLocalCollectorConfig(paths);
|
|
232
|
+
const sessionFile = await readLocalCollectorSessionFile(paths);
|
|
233
|
+
const session = await readLocalSessionReference(paths);
|
|
234
|
+
if (session.session_state !== "valid") {
|
|
235
|
+
return {
|
|
236
|
+
kind: "not_paired",
|
|
237
|
+
dashboardUrl: sessionFile.dashboard_url ?? config.dashboard_url,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const pairedAt = parseRequiredDate(sessionFile.paired_at, "paired_at");
|
|
241
|
+
const dashboardUrl = normalizeDashboardUrl(sessionFile.dashboard_url ?? config.dashboard_url);
|
|
242
|
+
const roots = backfillCollectionRoots(command, config.default_repo_paths);
|
|
243
|
+
const collectionRoots = normalizeCollectionRoots(await collectionRootPathAliases(roots));
|
|
244
|
+
const worktreeDiscovery = await discoverBackfillWorktrees(collectionRoots, command);
|
|
245
|
+
const window = backfillWindow(command, now, pairedAt);
|
|
246
|
+
await validateBackfillReachability({
|
|
247
|
+
fetchImpl: io.fetch,
|
|
248
|
+
dashboardUrl,
|
|
249
|
+
dryRun: command.dryRun,
|
|
250
|
+
});
|
|
251
|
+
return {
|
|
252
|
+
kind: "ready",
|
|
253
|
+
dashboardUrl,
|
|
254
|
+
collectionRoots,
|
|
255
|
+
worktreeDiscovery,
|
|
256
|
+
retryCommand: backfillDiscoveryRetryCommand(command, worktreeDiscovery),
|
|
257
|
+
sources: selectedSources(command.source),
|
|
258
|
+
window,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Finishes the scan's ledger before anything acts on it: adds what repo
|
|
263
|
+
* discovery could not enumerate, reads every candidate file once to find the
|
|
264
|
+
* ones this machine cannot actually deliver, and folds all of it into the
|
|
265
|
+
* reason table the operator sees. Mutates `scan` in place — it is the one
|
|
266
|
+
* owner of `issues` and `retryable_candidate_keys` — and returns the table.
|
|
267
|
+
*/
|
|
268
|
+
async function auditScannedCandidates(scan, discoveryIncompleteReasons) {
|
|
269
|
+
addRepoDiscoveryIssues(scan.issues, discoveryIncompleteReasons);
|
|
270
|
+
sortScanIssuesByPriority(scan.issues);
|
|
271
|
+
const guards = await countReadOnlyGuards(scan.candidates);
|
|
272
|
+
scan.retryable_candidate_keys = new Set([
|
|
273
|
+
...scan.retryable_candidate_keys,
|
|
274
|
+
...guards.retryable_candidate_keys,
|
|
275
|
+
]);
|
|
276
|
+
addReadOnlyGuardIssues(scan.issues, guards.counts);
|
|
277
|
+
return reasonCountsFor(scan.candidates, guards.counts, scan.issues);
|
|
278
|
+
}
|
|
225
279
|
/**
|
|
226
280
|
* PLAN: given the scan, should this run actually upload anything right now?
|
|
227
281
|
* `--all` without `--yes` needs interactive confirmation; `--dry-run` reports
|
|
@@ -229,7 +283,7 @@ async function scanBackfillRun(command, io, now) {
|
|
|
229
283
|
* anything else proceeds to UPLOAD.
|
|
230
284
|
*/
|
|
231
285
|
async function planBackfillRun(command, io, ctx) {
|
|
232
|
-
const { dashboardUrl, scan, reasonCounts
|
|
286
|
+
const { dashboardUrl, scan, reasonCounts } = ctx;
|
|
233
287
|
if (command.all && !command.yes) {
|
|
234
288
|
if (!command.json) {
|
|
235
289
|
writeDryRunSummary(io, {
|
|
@@ -241,18 +295,7 @@ async function planBackfillRun(command, io, ctx) {
|
|
|
241
295
|
}
|
|
242
296
|
const confirmed = await confirmAllBackfill(io);
|
|
243
297
|
if (!confirmed) {
|
|
244
|
-
return {
|
|
245
|
-
kind: "result",
|
|
246
|
-
result: {
|
|
247
|
-
...baseBackfillResult(command, backfillResultBaseArgs(ctx)),
|
|
248
|
-
status: "blocked",
|
|
249
|
-
counts: {
|
|
250
|
-
...baseBackfillResult(command, backfillResultBaseArgs(ctx)).counts,
|
|
251
|
-
remaining: scan.candidates.length,
|
|
252
|
-
},
|
|
253
|
-
failure_reason: "confirmation_declined",
|
|
254
|
-
},
|
|
255
|
-
};
|
|
298
|
+
return { kind: "result", result: confirmationDeclinedResult(command, ctx) };
|
|
256
299
|
}
|
|
257
300
|
}
|
|
258
301
|
if (command.dryRun) {
|
|
@@ -264,21 +307,37 @@ async function planBackfillRun(command, io, ctx) {
|
|
|
264
307
|
dryRunOnly: true,
|
|
265
308
|
});
|
|
266
309
|
}
|
|
267
|
-
return {
|
|
268
|
-
kind: "result",
|
|
269
|
-
result: {
|
|
270
|
-
...baseBackfillResult(command, backfillResultBaseArgs(ctx)),
|
|
271
|
-
status: blockingScanIssues(scan.issues).length > 0 ? "partial" : "complete",
|
|
272
|
-
dry_run: true,
|
|
273
|
-
retry_command: retryCommand,
|
|
274
|
-
...(blockingScanIssues(scan.issues).length > 0
|
|
275
|
-
? { failure_reason: blockingScanIssues(scan.issues)[0]?.reason }
|
|
276
|
-
: {}),
|
|
277
|
-
},
|
|
278
|
-
};
|
|
310
|
+
return { kind: "result", result: dryRunResult(command, ctx) };
|
|
279
311
|
}
|
|
280
312
|
return { kind: "proceed" };
|
|
281
313
|
}
|
|
314
|
+
/** The operator saw the `--all` review and said no. Nothing was written; everything remains. */
|
|
315
|
+
function confirmationDeclinedResult(command, ctx) {
|
|
316
|
+
const base = baseBackfillResult(command, backfillResultBaseArgs(ctx));
|
|
317
|
+
return {
|
|
318
|
+
...base,
|
|
319
|
+
status: "blocked",
|
|
320
|
+
counts: { ...base.counts, remaining: ctx.scan.candidates.length },
|
|
321
|
+
failure_reason: "confirmation_declined",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* What a `--dry-run` would have done. It completes unless the scan itself hit
|
|
326
|
+
* something blocking, because a dry run that reports "complete" over a scan
|
|
327
|
+
* that could not see all the history would be the confident wrong answer.
|
|
328
|
+
*/
|
|
329
|
+
function dryRunResult(command, ctx) {
|
|
330
|
+
const blockingIssues = blockingScanIssues(ctx.scan.issues);
|
|
331
|
+
return {
|
|
332
|
+
...baseBackfillResult(command, backfillResultBaseArgs(ctx)),
|
|
333
|
+
status: blockingIssues.length > 0 ? "partial" : "complete",
|
|
334
|
+
dry_run: true,
|
|
335
|
+
retry_command: ctx.retryCommand,
|
|
336
|
+
...(blockingIssues.length > 0
|
|
337
|
+
? { failure_reason: blockingIssues[0]?.reason }
|
|
338
|
+
: {}),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
282
341
|
/**
|
|
283
342
|
* UPLOAD: sync every uploadable candidate in fixed-size batches, heartbeating
|
|
284
343
|
* the backfill lock between batches, stopping early on an exhausted budget or
|
|
@@ -289,116 +348,124 @@ async function planBackfillRun(command, io, ctx) {
|
|
|
289
348
|
* siblings, so it is excluded via `oversizedCandidateKeys` throughout.
|
|
290
349
|
*/
|
|
291
350
|
async function uploadBackfillBatches(command, io, lock, ctx) {
|
|
292
|
-
const
|
|
293
|
-
const uploadable = uploadableCandidates(scan.candidates);
|
|
351
|
+
const uploadable = uploadableCandidates(ctx.scan.candidates);
|
|
294
352
|
const batches = buildBackfillBatches(uploadable);
|
|
295
353
|
const rawEvidenceBudget = {
|
|
296
354
|
remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
|
|
297
355
|
remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
|
|
298
356
|
};
|
|
299
|
-
const
|
|
300
|
-
let completedBatches = 0;
|
|
301
|
-
let failedBatches = 0;
|
|
357
|
+
const tally = emptyUploadTally();
|
|
302
358
|
let consecutiveFailures = 0;
|
|
303
|
-
let done = 0;
|
|
304
|
-
let failed = 0;
|
|
305
|
-
let deferred = 0;
|
|
306
|
-
let uploadedObjects = 0;
|
|
307
|
-
let uploadedChunks = 0;
|
|
308
|
-
let backfilledSessions = 0;
|
|
309
|
-
const durableCandidateKeys = new Set();
|
|
310
359
|
let blockedAt;
|
|
311
|
-
let failureReason = blockingScanIssues(scan.issues)[0]?.reason;
|
|
360
|
+
let failureReason = blockingScanIssues(ctx.scan.issues)[0]?.reason;
|
|
361
|
+
const stoppedAt = (what, index) => ({
|
|
362
|
+
what,
|
|
363
|
+
batch_index: index + 1,
|
|
364
|
+
batch_total: batches.length,
|
|
365
|
+
done: tally.done,
|
|
366
|
+
total: uploadable.length,
|
|
367
|
+
});
|
|
312
368
|
for (const [index, batch] of batches.entries()) {
|
|
313
369
|
await lock.handle.heartbeat();
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
worktrees,
|
|
318
|
-
codexAttribution: scan.codexAttribution,
|
|
319
|
-
claudeAttribution: scan.claudeAttribution,
|
|
320
|
-
rawEvidenceBudget,
|
|
321
|
-
fetchImpl: io.fetch,
|
|
322
|
-
});
|
|
323
|
-
syncResults.push(sync);
|
|
324
|
-
done += batch.candidates.length;
|
|
325
|
-
uploadedObjects += sync.raw_evidence_uploaded_object_count;
|
|
326
|
-
uploadedChunks += sync.raw_evidence_uploaded_chunk_count;
|
|
327
|
-
failed += countSessionUploadFailures(sync);
|
|
328
|
-
deferred +=
|
|
329
|
-
sync.raw_evidence_deferred_byte_budget +
|
|
330
|
-
sync.raw_evidence_deferred_object_budget;
|
|
331
|
-
const durableInBatch = durableBackfillCandidateKeys(batch, sync);
|
|
332
|
-
for (const key of durableInBatch)
|
|
333
|
-
durableCandidateKeys.add(key);
|
|
334
|
-
backfilledSessions = durableCandidateKeys.size;
|
|
335
|
-
const missingDurableMain = batch.candidates.filter((candidate) => !durableInBatch.has(candidateCursorKey(candidate)) &&
|
|
336
|
-
!oversizedCandidateKeys.has(candidateCursorKey(candidate))).length;
|
|
337
|
-
const batchFailed = sync.status !== "uploaded" ||
|
|
338
|
-
countSessionUploadFailures(sync) > 0 ||
|
|
339
|
-
missingDurableMain > 0;
|
|
340
|
-
const batchDeferred = sync.raw_evidence_deferred_byte_budget +
|
|
341
|
-
sync.raw_evidence_deferred_object_budget >
|
|
342
|
-
0;
|
|
343
|
-
if (batchFailed) {
|
|
344
|
-
failedBatches += 1;
|
|
345
|
-
consecutiveFailures += 1;
|
|
346
|
-
}
|
|
347
|
-
else {
|
|
348
|
-
consecutiveFailures = 0;
|
|
349
|
-
}
|
|
350
|
-
if (!batchFailed && !batchDeferred) {
|
|
351
|
-
completedBatches += 1;
|
|
352
|
-
}
|
|
370
|
+
const outcome = await uploadOneBackfillBatch(command, io, ctx, batch, rawEvidenceBudget);
|
|
371
|
+
foldBatchIntoTally(tally, batch, outcome);
|
|
372
|
+
consecutiveFailures = outcome.failed ? consecutiveFailures + 1 : 0;
|
|
353
373
|
if (!command.json) {
|
|
354
|
-
writeLine(io.stdout, `Uploaded ${done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
|
|
374
|
+
writeLine(io.stdout, `Uploaded ${tally.done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
|
|
355
375
|
}
|
|
356
376
|
await yieldToEventLoop();
|
|
357
|
-
if (
|
|
358
|
-
blockedAt =
|
|
359
|
-
what: "raw evidence budget exhausted",
|
|
360
|
-
batch_index: index + 1,
|
|
361
|
-
batch_total: batches.length,
|
|
362
|
-
done,
|
|
363
|
-
total: uploadable.length,
|
|
364
|
-
};
|
|
377
|
+
if (outcome.deferred) {
|
|
378
|
+
blockedAt = stoppedAt("raw evidence budget exhausted", index);
|
|
365
379
|
failureReason = "deferred_budget_exhausted";
|
|
366
380
|
break;
|
|
367
381
|
}
|
|
368
382
|
if (consecutiveFailures >= BACKFILL_MAX_CONSECUTIVE_FAILURES) {
|
|
369
|
-
blockedAt =
|
|
370
|
-
|
|
371
|
-
batch_index: index + 1,
|
|
372
|
-
batch_total: batches.length,
|
|
373
|
-
done,
|
|
374
|
-
total: uploadable.length,
|
|
375
|
-
};
|
|
376
|
-
failureReason =
|
|
377
|
-
sync.status === "spooled"
|
|
378
|
-
? sync.failure_reason
|
|
379
|
-
: missingDurableMain > 0
|
|
380
|
-
? "durable_session_pointer_missing"
|
|
381
|
-
: "upload_failed";
|
|
383
|
+
blockedAt = stoppedAt("consecutive upload failures", index);
|
|
384
|
+
failureReason = failedBatchReason(outcome.sync, outcome.missingDurableMain);
|
|
382
385
|
break;
|
|
383
386
|
}
|
|
384
387
|
}
|
|
388
|
+
return { ...tally, uploadable, batches, blockedAt, failureReason };
|
|
389
|
+
}
|
|
390
|
+
/** Uploads one batch and judges it; every judgement here is a named predicate. */
|
|
391
|
+
async function uploadOneBackfillBatch(command, io, ctx, batch, rawEvidenceBudget) {
|
|
392
|
+
const sync = await syncBackfillBatch({
|
|
393
|
+
command,
|
|
394
|
+
batch,
|
|
395
|
+
worktrees: ctx.worktrees,
|
|
396
|
+
codexAttribution: ctx.scan.codexAttribution,
|
|
397
|
+
claudeAttribution: ctx.scan.claudeAttribution,
|
|
398
|
+
rawEvidenceBudget,
|
|
399
|
+
fetchImpl: io.fetch,
|
|
400
|
+
});
|
|
401
|
+
const durableInBatch = durableBackfillCandidateKeys(batch, sync);
|
|
402
|
+
const missingDurableMain = countMissingDurableMains(batch, durableInBatch, ctx.oversizedCandidateKeys);
|
|
385
403
|
return {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
404
|
+
sync,
|
|
405
|
+
durableInBatch,
|
|
406
|
+
missingDurableMain,
|
|
407
|
+
failed: didBatchFail(sync, missingDurableMain),
|
|
408
|
+
deferred: deferredEvidenceCount(sync) > 0,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
function emptyUploadTally() {
|
|
412
|
+
return {
|
|
413
|
+
syncResults: [],
|
|
414
|
+
completedBatches: 0,
|
|
415
|
+
failedBatches: 0,
|
|
416
|
+
done: 0,
|
|
417
|
+
failed: 0,
|
|
418
|
+
deferred: 0,
|
|
419
|
+
uploadedObjects: 0,
|
|
420
|
+
uploadedChunks: 0,
|
|
421
|
+
backfilledSessions: 0,
|
|
422
|
+
durableCandidateKeys: new Set(),
|
|
400
423
|
};
|
|
401
424
|
}
|
|
425
|
+
function foldBatchIntoTally(tally, batch, outcome) {
|
|
426
|
+
tally.syncResults.push(outcome.sync);
|
|
427
|
+
tally.done += batch.candidates.length;
|
|
428
|
+
tally.uploadedObjects += outcome.sync.raw_evidence_uploaded_object_count;
|
|
429
|
+
tally.uploadedChunks += outcome.sync.raw_evidence_uploaded_chunk_count;
|
|
430
|
+
tally.failed += countSessionUploadFailures(outcome.sync);
|
|
431
|
+
tally.deferred += deferredEvidenceCount(outcome.sync);
|
|
432
|
+
for (const key of outcome.durableInBatch)
|
|
433
|
+
tally.durableCandidateKeys.add(key);
|
|
434
|
+
tally.backfilledSessions = tally.durableCandidateKeys.size;
|
|
435
|
+
if (outcome.failed)
|
|
436
|
+
tally.failedBatches += 1;
|
|
437
|
+
if (!outcome.failed && !outcome.deferred)
|
|
438
|
+
tally.completedBatches += 1;
|
|
439
|
+
}
|
|
440
|
+
/** Evidence the server accepted the batch for but had no budget left to store. */
|
|
441
|
+
function deferredEvidenceCount(sync) {
|
|
442
|
+
return (sync.raw_evidence_deferred_byte_budget +
|
|
443
|
+
sync.raw_evidence_deferred_object_budget);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Candidates in this batch that still owe a durable main transcript. An
|
|
447
|
+
* oversized skip is excluded (BLI-2727): it can never earn a pointer under the
|
|
448
|
+
* current cap, and counting it here would fail every batch it happens to share
|
|
449
|
+
* with genuinely uploaded siblings.
|
|
450
|
+
*/
|
|
451
|
+
function countMissingDurableMains(batch, durableInBatch, oversizedCandidateKeys) {
|
|
452
|
+
return batch.candidates.filter((candidate) => !durableInBatch.has(candidateCursorKey(candidate)) &&
|
|
453
|
+
!oversizedCandidateKeys.has(candidateCursorKey(candidate))).length;
|
|
454
|
+
}
|
|
455
|
+
/** A batch counts as failed if it did not upload, lost a session, or left one pointerless. */
|
|
456
|
+
function didBatchFail(sync, missingDurableMain) {
|
|
457
|
+
return (sync.status !== "uploaded" ||
|
|
458
|
+
countSessionUploadFailures(sync) > 0 ||
|
|
459
|
+
missingDurableMain > 0);
|
|
460
|
+
}
|
|
461
|
+
/** Names which of the three failure shapes ended the run, most specific first. */
|
|
462
|
+
function failedBatchReason(sync, missingDurableMain) {
|
|
463
|
+
if (sync.status === "spooled")
|
|
464
|
+
return sync.failure_reason;
|
|
465
|
+
if (missingDurableMain > 0)
|
|
466
|
+
return "durable_session_pointer_missing";
|
|
467
|
+
return "upload_failed";
|
|
468
|
+
}
|
|
402
469
|
/**
|
|
403
470
|
* REPORT: post the session report, record durable pointers, advance the
|
|
404
471
|
* cursor through the resolved contiguous prefix, decide completion, write the
|
|
@@ -410,107 +477,150 @@ async function uploadBackfillBatches(command, io, lock, ctx) {
|
|
|
410
477
|
* excludes it explicitly via `oversizedCandidateKeys`/`blockingScanIssues`.
|
|
411
478
|
*/
|
|
412
479
|
async function reportBackfillOutcome(command, io, ctx, upload) {
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
480
|
+
let { blockedAt, failureReason } = upload;
|
|
481
|
+
const posted = await postBackfillSessionReport(command, io, ctx, upload);
|
|
482
|
+
if (!posted.acknowledged && !blockedAt) {
|
|
483
|
+
blockedAt = sessionReportStopPoint(upload);
|
|
484
|
+
failureReason ??= sessionReportFailureReason(posted);
|
|
485
|
+
}
|
|
486
|
+
if (posted.acknowledged) {
|
|
487
|
+
await checkpointResolvedBackfillProgress(ctx, upload);
|
|
488
|
+
}
|
|
489
|
+
const completion = summarizeBackfillCompletion({
|
|
490
|
+
ctx,
|
|
491
|
+
upload,
|
|
492
|
+
posted,
|
|
493
|
+
stoppedEarly: Boolean(blockedAt),
|
|
494
|
+
failureReason,
|
|
495
|
+
});
|
|
496
|
+
if (completion.status === "complete" && command.all) {
|
|
497
|
+
await writeAllHistoryCompletionMarker(ctx);
|
|
498
|
+
}
|
|
499
|
+
return assembleBackfillResult({
|
|
500
|
+
command,
|
|
501
|
+
ctx,
|
|
502
|
+
upload,
|
|
503
|
+
posted,
|
|
504
|
+
completion,
|
|
505
|
+
blockedAt,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Tells the server what this run observed about every session, uploaded or
|
|
510
|
+
* not. Its acknowledgement — not raw-evidence durability alone — is what makes
|
|
511
|
+
* a historical cursor position irreversible, so the caller gates the whole
|
|
512
|
+
* checkpoint on the `acknowledged` flag returned here.
|
|
513
|
+
*/
|
|
514
|
+
async function postBackfillSessionReport(command, io, ctx, upload) {
|
|
416
515
|
const sessions = buildBackfillSessionReport({
|
|
417
|
-
candidates: scan.candidates,
|
|
418
|
-
syncResults,
|
|
419
|
-
now,
|
|
516
|
+
candidates: ctx.scan.candidates,
|
|
517
|
+
syncResults: upload.syncResults,
|
|
518
|
+
now: ctx.now,
|
|
420
519
|
});
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
homeDir: command.homeDir,
|
|
424
|
-
paths,
|
|
425
|
-
collectionRoots,
|
|
426
|
-
worktrees,
|
|
427
|
-
candidates: scan.candidates,
|
|
428
|
-
})
|
|
429
|
-
: null;
|
|
430
|
-
const report = sessions.length
|
|
431
|
-
? await postCodexSessionReport({
|
|
432
|
-
homeDir: command.homeDir,
|
|
433
|
-
repoRoot: reportContext?.repoRoot,
|
|
434
|
-
dashboardUrl,
|
|
520
|
+
if (sessions.length === 0) {
|
|
521
|
+
return {
|
|
435
522
|
sessions,
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
})
|
|
439
|
-
: emptyReport("no_sessions_observed");
|
|
440
|
-
const reportAcknowledged = sessions.length === 0 ||
|
|
441
|
-
(report.posted && report.recorded_count >= sessions.length);
|
|
442
|
-
if (!reportAcknowledged && !blockedAt) {
|
|
443
|
-
blockedAt = {
|
|
444
|
-
what: "session report failed",
|
|
445
|
-
batch_index: completedBatches,
|
|
446
|
-
batch_total: batches.length,
|
|
447
|
-
done,
|
|
448
|
-
total: uploadable.length,
|
|
523
|
+
report: emptyReport("no_sessions_observed"),
|
|
524
|
+
acknowledged: true,
|
|
449
525
|
};
|
|
450
|
-
failureReason ??=
|
|
451
|
-
report.posted && report.recorded_count < sessions.length
|
|
452
|
-
? "session_report_ack_incomplete"
|
|
453
|
-
: report.reason;
|
|
454
526
|
}
|
|
455
|
-
|
|
527
|
+
const reportContext = await ensureBackfillReportContext({
|
|
528
|
+
homeDir: command.homeDir,
|
|
529
|
+
paths: ctx.paths,
|
|
530
|
+
collectionRoots: ctx.collectionRoots,
|
|
531
|
+
worktrees: ctx.worktrees,
|
|
532
|
+
candidates: ctx.scan.candidates,
|
|
533
|
+
});
|
|
534
|
+
const report = await postCodexSessionReport({
|
|
535
|
+
homeDir: command.homeDir,
|
|
536
|
+
repoRoot: reportContext.repoRoot,
|
|
537
|
+
dashboardUrl: ctx.dashboardUrl,
|
|
538
|
+
sessions,
|
|
539
|
+
fetch: io.fetch,
|
|
540
|
+
now: ctx.now,
|
|
541
|
+
});
|
|
542
|
+
return {
|
|
543
|
+
sessions,
|
|
544
|
+
report,
|
|
545
|
+
acknowledged: report.posted && report.recorded_count >= sessions.length,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
/** Where a run that uploaded cleanly but could not file its report stopped. */
|
|
549
|
+
function sessionReportStopPoint(upload) {
|
|
550
|
+
return {
|
|
551
|
+
what: "session report failed",
|
|
552
|
+
batch_index: upload.completedBatches,
|
|
553
|
+
batch_total: upload.batches.length,
|
|
554
|
+
done: upload.done,
|
|
555
|
+
total: upload.uploadable.length,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
/** A partial acknowledgement is its own reason; otherwise the post's own. */
|
|
559
|
+
function sessionReportFailureReason(posted) {
|
|
560
|
+
return posted.report.posted &&
|
|
561
|
+
posted.report.recorded_count < posted.sessions.length
|
|
562
|
+
? "session_report_ack_incomplete"
|
|
563
|
+
: posted.report.reason;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Makes this run's progress durable once the server has acknowledged it:
|
|
567
|
+
* session pointers first, then the backfill cursor through the contiguous
|
|
568
|
+
* resolved prefix. Raw-evidence durability is necessary but not sufficient —
|
|
569
|
+
* both writes wait on the report acknowledgement, because a cursor advanced
|
|
570
|
+
* past a session the server never recorded can never be walked back.
|
|
571
|
+
*/
|
|
572
|
+
async function checkpointResolvedBackfillProgress(ctx, upload) {
|
|
573
|
+
const { paths, scan, cursor, now } = ctx;
|
|
574
|
+
if (upload.durableCandidateKeys.size > 0) {
|
|
456
575
|
await recordBackfillDurableSessionPointers({
|
|
457
576
|
paths,
|
|
458
577
|
candidates: scan.candidates,
|
|
459
|
-
syncResults,
|
|
460
|
-
now,
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
// Raw evidence durability is necessary but not sufficient: the server must
|
|
464
|
-
// also acknowledge the session attribution rows before their historical
|
|
465
|
-
// cursor positions become irreversible.
|
|
466
|
-
if (reportAcknowledged) {
|
|
467
|
-
const cursorAdvanced = advanceBackfillCursorThroughResolvedPrefix({
|
|
468
|
-
cursor,
|
|
469
|
-
candidates: scan.candidates,
|
|
470
|
-
durableCandidateKeys,
|
|
471
|
-
retryableCandidateKeys: scan.retryable_candidate_keys,
|
|
472
|
-
discoveryComplete: !scan.issues.some((issue) => issue.scope === "global"),
|
|
578
|
+
syncResults: upload.syncResults,
|
|
473
579
|
now,
|
|
474
580
|
});
|
|
475
|
-
if (cursorAdvanced)
|
|
476
|
-
await writeBackfillCursor(paths, cursor);
|
|
477
581
|
}
|
|
478
|
-
const
|
|
479
|
-
|
|
582
|
+
const cursorAdvanced = advanceBackfillCursorThroughResolvedPrefix({
|
|
583
|
+
cursor,
|
|
584
|
+
candidates: scan.candidates,
|
|
585
|
+
durableCandidateKeys: upload.durableCandidateKeys,
|
|
586
|
+
retryableCandidateKeys: scan.retryable_candidate_keys,
|
|
587
|
+
discoveryComplete: !scan.issues.some((issue) => issue.scope === "global"),
|
|
588
|
+
now,
|
|
589
|
+
});
|
|
590
|
+
if (cursorAdvanced)
|
|
591
|
+
await writeBackfillCursor(paths, cursor);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Answers "is this backfill finished, and if not, why not" as pure arithmetic
|
|
595
|
+
* over what the scan found and what the upload resolved — no side effects, so
|
|
596
|
+
* the one place BLI-2727's oversized-skip rule is applied is readable in full.
|
|
597
|
+
* `remaining` is a reporting total that still counts oversized skips so the
|
|
598
|
+
* JSON never goes silent about them; every `*Blocking` count excludes them,
|
|
599
|
+
* because a file too large for the current cap is a permanent labeled fact
|
|
600
|
+
* that no rerun can resolve and completion must not wait on forever.
|
|
601
|
+
*/
|
|
602
|
+
function summarizeBackfillCompletion(options) {
|
|
603
|
+
const { scan, oversizedCandidateKeys } = options.ctx;
|
|
604
|
+
const { upload, posted } = options;
|
|
605
|
+
const unresolvedUploadableKeys = new Set(upload.uploadable
|
|
606
|
+
.filter((candidate) => !upload.durableCandidateKeys.has(candidateCursorKey(candidate)))
|
|
480
607
|
.map(candidateCursorKey));
|
|
481
|
-
// "Blocking" views exclude a deterministic oversized skip (BLI-2727): it is
|
|
482
|
-
// still unresolved (it never gets a durable pointer), but it is a permanent,
|
|
483
|
-
// labeled fact rather than something completion should wait on forever.
|
|
484
608
|
const unresolvedUploadableBlocking = [...unresolvedUploadableKeys].filter((key) => !oversizedCandidateKeys.has(key)).length;
|
|
485
609
|
const unresolvedRetryableBlocking = [...scan.retryable_candidate_keys].filter((key) => !oversizedCandidateKeys.has(key)).length;
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
.reduce((total, issue) => total + issue.count, 0);
|
|
493
|
-
const reportRetryable = reportAcknowledged
|
|
494
|
-
? 0
|
|
495
|
-
// The cursor is intentionally all-or-nothing for the report. Even
|
|
496
|
-
// chunks already accepted by the server are retried idempotently when
|
|
497
|
-
// another required chunk lacks an acknowledgement.
|
|
498
|
-
: Math.max(1, sessions.length);
|
|
499
|
-
// `remaining` is a reporting total, not a completion gate: it still counts
|
|
500
|
-
// every unresolved candidate, including oversized skips, so the JSON output
|
|
501
|
-
// never goes silent about them (BLI-2727).
|
|
502
|
-
const remaining = unresolvedKnownKeys.size +
|
|
503
|
-
scan.omitted_candidate_count +
|
|
504
|
-
unseenGlobalFailures +
|
|
505
|
-
reportRetryable;
|
|
506
|
-
failed = Math.max(failed, unresolvedUploadableBlocking);
|
|
610
|
+
const remaining = countRemainingHistory({
|
|
611
|
+
scan,
|
|
612
|
+
unresolvedUploadableKeys,
|
|
613
|
+
posted,
|
|
614
|
+
});
|
|
615
|
+
const failed = Math.max(upload.failed, unresolvedUploadableBlocking);
|
|
507
616
|
const blockingIssues = blockingScanIssues(scan.issues);
|
|
508
617
|
const completionBlocked = blockingIssues.length > 0 ||
|
|
509
618
|
unresolvedUploadableBlocking > 0 ||
|
|
510
619
|
unresolvedRetryableBlocking > 0 ||
|
|
511
|
-
deferred > 0 ||
|
|
620
|
+
upload.deferred > 0 ||
|
|
512
621
|
failed > 0 ||
|
|
513
|
-
!
|
|
622
|
+
!posted.acknowledged;
|
|
623
|
+
let failureReason = options.failureReason;
|
|
514
624
|
if (!failureReason && completionBlocked) {
|
|
515
625
|
const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate)) &&
|
|
516
626
|
!oversizedCandidateKeys.has(candidateCursorKey(candidate)))?.reason;
|
|
@@ -518,59 +628,98 @@ async function reportBackfillOutcome(command, io, ctx, upload) {
|
|
|
518
628
|
blockingIssues[0]?.reason ??
|
|
519
629
|
(unresolvedUploadableBlocking > 0
|
|
520
630
|
? "durable_session_pointer_missing"
|
|
521
|
-
: retryableCandidateReason ?? "backfill_incomplete");
|
|
522
|
-
}
|
|
523
|
-
const status = blockedAt || completionBlocked ? "partial" : "complete";
|
|
524
|
-
// The marker is consumed by doctor/status as proof that archived history
|
|
525
|
-
// is covered. A bounded --since-days run may complete its requested
|
|
526
|
-
// window, but it is not proof of an all-history backfill.
|
|
527
|
-
if (status === "complete" && command.all) {
|
|
528
|
-
recordBackfillScanCoverage(cursor, sources, now, now);
|
|
529
|
-
await writeBackfillCursor(paths, cursor);
|
|
530
|
-
const oversizedCandidates = scan.candidates.filter((candidate) => oversizedCandidateKeys.has(candidateCursorKey(candidate)));
|
|
531
|
-
await writeBackfillCompletionMarker(paths, {
|
|
532
|
-
schema_version: "cockpit-backfill-complete.v2",
|
|
533
|
-
coverage_version: BACKFILL_COVERAGE_VERSION,
|
|
534
|
-
collection_scope_id: scopedCursor.collection_scope_id,
|
|
535
|
-
sources,
|
|
536
|
-
completed_at: now.toISOString(),
|
|
537
|
-
revalidate_after: new Date(now.getTime() + BACKFILL_COMPLETION_RECHECK_MS).toISOString(),
|
|
538
|
-
cursor,
|
|
539
|
-
...(oversizedCandidates.length > 0
|
|
540
|
-
? {
|
|
541
|
-
oversized_skips: {
|
|
542
|
-
reason: "file_too_large",
|
|
543
|
-
count: oversizedCandidates.length,
|
|
544
|
-
byte_sizes: oversizedCandidates.map((candidate) => candidate.byte_size),
|
|
545
|
-
},
|
|
546
|
-
}
|
|
547
|
-
: {}),
|
|
548
|
-
});
|
|
631
|
+
: (retryableCandidateReason ?? "backfill_incomplete"));
|
|
549
632
|
}
|
|
550
633
|
return {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
634
|
+
remaining,
|
|
635
|
+
failed,
|
|
636
|
+
status: options.stoppedEarly || completionBlocked ? "partial" : "complete",
|
|
637
|
+
failureReason,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* How much history a rerun still has to do. A reporting total, not a
|
|
642
|
+
* completion gate: it counts every unresolved candidate — oversized skips
|
|
643
|
+
* included, so `--json` never goes silent about them (BLI-2727) — plus what
|
|
644
|
+
* selection dropped, what discovery never saw, and the report itself when the
|
|
645
|
+
* server has not acknowledged it.
|
|
646
|
+
*/
|
|
647
|
+
function countRemainingHistory(options) {
|
|
648
|
+
const unresolvedKnownKeys = new Set([
|
|
649
|
+
...options.unresolvedUploadableKeys,
|
|
650
|
+
...options.scan.retryable_candidate_keys,
|
|
651
|
+
]);
|
|
652
|
+
const unseenGlobalFailures = options.scan.issues
|
|
653
|
+
.filter((issue) => issue.scope === "global")
|
|
654
|
+
.reduce((total, issue) => total + issue.count, 0);
|
|
655
|
+
const reportRetryable = options.posted.acknowledged
|
|
656
|
+
? 0
|
|
657
|
+
: // The cursor is intentionally all-or-nothing for the report. Even
|
|
658
|
+
// chunks already accepted by the server are retried idempotently when
|
|
659
|
+
// another required chunk lacks an acknowledgement.
|
|
660
|
+
Math.max(1, options.posted.sessions.length);
|
|
661
|
+
return (unresolvedKnownKeys.size +
|
|
662
|
+
options.scan.omitted_candidate_count +
|
|
663
|
+
unseenGlobalFailures +
|
|
664
|
+
reportRetryable);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Proof, for doctor and status, that archived history is covered. Only an
|
|
668
|
+
* `--all` run earns it: a bounded `--since-days` run may complete its
|
|
669
|
+
* requested window, but that is not proof of an all-history backfill.
|
|
670
|
+
*/
|
|
671
|
+
async function writeAllHistoryCompletionMarker(ctx) {
|
|
672
|
+
const { paths, cursor, sources, scopedCursor, scan, oversizedCandidateKeys, now } = ctx;
|
|
673
|
+
recordBackfillScanCoverage(cursor, sources, now, now);
|
|
674
|
+
await writeBackfillCursor(paths, cursor);
|
|
675
|
+
const oversizedCandidates = scan.candidates.filter((candidate) => oversizedCandidateKeys.has(candidateCursorKey(candidate)));
|
|
676
|
+
await writeBackfillCompletionMarker(paths, {
|
|
677
|
+
schema_version: "cockpit-backfill-complete.v2",
|
|
678
|
+
coverage_version: BACKFILL_COVERAGE_VERSION,
|
|
679
|
+
collection_scope_id: scopedCursor.collection_scope_id,
|
|
680
|
+
sources,
|
|
681
|
+
completed_at: now.toISOString(),
|
|
682
|
+
revalidate_after: new Date(now.getTime() + BACKFILL_COMPLETION_RECHECK_MS).toISOString(),
|
|
683
|
+
cursor,
|
|
684
|
+
...(oversizedCandidates.length > 0
|
|
685
|
+
? {
|
|
686
|
+
oversized_skips: {
|
|
687
|
+
reason: "file_too_large",
|
|
688
|
+
count: oversizedCandidates.length,
|
|
689
|
+
byte_sizes: oversizedCandidates.map((candidate) => candidate.byte_size),
|
|
690
|
+
},
|
|
691
|
+
}
|
|
692
|
+
: {}),
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
/** The `--json` payload: every stage's numbers merged onto the scan's baseline. */
|
|
696
|
+
function assembleBackfillResult(options) {
|
|
697
|
+
const { command, ctx, upload, posted, completion } = options;
|
|
698
|
+
const base = baseBackfillResult(command, backfillResultBaseArgs(ctx));
|
|
699
|
+
return {
|
|
700
|
+
...base,
|
|
701
|
+
status: completion.status,
|
|
702
|
+
retry_command: ctx.retryCommand,
|
|
554
703
|
counts: {
|
|
555
|
-
...
|
|
556
|
-
backfilled: backfilledSessions,
|
|
557
|
-
failed,
|
|
558
|
-
deferred,
|
|
559
|
-
remaining,
|
|
704
|
+
...base.counts,
|
|
705
|
+
backfilled: upload.backfilledSessions,
|
|
706
|
+
failed: completion.failed,
|
|
707
|
+
deferred: upload.deferred,
|
|
708
|
+
remaining: completion.remaining,
|
|
560
709
|
},
|
|
561
710
|
batches: {
|
|
562
|
-
total: batches.length,
|
|
563
|
-
completed: completedBatches,
|
|
564
|
-
failed: failedBatches,
|
|
711
|
+
total: upload.batches.length,
|
|
712
|
+
completed: upload.completedBatches,
|
|
713
|
+
failed: upload.failedBatches,
|
|
565
714
|
},
|
|
566
|
-
report,
|
|
715
|
+
report: posted.report,
|
|
567
716
|
server_acknowledged: {
|
|
568
|
-
codex_session_report_recorded_count: report.recorded_count,
|
|
569
|
-
raw_evidence_uploaded_object_count: uploadedObjects,
|
|
570
|
-
raw_evidence_uploaded_chunk_count: uploadedChunks,
|
|
717
|
+
codex_session_report_recorded_count: posted.report.recorded_count,
|
|
718
|
+
raw_evidence_uploaded_object_count: upload.uploadedObjects,
|
|
719
|
+
raw_evidence_uploaded_chunk_count: upload.uploadedChunks,
|
|
571
720
|
},
|
|
572
|
-
blocked_at: blockedAt,
|
|
573
|
-
failure_reason: failureReason,
|
|
721
|
+
blocked_at: options.blockedAt,
|
|
722
|
+
failure_reason: completion.failureReason,
|
|
574
723
|
};
|
|
575
724
|
}
|
|
576
725
|
function defaultBackfillWindowNotice() {
|
|
@@ -686,77 +835,98 @@ async function validateBackfillReachability(options) {
|
|
|
686
835
|
throw new Error(`Dashboard reachability failed with HTTP ${response.status}.`);
|
|
687
836
|
}
|
|
688
837
|
}
|
|
838
|
+
/**
|
|
839
|
+
* Reads both archived session stores and turns them into the candidates this
|
|
840
|
+
* run will consider: scan each selected store, keep what the cursor has not
|
|
841
|
+
* already resolved, order it, apply the operator's `--max-files` cap, then
|
|
842
|
+
* census what could not be accounted for.
|
|
843
|
+
*/
|
|
689
844
|
async function scanBackfillSessions(options) {
|
|
690
845
|
const scanLimit = options.command.maxFiles ?? 10_000;
|
|
691
846
|
const codexSessionDirs = defaultCodexSessionDirs(options.homeDir);
|
|
692
847
|
const claudeProjectsDir = path.join(options.homeDir, ".claude", "projects");
|
|
693
|
-
|
|
694
|
-
? await
|
|
695
|
-
sessionsDirs: codexSessionDirs,
|
|
696
|
-
worktrees: options.worktrees,
|
|
697
|
-
now: options.now,
|
|
698
|
-
sinceMinutes: options.window.since_minutes,
|
|
699
|
-
limit: scanLimit,
|
|
700
|
-
collectionRoots: options.collectionRoots,
|
|
701
|
-
})
|
|
848
|
+
const codexAttribution = options.sources.includes("codex")
|
|
849
|
+
? await scanCodexHistory(options, codexSessionDirs, scanLimit)
|
|
702
850
|
: null;
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
// history undiscovered. Re-scan the exact discovered population so a
|
|
706
|
-
// user-facing --max-files cap can resume from a truthful ordering.
|
|
707
|
-
codexAttribution = await scanAndAttributeCodexSessions({
|
|
708
|
-
sessionsDirs: codexSessionDirs,
|
|
709
|
-
worktrees: options.worktrees,
|
|
710
|
-
now: options.now,
|
|
711
|
-
sinceMinutes: options.window.since_minutes,
|
|
712
|
-
limit: Math.max(scanLimit + 1, codexAttribution.discovered_file_count),
|
|
713
|
-
collectionRoots: options.collectionRoots,
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
|
-
let claudeAttribution = options.sources.includes("claude_code")
|
|
717
|
-
? await scanAndAttributeClaudeSessions({
|
|
718
|
-
projectsDir: claudeProjectsDir,
|
|
719
|
-
worktrees: options.worktrees,
|
|
720
|
-
now: options.now,
|
|
721
|
-
sinceMinutes: options.window.since_minutes,
|
|
722
|
-
limit: scanLimit,
|
|
723
|
-
collectionRoots: options.collectionRoots,
|
|
724
|
-
})
|
|
851
|
+
const claudeAttribution = options.sources.includes("claude_code")
|
|
852
|
+
? await scanClaudeHistory(options, claudeProjectsDir, scanLimit)
|
|
725
853
|
: null;
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
collectionRoots: options.collectionRoots,
|
|
734
|
-
});
|
|
735
|
-
}
|
|
736
|
-
const allCandidates = [
|
|
737
|
-
...(codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
|
|
738
|
-
...(claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
|
|
739
|
-
]
|
|
740
|
-
.filter((candidate) => isAfterCursor(candidate, options.cursor) ||
|
|
741
|
-
isPointerlessTerminalRetry(candidate, options.pointerlessTerminalSessions))
|
|
742
|
-
.sort((a, b) => compareBackfillCandidatesForRetry(a, b, options.pointerlessTerminalSessions));
|
|
743
|
-
const candidates = allCandidates.slice(0, options.command.maxFiles ?? Number.MAX_SAFE_INTEGER);
|
|
744
|
-
const omittedCandidateCount = allCandidates.length - candidates.length;
|
|
854
|
+
const selection = selectBackfillCandidates({
|
|
855
|
+
codexAttribution,
|
|
856
|
+
claudeAttribution,
|
|
857
|
+
cursor: options.cursor,
|
|
858
|
+
pointerlessTerminalSessions: options.pointerlessTerminalSessions,
|
|
859
|
+
maxFiles: options.command.maxFiles,
|
|
860
|
+
});
|
|
745
861
|
const issues = await backfillScanIssues({
|
|
746
862
|
codexAttribution,
|
|
747
863
|
claudeAttribution,
|
|
748
864
|
codexSessionDirs,
|
|
749
865
|
claudeProjectsDir,
|
|
750
|
-
candidates,
|
|
751
|
-
omittedCandidateCount,
|
|
866
|
+
candidates: selection.candidates,
|
|
867
|
+
omittedCandidateCount: selection.omittedCandidateCount,
|
|
752
868
|
});
|
|
753
869
|
return {
|
|
754
|
-
candidates,
|
|
870
|
+
candidates: selection.candidates,
|
|
755
871
|
codexAttribution,
|
|
756
872
|
claudeAttribution,
|
|
757
873
|
issues,
|
|
758
|
-
retryable_candidate_keys: retryableCandidateKeys(candidates),
|
|
759
|
-
omitted_candidate_count: omittedCandidateCount,
|
|
874
|
+
retryable_candidate_keys: retryableCandidateKeys(selection.candidates),
|
|
875
|
+
omitted_candidate_count: selection.omittedCandidateCount,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* The adapter's own limit is a memory guard, not permission to silently leave
|
|
880
|
+
* history undiscovered. When it fires, re-scan the exact discovered population
|
|
881
|
+
* so a user-facing `--max-files` cap can resume from a truthful ordering.
|
|
882
|
+
*/
|
|
883
|
+
async function scanCodexHistory(request, sessionsDirs, scanLimit) {
|
|
884
|
+
const scan = (limit) => scanAndAttributeCodexSessions({
|
|
885
|
+
sessionsDirs,
|
|
886
|
+
worktrees: request.worktrees,
|
|
887
|
+
now: request.now,
|
|
888
|
+
sinceMinutes: request.window.since_minutes,
|
|
889
|
+
limit,
|
|
890
|
+
collectionRoots: request.collectionRoots,
|
|
891
|
+
});
|
|
892
|
+
const firstPass = await scan(scanLimit);
|
|
893
|
+
if (!firstPass.session_limit_applied)
|
|
894
|
+
return firstPass;
|
|
895
|
+
return scan(Math.max(scanLimit + 1, firstPass.discovered_file_count));
|
|
896
|
+
}
|
|
897
|
+
/** Same rule for the Claude store: a capped first pass is rescanned in full. */
|
|
898
|
+
async function scanClaudeHistory(request, projectsDir, scanLimit) {
|
|
899
|
+
const scan = (limit) => scanAndAttributeClaudeSessions({
|
|
900
|
+
projectsDir,
|
|
901
|
+
worktrees: request.worktrees,
|
|
902
|
+
now: request.now,
|
|
903
|
+
sinceMinutes: request.window.since_minutes,
|
|
904
|
+
limit,
|
|
905
|
+
collectionRoots: request.collectionRoots,
|
|
906
|
+
});
|
|
907
|
+
const firstPass = await scan(scanLimit);
|
|
908
|
+
if (!firstPass.session_limit_applied)
|
|
909
|
+
return firstPass;
|
|
910
|
+
return scan(Math.max(scanLimit + 1, firstPass.discovered_session_count));
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Which scanned sessions this run will actually work on. A session is in play
|
|
914
|
+
* if the cursor has not passed it, or if it is a terminal session that never
|
|
915
|
+
* earned a pointer and so deserves another attempt; those retries sort first
|
|
916
|
+
* so a `--max-files` cap spends its budget on them before newer history.
|
|
917
|
+
*/
|
|
918
|
+
function selectBackfillCandidates(options) {
|
|
919
|
+
const allCandidates = [
|
|
920
|
+
...(options.codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
|
|
921
|
+
...(options.claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
|
|
922
|
+
]
|
|
923
|
+
.filter((candidate) => isAfterCursor(candidate, options.cursor) ||
|
|
924
|
+
isPointerlessTerminalRetry(candidate, options.pointerlessTerminalSessions))
|
|
925
|
+
.sort((a, b) => compareBackfillCandidatesForRetry(a, b, options.pointerlessTerminalSessions));
|
|
926
|
+
const candidates = allCandidates.slice(0, options.maxFiles ?? Number.MAX_SAFE_INTEGER);
|
|
927
|
+
return {
|
|
928
|
+
candidates,
|
|
929
|
+
omittedCandidateCount: allCandidates.length - candidates.length,
|
|
760
930
|
};
|
|
761
931
|
}
|
|
762
932
|
async function loadPointerlessTerminalSessions(paths, sources) {
|
|
@@ -890,46 +1060,62 @@ function normalizedCursorPath(filePath) {
|
|
|
890
1060
|
const normalized = path.resolve(filePath);
|
|
891
1061
|
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
892
1062
|
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Everything the scan could not account for cleanly, in one ledger. Read in
|
|
1065
|
+
* three passes — what the Codex store hid, what the Claude store hid, then
|
|
1066
|
+
* what is wrong with the candidates that survived — because the first two are
|
|
1067
|
+
* global (discovery may have missed a session at any mtime, so no watermark is
|
|
1068
|
+
* safe to advance) and the third only stops the contiguous cursor prefix.
|
|
1069
|
+
*/
|
|
893
1070
|
async function backfillScanIssues(options) {
|
|
894
1071
|
const issues = [];
|
|
895
|
-
const add = (reason, count, scope) => {
|
|
896
|
-
if (count > 0)
|
|
897
|
-
issues.push({ reason, count, scope });
|
|
898
|
-
};
|
|
899
1072
|
if (options.codexAttribution) {
|
|
900
|
-
|
|
901
|
-
? Math.max(1, options.codexAttribution.discovered_file_count -
|
|
902
|
-
options.codexAttribution.scanned_file_count)
|
|
903
|
-
: 0, "global");
|
|
904
|
-
const missingTopLevelDirs = (await Promise.all(options.codexSessionDirs.map(isMissingPath))).filter(Boolean).length;
|
|
905
|
-
add("codex_directory_read_failed", Math.max(0, options.codexAttribution.directory_read_failed_count -
|
|
906
|
-
missingTopLevelDirs), "global");
|
|
907
|
-
add("codex_session_stat_failed", options.codexAttribution.stat_failed_count, "global");
|
|
1073
|
+
await addCodexStoreScanIssues(issues, options.codexAttribution, options.codexSessionDirs);
|
|
908
1074
|
}
|
|
909
1075
|
if (options.claudeAttribution) {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1076
|
+
await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.claudeProjectsDir, options.candidates);
|
|
1077
|
+
}
|
|
1078
|
+
addCandidateScanIssues(issues, options.candidates, options.omittedCandidateCount);
|
|
1079
|
+
sortScanIssuesByPriority(issues);
|
|
1080
|
+
return issues;
|
|
1081
|
+
}
|
|
1082
|
+
/** What the archived Codex store could not tell us — every one of these hides history. */
|
|
1083
|
+
async function addCodexStoreScanIssues(issues, codexAttribution, codexSessionDirs) {
|
|
1084
|
+
countScanIssue(issues, "codex_session_limit_applied", codexAttribution.session_limit_applied
|
|
1085
|
+
? Math.max(1, codexAttribution.discovered_file_count -
|
|
1086
|
+
codexAttribution.scanned_file_count)
|
|
1087
|
+
: 0, "global");
|
|
1088
|
+
// A session directory that simply does not exist on this machine is not a
|
|
1089
|
+
// read failure; subtract those before reporting one.
|
|
1090
|
+
const missingTopLevelDirs = (await Promise.all(codexSessionDirs.map(isMissingPath))).filter(Boolean).length;
|
|
1091
|
+
countScanIssue(issues, "codex_directory_read_failed", Math.max(0, codexAttribution.directory_read_failed_count - missingTopLevelDirs), "global");
|
|
1092
|
+
countScanIssue(issues, "codex_session_stat_failed", codexAttribution.stat_failed_count, "global");
|
|
1093
|
+
}
|
|
1094
|
+
/** The same census for the Claude store, which also has sidecars to account for. */
|
|
1095
|
+
async function addClaudeStoreScanIssues(issues, claudeAttribution, claudeProjectsDir, candidates) {
|
|
1096
|
+
countScanIssue(issues, "claude_session_limit_applied", claudeAttribution.session_limit_applied
|
|
1097
|
+
? Math.max(1, claudeAttribution.discovered_session_count -
|
|
1098
|
+
claudeAttribution.scanned_session_count)
|
|
1099
|
+
: 0, "global");
|
|
1100
|
+
const projectsDirMissing = await isMissingPath(claudeProjectsDir);
|
|
1101
|
+
countScanIssue(issues, "claude_project_dir_read_failed", Math.max(0, claudeAttribution.project_dir_read_failed_count -
|
|
1102
|
+
(projectsDirMissing ? 1 : 0)), "global");
|
|
1103
|
+
countScanIssue(issues, "claude_session_stat_failed", claudeAttribution.session_stat_failed_count, "global");
|
|
1104
|
+
countScanIssue(issues, "claude_sidecar_stat_failed", claudeAttribution.sidecar_stat_failed_count, "global");
|
|
1105
|
+
countScanIssue(issues, "claude_sidecar_dir_read_failed", await countUnreadableClaudeSidecarDirs(candidates), "global");
|
|
1106
|
+
}
|
|
1107
|
+
/** What is wrong with the sessions that did survive discovery, plus the one we chose to drop. */
|
|
1108
|
+
function addCandidateScanIssues(issues, candidates, omittedCandidateCount) {
|
|
1109
|
+
countScanIssue(issues, "backfill_max_files_applied", omittedCandidateCount, "selection");
|
|
1110
|
+
countScanIssue(issues, "candidate_file_read_failed", candidates.filter((candidate) => candidate.reason === "file_read_failed")
|
|
1111
|
+
.length, "candidate");
|
|
1112
|
+
countScanIssue(issues, "candidate_worktree_unavailable", candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) && candidate.worktree === null).length, "candidate");
|
|
1113
|
+
countScanIssue(issues, "claude_main_file_too_large", candidates.filter((candidate) => candidate.source === "claude_code" &&
|
|
926
1114
|
candidate.claude?.main_file_oversized).length, "candidate");
|
|
927
|
-
|
|
928
|
-
|
|
1115
|
+
countScanIssue(issues, "claude_sidecar_limit_applied", candidates.reduce((total, candidate) => total + (candidate.claude?.sidecars_capped ?? 0), 0), "candidate");
|
|
1116
|
+
countScanIssue(issues, "claude_sidecar_file_unreadable", candidates.reduce((total, candidate) => total +
|
|
929
1117
|
(candidate.claude?.sidecar_files.filter((sidecar) => sidecar.skipped_reason === "file_read_failed" ||
|
|
930
1118
|
sidecar.skipped_reason === "file_too_large").length ?? 0), 0), "candidate");
|
|
931
|
-
return issues.sort((a, b) => scanIssuePriority(a.scope) - scanIssuePriority(b.scope) ||
|
|
932
|
-
a.reason.localeCompare(b.reason));
|
|
933
1119
|
}
|
|
934
1120
|
function scanIssuePriority(scope) {
|
|
935
1121
|
if (scope === "global")
|
|
@@ -981,6 +1167,11 @@ function oversizedBackfillCandidateKeys(candidates) {
|
|
|
981
1167
|
function addScanIssue(issues, issue) {
|
|
982
1168
|
issues.push(issue);
|
|
983
1169
|
}
|
|
1170
|
+
/** Record one issue only if it actually happened; a zero count is not a finding. */
|
|
1171
|
+
function countScanIssue(issues, reason, count, scope) {
|
|
1172
|
+
if (count > 0)
|
|
1173
|
+
addScanIssue(issues, { reason, count, scope });
|
|
1174
|
+
}
|
|
984
1175
|
/** Repo discovery could not fully enumerate a root: one global issue per reason. */
|
|
985
1176
|
function addRepoDiscoveryIssues(issues, incompleteReasons) {
|
|
986
1177
|
for (const reason of incompleteReasons) {
|
|
@@ -1135,59 +1326,84 @@ function reasonCountsFor(candidates, guardCounts, scanIssues) {
|
|
|
1135
1326
|
...reasonClassification(reason),
|
|
1136
1327
|
}));
|
|
1137
1328
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1329
|
+
/**
|
|
1330
|
+
* The skip-reason table printed under every backfill run. These strings are
|
|
1331
|
+
* what a person reads when coverage is short, so each note says what would
|
|
1332
|
+
* have to change, not merely that something went wrong. Reason labels here
|
|
1333
|
+
* must match the ones the scan and the guards emit verbatim.
|
|
1334
|
+
*/
|
|
1335
|
+
const REASON_VERDICTS = new Map([
|
|
1336
|
+
[
|
|
1337
|
+
"secret_like_content_guard",
|
|
1338
|
+
{
|
|
1141
1339
|
classification: "retryable",
|
|
1142
1340
|
note: "historical guard result; collector now masks and retries",
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1341
|
+
},
|
|
1342
|
+
],
|
|
1343
|
+
[
|
|
1344
|
+
"secret_redaction_failed",
|
|
1345
|
+
{
|
|
1147
1346
|
classification: "retryable",
|
|
1148
|
-
note: "
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1347
|
+
note: "historical guard result; collector now masks and retries",
|
|
1348
|
+
},
|
|
1349
|
+
],
|
|
1350
|
+
[
|
|
1351
|
+
"file_too_large",
|
|
1352
|
+
{
|
|
1153
1353
|
classification: "retryable",
|
|
1154
|
-
note: "
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1354
|
+
note: "until the evidence file cap is raised",
|
|
1355
|
+
},
|
|
1356
|
+
],
|
|
1357
|
+
[
|
|
1358
|
+
"repo_not_on_disk",
|
|
1359
|
+
{ classification: "retryable", note: "repo must exist on disk" },
|
|
1360
|
+
],
|
|
1361
|
+
[
|
|
1362
|
+
"cwd_not_a_repo",
|
|
1363
|
+
{
|
|
1159
1364
|
classification: "permanent",
|
|
1160
1365
|
note: "cwd exists but is not a repo or folder workspace",
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1366
|
+
},
|
|
1367
|
+
],
|
|
1368
|
+
[
|
|
1369
|
+
"multiple_transcript_origins",
|
|
1370
|
+
{
|
|
1165
1371
|
classification: "permanent",
|
|
1166
1372
|
note: "multiple transcript origins; attribution is ambiguous",
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1373
|
+
},
|
|
1374
|
+
],
|
|
1375
|
+
[
|
|
1376
|
+
"single_repo_folder_fallback",
|
|
1377
|
+
{
|
|
1171
1378
|
classification: "permanent",
|
|
1172
1379
|
note: "uploadable single-repo folder workspace fallback",
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1380
|
+
},
|
|
1381
|
+
],
|
|
1382
|
+
[
|
|
1383
|
+
"multi_repo_folder_workspace",
|
|
1384
|
+
{
|
|
1177
1385
|
classification: "permanent",
|
|
1178
1386
|
note: "uploadable multi-repo folder workspace fallback",
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1387
|
+
},
|
|
1388
|
+
],
|
|
1389
|
+
]);
|
|
1390
|
+
const DEFERRED_REASON_VERDICT = {
|
|
1391
|
+
classification: "retryable",
|
|
1392
|
+
note: "rerun cockpit backfill to continue",
|
|
1393
|
+
};
|
|
1394
|
+
const UNKNOWN_REASON_VERDICT = {
|
|
1395
|
+
classification: "retryable",
|
|
1396
|
+
note: "rerun after fixing source or collector state",
|
|
1397
|
+
};
|
|
1398
|
+
function reasonClassification(reason) {
|
|
1399
|
+
const known = REASON_VERDICTS.get(reason);
|
|
1400
|
+
if (known)
|
|
1401
|
+
return known;
|
|
1402
|
+
// A budget deferral is the one family, rather than one label: whichever
|
|
1403
|
+
// budget ran out, the remedy is the same rerun.
|
|
1404
|
+
if (reason.startsWith("deferred_"))
|
|
1405
|
+
return DEFERRED_REASON_VERDICT;
|
|
1406
|
+
return UNKNOWN_REASON_VERDICT;
|
|
1191
1407
|
}
|
|
1192
1408
|
function uploadableCandidates(candidates) {
|
|
1193
1409
|
return candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
|
|
@@ -1248,8 +1464,33 @@ async function ensureBackfillReportContext(options) {
|
|
|
1248
1464
|
}
|
|
1249
1465
|
return { repoRoot };
|
|
1250
1466
|
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Hands one batch to the ordinary sync path. A batch whose repo has no local
|
|
1469
|
+
* work context yet is not a failure — backfill routinely visits repos this
|
|
1470
|
+
* machine has never synced — so that one blocker is answered by creating the
|
|
1471
|
+
* context and retrying; every other error propagates.
|
|
1472
|
+
*/
|
|
1251
1473
|
async function syncBackfillBatch(options) {
|
|
1252
|
-
const syncOptions =
|
|
1474
|
+
const syncOptions = backfillBatchSyncOptions(options);
|
|
1475
|
+
try {
|
|
1476
|
+
return await syncLocalAmbientEnvelope(syncOptions);
|
|
1477
|
+
}
|
|
1478
|
+
catch (error) {
|
|
1479
|
+
if (error instanceof LocalUploadBlockedError &&
|
|
1480
|
+
error.blocker === "missing_context") {
|
|
1481
|
+
await startLocalWorkContext({
|
|
1482
|
+
homeDir: options.command.homeDir,
|
|
1483
|
+
repoRoot: options.batch.worktree.repo_root,
|
|
1484
|
+
branch: options.batch.worktree.branch,
|
|
1485
|
+
});
|
|
1486
|
+
return await syncLocalAmbientEnvelope(syncOptions);
|
|
1487
|
+
}
|
|
1488
|
+
throw error;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
/** The batch translated into the live sync path's envelope, session files and all. */
|
|
1492
|
+
function backfillBatchSyncOptions(options) {
|
|
1493
|
+
return {
|
|
1253
1494
|
homeDir: options.command.homeDir,
|
|
1254
1495
|
repoRoot: options.batch.worktree.repo_root,
|
|
1255
1496
|
worktreeInventory: worktreeInventoryForRepo(options.batch.worktree, options.worktrees),
|
|
@@ -1282,29 +1523,21 @@ async function syncBackfillBatch(options) {
|
|
|
1282
1523
|
evidenceDeliveryMode: "operator_retry",
|
|
1283
1524
|
fetch: options.fetchImpl,
|
|
1284
1525
|
};
|
|
1285
|
-
try {
|
|
1286
|
-
return await syncLocalAmbientEnvelope(syncOptions);
|
|
1287
|
-
}
|
|
1288
|
-
catch (error) {
|
|
1289
|
-
if (error instanceof LocalUploadBlockedError &&
|
|
1290
|
-
error.blocker === "missing_context") {
|
|
1291
|
-
await startLocalWorkContext({
|
|
1292
|
-
homeDir: options.command.homeDir,
|
|
1293
|
-
repoRoot: options.batch.worktree.repo_root,
|
|
1294
|
-
branch: options.batch.worktree.branch,
|
|
1295
|
-
});
|
|
1296
|
-
return await syncLocalAmbientEnvelope(syncOptions);
|
|
1297
|
-
}
|
|
1298
|
-
throw error;
|
|
1299
|
-
}
|
|
1300
1526
|
}
|
|
1301
|
-
/**
|
|
1527
|
+
/**
|
|
1528
|
+
* One attribution row per session this run saw, uploaded or not — the only
|
|
1529
|
+
* record the server ever gets of a session the collector could not upload.
|
|
1530
|
+
* Exported for the BLI-3272 regression test; not part of the CLI surface.
|
|
1531
|
+
*/
|
|
1302
1532
|
export function buildBackfillSessionReport(options) {
|
|
1303
|
-
const
|
|
1304
|
-
|
|
1305
|
-
|
|
1533
|
+
const uploads = indexMainTranscriptUploads(options.syncResults);
|
|
1534
|
+
const bestBySession = bestCandidatePerSession(options.candidates);
|
|
1535
|
+
return [...bestBySession.values()].map((candidate) => sessionAttributionRow(candidate, uploads, options.now));
|
|
1536
|
+
}
|
|
1537
|
+
function indexMainTranscriptUploads(syncResults) {
|
|
1538
|
+
const bySourceAndSession = new Map();
|
|
1306
1539
|
const noUploadReasonBySessionId = new Map();
|
|
1307
|
-
for (const sync of
|
|
1540
|
+
for (const sync of syncResults) {
|
|
1308
1541
|
if (sync.status !== "uploaded")
|
|
1309
1542
|
continue;
|
|
1310
1543
|
for (const outcome of sync.raw_evidence_outcomes) {
|
|
@@ -1323,15 +1556,23 @@ export function buildBackfillSessionReport(options) {
|
|
|
1323
1556
|
}
|
|
1324
1557
|
continue;
|
|
1325
1558
|
}
|
|
1326
|
-
|
|
1559
|
+
bySourceAndSession.set(`${source}:${outcome.codex_session_id}`, {
|
|
1327
1560
|
upload_state: outcome.upload_state,
|
|
1328
1561
|
raw_evidence_pointer_id: outcome.raw_evidence_pointer_id,
|
|
1329
1562
|
reason: outcome.reason,
|
|
1330
1563
|
});
|
|
1331
1564
|
}
|
|
1332
1565
|
}
|
|
1566
|
+
return { bySourceAndSession, noUploadReasonBySessionId };
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* One row per session, not per file: the same session can be scanned from
|
|
1570
|
+
* several paths, so the best-attributed sighting wins and the most recent one
|
|
1571
|
+
* breaks a tie.
|
|
1572
|
+
*/
|
|
1573
|
+
function bestCandidatePerSession(candidates) {
|
|
1333
1574
|
const bestByKey = new Map();
|
|
1334
|
-
for (const candidate of
|
|
1575
|
+
for (const candidate of candidates) {
|
|
1335
1576
|
const key = `${candidate.source}:${candidate.session_id}`;
|
|
1336
1577
|
const existing = bestByKey.get(key);
|
|
1337
1578
|
if (!existing || rank(candidate.state) > rank(existing.state)) {
|
|
@@ -1343,60 +1584,63 @@ export function buildBackfillSessionReport(options) {
|
|
|
1343
1584
|
bestByKey.set(key, candidate);
|
|
1344
1585
|
}
|
|
1345
1586
|
}
|
|
1346
|
-
return
|
|
1347
|
-
|
|
1587
|
+
return bestByKey;
|
|
1588
|
+
}
|
|
1589
|
+
function sessionAttributionRow(candidate, uploads, now) {
|
|
1590
|
+
return {
|
|
1591
|
+
codex_session_id: candidate.session_id,
|
|
1592
|
+
source: candidate.source,
|
|
1593
|
+
observed_at: now.toISOString(),
|
|
1594
|
+
attribution_state: candidate.state,
|
|
1595
|
+
attribution_reason: candidate.reason,
|
|
1596
|
+
attribution_score: candidate.attribution_score,
|
|
1597
|
+
path_score: candidate.path_score,
|
|
1598
|
+
signals: candidate.signals,
|
|
1599
|
+
...(candidate.content_hash_sha256
|
|
1600
|
+
? { session_file_hash_sha256: candidate.content_hash_sha256 }
|
|
1601
|
+
: {}),
|
|
1602
|
+
session_file_byte_size: candidate.byte_size,
|
|
1603
|
+
session_file_mtime: candidate.session_file_mtime,
|
|
1604
|
+
...(candidate.worktree
|
|
1605
|
+
? {
|
|
1606
|
+
repo_fingerprint: candidate.worktree.repo_fingerprint,
|
|
1607
|
+
worktree_fingerprint: candidate.worktree.worktree_fingerprint,
|
|
1608
|
+
repo_label: candidate.worktree.repo_label,
|
|
1609
|
+
branch: candidate.worktree.branch,
|
|
1610
|
+
}
|
|
1611
|
+
: {}),
|
|
1612
|
+
...(candidate.cwd_basename ? { cwd_basename: candidate.cwd_basename } : {}),
|
|
1613
|
+
...(candidate.cwd_hash ? { cwd_hash: candidate.cwd_hash } : {}),
|
|
1614
|
+
...sessionUploadFields(candidate, uploads),
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* The upload half of a report row. Every branch names an outcome: a pointer
|
|
1619
|
+
* when one exists, and otherwise WHY there is none — BLI-2107 for an
|
|
1620
|
+
* attributed session and BLI-3272 for a refused one. Backfill is the path that
|
|
1621
|
+
* revisits old sessions, so a silent branch here would keep rewriting the very
|
|
1622
|
+
* NULL/NULL rows those tickets found.
|
|
1623
|
+
*/
|
|
1624
|
+
function sessionUploadFields(candidate, uploads) {
|
|
1625
|
+
const upload = uploads.bySourceAndSession.get(`${candidate.source}:${candidate.session_id}`);
|
|
1626
|
+
if (upload) {
|
|
1348
1627
|
return {
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
attribution_reason: candidate.reason,
|
|
1354
|
-
attribution_score: candidate.attribution_score,
|
|
1355
|
-
path_score: candidate.path_score,
|
|
1356
|
-
signals: candidate.signals,
|
|
1357
|
-
...(candidate.content_hash_sha256
|
|
1358
|
-
? { session_file_hash_sha256: candidate.content_hash_sha256 }
|
|
1359
|
-
: {}),
|
|
1360
|
-
session_file_byte_size: candidate.byte_size,
|
|
1361
|
-
session_file_mtime: candidate.session_file_mtime,
|
|
1362
|
-
...(candidate.worktree
|
|
1363
|
-
? {
|
|
1364
|
-
repo_fingerprint: candidate.worktree.repo_fingerprint,
|
|
1365
|
-
worktree_fingerprint: candidate.worktree.worktree_fingerprint,
|
|
1366
|
-
repo_label: candidate.worktree.repo_label,
|
|
1367
|
-
branch: candidate.worktree.branch,
|
|
1368
|
-
}
|
|
1628
|
+
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
1629
|
+
upload_state: upload.upload_state,
|
|
1630
|
+
...(upload.upload_state === "upload_failed"
|
|
1631
|
+
? { upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED }
|
|
1369
1632
|
: {}),
|
|
1370
|
-
...(candidate.cwd_basename ? { cwd_basename: candidate.cwd_basename } : {}),
|
|
1371
|
-
...(candidate.cwd_hash ? { cwd_hash: candidate.cwd_hash } : {}),
|
|
1372
|
-
...(upload
|
|
1373
|
-
? {
|
|
1374
|
-
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
1375
|
-
upload_state: upload.upload_state,
|
|
1376
|
-
...(upload.upload_state === "upload_failed"
|
|
1377
|
-
? { upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED }
|
|
1378
|
-
: {}),
|
|
1379
|
-
}
|
|
1380
|
-
: isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null)
|
|
1381
|
-
? {
|
|
1382
|
-
upload_state: "not_uploaded",
|
|
1383
|
-
// BLI-2107: same rule as live sync — an attributed session with
|
|
1384
|
-
// no pointer always says why, even when the answer is that this
|
|
1385
|
-
// path never recorded one.
|
|
1386
|
-
upload_reason: noUploadReasonBySessionId.get(candidate.session_id) ??
|
|
1387
|
-
NO_UPLOAD_ATTEMPT_RECORDED,
|
|
1388
|
-
}
|
|
1389
|
-
: {
|
|
1390
|
-
// BLI-3272: and the refused-attribution branch says why too. Same
|
|
1391
|
-
// NULL/NULL hole as live sync, same fix — backfill is the path
|
|
1392
|
-
// that revisits old sessions, so leaving it silent would keep
|
|
1393
|
-
// rewriting the very rows this ticket found.
|
|
1394
|
-
upload_state: "not_uploaded",
|
|
1395
|
-
upload_reason: noUploadReasonBySessionId.get(candidate.session_id) ??
|
|
1396
|
-
notUploadableAttributionStateReason(candidate.reason),
|
|
1397
|
-
}),
|
|
1398
1633
|
};
|
|
1399
|
-
}
|
|
1634
|
+
}
|
|
1635
|
+
const observedReason = uploads.noUploadReasonBySessionId.get(candidate.session_id);
|
|
1636
|
+
const wasUploadable = isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null);
|
|
1637
|
+
return {
|
|
1638
|
+
upload_state: "not_uploaded",
|
|
1639
|
+
upload_reason: observedReason ??
|
|
1640
|
+
(wasUploadable
|
|
1641
|
+
? NO_UPLOAD_ATTEMPT_RECORDED
|
|
1642
|
+
: notUploadableAttributionStateReason(candidate.reason)),
|
|
1643
|
+
};
|
|
1400
1644
|
}
|
|
1401
1645
|
function rank(state) {
|
|
1402
1646
|
switch (state) {
|
|
@@ -1414,16 +1658,35 @@ function rank(state) {
|
|
|
1414
1658
|
return 0;
|
|
1415
1659
|
}
|
|
1416
1660
|
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Which candidates in this batch are now safe to leave behind forever: a
|
|
1663
|
+
* session counts as durable only when its main transcript AND every sidecar
|
|
1664
|
+
* we expected earned a pointer, and nothing in it failed. This set is what the
|
|
1665
|
+
* cursor advances through, so an over-generous answer here loses history
|
|
1666
|
+
* permanently.
|
|
1667
|
+
*/
|
|
1417
1668
|
function durableBackfillCandidateKeys(batch, sync) {
|
|
1418
1669
|
const durable = new Set();
|
|
1419
1670
|
if (sync.status !== "uploaded")
|
|
1420
1671
|
return durable;
|
|
1421
|
-
if (sync
|
|
1422
|
-
sync.raw_evidence_deferred_object_budget > 0) {
|
|
1672
|
+
if (deferredEvidenceCount(sync) > 0) {
|
|
1423
1673
|
// Deferred outcomes have no per-file identity. Advancing any candidate in
|
|
1424
1674
|
// this batch could therefore strand the deferred main transcript.
|
|
1425
1675
|
return durable;
|
|
1426
1676
|
}
|
|
1677
|
+
const outcomesBySession = summarizeEvidenceOutcomesBySession(sync);
|
|
1678
|
+
for (const candidate of batch.candidates) {
|
|
1679
|
+
const summary = outcomesBySession.get(`${candidate.source}:${candidate.session_id}`);
|
|
1680
|
+
if (summary &&
|
|
1681
|
+
!summary.failed &&
|
|
1682
|
+
summary.durableMainCount > 0 &&
|
|
1683
|
+
summary.durableSidecarCount >= expectedSidecarCount(candidate)) {
|
|
1684
|
+
durable.add(candidateCursorKey(candidate));
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return durable;
|
|
1688
|
+
}
|
|
1689
|
+
function summarizeEvidenceOutcomesBySession(sync) {
|
|
1427
1690
|
const outcomesBySession = new Map();
|
|
1428
1691
|
for (const outcome of sync.raw_evidence_outcomes) {
|
|
1429
1692
|
const source = backfillSourceForEvidenceKind(outcome.kind);
|
|
@@ -1441,8 +1704,7 @@ function durableBackfillCandidateKeys(batch, sync) {
|
|
|
1441
1704
|
else if (outcome.raw_evidence_pointer_id &&
|
|
1442
1705
|
(outcome.upload_state === "uploaded" ||
|
|
1443
1706
|
outcome.upload_state === "reused_existing")) {
|
|
1444
|
-
if (outcome.kind === "codex_jsonl" ||
|
|
1445
|
-
outcome.kind === "claude_jsonl") {
|
|
1707
|
+
if (outcome.kind === "codex_jsonl" || outcome.kind === "claude_jsonl") {
|
|
1446
1708
|
summary.durableMainCount += 1;
|
|
1447
1709
|
}
|
|
1448
1710
|
else if (outcome.kind === "claude_jsonl_sidecar") {
|
|
@@ -1451,35 +1713,22 @@ function durableBackfillCandidateKeys(batch, sync) {
|
|
|
1451
1713
|
}
|
|
1452
1714
|
outcomesBySession.set(key, summary);
|
|
1453
1715
|
}
|
|
1454
|
-
|
|
1455
|
-
const summary = outcomesBySession.get(`${candidate.source}:${candidate.session_id}`);
|
|
1456
|
-
const expectedSidecars = candidate.source === "claude_code"
|
|
1457
|
-
? (candidate.claude?.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length ?? 0)
|
|
1458
|
-
: 0;
|
|
1459
|
-
if (summary &&
|
|
1460
|
-
!summary.failed &&
|
|
1461
|
-
summary.durableMainCount > 0 &&
|
|
1462
|
-
summary.durableSidecarCount >= expectedSidecars) {
|
|
1463
|
-
durable.add(candidateCursorKey(candidate));
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
return durable;
|
|
1716
|
+
return outcomesBySession;
|
|
1467
1717
|
}
|
|
1718
|
+
/** Sidecars this session owes a pointer for; one already skipped at scan time is not owed. */
|
|
1719
|
+
function expectedSidecarCount(candidate) {
|
|
1720
|
+
if (candidate.source !== "claude_code")
|
|
1721
|
+
return 0;
|
|
1722
|
+
return (candidate.claude?.sidecar_files.filter((sidecar) => !sidecar.skipped_reason)
|
|
1723
|
+
.length ?? 0);
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* Writes the pointer a session earned back into the live raw-evidence cursor,
|
|
1727
|
+
* so the next scheduled sync knows it is already delivered. Backfill and sync
|
|
1728
|
+
* share these cursors; the caller holds the collection lock for exactly this.
|
|
1729
|
+
*/
|
|
1468
1730
|
async function recordBackfillDurableSessionPointers(options) {
|
|
1469
|
-
const durableObjectBySession =
|
|
1470
|
-
for (const sync of options.syncResults) {
|
|
1471
|
-
for (const outcome of sync.raw_evidence_outcomes) {
|
|
1472
|
-
if (!outcome.codex_session_id ||
|
|
1473
|
-
(outcome.kind !== "codex_jsonl" && outcome.kind !== "claude_jsonl") ||
|
|
1474
|
-
(outcome.upload_state !== "uploaded" &&
|
|
1475
|
-
outcome.upload_state !== "reused_existing") ||
|
|
1476
|
-
!outcome.object_key) {
|
|
1477
|
-
continue;
|
|
1478
|
-
}
|
|
1479
|
-
const source = outcome.kind === "codex_jsonl" ? "codex" : "claude_code";
|
|
1480
|
-
durableObjectBySession.set(`${source}:${outcome.codex_session_id}`, outcome.object_key);
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1731
|
+
const durableObjectBySession = indexDurableMainObjectKeys(options.syncResults);
|
|
1483
1732
|
let recordedCount = 0;
|
|
1484
1733
|
for (const source of ["codex", "claude_code"]) {
|
|
1485
1734
|
const filename = source === "claude_code" ? CLAUDE_CURSOR_FILENAME : undefined;
|
|
@@ -1490,22 +1739,10 @@ async function recordBackfillDurableSessionPointers(options) {
|
|
|
1490
1739
|
continue;
|
|
1491
1740
|
const objectKey = durableObjectBySession.get(`${source}:${candidate.session_id}`);
|
|
1492
1741
|
const prior = cursor.sessions[candidate.session_id];
|
|
1742
|
+
// No pointer, no prior entry, or already pointed: nothing this pass owes.
|
|
1493
1743
|
if (!objectKey || !prior || prior.uploaded_object_key)
|
|
1494
1744
|
continue;
|
|
1495
|
-
cursor.sessions[candidate.session_id] =
|
|
1496
|
-
...prior,
|
|
1497
|
-
file_hash_sha256: candidate.content_hash_sha256,
|
|
1498
|
-
file_mtime_ms: candidate.session_file_mtime_ms,
|
|
1499
|
-
byte_size: candidate.byte_size,
|
|
1500
|
-
byte_offset: candidate.byte_size,
|
|
1501
|
-
state: candidate.state,
|
|
1502
|
-
reason: candidate.reason,
|
|
1503
|
-
worktree_fingerprint: candidate.worktree?.worktree_fingerprint ?? null,
|
|
1504
|
-
uploaded_object_key: objectKey,
|
|
1505
|
-
uploaded_at: options.now.toISOString(),
|
|
1506
|
-
uploaded_byte_size: candidate.byte_size,
|
|
1507
|
-
last_seen_at: options.now.toISOString(),
|
|
1508
|
-
};
|
|
1745
|
+
cursor.sessions[candidate.session_id] = deliveredCursorEntry(prior, candidate, objectKey, options.now);
|
|
1509
1746
|
changed = true;
|
|
1510
1747
|
recordedCount += 1;
|
|
1511
1748
|
}
|
|
@@ -1520,6 +1757,42 @@ async function recordBackfillDurableSessionPointers(options) {
|
|
|
1520
1757
|
console.error("[backfill] terminal session pointers recorded", JSON.stringify({ count: recordedCount }));
|
|
1521
1758
|
}
|
|
1522
1759
|
}
|
|
1760
|
+
/** Storage keys for main transcripts that actually landed, keyed by source and session. */
|
|
1761
|
+
function indexDurableMainObjectKeys(syncResults) {
|
|
1762
|
+
const durableObjectBySession = new Map();
|
|
1763
|
+
for (const sync of syncResults) {
|
|
1764
|
+
for (const outcome of sync.raw_evidence_outcomes) {
|
|
1765
|
+
if (!outcome.codex_session_id ||
|
|
1766
|
+
(outcome.kind !== "codex_jsonl" && outcome.kind !== "claude_jsonl") ||
|
|
1767
|
+
(outcome.upload_state !== "uploaded" &&
|
|
1768
|
+
outcome.upload_state !== "reused_existing") ||
|
|
1769
|
+
!outcome.object_key) {
|
|
1770
|
+
continue;
|
|
1771
|
+
}
|
|
1772
|
+
const source = outcome.kind === "codex_jsonl" ? "codex" : "claude_code";
|
|
1773
|
+
durableObjectBySession.set(`${source}:${outcome.codex_session_id}`, outcome.object_key);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
return durableObjectBySession;
|
|
1777
|
+
}
|
|
1778
|
+
/** The cursor entry for a session backfill has just delivered in full. */
|
|
1779
|
+
function deliveredCursorEntry(prior, candidate, objectKey, now) {
|
|
1780
|
+
return {
|
|
1781
|
+
...prior,
|
|
1782
|
+
file_hash_sha256: candidate.content_hash_sha256,
|
|
1783
|
+
file_mtime_ms: candidate.session_file_mtime_ms,
|
|
1784
|
+
byte_size: candidate.byte_size,
|
|
1785
|
+
// The whole file was delivered, so the incremental reader starts at its end.
|
|
1786
|
+
byte_offset: candidate.byte_size,
|
|
1787
|
+
state: candidate.state,
|
|
1788
|
+
reason: candidate.reason,
|
|
1789
|
+
worktree_fingerprint: candidate.worktree?.worktree_fingerprint ?? null,
|
|
1790
|
+
uploaded_object_key: objectKey,
|
|
1791
|
+
uploaded_at: now.toISOString(),
|
|
1792
|
+
uploaded_byte_size: candidate.byte_size,
|
|
1793
|
+
last_seen_at: now.toISOString(),
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1523
1796
|
function countSessionUploadFailures(sync) {
|
|
1524
1797
|
return sync.raw_evidence_outcomes.filter((outcome) => Boolean(outcome.codex_session_id) &&
|
|
1525
1798
|
outcome.upload_state === "upload_failed" &&
|