@bli-cockpit/cli 0.2.53 → 0.2.55

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.
Files changed (60) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +247 -0
  2. package/dist/adapters/attribution-core-paths.js +182 -0
  3. package/dist/adapters/attribution-core-score.js +159 -0
  4. package/dist/adapters/attribution-core-types.js +13 -0
  5. package/dist/adapters/attribution-core.js +13 -565
  6. package/dist/adapters/claude-attribution-discovery.js +186 -0
  7. package/dist/adapters/claude-attribution-score.js +204 -0
  8. package/dist/adapters/claude-attribution-signals.js +180 -0
  9. package/dist/adapters/claude-attribution-types.js +25 -0
  10. package/dist/adapters/claude-attribution.js +14 -569
  11. package/dist/commands/doctor-access.js +129 -0
  12. package/dist/commands/doctor-pipeline.js +326 -0
  13. package/dist/commands/doctor-registration.js +105 -0
  14. package/dist/commands/doctor-report.js +111 -0
  15. package/dist/commands/doctor-update.js +120 -0
  16. package/dist/commands/doctor.js +8 -753
  17. package/dist/commands/heartbeat.js +8 -0
  18. package/dist/commands/jarvis-contracts.js +8 -0
  19. package/dist/commands/jarvis-render.js +413 -0
  20. package/dist/commands/jarvis-turn.js +305 -0
  21. package/dist/commands/jarvis.js +23 -698
  22. package/dist/commands/local-args-collector-setup.js +250 -0
  23. package/dist/commands/local-args-collector-status.js +227 -0
  24. package/dist/commands/local-args-collector-work.js +175 -0
  25. package/dist/commands/local-args-collector.js +19 -624
  26. package/dist/commands/local-args-tower-admin.js +456 -0
  27. package/dist/commands/local-args-tower-chat.js +194 -0
  28. package/dist/commands/local-args-tower-pages.js +314 -0
  29. package/dist/commands/local-args-tower.js +13 -880
  30. package/dist/commands/local-help.js +10 -2
  31. package/dist/commands/onboard-completion.js +136 -0
  32. package/dist/commands/onboard-flows.js +165 -0
  33. package/dist/commands/onboard-setup.js +102 -0
  34. package/dist/commands/onboard.js +5 -392
  35. package/dist/commands/public-root.js +1 -1
  36. package/dist/commands/session-sync-counters.js +55 -0
  37. package/dist/commands/session-sync-health.js +8 -1
  38. package/dist/commands/session-sync-plan.js +47 -7
  39. package/dist/commands/session-sync-scan.js +4 -4
  40. package/dist/commands/session-sync.js +6 -0
  41. package/dist/commands/settings-render.js +27 -0
  42. package/dist/commands/sync-followups.js +5 -1
  43. package/dist/commands/sync.js +5 -1
  44. package/dist/commands/team-device-reasons.js +16 -0
  45. package/dist/commands/team.js +87 -7
  46. package/dist/evidence-upload-client.js +14 -763
  47. package/dist/evidence-upload-object.js +181 -0
  48. package/dist/evidence-upload-plan.js +233 -0
  49. package/dist/evidence-upload-terminal.js +309 -0
  50. package/dist/evidence-upload-transport.js +104 -0
  51. package/dist/spool/local-spool-io.js +122 -0
  52. package/dist/spool/local-spool-mutations.js +174 -0
  53. package/dist/spool/local-spool-parse.js +143 -0
  54. package/dist/spool/local-spool-types.js +22 -0
  55. package/dist/spool/local-spool.js +20 -426
  56. package/dist/upload-evidence-delivery-offer.js +144 -0
  57. package/dist/upload-evidence-delivery-reconcile.js +134 -0
  58. package/dist/upload-evidence-delivery-summary.js +205 -0
  59. package/dist/upload-evidence-delivery.js +12 -482
  60. package/package.json +3 -3
@@ -0,0 +1,181 @@
1
+ import { COMMIT_CRASHED_PLATFORM, RawEvidenceUploadCommitResponseSchema, isPermanentUploadFailure, } from "@bli-cockpit/telemetry-core";
2
+ import { isNonJsonResponseBody, requestJson, safeFailureDetail, sha256, } from "./evidence-upload-transport.js";
3
+ /**
4
+ * The `chunk` and `commit` stages for one already-`begin`-accepted object:
5
+ * send every chunk the server does not already have, then commit and turn
6
+ * the receipt into a `ChunkedUploadFileOutcome`.
7
+ */
8
+ export async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
9
+ const objectKey = entry.file.pointer.object_key ?? "";
10
+ if (disposition.disposition === "already_committed") {
11
+ if (!disposition.upload_id) {
12
+ return failedOutcome(entry.file, "begin_committed_receipt_unavailable");
13
+ }
14
+ }
15
+ if (disposition.disposition === "conflict" || !disposition.upload_id) {
16
+ return failedOutcome(entry.file, disposition.reason ?? "upload_conflict");
17
+ }
18
+ const chunked = await uploadObjectChunks(options, entry, disposition, chunkSizeBytes, objectKey);
19
+ if (!chunked.ok)
20
+ return chunked.outcome;
21
+ return commitUploadedObject(options, entry, disposition, objectKey, chunked.uploadedChunks);
22
+ }
23
+ async function uploadObjectChunks(options, entry, disposition, chunkSizeBytes, objectKey) {
24
+ const received = new Set(disposition.received_chunk_indexes);
25
+ let uploadedChunks = 0;
26
+ if (disposition.disposition === "already_committed" || disposition.commit_ready) {
27
+ return { ok: true, uploadedChunks };
28
+ }
29
+ for (let index = 0; index < entry.chunkCount; index += 1) {
30
+ if (received.has(index))
31
+ continue;
32
+ const chunk = entry.bytes.subarray(index * chunkSizeBytes, Math.min((index + 1) * chunkSizeBytes, entry.bytes.byteLength));
33
+ const chunkResponse = await requestJson(options, "/api/ambient/evidence/upload/chunk", {
34
+ schema_version: "ambient-raw-evidence-upload-chunk.v1",
35
+ generated_at: options.generatedAt,
36
+ provenance: options.provenance,
37
+ upload_id: disposition.upload_id,
38
+ object_key: objectKey,
39
+ chunk_index: index,
40
+ chunk_count: entry.chunkCount,
41
+ chunk_hash_sha256: sha256(chunk),
42
+ content_base64: chunk.toString("base64"),
43
+ });
44
+ if (!chunkResponse.ok) {
45
+ // Same treatment the commit path has had since BLI-2528: the server's
46
+ // own reason rides on the label, so `chunk_3_failed_http_413` becomes
47
+ // `chunk_3_failed_http_413_object_too_large` and the ledger row names
48
+ // the cause instead of the transport (BLI-3483).
49
+ const chunkDetail = safeFailureDetail(chunkResponse.body);
50
+ console.error("[evidence-upload] chunk rejected", JSON.stringify({
51
+ reason: "chunk_rejected",
52
+ upload_id: disposition.upload_id,
53
+ http_status: chunkResponse.status,
54
+ server_reason: chunkDetail ?? "none",
55
+ chunk_index: index,
56
+ chunk_count: entry.chunkCount,
57
+ uploaded_chunk_count: uploadedChunks,
58
+ }));
59
+ return {
60
+ ok: false,
61
+ outcome: failedOutcome(entry.file, `chunk_${index}_failed_http_${chunkResponse.status}${chunkDetail ? `_${chunkDetail}` : ""}`, uploadedChunks),
62
+ };
63
+ }
64
+ uploadedChunks += 1;
65
+ }
66
+ return { ok: true, uploadedChunks };
67
+ }
68
+ async function commitUploadedObject(options, entry, disposition, objectKey, uploadedChunks) {
69
+ const commit = await requestJson(options, "/api/ambient/evidence/upload/commit", {
70
+ schema_version: "ambient-raw-evidence-upload-commit.v1",
71
+ generated_at: options.generatedAt,
72
+ provenance: options.provenance,
73
+ upload_id: disposition.upload_id,
74
+ object_key: objectKey,
75
+ });
76
+ if (!commit.ok) {
77
+ // The server can tell us this object will be refused again. Take its reason
78
+ // verbatim so the label names the cause ("storage rejected 116 MB") instead
79
+ // of the transport ("commit_failed_http_500"), which is all the fleet could
80
+ // say for the 57 days of BLI-2528.
81
+ const permanent = permanentCommitRejection(commit.body);
82
+ if (permanent) {
83
+ return failedOutcome(entry.file, permanent, uploadedChunks);
84
+ }
85
+ // A 5xx whose body is not JSON did not come from the route. The commit
86
+ // handler always answers `{ code, message, ... }`; an HTML error page means
87
+ // the serverless process was killed — an out-of-memory on a large
88
+ // assembly, or a hard timeout — so nothing server-side ran a catch, wrote a
89
+ // ledger reason, or logged a line. Naming it separately is the only way an
90
+ // operator reading upload reasons can tell "the server refused these bytes"
91
+ // from "the server never survived them".
92
+ //
93
+ // The stem comes from telemetry-core so the label the collector writes and
94
+ // the label `classifyUploadFailure` reads are the same string by
95
+ // construction; the status rides on the end so a 502 gateway timeout can
96
+ // still be told from a 500 process kill, and core strips it back off.
97
+ if (commit.status >= 500 && isNonJsonResponseBody(commit.body)) {
98
+ console.error("[evidence-commit] the server died before it could answer; the object is still pending", JSON.stringify({
99
+ upload_id: disposition.upload_id,
100
+ http_status: commit.status,
101
+ byte_size: entry.bytes.byteLength,
102
+ chunk_count: entry.chunkCount,
103
+ uploaded_chunk_count: uploadedChunks,
104
+ reason: COMMIT_CRASHED_PLATFORM,
105
+ }));
106
+ return failedOutcome(entry.file, `${COMMIT_CRASHED_PLATFORM}_http_${commit.status}`, uploadedChunks);
107
+ }
108
+ const detail = safeFailureDetail(commit.body);
109
+ return failedOutcome(entry.file, `commit_failed_http_${commit.status}${detail ? `_${detail}` : ""}`, uploadedChunks);
110
+ }
111
+ // "already_committed" means a concurrent or earlier sync made these bytes
112
+ // durable; this sync does not own them, so they must be reported as reuse —
113
+ // a later ingest failure here must not clean up an object another sync's
114
+ // indexed refs already point at.
115
+ const parsedCommit = RawEvidenceUploadCommitResponseSchema.safeParse(commit.body);
116
+ if (!parsedCommit.success) {
117
+ return failedOutcome(entry.file, "commit_invalid_response", uploadedChunks);
118
+ }
119
+ if (!chunkCommitReceiptMatchesPointer(entry.file.pointer, parsedCommit.data)) {
120
+ return failedOutcome(entry.file, "commit_receipt_mismatch", uploadedChunks);
121
+ }
122
+ const committedPointer = entry.file.pointer;
123
+ if (parsedCommit.data.status === "already_committed") {
124
+ return {
125
+ pointer: committedPointer,
126
+ object_key: objectKey,
127
+ codex_session_id: entry.file.codex_session_id ?? null,
128
+ kind: entry.file.kind ?? "unknown",
129
+ artifact_metadata: entry.file.artifact_metadata,
130
+ upload_state: "reused_existing",
131
+ reason: "already_committed",
132
+ uploaded_chunk_count: uploadedChunks,
133
+ };
134
+ }
135
+ return {
136
+ pointer: committedPointer,
137
+ object_key: objectKey,
138
+ codex_session_id: entry.file.codex_session_id ?? null,
139
+ kind: entry.file.kind ?? "unknown",
140
+ artifact_metadata: entry.file.artifact_metadata,
141
+ upload_state: "uploaded",
142
+ reason: null,
143
+ uploaded_chunk_count: uploadedChunks,
144
+ };
145
+ }
146
+ /**
147
+ * The server's verdict that repeating this commit is pointless, or null.
148
+ *
149
+ * Two conditions, both required: the response says `retryable: false`, and the
150
+ * reason is one this collector has classified as permanent. The second check is
151
+ * the important one. A newer dashboard could declare a reason this CLI has never
152
+ * heard of, and quietly abandoning an object on a word we cannot interpret is
153
+ * exactly the silent drop the fleet contract forbids — so an unclassified reason
154
+ * falls through to the ordinary retry path and stays visible.
155
+ */
156
+ function permanentCommitRejection(body) {
157
+ if (!body || typeof body !== "object")
158
+ return null;
159
+ if (body.retryable !== false)
160
+ return null;
161
+ const reason = safeFailureDetail(body);
162
+ return reason && isPermanentUploadFailure(reason) ? reason : null;
163
+ }
164
+ function chunkCommitReceiptMatchesPointer(pointer, receipt) {
165
+ return (receipt.object_key === pointer.object_key &&
166
+ receipt.content_hash_sha256 === pointer.content_hash_sha256 &&
167
+ receipt.byte_size === pointer.byte_size &&
168
+ receipt.redaction === undefined);
169
+ }
170
+ export function failedOutcome(file, reason, uploadedChunks = 0) {
171
+ return {
172
+ pointer: file.pointer,
173
+ object_key: file.pointer.object_key ?? "",
174
+ codex_session_id: file.codex_session_id ?? null,
175
+ kind: file.kind ?? "unknown",
176
+ artifact_metadata: file.artifact_metadata,
177
+ upload_state: "upload_failed",
178
+ reason,
179
+ uploaded_chunk_count: uploadedChunks,
180
+ };
181
+ }
@@ -0,0 +1,233 @@
1
+ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RawEvidenceUploadBeginResponseSchema, } from "@bli-cockpit/telemetry-core";
2
+ import fs from "node:fs/promises";
3
+ import { requestJson, safeFailureDetail } from "./evidence-upload-transport.js";
4
+ import { failedOutcome, uploadOneObject } from "./evidence-upload-object.js";
5
+ import { duplicateOutcome, rekeyConflictedEntry, reportAbandonedUpload, } from "./evidence-upload-terminal.js";
6
+ import { describeError } from "./health-detail.js";
7
+ export async function loadPlannedEntries(options, chunkSizeBytes, outcomes) {
8
+ const loaded = [];
9
+ const loadedByObjectKey = new Map();
10
+ for (const file of options.files) {
11
+ let stat;
12
+ try {
13
+ stat = await fs.stat(file.local_path);
14
+ }
15
+ catch (error) {
16
+ // `file_read_failed` is a durable ledger label and stays. Beside it: a
17
+ // staged object that vanished (the GC won a race) and one the process
18
+ // has no permission to open are the same word and opposite repairs
19
+ // (BLI-3238).
20
+ console.error("[evidence-upload] staged object could not be stat'd", JSON.stringify({
21
+ reason: "file_read_failed",
22
+ stage: "stat",
23
+ ...describeError(error),
24
+ }));
25
+ outcomes.push(failedOutcome(file, "file_read_failed"));
26
+ continue;
27
+ }
28
+ if (stat.size === 0) {
29
+ outcomes.push(failedOutcome(file, "empty_file"));
30
+ continue;
31
+ }
32
+ if (stat.size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
33
+ outcomes.push(failedOutcome(file, "file_too_large"));
34
+ continue;
35
+ }
36
+ let bytes;
37
+ try {
38
+ bytes = await fs.readFile(file.local_path);
39
+ }
40
+ catch (error) {
41
+ // Stat succeeded and the read did not — narrower than the stat failure
42
+ // above and worth telling apart, so it carries its own stage label.
43
+ console.error("[evidence-upload] staged object could not be read after a successful stat", JSON.stringify({
44
+ reason: "file_read_failed",
45
+ stage: "read",
46
+ byte_size: stat.size,
47
+ ...describeError(error),
48
+ }));
49
+ outcomes.push(failedOutcome(file, "file_read_failed"));
50
+ continue;
51
+ }
52
+ if (bytes.byteLength === 0) {
53
+ outcomes.push(failedOutcome(file, "empty_file"));
54
+ continue;
55
+ }
56
+ if (bytes.byteLength > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
57
+ outcomes.push(failedOutcome(file, "file_too_large"));
58
+ continue;
59
+ }
60
+ // Content-addressed keys collide for byte-identical files (e.g. a resumed
61
+ // session copied twice); upload once and share the outcome instead of
62
+ // racing a second upload against a committed ledger row.
63
+ const existing = file.pointer.object_key
64
+ ? loadedByObjectKey.get(file.pointer.object_key)
65
+ : undefined;
66
+ if (existing) {
67
+ existing.duplicates.push(file);
68
+ continue;
69
+ }
70
+ const entry = {
71
+ file,
72
+ bytes,
73
+ chunkCount: Math.ceil(bytes.byteLength / chunkSizeBytes),
74
+ duplicates: [],
75
+ };
76
+ loaded.push(entry);
77
+ if (file.pointer.object_key) {
78
+ loadedByObjectKey.set(file.pointer.object_key, entry);
79
+ }
80
+ }
81
+ return loaded;
82
+ }
83
+ export function batches(items, size) {
84
+ const out = [];
85
+ for (let offset = 0; offset < items.length; offset += size) {
86
+ out.push(items.slice(offset, offset + size));
87
+ }
88
+ return out;
89
+ }
90
+ /**
91
+ * One `begin` round-trip for one batch, then delivery of everything it
92
+ * accepted. Returns `true` when the route itself cannot be used (old
93
+ * dashboard, or a ledger migration not yet applied) so the caller can fall
94
+ * back to the legacy single-shot route for what is left unresolved.
95
+ *
96
+ * A decision table over `begin`'s possible answers, in the order they are
97
+ * checked — transport unavailable, refused, unparseable, then delivered —
98
+ * so each branch stays a guard clause rather than nested conditionals.
99
+ */
100
+ export async function resolveBeginBatch(options, totalLoadedCount, batch, chunkSizeBytes, outcomes, resolvedEntries) {
101
+ const begin = await requestJson(options, "/api/ambient/evidence/upload/begin", {
102
+ schema_version: "ambient-raw-evidence-upload-begin.v1",
103
+ generated_at: options.generatedAt,
104
+ provenance: options.provenance,
105
+ objects: batch.map((entry) => ({
106
+ pointer: entry.file.pointer,
107
+ chunk_size_bytes: chunkSizeBytes,
108
+ chunk_count: entry.chunkCount,
109
+ })),
110
+ });
111
+ // 404 means an old dashboard without the chunk routes; a persistent 5xx
112
+ // (after retries) covers a new dashboard whose ledger migration has not
113
+ // been applied yet. Both still serve the legacy v1 route.
114
+ if (begin.status === 404 || begin.status >= 500) {
115
+ // Until BLI-3483 this downgrade abandoned the entire chunked path
116
+ // without a word, and the comment above named two causes that the fleet
117
+ // had no way to tell apart — an old dashboard versus an unapplied ledger
118
+ // migration. This is the BLI-2528 shape exactly: the code knew, the
119
+ // operator did not. Once per run, because `break` leaves the loop.
120
+ console.error("[evidence-upload] chunked upload unavailable; falling back to the legacy single-shot route", JSON.stringify({
121
+ reason: begin.status === 404
122
+ ? "begin_route_absent"
123
+ : "begin_server_error_after_retries",
124
+ http_status: begin.status,
125
+ server_reason: safeFailureDetail(begin.body) ?? "none",
126
+ objects_in_batch: batch.length,
127
+ objects_unresolved: totalLoadedCount - resolvedEntries.size,
128
+ }));
129
+ return true;
130
+ }
131
+ if (!begin.ok) {
132
+ // The server's own `{ reason }` sat unread in this body while the ledger
133
+ // recorded the transport status and nothing else (BLI-3483); the commit
134
+ // path has read it since BLI-2528 and this one now does the same.
135
+ const beginDetail = safeFailureDetail(begin.body);
136
+ const beginReason = `begin_failed_http_${begin.status}${beginDetail ? `_${beginDetail}` : ""}`;
137
+ console.error("[evidence-upload] begin refused these objects", JSON.stringify({
138
+ reason: "begin_rejected",
139
+ http_status: begin.status,
140
+ server_reason: beginDetail ?? "none",
141
+ objects_in_batch: batch.length,
142
+ }));
143
+ for (const entry of batch) {
144
+ outcomes.push(failedOutcome(entry.file, beginReason));
145
+ for (const duplicate of entry.duplicates) {
146
+ outcomes.push(failedOutcome(duplicate, beginReason));
147
+ }
148
+ resolvedEntries.add(entry);
149
+ }
150
+ return false;
151
+ }
152
+ const parsedBegin = RawEvidenceUploadBeginResponseSchema.safeParse(begin.body);
153
+ if (!parsedBegin.success) {
154
+ for (const entry of batch) {
155
+ outcomes.push(failedOutcome(entry.file, "begin_invalid_response"));
156
+ for (const duplicate of entry.duplicates) {
157
+ outcomes.push(failedOutcome(duplicate, "begin_invalid_response"));
158
+ }
159
+ resolvedEntries.add(entry);
160
+ }
161
+ return false;
162
+ }
163
+ const dispositions = readBeginDispositions(parsedBegin.data);
164
+ if (!dispositions) {
165
+ for (const entry of batch) {
166
+ outcomes.push(failedOutcome(entry.file, "begin_invalid_response"));
167
+ for (const duplicate of entry.duplicates) {
168
+ outcomes.push(failedOutcome(duplicate, "begin_invalid_response"));
169
+ }
170
+ resolvedEntries.add(entry);
171
+ }
172
+ return false;
173
+ }
174
+ for (const entry of batch) {
175
+ await deliverBatchEntry(options, entry, dispositions, chunkSizeBytes, outcomes);
176
+ resolvedEntries.add(entry);
177
+ }
178
+ return false;
179
+ }
180
+ /**
181
+ * One accepted object: settle a re-keyable conflict if `begin` reported one
182
+ * (BLI-3552), then chunk/commit it, then tell the server if the attempt was
183
+ * abandoned, then record the outcome for it and every byte-identical
184
+ * duplicate that rode along.
185
+ */
186
+ async function deliverBatchEntry(options, entry, dispositions, chunkSizeBytes, outcomes) {
187
+ const objectKey = entry.file.pointer.object_key ?? "";
188
+ const disposition = dispositions.get(objectKey);
189
+ if (!disposition) {
190
+ outcomes.push(failedOutcome(entry.file, "begin_missing_disposition"));
191
+ for (const duplicate of entry.duplicates) {
192
+ outcomes.push(failedOutcome(duplicate, "begin_missing_disposition"));
193
+ }
194
+ return;
195
+ }
196
+ if (disposition.raw_evidence_pointer_id !== entry.file.pointer.raw_evidence_pointer_id) {
197
+ outcomes.push(failedOutcome(entry.file, "begin_disposition_mismatch"));
198
+ for (const duplicate of entry.duplicates) {
199
+ outcomes.push(failedOutcome(duplicate, "begin_disposition_mismatch"));
200
+ }
201
+ return;
202
+ }
203
+ // A conflict a different name can settle gets one, here, before the
204
+ // upload is attempted (BLI-3552). Anything else comes back unchanged and
205
+ // fails on its own reason inside `uploadOneObject`.
206
+ const resolved = await rekeyConflictedEntry(options, entry, disposition, chunkSizeBytes);
207
+ const outcome = await uploadOneObject(options, resolved.entry, resolved.disposition, chunkSizeBytes);
208
+ await reportAbandonedUpload(options, resolved.disposition, outcome);
209
+ outcomes.push(outcome);
210
+ // The duplicates of a re-keyed primary were re-keyed with it: a duplicate
211
+ // is byte-identical and shared the primary's key, so it shares the new
212
+ // one too. Leaving them on the old key would point their evidence refs at
213
+ // somebody else's durable object.
214
+ for (const duplicate of resolved.entry.duplicates) {
215
+ outcomes.push(duplicateOutcome(outcome, duplicate));
216
+ }
217
+ }
218
+ export function readBeginDispositions(body) {
219
+ const map = new Map();
220
+ for (const entry of body.objects) {
221
+ if (map.has(entry.object_key))
222
+ return null;
223
+ map.set(entry.object_key, {
224
+ disposition: entry.disposition,
225
+ upload_id: entry.upload_id ?? null,
226
+ received_chunk_indexes: entry.received_chunk_indexes,
227
+ commit_ready: entry.commit_ready === true,
228
+ reason: entry.reason ?? null,
229
+ raw_evidence_pointer_id: entry.raw_evidence_pointer_id,
230
+ });
231
+ }
232
+ return map;
233
+ }