@bli-cockpit/cli 0.2.26 → 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/autostart.js +99 -13
- 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/autostart.js
CHANGED
|
@@ -226,6 +226,22 @@ async function installWindowsTask(options) {
|
|
|
226
226
|
"-File",
|
|
227
227
|
registrationPath,
|
|
228
228
|
]);
|
|
229
|
+
// BLI-2996: before this, a nonzero exit here (schtasks rejected the
|
|
230
|
+
// registration script, or Set-ScheduledTask threw) skipped straight to the
|
|
231
|
+
// return with `verified: null` and never logged anything — the doctor row
|
|
232
|
+
// said "needs repair" every run with no way to tell "the rewrite itself
|
|
233
|
+
// never ran" apart from "it ran and Windows still disagrees" (BLI-2996,
|
|
234
|
+
// Brandon's machine: same silent non-convergence under two different
|
|
235
|
+
// validator messages across CLI versions). Metadata only: exit code and
|
|
236
|
+
// whether the process produced any output, never the stderr/stdout text
|
|
237
|
+
// (it can carry the state directory or a workspace path).
|
|
238
|
+
if (created.code !== 0) {
|
|
239
|
+
console.error("[autostart] windows task repair rewrite failed", JSON.stringify({
|
|
240
|
+
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
241
|
+
exit_code: created.code,
|
|
242
|
+
had_output: Boolean(created.stderr.trim() || created.stdout.trim()),
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
229
245
|
const verified = created.code === 0
|
|
230
246
|
? await windowsTaskStatus({
|
|
231
247
|
...options,
|
|
@@ -238,6 +254,14 @@ async function installWindowsTask(options) {
|
|
|
238
254
|
})
|
|
239
255
|
: null;
|
|
240
256
|
const loaded = verified?.status === "loaded";
|
|
257
|
+
// windowsTaskStatus() above already logs the needs-repair case (with the
|
|
258
|
+
// problem list) when the rewrite ran but the read-back still disagrees; log
|
|
259
|
+
// the converged branch here too, tagged as a repair outcome and by name, so
|
|
260
|
+
// "did the rewrite actually fix it this run" never depends on inferring it
|
|
261
|
+
// from a routine status log emitted for an unrelated reason.
|
|
262
|
+
if (created.code === 0 && loaded) {
|
|
263
|
+
console.error("[autostart] windows task repair converged", JSON.stringify({ task_name: WINDOWS_AUTOSTART_TASK_NAME }));
|
|
264
|
+
}
|
|
241
265
|
return {
|
|
242
266
|
status: "installed",
|
|
243
267
|
label: AUTOSTART_LABEL,
|
|
@@ -549,10 +573,17 @@ function windowsTaskRegistrationProblems(taskXml, expected) {
|
|
|
549
573
|
.replace(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu, "")
|
|
550
574
|
.trim();
|
|
551
575
|
const exactExecBlock = execBlocks.length === 1 && actionRemainder === "" ? execBlocks[0] : "";
|
|
552
|
-
|
|
576
|
+
// Strip a wrapping quote pair defensively: every real capture we have shows
|
|
577
|
+
// Task Scheduler storing <Command> unquoted, but the writer builds the /TR
|
|
578
|
+
// executable segment with the same \"-escaped quoting it uses for the
|
|
579
|
+
// launcher argument (BLI-2598), and there is no verified capture proving
|
|
580
|
+
// schtasks' own TR-splitting heuristic always discards those quotes rather
|
|
581
|
+
// than leaving them as literal text (BLI-2996 canary-gated gap). A stray
|
|
582
|
+
// pair of quotes should never be the reason a correctly-registered task
|
|
583
|
+
// reads as broken.
|
|
584
|
+
const command = stripSurroundingQuotes(exactExecBlock
|
|
553
585
|
?.match(/<Command>\s*([^<]*?)\s*<\/Command>/iu)?.[1]
|
|
554
|
-
?.trim() ??
|
|
555
|
-
"";
|
|
586
|
+
?.trim() ?? "");
|
|
556
587
|
const actionArguments = exactExecBlock
|
|
557
588
|
.match(/<Arguments>\s*([^<]*?)\s*<\/Arguments>/iu)?.[1]
|
|
558
589
|
?.trim() ?? "";
|
|
@@ -654,16 +685,35 @@ function windowsWScriptPath(env = process.env) {
|
|
|
654
685
|
* DESKTOP-G2UO1GK (CLI 0.2.13, 2026-08-12), where the stored value differed
|
|
655
686
|
* from the expected one only by those quotes (BLI-2541).
|
|
656
687
|
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
688
|
+
* BLI-2996: that lesson was only ever applied to the pre-BLI-2677 `-File`
|
|
689
|
+
* action. The wscript launcher pair (`//B "<launcher>"`) has NO verified
|
|
690
|
+
* real-Windows capture — `windowlessWindowsTaskXml()` in autostart.test.ts is
|
|
691
|
+
* SYNTHETIC, built by hand-editing the one real capture we have rather than
|
|
692
|
+
* exported from a machine actually running this action. A repair that rewrites
|
|
693
|
+
* the task correctly but is judged against a guessed shape can fail forever
|
|
694
|
+
* without ever being wrong (Brandon's machine: same non-convergence under two
|
|
695
|
+
* different validator messages across two CLI versions, neither of which ever
|
|
696
|
+
* repaired anything). So this comparison is deliberately structural, not
|
|
697
|
+
* positional: tokenize the way CommandLineToArgvW groups quoted/unquoted
|
|
698
|
+
* arguments, then check flag-token-then-path-token semantically (a real path
|
|
699
|
+
* comparison, case- and quote-insensitive) instead of demanding the exact
|
|
700
|
+
* byte layout we happened to send. Whatever the wscript action's real
|
|
701
|
+
* normalization turns out to be once a canary captures it, this only fails on
|
|
702
|
+
* an actual different flag or different script — not on Windows' own
|
|
703
|
+
* reformatting.
|
|
661
704
|
*/
|
|
662
705
|
function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLauncherPath) {
|
|
663
|
-
|
|
706
|
+
const tokens = splitWindowsArgumentTokens(actionArguments);
|
|
707
|
+
if (tokens.length === 0) {
|
|
708
|
+
return "task action names no sync launcher to run";
|
|
709
|
+
}
|
|
710
|
+
if ((tokens[0] ?? "").toUpperCase() !== expectedFlags.toUpperCase()) {
|
|
664
711
|
return "task action does not run the launcher with the expected batch-mode flag";
|
|
665
712
|
}
|
|
666
|
-
|
|
713
|
+
if (tokens.length > 2) {
|
|
714
|
+
return "task action passes unexpected extra arguments to the sync launcher";
|
|
715
|
+
}
|
|
716
|
+
const launcherArgument = tokens[1];
|
|
667
717
|
if (!launcherArgument) {
|
|
668
718
|
return "task action names no sync launcher to run";
|
|
669
719
|
}
|
|
@@ -672,10 +722,46 @@ function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLa
|
|
|
672
722
|
}
|
|
673
723
|
return null;
|
|
674
724
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
725
|
+
/**
|
|
726
|
+
* Quote-aware whitespace tokenizer for an already-XML-decoded `<Arguments>`
|
|
727
|
+
* value: a `"..."` run is one token (quotes stripped, whitespace inside kept),
|
|
728
|
+
* everything else splits on whitespace. Windows paths never contain a quote
|
|
729
|
+
* character, so this does not need CommandLineToArgvW's backslash-escaping
|
|
730
|
+
* rules — only its quoting rule — to tell "one argument with a space in it"
|
|
731
|
+
* apart from "two arguments".
|
|
732
|
+
*/
|
|
733
|
+
function splitWindowsArgumentTokens(value) {
|
|
734
|
+
const tokens = [];
|
|
735
|
+
let current = "";
|
|
736
|
+
let inQuotes = false;
|
|
737
|
+
let hasToken = false;
|
|
738
|
+
for (const char of value) {
|
|
739
|
+
if (char === '"') {
|
|
740
|
+
inQuotes = !inQuotes;
|
|
741
|
+
hasToken = true;
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (!inQuotes && /\s/u.test(char)) {
|
|
745
|
+
if (hasToken) {
|
|
746
|
+
tokens.push(current);
|
|
747
|
+
current = "";
|
|
748
|
+
hasToken = false;
|
|
749
|
+
}
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
current += char;
|
|
753
|
+
hasToken = true;
|
|
754
|
+
}
|
|
755
|
+
if (hasToken)
|
|
756
|
+
tokens.push(current);
|
|
757
|
+
return tokens;
|
|
758
|
+
}
|
|
759
|
+
/** Removes one wrapping pair of double quotes, if present. Used only to
|
|
760
|
+
* normalize before a path comparison — never to reconstruct a shell-safe
|
|
761
|
+
* value. */
|
|
762
|
+
function stripSurroundingQuotes(value) {
|
|
763
|
+
const match = value.match(/^"([\s\S]*)"$/u);
|
|
764
|
+
return match ? match[1] : value;
|
|
679
765
|
}
|
|
680
766
|
function sameWindowsPath(left, right) {
|
|
681
767
|
if (!left || !right)
|