@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/msg.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `cockpit msg` — channels and messages, typed (BLI-3706, BLI-3749).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Eight verbs over `/api/msg/**` (BLI-3654 Wave 1a), which already accepts the
|
|
5
5
|
* collector device token through `resolveCaller({ allowDeviceToken: true })`
|
|
6
6
|
* on every route — no server-side door change was needed for this terminal.
|
|
7
7
|
*
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
19
19
|
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
20
20
|
import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
|
|
21
|
+
import { markReadThrough } from "./msg-read-line.js";
|
|
21
22
|
const TAG = "[msg cli]";
|
|
22
23
|
const READ_DEADLINE_MS = 30_000;
|
|
23
24
|
const WRITE_DEADLINE_MS = 30_000;
|
|
@@ -31,6 +32,8 @@ export async function runMsg(command, io) {
|
|
|
31
32
|
return listChannels(door);
|
|
32
33
|
case "read":
|
|
33
34
|
return readChannel(command, door);
|
|
35
|
+
case "unread":
|
|
36
|
+
return markUnread(command, door);
|
|
34
37
|
case "send":
|
|
35
38
|
return sendMessage(command, door);
|
|
36
39
|
case "thread":
|
|
@@ -151,11 +154,20 @@ async function readChannel(command, door) {
|
|
|
151
154
|
// BLI-10449: whether this channel is Tower's own or a Slack snapshot, and
|
|
152
155
|
// how fresh — the door's own sentence, printed as sent.
|
|
153
156
|
const provenance = body.provenance ?? null;
|
|
157
|
+
const readBoundary = body.readBoundary ?? null;
|
|
154
158
|
const limitApplied = body.limitApplied ?? null;
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
// `--mark-read` (BLI-21299): the person has just been shown these messages, so
|
|
160
|
+
// the read line moves to the newest top-level one - the only way a terminal
|
|
161
|
+
// moves it. Never implied by reading alone.
|
|
162
|
+
const readMove = command.markRead ? await markReadThrough(door, resolved.id, messages) : null;
|
|
163
|
+
const exitCode = readMove && !readMove.ok ? 1 : 0;
|
|
164
|
+
if (door.json) {
|
|
165
|
+
return emitAgentDoor(door, { ok: true, ...body, channelId: resolved.id, provenance, readBoundary, limitApplied, messages, nextCursor: body.nextCursor, hasMore: body.hasMore, coverage: body.coverage, ...(readMove ? { readMove } : {}) }, exitCode);
|
|
166
|
+
}
|
|
157
167
|
if (provenance?.sentence)
|
|
158
168
|
writeLine(door.io.stdout, provenance.sentence);
|
|
169
|
+
if (readBoundary?.sentence)
|
|
170
|
+
writeLine(door.io.stdout, readBoundary.sentence);
|
|
159
171
|
if (limitApplied !== null) {
|
|
160
172
|
writeLine(door.io.stdout, `Showing the newest ${limitApplied}: one read returns at most ${limitApplied} messages.`);
|
|
161
173
|
}
|
|
@@ -170,6 +182,36 @@ async function readChannel(command, door) {
|
|
|
170
182
|
const who = message.agent_label ?? message.user_id ?? message.slack_author_label ?? "someone";
|
|
171
183
|
writeLine(door.io.stdout, `${message.id} ${message.created_at} ${who} ${message.content ?? ""}`);
|
|
172
184
|
}
|
|
185
|
+
if (readMove) {
|
|
186
|
+
writeLine(readMove.ok ? door.io.stdout : door.io.stderr, readMove.ok
|
|
187
|
+
? `Read line moved to ${readMove.messageId} (${readMove.changed ? "advanced" : "already at or past it"}).`
|
|
188
|
+
: `Read line NOT moved (${readMove.reason}): ${readMove.detail} Run the same command again to retry.`);
|
|
189
|
+
}
|
|
190
|
+
return exitCode;
|
|
191
|
+
}
|
|
192
|
+
/** `cockpit msg unread <channel> --from <message-id>` — this message and everything after it counts as unread. */
|
|
193
|
+
async function markUnread(command, door) {
|
|
194
|
+
const ref = command.channelRef ?? "";
|
|
195
|
+
const messageId = command.unreadFrom ?? "";
|
|
196
|
+
const resolved = await resolveChannelId(door, ref);
|
|
197
|
+
if (resolved.status === "list_failed")
|
|
198
|
+
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
199
|
+
if (resolved.status === "not_found")
|
|
200
|
+
return channelNotFound(door, ref);
|
|
201
|
+
const answer = await askAgentDoor(door, {
|
|
202
|
+
path: `/api/msg/channels/${encodeURIComponent(resolved.id)}/read`,
|
|
203
|
+
method: "POST",
|
|
204
|
+
label: "msg unread",
|
|
205
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
206
|
+
body: { message_id: messageId, action: "unread" },
|
|
207
|
+
});
|
|
208
|
+
if (!answer.ok)
|
|
209
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
210
|
+
const move = answer.body.move ?? {};
|
|
211
|
+
writeLine(door.io.stderr, `${TAG} unread ${JSON.stringify({ channel_id: resolved.id, message_id: messageId })}`);
|
|
212
|
+
if (door.json)
|
|
213
|
+
return emitAgentDoor(door, { ok: true, channelId: resolved.id, messageId, move });
|
|
214
|
+
writeLine(door.io.stdout, `Marked ${messageId} and everything after it unread in ${ref}. Read line now at ${move.lastReadAt ?? "the start"}.`);
|
|
173
215
|
return 0;
|
|
174
216
|
}
|
|
175
217
|
async function readThread(command, door) {
|
|
@@ -80,6 +80,25 @@ function writeSharedFeeds(door, feeds) {
|
|
|
80
80
|
}
|
|
81
81
|
writeLine(door.io.stdout, " Its runs in full: `cockpit notes granola-status`.");
|
|
82
82
|
}
|
|
83
|
+
function writeHowToConnect(door) {
|
|
84
|
+
writeLine(door.io.stdout, "");
|
|
85
|
+
writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fellow api key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
|
|
86
|
+
writeLine(door.io.stdout, ' or: printf "%s" "<your circleback api key>" | cockpit notes connect --provider circleback --key-stdin');
|
|
87
|
+
writeLine(door.io.stdout, " or: cockpit notes connect --provider wisprflow (opens Wispr's sign-in in your browser and waits; no key)");
|
|
88
|
+
}
|
|
89
|
+
function writeAccountRows(door, accounts) {
|
|
90
|
+
for (const account of accounts) {
|
|
91
|
+
const health = account.status === "active"
|
|
92
|
+
? "active"
|
|
93
|
+
: `${account.status}: ${account.status_reason ?? "no reason recorded"}`;
|
|
94
|
+
// The workspace rides beside the provider because a Fellow account that
|
|
95
|
+
// points at the wrong subdomain fails with a 401 and nothing else says so.
|
|
96
|
+
const where = account.workspace ? `${account.provider}/${account.workspace}` : account.provider;
|
|
97
|
+
writeLine(door.io.stdout, `${(account.label ?? account.provider).padEnd(20)} ${where.padEnd(20)} ${health.padEnd(24)} ${account.last_synced_at ?? "never read"}`);
|
|
98
|
+
}
|
|
99
|
+
writeLine(door.io.stdout, "");
|
|
100
|
+
writeLine(door.io.stdout, `${accounts.length} notetaker(s). ids: ${accounts.map((account) => account.id).join(", ")}`);
|
|
101
|
+
}
|
|
83
102
|
export async function listNotesAccounts(command, door) {
|
|
84
103
|
const answer = await ask(door, {
|
|
85
104
|
path: "/api/notes/accounts",
|
|
@@ -101,25 +120,12 @@ export async function listNotesAccounts(command, door) {
|
|
|
101
120
|
if (accounts.length === 0) {
|
|
102
121
|
writeLine(door.io.stdout, "You have not connected a notetaker of your own.");
|
|
103
122
|
writeSharedFeeds(door, sharedFeeds);
|
|
104
|
-
|
|
105
|
-
writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fellow api key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
|
|
106
|
-
writeLine(door.io.stdout, ' or: printf "%s" "<your circleback api key>" | cockpit notes connect --provider circleback --key-stdin');
|
|
107
|
-
writeLine(door.io.stdout, " or: cockpit notes connect --provider wisprflow (opens Wispr's sign-in in your browser and waits; no key)");
|
|
123
|
+
writeHowToConnect(door);
|
|
108
124
|
if (coverage)
|
|
109
125
|
printCoverage(door, coverage);
|
|
110
126
|
return 0;
|
|
111
127
|
}
|
|
112
|
-
|
|
113
|
-
const health = account.status === "active"
|
|
114
|
-
? "active"
|
|
115
|
-
: `${account.status}: ${account.status_reason ?? "no reason recorded"}`;
|
|
116
|
-
// The workspace rides beside the provider because a Fellow account that
|
|
117
|
-
// points at the wrong subdomain fails with a 401 and nothing else says so.
|
|
118
|
-
const where = account.workspace ? `${account.provider}/${account.workspace}` : account.provider;
|
|
119
|
-
writeLine(door.io.stdout, `${(account.label ?? account.provider).padEnd(20)} ${where.padEnd(20)} ${health.padEnd(24)} ${account.last_synced_at ?? "never read"}`);
|
|
120
|
-
}
|
|
121
|
-
writeLine(door.io.stdout, "");
|
|
122
|
-
writeLine(door.io.stdout, `${accounts.length} notetaker(s). ids: ${accounts.map((account) => account.id).join(", ")}`);
|
|
128
|
+
writeAccountRows(door, accounts);
|
|
123
129
|
writeSharedFeeds(door, sharedFeeds);
|
|
124
130
|
if (coverage)
|
|
125
131
|
printCoverage(door, coverage);
|
|
@@ -159,21 +165,10 @@ export async function connectNotetaker(command, door, wisprflow = {}) {
|
|
|
159
165
|
if (isInteractiveStdin(door.io)) {
|
|
160
166
|
return fail(door, "key_required_on_stdin", 'The API key is read from stdin, never from a flag. Try: printf "%s" "<your key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
|
|
161
167
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// which can be far longer than any API key.
|
|
167
|
-
maxChars: command.provider === "wisprflow" ? SIGN_IN_MAX_CHARS : KEY_MAX_CHARS,
|
|
168
|
-
overflowMessage: "That is longer than any notetaker API key; nothing was sent.",
|
|
169
|
-
})).trim();
|
|
170
|
-
}
|
|
171
|
-
catch (error) {
|
|
172
|
-
return fail(door, "key_unreadable", error instanceof Error ? error.message : String(error));
|
|
173
|
-
}
|
|
174
|
-
if (apiKey === "") {
|
|
175
|
-
return fail(door, "key_required_on_stdin", "Nothing arrived on stdin, so nothing was connected.");
|
|
176
|
-
}
|
|
168
|
+
const key = await readConnectKey(command, door);
|
|
169
|
+
if ("exitCode" in key)
|
|
170
|
+
return key.exitCode;
|
|
171
|
+
const apiKey = key.apiKey;
|
|
177
172
|
const answer = await ask(door, {
|
|
178
173
|
path: "/api/notes/accounts",
|
|
179
174
|
method: "POST",
|
|
@@ -194,10 +189,32 @@ export async function connectNotetaker(command, door, wisprflow = {}) {
|
|
|
194
189
|
const account = answer.body.account;
|
|
195
190
|
if (door.json)
|
|
196
191
|
return emit(door, { ok: true, account });
|
|
197
|
-
|
|
192
|
+
writeConnected(door, account, command.provider ?? "fellow");
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
/** The key as piped on stdin, or the exit code of the refusal already written. */
|
|
196
|
+
async function readConnectKey(command, door) {
|
|
197
|
+
let apiKey;
|
|
198
|
+
try {
|
|
199
|
+
apiKey = (await readPipedText(door.io.stdin, {
|
|
200
|
+
// A pre-built Wispr Flow sign-in bundle carries an OAuth refresh token,
|
|
201
|
+
// which can be far longer than any API key.
|
|
202
|
+
maxChars: command.provider === "wisprflow" ? SIGN_IN_MAX_CHARS : KEY_MAX_CHARS,
|
|
203
|
+
overflowMessage: "That is longer than any notetaker API key; nothing was sent.",
|
|
204
|
+
})).trim();
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
return { exitCode: fail(door, "key_unreadable", error instanceof Error ? error.message : String(error)) };
|
|
208
|
+
}
|
|
209
|
+
if (apiKey === "") {
|
|
210
|
+
return { exitCode: fail(door, "key_required_on_stdin", "Nothing arrived on stdin, so nothing was connected.") };
|
|
211
|
+
}
|
|
212
|
+
return { apiKey };
|
|
213
|
+
}
|
|
214
|
+
function writeConnected(door, account, requestedProvider) {
|
|
215
|
+
const named = account?.provider ?? requestedProvider;
|
|
198
216
|
writeLine(door.io.stdout, `Connected ${account?.workspace ? `${named}/${account.workspace}` : named} (${account?.id ?? "no id returned"}).`);
|
|
199
217
|
writeLine(door.io.stdout, "Nothing is read until a sync runs: `cockpit notes sync <id>`, or wait for the half-hourly cron.");
|
|
200
|
-
return 0;
|
|
201
218
|
}
|
|
202
219
|
/**
|
|
203
220
|
* `cockpit notes accounts --show-webhook <id>` and
|
|
@@ -44,96 +44,122 @@ export async function syncNotetaker(command, door) {
|
|
|
44
44
|
// and the MCP tool call; nothing here reads a notetaker itself.
|
|
45
45
|
const env = door.io.env ?? {};
|
|
46
46
|
const pollMs = Number.parseInt(env["COCKPIT_NOTES_SYNC_POLL_MS"] ?? "", 10);
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// A timeout or a 5xx mid-history is the call's, not the account's: the
|
|
69
|
-
// cursor was saved after every meeting, so asking again resumes. Three in
|
|
70
|
-
// a row is a real outage and is reported with its reason.
|
|
71
|
-
const transient = /^(timeout|network|unreachable|http_5\d\d|BLI-E5\d\d|read_failed)/i.test(answer.reason);
|
|
72
|
-
if (transient && transientFailures < MAX_TRANSIENT_FAILURES) {
|
|
73
|
-
transientFailures += 1;
|
|
74
|
-
writeLine(door.io.stderr, `${TAG} sync call failed, retrying ${JSON.stringify({ reason: answer.reason, attempt: transientFailures })}`);
|
|
75
|
-
await sleepMs(liveWaitMs);
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
return failRun(door, answer.reason, answer.detail, last, passes, totals);
|
|
79
|
-
}
|
|
80
|
-
transientFailures = 0;
|
|
81
|
-
const run = answer.body.run;
|
|
82
|
-
if (!run) {
|
|
83
|
-
writeLine(door.io.stdout, "Tower answered without a run.");
|
|
84
|
-
return failRun(door, "no_run_in_answer", "Tower answered the sync without a run.", last, passes, totals);
|
|
85
|
-
}
|
|
86
|
-
last = run;
|
|
87
|
-
if (run.reason === "running") {
|
|
88
|
-
// Another pass (the cron, a second terminal) is live on this account. It
|
|
89
|
-
// writes the same cursor; wait for it rather than racing it.
|
|
90
|
-
liveWaits += 1;
|
|
91
|
-
writeLine(door.io.stderr, `${TAG} another pass is running ${JSON.stringify({ run_id: run.runId ?? null, waits: liveWaits })}`);
|
|
92
|
-
if (liveWaits > MAX_LIVE_WAITS) {
|
|
93
|
-
return failRun(door, "pass_still_running", "Another pass kept running on this notetaker. Check `cockpit notes sync-status`, then try again.", last, passes, totals);
|
|
94
|
-
}
|
|
95
|
-
await sleepMs(liveWaitMs);
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
passes += 1;
|
|
99
|
-
totals.stored += run.notesAdded;
|
|
100
|
-
totals.unchanged += run.notesUpdated;
|
|
101
|
-
totals.skipped += run.notesSkipped;
|
|
102
|
-
const waiting = typeof run.remaining === "number" ? `; ${run.remaining} waiting` : "";
|
|
103
|
-
if (run.repair) {
|
|
104
|
-
repairTotals.fixed += run.repair.fixed;
|
|
105
|
-
repairTotals.failed += run.repair.failed;
|
|
106
|
-
repairTotals.found = Math.max(repairTotals.found, run.repair.found);
|
|
107
|
-
}
|
|
108
|
-
const line = run.repair
|
|
109
|
-
? `pass ${passes}: ${run.reason}: repair: ${repairLine(run.repair)}${run.more ? ", continuing" : ""}.`
|
|
110
|
-
: `pass ${passes}: ${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped${run.more ? `${waiting}, continuing` : ""}.`;
|
|
111
|
-
// Progress is commentary in --json (stdout is the one answer), and the
|
|
112
|
-
// answer in a terminal.
|
|
113
|
-
writeLine(door.json ? door.io.stderr : door.io.stdout, line);
|
|
114
|
-
if (run.detail && !run.ok)
|
|
115
|
-
writeLine(door.json ? door.io.stderr : door.io.stdout, run.detail);
|
|
116
|
-
if (!run.ok) {
|
|
117
|
-
return failRun(door, run.reason, run.detail || `The pass ended ${run.reason}.`, run, passes, totals);
|
|
118
|
-
}
|
|
119
|
-
if (!run.more) {
|
|
120
|
-
if (door.json)
|
|
121
|
-
return emit(door, { ok: true, passes, totals, ...(repairing ? { repair: repairTotals } : {}), run });
|
|
122
|
-
writeLine(door.io.stdout, repairing
|
|
123
|
-
? `Repair finished after ${passes} pass${passes === 1 ? "" : "es"}: ${repairTotals.fixed} fixed, ${repairTotals.failed} failed (${repairTotals.found} carried the marker when the first pass began).`
|
|
124
|
-
: `Caught up after ${passes} pass${passes === 1 ? "" : "es"}: ${totals.stored} stored, ${totals.unchanged} unchanged, ${totals.skipped} skipped.`);
|
|
125
|
-
return 0;
|
|
126
|
-
}
|
|
127
|
-
// A pass that handled nothing and still says "more" would loop forever.
|
|
128
|
-
if (run.notesAdded + run.notesUpdated + run.notesSkipped === 0)
|
|
129
|
-
stalled += 1;
|
|
130
|
-
else
|
|
131
|
-
stalled = 0;
|
|
132
|
-
if (stalled >= MAX_STALLED_PASSES) {
|
|
133
|
-
return failRun(door, "no_progress", `Three passes in a row handled no meeting (last: ${run.reason}). The cursor is saved; try again later or check \`cockpit notes sync-status\`.`, run, passes, totals);
|
|
134
|
-
}
|
|
47
|
+
const settings = {
|
|
48
|
+
accountId,
|
|
49
|
+
// BLI-18006: `--repair-truncated` asks the same door to re-read Wispr Flow notes
|
|
50
|
+
// that were stored cut short. Fixed ones count as "unchanged" in the totals
|
|
51
|
+
// (the ledger's column), failed ones as "skipped"; the repair line says which.
|
|
52
|
+
repairing: command.repairTruncated === true,
|
|
53
|
+
liveWaitMs: Number.isFinite(pollMs) && pollMs >= 0 ? pollMs : LIVE_PASS_WAIT_MS,
|
|
54
|
+
};
|
|
55
|
+
const state = {
|
|
56
|
+
passes: 0,
|
|
57
|
+
stalled: 0,
|
|
58
|
+
liveWaits: 0,
|
|
59
|
+
transientFailures: 0,
|
|
60
|
+
last: undefined,
|
|
61
|
+
totals: { stored: 0, unchanged: 0, skipped: 0 },
|
|
62
|
+
repairTotals: { fixed: 0, failed: 0, found: 0 },
|
|
63
|
+
};
|
|
64
|
+
while (state.passes < MAX_SYNC_PASSES) {
|
|
65
|
+
const outcome = await runOnePass(door, settings, state);
|
|
66
|
+
if (outcome !== "again")
|
|
67
|
+
return outcome;
|
|
135
68
|
}
|
|
136
|
-
return
|
|
69
|
+
return stopSync(door, state, "too_many_passes", `Stopped after ${MAX_SYNC_PASSES} passes with meetings still waiting. Run \`cockpit notes sync ${accountId}\` again to continue from the saved cursor.`);
|
|
70
|
+
}
|
|
71
|
+
/** One door call and what follows it: an exit code when the sync is over, "again" when it is not. */
|
|
72
|
+
async function runOnePass(door, settings, state) {
|
|
73
|
+
const answer = await ask(door, {
|
|
74
|
+
path: `/api/notes/accounts/${encodeURIComponent(settings.accountId)}/sync`,
|
|
75
|
+
method: "POST",
|
|
76
|
+
label: settings.repairing ? "notes sync (repair truncated)" : "notes sync",
|
|
77
|
+
timeoutMs: settings.repairing ? REPAIR_DEADLINE_MS : WRITE_DEADLINE_MS,
|
|
78
|
+
...(settings.repairing ? { body: { repair: "truncated_transcripts" } } : {}),
|
|
79
|
+
});
|
|
80
|
+
if (!answer.ok)
|
|
81
|
+
return await retryOrStop(door, settings, state, answer);
|
|
82
|
+
state.transientFailures = 0;
|
|
83
|
+
const run = answer.body.run;
|
|
84
|
+
if (!run) {
|
|
85
|
+
writeLine(door.io.stdout, "Tower answered without a run.");
|
|
86
|
+
return stopSync(door, state, "no_run_in_answer", "Tower answered the sync without a run.");
|
|
87
|
+
}
|
|
88
|
+
state.last = run;
|
|
89
|
+
if (run.reason === "running")
|
|
90
|
+
return await waitForLivePass(door, settings, state, run);
|
|
91
|
+
recordPass(door, state, run);
|
|
92
|
+
if (!run.ok) {
|
|
93
|
+
return stopSync(door, state, run.reason, run.detail || `The pass ended ${run.reason}.`);
|
|
94
|
+
}
|
|
95
|
+
if (!run.more)
|
|
96
|
+
return finishSync(door, settings, state, run);
|
|
97
|
+
return checkProgress(door, state, run);
|
|
98
|
+
}
|
|
99
|
+
/** A timeout or a 5xx mid-history is the call's, not the account's: the cursor was saved after every meeting. */
|
|
100
|
+
async function retryOrStop(door, settings, state, answer) {
|
|
101
|
+
// Asking again resumes. Three in a row is a real outage and is reported with its reason.
|
|
102
|
+
const transient = /^(timeout|network|unreachable|http_5\d\d|BLI-E5\d\d|read_failed)/i.test(answer.reason);
|
|
103
|
+
if (!transient || state.transientFailures >= MAX_TRANSIENT_FAILURES) {
|
|
104
|
+
return stopSync(door, state, answer.reason, answer.detail);
|
|
105
|
+
}
|
|
106
|
+
state.transientFailures += 1;
|
|
107
|
+
writeLine(door.io.stderr, `${TAG} sync call failed, retrying ${JSON.stringify({ reason: answer.reason, attempt: state.transientFailures })}`);
|
|
108
|
+
await sleepMs(settings.liveWaitMs);
|
|
109
|
+
return "again";
|
|
110
|
+
}
|
|
111
|
+
/** Another pass (the cron, a second terminal) is live on this account. It writes the same cursor; wait for it rather than racing it. */
|
|
112
|
+
async function waitForLivePass(door, settings, state, run) {
|
|
113
|
+
state.liveWaits += 1;
|
|
114
|
+
writeLine(door.io.stderr, `${TAG} another pass is running ${JSON.stringify({ run_id: run.runId ?? null, waits: state.liveWaits })}`);
|
|
115
|
+
if (state.liveWaits > MAX_LIVE_WAITS) {
|
|
116
|
+
return stopSync(door, state, "pass_still_running", "Another pass kept running on this notetaker. Check `cockpit notes sync-status`, then try again.");
|
|
117
|
+
}
|
|
118
|
+
await sleepMs(settings.liveWaitMs);
|
|
119
|
+
return "again";
|
|
120
|
+
}
|
|
121
|
+
/** Add the pass to the totals and print its line. */
|
|
122
|
+
function recordPass(door, state, run) {
|
|
123
|
+
state.passes += 1;
|
|
124
|
+
state.totals.stored += run.notesAdded;
|
|
125
|
+
state.totals.unchanged += run.notesUpdated;
|
|
126
|
+
state.totals.skipped += run.notesSkipped;
|
|
127
|
+
const waiting = typeof run.remaining === "number" ? `; ${run.remaining} waiting` : "";
|
|
128
|
+
if (run.repair) {
|
|
129
|
+
state.repairTotals.fixed += run.repair.fixed;
|
|
130
|
+
state.repairTotals.failed += run.repair.failed;
|
|
131
|
+
state.repairTotals.found = Math.max(state.repairTotals.found, run.repair.found);
|
|
132
|
+
}
|
|
133
|
+
const line = run.repair
|
|
134
|
+
? `pass ${state.passes}: ${run.reason}: repair: ${repairLine(run.repair)}${run.more ? ", continuing" : ""}.`
|
|
135
|
+
: `pass ${state.passes}: ${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped${run.more ? `${waiting}, continuing` : ""}.`;
|
|
136
|
+
// Progress is commentary in --json (stdout is the one answer), and the
|
|
137
|
+
// answer in a terminal.
|
|
138
|
+
writeLine(door.json ? door.io.stderr : door.io.stdout, line);
|
|
139
|
+
if (run.detail && !run.ok)
|
|
140
|
+
writeLine(door.json ? door.io.stderr : door.io.stdout, run.detail);
|
|
141
|
+
}
|
|
142
|
+
function finishSync(door, settings, state, run) {
|
|
143
|
+
const { passes, totals, repairTotals } = state;
|
|
144
|
+
if (door.json)
|
|
145
|
+
return emit(door, { ok: true, passes, totals, ...(settings.repairing ? { repair: repairTotals } : {}), run });
|
|
146
|
+
writeLine(door.io.stdout, settings.repairing
|
|
147
|
+
? `Repair finished after ${passes} pass${passes === 1 ? "" : "es"}: ${repairTotals.fixed} fixed, ${repairTotals.failed} failed (${repairTotals.found} carried the marker when the first pass began).`
|
|
148
|
+
: `Caught up after ${passes} pass${passes === 1 ? "" : "es"}: ${totals.stored} stored, ${totals.unchanged} unchanged, ${totals.skipped} skipped.`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
/** A pass that handled nothing and still says "more" would loop forever. */
|
|
152
|
+
function checkProgress(door, state, run) {
|
|
153
|
+
if (run.notesAdded + run.notesUpdated + run.notesSkipped === 0)
|
|
154
|
+
state.stalled += 1;
|
|
155
|
+
else
|
|
156
|
+
state.stalled = 0;
|
|
157
|
+
if (state.stalled < MAX_STALLED_PASSES)
|
|
158
|
+
return "again";
|
|
159
|
+
return stopSync(door, state, "no_progress", `Three passes in a row handled no meeting (last: ${run.reason}). The cursor is saved; try again later or check \`cockpit notes sync-status\`.`);
|
|
160
|
+
}
|
|
161
|
+
function stopSync(door, state, reason, detail) {
|
|
162
|
+
return failRun(door, reason, detail, state.last, state.passes, state.totals);
|
|
137
163
|
}
|
|
138
164
|
/** A sync that did not finish: the reason on stderr, the structured answer on stdout in --json. */
|
|
139
165
|
function failRun(door, reason, detail, run, passes, totals) {
|
|
@@ -57,8 +57,14 @@ export async function connectWisprflowInBrowser(door, options = {}) {
|
|
|
57
57
|
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
58
58
|
const now = options.now ?? Date.now;
|
|
59
59
|
const open = options.open ?? ((url) => openInBrowser(url));
|
|
60
|
-
const
|
|
61
|
-
|
|
60
|
+
const started = await startSignIn(door);
|
|
61
|
+
if ("failure" in started)
|
|
62
|
+
return started.failure;
|
|
63
|
+
announceSignIn(door, started.signIn.authorizeUrl, open);
|
|
64
|
+
return await waitForSignIn(door, started.signIn.attemptId, sleep, now);
|
|
65
|
+
}
|
|
66
|
+
/** 1. Ask the one start door for a sign-in. */
|
|
67
|
+
async function startSignIn(door) {
|
|
62
68
|
const started = await ask(door, {
|
|
63
69
|
path: WISPRFLOW_START_PATH,
|
|
64
70
|
method: "POST",
|
|
@@ -69,8 +75,8 @@ export async function connectWisprflowInBrowser(door, options = {}) {
|
|
|
69
75
|
if (!started.ok) {
|
|
70
76
|
log(door, "start refused", { reason: started.reason });
|
|
71
77
|
if (started.reason === "account_already_connected")
|
|
72
|
-
return alreadyConnected(door);
|
|
73
|
-
return { ok: false, reason: started.reason, detail: started.detail };
|
|
78
|
+
return { failure: await alreadyConnected(door) };
|
|
79
|
+
return { failure: { ok: false, reason: started.reason, detail: started.detail } };
|
|
74
80
|
}
|
|
75
81
|
const body = started.body;
|
|
76
82
|
const authorizeUrl = typeof body.authorize_url === "string" ? body.authorize_url : "";
|
|
@@ -78,13 +84,19 @@ export async function connectWisprflowInBrowser(door, options = {}) {
|
|
|
78
84
|
if (authorizeUrl === "" || attemptId === "") {
|
|
79
85
|
log(door, "start answer unusable", { reason: "start_answer_unusable" });
|
|
80
86
|
return {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
failure: {
|
|
88
|
+
ok: false,
|
|
89
|
+
reason: "start_answer_unusable",
|
|
90
|
+
detail: "Tower started a sign-in but did not say where to send you or how to watch it, so nothing was connected.",
|
|
91
|
+
},
|
|
84
92
|
};
|
|
85
93
|
}
|
|
86
94
|
log(door, "started", { attempt_id: attemptId, expires_at: typeof body.expires_at === "string" ? body.expires_at : null });
|
|
87
|
-
|
|
95
|
+
return { signIn: { attemptId, authorizeUrl } };
|
|
96
|
+
}
|
|
97
|
+
/** 2. Print the address first, then open it. */
|
|
98
|
+
function announceSignIn(door, authorizeUrl, open) {
|
|
99
|
+
const stderr = door.io.stderr;
|
|
88
100
|
writeLine(stderr, "Connect Wispr Flow to Tower: approve on Wispr's page in your browser.");
|
|
89
101
|
writeLine(stderr, ` ${authorizeUrl}`);
|
|
90
102
|
writeLine(stderr, " Use the Wispr account that holds your notes, signed in with Google, Apple, Microsoft or company single sign-on.");
|
|
@@ -93,7 +105,9 @@ export async function connectWisprflowInBrowser(door, options = {}) {
|
|
|
93
105
|
if (!opened.ok)
|
|
94
106
|
writeLine(stderr, `(${opened.detail} The address above still works.)`);
|
|
95
107
|
writeLine(stderr, `Waiting up to ${MAX_WAIT_MS / 60_000} minutes for the sign-in to finish...`);
|
|
96
|
-
|
|
108
|
+
}
|
|
109
|
+
/** 3. Poll the one status door. */
|
|
110
|
+
async function waitForSignIn(door, attemptId, sleep, now) {
|
|
97
111
|
const deadline = now() + MAX_WAIT_MS;
|
|
98
112
|
let polls = 0;
|
|
99
113
|
let failures = 0;
|
|
@@ -115,20 +129,9 @@ export async function connectWisprflowInBrowser(door, options = {}) {
|
|
|
115
129
|
}
|
|
116
130
|
else {
|
|
117
131
|
failures = 0;
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
log(door, "poll", { attempt_id: attemptId, polls, status: state, reason });
|
|
122
|
-
if (state === "connected") {
|
|
123
|
-
const accountId = typeof status.account_id === "string" ? status.account_id : "";
|
|
124
|
-
log(door, "connected", { attempt_id: attemptId, account_id: accountId || null, polls });
|
|
125
|
-
return { ok: true, account: await readAccount(door, accountId), already: false };
|
|
126
|
-
}
|
|
127
|
-
if (state === "failed" || state === "expired") {
|
|
128
|
-
const why = reason ?? (state === "expired" ? "state_expired" : "unknown");
|
|
129
|
-
log(door, "ended without connecting", { attempt_id: attemptId, status: state, reason: why });
|
|
130
|
-
return { ok: false, reason: why, detail: failureDetail(why) };
|
|
131
|
-
}
|
|
132
|
+
const ended = await readEndOfSignIn(door, attemptId, polls, polled.body);
|
|
133
|
+
if (ended)
|
|
134
|
+
return ended;
|
|
132
135
|
}
|
|
133
136
|
await sleep(POLL_INTERVAL_MS);
|
|
134
137
|
}
|
|
@@ -140,6 +143,24 @@ export async function connectWisprflowInBrowser(door, options = {}) {
|
|
|
140
143
|
+ " Run this again for a fresh link, or check `cockpit notes accounts`.",
|
|
141
144
|
};
|
|
142
145
|
}
|
|
146
|
+
/** The sign-in's answer once it has one; undefined while it is still pending. */
|
|
147
|
+
async function readEndOfSignIn(door, attemptId, polls, pollBody) {
|
|
148
|
+
const status = pollBody;
|
|
149
|
+
const state = typeof status.status === "string" ? status.status : "unknown";
|
|
150
|
+
const reason = typeof status.reason === "string" ? status.reason : null;
|
|
151
|
+
log(door, "poll", { attempt_id: attemptId, polls, status: state, reason });
|
|
152
|
+
if (state === "connected") {
|
|
153
|
+
const accountId = typeof status.account_id === "string" ? status.account_id : "";
|
|
154
|
+
log(door, "connected", { attempt_id: attemptId, account_id: accountId || null, polls });
|
|
155
|
+
return { ok: true, account: await readAccount(door, accountId), already: false };
|
|
156
|
+
}
|
|
157
|
+
if (state === "failed" || state === "expired") {
|
|
158
|
+
const why = reason ?? (state === "expired" ? "state_expired" : "unknown");
|
|
159
|
+
log(door, "ended without connecting", { attempt_id: attemptId, status: state, reason: why });
|
|
160
|
+
return { ok: false, reason: why, detail: failureDetail(why) };
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
143
164
|
/** The start door said Wispr Flow is already connected: read which account and report it honestly. */
|
|
144
165
|
async function alreadyConnected(door) {
|
|
145
166
|
const accounts = await readAccounts(door);
|