@bli-cockpit/cli 0.1.28 → 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,
@@ -114,6 +120,7 @@ export async function runAttributedWorktreeSync(options) {
114
120
  homeDir: options.homeDir,
115
121
  repoRoot: worktree.repo_root,
116
122
  dashboardUrl: options.dashboardUrl,
123
+ worktreeInventory: worktreeInventoryForRepo(worktree, options.worktrees),
117
124
  codexSessionFiles: codexAttribution.results
118
125
  .filter((result) => result.state === "attributed" &&
119
126
  result.worktree?.worktree_fingerprint ===
@@ -227,12 +234,7 @@ export async function runAttributedWorktreeSync(options) {
227
234
  });
228
235
  return { ok, outcomes, codexAttribution, claudeAttribution, summary };
229
236
  }
230
- const ATTRIBUTION_STATE_RANK = {
231
- attributed: 3,
232
- ambiguous: 2,
233
- unattributed: 1,
234
- skipped: 0,
235
- };
237
+ export const ATTRIBUTION_STATE_RANK = CODEX_SESSION_ATTRIBUTION_STATE_RANK;
236
238
  function normalizeCodexResult(result) {
237
239
  return {
238
240
  source: "codex",
@@ -357,7 +359,8 @@ function buildAgentSessionReport(options) {
357
359
  raw_evidence_pointer_id: priorDurablePointer,
358
360
  upload_state: "reused_existing",
359
361
  }
360
- : result.state === "attributed"
362
+ : result.state === "attributed" ||
363
+ result.state === "attributed_fallback"
361
364
  ? { upload_state: "not_uploaded" }
362
365
  : {}),
363
366
  };
@@ -441,6 +444,7 @@ function buildAgentSessionSummary(options) {
441
444
  const codex = {
442
445
  scanned: options.codexAttribution.scanned_file_count,
443
446
  attributed: options.codexAttribution.counts.attributed,
447
+ attributed_fallback: options.codexAttribution.counts.attributed_fallback,
444
448
  ambiguous: options.codexAttribution.counts.ambiguous,
445
449
  unattributed: options.codexAttribution.counts.unattributed,
446
450
  skipped: options.codexAttribution.counts.skipped,
@@ -449,6 +453,7 @@ function buildAgentSessionSummary(options) {
449
453
  const claude = {
450
454
  scanned: options.claudeAttribution.scanned_session_count,
451
455
  attributed: options.claudeAttribution.counts.attributed,
456
+ attributed_fallback: options.claudeAttribution.counts.attributed_fallback,
452
457
  ambiguous: options.claudeAttribution.counts.ambiguous,
453
458
  unattributed: options.claudeAttribution.counts.unattributed,
454
459
  skipped: options.claudeAttribution.counts.skipped,
@@ -469,6 +474,7 @@ function buildAgentSessionSummary(options) {
469
474
  return {
470
475
  scanned: codex.scanned,
471
476
  attributed: codex.attributed,
477
+ attributed_fallback: codex.attributed_fallback,
472
478
  ambiguous: codex.ambiguous,
473
479
  unattributed: codex.unattributed,
474
480
  skipped: codex.skipped,
@@ -500,6 +506,7 @@ function emptyClaudeScan() {
500
506
  disabled_reason: "claude_collection_disabled_by_config",
501
507
  counts: {
502
508
  attributed: 0,
509
+ attributed_fallback: 0,
503
510
  ambiguous: 0,
504
511
  unattributed: 0,
505
512
  skipped: 0,
@@ -510,9 +517,28 @@ function emptyClaudeScan() {
510
517
  },
511
518
  };
512
519
  }
513
- async function isClaudeCollectionEnabled(paths) {
514
- const config = await readLocalCollectorConfig(paths).catch(() => null);
515
- return config?.collect_claude_jsonl !== false;
520
+ function worktreeInventoryForRepo(current, worktrees) {
521
+ return worktrees
522
+ .filter((worktree) => worktree.repo_fingerprint
523
+ ? worktree.repo_fingerprint === current.repo_fingerprint
524
+ : worktree.repo_label === current.repo_label)
525
+ .map((worktree) => ({
526
+ repo: worktree.repo_root,
527
+ repo_label: worktree.repo_label,
528
+ repo_fingerprint: worktree.repo_fingerprint,
529
+ repo_origin_url: worktree.repo_origin_url ?? undefined,
530
+ head_sha: worktree.head_sha ?? undefined,
531
+ worktree_label: worktree.worktree_label,
532
+ worktree_fingerprint: worktree.worktree_fingerprint,
533
+ worktree_is_primary: worktree.worktree_is_primary,
534
+ branch: worktree.branch,
535
+ }));
536
+ }
537
+ function fallbackCollectionRoots(savedRoots, worktrees) {
538
+ return normalizeCollectionRoots([
539
+ ...savedRoots,
540
+ ...worktrees.map((worktree) => worktree.requested_path || worktree.repo_root),
541
+ ]);
516
542
  }
517
543
  async function fileExists(filePath) {
518
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
+ }
@@ -4,7 +4,7 @@ import { readFileSync } from "node:fs";
4
4
  import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { resolveRepoWorktreeIdentity, } from "./repo-identity.js";
7
+ import { resolveRepoWorktreeIdentity, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
8
8
  import { normalizeCollectionRoots } from "./root-normalization.js";
9
9
  import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
10
10
  const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -138,6 +138,9 @@ export async function logoutLocalCollector(options = {}) {
138
138
  return { removed, session_file: paths.session_file };
139
139
  }
140
140
  export async function startLocalWorkContext(options = {}) {
141
+ if (options.activeTicketId && options.clearTicket) {
142
+ throw new Error("--ticket and --clear-ticket cannot be combined.");
143
+ }
141
144
  const now = options.now ?? new Date();
142
145
  const homeDir = options.homeDir ?? os.homedir();
143
146
  const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
@@ -161,6 +164,9 @@ export async function startLocalWorkContext(options = {}) {
161
164
  worktreeFingerprint: identity.worktree_fingerprint,
162
165
  });
163
166
  const existingContext = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
167
+ const activeTicketId = options.clearTicket
168
+ ? undefined
169
+ : (options.activeTicketId ?? existingContext?.active_ticket_id);
164
170
  const ticketBindingCandidates = options.activeTicketId
165
171
  ? [
166
172
  {
@@ -170,7 +176,9 @@ export async function startLocalWorkContext(options = {}) {
170
176
  evidence_labels: ["cockpit_start_ticket"],
171
177
  },
172
178
  ]
173
- : [];
179
+ : options.clearTicket
180
+ ? []
181
+ : (existingContext?.ticket_binding_candidates ?? []);
174
182
  const context = LocalWorkContextSchema.parse({
175
183
  work_context_id: workContextId,
176
184
  repo: identity.repo_root,
@@ -186,7 +194,7 @@ export async function startLocalWorkContext(options = {}) {
186
194
  session_id: sessionId,
187
195
  started_at: existingContext?.started_at ?? now.toISOString(),
188
196
  updated_at: now.toISOString(),
189
- active_ticket_id: options.activeTicketId ?? undefined,
197
+ active_ticket_id: activeTicketId,
190
198
  ticket_binding_candidates: ticketBindingCandidates,
191
199
  topic_label: options.topicLabel,
192
200
  topic_summary_redacted: options.topicSummaryRedacted,
@@ -229,8 +237,8 @@ export async function inspectLocalCollectorStatus(options = {}) {
229
237
  const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
230
238
  const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
231
239
  const branch = options.branch ?? identity.branch;
232
- const freshness = classifyCollectorFreshness(context, now);
233
240
  const uploadSpool = await summarizeLocalUploadSpool(paths);
241
+ const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
234
242
  const uploadState = !config
235
243
  ? "not_installed"
236
244
  : uploadSpool.pending_upload_count > 0
@@ -381,14 +389,14 @@ function workContextFile(paths, worktreeFingerprint) {
381
389
  return path.join(paths.work_contexts_dir, `${worktreeFingerprint}.json`);
382
390
  }
383
391
  async function resolveIdentityOrFallback(repoRoot, branchOverride) {
384
- const resolvedRoot = path.resolve(repoRoot);
392
+ const resolvedRoot = await stableWorktreeRoot(repoRoot);
385
393
  const identity = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
386
394
  if (identity) {
387
395
  return branchOverride ? { ...identity, branch: branchOverride } : identity;
388
396
  }
389
397
  const repoLabel = path.basename(resolvedRoot) || "workspace";
390
398
  const repoFingerprint = `repo-${sha256(`local:${resolvedRoot}`).slice(0, 24)}`;
391
- const worktreeFingerprint = `wt-${sha256(`${repoFingerprint}:${resolvedRoot}`).slice(0, 24)}`;
399
+ const worktreeFingerprint = stableWorktreeFingerprint(resolvedRoot);
392
400
  const branch = branchOverride ?? (await resolveGitBranch(resolvedRoot));
393
401
  return {
394
402
  requested_path: resolvedRoot,
@@ -426,13 +434,24 @@ async function writeJsonFile(filePath, value) {
426
434
  await fs.chmod(filePath, 0o600).catch(() => undefined);
427
435
  }
428
436
  }
429
- function classifyCollectorFreshness(context, now) {
437
+ export function classifyCollectorFreshness(context, lastUploadSuccessAt, now) {
430
438
  if (!context)
431
439
  return "missing";
432
- const updatedAt = Date.parse(context.updated_at ?? context.started_at);
433
- if (!Number.isFinite(updatedAt))
440
+ const latestActivityAt = latestTimestamp([
441
+ context.updated_at ?? context.started_at,
442
+ lastUploadSuccessAt,
443
+ ]);
444
+ if (latestActivityAt === null)
434
445
  return "stale";
435
- return now.getTime() - updatedAt <= 5 * 60 * 1000 ? "fresh" : "stale";
446
+ return now.getTime() - latestActivityAt <= 5 * 60 * 1000 ? "fresh" : "stale";
447
+ }
448
+ function latestTimestamp(values) {
449
+ const timestamps = values
450
+ .map((value) => Date.parse(value ?? ""))
451
+ .filter((value) => Number.isFinite(value));
452
+ if (timestamps.length === 0)
453
+ return null;
454
+ return Math.max(...timestamps);
436
455
  }
437
456
  function normalizeDashboardUrl(value) {
438
457
  const normalized = value.trim().replace(/\/+$/, "");
@@ -18,7 +18,7 @@ const SKIPPED_DIR_NAMES = new Set([
18
18
  export async function resolveRepoWorktreeIdentity(repoRoot) {
19
19
  const requestedPath = path.resolve(repoRoot);
20
20
  const gitRoot = await runGit(["rev-parse", "--show-toplevel"], requestedPath);
21
- const resolvedRoot = path.resolve(gitRoot.trim() || requestedPath);
21
+ const resolvedRoot = await stableWorktreeRoot(gitRoot.trim() || requestedPath);
22
22
  const branch = await resolveGitBranchWithGit(resolvedRoot);
23
23
  const headSha = await runGit(["rev-parse", "HEAD"], resolvedRoot).then((value) => value.trim() || null, () => null);
24
24
  const absoluteGitDir = await runGit(["rev-parse", "--absolute-git-dir"], resolvedRoot)
@@ -36,11 +36,6 @@ export async function resolveRepoWorktreeIdentity(repoRoot) {
36
36
  ? `origin:${repoOriginUrl}`
37
37
  : `local:${sha256(commonGitDir ?? resolvedRoot)}`;
38
38
  const repoFingerprint = `repo-${sha256(repoMaterial).slice(0, 24)}`;
39
- const worktreeMaterial = [
40
- repoFingerprint,
41
- resolvedRoot,
42
- commonGitDir ?? "",
43
- ].join("\n");
44
39
  const gitFilePath = path.join(resolvedRoot, ".git");
45
40
  const worktreeIsPrimary = await fs.stat(gitFilePath).then((stat) => stat.isDirectory(), () => false);
46
41
  return {
@@ -52,7 +47,7 @@ export async function resolveRepoWorktreeIdentity(repoRoot) {
52
47
  branch,
53
48
  head_sha: headSha,
54
49
  worktree_label: path.basename(resolvedRoot),
55
- worktree_fingerprint: `wt-${sha256(worktreeMaterial).slice(0, 24)}`,
50
+ worktree_fingerprint: stableWorktreeFingerprint(resolvedRoot),
56
51
  worktree_is_primary: worktreeIsPrimary,
57
52
  };
58
53
  }
@@ -144,10 +139,9 @@ async function hasGitMarker(dir) {
144
139
  return fs.stat(path.join(dir, ".git")).then((stat) => stat.isDirectory() || stat.isFile(), () => false);
145
140
  }
146
141
  async function fallbackFilesystemIdentity(repoRoot) {
147
- const resolvedRoot = path.resolve(repoRoot);
142
+ const resolvedRoot = await stableWorktreeRoot(repoRoot);
148
143
  const repoLabel = path.basename(resolvedRoot) || "repo";
149
144
  const repoFingerprint = `repo-${sha256(`local:${resolvedRoot}`).slice(0, 24)}`;
150
- const worktreeFingerprint = `wt-${sha256(`${repoFingerprint}:${resolvedRoot}`).slice(0, 24)}`;
151
145
  return {
152
146
  requested_path: resolvedRoot,
153
147
  repo_root: resolvedRoot,
@@ -157,10 +151,17 @@ async function fallbackFilesystemIdentity(repoRoot) {
157
151
  branch: await resolveBranchFromHead(resolvedRoot),
158
152
  head_sha: null,
159
153
  worktree_label: repoLabel,
160
- worktree_fingerprint: worktreeFingerprint,
154
+ worktree_fingerprint: stableWorktreeFingerprint(resolvedRoot),
161
155
  worktree_is_primary: true,
162
156
  };
163
157
  }
158
+ export async function stableWorktreeRoot(repoRoot) {
159
+ const resolvedRoot = path.resolve(repoRoot);
160
+ return fs.realpath(resolvedRoot).catch(() => resolvedRoot);
161
+ }
162
+ export function stableWorktreeFingerprint(repoRoot) {
163
+ return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot)}`).slice(0, 24)}`;
164
+ }
164
165
  async function resolveBranchFromHead(repoRoot) {
165
166
  try {
166
167
  const gitPath = path.join(repoRoot, ".git");
@@ -244,6 +245,10 @@ async function runGit(args, cwd) {
244
245
  });
245
246
  return stdout;
246
247
  }
248
+ function normalizeFingerprintPath(repoRoot) {
249
+ const resolved = path.resolve(repoRoot);
250
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
251
+ }
247
252
  function sha256(value) {
248
253
  return crypto.createHash("sha256").update(value, "utf8").digest("hex");
249
254
  }
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";
@@ -101,6 +101,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
101
101
  collector_version: LOCAL_COLLECTOR_VERSION,
102
102
  session_reference: sanitizeSessionReference(session),
103
103
  work_context: uploadWorkContext,
104
+ worktree_inventory: options.worktreeInventory ?? [],
104
105
  source_scan_results: sanitizeSourceScanResults(sourceCollection.scans, repoLabel),
105
106
  events,
106
107
  });
@@ -278,7 +279,7 @@ export async function syncLocalAmbientEnvelope(options = {}) {
278
279
  */
279
280
  export async function postCodexSessionReport(options) {
280
281
  if (options.sessions.length === 0) {
281
- return { posted: false, reason: "no_sessions_observed" };
282
+ return emptyCodexSessionReportResult("no_sessions_observed");
282
283
  }
283
284
  const fetchImpl = options.fetch ?? globalThis.fetch;
284
285
  if (!fetchImpl) {
@@ -290,7 +291,7 @@ export async function postCodexSessionReport(options) {
290
291
  const sessionFile = await readLocalCollectorSessionFile(paths);
291
292
  const session = await readLocalSessionReference(paths);
292
293
  if (session.session_state !== "valid") {
293
- return { posted: false, reason: "collector_not_paired" };
294
+ return emptyCodexSessionReportResult("collector_not_paired");
294
295
  }
295
296
  const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
296
297
  const activeContext = await readLocalWorkContextForRepo(paths, repoRoot);
@@ -315,7 +316,7 @@ export async function postCodexSessionReport(options) {
315
316
  });
316
317
  }
317
318
  catch {
318
- return { posted: false, reason: "collector_not_ready" };
319
+ return emptyCodexSessionReportResult("collector_not_ready");
319
320
  }
320
321
  }
321
322
  /**
@@ -325,33 +326,122 @@ export async function postCodexSessionReport(options) {
325
326
  */
326
327
  export async function reportCodexSessionAttributions(options) {
327
328
  if (options.sessions.length === 0) {
328
- return { posted: false, reason: "no_sessions_observed" };
329
+ return emptyCodexSessionReportResult("no_sessions_observed");
329
330
  }
330
- try {
331
- const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/codex-sessions`, {
332
- method: "POST",
333
- headers: {
334
- "Authorization": `Bearer ${options.deviceToken}`,
335
- "Content-Type": "application/json",
336
- },
337
- body: JSON.stringify({
338
- schema_version: "ambient-codex-session-attributions.v1",
339
- generated_at: options.generatedAt,
340
- provenance: options.provenance,
341
- sessions: options.sessions,
342
- }),
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,
343
338
  });
344
- if (response.status === 404) {
345
- 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
+ }
346
400
  }
347
- if (!response.ok) {
348
- return { posted: false, reason: `report_failed_http_${response.status}` };
401
+ catch {
402
+ lastStatus = null;
349
403
  }
350
- return { posted: true, reason: "recorded" };
404
+ if (attempt < maxAttempts)
405
+ await sleep(250 * attempt);
351
406
  }
352
- catch {
353
- 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));
354
440
  }
441
+ return chunks;
442
+ }
443
+ function defaultReportSleep(milliseconds) {
444
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
355
445
  }
356
446
  export async function reportAgentImageArtifacts(options) {
357
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.28",
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.8"
29
+ "@bli-cockpit/telemetry-core": "0.1.10"
30
30
  }
31
31
  }