@bli-cockpit/cli 0.2.155 → 0.2.156

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.
@@ -35,6 +35,7 @@ export async function runLocalSourceCollectors(options) {
35
35
  claudeAttributionScan: options.rawEvidenceClaudeAttributionScan,
36
36
  skipContentHashes: options.rawEvidenceSkipContentHashes,
37
37
  priorSnapshots: options.rawEvidencePriorSnapshots,
38
+ stubReoffers: options.rawEvidenceStubReoffers,
38
39
  byteBudget: options.rawEvidenceByteBudget,
39
40
  objectBudget: options.rawEvidenceObjectBudget,
40
41
  budget: options.rawEvidenceBudget,
@@ -74,6 +74,7 @@ export async function openCollection(context, options, places) {
74
74
  caps: startingCaps({ byteBudget, objectBudget, budget: options.budget }),
75
75
  skipContentHashes: options.skipContentHashes ?? new Set(),
76
76
  priorSnapshots: options.priorSnapshots ?? new Map(),
77
+ stubReoffers: options.stubReoffers ?? new Map(),
77
78
  budget: options.budget ?? {
78
79
  remainingBytes: byteBudget,
79
80
  remainingObjects: objectBudget,
@@ -20,6 +20,7 @@
20
20
  * says so: `snapshot_rewritten`, both sizes, and the byte range the first
21
21
  * difference falls in. Never content.
22
22
  */
23
+ import { REDACTION_STUB_LABEL } from "@bli-cockpit/telemetry-core";
23
24
  import { firstDifferenceRange, isPrefixSuccessor, prefixCheckpoints, } from "@bli-cockpit/telemetry-core/snapshot-supersede";
24
25
  /**
25
26
  * The newest committed snapshot per source key, from the cursor's objects map,
@@ -79,4 +80,32 @@ export function supersedeClaimFor(options) {
79
80
  first_differing_offset_below: range.below,
80
81
  }));
81
82
  return null;
83
+ }
84
+ /**
85
+ * BLI-11102: the claim a re-offered transcript makes when the server named its
86
+ * stored copy a redaction-crash stub. Only a main transcript that redacted
87
+ * cleanly this time claims; one that stubbed again claims nothing and keeps
88
+ * the ordinary path. Logs the decision either way.
89
+ */
90
+ export function stubReplacementClaim(options) {
91
+ if (!options.stubPointerId || (options.kind !== "codex_jsonl" && options.kind !== "claude_jsonl"))
92
+ return null;
93
+ if (options.completenessLabel === REDACTION_STUB_LABEL) {
94
+ console.error("[evidence-upload] stubbed session re-offered but it stubbed again; no claim", JSON.stringify({
95
+ reason: "stub_reoffer_still_stubbed",
96
+ kind: options.kind,
97
+ byte_size: options.byteSize,
98
+ }));
99
+ return null;
100
+ }
101
+ console.error("[evidence-upload] stubbed session re-offered; claiming to replace the stub", JSON.stringify({
102
+ reason: "stub_reoffered",
103
+ rule: "stub_replaced",
104
+ kind: options.kind,
105
+ byte_size: options.byteSize,
106
+ // The same bytes may already be durable (a later sync healed it without
107
+ // knowing about the stub); they are staged anyway so the claim has a carrier.
108
+ already_durable: options.alreadyDurable,
109
+ }));
110
+ return { pointer_id: options.stubPointerId, rule: "stub_replaced" };
82
111
  }
@@ -19,7 +19,7 @@ import { isSecretLikePath, sha256, shortHash, } from "./raw-evidence-keys.js";
19
19
  import { maskSecretsInTranscript, } from "./raw-evidence-sanitize.js";
20
20
  import { markBudgetCapApplied, markCapApplied, } from "./raw-evidence-completeness.js";
21
21
  import { evidenceEntry } from "./raw-evidence-manifest.js";
22
- import { describeSnapshot, supersedeClaimFor } from "./raw-evidence-snapshot-supersede.js";
22
+ import { describeSnapshot, stubReplacementClaim, supersedeClaimFor, } from "./raw-evidence-snapshot-supersede.js";
23
23
  import { admitToBudget, stageEvidenceBytes, } from "./raw-evidence-collection-state.js";
24
24
  /**
25
25
  * Reads, secret-guards, content-addresses, budget-checks, and copies one
@@ -64,7 +64,16 @@ export async function collectOneEvidenceFile(collection, options) {
64
64
  secretLikeFileName: names.secretLikeFileName,
65
65
  });
66
66
  const contentHash = sha256(sanitized.bytes);
67
- if (collection.skipContentHashes.has(contentHash)) {
67
+ // BLI-11102: a session the server named a redaction stub is re-offered even
68
+ // when these bytes are already durable, because the stub is not a delivery.
69
+ const stubClaim = stubReplacementClaim({
70
+ kind: options.kind,
71
+ stubPointerId: collection.stubReoffers.get(options.sessionId),
72
+ completenessLabel: sanitized.completenessLabel,
73
+ byteSize: sanitized.bytes.byteLength,
74
+ alreadyDurable: collection.skipContentHashes.has(contentHash),
75
+ });
76
+ if (collection.skipContentHashes.has(contentHash) && !stubClaim) {
68
77
  collection.reused.push({
69
78
  kind: options.kind,
70
79
  label: names.evidenceLabel,
@@ -83,6 +92,7 @@ export async function collectOneEvidenceFile(collection, options) {
83
92
  names,
84
93
  sanitized,
85
94
  contentHash,
95
+ stubClaim,
86
96
  });
87
97
  return true;
88
98
  }
@@ -139,7 +149,7 @@ async function stageOneTranscript(collection, file) {
139
149
  // BLI-11088: remember this snapshot's shape, and claim the last one when
140
150
  // these bytes rebuild it.
141
151
  snapshot: describeSnapshot(file.sanitized.bytes),
142
- supersedes: supersedeClaimFor({
152
+ supersedes: file.stubClaim ?? supersedeClaimFor({
143
153
  bytes: file.sanitized.bytes,
144
154
  contentHash: file.contentHash,
145
155
  kind: file.kind,
@@ -21,6 +21,7 @@ import { indexDurableMainObjectKeys } from "./backfill-evidence-outcomes.js";
21
21
  export async function recordBackfillDurableSessionPointers(options) {
22
22
  const durableObjectBySession = indexDurableMainObjectKeys(options.syncResults);
23
23
  let recordedCount = 0;
24
+ let stubsReplaced = 0;
24
25
  for (const source of ["codex", "claude_code"]) {
25
26
  const filename = source === "claude_code" ? CLAUDE_CURSOR_FILENAME : undefined;
26
27
  const cursor = await readRawEvidenceCursor(options.paths, { filename });
@@ -31,8 +32,13 @@ export async function recordBackfillDurableSessionPointers(options) {
31
32
  const objectKey = durableObjectBySession.get(`${source}:${candidate.session_id}`);
32
33
  const prior = cursor.sessions[candidate.session_id];
33
34
  // No pointer, no prior entry, or already pointed: nothing this pass owes.
34
- if (!objectKey || !prior || prior.uploaded_object_key)
35
+ // BLI-11102: a pointer the server named a redaction stub is not a
36
+ // delivery, so a real pointer replaces it and the mark clears.
37
+ const replacesStub = Boolean(prior?.stub_reoffer) && prior?.uploaded_object_key !== objectKey;
38
+ if (!objectKey || !prior || (prior.uploaded_object_key && !replacesStub))
35
39
  continue;
40
+ if (replacesStub)
41
+ stubsReplaced += 1;
36
42
  cursor.sessions[candidate.session_id] = deliveredCursorEntry(prior, candidate, objectKey, options.now);
37
43
  changed = true;
38
44
  recordedCount += 1;
@@ -45,12 +51,12 @@ export async function recordBackfillDurableSessionPointers(options) {
45
51
  }
46
52
  }
47
53
  if (recordedCount > 0) {
48
- console.error("[backfill] terminal session pointers recorded", JSON.stringify({ count: recordedCount }));
54
+ console.error("[backfill] terminal session pointers recorded", JSON.stringify({ count: recordedCount, stub_pointers_replaced: stubsReplaced }));
49
55
  }
50
56
  }
51
57
  /** The cursor entry for a session backfill has just delivered in full. */
52
58
  function deliveredCursorEntry(prior, candidate, objectKey, now) {
53
- return {
59
+ const entry = {
54
60
  ...prior,
55
61
  file_hash_sha256: candidate.content_hash_sha256,
56
62
  file_mtime_ms: candidate.session_file_mtime_ms,
@@ -65,6 +71,9 @@ function deliveredCursorEntry(prior, candidate, objectKey, now) {
65
71
  uploaded_byte_size: candidate.byte_size,
66
72
  last_seen_at: now.toISOString(),
67
73
  };
74
+ // The mark says "this pointer is a stub"; the pointer written above is not.
75
+ delete entry.stub_reoffer;
76
+ return entry;
68
77
  }
69
78
  export function advanceBackfillCursorThroughResolvedPrefix(options) {
70
79
  if (!options.discoveryComplete)
@@ -7,6 +7,7 @@ import { advanceBackfillCursorThroughResolvedPrefix, recordBackfillDurableSessio
7
7
  import { blockingScanIssues, cappedSidecarCount } from "./backfill-issues.js";
8
8
  import { backfillResultBaseArgs, baseBackfillResult, emptyReport, } from "./backfill-result.js";
9
9
  import { buildBackfillSessionReport } from "./backfill-session-report.js";
10
+ import { summarizeStubReoffer } from "./backfill-stub-reoffer.js";
10
11
  /**
11
12
  * REPORT: post the session report, record durable pointers, advance the
12
13
  * cursor through the resolved contiguous prefix, decide completion, write the
@@ -37,14 +38,17 @@ export async function reportBackfillOutcome(command, io, ctx, upload) {
37
38
  if (completion.status === "complete" && command.all) {
38
39
  await writeAllHistoryCompletionMarker(ctx);
39
40
  }
40
- return assembleBackfillResult({
41
- command,
42
- ctx,
43
- upload,
44
- posted,
45
- completion,
46
- blockedAt,
47
- });
41
+ return {
42
+ ...assembleBackfillResult({
43
+ command,
44
+ ctx,
45
+ upload,
46
+ posted,
47
+ completion,
48
+ blockedAt,
49
+ }),
50
+ stub_reoffer: summarizeStubReoffer(ctx.stubReoffer, upload.uploadable),
51
+ };
48
52
  }
49
53
  /**
50
54
  * Tells the server what this run observed about every session, uploaded or
@@ -20,6 +20,7 @@ import { addReadOnlyGuardIssues, addRepoDiscoveryIssues, backfillScanIssues, cou
20
20
  import { labelOutsideRootCandidates, outsideRootsWorkspace, } from "./backfill-outside-roots.js";
21
21
  import { reasonCountsFor } from "./backfill-reasons.js";
22
22
  import { blockedBackfillResult } from "./backfill-result.js";
23
+ import { emptyStubReofferPlan, loadStubReofferPlan, reopenStubbedSessions, } from "./backfill-stub-reoffer.js";
23
24
  import { resolveBackfillScope } from "./backfill-scope.js";
24
25
  /**
25
26
  * SCAN: is this machine paired, what roots/sources/window apply, and what did
@@ -47,6 +48,18 @@ export async function scanBackfillRun(command, io, now) {
47
48
  const scopedCursor = prepareBackfillCursorForScope(storedCursor, collectionRoots, sources);
48
49
  const cursor = scopedCursor.cursor;
49
50
  const pointerlessTerminalSessions = await loadPointerlessTerminalSessions(paths, sources);
51
+ // BLI-11102: a redaction-crash stub is not a delivery. The server names
52
+ // them; those sessions reopen exactly like pointer-less ones. A dry run
53
+ // keeps its one-request contract (the reachability probe) and does not ask.
54
+ const stubReoffer = command.dryRun
55
+ ? emptyStubReofferPlan("dry_run")
56
+ : await loadStubReofferPlan({
57
+ paths,
58
+ dashboardUrl: scope.dashboardUrl,
59
+ sources,
60
+ fetchImpl: io.fetch,
61
+ });
62
+ reopenStubbedSessions(pointerlessTerminalSessions, stubReoffer);
50
63
  const scan = await scanBackfillSessions({
51
64
  command,
52
65
  homeDir: command.homeDir ?? os.homedir(),
@@ -73,6 +86,7 @@ export async function scanBackfillRun(command, io, now) {
73
86
  cursor,
74
87
  scan,
75
88
  reasonCounts,
89
+ stubReoffer,
76
90
  oversizedCandidateKeys: oversizedBackfillCandidateKeys(scan.candidates),
77
91
  deterministicSkipCandidateKeys: deterministicSkipCandidateKeys(scan.candidates),
78
92
  };
@@ -0,0 +1,104 @@
1
+ import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
2
+ import { fetchStubbedSessions, stubSessionKey } from "../evidence-stubs-client.js";
3
+ import { describeError } from "../health-detail.js";
4
+ import { readLocalCollectorSessionFile } from "../local-state.js";
5
+ export function emptyStubReofferPlan(reason) {
6
+ return { status: "skipped", reason, sessions: new Map(), stub_count: 0, marked_local_sessions: 0 };
7
+ }
8
+ /** Ask the server which of this operator's pointers are stubs. Never throws; writes nothing. */
9
+ export async function loadStubReofferPlan(options) {
10
+ const session = await readLocalCollectorSessionFile(options.paths).catch(() => null);
11
+ if (!session || session.session_state !== "valid" || typeof session.device_token !== "string" || !session.device_token) {
12
+ console.error("[backfill] redaction stubs not asked", JSON.stringify({ reason: "no_device_session" }));
13
+ return emptyStubReofferPlan("no_device_session");
14
+ }
15
+ const answer = await fetchStubbedSessions({
16
+ dashboardUrl: options.dashboardUrl,
17
+ deviceToken: session.device_token,
18
+ fetchImpl: options.fetchImpl,
19
+ });
20
+ const sessions = new Map([...answer.sessions].filter(([key]) => options.sources.some((source) => key.startsWith(`${source}:`))));
21
+ return {
22
+ status: answer.status,
23
+ reason: answer.reason,
24
+ sessions,
25
+ stub_count: answer.unique_stub_count,
26
+ marked_local_sessions: 0,
27
+ };
28
+ }
29
+ /**
30
+ * Mark every stubbed session this machine tracks as not delivered. The caller
31
+ * holds the collection lock: sync writes the same cursor files.
32
+ */
33
+ export async function markStubReoffers(paths, plan, now) {
34
+ if (plan.sessions.size === 0)
35
+ return;
36
+ plan.marked_local_sessions = await markStubbedSessions(paths, plan.sessions, now);
37
+ }
38
+ /** Reopen stubbed sessions exactly like pointer-less ones: selected past the cursor, sorted first. */
39
+ export function reopenStubbedSessions(reopen, plan) {
40
+ for (const key of plan.sessions.keys()) {
41
+ const at = key.indexOf(":");
42
+ const source = key.slice(0, at);
43
+ reopen[source]?.add(key.slice(at + 1));
44
+ }
45
+ }
46
+ /** Session id -> the stub pointer the new upload claims to replace (the server retires the rest). */
47
+ export function stubClaimsForCandidates(candidates, plan) {
48
+ const claims = new Map();
49
+ for (const candidate of candidates) {
50
+ const pointer = plan.sessions.get(stubSessionKey(candidate.source, candidate.session_id))?.[0];
51
+ if (pointer)
52
+ claims.set(candidate.session_id, pointer);
53
+ }
54
+ return claims;
55
+ }
56
+ export function summarizeStubReoffer(plan, candidates) {
57
+ const reoffered = candidates.filter((candidate) => plan.sessions.has(stubSessionKey(candidate.source, candidate.session_id))).length;
58
+ const summary = {
59
+ status: plan.status,
60
+ reason: plan.reason,
61
+ stubs: plan.stub_count,
62
+ sessions: plan.sessions.size,
63
+ marked_local_sessions: plan.marked_local_sessions,
64
+ reoffered_sessions: reoffered,
65
+ };
66
+ if (plan.sessions.size > 0) {
67
+ console.error("[backfill] redaction-stubbed sessions re-offered", JSON.stringify({
68
+ ...summary,
69
+ reason: "stub_reoffered",
70
+ stub_door: `${summary.status}:${summary.reason}`,
71
+ // Stubbed on the server, but no file for it in this run's scan window.
72
+ not_found_locally: plan.sessions.size - reoffered,
73
+ }));
74
+ }
75
+ return summary;
76
+ }
77
+ async function markStubbedSessions(paths, sessions, now) {
78
+ let marked = 0;
79
+ for (const source of ["codex", "claude_code"]) {
80
+ const filename = source === "claude_code" ? CLAUDE_CURSOR_FILENAME : undefined;
81
+ try {
82
+ const cursor = await readRawEvidenceCursor(paths, { filename });
83
+ let changed = false;
84
+ for (const [key, pointerIds] of sessions) {
85
+ if (!key.startsWith(`${source}:`))
86
+ continue;
87
+ const entry = cursor.sessions[key.slice(source.length + 1)];
88
+ if (!entry)
89
+ continue;
90
+ entry.stub_reoffer = { reason: "stub_reoffered", stub_pointer_ids: pointerIds, marked_at: now.toISOString() };
91
+ changed = true;
92
+ marked += 1;
93
+ }
94
+ if (changed) {
95
+ await writeRawEvidenceCursor(paths, cursor, { filename, sessionsOnly: source === "claude_code" });
96
+ }
97
+ }
98
+ catch (error) {
99
+ // The mark is bookkeeping; the server's answer still drives this run.
100
+ console.error("[backfill] stub mark not written to the cursor", JSON.stringify({ reason: "stub_mark_write_failed", source, ...describeError(error) }));
101
+ }
102
+ }
103
+ return marked;
104
+ }
@@ -7,6 +7,7 @@ import { countSessionUploadFailures, deferredEvidenceCount, durableBackfillCandi
7
7
  import { blockingScanIssues } from "./backfill-issues.js";
8
8
  import { ensureOutsideRootsWorkspace, isOutsideRootsWorkspace, } from "./backfill-outside-roots.js";
9
9
  import { writeLine } from "./backfill-output.js";
10
+ import { stubClaimsForCandidates } from "./backfill-stub-reoffer.js";
10
11
  const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
11
12
  /**
12
13
  * UPLOAD: sync every uploadable candidate in fixed-size batches, heartbeating
@@ -101,6 +102,7 @@ async function uploadOneBackfillBatch(command, io, ctx, batch, rawEvidenceBudget
101
102
  codexAttribution: ctx.scan.codexAttribution,
102
103
  claudeAttribution: ctx.scan.claudeAttribution,
103
104
  rawEvidenceBudget,
105
+ stubClaims: stubClaimsForCandidates(batch.candidates, ctx.stubReoffer),
104
106
  fetchImpl: io.fetch,
105
107
  });
106
108
  const durableInBatch = durableBackfillCandidateKeys(batch, sync);
@@ -216,6 +218,8 @@ function backfillBatchSyncOptions(options) {
216
218
  })),
217
219
  claudeAttributionScan: options.claudeAttribution ?? undefined,
218
220
  rawEvidenceBudget: options.rawEvidenceBudget,
221
+ // BLI-11102: session id -> the redaction stub this upload replaces.
222
+ rawEvidenceStubReoffers: options.stubClaims,
219
223
  // `cockpit backfill` is only ever an operator asking — by hand, through
220
224
  // onboarding, through doctor, or by running the retry command Cockpit
221
225
  // printed. The delivery-backoff window is the scheduler's cadence, so it
@@ -25,6 +25,7 @@
25
25
  * backfill-evidence-outcomes.ts what one upload achieved per session
26
26
  * backfill-session-report.ts one attribution row per session seen
27
27
  * backfill-checkpoint.ts the durable cursor and pointer writes
28
+ * backfill-stub-reoffer.ts redaction-crash stubs: re-offered, not delivered
28
29
  * backfill-result.ts the `--json` payload
29
30
  * backfill-output.ts every line and prompt a person sees
30
31
  *
@@ -45,6 +46,7 @@ import { planBackfillRun } from "./backfill-plan.js";
45
46
  import { reportBackfillOutcome } from "./backfill-report.js";
46
47
  import { lockHeldResult } from "./backfill-result.js";
47
48
  import { scanBackfillRun } from "./backfill-scan.js";
49
+ import { markStubReoffers } from "./backfill-stub-reoffer.js";
48
50
  import { uploadBackfillBatches } from "./backfill-upload.js";
49
51
  // The command's public surface. Callers (doctor, onboard, local, the CLI
50
52
  // router, the BLI-3272 regression test) import these from `./backfill.js`;
@@ -135,6 +137,9 @@ export async function runBackfill(command, io) {
135
137
  if (scanned.scopedCursor.reset) {
136
138
  await writeBackfillCursor(paths, scanned.cursor);
137
139
  }
140
+ // BLI-11102: mark server-named stubs as not delivered, under the lock
141
+ // because sync writes the same cursor files.
142
+ await markStubReoffers(paths, scanned.stubReoffer, now);
138
143
  const upload = await uploadBackfillBatches(command, io, lock, scanned);
139
144
  return await reportBackfillOutcome(command, io, scanned, upload);
140
145
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.155");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.156");
25
25
  return 0;
26
26
  }
27
27
 
@@ -195,8 +195,21 @@ function parseSessionEntry(value) {
195
195
  uploaded_at: optionalString(record["uploaded_at"]),
196
196
  uploaded_byte_size: optionalNumberOrNull(record["uploaded_byte_size"]),
197
197
  last_seen_at: lastSeenAt,
198
+ ...parseStubReoffer(record["stub_reoffer"]),
198
199
  };
199
200
  }
201
+ function parseStubReoffer(value) {
202
+ if (!value || typeof value !== "object")
203
+ return {};
204
+ const record = value;
205
+ const markedAt = optionalString(record["marked_at"]);
206
+ const ids = Array.isArray(record["stub_pointer_ids"])
207
+ ? record["stub_pointer_ids"].filter((id) => typeof id === "string" && id.length > 0)
208
+ : [];
209
+ if (record["reason"] !== "stub_reoffered" || !markedAt || ids.length === 0)
210
+ return {};
211
+ return { stub_reoffer: { reason: "stub_reoffered", stub_pointer_ids: ids, marked_at: markedAt } };
212
+ }
200
213
  function parseCheckpoints(value) {
201
214
  if (!value || typeof value !== "object")
202
215
  return null;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Asks `POST /api/ambient/evidence/stubs` which of this operator's stored
3
+ * transcripts are only the redaction-crash stub, and groups the answer by agent
4
+ * session (BLI-11102).
5
+ *
6
+ * A stub is a promise to come back, not a delivery: the local cursor recorded
7
+ * an upload, so nothing local would ever offer that session again. The server
8
+ * holds the label, so the server is asked. `cockpit backfill` re-offers every
9
+ * session this returns.
10
+ *
11
+ * Never throws. A door that cannot be asked (an older dashboard answers 404, a
12
+ * network error, a malformed page) returns `unavailable` with the reason, and
13
+ * the backfill runs exactly as it did before. That is also the release gate:
14
+ * the collector only ever sends a `stub_replaced` claim for a stub this door
15
+ * named, so it cannot send one to a server that does not know the rule.
16
+ *
17
+ * Bounded: pages of up to 500, at most `maxPages` of them, walked by the
18
+ * server's keyset cursor. The result says whether the list was exhausted,
19
+ * capped by `maxPages`, or cut short by a failed page after some landed.
20
+ */
21
+ import { EVIDENCE_STUBS_MAX_PAGE, EVIDENCE_STUBS_SCHEMA_VERSION, EvidenceStubsResponseSchema, transcriptSessionFromObjectKey, } from "@bli-cockpit/telemetry-core";
22
+ import { describeError } from "./health-detail.js";
23
+ const STUBS_PAGE_TIMEOUT_MS = 15_000;
24
+ export const STUBS_DEFAULT_MAX_PAGES = 20;
25
+ export function stubSessionKey(source, sessionId) {
26
+ return `${source}:${sessionId}`;
27
+ }
28
+ export async function fetchStubbedSessions(options) {
29
+ const maxPages = options.maxPages ?? STUBS_DEFAULT_MAX_PAGES;
30
+ const sessions = new Map();
31
+ const seen = new Set();
32
+ let stubCount = 0;
33
+ let unkeyed = 0;
34
+ let pages = 0;
35
+ let after = null;
36
+ for (;;) {
37
+ if (pages >= maxPages) {
38
+ return finish("capped", "ok");
39
+ }
40
+ const page = await requestPage({ ...options, after });
41
+ if (!page.ok) {
42
+ return finish(pages === 0 ? "unavailable" : "failed_partial", page.reason);
43
+ }
44
+ pages += 1;
45
+ for (const stub of page.stubs) {
46
+ stubCount += 1;
47
+ if (seen.has(stub.ref_id))
48
+ continue;
49
+ seen.add(stub.ref_id);
50
+ const session = transcriptSessionFromObjectKey(stub.object_key);
51
+ if (!session) {
52
+ unkeyed += 1;
53
+ continue;
54
+ }
55
+ const key = stubSessionKey(session.source, session.session_id);
56
+ sessions.set(key, [...(sessions.get(key) ?? []), stub.raw_evidence_pointer_id]);
57
+ }
58
+ // Advance by what the server returned; a page that names no cursor is the last.
59
+ if (!page.nextAfter)
60
+ return finish("exhausted", "ok");
61
+ after = page.nextAfter;
62
+ }
63
+ function finish(status, reason) {
64
+ const result = {
65
+ status,
66
+ reason,
67
+ pages,
68
+ stub_count: stubCount,
69
+ unique_stub_count: seen.size,
70
+ unkeyed_stub_count: unkeyed,
71
+ sessions,
72
+ };
73
+ console.error("[collector stubs] redaction-crash stubs asked", JSON.stringify({
74
+ reason: status === "unavailable" ? `stub_door_unavailable:${reason}` : `stubs_${status}`,
75
+ pages,
76
+ stub_count: stubCount,
77
+ unique_stub_count: seen.size,
78
+ unkeyed_stub_count: unkeyed,
79
+ session_count: sessions.size,
80
+ ...(status === "failed_partial" ? { failure: reason } : {}),
81
+ }));
82
+ return result;
83
+ }
84
+ }
85
+ async function requestPage(options) {
86
+ const controller = new AbortController();
87
+ const timeout = setTimeout(() => controller.abort(), STUBS_PAGE_TIMEOUT_MS);
88
+ try {
89
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/stubs`, {
90
+ method: "POST",
91
+ headers: {
92
+ "Content-Type": "application/json",
93
+ Authorization: `Bearer ${options.deviceToken}`,
94
+ },
95
+ body: JSON.stringify({
96
+ schema_version: EVIDENCE_STUBS_SCHEMA_VERSION,
97
+ after: options.after,
98
+ limit: EVIDENCE_STUBS_MAX_PAGE,
99
+ }),
100
+ signal: controller.signal,
101
+ });
102
+ if (!response.ok)
103
+ return { ok: false, reason: `http_${response.status}` };
104
+ const parsed = EvidenceStubsResponseSchema.safeParse(await response.json().catch(() => null));
105
+ if (!parsed.success)
106
+ return { ok: false, reason: "invalid_response" };
107
+ return { ok: true, stubs: parsed.data.stubs, nextAfter: parsed.data.next_after };
108
+ }
109
+ catch (error) {
110
+ return { ok: false, reason: describeError(error).error_name };
111
+ }
112
+ finally {
113
+ clearTimeout(timeout);
114
+ }
115
+ }
@@ -125,6 +125,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
125
125
  operatorId: session.operator_id,
126
126
  workContextId: uploadContext.work_context_id,
127
127
  })),
128
+ rawEvidenceStubReoffers: options.rawEvidenceStubReoffers,
128
129
  rawEvidenceByteBudget: options.rawEvidenceByteBudget,
129
130
  rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
130
131
  rawEvidenceBudget: options.rawEvidenceBudget,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.155",
3
+ "version": "0.2.156",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.41",
31
- "@bli-cockpit/mcp": "0.1.75",
32
- "@bli-cockpit/telemetry-core": "0.1.53"
30
+ "@bli-cockpit/memory-mcp": "0.1.42",
31
+ "@bli-cockpit/mcp": "0.1.76",
32
+ "@bli-cockpit/telemetry-core": "0.1.54"
33
33
  }
34
34
  }