@bli-cockpit/cli 0.1.3 → 0.1.5
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/README.md +35 -7
- package/dist/adapters/codex-attribution.js +344 -0
- package/dist/adapters/local-sources.js +3 -0
- package/dist/adapters/raw-evidence.js +97 -17
- package/dist/commands/local.js +388 -15
- package/dist/cursors/raw-evidence-cursor.js +129 -0
- package/dist/evidence-upload-client.js +362 -0
- package/dist/local-state.js +93 -9
- package/dist/repo-identity.js +203 -0
- package/dist/server.js +7 -3
- package/dist/upload.js +266 -89
- package/package.json +2 -2
package/dist/upload.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { TelemetryIngestEnvelopeSchema, TelemetryIngestEventDtoSchema, } from "@bli-cockpit/telemetry-core";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
|
-
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference,
|
|
3
|
+
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
|
|
5
4
|
import { runLocalSourceCollectors } from "./adapters/local-sources.js";
|
|
5
|
+
import { uploadRawEvidenceFilesChunked, } from "./evidence-upload-client.js";
|
|
6
|
+
import { markObjectCommitted, readRawEvidenceCursor, writeRawEvidenceCursor, } from "./cursors/raw-evidence-cursor.js";
|
|
6
7
|
import { recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
|
|
7
8
|
export class LocalUploadBlockedError extends Error {
|
|
8
9
|
blocker;
|
|
@@ -29,17 +30,22 @@ export async function buildLocalAmbientEnvelope(options = {}) {
|
|
|
29
30
|
? "Collector session expired. Run `cockpit login` or `cockpit pair` again before `cockpit sync`."
|
|
30
31
|
: "Collector is not paired. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
|
|
31
32
|
}
|
|
32
|
-
const
|
|
33
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
34
|
+
const activeContext = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => {
|
|
33
35
|
throw new LocalUploadBlockedError("missing_context", "Active work context missing. Run `cockpit start --repo \"$PWD\"` before `cockpit sync`.", "cockpit start --repo \"$PWD\"");
|
|
34
36
|
});
|
|
35
|
-
const
|
|
36
|
-
const repoLabel = safeRepoLabel(repoRoot);
|
|
37
|
+
const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
|
|
37
38
|
const uploadContext = makeUploadWorkContext({
|
|
38
39
|
activeContext,
|
|
39
40
|
session,
|
|
40
41
|
repoLabel,
|
|
41
42
|
now,
|
|
42
43
|
});
|
|
44
|
+
const contextNamespacePrefix = `${session.operator_id}/${uploadContext.work_context_id}/`;
|
|
45
|
+
const skipContentHashes = options.skipContentHashes ??
|
|
46
|
+
new Set(Object.entries(options.cursorObjects ?? {})
|
|
47
|
+
.filter(([, entry]) => entry.object_key.startsWith(contextNamespacePrefix))
|
|
48
|
+
.map(([hash]) => hash));
|
|
43
49
|
const sourceCollection = await runLocalSourceCollectors({
|
|
44
50
|
repoRoot,
|
|
45
51
|
branch: uploadContext.branch,
|
|
@@ -49,6 +55,9 @@ export async function buildLocalAmbientEnvelope(options = {}) {
|
|
|
49
55
|
activeWorkContext: activeContext,
|
|
50
56
|
rawEvidenceStateDir: paths.state_dir,
|
|
51
57
|
rawEvidenceSessionsDir: path.join(paths.home_dir, ".codex", "sessions"),
|
|
58
|
+
rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
|
|
59
|
+
rawEvidenceCodexSessionFiles: options.codexSessionFiles,
|
|
60
|
+
rawEvidenceSkipContentHashes: skipContentHashes,
|
|
52
61
|
now,
|
|
53
62
|
});
|
|
54
63
|
const binding = sourceCollection.binding;
|
|
@@ -93,15 +102,21 @@ export async function buildLocalAmbientEnvelope(options = {}) {
|
|
|
93
102
|
source_scan_count: envelope.source_scan_results.length,
|
|
94
103
|
risk_flag_count: safeRiskFlags.length,
|
|
95
104
|
repo_label: repoLabel,
|
|
105
|
+
head_sha: uploadContext.head_sha ?? null,
|
|
96
106
|
raw_evidence_upload_files: sourceCollection.facts.raw_evidence?.upload_files ?? [],
|
|
107
|
+
raw_evidence_facts: sourceCollection.facts.raw_evidence,
|
|
97
108
|
};
|
|
98
109
|
}
|
|
99
110
|
export async function syncLocalAmbientEnvelope(options = {}) {
|
|
100
111
|
const attemptedAt = (options.now ?? new Date()).toISOString();
|
|
101
112
|
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
113
|
+
const cursor = await readRawEvidenceCursor(paths);
|
|
102
114
|
let built;
|
|
103
115
|
try {
|
|
104
|
-
built = await buildLocalAmbientEnvelope(
|
|
116
|
+
built = await buildLocalAmbientEnvelope({
|
|
117
|
+
...options,
|
|
118
|
+
cursorObjects: options.cursorObjects ?? cursor.objects,
|
|
119
|
+
});
|
|
105
120
|
}
|
|
106
121
|
catch (error) {
|
|
107
122
|
if (error instanceof LocalUploadBlockedError) {
|
|
@@ -116,49 +131,84 @@ export async function syncLocalAmbientEnvelope(options = {}) {
|
|
|
116
131
|
if (!fetchImpl) {
|
|
117
132
|
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
118
133
|
}
|
|
119
|
-
|
|
134
|
+
const provenance = built.envelope.work_context.provenance;
|
|
135
|
+
if (built.raw_evidence_upload_files.length > 0 && !provenance) {
|
|
136
|
+
throw new Error("Raw evidence upload requires collector provenance.");
|
|
137
|
+
}
|
|
138
|
+
let uploadOutcomes = [];
|
|
139
|
+
let uploadedChunkCount = 0;
|
|
140
|
+
let ingestAccepted = false;
|
|
120
141
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
142
|
+
if (built.raw_evidence_upload_files.length > 0 && provenance) {
|
|
143
|
+
const upload = await uploadRawEvidenceFilesChunked({
|
|
144
|
+
fetchImpl,
|
|
145
|
+
dashboardUrl: built.dashboard_url,
|
|
146
|
+
deviceToken: built.device_token,
|
|
147
|
+
provenance,
|
|
148
|
+
generatedAt: built.envelope.generated_at,
|
|
149
|
+
files: built.raw_evidence_upload_files,
|
|
150
|
+
});
|
|
151
|
+
uploadOutcomes = upload.outcomes;
|
|
152
|
+
uploadedChunkCount = upload.uploaded_chunk_count;
|
|
153
|
+
}
|
|
154
|
+
const envelope = pruneUndurablePointers(built.envelope, uploadOutcomes);
|
|
128
155
|
const response = await fetchImpl(`${built.dashboard_url}/api/ambient/ingest`, {
|
|
129
156
|
method: "POST",
|
|
130
157
|
headers: {
|
|
131
158
|
"Authorization": `Bearer ${built.device_token}`,
|
|
132
159
|
"Content-Type": "application/json",
|
|
133
160
|
},
|
|
134
|
-
body: JSON.stringify(
|
|
161
|
+
body: JSON.stringify(envelope),
|
|
135
162
|
});
|
|
136
163
|
const responseBody = await readResponseJson(response);
|
|
137
164
|
if (!response.ok) {
|
|
138
165
|
throw new Error(responseErrorMessage(responseBody, `Ambient ingest failed with HTTP ${response.status}`));
|
|
139
166
|
}
|
|
167
|
+
ingestAccepted = true;
|
|
168
|
+
for (const outcome of uploadOutcomes) {
|
|
169
|
+
if (outcome.upload_state === "upload_failed")
|
|
170
|
+
continue;
|
|
171
|
+
const contentHash = outcome.pointer.content_hash_sha256;
|
|
172
|
+
if (!contentHash)
|
|
173
|
+
continue;
|
|
174
|
+
markObjectCommitted(cursor, contentHash, {
|
|
175
|
+
object_key: outcome.object_key,
|
|
176
|
+
byte_size: outcome.pointer.byte_size ?? 0,
|
|
177
|
+
committed_at: attemptedAt,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
cursor.updated_at = attemptedAt;
|
|
181
|
+
await writeRawEvidenceCursor(paths, cursor);
|
|
140
182
|
await recordUploadSuccess(paths, { attemptedAt });
|
|
141
183
|
return {
|
|
142
184
|
status: "uploaded",
|
|
143
185
|
dashboard_url: built.dashboard_url,
|
|
144
186
|
ticket_id: built.ticket_id,
|
|
145
187
|
work_context_id: built.envelope.work_context.work_context_id,
|
|
188
|
+
head_sha: built.head_sha,
|
|
146
189
|
event_count: built.event_count,
|
|
147
190
|
source_scan_count: built.source_scan_count,
|
|
148
191
|
risk_flag_count: built.risk_flag_count,
|
|
149
|
-
raw_evidence_file_count: built.raw_evidence_upload_files.length,
|
|
150
192
|
http_status: response.status,
|
|
193
|
+
...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor),
|
|
151
194
|
};
|
|
152
195
|
}
|
|
153
196
|
catch (error) {
|
|
154
197
|
const failureReason = error instanceof Error ? error.message : String(error);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
198
|
+
// Once ingest accepted the envelope, the evidence refs are indexed and the
|
|
199
|
+
// objects are load-bearing — a later local write failure (cursor, spool)
|
|
200
|
+
// must not delete durable evidence.
|
|
201
|
+
const cleanupFailureReason = ingestAccepted
|
|
202
|
+
? null
|
|
203
|
+
: await cleanupUploadedRawEvidenceObjects({
|
|
204
|
+
fetchImpl,
|
|
205
|
+
dashboardUrl: built.dashboard_url,
|
|
206
|
+
deviceToken: built.device_token,
|
|
207
|
+
envelope: built.envelope,
|
|
208
|
+
objectKeys: uploadOutcomes
|
|
209
|
+
.filter((outcome) => outcome.upload_state === "uploaded")
|
|
210
|
+
.map((outcome) => outcome.object_key),
|
|
211
|
+
});
|
|
162
212
|
const spooledFailureReason = cleanupFailureReason
|
|
163
213
|
? `${failureReason}; raw evidence cleanup failed: ${cleanupFailureReason}`
|
|
164
214
|
: failureReason;
|
|
@@ -181,16 +231,163 @@ export async function syncLocalAmbientEnvelope(options = {}) {
|
|
|
181
231
|
dashboard_url: built.dashboard_url,
|
|
182
232
|
ticket_id: built.ticket_id,
|
|
183
233
|
work_context_id: built.envelope.work_context.work_context_id,
|
|
234
|
+
head_sha: built.head_sha,
|
|
184
235
|
event_count: built.event_count,
|
|
185
236
|
source_scan_count: built.source_scan_count,
|
|
186
237
|
risk_flag_count: built.risk_flag_count,
|
|
187
|
-
raw_evidence_file_count: built.raw_evidence_upload_files.length,
|
|
188
238
|
failure_reason: spooledFailureReason,
|
|
189
239
|
spool_entry_id: entry.spool_id,
|
|
190
240
|
retry_command: entry.retry_command,
|
|
241
|
+
...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor),
|
|
191
242
|
};
|
|
192
243
|
}
|
|
193
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Posts a Codex session attribution report using the paired collector
|
|
247
|
+
* credentials and the work context of a representative repo. Failures come
|
|
248
|
+
* back as reason labels so the harvest never hard-fails on reporting.
|
|
249
|
+
*/
|
|
250
|
+
export async function postCodexSessionReport(options) {
|
|
251
|
+
if (options.sessions.length === 0) {
|
|
252
|
+
return { posted: false, reason: "no_sessions_observed" };
|
|
253
|
+
}
|
|
254
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
255
|
+
if (!fetchImpl) {
|
|
256
|
+
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
260
|
+
const config = await readLocalCollectorConfig(paths);
|
|
261
|
+
const sessionFile = await readLocalCollectorSessionFile(paths);
|
|
262
|
+
const session = await readLocalSessionReference(paths);
|
|
263
|
+
if (session.session_state !== "valid") {
|
|
264
|
+
return { posted: false, reason: "collector_not_paired" };
|
|
265
|
+
}
|
|
266
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
267
|
+
const activeContext = await readLocalWorkContextForRepo(paths, repoRoot);
|
|
268
|
+
const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
|
|
269
|
+
const provenance = makeCollectorProvenance({
|
|
270
|
+
context: makeUploadWorkContext({
|
|
271
|
+
activeContext,
|
|
272
|
+
session,
|
|
273
|
+
repoLabel,
|
|
274
|
+
now: options.now ?? new Date(),
|
|
275
|
+
}),
|
|
276
|
+
session,
|
|
277
|
+
repoLabel,
|
|
278
|
+
});
|
|
279
|
+
return await reportCodexSessionAttributions({
|
|
280
|
+
fetchImpl,
|
|
281
|
+
dashboardUrl: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
|
|
282
|
+
deviceToken: sessionFile.device_token,
|
|
283
|
+
provenance,
|
|
284
|
+
generatedAt: (options.now ?? new Date()).toISOString(),
|
|
285
|
+
sessions: options.sessions,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return { posted: false, reason: "collector_not_ready" };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Reports Codex session attribution outcomes after sync. Non-fatal by design:
|
|
294
|
+
* older dashboards without the endpoint must not fail the harvest, so the
|
|
295
|
+
* caller receives a posted/skipped label instead of an exception.
|
|
296
|
+
*/
|
|
297
|
+
export async function reportCodexSessionAttributions(options) {
|
|
298
|
+
if (options.sessions.length === 0) {
|
|
299
|
+
return { posted: false, reason: "no_sessions_observed" };
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/codex-sessions`, {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: {
|
|
305
|
+
"Authorization": `Bearer ${options.deviceToken}`,
|
|
306
|
+
"Content-Type": "application/json",
|
|
307
|
+
},
|
|
308
|
+
body: JSON.stringify({
|
|
309
|
+
schema_version: "ambient-codex-session-attributions.v1",
|
|
310
|
+
generated_at: options.generatedAt,
|
|
311
|
+
provenance: options.provenance,
|
|
312
|
+
sessions: options.sessions,
|
|
313
|
+
}),
|
|
314
|
+
});
|
|
315
|
+
if (response.status === 404) {
|
|
316
|
+
return { posted: false, reason: "codex_session_api_unavailable" };
|
|
317
|
+
}
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
return { posted: false, reason: `report_failed_http_${response.status}` };
|
|
320
|
+
}
|
|
321
|
+
return { posted: true, reason: "recorded" };
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return { posted: false, reason: "report_network_error" };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
|
|
328
|
+
const cursorReused = built.raw_evidence_facts?.reused ?? [];
|
|
329
|
+
const serverReusedCount = outcomes.filter((outcome) => outcome.upload_state === "reused_existing").length;
|
|
330
|
+
const failed = outcomes.filter((outcome) => outcome.upload_state === "upload_failed");
|
|
331
|
+
const cursorReusedOutcomes = cursorReused.map((entry) => ({
|
|
332
|
+
object_key: cursor.objects[entry.content_hash_sha256]?.object_key ?? "",
|
|
333
|
+
raw_evidence_pointer_id: cursor.objects[entry.content_hash_sha256]?.object_key ?? "",
|
|
334
|
+
kind: entry.kind,
|
|
335
|
+
codex_session_id: entry.codex_session_id,
|
|
336
|
+
upload_state: "reused_existing",
|
|
337
|
+
reason: "cursor_content_match",
|
|
338
|
+
}));
|
|
339
|
+
return {
|
|
340
|
+
raw_evidence_file_count: built.raw_evidence_upload_files.length,
|
|
341
|
+
raw_evidence_uploaded_object_count: outcomes.filter((outcome) => outcome.upload_state === "uploaded").length,
|
|
342
|
+
raw_evidence_uploaded_chunk_count: uploadedChunkCount,
|
|
343
|
+
raw_evidence_reused_count: cursorReused.length + serverReusedCount,
|
|
344
|
+
raw_evidence_failed_count: failed.length,
|
|
345
|
+
raw_evidence_failure_reasons: [
|
|
346
|
+
...new Set(failed.map((outcome) => outcome.reason ?? "unknown")),
|
|
347
|
+
],
|
|
348
|
+
raw_evidence_outcomes: [
|
|
349
|
+
...outcomes.map((outcome) => ({
|
|
350
|
+
object_key: outcome.object_key,
|
|
351
|
+
raw_evidence_pointer_id: outcome.pointer.raw_evidence_pointer_id,
|
|
352
|
+
kind: outcome.kind,
|
|
353
|
+
codex_session_id: outcome.codex_session_id,
|
|
354
|
+
upload_state: outcome.upload_state,
|
|
355
|
+
reason: outcome.reason,
|
|
356
|
+
})),
|
|
357
|
+
...cursorReusedOutcomes,
|
|
358
|
+
],
|
|
359
|
+
cursor_tracked_object_count: Object.keys(cursor.objects).length,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Ingest refuses pointers whose objects never became durable, so failed
|
|
364
|
+
* uploads are pruned from the envelope instead of failing the whole sync;
|
|
365
|
+
* the failures stay visible via reason labels and the server-side ledger.
|
|
366
|
+
*/
|
|
367
|
+
function pruneUndurablePointers(envelope, outcomes) {
|
|
368
|
+
// Content-addressed keys mean one pointer id can carry several outcomes
|
|
369
|
+
// (byte-identical files); the pointer is durable if ANY outcome succeeded.
|
|
370
|
+
const durablePointerIds = new Set(outcomes
|
|
371
|
+
.filter((outcome) => outcome.upload_state !== "upload_failed")
|
|
372
|
+
.map((outcome) => outcome.pointer.raw_evidence_pointer_id));
|
|
373
|
+
const failedPointerIds = new Set(outcomes
|
|
374
|
+
.filter((outcome) => outcome.upload_state === "upload_failed" &&
|
|
375
|
+
!durablePointerIds.has(outcome.pointer.raw_evidence_pointer_id))
|
|
376
|
+
.map((outcome) => outcome.pointer.raw_evidence_pointer_id));
|
|
377
|
+
if (failedPointerIds.size === 0)
|
|
378
|
+
return envelope;
|
|
379
|
+
return {
|
|
380
|
+
...envelope,
|
|
381
|
+
events: envelope.events.map((event) => ({
|
|
382
|
+
...event,
|
|
383
|
+
raw_evidence_pointers: event.raw_evidence_pointers.filter((pointer) => !failedPointerIds.has(pointer.raw_evidence_pointer_id)),
|
|
384
|
+
redaction: {
|
|
385
|
+
...event.redaction,
|
|
386
|
+
raw_evidence_pointer_ids: event.redaction.raw_evidence_pointer_ids.filter((pointerId) => !failedPointerIds.has(pointerId)),
|
|
387
|
+
},
|
|
388
|
+
})),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
194
391
|
function makeUploadWorkContext(options) {
|
|
195
392
|
const provenance = makeCollectorProvenance({
|
|
196
393
|
context: options.activeContext,
|
|
@@ -200,6 +397,13 @@ function makeUploadWorkContext(options) {
|
|
|
200
397
|
return {
|
|
201
398
|
...options.activeContext,
|
|
202
399
|
repo: options.repoLabel,
|
|
400
|
+
repo_label: options.activeContext.repo_label ?? options.repoLabel,
|
|
401
|
+
repo_fingerprint: options.activeContext.repo_fingerprint,
|
|
402
|
+
repo_origin_url: options.activeContext.repo_origin_url,
|
|
403
|
+
head_sha: options.activeContext.head_sha,
|
|
404
|
+
worktree_label: options.activeContext.worktree_label,
|
|
405
|
+
worktree_fingerprint: options.activeContext.worktree_fingerprint,
|
|
406
|
+
worktree_is_primary: options.activeContext.worktree_is_primary,
|
|
203
407
|
operator_id: options.session.operator_id,
|
|
204
408
|
session_id: options.session.session_id,
|
|
205
409
|
updated_at: options.now.toISOString(),
|
|
@@ -252,9 +456,14 @@ function makeSourceScanCompletedEvent(options) {
|
|
|
252
456
|
raw_evidence_file_count: options.rawEvidenceFacts?.file_count ?? 0,
|
|
253
457
|
raw_evidence_byte_size: options.rawEvidenceFacts?.byte_size ?? 0,
|
|
254
458
|
raw_evidence_skipped_count: options.rawEvidenceFacts?.skipped_count ?? 0,
|
|
459
|
+
raw_evidence_reused_count: options.rawEvidenceFacts?.reused_count ?? 0,
|
|
255
460
|
},
|
|
256
461
|
attributes: {
|
|
257
462
|
repo_label: options.context.repo,
|
|
463
|
+
repo_fingerprint: options.context.repo_fingerprint ?? "unknown",
|
|
464
|
+
worktree_label: options.context.worktree_label ?? "unknown",
|
|
465
|
+
worktree_fingerprint: options.context.worktree_fingerprint ?? "unknown",
|
|
466
|
+
worktree_is_primary: options.context.worktree_is_primary ?? false,
|
|
258
467
|
branch: options.context.branch,
|
|
259
468
|
ticket_binding_state: options.binding.state,
|
|
260
469
|
ticket_binding_source: options.binding.selected_source ?? "none",
|
|
@@ -271,78 +480,40 @@ function makeSourceScanCompletedEvent(options) {
|
|
|
271
480
|
raw_evidence_pointers: rawEvidencePointers,
|
|
272
481
|
});
|
|
273
482
|
}
|
|
274
|
-
|
|
275
|
-
if (options.files.length === 0)
|
|
276
|
-
return [];
|
|
277
|
-
const provenance = options.envelope.work_context.provenance;
|
|
278
|
-
if (!provenance) {
|
|
279
|
-
throw new Error("Raw evidence upload requires collector provenance.");
|
|
280
|
-
}
|
|
281
|
-
const files = await Promise.all(options.files.map(async (file) => ({
|
|
282
|
-
pointer: file.pointer,
|
|
283
|
-
content_base64: (await fs.readFile(file.local_path)).toString("base64"),
|
|
284
|
-
})));
|
|
285
|
-
const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/upload`, {
|
|
286
|
-
method: "POST",
|
|
287
|
-
headers: {
|
|
288
|
-
"Authorization": `Bearer ${options.deviceToken}`,
|
|
289
|
-
"Content-Type": "application/json",
|
|
290
|
-
},
|
|
291
|
-
body: JSON.stringify({
|
|
292
|
-
schema_version: "ambient-raw-evidence-upload.v1",
|
|
293
|
-
generated_at: options.envelope.generated_at,
|
|
294
|
-
provenance,
|
|
295
|
-
files,
|
|
296
|
-
}),
|
|
297
|
-
});
|
|
298
|
-
const responseBody = await readResponseJson(response);
|
|
299
|
-
if (!response.ok) {
|
|
300
|
-
throw new Error(responseErrorMessage(responseBody, `Raw evidence upload failed with HTTP ${response.status}`));
|
|
301
|
-
}
|
|
302
|
-
return readUploadedRawEvidenceObjects(responseBody);
|
|
303
|
-
}
|
|
483
|
+
const CLEANUP_BATCH_SIZE = 25;
|
|
304
484
|
async function cleanupUploadedRawEvidenceObjects(options) {
|
|
305
|
-
if (options.
|
|
485
|
+
if (options.objectKeys.length === 0)
|
|
306
486
|
return null;
|
|
307
487
|
const provenance = options.envelope.work_context.provenance;
|
|
308
488
|
if (!provenance)
|
|
309
489
|
return "missing collector provenance";
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
490
|
+
const failures = [];
|
|
491
|
+
for (let offset = 0; offset < options.objectKeys.length; offset += CLEANUP_BATCH_SIZE) {
|
|
492
|
+
const batch = options.objectKeys.slice(offset, offset + CLEANUP_BATCH_SIZE);
|
|
493
|
+
try {
|
|
494
|
+
const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/upload`, {
|
|
495
|
+
method: "DELETE",
|
|
496
|
+
headers: {
|
|
497
|
+
"Authorization": `Bearer ${options.deviceToken}`,
|
|
498
|
+
"Content-Type": "application/json",
|
|
499
|
+
},
|
|
500
|
+
body: JSON.stringify({
|
|
501
|
+
schema_version: "ambient-raw-evidence-cleanup.v1",
|
|
502
|
+
generated_at: options.envelope.generated_at,
|
|
503
|
+
provenance,
|
|
504
|
+
object_keys: batch,
|
|
505
|
+
}),
|
|
506
|
+
});
|
|
507
|
+
const responseBody = await readResponseJson(response);
|
|
508
|
+
if (!response.ok) {
|
|
509
|
+
failures.push(responseErrorMessage(responseBody, `Raw evidence cleanup failed with HTTP ${response.status}`));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
327
514
|
}
|
|
328
|
-
return null;
|
|
329
|
-
}
|
|
330
|
-
catch (error) {
|
|
331
|
-
return error instanceof Error ? error.message : String(error);
|
|
332
515
|
}
|
|
333
|
-
|
|
334
|
-
function readUploadedRawEvidenceObjects(value) {
|
|
335
|
-
if (!value || typeof value !== "object")
|
|
336
|
-
return [];
|
|
337
|
-
const uploaded = value.uploaded;
|
|
338
|
-
if (!Array.isArray(uploaded))
|
|
339
|
-
return [];
|
|
340
|
-
return uploaded.flatMap((entry) => {
|
|
341
|
-
if (!entry || typeof entry !== "object")
|
|
342
|
-
return [];
|
|
343
|
-
const objectKey = entry.object_key;
|
|
344
|
-
return typeof objectKey === "string" && objectKey ? [{ object_key: objectKey }] : [];
|
|
345
|
-
});
|
|
516
|
+
return failures.length > 0 ? failures.join("; ") : null;
|
|
346
517
|
}
|
|
347
518
|
function makeCollectorProvenance(options) {
|
|
348
519
|
return {
|
|
@@ -351,6 +522,12 @@ function makeCollectorProvenance(options) {
|
|
|
351
522
|
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
352
523
|
repo: options.repoLabel,
|
|
353
524
|
branch: options.context.branch,
|
|
525
|
+
repo_label: options.context.repo_label ?? options.repoLabel,
|
|
526
|
+
repo_fingerprint: options.context.repo_fingerprint,
|
|
527
|
+
repo_origin_url: options.context.repo_origin_url,
|
|
528
|
+
worktree_label: options.context.worktree_label,
|
|
529
|
+
worktree_fingerprint: options.context.worktree_fingerprint,
|
|
530
|
+
worktree_is_primary: options.context.worktree_is_primary,
|
|
354
531
|
operator_id: options.session.operator_id,
|
|
355
532
|
session_id: options.session.session_id,
|
|
356
533
|
work_context_id: options.context.work_context_id,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,6 +26,6 @@
|
|
|
26
26
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
29
|
+
"@bli-cockpit/telemetry-core": "0.1.3"
|
|
30
30
|
}
|
|
31
31
|
}
|