@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.
- 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-direct.js +119 -0
- package/dist/commands/cal-overlay.js +73 -30
- package/dist/commands/cal.js +62 -25
- package/dist/commands/clean.js +4 -1
- package/dist/commands/docs-read.js +5 -2
- package/dist/commands/docs.js +38 -6
- 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-versions.js +93 -0
- package/dist/commands/issue-write.js +70 -23
- package/dist/commands/issue.js +73 -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 +69 -54
- package/dist/commands/local-args-tower-careers.js +73 -54
- package/dist/commands/local-args-tower-docs-msg.js +79 -51
- 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-msg.js +24 -6
- 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-setup.js +11 -4
- package/dist/commands/local-args-tower-work.js +74 -37
- 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 +20 -7
- 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/msg-read-line.js +32 -0
- package/dist/commands/msg.js +45 -3
- 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/setup.js +13 -0
- 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 +94 -68
- 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/spool/local-spool-parse.js +13 -12
- 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 +4 -4
package/dist/commands/docs.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
258
|
-
writeLine(door.io.stdout,
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
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);
|