@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.
- 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 +740 -1044
- 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 +183 -1842
- 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/local-state.js +12 -1
- package/dist/raw-evidence-gc.js +178 -0
- package/dist/raw-evidence-staging.js +322 -0
- 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 +153 -1159
- package/package.json +2 -2
|
@@ -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
|
+
}
|