@bli-cockpit/cli 0.2.48 → 0.2.50

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.
Files changed (83) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  3. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  4. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  5. package/dist/adapters/raw-evidence-facts.js +338 -0
  6. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  7. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  8. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  9. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  10. package/dist/adapters/raw-evidence.js +94 -1203
  11. package/dist/autostart-contract.js +79 -0
  12. package/dist/autostart-darwin-plist.js +265 -0
  13. package/dist/autostart-darwin.js +171 -0
  14. package/dist/autostart-windows-scripts.js +310 -0
  15. package/dist/autostart-windows-task-xml.js +260 -0
  16. package/dist/autostart-windows.js +237 -0
  17. package/dist/autostart-xml.js +23 -0
  18. package/dist/autostart.js +35 -1148
  19. package/dist/commands/agent-rules-command.js +55 -0
  20. package/dist/commands/agent-session-report.js +290 -0
  21. package/dist/commands/analyze.js +131 -0
  22. package/dist/commands/autostart-command.js +105 -0
  23. package/dist/commands/backfill-batches.js +34 -0
  24. package/dist/commands/backfill-candidates.js +54 -0
  25. package/dist/commands/backfill-checkpoint.js +101 -0
  26. package/dist/commands/backfill-command-line.js +70 -0
  27. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  28. package/dist/commands/backfill-issues.js +265 -0
  29. package/dist/commands/backfill-output.js +75 -0
  30. package/dist/commands/backfill-plan.js +71 -0
  31. package/dist/commands/backfill-reasons.js +107 -0
  32. package/dist/commands/backfill-report.js +298 -0
  33. package/dist/commands/backfill-result.js +150 -0
  34. package/dist/commands/backfill-scan.js +274 -0
  35. package/dist/commands/backfill-scope.js +114 -0
  36. package/dist/commands/backfill-session-report.js +145 -0
  37. package/dist/commands/backfill-types.js +1 -0
  38. package/dist/commands/backfill-upload.js +212 -0
  39. package/dist/commands/backfill.js +58 -1705
  40. package/dist/commands/cli-io.js +13 -0
  41. package/dist/commands/doctor.js +57 -0
  42. package/dist/commands/jarvis-trace.js +184 -0
  43. package/dist/commands/jarvis.js +323 -7
  44. package/dist/commands/local-arg-values.js +169 -0
  45. package/dist/commands/local-args-collector.js +604 -0
  46. package/dist/commands/local-args-tower.js +891 -0
  47. package/dist/commands/local-args.js +10 -1549
  48. package/dist/commands/local-help.js +30 -5
  49. package/dist/commands/local.js +21 -1786
  50. package/dist/commands/login.js +53 -0
  51. package/dist/commands/logout.js +66 -0
  52. package/dist/commands/memory-install-claude.js +294 -0
  53. package/dist/commands/memory-install-codex.js +205 -0
  54. package/dist/commands/memory-install-contract.js +231 -0
  55. package/dist/commands/memory-install-files.js +63 -0
  56. package/dist/commands/memory-install-skills.js +121 -0
  57. package/dist/commands/memory-install-toml.js +265 -0
  58. package/dist/commands/memory-install.js +378 -0
  59. package/dist/commands/onboard-receipts.js +66 -0
  60. package/dist/commands/onboard-report.js +274 -0
  61. package/dist/commands/onboard.js +449 -0
  62. package/dist/commands/ops-render.js +36 -0
  63. package/dist/commands/public-root.js +1 -1
  64. package/dist/commands/serve.js +13 -0
  65. package/dist/commands/session-sync.js +513 -534
  66. package/dist/commands/settings-render.js +28 -0
  67. package/dist/commands/settings.js +66 -2
  68. package/dist/commands/start.js +47 -0
  69. package/dist/commands/sync-followups.js +308 -0
  70. package/dist/commands/sync.js +387 -0
  71. package/dist/local-state-attributed-target.js +75 -0
  72. package/dist/local-state-config.js +147 -0
  73. package/dist/local-state-files.js +59 -0
  74. package/dist/local-state-identity.js +73 -0
  75. package/dist/local-state-pairing.js +263 -0
  76. package/dist/local-state-paths.js +61 -0
  77. package/dist/local-state-session.js +68 -0
  78. package/dist/local-state-status.js +163 -0
  79. package/dist/local-state-work-context.js +190 -0
  80. package/dist/local-state.js +34 -848
  81. package/dist/tower-client.js +3 -2
  82. package/dist/tower-stream.js +76 -6
  83. package/package.json +2 -1
@@ -0,0 +1,387 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { attributedSyncRunStatus, cursorStatusLine, displayTicketId, rawEvidenceSyncLine, shortSha, worktreeSyncRow, writeAgentSessionSummary, } from "./collection-report.js";
3
+ import { collectionRootConsentAliases } from "./collection-roots.js";
4
+ import { discoverCommandWorktrees } from "./local-discovery.js";
5
+ import { sendCollectorHeartbeatBestEffort, } from "./heartbeat.js";
6
+ import { classifySyncFailureRecords, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
7
+ import { runAttributedWorktreeSync, } from "./session-sync.js";
8
+ import { runAutostartSelfHealAfterSync, runMemoryInstallAfterSync, runScheduledSelfUpdateAfterSync, } from "./sync-followups.js";
9
+ import { describeError } from "../health-detail.js";
10
+ import { inspectBackfillLock } from "../backfill-lock.js";
11
+ import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
12
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
13
+ import { CollectionRootRequiredError, } from "../onboarding-roots.js";
14
+ import { rawEvidenceDedupSummary, rawEvidenceGcSummary, runRawEvidenceLocalGc, sweepDuplicateStagedRawEvidence, } from "../raw-evidence-gc.js";
15
+ import { normalizeCollectionRoots } from "../root-normalization.js";
16
+ import { acquireSyncLock } from "../sync-lock.js";
17
+ export async function runSync(command, io) {
18
+ const paths = getCollectorRuntimePaths(command.homeDir);
19
+ // BLI-3553, first thing in the tick: cap the scheduler's own logs. Nothing
20
+ // rotated them before, and two months of a 15-minute tick left 211 MB of
21
+ // sync.log on the reference Mac. Best-effort by construction — a rotation
22
+ // problem is its own log line, never a reason collection does not run.
23
+ await rotateCollectorLogsBestEffort(paths);
24
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
25
+ const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
26
+ const minCliVersionAtStart = await reportInstallEventsBestEffort({
27
+ homeDir: command.homeDir,
28
+ dashboardUrl,
29
+ command: "sync",
30
+ events: [{ step: "sync_started", status: "ok" }],
31
+ json: command.json,
32
+ io,
33
+ });
34
+ try {
35
+ const result = await runSyncWithHealthReceipt(command, io);
36
+ // BLI-3551: every tick checks in, including one that collected nothing.
37
+ // This is the only writer of `last_seen_at` that does not need an envelope,
38
+ // so it is what separates a quiet machine from a dead one.
39
+ await sendSyncHeartbeat(command, io, dashboardUrl, result.heartbeat);
40
+ const minCliVersion = await reportInstallEventsBestEffort({
41
+ homeDir: command.homeDir,
42
+ dashboardUrl,
43
+ command: "sync",
44
+ events: [result.completion],
45
+ json: command.json,
46
+ io,
47
+ });
48
+ // BLI-2601: self-update runs only after collection's own outcome above is
49
+ // already decided and reported, win or lose. See the function doc.
50
+ await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
51
+ await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
52
+ // BLI-3580: BLI Memory's registration converges the same way — after
53
+ // collection, at most once a day, its own receipt either way.
54
+ await runMemoryInstallAfterSync(command, io, dashboardUrl);
55
+ return result.exitCode;
56
+ }
57
+ catch (error) {
58
+ const errorCode = classifySyncHealthError(error);
59
+ // A machine whose sync THREW is still alive, and that is worth knowing —
60
+ // a device that stops checking in entirely is a different problem from one
61
+ // checking in with a failure every fifteen minutes.
62
+ await sendSyncHeartbeat(command, io, dashboardUrl, {
63
+ status: "fail",
64
+ reason: errorCode,
65
+ });
66
+ const minCliVersion = await reportInstallEventsBestEffort({
67
+ homeDir: command.homeDir,
68
+ dashboardUrl,
69
+ command: "sync",
70
+ events: [
71
+ {
72
+ step: "sync_complete",
73
+ status: "fail",
74
+ error_code: errorCode,
75
+ error_detail: redactedSyncErrorDetail(error),
76
+ },
77
+ ],
78
+ json: command.json,
79
+ io,
80
+ });
81
+ await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
82
+ await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
83
+ // BLI-3580: BLI Memory's registration converges the same way — after
84
+ // collection, at most once a day, its own receipt either way.
85
+ await runMemoryInstallAfterSync(command, io, dashboardUrl);
86
+ throw error;
87
+ }
88
+ }
89
+ /**
90
+ * The tick's check-in (BLI-3551).
91
+ *
92
+ * Resolving the roots is best-effort on purpose: a machine with NO approved
93
+ * root is exactly the machine whose silence needs explaining, so it still
94
+ * checks in — with an empty root list, which is itself the finding.
95
+ */
96
+ async function sendSyncHeartbeat(command, io, dashboardUrl, facts) {
97
+ const roots = await resolveSyncCollectionRoots(command).catch(() => []);
98
+ await sendCollectorHeartbeatBestEffort({
99
+ homeDir: command.homeDir,
100
+ dashboardUrl,
101
+ roots,
102
+ facts,
103
+ io,
104
+ }).catch((error) => {
105
+ // The sender already swallows everything it knows about; this is the net
106
+ // for anything it does not, because a heartbeat must never fail a sync.
107
+ console.error("[heartbeat] the check-in threw and was dropped", JSON.stringify({ reason: "heartbeat_threw", ...describeError(error) }));
108
+ return false;
109
+ });
110
+ }
111
+ async function runSyncWithHealthReceipt(command, io) {
112
+ const backfillLock = await inspectBackfillLock(getCollectorRuntimePaths(command.homeDir));
113
+ if (backfillLock.held) {
114
+ if (command.json) {
115
+ writeLine(io.stdout, JSON.stringify({
116
+ status: "live_sync_paused_during_backfill",
117
+ reason: "live sync paused during backfill",
118
+ held_since: backfillLock.held_since,
119
+ collection_complete: false,
120
+ upload_state: "not_uploaded",
121
+ retryable: true,
122
+ }, null, 2));
123
+ }
124
+ else {
125
+ writeLine(io.stdout, "Pausing normal sync while it catches up on old sessions.");
126
+ }
127
+ return {
128
+ exitCode: 0,
129
+ completion: {
130
+ step: "sync_complete",
131
+ status: "skipped",
132
+ error_code: "live_sync_paused_during_backfill",
133
+ },
134
+ heartbeat: {
135
+ status: "skipped",
136
+ reason: "live_sync_paused_during_backfill",
137
+ },
138
+ };
139
+ }
140
+ // Single-flight: a launchd timer and a manual sync must not interleave the
141
+ // cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
142
+ const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
143
+ if (!lock.acquired) {
144
+ if (command.json) {
145
+ writeLine(io.stdout, JSON.stringify({
146
+ status: "sync_already_running",
147
+ reason: "another sync owns the collection lock",
148
+ held_since: lock.held_since,
149
+ collection_complete: false,
150
+ upload_state: "not_uploaded",
151
+ retryable: true,
152
+ }, null, 2));
153
+ }
154
+ else {
155
+ writeLine(io.stdout, "Tower sync already running; skipping this run.");
156
+ }
157
+ return {
158
+ exitCode: 0,
159
+ completion: {
160
+ step: "sync_complete",
161
+ status: "skipped",
162
+ error_code: "sync_already_running",
163
+ },
164
+ heartbeat: { status: "skipped", reason: "sync_already_running" },
165
+ };
166
+ }
167
+ try {
168
+ const { exitCode, failureReasons, failureRecords, notice, sessionsObserved, sessionsOutsideRoot, } = await runSyncLocked(command, io);
169
+ const counts = {
170
+ sessionsObserved,
171
+ sessionsOutsideRoot,
172
+ };
173
+ if (exitCode === 0) {
174
+ // BLI-3551: an `ok` tick can still have something to say. `nothing_in_root`
175
+ // is the receipt that separates "this machine is alive and its operator
176
+ // works outside the approved roots" from "this machine is dead", which
177
+ // until now looked identical from the dashboard.
178
+ return {
179
+ exitCode,
180
+ completion: {
181
+ step: "sync_complete",
182
+ status: "ok",
183
+ ...(notice ? { error_detail: notice } : {}),
184
+ },
185
+ heartbeat: { status: "ok", reason: notice, ...counts },
186
+ };
187
+ }
188
+ // A sync that fails by exit code says exactly as much as one that throws.
189
+ // It used to say `sync_failed` and nothing else, so 100% of recorded
190
+ // failure rows carried a null detail and the real reason was reachable only
191
+ // by running `cockpit status` on the machine itself (BLI-2526).
192
+ const reasonText = failureReasons.join("; ");
193
+ // The bucket comes from the records the deciding branches wrote, not from
194
+ // this sentence (BLI-3551). The sentence is still the detail.
195
+ const errorCode = classifySyncFailureRecords(failureRecords);
196
+ return {
197
+ exitCode,
198
+ completion: {
199
+ step: "sync_complete",
200
+ status: "fail",
201
+ error_code: errorCode,
202
+ error_detail: redactedSyncErrorDetail(reasonText),
203
+ },
204
+ heartbeat: { status: "fail", reason: errorCode, ...counts },
205
+ };
206
+ }
207
+ finally {
208
+ await lock.handle.release();
209
+ }
210
+ }
211
+ /**
212
+ * Turn a finished run into an exit code and the reasons behind it.
213
+ *
214
+ * One place, so a future return path cannot reintroduce a code with no reason.
215
+ * The reasons come from the run itself — the code that decided `ok` is false is
216
+ * the only code that knows why.
217
+ */
218
+ function syncResult(run) {
219
+ return {
220
+ exitCode: run.ok ? 0 : 1,
221
+ failureReasons: run.ok ? [] : run.failure_reasons,
222
+ failureRecords: run.ok ? [] : run.failure_records,
223
+ notice: run.notice,
224
+ sessionsObserved: run.sessions_observed,
225
+ sessionsOutsideRoot: run.sessions_outside_root,
226
+ };
227
+ }
228
+ /**
229
+ * The sync runbook: resolve which roots to collect, dedup staged packs before
230
+ * touching anything else, discover worktrees, sync them, then report — one of
231
+ * three shapes depending on how many worktrees came back. The three shapes
232
+ * share nothing but `dedup`, so each gets its own step function below rather
233
+ * than one branchy body.
234
+ */
235
+ async function runSyncLocked(command, io) {
236
+ const collectionRoots = await resolveSyncCollectionRoots(command);
237
+ // Before anything is collected: collapse byte-identical staged packs. It runs
238
+ // first, unconditionally and unthrottled, because a machine that already
239
+ // holds 559 copies of one rollout needs the disk back before it stages
240
+ // anything else (BLI-3066). Safe by construction — a duplicate is identical
241
+ // by content hash to the survivor.
242
+ const dedup = await sweepDuplicateStagedRawEvidence(getCollectorRuntimePaths(command.homeDir), io.env);
243
+ if (!dedup.skipped && dedup.removed_dirs > 0) {
244
+ writeLine(io.stdout, rawEvidenceDedupSummary(dedup));
245
+ }
246
+ const worktrees = await discoverCommandWorktrees(collectionRoots, {
247
+ maxDepth: command.maxDepth,
248
+ maxRepos: command.maxRepos,
249
+ homeDir: command.homeDir,
250
+ allowEmpty: true,
251
+ }, io);
252
+ const run = await runAttributedWorktreeSync({
253
+ homeDir: command.homeDir,
254
+ dashboardUrl: command.dashboardUrl,
255
+ collectionRoots,
256
+ startContexts: false,
257
+ worktrees,
258
+ fetchImpl: io.fetch,
259
+ });
260
+ if (run.outcomes.length > 1) {
261
+ return reportMultiRepoSync(command, io, run, dedup);
262
+ }
263
+ // Zero worktrees is a legitimate steady state, not a failure: an approved
264
+ // root can hold no git repos, and sessions upload independently of
265
+ // worktrees (session-first, BLI-2581). This used to throw "Sync produced no
266
+ // result", which painted ~90 false-red sync_failed receipts per day on one
267
+ // fleet machine with a single empty root and taught people to ignore
268
+ // sync_failed (BLI-2722). A genuinely broken run still fails via run.ok.
269
+ if (run.outcomes.length === 0) {
270
+ return reportNoWorktreeSync(command, io, run, dedup);
271
+ }
272
+ return reportSingleRepoSync(command, io, run, dedup);
273
+ }
274
+ async function reportMultiRepoSync(command, io, run, dedup) {
275
+ const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
276
+ const collectionRunStatus = attributedSyncRunStatus(run);
277
+ const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
278
+ if (command.json) {
279
+ writeLine(io.stdout, JSON.stringify({
280
+ mode: "multi_repo",
281
+ status: collectionRunStatus,
282
+ collection_complete: run.ok,
283
+ results: run.outcomes.map((outcome) => outcome.sync),
284
+ repos: rows,
285
+ codex_sessions: run.summary,
286
+ raw_evidence_gc: gc,
287
+ raw_evidence_dedup: dedup,
288
+ }, null, 2));
289
+ return syncResult(run);
290
+ }
291
+ writeLine(run.ok ? io.stdout : io.stderr, `Tower parent sync ${collectionRunStatus} ${run.outcomes.filter((outcome) => outcome.sync.status === "uploaded").length}/${run.outcomes.length} worktree(s).`);
292
+ for (const outcome of run.outcomes) {
293
+ const { worktree, sync } = outcome;
294
+ const uploaded = sync.status === "uploaded";
295
+ const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
296
+ writeLine(uploaded ? io.stdout : io.stderr, `- ${worktree.repo_label}/${worktree.worktree_label} (${worktree.branch}) head:${shortSha(sync.head_sha ?? worktree.head_sha)} ${sync.status} objects:${sync.raw_evidence_uploaded_object_count} chunks:${sync.raw_evidence_uploaded_chunk_count} reused:${sync.raw_evidence_reused_count} failed:${sync.raw_evidence_failed_count} cursor:${sync.cursor_tracked_object_count}${failureSuffix}`);
297
+ }
298
+ writeAgentSessionSummary(io, run.summary);
299
+ if (gc && !gc.skipped)
300
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
301
+ return syncResult(run);
302
+ }
303
+ async function reportNoWorktreeSync(command, io, run, dedup) {
304
+ const collectionRunStatus = attributedSyncRunStatus(run);
305
+ const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
306
+ if (command.json) {
307
+ writeLine(io.stdout, JSON.stringify({
308
+ mode: "no_worktrees",
309
+ status: collectionRunStatus,
310
+ collection_complete: run.ok,
311
+ ...(run.notice ? { notice: run.notice } : {}),
312
+ codex_sessions: run.summary,
313
+ raw_evidence_gc: gc,
314
+ raw_evidence_dedup: dedup,
315
+ }, null, 2));
316
+ return syncResult(run);
317
+ }
318
+ writeLine(run.ok ? io.stdout : io.stderr, `Tower sync ${collectionRunStatus}: no git worktrees under this root; session scan ran.`);
319
+ if (run.notice) {
320
+ // Says out loud what the receipt now says to the dashboard: the sessions
321
+ // this machine ran were all outside the folders it is allowed to look at.
322
+ writeLine(io.stdout, `Every session seen this run was outside your approved folders (${run.notice}). Nothing was collected, and nothing is broken.`);
323
+ }
324
+ writeAgentSessionSummary(io, run.summary);
325
+ if (gc && !gc.skipped)
326
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
327
+ return syncResult(run);
328
+ }
329
+ async function reportSingleRepoSync(command, io, run, dedup) {
330
+ const result = run.outcomes[0]?.sync;
331
+ if (!result) {
332
+ throw new Error("Sync produced no result for the repo worktree.");
333
+ }
334
+ const collectionRunStatus = attributedSyncRunStatus(run);
335
+ const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
336
+ if (command.json) {
337
+ writeLine(io.stdout, JSON.stringify({
338
+ ...result,
339
+ status: collectionRunStatus,
340
+ collection_complete: run.ok,
341
+ codex_sessions: run.summary,
342
+ raw_evidence_gc: gc,
343
+ raw_evidence_dedup: dedup,
344
+ }, null, 2));
345
+ return syncResult(run);
346
+ }
347
+ if (run.ok) {
348
+ writeLine(io.stdout, "Tower uploaded this session.");
349
+ writeLine(io.stdout, `Ticket: ${displayTicketId(result.ticket_id)}`);
350
+ writeLine(io.stdout, `Context: ${result.work_context_id}`);
351
+ writeLine(io.stdout, `Head: ${shortSha(result.head_sha)}`);
352
+ writeLine(io.stdout, `Things recorded: ${result.event_count}`);
353
+ writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
354
+ writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
355
+ writeLine(io.stdout, rawEvidenceSyncLine(result));
356
+ writeAgentSessionSummary(io, run.summary);
357
+ writeLine(io.stdout, cursorStatusLine(result));
358
+ if (gc && !gc.skipped)
359
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
360
+ return syncResult(run);
361
+ }
362
+ if (result.status === "uploaded") {
363
+ writeLine(io.stderr, "Tower uploaded, but some sessions did not make it. Run `cockpit sync` again.");
364
+ writeAgentSessionSummary(io, run.summary);
365
+ return syncResult(run);
366
+ }
367
+ writeLine(io.stderr, "Tower could not upload. It saved a note to retry and will try again on the next sync.");
368
+ writeLine(io.stderr, `Ticket: ${displayTicketId(result.ticket_id)}`);
369
+ writeLine(io.stderr, `Failure: ${result.failure_reason}`);
370
+ writeLine(io.stderr, `Retry: ${result.retry_command}`);
371
+ return syncResult(run);
372
+ }
373
+ async function resolveSyncCollectionRoots(command) {
374
+ const explicitRoots = normalizeCollectionRoots(command.repoRoot ? [command.repoRoot] : []);
375
+ if (explicitRoots.length > 0) {
376
+ return collectionRootConsentAliases(explicitRoots);
377
+ }
378
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(command.homeDir)).catch(() => null);
379
+ const savedRoots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
380
+ if (savedRoots.length > 0) {
381
+ return collectionRootConsentAliases(savedRoots);
382
+ }
383
+ throw new CollectionRootRequiredError(`no explicit or saved collection root is available.`);
384
+ }
385
+ async function runSyncRawEvidenceGc(command, io) {
386
+ return runRawEvidenceLocalGc(getCollectorRuntimePaths(command.homeDir), io.env);
387
+ }
@@ -0,0 +1,75 @@
1
+ import path from "node:path";
2
+ import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
3
+ import { isSamePath } from "./root-normalization.js";
4
+ /**
5
+ * A transcript-attributed target is an identity a CALLER built — for a wrapper
6
+ * folder or a repo that no longer exists on disk — so every field of it is
7
+ * re-derived here before a single byte of context state is written. Nothing
8
+ * downstream can tell an invented fingerprint from a real one.
9
+ *
10
+ * The checks run in a fixed order and each throws its own sentence, so the
11
+ * first thing wrong is the thing the operator is told about.
12
+ */
13
+ export async function validateAttributedTargetIdentity(requestedRepoRoot, identity) {
14
+ const canonicalRoot = await stableWorktreeRoot(requestedRepoRoot);
15
+ const identityRoot = await stableWorktreeRoot(identity.repo_root);
16
+ const identityRequestedPath = await stableWorktreeRoot(identity.requested_path);
17
+ if (!isSamePath(canonicalRoot, identityRoot) ||
18
+ !isSamePath(canonicalRoot, identityRequestedPath)) {
19
+ throw new Error("Attributed target identity paths do not match the requested repo root.");
20
+ }
21
+ const expectedWorktreeFingerprint = stableWorktreeFingerprint(canonicalRoot);
22
+ if (identity.worktree_fingerprint !== expectedWorktreeFingerprint) {
23
+ throw new Error("Attributed target worktree fingerprint does not match its repo root.");
24
+ }
25
+ const expectedWorktreeLabel = path.basename(canonicalRoot) || "workspace";
26
+ if (identity.worktree_label !== expectedWorktreeLabel) {
27
+ throw new Error("Attributed target worktree label does not match its repo root.");
28
+ }
29
+ if (identity.repo_origin_url) {
30
+ assertRepoFieldsMatchOrigin(identity, identity.repo_origin_url);
31
+ }
32
+ else {
33
+ assertRepoFieldsMatchLocalRoot(identity, canonicalRoot, expectedWorktreeLabel);
34
+ }
35
+ return {
36
+ ...identity,
37
+ requested_path: canonicalRoot,
38
+ repo_root: canonicalRoot,
39
+ };
40
+ }
41
+ /**
42
+ * An origin-derived target is a repo reconstructed from transcript
43
+ * provenance, so it may not claim to be the live primary worktree of anything.
44
+ */
45
+ function assertRepoFieldsMatchOrigin(identity, repoOriginUrl) {
46
+ const normalizedOrigin = normalizeGitOrigin(repoOriginUrl);
47
+ if (normalizedOrigin !== repoOriginUrl) {
48
+ throw new Error("Attributed target repo origin is not normalized.");
49
+ }
50
+ if (identity.repo_fingerprint !== repoFingerprintFromOrigin(normalizedOrigin)) {
51
+ throw new Error("Attributed target repo fingerprint does not match its origin.");
52
+ }
53
+ if (identity.repo_label !== repoLabelFromOrigin(normalizedOrigin)) {
54
+ throw new Error("Attributed target repo label does not match its origin.");
55
+ }
56
+ if (identity.worktree_is_primary) {
57
+ throw new Error("Origin-derived attributed targets cannot claim a primary live worktree.");
58
+ }
59
+ }
60
+ /**
61
+ * Without an origin the only thing an identity can honestly be derived from is
62
+ * the local root, so the fingerprint, the label and primacy all follow from it.
63
+ */
64
+ function assertRepoFieldsMatchLocalRoot(identity, canonicalRoot, expectedWorktreeLabel) {
65
+ if (identity.repo_fingerprint !==
66
+ repoFingerprintFromLocalRoot(canonicalRoot)) {
67
+ throw new Error("Attributed target repo fingerprint does not match its local root.");
68
+ }
69
+ if (identity.repo_label !== expectedWorktreeLabel) {
70
+ throw new Error("Attributed target repo label does not match its local root.");
71
+ }
72
+ if (!identity.worktree_is_primary) {
73
+ throw new Error("Local attributed targets must use their primary local identity.");
74
+ }
75
+ }
@@ -0,0 +1,147 @@
1
+ import { LocalCollectorConfigSchema, } from "@bli-cockpit/telemetry-core";
2
+ import crypto from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { normalizeCollectionRoots } from "./root-normalization.js";
7
+ import { ensureRuntimeDirectories, getCollectorRuntimePaths, } from "./local-state-paths.js";
8
+ import { isMissingFileError, readJsonFile, writeJsonFile, } from "./local-state-files.js";
9
+ const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
10
+ export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
11
+ ? localCollectorPackage.version
12
+ : "0.0.0";
13
+ export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
14
+ /**
15
+ * `cockpit install` — the one write that registers collection roots, because
16
+ * widening an operator's boundary is a consent decision and nothing else may
17
+ * make it.
18
+ */
19
+ export async function installLocalCollector(options = {}) {
20
+ const homeDir = options.homeDir ?? os.homedir();
21
+ const repoRoots = normalizeRepoRoots(options.repoRoots);
22
+ const repoRoot = path.resolve(options.repoRoot ?? repoRoots[0] ?? process.cwd());
23
+ const paths = getCollectorRuntimePaths(homeDir);
24
+ await ensureRuntimeDirectories(paths);
25
+ const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
26
+ const defaultRepoPaths = normalizeRepoRoots([
27
+ ...(options.replaceRepoRoots ? [] : (existingConfig?.default_repo_paths ?? [])),
28
+ ...(repoRoots.length > 0 ? repoRoots : [repoRoot]),
29
+ ]);
30
+ const rawEvidenceUpload = migrateRawEvidenceUploadChoice(existingConfig);
31
+ const config = LocalCollectorConfigSchema.parse({
32
+ schema_version: "telemetry-core.v1",
33
+ dashboard_url: options.dashboardUrl ?? existingConfig?.dashboard_url ?? DEFAULT_DASHBOARD_URL,
34
+ supabase_url: options.supabaseUrl ?? existingConfig?.supabase_url,
35
+ collector_version: LOCAL_COLLECTOR_VERSION,
36
+ device_id: existingConfig?.device_id ?? `device-${crypto.randomUUID()}`,
37
+ device_name: normalizeDeviceName(options.deviceName) ??
38
+ existingConfig?.device_name ??
39
+ defaultDeviceName(),
40
+ claimed_owner_email: existingConfig?.claimed_owner_email,
41
+ operator_id: existingConfig?.operator_id,
42
+ default_repo_paths: defaultRepoPaths,
43
+ raw_evidence_upload: rawEvidenceUpload,
44
+ session_file_path: paths.session_file,
45
+ state_dir_path: paths.state_dir,
46
+ });
47
+ await writeJsonFile(paths.config_file, config);
48
+ return {
49
+ config,
50
+ paths,
51
+ auth_pairing_state: "missing",
52
+ message: "Local collector installed. Pair/login is still required before remote upload.",
53
+ };
54
+ }
55
+ /**
56
+ * The one on-disk migration this file performs: both retired raw-evidence
57
+ * choices become the durable one, so a machine installed before durable
58
+ * evidence existed stops holding the old answer forever.
59
+ */
60
+ function migrateRawEvidenceUploadChoice(existingConfig) {
61
+ return existingConfig?.raw_evidence_upload === "disabled" ||
62
+ existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
63
+ ? "remote_durable_opt_in"
64
+ : (existingConfig?.raw_evidence_upload ?? "remote_durable_opt_in");
65
+ }
66
+ function normalizeRepoRoots(repoRoots) {
67
+ if (!repoRoots)
68
+ return [];
69
+ const seen = new Set();
70
+ const candidates = [];
71
+ for (const root of repoRoots) {
72
+ const resolved = path.resolve(root);
73
+ if (seen.has(resolved))
74
+ continue;
75
+ seen.add(resolved);
76
+ candidates.push(resolved);
77
+ }
78
+ return normalizeCollectionRoots(candidates);
79
+ }
80
+ /**
81
+ * Reads the collector config, creating a minimal rootless one when the file
82
+ * does not exist yet (fresh machine where `cockpit onboard`/`install` has not
83
+ * run). Never registers collection roots: root consent stays with
84
+ * onboard/install. A corrupt existing config is never overwritten.
85
+ */
86
+ export async function ensureLocalCollectorConfig(options = {}) {
87
+ const homeDir = options.homeDir ?? os.homedir();
88
+ const paths = getCollectorRuntimePaths(homeDir);
89
+ await ensureRuntimeDirectories(paths);
90
+ let existing = null;
91
+ try {
92
+ existing = await readLocalCollectorConfig(paths);
93
+ }
94
+ catch (error) {
95
+ if (!isMissingFileError(error)) {
96
+ const reason = error instanceof Error ? error.message : String(error);
97
+ throw new Error(`Local collector config at ${paths.config_file} is unreadable (${reason}). ` +
98
+ "Run `cockpit onboard` to repair it.");
99
+ }
100
+ }
101
+ if (existing)
102
+ return { config: existing, paths, created: false };
103
+ const config = LocalCollectorConfigSchema.parse({
104
+ schema_version: "telemetry-core.v1",
105
+ dashboard_url: normalizeDashboardUrl(options.dashboardUrl ?? DEFAULT_DASHBOARD_URL),
106
+ collector_version: LOCAL_COLLECTOR_VERSION,
107
+ device_id: `device-${crypto.randomUUID()}`,
108
+ device_name: defaultDeviceName(),
109
+ default_repo_paths: [],
110
+ session_file_path: paths.session_file,
111
+ state_dir_path: paths.state_dir,
112
+ });
113
+ await writeJsonFile(paths.config_file, config);
114
+ return { config, paths, created: true };
115
+ }
116
+ /**
117
+ * Every caller that needs to know how this machine is configured comes through
118
+ * here, so a config that exists and will not parse fails the same way for all
119
+ * of them instead of each inventing a default.
120
+ */
121
+ export async function readLocalCollectorConfig(paths) {
122
+ return LocalCollectorConfigSchema.parse(await readJsonFile(paths.config_file));
123
+ }
124
+ /**
125
+ * A trailing slash in a dashboard URL produces `//api/...` request paths that
126
+ * some proxies answer differently, so the stored value is normalized once.
127
+ */
128
+ export function normalizeDashboardUrl(value) {
129
+ const normalized = value.trim().replace(/\/+$/, "");
130
+ if (!normalized)
131
+ throw new Error("Dashboard URL cannot be empty.");
132
+ return normalized;
133
+ }
134
+ /** The device name an operator sees in the dashboard before they pick one. */
135
+ export function defaultDeviceName() {
136
+ return normalizeDeviceName(os.hostname()) ?? "Local machine";
137
+ }
138
+ /** Keeps a hand-typed or hostname-derived device name inside what the schema accepts. */
139
+ export function normalizeDeviceName(value) {
140
+ const trimmed = value?.trim().replace(/\s+/g, " ");
141
+ return trimmed ? trimmed.slice(0, 120) : undefined;
142
+ }
143
+ /** A claimed owner email is a label a person types, so an unusable one is dropped rather than stored. */
144
+ export function normalizeOptionalEmail(value) {
145
+ const trimmed = value?.trim().toLowerCase();
146
+ return trimmed && trimmed.includes("@") ? trimmed : undefined;
147
+ }
@@ -0,0 +1,59 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
4
+ /**
5
+ * The one reader behind the config file, the session file and every work
6
+ * context — and therefore the one place worth reporting from.
7
+ *
8
+ * Roughly twenty call sites swallow this to `null` or a default with
9
+ * `.catch(() => null)`, each of them asking a reasonable question ("is this
10
+ * machine set up?") to which "no" is a legitimate answer. What none of them
11
+ * could distinguish is "no, nothing is there" from "yes, and it is corrupt or
12
+ * unreadable" — so the distinction is drawn HERE, once, rather than in twenty
13
+ * places where it would be twenty chances to forget (BLI-3238).
14
+ *
15
+ * The error still propagates unchanged; callers keep whatever they decided.
16
+ */
17
+ export async function readJsonFile(filePath) {
18
+ try {
19
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
20
+ }
21
+ catch (error) {
22
+ // Absent is the ordinary pre-onboarding state on every one of these files
23
+ // and stays quiet; anything else means state exists and cannot be used.
24
+ if (!isMissingFileFailure(error)) {
25
+ console.error("[local-state] a collector state file exists but could not be read", JSON.stringify({
26
+ reason: "state_file_unreadable",
27
+ // Which file, without the path: the basename of these is a fixed
28
+ // vocabulary (`config.json`, `session.json`, a work-context
29
+ // fingerprint) and carries no repo or operator name.
30
+ state_file: path.basename(filePath),
31
+ ...describeError(error),
32
+ }));
33
+ }
34
+ throw error;
35
+ }
36
+ }
37
+ /**
38
+ * The one writer behind every collector state file, because each of them holds
39
+ * a device token or an operator's work and must land owner-only on any host.
40
+ */
41
+ export async function writeJsonFile(filePath, value) {
42
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
43
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
44
+ mode: 0o600,
45
+ });
46
+ if (process.platform !== "win32") {
47
+ await fs.chmod(filePath, 0o600).catch(() => undefined);
48
+ }
49
+ }
50
+ /**
51
+ * Separate from `isMissingFileFailure`: the two callers here hold a raw `fs`
52
+ * rejection rather than a wrapped failure, and "the file was never there" is
53
+ * the one outcome they treat as success rather than an error to report.
54
+ */
55
+ export function isMissingFileError(error) {
56
+ return (error instanceof Error &&
57
+ "code" in error &&
58
+ error.code === "ENOENT");
59
+ }