@bli-cockpit/cli 0.2.56 → 0.2.58

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 (43) hide show
  1. package/dist/commands/agent-door.js +85 -0
  2. package/dist/commands/docs.js +227 -0
  3. package/dist/commands/issue-contracts.js +99 -0
  4. package/dist/commands/issue-write.js +129 -0
  5. package/dist/commands/issue.js +189 -0
  6. package/dist/commands/local-args-tower-docs-msg.js +126 -0
  7. package/dist/commands/local-args-tower-work.js +178 -0
  8. package/dist/commands/local-args-tower.js +7 -1
  9. package/dist/commands/local-args.js +10 -2
  10. package/dist/commands/local-help.js +70 -0
  11. package/dist/commands/local.js +12 -0
  12. package/dist/commands/mcp-bin-resolve.js +102 -0
  13. package/dist/commands/memory-install-claude.js +13 -5
  14. package/dist/commands/memory-install-config.js +140 -0
  15. package/dist/commands/memory-install-report.js +89 -0
  16. package/dist/commands/memory-install.js +51 -362
  17. package/dist/commands/msg.js +188 -0
  18. package/dist/commands/notes-door.js +120 -0
  19. package/dist/commands/notes-reads.js +134 -0
  20. package/dist/commands/notes-writes.js +208 -0
  21. package/dist/commands/notes.js +16 -442
  22. package/dist/commands/ops-render.js +18 -2
  23. package/dist/commands/ops.js +9 -2
  24. package/dist/commands/project.js +38 -0
  25. package/dist/commands/public-root.js +1 -1
  26. package/dist/commands/tower-mcp-claude.js +30 -0
  27. package/dist/commands/tower-mcp-codex.js +100 -0
  28. package/dist/commands/tower-mcp-contract.js +39 -0
  29. package/dist/commands/tower-mcp-install.js +75 -0
  30. package/dist/repo-identity-fingerprint.js +88 -0
  31. package/dist/repo-identity-git.js +76 -0
  32. package/dist/repo-identity-linked-worktrees.js +81 -0
  33. package/dist/repo-identity.js +5 -222
  34. package/dist/upload-envelope-build.js +240 -0
  35. package/dist/upload-envelope-event.js +198 -0
  36. package/dist/upload-envelope.js +16 -427
  37. package/dist/upload-ingest-receipt.js +121 -0
  38. package/dist/upload-session-reports-queue.js +156 -0
  39. package/dist/upload-session-reports-wire.js +275 -0
  40. package/dist/upload-session-reports.js +14 -425
  41. package/dist/upload-sync.js +291 -0
  42. package/dist/upload.js +24 -396
  43. package/package.json +6 -5
@@ -0,0 +1,291 @@
1
+ /**
2
+ * `syncLocalAmbientEnvelope`: one ambient sync, end to end.
3
+ *
4
+ * 1. Assemble the envelope — or record why this machine cannot upload at all
5
+ * and rethrow, so `cockpit status` names the blocker.
6
+ * 2. Deliver the raw evidence objects, skipping anything inside its delivery
7
+ * backoff window (`upload-evidence-delivery.ts`).
8
+ * 3. Reconcile the envelope with what actually became durable, POST it to
9
+ * ingest, and refuse to believe a receipt we cannot read
10
+ * (`upload-ingest-receipt.ts`).
11
+ * 4. Advance the content cursor, report any agent images, and write the ledger
12
+ * row — a retry when a later sync could still rescue the gap, a named
13
+ * blocker when it never can, nothing extra when the sync was clean.
14
+ * 5. On any failure, spool a retry that carries its own reason.
15
+ *
16
+ * The whole file is built around one rule from the fleet contract: a sync that
17
+ * did not collect everything must never read green. Objects held by backoff are
18
+ * reported as named failures, permanent rejections are logged and blocked
19
+ * rather than silently retried forever, and the ledger row always says why.
20
+ */
21
+ import { getCollectorRuntimePaths } from "./local-state.js";
22
+ import { uploadRawEvidenceFilesChunked, } from "./evidence-upload-client.js";
23
+ import { markObjectCommitted, readRawEvidenceCursor, writeRawEvidenceCursor, } from "./cursors/raw-evidence-cursor.js";
24
+ import { readRawEvidenceStagingState } from "./raw-evidence-staging.js";
25
+ import { recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
26
+ import { buildLocalAmbientEnvelope, LocalUploadBlockedError, } from "./upload-envelope.js";
27
+ import { applyRawEvidenceUploadOutcomes, hasRetryableEvidenceGap, logEvidenceBackoffBypassed, logEvidenceHeldByBackoff, logPermanentlyRejectedEvidence, partitionHeldEvidenceFiles, permanentEvidenceFailureReason, persistDeliveryAttempts, retrySourcesForFailedSync, retryableEvidenceGapReason, summarizeRawEvidenceDelivery, } from "./upload-evidence-delivery.js";
28
+ import { reportAgentImageArtifacts } from "./upload-agent-artifacts.js";
29
+ import { describeError } from "./health-detail.js";
30
+ import { postEnvelopeToIngest } from "./upload-ingest-receipt.js";
31
+ import { classifySyncFailure, } from "./upload-failure-reason.js";
32
+ export async function syncLocalAmbientEnvelope(options = {}) {
33
+ const attemptedAt = (options.now ?? new Date()).toISOString();
34
+ const paths = getCollectorRuntimePaths(options.homeDir);
35
+ const cursor = await readRawEvidenceCursor(paths);
36
+ const built = await buildEnvelopeOrRecordBlocker(paths, options, cursor.objects, attemptedAt);
37
+ const fetchImpl = options.fetch ?? globalThis.fetch;
38
+ if (!fetchImpl) {
39
+ throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
40
+ }
41
+ const provenance = built.envelope.work_context.provenance;
42
+ if (!provenance) {
43
+ throw new Error("Ambient upload requires collector provenance.");
44
+ }
45
+ // Assigned progressively so the spool path below can still report what did
46
+ // land if the upload pass throws part-way through.
47
+ let uploadOutcomes = [];
48
+ let uploadedChunkCount = 0;
49
+ const attemptedDate = options.now ?? new Date(attemptedAt);
50
+ const staging = await readRawEvidenceStagingState(paths.state_dir);
51
+ try {
52
+ if (built.raw_evidence_upload_files.length > 0) {
53
+ // Objects still inside their backoff window are not offered at all. They
54
+ // become held outcomes below, which keeps them out of the envelope, keeps
55
+ // the sync in retry_pending, and names the reason — the 559th identical
56
+ // attempt is what BLI-3066 made impossible. An operator-invoked retry
57
+ // (`cockpit backfill`, doctor) offers them anyway and says so: the window
58
+ // is the scheduler's cadence, not an answer to a person asking now
59
+ // (BLI-3118).
60
+ const { deliverable, held, bypassed } = partitionHeldEvidenceFiles(built.raw_evidence_upload_files, staging, attemptedDate, options.evidenceDeliveryMode);
61
+ logEvidenceHeldByBackoff(held, attemptedAt);
62
+ logEvidenceBackoffBypassed(bypassed, attemptedAt);
63
+ uploadOutcomes = held;
64
+ if (deliverable.length > 0) {
65
+ const upload = await uploadRawEvidenceFilesChunked({
66
+ fetchImpl,
67
+ dashboardUrl: built.dashboard_url,
68
+ deviceToken: built.device_token,
69
+ provenance,
70
+ generatedAt: built.envelope.generated_at,
71
+ files: deliverable,
72
+ });
73
+ uploadOutcomes = [...held, ...upload.outcomes];
74
+ uploadedChunkCount = upload.uploaded_chunk_count;
75
+ }
76
+ await persistDeliveryAttempts(paths.state_dir, staging, uploadOutcomes, attemptedDate);
77
+ }
78
+ const response = await postEnvelopeToIngest({
79
+ fetchImpl,
80
+ built,
81
+ envelope: applyRawEvidenceUploadOutcomes(built.envelope, uploadOutcomes),
82
+ });
83
+ advanceRawEvidenceCursor(cursor, uploadOutcomes, attemptedAt);
84
+ await writeRawEvidenceCursor(paths, cursor);
85
+ await reportAgentImageArtifacts({
86
+ fetchImpl,
87
+ dashboardUrl: built.dashboard_url,
88
+ deviceToken: built.device_token,
89
+ provenance,
90
+ generatedAt: built.envelope.generated_at,
91
+ ticketId: built.ticket_id,
92
+ repoFingerprint: built.envelope.work_context.repo_fingerprint,
93
+ worktreeFingerprint: built.envelope.work_context.worktree_fingerprint,
94
+ outcomes: uploadOutcomes,
95
+ reused: built.raw_evidence_facts?.reused ?? [],
96
+ cursorObjects: cursor.objects,
97
+ }).catch((error) => {
98
+ // The whole point of `agent_artifact_report_local_error` is that it is
99
+ // NOT the network error the reporter already labels itself — it is a
100
+ // throw from our own code on the way in. That distinction is only
101
+ // useful if the throw says what it was (BLI-3238).
102
+ console.error("[cockpit-sync] agent image artifact report threw locally", JSON.stringify({
103
+ reason: "agent_artifact_report_local_error",
104
+ ...describeError(error),
105
+ }));
106
+ return {
107
+ posted: false,
108
+ reason: "agent_artifact_report_local_error",
109
+ recorded_count: 0,
110
+ };
111
+ });
112
+ await recordIngestedSyncOutcome({
113
+ paths,
114
+ options,
115
+ built,
116
+ uploadOutcomes,
117
+ attemptedAt,
118
+ });
119
+ // The success branch logs too. A line that only fires on failure cannot
120
+ // answer "did anything land at all today?", which is the question that
121
+ // would have caught BLI-2528 in a week instead of 57 days.
122
+ console.error("[cockpit-sync] ingest accepted the envelope", JSON.stringify({
123
+ reason: "ingest_accepted",
124
+ http_status: response.status,
125
+ event_count: built.event_count,
126
+ raw_evidence_file_count: built.raw_evidence_upload_files.length,
127
+ uploaded_chunk_count: uploadedChunkCount,
128
+ failed_object_count: uploadOutcomes.filter((outcome) => outcome.upload_state === "upload_failed").length,
129
+ }));
130
+ return {
131
+ status: "uploaded",
132
+ dashboard_url: built.dashboard_url,
133
+ ticket_id: built.ticket_id,
134
+ work_context_id: built.envelope.work_context.work_context_id,
135
+ head_sha: built.head_sha,
136
+ event_count: built.event_count,
137
+ source_scan_count: built.source_scan_count,
138
+ risk_flag_count: built.risk_flag_count,
139
+ http_status: response.status,
140
+ ...summarizeRawEvidenceDelivery(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
141
+ };
142
+ }
143
+ catch (error) {
144
+ // The spool row used to store `error.message` verbatim, so every distinct
145
+ // server sentence became its own "reason" and nothing could count how many
146
+ // machines were failing the same way (BLI-3483). It now stores a label from
147
+ // a closed set plus a bounded, redacted cause.
148
+ const classified = classifySyncFailure(error);
149
+ // A committed content-addressed object may be shared by concurrent syncs.
150
+ // Never delete it from this error path: an ingest retry can reuse it, while
151
+ // client-side cleanup cannot prove exclusive ownership without racing a
152
+ // second sync that is about to index the same object.
153
+ const spooledFailureReason = classified.failure_reason;
154
+ console.error("[cockpit-sync] sync spooled for retry", JSON.stringify({
155
+ reason: classified.reason,
156
+ ...(classified.http_status === null
157
+ ? {}
158
+ : { http_status: classified.http_status }),
159
+ ...(classified.detail ? { detail: classified.detail } : {}),
160
+ event_count: built.event_count,
161
+ raw_evidence_file_count: built.raw_evidence_upload_files.length,
162
+ uploaded_chunk_count: uploadedChunkCount,
163
+ }));
164
+ const entry = await recordUploadFailure(paths, {
165
+ last_attempt_at: attemptedAt,
166
+ dashboard_url: built.dashboard_url,
167
+ work_context_id: built.envelope.work_context.work_context_id,
168
+ ticket_id: built.ticket_id,
169
+ repo_label: built.repo_label,
170
+ branch: built.envelope.work_context.branch,
171
+ event_count: built.event_count,
172
+ source_scan_count: built.source_scan_count,
173
+ risk_flag_count: built.risk_flag_count,
174
+ raw_evidence_file_count: built.raw_evidence_upload_files.length,
175
+ retry_sources: retrySourcesForFailedSync(options, built.raw_evidence_facts),
176
+ failure_reason: spooledFailureReason,
177
+ retry_command: "cockpit sync",
178
+ });
179
+ return {
180
+ status: "spooled",
181
+ dashboard_url: built.dashboard_url,
182
+ ticket_id: built.ticket_id,
183
+ work_context_id: built.envelope.work_context.work_context_id,
184
+ head_sha: built.head_sha,
185
+ event_count: built.event_count,
186
+ source_scan_count: built.source_scan_count,
187
+ risk_flag_count: built.risk_flag_count,
188
+ failure_reason: spooledFailureReason,
189
+ failure_class: classified.reason,
190
+ failure_http_status: classified.http_status,
191
+ spool_entry_id: entry.spool_id,
192
+ retry_command: entry.retry_command,
193
+ ...summarizeRawEvidenceDelivery(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
194
+ };
195
+ }
196
+ }
197
+ /**
198
+ * Assemble the envelope, and if this machine simply cannot upload, say so in
199
+ * the spool before the error leaves. A blocker that only ever surfaced as a
200
+ * thrown exception would be invisible to the next `cockpit status`.
201
+ */
202
+ async function buildEnvelopeOrRecordBlocker(paths, options, cursorObjects, attemptedAt) {
203
+ try {
204
+ return await buildLocalAmbientEnvelope({
205
+ ...options,
206
+ cursorObjects: options.cursorObjects ?? cursorObjects,
207
+ });
208
+ }
209
+ catch (error) {
210
+ if (error instanceof LocalUploadBlockedError) {
211
+ await recordUploadBlocked(paths, {
212
+ attemptedAt,
213
+ reason: error.message,
214
+ });
215
+ }
216
+ throw error;
217
+ }
218
+ }
219
+ /**
220
+ * Remember every object that is now durable, so the next sync can reuse the
221
+ * bytes instead of re-uploading them. A failed upload is deliberately absent:
222
+ * recording it would make the next sync skip a file that never landed.
223
+ */
224
+ function advanceRawEvidenceCursor(cursor, outcomes, attemptedAt) {
225
+ for (const outcome of outcomes) {
226
+ if (outcome.upload_state === "upload_failed")
227
+ continue;
228
+ const contentHash = outcome.pointer.content_hash_sha256;
229
+ if (!contentHash)
230
+ continue;
231
+ markObjectCommitted(cursor, contentHash, {
232
+ object_key: outcome.object_key,
233
+ byte_size: outcome.pointer.byte_size ?? 0,
234
+ committed_at: attemptedAt,
235
+ });
236
+ }
237
+ cursor.updated_at = attemptedAt;
238
+ }
239
+ /**
240
+ * What the operator is owed after ingest accepted the envelope, as a decision
241
+ * table on the evidence this sync left behind:
242
+ *
243
+ * | evidence state | ledger |
244
+ * |---------------------------------------|-------------------------------------|
245
+ * | a gap a later sync could rescue | success, then a queued retry naming why |
246
+ * | a gap no retry can rescue | success, then a blocker naming why, plus a stderr record |
247
+ * | nothing missing | success, pending state cleared |
248
+ *
249
+ * The middle row is the one that matters. A permanently rejected object gets no
250
+ * retry — nothing would change — but it must still be named, or `cockpit status`
251
+ * reads clean on a machine with missing collection (BLI-2528).
252
+ */
253
+ async function recordIngestedSyncOutcome(options) {
254
+ const { paths, built, uploadOutcomes, attemptedAt } = options;
255
+ const retryableEvidenceGap = hasRetryableEvidenceGap(built.raw_evidence_facts, uploadOutcomes);
256
+ await recordUploadSuccess(paths, {
257
+ attemptedAt,
258
+ workContextId: built.envelope.work_context.work_context_id,
259
+ clearPendingForContext: !retryableEvidenceGap,
260
+ });
261
+ if (retryableEvidenceGap) {
262
+ await recordUploadFailure(paths, {
263
+ last_attempt_at: attemptedAt,
264
+ dashboard_url: built.dashboard_url,
265
+ work_context_id: built.envelope.work_context.work_context_id,
266
+ ticket_id: built.ticket_id,
267
+ repo_label: built.repo_label,
268
+ branch: built.envelope.work_context.branch,
269
+ event_count: built.event_count,
270
+ source_scan_count: built.source_scan_count,
271
+ risk_flag_count: built.risk_flag_count,
272
+ raw_evidence_file_count: built.raw_evidence_upload_files.length,
273
+ retry_sources: retrySourcesForFailedSync(options.options, built.raw_evidence_facts),
274
+ failure_reason: retryableEvidenceGapReason(built.raw_evidence_facts, uploadOutcomes),
275
+ retry_command: "cockpit sync",
276
+ });
277
+ return;
278
+ }
279
+ const permanentReason = permanentEvidenceFailureReason(uploadOutcomes);
280
+ if (!permanentReason)
281
+ return;
282
+ await recordUploadBlocked(paths, {
283
+ attemptedAt,
284
+ reason: permanentReason,
285
+ });
286
+ logPermanentlyRejectedEvidence({
287
+ reason: permanentReason,
288
+ outcomes: uploadOutcomes,
289
+ attemptedAt,
290
+ });
291
+ }