@bli-cockpit/cli 0.2.56 → 0.2.58
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/dist/commands/agent-door.js +85 -0
- package/dist/commands/docs.js +227 -0
- package/dist/commands/issue-contracts.js +99 -0
- package/dist/commands/issue-write.js +129 -0
- package/dist/commands/issue.js +189 -0
- package/dist/commands/local-args-tower-docs-msg.js +126 -0
- package/dist/commands/local-args-tower-work.js +178 -0
- package/dist/commands/local-args-tower.js +7 -1
- package/dist/commands/local-args.js +10 -2
- package/dist/commands/local-help.js +70 -0
- package/dist/commands/local.js +12 -0
- package/dist/commands/mcp-bin-resolve.js +102 -0
- package/dist/commands/memory-install-claude.js +13 -5
- package/dist/commands/memory-install-config.js +140 -0
- package/dist/commands/memory-install-report.js +89 -0
- package/dist/commands/memory-install.js +51 -362
- package/dist/commands/msg.js +188 -0
- package/dist/commands/notes-door.js +120 -0
- package/dist/commands/notes-reads.js +134 -0
- package/dist/commands/notes-writes.js +208 -0
- package/dist/commands/notes.js +16 -442
- package/dist/commands/ops-render.js +18 -2
- package/dist/commands/ops.js +9 -2
- package/dist/commands/project.js +38 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/tower-mcp-claude.js +30 -0
- package/dist/commands/tower-mcp-codex.js +100 -0
- package/dist/commands/tower-mcp-contract.js +39 -0
- package/dist/commands/tower-mcp-install.js +75 -0
- package/dist/repo-identity-fingerprint.js +88 -0
- package/dist/repo-identity-git.js +76 -0
- package/dist/repo-identity-linked-worktrees.js +81 -0
- package/dist/repo-identity.js +5 -222
- package/dist/upload-envelope-build.js +240 -0
- package/dist/upload-envelope-event.js +198 -0
- package/dist/upload-envelope.js +16 -427
- package/dist/upload-ingest-receipt.js +121 -0
- package/dist/upload-session-reports-queue.js +156 -0
- package/dist/upload-session-reports-wire.js +275 -0
- package/dist/upload-session-reports.js +14 -425
- package/dist/upload-sync.js +291 -0
- package/dist/upload.js +24 -396
- package/package.json +6 -5
package/dist/repo-identity.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import crypto from "node:crypto";
|
|
3
1
|
import fs from "node:fs/promises";
|
|
4
2
|
import path from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
3
|
+
import { hasGitMarker, resolveGitBranchWithGit, resolveBranchFromHead, runGit } from "./repo-identity-git.js";
|
|
4
|
+
import { repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, normalizeGitOrigin, sha256, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity-fingerprint.js";
|
|
5
|
+
import { expandLinkedWorktrees } from "./repo-identity-linked-worktrees.js";
|
|
6
|
+
export { canonicalizeCollectionRootPaths, collectionRootPathAliases, normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity-fingerprint.js";
|
|
9
7
|
const SKIPPED_DIR_NAMES = new Set([
|
|
10
8
|
".cache",
|
|
11
9
|
".git",
|
|
@@ -203,112 +201,6 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
|
|
|
203
201
|
unreadable_dirs: unreadableDirs,
|
|
204
202
|
};
|
|
205
203
|
}
|
|
206
|
-
/**
|
|
207
|
-
* Canonicalizes existing collection roots through the filesystem so transcript
|
|
208
|
-
* paths and consent roots use the same spelling (for example `/private/var`
|
|
209
|
-
* versus the `/var` symlink on macOS). Missing roots remain resolved as typed.
|
|
210
|
-
*/
|
|
211
|
-
export async function canonicalizeCollectionRootPaths(roots) {
|
|
212
|
-
const canonical = await Promise.all(roots.map(stableWorktreeRoot));
|
|
213
|
-
return [...new Set(canonical)];
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Returns the operator-entered resolved paths plus filesystem-canonical aliases.
|
|
217
|
-
* Both are needed for absent child repos because the child itself cannot be
|
|
218
|
-
* realpathed after deletion, while its transcript may retain either spelling.
|
|
219
|
-
*/
|
|
220
|
-
export async function collectionRootPathAliases(roots) {
|
|
221
|
-
const resolved = roots.map((root) => path.resolve(root));
|
|
222
|
-
const canonical = await canonicalizeCollectionRootPaths(resolved);
|
|
223
|
-
return [...new Set([...resolved, ...canonical])];
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* Directory discovery skips dot-dirs, so Claude Code's isolation worktrees
|
|
227
|
-
* (`<repo>/.claude/worktrees/<name>`) and any other linked worktree are never
|
|
228
|
-
* found by walking the filesystem. A session whose cwd sits inside one would
|
|
229
|
-
* otherwise satisfy `isPathWithin(cwd, parentRoot)` and attribute confidently
|
|
230
|
-
* to the PARENT with the wrong branch and worktree fingerprint. Enumerating
|
|
231
|
-
* `git worktree list --porcelain` for each discovered repo adds the linked
|
|
232
|
-
* worktrees as first-class candidates with their own branch/fingerprint; the
|
|
233
|
-
* deepest-root tie-break (attribution-core D6) then attributes nested-worktree
|
|
234
|
-
* sessions to the correct linked worktree. Benefits Codex sessions identically.
|
|
235
|
-
*/
|
|
236
|
-
async function expandLinkedWorktrees(identities, maxWorktrees, allowedRoots) {
|
|
237
|
-
const byFingerprint = new Map(identities.map((identity) => [identity.worktree_fingerprint, identity]));
|
|
238
|
-
const seenRoots = new Set(identities.map((identity) => localPathKey(identity.repo_root)));
|
|
239
|
-
let maxWorktreesReached = identities.length > maxWorktrees;
|
|
240
|
-
for (const identity of identities.slice(0, maxWorktrees)) {
|
|
241
|
-
// One `git worktree list` from any worktree returns every worktree of that
|
|
242
|
-
// repo, so a single call per already-discovered repo covers its linked set.
|
|
243
|
-
for (const worktreePath of await listLinkedWorktreePaths(identity.repo_root)) {
|
|
244
|
-
const resolved = path.resolve(worktreePath);
|
|
245
|
-
const rootKey = localPathKey(resolved);
|
|
246
|
-
if (seenRoots.has(rootKey))
|
|
247
|
-
continue;
|
|
248
|
-
seenRoots.add(rootKey);
|
|
249
|
-
const linked = await resolveRepoWorktreeIdentity(resolved).catch(() => null);
|
|
250
|
-
if (!linked ||
|
|
251
|
-
byFingerprint.has(linked.worktree_fingerprint) ||
|
|
252
|
-
!isLinkedWorktreeWithinCollectionScope(linked, identities, allowedRoots)) {
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
if (byFingerprint.size >= maxWorktrees) {
|
|
256
|
-
maxWorktreesReached = true;
|
|
257
|
-
break;
|
|
258
|
-
}
|
|
259
|
-
byFingerprint.set(linked.worktree_fingerprint, linked);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
const worktrees = [...byFingerprint.values()]
|
|
263
|
-
.sort(compareIdentity)
|
|
264
|
-
.slice(0, maxWorktrees);
|
|
265
|
-
const incompleteReasons = maxWorktreesReached
|
|
266
|
-
? ["max_worktrees_reached"]
|
|
267
|
-
: [];
|
|
268
|
-
return {
|
|
269
|
-
worktrees,
|
|
270
|
-
complete: incompleteReasons.length === 0,
|
|
271
|
-
incomplete_reasons: incompleteReasons,
|
|
272
|
-
// Expansion asks git for its own worktree list; it never walks folders, so
|
|
273
|
-
// it has no unreadable directories of its own to report.
|
|
274
|
-
unreadable_dirs: [],
|
|
275
|
-
// Linked-worktree expansion is not scoped to one root; callers merge this
|
|
276
|
-
// into a result that already knows which roots were involved.
|
|
277
|
-
incomplete_roots: [],
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
function isLinkedWorktreeWithinCollectionScope(linked, discoveredFromApprovedRoots, allowedRoots) {
|
|
281
|
-
if (allowedRoots.some((root) => containsPath(root, linked.repo_root))) {
|
|
282
|
-
return true;
|
|
283
|
-
}
|
|
284
|
-
// Codex isolation worktrees live under ~/.codex/worktrees, outside the
|
|
285
|
-
// approved workspace parent. They remain in scope only when Git proves they
|
|
286
|
-
// belong to a clone discovered inside an approved root. Arbitrary sibling or
|
|
287
|
-
// personal linked worktrees do not inherit that consent.
|
|
288
|
-
if (!isCodexWorktreePath(linked.repo_root))
|
|
289
|
-
return false;
|
|
290
|
-
return discoveredFromApprovedRoots.some((identity) => identity.repo_fingerprint === linked.repo_fingerprint &&
|
|
291
|
-
allowedRoots.some((root) => containsPath(root, identity.repo_root)));
|
|
292
|
-
}
|
|
293
|
-
function localPathKey(value) {
|
|
294
|
-
const resolved = path.resolve(value);
|
|
295
|
-
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
296
|
-
}
|
|
297
|
-
async function listLinkedWorktreePaths(repoRoot) {
|
|
298
|
-
const porcelain = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => "");
|
|
299
|
-
const paths = [];
|
|
300
|
-
for (const line of porcelain.split("\n")) {
|
|
301
|
-
if (line.startsWith("worktree ")) {
|
|
302
|
-
const value = line.slice("worktree ".length).trim();
|
|
303
|
-
if (value)
|
|
304
|
-
paths.push(value);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
return paths;
|
|
308
|
-
}
|
|
309
|
-
async function hasGitMarker(dir) {
|
|
310
|
-
return fs.stat(path.join(dir, ".git")).then((stat) => stat.isDirectory() || stat.isFile(), () => false);
|
|
311
|
-
}
|
|
312
204
|
async function fallbackFilesystemIdentity(repoRoot) {
|
|
313
205
|
const resolvedRoot = await stableWorktreeRoot(repoRoot);
|
|
314
206
|
const repoLabel = path.basename(resolvedRoot) || "repo";
|
|
@@ -326,120 +218,11 @@ async function fallbackFilesystemIdentity(repoRoot) {
|
|
|
326
218
|
worktree_is_primary: true,
|
|
327
219
|
};
|
|
328
220
|
}
|
|
329
|
-
export async function stableWorktreeRoot(repoRoot) {
|
|
330
|
-
const resolvedRoot = path.resolve(repoRoot);
|
|
331
|
-
return fs.realpath(resolvedRoot).catch(() => resolvedRoot);
|
|
332
|
-
}
|
|
333
|
-
export function stableWorktreeFingerprint(repoRoot, pathApi = path) {
|
|
334
|
-
return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot, pathApi)}`).slice(0, 24)}`;
|
|
335
|
-
}
|
|
336
|
-
export function repoFingerprintFromLocalRoot(root, pathApi = path) {
|
|
337
|
-
return `repo-${sha256(`local:${normalizeFingerprintPath(root, pathApi)}`).slice(0, 24)}`;
|
|
338
|
-
}
|
|
339
|
-
export function repoFingerprintFromOrigin(origin) {
|
|
340
|
-
const normalizedOrigin = normalizeGitOrigin(origin);
|
|
341
|
-
return `repo-${sha256(`origin:${normalizedOrigin}`).slice(0, 24)}`;
|
|
342
|
-
}
|
|
343
|
-
async function resolveBranchFromHead(repoRoot) {
|
|
344
|
-
try {
|
|
345
|
-
const gitPath = path.join(repoRoot, ".git");
|
|
346
|
-
const stat = await fs.stat(gitPath);
|
|
347
|
-
const headPath = stat.isFile()
|
|
348
|
-
? path.join(await resolveLinkedGitDir(gitPath), "HEAD")
|
|
349
|
-
: path.join(gitPath, "HEAD");
|
|
350
|
-
const head = (await fs.readFile(headPath, "utf8")).trim();
|
|
351
|
-
if (head.startsWith("ref: refs/heads/")) {
|
|
352
|
-
return head.slice("ref: refs/heads/".length);
|
|
353
|
-
}
|
|
354
|
-
return head ? `detached:${head.slice(0, 12)}` : "unknown";
|
|
355
|
-
}
|
|
356
|
-
catch (error) {
|
|
357
|
-
// Not a repo → quiet, that is an ordinary approved folder. A `.git` that
|
|
358
|
-
// exists and will not read → every session from this worktree is labelled
|
|
359
|
-
// branch `unknown` and, until BLI-3238, nothing said why.
|
|
360
|
-
if (!isMissingFileFailure(error)) {
|
|
361
|
-
console.error("[repo-identity] could not read HEAD, branch recorded as unknown", JSON.stringify({
|
|
362
|
-
reason: "git_head_unreadable",
|
|
363
|
-
...describeError(error),
|
|
364
|
-
}));
|
|
365
|
-
}
|
|
366
|
-
return "unknown";
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
async function resolveLinkedGitDir(gitFile) {
|
|
370
|
-
const raw = await fs.readFile(gitFile, "utf8");
|
|
371
|
-
const match = raw.match(/^gitdir:\s*(.+)$/m);
|
|
372
|
-
if (!match)
|
|
373
|
-
return path.dirname(gitFile);
|
|
374
|
-
const gitDir = match[1].trim();
|
|
375
|
-
return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
|
|
376
|
-
}
|
|
377
|
-
export function normalizeGitOrigin(rawOrigin) {
|
|
378
|
-
const trimmed = rawOrigin.trim();
|
|
379
|
-
if (!trimmed)
|
|
380
|
-
return "";
|
|
381
|
-
const scpLike = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
|
|
382
|
-
if (scpLike && !trimmed.includes("://")) {
|
|
383
|
-
return normalizeOriginParts(scpLike[1] ?? "", scpLike[2] ?? "");
|
|
384
|
-
}
|
|
385
|
-
try {
|
|
386
|
-
const url = new URL(trimmed);
|
|
387
|
-
return normalizeOriginParts(url.hostname, url.pathname);
|
|
388
|
-
}
|
|
389
|
-
catch {
|
|
390
|
-
// Deliberately silent (BLI-3238). `new URL` is being used as the test for
|
|
391
|
-
// "is this origin URL-shaped?", and a plain path or an unusual remote form
|
|
392
|
-
// failing to parse IS the answer — the fallback below is the intended
|
|
393
|
-
// normalization for exactly that case, not a degradation.
|
|
394
|
-
return trimmed
|
|
395
|
-
.replace(/\.git$/i, "")
|
|
396
|
-
.replace(/^\/+|\/+$/g, "")
|
|
397
|
-
.toLowerCase();
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
export function repoLabelFromOrigin(origin) {
|
|
401
|
-
const segments = origin.split("/").filter(Boolean);
|
|
402
|
-
return segments.at(-1) ?? origin;
|
|
403
|
-
}
|
|
404
|
-
function normalizeOriginParts(host, repoPath) {
|
|
405
|
-
return [
|
|
406
|
-
host.trim().toLowerCase(),
|
|
407
|
-
repoPath
|
|
408
|
-
.trim()
|
|
409
|
-
.replace(/\.git$/i, "")
|
|
410
|
-
.replace(/^\/+|\/+$/g, "")
|
|
411
|
-
.toLowerCase(),
|
|
412
|
-
]
|
|
413
|
-
.filter(Boolean)
|
|
414
|
-
.join("/");
|
|
415
|
-
}
|
|
416
221
|
function shouldSkipDirectory(name) {
|
|
417
222
|
return name.startsWith(".") || SKIPPED_DIR_NAMES.has(name);
|
|
418
223
|
}
|
|
419
|
-
function compareIdentity(a, b) {
|
|
224
|
+
export function compareIdentity(a, b) {
|
|
420
225
|
return (a.repo_label.localeCompare(b.repo_label) ||
|
|
421
226
|
Number(b.worktree_is_primary) - Number(a.worktree_is_primary) ||
|
|
422
227
|
a.worktree_label.localeCompare(b.worktree_label));
|
|
423
|
-
}
|
|
424
|
-
async function resolveGitBranchWithGit(repoRoot) {
|
|
425
|
-
const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
|
|
426
|
-
if (branch && branch !== "HEAD")
|
|
427
|
-
return branch;
|
|
428
|
-
const head = await runGit(["rev-parse", "--short=12", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
|
|
429
|
-
return head ? `detached:${head}` : "unknown";
|
|
430
|
-
}
|
|
431
|
-
async function runGit(args, cwd) {
|
|
432
|
-
const { stdout } = await execFileAsync("git", args, {
|
|
433
|
-
cwd,
|
|
434
|
-
timeout: 2_000,
|
|
435
|
-
maxBuffer: 1024 * 1024,
|
|
436
|
-
});
|
|
437
|
-
return stdout;
|
|
438
|
-
}
|
|
439
|
-
function normalizeFingerprintPath(repoRoot, pathApi = path) {
|
|
440
|
-
const resolved = pathApi.resolve(repoRoot);
|
|
441
|
-
return pathApi.sep === "\\" ? resolved.toLowerCase() : resolved;
|
|
442
|
-
}
|
|
443
|
-
function sha256(value) {
|
|
444
|
-
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
445
228
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this machine ready to upload, and — if so — `buildLocalAmbientEnvelope`:
|
|
3
|
+
* five steps from a bare repo root to a validated envelope.
|
|
4
|
+
*
|
|
5
|
+
* - `readPairedCollector` — prove the collector is installed and paired, with
|
|
6
|
+
* its own reason and retry command for each of the three ways a machine can
|
|
7
|
+
* fail to be ready. "sync failed" with no next step is what turns a
|
|
8
|
+
* five-second fix into a support thread.
|
|
9
|
+
* - `buildLocalAmbientEnvelope` — find the work context for this repo, run the
|
|
10
|
+
* source collectors, sanitize what came back and assemble the one event
|
|
11
|
+
* (`upload-envelope-event.ts`), then parse the envelope against its schema.
|
|
12
|
+
* - The work-context and provenance helpers (`makeUploadWorkContext`,
|
|
13
|
+
* `makeCollectorProvenance`, `safeRepoLabel`) are also called directly by
|
|
14
|
+
* `upload-session-reports.ts`, which builds its own provenance without a
|
|
15
|
+
* full envelope.
|
|
16
|
+
*/
|
|
17
|
+
import { TelemetryIngestEnvelopeSchema, } from "@bli-cockpit/telemetry-core";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
|
|
20
|
+
import { runLocalSourceCollectors } from "./adapters/local-sources.js";
|
|
21
|
+
import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
|
|
22
|
+
import { normalizeDashboardUrl } from "./upload-http.js";
|
|
23
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
24
|
+
import { makeSourceScanCompletedEvent, sanitizeRiskFlag, sanitizeSessionReference, sanitizeSourceScanResults, selectedTicketBindingCandidate, } from "./upload-envelope-event.js";
|
|
25
|
+
export class LocalUploadBlockedError extends Error {
|
|
26
|
+
blocker;
|
|
27
|
+
retry_hint;
|
|
28
|
+
constructor(blocker, message, retryHint) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "LocalUploadBlockedError";
|
|
31
|
+
this.blocker = blocker;
|
|
32
|
+
this.retry_hint = retryHint;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Is this machine installed, paired, and holding a session that has not lapsed?
|
|
37
|
+
*/
|
|
38
|
+
async function readPairedCollector(paths) {
|
|
39
|
+
const config = await readLocalCollectorConfig(paths).catch((error) => {
|
|
40
|
+
// `not_installed` tells the operator to reinstall the CLI. That is the
|
|
41
|
+
// wrong instruction for a config that exists and is corrupt, and there was
|
|
42
|
+
// no way to tell which one this machine hit.
|
|
43
|
+
if (!isMissingFileFailure(error)) {
|
|
44
|
+
console.error("[upload-envelope] collector config present but unreadable, reporting it as not installed", JSON.stringify({
|
|
45
|
+
reason: "not_installed",
|
|
46
|
+
...describeError(error),
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
throw new LocalUploadBlockedError("not_installed", "Local collector config missing. Install/update the CLI, then run `cockpit do-everything` before `cockpit sync`.", "npm install -g @bli-cockpit/cli@latest && cockpit do-everything");
|
|
50
|
+
});
|
|
51
|
+
const sessionFile = await readLocalCollectorSessionFile(paths).catch((error) => {
|
|
52
|
+
// Same trap on the pairing half: `unpaired` sends the operator to
|
|
53
|
+
// `cockpit login`, which does not fix an unreadable session file.
|
|
54
|
+
if (!isMissingFileFailure(error)) {
|
|
55
|
+
console.error("[upload-envelope] session file present but unreadable, reporting the machine as unpaired", JSON.stringify({
|
|
56
|
+
reason: "unpaired",
|
|
57
|
+
...describeError(error),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
throw new LocalUploadBlockedError("unpaired", "No paired collector session found. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
|
|
61
|
+
});
|
|
62
|
+
const session = await readLocalSessionReference(paths);
|
|
63
|
+
if (session.session_state !== "valid") {
|
|
64
|
+
throw new LocalUploadBlockedError("unpaired", session.session_state === "expired"
|
|
65
|
+
? "Collector session expired. Run `cockpit login` or `cockpit pair` again before `cockpit sync`."
|
|
66
|
+
: "Collector is not paired. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
|
|
67
|
+
}
|
|
68
|
+
return { config, sessionFile, session };
|
|
69
|
+
}
|
|
70
|
+
export async function buildLocalAmbientEnvelope(options = {}) {
|
|
71
|
+
const now = options.now ?? new Date();
|
|
72
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
73
|
+
const collector = await readPairedCollector(paths);
|
|
74
|
+
const { config, sessionFile, session } = collector;
|
|
75
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
76
|
+
const activeContext = await readLocalWorkContextForRepo(paths, repoRoot).catch((error) => {
|
|
77
|
+
// The operator is told "run `cockpit start`", which is right when the file
|
|
78
|
+
// is simply absent and wrong when it exists and will not parse — the same
|
|
79
|
+
// advice, forever, on a machine that has already run it (BLI-3238).
|
|
80
|
+
if (!isMissingFileFailure(error)) {
|
|
81
|
+
console.error("[upload-envelope] work context present but unreadable, reporting it as missing", JSON.stringify({
|
|
82
|
+
reason: "missing_context",
|
|
83
|
+
...describeError(error),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
throw new LocalUploadBlockedError("missing_context", "Active work context missing. Run `cockpit start --workspace \"$PWD\"` before `cockpit sync`.", "cockpit start --workspace \"$PWD\"");
|
|
87
|
+
});
|
|
88
|
+
const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
|
|
89
|
+
const uploadContext = makeUploadWorkContext({
|
|
90
|
+
activeContext,
|
|
91
|
+
session,
|
|
92
|
+
repoLabel,
|
|
93
|
+
now,
|
|
94
|
+
});
|
|
95
|
+
const sourceCollection = await runLocalSourceCollectors({
|
|
96
|
+
repoRoot,
|
|
97
|
+
branch: uploadContext.branch,
|
|
98
|
+
operatorId: session.operator_id,
|
|
99
|
+
operatorLabel: session.email ?? session.auth_subject_id,
|
|
100
|
+
sessionId: session.session_id,
|
|
101
|
+
workContextId: uploadContext.work_context_id,
|
|
102
|
+
activeWorkContext: activeContext,
|
|
103
|
+
rawEvidenceStateDir: paths.state_dir,
|
|
104
|
+
rawEvidenceSessionsDirs: defaultCodexSessionDirs(paths.home_dir),
|
|
105
|
+
claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
|
|
106
|
+
rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
|
|
107
|
+
rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
|
|
108
|
+
rawEvidenceCodexSessionFiles: options.codexSessionFiles,
|
|
109
|
+
rawEvidenceCodexAttributionScan: options.codexAttributionScan,
|
|
110
|
+
rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
|
|
111
|
+
rawEvidenceClaudeAttributionScan: options.claudeAttributionScan,
|
|
112
|
+
rawEvidenceSkipContentHashes: reusableContentHashes(options, {
|
|
113
|
+
operatorId: session.operator_id,
|
|
114
|
+
workContextId: uploadContext.work_context_id,
|
|
115
|
+
}),
|
|
116
|
+
rawEvidenceByteBudget: options.rawEvidenceByteBudget,
|
|
117
|
+
rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
|
|
118
|
+
rawEvidenceBudget: options.rawEvidenceBudget,
|
|
119
|
+
rawEvidenceDeliveryMode: options.evidenceDeliveryMode,
|
|
120
|
+
now,
|
|
121
|
+
});
|
|
122
|
+
const binding = sourceCollection.binding;
|
|
123
|
+
const ticketBinding = selectedTicketBindingCandidate(binding);
|
|
124
|
+
const uploadWorkContext = {
|
|
125
|
+
...uploadContext,
|
|
126
|
+
active_ticket_id: binding.selected_ticket_id ?? undefined,
|
|
127
|
+
ticket_binding_candidates: ticketBinding ? [ticketBinding] : binding.candidates,
|
|
128
|
+
};
|
|
129
|
+
const safeRiskFlags = sourceCollection.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel));
|
|
130
|
+
const events = [
|
|
131
|
+
makeSourceScanCompletedEvent({
|
|
132
|
+
context: uploadWorkContext,
|
|
133
|
+
generatedAt: now.toISOString(),
|
|
134
|
+
binding,
|
|
135
|
+
ticketBinding,
|
|
136
|
+
scans: sourceCollection.scans,
|
|
137
|
+
gitChangedFileCount: sourceCollection.facts.git?.changed_file_count ?? 0,
|
|
138
|
+
gitAddedLines: sourceCollection.facts.git?.added_lines ?? 0,
|
|
139
|
+
gitDeletedLines: sourceCollection.facts.git?.deleted_lines ?? 0,
|
|
140
|
+
carOpenTicketCount: sourceCollection.facts.car?.open_ticket_count ?? 0,
|
|
141
|
+
rawEvidenceFacts: sourceCollection.facts.raw_evidence,
|
|
142
|
+
riskFlags: safeRiskFlags,
|
|
143
|
+
}),
|
|
144
|
+
];
|
|
145
|
+
const envelope = TelemetryIngestEnvelopeSchema.parse({
|
|
146
|
+
envelope_version: "telemetry-ingest.v1",
|
|
147
|
+
generated_at: now.toISOString(),
|
|
148
|
+
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
149
|
+
session_reference: sanitizeSessionReference(session),
|
|
150
|
+
work_context: uploadWorkContext,
|
|
151
|
+
worktree_inventory: options.worktreeInventory ?? [],
|
|
152
|
+
source_scan_results: sanitizeSourceScanResults(sourceCollection.scans, repoLabel),
|
|
153
|
+
events,
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
envelope,
|
|
157
|
+
dashboard_url: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
|
|
158
|
+
device_token: sessionFile.device_token,
|
|
159
|
+
ticket_id: binding.selected_ticket_id ?? null,
|
|
160
|
+
binding,
|
|
161
|
+
event_count: envelope.events.length,
|
|
162
|
+
source_scan_count: envelope.source_scan_results.length,
|
|
163
|
+
risk_flag_count: safeRiskFlags.length,
|
|
164
|
+
repo_label: repoLabel,
|
|
165
|
+
head_sha: uploadContext.head_sha ?? null,
|
|
166
|
+
raw_evidence_upload_files: sourceCollection.facts.raw_evidence?.upload_files ?? [],
|
|
167
|
+
raw_evidence_facts: sourceCollection.facts.raw_evidence,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Content hashes whose bytes are already durable and may be skipped this sync.
|
|
172
|
+
*
|
|
173
|
+
* Object keys embed the work context, so a cursor entry only counts as reuse
|
|
174
|
+
* when it was committed under THIS operator and context. The same bytes under a
|
|
175
|
+
* different context still need their own pointer and evidence ref.
|
|
176
|
+
*/
|
|
177
|
+
function reusableContentHashes(options, context) {
|
|
178
|
+
if (options.skipContentHashes)
|
|
179
|
+
return options.skipContentHashes;
|
|
180
|
+
return new Set(Object.entries(options.cursorObjects ?? {})
|
|
181
|
+
.filter(([, entry]) => rawEvidenceObjectKeyBelongsToWorkContext(entry.object_key, context))
|
|
182
|
+
.map(([hash]) => hash));
|
|
183
|
+
}
|
|
184
|
+
export function makeUploadWorkContext(options) {
|
|
185
|
+
const provenance = makeCollectorProvenance({
|
|
186
|
+
context: options.activeContext,
|
|
187
|
+
session: options.session,
|
|
188
|
+
repoLabel: options.repoLabel,
|
|
189
|
+
});
|
|
190
|
+
return {
|
|
191
|
+
...options.activeContext,
|
|
192
|
+
repo: options.repoLabel,
|
|
193
|
+
repo_label: options.activeContext.repo_label ?? options.repoLabel,
|
|
194
|
+
repo_fingerprint: options.activeContext.repo_fingerprint,
|
|
195
|
+
repo_origin_url: options.activeContext.repo_origin_url,
|
|
196
|
+
head_sha: options.activeContext.head_sha,
|
|
197
|
+
worktree_label: options.activeContext.worktree_label,
|
|
198
|
+
worktree_fingerprint: options.activeContext.worktree_fingerprint,
|
|
199
|
+
worktree_is_primary: options.activeContext.worktree_is_primary,
|
|
200
|
+
operator_id: options.session.operator_id,
|
|
201
|
+
session_id: options.session.session_id,
|
|
202
|
+
updated_at: options.now.toISOString(),
|
|
203
|
+
provenance,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export function makeCollectorProvenance(options) {
|
|
207
|
+
return {
|
|
208
|
+
capture_source: "collector_runtime",
|
|
209
|
+
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
210
|
+
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
211
|
+
repo: options.repoLabel,
|
|
212
|
+
branch: options.context.branch,
|
|
213
|
+
repo_label: options.context.repo_label ?? options.repoLabel,
|
|
214
|
+
repo_fingerprint: options.context.repo_fingerprint,
|
|
215
|
+
repo_origin_url: options.context.repo_origin_url,
|
|
216
|
+
worktree_label: options.context.worktree_label,
|
|
217
|
+
worktree_fingerprint: options.context.worktree_fingerprint,
|
|
218
|
+
worktree_is_primary: options.context.worktree_is_primary,
|
|
219
|
+
operator_id: options.session.operator_id,
|
|
220
|
+
session_id: options.session.session_id,
|
|
221
|
+
work_context_id: options.context.work_context_id,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/** The basename of a repo root, never the path that led to it. */
|
|
225
|
+
export function safeRepoLabel(repoRoot) {
|
|
226
|
+
const basename = path.basename(repoRoot.replace(/[\\/]+$/, ""));
|
|
227
|
+
return basename || "repo";
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Does this durable object belong to the operator and work context now syncing?
|
|
231
|
+
*
|
|
232
|
+
* Two key shapes are live: the original `<operator>/<context>/...` prefix and
|
|
233
|
+
* the readable `operators/.../ids/<operator>/<context>/...` layout.
|
|
234
|
+
*/
|
|
235
|
+
function rawEvidenceObjectKeyBelongsToWorkContext(objectKey, context) {
|
|
236
|
+
const legacyPrefix = `${context.operatorId}/${context.workContextId}/`;
|
|
237
|
+
const readableIdGuard = `/ids/${context.operatorId}/${context.workContextId}/`;
|
|
238
|
+
return (objectKey.startsWith(legacyPrefix) ||
|
|
239
|
+
(objectKey.startsWith("operators/") && objectKey.includes(readableIdGuard)));
|
|
240
|
+
}
|