@bli-cockpit/cli 0.2.49 → 0.2.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  2. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  4. package/dist/adapters/raw-evidence-facts.js +338 -0
  5. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  6. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  7. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  8. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  9. package/dist/adapters/raw-evidence.js +63 -1183
  10. package/dist/commands/backfill-batches.js +34 -0
  11. package/dist/commands/backfill-candidates.js +54 -0
  12. package/dist/commands/backfill-checkpoint.js +101 -0
  13. package/dist/commands/backfill-command-line.js +70 -0
  14. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  15. package/dist/commands/backfill-issues.js +265 -0
  16. package/dist/commands/backfill-output.js +75 -0
  17. package/dist/commands/backfill-plan.js +71 -0
  18. package/dist/commands/backfill-reasons.js +107 -0
  19. package/dist/commands/backfill-report.js +298 -0
  20. package/dist/commands/backfill-result.js +150 -0
  21. package/dist/commands/backfill-scan.js +274 -0
  22. package/dist/commands/backfill-scope.js +114 -0
  23. package/dist/commands/backfill-session-report.js +145 -0
  24. package/dist/commands/backfill-types.js +1 -0
  25. package/dist/commands/backfill-upload.js +212 -0
  26. package/dist/commands/backfill.js +41 -1961
  27. package/dist/commands/doctor.js +57 -0
  28. package/dist/commands/jarvis-trace.js +184 -0
  29. package/dist/commands/jarvis.js +144 -4
  30. package/dist/commands/local-args-collector.js +26 -0
  31. package/dist/commands/local-args-tower.js +21 -0
  32. package/dist/commands/local-args.js +3 -1
  33. package/dist/commands/local-help.js +19 -2
  34. package/dist/commands/local.js +3 -0
  35. package/dist/commands/memory-install-claude.js +294 -0
  36. package/dist/commands/memory-install-codex.js +205 -0
  37. package/dist/commands/memory-install-contract.js +286 -0
  38. package/dist/commands/memory-install-files.js +63 -0
  39. package/dist/commands/memory-install-skills.js +121 -0
  40. package/dist/commands/memory-install-toml.js +265 -0
  41. package/dist/commands/memory-install.js +465 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/sync-followups.js +105 -0
  44. package/dist/commands/sync.js +7 -1
  45. package/dist/local-state-attributed-target.js +75 -0
  46. package/dist/local-state-config.js +147 -0
  47. package/dist/local-state-files.js +59 -0
  48. package/dist/local-state-identity.js +73 -0
  49. package/dist/local-state-pairing.js +263 -0
  50. package/dist/local-state-paths.js +61 -0
  51. package/dist/local-state-session.js +68 -0
  52. package/dist/local-state-status.js +163 -0
  53. package/dist/local-state-work-context.js +190 -0
  54. package/dist/local-state.js +34 -848
  55. package/dist/tower-client.js +3 -2
  56. package/dist/tower-stream.js +57 -3
  57. package/package.json +2 -1
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What a person sees and is asked. Every line a human reads from
3
+ * `cockpit backfill` is written here: the dry-run review, the `--all`
4
+ * confirmation prompt, the final PASS/BLOCKED lines, and the skip/reason table
5
+ * that goes under all of them. A blocked or partial run always names its
6
+ * reason and prints the retry command; none of these paths may go silent.
7
+ */
8
+ import { uploadableCandidates } from "./backfill-batches.js";
9
+ export function writeDryRunSummary(io, options) {
10
+ const uploadable = uploadableCandidates(options.candidates);
11
+ writeLine(io.stdout, `${options.dryRunOnly ? "DRY-RUN" : "Review"}: ${options.candidates.length} session(s), ${uploadable.length} uploadable, ${options.candidates.length - uploadable.length} skipped.`);
12
+ writeReasonTable(io, options.reasonCounts);
13
+ if (options.dryRunOnly) {
14
+ writeLine(io.stdout, "DRY-RUN: wrote nothing (no cursor, marker, report, or upload).");
15
+ }
16
+ writeLine(io.stdout, `Verify after upload: ${options.dashboardUrl}/my-work`);
17
+ }
18
+ export function writeHumanBackfillResult(result, io) {
19
+ if (result.status === "complete") {
20
+ writeLine(io.stdout, `PASS: ${result.counts.backfilled} backfilled, ${result.counts.skipped} skipped (table). Verify: ${result.verify_url}`);
21
+ writeReasonTable(io, result.counts.reasons);
22
+ return;
23
+ }
24
+ if (result.status === "partial") {
25
+ const at = result.blocked_at;
26
+ if (at) {
27
+ writeLine(io.stderr, `BLOCKED: ${at.what} at batch ${at.batch_index}/${at.batch_total}, ${at.done}/${at.total} done`);
28
+ }
29
+ writeLine(io.stderr, `Failure: ${result.failure_reason ?? "partial_backfill"}`);
30
+ writeLine(io.stderr, `Retry: ${result.retry_command}`);
31
+ writeLine(io.stderr, `Stopped: ${result.counts.remaining} remaining — rerun cockpit backfill to continue`);
32
+ writeReasonTable(io, result.counts.reasons);
33
+ return;
34
+ }
35
+ const at = result.blocked_at;
36
+ if (at) {
37
+ writeLine(io.stderr, `BLOCKED: ${at.what} at batch ${at.batch_index}/${at.batch_total}, ${at.done}/${at.total} done`);
38
+ }
39
+ else {
40
+ writeLine(io.stderr, "BLOCKED: backfill could not run.");
41
+ }
42
+ writeLine(io.stderr, `Failure: ${result.failure_reason ?? "no_sessions"}`);
43
+ writeLine(io.stderr, `Retry: ${result.retry_command}`);
44
+ }
45
+ function writeReasonTable(io, reasons) {
46
+ if (reasons.length === 0)
47
+ return;
48
+ writeLine(io.stdout, "Skip/reason table:");
49
+ for (const reason of reasons) {
50
+ writeLine(io.stdout, `- ${reason.reason}: ${reason.count} (${reason.classification}; ${reason.note})`);
51
+ }
52
+ }
53
+ export async function confirmAllBackfill(io) {
54
+ const answer = await readLine(io, "Proceed with --all backfill upload? [y/N] ");
55
+ return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
56
+ }
57
+ async function readLine(io, prompt) {
58
+ io.stdout.write(prompt);
59
+ io.stdin.setEncoding("utf8");
60
+ return new Promise((resolve) => {
61
+ const onData = (chunk) => {
62
+ io.stdin.removeListener("data", onData);
63
+ io.stdin.pause();
64
+ resolve(chunk);
65
+ };
66
+ io.stdin.resume();
67
+ io.stdin.on("data", onData);
68
+ });
69
+ }
70
+ export function isInteractiveStdin(io) {
71
+ return Boolean(io.stdin.isTTY);
72
+ }
73
+ export function writeLine(stream, text) {
74
+ stream.write(`${text}\n`);
75
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * PLAN: given the scan, should this run actually upload anything right now?
3
+ * `--all` without `--yes` needs interactive confirmation; `--dry-run` reports
4
+ * what would happen and stops. Both terminal answers are results in their own
5
+ * right, so the operator still gets the full reason table either way.
6
+ */
7
+ import { blockingScanIssues } from "./backfill-issues.js";
8
+ import { confirmAllBackfill, writeDryRunSummary } from "./backfill-output.js";
9
+ import { backfillResultBaseArgs, baseBackfillResult, } from "./backfill-result.js";
10
+ /**
11
+ * PLAN: given the scan, should this run actually upload anything right now?
12
+ * `--all` without `--yes` needs interactive confirmation; `--dry-run` reports
13
+ * what would happen and stops there. Either returns a terminal result;
14
+ * anything else proceeds to UPLOAD.
15
+ */
16
+ export async function planBackfillRun(command, io, ctx) {
17
+ const { dashboardUrl, scan, reasonCounts } = ctx;
18
+ if (command.all && !command.yes) {
19
+ if (!command.json) {
20
+ writeDryRunSummary(io, {
21
+ candidates: scan.candidates,
22
+ reasonCounts,
23
+ dashboardUrl,
24
+ dryRunOnly: false,
25
+ });
26
+ }
27
+ const confirmed = await confirmAllBackfill(io);
28
+ if (!confirmed) {
29
+ return { kind: "result", result: confirmationDeclinedResult(command, ctx) };
30
+ }
31
+ }
32
+ if (command.dryRun) {
33
+ if (!command.json) {
34
+ writeDryRunSummary(io, {
35
+ candidates: scan.candidates,
36
+ reasonCounts,
37
+ dashboardUrl,
38
+ dryRunOnly: true,
39
+ });
40
+ }
41
+ return { kind: "result", result: dryRunResult(command, ctx) };
42
+ }
43
+ return { kind: "proceed" };
44
+ }
45
+ /** The operator saw the `--all` review and said no. Nothing was written; everything remains. */
46
+ function confirmationDeclinedResult(command, ctx) {
47
+ const base = baseBackfillResult(command, backfillResultBaseArgs(ctx));
48
+ return {
49
+ ...base,
50
+ status: "blocked",
51
+ counts: { ...base.counts, remaining: ctx.scan.candidates.length },
52
+ failure_reason: "confirmation_declined",
53
+ };
54
+ }
55
+ /**
56
+ * What a `--dry-run` would have done. It completes unless the scan itself hit
57
+ * something blocking, because a dry run that reports "complete" over a scan
58
+ * that could not see all the history would be the confident wrong answer.
59
+ */
60
+ function dryRunResult(command, ctx) {
61
+ const blockingIssues = blockingScanIssues(ctx.scan.issues);
62
+ return {
63
+ ...baseBackfillResult(command, backfillResultBaseArgs(ctx)),
64
+ status: blockingIssues.length > 0 ? "partial" : "complete",
65
+ dry_run: true,
66
+ retry_command: ctx.retryCommand,
67
+ ...(blockingIssues.length > 0
68
+ ? { failure_reason: blockingIssues[0]?.reason }
69
+ : {}),
70
+ };
71
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The skip-reason table printed under every backfill run: one row per reason,
3
+ * each saying whether a rerun can fix it and what would have to change.
4
+ * A reason with no verdict here is still reported — it falls back to the
5
+ * retryable default rather than going silent.
6
+ */
7
+ import { increment } from "./backfill-issues.js";
8
+ export function reasonCountsFor(candidates, guardCounts, scanIssues) {
9
+ const counts = new Map();
10
+ for (const candidate of candidates)
11
+ increment(counts, candidate.reason);
12
+ for (const [reason, count] of guardCounts) {
13
+ counts.set(reason, Math.max(counts.get(reason) ?? 0, count));
14
+ }
15
+ for (const issue of scanIssues) {
16
+ counts.set(issue.reason, Math.max(counts.get(issue.reason) ?? 0, issue.count));
17
+ }
18
+ for (const required of ["file_too_large", "repo_not_on_disk"]) {
19
+ counts.set(required, counts.get(required) ?? 0);
20
+ }
21
+ return [...counts.entries()]
22
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
23
+ .map(([reason, count]) => ({
24
+ reason,
25
+ count,
26
+ ...reasonClassification(reason),
27
+ }));
28
+ }
29
+ /**
30
+ * The skip-reason table printed under every backfill run. These strings are
31
+ * what a person reads when coverage is short, so each note says what would
32
+ * have to change, not merely that something went wrong. Reason labels here
33
+ * must match the ones the scan and the guards emit verbatim.
34
+ */
35
+ const REASON_VERDICTS = new Map([
36
+ [
37
+ "secret_like_content_guard",
38
+ {
39
+ classification: "retryable",
40
+ note: "historical guard result; collector now masks and retries",
41
+ },
42
+ ],
43
+ [
44
+ "secret_redaction_failed",
45
+ {
46
+ classification: "retryable",
47
+ note: "historical guard result; collector now masks and retries",
48
+ },
49
+ ],
50
+ [
51
+ "file_too_large",
52
+ {
53
+ classification: "retryable",
54
+ note: "until the evidence file cap is raised",
55
+ },
56
+ ],
57
+ [
58
+ "repo_not_on_disk",
59
+ { classification: "retryable", note: "repo must exist on disk" },
60
+ ],
61
+ [
62
+ "cwd_not_a_repo",
63
+ {
64
+ classification: "permanent",
65
+ note: "cwd exists but is not a repo or folder workspace",
66
+ },
67
+ ],
68
+ [
69
+ "multiple_transcript_origins",
70
+ {
71
+ classification: "permanent",
72
+ note: "multiple transcript origins; attribution is ambiguous",
73
+ },
74
+ ],
75
+ [
76
+ "single_repo_folder_fallback",
77
+ {
78
+ classification: "permanent",
79
+ note: "uploadable single-repo folder workspace fallback",
80
+ },
81
+ ],
82
+ [
83
+ "multi_repo_folder_workspace",
84
+ {
85
+ classification: "permanent",
86
+ note: "uploadable multi-repo folder workspace fallback",
87
+ },
88
+ ],
89
+ ]);
90
+ const DEFERRED_REASON_VERDICT = {
91
+ classification: "retryable",
92
+ note: "rerun cockpit backfill to continue",
93
+ };
94
+ const UNKNOWN_REASON_VERDICT = {
95
+ classification: "retryable",
96
+ note: "rerun after fixing source or collector state",
97
+ };
98
+ function reasonClassification(reason) {
99
+ const known = REASON_VERDICTS.get(reason);
100
+ if (known)
101
+ return known;
102
+ // A budget deferral is the one family, rather than one label: whichever
103
+ // budget ran out, the remedy is the same rerun.
104
+ if (reason.startsWith("deferred_"))
105
+ return DEFERRED_REASON_VERDICT;
106
+ return UNKNOWN_REASON_VERDICT;
107
+ }
@@ -0,0 +1,298 @@
1
+ import { BACKFILL_COMPLETION_RECHECK_MS, BACKFILL_COVERAGE_VERSION, recordBackfillScanCoverage, writeBackfillCompletionMarker, writeBackfillCursor, } from "../cursors/backfill-cursor.js";
2
+ import { describeError } from "../health-detail.js";
3
+ import { readLocalWorkContextForRepo, startLocalWorkContext, } from "../local-state.js";
4
+ import { postCodexSessionReport, } from "../upload.js";
5
+ import { candidateCursorKey } from "./backfill-candidates.js";
6
+ import { advanceBackfillCursorThroughResolvedPrefix, recordBackfillDurableSessionPointers, } from "./backfill-checkpoint.js";
7
+ import { blockingScanIssues } from "./backfill-issues.js";
8
+ import { backfillResultBaseArgs, baseBackfillResult, emptyReport, } from "./backfill-result.js";
9
+ import { buildBackfillSessionReport } from "./backfill-session-report.js";
10
+ /**
11
+ * REPORT: post the session report, record durable pointers, advance the
12
+ * cursor through the resolved contiguous prefix, decide completion, write the
13
+ * all-history completion marker when this run actually finished it, and
14
+ * assemble the final result. `remaining`/`completionBlocked` deliberately
15
+ * treat an oversized skip differently (BLI-2727): it still counts toward
16
+ * `remaining` so the JSON output never goes silent about it, but it never
17
+ * blocks completion on its own — every completion-gating computation below
18
+ * excludes it explicitly via `oversizedCandidateKeys`/`blockingScanIssues`.
19
+ */
20
+ export async function reportBackfillOutcome(command, io, ctx, upload) {
21
+ let { blockedAt, failureReason } = upload;
22
+ const posted = await postBackfillSessionReport(command, io, ctx, upload);
23
+ if (!posted.acknowledged && !blockedAt) {
24
+ blockedAt = sessionReportStopPoint(upload);
25
+ failureReason ??= sessionReportFailureReason(posted);
26
+ }
27
+ if (posted.acknowledged) {
28
+ await checkpointResolvedBackfillProgress(ctx, upload);
29
+ }
30
+ const completion = summarizeBackfillCompletion({
31
+ ctx,
32
+ upload,
33
+ posted,
34
+ stoppedEarly: Boolean(blockedAt),
35
+ failureReason,
36
+ });
37
+ if (completion.status === "complete" && command.all) {
38
+ await writeAllHistoryCompletionMarker(ctx);
39
+ }
40
+ return assembleBackfillResult({
41
+ command,
42
+ ctx,
43
+ upload,
44
+ posted,
45
+ completion,
46
+ blockedAt,
47
+ });
48
+ }
49
+ /**
50
+ * Tells the server what this run observed about every session, uploaded or
51
+ * not. Its acknowledgement — not raw-evidence durability alone — is what makes
52
+ * a historical cursor position irreversible, so the caller gates the whole
53
+ * checkpoint on the `acknowledged` flag returned here.
54
+ */
55
+ async function postBackfillSessionReport(command, io, ctx, upload) {
56
+ const sessions = buildBackfillSessionReport({
57
+ candidates: ctx.scan.candidates,
58
+ syncResults: upload.syncResults,
59
+ now: ctx.now,
60
+ });
61
+ if (sessions.length === 0) {
62
+ return {
63
+ sessions,
64
+ report: emptyReport("no_sessions_observed"),
65
+ acknowledged: true,
66
+ };
67
+ }
68
+ const reportContext = await ensureBackfillReportContext({
69
+ homeDir: command.homeDir,
70
+ paths: ctx.paths,
71
+ collectionRoots: ctx.collectionRoots,
72
+ worktrees: ctx.worktrees,
73
+ candidates: ctx.scan.candidates,
74
+ });
75
+ const report = await postCodexSessionReport({
76
+ homeDir: command.homeDir,
77
+ repoRoot: reportContext.repoRoot,
78
+ dashboardUrl: ctx.dashboardUrl,
79
+ sessions,
80
+ fetch: io.fetch,
81
+ now: ctx.now,
82
+ });
83
+ return {
84
+ sessions,
85
+ report,
86
+ acknowledged: report.posted && report.recorded_count >= sessions.length,
87
+ };
88
+ }
89
+ /** Where a run that uploaded cleanly but could not file its report stopped. */
90
+ function sessionReportStopPoint(upload) {
91
+ return {
92
+ what: "session report failed",
93
+ batch_index: upload.completedBatches,
94
+ batch_total: upload.batches.length,
95
+ done: upload.done,
96
+ total: upload.uploadable.length,
97
+ };
98
+ }
99
+ /** A partial acknowledgement is its own reason; otherwise the post's own. */
100
+ function sessionReportFailureReason(posted) {
101
+ return posted.report.posted &&
102
+ posted.report.recorded_count < posted.sessions.length
103
+ ? "session_report_ack_incomplete"
104
+ : posted.report.reason;
105
+ }
106
+ /**
107
+ * Makes this run's progress durable once the server has acknowledged it:
108
+ * session pointers first, then the backfill cursor through the contiguous
109
+ * resolved prefix. Raw-evidence durability is necessary but not sufficient —
110
+ * both writes wait on the report acknowledgement, because a cursor advanced
111
+ * past a session the server never recorded can never be walked back.
112
+ */
113
+ async function checkpointResolvedBackfillProgress(ctx, upload) {
114
+ const { paths, scan, cursor, now } = ctx;
115
+ if (upload.durableCandidateKeys.size > 0) {
116
+ await recordBackfillDurableSessionPointers({
117
+ paths,
118
+ candidates: scan.candidates,
119
+ syncResults: upload.syncResults,
120
+ now,
121
+ });
122
+ }
123
+ const cursorAdvanced = advanceBackfillCursorThroughResolvedPrefix({
124
+ cursor,
125
+ candidates: scan.candidates,
126
+ durableCandidateKeys: upload.durableCandidateKeys,
127
+ retryableCandidateKeys: scan.retryable_candidate_keys,
128
+ discoveryComplete: !scan.issues.some((issue) => issue.scope === "global"),
129
+ now,
130
+ });
131
+ if (cursorAdvanced)
132
+ await writeBackfillCursor(paths, cursor);
133
+ }
134
+ /**
135
+ * Answers "is this backfill finished, and if not, why not" as pure arithmetic
136
+ * over what the scan found and what the upload resolved — no side effects, so
137
+ * the one place BLI-2727's oversized-skip rule is applied is readable in full.
138
+ * `remaining` is a reporting total that still counts oversized skips so the
139
+ * JSON never goes silent about them; every `*Blocking` count excludes them,
140
+ * because a file too large for the current cap is a permanent labeled fact
141
+ * that no rerun can resolve and completion must not wait on forever.
142
+ */
143
+ function summarizeBackfillCompletion(options) {
144
+ const { scan, oversizedCandidateKeys } = options.ctx;
145
+ const { upload, posted } = options;
146
+ const unresolvedUploadableKeys = new Set(upload.uploadable
147
+ .filter((candidate) => !upload.durableCandidateKeys.has(candidateCursorKey(candidate)))
148
+ .map(candidateCursorKey));
149
+ const unresolvedUploadableBlocking = [...unresolvedUploadableKeys].filter((key) => !oversizedCandidateKeys.has(key)).length;
150
+ const unresolvedRetryableBlocking = [...scan.retryable_candidate_keys].filter((key) => !oversizedCandidateKeys.has(key)).length;
151
+ const remaining = countRemainingHistory({
152
+ scan,
153
+ unresolvedUploadableKeys,
154
+ posted,
155
+ });
156
+ const failed = Math.max(upload.failed, unresolvedUploadableBlocking);
157
+ const blockingIssues = blockingScanIssues(scan.issues);
158
+ const completionBlocked = blockingIssues.length > 0 ||
159
+ unresolvedUploadableBlocking > 0 ||
160
+ unresolvedRetryableBlocking > 0 ||
161
+ upload.deferred > 0 ||
162
+ failed > 0 ||
163
+ !posted.acknowledged;
164
+ let failureReason = options.failureReason;
165
+ if (!failureReason && completionBlocked) {
166
+ const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate)) &&
167
+ !oversizedCandidateKeys.has(candidateCursorKey(candidate)))?.reason;
168
+ failureReason =
169
+ blockingIssues[0]?.reason ??
170
+ (unresolvedUploadableBlocking > 0
171
+ ? "durable_session_pointer_missing"
172
+ : (retryableCandidateReason ?? "backfill_incomplete"));
173
+ }
174
+ return {
175
+ remaining,
176
+ failed,
177
+ status: options.stoppedEarly || completionBlocked ? "partial" : "complete",
178
+ failureReason,
179
+ };
180
+ }
181
+ /**
182
+ * How much history a rerun still has to do. A reporting total, not a
183
+ * completion gate: it counts every unresolved candidate — oversized skips
184
+ * included, so `--json` never goes silent about them (BLI-2727) — plus what
185
+ * selection dropped, what discovery never saw, and the report itself when the
186
+ * server has not acknowledged it.
187
+ */
188
+ function countRemainingHistory(options) {
189
+ const unresolvedKnownKeys = new Set([
190
+ ...options.unresolvedUploadableKeys,
191
+ ...options.scan.retryable_candidate_keys,
192
+ ]);
193
+ const unseenGlobalFailures = options.scan.issues
194
+ .filter((issue) => issue.scope === "global")
195
+ .reduce((total, issue) => total + issue.count, 0);
196
+ const reportRetryable = options.posted.acknowledged
197
+ ? 0
198
+ : // The cursor is intentionally all-or-nothing for the report. Even
199
+ // chunks already accepted by the server are retried idempotently when
200
+ // another required chunk lacks an acknowledgement.
201
+ Math.max(1, options.posted.sessions.length);
202
+ return (unresolvedKnownKeys.size +
203
+ options.scan.omitted_candidate_count +
204
+ unseenGlobalFailures +
205
+ reportRetryable);
206
+ }
207
+ /**
208
+ * Proof, for doctor and status, that archived history is covered. Only an
209
+ * `--all` run earns it: a bounded `--since-days` run may complete its
210
+ * requested window, but that is not proof of an all-history backfill.
211
+ */
212
+ async function writeAllHistoryCompletionMarker(ctx) {
213
+ const { paths, cursor, sources, scopedCursor, scan, oversizedCandidateKeys, now } = ctx;
214
+ recordBackfillScanCoverage(cursor, sources, now, now);
215
+ await writeBackfillCursor(paths, cursor);
216
+ const oversizedCandidates = scan.candidates.filter((candidate) => oversizedCandidateKeys.has(candidateCursorKey(candidate)));
217
+ await writeBackfillCompletionMarker(paths, {
218
+ schema_version: "cockpit-backfill-complete.v2",
219
+ coverage_version: BACKFILL_COVERAGE_VERSION,
220
+ collection_scope_id: scopedCursor.collection_scope_id,
221
+ sources,
222
+ completed_at: now.toISOString(),
223
+ revalidate_after: new Date(now.getTime() + BACKFILL_COMPLETION_RECHECK_MS).toISOString(),
224
+ cursor,
225
+ ...(oversizedCandidates.length > 0
226
+ ? {
227
+ oversized_skips: {
228
+ reason: "file_too_large",
229
+ count: oversizedCandidates.length,
230
+ byte_sizes: oversizedCandidates.map((candidate) => candidate.byte_size),
231
+ },
232
+ }
233
+ : {}),
234
+ });
235
+ }
236
+ /** The `--json` payload: every stage's numbers merged onto the scan's baseline. */
237
+ function assembleBackfillResult(options) {
238
+ const { command, ctx, upload, posted, completion } = options;
239
+ const base = baseBackfillResult(command, backfillResultBaseArgs(ctx));
240
+ return {
241
+ ...base,
242
+ status: completion.status,
243
+ retry_command: ctx.retryCommand,
244
+ counts: {
245
+ ...base.counts,
246
+ backfilled: upload.backfilledSessions,
247
+ failed: completion.failed,
248
+ deferred: upload.deferred,
249
+ remaining: completion.remaining,
250
+ },
251
+ batches: {
252
+ total: upload.batches.length,
253
+ completed: upload.completedBatches,
254
+ failed: upload.failedBatches,
255
+ },
256
+ report: posted.report,
257
+ server_acknowledged: {
258
+ codex_session_report_recorded_count: posted.report.recorded_count,
259
+ raw_evidence_uploaded_object_count: upload.uploadedObjects,
260
+ raw_evidence_uploaded_chunk_count: upload.uploadedChunks,
261
+ },
262
+ blocked_at: options.blockedAt,
263
+ failure_reason: completion.failureReason,
264
+ };
265
+ }
266
+ async function ensureBackfillReportContext(options) {
267
+ const candidateWorktree = options.candidates.find((candidate) => candidate.worktree)?.worktree;
268
+ const representative = candidateWorktree ?? options.worktrees[0] ?? null;
269
+ // A deleted repo or an approved folder workspace can legitimately produce
270
+ // report-only rows with no current git worktree. Use the exact approved root
271
+ // as a synthetic local context instead of falling through to process.cwd().
272
+ const repoRoot = representative?.repo_root ??
273
+ options.collectionRoots[0] ??
274
+ process.cwd();
275
+ try {
276
+ await readLocalWorkContextForRepo(options.paths, repoRoot);
277
+ }
278
+ catch {
279
+ // Reading it can legitimately fail — there is no context yet, which is
280
+ // precisely why the next line creates one. That read is a probe and stays
281
+ // silent; the CREATE is the branch that has to speak (BLI-3238).
282
+ await startLocalWorkContext({
283
+ homeDir: options.homeDir,
284
+ repoRoot,
285
+ branch: representative?.branch,
286
+ }).catch((error) => {
287
+ // Context creation is best-effort here: postCodexSessionReport converts
288
+ // a remaining local-context failure into a retryable report reason. But
289
+ // that reason is `collector_not_ready`, which points the operator at
290
+ // setup rather than at whatever actually failed here.
291
+ console.error("[cockpit-backfill] could not start a local work context for the batch", JSON.stringify({
292
+ reason: "work_context_start_failed",
293
+ ...describeError(error),
294
+ }));
295
+ });
296
+ }
297
+ return { repoRoot };
298
+ }