@bli-cockpit/cli 0.2.237 → 0.2.238
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/attribution-core-fallbacks.js +15 -11
- package/dist/adapters/attribution-core-score.js +111 -87
- package/dist/adapters/car-state.js +38 -35
- package/dist/adapters/claude-attribution-discovery.js +81 -56
- package/dist/adapters/claude-attribution-score.js +108 -94
- package/dist/adapters/claude-attribution-signals.js +52 -42
- package/dist/adapters/codex-attribution.js +42 -33
- package/dist/adapters/codex-session-signals.js +11 -9
- package/dist/adapters/git-state.js +56 -50
- package/dist/adapters/local-sources.js +52 -43
- package/dist/adapters/raw-evidence-git-diff.js +4 -2
- package/dist/adapters/raw-evidence-sanitize.js +15 -12
- package/dist/adapters/raw-evidence-transcript-file.js +32 -31
- package/dist/autostart-self-heal.js +18 -2
- package/dist/commands/agent-session-report.js +51 -59
- package/dist/commands/agent-setup-observed.js +30 -23
- package/dist/commands/agent-setup.js +96 -45
- package/dist/commands/autostart-heal.js +8 -2
- package/dist/commands/autostart-reading.js +3 -0
- package/dist/commands/backfill-evidence-outcomes.js +9 -6
- package/dist/commands/backfill-issues.js +3 -0
- package/dist/commands/backfill-scan.js +27 -22
- package/dist/commands/backfill-upload.js +69 -60
- package/dist/commands/backfill.js +26 -21
- package/dist/commands/brief-done.js +4 -1
- package/dist/commands/brief-edit.js +71 -46
- package/dist/commands/cal-detail.js +35 -0
- package/dist/commands/cal-overlay.js +73 -30
- package/dist/commands/cal.js +36 -23
- package/dist/commands/clean.js +4 -1
- package/dist/commands/docs-read.js +5 -2
- package/dist/commands/doctor-backfill.js +88 -71
- package/dist/commands/doctor-pipeline.js +59 -46
- package/dist/commands/heartbeat-post.js +116 -0
- package/dist/commands/heartbeat-token-lock.js +46 -33
- package/dist/commands/heartbeat.js +43 -125
- package/dist/commands/install-update.js +17 -15
- package/dist/commands/issue-show.js +27 -28
- package/dist/commands/issue-write.js +70 -23
- package/dist/commands/issue.js +68 -37
- package/dist/commands/jarvis-live-answer.js +99 -0
- package/dist/commands/jarvis-render.js +1 -94
- package/dist/commands/jarvis-trace.js +30 -22
- package/dist/commands/jarvis-turn-report.js +124 -0
- package/dist/commands/jarvis-turn.js +108 -182
- package/dist/commands/jarvis.js +26 -17
- package/dist/commands/local-args-collector-setup.js +82 -84
- package/dist/commands/local-args-collector-work.js +37 -38
- package/dist/commands/local-args-tower-admin.js +155 -142
- package/dist/commands/local-args-tower-cal.js +52 -50
- package/dist/commands/local-args-tower-careers.js +73 -54
- package/dist/commands/local-args-tower-docs-msg.js +55 -45
- package/dist/commands/local-args-tower-github.js +26 -20
- package/dist/commands/local-args-tower-hits.js +32 -20
- package/dist/commands/local-args-tower-manager.js +85 -4
- package/dist/commands/local-args-tower-memory.js +139 -117
- package/dist/commands/local-args-tower-notes.js +346 -0
- package/dist/commands/local-args-tower-pages.js +100 -378
- package/dist/commands/local-args-tower-settings.js +28 -29
- package/dist/commands/local-args-tower-work.js +52 -33
- package/dist/commands/local-args.js +80 -129
- package/dist/commands/local-help-commands-tower-ops.js +15 -4
- package/dist/commands/local-help-commands-tower.js +6 -2
- package/dist/commands/local-help-commands.js +342 -341
- package/dist/commands/local-help.js +87 -83
- package/dist/commands/local-run-guards.js +100 -0
- package/dist/commands/local.js +137 -206
- package/dist/commands/login.js +13 -9
- package/dist/commands/logs.js +55 -38
- package/dist/commands/mail-attachments.js +44 -26
- package/dist/commands/manager-render-home.js +208 -0
- package/dist/commands/manager-render.js +7 -3
- package/dist/commands/manager-requests.js +33 -0
- package/dist/commands/manager.js +39 -5
- package/dist/commands/memory-consolidation.js +18 -9
- package/dist/commands/memory-daemon-probe.js +37 -28
- package/dist/commands/memory-install-config.js +64 -60
- package/dist/commands/memory-install.js +21 -41
- package/dist/commands/memory-requeue.js +26 -19
- package/dist/commands/memory-rules.js +26 -18
- package/dist/commands/memory-search-print.js +26 -0
- package/dist/commands/memory-store.js +14 -30
- package/dist/commands/memory-verb-list.js +49 -48
- package/dist/commands/notes-accounts.js +49 -32
- package/dist/commands/notes-sync-loop.js +115 -89
- package/dist/commands/notes-wisprflow-login.js +44 -23
- package/dist/commands/notes-writes.js +115 -100
- package/dist/commands/notes.js +33 -55
- package/dist/commands/onboard-flows.js +69 -51
- package/dist/commands/onboard.js +11 -9
- package/dist/commands/ops-render-memory.js +10 -7
- package/dist/commands/ops-render.js +33 -30
- package/dist/commands/ops.js +3 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-outside-roots.js +40 -36
- package/dist/commands/session-sync-plan.js +14 -10
- package/dist/commands/session-sync-upload.js +24 -22
- package/dist/commands/session-sync.js +19 -8
- package/dist/commands/slack.js +25 -16
- package/dist/commands/sources-check.js +13 -7
- package/dist/commands/sources-discovery.js +25 -17
- package/dist/commands/sources-verdict.js +19 -18
- package/dist/commands/speed.js +23 -19
- package/dist/commands/start.js +20 -16
- package/dist/commands/sync-doctor.js +52 -48
- package/dist/commands/sync.js +22 -16
- package/dist/commands/usage-telemetry-toml.js +71 -45
- package/dist/commands/usage.js +91 -67
- package/dist/commands/workbook-render.js +10 -7
- package/dist/evidence-reconcile-client.js +75 -64
- package/dist/evidence-redelivery-plan.js +37 -34
- package/dist/evidence-redelivery.js +32 -29
- package/dist/evidence-transcripts-client.js +20 -20
- package/dist/evidence-upload-object.js +86 -89
- package/dist/evidence-upload-plan.js +94 -83
- package/dist/evidence-upload-terminal.js +45 -50
- package/dist/image-description-session.js +156 -159
- package/dist/local-state-pairing-code.js +39 -31
- package/dist/local-state-status.js +9 -2
- package/dist/onboarding-roots.js +5 -8
- package/dist/process-runner.js +8 -8
- package/dist/raw-evidence-gc.js +33 -38
- package/dist/raw-evidence-stuck-summary.js +7 -14
- package/dist/repo-identity.js +26 -23
- package/dist/scheduled-self-update.js +11 -15
- package/dist/second-install.js +3 -0
- package/dist/slim/codex-rules.js +23 -36
- package/dist/sync-lock.js +5 -15
- package/dist/tower-client.js +36 -53
- package/dist/tower-stream.js +7 -24
- package/dist/upload-agent-artifacts.js +58 -64
- package/dist/upload-envelope-build.js +60 -59
- package/dist/upload-evidence-delivery-reconcile.js +58 -56
- package/dist/upload-evidence-delivery-summary.js +32 -34
- package/dist/upload-ingest-receipt.js +20 -23
- package/dist/upload-session-reports-queue.js +4 -1
- package/dist/upload-session-reports-wire.js +64 -68
- package/dist/upload-sync.js +171 -157
- package/package.json +2 -2
|
@@ -182,57 +182,70 @@ async function runSyncRepair(context) {
|
|
|
182
182
|
// machine the operator already fixed by hand (BLI-2362).
|
|
183
183
|
const discoveryArgs = await savedDiscoveryLimitArgs(context.command.homeDir);
|
|
184
184
|
for (const repoRoot of roots) {
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
if (!isDefaultDashboardUrl(context.command.dashboardUrl)) {
|
|
189
|
-
args.push("--dashboard-url", context.command.dashboardUrl);
|
|
190
|
-
}
|
|
191
|
-
// One line every 30 s while doctor's own sync runs; the exec has nothing
|
|
192
|
-
// to count, so the line says it is still working and for how long.
|
|
193
|
-
const stopProgress = startProgress((text) => context.io.stderr.write(text), (elapsedMs) => waitingLine("sync", elapsedMs));
|
|
194
|
-
const result = await Promise.resolve(exec("cockpit", args)).finally(stopProgress);
|
|
195
|
-
const parsed = parseDoctorSyncJson(result.stdout);
|
|
196
|
-
const output = `${result.stdout}\n${result.stderr}`;
|
|
197
|
-
const status = jsonField(output, "status");
|
|
198
|
-
// BLI-4303, before any verdict: a sync that stood aside for the collection
|
|
199
|
-
// lock never ran, so neither its exit code nor its missing `uploaded`
|
|
200
|
-
// receipt says anything about this machine's health.
|
|
201
|
-
const standAside = syncStandAsideVerdict(parsed, status);
|
|
202
|
-
if (standAside) {
|
|
203
|
-
console.error("[cockpit-doctor] sync stood aside for a running collection run", JSON.stringify({
|
|
204
|
-
reason: standAside.code,
|
|
205
|
-
owner_alive: standAside.ownerAlive,
|
|
206
|
-
held_since: standAside.heldSince,
|
|
207
|
-
}));
|
|
208
|
-
// The gate (`withCollectionGate`) reads the lock again and turns this
|
|
209
|
-
// into "background sync is running"; the code only travels to it.
|
|
210
|
-
return needsFix("sync-fresh", standAside.code, "the sync stood aside for the collection lock");
|
|
211
|
-
}
|
|
212
|
-
if (status === "live_sync_paused_during_backfill") {
|
|
213
|
-
return needsFix("sync-fresh", status, "the sync paused for the historical catch-up");
|
|
214
|
-
}
|
|
215
|
-
if (result.code !== 0) {
|
|
216
|
-
const draining = syncBacklogDrainingVerdict(parsed);
|
|
217
|
-
if (draining) {
|
|
218
|
-
return needsFix("sync-fresh", "backlog_draining", `${repoRoot}: still catching up (${draining.remainingObjects} ` +
|
|
219
|
-
`file${draining.remainingObjects === 1 ? "" : "s"} left for later this run); ` +
|
|
220
|
-
"rerun `cockpit sync` to continue");
|
|
221
|
-
}
|
|
222
|
-
return fail("sync-fresh", status ?? "sync_failed", `${repoRoot}: ${syncFailureMessage(parsed, result.code)}`);
|
|
223
|
-
}
|
|
224
|
-
if (parsed?.collection_complete === false && typeof parsed.discovery_notice === "string") {
|
|
225
|
-
console.error("[doctor] partial discovery", JSON.stringify({ reason: "discovery_partial" }));
|
|
226
|
-
return needsFix("sync-fresh", "discovery_partial", parsed.discovery_notice);
|
|
227
|
-
}
|
|
228
|
-
if (status !== "uploaded") {
|
|
229
|
-
return fail("sync-fresh", status ?? "sync_unverified", `sync did not return an uploaded receipt for ${repoRoot}`);
|
|
230
|
-
}
|
|
185
|
+
const problem = await syncOneRoot(context, exec, repoRoot, discoveryArgs);
|
|
186
|
+
if (problem)
|
|
187
|
+
return problem;
|
|
231
188
|
}
|
|
232
189
|
return ok("sync-fresh", "synced", roots.length === 1
|
|
233
190
|
? "ran `cockpit sync` and received an uploaded receipt"
|
|
234
191
|
: `received uploaded receipts for ${roots.length} saved roots`);
|
|
235
192
|
}
|
|
193
|
+
function buildSyncArgs(context, repoRoot, discoveryArgs) {
|
|
194
|
+
const args = ["sync", "--json", "--workspace", repoRoot, ...discoveryArgs];
|
|
195
|
+
if (context.command.homeDir)
|
|
196
|
+
args.push("--home", context.command.homeDir);
|
|
197
|
+
if (!isDefaultDashboardUrl(context.command.dashboardUrl)) {
|
|
198
|
+
args.push("--dashboard-url", context.command.dashboardUrl);
|
|
199
|
+
}
|
|
200
|
+
return args;
|
|
201
|
+
}
|
|
202
|
+
/** Runs doctor's own sync for one root; returns the failing row, or null when an uploaded receipt came back. */
|
|
203
|
+
async function syncOneRoot(context, exec, repoRoot, discoveryArgs) {
|
|
204
|
+
const args = buildSyncArgs(context, repoRoot, discoveryArgs);
|
|
205
|
+
// One line every 30 s while doctor's own sync runs; the exec has nothing
|
|
206
|
+
// to count, so the line says it is still working and for how long.
|
|
207
|
+
const stopProgress = startProgress((text) => context.io.stderr.write(text), (elapsedMs) => waitingLine("sync", elapsedMs));
|
|
208
|
+
const result = await Promise.resolve(exec("cockpit", args)).finally(stopProgress);
|
|
209
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
210
|
+
return judgeSyncResult(repoRoot, result.code, parseDoctorSyncJson(result.stdout), jsonField(output, "status"));
|
|
211
|
+
}
|
|
212
|
+
/** The failing row for one root's finished sync, or null when it returned an uploaded receipt. */
|
|
213
|
+
function judgeSyncResult(repoRoot, exitCode, parsed, status) {
|
|
214
|
+
// BLI-4303, before any verdict: a sync that stood aside for the collection
|
|
215
|
+
// lock never ran, so neither its exit code nor its missing `uploaded`
|
|
216
|
+
// receipt says anything about this machine's health.
|
|
217
|
+
const standAside = syncStandAsideVerdict(parsed, status);
|
|
218
|
+
if (standAside) {
|
|
219
|
+
console.error("[cockpit-doctor] sync stood aside for a running collection run", JSON.stringify({
|
|
220
|
+
reason: standAside.code,
|
|
221
|
+
owner_alive: standAside.ownerAlive,
|
|
222
|
+
held_since: standAside.heldSince,
|
|
223
|
+
}));
|
|
224
|
+
// The gate (`withCollectionGate`) reads the lock again and turns this
|
|
225
|
+
// into "background sync is running"; the code only travels to it.
|
|
226
|
+
return needsFix("sync-fresh", standAside.code, "the sync stood aside for the collection lock");
|
|
227
|
+
}
|
|
228
|
+
if (status === "live_sync_paused_during_backfill") {
|
|
229
|
+
return needsFix("sync-fresh", status, "the sync paused for the historical catch-up");
|
|
230
|
+
}
|
|
231
|
+
if (exitCode !== 0) {
|
|
232
|
+
const draining = syncBacklogDrainingVerdict(parsed);
|
|
233
|
+
if (draining) {
|
|
234
|
+
return needsFix("sync-fresh", "backlog_draining", `${repoRoot}: still catching up (${draining.remainingObjects} ` +
|
|
235
|
+
`file${draining.remainingObjects === 1 ? "" : "s"} left for later this run); ` +
|
|
236
|
+
"rerun `cockpit sync` to continue");
|
|
237
|
+
}
|
|
238
|
+
return fail("sync-fresh", status ?? "sync_failed", `${repoRoot}: ${syncFailureMessage(parsed, exitCode)}`);
|
|
239
|
+
}
|
|
240
|
+
if (parsed?.collection_complete === false && typeof parsed.discovery_notice === "string") {
|
|
241
|
+
console.error("[doctor] partial discovery", JSON.stringify({ reason: "discovery_partial" }));
|
|
242
|
+
return needsFix("sync-fresh", "discovery_partial", parsed.discovery_notice);
|
|
243
|
+
}
|
|
244
|
+
if (status !== "uploaded") {
|
|
245
|
+
return fail("sync-fresh", status ?? "sync_unverified", `sync did not return an uploaded receipt for ${repoRoot}`);
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
236
249
|
// Re-exported so `doctor.ts`, the doctor tests and every other caller keep
|
|
237
250
|
// importing these from `./doctor-pipeline.js` regardless of which sibling
|
|
238
251
|
// computes them.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Posting one heartbeat and recording what the dashboard said back.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `heartbeat.ts` so that file stays a table of contents: it
|
|
5
|
+
* decides WHETHER and WHAT to send (dev-build gate, session, receipts, the
|
|
6
|
+
* payload); this file does the single HTTP exchange and says, on one stderr
|
|
7
|
+
* line each, whether it landed, was refused or never arrived.
|
|
8
|
+
*/
|
|
9
|
+
import { memoryInstallGaps } from "@bli-cockpit/telemetry-core";
|
|
10
|
+
import { describeError } from "../health-detail.js";
|
|
11
|
+
import { classifySyncHealthError } from "../sync-health-class.js";
|
|
12
|
+
import { noteHeartbeatRefusal, recordHeartbeatPendingToken, recordHeartbeatTokenExpiry, TOKEN_ROTATION_HEADER, } from "./heartbeat-token.js";
|
|
13
|
+
import { noteDoctorRequest } from "./sync-doctor-request.js";
|
|
14
|
+
const HEARTBEAT_TIMEOUT_MS = 5_000;
|
|
15
|
+
export async function postHeartbeat(options, deviceToken, heartbeat, memory) {
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timeout = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
|
|
18
|
+
try {
|
|
19
|
+
const response = await options.io.fetch(`${options.dashboardUrl}/api/ambient/heartbeat`, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
Authorization: `Bearer ${deviceToken}`,
|
|
24
|
+
// BLI-17994. "I store a renewed token if you send one": the dashboard
|
|
25
|
+
// only rotates for a collector that says so.
|
|
26
|
+
[TOKEN_ROTATION_HEADER]: "1",
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify(heartbeat),
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
});
|
|
31
|
+
noteHeartbeatRefusal(response.ok ? null : response.status);
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
await logHeartbeatRefused(response, heartbeat);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
await recordAcceptedHeartbeat(options, response, heartbeat, memory);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error("[heartbeat] could not reach the dashboard this tick", JSON.stringify({
|
|
41
|
+
reason: "heartbeat_transport_failed",
|
|
42
|
+
root_count: heartbeat.roots.length,
|
|
43
|
+
...describeError(error),
|
|
44
|
+
}));
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
clearTimeout(timeout);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A heartbeat is not retried, so the reason has to be said once, here.
|
|
53
|
+
* BLI-4019: the CLASS comes from `sync-health-class.ts`, the one place
|
|
54
|
+
* that decides what a failure is — a 401/403 is `auth_failed` and
|
|
55
|
+
* anything else is not, read off the observed status and never off the
|
|
56
|
+
* words in a message (BLI-3551's rule).
|
|
57
|
+
* BLI-17994. The door names why it refused (`device_token_expired`,
|
|
58
|
+
* `device_token_replaced`) in a `reason` field; say it here rather than
|
|
59
|
+
* leaving a bare status.
|
|
60
|
+
*/
|
|
61
|
+
async function logHeartbeatRefused(response, heartbeat) {
|
|
62
|
+
const refusal = await Promise.resolve().then(() => response.clone().json()).catch(() => null);
|
|
63
|
+
console.error("[heartbeat] the dashboard refused this tick's heartbeat", JSON.stringify({
|
|
64
|
+
reason: "heartbeat_rejected",
|
|
65
|
+
health_class: classifySyncHealthError({ httpStatus: response.status }),
|
|
66
|
+
http_status: response.status,
|
|
67
|
+
root_count: heartbeat.roots.length,
|
|
68
|
+
...(typeof refusal?.["reason"] === "string" ? { server_reason: refusal["reason"] } : {}),
|
|
69
|
+
...(response.status === 401 || response.status === 403
|
|
70
|
+
? {
|
|
71
|
+
next_action: "this token expired, was replaced or was revoked: run `cockpit doctor` to pair this machine again",
|
|
72
|
+
}
|
|
73
|
+
: {}),
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
async function recordAcceptedHeartbeat(options, response, heartbeat, memory) {
|
|
77
|
+
// The reply carries `token_expires_at` (and a `pending_device_token` when
|
|
78
|
+
// the token was near its end); the sibling records both, so this machine's
|
|
79
|
+
// own copy follows the server rather than drifting.
|
|
80
|
+
const reply = await response.clone().json().catch(() => null);
|
|
81
|
+
noteDoctorRequest(reply?.device_action);
|
|
82
|
+
// BLI-17994. Store a pending successor if the door handed one out (the
|
|
83
|
+
// date does not move: a pending token has not been used), then the date the
|
|
84
|
+
// door reports, which moves only when this request promoted a token.
|
|
85
|
+
await recordHeartbeatPendingToken(options.homeDir, reply);
|
|
86
|
+
await recordHeartbeatTokenExpiry(options.homeDir, response);
|
|
87
|
+
console.error("[heartbeat] checked in", JSON.stringify({
|
|
88
|
+
reason: "heartbeat_recorded",
|
|
89
|
+
http_status: response.status,
|
|
90
|
+
root_count: heartbeat.roots.length,
|
|
91
|
+
sync_status: heartbeat.last_sync_status,
|
|
92
|
+
sync_reason: heartbeat.last_sync_reason ?? null,
|
|
93
|
+
sessions_observed: heartbeat.sessions_observed ?? null,
|
|
94
|
+
sessions_outside_root: heartbeat.sessions_outside_root ?? null,
|
|
95
|
+
sessions_new_this_tick: heartbeat.sessions_new_this_tick ?? null,
|
|
96
|
+
sessions_pending_upload: heartbeat.sessions_pending_upload ?? null,
|
|
97
|
+
// BLI-3729. Both branches: a tick that reports no receipt says WHY,
|
|
98
|
+
// and a tick that reports one says whether memory is fully on. "Is BLI
|
|
99
|
+
// Memory switched on for this machine?" is now answerable from
|
|
100
|
+
// sync.err.log alone.
|
|
101
|
+
memory_receipt: memory.reason,
|
|
102
|
+
memory_gaps: memory.receipt ? memoryInstallGaps(memory.receipt) : null,
|
|
103
|
+
// BLI-3788. The hooks are registered (above) AND they either worked or
|
|
104
|
+
// did not (here). A prompt hook that misses its deadline prints
|
|
105
|
+
// nothing to the person, so these counts are the only trace one leaves
|
|
106
|
+
// on this machine; `null` means no counter file, never zero misses.
|
|
107
|
+
memory_hook_runs_24h: memory.receipt?.hook_runs_24h ?? null,
|
|
108
|
+
memory_hook_timeouts_24h: memory.receipt?.hook_timeouts_24h ?? null,
|
|
109
|
+
memory_hook_stats: memory.receipt?.hook_stats_reason ?? null,
|
|
110
|
+
// BLI-3884. Which route answered those recalls. `null` on a machine
|
|
111
|
+
// whose memory-mcp does not name one yet — never zero, which would
|
|
112
|
+
// read as "the daemon answered none".
|
|
113
|
+
memory_hook_via_daemon_24h: memory.receipt?.hook_via_daemon_24h ?? null,
|
|
114
|
+
memory_hook_via_direct_24h: memory.receipt?.hook_via_direct_24h ?? null,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
@@ -50,48 +50,61 @@ export async function acquireTokenRotationLock(homeDir, options = {}) {
|
|
|
50
50
|
return unlocked("state_dir_unavailable", error, started);
|
|
51
51
|
}
|
|
52
52
|
for (;;) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
await handle.close();
|
|
57
|
-
const waited = Date.now() - started;
|
|
58
|
-
if (waited > pollMs) {
|
|
59
|
-
console.error("[heartbeat] waited for another heartbeat to finish its token exchange", JSON.stringify({ reason: "token_lock_acquired_after_wait", waited_ms: waited }));
|
|
60
|
-
}
|
|
61
|
-
return {
|
|
62
|
-
acquired: true,
|
|
63
|
-
waited_ms: waited,
|
|
64
|
-
mode: "held",
|
|
65
|
-
release: async () => {
|
|
66
|
-
// Only remove a lock that is still ours: a holder that was taken over
|
|
67
|
-
// as stale must not delete its successor's lock.
|
|
68
|
-
const current = await readOwner(lockPath);
|
|
69
|
-
if (current === owner)
|
|
70
|
-
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
catch (error) {
|
|
75
|
-
if (error?.code !== "EEXIST") {
|
|
76
|
-
return unlocked("lock_file_unwritable", error, started);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
53
|
+
const created = await tryCreateLock({ lockPath, owner, started, pollMs });
|
|
54
|
+
if (created !== "held_by_someone_else")
|
|
55
|
+
return created;
|
|
79
56
|
const heldAt = await readHeldAt(lockPath);
|
|
80
57
|
if (heldAt === "gone")
|
|
81
58
|
continue; // released between our create and our read: try again at once
|
|
82
59
|
if (Date.now() - heldAt > staleMs) {
|
|
83
|
-
|
|
84
|
-
console.error("[heartbeat] removing a stale token-exchange lock", JSON.stringify({ reason: "token_lock_stale_taken_over", age_ms: Date.now() - heldAt }));
|
|
85
|
-
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
60
|
+
await takeOverStaleLock(lockPath, heldAt);
|
|
86
61
|
continue;
|
|
87
62
|
}
|
|
88
|
-
if (Date.now() - started >= waitMs)
|
|
89
|
-
|
|
90
|
-
return { acquired: false, waited_ms: Date.now() - started };
|
|
91
|
-
}
|
|
63
|
+
if (Date.now() - started >= waitMs)
|
|
64
|
+
return giveUpWaiting(started);
|
|
92
65
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
93
66
|
}
|
|
94
67
|
}
|
|
68
|
+
/** Creates the lock file; "held_by_someone_else" means it already exists, anything else unwritable falls back to unlocked. */
|
|
69
|
+
async function tryCreateLock(attempt) {
|
|
70
|
+
const { lockPath, owner, started, pollMs } = attempt;
|
|
71
|
+
try {
|
|
72
|
+
const handle = await fs.open(lockPath, "wx", 0o600);
|
|
73
|
+
await handle.writeFile(`${JSON.stringify({ pid: process.pid, owner, at_ms: Date.now() })}\n`);
|
|
74
|
+
await handle.close();
|
|
75
|
+
const waited = Date.now() - started;
|
|
76
|
+
if (waited > pollMs) {
|
|
77
|
+
console.error("[heartbeat] waited for another heartbeat to finish its token exchange", JSON.stringify({ reason: "token_lock_acquired_after_wait", waited_ms: waited }));
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
acquired: true,
|
|
81
|
+
waited_ms: waited,
|
|
82
|
+
mode: "held",
|
|
83
|
+
release: async () => {
|
|
84
|
+
// Only remove a lock that is still ours: a holder that was taken over
|
|
85
|
+
// as stale must not delete its successor's lock.
|
|
86
|
+
const current = await readOwner(lockPath);
|
|
87
|
+
if (current === owner)
|
|
88
|
+
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error?.code !== "EEXIST") {
|
|
94
|
+
return unlocked("lock_file_unwritable", error, started);
|
|
95
|
+
}
|
|
96
|
+
return "held_by_someone_else";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** A holder that died: remove its lock so the next create wins. */
|
|
100
|
+
async function takeOverStaleLock(lockPath, heldAt) {
|
|
101
|
+
console.error("[heartbeat] removing a stale token-exchange lock", JSON.stringify({ reason: "token_lock_stale_taken_over", age_ms: Date.now() - heldAt }));
|
|
102
|
+
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
103
|
+
}
|
|
104
|
+
function giveUpWaiting(started) {
|
|
105
|
+
console.error("[heartbeat] another heartbeat is still exchanging the device token; skipping this tick's check-in", JSON.stringify({ reason: "token_lock_busy", waited_ms: Date.now() - started, next_action: "the next tick checks in" }));
|
|
106
|
+
return { acquired: false, waited_ms: Date.now() - started };
|
|
107
|
+
}
|
|
95
108
|
/** Run `work` under the lock; `skipped` is the answer when the lock stays held past the wait. */
|
|
96
109
|
export async function withTokenRotationLock(homeDir, options, skipped, work) {
|
|
97
110
|
const lock = await acquireTokenRotationLock(homeDir, options);
|
|
@@ -19,25 +19,23 @@
|
|
|
19
19
|
* The path itself never leaves the machine, exactly as with `cwd_hash` on the
|
|
20
20
|
* session rows.
|
|
21
21
|
*/
|
|
22
|
-
import { noteDoctorRequest } from "./sync-doctor-request.js";
|
|
23
22
|
import crypto from "node:crypto";
|
|
24
23
|
import { collectAgentSetup } from "./agent-setup.js";
|
|
25
24
|
import fs from "node:fs";
|
|
26
25
|
import os from "node:os";
|
|
27
26
|
import path from "node:path";
|
|
28
|
-
import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, MemoryInstallReceiptSchema,
|
|
27
|
+
import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, MemoryInstallReceiptSchema, setupReceiptGaps, } from "@bli-cockpit/telemetry-core";
|
|
29
28
|
import { readCachedSetupReceipt } from "./setup-receipt.js";
|
|
30
29
|
import { readStagingInventory } from "../disk-usage.js";
|
|
31
30
|
import { describeError } from "../health-detail.js";
|
|
32
31
|
import { shouldSuppressFleetReceipts, } from "../dev-build.js";
|
|
33
32
|
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
34
|
-
import {
|
|
33
|
+
import { postHeartbeat } from "./heartbeat-post.js";
|
|
34
|
+
import { readHeartbeatSession } from "./heartbeat-token.js";
|
|
35
35
|
import { withTokenRotationLock } from "./heartbeat-token-lock.js";
|
|
36
|
-
import { classifySyncHealthError } from "../sync-health-class.js";
|
|
37
36
|
import { readMemoryReceiptFile } from "./memory-install-receipt.js";
|
|
38
37
|
import { readMemoryHookCounts } from "./memory-hook-counts.js";
|
|
39
38
|
import { readMemoryHookPerformance } from "./memory-hook-performance.js";
|
|
40
|
-
const HEARTBEAT_TIMEOUT_MS = 5_000;
|
|
41
39
|
/**
|
|
42
40
|
* The label form of one approved root.
|
|
43
41
|
*
|
|
@@ -226,23 +224,8 @@ export async function sendCollectorHeartbeatBestEffort(options) {
|
|
|
226
224
|
return withTokenRotationLock(options.homeDir, options.lock, false, () => sendCollectorHeartbeatLocked(options));
|
|
227
225
|
}
|
|
228
226
|
async function sendCollectorHeartbeatLocked(options) {
|
|
229
|
-
|
|
230
|
-
// device row, so an agent's worktree could keep a genuinely dead machine
|
|
231
|
-
// looking alive to the BLI-3550 reader.
|
|
232
|
-
const suppression = shouldSuppressFleetReceipts({
|
|
233
|
-
dashboardUrl: options.dashboardUrl,
|
|
234
|
-
...(options.devBuildProbe ? { probe: options.devBuildProbe } : {}),
|
|
235
|
-
});
|
|
236
|
-
if (suppression.suppressed) {
|
|
237
|
-
console.error("[heartbeat] dev build; this tick is not checking in with the fleet", JSON.stringify({
|
|
238
|
-
reason: "receipt_suppressed:dev_build",
|
|
239
|
-
detected_by: suppression.reason,
|
|
240
|
-
root_count: options.roots.length,
|
|
241
|
-
sync_status: options.facts.status,
|
|
242
|
-
fix: "set COCKPIT_DEV=0 to heartbeat from a checkout deliberately",
|
|
243
|
-
}));
|
|
227
|
+
if (isDevBuildSuppressed(options))
|
|
244
228
|
return false;
|
|
245
|
-
}
|
|
246
229
|
// BLI-4019. Whether this machine may knock at all, and the two log lines
|
|
247
230
|
// that go with it, live in `heartbeat-token.ts` — the check-in goes out even
|
|
248
231
|
// when the local copy says `expired`, because that is the door the server
|
|
@@ -255,12 +238,46 @@ async function sendCollectorHeartbeatLocked(options) {
|
|
|
255
238
|
...(options.homeDir ? { homeDir: options.homeDir } : {}),
|
|
256
239
|
...(options.now ? { now: options.now } : {}),
|
|
257
240
|
}).catch(() => ({ receipt: null, reason: "receipt_read_threw" }));
|
|
258
|
-
// BLI-3731. Read the CACHE, never re-probe: computing the receipt costs five
|
|
259
|
-
// file reads and a `--print-config` spawn, and these facts change about
|
|
260
|
-
// monthly. `cockpit memory install`, `onboard` and `doctor` refresh it; a
|
|
261
|
-
// tick only carries it. Both branches say something, because "the ops board
|
|
262
|
-
// shows no receipt for this machine" has two very different causes.
|
|
263
241
|
const setupReceipt = await readCachedSetupReceipt(options.homeDir, options.now ?? new Date());
|
|
242
|
+
logSetupReceiptCarried(setupReceipt);
|
|
243
|
+
const heartbeat = buildCollectorHeartbeat({
|
|
244
|
+
roots: options.roots,
|
|
245
|
+
facts: options.facts,
|
|
246
|
+
setupReceipt: setupReceipt ? { ...setupReceipt, agent_setup: await collectAgentSetup(options.homeDir ?? os.homedir()) } : null,
|
|
247
|
+
...(options.now ? { now: options.now } : {}),
|
|
248
|
+
memoryInstall: memory.receipt,
|
|
249
|
+
});
|
|
250
|
+
return postHeartbeat(options, session.device_token, heartbeat, memory);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* BLI-3554. A checkout's heartbeat would move `last_seen_at` on a real fleet
|
|
254
|
+
* device row, so an agent's worktree could keep a genuinely dead machine
|
|
255
|
+
* looking alive to the BLI-3550 reader.
|
|
256
|
+
*/
|
|
257
|
+
function isDevBuildSuppressed(options) {
|
|
258
|
+
const suppression = shouldSuppressFleetReceipts({
|
|
259
|
+
dashboardUrl: options.dashboardUrl,
|
|
260
|
+
...(options.devBuildProbe ? { probe: options.devBuildProbe } : {}),
|
|
261
|
+
});
|
|
262
|
+
if (!suppression.suppressed)
|
|
263
|
+
return false;
|
|
264
|
+
console.error("[heartbeat] dev build; this tick is not checking in with the fleet", JSON.stringify({
|
|
265
|
+
reason: "receipt_suppressed:dev_build",
|
|
266
|
+
detected_by: suppression.reason,
|
|
267
|
+
root_count: options.roots.length,
|
|
268
|
+
sync_status: options.facts.status,
|
|
269
|
+
fix: "set COCKPIT_DEV=0 to heartbeat from a checkout deliberately",
|
|
270
|
+
}));
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* BLI-3731. Read the CACHE, never re-probe: computing the receipt costs five
|
|
275
|
+
* file reads and a `--print-config` spawn, and these facts change about
|
|
276
|
+
* monthly. `cockpit memory install`, `onboard` and `doctor` refresh it; a
|
|
277
|
+
* tick only carries it. Both branches say something, because "the ops board
|
|
278
|
+
* shows no receipt for this machine" has two very different causes.
|
|
279
|
+
*/
|
|
280
|
+
function logSetupReceiptCarried(setupReceipt) {
|
|
264
281
|
console.error(setupReceipt
|
|
265
282
|
? "[heartbeat] carrying this machine's setup receipt"
|
|
266
283
|
: "[heartbeat] no usable setup receipt to carry; the ops board keeps the last one it has", JSON.stringify(setupReceipt
|
|
@@ -273,103 +290,4 @@ async function sendCollectorHeartbeatLocked(options) {
|
|
|
273
290
|
reason: "setup_receipt_absent_or_stale",
|
|
274
291
|
next_action: "run `cockpit doctor` to refresh it",
|
|
275
292
|
}));
|
|
276
|
-
const heartbeat = buildCollectorHeartbeat({
|
|
277
|
-
roots: options.roots,
|
|
278
|
-
facts: options.facts,
|
|
279
|
-
setupReceipt: setupReceipt ? { ...setupReceipt, agent_setup: await collectAgentSetup(options.homeDir ?? os.homedir()) } : null,
|
|
280
|
-
...(options.now ? { now: options.now } : {}),
|
|
281
|
-
memoryInstall: memory.receipt,
|
|
282
|
-
});
|
|
283
|
-
const controller = new AbortController();
|
|
284
|
-
const timeout = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
|
|
285
|
-
try {
|
|
286
|
-
const response = await options.io.fetch(`${options.dashboardUrl}/api/ambient/heartbeat`, {
|
|
287
|
-
method: "POST",
|
|
288
|
-
headers: {
|
|
289
|
-
"Content-Type": "application/json",
|
|
290
|
-
Authorization: `Bearer ${session.device_token}`,
|
|
291
|
-
// BLI-17994. "I store a renewed token if you send one": the dashboard
|
|
292
|
-
// only rotates for a collector that says so.
|
|
293
|
-
[TOKEN_ROTATION_HEADER]: "1",
|
|
294
|
-
},
|
|
295
|
-
body: JSON.stringify(heartbeat),
|
|
296
|
-
signal: controller.signal,
|
|
297
|
-
});
|
|
298
|
-
noteHeartbeatRefusal(response.ok ? null : response.status);
|
|
299
|
-
if (!response.ok) {
|
|
300
|
-
// A heartbeat is not retried, so the reason has to be said once, here.
|
|
301
|
-
// BLI-4019: the CLASS comes from `sync-health-class.ts`, the one place
|
|
302
|
-
// that decides what a failure is — a 401/403 is `auth_failed` and
|
|
303
|
-
// anything else is not, read off the observed status and never off the
|
|
304
|
-
// words in a message (BLI-3551's rule).
|
|
305
|
-
// BLI-17994. The door names why it refused (`device_token_expired`,
|
|
306
|
-
// `device_token_replaced`) in a `reason` field; say it here rather than
|
|
307
|
-
// leaving a bare status.
|
|
308
|
-
const refusal = await Promise.resolve().then(() => response.clone().json()).catch(() => null);
|
|
309
|
-
console.error("[heartbeat] the dashboard refused this tick's heartbeat", JSON.stringify({
|
|
310
|
-
reason: "heartbeat_rejected",
|
|
311
|
-
health_class: classifySyncHealthError({ httpStatus: response.status }),
|
|
312
|
-
http_status: response.status,
|
|
313
|
-
root_count: heartbeat.roots.length,
|
|
314
|
-
...(typeof refusal?.["reason"] === "string" ? { server_reason: refusal["reason"] } : {}),
|
|
315
|
-
...(response.status === 401 || response.status === 403
|
|
316
|
-
? {
|
|
317
|
-
next_action: "this token expired, was replaced or was revoked: run `cockpit doctor` to pair this machine again",
|
|
318
|
-
}
|
|
319
|
-
: {}),
|
|
320
|
-
}));
|
|
321
|
-
return false;
|
|
322
|
-
}
|
|
323
|
-
// The reply carries `token_expires_at` (and a `pending_device_token` when
|
|
324
|
-
// the token was near its end); the sibling records both, so this machine's
|
|
325
|
-
// own copy follows the server rather than drifting.
|
|
326
|
-
const reply = await response.clone().json().catch(() => null);
|
|
327
|
-
noteDoctorRequest(reply?.device_action);
|
|
328
|
-
// BLI-17994. Store a pending successor if the door handed one out (the
|
|
329
|
-
// date does not move: a pending token has not been used), then the date the
|
|
330
|
-
// door reports, which moves only when this request promoted a token.
|
|
331
|
-
await recordHeartbeatPendingToken(options.homeDir, reply);
|
|
332
|
-
await recordHeartbeatTokenExpiry(options.homeDir, response);
|
|
333
|
-
console.error("[heartbeat] checked in", JSON.stringify({
|
|
334
|
-
reason: "heartbeat_recorded",
|
|
335
|
-
http_status: response.status,
|
|
336
|
-
root_count: heartbeat.roots.length,
|
|
337
|
-
sync_status: heartbeat.last_sync_status,
|
|
338
|
-
sync_reason: heartbeat.last_sync_reason ?? null,
|
|
339
|
-
sessions_observed: heartbeat.sessions_observed ?? null,
|
|
340
|
-
sessions_outside_root: heartbeat.sessions_outside_root ?? null,
|
|
341
|
-
sessions_new_this_tick: heartbeat.sessions_new_this_tick ?? null,
|
|
342
|
-
sessions_pending_upload: heartbeat.sessions_pending_upload ?? null,
|
|
343
|
-
// BLI-3729. Both branches: a tick that reports no receipt says WHY,
|
|
344
|
-
// and a tick that reports one says whether memory is fully on. "Is BLI
|
|
345
|
-
// Memory switched on for this machine?" is now answerable from
|
|
346
|
-
// sync.err.log alone.
|
|
347
|
-
memory_receipt: memory.reason,
|
|
348
|
-
memory_gaps: memory.receipt ? memoryInstallGaps(memory.receipt) : null,
|
|
349
|
-
// BLI-3788. The hooks are registered (above) AND they either worked or
|
|
350
|
-
// did not (here). A prompt hook that misses its deadline prints
|
|
351
|
-
// nothing to the person, so these counts are the only trace one leaves
|
|
352
|
-
// on this machine; `null` means no counter file, never zero misses.
|
|
353
|
-
memory_hook_runs_24h: memory.receipt?.hook_runs_24h ?? null,
|
|
354
|
-
memory_hook_timeouts_24h: memory.receipt?.hook_timeouts_24h ?? null,
|
|
355
|
-
memory_hook_stats: memory.receipt?.hook_stats_reason ?? null,
|
|
356
|
-
// BLI-3884. Which route answered those recalls. `null` on a machine
|
|
357
|
-
// whose memory-mcp does not name one yet — never zero, which would
|
|
358
|
-
// read as "the daemon answered none".
|
|
359
|
-
memory_hook_via_daemon_24h: memory.receipt?.hook_via_daemon_24h ?? null,
|
|
360
|
-
memory_hook_via_direct_24h: memory.receipt?.hook_via_direct_24h ?? null,
|
|
361
|
-
}));
|
|
362
|
-
return true;
|
|
363
|
-
}
|
|
364
|
-
catch (error) {
|
|
365
|
-
console.error("[heartbeat] could not reach the dashboard this tick", JSON.stringify({
|
|
366
|
-
reason: "heartbeat_transport_failed",
|
|
367
|
-
root_count: heartbeat.roots.length,
|
|
368
|
-
...describeError(error),
|
|
369
|
-
}));
|
|
370
|
-
return false;
|
|
371
|
-
}
|
|
372
|
-
finally {
|
|
373
|
-
clearTimeout(timeout);
|
|
374
|
-
}
|
|
375
293
|
}
|
|
@@ -102,21 +102,7 @@ export async function runUpdate(command, io) {
|
|
|
102
102
|
addInstallEvent(installEvents, "npm_install", "fail", error.eacces
|
|
103
103
|
? "npm_install_eacces"
|
|
104
104
|
: "npm_install_failed");
|
|
105
|
-
|
|
106
|
-
writeLine(io.stdout, JSON.stringify({
|
|
107
|
-
status: "blocked",
|
|
108
|
-
step: "npm_install",
|
|
109
|
-
command: `npm ${selfUpdateInstallArgs().join(" ")}`,
|
|
110
|
-
exit_code: error.result.code,
|
|
111
|
-
}, null, 2));
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
writeLine(io.stderr, "BLOCKED: npm install failed; Tower CLI was not refreshed.");
|
|
115
|
-
if (error.eacces) {
|
|
116
|
-
writeLine(io.stderr, "Fix Homebrew npm ownership once: sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit");
|
|
117
|
-
writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
|
|
118
|
-
}
|
|
119
|
-
}
|
|
105
|
+
writeSelfUpdateBlocked(command, io, error);
|
|
120
106
|
return finish(error.result.code || 1);
|
|
121
107
|
}
|
|
122
108
|
addInstallEvent(installEvents, "npm_install", "ok");
|
|
@@ -131,6 +117,22 @@ export async function runUpdate(command, io) {
|
|
|
131
117
|
addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
|
|
132
118
|
return finish(onboard.code);
|
|
133
119
|
}
|
|
120
|
+
function writeSelfUpdateBlocked(command, io, error) {
|
|
121
|
+
if (command.json) {
|
|
122
|
+
writeLine(io.stdout, JSON.stringify({
|
|
123
|
+
status: "blocked",
|
|
124
|
+
step: "npm_install",
|
|
125
|
+
command: `npm ${selfUpdateInstallArgs().join(" ")}`,
|
|
126
|
+
exit_code: error.result.code,
|
|
127
|
+
}, null, 2));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
writeLine(io.stderr, "BLOCKED: npm install failed; Tower CLI was not refreshed.");
|
|
131
|
+
if (error.eacces) {
|
|
132
|
+
writeLine(io.stderr, "Fix Homebrew npm ownership once: sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit");
|
|
133
|
+
writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
134
136
|
export async function runSelfUpdate(io, options = {}) {
|
|
135
137
|
const exec = io.exec ?? defaultExec();
|
|
136
138
|
const tag = options.tag ?? "latest";
|
|
@@ -91,40 +91,39 @@ export async function showIssue(command, door) {
|
|
|
91
91
|
results.push(await fetchOneIssue(door, ref));
|
|
92
92
|
// BLI-10450: one directory call for the whole batch, never one per issue.
|
|
93
93
|
const directory = await resolveAssigneeDirectory(door, results.filter((result) => result.ok).map((result) => result.issue.assignee_id));
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
94
|
+
if (refs.length === 1)
|
|
95
|
+
return reportSingleIssue(door, results[0], directory);
|
|
96
|
+
return reportSeveralIssues(door, results, directory);
|
|
97
|
+
}
|
|
98
|
+
/** The JSON body for one issue that was read: the issue, its comments and any Linear link. */
|
|
99
|
+
function readIssueJson(result, directory) {
|
|
100
|
+
return {
|
|
101
|
+
issue: withSizeLabel(withAssigneeIdentity(result.issue, directory)),
|
|
102
|
+
comments: result.comments,
|
|
103
|
+
...(result.linearTicketId ? { linear_ticket_id: result.linearTicketId } : {}),
|
|
104
|
+
...(result.commentsUnreadReason ? { comments_unread_reason: result.commentsUnreadReason } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// A single ref keeps the pre-BLI-10450 shape and failure path exactly.
|
|
108
|
+
function reportSingleIssue(door, result, directory) {
|
|
109
|
+
if (!result || !result.ok) {
|
|
110
|
+
return failAgentDoor(door, TAG, result?.reason ?? "issue_not_found_or_unreadable", result?.detail ?? "That issue does not exist, or you cannot read it.");
|
|
111
111
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
if (door.json)
|
|
113
|
+
return emitAgentDoor(door, { ok: true, ...readIssueJson(result, directory) });
|
|
114
|
+
renderOneIssue(door, result, directory);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
// Several refs: `{ ok, issues: [...] }`, one entry per ref in the order
|
|
118
|
+
// given, each carrying `ref` so a caller can match an answer back to what
|
|
119
|
+
// it asked for. One bad id never loses the rest of the batch.
|
|
120
|
+
function reportSeveralIssues(door, results, directory) {
|
|
115
121
|
const failed = results.filter((result) => !result.ok);
|
|
116
122
|
if (door.json) {
|
|
117
123
|
return emitAgentDoor(door, {
|
|
118
124
|
ok: failed.length === 0,
|
|
119
125
|
issues: results.map((result) => result.ok
|
|
120
|
-
? {
|
|
121
|
-
ref: result.ref,
|
|
122
|
-
ok: true,
|
|
123
|
-
issue: withSizeLabel(withAssigneeIdentity(result.issue, directory)),
|
|
124
|
-
comments: result.comments,
|
|
125
|
-
...(result.linearTicketId ? { linear_ticket_id: result.linearTicketId } : {}),
|
|
126
|
-
...(result.commentsUnreadReason ? { comments_unread_reason: result.commentsUnreadReason } : {}),
|
|
127
|
-
}
|
|
126
|
+
? { ref: result.ref, ok: true, ...readIssueJson(result, directory) }
|
|
128
127
|
: { ref: result.ref, ok: false, error: result.reason, detail: result.detail }),
|
|
129
128
|
}, failed.length === 0 ? 0 : 1);
|
|
130
129
|
}
|