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