@bli-cockpit/cli 0.2.48 → 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/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/tower-stream.js +20 -4
- package/package.json +1 -1
|
@@ -6,12 +6,12 @@ import path from "node:path";
|
|
|
6
6
|
import { makeSourceAdapterIdentity, } from "./common.js";
|
|
7
7
|
import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
|
|
8
8
|
import { defaultCodexSessionDirs, } from "./codex-attribution.js";
|
|
9
|
-
import {
|
|
9
|
+
import { recordClaudeAttributionCompleteness, recordCodexAttributionCompleteness, } from "./raw-evidence-attribution-gaps.js";
|
|
10
10
|
import { describeError } from "../health-detail.js";
|
|
11
11
|
import { contentKeyedRawEvidencePackId, DELIVERY_BACKOFF_BYPASS_REASON, DELIVERY_BACKOFF_HOLDING_REASON, deliveryBackoffApplies, evidenceSourceKey, heldSourceKeys, readRawEvidenceStagingState, recordStagedObject, resolveStagedObject, } from "../raw-evidence-staging.js";
|
|
12
12
|
import { isSecretLikePath, safeKeySegment, sha256, shortHash, } from "./raw-evidence-keys.js";
|
|
13
|
-
import { sanitizeTextEvidenceForUpload } from "./raw-evidence-sanitize.js";
|
|
14
|
-
import { countEvidenceEntries, makeEvidenceCompleteness, markBudgetCapApplied, markCapApplied, recordScanned,
|
|
13
|
+
import { sanitizeTextEvidenceForUpload, } from "./raw-evidence-sanitize.js";
|
|
14
|
+
import { countEvidenceEntries, makeEvidenceCompleteness, markBudgetCapApplied, markCapApplied, recordScanned, } from "./raw-evidence-completeness.js";
|
|
15
15
|
import { evidenceEntry, pointerFromEntry, RAW_EVIDENCE_BUCKET, } from "./raw-evidence-manifest.js";
|
|
16
16
|
import { chmodPrivate, ensurePrivateDir, persistStagingState, promoteStagedPack, stageManifest, } from "./raw-evidence-pack-store.js";
|
|
17
17
|
import { GIT_DIFF_TIMEOUT_MS, MAX_GIT_DIFF_BYTES, REPO_ROOT_MISSING_REASON, RepoRootMissingError, repoRootExists, runGitDiff, } from "./raw-evidence-git-diff.js";
|
|
@@ -30,59 +30,68 @@ const CLAUDE_MAX_COLLECT_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
|
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
// The pass
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
33
|
+
/**
|
|
34
|
+
* Decide which bytes on this laptop are allowed to become durable evidence for
|
|
35
|
+
* one repo, and put exactly those into one private, content-keyed pack.
|
|
36
|
+
*
|
|
37
|
+
* Never throws: a crash mid-pass still returns facts and a `failed` scan, so a
|
|
38
|
+
* broken sync is reported rather than silently absent.
|
|
39
|
+
*/
|
|
33
40
|
export async function collectRawEvidencePack(context, options) {
|
|
34
|
-
const startedAt = context.now.toISOString();
|
|
35
41
|
const rawEvidenceRoot = path.join(options.stateDir, "raw-evidence");
|
|
36
|
-
|
|
37
|
-
// until the content is, so bytes land in a private staging directory and the
|
|
38
|
-
// directory is then renamed to its content-keyed name — or dropped, when an
|
|
39
|
-
// identical pack is already there.
|
|
40
|
-
const stagingDir = path.join(rawEvidenceRoot, `.staging-${process.pid}-${crypto.randomUUID().slice(0, 8)}`);
|
|
42
|
+
const stagingDir = newStagingDir(rawEvidenceRoot);
|
|
41
43
|
const sinceMinutes = options.sinceMinutes ?? DEFAULT_SINCE_MINUTES;
|
|
42
44
|
const collection = await openCollection(context, options, {
|
|
43
45
|
rawEvidenceRoot,
|
|
44
46
|
stagingDir,
|
|
45
47
|
});
|
|
46
48
|
const scanWindow = {
|
|
47
|
-
startedAt,
|
|
49
|
+
startedAt: context.now.toISOString(),
|
|
48
50
|
finishedAt: () => context.now.toISOString(),
|
|
49
51
|
sinceMinutes,
|
|
50
52
|
};
|
|
53
|
+
const run = { stagingDir, window: scanWindow };
|
|
51
54
|
try {
|
|
52
55
|
await ensurePrivateDir(stagingDir);
|
|
53
56
|
await ensurePrivateDir(collection.filesDir);
|
|
54
57
|
recordAttributionCompleteness(collection, options);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
sessionsDirs: options.sessionsDirs,
|
|
60
|
-
sinceMinutes,
|
|
61
|
-
limit: options.sessionLimit ?? DEFAULT_SESSION_LIMIT,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
if (options.includeClaudeJsonl !== false && options.claudeSessionFiles) {
|
|
65
|
-
await collectClaudeJsonlFiles(collection, options.claudeSessionFiles);
|
|
66
|
-
}
|
|
67
|
-
await collectGitDiffFiles(collection, options.repoRoot);
|
|
68
|
-
if (collection.entries.length === 0) {
|
|
69
|
-
return await finishWithEmptyPack(collection, options, {
|
|
70
|
-
stagingDir,
|
|
71
|
-
window: scanWindow,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
return await finishWithPromotedPack(collection, options, {
|
|
75
|
-
stagingDir,
|
|
76
|
-
window: scanWindow,
|
|
77
|
-
});
|
|
58
|
+
await collectEverySource(collection, options, sinceMinutes);
|
|
59
|
+
return collection.entries.length === 0
|
|
60
|
+
? await finishWithEmptyPack(collection, options, run)
|
|
61
|
+
: await finishWithPromotedPack(collection, options, run);
|
|
78
62
|
}
|
|
79
63
|
catch (error) {
|
|
80
|
-
return await finishWithFailedPack(collection, {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
64
|
+
return await finishWithFailedPack(collection, { ...run, error });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Staging first, promotion second (BLI-3066). The pack id cannot be known until
|
|
69
|
+
* the content is, so bytes land in a private per-attempt directory, and that
|
|
70
|
+
* directory is then renamed to its content-keyed name — or dropped, when an
|
|
71
|
+
* identical pack is already there.
|
|
72
|
+
*/
|
|
73
|
+
function newStagingDir(rawEvidenceRoot) {
|
|
74
|
+
return path.join(rawEvidenceRoot, `.staging-${process.pid}-${crypto.randomUUID().slice(0, 8)}`);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Every kind of evidence this repo can offer, in the order it is read. Codex and
|
|
78
|
+
* Claude transcripts are each switchable off by the caller; the git diffs are
|
|
79
|
+
* not, because they are half the evidence of what someone actually changed.
|
|
80
|
+
*/
|
|
81
|
+
async function collectEverySource(collection, options, sinceMinutes) {
|
|
82
|
+
if (options.includeCodexJsonl !== false) {
|
|
83
|
+
await collectCodexJsonlFiles(collection, {
|
|
84
|
+
codexSessionFiles: options.codexSessionFiles,
|
|
85
|
+
sessionsDir: options.sessionsDir,
|
|
86
|
+
sessionsDirs: options.sessionsDirs,
|
|
87
|
+
sinceMinutes,
|
|
88
|
+
limit: options.sessionLimit ?? DEFAULT_SESSION_LIMIT,
|
|
84
89
|
});
|
|
85
90
|
}
|
|
91
|
+
if (options.includeClaudeJsonl !== false && options.claudeSessionFiles) {
|
|
92
|
+
await collectClaudeJsonlFiles(collection, options.claudeSessionFiles);
|
|
93
|
+
}
|
|
94
|
+
await collectGitDiffFiles(collection, options.repoRoot);
|
|
86
95
|
}
|
|
87
96
|
async function openCollection(context, options, places) {
|
|
88
97
|
const byteBudget = options.byteBudget ?? RAW_EVIDENCE_DEFAULT_BYTE_BUDGET;
|
|
@@ -225,20 +234,64 @@ async function finishWithPromotedPack(collection, options, run) {
|
|
|
225
234
|
workContextId: context.workContextId,
|
|
226
235
|
entries,
|
|
227
236
|
});
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
237
|
+
rebaseStagedEntriesOntoPack(collection, promotion.evidenceDir);
|
|
238
|
+
const manifestPath = await addManifestToPack(collection, promotion, packId);
|
|
239
|
+
await persistStagingState(options.stateDir, collection.staging, context.now.toISOString());
|
|
240
|
+
logPackStaged(collection, promotion, packId);
|
|
241
|
+
const facts = makePromotedPackFacts(collection, {
|
|
242
|
+
packId,
|
|
243
|
+
manifestPath,
|
|
244
|
+
promotion,
|
|
245
|
+
window: run.window,
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
facts,
|
|
249
|
+
scan: makeRawEvidenceScan({
|
|
250
|
+
context,
|
|
251
|
+
startedAt: run.window.startedAt,
|
|
252
|
+
// A pack of nothing but its own manifest carries no evidence, so it
|
|
253
|
+
// reports partial however cleanly it was written.
|
|
254
|
+
status: entries.length > 1 ? "ok" : "partial",
|
|
255
|
+
facts,
|
|
256
|
+
}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** A pack with files: the same shell as an empty one, plus what is in it. */
|
|
260
|
+
function makePromotedPackFacts(collection, place) {
|
|
261
|
+
const entries = collection.entries;
|
|
262
|
+
return {
|
|
263
|
+
...packFactsShell(collection, {
|
|
264
|
+
packId: place.packId,
|
|
265
|
+
manifestPath: place.manifestPath,
|
|
266
|
+
evidenceDir: place.promotion.evidenceDir,
|
|
267
|
+
window: place.window,
|
|
268
|
+
}),
|
|
269
|
+
stage_state: place.promotion.state,
|
|
270
|
+
file_count: entries.length,
|
|
271
|
+
byte_size: totalByteSize(entries),
|
|
272
|
+
content_kinds: [...new Set(entries.map((entry) => entry.kind))],
|
|
273
|
+
pointers: entries.map(pointerFromEntry),
|
|
274
|
+
upload_files: entries.map(uploadFileFromEntry),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Write the manifest and make it an object of the pack like any other file, so
|
|
279
|
+
* the uploader has one list to walk. Returns where it landed.
|
|
280
|
+
*/
|
|
281
|
+
async function addManifestToPack(collection, promotion, packId) {
|
|
282
|
+
const context = collection.context;
|
|
283
|
+
const manifestPath = path.join(promotion.evidenceDir, "manifest.json");
|
|
231
284
|
const manifestBytes = await stageManifest({
|
|
232
285
|
context,
|
|
233
286
|
packId,
|
|
234
287
|
manifestPath,
|
|
235
|
-
entries,
|
|
288
|
+
entries: collection.entries,
|
|
236
289
|
skipped: collection.skipped,
|
|
237
290
|
redacted: collection.redacted,
|
|
238
291
|
reused: collection.reused,
|
|
239
292
|
reusePack: promotion.state === "reused",
|
|
240
293
|
});
|
|
241
|
-
entries.push(evidenceEntry({
|
|
294
|
+
collection.entries.push(evidenceEntry({
|
|
242
295
|
context,
|
|
243
296
|
kind: "manifest",
|
|
244
297
|
packId,
|
|
@@ -261,42 +314,22 @@ async function finishWithPromotedPack(collection, options, run) {
|
|
|
261
314
|
// now a nice-to-have rather than the only thing between us and a loop.
|
|
262
315
|
contentAddress: `manifest/${sha256(manifestBytes).slice(0, 16)}.json`,
|
|
263
316
|
}));
|
|
264
|
-
|
|
317
|
+
return manifestPath;
|
|
318
|
+
}
|
|
319
|
+
/** The success line. A sync that staged nothing new still has to say so. */
|
|
320
|
+
function logPackStaged(collection, promotion, packId) {
|
|
265
321
|
console.error("[raw-evidence] pack staged", JSON.stringify({
|
|
266
322
|
pack_id: packId,
|
|
267
323
|
stage_state: promotion.state,
|
|
268
324
|
reason: stageReasonLabel(promotion.state, promotion.priorPackCount),
|
|
269
325
|
prior_pack_count: promotion.priorPackCount,
|
|
270
326
|
refilled_file_count: promotion.refilledFileCount,
|
|
271
|
-
file_count: entries.length,
|
|
272
|
-
byte_size: totalByteSize(entries),
|
|
327
|
+
file_count: collection.entries.length,
|
|
328
|
+
byte_size: totalByteSize(collection.entries),
|
|
273
329
|
staged_new: collection.stagedNewCount,
|
|
274
330
|
staged_reused: collection.stagedReusedCount,
|
|
275
331
|
delivery_held: collection.deliveryHeldCount,
|
|
276
332
|
}));
|
|
277
|
-
const facts = {
|
|
278
|
-
...packFactsShell(collection, {
|
|
279
|
-
packId,
|
|
280
|
-
manifestPath,
|
|
281
|
-
evidenceDir,
|
|
282
|
-
window: run.window,
|
|
283
|
-
}),
|
|
284
|
-
stage_state: promotion.state,
|
|
285
|
-
file_count: entries.length,
|
|
286
|
-
byte_size: totalByteSize(entries),
|
|
287
|
-
content_kinds: [...new Set(entries.map((entry) => entry.kind))],
|
|
288
|
-
pointers: entries.map(pointerFromEntry),
|
|
289
|
-
upload_files: entries.map(uploadFileFromEntry),
|
|
290
|
-
};
|
|
291
|
-
return {
|
|
292
|
-
facts,
|
|
293
|
-
scan: makeRawEvidenceScan({
|
|
294
|
-
context,
|
|
295
|
-
startedAt: run.window.startedAt,
|
|
296
|
-
status: entries.length > 1 ? "ok" : "partial",
|
|
297
|
-
facts,
|
|
298
|
-
}),
|
|
299
|
-
};
|
|
300
333
|
}
|
|
301
334
|
/**
|
|
302
335
|
* A crashed pass. The staged-object index is deliberately NOT written: this
|
|
@@ -484,108 +517,23 @@ function recordAttributionCompleteness(collection, options) {
|
|
|
484
517
|
recordClaudeAttributionCompleteness(collection, options.claudeAttributionScan, new Set(options.claudeSessionFiles?.map((file) => file.local_path) ?? []));
|
|
485
518
|
}
|
|
486
519
|
}
|
|
487
|
-
function recordCodexAttributionCompleteness(collection, scan, selectedPaths) {
|
|
488
|
-
recordScanned(collection, "codex_attribution", scan.scanned_file_count);
|
|
489
|
-
collection.caps.push({
|
|
490
|
-
source: "codex_attribution",
|
|
491
|
-
cap_type: "scan_window_minutes",
|
|
492
|
-
limit: scan.since_minutes,
|
|
493
|
-
observed: scan.since_minutes,
|
|
494
|
-
applied: false,
|
|
495
|
-
}, {
|
|
496
|
-
source: "codex_attribution",
|
|
497
|
-
cap_type: "session_limit",
|
|
498
|
-
limit: scan.session_limit,
|
|
499
|
-
observed: scan.discovered_file_count,
|
|
500
|
-
applied: scan.session_limit_applied,
|
|
501
|
-
});
|
|
502
|
-
recordSkipCount(collection, "codex_attribution", "session_limit_overflow", Math.max(0, scan.discovered_file_count - scan.scanned_file_count));
|
|
503
|
-
recordSkipCount(collection, "codex_attribution", "directory_read_failed", scan.directory_read_failed_count);
|
|
504
|
-
recordSkipCount(collection, "codex_attribution", "file_stat_failed", scan.stat_failed_count);
|
|
505
|
-
recordSkipCount(collection, "codex_attribution", "secret_like_directory", scan.secret_path_skipped_count);
|
|
506
|
-
recordAttributionResultSkips(collection, "codex_attribution", scan.results, selectedPaths);
|
|
507
|
-
}
|
|
508
|
-
function recordClaudeAttributionCompleteness(collection, scan, selectedPaths) {
|
|
509
|
-
recordScanned(collection, "claude_attribution", scan.scanned_session_count);
|
|
510
|
-
if (scan.disabled_reason) {
|
|
511
|
-
recordSkipCount(collection, "claude_attribution", scan.disabled_reason, 1);
|
|
512
|
-
}
|
|
513
|
-
collection.caps.push({
|
|
514
|
-
source: "claude_attribution",
|
|
515
|
-
cap_type: "scan_window_minutes",
|
|
516
|
-
limit: scan.since_minutes,
|
|
517
|
-
observed: scan.since_minutes,
|
|
518
|
-
applied: false,
|
|
519
|
-
}, {
|
|
520
|
-
source: "claude_attribution",
|
|
521
|
-
cap_type: "session_limit",
|
|
522
|
-
limit: scan.session_limit,
|
|
523
|
-
observed: scan.discovered_session_count,
|
|
524
|
-
applied: scan.session_limit_applied,
|
|
525
|
-
}, {
|
|
526
|
-
source: "claude_attribution",
|
|
527
|
-
cap_type: "max_file_bytes",
|
|
528
|
-
limit: scan.max_file_bytes,
|
|
529
|
-
applied: scan.counts.mains_oversized > 0 ||
|
|
530
|
-
scan.results.some((result) => result.reason === "file_too_large"),
|
|
531
|
-
}, {
|
|
532
|
-
source: "claude_attribution",
|
|
533
|
-
cap_type: "max_sidecar_files",
|
|
534
|
-
limit: scan.max_sidecar_files,
|
|
535
|
-
observed: scan.max_sidecar_files + scan.counts.sidecars_capped,
|
|
536
|
-
applied: scan.counts.sidecars_capped > 0,
|
|
537
|
-
}, {
|
|
538
|
-
source: "claude_attribution",
|
|
539
|
-
cap_type: "max_line_buffer_bytes",
|
|
540
|
-
limit: scan.max_line_buffer_bytes,
|
|
541
|
-
applied: scan.counts.oversized_lines_skipped > 0,
|
|
542
|
-
});
|
|
543
|
-
recordSkipCount(collection, "claude_attribution", "session_limit_overflow", Math.max(0, scan.discovered_session_count - scan.scanned_session_count));
|
|
544
|
-
recordSkipCount(collection, "claude_attribution", "secret_like_project_dir", scan.project_dirs_skipped);
|
|
545
|
-
recordSkipCount(collection, "claude_attribution", "project_dir_read_failed", scan.project_dir_read_failed_count);
|
|
546
|
-
recordSkipCount(collection, "claude_attribution", "session_stat_failed", scan.session_stat_failed_count);
|
|
547
|
-
recordSkipCount(collection, "claude_attribution", "sidecar_dir_read_failed", scan.sidecar_dir_read_failed_count);
|
|
548
|
-
recordSkipCount(collection, "claude_attribution", "sidecar_stat_failed", scan.sidecar_stat_failed_count);
|
|
549
|
-
recordSkipCount(collection, "claude_attribution", "sidecar_limit_overflow", scan.counts.sidecars_capped);
|
|
550
|
-
recordTruncationCount(collection, "claude_attribution", "oversized_jsonl_line", scan.counts.oversized_lines_skipped, { max_bytes: scan.max_line_buffer_bytes });
|
|
551
|
-
recordAttributionResultSkips(collection, "claude_attribution", scan.results, selectedPaths);
|
|
552
|
-
for (const result of scan.results) {
|
|
553
|
-
for (const sidecar of result.sidecar_files) {
|
|
554
|
-
if (!sidecar.skipped_reason)
|
|
555
|
-
continue;
|
|
556
|
-
recordSkipCount(collection, "claude_attribution", `sidecar_${sidecar.skipped_reason}`, 1);
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
function recordAttributionResultSkips(collection, source, results, selectedPaths) {
|
|
561
|
-
for (const result of results) {
|
|
562
|
-
if (isAttributionAccountedFor(result, selectedPaths))
|
|
563
|
-
continue;
|
|
564
|
-
const reason = result.state === "skipped"
|
|
565
|
-
? result.reason
|
|
566
|
-
: `attribution_${result.state}:${result.reason}`;
|
|
567
|
-
recordSkipCount(collection, source, reason, 1);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
/**
|
|
571
|
-
* Not every unattributed result is a gap. A selected session is being collected
|
|
572
|
-
* by this pass, and a live-sync-safe synthetic target is owned by another
|
|
573
|
-
* workspace's pack — neither is missing evidence.
|
|
574
|
-
*/
|
|
575
|
-
function isAttributionAccountedFor(result, selectedPaths) {
|
|
576
|
-
if (result.state === "attributed")
|
|
577
|
-
return true;
|
|
578
|
-
if (selectedPaths.has(result.file_path))
|
|
579
|
-
return true;
|
|
580
|
-
return (result.worktree !== null &&
|
|
581
|
-
isLiveRawEvidenceSyncAttribution(result.state, result.reason, true));
|
|
582
|
-
}
|
|
583
520
|
// ---------------------------------------------------------------------------
|
|
584
521
|
// Codex transcripts
|
|
585
522
|
// ---------------------------------------------------------------------------
|
|
586
523
|
async function collectCodexJsonlFiles(collection, options) {
|
|
524
|
+
for (const candidate of await chooseCodexCandidates(collection, options)) {
|
|
525
|
+
recordScanned(collection, "codex_jsonl");
|
|
526
|
+
await collectOneCodexSession(collection, candidate);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Which Codex transcripts this pass will look at, and the record of how many it
|
|
531
|
+
* had to leave out. Attribution's own list is never trimmed — the session limit
|
|
532
|
+
* only guards the fallback walk, which can turn up every session on the machine.
|
|
533
|
+
*/
|
|
534
|
+
async function chooseCodexCandidates(collection, options) {
|
|
587
535
|
const attributed = Boolean(options.codexSessionFiles);
|
|
588
|
-
const
|
|
536
|
+
const candidates = options.codexSessionFiles
|
|
589
537
|
? options.codexSessionFiles.map((file) => ({
|
|
590
538
|
filePath: file.local_path,
|
|
591
539
|
codexSessionId: file.codex_session_id,
|
|
@@ -595,36 +543,34 @@ async function collectCodexJsonlFiles(collection, options) {
|
|
|
595
543
|
source: "codex_jsonl",
|
|
596
544
|
cap_type: "session_limit",
|
|
597
545
|
limit: options.limit,
|
|
598
|
-
observed:
|
|
599
|
-
applied: !attributed &&
|
|
546
|
+
observed: candidates.length,
|
|
547
|
+
applied: !attributed && candidates.length > options.limit,
|
|
548
|
+
});
|
|
549
|
+
return attributed ? candidates : candidates.slice(0, options.limit);
|
|
550
|
+
}
|
|
551
|
+
/** The transcript, then the images it explicitly attached — never the reverse. */
|
|
552
|
+
async function collectOneCodexSession(collection, candidate) {
|
|
553
|
+
const codexSessionId = candidate.codexSessionId ?? shortHash(candidate.filePath);
|
|
554
|
+
const transcriptAccepted = await collectOneEvidenceFile(collection, {
|
|
555
|
+
filePath: candidate.filePath,
|
|
556
|
+
kind: "codex_jsonl",
|
|
557
|
+
sessionId: codexSessionId,
|
|
558
|
+
mediaType: "application/jsonl",
|
|
559
|
+
// Guard before read/toString: Codex files can exceed the buffered commit
|
|
560
|
+
// ceiling just like Claude mains.
|
|
561
|
+
maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
|
|
562
|
+
redactedSummary: "Raw Codex JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
|
|
563
|
+
contentAddress: (hash16) => `codex/${safeKeySegment(codexSessionId)}/${hash16}.jsonl`,
|
|
564
|
+
});
|
|
565
|
+
if (!transcriptAccepted)
|
|
566
|
+
return;
|
|
567
|
+
await collectAgentImagesFromTranscript(collection, {
|
|
568
|
+
filePath: candidate.filePath,
|
|
569
|
+
source: "codex",
|
|
570
|
+
sessionId: codexSessionId,
|
|
571
|
+
kind: "codex_image_attachment",
|
|
572
|
+
contentAddress: (hash16, extension) => `codex/${safeKeySegment(codexSessionId)}/images/${hash16}.${extension}`,
|
|
600
573
|
});
|
|
601
|
-
const candidates = attributed
|
|
602
|
-
? resolvedCandidates
|
|
603
|
-
: resolvedCandidates.slice(0, options.limit);
|
|
604
|
-
for (const candidate of candidates) {
|
|
605
|
-
recordScanned(collection, "codex_jsonl");
|
|
606
|
-
const codexSessionId = candidate.codexSessionId ?? shortHash(candidate.filePath);
|
|
607
|
-
const transcriptAccepted = await collectOneEvidenceFile(collection, {
|
|
608
|
-
filePath: candidate.filePath,
|
|
609
|
-
kind: "codex_jsonl",
|
|
610
|
-
sessionId: codexSessionId,
|
|
611
|
-
mediaType: "application/jsonl",
|
|
612
|
-
// Guard before read/toString: Codex files can exceed the buffered commit
|
|
613
|
-
// ceiling just like Claude mains.
|
|
614
|
-
maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
|
|
615
|
-
redactedSummary: "Raw Codex JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
|
|
616
|
-
contentAddress: (hash16) => `codex/${safeKeySegment(codexSessionId)}/${hash16}.jsonl`,
|
|
617
|
-
});
|
|
618
|
-
if (!transcriptAccepted)
|
|
619
|
-
continue;
|
|
620
|
-
await collectAgentImagesFromTranscript(collection, {
|
|
621
|
-
filePath: candidate.filePath,
|
|
622
|
-
source: "codex",
|
|
623
|
-
sessionId: codexSessionId,
|
|
624
|
-
kind: "codex_image_attachment",
|
|
625
|
-
contentAddress: (hash16, extension) => `codex/${safeKeySegment(codexSessionId)}/images/${hash16}.${extension}`,
|
|
626
|
-
});
|
|
627
|
-
}
|
|
628
574
|
}
|
|
629
575
|
/** The unattributed fallback: every recent Codex transcript on this machine. */
|
|
630
576
|
function walkRecentCodexJsonlFiles(collection, options) {
|
|
@@ -779,34 +725,43 @@ async function collectOneAgentImageFile(collection, options) {
|
|
|
779
725
|
});
|
|
780
726
|
return;
|
|
781
727
|
}
|
|
728
|
+
await writeAgentImageToPack(collection, {
|
|
729
|
+
...options,
|
|
730
|
+
contentHash,
|
|
731
|
+
metadata,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
/** Put an accepted image's bytes on disk and add its manifest entry. */
|
|
735
|
+
async function writeAgentImageToPack(collection, image) {
|
|
736
|
+
const raw = image.image.bytes;
|
|
782
737
|
collection.index.value += 1;
|
|
783
|
-
const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-agent-image-${contentHash.slice(0, 16)}.${
|
|
738
|
+
const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-agent-image-${image.contentHash.slice(0, 16)}.${image.image.extension}`);
|
|
784
739
|
const imageSourceKey = evidenceSourceKey({
|
|
785
|
-
kind:
|
|
786
|
-
sessionId:
|
|
787
|
-
label:
|
|
740
|
+
kind: image.kind,
|
|
741
|
+
sessionId: image.sessionId,
|
|
742
|
+
label: image.image.label,
|
|
788
743
|
});
|
|
789
744
|
const staged = await stageEvidenceBytes(collection, {
|
|
790
|
-
contentHash,
|
|
745
|
+
contentHash: image.contentHash,
|
|
791
746
|
bytes: raw,
|
|
792
747
|
fileName: path.basename(relativePath),
|
|
793
|
-
kind:
|
|
748
|
+
kind: image.kind,
|
|
794
749
|
sourceKey: imageSourceKey,
|
|
795
750
|
});
|
|
796
751
|
collection.entries.push(evidenceEntry({
|
|
797
752
|
context: collection.context,
|
|
798
|
-
kind:
|
|
753
|
+
kind: image.kind,
|
|
799
754
|
packId: collection.packId,
|
|
800
755
|
localPath: staged.local_path,
|
|
801
756
|
relativePath,
|
|
802
757
|
stagedInPack: staged.staged_in_pack,
|
|
803
758
|
sourceKey: imageSourceKey,
|
|
804
|
-
mediaType: metadata.media_type,
|
|
759
|
+
mediaType: image.metadata.media_type,
|
|
805
760
|
redactedSummary: "Raw image explicitly attached to an agent session, preserved in private durable storage.",
|
|
806
761
|
bytes: raw,
|
|
807
|
-
codexSessionId:
|
|
808
|
-
contentAddress:
|
|
809
|
-
artifactMetadata: metadata,
|
|
762
|
+
codexSessionId: image.sessionId,
|
|
763
|
+
contentAddress: image.contentAddress(image.contentHash.slice(0, 16), image.image.extension),
|
|
764
|
+
artifactMetadata: image.metadata,
|
|
810
765
|
}));
|
|
811
766
|
}
|
|
812
767
|
// ---------------------------------------------------------------------------
|
|
@@ -822,19 +777,11 @@ async function collectOneAgentImageFile(collection, options) {
|
|
|
822
777
|
* collecting. Every false return has pushed a named skip first.
|
|
823
778
|
*/
|
|
824
779
|
async function collectOneEvidenceFile(collection, options) {
|
|
825
|
-
const
|
|
826
|
-
const secretLikeFileName = isSecretLikePath(fileName);
|
|
827
|
-
const evidenceLabel = secretLikeFileName ? "[REDACTED_FILE_NAME]" : fileName;
|
|
828
|
-
const packedFileName = secretLikeFileName ? "redacted-file.jsonl" : fileName;
|
|
829
|
-
const sourceKey = evidenceSourceKey({
|
|
830
|
-
kind: options.kind,
|
|
831
|
-
sessionId: options.sessionId,
|
|
832
|
-
sourcePath: options.filePath,
|
|
833
|
-
});
|
|
780
|
+
const names = nameEvidenceFile(options);
|
|
834
781
|
const skip = (reason) => {
|
|
835
782
|
collection.skipped.push({
|
|
836
783
|
kind: options.kind,
|
|
837
|
-
label: evidenceLabel,
|
|
784
|
+
label: names.evidenceLabel,
|
|
838
785
|
reason,
|
|
839
786
|
});
|
|
840
787
|
return false;
|
|
@@ -843,14 +790,10 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
843
790
|
// has failed repeatedly costs nothing at all this cycle — no read, no hash,
|
|
844
791
|
// no copy, no request — and the hold is a named, retryable gap so a held
|
|
845
792
|
// session cannot make the sync look clean (BLI-3066).
|
|
846
|
-
if (collection.heldSources.has(sourceKey)) {
|
|
793
|
+
if (collection.heldSources.has(names.sourceKey)) {
|
|
847
794
|
collection.deliveryHeldCount += 1;
|
|
848
795
|
skip(DELIVERY_BACKOFF_HOLDING_REASON);
|
|
849
|
-
|
|
850
|
-
reason: DELIVERY_BACKOFF_HOLDING_REASON,
|
|
851
|
-
kind: options.kind,
|
|
852
|
-
source_key: sourceKey,
|
|
853
|
-
}));
|
|
796
|
+
logDeliveryBackoffHold(options.kind, names.sourceKey);
|
|
854
797
|
return false;
|
|
855
798
|
}
|
|
856
799
|
const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes);
|
|
@@ -860,74 +803,114 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
860
803
|
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
861
804
|
return skip("file_too_large");
|
|
862
805
|
}
|
|
863
|
-
const
|
|
806
|
+
const sanitized = maskSecretsInTranscript(collection, {
|
|
807
|
+
raw: read.bytes,
|
|
808
|
+
kind: options.kind,
|
|
809
|
+
evidenceLabel: names.evidenceLabel,
|
|
810
|
+
secretLikeFileName: names.secretLikeFileName,
|
|
811
|
+
});
|
|
812
|
+
const contentHash = sha256(sanitized.bytes);
|
|
813
|
+
if (collection.skipContentHashes.has(contentHash)) {
|
|
814
|
+
collection.reused.push({
|
|
815
|
+
kind: options.kind,
|
|
816
|
+
label: names.evidenceLabel,
|
|
817
|
+
content_hash_sha256: contentHash,
|
|
818
|
+
codex_session_id: options.sessionId,
|
|
819
|
+
});
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
const deferReason = admitToBudget(collection.budget, sanitized.bytes.byteLength);
|
|
823
|
+
if (deferReason) {
|
|
824
|
+
markBudgetCapApplied(collection, deferReason);
|
|
825
|
+
return skip(deferReason);
|
|
826
|
+
}
|
|
827
|
+
await stageOneTranscript(collection, {
|
|
828
|
+
...options,
|
|
829
|
+
names,
|
|
830
|
+
sanitized,
|
|
831
|
+
contentHash,
|
|
832
|
+
});
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
function nameEvidenceFile(options) {
|
|
836
|
+
const fileName = path.basename(options.filePath);
|
|
837
|
+
const secretLikeFileName = isSecretLikePath(fileName);
|
|
838
|
+
return {
|
|
839
|
+
secretLikeFileName,
|
|
840
|
+
evidenceLabel: secretLikeFileName ? "[REDACTED_FILE_NAME]" : fileName,
|
|
841
|
+
packedFileName: secretLikeFileName ? "redacted-file.jsonl" : fileName,
|
|
842
|
+
sourceKey: evidenceSourceKey({
|
|
843
|
+
kind: options.kind,
|
|
844
|
+
sessionId: options.sessionId,
|
|
845
|
+
sourcePath: options.filePath,
|
|
846
|
+
}),
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function logDeliveryBackoffHold(kind, sourceKey) {
|
|
850
|
+
console.error("[raw-evidence] delivery backoff holding source", JSON.stringify({
|
|
851
|
+
reason: DELIVERY_BACKOFF_HOLDING_REASON,
|
|
852
|
+
kind,
|
|
853
|
+
source_key: sourceKey,
|
|
854
|
+
}));
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Mask secret-like values in a transcript and record that it happened, because
|
|
858
|
+
* masking is invisible in the uploaded bytes and an operator has to be able to
|
|
859
|
+
* see that this file went up altered.
|
|
860
|
+
*/
|
|
861
|
+
function maskSecretsInTranscript(collection, options) {
|
|
864
862
|
const sanitized = sanitizeTextEvidenceForUpload({
|
|
865
|
-
text: raw.toString("utf8"),
|
|
866
|
-
originalBytes: raw,
|
|
863
|
+
text: options.raw.toString("utf8"),
|
|
864
|
+
originalBytes: options.raw,
|
|
867
865
|
redactedFields: [`${options.kind}.body`],
|
|
868
|
-
secretLikeFileName,
|
|
866
|
+
secretLikeFileName: options.secretLikeFileName,
|
|
869
867
|
});
|
|
870
868
|
if (sanitized.status === "redacted") {
|
|
871
869
|
collection.redacted.push({
|
|
872
870
|
kind: options.kind,
|
|
873
|
-
label: evidenceLabel,
|
|
871
|
+
label: options.evidenceLabel,
|
|
874
872
|
redaction: sanitized.redaction,
|
|
875
873
|
completenessLabel: sanitized.completenessLabel,
|
|
876
874
|
});
|
|
877
875
|
console.error("[raw-evidence] text evidence sanitized", JSON.stringify({
|
|
878
876
|
kind: options.kind,
|
|
879
877
|
mode: sanitized.completenessLabel,
|
|
880
|
-
original_bytes: raw.byteLength,
|
|
878
|
+
original_bytes: options.raw.byteLength,
|
|
881
879
|
uploaded_bytes: sanitized.bytes.byteLength,
|
|
882
880
|
}));
|
|
883
881
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
const wasRedacted = sanitized.status === "redacted";
|
|
889
|
-
const contentHash = sha256(evidenceBytes);
|
|
890
|
-
if (collection.skipContentHashes.has(contentHash)) {
|
|
891
|
-
collection.reused.push({
|
|
892
|
-
kind: options.kind,
|
|
893
|
-
label: evidenceLabel,
|
|
894
|
-
content_hash_sha256: contentHash,
|
|
895
|
-
codex_session_id: options.sessionId,
|
|
896
|
-
});
|
|
897
|
-
return true;
|
|
898
|
-
}
|
|
899
|
-
const deferReason = admitToBudget(collection.budget, evidenceBytes.byteLength);
|
|
900
|
-
if (deferReason) {
|
|
901
|
-
markBudgetCapApplied(collection, deferReason);
|
|
902
|
-
return skip(deferReason);
|
|
903
|
-
}
|
|
882
|
+
return sanitized;
|
|
883
|
+
}
|
|
884
|
+
/** Put an accepted transcript's bytes on disk and add its manifest entry. */
|
|
885
|
+
async function stageOneTranscript(collection, file) {
|
|
904
886
|
collection.index.value += 1;
|
|
905
|
-
const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(
|
|
887
|
+
const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(file.filePath)}-${file.names.packedFileName}`);
|
|
906
888
|
const staged = await stageEvidenceBytes(collection, {
|
|
907
|
-
contentHash,
|
|
908
|
-
bytes:
|
|
889
|
+
contentHash: file.contentHash,
|
|
890
|
+
bytes: file.sanitized.bytes,
|
|
909
891
|
fileName: path.basename(relativePath),
|
|
910
|
-
kind:
|
|
911
|
-
sourceKey,
|
|
892
|
+
kind: file.kind,
|
|
893
|
+
sourceKey: file.names.sourceKey,
|
|
912
894
|
});
|
|
913
895
|
collection.entries.push(evidenceEntry({
|
|
914
896
|
context: collection.context,
|
|
915
|
-
kind:
|
|
897
|
+
kind: file.kind,
|
|
916
898
|
packId: collection.packId,
|
|
917
899
|
localPath: staged.local_path,
|
|
918
900
|
relativePath,
|
|
919
|
-
mediaType:
|
|
920
|
-
redactedSummary:
|
|
921
|
-
? `${
|
|
922
|
-
:
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
901
|
+
mediaType: file.mediaType,
|
|
902
|
+
redactedSummary: file.sanitized.status === "redacted"
|
|
903
|
+
? `${file.redactedSummary} Secret-like values were deterministically redacted before upload.`
|
|
904
|
+
: file.redactedSummary,
|
|
905
|
+
// Both branches carry a record now (BLI-3277), so "was anything
|
|
906
|
+
// replaced?" is the status, never the presence of `redaction`.
|
|
907
|
+
redaction: file.sanitized.redaction,
|
|
908
|
+
bytes: file.sanitized.bytes,
|
|
909
|
+
codexSessionId: file.sessionId,
|
|
910
|
+
contentAddress: file.contentAddress(file.contentHash.slice(0, 16)),
|
|
927
911
|
stagedInPack: staged.staged_in_pack,
|
|
928
|
-
sourceKey,
|
|
912
|
+
sourceKey: file.names.sourceKey,
|
|
929
913
|
}));
|
|
930
|
-
return true;
|
|
931
914
|
}
|
|
932
915
|
/**
|
|
933
916
|
* Size is checked twice on purpose: once by `stat` so an oversized transcript
|
|
@@ -1024,70 +1007,17 @@ const GIT_DIFF_TARGETS = [
|
|
|
1024
1007
|
{ label: "staged", args: ["diff", "--cached", "--no-ext-diff", "--"] },
|
|
1025
1008
|
];
|
|
1026
1009
|
async function collectGitDiffFiles(collection, repoRoot) {
|
|
1027
|
-
// BLI-3551: asked once per root, before either target, so a pruned worktree
|
|
1028
|
-
// costs ONE line instead of one per diff target per session per tick — and
|
|
1029
|
-
// the line says the folder is gone rather than accusing git of failing.
|
|
1030
1010
|
if (!(await repoRootExists(repoRoot))) {
|
|
1031
|
-
|
|
1032
|
-
reason: REPO_ROOT_MISSING_REASON,
|
|
1033
|
-
diff_targets_skipped: GIT_DIFF_TARGETS.length,
|
|
1034
|
-
next_action: "nothing to do; the diff returns when the worktree is restored or the session ages out",
|
|
1035
|
-
}));
|
|
1036
|
-
for (const target of GIT_DIFF_TARGETS) {
|
|
1037
|
-
recordScanned(collection, "git_diff");
|
|
1038
|
-
collection.skipped.push({
|
|
1039
|
-
kind: "git_diff",
|
|
1040
|
-
label: target.label,
|
|
1041
|
-
reason: REPO_ROOT_MISSING_REASON,
|
|
1042
|
-
});
|
|
1043
|
-
}
|
|
1011
|
+
skipEveryDiffTargetForMissingRoot(collection);
|
|
1044
1012
|
return;
|
|
1045
1013
|
}
|
|
1046
1014
|
for (const target of GIT_DIFF_TARGETS) {
|
|
1047
1015
|
recordScanned(collection, "git_diff");
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
diff = await runGitDiff(target.args, repoRoot);
|
|
1051
|
-
}
|
|
1052
|
-
catch (error) {
|
|
1053
|
-
// The root was there a moment ago and is not now (or a second collector
|
|
1054
|
-
// pruned it mid-tick). Same named outcome, still not a git failure.
|
|
1055
|
-
if (error instanceof RepoRootMissingError) {
|
|
1056
|
-
collection.skipped.push({
|
|
1057
|
-
kind: "git_diff",
|
|
1058
|
-
label: target.label,
|
|
1059
|
-
reason: REPO_ROOT_MISSING_REASON,
|
|
1060
|
-
});
|
|
1061
|
-
continue;
|
|
1062
|
-
}
|
|
1063
|
-
// `git_diff_failed` is the skip label and stays. It covers git not being
|
|
1064
|
-
// installed, the folder not being a repo, a locked index and a diff that
|
|
1065
|
-
// exceeded the child-process buffer — and the diff is half the evidence
|
|
1066
|
-
// for what someone actually changed, so losing it quietly matters.
|
|
1067
|
-
console.error("[raw-evidence] git diff failed", JSON.stringify({
|
|
1068
|
-
reason: "git_diff_failed",
|
|
1069
|
-
diff_target: target.label,
|
|
1070
|
-
...describeError(error),
|
|
1071
|
-
}));
|
|
1072
|
-
collection.skipped.push({
|
|
1073
|
-
kind: "git_diff",
|
|
1074
|
-
label: target.label,
|
|
1075
|
-
reason: "git_diff_failed",
|
|
1076
|
-
});
|
|
1016
|
+
const diff = await runOneGitDiff(collection, target, repoRoot);
|
|
1017
|
+
if (!diff)
|
|
1077
1018
|
continue;
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
markCapApplied(collection, "git_diff", diff.truncationCapType);
|
|
1081
|
-
collection.truncated.push({
|
|
1082
|
-
kind: "git_diff",
|
|
1083
|
-
reason: diff.truncationReason,
|
|
1084
|
-
...(diff.truncationCapType === "max_bytes_per_diff"
|
|
1085
|
-
? { max_bytes: MAX_GIT_DIFF_BYTES }
|
|
1086
|
-
: {}),
|
|
1087
|
-
observed_bytes: diff.observedBytes,
|
|
1088
|
-
included_bytes: Buffer.byteLength(diff.stdout, "utf8"),
|
|
1089
|
-
});
|
|
1090
|
-
}
|
|
1019
|
+
if (diff.truncated)
|
|
1020
|
+
recordGitDiffTruncation(collection, diff);
|
|
1091
1021
|
// An empty diff is not a gap: there was simply nothing to record. The
|
|
1092
1022
|
// truncation marker above still stands even when zero bytes survived.
|
|
1093
1023
|
if (!diff.stdout.trim())
|
|
@@ -1099,28 +1029,79 @@ async function collectGitDiffFiles(collection, repoRoot) {
|
|
|
1099
1029
|
});
|
|
1100
1030
|
}
|
|
1101
1031
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1032
|
+
/**
|
|
1033
|
+
* BLI-3551: the root is asked about once per pass, before either target, so a
|
|
1034
|
+
* pruned worktree costs ONE line instead of one per diff target per session per
|
|
1035
|
+
* tick — and the line says the folder is gone rather than accusing git of
|
|
1036
|
+
* failing. Both targets still get their own named gap.
|
|
1037
|
+
*/
|
|
1038
|
+
function skipEveryDiffTargetForMissingRoot(collection) {
|
|
1039
|
+
console.error("[raw-evidence] git diff skipped, repository root is no longer on disk", JSON.stringify({
|
|
1040
|
+
reason: REPO_ROOT_MISSING_REASON,
|
|
1041
|
+
diff_targets_skipped: GIT_DIFF_TARGETS.length,
|
|
1042
|
+
next_action: "nothing to do; the diff returns when the worktree is restored or the session ages out",
|
|
1043
|
+
}));
|
|
1044
|
+
for (const target of GIT_DIFF_TARGETS) {
|
|
1045
|
+
recordScanned(collection, "git_diff");
|
|
1046
|
+
collection.skipped.push({
|
|
1109
1047
|
kind: "git_diff",
|
|
1110
1048
|
label: target.label,
|
|
1111
|
-
|
|
1112
|
-
completenessLabel: sanitized.completenessLabel,
|
|
1049
|
+
reason: REPO_ROOT_MISSING_REASON,
|
|
1113
1050
|
});
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Run one diff, or record why it produced nothing. Null means the gap is
|
|
1055
|
+
* already named in the ledger, so the caller only has to move on.
|
|
1056
|
+
*/
|
|
1057
|
+
async function runOneGitDiff(collection, target, repoRoot) {
|
|
1058
|
+
try {
|
|
1059
|
+
return await runGitDiff(target.args, repoRoot);
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
// The root was there a moment ago and is not now (or a second collector
|
|
1063
|
+
// pruned it mid-tick). Same named outcome, still not a git failure.
|
|
1064
|
+
if (error instanceof RepoRootMissingError) {
|
|
1065
|
+
collection.skipped.push({
|
|
1066
|
+
kind: "git_diff",
|
|
1067
|
+
label: target.label,
|
|
1068
|
+
reason: REPO_ROOT_MISSING_REASON,
|
|
1069
|
+
});
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
// `git_diff_failed` is the skip label and stays. It covers git not being
|
|
1073
|
+
// installed, the folder not being a repo, a locked index and a diff that
|
|
1074
|
+
// exceeded the child-process buffer — and the diff is half the evidence
|
|
1075
|
+
// for what someone actually changed, so losing it quietly matters.
|
|
1076
|
+
console.error("[raw-evidence] git diff failed", JSON.stringify({
|
|
1077
|
+
reason: "git_diff_failed",
|
|
1078
|
+
diff_target: target.label,
|
|
1079
|
+
...describeError(error),
|
|
1118
1080
|
}));
|
|
1081
|
+
collection.skipped.push({
|
|
1082
|
+
kind: "git_diff",
|
|
1083
|
+
label: target.label,
|
|
1084
|
+
reason: "git_diff_failed",
|
|
1085
|
+
});
|
|
1086
|
+
return null;
|
|
1119
1087
|
}
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1088
|
+
}
|
|
1089
|
+
/** A diff that hit its size or time cap is partial evidence, and says so. */
|
|
1090
|
+
function recordGitDiffTruncation(collection, diff) {
|
|
1091
|
+
markCapApplied(collection, "git_diff", diff.truncationCapType);
|
|
1092
|
+
collection.truncated.push({
|
|
1093
|
+
kind: "git_diff",
|
|
1094
|
+
reason: diff.truncationReason,
|
|
1095
|
+
...(diff.truncationCapType === "max_bytes_per_diff"
|
|
1096
|
+
? { max_bytes: MAX_GIT_DIFF_BYTES }
|
|
1097
|
+
: {}),
|
|
1098
|
+
observed_bytes: diff.observedBytes,
|
|
1099
|
+
included_bytes: Buffer.byteLength(diff.stdout, "utf8"),
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
async function stageOneGitDiff(collection, target) {
|
|
1103
|
+
const sanitized = maskSecretsInGitDiff(collection, target);
|
|
1104
|
+
const contentHash = sha256(sanitized.bytes);
|
|
1124
1105
|
if (collection.skipContentHashes.has(contentHash)) {
|
|
1125
1106
|
collection.reused.push({
|
|
1126
1107
|
kind: "git_diff",
|
|
@@ -1130,7 +1111,7 @@ async function stageOneGitDiff(collection, target) {
|
|
|
1130
1111
|
});
|
|
1131
1112
|
return;
|
|
1132
1113
|
}
|
|
1133
|
-
const deferReason = admitToBudget(collection.budget,
|
|
1114
|
+
const deferReason = admitToBudget(collection.budget, sanitized.bytes.byteLength);
|
|
1134
1115
|
if (deferReason) {
|
|
1135
1116
|
markBudgetCapApplied(collection, deferReason);
|
|
1136
1117
|
collection.skipped.push({
|
|
@@ -1140,15 +1121,45 @@ async function stageOneGitDiff(collection, target) {
|
|
|
1140
1121
|
});
|
|
1141
1122
|
return;
|
|
1142
1123
|
}
|
|
1143
|
-
|
|
1124
|
+
await writeGitDiffToPack(collection, { target, sanitized, contentHash });
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Mask secret-like values in a diff and record that it happened. A diff carries
|
|
1128
|
+
* whatever a person pasted into a config file, so this is the branch that most
|
|
1129
|
+
* often fires — and an operator has to see that the bytes went up altered.
|
|
1130
|
+
*/
|
|
1131
|
+
function maskSecretsInGitDiff(collection, target) {
|
|
1132
|
+
const sanitized = sanitizeTextEvidenceForUpload({
|
|
1133
|
+
text: target.diffText,
|
|
1134
|
+
redactedFields: [`git_diff.${target.label}`],
|
|
1135
|
+
});
|
|
1136
|
+
if (sanitized.status === "redacted") {
|
|
1137
|
+
collection.redacted.push({
|
|
1138
|
+
kind: "git_diff",
|
|
1139
|
+
label: target.label,
|
|
1140
|
+
redaction: sanitized.redaction,
|
|
1141
|
+
completenessLabel: sanitized.completenessLabel,
|
|
1142
|
+
});
|
|
1143
|
+
console.error("[raw-evidence] git diff sanitized", JSON.stringify({
|
|
1144
|
+
mode: sanitized.completenessLabel,
|
|
1145
|
+
original_bytes: Buffer.byteLength(target.diffText, "utf8"),
|
|
1146
|
+
uploaded_bytes: sanitized.bytes.byteLength,
|
|
1147
|
+
}));
|
|
1148
|
+
}
|
|
1149
|
+
return sanitized;
|
|
1150
|
+
}
|
|
1151
|
+
/** Put an accepted diff's bytes on disk and add its manifest entry. */
|
|
1152
|
+
async function writeGitDiffToPack(collection, diff) {
|
|
1153
|
+
const label = diff.target.label;
|
|
1154
|
+
const relativePath = path.join("files", `git-${label}.diff`);
|
|
1144
1155
|
const diffSourceKey = evidenceSourceKey({
|
|
1145
1156
|
kind: "git_diff",
|
|
1146
1157
|
sessionId: collection.context.workContextId,
|
|
1147
|
-
label
|
|
1158
|
+
label,
|
|
1148
1159
|
});
|
|
1149
1160
|
const staged = await stageEvidenceBytes(collection, {
|
|
1150
|
-
contentHash,
|
|
1151
|
-
bytes:
|
|
1161
|
+
contentHash: diff.contentHash,
|
|
1162
|
+
bytes: diff.sanitized.bytes,
|
|
1152
1163
|
fileName: path.basename(relativePath),
|
|
1153
1164
|
kind: "git_diff",
|
|
1154
1165
|
sourceKey: diffSourceKey,
|
|
@@ -1162,13 +1173,13 @@ async function stageOneGitDiff(collection, target) {
|
|
|
1162
1173
|
stagedInPack: staged.staged_in_pack,
|
|
1163
1174
|
sourceKey: diffSourceKey,
|
|
1164
1175
|
mediaType: "text/x-diff",
|
|
1165
|
-
redactedSummary: gitDiffSummary(
|
|
1166
|
-
redacted:
|
|
1167
|
-
truncated: target.truncated,
|
|
1176
|
+
redactedSummary: gitDiffSummary(label, {
|
|
1177
|
+
redacted: diff.sanitized.status === "redacted",
|
|
1178
|
+
truncated: diff.target.truncated,
|
|
1168
1179
|
}),
|
|
1169
|
-
redaction,
|
|
1170
|
-
bytes:
|
|
1171
|
-
contentAddress: `git-diff/${
|
|
1180
|
+
redaction: diff.sanitized.redaction,
|
|
1181
|
+
bytes: diff.sanitized.bytes,
|
|
1182
|
+
contentAddress: `git-diff/${label}-${diff.contentHash.slice(0, 16)}.diff`,
|
|
1172
1183
|
}));
|
|
1173
1184
|
}
|
|
1174
1185
|
function gitDiffSummary(label, state) {
|