@bli-cockpit/cli 0.1.29 → 0.1.30

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.
@@ -6,11 +6,13 @@
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { getCollectorRuntimePaths, startLocalWorkContext, readLocalCollectorConfig } from "../local-state.js";
9
+ import { CODEX_SESSION_ATTRIBUTION_STATE_RANK, } from "@bli-cockpit/telemetry-core";
9
10
  import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope } from "../upload.js";
10
- import { CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT, CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
11
+ import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
11
12
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
12
13
  import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
13
14
  import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
15
+ import { normalizeCollectionRoots } from "../root-normalization.js";
14
16
  const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
15
17
  const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
16
18
  const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
@@ -28,13 +30,16 @@ export async function runAttributedWorktreeSync(options) {
28
30
  const now = new Date();
29
31
  const homeDir = options.homeDir ?? os.homedir();
30
32
  const paths = getCollectorRuntimePaths(options.homeDir);
31
- const claudeEnabled = await isClaudeCollectionEnabled(paths);
33
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
34
+ const claudeEnabled = config?.collect_claude_jsonl !== false;
35
+ const collectionRoots = fallbackCollectionRoots(config?.default_repo_paths ?? [], options.worktrees);
32
36
  const codexAttribution = await scanAndAttributeCodexSessions({
33
37
  sessionsDirs: defaultCodexSessionDirs(homeDir),
34
38
  worktrees: options.worktrees,
35
39
  now,
36
- sinceMinutes: CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES,
37
- limit: CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT,
40
+ sinceMinutes: CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
41
+ limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
42
+ collectionRoots,
38
43
  });
39
44
  // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
40
45
  // days so the first sync captures retroactive history instead of only 24h.
@@ -45,6 +50,7 @@ export async function runAttributedWorktreeSync(options) {
45
50
  projectsDir: path.join(homeDir, ".claude", "projects"),
46
51
  worktrees: options.worktrees,
47
52
  now,
53
+ collectionRoots,
48
54
  sinceMinutes: firstRunBackfill
49
55
  ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
50
56
  : undefined,
@@ -228,12 +234,7 @@ export async function runAttributedWorktreeSync(options) {
228
234
  });
229
235
  return { ok, outcomes, codexAttribution, claudeAttribution, summary };
230
236
  }
231
- const ATTRIBUTION_STATE_RANK = {
232
- attributed: 3,
233
- ambiguous: 2,
234
- unattributed: 1,
235
- skipped: 0,
236
- };
237
+ export const ATTRIBUTION_STATE_RANK = CODEX_SESSION_ATTRIBUTION_STATE_RANK;
237
238
  function normalizeCodexResult(result) {
238
239
  return {
239
240
  source: "codex",
@@ -358,7 +359,8 @@ function buildAgentSessionReport(options) {
358
359
  raw_evidence_pointer_id: priorDurablePointer,
359
360
  upload_state: "reused_existing",
360
361
  }
361
- : result.state === "attributed"
362
+ : result.state === "attributed" ||
363
+ result.state === "attributed_fallback"
362
364
  ? { upload_state: "not_uploaded" }
363
365
  : {}),
364
366
  };
@@ -442,6 +444,7 @@ function buildAgentSessionSummary(options) {
442
444
  const codex = {
443
445
  scanned: options.codexAttribution.scanned_file_count,
444
446
  attributed: options.codexAttribution.counts.attributed,
447
+ attributed_fallback: options.codexAttribution.counts.attributed_fallback,
445
448
  ambiguous: options.codexAttribution.counts.ambiguous,
446
449
  unattributed: options.codexAttribution.counts.unattributed,
447
450
  skipped: options.codexAttribution.counts.skipped,
@@ -450,6 +453,7 @@ function buildAgentSessionSummary(options) {
450
453
  const claude = {
451
454
  scanned: options.claudeAttribution.scanned_session_count,
452
455
  attributed: options.claudeAttribution.counts.attributed,
456
+ attributed_fallback: options.claudeAttribution.counts.attributed_fallback,
453
457
  ambiguous: options.claudeAttribution.counts.ambiguous,
454
458
  unattributed: options.claudeAttribution.counts.unattributed,
455
459
  skipped: options.claudeAttribution.counts.skipped,
@@ -470,6 +474,7 @@ function buildAgentSessionSummary(options) {
470
474
  return {
471
475
  scanned: codex.scanned,
472
476
  attributed: codex.attributed,
477
+ attributed_fallback: codex.attributed_fallback,
473
478
  ambiguous: codex.ambiguous,
474
479
  unattributed: codex.unattributed,
475
480
  skipped: codex.skipped,
@@ -501,6 +506,7 @@ function emptyClaudeScan() {
501
506
  disabled_reason: "claude_collection_disabled_by_config",
502
507
  counts: {
503
508
  attributed: 0,
509
+ attributed_fallback: 0,
504
510
  ambiguous: 0,
505
511
  unattributed: 0,
506
512
  skipped: 0,
@@ -528,9 +534,11 @@ function worktreeInventoryForRepo(current, worktrees) {
528
534
  branch: worktree.branch,
529
535
  }));
530
536
  }
531
- async function isClaudeCollectionEnabled(paths) {
532
- const config = await readLocalCollectorConfig(paths).catch(() => null);
533
- return config?.collect_claude_jsonl !== false;
537
+ function fallbackCollectionRoots(savedRoots, worktrees) {
538
+ return normalizeCollectionRoots([
539
+ ...savedRoots,
540
+ ...worktrees.map((worktree) => worktree.requested_path || worktree.repo_root),
541
+ ]);
534
542
  }
535
543
  async function fileExists(filePath) {
536
544
  const { stat } = await import("node:fs/promises");
@@ -0,0 +1,130 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ export const BACKFILL_CURSOR_FILENAME = "backfill.json";
5
+ export const BACKFILL_COMPLETION_MARKER_FILENAME = "backfill-complete.json";
6
+ export function emptyBackfillCursorState() {
7
+ return {
8
+ schema_version: "cockpit-backfill-cursor.v1",
9
+ updated_at: null,
10
+ sources: {
11
+ codex: emptySourceCursor(),
12
+ claude_code: emptySourceCursor(),
13
+ },
14
+ };
15
+ }
16
+ export async function readBackfillCursor(paths) {
17
+ try {
18
+ const raw = JSON.parse(await fs.readFile(backfillCursorPath(paths), "utf8"));
19
+ return parseBackfillCursor(raw);
20
+ }
21
+ catch {
22
+ return emptyBackfillCursorState();
23
+ }
24
+ }
25
+ export async function writeBackfillCursor(paths, state) {
26
+ const filePath = backfillCursorPath(paths);
27
+ await writePrivateJson(filePath, state);
28
+ }
29
+ export async function writeBackfillCompletionMarker(paths, marker) {
30
+ await writePrivateJson(backfillCompletionMarkerPath(paths), marker);
31
+ }
32
+ export function recordBackfillCursorObservations(cursor, observations, now) {
33
+ for (const observation of observations) {
34
+ const source = cursor.sources[observation.source] ?? emptySourceCursor();
35
+ const oldest = source.oldest_mtime_ms_processed;
36
+ if (oldest === null ||
37
+ observation.session_file_mtime_ms < oldest) {
38
+ source.oldest_mtime_ms_processed = observation.session_file_mtime_ms;
39
+ source.oldest_mtime_processed = observation.session_file_mtime;
40
+ }
41
+ source.state_counts[observation.state] =
42
+ (source.state_counts[observation.state] ?? 0) + 1;
43
+ source.reason_counts[observation.reason] =
44
+ (source.reason_counts[observation.reason] ?? 0) + 1;
45
+ cursor.sources[observation.source] = source;
46
+ }
47
+ cursor.updated_at = now.toISOString();
48
+ }
49
+ export function backfillCursorPath(paths) {
50
+ return path.join(paths.cursors_dir, BACKFILL_CURSOR_FILENAME);
51
+ }
52
+ export function backfillCompletionMarkerPath(paths) {
53
+ return path.join(paths.cursors_dir, BACKFILL_COMPLETION_MARKER_FILENAME);
54
+ }
55
+ function emptySourceCursor() {
56
+ return {
57
+ oldest_mtime_ms_processed: null,
58
+ oldest_mtime_processed: null,
59
+ state_counts: {},
60
+ reason_counts: {},
61
+ };
62
+ }
63
+ function parseBackfillCursor(value) {
64
+ if (!value || typeof value !== "object")
65
+ return emptyBackfillCursorState();
66
+ const record = value;
67
+ const sources = record["sources"];
68
+ const parsed = emptyBackfillCursorState();
69
+ parsed.updated_at = optionalString(record["updated_at"]);
70
+ if (sources && typeof sources === "object") {
71
+ const sourceRecord = sources;
72
+ parsed.sources.codex = parseSourceCursor(sourceRecord["codex"]);
73
+ parsed.sources.claude_code = parseSourceCursor(sourceRecord["claude_code"]);
74
+ }
75
+ return parsed;
76
+ }
77
+ function parseSourceCursor(value) {
78
+ if (!value || typeof value !== "object")
79
+ return emptySourceCursor();
80
+ const record = value;
81
+ const oldest = optionalNumber(record["oldest_mtime_ms_processed"]);
82
+ return {
83
+ oldest_mtime_ms_processed: oldest,
84
+ oldest_mtime_processed: optionalString(record["oldest_mtime_processed"]) ??
85
+ (oldest === null ? null : new Date(oldest).toISOString()),
86
+ state_counts: parseNumberRecord(record["state_counts"]),
87
+ reason_counts: parseNumberRecord(record["reason_counts"]),
88
+ };
89
+ }
90
+ function parseNumberRecord(value) {
91
+ if (!value || typeof value !== "object")
92
+ return {};
93
+ const out = {};
94
+ for (const [key, raw] of Object.entries(value)) {
95
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) {
96
+ out[key] = Math.floor(raw);
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+ async function writePrivateJson(filePath, value) {
102
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
103
+ const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
104
+ const serialized = `${JSON.stringify(value, null, 2)}\n`;
105
+ let handle = null;
106
+ try {
107
+ handle = await fs.open(tempPath, "w", 0o600);
108
+ await handle.writeFile(serialized);
109
+ await handle.sync();
110
+ }
111
+ finally {
112
+ await handle?.close();
113
+ }
114
+ try {
115
+ await fs.rename(tempPath, filePath);
116
+ }
117
+ catch (error) {
118
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
119
+ throw error;
120
+ }
121
+ if (process.platform !== "win32") {
122
+ await fs.chmod(filePath, 0o600).catch(() => undefined);
123
+ }
124
+ }
125
+ function optionalString(value) {
126
+ return typeof value === "string" && value.trim() ? value : null;
127
+ }
128
+ function optionalNumber(value) {
129
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
130
+ }
package/dist/upload.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AgentImageArtifactReportRequestSchema, EvidenceCompletenessPayloadSchema, TelemetryIngestEnvelopeSchema, TelemetryIngestEventDtoSchema, } from "@bli-cockpit/telemetry-core";
1
+ import { AgentImageArtifactReportRequestSchema, CODEX_SESSION_REPORT_MAX_SESSIONS, CodexSessionAttributionReportResponseSchema, EvidenceCompletenessPayloadSchema, TelemetryIngestEnvelopeSchema, TelemetryIngestEventDtoSchema, } from "@bli-cockpit/telemetry-core";
2
2
  import path from "node:path";
3
3
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
4
4
  import { runLocalSourceCollectors } from "./adapters/local-sources.js";
@@ -279,7 +279,7 @@ export async function syncLocalAmbientEnvelope(options = {}) {
279
279
  */
280
280
  export async function postCodexSessionReport(options) {
281
281
  if (options.sessions.length === 0) {
282
- return { posted: false, reason: "no_sessions_observed" };
282
+ return emptyCodexSessionReportResult("no_sessions_observed");
283
283
  }
284
284
  const fetchImpl = options.fetch ?? globalThis.fetch;
285
285
  if (!fetchImpl) {
@@ -291,7 +291,7 @@ export async function postCodexSessionReport(options) {
291
291
  const sessionFile = await readLocalCollectorSessionFile(paths);
292
292
  const session = await readLocalSessionReference(paths);
293
293
  if (session.session_state !== "valid") {
294
- return { posted: false, reason: "collector_not_paired" };
294
+ return emptyCodexSessionReportResult("collector_not_paired");
295
295
  }
296
296
  const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
297
297
  const activeContext = await readLocalWorkContextForRepo(paths, repoRoot);
@@ -316,7 +316,7 @@ export async function postCodexSessionReport(options) {
316
316
  });
317
317
  }
318
318
  catch {
319
- return { posted: false, reason: "collector_not_ready" };
319
+ return emptyCodexSessionReportResult("collector_not_ready");
320
320
  }
321
321
  }
322
322
  /**
@@ -326,33 +326,122 @@ export async function postCodexSessionReport(options) {
326
326
  */
327
327
  export async function reportCodexSessionAttributions(options) {
328
328
  if (options.sessions.length === 0) {
329
- return { posted: false, reason: "no_sessions_observed" };
329
+ return emptyCodexSessionReportResult("no_sessions_observed");
330
330
  }
331
- try {
332
- const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/codex-sessions`, {
333
- method: "POST",
334
- headers: {
335
- "Authorization": `Bearer ${options.deviceToken}`,
336
- "Content-Type": "application/json",
337
- },
338
- body: JSON.stringify({
339
- schema_version: "ambient-codex-session-attributions.v1",
340
- generated_at: options.generatedAt,
341
- provenance: options.provenance,
342
- sessions: options.sessions,
343
- }),
331
+ const chunks = [];
332
+ const sessionChunks = chunkArray(options.sessions, CODEX_SESSION_REPORT_MAX_SESSIONS);
333
+ for (const [index, sessions] of sessionChunks.entries()) {
334
+ const chunk = await postCodexSessionAttributionChunk({
335
+ ...options,
336
+ sessions,
337
+ batchIndex: index + 1,
344
338
  });
345
- if (response.status === 404) {
346
- return { posted: false, reason: "codex_session_api_unavailable" };
339
+ chunks.push(chunk);
340
+ }
341
+ const failedCount = chunks.filter((chunk) => !chunk.posted).length;
342
+ const recordedCount = chunks.reduce((sum, chunk) => sum + chunk.recorded_count, 0);
343
+ return {
344
+ posted: failedCount === 0,
345
+ reason: failedCount === 0
346
+ ? "recorded"
347
+ : chunks.find((chunk) => !chunk.posted)?.reason ?? "report_failed",
348
+ chunk_count: chunks.length,
349
+ recorded_count: recordedCount,
350
+ failed_count: failedCount,
351
+ chunks,
352
+ };
353
+ }
354
+ async function postCodexSessionAttributionChunk(options) {
355
+ const maxAttempts = options.maxAttemptsPerRequest ?? 3;
356
+ const sleep = options.sleep ?? defaultReportSleep;
357
+ let lastStatus = null;
358
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
359
+ try {
360
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/codex-sessions`, {
361
+ method: "POST",
362
+ headers: {
363
+ "Authorization": `Bearer ${options.deviceToken}`,
364
+ "Content-Type": "application/json",
365
+ },
366
+ body: JSON.stringify({
367
+ schema_version: "ambient-codex-session-attributions.v1",
368
+ generated_at: options.generatedAt,
369
+ provenance: options.provenance,
370
+ sessions: options.sessions,
371
+ }),
372
+ });
373
+ lastStatus = response.status;
374
+ const body = await readResponseJson(response);
375
+ if (response.status === 404) {
376
+ return codexSessionReportChunkResult(options, {
377
+ posted: false,
378
+ reason: "codex_session_api_unavailable",
379
+ httpStatus: response.status,
380
+ recordedCount: 0,
381
+ });
382
+ }
383
+ if (response.ok) {
384
+ const parsed = CodexSessionAttributionReportResponseSchema.safeParse(body);
385
+ return codexSessionReportChunkResult(options, {
386
+ posted: true,
387
+ reason: "recorded",
388
+ httpStatus: response.status,
389
+ recordedCount: parsed.success ? parsed.data.recorded_count : 0,
390
+ });
391
+ }
392
+ if (response.status < 500 && response.status !== 429) {
393
+ return codexSessionReportChunkResult(options, {
394
+ posted: false,
395
+ reason: `report_failed_http_${response.status}`,
396
+ httpStatus: response.status,
397
+ recordedCount: 0,
398
+ });
399
+ }
347
400
  }
348
- if (!response.ok) {
349
- return { posted: false, reason: `report_failed_http_${response.status}` };
401
+ catch {
402
+ lastStatus = null;
350
403
  }
351
- return { posted: true, reason: "recorded" };
404
+ if (attempt < maxAttempts)
405
+ await sleep(250 * attempt);
352
406
  }
353
- catch {
354
- return { posted: false, reason: "report_network_error" };
407
+ return codexSessionReportChunkResult(options, {
408
+ posted: false,
409
+ reason: lastStatus === null
410
+ ? "report_network_error"
411
+ : `report_failed_http_${lastStatus}`,
412
+ httpStatus: lastStatus,
413
+ recordedCount: 0,
414
+ });
415
+ }
416
+ function codexSessionReportChunkResult(options, result) {
417
+ return {
418
+ batch_index: options.batchIndex,
419
+ session_count: options.sessions.length,
420
+ posted: result.posted,
421
+ reason: result.reason,
422
+ http_status: result.httpStatus,
423
+ recorded_count: result.recordedCount,
424
+ };
425
+ }
426
+ function emptyCodexSessionReportResult(reason) {
427
+ return {
428
+ posted: false,
429
+ reason,
430
+ chunk_count: 0,
431
+ recorded_count: 0,
432
+ failed_count: 0,
433
+ chunks: [],
434
+ };
435
+ }
436
+ function chunkArray(items, size) {
437
+ const chunks = [];
438
+ for (let offset = 0; offset < items.length; offset += size) {
439
+ chunks.push(items.slice(offset, offset + size));
355
440
  }
441
+ return chunks;
442
+ }
443
+ function defaultReportSleep(milliseconds) {
444
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
356
445
  }
357
446
  export async function reportAgentImageArtifacts(options) {
358
447
  const artifacts = agentArtifactsFromEvidence(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,6 @@
26
26
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
- "@bli-cockpit/telemetry-core": "0.1.9"
29
+ "@bli-cockpit/telemetry-core": "0.1.10"
30
30
  }
31
31
  }