@bli-cockpit/cli 0.2.237 → 0.2.239

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.
Files changed (148) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +15 -11
  2. package/dist/adapters/attribution-core-score.js +111 -87
  3. package/dist/adapters/car-state.js +38 -35
  4. package/dist/adapters/claude-attribution-discovery.js +81 -56
  5. package/dist/adapters/claude-attribution-score.js +108 -94
  6. package/dist/adapters/claude-attribution-signals.js +52 -42
  7. package/dist/adapters/codex-attribution.js +42 -33
  8. package/dist/adapters/codex-session-signals.js +11 -9
  9. package/dist/adapters/git-state.js +56 -50
  10. package/dist/adapters/local-sources.js +52 -43
  11. package/dist/adapters/raw-evidence-git-diff.js +4 -2
  12. package/dist/adapters/raw-evidence-sanitize.js +15 -12
  13. package/dist/adapters/raw-evidence-transcript-file.js +32 -31
  14. package/dist/autostart-self-heal.js +18 -2
  15. package/dist/commands/agent-session-report.js +51 -59
  16. package/dist/commands/agent-setup-observed.js +30 -23
  17. package/dist/commands/agent-setup.js +96 -45
  18. package/dist/commands/autostart-heal.js +8 -2
  19. package/dist/commands/autostart-reading.js +3 -0
  20. package/dist/commands/backfill-evidence-outcomes.js +9 -6
  21. package/dist/commands/backfill-issues.js +3 -0
  22. package/dist/commands/backfill-scan.js +27 -22
  23. package/dist/commands/backfill-upload.js +69 -60
  24. package/dist/commands/backfill.js +26 -21
  25. package/dist/commands/brief-done.js +4 -1
  26. package/dist/commands/brief-edit.js +71 -46
  27. package/dist/commands/cal-detail.js +35 -0
  28. package/dist/commands/cal-direct.js +119 -0
  29. package/dist/commands/cal-overlay.js +73 -30
  30. package/dist/commands/cal.js +62 -25
  31. package/dist/commands/clean.js +4 -1
  32. package/dist/commands/docs-read.js +5 -2
  33. package/dist/commands/docs.js +38 -6
  34. package/dist/commands/doctor-backfill.js +88 -71
  35. package/dist/commands/doctor-pipeline.js +59 -46
  36. package/dist/commands/heartbeat-post.js +116 -0
  37. package/dist/commands/heartbeat-token-lock.js +46 -33
  38. package/dist/commands/heartbeat.js +43 -125
  39. package/dist/commands/install-update.js +17 -15
  40. package/dist/commands/issue-show.js +27 -28
  41. package/dist/commands/issue-versions.js +93 -0
  42. package/dist/commands/issue-write.js +70 -23
  43. package/dist/commands/issue.js +73 -37
  44. package/dist/commands/jarvis-live-answer.js +99 -0
  45. package/dist/commands/jarvis-render.js +1 -94
  46. package/dist/commands/jarvis-trace.js +30 -22
  47. package/dist/commands/jarvis-turn-report.js +124 -0
  48. package/dist/commands/jarvis-turn.js +108 -182
  49. package/dist/commands/jarvis.js +26 -17
  50. package/dist/commands/local-args-collector-setup.js +82 -84
  51. package/dist/commands/local-args-collector-work.js +37 -38
  52. package/dist/commands/local-args-tower-admin.js +155 -142
  53. package/dist/commands/local-args-tower-cal.js +69 -54
  54. package/dist/commands/local-args-tower-careers.js +73 -54
  55. package/dist/commands/local-args-tower-docs-msg.js +79 -51
  56. package/dist/commands/local-args-tower-github.js +26 -20
  57. package/dist/commands/local-args-tower-hits.js +32 -20
  58. package/dist/commands/local-args-tower-manager.js +85 -4
  59. package/dist/commands/local-args-tower-memory.js +139 -117
  60. package/dist/commands/local-args-tower-msg.js +24 -6
  61. package/dist/commands/local-args-tower-notes.js +346 -0
  62. package/dist/commands/local-args-tower-pages.js +100 -378
  63. package/dist/commands/local-args-tower-settings.js +28 -29
  64. package/dist/commands/local-args-tower-setup.js +11 -4
  65. package/dist/commands/local-args-tower-work.js +74 -37
  66. package/dist/commands/local-args.js +80 -129
  67. package/dist/commands/local-help-commands-tower-ops.js +15 -4
  68. package/dist/commands/local-help-commands-tower.js +20 -7
  69. package/dist/commands/local-help-commands.js +342 -341
  70. package/dist/commands/local-help.js +87 -83
  71. package/dist/commands/local-run-guards.js +100 -0
  72. package/dist/commands/local.js +137 -206
  73. package/dist/commands/login.js +13 -9
  74. package/dist/commands/logs.js +55 -38
  75. package/dist/commands/mail-attachments.js +44 -26
  76. package/dist/commands/manager-render-home.js +208 -0
  77. package/dist/commands/manager-render.js +7 -3
  78. package/dist/commands/manager-requests.js +33 -0
  79. package/dist/commands/manager.js +39 -5
  80. package/dist/commands/memory-consolidation.js +18 -9
  81. package/dist/commands/memory-daemon-probe.js +37 -28
  82. package/dist/commands/memory-install-config.js +64 -60
  83. package/dist/commands/memory-install.js +21 -41
  84. package/dist/commands/memory-requeue.js +26 -19
  85. package/dist/commands/memory-rules.js +26 -18
  86. package/dist/commands/memory-search-print.js +26 -0
  87. package/dist/commands/memory-store.js +14 -30
  88. package/dist/commands/memory-verb-list.js +49 -48
  89. package/dist/commands/msg-read-line.js +32 -0
  90. package/dist/commands/msg.js +45 -3
  91. package/dist/commands/notes-accounts.js +49 -32
  92. package/dist/commands/notes-sync-loop.js +115 -89
  93. package/dist/commands/notes-wisprflow-login.js +44 -23
  94. package/dist/commands/notes-writes.js +115 -100
  95. package/dist/commands/notes.js +33 -55
  96. package/dist/commands/onboard-flows.js +69 -51
  97. package/dist/commands/onboard.js +11 -9
  98. package/dist/commands/ops-render-memory.js +10 -7
  99. package/dist/commands/ops-render.js +33 -30
  100. package/dist/commands/ops.js +3 -0
  101. package/dist/commands/public-root.js +1 -1
  102. package/dist/commands/session-sync-outside-roots.js +40 -36
  103. package/dist/commands/session-sync-plan.js +14 -10
  104. package/dist/commands/session-sync-upload.js +24 -22
  105. package/dist/commands/session-sync.js +19 -8
  106. package/dist/commands/setup.js +13 -0
  107. package/dist/commands/slack.js +25 -16
  108. package/dist/commands/sources-check.js +13 -7
  109. package/dist/commands/sources-discovery.js +25 -17
  110. package/dist/commands/sources-verdict.js +19 -18
  111. package/dist/commands/speed.js +23 -19
  112. package/dist/commands/start.js +20 -16
  113. package/dist/commands/sync-doctor.js +52 -48
  114. package/dist/commands/sync.js +22 -16
  115. package/dist/commands/usage-telemetry-toml.js +71 -45
  116. package/dist/commands/usage.js +94 -68
  117. package/dist/commands/workbook-render.js +10 -7
  118. package/dist/evidence-reconcile-client.js +75 -64
  119. package/dist/evidence-redelivery-plan.js +37 -34
  120. package/dist/evidence-redelivery.js +32 -29
  121. package/dist/evidence-transcripts-client.js +20 -20
  122. package/dist/evidence-upload-object.js +86 -89
  123. package/dist/evidence-upload-plan.js +94 -83
  124. package/dist/evidence-upload-terminal.js +45 -50
  125. package/dist/image-description-session.js +156 -159
  126. package/dist/local-state-pairing-code.js +39 -31
  127. package/dist/local-state-status.js +9 -2
  128. package/dist/onboarding-roots.js +5 -8
  129. package/dist/process-runner.js +8 -8
  130. package/dist/raw-evidence-gc.js +33 -38
  131. package/dist/raw-evidence-stuck-summary.js +7 -14
  132. package/dist/repo-identity.js +26 -23
  133. package/dist/scheduled-self-update.js +11 -15
  134. package/dist/second-install.js +3 -0
  135. package/dist/slim/codex-rules.js +23 -36
  136. package/dist/spool/local-spool-parse.js +13 -12
  137. package/dist/sync-lock.js +5 -15
  138. package/dist/tower-client.js +36 -53
  139. package/dist/tower-stream.js +7 -24
  140. package/dist/upload-agent-artifacts.js +58 -64
  141. package/dist/upload-envelope-build.js +60 -59
  142. package/dist/upload-evidence-delivery-reconcile.js +58 -56
  143. package/dist/upload-evidence-delivery-summary.js +32 -34
  144. package/dist/upload-ingest-receipt.js +20 -23
  145. package/dist/upload-session-reports-queue.js +4 -1
  146. package/dist/upload-session-reports-wire.js +64 -68
  147. package/dist/upload-sync.js +171 -157
  148. package/package.json +4 -4
@@ -27,6 +27,7 @@
27
27
  * `document_not_writable`, `title_too_long`, `content_too_long`,
28
28
  * `circular_parent`, and so on — never rephrased here.
29
29
  */
30
+ import { randomUUID } from "node:crypto";
30
31
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
31
32
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
32
33
  import { readDoc } from "./docs-read.js";
@@ -59,6 +60,8 @@ export async function runDocs(command, io) {
59
60
  return syncStatusDocs(command, door);
60
61
  case "export":
61
62
  return exportDoc(command, door);
63
+ case "mutation-status":
64
+ return mutationStatusDocs(command, door);
62
65
  }
63
66
  }
64
67
  /** `--parent`, `--query`, `--word`, `--limit`, `--bodies` (BLI-10450) as the door's own query parameters (BLI-3737). */
@@ -248,14 +251,41 @@ async function updateDoc(command, door) {
248
251
  && !command.clearParent) {
249
252
  return failAgentDoor(door, TAG, "invalid_body", "docs update needs at least one of --title, --visibility, --parent/--clear-parent, or a body on --body-stdin/--file.");
250
253
  }
251
- const answer = await updateDocument(command, door, resolved.id, bodyMarkdown);
254
+ // BLI-21298: every save carries a request id, so a lost response can be checked and a retry applies once.
255
+ const requestId = command.requestId ?? randomUUID();
256
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision })}`);
257
+ const answer = await updateDocument(command, door, resolved.id, bodyMarkdown, requestId);
258
+ if (!answer.ok)
259
+ return refuseDocsWrite(door, answer, requestId);
260
+ const { document = {}, mutation } = answer.body;
261
+ writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ document_id: document["id"] ?? resolved.id, request_id: requestId, revision: mutation?.["revision"] ?? document["revision"] })}`);
262
+ if (door.json)
263
+ return emitAgentDoor(door, { ok: true, document, mutation });
264
+ writeLine(door.io.stdout, `Updated ${String(document["title"] ?? "")} (${String(document["id"] ?? resolved.id)}), revision ${String(mutation?.["revision"] ?? document["revision"] ?? "?")}.`);
265
+ return 0;
266
+ }
267
+ /** Says where the save stands, then fails with the door's own reason (a conflict names the current revision). */
268
+ function refuseDocsWrite(door, answer, requestId) {
269
+ const mutation = answer.body?.mutation;
270
+ writeLine(door.io.stderr, `${TAG} check the save with cockpit docs mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
271
+ if (mutation?.current) {
272
+ writeLine(door.io.stderr, `${TAG} conflict ${JSON.stringify({ request_id: requestId, current_revision: mutation.current.revision ?? mutation.revision })}`);
273
+ }
274
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
275
+ }
276
+ /** BLI-21298: reads only your own save receipt. A missing receipt is unknown_check_again, never "nothing changed". */
277
+ async function mutationStatusDocs(command, door) {
278
+ const answer = await askAgentDoor(door, {
279
+ path: `/api/docs/mutations/${encodeURIComponent(command.docRef ?? "")}`,
280
+ method: "GET",
281
+ label: "docs mutation status",
282
+ timeoutMs: READ_DEADLINE_MS,
283
+ });
252
284
  if (!answer.ok)
253
285
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
254
- const document = answer.body.document ?? {};
255
- writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ document_id: document["id"] ?? resolved.id })}`);
256
286
  if (door.json)
257
- return emitAgentDoor(door, { ok: true, document });
258
- writeLine(door.io.stdout, `Updated ${String(document["title"] ?? "")} (${String(document["id"] ?? resolved.id)}).`);
287
+ return emitAgentDoor(door, { ok: true, ...answer.body });
288
+ writeLine(door.io.stdout, JSON.stringify(answer.body));
259
289
  return 0;
260
290
  }
261
291
  /**
@@ -327,13 +357,15 @@ async function deleteOneDocument(door, ref, id) {
327
357
  return { ref, ok: false, reason: answer.reason, detail: answer.detail };
328
358
  return { ref, ok: true, id };
329
359
  }
330
- async function updateDocument(command, door, documentId, bodyMarkdown) {
360
+ async function updateDocument(command, door, documentId, bodyMarkdown, requestId) {
331
361
  return askAgentDoor(door, {
332
362
  path: `/api/docs/documents/${encodeURIComponent(documentId)}`,
333
363
  method: "PATCH",
334
364
  label: "docs update",
335
365
  timeoutMs: WRITE_DEADLINE_MS,
336
366
  body: {
367
+ request_id: requestId,
368
+ ...(command.expectedRevision !== undefined ? { expected_revision: command.expectedRevision } : {}),
337
369
  ...(command.title !== undefined ? { title: command.title } : {}),
338
370
  ...(bodyMarkdown !== undefined ? { body_markdown: bodyMarkdown } : {}),
339
371
  ...(command.visibility !== undefined ? { visibility: command.visibility } : {}),
@@ -36,91 +36,108 @@ export async function checkBackfillState(context) {
36
36
  */
37
37
  export async function fixBackfillState(context) {
38
38
  const started = Date.now();
39
- const deadline = started + doctorBackfillBudgetSeconds(context, started) * 1000;
40
- let caughtUp = 0;
41
- let chunks = 0;
42
- let live = null;
43
- let remaining = null;
44
- const receipt = () => `caught up on ${caughtUp} old sessions in ${chunks} chunk${chunks === 1 ? "" : "s"} over ${clock(Date.now() - started)}`;
45
- const unfinished = (code, reason) => ({
46
- ...needsFix("backfill-complete", code, `${receipt()}, ${remaining ?? "unknown"} still to go; ${reason}; run \`cockpit doctor\` again to continue`),
39
+ const run = {
40
+ started,
41
+ deadline: started + doctorBackfillBudgetSeconds(context, started) * 1000,
42
+ caughtUp: 0,
43
+ chunks: 0,
44
+ live: null,
45
+ remaining: null,
46
+ };
47
+ const stopProgress = startProgress((text) => context.io.stderr.write(text), progressReporter(run));
48
+ try {
49
+ return await catchUpChunkByChunk(context, run);
50
+ }
51
+ catch (error) {
52
+ console.error("[doctor] backfill-complete failed", JSON.stringify({ reason: "backfill_threw", error_name: error instanceof Error ? error.name : "unknown" }));
53
+ return unfinishedRow(run, "backfill_threw", "backfill failed");
54
+ }
55
+ finally {
56
+ stopProgress();
57
+ }
58
+ }
59
+ function describeReceipt(run) {
60
+ return `caught up on ${run.caughtUp} old sessions in ${run.chunks} chunk${run.chunks === 1 ? "" : "s"} over ${clock(Date.now() - run.started)}`;
61
+ }
62
+ function unfinishedRow(run, code, reason) {
63
+ return {
64
+ ...needsFix("backfill-complete", code, `${describeReceipt(run)}, ${run.remaining ?? "unknown"} still to go; ${reason}; run \`cockpit doctor\` again to continue`),
47
65
  nextAction: "cockpit doctor",
48
- });
66
+ };
67
+ }
68
+ function progressReporter(run) {
49
69
  let lastLine = null;
50
- const stopProgress = startProgress((text) => context.io.stderr.write(text), (elapsedMs) => {
70
+ return (elapsedMs) => {
71
+ const { live } = run;
51
72
  const snapshot = {
52
- caughtUp: live === null ? caughtUp : caughtUp + live.uploaded,
53
- remaining: live?.remaining ?? remaining,
73
+ caughtUp: live === null ? run.caughtUp : run.caughtUp + live.uploaded,
74
+ remaining: live?.remaining ?? run.remaining,
54
75
  };
55
76
  console.error("[doctor] backfill-complete progress", JSON.stringify({
56
77
  status: live === null ? "between_chunks" : "running", processed: live?.processed ?? null,
57
- uploaded: live === null ? null : caughtUp + live.uploaded,
58
- caught_up: snapshot.caughtUp, remaining: snapshot.remaining, chunks, elapsed_seconds: Math.floor(elapsedMs / 1000),
78
+ uploaded: live === null ? null : run.caughtUp + live.uploaded,
79
+ caught_up: snapshot.caughtUp, remaining: snapshot.remaining, chunks: run.chunks, elapsed_seconds: Math.floor(elapsedMs / 1000),
59
80
  }));
60
81
  const line = backfillProgressLine(snapshot, lastLine, elapsedMs);
61
82
  lastLine = snapshot;
62
83
  return line;
63
- });
64
- try {
65
- while (Date.now() < deadline) {
66
- const row = await withCollectionGate(context, "backfill-complete", async () => {
67
- if (Date.now() >= deadline)
68
- return unfinished("backfill_budget_timeout", "backfill time budget reached");
69
- const capture = capturedIo(context.io, false);
70
- live = null;
71
- chunks += 1;
72
- const code = await runBackfillCommand({
73
- homeDir: context.command.homeDir, repoRoot: context.command.repoRoot,
74
- all: true, dryRun: false, yes: true, json: true,
75
- deadlineMs: deadline,
76
- // Doctor never waits for the collection lock (BLI-12872): the gate
77
- // above looked at it, and a busy answer here is reported, not retried.
78
- syncLockWaitMs: 0,
79
- onProgress: (progress) => { live = progress; },
80
- }, capture.io);
81
- const parsed = parseDoctorBackfillJson(capture.stdout());
82
- const counts = asRecord(parsed?.counts);
83
- const count = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
84
- caughtUp += count(counts?.["backfilled"]) ?? 0;
85
- remaining = count(counts?.["remaining"]) ?? remaining;
86
- live = null;
87
- if (code === 0 && parsed?.status === "complete") {
88
- // Reporting totals include permanent caps. Preserve their marker
89
- // note instead of retrying history the collector says is complete.
90
- const checked = await checkBackfillState(context);
91
- return ok("backfill-complete", checked.status === "ok" ? checked.code : "complete", receipt() + (checked.status === "ok" ? `; ${checked.message}` : ""));
92
- }
93
- const verdict = backfillFixVerdict(parsed, jsonField(capture.stderr(), "failure_reason"));
94
- if (parsed?.status === "partial" &&
95
- (parsed.failure_reason === "deferred_budget_exhausted" || !parsed.failure_reason)) {
96
- return needsFix("backfill-complete", "backfill_chunk_pending", "more history remains");
97
- }
98
- return { ...verdict, message: typeof parsed?.failure_reason === "string" ? parsed.failure_reason : "backfill failed without a valid completion receipt" };
99
- });
100
- if (row.status === "ok") {
101
- console.error("[doctor] backfill-complete finished", JSON.stringify({ caught_up: caughtUp, remaining, chunks, elapsed_seconds: Math.floor((Date.now() - started) / 1000) }));
102
- return row;
103
- }
104
- if (row.status === "skipped") {
105
- // A running sync (or another catch-up) owns the lock. Doctor does not
106
- // wait; it says what this run managed before it stood aside.
107
- console.error("[doctor] backfill-complete stood aside", JSON.stringify({ reason: row.code, caught_up: caughtUp, remaining, chunks }));
108
- return caughtUp > 0 ? { ...row, message: `${receipt()}; ${row.message}` } : row;
109
- }
110
- if (row.code === "sync_stuck_not_ended")
111
- return row;
112
- if (row.code !== "backfill_chunk_pending")
113
- return unfinished(row.code, row.message);
84
+ };
85
+ }
86
+ async function catchUpChunkByChunk(context, run) {
87
+ while (Date.now() < run.deadline) {
88
+ const row = await withCollectionGate(context, "backfill-complete", () => runOneChunk(context, run));
89
+ if (row.status === "ok") {
90
+ console.error("[doctor] backfill-complete finished", JSON.stringify({ caught_up: run.caughtUp, remaining: run.remaining, chunks: run.chunks, elapsed_seconds: Math.floor((Date.now() - run.started) / 1000) }));
91
+ return row;
114
92
  }
115
- return unfinished("backfill_budget_timeout", "backfill time budget reached");
93
+ if (row.status === "skipped") {
94
+ // A running sync (or another catch-up) owns the lock. Doctor does not
95
+ // wait; it says what this run managed before it stood aside.
96
+ console.error("[doctor] backfill-complete stood aside", JSON.stringify({ reason: row.code, caught_up: run.caughtUp, remaining: run.remaining, chunks: run.chunks }));
97
+ return run.caughtUp > 0 ? { ...row, message: `${describeReceipt(run)}; ${row.message}` } : row;
98
+ }
99
+ if (row.code === "sync_stuck_not_ended")
100
+ return row;
101
+ if (row.code !== "backfill_chunk_pending")
102
+ return unfinishedRow(run, row.code, row.message);
116
103
  }
117
- catch (error) {
118
- console.error("[doctor] backfill-complete failed", JSON.stringify({ reason: "backfill_threw", error_name: error instanceof Error ? error.name : "unknown" }));
119
- return unfinished("backfill_threw", "backfill failed");
104
+ return unfinishedRow(run, "backfill_budget_timeout", "backfill time budget reached");
105
+ }
106
+ async function runOneChunk(context, run) {
107
+ if (Date.now() >= run.deadline)
108
+ return unfinishedRow(run, "backfill_budget_timeout", "backfill time budget reached");
109
+ const capture = capturedIo(context.io, false);
110
+ run.live = null;
111
+ run.chunks += 1;
112
+ const code = await runBackfillCommand({
113
+ homeDir: context.command.homeDir, repoRoot: context.command.repoRoot,
114
+ all: true, dryRun: false, yes: true, json: true,
115
+ deadlineMs: run.deadline,
116
+ // Doctor never waits for the collection lock (BLI-12872): the gate
117
+ // above looked at it, and a busy answer here is reported, not retried.
118
+ syncLockWaitMs: 0,
119
+ onProgress: (progress) => { run.live = progress; },
120
+ }, capture.io);
121
+ const parsed = parseDoctorBackfillJson(capture.stdout());
122
+ const counts = asRecord(parsed?.counts);
123
+ run.caughtUp += nonNegativeCount(counts?.["backfilled"]) ?? 0;
124
+ run.remaining = nonNegativeCount(counts?.["remaining"]) ?? run.remaining;
125
+ run.live = null;
126
+ if (code === 0 && parsed?.status === "complete") {
127
+ // Reporting totals include permanent caps. Preserve their marker
128
+ // note instead of retrying history the collector says is complete.
129
+ const checked = await checkBackfillState(context);
130
+ return ok("backfill-complete", checked.status === "ok" ? checked.code : "complete", describeReceipt(run) + (checked.status === "ok" ? `; ${checked.message}` : ""));
120
131
  }
121
- finally {
122
- stopProgress();
132
+ const verdict = backfillFixVerdict(parsed, jsonField(capture.stderr(), "failure_reason"));
133
+ if (parsed?.status === "partial" &&
134
+ (parsed.failure_reason === "deferred_budget_exhausted" || !parsed.failure_reason)) {
135
+ return needsFix("backfill-complete", "backfill_chunk_pending", "more history remains");
123
136
  }
137
+ return { ...verdict, message: typeof parsed?.failure_reason === "string" ? parsed.failure_reason : "backfill failed without a valid completion receipt" };
138
+ }
139
+ function nonNegativeCount(value) {
140
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
124
141
  }
125
142
  function capturedIo(io, forward) {
126
143
  const stdoutChunks = [];
@@ -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 args = ["sync", "--json", "--workspace", repoRoot, ...discoveryArgs];
186
- if (context.command.homeDir)
187
- args.push("--home", context.command.homeDir);
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
- try {
54
- const handle = await fs.open(lockPath, "wx", 0o600);
55
- await handle.writeFile(`${JSON.stringify({ pid: process.pid, owner, at_ms: Date.now() })}\n`);
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
- // A holder that died: take the lock over.
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
- 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" }));
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);