@bli-cockpit/cli 0.2.56 → 0.2.57
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/commands/agent-door.js +85 -0
- package/dist/commands/docs.js +227 -0
- package/dist/commands/local-args-tower-docs-msg.js +126 -0
- package/dist/commands/local-args-tower.js +4 -1
- package/dist/commands/local-args.js +5 -1
- package/dist/commands/local-help.js +36 -0
- package/dist/commands/local.js +6 -0
- package/dist/commands/mcp-bin-resolve.js +102 -0
- package/dist/commands/memory-install-claude.js +13 -5
- package/dist/commands/memory-install.js +42 -102
- package/dist/commands/msg.js +188 -0
- package/dist/commands/ops-render.js +6 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/tower-mcp-claude.js +30 -0
- package/dist/commands/tower-mcp-codex.js +100 -0
- package/dist/commands/tower-mcp-contract.js +39 -0
- package/dist/commands/tower-mcp-install.js +75 -0
- package/dist/upload-envelope-build.js +240 -0
- package/dist/upload-envelope-event.js +198 -0
- package/dist/upload-envelope.js +16 -427
- package/dist/upload-ingest-receipt.js +121 -0
- package/dist/upload-session-reports-queue.js +156 -0
- package/dist/upload-session-reports-wire.js +275 -0
- package/dist/upload-session-reports.js +14 -425
- package/dist/upload-sync.js +291 -0
- package/dist/upload.js +24 -396
- package/package.json +3 -2
package/dist/upload-envelope.js
CHANGED
|
@@ -3,432 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Table of contents:
|
|
5
5
|
*
|
|
6
|
-
* - `
|
|
7
|
-
* all, each carrying the command that fixes
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
6
|
+
* - `upload-envelope-build.ts` — `LocalUploadBlockedError` (the three reasons
|
|
7
|
+
* this machine cannot upload at all, each carrying the command that fixes
|
|
8
|
+
* it) and `buildLocalAmbientEnvelope`, the entry point: prove the collector
|
|
9
|
+
* is installed and paired, find the work context for this repo, run the
|
|
10
|
+
* source collectors, sanitize what came back, parse the envelope. Also the
|
|
11
|
+
* work-context and provenance helpers (`makeUploadWorkContext`,
|
|
12
|
+
* `makeCollectorProvenance`, `safeRepoLabel`) that `upload-session-reports.ts`
|
|
13
|
+
* reuses directly.
|
|
14
|
+
* - `upload-envelope-event.ts` — the `source_scan_completed` event, the single
|
|
15
|
+
* event every sync sends, plus every sanitizer that keeps local paths, long
|
|
16
|
+
* diagnostic strings and nested pointers out of the envelope before it
|
|
17
|
+
* leaves the machine.
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
* file
|
|
19
|
+
* Every public name below is still importable from `./upload-envelope.js`
|
|
20
|
+
* regardless of which sibling it now lives in. Nothing in this file performs
|
|
21
|
+
* network I/O — `upload.ts` delivers what this file assembles.
|
|
20
22
|
*/
|
|
21
|
-
|
|
22
|
-
import path from "node:path";
|
|
23
|
-
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
|
|
24
|
-
import { runLocalSourceCollectors } from "./adapters/local-sources.js";
|
|
25
|
-
import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
|
|
26
|
-
import { normalizeDashboardUrl } from "./upload-http.js";
|
|
27
|
-
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
28
|
-
export class LocalUploadBlockedError extends Error {
|
|
29
|
-
blocker;
|
|
30
|
-
retry_hint;
|
|
31
|
-
constructor(blocker, message, retryHint) {
|
|
32
|
-
super(message);
|
|
33
|
-
this.name = "LocalUploadBlockedError";
|
|
34
|
-
this.blocker = blocker;
|
|
35
|
-
this.retry_hint = retryHint;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
export async function buildLocalAmbientEnvelope(options = {}) {
|
|
39
|
-
const now = options.now ?? new Date();
|
|
40
|
-
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
41
|
-
const collector = await readPairedCollector(paths);
|
|
42
|
-
const { config, sessionFile, session } = collector;
|
|
43
|
-
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
44
|
-
const activeContext = await readLocalWorkContextForRepo(paths, repoRoot).catch((error) => {
|
|
45
|
-
// The operator is told "run `cockpit start`", which is right when the file
|
|
46
|
-
// is simply absent and wrong when it exists and will not parse — the same
|
|
47
|
-
// advice, forever, on a machine that has already run it (BLI-3238).
|
|
48
|
-
if (!isMissingFileFailure(error)) {
|
|
49
|
-
console.error("[upload-envelope] work context present but unreadable, reporting it as missing", JSON.stringify({
|
|
50
|
-
reason: "missing_context",
|
|
51
|
-
...describeError(error),
|
|
52
|
-
}));
|
|
53
|
-
}
|
|
54
|
-
throw new LocalUploadBlockedError("missing_context", "Active work context missing. Run `cockpit start --workspace \"$PWD\"` before `cockpit sync`.", "cockpit start --workspace \"$PWD\"");
|
|
55
|
-
});
|
|
56
|
-
const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
|
|
57
|
-
const uploadContext = makeUploadWorkContext({
|
|
58
|
-
activeContext,
|
|
59
|
-
session,
|
|
60
|
-
repoLabel,
|
|
61
|
-
now,
|
|
62
|
-
});
|
|
63
|
-
const sourceCollection = await runLocalSourceCollectors({
|
|
64
|
-
repoRoot,
|
|
65
|
-
branch: uploadContext.branch,
|
|
66
|
-
operatorId: session.operator_id,
|
|
67
|
-
operatorLabel: session.email ?? session.auth_subject_id,
|
|
68
|
-
sessionId: session.session_id,
|
|
69
|
-
workContextId: uploadContext.work_context_id,
|
|
70
|
-
activeWorkContext: activeContext,
|
|
71
|
-
rawEvidenceStateDir: paths.state_dir,
|
|
72
|
-
rawEvidenceSessionsDirs: defaultCodexSessionDirs(paths.home_dir),
|
|
73
|
-
claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
|
|
74
|
-
rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
|
|
75
|
-
rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
|
|
76
|
-
rawEvidenceCodexSessionFiles: options.codexSessionFiles,
|
|
77
|
-
rawEvidenceCodexAttributionScan: options.codexAttributionScan,
|
|
78
|
-
rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
|
|
79
|
-
rawEvidenceClaudeAttributionScan: options.claudeAttributionScan,
|
|
80
|
-
rawEvidenceSkipContentHashes: reusableContentHashes(options, {
|
|
81
|
-
operatorId: session.operator_id,
|
|
82
|
-
workContextId: uploadContext.work_context_id,
|
|
83
|
-
}),
|
|
84
|
-
rawEvidenceByteBudget: options.rawEvidenceByteBudget,
|
|
85
|
-
rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
|
|
86
|
-
rawEvidenceBudget: options.rawEvidenceBudget,
|
|
87
|
-
rawEvidenceDeliveryMode: options.evidenceDeliveryMode,
|
|
88
|
-
now,
|
|
89
|
-
});
|
|
90
|
-
const binding = sourceCollection.binding;
|
|
91
|
-
const ticketBinding = selectedTicketBindingCandidate(binding);
|
|
92
|
-
const uploadWorkContext = {
|
|
93
|
-
...uploadContext,
|
|
94
|
-
active_ticket_id: binding.selected_ticket_id ?? undefined,
|
|
95
|
-
ticket_binding_candidates: ticketBinding ? [ticketBinding] : binding.candidates,
|
|
96
|
-
};
|
|
97
|
-
const safeRiskFlags = sourceCollection.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel));
|
|
98
|
-
const events = [
|
|
99
|
-
makeSourceScanCompletedEvent({
|
|
100
|
-
context: uploadWorkContext,
|
|
101
|
-
generatedAt: now.toISOString(),
|
|
102
|
-
binding,
|
|
103
|
-
ticketBinding,
|
|
104
|
-
scans: sourceCollection.scans,
|
|
105
|
-
gitChangedFileCount: sourceCollection.facts.git?.changed_file_count ?? 0,
|
|
106
|
-
gitAddedLines: sourceCollection.facts.git?.added_lines ?? 0,
|
|
107
|
-
gitDeletedLines: sourceCollection.facts.git?.deleted_lines ?? 0,
|
|
108
|
-
carOpenTicketCount: sourceCollection.facts.car?.open_ticket_count ?? 0,
|
|
109
|
-
rawEvidenceFacts: sourceCollection.facts.raw_evidence,
|
|
110
|
-
riskFlags: safeRiskFlags,
|
|
111
|
-
}),
|
|
112
|
-
];
|
|
113
|
-
const envelope = TelemetryIngestEnvelopeSchema.parse({
|
|
114
|
-
envelope_version: "telemetry-ingest.v1",
|
|
115
|
-
generated_at: now.toISOString(),
|
|
116
|
-
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
117
|
-
session_reference: sanitizeSessionReference(session),
|
|
118
|
-
work_context: uploadWorkContext,
|
|
119
|
-
worktree_inventory: options.worktreeInventory ?? [],
|
|
120
|
-
source_scan_results: sanitizeSourceScanResults(sourceCollection.scans, repoLabel),
|
|
121
|
-
events,
|
|
122
|
-
});
|
|
123
|
-
return {
|
|
124
|
-
envelope,
|
|
125
|
-
dashboard_url: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
|
|
126
|
-
device_token: sessionFile.device_token,
|
|
127
|
-
ticket_id: binding.selected_ticket_id ?? null,
|
|
128
|
-
binding,
|
|
129
|
-
event_count: envelope.events.length,
|
|
130
|
-
source_scan_count: envelope.source_scan_results.length,
|
|
131
|
-
risk_flag_count: safeRiskFlags.length,
|
|
132
|
-
repo_label: repoLabel,
|
|
133
|
-
head_sha: uploadContext.head_sha ?? null,
|
|
134
|
-
raw_evidence_upload_files: sourceCollection.facts.raw_evidence?.upload_files ?? [],
|
|
135
|
-
raw_evidence_facts: sourceCollection.facts.raw_evidence,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* Is this machine installed, paired, and holding a session that has not lapsed?
|
|
140
|
-
*
|
|
141
|
-
* Three ways to fail, each with its own command to run, because "sync failed"
|
|
142
|
-
* with no next step is what turns a five-second fix into a support thread.
|
|
143
|
-
*/
|
|
144
|
-
async function readPairedCollector(paths) {
|
|
145
|
-
const config = await readLocalCollectorConfig(paths).catch((error) => {
|
|
146
|
-
// `not_installed` tells the operator to reinstall the CLI. That is the
|
|
147
|
-
// wrong instruction for a config that exists and is corrupt, and there was
|
|
148
|
-
// no way to tell which one this machine hit.
|
|
149
|
-
if (!isMissingFileFailure(error)) {
|
|
150
|
-
console.error("[upload-envelope] collector config present but unreadable, reporting it as not installed", JSON.stringify({
|
|
151
|
-
reason: "not_installed",
|
|
152
|
-
...describeError(error),
|
|
153
|
-
}));
|
|
154
|
-
}
|
|
155
|
-
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");
|
|
156
|
-
});
|
|
157
|
-
const sessionFile = await readLocalCollectorSessionFile(paths).catch((error) => {
|
|
158
|
-
// Same trap on the pairing half: `unpaired` sends the operator to
|
|
159
|
-
// `cockpit login`, which does not fix an unreadable session file.
|
|
160
|
-
if (!isMissingFileFailure(error)) {
|
|
161
|
-
console.error("[upload-envelope] session file present but unreadable, reporting the machine as unpaired", JSON.stringify({
|
|
162
|
-
reason: "unpaired",
|
|
163
|
-
...describeError(error),
|
|
164
|
-
}));
|
|
165
|
-
}
|
|
166
|
-
throw new LocalUploadBlockedError("unpaired", "No paired collector session found. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
|
|
167
|
-
});
|
|
168
|
-
const session = await readLocalSessionReference(paths);
|
|
169
|
-
if (session.session_state !== "valid") {
|
|
170
|
-
throw new LocalUploadBlockedError("unpaired", session.session_state === "expired"
|
|
171
|
-
? "Collector session expired. Run `cockpit login` or `cockpit pair` again before `cockpit sync`."
|
|
172
|
-
: "Collector is not paired. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
|
|
173
|
-
}
|
|
174
|
-
return { config, sessionFile, session };
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Content hashes whose bytes are already durable and may be skipped this sync.
|
|
178
|
-
*
|
|
179
|
-
* Object keys embed the work context, so a cursor entry only counts as reuse
|
|
180
|
-
* when it was committed under THIS operator and context. The same bytes under a
|
|
181
|
-
* different context still need their own pointer and evidence ref.
|
|
182
|
-
*/
|
|
183
|
-
function reusableContentHashes(options, context) {
|
|
184
|
-
if (options.skipContentHashes)
|
|
185
|
-
return options.skipContentHashes;
|
|
186
|
-
return new Set(Object.entries(options.cursorObjects ?? {})
|
|
187
|
-
.filter(([, entry]) => rawEvidenceObjectKeyBelongsToWorkContext(entry.object_key, context))
|
|
188
|
-
.map(([hash]) => hash));
|
|
189
|
-
}
|
|
190
|
-
export function makeUploadWorkContext(options) {
|
|
191
|
-
const provenance = makeCollectorProvenance({
|
|
192
|
-
context: options.activeContext,
|
|
193
|
-
session: options.session,
|
|
194
|
-
repoLabel: options.repoLabel,
|
|
195
|
-
});
|
|
196
|
-
return {
|
|
197
|
-
...options.activeContext,
|
|
198
|
-
repo: options.repoLabel,
|
|
199
|
-
repo_label: options.activeContext.repo_label ?? options.repoLabel,
|
|
200
|
-
repo_fingerprint: options.activeContext.repo_fingerprint,
|
|
201
|
-
repo_origin_url: options.activeContext.repo_origin_url,
|
|
202
|
-
head_sha: options.activeContext.head_sha,
|
|
203
|
-
worktree_label: options.activeContext.worktree_label,
|
|
204
|
-
worktree_fingerprint: options.activeContext.worktree_fingerprint,
|
|
205
|
-
worktree_is_primary: options.activeContext.worktree_is_primary,
|
|
206
|
-
operator_id: options.session.operator_id,
|
|
207
|
-
session_id: options.session.session_id,
|
|
208
|
-
updated_at: options.now.toISOString(),
|
|
209
|
-
provenance,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
export function makeCollectorProvenance(options) {
|
|
213
|
-
return {
|
|
214
|
-
capture_source: "collector_runtime",
|
|
215
|
-
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
216
|
-
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
217
|
-
repo: options.repoLabel,
|
|
218
|
-
branch: options.context.branch,
|
|
219
|
-
repo_label: options.context.repo_label ?? options.repoLabel,
|
|
220
|
-
repo_fingerprint: options.context.repo_fingerprint,
|
|
221
|
-
repo_origin_url: options.context.repo_origin_url,
|
|
222
|
-
worktree_label: options.context.worktree_label,
|
|
223
|
-
worktree_fingerprint: options.context.worktree_fingerprint,
|
|
224
|
-
worktree_is_primary: options.context.worktree_is_primary,
|
|
225
|
-
operator_id: options.session.operator_id,
|
|
226
|
-
session_id: options.session.session_id,
|
|
227
|
-
work_context_id: options.context.work_context_id,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
/**
|
|
231
|
-
* The one event every sync sends.
|
|
232
|
-
*
|
|
233
|
-
* Its privacy story turns on a single question — did this sync produce raw
|
|
234
|
-
* evidence objects? If it did, the bytes went to durable storage separately and
|
|
235
|
-
* this event carries only references to them; if it did not, the event is pure
|
|
236
|
-
* metadata. Both the classification and the human-readable summary follow from
|
|
237
|
-
* that, so it is computed once here and threaded through.
|
|
238
|
-
*/
|
|
239
|
-
function makeSourceScanCompletedEvent(options) {
|
|
240
|
-
if (!options.context.provenance) {
|
|
241
|
-
throw new Error("Upload work context is missing provenance.");
|
|
242
|
-
}
|
|
243
|
-
const rawEvidencePointers = options.rawEvidenceFacts?.pointers ?? [];
|
|
244
|
-
const hasRawEvidence = rawEvidencePointers.length > 0;
|
|
245
|
-
const eventPrivacyClassification = hasRawEvidence
|
|
246
|
-
? "redacted_summary"
|
|
247
|
-
: "metadata";
|
|
248
|
-
return TelemetryIngestEventDtoSchema.parse({
|
|
249
|
-
event_id: `ambient-sync:${options.context.work_context_id}:${options.generatedAt}`,
|
|
250
|
-
event_type: "source_scan_completed",
|
|
251
|
-
occurred_at: options.generatedAt,
|
|
252
|
-
provenance: options.context.provenance,
|
|
253
|
-
privacy_classification: eventPrivacyClassification,
|
|
254
|
-
redaction: {
|
|
255
|
-
privacy_classification: eventPrivacyClassification,
|
|
256
|
-
redaction_status: hasRawEvidence
|
|
257
|
-
? "raw_remote_durable"
|
|
258
|
-
: "metadata_only",
|
|
259
|
-
redacted_fields: [
|
|
260
|
-
"prompt_body",
|
|
261
|
-
"response_body",
|
|
262
|
-
"diff_body",
|
|
263
|
-
"transcript_body",
|
|
264
|
-
"git.changed_paths",
|
|
265
|
-
"local_file_paths",
|
|
266
|
-
],
|
|
267
|
-
raw_evidence_pointer_ids: rawEvidencePointers.map((pointer) => pointer.raw_evidence_pointer_id),
|
|
268
|
-
redacted_summary: hasRawEvidence
|
|
269
|
-
? "Collector uploaded raw evidence objects separately and sent only references, hashes, source scan, binding, and risk summaries to ingest."
|
|
270
|
-
: "Collector uploaded metadata-only local work, source scan, binding, and risk summaries.",
|
|
271
|
-
},
|
|
272
|
-
redacted_summary: hasRawEvidence
|
|
273
|
-
? "Collector uploaded raw evidence objects separately, then sent evidence references and metadata summaries."
|
|
274
|
-
: "Collector uploaded metadata-only source scan, ticket binding, and risk summaries.",
|
|
275
|
-
metrics: sourceScanEventMetrics(options),
|
|
276
|
-
attributes: sourceScanEventAttributes(options, hasRawEvidence),
|
|
277
|
-
evidence_completeness: options.rawEvidenceFacts?.evidence_completeness,
|
|
278
|
-
ticket_binding: options.ticketBinding ?? undefined,
|
|
279
|
-
risk_flags: options.riskFlags,
|
|
280
|
-
raw_evidence_pointers: rawEvidencePointers,
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
/** Every number this sync counted, all of them safe to publish. */
|
|
284
|
-
function sourceScanEventMetrics(options) {
|
|
285
|
-
const evidenceCompleteness = options.rawEvidenceFacts?.evidence_completeness;
|
|
286
|
-
return {
|
|
287
|
-
git_changed_file_count: options.gitChangedFileCount,
|
|
288
|
-
git_added_lines: options.gitAddedLines,
|
|
289
|
-
git_deleted_lines: options.gitDeletedLines,
|
|
290
|
-
car_open_ticket_count: options.carOpenTicketCount,
|
|
291
|
-
source_scan_count: options.scans.length,
|
|
292
|
-
risk_flag_count: options.riskFlags.length,
|
|
293
|
-
raw_evidence_file_count: options.rawEvidenceFacts?.file_count ?? 0,
|
|
294
|
-
raw_evidence_byte_size: options.rawEvidenceFacts?.byte_size ?? 0,
|
|
295
|
-
raw_evidence_skipped_count: options.rawEvidenceFacts?.skipped_count ?? 0,
|
|
296
|
-
raw_evidence_sanitized_count: options.rawEvidenceFacts?.sanitized_count ?? 0,
|
|
297
|
-
raw_evidence_reused_count: options.rawEvidenceFacts?.reused_count ?? 0,
|
|
298
|
-
evidence_scanned_count: evidenceCompleteness?.totals.scanned_count ?? 0,
|
|
299
|
-
evidence_included_count: evidenceCompleteness?.totals.included_count ?? 0,
|
|
300
|
-
evidence_skipped_count: evidenceCompleteness?.totals.skipped_count ?? 0,
|
|
301
|
-
evidence_truncated_count: evidenceCompleteness?.totals.truncated_count ?? 0,
|
|
302
|
-
evidence_deferred_count: evidenceCompleteness?.totals.deferred_count ?? 0,
|
|
303
|
-
evidence_reused_count: evidenceCompleteness?.totals.reused_count ?? 0,
|
|
304
|
-
evidence_failed_count: evidenceCompleteness?.totals.failed_count ?? 0,
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* Where and what this work was, plus the two claims a reader must be able to
|
|
309
|
-
* check without opening anything: nothing raw is in here, and here is how
|
|
310
|
-
* complete the evidence behind it actually is. Absent completeness reads as
|
|
311
|
-
* incomplete on purpose — silence is not a clean bill of health.
|
|
312
|
-
*/
|
|
313
|
-
function sourceScanEventAttributes(options, hasRawEvidence) {
|
|
314
|
-
const evidenceCompleteness = options.rawEvidenceFacts?.evidence_completeness;
|
|
315
|
-
return {
|
|
316
|
-
repo_label: options.context.repo,
|
|
317
|
-
repo_fingerprint: options.context.repo_fingerprint ?? "unknown",
|
|
318
|
-
worktree_label: options.context.worktree_label ?? "unknown",
|
|
319
|
-
worktree_fingerprint: options.context.worktree_fingerprint ?? "unknown",
|
|
320
|
-
worktree_is_primary: options.context.worktree_is_primary ?? false,
|
|
321
|
-
branch: options.context.branch,
|
|
322
|
-
ticket_binding_state: options.binding.state,
|
|
323
|
-
ticket_binding_source: options.binding.selected_source ?? "none",
|
|
324
|
-
ticket_id: options.binding.selected_ticket_id ?? "unbound",
|
|
325
|
-
source_adapters: options.scans.map((scan) => scan.adapter.adapter_name),
|
|
326
|
-
source_statuses: options.scans.map((scan) => `${scan.adapter.adapter_name}:${scan.status}`),
|
|
327
|
-
redaction_mode: hasRawEvidence
|
|
328
|
-
? "remote_durable_raw_evidence"
|
|
329
|
-
: "metadata_only",
|
|
330
|
-
raw_payload_included: false,
|
|
331
|
-
evidence_completeness_schema_version: evidenceCompleteness?.schema_version ?? "evidence-completeness.v1",
|
|
332
|
-
evidence_completeness_status: evidenceCompleteness?.status ?? "unknown",
|
|
333
|
-
evidence_incomplete: evidenceCompleteness
|
|
334
|
-
? evidenceCompleteness.status !== "complete"
|
|
335
|
-
: true,
|
|
336
|
-
...(options.context.topic_label
|
|
337
|
-
? { topic_label: options.context.topic_label }
|
|
338
|
-
: {}),
|
|
339
|
-
...(options.context.topic_summary_redacted
|
|
340
|
-
? { topic_summary_redacted: options.context.topic_summary_redacted }
|
|
341
|
-
: {}),
|
|
342
|
-
...(options.context.work_intent
|
|
343
|
-
? { work_intent: options.context.work_intent }
|
|
344
|
-
: {}),
|
|
345
|
-
...(options.context.work_phase
|
|
346
|
-
? { work_phase: options.context.work_phase }
|
|
347
|
-
: {}),
|
|
348
|
-
...(options.context.intent_source
|
|
349
|
-
? { intent_source: options.context.intent_source }
|
|
350
|
-
: {}),
|
|
351
|
-
...(options.context.intent_confidence !== undefined
|
|
352
|
-
? { intent_confidence: options.context.intent_confidence }
|
|
353
|
-
: {}),
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
function sanitizeSessionReference(session) {
|
|
357
|
-
return {
|
|
358
|
-
...session,
|
|
359
|
-
session_file_path: "local-session-file",
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
/**
|
|
363
|
-
* Scan results carry their own nested events, and those events must not smuggle
|
|
364
|
-
* evidence pointers into the envelope — the top-level event owns the pointers,
|
|
365
|
-
* and a duplicate ref here would be counted twice by the ingest receipt.
|
|
366
|
-
*/
|
|
367
|
-
function sanitizeSourceScanResults(scans, repoLabel) {
|
|
368
|
-
return scans.map((scan) => ({
|
|
369
|
-
...scan,
|
|
370
|
-
diagnostic_labels: scan.diagnostic_labels.map(sanitizeDiagnosticLabel),
|
|
371
|
-
events: scan.events.map((event) => ({
|
|
372
|
-
...event,
|
|
373
|
-
raw_evidence_pointers: [],
|
|
374
|
-
redaction: {
|
|
375
|
-
...event.redaction,
|
|
376
|
-
raw_evidence_pointer_ids: [],
|
|
377
|
-
},
|
|
378
|
-
})),
|
|
379
|
-
risk_flags: scan.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel)),
|
|
380
|
-
}));
|
|
381
|
-
}
|
|
382
|
-
/**
|
|
383
|
-
* A diagnostic label that carries a newline or a path separator is a local path
|
|
384
|
-
* or a stack trace wearing a label's clothes; keep the prefix, drop the rest.
|
|
385
|
-
*/
|
|
386
|
-
function sanitizeDiagnosticLabel(label) {
|
|
387
|
-
if (label.includes("\n") || label.includes("/") || label.includes("\\")) {
|
|
388
|
-
const prefix = label.split(":", 1)[0]?.trim();
|
|
389
|
-
return prefix ? `${prefix}:redacted` : "diagnostic_redacted";
|
|
390
|
-
}
|
|
391
|
-
return label.length > 160 ? `${label.slice(0, 157)}...` : label;
|
|
392
|
-
}
|
|
393
|
-
function sanitizeRiskFlag(flag, repoLabel) {
|
|
394
|
-
return {
|
|
395
|
-
...flag,
|
|
396
|
-
provenance: {
|
|
397
|
-
...flag.provenance,
|
|
398
|
-
repo: repoLabel,
|
|
399
|
-
},
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* The candidate the binding actually selected, synthesised when the resolver
|
|
404
|
-
* chose a ticket that is not in its own candidate list (a `cockpit start`
|
|
405
|
-
* binding, for instance, which is a decision rather than a guess).
|
|
406
|
-
*/
|
|
407
|
-
function selectedTicketBindingCandidate(binding) {
|
|
408
|
-
if (!binding.selected_ticket_id || !binding.selected_source)
|
|
409
|
-
return null;
|
|
410
|
-
return (binding.candidates.find((candidate) => candidate.ticket_id === binding.selected_ticket_id &&
|
|
411
|
-
candidate.binding_source === binding.selected_source) ?? {
|
|
412
|
-
ticket_id: binding.selected_ticket_id,
|
|
413
|
-
binding_source: binding.selected_source,
|
|
414
|
-
confidence: 1,
|
|
415
|
-
evidence_labels: [`bound_by:${binding.selected_source}`],
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
/** The basename of a repo root, never the path that led to it. */
|
|
419
|
-
export function safeRepoLabel(repoRoot) {
|
|
420
|
-
const basename = path.basename(repoRoot.replace(/[\\/]+$/, ""));
|
|
421
|
-
return basename || "repo";
|
|
422
|
-
}
|
|
423
|
-
/**
|
|
424
|
-
* Does this durable object belong to the operator and work context now syncing?
|
|
425
|
-
*
|
|
426
|
-
* Two key shapes are live: the original `<operator>/<context>/...` prefix and
|
|
427
|
-
* the readable `operators/.../ids/<operator>/<context>/...` layout.
|
|
428
|
-
*/
|
|
429
|
-
function rawEvidenceObjectKeyBelongsToWorkContext(objectKey, context) {
|
|
430
|
-
const legacyPrefix = `${context.operatorId}/${context.workContextId}/`;
|
|
431
|
-
const readableIdGuard = `/ids/${context.operatorId}/${context.workContextId}/`;
|
|
432
|
-
return (objectKey.startsWith(legacyPrefix) ||
|
|
433
|
-
(objectKey.startsWith("operators/") && objectKey.includes(readableIdGuard)));
|
|
434
|
-
}
|
|
23
|
+
export { LocalUploadBlockedError, buildLocalAmbientEnvelope, makeCollectorProvenance, makeUploadWorkContext, safeRepoLabel, } from "./upload-envelope-build.js";
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describeError } from "./health-detail.js";
|
|
2
|
+
import { isNonEmptyString, readResponseJson, serverFailureDetail, } from "./upload-http.js";
|
|
3
|
+
import { SyncDeliveryError } from "./upload-failure-reason.js";
|
|
4
|
+
export async function postEnvelopeToIngest(options) {
|
|
5
|
+
let response;
|
|
6
|
+
try {
|
|
7
|
+
response = await options.fetchImpl(`${options.built.dashboard_url}/api/ambient/ingest`, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: {
|
|
10
|
+
"Authorization": `Bearer ${options.built.device_token}`,
|
|
11
|
+
"Content-Type": "application/json",
|
|
12
|
+
},
|
|
13
|
+
body: JSON.stringify(options.envelope),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
// No status came back at all, so no server-side reason exists to read. The
|
|
18
|
+
// error's own name and code (`TypeError` / `ENOTFOUND` / `ECONNREFUSED`)
|
|
19
|
+
// are the whole answer, and they are what separates "this machine is
|
|
20
|
+
// offline" from "the dashboard refused us" — two words that used to be the
|
|
21
|
+
// same spool row.
|
|
22
|
+
const described = describeError(error);
|
|
23
|
+
console.error("[cockpit-sync] ingest request failed before a status came back", JSON.stringify({ reason: "ingest_transport_error", ...described }));
|
|
24
|
+
throw new SyncDeliveryError("ingest_transport_error", {
|
|
25
|
+
detail: [
|
|
26
|
+
described.error_name,
|
|
27
|
+
described.error_code,
|
|
28
|
+
described.cause_code ?? described.cause_name,
|
|
29
|
+
described.error_detail,
|
|
30
|
+
]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(" "),
|
|
33
|
+
cause: error,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const responseBody = await readResponseJson(response);
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
const serverReason = serverFailureDetail(responseBody);
|
|
39
|
+
console.error("[cockpit-sync] ingest refused the envelope", JSON.stringify({
|
|
40
|
+
reason: "ingest_rejected",
|
|
41
|
+
http_status: response.status,
|
|
42
|
+
server_reason: serverReason ?? "none",
|
|
43
|
+
event_count: options.envelope.events.length,
|
|
44
|
+
}));
|
|
45
|
+
throw new SyncDeliveryError("ingest_rejected", {
|
|
46
|
+
httpStatus: response.status,
|
|
47
|
+
detail: `http ${response.status}; server reason ${serverReason ?? "none"}`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
assertAmbientIngestReceipt(response, responseBody, options.envelope);
|
|
51
|
+
return response;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The receipt that proves ingest persisted exactly what was submitted.
|
|
55
|
+
*
|
|
56
|
+
* Anything short of a 202 whose counts match the envelope's own totals is
|
|
57
|
+
* treated as no receipt at all — a proxy's 200, a truncated body, or a
|
|
58
|
+
* dashboard that accepted fewer rows than were sent all land here. They used to
|
|
59
|
+
* land here under the SAME sentence, which is the BLI-3483 complaint: a
|
|
60
|
+
* network appliance answering on the dashboard's behalf and a dashboard that
|
|
61
|
+
* genuinely persisted three of four rows are opposite repairs. They are now two
|
|
62
|
+
* reasons, and the second one names the field that disagreed and by how much.
|
|
63
|
+
*/
|
|
64
|
+
function assertAmbientIngestReceipt(response, value, envelope) {
|
|
65
|
+
if (response.status !== 202 || !value || typeof value !== "object") {
|
|
66
|
+
const detail = response.status !== 202
|
|
67
|
+
? `http ${response.status}; the ingest route answers 202, so something in front of it replied`
|
|
68
|
+
: "202 with a body that is not an object";
|
|
69
|
+
console.error("[cockpit-sync] ingest answered without a durable receipt", JSON.stringify({
|
|
70
|
+
reason: "ingest_receipt_not_202",
|
|
71
|
+
http_status: response.status,
|
|
72
|
+
body_is_object: Boolean(value) && typeof value === "object",
|
|
73
|
+
}));
|
|
74
|
+
throw new SyncDeliveryError("ingest_receipt_not_202", {
|
|
75
|
+
httpStatus: response.status,
|
|
76
|
+
detail,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const record = value;
|
|
80
|
+
const ingest = record["ingest"] && typeof record["ingest"] === "object"
|
|
81
|
+
? record["ingest"]
|
|
82
|
+
: null;
|
|
83
|
+
const expectedFactCount = envelope.events.length;
|
|
84
|
+
const expectedRiskFlagCount = envelope.events.reduce((total, event) => total + event.risk_flags.length, 0);
|
|
85
|
+
const expectedEvidenceRefCount = envelope.events.reduce((total, event) => total + event.raw_evidence_pointers.length, 0);
|
|
86
|
+
const mismatches = [];
|
|
87
|
+
if (record["ok"] !== true)
|
|
88
|
+
mismatches.push("ok not true");
|
|
89
|
+
if (!ingest)
|
|
90
|
+
mismatches.push("no ingest block");
|
|
91
|
+
if (ingest && !isNonEmptyString(ingest["work_session_id"])) {
|
|
92
|
+
mismatches.push("blank work_session_id");
|
|
93
|
+
}
|
|
94
|
+
if (ingest) {
|
|
95
|
+
mismatches.push(...countMismatch("fact_count", ingest["fact_count"], expectedFactCount), ...countMismatch("risk_flag_count", ingest["risk_flag_count"], expectedRiskFlagCount), ...countMismatch("evidence_ref_count", ingest["evidence_ref_count"], expectedEvidenceRefCount));
|
|
96
|
+
}
|
|
97
|
+
if (mismatches.length === 0)
|
|
98
|
+
return;
|
|
99
|
+
console.error("[cockpit-sync] ingest receipt does not match what was submitted", JSON.stringify({
|
|
100
|
+
reason: "ingest_receipt_incomplete",
|
|
101
|
+
http_status: response.status,
|
|
102
|
+
mismatch_count: mismatches.length,
|
|
103
|
+
mismatches,
|
|
104
|
+
}));
|
|
105
|
+
throw new SyncDeliveryError("ingest_receipt_incomplete", {
|
|
106
|
+
httpStatus: response.status,
|
|
107
|
+
detail: mismatches.join("; "),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `submitted 4, receipt 3` — the sentence that tells partial persistence from a
|
|
112
|
+
* receipt that never carried the field at all. Counts are numbers this
|
|
113
|
+
* collector computed and numbers the server echoed; neither is content.
|
|
114
|
+
*/
|
|
115
|
+
function countMismatch(field, received, expected) {
|
|
116
|
+
if (received === expected)
|
|
117
|
+
return [];
|
|
118
|
+
return [
|
|
119
|
+
`${field} submitted ${expected}, receipt ${typeof received === "number" ? received : "absent"}`,
|
|
120
|
+
];
|
|
121
|
+
}
|