@bli-cockpit/cli 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -160,6 +160,9 @@ Local files:
160
160
  - `~/.config/bli-cockpit/config.json`: dashboard URL, device label, collection roots.
161
161
  - `~/.config/bli-cockpit/session.json`: paired device token and owner metadata.
162
162
  - `~/.local/state/bli-cockpit/spool/`: safe retry records.
163
+ - `~/.local/state/bli-cockpit/spool/install-events/`: private atomic collector
164
+ health receipts waiting for authenticated delivery. Receipts contain only
165
+ sanitized operation metadata, never command output or transcript content.
163
166
  - `~/.local/state/bli-cockpit/cursors/`: upload/backfill cursors, no raw content.
164
167
  - `.codex-autorunner/contextspace/active_context.md`: current work context inside a repo.
165
168
 
@@ -180,6 +183,11 @@ cockpit status --workspace "$PWD" --json
180
183
  cockpit sync --workspace "$PWD" --json
181
184
  ```
182
185
 
186
+ `status --json` includes `pending_health_receipt_count`,
187
+ `oldest_pending_health_receipt_at`, and
188
+ `last_health_receipt_failure_reason`. A nonzero count means Cockpit preserved a
189
+ collector failure locally and will replay it on the next authenticated run.
190
+
183
191
  If `cockpit update` fails with npm `EACCES`, fix Homebrew global-package ownership once:
184
192
 
185
193
  ```bash
@@ -18,6 +18,7 @@ import { discoverGitWorktrees } from "../repo-identity.js";
18
18
  import { runAttributedWorktreeSync, } from "./session-sync.js";
19
19
  import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
20
20
  import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
21
+ import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
21
22
  export const rootCommandNames = new Set([
22
23
  "onboard",
23
24
  "update",
@@ -671,6 +672,28 @@ export async function reportInstallEventsBestEffort(options) {
671
672
  if (options.events.length === 0)
672
673
  return;
673
674
  const paths = getCollectorRuntimePaths(options.homeDir);
675
+ try {
676
+ await enqueueInstallEventEntry(paths, {
677
+ dashboardUrl: options.dashboardUrl,
678
+ cliVersion: LOCAL_COLLECTOR_VERSION,
679
+ command: options.command,
680
+ osPlatform: os.platform(),
681
+ events: options.events.map((event) => ({
682
+ step: event.step.trim().slice(0, 120),
683
+ status: event.status,
684
+ ...(event.error_code
685
+ ? { error_code: sanitizeInstallErrorCode(event.error_code) }
686
+ : {}),
687
+ ...(event.at ? { at: event.at } : {}),
688
+ })),
689
+ });
690
+ }
691
+ catch {
692
+ if (options.json) {
693
+ writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
694
+ }
695
+ return;
696
+ }
674
697
  const session = await readLocalCollectorSessionFile(paths).catch(() => null);
675
698
  if (!session ||
676
699
  session.session_state !== "valid" ||
@@ -678,34 +701,47 @@ export async function reportInstallEventsBestEffort(options) {
678
701
  !session.device_token) {
679
702
  return;
680
703
  }
681
- const controller = new AbortController();
682
- const timeout = setTimeout(() => controller.abort(), 5_000);
683
- try {
684
- const response = await options.io.fetch(`${options.dashboardUrl}/api/ambient/install-events`, {
685
- method: "POST",
686
- headers: {
687
- "Content-Type": "application/json",
688
- Authorization: `Bearer ${session.device_token}`,
689
- },
690
- body: JSON.stringify({
691
- cli_version: LOCAL_COLLECTOR_VERSION,
692
- command: options.command,
693
- os_platform: os.platform(),
694
- events: options.events.slice(0, 40),
695
- }),
696
- signal: controller.signal,
697
- });
698
- if (!response.ok) {
699
- throw new Error(`http_${response.status}`);
700
- }
701
- }
702
- catch (error) {
703
- if (options.json) {
704
- writeLine(options.io.stderr, `Install event telemetry skipped: ${classifyInstallTelemetryError(error)}`);
705
- }
704
+ const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
705
+ const failures = [];
706
+ for (let offset = 0; offset < pending.length; offset += 5) {
707
+ await Promise.all(pending.slice(offset, offset + 5).map(async (entry) => {
708
+ const controller = new AbortController();
709
+ const timeout = setTimeout(() => controller.abort(), 5_000);
710
+ try {
711
+ const response = await options.io.fetch(`${entry.dashboard_url}/api/ambient/install-events`, {
712
+ method: "POST",
713
+ headers: {
714
+ "Content-Type": "application/json",
715
+ Authorization: `Bearer ${session.device_token}`,
716
+ },
717
+ body: JSON.stringify({
718
+ cli_version: entry.cli_version,
719
+ command: entry.command,
720
+ os_platform: entry.os_platform,
721
+ events: entry.events,
722
+ }),
723
+ signal: controller.signal,
724
+ });
725
+ if (!response.ok) {
726
+ throw new Error(`http_${response.status}`);
727
+ }
728
+ await removeInstallEventEntry(paths, entry.outbox_id);
729
+ }
730
+ catch (error) {
731
+ const failureReason = classifyInstallTelemetryError(error);
732
+ failures.push(failureReason);
733
+ await recordInstallEventAttemptFailure(paths, entry, {
734
+ attemptedAt: new Date().toISOString(),
735
+ failureReason,
736
+ }).catch(() => undefined);
737
+ }
738
+ finally {
739
+ clearTimeout(timeout);
740
+ }
741
+ }));
706
742
  }
707
- finally {
708
- clearTimeout(timeout);
743
+ if (options.json && failures.length > 0) {
744
+ writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
709
745
  }
710
746
  }
711
747
  function classifyInstallTelemetryError(error) {
@@ -1742,6 +1778,54 @@ async function runStart(command, io) {
1742
1778
  return 0;
1743
1779
  }
1744
1780
  async function runSync(command, io) {
1781
+ const paths = getCollectorRuntimePaths(command.homeDir);
1782
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
1783
+ const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
1784
+ await reportInstallEventsBestEffort({
1785
+ homeDir: command.homeDir,
1786
+ dashboardUrl,
1787
+ command: "sync",
1788
+ events: [{ step: "sync_started", status: "ok" }],
1789
+ json: command.json,
1790
+ io,
1791
+ });
1792
+ try {
1793
+ const code = await runSyncWithHealthReceipt(command, io);
1794
+ await reportInstallEventsBestEffort({
1795
+ homeDir: command.homeDir,
1796
+ dashboardUrl,
1797
+ command: "sync",
1798
+ events: [
1799
+ {
1800
+ step: "sync_complete",
1801
+ status: code === 0 ? "ok" : "fail",
1802
+ ...(code === 0 ? {} : { error_code: "sync_failed" }),
1803
+ },
1804
+ ],
1805
+ json: command.json,
1806
+ io,
1807
+ });
1808
+ return code;
1809
+ }
1810
+ catch (error) {
1811
+ await reportInstallEventsBestEffort({
1812
+ homeDir: command.homeDir,
1813
+ dashboardUrl,
1814
+ command: "sync",
1815
+ events: [
1816
+ {
1817
+ step: "sync_complete",
1818
+ status: "fail",
1819
+ error_code: classifySyncHealthError(error),
1820
+ },
1821
+ ],
1822
+ json: command.json,
1823
+ io,
1824
+ });
1825
+ throw error;
1826
+ }
1827
+ }
1828
+ async function runSyncWithHealthReceipt(command, io) {
1745
1829
  const backfillLock = await inspectBackfillLock(getCollectorRuntimePaths(command.homeDir));
1746
1830
  if (backfillLock.held) {
1747
1831
  if (command.json) {
@@ -1775,6 +1859,19 @@ async function runSync(command, io) {
1775
1859
  await lock.handle.release();
1776
1860
  }
1777
1861
  }
1862
+ function classifySyncHealthError(error) {
1863
+ const message = errorMessage(error);
1864
+ if (/auth|token|session|unauthorized|forbidden|401|403/iu.test(message)) {
1865
+ return "auth_failed";
1866
+ }
1867
+ if (/fetch|network|enotfound|econnrefused|timeout/iu.test(message)) {
1868
+ return "network_failed";
1869
+ }
1870
+ if (/collection.root|workspace|repo|worktree/iu.test(message)) {
1871
+ return "collection_root_failed";
1872
+ }
1873
+ return "sync_failed";
1874
+ }
1778
1875
  async function runSyncLocked(command, io) {
1779
1876
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1780
1877
  const run = await runAttributedWorktreeSync({
@@ -2015,6 +2112,8 @@ async function runStatus(command, io) {
2015
2112
  writeLine(io.stdout, `last_upload_success: ${status.last_upload_success_at ?? "never"}`);
2016
2113
  writeLine(io.stdout, `last_upload_failure: ${status.last_upload_failure_reason ?? "none"}`);
2017
2114
  writeLine(io.stdout, `pending_uploads: ${status.pending_upload_count}`);
2115
+ writeLine(io.stdout, `pending_health_receipts: ${status.pending_health_receipt_count}`);
2116
+ writeLine(io.stdout, `last_health_receipt_failure: ${status.last_health_receipt_failure_reason ?? "none"}`);
2018
2117
  writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
2019
2118
  for (const detail of status.details)
2020
2119
  writeLine(io.stdout, `- ${detail}`);
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { resolveRepoWorktreeIdentity, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
8
8
  import { normalizeCollectionRoots } from "./root-normalization.js";
9
9
  import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
10
+ import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
10
11
  const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
11
12
  export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
12
13
  ? localCollectorPackage.version
@@ -237,7 +238,10 @@ export async function inspectLocalCollectorStatus(options = {}) {
237
238
  const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
238
239
  const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
239
240
  const branch = options.branch ?? identity.branch;
240
- const uploadSpool = await summarizeLocalUploadSpool(paths);
241
+ const [uploadSpool, healthOutbox] = await Promise.all([
242
+ summarizeLocalUploadSpool(paths),
243
+ summarizeInstallEventOutbox(paths),
244
+ ]);
241
245
  const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
242
246
  const uploadState = !config
243
247
  ? "not_installed"
@@ -268,6 +272,9 @@ export async function inspectLocalCollectorStatus(options = {}) {
268
272
  if (uploadSpool.pending_upload_count > 0) {
269
273
  details.push(`Upload retry pending: ${uploadSpool.pending_upload_count} safe metadata record(s) spooled. Run \`${uploadSpool.retry_command ?? "cockpit sync"}\` to retry.`);
270
274
  }
275
+ if (healthOutbox.pending_count > 0) {
276
+ details.push(`Collector health retry pending: ${healthOutbox.pending_count} sanitized receipt(s) queued since ${healthOutbox.oldest_created_at ?? "unknown"}.`);
277
+ }
271
278
  return {
272
279
  installed: Boolean(config),
273
280
  config_file: paths.config_file,
@@ -293,6 +300,9 @@ export async function inspectLocalCollectorStatus(options = {}) {
293
300
  last_upload_failure_reason: uploadSpool.last_upload_failure_reason,
294
301
  pending_upload_count: uploadSpool.pending_upload_count,
295
302
  upload_retry_command: uploadSpool.retry_command,
303
+ pending_health_receipt_count: healthOutbox.pending_count,
304
+ oldest_pending_health_receipt_at: healthOutbox.oldest_created_at,
305
+ last_health_receipt_failure_reason: healthOutbox.last_failure_reason,
296
306
  details,
297
307
  };
298
308
  }
@@ -0,0 +1,191 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ const OUTBOX_DIRECTORY = "install-events";
5
+ const MAX_PENDING_ENTRIES = 100;
6
+ const MAX_EVENTS_PER_ENTRY = 40;
7
+ export async function enqueueInstallEventEntry(paths, options) {
8
+ if (options.events.length === 0)
9
+ return null;
10
+ const createdAt = (options.now ?? new Date()).toISOString();
11
+ const entry = {
12
+ schema_version: "cockpit-install-event-outbox.v1",
13
+ outbox_id: `install-event-${crypto.randomUUID()}`,
14
+ created_at: createdAt,
15
+ last_attempt_at: null,
16
+ retry_count: 0,
17
+ last_failure_reason: null,
18
+ dashboard_url: options.dashboardUrl,
19
+ cli_version: options.cliVersion,
20
+ command: options.command,
21
+ os_platform: options.osPlatform,
22
+ events: options.events.slice(0, MAX_EVENTS_PER_ENTRY).map((event) => ({
23
+ step: event.step,
24
+ status: event.status,
25
+ ...(event.error_code ? { error_code: event.error_code } : {}),
26
+ at: event.at ?? createdAt,
27
+ })),
28
+ };
29
+ await writeEntry(paths, entry);
30
+ await pruneInstallEventOutbox(paths);
31
+ return entry;
32
+ }
33
+ export async function readPendingInstallEventEntries(paths) {
34
+ const directory = installEventOutboxDirectory(paths);
35
+ const names = await fs.readdir(directory).catch(() => []);
36
+ const entries = [];
37
+ for (const name of names.filter((candidate) => candidate.endsWith(".json"))) {
38
+ const filePath = path.join(directory, name);
39
+ try {
40
+ const parsed = parseEntry(JSON.parse(await fs.readFile(filePath, "utf8")));
41
+ if (!parsed) {
42
+ await fs.rm(filePath, { force: true });
43
+ continue;
44
+ }
45
+ entries.push(parsed);
46
+ }
47
+ catch {
48
+ await fs.rm(filePath, { force: true }).catch(() => undefined);
49
+ }
50
+ }
51
+ return entries.sort((left, right) => Date.parse(left.created_at) - Date.parse(right.created_at) ||
52
+ left.outbox_id.localeCompare(right.outbox_id));
53
+ }
54
+ export async function summarizeInstallEventOutbox(paths) {
55
+ const entries = await readPendingInstallEventEntries(paths);
56
+ const attempted = entries
57
+ .filter((entry) => entry.last_attempt_at)
58
+ .sort((left, right) => Date.parse(right.last_attempt_at) - Date.parse(left.last_attempt_at))[0];
59
+ return {
60
+ pending_count: entries.length,
61
+ oldest_created_at: entries[0]?.created_at ?? null,
62
+ last_attempt_at: attempted?.last_attempt_at ?? null,
63
+ last_failure_reason: attempted?.last_failure_reason ?? null,
64
+ };
65
+ }
66
+ export async function recordInstallEventAttemptFailure(paths, entry, options) {
67
+ await writeEntry(paths, {
68
+ ...entry,
69
+ last_attempt_at: options.attemptedAt,
70
+ retry_count: entry.retry_count + 1,
71
+ last_failure_reason: options.failureReason,
72
+ });
73
+ }
74
+ export async function removeInstallEventEntry(paths, outboxId) {
75
+ await fs.rm(entryPath(paths, outboxId), { force: true });
76
+ }
77
+ export function installEventOutboxDirectory(paths) {
78
+ return path.join(paths.spool_dir, OUTBOX_DIRECTORY);
79
+ }
80
+ async function pruneInstallEventOutbox(paths) {
81
+ const entries = await readPendingInstallEventEntries(paths);
82
+ const excess = entries.slice(0, Math.max(0, entries.length - MAX_PENDING_ENTRIES));
83
+ await Promise.all(excess.map((entry) => removeInstallEventEntry(paths, entry.outbox_id)));
84
+ }
85
+ async function writeEntry(paths, entry) {
86
+ const directory = installEventOutboxDirectory(paths);
87
+ const filePath = entryPath(paths, entry.outbox_id);
88
+ const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
89
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
90
+ if (process.platform !== "win32") {
91
+ await fs.chmod(directory, 0o700).catch(() => undefined);
92
+ }
93
+ try {
94
+ await fs.writeFile(tempPath, `${JSON.stringify(entry, null, 2)}\n`, {
95
+ mode: 0o600,
96
+ flag: "wx",
97
+ });
98
+ await fs.rename(tempPath, filePath);
99
+ if (process.platform !== "win32") {
100
+ await fs.chmod(filePath, 0o600).catch(() => undefined);
101
+ }
102
+ }
103
+ finally {
104
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
105
+ }
106
+ }
107
+ function entryPath(paths, outboxId) {
108
+ return path.join(installEventOutboxDirectory(paths), `${safeOutboxId(outboxId)}.json`);
109
+ }
110
+ function safeOutboxId(value) {
111
+ return value.replace(/[^a-z0-9_-]/giu, "").slice(0, 120);
112
+ }
113
+ function parseEntry(value) {
114
+ if (!value || typeof value !== "object" || Array.isArray(value))
115
+ return null;
116
+ const record = value;
117
+ const command = installEventCommand(record["command"]);
118
+ const events = Array.isArray(record["events"])
119
+ ? record["events"].map(parseEvent).filter(isPresent)
120
+ : [];
121
+ if (record["schema_version"] !== "cockpit-install-event-outbox.v1" ||
122
+ !stringValue(record["outbox_id"]) ||
123
+ !stringValue(record["created_at"]) ||
124
+ !stringValue(record["dashboard_url"]) ||
125
+ !stringValue(record["cli_version"]) ||
126
+ !stringValue(record["os_platform"]) ||
127
+ !command ||
128
+ events.length === 0) {
129
+ return null;
130
+ }
131
+ return {
132
+ schema_version: "cockpit-install-event-outbox.v1",
133
+ outbox_id: stringValue(record["outbox_id"]),
134
+ created_at: stringValue(record["created_at"]),
135
+ last_attempt_at: stringValue(record["last_attempt_at"]),
136
+ retry_count: finiteNumber(record["retry_count"]),
137
+ last_failure_reason: stringValue(record["last_failure_reason"]),
138
+ dashboard_url: stringValue(record["dashboard_url"]),
139
+ cli_version: stringValue(record["cli_version"]),
140
+ command,
141
+ os_platform: stringValue(record["os_platform"]),
142
+ events: events.slice(0, MAX_EVENTS_PER_ENTRY),
143
+ };
144
+ }
145
+ function parseEvent(value) {
146
+ if (!value || typeof value !== "object" || Array.isArray(value))
147
+ return null;
148
+ const record = value;
149
+ const step = stringValue(record["step"]);
150
+ const status = installEventStatus(record["status"]);
151
+ const at = stringValue(record["at"]);
152
+ if (!step || !status || !at)
153
+ return null;
154
+ return {
155
+ step,
156
+ status,
157
+ ...(stringValue(record["error_code"])
158
+ ? { error_code: stringValue(record["error_code"]) }
159
+ : {}),
160
+ at,
161
+ };
162
+ }
163
+ function installEventCommand(value) {
164
+ return [
165
+ "onboard",
166
+ "update",
167
+ "install",
168
+ "login",
169
+ "sync",
170
+ "backfill",
171
+ "doctor",
172
+ ].includes(String(value))
173
+ ? value
174
+ : null;
175
+ }
176
+ function installEventStatus(value) {
177
+ return value === "ok" || value === "fail" || value === "skipped"
178
+ ? value
179
+ : null;
180
+ }
181
+ function stringValue(value) {
182
+ return typeof value === "string" && value.trim() ? value : null;
183
+ }
184
+ function finiteNumber(value) {
185
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
186
+ ? Math.floor(value)
187
+ : 0;
188
+ }
189
+ function isPresent(value) {
190
+ return value !== null;
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {