@bli-cockpit/cli 0.2.3 → 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
@@ -54,6 +54,7 @@ On a blank Mac, `npm i -g @bli-cockpit/cli && cockpit do-everything` is enough t
54
54
  | `cockpit status` | Prints install / sign-in / capture / upload health in one screen. | The "is it working?" command. Run it whenever you're unsure. |
55
55
  | `cockpit backfill --all` | Uploads your HISTORICAL Codex + Claude sessions (from before Cockpit existed on this Mac). | One-time catch-up so your past work counts too. "Backfill" = fill in the back-catalog. |
56
56
  | `cockpit sync` | Captures and uploads once, right now. This is what the background agent runs every 15 min — you almost never type it yourself. | Named for what it does: synchronize local session files up to the dashboard. |
57
+ | `cockpit analyze` | Runs a fresh sync, then queues batch analysis of your latest uploaded sessions. It returns immediately; status and results appear in My Work. | One command when you want newly finished work uploaded and judged now. |
57
58
  | `cockpit start --ticket <id>` | Tags your CURRENT work with a Linear ticket so sessions attribute to it. `--clear-ticket` returns to general capture. | "Start (working on) X." Agents usually run this for you per the agent rules. |
58
59
  | `cockpit login` / `cockpit pair` | Just the sign-in + device-registration step, standalone (onboard already includes it). Two names, one command: `login` is what humans guess, `pair` is what the dashboard's device screen calls it. | Recovery path when a session expires or you switch accounts. |
59
60
  | `cockpit logout` | Deletes this machine's session. | The undo of login. |
@@ -93,6 +94,23 @@ cockpit onboard --no-auth
93
94
 
94
95
  `cockpit update` installs the latest public CLI and reruns onboarding checks against saved roots. `cockpit upgrade` is the same command.
95
96
 
97
+ ## Analyze latest work
98
+
99
+ ```bash
100
+ cockpit analyze --workspace "$PWD"
101
+ ```
102
+
103
+ `cockpit analyze` first performs the same durable upload as `cockpit sync`. It
104
+ queues the analysis only after that upload succeeds, then exits without waiting
105
+ for the Fireworks batch. Use `--json` for a single machine-readable object that
106
+ contains both the sync receipt and queued job status. The dashboard button
107
+ analyzes the latest upload already stored by the background collector; run the
108
+ CLI command when the work ended too recently for the 15-minute background sync.
109
+ Each person can queue analysis once every three hours, subject to the team's
110
+ shared `$2` UTC-day judge cap. Completion can take several minutes plus the wait
111
+ for the next 15-minute collector poll; My Work shows queued, running, done, or
112
+ failed status while you wait.
113
+
96
114
  ## Parent Mode
97
115
 
98
116
  Use a parent folder such as `~/BLI` when it contains multiple repos. Cockpit scans child git repos/worktrees, creates one stable work context per worktree, and rolls them up under repo rows in the dashboard.
@@ -142,6 +160,9 @@ Local files:
142
160
  - `~/.config/bli-cockpit/config.json`: dashboard URL, device label, collection roots.
143
161
  - `~/.config/bli-cockpit/session.json`: paired device token and owner metadata.
144
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.
145
166
  - `~/.local/state/bli-cockpit/cursors/`: upload/backfill cursors, no raw content.
146
167
  - `.codex-autorunner/contextspace/active_context.md`: current work context inside a repo.
147
168
 
@@ -162,6 +183,11 @@ cockpit status --workspace "$PWD" --json
162
183
  cockpit sync --workspace "$PWD" --json
163
184
  ```
164
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
+
165
191
  If `cockpit update` fails with npm `EACCES`, fix Homebrew global-package ownership once:
166
192
 
167
193
  ```bash
@@ -29,6 +29,8 @@ export function parseLocalArgs(argv) {
29
29
  return parseStartArgs(argv.slice(1));
30
30
  case "sync":
31
31
  return parseSyncArgs(argv.slice(1));
32
+ case "analyze":
33
+ return parseAnalyzeArgs(argv.slice(1));
32
34
  case "backfill":
33
35
  return parseBackfillArgs(argv.slice(1));
34
36
  case "status":
@@ -302,6 +304,12 @@ function parseStartArgs(args) {
302
304
  };
303
305
  }
304
306
  function parseSyncArgs(args) {
307
+ return parseSyncLikeArgs(args, "sync");
308
+ }
309
+ function parseAnalyzeArgs(args) {
310
+ return parseSyncLikeArgs(args, "analyze");
311
+ }
312
+ function parseSyncLikeArgs(args, command) {
305
313
  const values = parseNamedArgs(args, {
306
314
  allowedFlags: [
307
315
  "--home",
@@ -321,9 +329,9 @@ function parseSyncArgs(args) {
321
329
  "--max-repos",
322
330
  ],
323
331
  });
324
- assertNoPositionals(values.positionals, "sync");
332
+ assertNoPositionals(values.positionals, command);
325
333
  return {
326
- kind: "sync",
334
+ kind: command,
327
335
  homeDir: optionalNonEmpty(values.flags.get("--home")),
328
336
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
329
337
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
@@ -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",
@@ -30,6 +31,7 @@ export const rootCommandNames = new Set([
30
31
  "logout",
31
32
  "start",
32
33
  "sync",
34
+ "analyze",
33
35
  "backfill",
34
36
  "status",
35
37
  "sessions",
@@ -76,6 +78,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
76
78
  return await runStart(command, io);
77
79
  case "sync":
78
80
  return await runSync(command, io);
81
+ case "analyze":
82
+ return await runAnalyze(command, io);
79
83
  case "backfill":
80
84
  return await runBackfillCommand(command, io);
81
85
  case "status":
@@ -112,6 +116,7 @@ export function localCommandHelp(command) {
112
116
  " cockpit logout",
113
117
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
114
118
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
119
+ " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
115
120
  " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--yes] [--workspace <path>] [--json]",
116
121
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
117
122
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -236,6 +241,17 @@ function localSubcommandHelp(command) {
236
241
  "--max-depth and --max-repos.",
237
242
  ],
238
243
  ],
244
+ [
245
+ "analyze",
246
+ [
247
+ "Usage: cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--json]",
248
+ "",
249
+ "Uploads the latest local ambient evidence, then queues one analysis job.",
250
+ "The command returns after the batch is queued; view status and results in My Work.",
251
+ "Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
252
+ "`--repo <path>` remains supported as a backward-compatible alias.",
253
+ ],
254
+ ],
239
255
  [
240
256
  "backfill",
241
257
  [
@@ -656,6 +672,28 @@ export async function reportInstallEventsBestEffort(options) {
656
672
  if (options.events.length === 0)
657
673
  return;
658
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
+ }
659
697
  const session = await readLocalCollectorSessionFile(paths).catch(() => null);
660
698
  if (!session ||
661
699
  session.session_state !== "valid" ||
@@ -663,34 +701,47 @@ export async function reportInstallEventsBestEffort(options) {
663
701
  !session.device_token) {
664
702
  return;
665
703
  }
666
- const controller = new AbortController();
667
- const timeout = setTimeout(() => controller.abort(), 5_000);
668
- try {
669
- const response = await options.io.fetch(`${options.dashboardUrl}/api/ambient/install-events`, {
670
- method: "POST",
671
- headers: {
672
- "Content-Type": "application/json",
673
- Authorization: `Bearer ${session.device_token}`,
674
- },
675
- body: JSON.stringify({
676
- cli_version: LOCAL_COLLECTOR_VERSION,
677
- command: options.command,
678
- os_platform: os.platform(),
679
- events: options.events.slice(0, 40),
680
- }),
681
- signal: controller.signal,
682
- });
683
- if (!response.ok) {
684
- throw new Error(`http_${response.status}`);
685
- }
686
- }
687
- catch (error) {
688
- if (options.json) {
689
- writeLine(options.io.stderr, `Install event telemetry skipped: ${classifyInstallTelemetryError(error)}`);
690
- }
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
+ }));
691
742
  }
692
- finally {
693
- clearTimeout(timeout);
743
+ if (options.json && failures.length > 0) {
744
+ writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
694
745
  }
695
746
  }
696
747
  function classifyInstallTelemetryError(error) {
@@ -1727,6 +1778,54 @@ async function runStart(command, io) {
1727
1778
  return 0;
1728
1779
  }
1729
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) {
1730
1829
  const backfillLock = await inspectBackfillLock(getCollectorRuntimePaths(command.homeDir));
1731
1830
  if (backfillLock.held) {
1732
1831
  if (command.json) {
@@ -1760,6 +1859,19 @@ async function runSync(command, io) {
1760
1859
  await lock.handle.release();
1761
1860
  }
1762
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
+ }
1763
1875
  async function runSyncLocked(command, io) {
1764
1876
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1765
1877
  const run = await runAttributedWorktreeSync({
@@ -1825,6 +1937,137 @@ async function runSyncLocked(command, io) {
1825
1937
  writeLine(io.stderr, `Retry: ${result.retry_command}`);
1826
1938
  return 1;
1827
1939
  }
1940
+ async function runAnalyze(command, io) {
1941
+ const syncStdout = [];
1942
+ const syncStderr = [];
1943
+ const syncIo = {
1944
+ ...io,
1945
+ stdout: bufferedWritable(syncStdout),
1946
+ stderr: bufferedWritable(syncStderr),
1947
+ };
1948
+ const syncCommand = {
1949
+ ...command,
1950
+ kind: "sync",
1951
+ json: true,
1952
+ };
1953
+ const syncExitCode = await runSync(syncCommand, syncIo);
1954
+ const syncOutput = parseCapturedJson(syncStdout);
1955
+ if (syncExitCode !== 0 || !isUploadedSyncResult(syncOutput)) {
1956
+ if (command.json) {
1957
+ writeLine(io.stdout, JSON.stringify({
1958
+ status: syncExitCode === 0 ? "sync_not_completed" : "sync_failed",
1959
+ sync: syncOutput,
1960
+ error: syncStderr.join("").trim()
1961
+ || (syncExitCode === 0
1962
+ ? "Cockpit sync did not upload fresh evidence."
1963
+ : "Cockpit sync failed."),
1964
+ }, null, 2));
1965
+ }
1966
+ else {
1967
+ replayCaptured(io.stderr, syncStderr);
1968
+ writeLine(io.stderr, syncExitCode === 0
1969
+ ? "Analysis was not queued because a fresh upload did not complete."
1970
+ : "Analysis was not queued because the latest upload failed.");
1971
+ }
1972
+ return syncExitCode === 0 ? 1 : syncExitCode;
1973
+ }
1974
+ const paths = getCollectorRuntimePaths(command.homeDir);
1975
+ const session = await readLocalCollectorSessionFile(paths).catch(() => {
1976
+ throw new Error("Cockpit is not signed in. Run `cockpit onboard` or `cockpit login` first.");
1977
+ });
1978
+ const dashboardUrl = normalizeUrl(command.dashboardUrl ?? session.dashboard_url ?? DEFAULT_DASHBOARD_URL);
1979
+ const response = await io.fetch(`${dashboardUrl}/api/ambient/analyze`, {
1980
+ method: "POST",
1981
+ headers: {
1982
+ authorization: `Bearer ${session.device_token}`,
1983
+ "content-type": "application/json",
1984
+ },
1985
+ body: "{}",
1986
+ });
1987
+ const body = await readAnalyzeApiResponse(response);
1988
+ if (!response.ok) {
1989
+ const message = typeof body.message === "string"
1990
+ ? body.message
1991
+ : `Analysis request failed with HTTP ${response.status}.`;
1992
+ if (command.json) {
1993
+ writeLine(io.stdout, JSON.stringify({
1994
+ status: "analyze_failed",
1995
+ sync: syncOutput,
1996
+ http_status: response.status,
1997
+ code: typeof body.code === "string" ? body.code : null,
1998
+ message,
1999
+ retry_after_seconds: typeof body.retry_after_seconds === "number"
2000
+ ? body.retry_after_seconds
2001
+ : null,
2002
+ }, null, 2));
2003
+ }
2004
+ else {
2005
+ replayCaptured(io.stderr, syncStderr);
2006
+ writeLine(io.stderr, `Analysis was not queued: ${message}`);
2007
+ }
2008
+ return 1;
2009
+ }
2010
+ const jobId = typeof body.job?.id === "string" ? body.job.id : null;
2011
+ const jobStatus = typeof body.job?.status === "string" ? body.job.status : "pending";
2012
+ if (command.json) {
2013
+ writeLine(io.stdout, JSON.stringify({
2014
+ status: "queued",
2015
+ sync: syncOutput,
2016
+ job: body.job ?? null,
2017
+ dashboard_url: `${dashboardUrl}/my-work`,
2018
+ }, null, 2));
2019
+ return 0;
2020
+ }
2021
+ replayCaptured(io.stderr, syncStderr);
2022
+ writeLine(io.stdout, "Cockpit latest evidence uploaded.");
2023
+ writeLine(io.stdout, "Cockpit analysis queued.");
2024
+ if (jobId)
2025
+ writeLine(io.stdout, `Job: ${jobId}`);
2026
+ writeLine(io.stdout, `Status: ${jobStatus === "pending" ? "queued" : jobStatus}`);
2027
+ writeLine(io.stdout, `Results: ${dashboardUrl}/my-work`);
2028
+ return 0;
2029
+ }
2030
+ async function readAnalyzeApiResponse(response) {
2031
+ const text = await response.text();
2032
+ if (!text)
2033
+ return {};
2034
+ try {
2035
+ const value = JSON.parse(text);
2036
+ return value && typeof value === "object" ? value : {};
2037
+ }
2038
+ catch {
2039
+ return {};
2040
+ }
2041
+ }
2042
+ function parseCapturedJson(chunks) {
2043
+ const text = chunks.join("").trim();
2044
+ if (!text)
2045
+ return null;
2046
+ try {
2047
+ return JSON.parse(text);
2048
+ }
2049
+ catch {
2050
+ return text;
2051
+ }
2052
+ }
2053
+ function isUploadedSyncResult(value) {
2054
+ return Boolean(value
2055
+ && typeof value === "object"
2056
+ && !Array.isArray(value)
2057
+ && value["status"] === "uploaded");
2058
+ }
2059
+ function bufferedWritable(chunks) {
2060
+ return {
2061
+ write(chunk) {
2062
+ chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
2063
+ return true;
2064
+ },
2065
+ };
2066
+ }
2067
+ function replayCaptured(stream, chunks) {
2068
+ for (const chunk of chunks)
2069
+ stream.write(chunk);
2070
+ }
1828
2071
  async function runSyncRawEvidenceGc(command, io) {
1829
2072
  return runRawEvidenceLocalGc(getCollectorRuntimePaths(command.homeDir), io.env);
1830
2073
  }
@@ -1869,6 +2112,8 @@ async function runStatus(command, io) {
1869
2112
  writeLine(io.stdout, `last_upload_success: ${status.last_upload_success_at ?? "never"}`);
1870
2113
  writeLine(io.stdout, `last_upload_failure: ${status.last_upload_failure_reason ?? "none"}`);
1871
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"}`);
1872
2117
  writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
1873
2118
  for (const detail of status.details)
1874
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.3",
3
+ "version": "0.2.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {