@bli-cockpit/cli 0.2.28 → 0.2.30
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 +22 -15
- package/dist/adapters/local-sources.js +1 -0
- package/dist/adapters/raw-evidence-completeness.js +226 -0
- package/dist/adapters/raw-evidence-git-diff.js +90 -0
- package/dist/adapters/raw-evidence-keys.js +92 -0
- package/dist/adapters/raw-evidence-manifest.js +132 -0
- package/dist/adapters/raw-evidence-pack-store.js +136 -0
- package/dist/adapters/raw-evidence-sanitize.js +190 -0
- package/dist/adapters/raw-evidence.js +656 -1257
- package/dist/commands/backfill.js +7 -0
- package/dist/commands/cli-io.js +92 -0
- package/dist/commands/collection-report.js +139 -0
- package/dist/commands/collection-roots.js +153 -0
- package/dist/commands/doctor.js +19 -17
- package/dist/commands/install-receipts.js +193 -0
- package/dist/commands/install-update.js +305 -0
- package/dist/commands/local-auth.js +268 -0
- package/dist/commands/local-discovery.js +100 -0
- package/dist/commands/local-help.js +281 -0
- package/dist/commands/local.js +182 -1872
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sessions.js +162 -0
- package/dist/commands/status.js +230 -0
- package/dist/evidence-upload-client.js +43 -2
- package/dist/raw-evidence-gc.js +1 -1
- package/dist/raw-evidence-staging.js +15 -2
- package/dist/upload-agent-artifacts.js +153 -0
- package/dist/upload-envelope.js +407 -0
- package/dist/upload-evidence-delivery.js +505 -0
- package/dist/upload-http.js +46 -0
- package/dist/upload-session-reports.js +404 -0
- package/dist/upload.js +132 -1264
- package/package.json +2 -2
|
@@ -1163,6 +1163,13 @@ async function syncBackfillBatch(options) {
|
|
|
1163
1163
|
})),
|
|
1164
1164
|
claudeAttributionScan: options.claudeAttribution ?? undefined,
|
|
1165
1165
|
rawEvidenceBudget: options.rawEvidenceBudget,
|
|
1166
|
+
// `cockpit backfill` is only ever an operator asking — by hand, through
|
|
1167
|
+
// onboarding, through doctor, or by running the retry command Cockpit
|
|
1168
|
+
// printed. The delivery-backoff window is the scheduler's cadence, so it
|
|
1169
|
+
// does not gate this pass: honouring it here made the retry that follows a
|
|
1170
|
+
// failed commit a 15-minute no-op that reported
|
|
1171
|
+
// `durable_session_pointer_missing` and never named the hold (BLI-3118).
|
|
1172
|
+
evidenceDeliveryMode: "operator_retry",
|
|
1166
1173
|
fetch: options.fetchImpl,
|
|
1167
1174
|
};
|
|
1168
1175
|
try {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
|
|
2
|
+
export function writeLine(stream, text) {
|
|
3
|
+
stream.write(`${text}\n`);
|
|
4
|
+
}
|
|
5
|
+
/** Writes text through unchanged, adding only the trailing newline it lacks. */
|
|
6
|
+
export function writeRaw(stream, text) {
|
|
7
|
+
if (!text)
|
|
8
|
+
return;
|
|
9
|
+
stream.write(text);
|
|
10
|
+
if (!text.endsWith("\n"))
|
|
11
|
+
stream.write("\n");
|
|
12
|
+
}
|
|
13
|
+
export function errorMessage(error) {
|
|
14
|
+
return error instanceof Error ? error.message : String(error);
|
|
15
|
+
}
|
|
16
|
+
export function writeExecOutput(io, result, options) {
|
|
17
|
+
if (options.stdout)
|
|
18
|
+
writeRaw(io.stdout, result.stdout);
|
|
19
|
+
if (options.stderr)
|
|
20
|
+
writeRaw(io.stderr, result.stderr);
|
|
21
|
+
}
|
|
22
|
+
export function isInteractiveStdin(io) {
|
|
23
|
+
return Boolean(io.stdin.isTTY);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reads one line from stdin after writing a prompt. Shared by the onboard email
|
|
27
|
+
* prompt and the autostart prompt; callers gate on `isInteractiveStdin` first so
|
|
28
|
+
* headless / piped / spawned runs never block on input.
|
|
29
|
+
*/
|
|
30
|
+
export async function readLine(io, prompt) {
|
|
31
|
+
io.stdout.write(prompt);
|
|
32
|
+
io.stdin.setEncoding("utf8");
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const onData = (chunk) => {
|
|
35
|
+
io.stdin.removeListener("data", onData);
|
|
36
|
+
io.stdin.pause();
|
|
37
|
+
resolve(chunk);
|
|
38
|
+
};
|
|
39
|
+
io.stdin.resume();
|
|
40
|
+
io.stdin.on("data", onData);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** Enter means yes: only an explicit `n`/`no` declines. */
|
|
44
|
+
export function yesByDefault(raw) {
|
|
45
|
+
const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
|
|
46
|
+
return answer !== "n" && answer !== "no";
|
|
47
|
+
}
|
|
48
|
+
/** Collects what a nested command writes instead of letting it reach the user. */
|
|
49
|
+
export function bufferedWritable(chunks) {
|
|
50
|
+
return {
|
|
51
|
+
write(chunk) {
|
|
52
|
+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function replayCaptured(stream, chunks) {
|
|
58
|
+
for (const chunk of chunks)
|
|
59
|
+
stream.write(chunk);
|
|
60
|
+
}
|
|
61
|
+
/** Captured `--json` stdout, parsed when it is JSON and handed back raw when it is not. */
|
|
62
|
+
export function parseCapturedJson(chunks) {
|
|
63
|
+
const text = chunks.join("").trim();
|
|
64
|
+
if (!text)
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(text);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function defaultExec() {
|
|
74
|
+
return createCapturedExecRunner();
|
|
75
|
+
}
|
|
76
|
+
export function defaultInteractiveExec() {
|
|
77
|
+
return createInteractiveExecRunner();
|
|
78
|
+
}
|
|
79
|
+
export function defaultIo() {
|
|
80
|
+
if (!globalThis.fetch) {
|
|
81
|
+
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
stdin: process.stdin,
|
|
85
|
+
stdout: process.stdout,
|
|
86
|
+
stderr: process.stderr,
|
|
87
|
+
env: process.env,
|
|
88
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
89
|
+
exec: defaultExec(),
|
|
90
|
+
interactiveExec: defaultInteractiveExec(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { writeLine } from "./cli-io.js";
|
|
2
|
+
import { matchesLiveSyncWorktree, } from "./session-sync.js";
|
|
3
|
+
export function shortSha(value) {
|
|
4
|
+
return value ? value.slice(0, 12) : "unknown";
|
|
5
|
+
}
|
|
6
|
+
export function displayTicketId(ticketId) {
|
|
7
|
+
return ticketId ?? "none (general work)";
|
|
8
|
+
}
|
|
9
|
+
export function displayWorkLabel(status) {
|
|
10
|
+
if (status.work_label && status.work_id)
|
|
11
|
+
return `${status.work_label} (${status.work_id})`;
|
|
12
|
+
return status.work_label ?? status.work_id ?? "no active work context";
|
|
13
|
+
}
|
|
14
|
+
function sourceFunnelLine(label, counts) {
|
|
15
|
+
// Same counters, plain words. Every counter still prints, including the ones
|
|
16
|
+
// that are zero: a funnel that hides a bucket cannot show where sessions go.
|
|
17
|
+
const readFailures = counts.read_failures > 0 ? `, ${counts.read_failures} could not be read` : "";
|
|
18
|
+
return `${label} sessions: ${counts.attributed} matched to a repo, ${counts.attributed_fallback} by best guess, ${counts.ambiguous} unclear which repo, ${counts.unattributed} could not be matched, ${counts.stale} too old, ${counts.skipped} skipped${readFailures}`;
|
|
19
|
+
}
|
|
20
|
+
function attributionReportLine(summary) {
|
|
21
|
+
// The parenthesised value is the machine reason label and never changes.
|
|
22
|
+
return summary.report_posted
|
|
23
|
+
? "Session report: saved"
|
|
24
|
+
: `Session report: skipped (${summary.report_reason})`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One funnel line per source, an anomaly diagnostics line only when something
|
|
28
|
+
* is nonzero (clean syncs stay one line per source), and the report line.
|
|
29
|
+
* Counts only — project-dir slugs encode full local paths and never print
|
|
30
|
+
* (B.4 §5).
|
|
31
|
+
*/
|
|
32
|
+
export function writeAgentSessionSummary(io, summary) {
|
|
33
|
+
writeLine(io.stdout, sourceFunnelLine("Codex", summary.codex));
|
|
34
|
+
writeLine(io.stdout, sourceFunnelLine("Claude", summary.claude));
|
|
35
|
+
const claudeDiagnostics = claudeDiagnosticsLine(summary);
|
|
36
|
+
if (claudeDiagnostics)
|
|
37
|
+
writeLine(io.stdout, claudeDiagnostics);
|
|
38
|
+
writeLine(io.stdout, attributionReportLine(summary));
|
|
39
|
+
}
|
|
40
|
+
function claudeDiagnosticsLine(summary) {
|
|
41
|
+
// Anomaly-only (D34): sidecars_collected/uploaded are normal-operation
|
|
42
|
+
// counters and must NOT trigger this line, or a healthy orchestrated sync
|
|
43
|
+
// prints it 48×/day in launchd logs. Clean syncs stay one line per source.
|
|
44
|
+
const claude = summary.claude;
|
|
45
|
+
const parts = [];
|
|
46
|
+
if (claude.sidecars_skipped)
|
|
47
|
+
parts.push(`${claude.sidecars_skipped} helper sessions skipped`);
|
|
48
|
+
if (claude.sidecars_capped)
|
|
49
|
+
parts.push(`${claude.sidecars_capped} helper sessions cut short`);
|
|
50
|
+
if (claude.sidecars_failed)
|
|
51
|
+
parts.push(`${claude.sidecars_failed} helper sessions could not be read`);
|
|
52
|
+
if (claude.mains_oversized)
|
|
53
|
+
parts.push(`${claude.mains_oversized} sessions too big`);
|
|
54
|
+
if (claude.oversized_lines_skipped)
|
|
55
|
+
parts.push(`${claude.oversized_lines_skipped} oversized lines skipped`);
|
|
56
|
+
if (claude.project_dirs_skipped)
|
|
57
|
+
parts.push(`${claude.project_dirs_skipped} folders skipped`);
|
|
58
|
+
if (claude.sessions_schema_drift)
|
|
59
|
+
parts.push(`${claude.sessions_schema_drift} sessions in an unexpected format`);
|
|
60
|
+
if (claude.growth_damped)
|
|
61
|
+
parts.push(`${claude.growth_damped} fast-growing sessions slowed down`);
|
|
62
|
+
if (claude.first_run_backfill)
|
|
63
|
+
parts.push("first run, catching up on history");
|
|
64
|
+
if (summary.files_deferred_byte_budget)
|
|
65
|
+
parts.push(`${summary.files_deferred_byte_budget} files held back, size limit`);
|
|
66
|
+
if (summary.files_deferred_object_budget)
|
|
67
|
+
parts.push(`${summary.files_deferred_object_budget} files held back, file-count limit`);
|
|
68
|
+
return parts.length > 0 ? `Claude problems: ${parts.join(", ")}` : null;
|
|
69
|
+
}
|
|
70
|
+
export function rawEvidenceSyncLine(sync) {
|
|
71
|
+
// The reason lists themselves are machine labels and stay verbatim.
|
|
72
|
+
const failures = sync.raw_evidence_failure_reasons.length > 0
|
|
73
|
+
? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
|
|
74
|
+
: "";
|
|
75
|
+
const retries = sync.raw_evidence_retry_reasons.length > 0
|
|
76
|
+
? ` retry_required: ${sync.raw_evidence_retry_reasons.join(",")}`
|
|
77
|
+
: "";
|
|
78
|
+
// A held object and a nine-day-old first failure both belong on this line.
|
|
79
|
+
// Neither used to appear anywhere, which is how a 1,030-attempt loop stayed
|
|
80
|
+
// invisible (BLI-3066).
|
|
81
|
+
const held = sync.raw_evidence_delivery_held_count > 0
|
|
82
|
+
? `, ${sync.raw_evidence_delivery_held_count} waiting`
|
|
83
|
+
: "";
|
|
84
|
+
const stuck = sync.raw_evidence_stuck_object_count > 0
|
|
85
|
+
? `, ${sync.raw_evidence_stuck_object_count} stuck (${sync.raw_evidence_max_delivery_attempts} tries since ${sync.raw_evidence_oldest_delivery_failure_at ?? "unknown"})`
|
|
86
|
+
: "";
|
|
87
|
+
return `Files: ${sync.raw_evidence_uploaded_object_count} uploaded, ${sync.raw_evidence_reused_count} already there, ${sync.raw_evidence_failed_count} failed${held}${stuck}${failures}${retries}`;
|
|
88
|
+
}
|
|
89
|
+
export function cursorStatusLine(sync) {
|
|
90
|
+
return `Tracked so far: ${sync.cursor_tracked_object_count} uploaded item(s)`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* One line that cannot say "fine" while an object has never been accepted.
|
|
94
|
+
* Named reasons, worst attempt count, and the date it started.
|
|
95
|
+
*/
|
|
96
|
+
export function stuckEvidenceLine(status) {
|
|
97
|
+
if (status.stuck_evidence_object_count === 0)
|
|
98
|
+
return "none";
|
|
99
|
+
return `${status.stuck_evidence_object_count} object(s), ${status.stuck_evidence_held_count} held, worst ${status.stuck_evidence_max_attempts} attempt(s) since ${status.stuck_evidence_oldest_failure_at ?? "unknown"} (${status.stuck_evidence_reasons.join(",") || "unknown"})`;
|
|
100
|
+
}
|
|
101
|
+
/** The `--json` row for one synced worktree, shared by onboard and sync. */
|
|
102
|
+
export function worktreeSyncRow(outcome, run) {
|
|
103
|
+
const { worktree, context, sync } = outcome;
|
|
104
|
+
const matchesWorktree = (result) => matchesLiveSyncWorktree(result, worktree);
|
|
105
|
+
const codexSessionCount = run.codexAttribution.results.filter(matchesWorktree).length;
|
|
106
|
+
const claudeSessionCount = run.claudeAttribution.results.filter(matchesWorktree).length;
|
|
107
|
+
const attributedSessionCount = codexSessionCount + claudeSessionCount;
|
|
108
|
+
return {
|
|
109
|
+
repo_label: context?.repo_label ?? worktree.repo_label,
|
|
110
|
+
repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
|
|
111
|
+
worktree_label: context?.worktree_label ?? worktree.worktree_label,
|
|
112
|
+
worktree_fingerprint: context?.worktree_fingerprint ?? worktree.worktree_fingerprint,
|
|
113
|
+
branch: context?.branch ?? worktree.branch,
|
|
114
|
+
head_sha: sync.head_sha ?? worktree.head_sha,
|
|
115
|
+
work_context_id: context?.work_context_id ?? sync.work_context_id,
|
|
116
|
+
upload_status: sync.status,
|
|
117
|
+
raw_evidence_file_count: sync.raw_evidence_file_count,
|
|
118
|
+
raw_evidence_uploaded_object_count: sync.raw_evidence_uploaded_object_count,
|
|
119
|
+
raw_evidence_uploaded_chunk_count: sync.raw_evidence_uploaded_chunk_count,
|
|
120
|
+
raw_evidence_reused_count: sync.raw_evidence_reused_count,
|
|
121
|
+
raw_evidence_failed_count: sync.raw_evidence_failed_count,
|
|
122
|
+
raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
|
|
123
|
+
raw_evidence_retry_required: sync.raw_evidence_retry_required,
|
|
124
|
+
raw_evidence_retry_reasons: sync.raw_evidence_retry_reasons,
|
|
125
|
+
attributed_session_count: attributedSessionCount,
|
|
126
|
+
codex_session_count: codexSessionCount,
|
|
127
|
+
claude_session_count: claudeSessionCount,
|
|
128
|
+
cursor_tracked_object_count: sync.cursor_tracked_object_count,
|
|
129
|
+
failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Green only when the whole run is green; an accepted upload with partial collection is `partial`. */
|
|
133
|
+
export function attributedSyncRunStatus(run) {
|
|
134
|
+
if (run.ok)
|
|
135
|
+
return "uploaded";
|
|
136
|
+
return run.outcomes.every((outcome) => outcome.sync.status === "uploaded")
|
|
137
|
+
? "partial"
|
|
138
|
+
: "spooled";
|
|
139
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The folders this machine is allowed to collect from: resolving them, saving
|
|
3
|
+
* them, and reading them back to prove setup actually worked.
|
|
4
|
+
*
|
|
5
|
+
* Widening the boundary is a consent decision a person makes, never a fix an
|
|
6
|
+
* agent applies — so everything here either asks or reports, and the read-back
|
|
7
|
+
* fails loudly rather than handing over a machine that will never collect.
|
|
8
|
+
* Split out of commands/local.ts (BLI-3104); moved verbatim.
|
|
9
|
+
*/
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { stat } from "node:fs/promises";
|
|
13
|
+
import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
|
|
14
|
+
import { COLLECTION_ROOT_REQUIRED, resolveOnboardingRoots, } from "../onboarding-roots.js";
|
|
15
|
+
import { getCollectorRuntimePaths, installLocalCollector, readLocalCollectorConfig, } from "../local-state.js";
|
|
16
|
+
import { collectionRootPathAliases } from "../repo-identity.js";
|
|
17
|
+
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
18
|
+
export function onboardingRootPrompt(io) {
|
|
19
|
+
return {
|
|
20
|
+
confirm: async (message) => yesByDefault(await readLine(io, message)),
|
|
21
|
+
input: (message) => readLine(io, message),
|
|
22
|
+
message: (message) => writeLine(io.stdout, message),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export async function resolveOnboardingRootsForCommand(command, io) {
|
|
26
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
27
|
+
const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
|
|
28
|
+
const interactive = !command.json && isInteractiveStdin(io);
|
|
29
|
+
const rootsResult = await resolveOnboardingRoots({
|
|
30
|
+
homeDir: command.homeDir,
|
|
31
|
+
explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
|
|
32
|
+
config: existingConfig,
|
|
33
|
+
interactive,
|
|
34
|
+
allowHomeRoot: command.allowHomeRoot,
|
|
35
|
+
prompt: interactive ? onboardingRootPrompt(io) : undefined,
|
|
36
|
+
});
|
|
37
|
+
const collectionRoots = rootsResult.roots;
|
|
38
|
+
const primaryRoot = collectionRoots[0];
|
|
39
|
+
if (!primaryRoot) {
|
|
40
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
|
|
41
|
+
}
|
|
42
|
+
const replaceRepoRoots = rootsResult.source === "prompt" &&
|
|
43
|
+
!command.collectionRoots?.length &&
|
|
44
|
+
(existingConfig?.default_repo_paths.length ?? 0) > 0;
|
|
45
|
+
return {
|
|
46
|
+
existingConfig,
|
|
47
|
+
rootsResult,
|
|
48
|
+
collectionRoots,
|
|
49
|
+
primaryRoot,
|
|
50
|
+
replaceRepoRoots,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export async function persistOnboardingRootConfig(command, resolution) {
|
|
54
|
+
const result = await installLocalCollector({
|
|
55
|
+
homeDir: command.homeDir,
|
|
56
|
+
repoRoot: resolution.primaryRoot,
|
|
57
|
+
repoRoots: resolution.collectionRoots,
|
|
58
|
+
replaceRepoRoots: resolution.replaceRepoRoots,
|
|
59
|
+
dashboardUrl: command.dashboardUrl,
|
|
60
|
+
deviceName: command.deviceName,
|
|
61
|
+
});
|
|
62
|
+
await assertCollectionRootPersisted(command.homeDir);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Setup does not get to claim success on its own say-so.
|
|
67
|
+
*
|
|
68
|
+
* Onboarding used to write the config and report success without ever reading
|
|
69
|
+
* it back. Savina's onboard did exactly that, persisted nothing, and every
|
|
70
|
+
* scheduled sync afterwards threw `collection_root_required` into a log nobody
|
|
71
|
+
* reads — twelve consecutive failures, six days at 3 uploaded of 195, found
|
|
72
|
+
* only by hand-querying the database. BLI-1986 fixed one path into that state;
|
|
73
|
+
* this closes the state itself.
|
|
74
|
+
*
|
|
75
|
+
* So we read the config back through the SAME resolution the scheduled sync
|
|
76
|
+
* will use, and fail here — in front of a human who can still fix it — rather
|
|
77
|
+
* than silently handing back a machine that will never collect.
|
|
78
|
+
*/
|
|
79
|
+
export async function assertCollectionRootPersisted(homeDir) {
|
|
80
|
+
const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
|
|
81
|
+
const saved = normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
82
|
+
if (saved.length === 0) {
|
|
83
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootNotPersistedMessage(homeDir)}`);
|
|
84
|
+
}
|
|
85
|
+
// Present in the file is not the same as usable. A root that no longer
|
|
86
|
+
// exists on disk resolves to nothing at sync time, which is the same silent
|
|
87
|
+
// dead end arriving one step later.
|
|
88
|
+
const usable = [];
|
|
89
|
+
for (const root of saved) {
|
|
90
|
+
if (await directoryExists(root))
|
|
91
|
+
usable.push(root);
|
|
92
|
+
}
|
|
93
|
+
if (usable.length === 0) {
|
|
94
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootMissingOnDiskMessage(saved, homeDir)}`);
|
|
95
|
+
}
|
|
96
|
+
return usable;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Keep both spellings when an existing root is reached through a filesystem
|
|
100
|
+
* alias such as macOS `/var` -> `/private/var`. Deleted child paths cannot be
|
|
101
|
+
* realpathed later, so either transcript spelling must remain inside the same
|
|
102
|
+
* operator-approved physical root.
|
|
103
|
+
*/
|
|
104
|
+
export async function collectionRootConsentAliases(roots) {
|
|
105
|
+
return normalizeCollectionRoots(await collectionRootPathAliases(roots));
|
|
106
|
+
}
|
|
107
|
+
// Placeholders like <path-to-your-work-folder> make a person stop and think.
|
|
108
|
+
// These print real, paste-able commands with this machine's actual paths in
|
|
109
|
+
// them, so the fix is a copy away rather than a puzzle.
|
|
110
|
+
function collectionRootNotPersistedMessage(homeDir) {
|
|
111
|
+
const home = path.resolve(homeDir ?? os.homedir());
|
|
112
|
+
return [
|
|
113
|
+
"Setup finished without saving a collection root, so this machine would never collect anything.",
|
|
114
|
+
"Nothing was saved, so nothing is broken — setup just did not finish.",
|
|
115
|
+
"",
|
|
116
|
+
"Fix it by running ONE of these:",
|
|
117
|
+
"",
|
|
118
|
+
" # Sync everything on this machine (what most people want on a work laptop)",
|
|
119
|
+
" cockpit do-everything --allow-home-root",
|
|
120
|
+
"",
|
|
121
|
+
" # Or sync one folder — replace the path with where your projects live",
|
|
122
|
+
` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
|
|
123
|
+
"",
|
|
124
|
+
" # Or answer the folder question interactively",
|
|
125
|
+
" cockpit do-everything",
|
|
126
|
+
"",
|
|
127
|
+
"Then check it worked:",
|
|
128
|
+
" cockpit status",
|
|
129
|
+
].join("\n");
|
|
130
|
+
}
|
|
131
|
+
function collectionRootMissingOnDiskMessage(saved, homeDir) {
|
|
132
|
+
const home = path.resolve(homeDir ?? os.homedir());
|
|
133
|
+
return [
|
|
134
|
+
"Cockpit is set up to collect from a folder that is not on this machine:",
|
|
135
|
+
...saved.map((root) => ` ${root}`),
|
|
136
|
+
"",
|
|
137
|
+
"That usually means the folder was renamed, moved, or deleted since setup.",
|
|
138
|
+
"",
|
|
139
|
+
"Fix it by running ONE of these:",
|
|
140
|
+
"",
|
|
141
|
+
" # Point Cockpit at where your projects actually live now",
|
|
142
|
+
` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
|
|
143
|
+
"",
|
|
144
|
+
" # Or sync everything on this machine and stop worrying about the path",
|
|
145
|
+
" cockpit do-everything --allow-home-root",
|
|
146
|
+
"",
|
|
147
|
+
"Not sure where your projects are? This lists the folders Cockpit can see:",
|
|
148
|
+
" cockpit status",
|
|
149
|
+
].join("\n");
|
|
150
|
+
}
|
|
151
|
+
async function directoryExists(dir) {
|
|
152
|
+
return stat(dir).then((stats) => stats.isDirectory(), () => false);
|
|
153
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -175,7 +175,7 @@ async function readAuthState(context) {
|
|
|
175
175
|
if (session?.session_state === "valid" &&
|
|
176
176
|
typeof session.device_token === "string" &&
|
|
177
177
|
session.device_token) {
|
|
178
|
-
return ok("authed", "device_token_present", "
|
|
178
|
+
return ok("authed", "device_token_present", "this machine is signed in");
|
|
179
179
|
}
|
|
180
180
|
return hardStop("authed", "pairing_required", [
|
|
181
181
|
"device is not signed in.",
|
|
@@ -216,12 +216,12 @@ async function checkAutostartState(context) {
|
|
|
216
216
|
exec,
|
|
217
217
|
});
|
|
218
218
|
if (result.status === "loaded") {
|
|
219
|
-
return ok("autostart-alive", "already_installed", "
|
|
219
|
+
return ok("autostart-alive", "already_installed", "background sync is running");
|
|
220
220
|
}
|
|
221
221
|
if (result.status === "unsupported") {
|
|
222
222
|
return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
|
|
223
223
|
}
|
|
224
|
-
return needsFix("autostart-alive", result.status === "not_loaded" ? "not_loaded" : "absent", "
|
|
224
|
+
return needsFix("autostart-alive", result.status === "not_loaded" ? "not_loaded" : "absent", "background sync is not running");
|
|
225
225
|
}
|
|
226
226
|
async function fixAutostartState(context) {
|
|
227
227
|
const exec = context.io.exec;
|
|
@@ -241,7 +241,7 @@ async function fixAutostartState(context) {
|
|
|
241
241
|
if (result.loaded === false) {
|
|
242
242
|
return fail("autostart-alive", "autostart_load_failed", result.message ?? "operating-system scheduler load failed");
|
|
243
243
|
}
|
|
244
|
-
return ok("autostart-alive", "installed", "
|
|
244
|
+
return ok("autostart-alive", "installed", "background sync installed and running");
|
|
245
245
|
}
|
|
246
246
|
/**
|
|
247
247
|
* Pure so it can be unit-tested without touching the real machine's home
|
|
@@ -261,10 +261,10 @@ export function backfillCompletionStepState(marker, roots) {
|
|
|
261
261
|
}
|
|
262
262
|
const oversized = marker?.oversized_skips;
|
|
263
263
|
if (oversized && oversized.count > 0) {
|
|
264
|
-
return ok("backfill-complete", "complete_with_oversized_skips", `
|
|
265
|
-
`(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"}
|
|
264
|
+
return ok("backfill-complete", "complete_with_oversized_skips", `caught up on old Codex and Claude sessions in every saved folder ` +
|
|
265
|
+
`(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"} too big to upload)`);
|
|
266
266
|
}
|
|
267
|
-
return ok("backfill-complete", "complete", "
|
|
267
|
+
return ok("backfill-complete", "complete", "caught up on old Codex and Claude sessions in every saved folder");
|
|
268
268
|
}
|
|
269
269
|
async function checkBackfillState(context) {
|
|
270
270
|
const paths = getCollectorRuntimePaths();
|
|
@@ -275,10 +275,10 @@ async function checkBackfillState(context) {
|
|
|
275
275
|
return covered;
|
|
276
276
|
const lock = await inspectBackfillLock(paths);
|
|
277
277
|
if (lock.held) {
|
|
278
|
-
return needsFix("backfill-complete", "backfill_already_running", `
|
|
278
|
+
return needsFix("backfill-complete", "backfill_already_running", `another catch-up run is still going, and this one has not finished yet (running since ${lock.held_since ?? "unknown"})`);
|
|
279
279
|
}
|
|
280
280
|
const cursor = await readBackfillCursor(paths);
|
|
281
|
-
return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "
|
|
281
|
+
return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "the catch-up over your old sessions has not finished");
|
|
282
282
|
}
|
|
283
283
|
async function fixBackfillState(context) {
|
|
284
284
|
const capture = capturedIo(context.io, !context.command.json);
|
|
@@ -308,20 +308,20 @@ async function fixBackfillState(context) {
|
|
|
308
308
|
}
|
|
309
309
|
async function checkGcState(context) {
|
|
310
310
|
if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
|
|
311
|
-
return skipped("gc-checked", "skipped_disabled", "
|
|
311
|
+
return skipped("gc-checked", "skipped_disabled", "cleanup is switched off");
|
|
312
312
|
}
|
|
313
313
|
const paths = getCollectorRuntimePaths();
|
|
314
314
|
const marker = path.join(paths.state_dir, ".last-raw-evidence-gc");
|
|
315
315
|
const info = await fs.stat(marker).catch(() => null);
|
|
316
316
|
if (info && Date.now() - info.mtimeMs < GC_MIN_INTERVAL_MS) {
|
|
317
|
-
return skipped("gc-checked", "skipped_throttled", "
|
|
317
|
+
return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
|
|
318
318
|
}
|
|
319
|
-
return needsFix("gc-checked", "due", "
|
|
319
|
+
return needsFix("gc-checked", "due", "cleanup is due");
|
|
320
320
|
}
|
|
321
321
|
async function fixGcState(context) {
|
|
322
322
|
const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(), context.io.env);
|
|
323
323
|
if (result.skipped) {
|
|
324
|
-
return skipped("gc-checked", "skipped_throttled", "
|
|
324
|
+
return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
|
|
325
325
|
}
|
|
326
326
|
if (result.removed_dirs === 0) {
|
|
327
327
|
return ok("gc-checked", "nothing_eligible", rawEvidenceGcSummary(result));
|
|
@@ -422,8 +422,8 @@ async function fixSyncState(context) {
|
|
|
422
422
|
if (result.code !== 0) {
|
|
423
423
|
const draining = syncBacklogDrainingVerdict(parsed);
|
|
424
424
|
if (draining) {
|
|
425
|
-
return needsFix("sync-fresh", "backlog_draining", `${repoRoot}:
|
|
426
|
-
`
|
|
425
|
+
return needsFix("sync-fresh", "backlog_draining", `${repoRoot}: still catching up (${draining.remainingObjects} ` +
|
|
426
|
+
`file${draining.remainingObjects === 1 ? "" : "s"} left for later this run); ` +
|
|
427
427
|
"rerun `cockpit sync` to continue");
|
|
428
428
|
}
|
|
429
429
|
return fail("sync-fresh", status ?? "sync_failed", `sync failed for ${repoRoot}`);
|
|
@@ -464,9 +464,11 @@ function writeDoctorOutput(command, io, rows) {
|
|
|
464
464
|
return;
|
|
465
465
|
}
|
|
466
466
|
writeLine(io.stdout, command.dryRun ? "Cockpit doctor dry-run" : "Cockpit doctor");
|
|
467
|
-
|
|
467
|
+
// The machine `code` stays in `--json`; a human reading the table wants the
|
|
468
|
+
// sentence, not the label (BLI-3194).
|
|
469
|
+
writeLine(io.stdout, "state step result");
|
|
468
470
|
for (const row of rows) {
|
|
469
|
-
writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${
|
|
471
|
+
writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${oneLine(row.message)}`);
|
|
470
472
|
}
|
|
471
473
|
const explanations = rows.filter((row) => (row.hardStop || row.status === "fail") && row.message.includes("\n"));
|
|
472
474
|
for (const row of explanations) {
|