@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.
- package/README.md +10 -1
- package/dist/adapters/attribution-core.js +70 -1
- package/dist/adapters/claude-attribution.js +4 -3
- package/dist/adapters/codex-attribution.js +6 -5
- package/dist/adapters/common.js +2 -5
- package/dist/backfill-lock.js +108 -0
- package/dist/commands/backfill.js +964 -0
- package/dist/commands/local-args.js +67 -2
- package/dist/commands/local.js +310 -11
- package/dist/commands/session-sync.js +40 -14
- package/dist/cursors/backfill-cursor.js +130 -0
- package/dist/local-state.js +29 -10
- package/dist/repo-identity.js +15 -10
- package/dist/upload.js +115 -25
- package/package.json +2 -2
|
@@ -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 {
|
|
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
|
|
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:
|
|
37
|
-
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
+
}
|
package/dist/local-state.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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 =
|
|
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
|
|
433
|
-
|
|
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() -
|
|
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(/\/+$/, "");
|
package/dist/repo-identity.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
329
|
+
return emptyCodexSessionReportResult("no_sessions_observed");
|
|
329
330
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
348
|
-
|
|
401
|
+
catch {
|
|
402
|
+
lastStatus = null;
|
|
349
403
|
}
|
|
350
|
-
|
|
404
|
+
if (attempt < maxAttempts)
|
|
405
|
+
await sleep(250 * attempt);
|
|
351
406
|
}
|
|
352
|
-
|
|
353
|
-
|
|
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.
|
|
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.
|
|
29
|
+
"@bli-cockpit/telemetry-core": "0.1.10"
|
|
30
30
|
}
|
|
31
31
|
}
|