@bli-cockpit/cli 0.2.27 → 0.2.29
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/local-sources.js +1 -0
- package/dist/adapters/raw-evidence-completeness.js +226 -0
- package/dist/adapters/raw-evidence-git-diff.js +90 -0
- package/dist/adapters/raw-evidence-keys.js +92 -0
- package/dist/adapters/raw-evidence-manifest.js +132 -0
- package/dist/adapters/raw-evidence-pack-store.js +136 -0
- package/dist/adapters/raw-evidence-sanitize.js +190 -0
- package/dist/adapters/raw-evidence.js +740 -1044
- package/dist/commands/backfill.js +7 -0
- package/dist/commands/cli-io.js +92 -0
- package/dist/commands/collection-report.js +135 -0
- package/dist/commands/collection-roots.js +153 -0
- package/dist/commands/install-receipts.js +193 -0
- package/dist/commands/install-update.js +305 -0
- package/dist/commands/local-auth.js +268 -0
- package/dist/commands/local-discovery.js +100 -0
- package/dist/commands/local-help.js +281 -0
- package/dist/commands/local.js +183 -1842
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sessions.js +162 -0
- package/dist/commands/status.js +191 -0
- package/dist/evidence-upload-client.js +43 -2
- package/dist/local-state.js +12 -1
- package/dist/raw-evidence-gc.js +178 -0
- package/dist/raw-evidence-staging.js +322 -0
- package/dist/upload-agent-artifacts.js +153 -0
- package/dist/upload-envelope.js +407 -0
- package/dist/upload-evidence-delivery.js +505 -0
- package/dist/upload-http.js +46 -0
- package/dist/upload-session-reports.js +404 -0
- package/dist/upload.js +153 -1159
- package/package.json +2 -2
package/dist/upload.js
CHANGED
|
@@ -1,145 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
|
|
4
|
-
import { runLocalSourceCollectors } from "./adapters/local-sources.js";
|
|
5
|
-
import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
|
|
1
|
+
import { getCollectorRuntimePaths } from "./local-state.js";
|
|
6
2
|
import { uploadRawEvidenceFilesChunked, } from "./evidence-upload-client.js";
|
|
7
3
|
import { markObjectCommitted, readRawEvidenceCursor, writeRawEvidenceCursor, } from "./cursors/raw-evidence-cursor.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
export async function buildLocalAmbientEnvelope(options = {}) {
|
|
20
|
-
const now = options.now ?? new Date();
|
|
21
|
-
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
22
|
-
const config = await readLocalCollectorConfig(paths).catch(() => {
|
|
23
|
-
throw new LocalUploadBlockedError("not_installed", "Local collector config missing. Install/update the CLI, then run `cockpit do-everything` before `cockpit sync`.", "npm install -g @bli-cockpit/cli@latest && cockpit do-everything");
|
|
24
|
-
});
|
|
25
|
-
const sessionFile = await readLocalCollectorSessionFile(paths).catch(() => {
|
|
26
|
-
throw new LocalUploadBlockedError("unpaired", "No paired collector session found. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
|
|
27
|
-
});
|
|
28
|
-
const session = await readLocalSessionReference(paths);
|
|
29
|
-
if (session.session_state !== "valid") {
|
|
30
|
-
throw new LocalUploadBlockedError("unpaired", session.session_state === "expired"
|
|
31
|
-
? "Collector session expired. Run `cockpit login` or `cockpit pair` again before `cockpit sync`."
|
|
32
|
-
: "Collector is not paired. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
|
|
33
|
-
}
|
|
34
|
-
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
35
|
-
const activeContext = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => {
|
|
36
|
-
throw new LocalUploadBlockedError("missing_context", "Active work context missing. Run `cockpit start --workspace \"$PWD\"` before `cockpit sync`.", "cockpit start --workspace \"$PWD\"");
|
|
37
|
-
});
|
|
38
|
-
const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
|
|
39
|
-
const uploadContext = makeUploadWorkContext({
|
|
40
|
-
activeContext,
|
|
41
|
-
session,
|
|
42
|
-
repoLabel,
|
|
43
|
-
now,
|
|
44
|
-
});
|
|
45
|
-
const skipContentHashes = options.skipContentHashes ??
|
|
46
|
-
new Set(Object.entries(options.cursorObjects ?? {})
|
|
47
|
-
.filter(([, entry]) => rawEvidenceObjectKeyBelongsToWorkContext(entry.object_key, {
|
|
48
|
-
operatorId: session.operator_id,
|
|
49
|
-
workContextId: uploadContext.work_context_id,
|
|
50
|
-
}))
|
|
51
|
-
.map(([hash]) => hash));
|
|
52
|
-
const sourceCollection = await runLocalSourceCollectors({
|
|
53
|
-
repoRoot,
|
|
54
|
-
branch: uploadContext.branch,
|
|
55
|
-
operatorId: session.operator_id,
|
|
56
|
-
operatorLabel: session.email ?? session.auth_subject_id,
|
|
57
|
-
sessionId: session.session_id,
|
|
58
|
-
workContextId: uploadContext.work_context_id,
|
|
59
|
-
activeWorkContext: activeContext,
|
|
60
|
-
rawEvidenceStateDir: paths.state_dir,
|
|
61
|
-
rawEvidenceSessionsDirs: defaultCodexSessionDirs(paths.home_dir),
|
|
62
|
-
claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
|
|
63
|
-
rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
|
|
64
|
-
rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
|
|
65
|
-
rawEvidenceCodexSessionFiles: options.codexSessionFiles,
|
|
66
|
-
rawEvidenceCodexAttributionScan: options.codexAttributionScan,
|
|
67
|
-
rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
|
|
68
|
-
rawEvidenceClaudeAttributionScan: options.claudeAttributionScan,
|
|
69
|
-
rawEvidenceSkipContentHashes: skipContentHashes,
|
|
70
|
-
rawEvidenceByteBudget: options.rawEvidenceByteBudget,
|
|
71
|
-
rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
|
|
72
|
-
rawEvidenceBudget: options.rawEvidenceBudget,
|
|
73
|
-
now,
|
|
74
|
-
});
|
|
75
|
-
const binding = sourceCollection.binding;
|
|
76
|
-
const ticketBinding = selectedTicketBindingCandidate(binding);
|
|
77
|
-
const uploadWorkContext = {
|
|
78
|
-
...uploadContext,
|
|
79
|
-
active_ticket_id: binding.selected_ticket_id ?? undefined,
|
|
80
|
-
ticket_binding_candidates: ticketBinding ? [ticketBinding] : binding.candidates,
|
|
81
|
-
};
|
|
82
|
-
const safeRiskFlags = sourceCollection.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel));
|
|
83
|
-
const events = [
|
|
84
|
-
makeSourceScanCompletedEvent({
|
|
85
|
-
context: uploadWorkContext,
|
|
86
|
-
generatedAt: now.toISOString(),
|
|
87
|
-
binding,
|
|
88
|
-
ticketBinding,
|
|
89
|
-
scans: sourceCollection.scans,
|
|
90
|
-
gitChangedFileCount: sourceCollection.facts.git?.changed_file_count ?? 0,
|
|
91
|
-
gitAddedLines: sourceCollection.facts.git?.added_lines ?? 0,
|
|
92
|
-
gitDeletedLines: sourceCollection.facts.git?.deleted_lines ?? 0,
|
|
93
|
-
carOpenTicketCount: sourceCollection.facts.car?.open_ticket_count ?? 0,
|
|
94
|
-
rawEvidenceFacts: sourceCollection.facts.raw_evidence,
|
|
95
|
-
riskFlags: safeRiskFlags,
|
|
96
|
-
}),
|
|
97
|
-
];
|
|
98
|
-
const envelope = TelemetryIngestEnvelopeSchema.parse({
|
|
99
|
-
envelope_version: "telemetry-ingest.v1",
|
|
100
|
-
generated_at: now.toISOString(),
|
|
101
|
-
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
102
|
-
session_reference: sanitizeSessionReference(session),
|
|
103
|
-
work_context: uploadWorkContext,
|
|
104
|
-
worktree_inventory: options.worktreeInventory ?? [],
|
|
105
|
-
source_scan_results: sanitizeSourceScanResults(sourceCollection.scans, repoLabel),
|
|
106
|
-
events,
|
|
107
|
-
});
|
|
108
|
-
return {
|
|
109
|
-
envelope,
|
|
110
|
-
dashboard_url: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
|
|
111
|
-
device_token: sessionFile.device_token,
|
|
112
|
-
ticket_id: binding.selected_ticket_id ?? null,
|
|
113
|
-
binding,
|
|
114
|
-
event_count: envelope.events.length,
|
|
115
|
-
source_scan_count: envelope.source_scan_results.length,
|
|
116
|
-
risk_flag_count: safeRiskFlags.length,
|
|
117
|
-
repo_label: repoLabel,
|
|
118
|
-
head_sha: uploadContext.head_sha ?? null,
|
|
119
|
-
raw_evidence_upload_files: sourceCollection.facts.raw_evidence?.upload_files ?? [],
|
|
120
|
-
raw_evidence_facts: sourceCollection.facts.raw_evidence,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
4
|
+
import { readRawEvidenceStagingState } from "./raw-evidence-staging.js";
|
|
5
|
+
import { recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
|
|
6
|
+
import { buildLocalAmbientEnvelope, LocalUploadBlockedError, } from "./upload-envelope.js";
|
|
7
|
+
import { applyRawEvidenceUploadOutcomes, hasRetryableEvidenceGap, logEvidenceBackoffBypassed, logEvidenceHeldByBackoff, logPermanentlyRejectedEvidence, partitionHeldEvidenceFiles, permanentEvidenceFailureReason, persistDeliveryAttempts, retrySourcesForFailedSync, retryableEvidenceGapReason, summarizeRawEvidenceDelivery, } from "./upload-evidence-delivery.js";
|
|
8
|
+
import { reportAgentImageArtifacts } from "./upload-agent-artifacts.js";
|
|
9
|
+
import { isNonEmptyString, readResponseJson, responseErrorMessage, } from "./upload-http.js";
|
|
10
|
+
export { LocalUploadBlockedError, buildLocalAmbientEnvelope, } from "./upload-envelope.js";
|
|
11
|
+
export { partitionHeldEvidenceFiles } from "./upload-evidence-delivery.js";
|
|
12
|
+
export { reportAgentImageArtifacts } from "./upload-agent-artifacts.js";
|
|
13
|
+
export { flushPendingCodexSessionReports, postCodexSessionReport, queueCodexSessionReport, reportCodexSessionAttributions, } from "./upload-session-reports.js";
|
|
123
14
|
export async function syncLocalAmbientEnvelope(options = {}) {
|
|
124
15
|
const attemptedAt = (options.now ?? new Date()).toISOString();
|
|
125
16
|
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
126
17
|
const cursor = await readRawEvidenceCursor(paths);
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
built = await buildLocalAmbientEnvelope({
|
|
130
|
-
...options,
|
|
131
|
-
cursorObjects: options.cursorObjects ?? cursor.objects,
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
catch (error) {
|
|
135
|
-
if (error instanceof LocalUploadBlockedError) {
|
|
136
|
-
await recordUploadBlocked(paths, {
|
|
137
|
-
attemptedAt,
|
|
138
|
-
reason: error.message,
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
throw error;
|
|
142
|
-
}
|
|
18
|
+
const built = await buildEnvelopeOrRecordBlocker(paths, options, cursor.objects, attemptedAt);
|
|
143
19
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
144
20
|
if (!fetchImpl) {
|
|
145
21
|
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
@@ -148,52 +24,45 @@ export async function syncLocalAmbientEnvelope(options = {}) {
|
|
|
148
24
|
if (!provenance) {
|
|
149
25
|
throw new Error("Ambient upload requires collector provenance.");
|
|
150
26
|
}
|
|
27
|
+
// Assigned progressively so the spool path below can still report what did
|
|
28
|
+
// land if the upload pass throws part-way through.
|
|
151
29
|
let uploadOutcomes = [];
|
|
152
30
|
let uploadedChunkCount = 0;
|
|
31
|
+
const attemptedDate = options.now ?? new Date(attemptedAt);
|
|
32
|
+
const staging = await readRawEvidenceStagingState(paths.state_dir);
|
|
153
33
|
try {
|
|
154
|
-
if (built.raw_evidence_upload_files.length > 0
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
|
|
34
|
+
if (built.raw_evidence_upload_files.length > 0) {
|
|
35
|
+
// Objects still inside their backoff window are not offered at all. They
|
|
36
|
+
// become held outcomes below, which keeps them out of the envelope, keeps
|
|
37
|
+
// the sync in retry_pending, and names the reason — the 559th identical
|
|
38
|
+
// attempt is what BLI-3066 made impossible. An operator-invoked retry
|
|
39
|
+
// (`cockpit backfill`, doctor) offers them anyway and says so: the window
|
|
40
|
+
// is the scheduler's cadence, not an answer to a person asking now
|
|
41
|
+
// (BLI-3118).
|
|
42
|
+
const { deliverable, held, bypassed } = partitionHeldEvidenceFiles(built.raw_evidence_upload_files, staging, attemptedDate, options.evidenceDeliveryMode);
|
|
43
|
+
logEvidenceHeldByBackoff(held, attemptedAt);
|
|
44
|
+
logEvidenceBackoffBypassed(bypassed, attemptedAt);
|
|
45
|
+
uploadOutcomes = held;
|
|
46
|
+
if (deliverable.length > 0) {
|
|
47
|
+
const upload = await uploadRawEvidenceFilesChunked({
|
|
48
|
+
fetchImpl,
|
|
49
|
+
dashboardUrl: built.dashboard_url,
|
|
50
|
+
deviceToken: built.device_token,
|
|
51
|
+
provenance,
|
|
52
|
+
generatedAt: built.envelope.generated_at,
|
|
53
|
+
files: deliverable,
|
|
54
|
+
});
|
|
55
|
+
uploadOutcomes = [...held, ...upload.outcomes];
|
|
56
|
+
uploadedChunkCount = upload.uploaded_chunk_count;
|
|
57
|
+
}
|
|
58
|
+
await persistDeliveryAttempts(paths.state_dir, staging, uploadOutcomes, attemptedDate);
|
|
165
59
|
}
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
"Authorization": `Bearer ${built.device_token}`,
|
|
171
|
-
"Content-Type": "application/json",
|
|
172
|
-
},
|
|
173
|
-
body: JSON.stringify(envelope),
|
|
60
|
+
const response = await postEnvelopeToIngest({
|
|
61
|
+
fetchImpl,
|
|
62
|
+
built,
|
|
63
|
+
envelope: applyRawEvidenceUploadOutcomes(built.envelope, uploadOutcomes),
|
|
174
64
|
});
|
|
175
|
-
|
|
176
|
-
if (!response.ok) {
|
|
177
|
-
throw new Error(responseErrorMessage(responseBody, `Ambient ingest failed with HTTP ${response.status}`));
|
|
178
|
-
}
|
|
179
|
-
// A 2xx response may have persisted the envelope even when a proxy or
|
|
180
|
-
// incompatible dashboard mangles the receipt. Keep uploaded objects in
|
|
181
|
-
// that ambiguous case, but do not advance cursors or clear retry state
|
|
182
|
-
// until the first-party route proves the exact submitted row counts.
|
|
183
|
-
assertAmbientIngestReceipt(response, responseBody, envelope);
|
|
184
|
-
for (const outcome of uploadOutcomes) {
|
|
185
|
-
if (outcome.upload_state === "upload_failed")
|
|
186
|
-
continue;
|
|
187
|
-
const contentHash = outcome.pointer.content_hash_sha256;
|
|
188
|
-
if (!contentHash)
|
|
189
|
-
continue;
|
|
190
|
-
markObjectCommitted(cursor, contentHash, {
|
|
191
|
-
object_key: outcome.object_key,
|
|
192
|
-
byte_size: outcome.pointer.byte_size ?? 0,
|
|
193
|
-
committed_at: attemptedAt,
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
cursor.updated_at = attemptedAt;
|
|
65
|
+
advanceRawEvidenceCursor(cursor, uploadOutcomes, attemptedAt);
|
|
197
66
|
await writeRawEvidenceCursor(paths, cursor);
|
|
198
67
|
await reportAgentImageArtifacts({
|
|
199
68
|
fetchImpl,
|
|
@@ -212,51 +81,13 @@ export async function syncLocalAmbientEnvelope(options = {}) {
|
|
|
212
81
|
reason: "agent_artifact_report_local_error",
|
|
213
82
|
recorded_count: 0,
|
|
214
83
|
}));
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
84
|
+
await recordIngestedSyncOutcome({
|
|
85
|
+
paths,
|
|
86
|
+
options,
|
|
87
|
+
built,
|
|
88
|
+
uploadOutcomes,
|
|
218
89
|
attemptedAt,
|
|
219
|
-
workContextId: built.envelope.work_context.work_context_id,
|
|
220
|
-
clearPendingForContext: !retryableEvidenceGap,
|
|
221
90
|
});
|
|
222
|
-
if (retryableEvidenceGap) {
|
|
223
|
-
await recordUploadFailure(paths, {
|
|
224
|
-
last_attempt_at: attemptedAt,
|
|
225
|
-
dashboard_url: built.dashboard_url,
|
|
226
|
-
work_context_id: built.envelope.work_context.work_context_id,
|
|
227
|
-
ticket_id: built.ticket_id,
|
|
228
|
-
repo_label: built.repo_label,
|
|
229
|
-
branch: built.envelope.work_context.branch,
|
|
230
|
-
event_count: built.event_count,
|
|
231
|
-
source_scan_count: built.source_scan_count,
|
|
232
|
-
risk_flag_count: built.risk_flag_count,
|
|
233
|
-
raw_evidence_file_count: built.raw_evidence_upload_files.length,
|
|
234
|
-
retry_sources: retrySources,
|
|
235
|
-
failure_reason: retryableEvidenceGapReason(built.raw_evidence_facts, uploadOutcomes),
|
|
236
|
-
retry_command: "cockpit sync",
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
else {
|
|
240
|
-
// No retry to queue, but a permanently rejected object still has to say
|
|
241
|
-
// so. `recordUploadBlocked` names the reason without spooling a retry —
|
|
242
|
-
// the machine reports the failure instead of promising a fix it cannot
|
|
243
|
-
// deliver, and `cockpit status` stops reading clean.
|
|
244
|
-
const permanentReason = permanentEvidenceFailureReason(uploadOutcomes);
|
|
245
|
-
if (permanentReason) {
|
|
246
|
-
await recordUploadBlocked(paths, {
|
|
247
|
-
attemptedAt,
|
|
248
|
-
reason: permanentReason,
|
|
249
|
-
});
|
|
250
|
-
// stderr, which launchd captures to `sync.err.log`, so an unattended
|
|
251
|
-
// machine leaves a dated record of the objects it gave up on. Reason
|
|
252
|
-
// labels and counts only — never a path or a byte of content.
|
|
253
|
-
console.error("[cockpit-sync] raw evidence permanently rejected", JSON.stringify({
|
|
254
|
-
attempted_at: attemptedAt,
|
|
255
|
-
reason: permanentReason,
|
|
256
|
-
object_count: permanentFailedOutcomes(uploadOutcomes).length,
|
|
257
|
-
}));
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
91
|
return {
|
|
261
92
|
status: "uploaded",
|
|
262
93
|
dashboard_url: built.dashboard_url,
|
|
@@ -267,7 +98,7 @@ export async function syncLocalAmbientEnvelope(options = {}) {
|
|
|
267
98
|
source_scan_count: built.source_scan_count,
|
|
268
99
|
risk_flag_count: built.risk_flag_count,
|
|
269
100
|
http_status: response.status,
|
|
270
|
-
...
|
|
101
|
+
...summarizeRawEvidenceDelivery(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
|
|
271
102
|
};
|
|
272
103
|
}
|
|
273
104
|
catch (error) {
|
|
@@ -304,955 +135,137 @@ export async function syncLocalAmbientEnvelope(options = {}) {
|
|
|
304
135
|
failure_reason: spooledFailureReason,
|
|
305
136
|
spool_entry_id: entry.spool_id,
|
|
306
137
|
retry_command: entry.retry_command,
|
|
307
|
-
...
|
|
138
|
+
...summarizeRawEvidenceDelivery(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
|
|
308
139
|
};
|
|
309
140
|
}
|
|
310
141
|
}
|
|
311
|
-
function retrySourcesForFailedSync(options, facts) {
|
|
312
|
-
const sources = new Set();
|
|
313
|
-
if ((options.codexSessionFiles?.length ?? 0) > 0 ||
|
|
314
|
-
(options.codexAttributionScan?.directory_read_failed_count ?? 0) > 0 ||
|
|
315
|
-
(options.codexAttributionScan?.stat_failed_count ?? 0) > 0 ||
|
|
316
|
-
facts?.evidence_completeness.source_counts.some((count) => count.source.startsWith("codex_") &&
|
|
317
|
-
count.scanned_count + count.included_count + count.reused_count > 0)) {
|
|
318
|
-
sources.add("codex");
|
|
319
|
-
}
|
|
320
|
-
if ((options.claudeSessionFiles?.length ?? 0) > 0 ||
|
|
321
|
-
(options.claudeAttributionScan?.project_dir_read_failed_count ?? 0) > 0 ||
|
|
322
|
-
(options.claudeAttributionScan?.session_stat_failed_count ?? 0) > 0 ||
|
|
323
|
-
(options.claudeAttributionScan?.sidecar_dir_read_failed_count ?? 0) > 0 ||
|
|
324
|
-
(options.claudeAttributionScan?.sidecar_stat_failed_count ?? 0) > 0 ||
|
|
325
|
-
facts?.evidence_completeness.source_counts.some((count) => count.source.startsWith("claude_") &&
|
|
326
|
-
count.scanned_count + count.included_count + count.reused_count > 0)) {
|
|
327
|
-
sources.add("claude_code");
|
|
328
|
-
}
|
|
329
|
-
return [...sources];
|
|
330
|
-
}
|
|
331
142
|
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* and counting it as one is what kept Edward's Mac in `retry_pending` through
|
|
336
|
-
* 13 consecutive syncs that were never going to end differently (BLI-2528).
|
|
143
|
+
* Assemble the envelope, and if this machine simply cannot upload, say so in
|
|
144
|
+
* the spool before the error leaves. A blocker that only ever surfaced as a
|
|
145
|
+
* thrown exception would be invisible to the next `cockpit status`.
|
|
337
146
|
*/
|
|
338
|
-
function
|
|
339
|
-
return outcomes.filter((outcome) => outcome.upload_state === "upload_failed" &&
|
|
340
|
-
!isPermanentUploadFailure(outcome.reason));
|
|
341
|
-
}
|
|
342
|
-
/** Failed uploads that no retry can rescue, kept so they can still be named. */
|
|
343
|
-
function permanentFailedOutcomes(outcomes) {
|
|
344
|
-
return outcomes.filter((outcome) => outcome.upload_state === "upload_failed" &&
|
|
345
|
-
isPermanentUploadFailure(outcome.reason));
|
|
346
|
-
}
|
|
347
|
-
/**
|
|
348
|
-
* The reason to show for objects that failed for good.
|
|
349
|
-
*
|
|
350
|
-
* Returns null when there are none. These never queue a retry, but they must
|
|
351
|
-
* never disappear either: a machine with a permanently rejected object has
|
|
352
|
-
* missing collection, and a status that reads clean would hide it.
|
|
353
|
-
*/
|
|
354
|
-
function permanentEvidenceFailureReason(outcomes) {
|
|
355
|
-
const reasons = new Set(permanentFailedOutcomes(outcomes).map((outcome) => outcome.reason ?? "unknown"));
|
|
356
|
-
if (reasons.size === 0)
|
|
357
|
-
return null;
|
|
358
|
-
return `raw_evidence_permanently_rejected:${[...reasons].sort().join(",")}`;
|
|
359
|
-
}
|
|
360
|
-
function hasRetryableEvidenceGap(facts, outcomes) {
|
|
361
|
-
if (retryableFailedOutcomes(outcomes).length > 0) {
|
|
362
|
-
return true;
|
|
363
|
-
}
|
|
364
|
-
if (!facts)
|
|
365
|
-
return false;
|
|
366
|
-
if (facts.deferred_byte_budget_count > 0 ||
|
|
367
|
-
facts.deferred_object_budget_count > 0) {
|
|
368
|
-
return true;
|
|
369
|
-
}
|
|
370
|
-
if (facts.evidence_completeness.status === "failed" ||
|
|
371
|
-
facts.evidence_completeness.totals.failed_count > 0 ||
|
|
372
|
-
facts.evidence_completeness.failure_reasons.length > 0) {
|
|
373
|
-
return true;
|
|
374
|
-
}
|
|
375
|
-
return facts.evidence_completeness.skip_reasons.some(({ reason }) => /(?:read|stat|directory)_failed|session_limit_overflow/iu.test(reason));
|
|
376
|
-
}
|
|
377
|
-
function retryableEvidenceGapReason(facts, outcomes) {
|
|
378
|
-
const reasons = new Set();
|
|
379
|
-
for (const outcome of retryableFailedOutcomes(outcomes)) {
|
|
380
|
-
reasons.add(outcome.reason ?? "upload_failed");
|
|
381
|
-
}
|
|
382
|
-
if (facts) {
|
|
383
|
-
if (facts.deferred_byte_budget_count > 0)
|
|
384
|
-
reasons.add("deferred_byte_budget");
|
|
385
|
-
if (facts.deferred_object_budget_count > 0) {
|
|
386
|
-
reasons.add("deferred_object_budget");
|
|
387
|
-
}
|
|
388
|
-
for (const { reason } of facts.evidence_completeness.failure_reasons) {
|
|
389
|
-
reasons.add(reason);
|
|
390
|
-
}
|
|
391
|
-
if (facts.evidence_completeness.status === "failed" &&
|
|
392
|
-
facts.evidence_completeness.failure_reasons.length === 0) {
|
|
393
|
-
reasons.add("evidence_completeness_failed");
|
|
394
|
-
}
|
|
395
|
-
for (const { reason } of facts.evidence_completeness.skip_reasons) {
|
|
396
|
-
if (/(?:read|stat|directory)_failed|session_limit_overflow/iu.test(reason)) {
|
|
397
|
-
reasons.add(reason);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
return `partial_raw_evidence_retry_required:${[
|
|
402
|
-
...reasons,
|
|
403
|
-
].sort().join(",") || "unknown"}`;
|
|
404
|
-
}
|
|
405
|
-
/**
|
|
406
|
-
* Posts a Codex session attribution report using the paired collector
|
|
407
|
-
* credentials and the work context of a representative repo. Failures come
|
|
408
|
-
* back as reason labels so the harvest never hard-fails on reporting.
|
|
409
|
-
*/
|
|
410
|
-
export async function postCodexSessionReport(options) {
|
|
411
|
-
if (options.sessions.length === 0) {
|
|
412
|
-
return emptyCodexSessionReportResult("no_sessions_observed");
|
|
413
|
-
}
|
|
414
|
-
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
415
|
-
if (!fetchImpl) {
|
|
416
|
-
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
417
|
-
}
|
|
147
|
+
async function buildEnvelopeOrRecordBlocker(paths, options, cursorObjects, attemptedAt) {
|
|
418
148
|
try {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const session = await readLocalSessionReference(paths);
|
|
423
|
-
if (session.session_state !== "valid") {
|
|
424
|
-
return emptyCodexSessionReportResult("collector_not_paired");
|
|
425
|
-
}
|
|
426
|
-
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
427
|
-
const activeContext = await readLocalWorkContextForRepo(paths, repoRoot);
|
|
428
|
-
const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
|
|
429
|
-
const provenance = makeCollectorProvenance({
|
|
430
|
-
context: makeUploadWorkContext({
|
|
431
|
-
activeContext,
|
|
432
|
-
session,
|
|
433
|
-
repoLabel,
|
|
434
|
-
now: options.now ?? new Date(),
|
|
435
|
-
}),
|
|
436
|
-
session,
|
|
437
|
-
repoLabel,
|
|
438
|
-
});
|
|
439
|
-
return await reportCodexSessionAttributions({
|
|
440
|
-
fetchImpl,
|
|
441
|
-
dashboardUrl: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
|
|
442
|
-
deviceToken: sessionFile.device_token,
|
|
443
|
-
provenance,
|
|
444
|
-
generatedAt: (options.now ?? new Date()).toISOString(),
|
|
445
|
-
sessions: options.sessions,
|
|
149
|
+
return await buildLocalAmbientEnvelope({
|
|
150
|
+
...options,
|
|
151
|
+
cursorObjects: options.cursorObjects ?? cursorObjects,
|
|
446
152
|
});
|
|
447
153
|
}
|
|
448
|
-
catch {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
/**
|
|
453
|
-
* Queues the safe session-attribution rows before the network request. The
|
|
454
|
-
* queue is merged per work context, so an endpoint outage retains every
|
|
455
|
-
* observed session without growing one duplicate report per scheduler pass.
|
|
456
|
-
*/
|
|
457
|
-
export async function queueCodexSessionReport(options) {
|
|
458
|
-
if (options.sessions.length === 0)
|
|
459
|
-
return null;
|
|
460
|
-
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
461
|
-
return await recordPendingSessionReport(paths, {
|
|
462
|
-
attempted_at: options.generatedAt,
|
|
463
|
-
dashboard_url: normalizeDashboardUrl(options.dashboardUrl),
|
|
464
|
-
generated_at: options.generatedAt,
|
|
465
|
-
work_context_id: options.workContextId,
|
|
466
|
-
repo_label: safeRepoLabel(options.repoLabel),
|
|
467
|
-
branch: options.branch,
|
|
468
|
-
repo_fingerprint: options.repoFingerprint,
|
|
469
|
-
repo_origin_url: options.repoOriginUrl,
|
|
470
|
-
worktree_label: options.worktreeLabel,
|
|
471
|
-
worktree_fingerprint: options.worktreeFingerprint,
|
|
472
|
-
worktree_is_primary: options.worktreeIsPrimary,
|
|
473
|
-
sessions: options.sessions,
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
/**
|
|
477
|
-
* Flushes every durable session-attribution report using current credentials.
|
|
478
|
-
* Report payloads do not depend on the source files still being inside the live
|
|
479
|
-
* scan window, so a transient endpoint failure cannot silently age them out.
|
|
480
|
-
*/
|
|
481
|
-
export async function flushPendingCodexSessionReports(options) {
|
|
482
|
-
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
483
|
-
const state = await readLocalUploadSpoolState(paths);
|
|
484
|
-
if (state.pending_session_reports.length === 0) {
|
|
485
|
-
return emptyCodexSessionReportResult("no_pending_session_reports");
|
|
486
|
-
}
|
|
487
|
-
const attemptedAt = (options.now ?? new Date()).toISOString();
|
|
488
|
-
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
489
|
-
if (!fetchImpl) {
|
|
490
|
-
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
491
|
-
}
|
|
492
|
-
let sessionFile;
|
|
493
|
-
let session;
|
|
494
|
-
try {
|
|
495
|
-
[sessionFile, session] = await Promise.all([
|
|
496
|
-
readLocalCollectorSessionFile(paths),
|
|
497
|
-
readLocalSessionReference(paths),
|
|
498
|
-
]);
|
|
499
|
-
}
|
|
500
|
-
catch {
|
|
501
|
-
return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_ready");
|
|
502
|
-
}
|
|
503
|
-
if (session.session_state !== "valid") {
|
|
504
|
-
return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_paired");
|
|
505
|
-
}
|
|
506
|
-
const results = [];
|
|
507
|
-
for (const pending of state.pending_session_reports) {
|
|
508
|
-
const provenance = {
|
|
509
|
-
capture_source: "collector_runtime",
|
|
510
|
-
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
511
|
-
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
512
|
-
repo: pending.repo_label,
|
|
513
|
-
branch: pending.branch,
|
|
514
|
-
repo_label: pending.repo_label,
|
|
515
|
-
repo_fingerprint: pending.repo_fingerprint,
|
|
516
|
-
...(pending.repo_origin_url
|
|
517
|
-
? { repo_origin_url: pending.repo_origin_url }
|
|
518
|
-
: {}),
|
|
519
|
-
worktree_label: pending.worktree_label,
|
|
520
|
-
worktree_fingerprint: pending.worktree_fingerprint,
|
|
521
|
-
worktree_is_primary: pending.worktree_is_primary,
|
|
522
|
-
operator_id: session.operator_id,
|
|
523
|
-
session_id: session.session_id,
|
|
524
|
-
work_context_id: pending.work_context_id,
|
|
525
|
-
};
|
|
526
|
-
const result = await reportCodexSessionAttributions({
|
|
527
|
-
fetchImpl,
|
|
528
|
-
dashboardUrl: normalizeDashboardUrl(pending.dashboard_url || sessionFile.dashboard_url),
|
|
529
|
-
deviceToken: sessionFile.device_token,
|
|
530
|
-
provenance,
|
|
531
|
-
generatedAt: pending.generated_at,
|
|
532
|
-
sessions: pending.sessions,
|
|
533
|
-
maxAttemptsPerRequest: options.maxAttemptsPerRequest,
|
|
534
|
-
sleep: options.sleep,
|
|
535
|
-
});
|
|
536
|
-
results.push(result);
|
|
537
|
-
if (result.posted) {
|
|
538
|
-
await recordSessionReportSuccess(paths, {
|
|
539
|
-
reportId: pending.report_id,
|
|
540
|
-
attemptedAt,
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
else {
|
|
544
|
-
await recordSessionReportFailure(paths, {
|
|
545
|
-
reportId: pending.report_id,
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error instanceof LocalUploadBlockedError) {
|
|
156
|
+
await recordUploadBlocked(paths, {
|
|
546
157
|
attemptedAt,
|
|
547
|
-
reason:
|
|
158
|
+
reason: error.message,
|
|
548
159
|
});
|
|
549
160
|
}
|
|
161
|
+
throw error;
|
|
550
162
|
}
|
|
551
|
-
return combineCodexSessionReportResults(results);
|
|
552
|
-
}
|
|
553
|
-
async function failPendingSessionReports(paths, pendingReports, attemptedAt, reason) {
|
|
554
|
-
for (const pending of pendingReports) {
|
|
555
|
-
await recordSessionReportFailure(paths, {
|
|
556
|
-
reportId: pending.report_id,
|
|
557
|
-
attemptedAt,
|
|
558
|
-
reason,
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
return {
|
|
562
|
-
posted: false,
|
|
563
|
-
reason,
|
|
564
|
-
chunk_count: 0,
|
|
565
|
-
recorded_count: 0,
|
|
566
|
-
failed_count: pendingReports.length,
|
|
567
|
-
chunks: [],
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
function combineCodexSessionReportResults(results) {
|
|
571
|
-
if (results.length === 0) {
|
|
572
|
-
return emptyCodexSessionReportResult("no_pending_session_reports");
|
|
573
|
-
}
|
|
574
|
-
const failed = results.filter((result) => !result.posted);
|
|
575
|
-
return {
|
|
576
|
-
posted: failed.length === 0,
|
|
577
|
-
reason: failed[0]?.reason ??
|
|
578
|
-
(results.every((result) => result.reason === "recorded")
|
|
579
|
-
? "recorded"
|
|
580
|
-
: results[0]?.reason ?? "recorded"),
|
|
581
|
-
chunk_count: results.reduce((total, result) => total + result.chunk_count, 0),
|
|
582
|
-
recorded_count: results.reduce((total, result) => total + result.recorded_count, 0),
|
|
583
|
-
failed_count: results.reduce((total, result) => total + result.failed_count, 0),
|
|
584
|
-
chunks: results.flatMap((result) => result.chunks),
|
|
585
|
-
};
|
|
586
163
|
}
|
|
587
164
|
/**
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
165
|
+
* POST the envelope and refuse to believe an unreadable answer.
|
|
166
|
+
*
|
|
167
|
+
* A 2xx may have persisted the envelope even when a proxy or an incompatible
|
|
168
|
+
* dashboard mangles the receipt. Uploaded objects are kept in that ambiguous
|
|
169
|
+
* case — they are durable either way — but the caller must not advance cursors
|
|
170
|
+
* or clear retry state until the first-party route proves the exact submitted
|
|
171
|
+
* row counts, which is what the receipt check demands.
|
|
591
172
|
*/
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
...options,
|
|
601
|
-
sessions,
|
|
602
|
-
batchIndex: index + 1,
|
|
603
|
-
});
|
|
604
|
-
chunks.push(chunk);
|
|
605
|
-
}
|
|
606
|
-
const failedCount = chunks.filter((chunk) => !chunk.posted).length;
|
|
607
|
-
const recordedCount = chunks.reduce((sum, chunk) => sum + chunk.recorded_count, 0);
|
|
608
|
-
return {
|
|
609
|
-
posted: failedCount === 0,
|
|
610
|
-
reason: failedCount === 0
|
|
611
|
-
? "recorded"
|
|
612
|
-
: chunks.find((chunk) => !chunk.posted)?.reason ?? "report_failed",
|
|
613
|
-
chunk_count: chunks.length,
|
|
614
|
-
recorded_count: recordedCount,
|
|
615
|
-
failed_count: failedCount,
|
|
616
|
-
chunks,
|
|
617
|
-
};
|
|
618
|
-
}
|
|
619
|
-
async function postCodexSessionAttributionChunk(options) {
|
|
620
|
-
const maxAttempts = options.maxAttemptsPerRequest ?? 3;
|
|
621
|
-
const sleep = options.sleep ?? defaultReportSleep;
|
|
622
|
-
let lastStatus = null;
|
|
623
|
-
let lastFailureReason = null;
|
|
624
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
625
|
-
try {
|
|
626
|
-
const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/codex-sessions`, {
|
|
627
|
-
method: "POST",
|
|
628
|
-
headers: {
|
|
629
|
-
"Authorization": `Bearer ${options.deviceToken}`,
|
|
630
|
-
"Content-Type": "application/json",
|
|
631
|
-
},
|
|
632
|
-
body: JSON.stringify({
|
|
633
|
-
schema_version: "ambient-codex-session-attributions.v1",
|
|
634
|
-
generated_at: options.generatedAt,
|
|
635
|
-
provenance: options.provenance,
|
|
636
|
-
sessions: options.sessions,
|
|
637
|
-
}),
|
|
638
|
-
});
|
|
639
|
-
lastStatus = response.status;
|
|
640
|
-
const body = await readResponseJson(response);
|
|
641
|
-
if (response.status === 404) {
|
|
642
|
-
return codexSessionReportChunkResult(options, {
|
|
643
|
-
posted: false,
|
|
644
|
-
reason: "codex_session_api_unavailable",
|
|
645
|
-
httpStatus: response.status,
|
|
646
|
-
recordedCount: 0,
|
|
647
|
-
});
|
|
648
|
-
}
|
|
649
|
-
if (response.ok) {
|
|
650
|
-
const parsed = CodexSessionAttributionReportResponseSchema.safeParse(body);
|
|
651
|
-
if (!parsed.success) {
|
|
652
|
-
lastFailureReason = "report_invalid_response";
|
|
653
|
-
}
|
|
654
|
-
else if (parsed.data.recorded_count <
|
|
655
|
-
requiredCodexSessionAcknowledgementCount(options.sessions)) {
|
|
656
|
-
lastFailureReason = "report_incomplete_acknowledgement";
|
|
657
|
-
}
|
|
658
|
-
else {
|
|
659
|
-
return codexSessionReportChunkResult(options, {
|
|
660
|
-
posted: true,
|
|
661
|
-
reason: "recorded",
|
|
662
|
-
httpStatus: response.status,
|
|
663
|
-
recordedCount: parsed.data.recorded_count,
|
|
664
|
-
});
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
else {
|
|
668
|
-
lastFailureReason = `report_failed_http_${response.status}`;
|
|
669
|
-
}
|
|
670
|
-
if (response.status < 500 && response.status !== 429) {
|
|
671
|
-
if (response.ok) {
|
|
672
|
-
// A malformed or short 2xx response is retryable. The server may have
|
|
673
|
-
// failed between persisting the rows and producing its acknowledgement,
|
|
674
|
-
// so only a schema-valid, sufficiently large acknowledgement is safe
|
|
675
|
-
// to use for deleting a durable pending report.
|
|
676
|
-
if (attempt < maxAttempts)
|
|
677
|
-
await sleep(250 * attempt);
|
|
678
|
-
continue;
|
|
679
|
-
}
|
|
680
|
-
return codexSessionReportChunkResult(options, {
|
|
681
|
-
posted: false,
|
|
682
|
-
reason: `report_failed_http_${response.status}`,
|
|
683
|
-
httpStatus: response.status,
|
|
684
|
-
recordedCount: 0,
|
|
685
|
-
});
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
catch {
|
|
689
|
-
lastStatus = null;
|
|
690
|
-
lastFailureReason = "report_network_error";
|
|
691
|
-
}
|
|
692
|
-
if (attempt < maxAttempts)
|
|
693
|
-
await sleep(250 * attempt);
|
|
694
|
-
}
|
|
695
|
-
return codexSessionReportChunkResult(options, {
|
|
696
|
-
posted: false,
|
|
697
|
-
reason: lastFailureReason ??
|
|
698
|
-
(lastStatus === null
|
|
699
|
-
? "report_network_error"
|
|
700
|
-
: `report_failed_http_${lastStatus}`),
|
|
701
|
-
httpStatus: lastStatus,
|
|
702
|
-
recordedCount: 0,
|
|
703
|
-
});
|
|
704
|
-
}
|
|
705
|
-
function codexSessionReportChunkResult(options, result) {
|
|
706
|
-
return {
|
|
707
|
-
batch_index: options.batchIndex,
|
|
708
|
-
session_count: options.sessions.length,
|
|
709
|
-
posted: result.posted,
|
|
710
|
-
reason: result.reason,
|
|
711
|
-
http_status: result.httpStatus,
|
|
712
|
-
recorded_count: result.recordedCount,
|
|
713
|
-
};
|
|
714
|
-
}
|
|
715
|
-
function emptyCodexSessionReportResult(reason) {
|
|
716
|
-
return {
|
|
717
|
-
posted: false,
|
|
718
|
-
reason,
|
|
719
|
-
chunk_count: 0,
|
|
720
|
-
recorded_count: 0,
|
|
721
|
-
failed_count: 0,
|
|
722
|
-
chunks: [],
|
|
723
|
-
};
|
|
724
|
-
}
|
|
725
|
-
function chunkArray(items, size) {
|
|
726
|
-
const chunks = [];
|
|
727
|
-
for (let offset = 0; offset < items.length; offset += size) {
|
|
728
|
-
chunks.push(items.slice(offset, offset + size));
|
|
729
|
-
}
|
|
730
|
-
return chunks;
|
|
731
|
-
}
|
|
732
|
-
function requiredCodexSessionAcknowledgementCount(sessions) {
|
|
733
|
-
return new Set(sessions.map((session) => `${session.source ?? "codex"}:${session.codex_session_id}`)).size;
|
|
734
|
-
}
|
|
735
|
-
function defaultReportSleep(milliseconds) {
|
|
736
|
-
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
737
|
-
}
|
|
738
|
-
export async function reportAgentImageArtifacts(options) {
|
|
739
|
-
const artifacts = agentArtifactsFromEvidence(options);
|
|
740
|
-
if (artifacts.length === 0) {
|
|
741
|
-
return {
|
|
742
|
-
posted: false,
|
|
743
|
-
reason: "no_agent_image_artifacts",
|
|
744
|
-
recorded_count: 0,
|
|
745
|
-
};
|
|
746
|
-
}
|
|
747
|
-
const payload = AgentImageArtifactReportRequestSchema.parse({
|
|
748
|
-
schema_version: "ambient-agent-image-artifacts.v1",
|
|
749
|
-
generated_at: options.generatedAt,
|
|
750
|
-
provenance: options.provenance,
|
|
751
|
-
artifacts,
|
|
173
|
+
async function postEnvelopeToIngest(options) {
|
|
174
|
+
const response = await options.fetchImpl(`${options.built.dashboard_url}/api/ambient/ingest`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: {
|
|
177
|
+
"Authorization": `Bearer ${options.built.device_token}`,
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
},
|
|
180
|
+
body: JSON.stringify(options.envelope),
|
|
752
181
|
});
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
headers: {
|
|
757
|
-
"Authorization": `Bearer ${options.deviceToken}`,
|
|
758
|
-
"Content-Type": "application/json",
|
|
759
|
-
},
|
|
760
|
-
body: JSON.stringify(payload),
|
|
761
|
-
});
|
|
762
|
-
if (response.status === 404) {
|
|
763
|
-
return {
|
|
764
|
-
posted: false,
|
|
765
|
-
reason: "agent_artifact_api_unavailable",
|
|
766
|
-
recorded_count: 0,
|
|
767
|
-
};
|
|
768
|
-
}
|
|
769
|
-
if (!response.ok) {
|
|
770
|
-
return {
|
|
771
|
-
posted: false,
|
|
772
|
-
reason: `report_failed_http_${response.status}`,
|
|
773
|
-
recorded_count: 0,
|
|
774
|
-
};
|
|
775
|
-
}
|
|
776
|
-
const body = await readResponseJson(response);
|
|
777
|
-
const recordedCount = body && typeof body === "object"
|
|
778
|
-
? Number(body.recorded_count ?? 0)
|
|
779
|
-
: 0;
|
|
780
|
-
return {
|
|
781
|
-
posted: true,
|
|
782
|
-
reason: "recorded",
|
|
783
|
-
recorded_count: Number.isFinite(recordedCount) ? recordedCount : 0,
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
catch {
|
|
787
|
-
return { posted: false, reason: "report_network_error", recorded_count: 0 };
|
|
182
|
+
const responseBody = await readResponseJson(response);
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
throw new Error(responseErrorMessage(responseBody, `Ambient ingest failed with HTTP ${response.status}`));
|
|
788
185
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
const byPointerId = new Map();
|
|
792
|
-
for (const outcome of options.outcomes) {
|
|
793
|
-
if (outcome.upload_state === "upload_failed" || !outcome.artifact_metadata) {
|
|
794
|
-
continue;
|
|
795
|
-
}
|
|
796
|
-
const artifact = agentArtifactFromMetadata({
|
|
797
|
-
metadata: outcome.artifact_metadata,
|
|
798
|
-
rawEvidencePointerId: outcome.pointer.raw_evidence_pointer_id,
|
|
799
|
-
objectKey: outcome.object_key,
|
|
800
|
-
ticketId: options.ticketId,
|
|
801
|
-
repoFingerprint: options.repoFingerprint,
|
|
802
|
-
worktreeFingerprint: options.worktreeFingerprint,
|
|
803
|
-
uploadState: outcome.upload_state,
|
|
804
|
-
});
|
|
805
|
-
byPointerId.set(artifact.raw_evidence_pointer_id, artifact);
|
|
806
|
-
}
|
|
807
|
-
for (const entry of options.reused) {
|
|
808
|
-
if (!entry.artifact_metadata)
|
|
809
|
-
continue;
|
|
810
|
-
const objectKey = options.cursorObjects[entry.content_hash_sha256]?.object_key;
|
|
811
|
-
if (!objectKey)
|
|
812
|
-
continue;
|
|
813
|
-
const artifact = agentArtifactFromMetadata({
|
|
814
|
-
metadata: entry.artifact_metadata,
|
|
815
|
-
rawEvidencePointerId: objectKey,
|
|
816
|
-
objectKey,
|
|
817
|
-
ticketId: options.ticketId,
|
|
818
|
-
repoFingerprint: options.repoFingerprint,
|
|
819
|
-
worktreeFingerprint: options.worktreeFingerprint,
|
|
820
|
-
uploadState: "reused_existing",
|
|
821
|
-
});
|
|
822
|
-
byPointerId.set(artifact.raw_evidence_pointer_id, artifact);
|
|
823
|
-
}
|
|
824
|
-
return [...byPointerId.values()];
|
|
825
|
-
}
|
|
826
|
-
function agentArtifactFromMetadata(options) {
|
|
827
|
-
return {
|
|
828
|
-
raw_evidence_pointer_id: options.rawEvidencePointerId,
|
|
829
|
-
agent_source: options.metadata.agent_source,
|
|
830
|
-
source_session_id: options.metadata.source_session_id,
|
|
831
|
-
...(options.metadata.source_message_id
|
|
832
|
-
? { source_message_id: options.metadata.source_message_id }
|
|
833
|
-
: {}),
|
|
834
|
-
...(options.metadata.turn_index !== undefined
|
|
835
|
-
? { turn_index: options.metadata.turn_index }
|
|
836
|
-
: {}),
|
|
837
|
-
occurred_at: options.metadata.occurred_at,
|
|
838
|
-
artifact_kind: options.metadata.artifact_kind,
|
|
839
|
-
capture_origin: "agent_session_attachment",
|
|
840
|
-
...(options.ticketId ? { ticket_id: options.ticketId } : {}),
|
|
841
|
-
...(options.repoFingerprint
|
|
842
|
-
? { repo_fingerprint: options.repoFingerprint }
|
|
843
|
-
: {}),
|
|
844
|
-
...(options.worktreeFingerprint
|
|
845
|
-
? { worktree_fingerprint: options.worktreeFingerprint }
|
|
846
|
-
: {}),
|
|
847
|
-
storage_bucket: "ambient-raw-evidence",
|
|
848
|
-
object_key: options.objectKey,
|
|
849
|
-
content_hash_sha256: options.metadata.content_hash_sha256,
|
|
850
|
-
byte_size: options.metadata.byte_size,
|
|
851
|
-
media_type: options.metadata.media_type,
|
|
852
|
-
width: options.metadata.width,
|
|
853
|
-
height: options.metadata.height,
|
|
854
|
-
redaction_status: "not_started",
|
|
855
|
-
ocr_status: "not_started",
|
|
856
|
-
labels: {
|
|
857
|
-
collector_upload_state: options.uploadState,
|
|
858
|
-
...(options.metadata.source_sidecar_id
|
|
859
|
-
? { source_sidecar_id: options.metadata.source_sidecar_id }
|
|
860
|
-
: {}),
|
|
861
|
-
},
|
|
862
|
-
};
|
|
863
|
-
}
|
|
864
|
-
function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
|
|
865
|
-
const cursorReused = built.raw_evidence_facts?.reused ?? [];
|
|
866
|
-
const serverReusedCount = outcomes.filter((outcome) => outcome.upload_state === "reused_existing").length;
|
|
867
|
-
const failed = outcomes.filter((outcome) => outcome.upload_state === "upload_failed");
|
|
868
|
-
const retryRequired = hasRetryableEvidenceGap(built.raw_evidence_facts, outcomes);
|
|
869
|
-
const retryReason = retryRequired
|
|
870
|
-
? retryableEvidenceGapReason(built.raw_evidence_facts, outcomes)
|
|
871
|
-
: null;
|
|
872
|
-
const cursorReusedOutcomes = cursorReused.map((entry) => ({
|
|
873
|
-
object_key: cursor.objects[entry.content_hash_sha256]?.object_key ?? "",
|
|
874
|
-
raw_evidence_pointer_id: cursor.objects[entry.content_hash_sha256]?.object_key ?? "",
|
|
875
|
-
kind: entry.kind,
|
|
876
|
-
codex_session_id: entry.codex_session_id,
|
|
877
|
-
...(entry.artifact_metadata
|
|
878
|
-
? { artifact_metadata: entry.artifact_metadata }
|
|
879
|
-
: {}),
|
|
880
|
-
upload_state: "reused_existing",
|
|
881
|
-
reason: "cursor_content_match",
|
|
882
|
-
}));
|
|
883
|
-
return {
|
|
884
|
-
raw_evidence_file_count: built.raw_evidence_upload_files.length,
|
|
885
|
-
raw_evidence_uploaded_object_count: outcomes.filter((outcome) => outcome.upload_state === "uploaded").length,
|
|
886
|
-
raw_evidence_uploaded_chunk_count: uploadedChunkCount,
|
|
887
|
-
raw_evidence_reused_count: cursorReused.length + serverReusedCount,
|
|
888
|
-
raw_evidence_failed_count: failed.length,
|
|
889
|
-
raw_evidence_sanitized_count: built.raw_evidence_facts?.sanitized_count ?? 0,
|
|
890
|
-
raw_evidence_failure_reasons: [
|
|
891
|
-
...new Set(failed.map((outcome) => outcome.reason ?? "unknown")),
|
|
892
|
-
],
|
|
893
|
-
raw_evidence_retry_required: retryRequired,
|
|
894
|
-
raw_evidence_retry_reasons: retryReason
|
|
895
|
-
? retryReason
|
|
896
|
-
.replace(/^partial_raw_evidence_retry_required:/u, "")
|
|
897
|
-
.split(",")
|
|
898
|
-
.filter(Boolean)
|
|
899
|
-
: [],
|
|
900
|
-
raw_evidence_outcomes: [
|
|
901
|
-
...outcomes.map((outcome) => ({
|
|
902
|
-
object_key: outcome.object_key,
|
|
903
|
-
raw_evidence_pointer_id: outcome.pointer.raw_evidence_pointer_id,
|
|
904
|
-
kind: outcome.kind,
|
|
905
|
-
codex_session_id: outcome.codex_session_id,
|
|
906
|
-
...(outcome.artifact_metadata
|
|
907
|
-
? { artifact_metadata: outcome.artifact_metadata }
|
|
908
|
-
: {}),
|
|
909
|
-
upload_state: outcome.upload_state,
|
|
910
|
-
reason: outcome.reason,
|
|
911
|
-
})),
|
|
912
|
-
...cursorReusedOutcomes,
|
|
913
|
-
],
|
|
914
|
-
raw_evidence_deferred_byte_budget: built.raw_evidence_facts?.deferred_byte_budget_count ?? 0,
|
|
915
|
-
raw_evidence_deferred_object_budget: built.raw_evidence_facts?.deferred_object_budget_count ?? 0,
|
|
916
|
-
cursor_tracked_object_count: Object.keys(cursor.objects).length,
|
|
917
|
-
};
|
|
186
|
+
assertAmbientIngestReceipt(response, responseBody, options.envelope);
|
|
187
|
+
return response;
|
|
918
188
|
}
|
|
919
189
|
/**
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
* redaction metadata; apply those before ingest so refs describe the bytes
|
|
924
|
-
* actually stored in the durable bucket.
|
|
190
|
+
* Remember every object that is now durable, so the next sync can reuse the
|
|
191
|
+
* bytes instead of re-uploading them. A failed upload is deliberately absent:
|
|
192
|
+
* recording it would make the next sync skip a file that never landed.
|
|
925
193
|
*/
|
|
926
|
-
function
|
|
927
|
-
// Content-addressed keys mean one pointer id can carry several outcomes
|
|
928
|
-
// (byte-identical files); the pointer is durable if ANY outcome succeeded.
|
|
929
|
-
const durablePointers = new Map();
|
|
194
|
+
function advanceRawEvidenceCursor(cursor, outcomes, attemptedAt) {
|
|
930
195
|
for (const outcome of outcomes) {
|
|
931
196
|
if (outcome.upload_state === "upload_failed")
|
|
932
197
|
continue;
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const durablePointerIds = new Set([...durablePointers.keys()]);
|
|
936
|
-
const failedPointerIds = new Set(outcomes
|
|
937
|
-
.filter((outcome) => outcome.upload_state === "upload_failed" &&
|
|
938
|
-
!durablePointerIds.has(outcome.pointer.raw_evidence_pointer_id))
|
|
939
|
-
.map((outcome) => outcome.pointer.raw_evidence_pointer_id));
|
|
940
|
-
const failedOutcomes = outcomes.filter((outcome) => failedPointerIds.has(outcome.pointer.raw_evidence_pointer_id));
|
|
941
|
-
if (failedPointerIds.size === 0 && durablePointers.size === 0)
|
|
942
|
-
return envelope;
|
|
943
|
-
return {
|
|
944
|
-
...envelope,
|
|
945
|
-
events: envelope.events.map((event) => ({
|
|
946
|
-
...event,
|
|
947
|
-
metrics: failedPointerIds.size > 0
|
|
948
|
-
? {
|
|
949
|
-
...event.metrics,
|
|
950
|
-
evidence_failed_count: (event.metrics["evidence_failed_count"] ?? 0) +
|
|
951
|
-
failedOutcomes.length,
|
|
952
|
-
}
|
|
953
|
-
: event.metrics,
|
|
954
|
-
attributes: failedPointerIds.size > 0 && event.evidence_completeness
|
|
955
|
-
? {
|
|
956
|
-
...event.attributes,
|
|
957
|
-
evidence_completeness_schema_version: event.evidence_completeness.schema_version,
|
|
958
|
-
evidence_completeness_status: "partial",
|
|
959
|
-
evidence_incomplete: true,
|
|
960
|
-
}
|
|
961
|
-
: event.attributes,
|
|
962
|
-
evidence_completeness: failedPointerIds.size > 0 && event.evidence_completeness
|
|
963
|
-
? markCompletenessUploadFailures(event.evidence_completeness, failedOutcomes)
|
|
964
|
-
: event.evidence_completeness,
|
|
965
|
-
raw_evidence_pointers: event.raw_evidence_pointers.flatMap((pointer) => {
|
|
966
|
-
const pointerId = pointer.raw_evidence_pointer_id;
|
|
967
|
-
if (failedPointerIds.has(pointerId))
|
|
968
|
-
return [];
|
|
969
|
-
return [durablePointers.get(pointerId) ?? pointer];
|
|
970
|
-
}),
|
|
971
|
-
redaction: failedPointerIds.size > 0
|
|
972
|
-
? {
|
|
973
|
-
...event.redaction,
|
|
974
|
-
raw_evidence_pointer_ids: event.redaction.raw_evidence_pointer_ids.filter((pointerId) => !failedPointerIds.has(pointerId)),
|
|
975
|
-
}
|
|
976
|
-
: event.redaction,
|
|
977
|
-
})),
|
|
978
|
-
};
|
|
979
|
-
}
|
|
980
|
-
function markCompletenessUploadFailures(completeness, failedOutcomes) {
|
|
981
|
-
const failureCounts = new Map();
|
|
982
|
-
for (const outcome of failedOutcomes) {
|
|
983
|
-
const source = outcome.kind ?? "raw_evidence";
|
|
984
|
-
failureCounts.set(source, (failureCounts.get(source) ?? 0) + 1);
|
|
985
|
-
}
|
|
986
|
-
const totalFailures = [...failureCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
987
|
-
const sourceCounts = [...completeness.source_counts];
|
|
988
|
-
for (const [source, count] of failureCounts) {
|
|
989
|
-
const existingIndex = sourceCounts.findIndex((entry) => entry.source === source);
|
|
990
|
-
if (existingIndex === -1) {
|
|
991
|
-
sourceCounts.push({
|
|
992
|
-
source,
|
|
993
|
-
scanned_count: 0,
|
|
994
|
-
included_count: 0,
|
|
995
|
-
skipped_count: 0,
|
|
996
|
-
truncated_count: 0,
|
|
997
|
-
deferred_count: 0,
|
|
998
|
-
reused_count: 0,
|
|
999
|
-
failed_count: count,
|
|
1000
|
-
});
|
|
1001
|
-
continue;
|
|
1002
|
-
}
|
|
1003
|
-
const existing = sourceCounts[existingIndex];
|
|
1004
|
-
if (!existing)
|
|
198
|
+
const contentHash = outcome.pointer.content_hash_sha256;
|
|
199
|
+
if (!contentHash)
|
|
1005
200
|
continue;
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
const failureReasons = [...completeness.failure_reasons];
|
|
1012
|
-
for (const [source, count] of failureCounts) {
|
|
1013
|
-
const reason = "upload_failed";
|
|
1014
|
-
const existingIndex = failureReasons.findIndex((entry) => entry.source === source && entry.reason === reason);
|
|
1015
|
-
if (existingIndex === -1) {
|
|
1016
|
-
failureReasons.push({ source, reason, count });
|
|
1017
|
-
}
|
|
1018
|
-
else {
|
|
1019
|
-
const existing = failureReasons[existingIndex];
|
|
1020
|
-
if (existing) {
|
|
1021
|
-
failureReasons[existingIndex] = {
|
|
1022
|
-
...existing,
|
|
1023
|
-
count: existing.count + count,
|
|
1024
|
-
};
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
201
|
+
markObjectCommitted(cursor, contentHash, {
|
|
202
|
+
object_key: outcome.object_key,
|
|
203
|
+
byte_size: outcome.pointer.byte_size ?? 0,
|
|
204
|
+
committed_at: attemptedAt,
|
|
205
|
+
});
|
|
1027
206
|
}
|
|
1028
|
-
|
|
1029
|
-
...completeness,
|
|
1030
|
-
status: "partial",
|
|
1031
|
-
source_counts: sourceCounts,
|
|
1032
|
-
totals: {
|
|
1033
|
-
...completeness.totals,
|
|
1034
|
-
failed_count: completeness.totals.failed_count + totalFailures,
|
|
1035
|
-
},
|
|
1036
|
-
failure_reasons: failureReasons.sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
|
|
1037
|
-
notes: [
|
|
1038
|
-
...new Set([
|
|
1039
|
-
...completeness.notes,
|
|
1040
|
-
"Some collected evidence did not become durable; downstream analysis should lower confidence.",
|
|
1041
|
-
]),
|
|
1042
|
-
],
|
|
1043
|
-
});
|
|
207
|
+
cursor.updated_at = attemptedAt;
|
|
1044
208
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
209
|
+
/**
|
|
210
|
+
* What the operator is owed after ingest accepted the envelope, as a decision
|
|
211
|
+
* table on the evidence this sync left behind:
|
|
212
|
+
*
|
|
213
|
+
* | evidence state | ledger |
|
|
214
|
+
* |---------------------------------------|-------------------------------------|
|
|
215
|
+
* | a gap a later sync could rescue | success, then a queued retry naming why |
|
|
216
|
+
* | a gap no retry can rescue | success, then a blocker naming why, plus a stderr record |
|
|
217
|
+
* | nothing missing | success, pending state cleared |
|
|
218
|
+
*
|
|
219
|
+
* The middle row is the one that matters. A permanently rejected object gets no
|
|
220
|
+
* retry — nothing would change — but it must still be named, or `cockpit status`
|
|
221
|
+
* reads clean on a machine with missing collection (BLI-2528).
|
|
222
|
+
*/
|
|
223
|
+
async function recordIngestedSyncOutcome(options) {
|
|
224
|
+
const { paths, built, uploadOutcomes, attemptedAt } = options;
|
|
225
|
+
const retryableEvidenceGap = hasRetryableEvidenceGap(built.raw_evidence_facts, uploadOutcomes);
|
|
226
|
+
await recordUploadSuccess(paths, {
|
|
227
|
+
attemptedAt,
|
|
228
|
+
workContextId: built.envelope.work_context.work_context_id,
|
|
229
|
+
clearPendingForContext: !retryableEvidenceGap,
|
|
1050
230
|
});
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
? "redacted_summary"
|
|
1076
|
-
: "metadata";
|
|
1077
|
-
return TelemetryIngestEventDtoSchema.parse({
|
|
1078
|
-
event_id: `ambient-sync:${options.context.work_context_id}:${options.generatedAt}`,
|
|
1079
|
-
event_type: "source_scan_completed",
|
|
1080
|
-
occurred_at: options.generatedAt,
|
|
1081
|
-
provenance: options.context.provenance,
|
|
1082
|
-
privacy_classification: eventPrivacyClassification,
|
|
1083
|
-
redaction: {
|
|
1084
|
-
privacy_classification: eventPrivacyClassification,
|
|
1085
|
-
redaction_status: hasRawEvidence
|
|
1086
|
-
? "raw_remote_durable"
|
|
1087
|
-
: "metadata_only",
|
|
1088
|
-
redacted_fields: [
|
|
1089
|
-
"prompt_body",
|
|
1090
|
-
"response_body",
|
|
1091
|
-
"diff_body",
|
|
1092
|
-
"transcript_body",
|
|
1093
|
-
"git.changed_paths",
|
|
1094
|
-
"local_file_paths",
|
|
1095
|
-
],
|
|
1096
|
-
raw_evidence_pointer_ids: rawEvidencePointers.map((pointer) => pointer.raw_evidence_pointer_id),
|
|
1097
|
-
redacted_summary: hasRawEvidence
|
|
1098
|
-
? "Collector uploaded raw evidence objects separately and sent only references, hashes, source scan, binding, and risk summaries to ingest."
|
|
1099
|
-
: "Collector uploaded metadata-only local work, source scan, binding, and risk summaries.",
|
|
1100
|
-
},
|
|
1101
|
-
redacted_summary: hasRawEvidence
|
|
1102
|
-
? "Collector uploaded raw evidence objects separately, then sent evidence references and metadata summaries."
|
|
1103
|
-
: "Collector uploaded metadata-only source scan, ticket binding, and risk summaries.",
|
|
1104
|
-
metrics: {
|
|
1105
|
-
git_changed_file_count: options.gitChangedFileCount,
|
|
1106
|
-
git_added_lines: options.gitAddedLines,
|
|
1107
|
-
git_deleted_lines: options.gitDeletedLines,
|
|
1108
|
-
car_open_ticket_count: options.carOpenTicketCount,
|
|
1109
|
-
source_scan_count: options.scans.length,
|
|
1110
|
-
risk_flag_count: options.riskFlags.length,
|
|
1111
|
-
raw_evidence_file_count: options.rawEvidenceFacts?.file_count ?? 0,
|
|
1112
|
-
raw_evidence_byte_size: options.rawEvidenceFacts?.byte_size ?? 0,
|
|
1113
|
-
raw_evidence_skipped_count: options.rawEvidenceFacts?.skipped_count ?? 0,
|
|
1114
|
-
raw_evidence_sanitized_count: options.rawEvidenceFacts?.sanitized_count ?? 0,
|
|
1115
|
-
raw_evidence_reused_count: options.rawEvidenceFacts?.reused_count ?? 0,
|
|
1116
|
-
evidence_scanned_count: evidenceCompleteness?.totals.scanned_count ?? 0,
|
|
1117
|
-
evidence_included_count: evidenceCompleteness?.totals.included_count ?? 0,
|
|
1118
|
-
evidence_skipped_count: evidenceCompleteness?.totals.skipped_count ?? 0,
|
|
1119
|
-
evidence_truncated_count: evidenceCompleteness?.totals.truncated_count ?? 0,
|
|
1120
|
-
evidence_deferred_count: evidenceCompleteness?.totals.deferred_count ?? 0,
|
|
1121
|
-
evidence_reused_count: evidenceCompleteness?.totals.reused_count ?? 0,
|
|
1122
|
-
evidence_failed_count: evidenceCompleteness?.totals.failed_count ?? 0,
|
|
1123
|
-
},
|
|
1124
|
-
attributes: {
|
|
1125
|
-
repo_label: options.context.repo,
|
|
1126
|
-
repo_fingerprint: options.context.repo_fingerprint ?? "unknown",
|
|
1127
|
-
worktree_label: options.context.worktree_label ?? "unknown",
|
|
1128
|
-
worktree_fingerprint: options.context.worktree_fingerprint ?? "unknown",
|
|
1129
|
-
worktree_is_primary: options.context.worktree_is_primary ?? false,
|
|
1130
|
-
branch: options.context.branch,
|
|
1131
|
-
ticket_binding_state: options.binding.state,
|
|
1132
|
-
ticket_binding_source: options.binding.selected_source ?? "none",
|
|
1133
|
-
ticket_id: options.binding.selected_ticket_id ?? "unbound",
|
|
1134
|
-
source_adapters: options.scans.map((scan) => scan.adapter.adapter_name),
|
|
1135
|
-
source_statuses: options.scans.map((scan) => `${scan.adapter.adapter_name}:${scan.status}`),
|
|
1136
|
-
redaction_mode: hasRawEvidence
|
|
1137
|
-
? "remote_durable_raw_evidence"
|
|
1138
|
-
: "metadata_only",
|
|
1139
|
-
raw_payload_included: false,
|
|
1140
|
-
evidence_completeness_schema_version: evidenceCompleteness?.schema_version ?? "evidence-completeness.v1",
|
|
1141
|
-
evidence_completeness_status: evidenceCompleteness?.status ?? "unknown",
|
|
1142
|
-
evidence_incomplete: evidenceCompleteness
|
|
1143
|
-
? evidenceCompleteness.status !== "complete"
|
|
1144
|
-
: true,
|
|
1145
|
-
...(options.context.topic_label
|
|
1146
|
-
? { topic_label: options.context.topic_label }
|
|
1147
|
-
: {}),
|
|
1148
|
-
...(options.context.topic_summary_redacted
|
|
1149
|
-
? { topic_summary_redacted: options.context.topic_summary_redacted }
|
|
1150
|
-
: {}),
|
|
1151
|
-
...(options.context.work_intent
|
|
1152
|
-
? { work_intent: options.context.work_intent }
|
|
1153
|
-
: {}),
|
|
1154
|
-
...(options.context.work_phase
|
|
1155
|
-
? { work_phase: options.context.work_phase }
|
|
1156
|
-
: {}),
|
|
1157
|
-
...(options.context.intent_source
|
|
1158
|
-
? { intent_source: options.context.intent_source }
|
|
1159
|
-
: {}),
|
|
1160
|
-
...(options.context.intent_confidence !== undefined
|
|
1161
|
-
? { intent_confidence: options.context.intent_confidence }
|
|
1162
|
-
: {}),
|
|
1163
|
-
},
|
|
1164
|
-
evidence_completeness: evidenceCompleteness,
|
|
1165
|
-
ticket_binding: options.ticketBinding ?? undefined,
|
|
1166
|
-
risk_flags: options.riskFlags,
|
|
1167
|
-
raw_evidence_pointers: rawEvidencePointers,
|
|
231
|
+
if (retryableEvidenceGap) {
|
|
232
|
+
await recordUploadFailure(paths, {
|
|
233
|
+
last_attempt_at: attemptedAt,
|
|
234
|
+
dashboard_url: built.dashboard_url,
|
|
235
|
+
work_context_id: built.envelope.work_context.work_context_id,
|
|
236
|
+
ticket_id: built.ticket_id,
|
|
237
|
+
repo_label: built.repo_label,
|
|
238
|
+
branch: built.envelope.work_context.branch,
|
|
239
|
+
event_count: built.event_count,
|
|
240
|
+
source_scan_count: built.source_scan_count,
|
|
241
|
+
risk_flag_count: built.risk_flag_count,
|
|
242
|
+
raw_evidence_file_count: built.raw_evidence_upload_files.length,
|
|
243
|
+
retry_sources: retrySourcesForFailedSync(options.options, built.raw_evidence_facts),
|
|
244
|
+
failure_reason: retryableEvidenceGapReason(built.raw_evidence_facts, uploadOutcomes),
|
|
245
|
+
retry_command: "cockpit sync",
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const permanentReason = permanentEvidenceFailureReason(uploadOutcomes);
|
|
250
|
+
if (!permanentReason)
|
|
251
|
+
return;
|
|
252
|
+
await recordUploadBlocked(paths, {
|
|
253
|
+
attemptedAt,
|
|
254
|
+
reason: permanentReason,
|
|
1168
255
|
});
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
1174
|
-
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
1175
|
-
repo: options.repoLabel,
|
|
1176
|
-
branch: options.context.branch,
|
|
1177
|
-
repo_label: options.context.repo_label ?? options.repoLabel,
|
|
1178
|
-
repo_fingerprint: options.context.repo_fingerprint,
|
|
1179
|
-
repo_origin_url: options.context.repo_origin_url,
|
|
1180
|
-
worktree_label: options.context.worktree_label,
|
|
1181
|
-
worktree_fingerprint: options.context.worktree_fingerprint,
|
|
1182
|
-
worktree_is_primary: options.context.worktree_is_primary,
|
|
1183
|
-
operator_id: options.session.operator_id,
|
|
1184
|
-
session_id: options.session.session_id,
|
|
1185
|
-
work_context_id: options.context.work_context_id,
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
function sanitizeSessionReference(session) {
|
|
1189
|
-
return {
|
|
1190
|
-
...session,
|
|
1191
|
-
session_file_path: "local-session-file",
|
|
1192
|
-
};
|
|
1193
|
-
}
|
|
1194
|
-
function sanitizeSourceScanResults(scans, repoLabel) {
|
|
1195
|
-
return scans.map((scan) => ({
|
|
1196
|
-
...scan,
|
|
1197
|
-
diagnostic_labels: scan.diagnostic_labels.map(sanitizeDiagnosticLabel),
|
|
1198
|
-
events: scan.events.map((event) => ({
|
|
1199
|
-
...event,
|
|
1200
|
-
raw_evidence_pointers: [],
|
|
1201
|
-
redaction: {
|
|
1202
|
-
...event.redaction,
|
|
1203
|
-
raw_evidence_pointer_ids: [],
|
|
1204
|
-
},
|
|
1205
|
-
})),
|
|
1206
|
-
risk_flags: scan.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel)),
|
|
1207
|
-
}));
|
|
1208
|
-
}
|
|
1209
|
-
function sanitizeDiagnosticLabel(label) {
|
|
1210
|
-
if (label.includes("\n") || label.includes("/") || label.includes("\\")) {
|
|
1211
|
-
const prefix = label.split(":", 1)[0]?.trim();
|
|
1212
|
-
return prefix ? `${prefix}:redacted` : "diagnostic_redacted";
|
|
1213
|
-
}
|
|
1214
|
-
return label.length > 160 ? `${label.slice(0, 157)}...` : label;
|
|
1215
|
-
}
|
|
1216
|
-
function sanitizeRiskFlag(flag, repoLabel) {
|
|
1217
|
-
return {
|
|
1218
|
-
...flag,
|
|
1219
|
-
provenance: {
|
|
1220
|
-
...flag.provenance,
|
|
1221
|
-
repo: repoLabel,
|
|
1222
|
-
},
|
|
1223
|
-
};
|
|
1224
|
-
}
|
|
1225
|
-
function selectedTicketBindingCandidate(binding) {
|
|
1226
|
-
if (!binding.selected_ticket_id || !binding.selected_source)
|
|
1227
|
-
return null;
|
|
1228
|
-
return (binding.candidates.find((candidate) => candidate.ticket_id === binding.selected_ticket_id &&
|
|
1229
|
-
candidate.binding_source === binding.selected_source) ?? {
|
|
1230
|
-
ticket_id: binding.selected_ticket_id,
|
|
1231
|
-
binding_source: binding.selected_source,
|
|
1232
|
-
confidence: 1,
|
|
1233
|
-
evidence_labels: [`bound_by:${binding.selected_source}`],
|
|
256
|
+
logPermanentlyRejectedEvidence({
|
|
257
|
+
reason: permanentReason,
|
|
258
|
+
outcomes: uploadOutcomes,
|
|
259
|
+
attemptedAt,
|
|
1234
260
|
});
|
|
1235
261
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
catch {
|
|
1244
|
-
return { message: text };
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
function responseErrorMessage(value, fallback) {
|
|
1248
|
-
if (value && typeof value === "object") {
|
|
1249
|
-
const record = value;
|
|
1250
|
-
const message = record["message"] ?? record["error"];
|
|
1251
|
-
if (typeof message === "string" && message.trim())
|
|
1252
|
-
return message;
|
|
1253
|
-
}
|
|
1254
|
-
return fallback;
|
|
1255
|
-
}
|
|
262
|
+
/**
|
|
263
|
+
* The receipt that proves ingest persisted exactly what was submitted.
|
|
264
|
+
*
|
|
265
|
+
* Anything short of a 202 whose counts match the envelope's own totals is
|
|
266
|
+
* treated as no receipt at all — a proxy's 200, a truncated body, or a
|
|
267
|
+
* dashboard that accepted fewer rows than were sent all land here.
|
|
268
|
+
*/
|
|
1256
269
|
function assertAmbientIngestReceipt(response, value, envelope) {
|
|
1257
270
|
if (response.status !== 202 || !value || typeof value !== "object") {
|
|
1258
271
|
throw new Error("Ambient ingest returned an invalid durable receipt.");
|
|
@@ -1272,23 +285,4 @@ function assertAmbientIngestReceipt(response, value, envelope) {
|
|
|
1272
285
|
ingest["evidence_ref_count"] !== expectedEvidenceRefCount) {
|
|
1273
286
|
throw new Error("Ambient ingest returned an incomplete durable receipt.");
|
|
1274
287
|
}
|
|
1275
|
-
}
|
|
1276
|
-
function isNonEmptyString(value) {
|
|
1277
|
-
return typeof value === "string" && value.trim().length > 0;
|
|
1278
|
-
}
|
|
1279
|
-
function normalizeDashboardUrl(value) {
|
|
1280
|
-
const normalized = value.trim().replace(/\/+$/, "");
|
|
1281
|
-
if (!normalized)
|
|
1282
|
-
throw new Error("Dashboard URL cannot be empty.");
|
|
1283
|
-
return normalized;
|
|
1284
|
-
}
|
|
1285
|
-
function safeRepoLabel(repoRoot) {
|
|
1286
|
-
const basename = path.basename(repoRoot.replace(/[\\/]+$/, ""));
|
|
1287
|
-
return basename || "repo";
|
|
1288
|
-
}
|
|
1289
|
-
function rawEvidenceObjectKeyBelongsToWorkContext(objectKey, context) {
|
|
1290
|
-
const legacyPrefix = `${context.operatorId}/${context.workContextId}/`;
|
|
1291
|
-
const readableIdGuard = `/ids/${context.operatorId}/${context.workContextId}/`;
|
|
1292
|
-
return (objectKey.startsWith(legacyPrefix) ||
|
|
1293
|
-
(objectKey.startsWith("operators/") && objectKey.includes(readableIdGuard)));
|
|
1294
288
|
}
|