@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 +26 -0
- package/dist/commands/local-args.js +10 -2
- package/dist/commands/local.js +272 -27
- package/dist/local-state.js +11 -1
- package/dist/spool/install-event-outbox.js +191 -0
- package/package.json +1 -1
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,
|
|
332
|
+
assertNoPositionals(values.positionals, command);
|
|
325
333
|
return {
|
|
326
|
-
kind:
|
|
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")),
|
package/dist/commands/local.js
CHANGED
|
@@ -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
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
693
|
-
|
|
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}`);
|
package/dist/local-state.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|