@bli-cockpit/cli 0.2.28 → 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.
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.28");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.29");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `cockpit sessions` — the operator's local answer to "why is session X
3
+ * missing?".
4
+ *
5
+ * Read-only: re-runs attribution, uploads nothing, writes no cursor. Per-session
6
+ * reasons otherwise live only in a service-role table with no UI (B.4 §6).
7
+ * Counts and labels only — the project-dir slug encodes a local path and is
8
+ * never printed. Split out of commands/local.ts (BLI-3104); moved verbatim.
9
+ */
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { writeLine } from "./cli-io.js";
13
+ import { discoverCommandWorktrees } from "./local-discovery.js";
14
+ import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
15
+ import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
16
+ import { getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "../local-state.js";
17
+ const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
18
+ const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
19
+ export async function runSessions(command, io) {
20
+ const now = new Date();
21
+ const homeDir = command.homeDir ?? os.homedir();
22
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
23
+ const window = await sessionsScanWindow(command, now);
24
+ const wantCodex = command.source !== "claude";
25
+ const wantClaude = command.source !== "codex";
26
+ const codex = wantCodex
27
+ ? await scanAndAttributeCodexSessions({
28
+ sessionsDirs: defaultCodexSessionDirs(homeDir),
29
+ worktrees,
30
+ now,
31
+ sinceMinutes: window.since_minutes ?? CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
32
+ limit: window.limit,
33
+ })
34
+ : null;
35
+ const claude = wantClaude
36
+ ? await scanAndAttributeClaudeSessions({
37
+ projectsDir: path.join(homeDir, ".claude", "projects"),
38
+ worktrees,
39
+ now,
40
+ sinceMinutes: window.since_minutes ?? undefined,
41
+ limit: window.mode === "default" ? undefined : window.limit,
42
+ })
43
+ : null;
44
+ // Safe output contract (B.4 §6): id, state, reason, scores, signals, sidecar
45
+ // skip reasons, plus the repo_label basename. Branch is intentionally omitted
46
+ // — branch names can carry operator-authored task/customer text.
47
+ const codexRows = (codex?.results ?? []).map((result) => ({
48
+ source: "codex",
49
+ session_id: result.codex_session_id,
50
+ state: result.state,
51
+ reason: result.reason,
52
+ attribution_score: result.attribution_score,
53
+ path_score: result.path_score,
54
+ signals: result.signals,
55
+ repo_label: result.worktree?.repo_label ?? null,
56
+ }));
57
+ const claudeRows = (claude?.results ?? []).map((result) => ({
58
+ source: "claude_code",
59
+ session_id: result.claude_session_id,
60
+ state: result.state,
61
+ reason: result.reason,
62
+ attribution_score: result.attribution_score,
63
+ path_score: result.path_score,
64
+ signals: result.signals,
65
+ repo_label: result.worktree?.repo_label ?? null,
66
+ main_file_oversized: result.main_file_oversized,
67
+ sidecar_skips: result.sidecar_files
68
+ .filter((sidecar) => sidecar.skipped_reason)
69
+ .map((sidecar) => ({
70
+ file_name: sidecar.file_name,
71
+ reason: sidecar.skipped_reason,
72
+ })),
73
+ }));
74
+ if (command.json) {
75
+ writeLine(io.stdout, JSON.stringify({
76
+ window,
77
+ ...(codex
78
+ ? { codex: { counts: codex.counts, sessions: codexRows } }
79
+ : {}),
80
+ ...(claude
81
+ ? {
82
+ claude: {
83
+ counts: claude.counts,
84
+ project_dirs_skipped: claude.project_dirs_skipped,
85
+ sessions: claudeRows,
86
+ },
87
+ }
88
+ : {}),
89
+ }, null, 2));
90
+ return 0;
91
+ }
92
+ writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
93
+ writeLine(io.stdout, `window: ${sessionsWindowLine(window)}`);
94
+ for (const row of codexRows) {
95
+ writeLine(io.stdout, sessionRowLine(row));
96
+ }
97
+ for (const row of claudeRows) {
98
+ writeLine(io.stdout, sessionRowLine(row));
99
+ for (const sidecar of row.sidecar_skips) {
100
+ writeLine(io.stdout, ` sidecar ${sidecar.file_name}: ${sidecar.reason}`);
101
+ }
102
+ }
103
+ if (codexRows.length === 0 && claudeRows.length === 0) {
104
+ writeLine(io.stdout, "No sessions observed in the scan window.");
105
+ }
106
+ return 0;
107
+ }
108
+ /** `--all`, an explicit `--since-days` capped at pairing, or the default window. */
109
+ async function sessionsScanWindow(command, now) {
110
+ if (command.all) {
111
+ return {
112
+ mode: "all",
113
+ since_days: null,
114
+ started_at: new Date(now.getTime() - ALL_SESSION_SCAN_WINDOW_MINUTES * 60_000)
115
+ .toISOString(),
116
+ paired_at: await readPairedAt(command.homeDir),
117
+ since_minutes: ALL_SESSION_SCAN_WINDOW_MINUTES,
118
+ limit: SESSION_SCAN_OVERRIDE_LIMIT,
119
+ };
120
+ }
121
+ if (command.sinceDays !== undefined) {
122
+ const requestedMs = now.getTime() - command.sinceDays * 24 * 60 * 60_000;
123
+ const pairedAt = await readPairedAt(command.homeDir);
124
+ const pairedMs = pairedAt ? Date.parse(pairedAt) : Number.NaN;
125
+ const startedAtMs = Number.isFinite(pairedMs)
126
+ ? Math.max(requestedMs, pairedMs)
127
+ : requestedMs;
128
+ return {
129
+ mode: "since_days",
130
+ since_days: command.sinceDays,
131
+ started_at: new Date(startedAtMs).toISOString(),
132
+ paired_at: pairedAt,
133
+ since_minutes: Math.max(1, Math.ceil((now.getTime() - startedAtMs) / 60_000)),
134
+ limit: SESSION_SCAN_OVERRIDE_LIMIT,
135
+ };
136
+ }
137
+ return {
138
+ mode: "default",
139
+ since_days: null,
140
+ started_at: null,
141
+ paired_at: await readPairedAt(command.homeDir),
142
+ since_minutes: null,
143
+ limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
144
+ };
145
+ }
146
+ async function readPairedAt(homeDir) {
147
+ const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(homeDir)).catch(() => null);
148
+ return typeof session?.paired_at === "string" ? session.paired_at : null;
149
+ }
150
+ function sessionsWindowLine(window) {
151
+ if (window.mode === "all")
152
+ return "all local history";
153
+ if (window.mode === "since_days") {
154
+ return `since ${window.started_at} (${window.since_days} day request, paired_at cap ${window.paired_at ?? "unavailable"})`;
155
+ }
156
+ return "default scan window";
157
+ }
158
+ function sessionRowLine(row) {
159
+ const repo = row.repo_label ? ` repo:${row.repo_label}` : "";
160
+ const signals = row.signals.length > 0 ? ` signals:${row.signals.join("|")}` : "";
161
+ return `- [${row.source}] ${row.session_id} ${row.state} (${row.reason}) score:${row.attribution_score} path:${row.path_score}${repo}${signals}`;
162
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `cockpit status` — what this machine believes about itself: install, pairing,
3
+ * active work, upload state, retry backlog, and how far historical backfill
4
+ * has got.
5
+ *
6
+ * Read-only. Split out of commands/local.ts (BLI-3104); moved verbatim, since
7
+ * every line here is what an intern pastes into Slack when something looks
8
+ * wrong.
9
+ */
10
+ import { readdir, stat } from "node:fs/promises";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { writeLine } from "./cli-io.js";
14
+ import { displayTicketId, displayWorkLabel, shortSha, stuckEvidenceLine, } from "./collection-report.js";
15
+ import { discoverCommandWorktrees } from "./local-discovery.js";
16
+ import { defaultCodexSessionDirs } from "../adapters/codex-attribution.js";
17
+ import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
18
+ import { getCollectorRuntimePaths, inspectLocalCollectorStatus, readLocalCollectorConfig, } from "../local-state.js";
19
+ import { normalizeCollectionRoots } from "../root-normalization.js";
20
+ export async function runStatus(command, io) {
21
+ const backfillCursor = await inspectBackfillCursor(command.homeDir);
22
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
23
+ if (worktrees.length > 1) {
24
+ const statuses = await Promise.all(worktrees.map(async (worktree) => ({
25
+ ...(await inspectLocalCollectorStatus({
26
+ homeDir: command.homeDir,
27
+ repoRoot: worktree.repo_root,
28
+ })),
29
+ head_sha: worktree.head_sha,
30
+ })));
31
+ if (command.json) {
32
+ writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses, backfill_cursor: backfillCursor }, null, 2));
33
+ return 0;
34
+ }
35
+ writeLine(io.stdout, "Cockpit parent status");
36
+ writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
37
+ for (const status of statuses) {
38
+ writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
39
+ }
40
+ return 0;
41
+ }
42
+ const status = await inspectLocalCollectorStatus(command);
43
+ if (command.json) {
44
+ writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor }, null, 2));
45
+ return 0;
46
+ }
47
+ writeLine(io.stdout, "Cockpit local status");
48
+ writeLine(io.stdout, `installed: ${status.installed}`);
49
+ writeLine(io.stdout, `session_state: ${status.session_state}`);
50
+ writeLine(io.stdout, `repo: ${status.repo}`);
51
+ writeLine(io.stdout, `branch: ${status.branch}`);
52
+ writeLine(io.stdout, `ticket: ${displayTicketId(status.active_ticket_id)}`);
53
+ writeLine(io.stdout, `work: ${displayWorkLabel(status)}`);
54
+ writeLine(io.stdout, `collector_freshness: ${status.collector_freshness}`);
55
+ writeLine(io.stdout, `collector_version: ${status.collector_version}`);
56
+ writeLine(io.stdout, `upload_state: ${status.upload_state}`);
57
+ writeLine(io.stdout, `last_upload_attempt: ${status.last_upload_attempt_at ?? "never"}`);
58
+ writeLine(io.stdout, `last_upload_success: ${status.last_upload_success_at ?? "never"}`);
59
+ writeLine(io.stdout, `last_upload_failure: ${status.last_upload_failure_reason ?? "none"}`);
60
+ writeLine(io.stdout, `pending_uploads: ${status.pending_upload_count}`);
61
+ writeLine(io.stdout, `pending_health_receipts: ${status.pending_health_receipt_count}`);
62
+ writeLine(io.stdout, `last_health_receipt_failure: ${status.last_health_receipt_failure_reason ?? "none"}`);
63
+ writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
64
+ writeLine(io.stdout, `stuck_evidence: ${stuckEvidenceLine(status)}`);
65
+ for (const detail of status.details)
66
+ writeLine(io.stdout, `- ${detail}`);
67
+ return 0;
68
+ }
69
+ /**
70
+ * Done, still working, or never started — and when it is still working, how
71
+ * many session files sit older than the cursor.
72
+ */
73
+ async function inspectBackfillCursor(homeDir) {
74
+ const paths = getCollectorRuntimePaths(homeDir);
75
+ const roots = await currentBackfillRoots(homeDir);
76
+ const marker = await readBackfillCompletionMarker(paths);
77
+ if (backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
78
+ return {
79
+ state: "done",
80
+ remaining_count: 0,
81
+ updated_at: marker?.cursor.updated_at ?? null,
82
+ completed_at: marker?.completed_at ?? null,
83
+ sources: summarizeBackfillCursorSources(marker?.cursor ?? emptyBackfillCursorState()),
84
+ };
85
+ }
86
+ const cursor = (prepareBackfillCursorForScope(await readBackfillCursor(paths), roots, ["codex", "claude_code"])).cursor;
87
+ if (!cursor.updated_at) {
88
+ return {
89
+ state: "never_run",
90
+ remaining_count: 0,
91
+ updated_at: null,
92
+ completed_at: null,
93
+ sources: summarizeBackfillCursorSources(cursor),
94
+ };
95
+ }
96
+ return {
97
+ state: "remaining",
98
+ remaining_count: await countRemainingBackfillSessionFiles(homeDir ?? os.homedir(), cursor),
99
+ updated_at: cursor.updated_at,
100
+ completed_at: null,
101
+ sources: summarizeBackfillCursorSources(cursor),
102
+ };
103
+ }
104
+ async function currentBackfillRoots(homeDir) {
105
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
106
+ return normalizeCollectionRoots(config?.default_repo_paths ?? []);
107
+ }
108
+ function summarizeBackfillCursorSources(cursor) {
109
+ return {
110
+ codex: summarizeBackfillSource(cursor.sources.codex),
111
+ claude_code: summarizeBackfillSource(cursor.sources.claude_code),
112
+ };
113
+ }
114
+ function summarizeBackfillSource(source) {
115
+ return {
116
+ oldest_mtime_processed: source.oldest_mtime_processed,
117
+ observed_count: Object.values(source.state_counts).reduce((total, count) => total + count, 0),
118
+ state_counts: source.state_counts,
119
+ reason_counts: source.reason_counts,
120
+ };
121
+ }
122
+ function backfillCursorLine(status) {
123
+ switch (status.state) {
124
+ case "done":
125
+ return `done (${status.completed_at ?? "completion marker present"})`;
126
+ case "never_run":
127
+ return "never run";
128
+ case "remaining":
129
+ return `${status.remaining_count} remaining`;
130
+ }
131
+ }
132
+ async function countRemainingBackfillSessionFiles(homeDir, cursor) {
133
+ const codex = await countJsonlFilesBeforeCursor(defaultCodexSessionDirs(homeDir), cursor.sources.codex.oldest_mtime_ms_processed);
134
+ const claude = await countClaudeMainFilesBeforeCursor(path.join(homeDir, ".claude", "projects"), cursor.sources.claude_code.oldest_mtime_ms_processed);
135
+ return codex + claude;
136
+ }
137
+ async function countJsonlFilesBeforeCursor(roots, oldestProcessedMs) {
138
+ let count = 0;
139
+ await walkFiles(roots, async (filePath, entryName) => {
140
+ if (!entryName.endsWith(".jsonl"))
141
+ return;
142
+ const info = await stat(filePath).catch(() => null);
143
+ if (!info?.isFile())
144
+ return;
145
+ if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
146
+ count += 1;
147
+ });
148
+ return count;
149
+ }
150
+ async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs) {
151
+ let count = 0;
152
+ await walkFiles([projectsDir], async (filePath, entryName) => {
153
+ if (!entryName.endsWith(".jsonl"))
154
+ return;
155
+ if (filePath.includes(`${path.sep}subagents${path.sep}`))
156
+ return;
157
+ const info = await stat(filePath).catch(() => null);
158
+ if (!info?.isFile())
159
+ return;
160
+ if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
161
+ count += 1;
162
+ });
163
+ return count;
164
+ }
165
+ /** Unreadable folders are skipped rather than failing the walk. */
166
+ async function walkFiles(roots, onFile, shouldStop = () => false) {
167
+ const stack = [...roots];
168
+ while (stack.length > 0 && !shouldStop()) {
169
+ const current = stack.pop();
170
+ if (!current)
171
+ continue;
172
+ let entries;
173
+ try {
174
+ entries = await readdir(current, { withFileTypes: true });
175
+ }
176
+ catch {
177
+ continue;
178
+ }
179
+ for (const entry of entries) {
180
+ if (shouldStop())
181
+ return;
182
+ const full = path.join(current, entry.name);
183
+ if (entry.isDirectory()) {
184
+ stack.push(full);
185
+ }
186
+ else if (entry.isFile()) {
187
+ await onFile(full, entry.name);
188
+ }
189
+ }
190
+ }
191
+ }
@@ -1,4 +1,4 @@
1
- import { RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, RawEvidenceLegacyUploadResponseSchema, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, RawEvidenceUploadCommitResponseSchema, isPermanentUploadFailure, } from "@bli-cockpit/telemetry-core";
1
+ import { COMMIT_CRASHED_PLATFORM, RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, RawEvidenceLegacyUploadResponseSchema, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, RawEvidenceUploadCommitResponseSchema, isPermanentUploadFailure, } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  const DEFAULT_MAX_ATTEMPTS = 3;
@@ -289,6 +289,29 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
289
289
  if (permanent) {
290
290
  return failedOutcome(entry.file, permanent, uploadedChunks);
291
291
  }
292
+ // A 5xx whose body is not JSON did not come from the route. The commit
293
+ // handler always answers `{ code, message, ... }`; an HTML error page means
294
+ // the serverless process was killed — an out-of-memory on a large
295
+ // assembly, or a hard timeout — so nothing server-side ran a catch, wrote a
296
+ // ledger reason, or logged a line. Naming it separately is the only way an
297
+ // operator reading upload reasons can tell "the server refused these bytes"
298
+ // from "the server never survived them".
299
+ //
300
+ // The stem comes from telemetry-core so the label the collector writes and
301
+ // the label `classifyUploadFailure` reads are the same string by
302
+ // construction; the status rides on the end so a 502 gateway timeout can
303
+ // still be told from a 500 process kill, and core strips it back off.
304
+ if (commit.status >= 500 && isNonJsonResponseBody(commit.body)) {
305
+ console.error("[evidence-commit] the server died before it could answer; the object is still pending", JSON.stringify({
306
+ upload_id: disposition.upload_id,
307
+ http_status: commit.status,
308
+ byte_size: entry.bytes.byteLength,
309
+ chunk_count: entry.chunkCount,
310
+ uploaded_chunk_count: uploadedChunks,
311
+ reason: COMMIT_CRASHED_PLATFORM,
312
+ }));
313
+ return failedOutcome(entry.file, `${COMMIT_CRASHED_PLATFORM}_http_${commit.status}`, uploadedChunks);
314
+ }
292
315
  const detail = safeFailureDetail(commit.body);
293
316
  return failedOutcome(entry.file, `commit_failed_http_${commit.status}${detail ? `_${detail}` : ""}`, uploadedChunks);
294
317
  }
@@ -530,6 +553,22 @@ function summarizeOutcomes(outcomes, usedLegacyFallback) {
530
553
  used_legacy_fallback: usedLegacyFallback,
531
554
  };
532
555
  }
556
+ /**
557
+ * Marks a body the server did not produce as JSON.
558
+ *
559
+ * A dashboard route always answers with a JSON envelope, so an HTML body on a
560
+ * 500 means the response came from the platform's error page and not from the
561
+ * route — the process died before any handler ran. That distinction is the
562
+ * whole difference between "the commit rejected these bytes" and "the commit
563
+ * never got to decide", and it was invisible for the nine days of BLI-3067
564
+ * because both collapsed into `commit_failed_http_500`.
565
+ */
566
+ export const NON_JSON_RESPONSE_BODY_KEY = "__cockpit_response_body_format";
567
+ function isNonJsonResponseBody(body) {
568
+ return (!!body &&
569
+ typeof body === "object" &&
570
+ body[NON_JSON_RESPONSE_BODY_KEY] === "non_json");
571
+ }
533
572
  async function readResponseJson(response) {
534
573
  const text = await response.text();
535
574
  if (!text)
@@ -538,7 +577,9 @@ async function readResponseJson(response) {
538
577
  return JSON.parse(text);
539
578
  }
540
579
  catch {
541
- return { message: text };
580
+ // The raw text is kept for the caller that wants to show it, never for a
581
+ // label or a log — it is an unbounded HTML page.
582
+ return { message: text, [NON_JSON_RESPONSE_BODY_KEY]: "non_json" };
542
583
  }
543
584
  }
544
585
  function batches(items, size) {
@@ -1,3 +1,4 @@
1
+ import { DELIVERY_BACKOFF_HOLDING } from "@bli-cockpit/telemetry-core";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
@@ -34,8 +35,20 @@ export const RAW_EVIDENCE_STAGING_FILENAME = "raw-evidence-staging.json";
34
35
  */
35
36
  export const EVIDENCE_DELIVERY_BACKOFF_BASE_MS = 15 * 60 * 1000;
36
37
  export const EVIDENCE_DELIVERY_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
37
- /** The reason label written when an object is being held by backoff. */
38
- export const DELIVERY_BACKOFF_HOLDING_REASON = "delivery_backoff_holding";
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
+ }
39
52
  const MAX_TRACKED_STAGED_OBJECTS = 5_000;
40
53
  const MAX_TRACKED_DELIVERY_ATTEMPTS = 5_000;
41
54
  export function emptyRawEvidenceStagingState() {
@@ -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
+ }