@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
@@ -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, memoryInstallGaps, setupReceiptGaps, } from "@bli-cockpit/telemetry-core";
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 { noteHeartbeatRefusal, readHeartbeatSession, recordHeartbeatTokenExpiry, recordHeartbeatPendingToken, TOKEN_ROTATION_HEADER, } from "./heartbeat-token.js";
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
- // BLI-3554. A checkout's heartbeat would move `last_seen_at` on a real fleet
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
- if (command.json) {
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
- // A single ref keeps the pre-BLI-10450 shape and failure path exactly.
95
- if (refs.length === 1) {
96
- const [result] = results;
97
- if (!result || !result.ok) {
98
- return failAgentDoor(door, TAG, result?.reason ?? "issue_not_found_or_unreadable", result?.detail ?? "That issue does not exist, or you cannot read it.");
99
- }
100
- if (door.json) {
101
- return emitAgentDoor(door, {
102
- ok: true,
103
- issue: withSizeLabel(withAssigneeIdentity(result.issue, directory)),
104
- comments: result.comments,
105
- ...(result.linearTicketId ? { linear_ticket_id: result.linearTicketId } : {}),
106
- ...(result.commentsUnreadReason ? { comments_unread_reason: result.commentsUnreadReason } : {}),
107
- });
108
- }
109
- renderOneIssue(door, result, directory);
110
- return 0;
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
- // Several refs: `{ ok, issues: [...] }`, one entry per ref in the order
113
- // given, each carrying `ref` so a caller can match an answer back to what
114
- // it asked for. One bad id never loses the rest of the batch.
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
  }
@@ -0,0 +1,93 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * `cockpit ticket versions <id>` and `cockpit ticket restore <id> --version <n>`
4
+ * (BLI-21300): the saved descriptions of one ticket, and the write that puts an
5
+ * older one back as a NEW version. Same doors the browser uses
6
+ * (`GET .../versions`, `POST .../versions/restore`).
7
+ *
8
+ * `restore` takes the version NUMBER the person read in `versions`, looks up
9
+ * that version's id in the same list, and sends the id: the door never guesses
10
+ * "the previous one". A number the list does not carry is refused here with
11
+ * the numbers that do exist, before anything is written.
12
+ */
13
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
14
+ import { writeLine } from "./cli-io.js";
15
+ import { READ_DEADLINE_MS, TAG, WRITE_DEADLINE_MS } from "./issue-contracts.js";
16
+ export async function listIssueVersions(command, door) {
17
+ const ref = command.issueRef ?? "";
18
+ const query = command.limit === undefined ? "" : `?limit=${command.limit}`;
19
+ const answer = await askAgentDoor(door, {
20
+ path: `/api/work/issues/${encodeURIComponent(ref)}/versions${query}`,
21
+ method: "GET",
22
+ label: "issue versions",
23
+ timeoutMs: READ_DEADLINE_MS,
24
+ });
25
+ if (!answer.ok)
26
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
27
+ const versions = answer.body.versions ?? [];
28
+ console.error(`${TAG} versions ${JSON.stringify({ count: versions.length })}`);
29
+ if (door.json)
30
+ return emitAgentDoor(door, { ok: true, versions });
31
+ if (versions.length === 0) {
32
+ writeLine(door.io.stdout, `No saved description versions for ${ref}.`);
33
+ return 0;
34
+ }
35
+ for (const row of versions) {
36
+ const from = row.restored_from_version ? ` (restored from ${row.restored_from_version})` : "";
37
+ writeLine(door.io.stdout, `v${row.version} ${row.id} ${row.created_at} ${row.source}${from} ${row.markdown.length} chars`);
38
+ }
39
+ writeLine(door.io.stdout, "");
40
+ writeLine(door.io.stdout, `${versions.length} version(s), newest first. Restore one with: cockpit ticket restore ${ref} --version <n>`);
41
+ return 0;
42
+ }
43
+ export async function restoreIssueVersion(command, door) {
44
+ const ref = command.issueRef ?? "";
45
+ const wanted = command.versionNumber;
46
+ const listed = await askAgentDoor(door, {
47
+ path: `/api/work/issues/${encodeURIComponent(ref)}/versions?limit=500`,
48
+ method: "GET",
49
+ label: "issue versions",
50
+ timeoutMs: READ_DEADLINE_MS,
51
+ });
52
+ if (!listed.ok)
53
+ return failAgentDoor(door, TAG, listed.reason, listed.detail);
54
+ const versions = listed.body.versions ?? [];
55
+ const chosen = versions.find(row => row.version === wanted);
56
+ if (!chosen) {
57
+ const have = versions.map(row => row.version).join(", ") || "none";
58
+ console.error(`${TAG} restore refused ${JSON.stringify({ reason: "version_not_found", requested: wanted ?? null, available: versions.length })}`);
59
+ return failAgentDoor(door, TAG, "version_not_found", `${ref} has no version ${wanted}. Versions: ${have}.`);
60
+ }
61
+ const requestId = command.requestId ?? randomUUID();
62
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision, version: chosen.version })}`);
63
+ let answer;
64
+ try {
65
+ answer = await askAgentDoor(door, {
66
+ path: `/api/work/issues/${encodeURIComponent(ref)}/versions/restore`,
67
+ method: "POST",
68
+ label: "issue restore",
69
+ timeoutMs: WRITE_DEADLINE_MS,
70
+ body: { request_id: requestId, expected_revision: command.expectedRevision, version_id: chosen.id },
71
+ });
72
+ }
73
+ catch {
74
+ console.error(`${TAG} response lost`, JSON.stringify({ reason: "mutation_unknown" }));
75
+ writeLine(door.io.stderr, `${TAG} check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
76
+ return failAgentDoor(door, TAG, "mutation_unknown", "Result unknown. Check this request ID before retrying with identical input.");
77
+ }
78
+ if (!answer.ok) {
79
+ writeLine(door.io.stderr, `${TAG} check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
80
+ const mutation = answer.body?.mutation ?? { request_id: requestId, outcome: "unknown_check_again" };
81
+ if (door.json)
82
+ return emitAgentDoor(door, { ok: false, error: answer.reason, detail: answer.detail, mutation }, 1);
83
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
84
+ }
85
+ const body = answer.body;
86
+ const newVersion = body.mutation?.description_version;
87
+ writeLine(door.io.stderr, `${TAG} restored ${JSON.stringify({ restored_version: chosen.version, new_version: newVersion ?? null })}`);
88
+ writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
89
+ if (door.json)
90
+ return emitAgentDoor(door, { ok: true, issue: body.issue, mutation: body.mutation });
91
+ writeLine(door.io.stdout, `Restored version ${chosen.version} of ${ref} as new version ${newVersion ?? "?"}.`);
92
+ return 0;
93
+ }
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  /**
2
3
  * The verbs that CHANGE something: `cockpit issue create|update|move|comment`
3
4
  * (BLI-3716). The reads and the router live in `issue.ts`, the shared shapes
@@ -19,12 +20,14 @@ export async function createIssue(command, door) {
19
20
  const body = await readBody(command, door.io);
20
21
  if (!body.ok)
21
22
  return failAgentDoor(door, TAG, body.reason, body.detail);
22
- const answer = await askAgentDoor(door, {
23
+ const requestId = command.requestId ?? randomUUID();
24
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision })}`);
25
+ const answer = await askTicketWrite(door, {
23
26
  path: "/api/work/issues",
24
27
  method: "POST",
25
28
  label: "issue create",
26
29
  timeoutMs: WRITE_DEADLINE_MS,
27
- body: {
30
+ body: { request_id: requestId, expected_revision: command.expectedRevision,
28
31
  title: command.title,
29
32
  ...(body.text ? { description: body.text } : {}),
30
33
  ...(refs.projectId ? { project_id: refs.projectId } : {}),
@@ -35,11 +38,12 @@ export async function createIssue(command, door) {
35
38
  },
36
39
  });
37
40
  if (!answer.ok)
38
- return failAgentDoor(door, TAG, answer.reason, answer.detail);
41
+ return refuseWrite(door, answer, requestId);
39
42
  const issue = maybeWithSizeLabel(answer.body.issue);
40
43
  writeLine(door.io.stderr, `${TAG} created ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null, description_bytes: Buffer.byteLength(body.text, "utf8") })}`);
44
+ writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(answer.body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
41
45
  if (door.json)
42
- return emitAgentDoor(door, { ok: true, issue });
46
+ return emitAgentDoor(door, { ok: true, issue, mutation: answer.body.mutation });
43
47
  writeLine(door.io.stdout, `Created ${issue?.identifier ?? ""} — ${issue?.title ?? ""} (${issue?.id ?? ""}).`);
44
48
  return 0;
45
49
  }
@@ -55,21 +59,17 @@ export async function updateIssue(command, door) {
55
59
  return failAgentDoor(door, TAG, body.reason, body.detail);
56
60
  description = body.text;
57
61
  }
58
- if (description === undefined
59
- && command.title === undefined
60
- && command.priority === undefined
61
- && command.estimate === undefined
62
- && command.assignee === undefined
63
- && refs.projectId === undefined
64
- && refs.parentId === undefined) {
62
+ if (!changesAnything(command, description, refs)) {
65
63
  return failAgentDoor(door, TAG, "invalid_body", "issue update needs at least one of --title, --priority, --size, --assignee, --project, --parent, or a description on --body-stdin/--file.");
66
64
  }
67
- const answer = await askAgentDoor(door, {
65
+ const requestId = command.requestId ?? randomUUID();
66
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision })}`);
67
+ const answer = await askTicketWrite(door, {
68
68
  path: `/api/work/issues/${encodeURIComponent(ref)}`,
69
69
  method: "PATCH",
70
70
  label: "issue update",
71
71
  timeoutMs: WRITE_DEADLINE_MS,
72
- body: {
72
+ body: { request_id: requestId, expected_revision: command.expectedRevision,
73
73
  ...(command.title !== undefined ? { title: command.title } : {}),
74
74
  ...(description !== undefined ? { description } : {}),
75
75
  ...(command.priority === undefined ? {} : { priority: command.priority }),
@@ -80,29 +80,33 @@ export async function updateIssue(command, door) {
80
80
  },
81
81
  });
82
82
  if (!answer.ok)
83
- return failAgentDoor(door, TAG, answer.reason, answer.detail);
83
+ return refuseWrite(door, answer, requestId);
84
84
  const issue = maybeWithSizeLabel(answer.body.issue);
85
85
  writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null })}`);
86
+ writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(answer.body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
86
87
  if (door.json)
87
- return emitAgentDoor(door, { ok: true, issue });
88
+ return emitAgentDoor(door, { ok: true, issue, mutation: answer.body.mutation });
88
89
  writeLine(door.io.stdout, `Updated ${issue?.identifier ?? ref} — ${issue?.title ?? ""}.`);
89
90
  return 0;
90
91
  }
91
92
  export async function moveIssue(command, door) {
92
93
  const ref = command.issueRef ?? "";
93
- const answer = await askAgentDoor(door, {
94
+ const requestId = command.requestId ?? randomUUID();
95
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision })}`);
96
+ const answer = await askTicketWrite(door, {
94
97
  path: `/api/work/issues/${encodeURIComponent(ref)}/state`,
95
98
  method: "POST",
96
99
  label: "issue move",
97
100
  timeoutMs: WRITE_DEADLINE_MS,
98
- body: { state: command.moveState },
101
+ body: { request_id: requestId, expected_revision: command.expectedRevision, state: command.moveState },
99
102
  });
100
103
  if (!answer.ok)
101
- return failAgentDoor(door, TAG, answer.reason, answer.detail);
104
+ return refuseWrite(door, answer, requestId);
102
105
  const issue = answer.body.issue;
103
106
  writeLine(door.io.stderr, `${TAG} moved ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null, to_state: command.moveState })}`);
107
+ writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(answer.body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
104
108
  if (door.json)
105
- return emitAgentDoor(door, { ok: true, issue });
109
+ return emitAgentDoor(door, { ok: true, issue, mutation: answer.body.mutation });
106
110
  writeLine(door.io.stdout, `${issue?.identifier ?? ref} is now ${issue?.state ?? command.moveState}.`);
107
111
  return 0;
108
112
  }
@@ -114,19 +118,62 @@ export async function commentIssue(command, door) {
114
118
  if (body.text.trim() === "") {
115
119
  return failAgentDoor(door, TAG, "invalid_body", 'A comment is never taken on the command line — pipe it in: `echo "..." | cockpit issue comment BLI-3654`.');
116
120
  }
117
- const answer = await askAgentDoor(door, {
121
+ const requestId = command.requestId ?? randomUUID();
122
+ writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision })}`);
123
+ const answer = await askTicketWrite(door, {
118
124
  path: `/api/work/issues/${encodeURIComponent(ref)}/comments`,
119
125
  method: "POST",
120
126
  label: "issue comment",
121
127
  timeoutMs: WRITE_DEADLINE_MS,
122
- body: { body_markdown: body.text },
128
+ body: { request_id: requestId, expected_revision: command.expectedRevision, body_markdown: body.text },
123
129
  });
124
130
  if (!answer.ok)
125
- return failAgentDoor(door, TAG, answer.reason, answer.detail);
131
+ return refuseWrite(door, answer, requestId);
126
132
  const comment = answer.body.comment;
127
133
  writeLine(door.io.stderr, `${TAG} commented ${JSON.stringify({ comment_id: comment?.id ?? null, byte_size: Buffer.byteLength(body.text, "utf8") })}`);
134
+ writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(answer.body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
128
135
  if (door.json)
129
- return emitAgentDoor(door, { ok: true, comment });
136
+ return emitAgentDoor(door, { ok: true, comment, mutation: answer.body.mutation });
130
137
  writeLine(door.io.stdout, `Commented on ${ref} (${comment?.id ?? ""}).`);
131
138
  return 0;
139
+ }
140
+ /** A truncated or missing response body cannot be called a successful ticket write. */
141
+ async function askTicketWrite(door, options) {
142
+ try {
143
+ const answer = await askAgentDoor(door, options);
144
+ if (!answer.ok)
145
+ return answer;
146
+ const body = answer.body;
147
+ if (body && (body.issue || body.comment))
148
+ return answer;
149
+ }
150
+ catch {
151
+ console.error(`${TAG} response lost`, JSON.stringify({ reason: "mutation_unknown" }));
152
+ }
153
+ const requestId = options.body.request_id;
154
+ return { ok: false, reason: "mutation_unknown", detail: "Result unknown. Check this request ID before retrying with identical input.",
155
+ body: { mutation: { request_id: requestId, outcome: "unknown_check_again" } } };
156
+ }
157
+ /** Did the person ask for at least one field to change? */
158
+ function changesAnything(command, description, refs) {
159
+ return description !== undefined
160
+ || command.title !== undefined
161
+ || command.priority !== undefined
162
+ || command.estimate !== undefined
163
+ || command.assignee !== undefined
164
+ || refs.projectId !== undefined
165
+ || refs.parentId !== undefined;
166
+ }
167
+ /** Says where the local commit stands, then fails with the door's own reason. */
168
+ function refuseWrite(door, answer, requestId) {
169
+ writeLine(door.io.stderr, `${TAG} check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
170
+ const mutation = answer.body?.mutation ?? { request_id: requestId, outcome: "unknown_check_again" };
171
+ if (door.json) {
172
+ writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ reason: answer.reason, request_id: requestId })}`);
173
+ return emitAgentDoor(door, { ok: false, error: answer.reason, detail: answer.detail, mutation }, 1);
174
+ }
175
+ const receipt = mutation;
176
+ if (receipt.current)
177
+ writeLine(door.io.stderr, `${TAG} conflict ${JSON.stringify({ request_id: requestId, outcome: receipt.outcome, current_state: receipt.current.state, current_revision: receipt.current.revision })}`);
178
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
132
179
  }