@bli-cockpit/cli 0.2.27 → 0.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ import { DELIVERY_BACKOFF_HOLDING } from "@bli-cockpit/telemetry-core";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ /**
6
+ * Durable local state for raw-evidence STAGING and DELIVERY ATTEMPTS.
7
+ *
8
+ * Two questions this file answers, and BLI-3066 is what it costs when nothing
9
+ * does:
10
+ *
11
+ * 1. "Have I already staged these exact bytes?" — the pack id used to fold
12
+ * `now` into its hash, so every 15-minute sync minted a new directory and
13
+ * copied the same transcript into it again. One Codex rollout reached 559
14
+ * byte-identical copies, 182 GiB, on one laptop.
15
+ * 2. "How many times have I already offered this object, and how did that go?"
16
+ * — nothing counted, so the same commit failed 1,030 times in nine days at
17
+ * full 15-minute cadence, and every attempt looked like the first.
18
+ *
19
+ * The cursor (`cursors/raw-evidence.json`) answers the *committed* question and
20
+ * deliberately stays that way: an entry there means the object is durable
21
+ * remotely. This file is the pre-commit side — staged but not yet acknowledged —
22
+ * so a cursor reader can never mistake "on this disk" for "safe in the bucket".
23
+ *
24
+ * Metadata only: content hashes, byte sizes, pack ids, pack-relative paths,
25
+ * reason labels, timestamps. Never content, never an absolute path.
26
+ */
27
+ export const RAW_EVIDENCE_STAGING_FILENAME = "raw-evidence-staging.json";
28
+ /**
29
+ * Backoff schedule for an object whose delivery keeps failing.
30
+ *
31
+ * 15 min (the scheduler's own cadence, so the first retry is simply the next
32
+ * sync), doubling to a 6 h ceiling. Nine days of failure is then ~40 attempts
33
+ * instead of 1,030, and the object is still retried four times a day — a server
34
+ * fix lands within hours, not on the next reinstall.
35
+ */
36
+ export const EVIDENCE_DELIVERY_BACKOFF_BASE_MS = 15 * 60 * 1000;
37
+ export const EVIDENCE_DELIVERY_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
38
+ /**
39
+ * The reason label written when an object is being held by backoff.
40
+ *
41
+ * Aliased from telemetry-core rather than spelled again here: the label is read
42
+ * by `classifyUploadFailure` on the way through the ledger, and a second copy
43
+ * of the string is how the collector's word and the server's word drift apart.
44
+ */
45
+ export const DELIVERY_BACKOFF_HOLDING_REASON = DELIVERY_BACKOFF_HOLDING;
46
+ /** The reason label written when an operator's retry is offered anyway. */
47
+ export const DELIVERY_BACKOFF_BYPASS_REASON = "delivery_backoff_bypassed_operator_retry";
48
+ /** Whether the delivery-backoff window gates this pass. Default: it does. */
49
+ export function deliveryBackoffApplies(mode) {
50
+ return (mode ?? "scheduled") === "scheduled";
51
+ }
52
+ const MAX_TRACKED_STAGED_OBJECTS = 5_000;
53
+ const MAX_TRACKED_DELIVERY_ATTEMPTS = 5_000;
54
+ export function emptyRawEvidenceStagingState() {
55
+ return {
56
+ schema_version: "cockpit-raw-evidence-staging.v1",
57
+ updated_at: null,
58
+ staged: {},
59
+ delivery_attempts: {},
60
+ };
61
+ }
62
+ export function rawEvidenceStagingStatePath(stateDir) {
63
+ return path.join(stateDir, RAW_EVIDENCE_STAGING_FILENAME);
64
+ }
65
+ export async function readRawEvidenceStagingState(stateDir) {
66
+ try {
67
+ const raw = JSON.parse(await fs.readFile(rawEvidenceStagingStatePath(stateDir), "utf8"));
68
+ return parseStagingState(raw);
69
+ }
70
+ catch {
71
+ return emptyRawEvidenceStagingState();
72
+ }
73
+ }
74
+ /**
75
+ * Atomic write, same shape as the cursor: temp sibling, fsync, rename. A
76
+ * launchd sync and a hand-run sync can race, and a truncated state file would
77
+ * lose every attempt count at once — which is exactly the thing that must not
78
+ * be losable.
79
+ */
80
+ export async function writeRawEvidenceStagingState(stateDir, state) {
81
+ const filePath = rawEvidenceStagingStatePath(stateDir);
82
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
83
+ const pruned = pruneStagingState(state);
84
+ const serialized = `${JSON.stringify(pruned, null, 2)}\n`;
85
+ const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
86
+ let handle = null;
87
+ try {
88
+ handle = await fs.open(tempPath, "w", 0o600);
89
+ await handle.writeFile(serialized);
90
+ await handle.sync();
91
+ }
92
+ finally {
93
+ await handle?.close();
94
+ }
95
+ try {
96
+ await fs.rename(tempPath, filePath);
97
+ }
98
+ catch (error) {
99
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
100
+ throw error;
101
+ }
102
+ if (process.platform !== "win32") {
103
+ await fs.chmod(filePath, 0o600).catch(() => undefined);
104
+ }
105
+ }
106
+ export function recordStagedObject(state, contentHash, entry) {
107
+ state.staged[contentHash] = entry;
108
+ }
109
+ /**
110
+ * The absolute path of an already-staged copy of these bytes, or null.
111
+ *
112
+ * Verified against the filesystem, not trusted from the index: a dedup sweep,
113
+ * a GC pass, or an operator with `rm -rf` can all have removed the file since
114
+ * it was recorded. A stale entry is dropped so the caller re-stages rather than
115
+ * handing the uploader a path that is not there.
116
+ */
117
+ export async function resolveStagedObject(rawEvidenceRoot, state, contentHash) {
118
+ const entry = state.staged[contentHash];
119
+ if (!entry)
120
+ return null;
121
+ const localPath = stagedObjectPath(rawEvidenceRoot, entry);
122
+ const info = await fs.stat(localPath).catch(() => null);
123
+ if (!info?.isFile() || info.size !== entry.byte_size) {
124
+ delete state.staged[contentHash];
125
+ return null;
126
+ }
127
+ return { local_path: localPath, entry };
128
+ }
129
+ export function stagedObjectPath(rawEvidenceRoot, entry) {
130
+ return path.join(rawEvidenceRoot, entry.pack_id, ...entry.relative_path.split("/").filter(Boolean));
131
+ }
132
+ /**
133
+ * Delay before the next attempt on an object that has failed `attempts` times.
134
+ * `attempts` is 1-based: the first failure waits one scheduler cadence.
135
+ */
136
+ export function evidenceDeliveryBackoffMs(attempts) {
137
+ const safeAttempts = Math.max(1, Math.floor(attempts));
138
+ const exponent = Math.min(safeAttempts - 1, 32);
139
+ const delay = EVIDENCE_DELIVERY_BACKOFF_BASE_MS * 2 ** exponent;
140
+ return Math.min(delay, EVIDENCE_DELIVERY_BACKOFF_MAX_MS);
141
+ }
142
+ export function recordDeliveryFailure(state, contentHash, options) {
143
+ const previous = state.delivery_attempts[contentHash];
144
+ const attempts = (previous?.attempts ?? 0) + 1;
145
+ const attemptedAtIso = options.attemptedAt.toISOString();
146
+ const entry = {
147
+ attempts,
148
+ first_failed_at: previous?.first_failed_at ?? attemptedAtIso,
149
+ last_attempt_at: attemptedAtIso,
150
+ last_reason: options.reason,
151
+ next_attempt_at: new Date(options.attemptedAt.getTime() + evidenceDeliveryBackoffMs(attempts)).toISOString(),
152
+ byte_size: options.byteSize ?? previous?.byte_size ?? 0,
153
+ source_key: options.sourceKey ?? previous?.source_key ?? null,
154
+ };
155
+ state.delivery_attempts[contentHash] = entry;
156
+ return entry;
157
+ }
158
+ /** An object that landed clears its history; the next failure starts at one. */
159
+ export function clearDeliveryAttempt(state, contentHash) {
160
+ if (!state.delivery_attempts[contentHash])
161
+ return false;
162
+ delete state.delivery_attempts[contentHash];
163
+ return true;
164
+ }
165
+ export function deliveryHold(state, contentHash, now) {
166
+ if (!contentHash)
167
+ return null;
168
+ const entry = state.delivery_attempts[contentHash];
169
+ if (!entry)
170
+ return null;
171
+ return Date.parse(entry.next_attempt_at) > now.getTime() ? entry : null;
172
+ }
173
+ /**
174
+ * Source keys currently held by backoff.
175
+ *
176
+ * Collection consults this BEFORE reading a file, so a held 350 MB transcript
177
+ * costs nothing at all this cycle — no read, no hash, no copy, no upload. The
178
+ * hold is still a named, retryable gap, never a silent drop.
179
+ */
180
+ export function heldSourceKeys(state, now) {
181
+ const held = new Set();
182
+ for (const entry of Object.values(state.delivery_attempts)) {
183
+ if (!entry.source_key)
184
+ continue;
185
+ if (Date.parse(entry.next_attempt_at) > now.getTime()) {
186
+ held.add(entry.source_key);
187
+ }
188
+ }
189
+ return held;
190
+ }
191
+ export function evidenceSourceKey(options) {
192
+ const source = options.sourcePath
193
+ ? shortHash(options.sourcePath)
194
+ : options.label
195
+ ? shortHash(options.label)
196
+ : "unknown";
197
+ return `${options.kind}:${options.sessionId ?? "none"}:${source}`;
198
+ }
199
+ /**
200
+ * What `cockpit status` and the health receipt need in order to be unable to
201
+ * read green while an object has been failing for nine days.
202
+ */
203
+ export function summarizeStuckEvidence(state, now) {
204
+ const entries = Object.values(state.delivery_attempts);
205
+ const reasons = new Set();
206
+ let heldCount = 0;
207
+ let maxAttempts = 0;
208
+ let oldest = null;
209
+ for (const entry of entries) {
210
+ reasons.add(entry.last_reason);
211
+ if (Date.parse(entry.next_attempt_at) > now.getTime())
212
+ heldCount += 1;
213
+ maxAttempts = Math.max(maxAttempts, entry.attempts);
214
+ if (!oldest || entry.first_failed_at.localeCompare(oldest) < 0) {
215
+ oldest = entry.first_failed_at;
216
+ }
217
+ }
218
+ return {
219
+ stuck_object_count: entries.length,
220
+ held_object_count: heldCount,
221
+ max_attempts: maxAttempts,
222
+ oldest_first_failed_at: oldest,
223
+ reasons: [...reasons].sort(),
224
+ };
225
+ }
226
+ /**
227
+ * Pack id derived from CONTENT, never from the clock.
228
+ *
229
+ * The manifest is excluded from the hash on purpose: its own object key
230
+ * contains the pack id, so folding it in would be circular. Hashes are sorted,
231
+ * so a change in collection order alone does not mint a new pack.
232
+ */
233
+ export function contentKeyedRawEvidencePackId(options) {
234
+ const material = [...options.contentHashes].sort().join(",");
235
+ return `${options.workContextId}-${shortHash(`${options.workContextId}:${material}`)}`;
236
+ }
237
+ function pruneStagingState(state) {
238
+ return {
239
+ ...state,
240
+ staged: pruneNewest(state.staged, MAX_TRACKED_STAGED_OBJECTS, (entry) => entry.staged_at),
241
+ delivery_attempts: pruneNewest(state.delivery_attempts, MAX_TRACKED_DELIVERY_ATTEMPTS, (entry) => entry.last_attempt_at),
242
+ };
243
+ }
244
+ function pruneNewest(record, max, sortKey) {
245
+ const entries = Object.entries(record);
246
+ if (entries.length <= max)
247
+ return record;
248
+ entries.sort((a, b) => sortKey(b[1]).localeCompare(sortKey(a[1])));
249
+ return Object.fromEntries(entries.slice(0, max));
250
+ }
251
+ function parseStagingState(value) {
252
+ if (!value || typeof value !== "object") {
253
+ return emptyRawEvidenceStagingState();
254
+ }
255
+ const record = value;
256
+ return {
257
+ schema_version: "cockpit-raw-evidence-staging.v1",
258
+ updated_at: optionalString(record["updated_at"]),
259
+ staged: parseRecord(record["staged"], parseStagedEntry),
260
+ delivery_attempts: parseRecord(record["delivery_attempts"], parseAttemptEntry),
261
+ };
262
+ }
263
+ function parseRecord(value, parseEntry) {
264
+ if (!value || typeof value !== "object")
265
+ return {};
266
+ const out = {};
267
+ for (const [key, entry] of Object.entries(value)) {
268
+ const parsed = parseEntry(entry);
269
+ if (parsed)
270
+ out[key] = parsed;
271
+ }
272
+ return out;
273
+ }
274
+ function parseStagedEntry(value) {
275
+ if (!value || typeof value !== "object")
276
+ return null;
277
+ const record = value;
278
+ const packId = optionalString(record["pack_id"]);
279
+ const relativePath = optionalString(record["relative_path"]);
280
+ const stagedAt = optionalString(record["staged_at"]);
281
+ if (!packId || !relativePath || !stagedAt)
282
+ return null;
283
+ return {
284
+ pack_id: packId,
285
+ relative_path: relativePath,
286
+ byte_size: optionalNumber(record["byte_size"]),
287
+ source_key: optionalString(record["source_key"]),
288
+ staged_at: stagedAt,
289
+ };
290
+ }
291
+ function parseAttemptEntry(value) {
292
+ if (!value || typeof value !== "object")
293
+ return null;
294
+ const record = value;
295
+ const lastAttemptAt = optionalString(record["last_attempt_at"]);
296
+ const nextAttemptAt = optionalString(record["next_attempt_at"]);
297
+ if (!lastAttemptAt || !nextAttemptAt)
298
+ return null;
299
+ const attempts = optionalNumber(record["attempts"]);
300
+ return {
301
+ attempts: attempts > 0 ? attempts : 1,
302
+ first_failed_at: optionalString(record["first_failed_at"]) ?? lastAttemptAt,
303
+ last_attempt_at: lastAttemptAt,
304
+ last_reason: optionalString(record["last_reason"]) ?? "unknown",
305
+ next_attempt_at: nextAttemptAt,
306
+ byte_size: optionalNumber(record["byte_size"]),
307
+ source_key: optionalString(record["source_key"]),
308
+ };
309
+ }
310
+ function optionalString(value) {
311
+ return typeof value === "string" && value.trim() ? value : null;
312
+ }
313
+ function optionalNumber(value) {
314
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
315
+ }
316
+ function shortHash(value) {
317
+ return crypto
318
+ .createHash("sha256")
319
+ .update(value, "utf8")
320
+ .digest("hex")
321
+ .slice(0, 12);
322
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Telling the dashboard about images an agent produced or was shown.
3
+ *
4
+ * A screenshot pasted into a Codex or Claude session is already uploaded as raw
5
+ * evidence like any other object; this is the second, separate report that says
6
+ * "that object is an image, from this session, at this turn" so the dashboard
7
+ * can queue it for redaction and OCR.
8
+ *
9
+ * Non-fatal by design: an older dashboard without the endpoint must not fail a
10
+ * harvest, so every path here returns a `{ posted, reason }` label instead of
11
+ * throwing.
12
+ */
13
+ import { AgentImageArtifactReportRequestSchema, } from "@bli-cockpit/telemetry-core";
14
+ import { readResponseJson } from "./upload-http.js";
15
+ export async function reportAgentImageArtifacts(options) {
16
+ const artifacts = agentArtifactsFromEvidence(options);
17
+ if (artifacts.length === 0) {
18
+ return {
19
+ posted: false,
20
+ reason: "no_agent_image_artifacts",
21
+ recorded_count: 0,
22
+ };
23
+ }
24
+ const payload = AgentImageArtifactReportRequestSchema.parse({
25
+ schema_version: "ambient-agent-image-artifacts.v1",
26
+ generated_at: options.generatedAt,
27
+ provenance: options.provenance,
28
+ artifacts,
29
+ });
30
+ try {
31
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/agent-artifacts`, {
32
+ method: "POST",
33
+ headers: {
34
+ "Authorization": `Bearer ${options.deviceToken}`,
35
+ "Content-Type": "application/json",
36
+ },
37
+ body: JSON.stringify(payload),
38
+ });
39
+ if (response.status === 404) {
40
+ return {
41
+ posted: false,
42
+ reason: "agent_artifact_api_unavailable",
43
+ recorded_count: 0,
44
+ };
45
+ }
46
+ if (!response.ok) {
47
+ return {
48
+ posted: false,
49
+ reason: `report_failed_http_${response.status}`,
50
+ recorded_count: 0,
51
+ };
52
+ }
53
+ const body = await readResponseJson(response);
54
+ const recordedCount = body && typeof body === "object"
55
+ ? Number(body.recorded_count ?? 0)
56
+ : 0;
57
+ return {
58
+ posted: true,
59
+ reason: "recorded",
60
+ recorded_count: Number.isFinite(recordedCount) ? recordedCount : 0,
61
+ };
62
+ }
63
+ catch {
64
+ return { posted: false, reason: "report_network_error", recorded_count: 0 };
65
+ }
66
+ }
67
+ /**
68
+ * The images this sync made durable, from both directions.
69
+ *
70
+ * Freshly uploaded objects come from the upload outcomes; images whose bytes
71
+ * this machine already had come from the cursor, which is the only place their
72
+ * object key still exists. Keyed by pointer id so a replay of the same image
73
+ * reports once, with the fresher outcome winning.
74
+ */
75
+ function agentArtifactsFromEvidence(options) {
76
+ const byPointerId = new Map();
77
+ for (const outcome of options.outcomes) {
78
+ if (outcome.upload_state === "upload_failed" || !outcome.artifact_metadata) {
79
+ continue;
80
+ }
81
+ const artifact = agentArtifactFromMetadata({
82
+ metadata: outcome.artifact_metadata,
83
+ rawEvidencePointerId: outcome.pointer.raw_evidence_pointer_id,
84
+ objectKey: outcome.object_key,
85
+ ticketId: options.ticketId,
86
+ repoFingerprint: options.repoFingerprint,
87
+ worktreeFingerprint: options.worktreeFingerprint,
88
+ uploadState: outcome.upload_state,
89
+ });
90
+ byPointerId.set(artifact.raw_evidence_pointer_id, artifact);
91
+ }
92
+ for (const entry of options.reused) {
93
+ if (!entry.artifact_metadata)
94
+ continue;
95
+ const objectKey = options.cursorObjects[entry.content_hash_sha256]?.object_key;
96
+ if (!objectKey)
97
+ continue;
98
+ const artifact = agentArtifactFromMetadata({
99
+ metadata: entry.artifact_metadata,
100
+ rawEvidencePointerId: objectKey,
101
+ objectKey,
102
+ ticketId: options.ticketId,
103
+ repoFingerprint: options.repoFingerprint,
104
+ worktreeFingerprint: options.worktreeFingerprint,
105
+ uploadState: "reused_existing",
106
+ });
107
+ byPointerId.set(artifact.raw_evidence_pointer_id, artifact);
108
+ }
109
+ return [...byPointerId.values()];
110
+ }
111
+ /**
112
+ * One report row. Redaction and OCR both start as `not_started`: the dashboard
113
+ * owns those passes, and the collector must never claim an image has been
114
+ * cleared when nothing has looked at it yet.
115
+ */
116
+ function agentArtifactFromMetadata(options) {
117
+ return {
118
+ raw_evidence_pointer_id: options.rawEvidencePointerId,
119
+ agent_source: options.metadata.agent_source,
120
+ source_session_id: options.metadata.source_session_id,
121
+ ...(options.metadata.source_message_id
122
+ ? { source_message_id: options.metadata.source_message_id }
123
+ : {}),
124
+ ...(options.metadata.turn_index !== undefined
125
+ ? { turn_index: options.metadata.turn_index }
126
+ : {}),
127
+ occurred_at: options.metadata.occurred_at,
128
+ artifact_kind: options.metadata.artifact_kind,
129
+ capture_origin: "agent_session_attachment",
130
+ ...(options.ticketId ? { ticket_id: options.ticketId } : {}),
131
+ ...(options.repoFingerprint
132
+ ? { repo_fingerprint: options.repoFingerprint }
133
+ : {}),
134
+ ...(options.worktreeFingerprint
135
+ ? { worktree_fingerprint: options.worktreeFingerprint }
136
+ : {}),
137
+ storage_bucket: "ambient-raw-evidence",
138
+ object_key: options.objectKey,
139
+ content_hash_sha256: options.metadata.content_hash_sha256,
140
+ byte_size: options.metadata.byte_size,
141
+ media_type: options.metadata.media_type,
142
+ width: options.metadata.width,
143
+ height: options.metadata.height,
144
+ redaction_status: "not_started",
145
+ ocr_status: "not_started",
146
+ labels: {
147
+ collector_upload_state: options.uploadState,
148
+ ...(options.metadata.source_sidecar_id
149
+ ? { source_sidecar_id: options.metadata.source_sidecar_id }
150
+ : {}),
151
+ },
152
+ };
153
+ }