@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,73 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { repoFingerprintFromLocalRoot, resolveRepoWorktreeIdentity, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
4
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
5
+ /**
6
+ * A folder that is not a git repo is a legitimate workspace under the
7
+ * session-first commandment, so this always answers with an identity: git's
8
+ * own when there is one, a path-derived one otherwise. Nothing may be withheld
9
+ * for lack of a repo.
10
+ */
11
+ export async function resolveIdentityOrFallback(repoRoot, branchOverride) {
12
+ const resolvedRoot = await stableWorktreeRoot(repoRoot);
13
+ const identity = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
14
+ if (identity) {
15
+ return branchOverride ? { ...identity, branch: branchOverride } : identity;
16
+ }
17
+ const repoLabel = path.basename(resolvedRoot) || "workspace";
18
+ const repoFingerprint = repoFingerprintFromLocalRoot(resolvedRoot);
19
+ const worktreeFingerprint = stableWorktreeFingerprint(resolvedRoot);
20
+ const branch = branchOverride ?? (await resolveGitBranch(resolvedRoot));
21
+ return {
22
+ requested_path: resolvedRoot,
23
+ repo_root: resolvedRoot,
24
+ repo_label: repoLabel,
25
+ repo_fingerprint: repoFingerprint,
26
+ repo_origin_url: null,
27
+ branch,
28
+ head_sha: null,
29
+ worktree_label: repoLabel,
30
+ worktree_fingerprint: worktreeFingerprint,
31
+ worktree_is_primary: true,
32
+ };
33
+ }
34
+ /**
35
+ * Reads HEAD directly rather than shelling out to git, because this runs on
36
+ * every sync tick on machines where a spawned process is the expensive part.
37
+ */
38
+ export async function resolveGitBranch(repoRoot) {
39
+ try {
40
+ const gitPath = path.join(repoRoot, ".git");
41
+ const stat = await fs.stat(gitPath);
42
+ const headPath = stat.isFile()
43
+ ? path.join(await resolveWorktreeGitDir(gitPath), "HEAD")
44
+ : path.join(gitPath, "HEAD");
45
+ const head = (await fs.readFile(headPath, "utf8")).trim();
46
+ if (head.startsWith("ref: refs/heads/")) {
47
+ return head.slice("ref: refs/heads/".length);
48
+ }
49
+ return head ? `detached:${head.slice(0, 12)}` : "unknown";
50
+ }
51
+ catch (error) {
52
+ // A folder that is not a git repo is a legitimate workspace under the
53
+ // session-first commandment, so a missing `.git` stays quiet. A `.git`
54
+ // that exists and cannot be read is a different thing: every session
55
+ // collected from this repo gets branch `unknown` and nothing says why.
56
+ if (!isMissingFileFailure(error)) {
57
+ console.error("[local-state] could not read HEAD, branch recorded as unknown", JSON.stringify({
58
+ reason: "git_head_unreadable",
59
+ ...describeError(error),
60
+ }));
61
+ }
62
+ return "unknown";
63
+ }
64
+ }
65
+ /** A linked worktree's `.git` is a file pointing elsewhere, so HEAD is not where it looks. */
66
+ async function resolveWorktreeGitDir(gitFile) {
67
+ const raw = await fs.readFile(gitFile, "utf8");
68
+ const match = raw.match(/^gitdir:\s*(.+)$/m);
69
+ if (!match)
70
+ return path.dirname(gitFile);
71
+ const gitDir = match[1].trim();
72
+ return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
73
+ }
@@ -0,0 +1,263 @@
1
+ import { LocalCollectorSessionFileSchema, } from "@bli-cockpit/telemetry-core";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
6
+ import { serverFailureDetail } from "./upload-http.js";
7
+ import { ensureRuntimeDirectories, getCollectorRuntimePaths, } from "./local-state-paths.js";
8
+ import { isMissingFileError, writeJsonFile } from "./local-state-files.js";
9
+ import { defaultDeviceName, LOCAL_COLLECTOR_VERSION, normalizeDashboardUrl, normalizeDeviceName, normalizeOptionalEmail, readLocalCollectorConfig, } from "./local-state-config.js";
10
+ import { toSessionReference } from "./local-state-session.js";
11
+ /**
12
+ * `cockpit login` — the whole device-pairing handshake, because a machine can
13
+ * collect locally without it but can never upload until the dashboard has
14
+ * approved this device.
15
+ */
16
+ export async function pairLocalCollector(options = {}) {
17
+ const homeDir = options.homeDir ?? os.homedir();
18
+ const paths = getCollectorRuntimePaths(homeDir);
19
+ await ensureRuntimeDirectories(paths);
20
+ const config = await readConfigOrRefusePairing(paths);
21
+ const dashboardUrl = normalizeDashboardUrl(options.dashboardUrl ?? config.dashboard_url);
22
+ const device = resolvePairingDeviceIdentity(config, options);
23
+ await recordDeviceIdentityWhenChanged(paths, config, device);
24
+ const fetchImpl = options.fetch ?? globalThis.fetch;
25
+ if (!fetchImpl) {
26
+ throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
27
+ }
28
+ const startResponse = await postPairStart(fetchImpl, dashboardUrl, {
29
+ device_id: device.deviceId,
30
+ device_name: device.deviceName,
31
+ claimed_owner_email: device.claimedOwnerEmail,
32
+ collector_version: LOCAL_COLLECTOR_VERSION,
33
+ }, options.pairingAccessToken);
34
+ options.onPairStarted?.(startResponse);
35
+ const sessionFile = await pollPairRequest(fetchImpl, dashboardUrl, {
36
+ paths,
37
+ startResponse,
38
+ pollIntervalMs: options.pollIntervalMs,
39
+ timeoutMs: options.timeoutMs,
40
+ sleep: options.sleep,
41
+ });
42
+ return {
43
+ status: "paired",
44
+ session: toSessionReference(sessionFile),
45
+ session_file: paths.session_file,
46
+ dashboard_url: dashboardUrl,
47
+ approve_url: startResponse.approve_url,
48
+ };
49
+ }
50
+ async function readConfigOrRefusePairing(paths) {
51
+ return readLocalCollectorConfig(paths).catch((error) => {
52
+ // "Local config missing" is correct when it is absent. A config that
53
+ // exists and will not parse gets the same sentence and the same useless
54
+ // advice — run onboard again, which will not fix it (BLI-3238).
55
+ if (!isMissingFileFailure(error)) {
56
+ console.error("[local-state] collector config present but unreadable, reporting it as missing", JSON.stringify({
57
+ reason: "config_unreadable",
58
+ ...describeError(error),
59
+ }));
60
+ }
61
+ throw new Error("Local config missing. Run `cockpit onboard` (or `cockpit install`) first, then retry `cockpit login`.");
62
+ });
63
+ }
64
+ function resolvePairingDeviceIdentity(config, options) {
65
+ return {
66
+ deviceId: config.device_id ?? `device-${crypto.randomUUID()}`,
67
+ deviceName: normalizeDeviceName(options.deviceName) ??
68
+ config.device_name ??
69
+ defaultDeviceName(),
70
+ claimedOwnerEmail: normalizeOptionalEmail(options.claimedOwnerEmail) ??
71
+ config.claimed_owner_email,
72
+ };
73
+ }
74
+ /**
75
+ * The dashboard will approve the identity this request claims, so the config
76
+ * has to be holding that same identity before the request goes out.
77
+ */
78
+ async function recordDeviceIdentityWhenChanged(paths, config, device) {
79
+ if (!config.device_id ||
80
+ config.device_name !== device.deviceName ||
81
+ (device.claimedOwnerEmail &&
82
+ config.claimed_owner_email !== device.claimedOwnerEmail)) {
83
+ await writeJsonFile(paths.config_file, {
84
+ ...config,
85
+ device_id: device.deviceId,
86
+ device_name: device.deviceName,
87
+ claimed_owner_email: device.claimedOwnerEmail,
88
+ });
89
+ }
90
+ }
91
+ /**
92
+ * `cockpit logout` — removing the session file is the whole revocation on this
93
+ * side, and an absent file is success, not a failure to report.
94
+ */
95
+ export async function logoutLocalCollector(options = {}) {
96
+ const homeDir = options.homeDir ?? os.homedir();
97
+ const paths = getCollectorRuntimePaths(homeDir);
98
+ let removed = false;
99
+ try {
100
+ await fs.unlink(paths.session_file);
101
+ removed = true;
102
+ }
103
+ catch (error) {
104
+ if (!isMissingFileError(error))
105
+ throw error;
106
+ }
107
+ return { removed, session_file: paths.session_file };
108
+ }
109
+ async function postPairStart(fetchImpl, dashboardUrl, body, accessToken) {
110
+ const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/start`, {
111
+ method: "POST",
112
+ headers: {
113
+ "Content-Type": "application/json",
114
+ ...(accessToken ? { "Authorization": `Bearer ${accessToken}` } : {}),
115
+ },
116
+ body: JSON.stringify(body),
117
+ });
118
+ const parsed = await readResponseJson(response);
119
+ if (!response.ok) {
120
+ throw new Error(pairFailureMessage("Pair request failed", response, parsed));
121
+ }
122
+ return parsePairStartResponse(parsed);
123
+ }
124
+ async function pollPairRequest(fetchImpl, dashboardUrl, options) {
125
+ const pollIntervalMs = options.pollIntervalMs ?? options.startResponse.poll_after_ms;
126
+ const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
127
+ const sleepImpl = options.sleep ??
128
+ ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
129
+ const deadline = Date.now() + timeoutMs;
130
+ while (Date.now() <= deadline) {
131
+ const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/poll`, {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/json" },
134
+ body: JSON.stringify({
135
+ request_id: options.startResponse.request_id,
136
+ client_secret: options.startResponse.client_secret,
137
+ }),
138
+ });
139
+ const parsed = await readResponseJson(response);
140
+ if (!response.ok) {
141
+ throw new Error(pairFailureMessage("Pair polling failed", response, parsed));
142
+ }
143
+ const status = readStringField(parsed, "status");
144
+ if (status === "approved") {
145
+ const session = parsePairApprovedSession(parsed, options.paths.session_file);
146
+ await writeJsonFile(options.paths.session_file, session);
147
+ return session;
148
+ }
149
+ if (status === "expired") {
150
+ throw new Error("Pair request expired. Run `cockpit login` again.");
151
+ }
152
+ if (status === "revoked") {
153
+ throw new Error("Pair request was revoked. Run `cockpit login` again.");
154
+ }
155
+ if (status !== "pending") {
156
+ throw new Error(`Unexpected pair request status: ${status}`);
157
+ }
158
+ await sleepImpl(Math.max(250, pollIntervalMs));
159
+ }
160
+ throw new Error("Timed out waiting for dashboard approval. Run `cockpit login` again.");
161
+ }
162
+ /**
163
+ * What `cockpit login` tells the operator when pairing is refused.
164
+ *
165
+ * "Pair request failed" was the whole message — five words, while the status
166
+ * code sat in hand (BLI-3483). A 401 (this build's token is not accepted), a
167
+ * 403 (the dashboard knows the device and is refusing it), a 404 (wrong
168
+ * dashboard URL) and a 502 (something in front of the dashboard answered) are
169
+ * four different next actions, and the operator could not tell them apart.
170
+ * `upload.ts` has named its HTTP status since it was written; this is the same
171
+ * shape. The server's own words come first when it supplied any, and the status
172
+ * always rides at the end so it is never the thing that got dropped.
173
+ *
174
+ * Logged as well as thrown: `cockpit login` failures happen on a machine that
175
+ * is not collecting yet, so the terminal is the only receipt there is.
176
+ */
177
+ function pairFailureMessage(fallback, response, body) {
178
+ const serverWords = responseErrorMessage(body, fallback);
179
+ console.error("[local-state] pairing request refused", JSON.stringify({
180
+ reason: fallback === "Pair request failed" ? "pair_start_refused" : "pair_poll_refused",
181
+ http_status: response.status,
182
+ server_reason: serverFailureDetail(body) ?? "none",
183
+ }));
184
+ return `${serverWords} (HTTP ${response.status})`;
185
+ }
186
+ async function readResponseJson(response) {
187
+ const text = await response.text();
188
+ if (!text)
189
+ return {};
190
+ try {
191
+ return JSON.parse(text);
192
+ }
193
+ catch {
194
+ // Pairing path. A captive portal or a proxy answering with HTML is the
195
+ // classic reason `cockpit login` fails on a new machine and the operator
196
+ // sees only "pair request failed". The body is never logged; its shape is.
197
+ console.error("[local-state] pairing reply was not JSON", JSON.stringify({
198
+ reason: "response_body_not_json",
199
+ http_status: response.status,
200
+ byte_size: text.length,
201
+ content_type: response.headers.get("content-type") ?? "none",
202
+ }));
203
+ return { message: text };
204
+ }
205
+ }
206
+ function parsePairStartResponse(value) {
207
+ if (!value || typeof value !== "object") {
208
+ throw new Error("Pair request response was not an object.");
209
+ }
210
+ const record = value;
211
+ return {
212
+ request_id: requiredString(record, "request_id"),
213
+ user_code: requiredString(record, "user_code"),
214
+ approve_url: requiredString(record, "approve_url"),
215
+ expires_at: requiredString(record, "expires_at"),
216
+ poll_after_ms: requiredNumber(record, "poll_after_ms"),
217
+ client_secret: requiredString(record, "client_secret"),
218
+ };
219
+ }
220
+ function parsePairApprovedSession(value, sessionFilePath) {
221
+ if (!value || typeof value !== "object") {
222
+ throw new Error("Pair approval response was not an object.");
223
+ }
224
+ const session = value["session"];
225
+ if (!session || typeof session !== "object") {
226
+ throw new Error("Pair approval response did not include a session.");
227
+ }
228
+ return LocalCollectorSessionFileSchema.parse({
229
+ ...session,
230
+ session_file_path: sessionFilePath,
231
+ });
232
+ }
233
+ function responseErrorMessage(value, fallback) {
234
+ if (value && typeof value === "object") {
235
+ const record = value;
236
+ const message = record["message"] ?? record["error"];
237
+ if (typeof message === "string" && message.trim())
238
+ return message;
239
+ }
240
+ return fallback;
241
+ }
242
+ function readStringField(value, field) {
243
+ if (value && typeof value === "object") {
244
+ const entry = value[field];
245
+ if (typeof entry === "string")
246
+ return entry;
247
+ }
248
+ throw new Error(`Pair response missing ${field}.`);
249
+ }
250
+ function requiredString(record, field) {
251
+ const value = record[field];
252
+ if (typeof value !== "string" || !value.trim()) {
253
+ throw new Error(`Pair response missing ${field}.`);
254
+ }
255
+ return value;
256
+ }
257
+ function requiredNumber(record, field) {
258
+ const value = record[field];
259
+ if (typeof value !== "number" || !Number.isFinite(value)) {
260
+ throw new Error(`Pair response missing ${field}.`);
261
+ }
262
+ return value;
263
+ }
@@ -0,0 +1,61 @@
1
+ import { getUserLocalCockpitPaths } from "@bli-cockpit/telemetry-core";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { describeError } from "./health-detail.js";
6
+ /**
7
+ * The one place a collector file's address is computed, so no caller joins a
8
+ * state path by hand and drifts from the durable on-disk layout.
9
+ */
10
+ export function getCollectorRuntimePaths(homeDir = os.homedir()) {
11
+ const paths = getUserLocalCockpitPaths(homeDir);
12
+ return {
13
+ ...paths,
14
+ active_work_context_file: path.join(paths.state_dir, "active-work-context.json"),
15
+ work_contexts_dir: path.join(paths.state_dir, "work-contexts"),
16
+ };
17
+ }
18
+ /**
19
+ * Every write below assumes its directory exists and is owner-only, so this
20
+ * runs before the first one on a fresh machine and after every upgrade.
21
+ */
22
+ export async function ensureRuntimeDirectories(paths) {
23
+ await fs.mkdir(paths.config_dir, { recursive: true, mode: 0o700 });
24
+ await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
25
+ await fs.mkdir(paths.spool_dir, { recursive: true, mode: 0o700 });
26
+ await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
27
+ await fs.mkdir(paths.work_contexts_dir, { recursive: true, mode: 0o700 });
28
+ if (process.platform !== "win32") {
29
+ // The `mode` above only applies to directories this call CREATES, so these
30
+ // chmods are what actually tightens a directory that already existed with
31
+ // looser bits. Failing means the operator's device token and cursors stay
32
+ // world-readable — non-fatal, deliberately, but not something to find out
33
+ // about never (BLI-3238). Reported by directory name only, never a path.
34
+ const tightened = [
35
+ ["config_dir", fs.chmod(paths.config_dir, 0o700)],
36
+ ["state_dir", fs.chmod(paths.state_dir, 0o700)],
37
+ ["spool_dir", fs.chmod(paths.spool_dir, 0o700)],
38
+ ["cursors_dir", fs.chmod(paths.cursors_dir, 0o700)],
39
+ ["work_contexts_dir", fs.chmod(paths.work_contexts_dir, 0o700)],
40
+ ];
41
+ await Promise.all(tightened.map(async ([name, work]) => {
42
+ try {
43
+ await work;
44
+ }
45
+ catch (error) {
46
+ console.error("[local-state] could not restrict a runtime directory to owner-only", JSON.stringify({
47
+ reason: "runtime_dir_chmod_failed",
48
+ directory: name,
49
+ ...describeError(error),
50
+ }));
51
+ }
52
+ }));
53
+ }
54
+ }
55
+ /**
56
+ * One file per worktree fingerprint, so two checkouts of the same repo keep
57
+ * separate contexts instead of overwriting each other.
58
+ */
59
+ export function workContextFile(paths, worktreeFingerprint) {
60
+ return path.join(paths.work_contexts_dir, `${worktreeFingerprint}.json`);
61
+ }
@@ -0,0 +1,68 @@
1
+ import { LocalCollectorSessionFileSchema, LocalUserSessionReferenceSchema, } from "@bli-cockpit/telemetry-core";
2
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
3
+ import { readJsonFile } from "./local-state-files.js";
4
+ /**
5
+ * Who this machine is signed in as, in the shape every caller wants: a
6
+ * reference that always exists, so nothing downstream has to handle "there is
7
+ * no session file yet" as an error.
8
+ */
9
+ export async function readLocalSessionReference(paths, fallback = {}) {
10
+ try {
11
+ const rawSession = await readJsonFile(paths.session_file);
12
+ const collectorSession = LocalCollectorSessionFileSchema.safeParse(rawSession);
13
+ if (collectorSession.success) {
14
+ return toSessionReference(collectorSession.data);
15
+ }
16
+ return LocalUserSessionReferenceSchema.parse(rawSession);
17
+ }
18
+ catch (error) {
19
+ // `session_state: "missing"` is correct before login and says so loudly
20
+ // enough on its own. It is also what a session file that EXISTS but is
21
+ // corrupt, truncated or unreadable collapses to — a paired machine that
22
+ // silently reads as never-signed-in, which is indistinguishable in every
23
+ // downstream receipt (BLI-3238).
24
+ if (!isMissingFileFailure(error)) {
25
+ console.error("[local-state] session file present but unusable, reading as missing", JSON.stringify({
26
+ reason: "session_file_unusable",
27
+ ...describeError(error),
28
+ }));
29
+ }
30
+ return LocalUserSessionReferenceSchema.parse({
31
+ operator_id: fallback.operatorId ?? "unknown",
32
+ auth_subject_id: "unknown",
33
+ session_id: fallback.sessionId ?? "missing",
34
+ session_file_path: paths.session_file,
35
+ session_state: "missing",
36
+ });
37
+ }
38
+ }
39
+ /**
40
+ * The strict read, for the callers that need the device token itself and
41
+ * cannot proceed on a "missing" reference.
42
+ */
43
+ export async function readLocalCollectorSessionFile(paths) {
44
+ return LocalCollectorSessionFileSchema.parse(await readJsonFile(paths.session_file));
45
+ }
46
+ /**
47
+ * Drops the device token and re-states expiry as a session state, so an
48
+ * expired session cannot travel as `valid` just because the file says so.
49
+ */
50
+ export function toSessionReference(session) {
51
+ const now = Date.now();
52
+ const expiresAt = Date.parse(session.expires_at ?? "");
53
+ const sessionState = Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : session.session_state;
54
+ return LocalUserSessionReferenceSchema.parse({
55
+ operator_id: session.operator_id,
56
+ auth_subject_id: session.auth_subject_id,
57
+ email: session.email,
58
+ team_id: session.team_id,
59
+ device_id: session.device_id,
60
+ device_name: session.device_name,
61
+ session_id: session.session_id,
62
+ session_file_path: session.session_file_path,
63
+ session_state: sessionState,
64
+ auth_method: session.auth_method,
65
+ issued_at: session.issued_at,
66
+ expires_at: session.expires_at,
67
+ });
68
+ }
@@ -0,0 +1,163 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
4
+ import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
5
+ import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
6
+ import { getCollectorRuntimePaths, } from "./local-state-paths.js";
7
+ import { LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, } from "./local-state-config.js";
8
+ import { readLocalSessionReference } from "./local-state-session.js";
9
+ import { resolveIdentityOrFallback } from "./local-state-identity.js";
10
+ import { readLocalWorkContextForRepo, workDisplayLabel, } from "./local-state-work-context.js";
11
+ /**
12
+ * `cockpit status` — the one answer to "is this machine collecting, and is
13
+ * anything landing?". A green status must never be able to hide a session that
14
+ * never arrived, so every queue and every stuck object is reported here.
15
+ */
16
+ export async function inspectLocalCollectorStatus(options = {}) {
17
+ const now = options.now ?? new Date();
18
+ const reading = await readCollectorStatus(options, now);
19
+ const { paths, config, session, identity, context, branch } = reading;
20
+ const { uploadSpool, healthOutbox, stuckEvidence } = reading;
21
+ const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
22
+ const uploadState = classifyUploadState(config, uploadSpool, session);
23
+ return {
24
+ installed: Boolean(config),
25
+ config_file: paths.config_file,
26
+ session_file: paths.session_file,
27
+ collector_version: LOCAL_COLLECTOR_VERSION,
28
+ config_collector_version: config?.collector_version ?? null,
29
+ session_state: session.session_state,
30
+ repo: context?.repo ?? identity.repo_root,
31
+ branch,
32
+ active_ticket_id: context?.active_ticket_id ?? null,
33
+ work_label: context ? workDisplayLabel(context) : null,
34
+ work_id: context?.work_context_id ?? null,
35
+ work_context_id: context?.work_context_id ?? null,
36
+ repo_label: context?.repo_label ?? identity.repo_label,
37
+ repo_fingerprint: context?.repo_fingerprint ?? identity.repo_fingerprint,
38
+ worktree_label: context?.worktree_label ?? identity.worktree_label,
39
+ worktree_fingerprint: context?.worktree_fingerprint ?? identity.worktree_fingerprint,
40
+ worktree_is_primary: context?.worktree_is_primary ?? identity.worktree_is_primary,
41
+ collector_freshness: freshness,
42
+ upload_state: uploadState,
43
+ last_upload_attempt_at: uploadSpool.last_upload_attempt_at,
44
+ last_upload_success_at: uploadSpool.last_upload_success_at,
45
+ last_upload_failure_reason: uploadSpool.last_upload_failure_reason,
46
+ pending_upload_count: uploadSpool.pending_upload_count,
47
+ upload_retry_command: uploadSpool.retry_command,
48
+ pending_health_receipt_count: healthOutbox.pending_count,
49
+ oldest_pending_health_receipt_at: healthOutbox.oldest_created_at,
50
+ last_health_receipt_failure_reason: healthOutbox.last_failure_reason,
51
+ stuck_evidence_object_count: stuckEvidence.stuck_object_count,
52
+ stuck_evidence_held_count: stuckEvidence.held_object_count,
53
+ stuck_evidence_max_attempts: stuckEvidence.max_attempts,
54
+ stuck_evidence_oldest_failure_at: stuckEvidence.oldest_first_failed_at,
55
+ stuck_evidence_reasons: stuckEvidence.reasons,
56
+ details: describeCollectorStatus(reading),
57
+ };
58
+ }
59
+ async function readCollectorStatus(options, now) {
60
+ const homeDir = options.homeDir ?? os.homedir();
61
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
62
+ const paths = getCollectorRuntimePaths(homeDir);
63
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
64
+ const session = await readLocalSessionReference(paths, {
65
+ operatorId: options.operatorId,
66
+ sessionId: options.sessionId,
67
+ });
68
+ const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
69
+ const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
70
+ const branch = options.branch ?? identity.branch;
71
+ const [uploadSpool, healthOutbox, stagingState] = await Promise.all([
72
+ summarizeLocalUploadSpool(paths),
73
+ summarizeInstallEventOutbox(paths),
74
+ readRawEvidenceStagingState(paths.state_dir),
75
+ ]);
76
+ const stuckEvidence = summarizeStuckEvidence(stagingState, now);
77
+ return {
78
+ paths,
79
+ config,
80
+ session,
81
+ identity,
82
+ context,
83
+ branch,
84
+ uploadSpool,
85
+ healthOutbox,
86
+ stuckEvidence,
87
+ };
88
+ }
89
+ // Not installed outranks everything, and a pending retry outranks a valid
90
+ // session. The return type is inferred: the four labels are already written
91
+ // once in `LocalCollectorStatus` and once here, and a third copy would be a
92
+ // third place to forget one.
93
+ function classifyUploadState(config, uploadSpool, session) {
94
+ return !config
95
+ ? "not_installed"
96
+ : uploadSpool.pending_upload_count > 0
97
+ ? "retry_pending"
98
+ : session.session_state === "valid"
99
+ ? "ready"
100
+ : "local_only_missing_auth";
101
+ }
102
+ /**
103
+ * The sentences an operator actually reads. Each of the first five is stated
104
+ * either way round, because a line that only appears on failure cannot answer
105
+ * "did anything land at all today?"; the last four appear only when there is a
106
+ * queue, and each names its own reason and count.
107
+ */
108
+ function describeCollectorStatus(reading) {
109
+ const { paths, config, session, context, uploadSpool, healthOutbox, stuckEvidence } = reading;
110
+ const details = [];
111
+ details.push(config
112
+ ? `Config exists at ${paths.config_file}.`
113
+ : "Local config missing. Run `cockpit install`.");
114
+ details.push(session.session_state === "valid"
115
+ ? "Local user session is valid."
116
+ : "Local user session missing or not paired; upload remains local-only.");
117
+ details.push(context
118
+ ? `Active work ${workDisplayLabel(context)} (${context.work_context_id}) last updated ${context.updated_at ?? context.started_at}.`
119
+ : "Active work context missing. Run `cockpit start`.");
120
+ details.push(uploadSpool.last_upload_attempt_at
121
+ ? `Last upload attempt: ${uploadSpool.last_upload_attempt_at}.`
122
+ : "No upload has been attempted yet.");
123
+ details.push(uploadSpool.last_upload_success_at
124
+ ? `Last upload success: ${uploadSpool.last_upload_success_at}.`
125
+ : "No successful upload recorded yet.");
126
+ if (uploadSpool.last_upload_failure_reason) {
127
+ details.push(`Last upload failure: ${uploadSpool.last_upload_failure_reason}.`);
128
+ }
129
+ if (uploadSpool.pending_upload_count > 0) {
130
+ details.push(`Upload retry pending: ${uploadSpool.pending_upload_count} safe metadata record(s) spooled. Run \`${uploadSpool.retry_command ?? "cockpit sync"}\` to retry.`);
131
+ }
132
+ if (healthOutbox.pending_count > 0) {
133
+ details.push(`Collector health retry pending: ${healthOutbox.pending_count} sanitized receipt(s) queued since ${healthOutbox.oldest_created_at ?? "unknown"}.`);
134
+ }
135
+ if (stuckEvidence.stuck_object_count > 0) {
136
+ 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"}.`);
137
+ }
138
+ return details;
139
+ }
140
+ /**
141
+ * Whether this machine looks alive right now. A successful upload counts as
142
+ * activity as much as a context update does, so a long session that is still
143
+ * shipping data does not read as stale.
144
+ */
145
+ export function classifyCollectorFreshness(context, lastUploadSuccessAt, now) {
146
+ if (!context)
147
+ return "missing";
148
+ const latestActivityAt = latestTimestamp([
149
+ context.updated_at ?? context.started_at,
150
+ lastUploadSuccessAt,
151
+ ]);
152
+ if (latestActivityAt === null)
153
+ return "stale";
154
+ return now.getTime() - latestActivityAt <= 5 * 60 * 1000 ? "fresh" : "stale";
155
+ }
156
+ function latestTimestamp(values) {
157
+ const timestamps = values
158
+ .map((value) => Date.parse(value ?? ""))
159
+ .filter((value) => Number.isFinite(value));
160
+ if (timestamps.length === 0)
161
+ return null;
162
+ return Math.max(...timestamps);
163
+ }