browser-broker 0.1.0

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 (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,401 @@
1
+ import fs from 'node:fs';
2
+ import { browserIsRunning, RealBrowserDriver, modeFor } from "../browser/real.js";
3
+ import { DEFAULT_BROWSER_IDS } from "../browser/driver.js";
4
+ import { BrokerError } from "../errors.js";
5
+ import { SIGNABLE_BROWSER } from "../service/operations/sign-in.js";
6
+ import { runSetupHandshake } from "../browser/setup.js";
7
+ import { COLLISION_HINT, NO_BROWSER_NOTE, relativeProfilePath, signInCompletion, signInInstructions, signInProfileDirectory, } from "./sign-in.js";
8
+ /**
9
+ * `broker login` — open the signed-in browser for a person, and wait.
10
+ *
11
+ * ── What this command is, in one sentence ───────────────────────────────
12
+ *
13
+ * It is the **only** part of this design a person has to perform by hand, and
14
+ * everything about its shape follows from that: it takes no lease, spends no
15
+ * tab budget, drives nothing, and its entire output is instructions.
16
+ *
17
+ * ── The four steps, and which of them are refusals ──────────────────────
18
+ *
19
+ * `SCHEMA.md` §5.5.1 gives the sequence and this file performs it in order:
20
+ *
21
+ * 1. **Claim the browser through the service** — which is where the refusals
22
+ * live: a live lease holding a tab, the private browser, a sign-in already
23
+ * in progress. None of them is checked here, deliberately: they are facts
24
+ * about leases and lease liveness is derived inside the arbitration
25
+ * transaction (§2.4). A check made in this file would read rows the sweep
26
+ * had not reconciled.
27
+ * 2. **Get them a window**, against the configured profile.
28
+ * 3. **Wait**, while they sign in.
29
+ * 4. **Give the browser back** on every path out of the command, and on an
30
+ * interruption — by a signal handler, because a `finally` does not run on
31
+ * a signal. What that does and does not cover is stated exactly at step
32
+ * four's own comment below; the short version is that a handler covers the
33
+ * deaths that let a process run code and nothing else, so the store also
34
+ * records who holds a sign-in and a later `broker login` reclaims one whose
35
+ * process is gone.
36
+ *
37
+ * ── Why the profile is asserted rather than trusted ─────────────────────
38
+ *
39
+ * **Measured, and it is the failure this command exists to prevent.** A
40
+ * second browser launched against a profile directory already in use does not
41
+ * report a lock error: it **hands its address to the browser already holding
42
+ * the profile**, with nothing on the error stream and no endpoint of its own.
43
+ * A launcher waiting on its own endpoint therefore waits for something that
44
+ * will never appear. (The losing process does not exit on its own either;
45
+ * `launch.ts` ends it by the identifier it spawned.)
46
+ *
47
+ * And the obvious guard does not work: the single-instance lock file a POSIX
48
+ * system leaves in a profile directory **does not exist on Windows**, so a
49
+ * cross-platform check for it does not report *no lock* there — it **always
50
+ * passes**. A guard that cannot fail on one platform is worse than no guard.
51
+ *
52
+ * So this file makes **no** negative inference. It asks `browserIsRunning`,
53
+ * which verifies an endpoint answers *and* identifies itself as the expected
54
+ * browser, and when it starts one it goes through `coldStart`, whose contract
55
+ * is that success is an endpoint that answered — never a command that exited.
56
+ * The positive assertion is the whole mechanism; there is no path here that
57
+ * concludes a browser is available because nothing said otherwise.
58
+ *
59
+ * ── Why the profile is never recreated ──────────────────────────────────
60
+ *
61
+ * `runSetupHandshake` is called rather than respelled, and that is not
62
+ * economy: it is the one implementation of **setup may create, and may never
63
+ * destroy** (`setup.profile_never_destroyed`, §7.2). The profile holds a
64
+ * sign-in a person put there by hand, and a second implementation of
65
+ * "establish a profile" is exactly where the branch that clears a directory
66
+ * because it looks unfamiliar would eventually be written.
67
+ */
68
+ /** How the person's window is watched for, and how often. */
69
+ export const CLOSE_POLL_INTERVAL_MS = 1_000;
70
+ /**
71
+ * The signals a person's interruption arrives as.
72
+ *
73
+ * `SIGINT` is Ctrl-C, which is the one that matters: it is how anybody stops
74
+ * a command that is sitting there waiting, and it is the keystroke that used
75
+ * to strand the browser. `SIGTERM` is the ordinary polite termination — what
76
+ * a supervisor, a shell logout or a `taskkill` without `/F` sends.
77
+ *
78
+ * **`SIGKILL` is deliberately absent and cannot be added.** It is not
79
+ * deliverable to a handler by design, which is precisely why the recovery
80
+ * path in `service/signin-recovery.ts` exists rather than this list being
81
+ * extended until it feels complete.
82
+ */
83
+ export const INTERRUPT_SIGNALS = ['SIGINT', 'SIGTERM'];
84
+ /**
85
+ * The real one: process signal handlers, and an exit once the browser is back.
86
+ *
87
+ * ── Why this exits rather than letting the process continue ─────────────
88
+ *
89
+ * Installing a handler for `SIGINT` **takes over the default disposition**: a
90
+ * process carrying one runs the handler and carries on waiting rather than
91
+ * ending, which from a person's side is a command that has stopped responding
92
+ * to Ctrl-C. That would trade one bad outcome for another, so the handler does
93
+ * the work and then ends the process itself.
94
+ *
95
+ * The exit code is the conventional `128 + signal number`, which is what a
96
+ * shell reports for a process killed by that signal — so a script watching
97
+ * this command sees what it saw before rather than a new number to learn.
98
+ */
99
+ export function realInterruptHandling() {
100
+ return {
101
+ install: (onInterrupt) => {
102
+ const registered = INTERRUPT_SIGNALS.map((signal) => {
103
+ const listener = () => {
104
+ onInterrupt(signal);
105
+ };
106
+ process.on(signal, listener);
107
+ return { signal, listener };
108
+ });
109
+ return () => {
110
+ for (const { signal, listener } of registered) {
111
+ process.off(signal, listener);
112
+ }
113
+ };
114
+ },
115
+ };
116
+ }
117
+ /** `128 + n`, the exit code a shell reports for a death by signal. */
118
+ export const SIGNAL_EXIT_CODES = {
119
+ SIGINT: 130,
120
+ SIGTERM: 143,
121
+ };
122
+ /**
123
+ * The exit code when an interruption was caught and the browser could **not**
124
+ * be given back.
125
+ *
126
+ * Distinct from the signal codes on purpose: those say "this process was
127
+ * interrupted", which is ordinary and is what a script expects. This one says
128
+ * the interruption was handled and the cleanup it exists to perform failed, so
129
+ * something is left behind — a different fact, and one a script watching this
130
+ * command should be able to tell apart without parsing English.
131
+ */
132
+ export const EXIT_INTERRUPT_INCOMPLETE = 70;
133
+ /**
134
+ * The real window: a headed browser against the configured profile.
135
+ *
136
+ * It attaches to one that is already running and starts one otherwise, which
137
+ * is the arrangement §5.5.1 describes — *"nothing is stopped and nothing is
138
+ * relaunched"*, because relaunching is a chance to lose the very thing being
139
+ * protected.
140
+ */
141
+ export function realSignInWindow(options = {}) {
142
+ const driver = new RealBrowserDriver(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl });
143
+ return {
144
+ open: async (request) => {
145
+ if (request.alreadyRunning) {
146
+ const record = await browserIsRunning(request.profileDirectory, options);
147
+ if (record === undefined) {
148
+ // The record stopped checking out between the caller's look and
149
+ // this one. Treated as not running rather than as an error, because
150
+ // that is exactly what a stale record means (§1.2c).
151
+ const session = await driver.coldStart({
152
+ browser: request.browser,
153
+ profileDirectory: request.profileDirectory,
154
+ mode: modeFor(request.browser),
155
+ });
156
+ return finishOpen(session, true);
157
+ }
158
+ const session = await driver.attach(request.browser, record);
159
+ // Attaching is non-destructive and the browser was not started here,
160
+ // so the person is being handed a window that already existed.
161
+ return finishOpen(session, false);
162
+ }
163
+ const session = await driver.coldStart({
164
+ browser: request.browser,
165
+ profileDirectory: request.profileDirectory,
166
+ mode: modeFor(request.browser),
167
+ });
168
+ return finishOpen(session, true);
169
+ },
170
+ waitForClose: async (request) => {
171
+ // **Watched by asking the endpoint, not by watching the process.** A
172
+ // process identifier can be reused, and a browser that is exiting holds
173
+ // its identifier for a moment after its window has gone. The endpoint
174
+ // answering with the expected identity is the same positive test
175
+ // everything else here uses, and its *absence* is the only negative
176
+ // conclusion this file draws — which is sound in this direction: an
177
+ // endpoint that does not answer is not serving anybody.
178
+ for (;;) {
179
+ const record = await browserIsRunning(request.profileDirectory, options);
180
+ if (record === undefined) {
181
+ return;
182
+ }
183
+ await new Promise((resolve) => setTimeout(resolve, CLOSE_POLL_INTERVAL_MS));
184
+ }
185
+ },
186
+ };
187
+ }
188
+ /**
189
+ * Release this process's connection and report the browser's identifier.
190
+ *
191
+ * **Detaching is not closing.** Measured (`real.ts`): attaching and detaching
192
+ * are non-destructive to tabs and cookies, and the browser is adopted rather
193
+ * than owned — so letting go of the connection leaves the person's window
194
+ * exactly where it was, which is the point. This process must not be holding
195
+ * a connection while a person drives the window, because this process is
196
+ * going to exit.
197
+ */
198
+ async function finishOpen(session, startedIt) {
199
+ const { pid } = session.describe();
200
+ await session.detach();
201
+ return { pid, startedIt };
202
+ }
203
+ /**
204
+ * Run the command.
205
+ *
206
+ * Returns an exit code rather than calling out to the process, like every
207
+ * other command here, so the whole of it is reachable from a test.
208
+ */
209
+ export async function runLoginCommand(options) {
210
+ const { broker, environment, streams, json } = options;
211
+ const requested = options.browser ?? SIGNABLE_BROWSER;
212
+ // Establish the profile **before** claiming the browser, because a claim
213
+ // that succeeded and then failed to find a profile would leave the browser
214
+ // in `signing-in` over a directory that was never there. It creates what is
215
+ // absent and leaves alone what is present — never recreating one that
216
+ // exists, which is the whole of `setup.profile_never_destroyed`.
217
+ await runSetupHandshake(options.store, environment.profileRoot, {
218
+ browsers: [...environment.regularBrowsers, ...environment.privateBrowsers],
219
+ });
220
+ // Step 1. Every refusal is the service's; this route adds none of its own.
221
+ //
222
+ // **The owner is recorded as part of taking the claim**, so a sign-in is
223
+ // never held by a process the store cannot name. See step eight of the
224
+ // schema for why this is the command's identifier rather than the browser's.
225
+ const began = await broker.begin_sign_in({
226
+ browser: requested,
227
+ ownerPid: options.ownerPid ?? process.pid,
228
+ });
229
+ const browser = began.browser;
230
+ const profileDir = signInProfileDirectory(environment.profileRoot, browser);
231
+ // ── The interruption handler, installed as soon as there is something to
232
+ // give back and not one line earlier ──────────────────────────────────
233
+ //
234
+ // **Ordering is the whole correctness argument here.** Installed before the
235
+ // claim, it could fire when there is no claim to release and would call
236
+ // `end_sign_in` against a browser that is not signing in — which the service
237
+ // refuses, so a person interrupting an early failure would be handed a
238
+ // confusing refusal on their way out. Installed after the window opens, a
239
+ // Ctrl-C during the launch — which is a slow step and therefore a likely
240
+ // moment to press it — would strand exactly the state this is here to
241
+ // prevent.
242
+ //
243
+ // So it goes immediately after the claim is taken and immediately before
244
+ // anything slow, and it is removed in the `finally` so a completed command
245
+ // leaves no listener behind.
246
+ const handling = options.interrupts ?? { install: () => () => { } };
247
+ const endProcess = options.exit ?? ((code) => process.exit(code));
248
+ let interrupted = false;
249
+ const remove = handling.install((signal) => {
250
+ // **Re-entrancy matters more than it looks.** A person who presses Ctrl-C
251
+ // and sees nothing happen immediately presses it again, and a second run
252
+ // through here would call `end_sign_in` twice — the second against a
253
+ // browser already given back, which refuses. Latching means the extra
254
+ // presses are ignored rather than producing a refusal on the way out.
255
+ if (interrupted) {
256
+ return;
257
+ }
258
+ interrupted = true;
259
+ streams.err('');
260
+ streams.err(`Interrupted. Giving the ${browser} browser back before exiting — it would otherwise refuse every caller until somebody intervened.`);
261
+ void (async () => {
262
+ let code = SIGNAL_EXIT_CODES[signal];
263
+ try {
264
+ await broker.end_sign_in({ browser });
265
+ streams.err(`The ${browser} browser is serving again.`);
266
+ }
267
+ catch (error) {
268
+ // Reported, and it changes the exit code: a person whose browser was
269
+ // **not** given back needs to know that the thing this message
270
+ // promised did not happen. Exiting zero-shaped here would be the same
271
+ // class of defect as the comment that used to claim a `finally`
272
+ // covered a signal.
273
+ streams.err(`The browser could not be returned to service: ${error instanceof Error ? error.message : String(error)}`);
274
+ streams.err('It is recorded as signing-in and this process is about to exit. The next `broker login` will reclaim it, because the sign-in records which process was holding it.');
275
+ code = EXIT_INTERRUPT_INCOMPLETE;
276
+ }
277
+ endProcess(code);
278
+ })();
279
+ });
280
+ const signals = { dispose: remove };
281
+ let opened;
282
+ try {
283
+ const window = options.window ?? realSignInWindow();
284
+ // Asked positively, and the answer is used only in the direction where it
285
+ // is meaningful: a verified record means a browser is there to attach to.
286
+ // A missing one means *this call could not verify one*, which is why the
287
+ // branch it leads to starts a browser and asserts an endpoint rather than
288
+ // assuming the profile is free.
289
+ const running = await browserIsRunning(profileDir);
290
+ opened = await window.open({
291
+ browser,
292
+ profileDirectory: profileDir,
293
+ alreadyRunning: running !== undefined,
294
+ });
295
+ const relative = relativeProfilePath(environment.profileRoot, browser);
296
+ if (json) {
297
+ // One document, and the human text goes to the error stream (§5.6).
298
+ streams.out(JSON.stringify({
299
+ outcome: 'accepted',
300
+ value: {
301
+ browser,
302
+ state: began.state,
303
+ profileRelativePath: relative,
304
+ startedBrowser: opened.startedIt,
305
+ pid: opened.pid,
306
+ },
307
+ }));
308
+ for (const line of signInInstructions(browser, relative)) {
309
+ streams.err(line);
310
+ }
311
+ }
312
+ else {
313
+ if (opened.startedIt) {
314
+ streams.out(NO_BROWSER_NOTE);
315
+ streams.out('');
316
+ }
317
+ for (const line of signInInstructions(browser, relative)) {
318
+ streams.out(line);
319
+ }
320
+ }
321
+ // Step 3. The person signs in. Nothing happens here until they close it.
322
+ await window.waitForClose({ profileDirectory: profileDir, pid: opened.pid });
323
+ }
324
+ finally {
325
+ // ── Step 4, and what this `finally` actually guarantees ─────────────
326
+ //
327
+ // A browser left in `signing-in` refuses **every** caller with a message
328
+ // about a person who has walked away, so the browser is given back on
329
+ // every path out of here — a normal completion and a refusal from the
330
+ // window alike.
331
+ //
332
+ // **It does not cover an interruption, and it never did.** A `finally`
333
+ // is ordinary control flow: the runtime unwinds to it when a call
334
+ // returns or throws. A signal is not either of those, and with no
335
+ // handler installed the default disposition for `SIGINT` terminates the
336
+ // process without unwinding anything — so this block does not run, and
337
+ // before the handler below existed a Ctrl-C left the browser
338
+ // unrecoverable. That is why the handler is installed rather than being
339
+ // relied upon from here, and it is why the store records an owner as
340
+ // well: a handler cannot run on `SIGKILL` or on a power cut.
341
+ signals.dispose();
342
+ // **Nothing is given back twice.** When the handler ran it has already
343
+ // called `end_sign_in`, and calling it again would hit the service's own
344
+ // refusal for ending a sign-in that never began — turning a clean
345
+ // interruption into an error message on the way out. The handler owns the
346
+ // release from the moment it fires, and this block owns every other path.
347
+ //
348
+ // Written as a condition around the work rather than as an early `return`,
349
+ // deliberately: a `return` inside a `finally` **discards an exception the
350
+ // `try` was throwing**, so the one shape that reads most naturally here is
351
+ // the one that would silently swallow a genuine launch failure.
352
+ if (!interrupted) {
353
+ try {
354
+ const ended = await broker.end_sign_in({ browser });
355
+ if (!json) {
356
+ streams.out('');
357
+ for (const line of signInCompletion(browser, ended.queueDepth)) {
358
+ streams.out(line);
359
+ }
360
+ }
361
+ else {
362
+ streams.err(`The ${browser} browser is serving again.`);
363
+ }
364
+ }
365
+ catch (error) {
366
+ // Reported rather than swallowed and never allowed to replace the
367
+ // original failure: if this is running because something above threw,
368
+ // that is the thing the person needs to read.
369
+ streams.err(`The browser could not be returned to service: ${error instanceof Error ? error.message : String(error)}`);
370
+ }
371
+ }
372
+ }
373
+ return 0;
374
+ }
375
+ /**
376
+ * Turn a launch refusal into something a person can act on.
377
+ *
378
+ * The launch's own message is accurate and is about endpoints; this adds what
379
+ * to *do*, which is the part a person needs and the part a message about
380
+ * endpoints cannot supply.
381
+ */
382
+ export function explainLoginFailure(error) {
383
+ if (error instanceof BrokerError && error.message.includes('already running against this')) {
384
+ return `${error.message}\n\n${COLLISION_HINT}`;
385
+ }
386
+ return error instanceof Error ? error.message : String(error);
387
+ }
388
+ /**
389
+ * Whether a profile directory exists at all, for the caller that wants to
390
+ * report rather than create.
391
+ */
392
+ export function profileExists(profileRoot, browser) {
393
+ try {
394
+ return fs.statSync(signInProfileDirectory(profileRoot, browser)).isDirectory();
395
+ }
396
+ catch {
397
+ return false;
398
+ }
399
+ }
400
+ /** The browsers this command will answer for, for a usage message. */
401
+ export const LOGIN_BROWSERS = DEFAULT_BROWSER_IDS;
@@ -0,0 +1,186 @@
1
+ import { resolveAutomationProbe } from "../browser/automation-probe.js";
2
+ import { runDoctor, formatReport } from "../doctor/report.js";
3
+ import { EXIT } from "./adapter.js";
4
+ import { readLedger } from "../operations/ledger.js";
5
+ import { writeSnapshot } from "../report/snapshot.js";
6
+ /**
7
+ * Exit codes, chosen so situations wanting opposite responses are
8
+ * distinguishable without parsing anything (§5.6).
9
+ *
10
+ * Re-exported from the adapter that owns them rather than restated, so there
11
+ * is one set of numbers in this build and no second copy to drift. The
12
+ * re-export exists because these three commands are not adapter operations —
13
+ * they have no service call behind them — and importing the adapter's own
14
+ * constant is how they stay in step with the route that does.
15
+ */
16
+ export const COMMAND_EXIT = EXIT;
17
+ /** `--name value` and `--name=value`, plus bare `--flag`. */
18
+ export function parseFlags(rest) {
19
+ const parsed = {};
20
+ for (let index = 0; index < rest.length; index += 1) {
21
+ const word = rest[index];
22
+ if (word === undefined || !word.startsWith('--')) {
23
+ continue;
24
+ }
25
+ const body = word.slice(2);
26
+ const equals = body.indexOf('=');
27
+ if (equals !== -1) {
28
+ parsed[body.slice(0, equals)] = body.slice(equals + 1);
29
+ continue;
30
+ }
31
+ const next = rest[index + 1];
32
+ if (next === undefined || next.startsWith('--')) {
33
+ parsed[body] = true;
34
+ continue;
35
+ }
36
+ parsed[body] = next;
37
+ index += 1;
38
+ }
39
+ return parsed;
40
+ }
41
+ function asString(value) {
42
+ return typeof value === 'string' ? value : undefined;
43
+ }
44
+ function asNumber(value) {
45
+ const text = asString(value);
46
+ if (text === undefined) {
47
+ return undefined;
48
+ }
49
+ const parsed = Number(text);
50
+ return Number.isFinite(parsed) ? parsed : undefined;
51
+ }
52
+ /**
53
+ * `broker snapshot --out <path>`.
54
+ *
55
+ * **No browser connection is supplied by this route**, and that is stated
56
+ * rather than hidden: the command line does not attach to a browser, so every
57
+ * address in the document it writes reads as `unreachable` with a note giving
58
+ * the single reason. The row that brings the real driver is the row that
59
+ * passes a source through. §4.2a's requirement is about what the document
60
+ * says when a browser does not answer, and this route satisfies it in the
61
+ * most literal way available — it asks nothing and says so, rather than
62
+ * leaving a blank.
63
+ */
64
+ export async function runSnapshotCommand(rest, options) {
65
+ const flags = parseFlags(rest);
66
+ const outputPath = asString(flags.out) ?? asString(flags.output) ?? asString(flags.path);
67
+ if (outputPath === undefined) {
68
+ options.streams.err('broker snapshot needs somewhere to write: --out <path>. It writes one self-contained HTML file and exits.');
69
+ return COMMAND_EXIT.malformed;
70
+ }
71
+ const result = await writeSnapshot(options.db, {
72
+ outputPath,
73
+ eventLimit: asNumber(flags.events),
74
+ feedbackLimit: asNumber(flags.feedback),
75
+ version: options.version,
76
+ });
77
+ if (options.json) {
78
+ options.streams.out(JSON.stringify({
79
+ path: result.path,
80
+ bytes: result.bytes,
81
+ at: result.at,
82
+ tabs_asked: result.tabsAsked,
83
+ tabs_unreachable: result.tabsUnreachable,
84
+ }));
85
+ }
86
+ else {
87
+ options.streams.out(`snapshot: ${result.path}`);
88
+ options.streams.out(`taken at: ${result.at}`);
89
+ // Said on every run, not only when something is wrong. §4.1's rule is
90
+ // that the document must not be mistaken for a window, and the person
91
+ // most likely to make that mistake is the one who just generated it.
92
+ options.streams.out('This is a photograph, not a window: it does not refresh. Generate another to see the current picture.');
93
+ if (result.tabsUnreachable > 0) {
94
+ options.streams.out(`${String(result.tabsUnreachable)} of ${String(result.tabsAsked + result.tabsUnreachable)} tab address(es) could not be read and are shown as unreachable.`);
95
+ }
96
+ }
97
+ return COMMAND_EXIT.accepted;
98
+ }
99
+ /**
100
+ * `broker doctor`.
101
+ *
102
+ * **Reports and changes nothing** (§5.5), and exits with a distinct code on
103
+ * any failure, so it is usable exactly where a readiness check would have
104
+ * been used. The full list is on the output stream in both modes — the
105
+ * machine-readable one is one document, per §5.6.
106
+ */
107
+ export function runDoctorCommand(options) {
108
+ // **The configured budget is passed, and until it was, the check could not
109
+ // fail.** `checkTabBudget` compares the stored value against this process's
110
+ // own; with nothing supplied it reported `unknown` regardless of what the
111
+ // store held. The only callers ever passing it were the doctor's own tests,
112
+ // which is why the gap survived — see `operations/status.ts` for the other
113
+ // half of the same failure.
114
+ //
115
+ // **The automation probe is passed the same way, for the same reason.**
116
+ // `checkAutomation` compares what it is told against nothing on its own;
117
+ // with no probe supplied it reported `unknown` regardless of whether a
118
+ // browser binary was actually resolvable. The only callers ever passing
119
+ // one were the doctor's own tests — see `resolveAutomationProbe` in
120
+ // `browser/automation-probe.ts` for the real answer this call site now
121
+ // supplies by default.
122
+ const report = runDoctor(options.environment, options.db, {
123
+ configuredTabBudget: options.environment.tabBudget,
124
+ automation: options.automationProbe ?? resolveAutomationProbe(),
125
+ });
126
+ if (options.json) {
127
+ options.streams.out(JSON.stringify({
128
+ store: report.storeLocation,
129
+ exit_code: report.exitCode,
130
+ checks: report.checks.map((check) => ({
131
+ id: check.id,
132
+ group: check.group,
133
+ status: check.status,
134
+ detail: check.detail,
135
+ remedy: check.remedy,
136
+ })),
137
+ }));
138
+ }
139
+ else {
140
+ for (const line of formatReport(report)) {
141
+ options.streams.out(line);
142
+ }
143
+ }
144
+ return report.exitCode;
145
+ }
146
+ /**
147
+ * `broker events` — a slice of the ledger (`MILESTONES.md` #47).
148
+ *
149
+ * Sliced by kind, outcome and rule, with the cursor the counter primary key
150
+ * already provides (§1.6). Every filter is a bound parameter; nothing a
151
+ * caller types reaches the SQL text.
152
+ */
153
+ export function runEventsCommand(rest, options) {
154
+ const flags = parseFlags(rest);
155
+ const query = {
156
+ kinds: asString(flags.kind)?.split(',') ?? undefined,
157
+ outcome: asString(flags.outcome),
158
+ guard: asString(flags.guard),
159
+ sessionId: asString(flags['session-id']),
160
+ claimId: asString(flags['claim-id']),
161
+ since: asNumber(flags.since),
162
+ before: asNumber(flags.before),
163
+ limit: asNumber(flags.limit),
164
+ order: flags.since === undefined ? 'newest' : 'oldest',
165
+ };
166
+ const slice = readLedger(options.db, query);
167
+ if (options.json) {
168
+ options.streams.out(JSON.stringify({
169
+ entries: slice.entries,
170
+ cursor: slice.cursor,
171
+ total: slice.total,
172
+ }));
173
+ return COMMAND_EXIT.accepted;
174
+ }
175
+ if (slice.entries.length === 0) {
176
+ options.streams.out('No ledger entries match.');
177
+ return COMMAND_EXIT.accepted;
178
+ }
179
+ for (const entry of slice.entries) {
180
+ const guard = entry.guard === null ? '' : ` guard=${entry.guard}`;
181
+ const session = entry.sessionId === null ? '' : ` session=${entry.sessionId}`;
182
+ options.streams.out(`#${String(entry.id)} ${entry.at} ${entry.kind} ${entry.outcome} via=${entry.adapter}${guard}${session}`);
183
+ }
184
+ options.streams.out(`${String(slice.entries.length)} of ${String(slice.total)} matching entries.${slice.cursor === null ? '' : ` Read on with --since ${String(slice.cursor)}.`}`);
185
+ return COMMAND_EXIT.accepted;
186
+ }