@bli-cockpit/cli 0.2.47 → 0.2.49
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/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
- package/dist/adapters/raw-evidence.js +360 -349
- package/dist/autostart-contract.js +79 -0
- package/dist/autostart-darwin-plist.js +265 -0
- package/dist/autostart-darwin.js +171 -0
- package/dist/autostart-windows-scripts.js +310 -0
- package/dist/autostart-windows-task-xml.js +260 -0
- package/dist/autostart-windows.js +237 -0
- package/dist/autostart-xml.js +23 -0
- package/dist/autostart.js +35 -1148
- package/dist/commands/agent-rules-command.js +55 -0
- package/dist/commands/agent-session-report.js +290 -0
- package/dist/commands/analyze.js +131 -0
- package/dist/commands/autostart-command.js +105 -0
- package/dist/commands/backfill.js +824 -551
- package/dist/commands/cli-io.js +13 -0
- package/dist/commands/heartbeat.js +18 -0
- package/dist/commands/install-receipts.js +34 -0
- package/dist/commands/jarvis.js +179 -3
- package/dist/commands/local-arg-values.js +169 -0
- package/dist/commands/local-args-collector.js +578 -0
- package/dist/commands/local-args-tower.js +870 -0
- package/dist/commands/local-args.js +8 -1549
- package/dist/commands/local-help.js +11 -3
- package/dist/commands/local.js +18 -1786
- package/dist/commands/login.js +53 -0
- package/dist/commands/logout.js +66 -0
- package/dist/commands/onboard-receipts.js +66 -0
- package/dist/commands/onboard-report.js +274 -0
- package/dist/commands/onboard.js +449 -0
- package/dist/commands/ops-render.js +36 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/serve.js +13 -0
- package/dist/commands/session-sync.js +513 -534
- package/dist/commands/settings-render.js +28 -0
- package/dist/commands/settings.js +66 -2
- package/dist/commands/start.js +47 -0
- package/dist/commands/sync-followups.js +203 -0
- package/dist/commands/sync.js +381 -0
- package/dist/dev-build.js +186 -0
- package/dist/tower-stream.js +20 -4
- package/package.json +2 -2
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { writeLine } from "./cli-io.js";
|
|
2
|
+
import { attributedSyncRunStatus, cursorStatusLine, displayTicketId, rawEvidenceSyncLine, shortSha, worktreeSyncRow, writeAgentSessionSummary, } from "./collection-report.js";
|
|
3
|
+
import { collectionRootConsentAliases } from "./collection-roots.js";
|
|
4
|
+
import { discoverCommandWorktrees } from "./local-discovery.js";
|
|
5
|
+
import { sendCollectorHeartbeatBestEffort, } from "./heartbeat.js";
|
|
6
|
+
import { classifySyncFailureRecords, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
|
|
7
|
+
import { runAttributedWorktreeSync, } from "./session-sync.js";
|
|
8
|
+
import { runAutostartSelfHealAfterSync, runScheduledSelfUpdateAfterSync, } from "./sync-followups.js";
|
|
9
|
+
import { describeError } from "../health-detail.js";
|
|
10
|
+
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
11
|
+
import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
|
|
12
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
13
|
+
import { CollectionRootRequiredError, } from "../onboarding-roots.js";
|
|
14
|
+
import { rawEvidenceDedupSummary, rawEvidenceGcSummary, runRawEvidenceLocalGc, sweepDuplicateStagedRawEvidence, } from "../raw-evidence-gc.js";
|
|
15
|
+
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
16
|
+
import { acquireSyncLock } from "../sync-lock.js";
|
|
17
|
+
export async function runSync(command, io) {
|
|
18
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
19
|
+
// BLI-3553, first thing in the tick: cap the scheduler's own logs. Nothing
|
|
20
|
+
// rotated them before, and two months of a 15-minute tick left 211 MB of
|
|
21
|
+
// sync.log on the reference Mac. Best-effort by construction — a rotation
|
|
22
|
+
// problem is its own log line, never a reason collection does not run.
|
|
23
|
+
await rotateCollectorLogsBestEffort(paths);
|
|
24
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
25
|
+
const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
|
|
26
|
+
const minCliVersionAtStart = await reportInstallEventsBestEffort({
|
|
27
|
+
homeDir: command.homeDir,
|
|
28
|
+
dashboardUrl,
|
|
29
|
+
command: "sync",
|
|
30
|
+
events: [{ step: "sync_started", status: "ok" }],
|
|
31
|
+
json: command.json,
|
|
32
|
+
io,
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
const result = await runSyncWithHealthReceipt(command, io);
|
|
36
|
+
// BLI-3551: every tick checks in, including one that collected nothing.
|
|
37
|
+
// This is the only writer of `last_seen_at` that does not need an envelope,
|
|
38
|
+
// so it is what separates a quiet machine from a dead one.
|
|
39
|
+
await sendSyncHeartbeat(command, io, dashboardUrl, result.heartbeat);
|
|
40
|
+
const minCliVersion = await reportInstallEventsBestEffort({
|
|
41
|
+
homeDir: command.homeDir,
|
|
42
|
+
dashboardUrl,
|
|
43
|
+
command: "sync",
|
|
44
|
+
events: [result.completion],
|
|
45
|
+
json: command.json,
|
|
46
|
+
io,
|
|
47
|
+
});
|
|
48
|
+
// BLI-2601: self-update runs only after collection's own outcome above is
|
|
49
|
+
// already decided and reported, win or lose. See the function doc.
|
|
50
|
+
await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
|
|
51
|
+
await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
|
|
52
|
+
return result.exitCode;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const errorCode = classifySyncHealthError(error);
|
|
56
|
+
// A machine whose sync THREW is still alive, and that is worth knowing —
|
|
57
|
+
// a device that stops checking in entirely is a different problem from one
|
|
58
|
+
// checking in with a failure every fifteen minutes.
|
|
59
|
+
await sendSyncHeartbeat(command, io, dashboardUrl, {
|
|
60
|
+
status: "fail",
|
|
61
|
+
reason: errorCode,
|
|
62
|
+
});
|
|
63
|
+
const minCliVersion = await reportInstallEventsBestEffort({
|
|
64
|
+
homeDir: command.homeDir,
|
|
65
|
+
dashboardUrl,
|
|
66
|
+
command: "sync",
|
|
67
|
+
events: [
|
|
68
|
+
{
|
|
69
|
+
step: "sync_complete",
|
|
70
|
+
status: "fail",
|
|
71
|
+
error_code: errorCode,
|
|
72
|
+
error_detail: redactedSyncErrorDetail(error),
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
json: command.json,
|
|
76
|
+
io,
|
|
77
|
+
});
|
|
78
|
+
await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
|
|
79
|
+
await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The tick's check-in (BLI-3551).
|
|
85
|
+
*
|
|
86
|
+
* Resolving the roots is best-effort on purpose: a machine with NO approved
|
|
87
|
+
* root is exactly the machine whose silence needs explaining, so it still
|
|
88
|
+
* checks in — with an empty root list, which is itself the finding.
|
|
89
|
+
*/
|
|
90
|
+
async function sendSyncHeartbeat(command, io, dashboardUrl, facts) {
|
|
91
|
+
const roots = await resolveSyncCollectionRoots(command).catch(() => []);
|
|
92
|
+
await sendCollectorHeartbeatBestEffort({
|
|
93
|
+
homeDir: command.homeDir,
|
|
94
|
+
dashboardUrl,
|
|
95
|
+
roots,
|
|
96
|
+
facts,
|
|
97
|
+
io,
|
|
98
|
+
}).catch((error) => {
|
|
99
|
+
// The sender already swallows everything it knows about; this is the net
|
|
100
|
+
// for anything it does not, because a heartbeat must never fail a sync.
|
|
101
|
+
console.error("[heartbeat] the check-in threw and was dropped", JSON.stringify({ reason: "heartbeat_threw", ...describeError(error) }));
|
|
102
|
+
return false;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async function runSyncWithHealthReceipt(command, io) {
|
|
106
|
+
const backfillLock = await inspectBackfillLock(getCollectorRuntimePaths(command.homeDir));
|
|
107
|
+
if (backfillLock.held) {
|
|
108
|
+
if (command.json) {
|
|
109
|
+
writeLine(io.stdout, JSON.stringify({
|
|
110
|
+
status: "live_sync_paused_during_backfill",
|
|
111
|
+
reason: "live sync paused during backfill",
|
|
112
|
+
held_since: backfillLock.held_since,
|
|
113
|
+
collection_complete: false,
|
|
114
|
+
upload_state: "not_uploaded",
|
|
115
|
+
retryable: true,
|
|
116
|
+
}, null, 2));
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
writeLine(io.stdout, "Pausing normal sync while it catches up on old sessions.");
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
exitCode: 0,
|
|
123
|
+
completion: {
|
|
124
|
+
step: "sync_complete",
|
|
125
|
+
status: "skipped",
|
|
126
|
+
error_code: "live_sync_paused_during_backfill",
|
|
127
|
+
},
|
|
128
|
+
heartbeat: {
|
|
129
|
+
status: "skipped",
|
|
130
|
+
reason: "live_sync_paused_during_backfill",
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Single-flight: a launchd timer and a manual sync must not interleave the
|
|
135
|
+
// cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
|
|
136
|
+
const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
|
|
137
|
+
if (!lock.acquired) {
|
|
138
|
+
if (command.json) {
|
|
139
|
+
writeLine(io.stdout, JSON.stringify({
|
|
140
|
+
status: "sync_already_running",
|
|
141
|
+
reason: "another sync owns the collection lock",
|
|
142
|
+
held_since: lock.held_since,
|
|
143
|
+
collection_complete: false,
|
|
144
|
+
upload_state: "not_uploaded",
|
|
145
|
+
retryable: true,
|
|
146
|
+
}, null, 2));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
writeLine(io.stdout, "Tower sync already running; skipping this run.");
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
exitCode: 0,
|
|
153
|
+
completion: {
|
|
154
|
+
step: "sync_complete",
|
|
155
|
+
status: "skipped",
|
|
156
|
+
error_code: "sync_already_running",
|
|
157
|
+
},
|
|
158
|
+
heartbeat: { status: "skipped", reason: "sync_already_running" },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const { exitCode, failureReasons, failureRecords, notice, sessionsObserved, sessionsOutsideRoot, } = await runSyncLocked(command, io);
|
|
163
|
+
const counts = {
|
|
164
|
+
sessionsObserved,
|
|
165
|
+
sessionsOutsideRoot,
|
|
166
|
+
};
|
|
167
|
+
if (exitCode === 0) {
|
|
168
|
+
// BLI-3551: an `ok` tick can still have something to say. `nothing_in_root`
|
|
169
|
+
// is the receipt that separates "this machine is alive and its operator
|
|
170
|
+
// works outside the approved roots" from "this machine is dead", which
|
|
171
|
+
// until now looked identical from the dashboard.
|
|
172
|
+
return {
|
|
173
|
+
exitCode,
|
|
174
|
+
completion: {
|
|
175
|
+
step: "sync_complete",
|
|
176
|
+
status: "ok",
|
|
177
|
+
...(notice ? { error_detail: notice } : {}),
|
|
178
|
+
},
|
|
179
|
+
heartbeat: { status: "ok", reason: notice, ...counts },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// A sync that fails by exit code says exactly as much as one that throws.
|
|
183
|
+
// It used to say `sync_failed` and nothing else, so 100% of recorded
|
|
184
|
+
// failure rows carried a null detail and the real reason was reachable only
|
|
185
|
+
// by running `cockpit status` on the machine itself (BLI-2526).
|
|
186
|
+
const reasonText = failureReasons.join("; ");
|
|
187
|
+
// The bucket comes from the records the deciding branches wrote, not from
|
|
188
|
+
// this sentence (BLI-3551). The sentence is still the detail.
|
|
189
|
+
const errorCode = classifySyncFailureRecords(failureRecords);
|
|
190
|
+
return {
|
|
191
|
+
exitCode,
|
|
192
|
+
completion: {
|
|
193
|
+
step: "sync_complete",
|
|
194
|
+
status: "fail",
|
|
195
|
+
error_code: errorCode,
|
|
196
|
+
error_detail: redactedSyncErrorDetail(reasonText),
|
|
197
|
+
},
|
|
198
|
+
heartbeat: { status: "fail", reason: errorCode, ...counts },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await lock.handle.release();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Turn a finished run into an exit code and the reasons behind it.
|
|
207
|
+
*
|
|
208
|
+
* One place, so a future return path cannot reintroduce a code with no reason.
|
|
209
|
+
* The reasons come from the run itself — the code that decided `ok` is false is
|
|
210
|
+
* the only code that knows why.
|
|
211
|
+
*/
|
|
212
|
+
function syncResult(run) {
|
|
213
|
+
return {
|
|
214
|
+
exitCode: run.ok ? 0 : 1,
|
|
215
|
+
failureReasons: run.ok ? [] : run.failure_reasons,
|
|
216
|
+
failureRecords: run.ok ? [] : run.failure_records,
|
|
217
|
+
notice: run.notice,
|
|
218
|
+
sessionsObserved: run.sessions_observed,
|
|
219
|
+
sessionsOutsideRoot: run.sessions_outside_root,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The sync runbook: resolve which roots to collect, dedup staged packs before
|
|
224
|
+
* touching anything else, discover worktrees, sync them, then report — one of
|
|
225
|
+
* three shapes depending on how many worktrees came back. The three shapes
|
|
226
|
+
* share nothing but `dedup`, so each gets its own step function below rather
|
|
227
|
+
* than one branchy body.
|
|
228
|
+
*/
|
|
229
|
+
async function runSyncLocked(command, io) {
|
|
230
|
+
const collectionRoots = await resolveSyncCollectionRoots(command);
|
|
231
|
+
// Before anything is collected: collapse byte-identical staged packs. It runs
|
|
232
|
+
// first, unconditionally and unthrottled, because a machine that already
|
|
233
|
+
// holds 559 copies of one rollout needs the disk back before it stages
|
|
234
|
+
// anything else (BLI-3066). Safe by construction — a duplicate is identical
|
|
235
|
+
// by content hash to the survivor.
|
|
236
|
+
const dedup = await sweepDuplicateStagedRawEvidence(getCollectorRuntimePaths(command.homeDir), io.env);
|
|
237
|
+
if (!dedup.skipped && dedup.removed_dirs > 0) {
|
|
238
|
+
writeLine(io.stdout, rawEvidenceDedupSummary(dedup));
|
|
239
|
+
}
|
|
240
|
+
const worktrees = await discoverCommandWorktrees(collectionRoots, {
|
|
241
|
+
maxDepth: command.maxDepth,
|
|
242
|
+
maxRepos: command.maxRepos,
|
|
243
|
+
homeDir: command.homeDir,
|
|
244
|
+
allowEmpty: true,
|
|
245
|
+
}, io);
|
|
246
|
+
const run = await runAttributedWorktreeSync({
|
|
247
|
+
homeDir: command.homeDir,
|
|
248
|
+
dashboardUrl: command.dashboardUrl,
|
|
249
|
+
collectionRoots,
|
|
250
|
+
startContexts: false,
|
|
251
|
+
worktrees,
|
|
252
|
+
fetchImpl: io.fetch,
|
|
253
|
+
});
|
|
254
|
+
if (run.outcomes.length > 1) {
|
|
255
|
+
return reportMultiRepoSync(command, io, run, dedup);
|
|
256
|
+
}
|
|
257
|
+
// Zero worktrees is a legitimate steady state, not a failure: an approved
|
|
258
|
+
// root can hold no git repos, and sessions upload independently of
|
|
259
|
+
// worktrees (session-first, BLI-2581). This used to throw "Sync produced no
|
|
260
|
+
// result", which painted ~90 false-red sync_failed receipts per day on one
|
|
261
|
+
// fleet machine with a single empty root and taught people to ignore
|
|
262
|
+
// sync_failed (BLI-2722). A genuinely broken run still fails via run.ok.
|
|
263
|
+
if (run.outcomes.length === 0) {
|
|
264
|
+
return reportNoWorktreeSync(command, io, run, dedup);
|
|
265
|
+
}
|
|
266
|
+
return reportSingleRepoSync(command, io, run, dedup);
|
|
267
|
+
}
|
|
268
|
+
async function reportMultiRepoSync(command, io, run, dedup) {
|
|
269
|
+
const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
|
|
270
|
+
const collectionRunStatus = attributedSyncRunStatus(run);
|
|
271
|
+
const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
|
|
272
|
+
if (command.json) {
|
|
273
|
+
writeLine(io.stdout, JSON.stringify({
|
|
274
|
+
mode: "multi_repo",
|
|
275
|
+
status: collectionRunStatus,
|
|
276
|
+
collection_complete: run.ok,
|
|
277
|
+
results: run.outcomes.map((outcome) => outcome.sync),
|
|
278
|
+
repos: rows,
|
|
279
|
+
codex_sessions: run.summary,
|
|
280
|
+
raw_evidence_gc: gc,
|
|
281
|
+
raw_evidence_dedup: dedup,
|
|
282
|
+
}, null, 2));
|
|
283
|
+
return syncResult(run);
|
|
284
|
+
}
|
|
285
|
+
writeLine(run.ok ? io.stdout : io.stderr, `Tower parent sync ${collectionRunStatus} ${run.outcomes.filter((outcome) => outcome.sync.status === "uploaded").length}/${run.outcomes.length} worktree(s).`);
|
|
286
|
+
for (const outcome of run.outcomes) {
|
|
287
|
+
const { worktree, sync } = outcome;
|
|
288
|
+
const uploaded = sync.status === "uploaded";
|
|
289
|
+
const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
|
|
290
|
+
writeLine(uploaded ? io.stdout : io.stderr, `- ${worktree.repo_label}/${worktree.worktree_label} (${worktree.branch}) head:${shortSha(sync.head_sha ?? worktree.head_sha)} ${sync.status} objects:${sync.raw_evidence_uploaded_object_count} chunks:${sync.raw_evidence_uploaded_chunk_count} reused:${sync.raw_evidence_reused_count} failed:${sync.raw_evidence_failed_count} cursor:${sync.cursor_tracked_object_count}${failureSuffix}`);
|
|
291
|
+
}
|
|
292
|
+
writeAgentSessionSummary(io, run.summary);
|
|
293
|
+
if (gc && !gc.skipped)
|
|
294
|
+
writeLine(io.stdout, rawEvidenceGcSummary(gc));
|
|
295
|
+
return syncResult(run);
|
|
296
|
+
}
|
|
297
|
+
async function reportNoWorktreeSync(command, io, run, dedup) {
|
|
298
|
+
const collectionRunStatus = attributedSyncRunStatus(run);
|
|
299
|
+
const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
|
|
300
|
+
if (command.json) {
|
|
301
|
+
writeLine(io.stdout, JSON.stringify({
|
|
302
|
+
mode: "no_worktrees",
|
|
303
|
+
status: collectionRunStatus,
|
|
304
|
+
collection_complete: run.ok,
|
|
305
|
+
...(run.notice ? { notice: run.notice } : {}),
|
|
306
|
+
codex_sessions: run.summary,
|
|
307
|
+
raw_evidence_gc: gc,
|
|
308
|
+
raw_evidence_dedup: dedup,
|
|
309
|
+
}, null, 2));
|
|
310
|
+
return syncResult(run);
|
|
311
|
+
}
|
|
312
|
+
writeLine(run.ok ? io.stdout : io.stderr, `Tower sync ${collectionRunStatus}: no git worktrees under this root; session scan ran.`);
|
|
313
|
+
if (run.notice) {
|
|
314
|
+
// Says out loud what the receipt now says to the dashboard: the sessions
|
|
315
|
+
// this machine ran were all outside the folders it is allowed to look at.
|
|
316
|
+
writeLine(io.stdout, `Every session seen this run was outside your approved folders (${run.notice}). Nothing was collected, and nothing is broken.`);
|
|
317
|
+
}
|
|
318
|
+
writeAgentSessionSummary(io, run.summary);
|
|
319
|
+
if (gc && !gc.skipped)
|
|
320
|
+
writeLine(io.stdout, rawEvidenceGcSummary(gc));
|
|
321
|
+
return syncResult(run);
|
|
322
|
+
}
|
|
323
|
+
async function reportSingleRepoSync(command, io, run, dedup) {
|
|
324
|
+
const result = run.outcomes[0]?.sync;
|
|
325
|
+
if (!result) {
|
|
326
|
+
throw new Error("Sync produced no result for the repo worktree.");
|
|
327
|
+
}
|
|
328
|
+
const collectionRunStatus = attributedSyncRunStatus(run);
|
|
329
|
+
const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
|
|
330
|
+
if (command.json) {
|
|
331
|
+
writeLine(io.stdout, JSON.stringify({
|
|
332
|
+
...result,
|
|
333
|
+
status: collectionRunStatus,
|
|
334
|
+
collection_complete: run.ok,
|
|
335
|
+
codex_sessions: run.summary,
|
|
336
|
+
raw_evidence_gc: gc,
|
|
337
|
+
raw_evidence_dedup: dedup,
|
|
338
|
+
}, null, 2));
|
|
339
|
+
return syncResult(run);
|
|
340
|
+
}
|
|
341
|
+
if (run.ok) {
|
|
342
|
+
writeLine(io.stdout, "Tower uploaded this session.");
|
|
343
|
+
writeLine(io.stdout, `Ticket: ${displayTicketId(result.ticket_id)}`);
|
|
344
|
+
writeLine(io.stdout, `Context: ${result.work_context_id}`);
|
|
345
|
+
writeLine(io.stdout, `Head: ${shortSha(result.head_sha)}`);
|
|
346
|
+
writeLine(io.stdout, `Things recorded: ${result.event_count}`);
|
|
347
|
+
writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
|
|
348
|
+
writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
|
|
349
|
+
writeLine(io.stdout, rawEvidenceSyncLine(result));
|
|
350
|
+
writeAgentSessionSummary(io, run.summary);
|
|
351
|
+
writeLine(io.stdout, cursorStatusLine(result));
|
|
352
|
+
if (gc && !gc.skipped)
|
|
353
|
+
writeLine(io.stdout, rawEvidenceGcSummary(gc));
|
|
354
|
+
return syncResult(run);
|
|
355
|
+
}
|
|
356
|
+
if (result.status === "uploaded") {
|
|
357
|
+
writeLine(io.stderr, "Tower uploaded, but some sessions did not make it. Run `cockpit sync` again.");
|
|
358
|
+
writeAgentSessionSummary(io, run.summary);
|
|
359
|
+
return syncResult(run);
|
|
360
|
+
}
|
|
361
|
+
writeLine(io.stderr, "Tower could not upload. It saved a note to retry and will try again on the next sync.");
|
|
362
|
+
writeLine(io.stderr, `Ticket: ${displayTicketId(result.ticket_id)}`);
|
|
363
|
+
writeLine(io.stderr, `Failure: ${result.failure_reason}`);
|
|
364
|
+
writeLine(io.stderr, `Retry: ${result.retry_command}`);
|
|
365
|
+
return syncResult(run);
|
|
366
|
+
}
|
|
367
|
+
async function resolveSyncCollectionRoots(command) {
|
|
368
|
+
const explicitRoots = normalizeCollectionRoots(command.repoRoot ? [command.repoRoot] : []);
|
|
369
|
+
if (explicitRoots.length > 0) {
|
|
370
|
+
return collectionRootConsentAliases(explicitRoots);
|
|
371
|
+
}
|
|
372
|
+
const config = await readLocalCollectorConfig(getCollectorRuntimePaths(command.homeDir)).catch(() => null);
|
|
373
|
+
const savedRoots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
374
|
+
if (savedRoots.length > 0) {
|
|
375
|
+
return collectionRootConsentAliases(savedRoots);
|
|
376
|
+
}
|
|
377
|
+
throw new CollectionRootRequiredError(`no explicit or saved collection root is available.`);
|
|
378
|
+
}
|
|
379
|
+
async function runSyncRawEvidenceGc(command, io) {
|
|
380
|
+
return runRawEvidenceLocalGc(getCollectorRuntimePaths(command.homeDir), io.env);
|
|
381
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this collector a DEV BUILD? One predicate, two callers.
|
|
3
|
+
*
|
|
4
|
+
* BLI-3554. Edward's device row carried 47 `npm_install_eacces` receipts
|
|
5
|
+
* stamped `cli_version` 0.1.50 between 2026-09-02T16:55Z and 2026-09-03T06:25Z.
|
|
6
|
+
* 0.1.50 was never a published install on that machine — it was the workspace
|
|
7
|
+
* version in `packages/cockpit-local-collector/package.json` at the time, so
|
|
8
|
+
* every one of those rows came from an agent or a test running the built
|
|
9
|
+
* collector inside a worktree. They land in the same production
|
|
10
|
+
* `cockpit_install_events` table the fleet reader (BLI-3550,
|
|
11
|
+
* `apps/dashboard/src/lib/ops/fleet-liveness.ts`) alarms on, so a developer's
|
|
12
|
+
* sandbox failure reads as a fleet machine in trouble.
|
|
13
|
+
*
|
|
14
|
+
* The predicate is deliberately NOT a version comparison. A version equal to
|
|
15
|
+
* the workspace version is a symptom: it changes on every release, it is equal
|
|
16
|
+
* for the one machine that legitimately runs the newest published build the
|
|
17
|
+
* day it ships, and it says nothing about where the code was loaded from.
|
|
18
|
+
* What we can actually observe is the FILE the running code was loaded from:
|
|
19
|
+
*
|
|
20
|
+
* - a published install lives under a `node_modules/` prefix
|
|
21
|
+
* (`/opt/homebrew/lib/node_modules/@bli-cockpit/cli/dist/…`,
|
|
22
|
+
* `C:\\Users\\x\\AppData\\Roaming\\npm\\node_modules\\@bli-cockpit\\cli\\dist\\…`),
|
|
23
|
+
* and that is the whole fleet;
|
|
24
|
+
* - a dev build lives inside a checkout of this repo — a
|
|
25
|
+
* `packages/cockpit-local-collector/` (or `packages/cockpit-cli/`) ancestor
|
|
26
|
+
* whose `package.json` names the workspace, with a `.git` marker or a
|
|
27
|
+
* `.claude/worktrees` segment above it.
|
|
28
|
+
*
|
|
29
|
+
* `COCKPIT_DEV=1` forces dev. `COCKPIT_DEV=0` forces the opposite — that is the
|
|
30
|
+
* escape hatch for a deliberate live test from a checkout, documented in
|
|
31
|
+
* `docs/runbooks/cockpit-collector-receipts.md`.
|
|
32
|
+
*
|
|
33
|
+
* Suppression only bites when the target is a PRODUCTION dashboard. Pointing a
|
|
34
|
+
* checkout at `http://localhost:3000` is how the receipt path itself is
|
|
35
|
+
* developed, and refusing to post there would make this predicate the reason
|
|
36
|
+
* the next receipt bug cannot be reproduced.
|
|
37
|
+
*/
|
|
38
|
+
import fs from "node:fs";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
/** The workspaces whose `dist/` a developer or agent runs from a checkout. */
|
|
42
|
+
const WORKSPACE_PACKAGE_NAMES = new Set([
|
|
43
|
+
"@bli-cockpit/local-collector",
|
|
44
|
+
"@bli-cockpit/cli",
|
|
45
|
+
]);
|
|
46
|
+
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
47
|
+
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
|
|
48
|
+
/**
|
|
49
|
+
* Walks up from the running module. Pure apart from the two injected readers,
|
|
50
|
+
* so a Windows layout can be asserted from a macOS test run.
|
|
51
|
+
*/
|
|
52
|
+
export function detectDevBuild(probe = {}) {
|
|
53
|
+
const env = probe.env ?? process.env;
|
|
54
|
+
const rawFlag = (env.COCKPIT_DEV ?? "").trim().toLowerCase();
|
|
55
|
+
if (TRUE_VALUES.has(rawFlag)) {
|
|
56
|
+
return { devBuild: true, reason: "cockpit_dev_env" };
|
|
57
|
+
}
|
|
58
|
+
if (FALSE_VALUES.has(rawFlag)) {
|
|
59
|
+
// Deliberate live test from a checkout: the operator has said so out loud,
|
|
60
|
+
// and the path evidence below is not allowed to overrule them.
|
|
61
|
+
return { devBuild: false, reason: "cockpit_dev_env_disabled" };
|
|
62
|
+
}
|
|
63
|
+
const pathApi = probe.pathApi ?? path;
|
|
64
|
+
const modulePath = probe.modulePath ?? currentModulePath();
|
|
65
|
+
if (!modulePath)
|
|
66
|
+
return { devBuild: false, reason: "not_a_checkout" };
|
|
67
|
+
if (hasNodeModulesSegment(modulePath)) {
|
|
68
|
+
// Every published install is under a `node_modules/` prefix, on both host
|
|
69
|
+
// families. This branch is the whole fleet, and it is checked first so a
|
|
70
|
+
// machine that happens to have a repo checkout somewhere above its global
|
|
71
|
+
// npm prefix cannot be mislabelled.
|
|
72
|
+
return { devBuild: false, reason: "published_install" };
|
|
73
|
+
}
|
|
74
|
+
const readPackageName = probe.readPackageName ?? defaultReadPackageName;
|
|
75
|
+
const pathExists = probe.pathExists ?? defaultPathExists;
|
|
76
|
+
const packageRoot = findWorkspacePackageRoot(pathApi.dirname(modulePath), pathApi, readPackageName);
|
|
77
|
+
if (!packageRoot)
|
|
78
|
+
return { devBuild: false, reason: "not_a_checkout" };
|
|
79
|
+
if (!hasCheckoutMarkerAbove(packageRoot, pathApi, pathExists)) {
|
|
80
|
+
return { devBuild: false, reason: "not_a_checkout" };
|
|
81
|
+
}
|
|
82
|
+
return { devBuild: true, reason: "workspace_checkout" };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Should this process withhold fleet receipts (install events, heartbeat)?
|
|
86
|
+
*
|
|
87
|
+
* Two facts, in this order: is the code a dev build, and is the target the
|
|
88
|
+
* production fleet. A dev build talking to `localhost` still posts, because
|
|
89
|
+
* that is the only way to exercise the receipt path at all.
|
|
90
|
+
*/
|
|
91
|
+
export function shouldSuppressFleetReceipts(options) {
|
|
92
|
+
const verdict = detectDevBuild(options.probe ?? {});
|
|
93
|
+
if (!verdict.devBuild)
|
|
94
|
+
return { suppressed: false, reason: verdict.reason };
|
|
95
|
+
if (isLocalDashboardUrl(options.dashboardUrl)) {
|
|
96
|
+
return { suppressed: false, reason: "local_dashboard" };
|
|
97
|
+
}
|
|
98
|
+
return { suppressed: true, reason: verdict.reason };
|
|
99
|
+
}
|
|
100
|
+
/** A dashboard on this machine — safe for a checkout to post at. */
|
|
101
|
+
export function isLocalDashboardUrl(dashboardUrl) {
|
|
102
|
+
let hostname;
|
|
103
|
+
try {
|
|
104
|
+
hostname = new URL(dashboardUrl).hostname.toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// An unparseable URL is not demonstrably local, and this predicate only
|
|
108
|
+
// ever widens suppression when it says false.
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const bare = hostname.replace(/^\[|\]$/gu, "");
|
|
112
|
+
return (bare === "localhost" ||
|
|
113
|
+
bare === "127.0.0.1" ||
|
|
114
|
+
bare === "::1" ||
|
|
115
|
+
bare === "0.0.0.0" ||
|
|
116
|
+
bare.endsWith(".localhost"));
|
|
117
|
+
}
|
|
118
|
+
function currentModulePath() {
|
|
119
|
+
try {
|
|
120
|
+
return fileURLToPath(import.meta.url);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Case-insensitive on purpose: Windows spells the same folder
|
|
128
|
+
* `Node_Modules` without complaint, and both separators appear on that host
|
|
129
|
+
* because a POSIX-style path survives most Node APIs there.
|
|
130
|
+
*/
|
|
131
|
+
function hasNodeModulesSegment(modulePath) {
|
|
132
|
+
return modulePath
|
|
133
|
+
.split(/[\\/]+/u)
|
|
134
|
+
.some((segment) => segment.toLowerCase() === "node_modules");
|
|
135
|
+
}
|
|
136
|
+
function findWorkspacePackageRoot(startDir, pathApi, readPackageName) {
|
|
137
|
+
let dir = startDir;
|
|
138
|
+
// Bounded: `path.dirname` reaches a fixed point at `/` or `C:\`, and the
|
|
139
|
+
// depth guard keeps a pathological symlink loop from spinning a sync walk.
|
|
140
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
141
|
+
const name = readPackageName(pathApi.join(dir, "package.json"));
|
|
142
|
+
if (name && WORKSPACE_PACKAGE_NAMES.has(name))
|
|
143
|
+
return dir;
|
|
144
|
+
const parent = pathApi.dirname(dir);
|
|
145
|
+
if (parent === dir)
|
|
146
|
+
return null;
|
|
147
|
+
dir = parent;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* A repo checkout marker at or above the workspace package: `.git` (a
|
|
153
|
+
* directory in a normal clone, a FILE in a `git worktree`), or the
|
|
154
|
+
* `.claude/worktrees` staging area agents run from.
|
|
155
|
+
*/
|
|
156
|
+
function hasCheckoutMarkerAbove(packageRoot, pathApi, pathExists) {
|
|
157
|
+
let dir = packageRoot;
|
|
158
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
159
|
+
if (pathExists(pathApi.join(dir, ".git")))
|
|
160
|
+
return true;
|
|
161
|
+
if (pathExists(pathApi.join(dir, ".claude", "worktrees")))
|
|
162
|
+
return true;
|
|
163
|
+
const parent = pathApi.dirname(dir);
|
|
164
|
+
if (parent === dir)
|
|
165
|
+
return false;
|
|
166
|
+
dir = parent;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
function defaultReadPackageName(packageJsonPath) {
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
173
|
+
return typeof parsed.name === "string" ? parsed.name : null;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function defaultPathExists(candidate) {
|
|
180
|
+
try {
|
|
181
|
+
return fs.existsSync(candidate);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
package/dist/tower-stream.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Reading a Tower turn as it happens (BLI-3457).
|
|
3
3
|
*
|
|
4
4
|
* The dashboard's conversation routes speak one JSON object per line
|
|
5
|
-
* (`application/x-ndjson`): `activity` events as tools start and settle,
|
|
6
|
-
*
|
|
5
|
+
* (`application/x-ndjson`): `activity` events as tools start and settle,
|
|
6
|
+
* `token` events carrying the answer as it is written (BLI-3517), then exactly
|
|
7
|
+
* one `final` event carrying what the plain JSON response used to be.
|
|
7
8
|
* That is the same wire format the browser already reads
|
|
8
9
|
* (`apps/dashboard/src/lib/webchat/tool-trace.ts`); this is the terminal's
|
|
9
10
|
* reader for it.
|
|
@@ -89,6 +90,8 @@ export async function readTowerTurn(response, options = {}) {
|
|
|
89
90
|
const notes = [];
|
|
90
91
|
let streamed = true;
|
|
91
92
|
let final = null;
|
|
93
|
+
let draft = "";
|
|
94
|
+
let tokenFrames = 0;
|
|
92
95
|
for await (const item of readTowerStream(response)) {
|
|
93
96
|
if (item.kind === "note") {
|
|
94
97
|
if (item.reason === "stream_not_available")
|
|
@@ -101,6 +104,13 @@ export async function readTowerTurn(response, options = {}) {
|
|
|
101
104
|
final = item.event;
|
|
102
105
|
break;
|
|
103
106
|
}
|
|
107
|
+
if (item.event.type === "token") {
|
|
108
|
+
const text = item.event.text ?? "";
|
|
109
|
+
tokenFrames += 1;
|
|
110
|
+
draft = item.event.reset ? text : draft + text;
|
|
111
|
+
options.onToken?.(item.event);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
104
114
|
activity.push(item.event);
|
|
105
115
|
options.onActivity?.(item.event);
|
|
106
116
|
}
|
|
@@ -117,6 +127,8 @@ export async function readTowerTurn(response, options = {}) {
|
|
|
117
127
|
options.log?.(`[tower stream] turn incomplete ${JSON.stringify({
|
|
118
128
|
reason,
|
|
119
129
|
activity_events: activity.length,
|
|
130
|
+
token_frames: tokenFrames,
|
|
131
|
+
drafted_chars: draft.length,
|
|
120
132
|
notes: notes.length,
|
|
121
133
|
elapsed_ms: elapsedMs,
|
|
122
134
|
})}`);
|
|
@@ -125,10 +137,14 @@ export async function readTowerTurn(response, options = {}) {
|
|
|
125
137
|
options.log?.(`[tower stream] turn complete ${JSON.stringify({
|
|
126
138
|
streamed,
|
|
127
139
|
activity_events: activity.length,
|
|
140
|
+
// BLI-3517: zero token frames against a streaming dashboard means the
|
|
141
|
+
// answer arrived all at once — worth telling apart from a quiet turn.
|
|
142
|
+
token_frames: tokenFrames,
|
|
143
|
+
drafted_chars: draft.length,
|
|
128
144
|
notes: notes.length,
|
|
129
145
|
elapsed_ms: elapsedMs,
|
|
130
146
|
})}`);
|
|
131
|
-
return { ok: true, final, activity, streamed };
|
|
147
|
+
return { ok: true, final, activity, streamed, draft };
|
|
132
148
|
}
|
|
133
149
|
/** One plain sentence per way a turn can end without an answer. */
|
|
134
150
|
export function streamFailureDetail(reason, detail) {
|
|
@@ -207,7 +223,7 @@ function parseLine(line) {
|
|
|
207
223
|
return { kind: "note", reason: "unexpected_event_type" };
|
|
208
224
|
}
|
|
209
225
|
const type = parsed.type;
|
|
210
|
-
if (type !== "activity" && type !== "final") {
|
|
226
|
+
if (type !== "activity" && type !== "token" && type !== "final") {
|
|
211
227
|
return { kind: "note", reason: "unexpected_event_type" };
|
|
212
228
|
}
|
|
213
229
|
return { kind: "event", event: parsed };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.49",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"pretypecheck": "npm run build",
|
|
25
25
|
"typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
|
|
26
26
|
"pretest": "npm run build",
|
|
27
|
-
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
27
|
+
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@bli-cockpit/telemetry-core": "0.1.26"
|