@bli-cockpit/cli 0.2.49 → 0.2.51

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 (57) hide show
  1. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  2. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  4. package/dist/adapters/raw-evidence-facts.js +338 -0
  5. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  6. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  7. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  8. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  9. package/dist/adapters/raw-evidence.js +63 -1183
  10. package/dist/commands/backfill-batches.js +34 -0
  11. package/dist/commands/backfill-candidates.js +54 -0
  12. package/dist/commands/backfill-checkpoint.js +101 -0
  13. package/dist/commands/backfill-command-line.js +70 -0
  14. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  15. package/dist/commands/backfill-issues.js +265 -0
  16. package/dist/commands/backfill-output.js +75 -0
  17. package/dist/commands/backfill-plan.js +71 -0
  18. package/dist/commands/backfill-reasons.js +107 -0
  19. package/dist/commands/backfill-report.js +298 -0
  20. package/dist/commands/backfill-result.js +150 -0
  21. package/dist/commands/backfill-scan.js +274 -0
  22. package/dist/commands/backfill-scope.js +114 -0
  23. package/dist/commands/backfill-session-report.js +145 -0
  24. package/dist/commands/backfill-types.js +1 -0
  25. package/dist/commands/backfill-upload.js +212 -0
  26. package/dist/commands/backfill.js +41 -1961
  27. package/dist/commands/doctor.js +57 -0
  28. package/dist/commands/jarvis-trace.js +184 -0
  29. package/dist/commands/jarvis.js +144 -4
  30. package/dist/commands/local-args-collector.js +26 -0
  31. package/dist/commands/local-args-tower.js +21 -0
  32. package/dist/commands/local-args.js +3 -1
  33. package/dist/commands/local-help.js +19 -2
  34. package/dist/commands/local.js +3 -0
  35. package/dist/commands/memory-install-claude.js +294 -0
  36. package/dist/commands/memory-install-codex.js +205 -0
  37. package/dist/commands/memory-install-contract.js +286 -0
  38. package/dist/commands/memory-install-files.js +63 -0
  39. package/dist/commands/memory-install-skills.js +121 -0
  40. package/dist/commands/memory-install-toml.js +265 -0
  41. package/dist/commands/memory-install.js +465 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/sync-followups.js +105 -0
  44. package/dist/commands/sync.js +7 -1
  45. package/dist/local-state-attributed-target.js +75 -0
  46. package/dist/local-state-config.js +147 -0
  47. package/dist/local-state-files.js +59 -0
  48. package/dist/local-state-identity.js +73 -0
  49. package/dist/local-state-pairing.js +263 -0
  50. package/dist/local-state-paths.js +61 -0
  51. package/dist/local-state-session.js +68 -0
  52. package/dist/local-state-status.js +163 -0
  53. package/dist/local-state-work-context.js +190 -0
  54. package/dist/local-state.js +34 -848
  55. package/dist/tower-client.js +3 -2
  56. package/dist/tower-stream.js +57 -3
  57. package/package.json +2 -1
@@ -1,853 +1,39 @@
1
- import { getUserLocalCockpitPaths, LocalCollectorSessionFileSchema, LocalCollectorConfigSchema, LocalUserSessionReferenceSchema, LocalWorkContextSchema, } from "@bli-cockpit/telemetry-core";
2
- import crypto from "node:crypto";
3
- import { readFileSync } from "node:fs";
4
- import fs from "node:fs/promises";
5
- import os from "node:os";
6
- import path from "node:path";
7
- import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, resolveRepoWorktreeIdentity, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
8
- import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
9
- import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
10
- import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
11
- import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
12
- import { describeError, isMissingFileFailure } from "./health-detail.js";
13
- import { serverFailureDetail } from "./upload-http.js";
14
- const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
15
- export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
16
- ? localCollectorPackage.version
17
- : "0.0.0";
18
- export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
19
- export function getCollectorRuntimePaths(homeDir = os.homedir()) {
20
- const paths = getUserLocalCockpitPaths(homeDir);
21
- return {
22
- ...paths,
23
- active_work_context_file: path.join(paths.state_dir, "active-work-context.json"),
24
- work_contexts_dir: path.join(paths.state_dir, "work-contexts"),
25
- };
26
- }
27
- export async function installLocalCollector(options = {}) {
28
- const homeDir = options.homeDir ?? os.homedir();
29
- const repoRoots = normalizeRepoRoots(options.repoRoots);
30
- const repoRoot = path.resolve(options.repoRoot ?? repoRoots[0] ?? process.cwd());
31
- const paths = getCollectorRuntimePaths(homeDir);
32
- await ensureRuntimeDirectories(paths);
33
- const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
34
- const defaultRepoPaths = normalizeRepoRoots([
35
- ...(options.replaceRepoRoots ? [] : (existingConfig?.default_repo_paths ?? [])),
36
- ...(repoRoots.length > 0 ? repoRoots : [repoRoot]),
37
- ]);
38
- const rawEvidenceUpload = existingConfig?.raw_evidence_upload === "disabled" ||
39
- existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
40
- ? "remote_durable_opt_in"
41
- : (existingConfig?.raw_evidence_upload ?? "remote_durable_opt_in");
42
- const config = LocalCollectorConfigSchema.parse({
43
- schema_version: "telemetry-core.v1",
44
- dashboard_url: options.dashboardUrl ?? existingConfig?.dashboard_url ?? DEFAULT_DASHBOARD_URL,
45
- supabase_url: options.supabaseUrl ?? existingConfig?.supabase_url,
46
- collector_version: LOCAL_COLLECTOR_VERSION,
47
- device_id: existingConfig?.device_id ?? `device-${crypto.randomUUID()}`,
48
- device_name: normalizeDeviceName(options.deviceName) ??
49
- existingConfig?.device_name ??
50
- defaultDeviceName(),
51
- claimed_owner_email: existingConfig?.claimed_owner_email,
52
- operator_id: existingConfig?.operator_id,
53
- default_repo_paths: defaultRepoPaths,
54
- raw_evidence_upload: rawEvidenceUpload,
55
- session_file_path: paths.session_file,
56
- state_dir_path: paths.state_dir,
57
- });
58
- await writeJsonFile(paths.config_file, config);
59
- return {
60
- config,
61
- paths,
62
- auth_pairing_state: "missing",
63
- message: "Local collector installed. Pair/login is still required before remote upload.",
64
- };
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
1
  /**
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
- export async function pairLocalCollector(options = {}) {
117
- const homeDir = options.homeDir ?? os.homedir();
118
- const paths = getCollectorRuntimePaths(homeDir);
119
- await ensureRuntimeDirectories(paths);
120
- const config = await readLocalCollectorConfig(paths).catch((error) => {
121
- // "Local config missing" is correct when it is absent. A config that
122
- // exists and will not parse gets the same sentence and the same useless
123
- // advice — run onboard again, which will not fix it (BLI-3238).
124
- if (!isMissingFileFailure(error)) {
125
- console.error("[local-state] collector config present but unreadable, reporting it as missing", JSON.stringify({
126
- reason: "config_unreadable",
127
- ...describeError(error),
128
- }));
129
- }
130
- throw new Error("Local config missing. Run `cockpit onboard` (or `cockpit install`) first, then retry `cockpit login`.");
131
- });
132
- const dashboardUrl = normalizeDashboardUrl(options.dashboardUrl ?? config.dashboard_url);
133
- const deviceId = config.device_id ?? `device-${crypto.randomUUID()}`;
134
- const deviceName = normalizeDeviceName(options.deviceName) ??
135
- config.device_name ??
136
- defaultDeviceName();
137
- const claimedOwnerEmail = normalizeOptionalEmail(options.claimedOwnerEmail) ??
138
- config.claimed_owner_email;
139
- if (!config.device_id ||
140
- config.device_name !== deviceName ||
141
- (claimedOwnerEmail && config.claimed_owner_email !== claimedOwnerEmail)) {
142
- await writeJsonFile(paths.config_file, {
143
- ...config,
144
- device_id: deviceId,
145
- device_name: deviceName,
146
- claimed_owner_email: claimedOwnerEmail,
147
- });
148
- }
149
- const fetchImpl = options.fetch ?? globalThis.fetch;
150
- if (!fetchImpl) {
151
- throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
152
- }
153
- const startResponse = await postPairStart(fetchImpl, dashboardUrl, {
154
- device_id: deviceId,
155
- device_name: deviceName,
156
- claimed_owner_email: claimedOwnerEmail,
157
- collector_version: LOCAL_COLLECTOR_VERSION,
158
- }, options.pairingAccessToken);
159
- options.onPairStarted?.(startResponse);
160
- const sessionFile = await pollPairRequest(fetchImpl, dashboardUrl, {
161
- paths,
162
- startResponse,
163
- pollIntervalMs: options.pollIntervalMs,
164
- timeoutMs: options.timeoutMs,
165
- sleep: options.sleep,
166
- });
167
- return {
168
- status: "paired",
169
- session: toSessionReference(sessionFile),
170
- session_file: paths.session_file,
171
- dashboard_url: dashboardUrl,
172
- approve_url: startResponse.approve_url,
173
- };
174
- }
175
- export async function logoutLocalCollector(options = {}) {
176
- const homeDir = options.homeDir ?? os.homedir();
177
- const paths = getCollectorRuntimePaths(homeDir);
178
- let removed = false;
179
- try {
180
- await fs.unlink(paths.session_file);
181
- removed = true;
182
- }
183
- catch (error) {
184
- if (!isMissingFileError(error))
185
- throw error;
186
- }
187
- return { removed, session_file: paths.session_file };
188
- }
189
- export async function startLocalWorkContext(options = {}) {
190
- return writeLocalWorkContext(options);
191
- }
192
- /**
193
- * Starts a context for a transcript-attributed sync target that does not exist
194
- * in the current git inventory, such as an approved wrapper folder or a
195
- * deleted repo reconstructed from transcript provenance. This internal path
196
- * validates the exact target identity before writing any context state.
197
- */
198
- export async function startLocalWorkContextForAttributedTarget(options, attributedIdentity) {
199
- const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
200
- const identity = await validateAttributedTargetIdentity(repoRoot, attributedIdentity);
201
- return writeLocalWorkContext(options, identity);
202
- }
203
- async function writeLocalWorkContext(options, attributedIdentity) {
204
- if (options.activeTicketId && options.clearTicket) {
205
- throw new Error("--ticket and --clear-ticket cannot be combined.");
206
- }
207
- const now = options.now ?? new Date();
208
- const homeDir = options.homeDir ?? os.homedir();
209
- const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
210
- const paths = getCollectorRuntimePaths(homeDir);
211
- await ensureRuntimeDirectories(paths);
212
- const config = await readLocalCollectorConfig(paths).catch(() => null);
213
- const session = await readLocalSessionReference(paths, {
214
- operatorId: options.operatorId,
215
- sessionId: options.sessionId,
216
- });
217
- const identity = attributedIdentity ??
218
- (await resolveIdentityOrFallback(repoRoot, options.branch));
219
- const branch = attributedIdentity
220
- ? attributedIdentity.branch
221
- : (options.branch ?? identity.branch);
222
- const sessionId = options.sessionId ??
223
- (session.session_state === "missing" ? `local-${crypto.randomUUID()}` : session.session_id);
224
- const operatorId = options.operatorId ?? session.operator_id;
225
- const deviceId = session.device_id ?? config?.device_id ?? "device-unknown";
226
- const workContextId = stableWorkContextId({
227
- operatorId,
228
- deviceId,
229
- repoFingerprint: identity.repo_fingerprint,
230
- worktreeFingerprint: identity.worktree_fingerprint,
231
- });
232
- const existingContext = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
233
- const activeTicketId = options.clearTicket
234
- ? undefined
235
- : (options.activeTicketId ?? existingContext?.active_ticket_id);
236
- const ticketBindingCandidates = options.activeTicketId
237
- ? [
238
- {
239
- ticket_id: options.activeTicketId,
240
- binding_source: "active_work_context",
241
- confidence: 1,
242
- evidence_labels: ["cockpit_start_ticket"],
243
- },
244
- ]
245
- : options.clearTicket
246
- ? []
247
- : (existingContext?.ticket_binding_candidates ?? []);
248
- const context = LocalWorkContextSchema.parse({
249
- work_context_id: workContextId,
250
- repo: identity.repo_root,
251
- branch,
252
- repo_label: identity.repo_label,
253
- repo_fingerprint: identity.repo_fingerprint,
254
- repo_origin_url: identity.repo_origin_url ?? undefined,
255
- head_sha: identity.head_sha ?? undefined,
256
- worktree_label: identity.worktree_label,
257
- worktree_fingerprint: identity.worktree_fingerprint,
258
- worktree_is_primary: identity.worktree_is_primary,
259
- operator_id: operatorId,
260
- session_id: sessionId,
261
- started_at: existingContext?.started_at ?? now.toISOString(),
262
- updated_at: now.toISOString(),
263
- active_ticket_id: activeTicketId,
264
- ticket_binding_candidates: ticketBindingCandidates,
265
- topic_label: options.topicLabel,
266
- topic_summary_redacted: options.topicSummaryRedacted,
267
- work_intent: options.workIntent,
268
- work_phase: options.workPhase,
269
- intent_source: options.intentSource,
270
- intent_confidence: options.intentConfidence,
271
- pull_request_url: existingContext?.pull_request_url,
272
- provenance: {
273
- capture_source: "collector_runtime",
274
- capture_adapter_version: LOCAL_COLLECTOR_VERSION,
275
- collector_version: LOCAL_COLLECTOR_VERSION,
276
- repo: identity.repo_root,
277
- branch,
278
- repo_label: identity.repo_label,
279
- repo_fingerprint: identity.repo_fingerprint,
280
- repo_origin_url: identity.repo_origin_url ?? undefined,
281
- worktree_label: identity.worktree_label,
282
- worktree_fingerprint: identity.worktree_fingerprint,
283
- worktree_is_primary: identity.worktree_is_primary,
284
- operator_id: operatorId,
285
- session_id: sessionId,
286
- work_context_id: workContextId,
287
- },
288
- });
289
- await writeJsonFile(workContextFile(paths, identity.worktree_fingerprint), context);
290
- await writeJsonFile(paths.active_work_context_file, context);
291
- return context;
292
- }
293
- async function validateAttributedTargetIdentity(requestedRepoRoot, identity) {
294
- const canonicalRoot = await stableWorktreeRoot(requestedRepoRoot);
295
- const identityRoot = await stableWorktreeRoot(identity.repo_root);
296
- const identityRequestedPath = await stableWorktreeRoot(identity.requested_path);
297
- if (!isSamePath(canonicalRoot, identityRoot) ||
298
- !isSamePath(canonicalRoot, identityRequestedPath)) {
299
- throw new Error("Attributed target identity paths do not match the requested repo root.");
300
- }
301
- const expectedWorktreeFingerprint = stableWorktreeFingerprint(canonicalRoot);
302
- if (identity.worktree_fingerprint !== expectedWorktreeFingerprint) {
303
- throw new Error("Attributed target worktree fingerprint does not match its repo root.");
304
- }
305
- const expectedWorktreeLabel = path.basename(canonicalRoot) || "workspace";
306
- if (identity.worktree_label !== expectedWorktreeLabel) {
307
- throw new Error("Attributed target worktree label does not match its repo root.");
308
- }
309
- if (identity.repo_origin_url) {
310
- const normalizedOrigin = normalizeGitOrigin(identity.repo_origin_url);
311
- if (normalizedOrigin !== identity.repo_origin_url) {
312
- throw new Error("Attributed target repo origin is not normalized.");
313
- }
314
- if (identity.repo_fingerprint !== repoFingerprintFromOrigin(normalizedOrigin)) {
315
- throw new Error("Attributed target repo fingerprint does not match its origin.");
316
- }
317
- if (identity.repo_label !== repoLabelFromOrigin(normalizedOrigin)) {
318
- throw new Error("Attributed target repo label does not match its origin.");
319
- }
320
- if (identity.worktree_is_primary) {
321
- throw new Error("Origin-derived attributed targets cannot claim a primary live worktree.");
322
- }
323
- }
324
- else {
325
- if (identity.repo_fingerprint !==
326
- repoFingerprintFromLocalRoot(canonicalRoot)) {
327
- throw new Error("Attributed target repo fingerprint does not match its local root.");
328
- }
329
- if (identity.repo_label !== expectedWorktreeLabel) {
330
- throw new Error("Attributed target repo label does not match its local root.");
331
- }
332
- if (!identity.worktree_is_primary) {
333
- throw new Error("Local attributed targets must use their primary local identity.");
334
- }
335
- }
336
- return {
337
- ...identity,
338
- requested_path: canonicalRoot,
339
- repo_root: canonicalRoot,
340
- };
341
- }
342
- export async function inspectLocalCollectorStatus(options = {}) {
343
- const now = options.now ?? new Date();
344
- const homeDir = options.homeDir ?? os.homedir();
345
- const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
346
- const paths = getCollectorRuntimePaths(homeDir);
347
- const config = await readLocalCollectorConfig(paths).catch(() => null);
348
- const session = await readLocalSessionReference(paths, {
349
- operatorId: options.operatorId,
350
- sessionId: options.sessionId,
351
- });
352
- const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
353
- const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
354
- const branch = options.branch ?? identity.branch;
355
- const [uploadSpool, healthOutbox, stagingState] = await Promise.all([
356
- summarizeLocalUploadSpool(paths),
357
- summarizeInstallEventOutbox(paths),
358
- readRawEvidenceStagingState(paths.state_dir),
359
- ]);
360
- const stuckEvidence = summarizeStuckEvidence(stagingState, now);
361
- const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
362
- const uploadState = !config
363
- ? "not_installed"
364
- : uploadSpool.pending_upload_count > 0
365
- ? "retry_pending"
366
- : session.session_state === "valid"
367
- ? "ready"
368
- : "local_only_missing_auth";
369
- const details = [];
370
- details.push(config
371
- ? `Config exists at ${paths.config_file}.`
372
- : "Local config missing. Run `cockpit install`.");
373
- details.push(session.session_state === "valid"
374
- ? "Local user session is valid."
375
- : "Local user session missing or not paired; upload remains local-only.");
376
- details.push(context
377
- ? `Active work ${workDisplayLabel(context)} (${context.work_context_id}) last updated ${context.updated_at ?? context.started_at}.`
378
- : "Active work context missing. Run `cockpit start`.");
379
- details.push(uploadSpool.last_upload_attempt_at
380
- ? `Last upload attempt: ${uploadSpool.last_upload_attempt_at}.`
381
- : "No upload has been attempted yet.");
382
- details.push(uploadSpool.last_upload_success_at
383
- ? `Last upload success: ${uploadSpool.last_upload_success_at}.`
384
- : "No successful upload recorded yet.");
385
- if (uploadSpool.last_upload_failure_reason) {
386
- details.push(`Last upload failure: ${uploadSpool.last_upload_failure_reason}.`);
387
- }
388
- if (uploadSpool.pending_upload_count > 0) {
389
- details.push(`Upload retry pending: ${uploadSpool.pending_upload_count} safe metadata record(s) spooled. Run \`${uploadSpool.retry_command ?? "cockpit sync"}\` to retry.`);
390
- }
391
- if (healthOutbox.pending_count > 0) {
392
- details.push(`Collector health retry pending: ${healthOutbox.pending_count} sanitized receipt(s) queued since ${healthOutbox.oldest_created_at ?? "unknown"}.`);
393
- }
394
- if (stuckEvidence.stuck_object_count > 0) {
395
- details.push(`Raw evidence stuck: ${stuckEvidence.stuck_object_count} object(s) have never been accepted (${stuckEvidence.held_object_count} waiting on backoff, worst ${stuckEvidence.max_attempts} attempt(s) since ${stuckEvidence.oldest_first_failed_at ?? "unknown"}) — reasons: ${stuckEvidence.reasons.join(", ") || "unknown"}.`);
396
- }
397
- return {
398
- installed: Boolean(config),
399
- config_file: paths.config_file,
400
- session_file: paths.session_file,
401
- collector_version: LOCAL_COLLECTOR_VERSION,
402
- config_collector_version: config?.collector_version ?? null,
403
- session_state: session.session_state,
404
- repo: context?.repo ?? identity.repo_root,
405
- branch,
406
- active_ticket_id: context?.active_ticket_id ?? null,
407
- work_label: context ? workDisplayLabel(context) : null,
408
- work_id: context?.work_context_id ?? null,
409
- work_context_id: context?.work_context_id ?? null,
410
- repo_label: context?.repo_label ?? identity.repo_label,
411
- repo_fingerprint: context?.repo_fingerprint ?? identity.repo_fingerprint,
412
- worktree_label: context?.worktree_label ?? identity.worktree_label,
413
- worktree_fingerprint: context?.worktree_fingerprint ?? identity.worktree_fingerprint,
414
- worktree_is_primary: context?.worktree_is_primary ?? identity.worktree_is_primary,
415
- collector_freshness: freshness,
416
- upload_state: uploadState,
417
- last_upload_attempt_at: uploadSpool.last_upload_attempt_at,
418
- last_upload_success_at: uploadSpool.last_upload_success_at,
419
- last_upload_failure_reason: uploadSpool.last_upload_failure_reason,
420
- pending_upload_count: uploadSpool.pending_upload_count,
421
- upload_retry_command: uploadSpool.retry_command,
422
- pending_health_receipt_count: healthOutbox.pending_count,
423
- oldest_pending_health_receipt_at: healthOutbox.oldest_created_at,
424
- last_health_receipt_failure_reason: healthOutbox.last_failure_reason,
425
- stuck_evidence_object_count: stuckEvidence.stuck_object_count,
426
- stuck_evidence_held_count: stuckEvidence.held_object_count,
427
- stuck_evidence_max_attempts: stuckEvidence.max_attempts,
428
- stuck_evidence_oldest_failure_at: stuckEvidence.oldest_first_failed_at,
429
- stuck_evidence_reasons: stuckEvidence.reasons,
430
- details,
431
- };
432
- }
433
- function workDisplayLabel(context) {
434
- const repoLabel = (context.repo_label ?? path.basename(context.repo)) || "workspace";
435
- const worktreeLabel = context.worktree_label ?? repoLabel;
436
- return `${repoLabel}/${worktreeLabel}`;
437
- }
438
- export async function readLocalCollectorConfig(paths) {
439
- return LocalCollectorConfigSchema.parse(await readJsonFile(paths.config_file));
440
- }
441
- export async function readLocalWorkContext(paths) {
442
- return LocalWorkContextSchema.parse(await readJsonFile(paths.active_work_context_file));
443
- }
444
- export async function readLocalWorkContextForRepo(paths, repoRoot) {
445
- const identity = await resolveIdentityOrFallback(repoRoot);
446
- const context = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
447
- if (context)
448
- return context;
449
- const active = await readLocalWorkContext(paths);
450
- if (active.worktree_fingerprint === identity.worktree_fingerprint ||
451
- path.resolve(active.repo) === identity.repo_root) {
452
- return active;
453
- }
454
- throw new Error(`Active work context missing for ${identity.worktree_label}. Run \`cockpit start --workspace "${identity.repo_root}"\`.`);
455
- }
456
- async function readLocalWorkContextByFingerprint(paths, worktreeFingerprint) {
457
- return LocalWorkContextSchema.parse(await readJsonFile(workContextFile(paths, worktreeFingerprint)));
458
- }
459
- export async function readLocalSessionReference(paths, fallback = {}) {
460
- try {
461
- const rawSession = await readJsonFile(paths.session_file);
462
- const collectorSession = LocalCollectorSessionFileSchema.safeParse(rawSession);
463
- if (collectorSession.success) {
464
- return toSessionReference(collectorSession.data);
465
- }
466
- return LocalUserSessionReferenceSchema.parse(rawSession);
467
- }
468
- catch (error) {
469
- // `session_state: "missing"` is correct before login and says so loudly
470
- // enough on its own. It is also what a session file that EXISTS but is
471
- // corrupt, truncated or unreadable collapses to — a paired machine that
472
- // silently reads as never-signed-in, which is indistinguishable in every
473
- // downstream receipt (BLI-3238).
474
- if (!isMissingFileFailure(error)) {
475
- console.error("[local-state] session file present but unusable, reading as missing", JSON.stringify({
476
- reason: "session_file_unusable",
477
- ...describeError(error),
478
- }));
479
- }
480
- return LocalUserSessionReferenceSchema.parse({
481
- operator_id: fallback.operatorId ?? "unknown",
482
- auth_subject_id: "unknown",
483
- session_id: fallback.sessionId ?? "missing",
484
- session_file_path: paths.session_file,
485
- session_state: "missing",
486
- });
487
- }
488
- }
489
- export async function readLocalCollectorSessionFile(paths) {
490
- return LocalCollectorSessionFileSchema.parse(await readJsonFile(paths.session_file));
491
- }
492
- export async function resolveGitBranch(repoRoot) {
493
- try {
494
- const gitPath = path.join(repoRoot, ".git");
495
- const stat = await fs.stat(gitPath);
496
- const headPath = stat.isFile()
497
- ? path.join(await resolveWorktreeGitDir(gitPath), "HEAD")
498
- : path.join(gitPath, "HEAD");
499
- const head = (await fs.readFile(headPath, "utf8")).trim();
500
- if (head.startsWith("ref: refs/heads/")) {
501
- return head.slice("ref: refs/heads/".length);
502
- }
503
- return head ? `detached:${head.slice(0, 12)}` : "unknown";
504
- }
505
- catch (error) {
506
- // A folder that is not a git repo is a legitimate workspace under the
507
- // session-first commandment, so a missing `.git` stays quiet. A `.git`
508
- // that exists and cannot be read is a different thing: every session
509
- // collected from this repo gets branch `unknown` and nothing says why.
510
- if (!isMissingFileFailure(error)) {
511
- console.error("[local-state] could not read HEAD, branch recorded as unknown", JSON.stringify({
512
- reason: "git_head_unreadable",
513
- ...describeError(error),
514
- }));
515
- }
516
- return "unknown";
517
- }
518
- }
519
- async function resolveWorktreeGitDir(gitFile) {
520
- const raw = await fs.readFile(gitFile, "utf8");
521
- const match = raw.match(/^gitdir:\s*(.+)$/m);
522
- if (!match)
523
- return path.dirname(gitFile);
524
- const gitDir = match[1].trim();
525
- return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
526
- }
527
- async function ensureRuntimeDirectories(paths) {
528
- await fs.mkdir(paths.config_dir, { recursive: true, mode: 0o700 });
529
- await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
530
- await fs.mkdir(paths.spool_dir, { recursive: true, mode: 0o700 });
531
- await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
532
- await fs.mkdir(paths.work_contexts_dir, { recursive: true, mode: 0o700 });
533
- if (process.platform !== "win32") {
534
- // The `mode` above only applies to directories this call CREATES, so these
535
- // chmods are what actually tightens a directory that already existed with
536
- // looser bits. Failing means the operator's device token and cursors stay
537
- // world-readable — non-fatal, deliberately, but not something to find out
538
- // about never (BLI-3238). Reported by directory name only, never a path.
539
- const tightened = [
540
- ["config_dir", fs.chmod(paths.config_dir, 0o700)],
541
- ["state_dir", fs.chmod(paths.state_dir, 0o700)],
542
- ["spool_dir", fs.chmod(paths.spool_dir, 0o700)],
543
- ["cursors_dir", fs.chmod(paths.cursors_dir, 0o700)],
544
- ["work_contexts_dir", fs.chmod(paths.work_contexts_dir, 0o700)],
545
- ];
546
- await Promise.all(tightened.map(async ([name, work]) => {
547
- try {
548
- await work;
549
- }
550
- catch (error) {
551
- console.error("[local-state] could not restrict a runtime directory to owner-only", JSON.stringify({
552
- reason: "runtime_dir_chmod_failed",
553
- directory: name,
554
- ...describeError(error),
555
- }));
556
- }
557
- }));
558
- }
559
- }
560
- function workContextFile(paths, worktreeFingerprint) {
561
- return path.join(paths.work_contexts_dir, `${worktreeFingerprint}.json`);
562
- }
563
- async function resolveIdentityOrFallback(repoRoot, branchOverride) {
564
- const resolvedRoot = await stableWorktreeRoot(repoRoot);
565
- const identity = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
566
- if (identity) {
567
- return branchOverride ? { ...identity, branch: branchOverride } : identity;
568
- }
569
- const repoLabel = path.basename(resolvedRoot) || "workspace";
570
- const repoFingerprint = repoFingerprintFromLocalRoot(resolvedRoot);
571
- const worktreeFingerprint = stableWorktreeFingerprint(resolvedRoot);
572
- const branch = branchOverride ?? (await resolveGitBranch(resolvedRoot));
573
- return {
574
- requested_path: resolvedRoot,
575
- repo_root: resolvedRoot,
576
- repo_label: repoLabel,
577
- repo_fingerprint: repoFingerprint,
578
- repo_origin_url: null,
579
- branch,
580
- head_sha: null,
581
- worktree_label: repoLabel,
582
- worktree_fingerprint: worktreeFingerprint,
583
- worktree_is_primary: true,
584
- };
585
- }
586
- function stableWorkContextId(input) {
587
- return `work-${sha256([
588
- input.operatorId,
589
- input.deviceId,
590
- input.repoFingerprint,
591
- input.worktreeFingerprint,
592
- ].join(":")).slice(0, 32)}`;
593
- }
594
- function sha256(value) {
595
- return crypto.createHash("sha256").update(value, "utf8").digest("hex");
596
- }
597
- /**
598
- * The one reader behind the config file, the session file and every work
599
- * context — and therefore the one place worth reporting from.
2
+ * The collector's own state on a machine the durable on-disk contract behind
3
+ * `cockpit install`, `login`, `logout`, `start` and `status`.
600
4
  *
601
- * Roughly twenty call sites swallow this to `null` or a default with
602
- * `.catch(() => null)`, each of them asking a reasonable question ("is this
603
- * machine set up?") to which "no" is a legitimate answer. What none of them
604
- * could distinguish is "no, nothing is there" from "yes, and it is corrupt or
605
- * unreadable" — so the distinction is drawn HERE, once, rather than in twenty
606
- * places where it would be twenty chances to forget (BLI-3238).
607
- *
608
- * The error still propagates unchanged; callers keep whatever they decided.
609
- */
610
- async function readJsonFile(filePath) {
611
- try {
612
- return JSON.parse(await fs.readFile(filePath, "utf8"));
613
- }
614
- catch (error) {
615
- // Absent is the ordinary pre-onboarding state on every one of these files
616
- // and stays quiet; anything else means state exists and cannot be used.
617
- if (!isMissingFileFailure(error)) {
618
- console.error("[local-state] a collector state file exists but could not be read", JSON.stringify({
619
- reason: "state_file_unreadable",
620
- // Which file, without the path: the basename of these is a fixed
621
- // vocabulary (`config.json`, `session.json`, a work-context
622
- // fingerprint) and carries no repo or operator name.
623
- state_file: path.basename(filePath),
624
- ...describeError(error),
625
- }));
626
- }
627
- throw error;
628
- }
629
- }
630
- async function writeJsonFile(filePath, value) {
631
- await fs.mkdir(path.dirname(filePath), { recursive: true });
632
- await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
633
- mode: 0o600,
634
- });
635
- if (process.platform !== "win32") {
636
- await fs.chmod(filePath, 0o600).catch(() => undefined);
637
- }
638
- }
639
- export function classifyCollectorFreshness(context, lastUploadSuccessAt, now) {
640
- if (!context)
641
- return "missing";
642
- const latestActivityAt = latestTimestamp([
643
- context.updated_at ?? context.started_at,
644
- lastUploadSuccessAt,
645
- ]);
646
- if (latestActivityAt === null)
647
- return "stale";
648
- return now.getTime() - latestActivityAt <= 5 * 60 * 1000 ? "fresh" : "stale";
649
- }
650
- function latestTimestamp(values) {
651
- const timestamps = values
652
- .map((value) => Date.parse(value ?? ""))
653
- .filter((value) => Number.isFinite(value));
654
- if (timestamps.length === 0)
655
- return null;
656
- return Math.max(...timestamps);
657
- }
658
- function normalizeDashboardUrl(value) {
659
- const normalized = value.trim().replace(/\/+$/, "");
660
- if (!normalized)
661
- throw new Error("Dashboard URL cannot be empty.");
662
- return normalized;
663
- }
664
- async function postPairStart(fetchImpl, dashboardUrl, body, accessToken) {
665
- const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/start`, {
666
- method: "POST",
667
- headers: {
668
- "Content-Type": "application/json",
669
- ...(accessToken ? { "Authorization": `Bearer ${accessToken}` } : {}),
670
- },
671
- body: JSON.stringify(body),
672
- });
673
- const parsed = await readResponseJson(response);
674
- if (!response.ok) {
675
- throw new Error(pairFailureMessage("Pair request failed", response, parsed));
676
- }
677
- return parsePairStartResponse(parsed);
678
- }
679
- async function pollPairRequest(fetchImpl, dashboardUrl, options) {
680
- const pollIntervalMs = options.pollIntervalMs ?? options.startResponse.poll_after_ms;
681
- const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
682
- const sleepImpl = options.sleep ??
683
- ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
684
- const deadline = Date.now() + timeoutMs;
685
- while (Date.now() <= deadline) {
686
- const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/poll`, {
687
- method: "POST",
688
- headers: { "Content-Type": "application/json" },
689
- body: JSON.stringify({
690
- request_id: options.startResponse.request_id,
691
- client_secret: options.startResponse.client_secret,
692
- }),
693
- });
694
- const parsed = await readResponseJson(response);
695
- if (!response.ok) {
696
- throw new Error(pairFailureMessage("Pair polling failed", response, parsed));
697
- }
698
- const status = readStringField(parsed, "status");
699
- if (status === "approved") {
700
- const session = parsePairApprovedSession(parsed, options.paths.session_file);
701
- await writeJsonFile(options.paths.session_file, session);
702
- return session;
703
- }
704
- if (status === "expired") {
705
- throw new Error("Pair request expired. Run `cockpit login` again.");
706
- }
707
- if (status === "revoked") {
708
- throw new Error("Pair request was revoked. Run `cockpit login` again.");
709
- }
710
- if (status !== "pending") {
711
- throw new Error(`Unexpected pair request status: ${status}`);
712
- }
713
- await sleepImpl(Math.max(250, pollIntervalMs));
714
- }
715
- throw new Error("Timed out waiting for dashboard approval. Run `cockpit login` again.");
716
- }
717
- /**
718
- * What `cockpit login` tells the operator when pairing is refused.
5
+ * This file is the table of contents and holds no logic. Each state family
6
+ * lives in a named sibling, and every public name below is still importable
7
+ * from `./local-state.js`, which is what ~90 modules and suites depend on:
719
8
  *
720
- * "Pair request failed" was the whole message — five words, while the status
721
- * code sat in hand (BLI-3483). A 401 (this build's token is not accepted), a
722
- * 403 (the dashboard knows the device and is refusing it), a 404 (wrong
723
- * dashboard URL) and a 502 (something in front of the dashboard answered) are
724
- * four different next actions, and the operator could not tell them apart.
725
- * `upload.ts` has named its HTTP status since it was written; this is the same
726
- * shape. The server's own words come first when it supplied any, and the status
727
- * always rides at the end so it is never the thing that got dropped.
9
+ * - `local-state-paths.ts` where every collector file lives, the owner-only
10
+ * directory setup, and the per-worktree context filename.
11
+ * - `local-state-files.ts` the ONE reader and ONE writer behind every state
12
+ * file, and therefore the one place a corrupt file is reported from.
13
+ * - `local-state-config.ts` `config.json`: install, ensure, read, the values
14
+ * they default (collector version, dashboard URL, device name), and the one
15
+ * on-disk migration of a retired raw-evidence choice.
16
+ * - `local-state-session.ts` `session.json` read as a reference, with expiry
17
+ * collapsed into the session state.
18
+ * - `local-state-pairing.ts` — `cockpit login` and `cockpit logout`: the two
19
+ * operations that make and unmake that session file, plus the pair
20
+ * start/poll HTTP client and its refusal message.
21
+ * - `local-state-identity.ts` — which repo and worktree a folder is, with the
22
+ * path-derived fallback that keeps a non-git folder collectable.
23
+ * - `local-state-attributed-target.ts` — re-derives a caller-supplied identity
24
+ * for a wrapper folder or a deleted repo before any context state is written.
25
+ * - `local-state-work-context.ts` — the active work context and the
26
+ * per-worktree ones: what `cockpit start` bound, and the stable work id.
27
+ * - `local-state-status.ts` — `cockpit status`: the reading, the sentences an
28
+ * operator reads, and the freshness classifier.
728
29
  *
729
- * Logged as well as thrown: `cockpit login` failures happen on a machine that
730
- * is not collecting yet, so the terminal is the only receipt there is.
30
+ * A new sibling here must also join `scripts/build-public-cli.mjs`
31
+ * `runtimeFiles`, or the repo tests stay green while the packed CLI breaks.
731
32
  */
732
- function pairFailureMessage(fallback, response, body) {
733
- const serverWords = responseErrorMessage(body, fallback);
734
- console.error("[local-state] pairing request refused", JSON.stringify({
735
- reason: fallback === "Pair request failed" ? "pair_start_refused" : "pair_poll_refused",
736
- http_status: response.status,
737
- server_reason: serverFailureDetail(body) ?? "none",
738
- }));
739
- return `${serverWords} (HTTP ${response.status})`;
740
- }
741
- async function readResponseJson(response) {
742
- const text = await response.text();
743
- if (!text)
744
- return {};
745
- try {
746
- return JSON.parse(text);
747
- }
748
- catch {
749
- // Pairing path. A captive portal or a proxy answering with HTML is the
750
- // classic reason `cockpit login` fails on a new machine and the operator
751
- // sees only "pair request failed". The body is never logged; its shape is.
752
- console.error("[local-state] pairing reply was not JSON", JSON.stringify({
753
- reason: "response_body_not_json",
754
- http_status: response.status,
755
- byte_size: text.length,
756
- content_type: response.headers.get("content-type") ?? "none",
757
- }));
758
- return { message: text };
759
- }
760
- }
761
- function parsePairStartResponse(value) {
762
- if (!value || typeof value !== "object") {
763
- throw new Error("Pair request response was not an object.");
764
- }
765
- const record = value;
766
- return {
767
- request_id: requiredString(record, "request_id"),
768
- user_code: requiredString(record, "user_code"),
769
- approve_url: requiredString(record, "approve_url"),
770
- expires_at: requiredString(record, "expires_at"),
771
- poll_after_ms: requiredNumber(record, "poll_after_ms"),
772
- client_secret: requiredString(record, "client_secret"),
773
- };
774
- }
775
- function parsePairApprovedSession(value, sessionFilePath) {
776
- if (!value || typeof value !== "object") {
777
- throw new Error("Pair approval response was not an object.");
778
- }
779
- const session = value["session"];
780
- if (!session || typeof session !== "object") {
781
- throw new Error("Pair approval response did not include a session.");
782
- }
783
- return LocalCollectorSessionFileSchema.parse({
784
- ...session,
785
- session_file_path: sessionFilePath,
786
- });
787
- }
788
- function toSessionReference(session) {
789
- const now = Date.now();
790
- const expiresAt = Date.parse(session.expires_at ?? "");
791
- const sessionState = Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : session.session_state;
792
- return LocalUserSessionReferenceSchema.parse({
793
- operator_id: session.operator_id,
794
- auth_subject_id: session.auth_subject_id,
795
- email: session.email,
796
- team_id: session.team_id,
797
- device_id: session.device_id,
798
- device_name: session.device_name,
799
- session_id: session.session_id,
800
- session_file_path: session.session_file_path,
801
- session_state: sessionState,
802
- auth_method: session.auth_method,
803
- issued_at: session.issued_at,
804
- expires_at: session.expires_at,
805
- });
806
- }
807
- function defaultDeviceName() {
808
- return normalizeDeviceName(os.hostname()) ?? "Local machine";
809
- }
810
- function normalizeDeviceName(value) {
811
- const trimmed = value?.trim().replace(/\s+/g, " ");
812
- return trimmed ? trimmed.slice(0, 120) : undefined;
813
- }
814
- function normalizeOptionalEmail(value) {
815
- const trimmed = value?.trim().toLowerCase();
816
- return trimmed && trimmed.includes("@") ? trimmed : undefined;
817
- }
818
- function responseErrorMessage(value, fallback) {
819
- if (value && typeof value === "object") {
820
- const record = value;
821
- const message = record["message"] ?? record["error"];
822
- if (typeof message === "string" && message.trim())
823
- return message;
824
- }
825
- return fallback;
826
- }
827
- function readStringField(value, field) {
828
- if (value && typeof value === "object") {
829
- const entry = value[field];
830
- if (typeof entry === "string")
831
- return entry;
832
- }
833
- throw new Error(`Pair response missing ${field}.`);
834
- }
835
- function requiredString(record, field) {
836
- const value = record[field];
837
- if (typeof value !== "string" || !value.trim()) {
838
- throw new Error(`Pair response missing ${field}.`);
839
- }
840
- return value;
841
- }
842
- function requiredNumber(record, field) {
843
- const value = record[field];
844
- if (typeof value !== "number" || !Number.isFinite(value)) {
845
- throw new Error(`Pair response missing ${field}.`);
846
- }
847
- return value;
848
- }
849
- function isMissingFileError(error) {
850
- return (error instanceof Error &&
851
- "code" in error &&
852
- error.code === "ENOENT");
853
- }
33
+ export { getCollectorRuntimePaths } from "./local-state-paths.js";
34
+ export { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, installLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, } from "./local-state-config.js";
35
+ export { readLocalCollectorSessionFile, readLocalSessionReference, } from "./local-state-session.js";
36
+ export { logoutLocalCollector, pairLocalCollector, } from "./local-state-pairing.js";
37
+ export { resolveGitBranch } from "./local-state-identity.js";
38
+ export { readLocalWorkContext, readLocalWorkContextForRepo, startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from "./local-state-work-context.js";
39
+ export { classifyCollectorFreshness, inspectLocalCollectorStatus, } from "./local-state-status.js";