@bli-cockpit/cli 0.2.27 → 0.2.28
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.js +327 -30
- package/dist/commands/local.js +33 -2
- package/dist/commands/public-root.js +1 -1
- package/dist/local-state.js +12 -1
- package/dist/raw-evidence-gc.js +178 -0
- package/dist/raw-evidence-staging.js +309 -0
- package/dist/upload.js +141 -15
- package/package.json +1 -1
|
@@ -8,6 +8,7 @@ import { makeSourceAdapterIdentity, } from "./common.js";
|
|
|
8
8
|
import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
|
|
9
9
|
import { defaultCodexSessionDirs, } from "./codex-attribution.js";
|
|
10
10
|
import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
|
|
11
|
+
import { contentKeyedRawEvidencePackId, DELIVERY_BACKOFF_HOLDING_REASON, evidenceSourceKey, heldSourceKeys, readRawEvidenceStagingState, recordStagedObject, resolveStagedObject, writeRawEvidenceStagingState, } from "../raw-evidence-staging.js";
|
|
11
12
|
const DEFAULT_SINCE_MINUTES = 24 * 60;
|
|
12
13
|
const DEFAULT_SESSION_LIMIT = 50;
|
|
13
14
|
const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
|
|
@@ -23,9 +24,14 @@ export const RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET = 300;
|
|
|
23
24
|
const CLAUDE_MAX_COLLECT_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
|
|
24
25
|
export async function collectRawEvidencePack(context, options) {
|
|
25
26
|
const startedAt = context.now.toISOString();
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
const rawEvidenceRoot = path.join(options.stateDir, "raw-evidence");
|
|
28
|
+
// Staging first, promotion second (BLI-3066). The pack id cannot be known
|
|
29
|
+
// until the content is, so bytes land in a private staging directory and the
|
|
30
|
+
// directory is then renamed to its content-keyed name — or dropped, when an
|
|
31
|
+
// identical pack is already there.
|
|
32
|
+
const stagingDir = path.join(rawEvidenceRoot, `.staging-${process.pid}-${crypto.randomUUID().slice(0, 8)}`);
|
|
33
|
+
const filesDir = path.join(stagingDir, "files");
|
|
34
|
+
const staging = await readRawEvidenceStagingState(options.stateDir);
|
|
29
35
|
const entries = [];
|
|
30
36
|
const skipped = [];
|
|
31
37
|
const truncated = [];
|
|
@@ -39,7 +45,13 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
39
45
|
const collection = {
|
|
40
46
|
context,
|
|
41
47
|
filesDir,
|
|
42
|
-
|
|
48
|
+
rawEvidenceRoot,
|
|
49
|
+
packId: "",
|
|
50
|
+
staging,
|
|
51
|
+
heldSources: heldSourceKeys(staging, context.now),
|
|
52
|
+
stagedReusedCount: 0,
|
|
53
|
+
stagedNewCount: 0,
|
|
54
|
+
deliveryHeldCount: 0,
|
|
43
55
|
entries,
|
|
44
56
|
skipped,
|
|
45
57
|
truncated,
|
|
@@ -101,7 +113,7 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
101
113
|
index: { value: 0 },
|
|
102
114
|
};
|
|
103
115
|
try {
|
|
104
|
-
await ensurePrivateDir(
|
|
116
|
+
await ensurePrivateDir(stagingDir);
|
|
105
117
|
await ensurePrivateDir(filesDir);
|
|
106
118
|
recordAttributionCompleteness(collection, {
|
|
107
119
|
codex: options.codexAttributionScan,
|
|
@@ -125,6 +137,16 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
125
137
|
const deferredByteBudgetCount = skipped.filter((entry) => entry.reason === "deferred_byte_budget").reduce((sum, entry) => sum + evidenceEntryCount(entry), 0);
|
|
126
138
|
const deferredObjectBudgetCount = skipped.filter((entry) => entry.reason === "deferred_object_budget").reduce((sum, entry) => sum + evidenceEntryCount(entry), 0);
|
|
127
139
|
if (entries.length === 0) {
|
|
140
|
+
// Nothing collected: drop the staging directory instead of leaving an
|
|
141
|
+
// empty pack behind. This used to leave one empty `work-*` dir per sync.
|
|
142
|
+
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
143
|
+
const packId = contentKeyedRawEvidencePackId({
|
|
144
|
+
workContextId: context.workContextId,
|
|
145
|
+
contentHashes: [],
|
|
146
|
+
});
|
|
147
|
+
collection.packId = packId;
|
|
148
|
+
const evidenceDir = path.join(rawEvidenceRoot, packId);
|
|
149
|
+
await persistStagingState(options.stateDir, collection);
|
|
128
150
|
const evidenceCompleteness = makeEvidenceCompleteness(collection, {
|
|
129
151
|
startedAt,
|
|
130
152
|
finishedAt: context.now.toISOString(),
|
|
@@ -140,6 +162,10 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
140
162
|
skipped_count: countEvidenceEntries(skipped),
|
|
141
163
|
sanitized_count: redacted.length,
|
|
142
164
|
reused_count: reused.length,
|
|
165
|
+
staged_reused_count: collection.stagedReusedCount,
|
|
166
|
+
staged_new_count: collection.stagedNewCount,
|
|
167
|
+
delivery_held_count: collection.deliveryHeldCount,
|
|
168
|
+
stage_state: "empty",
|
|
143
169
|
deferred_byte_budget_count: deferredByteBudgetCount,
|
|
144
170
|
deferred_object_budget_count: deferredObjectBudgetCount,
|
|
145
171
|
content_kinds: [],
|
|
@@ -158,18 +184,45 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
158
184
|
}),
|
|
159
185
|
};
|
|
160
186
|
}
|
|
161
|
-
|
|
187
|
+
// The pack is named by what is in it, never by when it was made. Identical
|
|
188
|
+
// content on the next sync resolves to the identical directory, which is
|
|
189
|
+
// the whole fix for BLI-3066.
|
|
190
|
+
const packId = contentKeyedRawEvidencePackId({
|
|
191
|
+
workContextId: context.workContextId,
|
|
192
|
+
contentHashes: entries.map((entry) => entry.content_hash_sha256),
|
|
193
|
+
});
|
|
194
|
+
collection.packId = packId;
|
|
195
|
+
const promotion = await promoteStagedPack({
|
|
196
|
+
rawEvidenceRoot,
|
|
197
|
+
stagingDir,
|
|
198
|
+
packId,
|
|
199
|
+
workContextId: context.workContextId,
|
|
200
|
+
entries,
|
|
201
|
+
});
|
|
202
|
+
const evidenceDir = promotion.evidenceDir;
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
if (!entry.staged_in_pack)
|
|
205
|
+
continue;
|
|
206
|
+
entry.local_path = path.join(evidenceDir, "files", path.basename(entry.local_path));
|
|
207
|
+
recordStagedObject(collection.staging, entry.content_hash_sha256, {
|
|
208
|
+
pack_id: packId,
|
|
209
|
+
relative_path: `files/${path.basename(entry.local_path)}`,
|
|
210
|
+
byte_size: entry.byte_size,
|
|
211
|
+
source_key: entry.source_key,
|
|
212
|
+
staged_at: context.now.toISOString(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const manifestPath = path.join(evidenceDir, "manifest.json");
|
|
216
|
+
const manifestBytes = await stageManifest({
|
|
162
217
|
context,
|
|
163
218
|
packId,
|
|
219
|
+
manifestPath,
|
|
164
220
|
entries,
|
|
165
221
|
skipped,
|
|
166
222
|
redacted,
|
|
167
223
|
reused,
|
|
224
|
+
reusePack: promotion.state === "reused",
|
|
168
225
|
});
|
|
169
|
-
const manifestPath = path.join(evidenceDir, "manifest.json");
|
|
170
|
-
const manifestBytes = Buffer.from(`${JSON.stringify(manifestWithoutSelf, null, 2)}\n`, "utf8");
|
|
171
|
-
await fs.writeFile(manifestPath, manifestBytes, { mode: 0o600 });
|
|
172
|
-
await chmodPrivate(manifestPath, 0o600);
|
|
173
226
|
const manifestEntry = evidenceEntry({
|
|
174
227
|
context,
|
|
175
228
|
kind: "manifest",
|
|
@@ -181,6 +234,25 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
181
234
|
bytes: manifestBytes,
|
|
182
235
|
});
|
|
183
236
|
entries.push(manifestEntry);
|
|
237
|
+
await persistStagingState(options.stateDir, collection);
|
|
238
|
+
console.error("[raw-evidence] pack staged", JSON.stringify({
|
|
239
|
+
pack_id: packId,
|
|
240
|
+
stage_state: promotion.state,
|
|
241
|
+
reason: promotion.state === "reused"
|
|
242
|
+
? "staged_reused"
|
|
243
|
+
: promotion.state === "restaged_incomplete"
|
|
244
|
+
? "restaged_incomplete"
|
|
245
|
+
: promotion.priorPackCount > 0
|
|
246
|
+
? "restaged_content_changed"
|
|
247
|
+
: "staged_new",
|
|
248
|
+
prior_pack_count: promotion.priorPackCount,
|
|
249
|
+
refilled_file_count: promotion.refilledFileCount,
|
|
250
|
+
file_count: entries.length,
|
|
251
|
+
byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
|
|
252
|
+
staged_new: collection.stagedNewCount,
|
|
253
|
+
staged_reused: collection.stagedReusedCount,
|
|
254
|
+
delivery_held: collection.deliveryHeldCount,
|
|
255
|
+
}));
|
|
184
256
|
const evidenceCompleteness = makeEvidenceCompleteness(collection, {
|
|
185
257
|
startedAt,
|
|
186
258
|
finishedAt: context.now.toISOString(),
|
|
@@ -191,6 +263,10 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
191
263
|
manifest_path: manifestPath,
|
|
192
264
|
evidence_dir: evidenceDir,
|
|
193
265
|
storage_bucket: RAW_EVIDENCE_BUCKET,
|
|
266
|
+
staged_reused_count: collection.stagedReusedCount,
|
|
267
|
+
staged_new_count: collection.stagedNewCount,
|
|
268
|
+
delivery_held_count: collection.deliveryHeldCount,
|
|
269
|
+
stage_state: promotion.state,
|
|
194
270
|
file_count: entries.length,
|
|
195
271
|
byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
|
|
196
272
|
skipped_count: countEvidenceEntries(skipped),
|
|
@@ -222,11 +298,28 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
222
298
|
}),
|
|
223
299
|
};
|
|
224
300
|
}
|
|
225
|
-
catch {
|
|
301
|
+
catch (error) {
|
|
226
302
|
failed.push({
|
|
227
303
|
kind: "raw_evidence",
|
|
228
304
|
reason: "collection_failed",
|
|
229
305
|
});
|
|
306
|
+
// Staging is per-attempt scratch: a crashed pass must not leave a partial
|
|
307
|
+
// directory behind to be counted, re-hashed or swept later.
|
|
308
|
+
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
309
|
+
const packId = collection.packId ||
|
|
310
|
+
contentKeyedRawEvidencePackId({
|
|
311
|
+
workContextId: context.workContextId,
|
|
312
|
+
contentHashes: entries.map((entry) => entry.content_hash_sha256),
|
|
313
|
+
});
|
|
314
|
+
const evidenceDir = path.join(rawEvidenceRoot, packId);
|
|
315
|
+
console.error("[raw-evidence] pack collection failed", JSON.stringify({
|
|
316
|
+
pack_id: packId,
|
|
317
|
+
reason: "collection_failed",
|
|
318
|
+
detail: error instanceof Error ? error.name : typeof error,
|
|
319
|
+
collected_file_count: entries.length,
|
|
320
|
+
staged_new: collection.stagedNewCount,
|
|
321
|
+
staged_reused: collection.stagedReusedCount,
|
|
322
|
+
}));
|
|
230
323
|
const evidenceCompleteness = makeEvidenceCompleteness(collection, {
|
|
231
324
|
startedAt,
|
|
232
325
|
finishedAt: context.now.toISOString(),
|
|
@@ -242,6 +335,10 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
242
335
|
skipped_count: countEvidenceEntries(skipped),
|
|
243
336
|
sanitized_count: redacted.length,
|
|
244
337
|
reused_count: reused.length,
|
|
338
|
+
staged_reused_count: collection.stagedReusedCount,
|
|
339
|
+
staged_new_count: collection.stagedNewCount,
|
|
340
|
+
delivery_held_count: collection.deliveryHeldCount,
|
|
341
|
+
stage_state: "empty",
|
|
245
342
|
deferred_byte_budget_count: skipped
|
|
246
343
|
.filter((entry) => entry.reason === "deferred_byte_budget")
|
|
247
344
|
.reduce((sum, entry) => sum + evidenceEntryCount(entry), 0),
|
|
@@ -293,6 +390,10 @@ function makeRawEvidenceScan(options) {
|
|
|
293
390
|
`bytes:${options.facts.byte_size}`,
|
|
294
391
|
`skipped:${options.facts.skipped_count}`,
|
|
295
392
|
`reused:${options.facts.reused_count}`,
|
|
393
|
+
`stage_state:${options.facts.stage_state}`,
|
|
394
|
+
`staged_new:${options.facts.staged_new_count}`,
|
|
395
|
+
`staged_reused:${options.facts.staged_reused_count}`,
|
|
396
|
+
`delivery_held:${options.facts.delivery_held_count}`,
|
|
296
397
|
`completeness:${options.facts.evidence_completeness.status}`,
|
|
297
398
|
`truncated:${options.facts.evidence_completeness.totals.truncated_count}`,
|
|
298
399
|
`deferred:${options.facts.evidence_completeness.totals.deferred_count}`,
|
|
@@ -576,15 +677,26 @@ async function collectOneAgentImageFile(collection, options) {
|
|
|
576
677
|
}
|
|
577
678
|
collection.index.value += 1;
|
|
578
679
|
const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-agent-image-${contentHash.slice(0, 16)}.${options.image.extension}`);
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
680
|
+
const imageSourceKey = evidenceSourceKey({
|
|
681
|
+
kind: options.kind,
|
|
682
|
+
sessionId: options.sessionId,
|
|
683
|
+
label: options.image.label,
|
|
684
|
+
});
|
|
685
|
+
const staged = await stageEvidenceBytes(collection, {
|
|
686
|
+
contentHash,
|
|
687
|
+
bytes: raw,
|
|
688
|
+
fileName: path.basename(relativePath),
|
|
689
|
+
kind: options.kind,
|
|
690
|
+
sourceKey: imageSourceKey,
|
|
691
|
+
});
|
|
582
692
|
collection.entries.push(evidenceEntry({
|
|
583
693
|
context: collection.context,
|
|
584
694
|
kind: options.kind,
|
|
585
695
|
packId: collection.packId,
|
|
586
|
-
localPath:
|
|
696
|
+
localPath: staged.local_path,
|
|
587
697
|
relativePath,
|
|
698
|
+
stagedInPack: staged.staged_in_pack,
|
|
699
|
+
sourceKey: imageSourceKey,
|
|
588
700
|
mediaType: metadata.media_type,
|
|
589
701
|
redactedSummary: "Raw image explicitly attached to an agent session, preserved in private durable storage.",
|
|
590
702
|
bytes: raw,
|
|
@@ -606,6 +718,29 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
606
718
|
? "[REDACTED_FILE_NAME]"
|
|
607
719
|
: fileName;
|
|
608
720
|
const packedFileName = secretLikeFileName ? "redacted-file.jsonl" : fileName;
|
|
721
|
+
const sourceKey = evidenceSourceKey({
|
|
722
|
+
kind: options.kind,
|
|
723
|
+
sessionId: options.sessionId,
|
|
724
|
+
sourcePath: options.filePath,
|
|
725
|
+
});
|
|
726
|
+
// Delivery backoff is checked BEFORE the file is read. An object whose commit
|
|
727
|
+
// has failed repeatedly costs nothing at all this cycle — no read, no hash,
|
|
728
|
+
// no copy, no request — and the hold is a named, retryable gap so a held
|
|
729
|
+
// session cannot make the sync look clean (BLI-3066).
|
|
730
|
+
if (collection.heldSources.has(sourceKey)) {
|
|
731
|
+
collection.deliveryHeldCount += 1;
|
|
732
|
+
collection.skipped.push({
|
|
733
|
+
kind: options.kind,
|
|
734
|
+
label: evidenceLabel,
|
|
735
|
+
reason: DELIVERY_BACKOFF_HOLDING_REASON,
|
|
736
|
+
});
|
|
737
|
+
console.error("[raw-evidence] delivery backoff holding source", JSON.stringify({
|
|
738
|
+
reason: DELIVERY_BACKOFF_HOLDING_REASON,
|
|
739
|
+
kind: options.kind,
|
|
740
|
+
source_key: sourceKey,
|
|
741
|
+
}));
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
609
744
|
if (options.maxFileBytes) {
|
|
610
745
|
let stat;
|
|
611
746
|
try {
|
|
@@ -694,14 +829,18 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
694
829
|
}
|
|
695
830
|
collection.index.value += 1;
|
|
696
831
|
const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(options.filePath)}-${packedFileName}`);
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
832
|
+
const staged = await stageEvidenceBytes(collection, {
|
|
833
|
+
contentHash,
|
|
834
|
+
bytes: evidenceBytes,
|
|
835
|
+
fileName: path.basename(relativePath),
|
|
836
|
+
kind: options.kind,
|
|
837
|
+
sourceKey,
|
|
838
|
+
});
|
|
700
839
|
collection.entries.push(evidenceEntry({
|
|
701
840
|
context: collection.context,
|
|
702
841
|
kind: options.kind,
|
|
703
842
|
packId: collection.packId,
|
|
704
|
-
localPath:
|
|
843
|
+
localPath: staged.local_path,
|
|
705
844
|
relativePath,
|
|
706
845
|
mediaType: options.mediaType,
|
|
707
846
|
redactedSummary: redaction
|
|
@@ -711,9 +850,38 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
711
850
|
bytes: evidenceBytes,
|
|
712
851
|
codexSessionId: options.sessionId,
|
|
713
852
|
contentAddress: options.contentAddress(contentHash.slice(0, 16)),
|
|
853
|
+
stagedInPack: staged.staged_in_pack,
|
|
854
|
+
sourceKey,
|
|
714
855
|
}));
|
|
715
856
|
return true;
|
|
716
857
|
}
|
|
858
|
+
/**
|
|
859
|
+
* Put these bytes on disk once.
|
|
860
|
+
*
|
|
861
|
+
* If an earlier sync already staged this exact content and the copy is still
|
|
862
|
+
* there, that copy is used — the uploader only needs a readable path, and it
|
|
863
|
+
* does not care which pack directory holds it. This is the branch that stops
|
|
864
|
+
* one 334 MB rollout from becoming 559 copies while its commit keeps failing.
|
|
865
|
+
*/
|
|
866
|
+
async function stageEvidenceBytes(collection, options) {
|
|
867
|
+
const existing = await resolveStagedObject(collection.rawEvidenceRoot, collection.staging, options.contentHash);
|
|
868
|
+
if (existing) {
|
|
869
|
+
collection.stagedReusedCount += 1;
|
|
870
|
+
console.error("[raw-evidence] staged copy reused", JSON.stringify({
|
|
871
|
+
reason: "staged_reused",
|
|
872
|
+
kind: options.kind,
|
|
873
|
+
content_hash_prefix: options.contentHash.slice(0, 16),
|
|
874
|
+
byte_size: existing.entry.byte_size,
|
|
875
|
+
pack_id: existing.entry.pack_id,
|
|
876
|
+
}));
|
|
877
|
+
return { local_path: existing.local_path, staged_in_pack: false };
|
|
878
|
+
}
|
|
879
|
+
const destination = path.join(collection.filesDir, options.fileName);
|
|
880
|
+
await fs.writeFile(destination, options.bytes, { mode: 0o600 });
|
|
881
|
+
await chmodPrivate(destination, 0o600);
|
|
882
|
+
collection.stagedNewCount += 1;
|
|
883
|
+
return { local_path: destination, staged_in_pack: true };
|
|
884
|
+
}
|
|
717
885
|
/**
|
|
718
886
|
* Decrements the per-sync budget when a file fits, or returns a deferred-skip
|
|
719
887
|
* reason when it does not. The object budget bounds request count; the byte
|
|
@@ -934,15 +1102,26 @@ async function collectGitDiffFiles(collection, repoRoot) {
|
|
|
934
1102
|
continue;
|
|
935
1103
|
}
|
|
936
1104
|
const relativePath = path.join("files", `git-${target.label}.diff`);
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
1105
|
+
const diffSourceKey = evidenceSourceKey({
|
|
1106
|
+
kind: "git_diff",
|
|
1107
|
+
sessionId: collection.context.workContextId,
|
|
1108
|
+
label: target.label,
|
|
1109
|
+
});
|
|
1110
|
+
const staged = await stageEvidenceBytes(collection, {
|
|
1111
|
+
contentHash,
|
|
1112
|
+
bytes: raw,
|
|
1113
|
+
fileName: path.basename(relativePath),
|
|
1114
|
+
kind: "git_diff",
|
|
1115
|
+
sourceKey: diffSourceKey,
|
|
1116
|
+
});
|
|
940
1117
|
collection.entries.push(evidenceEntry({
|
|
941
1118
|
context: collection.context,
|
|
942
1119
|
kind: "git_diff",
|
|
943
1120
|
packId: collection.packId,
|
|
944
|
-
localPath:
|
|
1121
|
+
localPath: staged.local_path,
|
|
945
1122
|
relativePath,
|
|
1123
|
+
stagedInPack: staged.staged_in_pack,
|
|
1124
|
+
sourceKey: diffSourceKey,
|
|
946
1125
|
mediaType: "text/x-diff",
|
|
947
1126
|
redactedSummary: redaction
|
|
948
1127
|
? `Raw git ${target.label} diff preserved locally with env/secret paths excluded and secret-like values deterministically redacted.`
|
|
@@ -1309,6 +1488,8 @@ function evidenceEntry(options) {
|
|
|
1309
1488
|
redacted_summary: options.redactedSummary,
|
|
1310
1489
|
...(options.redaction ? { redaction: options.redaction } : {}),
|
|
1311
1490
|
codex_session_id: options.codexSessionId ?? null,
|
|
1491
|
+
staged_in_pack: options.stagedInPack !== false,
|
|
1492
|
+
source_key: options.sourceKey ?? null,
|
|
1312
1493
|
...(options.artifactMetadata
|
|
1313
1494
|
? {
|
|
1314
1495
|
artifact_metadata: {
|
|
@@ -1323,6 +1504,10 @@ function evidenceEntry(options) {
|
|
|
1323
1504
|
: {}),
|
|
1324
1505
|
};
|
|
1325
1506
|
}
|
|
1507
|
+
// `local_path` is redacted out, and so are the staging bookkeeping fields:
|
|
1508
|
+
// where a copy happens to live on this disk is not part of the pack's identity,
|
|
1509
|
+
// and putting it in the manifest would make byte-identical content produce
|
|
1510
|
+
// different manifests.
|
|
1326
1511
|
function redactManifestEntry(entry) {
|
|
1327
1512
|
return {
|
|
1328
1513
|
kind: entry.kind,
|
|
@@ -1417,13 +1602,125 @@ function readableKeySegment(value, fallback, options = {}) {
|
|
|
1417
1602
|
function posixPath(parts) {
|
|
1418
1603
|
return parts.join("/").replace(/\\/g, "/").replace(/\/+/g, "/");
|
|
1419
1604
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1605
|
+
/**
|
|
1606
|
+
* Turn the staging directory into the content-keyed pack directory.
|
|
1607
|
+
*
|
|
1608
|
+
* Three outcomes, all of them named:
|
|
1609
|
+
* - the pack does not exist yet: rename staging into place (`new`);
|
|
1610
|
+
* - it exists and holds every file this pass staged: adopt it and delete the
|
|
1611
|
+
* staging copy (`reused`) — nothing is copied twice;
|
|
1612
|
+
* - it exists but is missing files: fill the gaps from staging
|
|
1613
|
+
* (`restaged_incomplete`), because a half-written pack must not be trusted.
|
|
1614
|
+
*
|
|
1615
|
+
* Never deletes an existing pack directory wholesale: another pack's entries
|
|
1616
|
+
* can point into it through the staged-object index.
|
|
1617
|
+
*/
|
|
1618
|
+
async function promoteStagedPack(options) {
|
|
1619
|
+
const evidenceDir = path.join(options.rawEvidenceRoot, options.packId);
|
|
1620
|
+
const priorPackCount = await countPriorPacks(options.rawEvidenceRoot, options.workContextId, options.packId);
|
|
1621
|
+
const existing = await fs.stat(evidenceDir).catch(() => null);
|
|
1622
|
+
if (!existing?.isDirectory()) {
|
|
1623
|
+
try {
|
|
1624
|
+
await fs.rename(options.stagingDir, evidenceDir);
|
|
1625
|
+
return {
|
|
1626
|
+
state: "new",
|
|
1627
|
+
evidenceDir,
|
|
1628
|
+
priorPackCount,
|
|
1629
|
+
refilledFileCount: 0,
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
catch {
|
|
1633
|
+
// A concurrent sync can win the race to the same content-keyed name.
|
|
1634
|
+
// Losing it is fine: the winner staged the identical bytes.
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
let refilledFileCount = 0;
|
|
1638
|
+
await ensurePrivateDir(path.join(evidenceDir, "files"));
|
|
1639
|
+
for (const entry of options.entries) {
|
|
1640
|
+
if (!entry.staged_in_pack)
|
|
1641
|
+
continue;
|
|
1642
|
+
const fileName = path.basename(entry.local_path);
|
|
1643
|
+
const target = path.join(evidenceDir, "files", fileName);
|
|
1644
|
+
const info = await fs.stat(target).catch(() => null);
|
|
1645
|
+
if (info?.isFile() && info.size === entry.byte_size)
|
|
1646
|
+
continue;
|
|
1647
|
+
await fs.copyFile(entry.local_path, target).catch(() => undefined);
|
|
1648
|
+
await chmodPrivate(target, 0o600);
|
|
1649
|
+
refilledFileCount += 1;
|
|
1650
|
+
}
|
|
1651
|
+
await fs.rm(options.stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1652
|
+
return {
|
|
1653
|
+
state: refilledFileCount > 0 ? "restaged_incomplete" : "reused",
|
|
1654
|
+
evidenceDir,
|
|
1655
|
+
priorPackCount,
|
|
1656
|
+
refilledFileCount,
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
async function countPriorPacks(rawEvidenceRoot, workContextId, packId) {
|
|
1660
|
+
const entries = await fs
|
|
1661
|
+
.readdir(rawEvidenceRoot, { withFileTypes: true })
|
|
1662
|
+
.catch(() => []);
|
|
1663
|
+
return entries.filter((entry) => entry.isDirectory() &&
|
|
1664
|
+
entry.name !== packId &&
|
|
1665
|
+
entry.name.startsWith(`${workContextId}-`)).length;
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Write the manifest, or keep the one already in a reused pack.
|
|
1669
|
+
*
|
|
1670
|
+
* Byte-stability matters here: the manifest's object key is the only key that
|
|
1671
|
+
* embeds the pack id, so rewriting it with a fresh `created_at` every sync
|
|
1672
|
+
* would push different bytes at the same content-addressed key forever. A
|
|
1673
|
+
* reused pack whose manifest already describes exactly these files keeps it.
|
|
1674
|
+
*/
|
|
1675
|
+
async function stageManifest(options) {
|
|
1676
|
+
if (options.reusePack) {
|
|
1677
|
+
const existing = await fs.readFile(options.manifestPath).catch(() => null);
|
|
1678
|
+
if (existing && manifestDescribesEntries(existing, options.entries)) {
|
|
1679
|
+
return existing;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
const manifestBytes = Buffer.from(`${JSON.stringify(makeManifest({
|
|
1683
|
+
context: options.context,
|
|
1684
|
+
packId: options.packId,
|
|
1685
|
+
entries: options.entries,
|
|
1686
|
+
skipped: options.skipped,
|
|
1687
|
+
redacted: options.redacted,
|
|
1688
|
+
reused: options.reused,
|
|
1689
|
+
}), null, 2)}\n`, "utf8");
|
|
1690
|
+
await fs.writeFile(options.manifestPath, manifestBytes, { mode: 0o600 });
|
|
1691
|
+
await chmodPrivate(options.manifestPath, 0o600);
|
|
1692
|
+
return manifestBytes;
|
|
1693
|
+
}
|
|
1694
|
+
function manifestDescribesEntries(manifestBytes, entries) {
|
|
1695
|
+
try {
|
|
1696
|
+
const parsed = JSON.parse(manifestBytes.toString("utf8"));
|
|
1697
|
+
const recorded = (parsed.files ?? [])
|
|
1698
|
+
.map((file) => typeof file.content_hash_sha256 === "string"
|
|
1699
|
+
? file.content_hash_sha256
|
|
1700
|
+
: "")
|
|
1701
|
+
.filter(Boolean)
|
|
1702
|
+
.sort();
|
|
1703
|
+
const expected = entries
|
|
1704
|
+
.map((entry) => entry.content_hash_sha256)
|
|
1705
|
+
.sort();
|
|
1706
|
+
return (recorded.length === expected.length &&
|
|
1707
|
+
recorded.every((hash, index) => hash === expected[index]));
|
|
1708
|
+
}
|
|
1709
|
+
catch {
|
|
1710
|
+
return false;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
async function persistStagingState(stateDir, collection) {
|
|
1714
|
+
collection.staging.updated_at = collection.context.now.toISOString();
|
|
1715
|
+
await writeRawEvidenceStagingState(stateDir, collection.staging).catch((error) => {
|
|
1716
|
+
// Losing this file costs reuse and attempt counts, not evidence, so it
|
|
1717
|
+
// must never fail a collection — but it must never be silent either.
|
|
1718
|
+
console.error("[raw-evidence] staging state write failed", JSON.stringify({
|
|
1719
|
+
reason: "staging_state_write_failed",
|
|
1720
|
+
detail: error instanceof Error ? error.name : typeof error,
|
|
1721
|
+
staged_count: Object.keys(collection.staging.staged).length,
|
|
1722
|
+
}));
|
|
1723
|
+
});
|
|
1427
1724
|
}
|
|
1428
1725
|
function shortHash(value) {
|
|
1429
1726
|
return crypto.createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12);
|
package/dist/commands/local.js
CHANGED
|
@@ -20,7 +20,7 @@ import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } fro
|
|
|
20
20
|
import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limits.js";
|
|
21
21
|
import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
|
|
22
22
|
import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
|
|
23
|
-
import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
|
|
23
|
+
import { rawEvidenceDedupSummary, rawEvidenceGcSummary, runRawEvidenceLocalGc, sweepDuplicateStagedRawEvidence, } from "../raw-evidence-gc.js";
|
|
24
24
|
import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
|
|
25
25
|
import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
|
|
26
26
|
import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
|
|
@@ -1808,7 +1808,16 @@ function rawEvidenceSyncLine(sync) {
|
|
|
1808
1808
|
const retries = sync.raw_evidence_retry_reasons.length > 0
|
|
1809
1809
|
? ` retry_required: ${sync.raw_evidence_retry_reasons.join(",")}`
|
|
1810
1810
|
: "";
|
|
1811
|
-
|
|
1811
|
+
// A held object and a nine-day-old first failure both belong on this line.
|
|
1812
|
+
// Neither used to appear anywhere, which is how a 1,030-attempt loop stayed
|
|
1813
|
+
// invisible (BLI-3066).
|
|
1814
|
+
const held = sync.raw_evidence_delivery_held_count > 0
|
|
1815
|
+
? ` held: ${sync.raw_evidence_delivery_held_count}`
|
|
1816
|
+
: "";
|
|
1817
|
+
const stuck = sync.raw_evidence_stuck_object_count > 0
|
|
1818
|
+
? ` stuck: ${sync.raw_evidence_stuck_object_count} (worst ${sync.raw_evidence_max_delivery_attempts} attempt(s) since ${sync.raw_evidence_oldest_delivery_failure_at ?? "unknown"})`
|
|
1819
|
+
: "";
|
|
1820
|
+
return `Raw evidence: uploaded ${sync.raw_evidence_uploaded_object_count} object(s) in ${sync.raw_evidence_uploaded_chunk_count} chunk(s), reused ${sync.raw_evidence_reused_count}, failed ${sync.raw_evidence_failed_count}${held}${stuck}${failures}${retries}`;
|
|
1812
1821
|
}
|
|
1813
1822
|
function cursorStatusLine(sync) {
|
|
1814
1823
|
return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
|
|
@@ -2488,6 +2497,15 @@ function syncResult(run) {
|
|
|
2488
2497
|
}
|
|
2489
2498
|
async function runSyncLocked(command, io) {
|
|
2490
2499
|
const collectionRoots = await resolveSyncCollectionRoots(command);
|
|
2500
|
+
// Before anything is collected: collapse byte-identical staged packs. It runs
|
|
2501
|
+
// first, unconditionally and unthrottled, because a machine that already
|
|
2502
|
+
// holds 559 copies of one rollout needs the disk back before it stages
|
|
2503
|
+
// anything else (BLI-3066). Safe by construction — a duplicate is identical
|
|
2504
|
+
// by content hash to the survivor.
|
|
2505
|
+
const dedup = await sweepDuplicateStagedRawEvidence(getCollectorRuntimePaths(command.homeDir), io.env);
|
|
2506
|
+
if (!dedup.skipped && dedup.removed_dirs > 0) {
|
|
2507
|
+
writeLine(io.stdout, rawEvidenceDedupSummary(dedup));
|
|
2508
|
+
}
|
|
2491
2509
|
const worktrees = await discoverCommandWorktrees(collectionRoots, {
|
|
2492
2510
|
maxDepth: command.maxDepth,
|
|
2493
2511
|
maxRepos: command.maxRepos,
|
|
@@ -2515,6 +2533,7 @@ async function runSyncLocked(command, io) {
|
|
|
2515
2533
|
repos: rows,
|
|
2516
2534
|
codex_sessions: run.summary,
|
|
2517
2535
|
raw_evidence_gc: gc,
|
|
2536
|
+
raw_evidence_dedup: dedup,
|
|
2518
2537
|
}, null, 2));
|
|
2519
2538
|
return syncResult(run);
|
|
2520
2539
|
}
|
|
@@ -2546,6 +2565,7 @@ async function runSyncLocked(command, io) {
|
|
|
2546
2565
|
collection_complete: run.ok,
|
|
2547
2566
|
codex_sessions: run.summary,
|
|
2548
2567
|
raw_evidence_gc: gc,
|
|
2568
|
+
raw_evidence_dedup: dedup,
|
|
2549
2569
|
}, null, 2));
|
|
2550
2570
|
return syncResult(run);
|
|
2551
2571
|
}
|
|
@@ -2568,6 +2588,7 @@ async function runSyncLocked(command, io) {
|
|
|
2568
2588
|
collection_complete: run.ok,
|
|
2569
2589
|
codex_sessions: run.summary,
|
|
2570
2590
|
raw_evidence_gc: gc,
|
|
2591
|
+
raw_evidence_dedup: dedup,
|
|
2571
2592
|
}, null, 2));
|
|
2572
2593
|
return syncResult(run);
|
|
2573
2594
|
}
|
|
@@ -2801,10 +2822,20 @@ async function runStatus(command, io) {
|
|
|
2801
2822
|
writeLine(io.stdout, `pending_health_receipts: ${status.pending_health_receipt_count}`);
|
|
2802
2823
|
writeLine(io.stdout, `last_health_receipt_failure: ${status.last_health_receipt_failure_reason ?? "none"}`);
|
|
2803
2824
|
writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
|
|
2825
|
+
writeLine(io.stdout, `stuck_evidence: ${stuckEvidenceLine(status)}`);
|
|
2804
2826
|
for (const detail of status.details)
|
|
2805
2827
|
writeLine(io.stdout, `- ${detail}`);
|
|
2806
2828
|
return 0;
|
|
2807
2829
|
}
|
|
2830
|
+
/**
|
|
2831
|
+
* One line that cannot say "fine" while an object has never been accepted.
|
|
2832
|
+
* Named reasons, worst attempt count, and the date it started.
|
|
2833
|
+
*/
|
|
2834
|
+
function stuckEvidenceLine(status) {
|
|
2835
|
+
if (status.stuck_evidence_object_count === 0)
|
|
2836
|
+
return "none";
|
|
2837
|
+
return `${status.stuck_evidence_object_count} object(s), ${status.stuck_evidence_held_count} held, worst ${status.stuck_evidence_max_attempts} attempt(s) since ${status.stuck_evidence_oldest_failure_at ?? "unknown"} (${status.stuck_evidence_reasons.join(",") || "unknown"})`;
|
|
2838
|
+
}
|
|
2808
2839
|
function displayTicketId(ticketId) {
|
|
2809
2840
|
return ticketId ?? "general ambient";
|
|
2810
2841
|
}
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.28");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
package/dist/local-state.js
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOr
|
|
|
8
8
|
import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
|
|
9
9
|
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
10
10
|
import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
|
|
11
|
+
import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
|
|
11
12
|
const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
12
13
|
export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
|
|
13
14
|
? localCollectorPackage.version
|
|
@@ -340,10 +341,12 @@ export async function inspectLocalCollectorStatus(options = {}) {
|
|
|
340
341
|
const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
|
|
341
342
|
const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
|
|
342
343
|
const branch = options.branch ?? identity.branch;
|
|
343
|
-
const [uploadSpool, healthOutbox] = await Promise.all([
|
|
344
|
+
const [uploadSpool, healthOutbox, stagingState] = await Promise.all([
|
|
344
345
|
summarizeLocalUploadSpool(paths),
|
|
345
346
|
summarizeInstallEventOutbox(paths),
|
|
347
|
+
readRawEvidenceStagingState(paths.state_dir),
|
|
346
348
|
]);
|
|
349
|
+
const stuckEvidence = summarizeStuckEvidence(stagingState, now);
|
|
347
350
|
const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
|
|
348
351
|
const uploadState = !config
|
|
349
352
|
? "not_installed"
|
|
@@ -377,6 +380,9 @@ export async function inspectLocalCollectorStatus(options = {}) {
|
|
|
377
380
|
if (healthOutbox.pending_count > 0) {
|
|
378
381
|
details.push(`Collector health retry pending: ${healthOutbox.pending_count} sanitized receipt(s) queued since ${healthOutbox.oldest_created_at ?? "unknown"}.`);
|
|
379
382
|
}
|
|
383
|
+
if (stuckEvidence.stuck_object_count > 0) {
|
|
384
|
+
details.push(`Raw evidence stuck: ${stuckEvidence.stuck_object_count} object(s) have never been accepted (${stuckEvidence.held_object_count} waiting on backoff, worst ${stuckEvidence.max_attempts} attempt(s) since ${stuckEvidence.oldest_first_failed_at ?? "unknown"}) — reasons: ${stuckEvidence.reasons.join(", ") || "unknown"}.`);
|
|
385
|
+
}
|
|
380
386
|
return {
|
|
381
387
|
installed: Boolean(config),
|
|
382
388
|
config_file: paths.config_file,
|
|
@@ -405,6 +411,11 @@ export async function inspectLocalCollectorStatus(options = {}) {
|
|
|
405
411
|
pending_health_receipt_count: healthOutbox.pending_count,
|
|
406
412
|
oldest_pending_health_receipt_at: healthOutbox.oldest_created_at,
|
|
407
413
|
last_health_receipt_failure_reason: healthOutbox.last_failure_reason,
|
|
414
|
+
stuck_evidence_object_count: stuckEvidence.stuck_object_count,
|
|
415
|
+
stuck_evidence_held_count: stuckEvidence.held_object_count,
|
|
416
|
+
stuck_evidence_max_attempts: stuckEvidence.max_attempts,
|
|
417
|
+
stuck_evidence_oldest_failure_at: stuckEvidence.oldest_first_failed_at,
|
|
418
|
+
stuck_evidence_reasons: stuckEvidence.reasons,
|
|
408
419
|
details,
|
|
409
420
|
};
|
|
410
421
|
}
|