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,895 @@
1
+ import fs from 'node:fs';
2
+ import { readEnvironment } from "../config/environment.js";
3
+ import { BrokerError } from "../errors.js";
4
+ import { openStoreForDiagnosis, prepareStore } from "../store/open.js";
5
+ import { DEFAULT_LIMIT, readFeedback, refuseFilters, renderFeedback, } from "../feedback/read.js";
6
+ import { isFeedbackCategory } from "../feedback/record.js";
7
+ import { cliAdapter, EXIT, parseArguments, withoutSecrets } from "./adapter.js";
8
+ import { OPERATION_COMMANDS, parseCommand, STANDALONE_COMMANDS, } from "./commands.js";
9
+ import { describeSetupReport, runSetupHandshake } from "../browser/setup.js";
10
+ import { ArtifactStore } from "../artifacts/store.js";
11
+ import { runDiffs } from "./diffs.js";
12
+ import { runCaptures } from "./telemetry.js";
13
+ import { runImage } from "./image.js";
14
+ import { runDoctorCommand, runEventsCommand, runSnapshotCommand } from "./operations-commands.js";
15
+ import { explainLoginFailure, runLoginCommand } from "./login-command.js";
16
+ import { runReconcileCommand } from "./reconcile-command.js";
17
+ const defaultStreams = {
18
+ out: (line) => process.stdout.write(`${line}\n`),
19
+ err: (line) => process.stderr.write(`${line}\n`),
20
+ };
21
+ /**
22
+ * The version this build reports. Read from the manifest rather than written
23
+ * twice, so the two cannot disagree.
24
+ */
25
+ async function readVersion() {
26
+ const manifest = await import('../../package.json', { with: { type: 'json' } });
27
+ const version = manifest.default.version;
28
+ return typeof version === 'string' ? version : '0.0.0';
29
+ }
30
+ /**
31
+ * Usage, assembled from the command table rather than written out beside it.
32
+ *
33
+ * Two lists that have to agree is one list somebody eventually forgets, and
34
+ * the forgetting is silent: a command that works and is undocumented reads
35
+ * exactly like one that does not exist.
36
+ */
37
+ function usage() {
38
+ const pad = (words) => `broker ${words.join(' ')}`.padEnd(22);
39
+ return [
40
+ 'broker — brokers access to a fixed set of browsers.',
41
+ '',
42
+ 'Usage:',
43
+ ' broker open the store, step its schema, report and exit',
44
+ ' broker --version print the version this build reports',
45
+ ' broker --help print this message',
46
+ '',
47
+ 'Operations — every one of these is one service call:',
48
+ ...OPERATION_COMMANDS.map((command) => ` ${pad(command.words)}${command.summary}`),
49
+ '',
50
+ 'Commands with no operation behind them:',
51
+ ...STANDALONE_COMMANDS.map((command) => ` ${pad(command.words)}${command.summary}`),
52
+ '',
53
+ 'Output: human-readable by default; --json for one document per call,',
54
+ 'with all human text on the error stream.',
55
+ ].join('\n');
56
+ }
57
+ /**
58
+ * What `broker <command> --help` prints.
59
+ *
60
+ * ── Why asking a command for help must not print the whole table ────────
61
+ *
62
+ * A global `--help` branch that matches the flag **anywhere in the argument
63
+ * vector** answers every per-command request with the top-level table. The
64
+ * caller asked what `doctor` does and is handed the list of every command,
65
+ * which is the one answer they already had — and worse, it reads as though
66
+ * `doctor` has no help rather than as though the flag was swallowed.
67
+ *
68
+ * The summary is taken from the command table rather than written out again
69
+ * here, for the reason that table's own header gives: two lists that have to
70
+ * agree is one list somebody eventually forgets, and the forgetting is silent.
71
+ */
72
+ function commandUsage(words, summary, options = []) {
73
+ // The command's own options come from the command table rather than from a
74
+ // list kept here, for the reason that table's header gives: two lists that
75
+ // have to agree is one list somebody eventually forgets.
76
+ const width = Math.max(21, ...options.map((option) => option.flag.length + 2));
77
+ return [
78
+ `broker ${words.join(' ')} — ${summary}`,
79
+ '',
80
+ 'Usage:',
81
+ ` broker ${words.join(' ')} [options]`,
82
+ '',
83
+ 'Options:',
84
+ ...options.map((option) => ` ${option.flag.padEnd(width)}${option.summary}`),
85
+ ` ${'--json'.padEnd(width)}one document on the output stream, human text on the error stream`,
86
+ ` ${'--help'.padEnd(width)}print this message`,
87
+ '',
88
+ 'Run `broker --help` for every command.',
89
+ ].join('\n');
90
+ }
91
+ /**
92
+ * The setup handshake every spawn runs (`SCHEMA.md` §1.2d).
93
+ *
94
+ * "Every spawn runs it, not just the first one" — which is not belt and
95
+ * braces but the only workable arrangement when there is no long-lived
96
+ * process to have done it once. It is idempotent by design: it creates what
97
+ * is absent and leaves alone what is present.
98
+ *
99
+ * The browser rows, the profile directories and the browsers themselves are
100
+ * the rest of that table and belong to rows #7 and #19 onward. What this row
101
+ * wires is the part that has to happen before any of them: the file, the
102
+ * pragmas, and the schema version.
103
+ */
104
+ async function openAndStep(environment, streams) {
105
+ // The spawn path, which steps the schema and settles the budget agreement.
106
+ //
107
+ // **The report below reads `store.stepped` rather than stepping again.**
108
+ // Stepping is idempotent, so a second call would truthfully answer "nothing
109
+ // to do" — and this command would then report a store it had just created
110
+ // from nothing as having already been at the current version.
111
+ const store = await prepareStore(environment);
112
+ const stepped = store.stepped;
113
+ streams.out(`store: ${store.location}`);
114
+ if (stepped.applied.length === 0) {
115
+ streams.out(`schema: already at version ${String(stepped.to)}, nothing to do`);
116
+ }
117
+ else {
118
+ streams.out(`schema: stepped from version ${String(stepped.from)} to ${String(stepped.to)} (${String(stepped.applied.length)} step(s) applied)`);
119
+ }
120
+ return store;
121
+ }
122
+ /**
123
+ * The store this run works on: the one the spawn already opened, or a fresh
124
+ * prepared one when nothing was supplied.
125
+ *
126
+ * ── Why a command must not simply open its own ──────────────────────────
127
+ *
128
+ * A shipped binary builds its runtime before dispatching, and that runtime has
129
+ * already opened, stepped and settled the budget agreement on the store this
130
+ * process is going to use (`src/bin/broker.ts`). A command that opened a
131
+ * second one would put **two independent open paths in a single spawn**, and
132
+ * two paths that each perform the same startup obligations are two paths that
133
+ * can drift — with the drift invisible, because whichever one is still correct
134
+ * satisfies any end-to-end assertion on its own. Measured: with the runtime's
135
+ * agreement removed, a disagreeing spawn was still refused by the other path,
136
+ * so nothing observable changed and no test could see the loss.
137
+ *
138
+ * So the supplied handle wins whenever there is one, and `owned` says whether
139
+ * this run is the one that has to close it — closing a store the runtime owns
140
+ * would pull the file out from under everything else the spawn is doing.
141
+ *
142
+ * Opening is still possible for the caller that supplied nothing: `run` is
143
+ * driven in-process with an argument vector by the conformance suite and by
144
+ * most command tests, and that caller has no runtime. It gets `prepareStore`,
145
+ * which is the same three obligations in the same order.
146
+ */
147
+ async function storeForRun(options, environment) {
148
+ if (options.store !== undefined) {
149
+ return { store: options.store, owned: false };
150
+ }
151
+ return { store: await prepareStore(environment), owned: true };
152
+ }
153
+ /**
154
+ * Run the command line.
155
+ *
156
+ * Returns an exit code rather than calling out to the process, so the whole
157
+ * of it is reachable from a test. Non-zero on refusal, with the reason on the
158
+ * error stream.
159
+ */
160
+ export async function run(argv, options = {}) {
161
+ const streams = options.streams ?? defaultStreams;
162
+ const json = argv.includes('--json');
163
+ const wantsHelp = argv.includes('--help') || argv.includes('-h');
164
+ // **Asked of a command, answered by that command.** The flag is dispatched
165
+ // through the command table first, so `broker doctor --help` describes
166
+ // `doctor`. Only a request that names no command falls through to the table
167
+ // of everything — which is what `broker --help` means and all it means.
168
+ if (wantsHelp) {
169
+ const parsed = parseCommand(argv);
170
+ if (parsed.kind === 'operation' || parsed.kind === 'standalone') {
171
+ streams.out(commandUsage(parsed.command.words, parsed.command.summary, parsed.command.options));
172
+ return EXIT.accepted;
173
+ }
174
+ streams.out(usage());
175
+ return EXIT.accepted;
176
+ }
177
+ if (argv.includes('--version') || argv.includes('-v')) {
178
+ streams.out(await readVersion());
179
+ return EXIT.accepted;
180
+ }
181
+ if (argv.length > 0) {
182
+ const parsed = parseCommand(argv);
183
+ if (parsed.kind === 'operation') {
184
+ // `broker feedback` carries **both halves** (§5.3): it writes a row with
185
+ // the same arguments the tool takes, and **with no writing arguments it
186
+ // reads the rows back**. The reading half has no service operation
187
+ // behind it — a caller writes feedback and a person reads it — so it is
188
+ // dispatched before the operation path rather than through it.
189
+ if (parsed.command.operation === 'feedback' && isReadingFeedback(parsed.rest)) {
190
+ return readFeedbackCommand(parsed.rest, { streams, json, options });
191
+ }
192
+ return runOperation(parsed.command.operation, parsed.rest, { streams, json, options });
193
+ }
194
+ if (parsed.kind === 'standalone') {
195
+ const name = parsed.command.words.join(' ');
196
+ // Built, and reaching their implementations. `login` is the one still
197
+ // owed, and it keeps the honest refusal below rather than being quietly
198
+ // absent — a command that pretended to work would be worse than one
199
+ // that says it does not.
200
+ if (name === 'snapshot' ||
201
+ name === 'doctor' ||
202
+ name === 'events' ||
203
+ name === 'diffs' ||
204
+ name === 'captures' ||
205
+ name === 'image' ||
206
+ name === 'reconcile') {
207
+ return await runOperationsCommand(name, parsed.rest, { streams, json, options });
208
+ }
209
+ if (name === 'init') {
210
+ return await runInitCommand({ streams, json, options });
211
+ }
212
+ if (name === 'login') {
213
+ return await runLogin(parsed.rest, { streams, json, options });
214
+ }
215
+ streams.err(`broker ${name} is not built yet — owed by ${parsed.command.owedBy}.`);
216
+ return EXIT.unexpected;
217
+ }
218
+ // Reading the ledger is on the operations command surface (§5.4) rather
219
+ // than being one of §5.5's four, so it is not in either table above: it
220
+ // is neither a mirror of an agent operation nor a command with a written
221
+ // waiver. It is a read of history, and it decides nothing.
222
+ if (parsed.kind === 'unknown' && argv[0] === 'events') {
223
+ return await runOperationsCommand('events', argv.slice(1), { streams, json, options });
224
+ }
225
+ }
226
+ const unknownFlag = argv.find((argument) => argument.startsWith('-') && argument !== '--json');
227
+ if (unknownFlag !== undefined) {
228
+ streams.err(`Unrecognised option: ${unknownFlag}`);
229
+ streams.err(usage());
230
+ return EXIT.malformed;
231
+ }
232
+ if (argv.length > 0) {
233
+ streams.err(`Unrecognised command: ${String(argv[0])}`);
234
+ streams.err(usage());
235
+ return EXIT.malformed;
236
+ }
237
+ let store;
238
+ try {
239
+ const environment = readEnvironment({ env: options.env });
240
+ store = await openAndStep(environment, streams);
241
+ return EXIT.accepted;
242
+ }
243
+ catch (error) {
244
+ if (error instanceof BrokerError) {
245
+ // A refusal: this service declining to run, named by the rule that
246
+ // refused. Not a stack trace — the message is the whole of what the
247
+ // person who set the variable needs.
248
+ streams.err(`refused (${error.rule}): ${error.message}`);
249
+ return EXIT.unexpected;
250
+ }
251
+ throw error;
252
+ }
253
+ finally {
254
+ store?.close();
255
+ }
256
+ }
257
+ /**
258
+ * The refusal every operation command gives while the service layer is
259
+ * unbuilt.
260
+ *
261
+ * **This is a stub with a seam behind it, and it is deliberately not a
262
+ * pretend success.** The service layer is row #10 onward and is not on `main`
263
+ * yet; a command that answered `accepted` without one would be a route
264
+ * reporting an operation that did not happen — the precise failure
265
+ * `DECISIONS.md` §5 is about, in the other direction.
266
+ *
267
+ * It is shaped as an ordinary refusal rather than a crash so that the whole
268
+ * refusal path — the exit code, the rule name, the machine-readable
269
+ * document, the never-printed fields — is exercised by real tests now, and so
270
+ * that the join is a substitution rather than a rewrite: pass a real
271
+ * {@link BrokerService} and every command routes to it with nothing else
272
+ * changing.
273
+ */
274
+ export function serviceUnavailable() {
275
+ return {
276
+ perform: (request) => Promise.resolve({
277
+ outcome: 'refused',
278
+ code: 'service_unavailable',
279
+ rule: 'service.not_built',
280
+ message: `The service layer this build would call for "${request.operation}" is not present. Supply a service to run this command.`,
281
+ }),
282
+ };
283
+ }
284
+ /**
285
+ * Whether this invocation asked to wait.
286
+ *
287
+ * Read off the argument vector rather than out of `parseArguments`, because
288
+ * the parser is the *service's* input shaping — every key it produces is sent
289
+ * on as an operation argument. `--wait` is not an argument to `claim` (§3.2
290
+ * has no such field) and must not become one: it is a behaviour of this route
291
+ * and of nothing else, which is the distinction §5.3 draws when it says this
292
+ * is "the one place this route does something the tool surface does not".
293
+ */
294
+ function wantsWait(rest) {
295
+ return rest.includes('--wait');
296
+ }
297
+ /**
298
+ * Poll a queued claim until it is granted, its place is lost, or it is
299
+ * refused.
300
+ *
301
+ * ── The interval is the service's number, not this route's ──────────────
302
+ *
303
+ * §5.3 says "just under the lease lifetime", and the queued response already
304
+ * carries that number as `checkBackSeconds` — computed by
305
+ * `checkBackSeconds()` in the claim operation as nine parts in ten of the
306
+ * place's lifetime. **This reads it off the response rather than recomputing
307
+ * it**, so a deployment that shortens `BROKER_QUEUE_SECONDS` moves the poll
308
+ * with it and this file has no second opinion to drift. The scheduling nudge
309
+ * the queued caller is handed and the schedule `--wait` actually keeps are
310
+ * therefore the same number by construction.
311
+ *
312
+ * The fallback exists only for a response with no such field, and is
313
+ * deliberately the same nine-parts-in-ten rule rather than a constant.
314
+ *
315
+ * ── Polling is renewing, and there is no renew verb ─────────────────────
316
+ *
317
+ * §2.5: "any call carrying this key extends the place". `status` is that
318
+ * call — it extends the lease as the *effect* of asking, which is why row #14
319
+ * refuses to make renewal a verb of its own. So this loop calls `status` and
320
+ * nothing else: the place is held **because** it is being asked about, and a
321
+ * caller that stops asking loses it to the same lazy sweep that expires
322
+ * leases. Adding a renew here would be inventing the verb the design removed.
323
+ *
324
+ * ── Why it can stop, and why that is not a timeout ──────────────────────
325
+ *
326
+ * There is no deadline of this route's own. It ends when the service says the
327
+ * lease is `active` (granted), or when the service stops recognising the key
328
+ * — which is what a lost place looks like from here, because the sweep
329
+ * expires it and `key.valid`/`claim.live` then refuses. Both endings come
330
+ * from the service; this loop invents neither.
331
+ */
332
+ async function waitForGrant(service, granted, context) {
333
+ const first = granted.value;
334
+ // Already granted: nothing to wait for, and saying so matters more than it
335
+ // looks. A caller that passes `--wait` on a service with spare capacity
336
+ // gets its tab immediately, and a loop that polled once anyway would spend
337
+ // a lease's worth of time proving what the first response already said.
338
+ if (first['outcome'] !== 'queued') {
339
+ return granted;
340
+ }
341
+ const key = first['key'];
342
+ if (typeof key !== 'string') {
343
+ // Nothing to poll with. Handing back the queued response is the honest
344
+ // outcome — the caller still has a place, it simply cannot be waited on
345
+ // from here.
346
+ return granted;
347
+ }
348
+ const sleep = context.options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
349
+ // Said out loud, on the error stream so `--json` still produces exactly one
350
+ // document (§5.6). A command that silently blocks for nine minutes is
351
+ // indistinguishable from one that has hung.
352
+ const announce = (line) => {
353
+ context.streams.err(line);
354
+ };
355
+ let interval = checkBackFrom(first);
356
+ announce(`queued at position ${describe(first['position'])}; waiting. ` +
357
+ `Checking in every ${String(interval)}s — just under the ${describe(first['queueSeconds'])}s this place lives, because a check made exactly at the deadline races the reclamation. ` +
358
+ `Each check also holds the place; stopping loses it.`);
359
+ for (;;) {
360
+ await sleep(interval * 1000);
361
+ const polled = await cliAdapter.invoke(service, 'status', ['--key', key]);
362
+ if (polled.outcome !== 'accepted') {
363
+ // The place is gone, or the key stopped being valid. The service's own
364
+ // refusal is the answer — returned rather than reworded, so the caller
365
+ // sees the rule that ended the wait.
366
+ return polled;
367
+ }
368
+ const value = polled.value;
369
+ if (value['state'] === 'active') {
370
+ announce('granted.');
371
+ // **The claim's key with the status call's facts**, assembled field by
372
+ // field rather than by spreading the queued response.
373
+ //
374
+ // The key has to come from the claim: it is returned exactly once
375
+ // (§2.2), the grant is what carried it, and handing back the poll alone
376
+ // would strip the caller of the only thing that addresses the lease.
377
+ //
378
+ // Everything else has to come from the poll, and spreading the queued
379
+ // response would have been the bug: `position`, `queueSeconds` and the
380
+ // `checkBack` sentence telling the caller how to hold a *place* are all
381
+ // true of a state this lease has left. A granted response carrying
382
+ // queue advice reads as though the wait had not finished.
383
+ return {
384
+ outcome: 'accepted',
385
+ value: {
386
+ outcome: 'granted',
387
+ claimId: value['claimId'] ?? first['claimId'],
388
+ key,
389
+ browserId: value['browserId'] ?? first['browserId'],
390
+ ...(typeof value['tabId'] === 'string' ? { tabId: value['tabId'] } : {}),
391
+ ...(typeof value['expiresAt'] === 'string' ? { expiresAt: value['expiresAt'] } : {}),
392
+ ...(typeof value['ttlSeconds'] === 'number' ? { leaseSeconds: value['ttlSeconds'] } : {}),
393
+ },
394
+ };
395
+ }
396
+ // Still queued. The interval is re-read every poll rather than captured
397
+ // once, so a lease whose lifetime is reconfigured mid-wait is followed
398
+ // rather than outlived.
399
+ interval = checkBackFrom(value);
400
+ announce(`still queued at position ${describe(value['position'])}; next check in ${String(interval)}s.`);
401
+ }
402
+ }
403
+ /**
404
+ * A field of an arbitrary response, rendered for a person.
405
+ *
406
+ * The values come off a `Record<string, unknown>`, so the compiler is right
407
+ * that an object could arrive — and `String({})` produces
408
+ * `[object Object]`, which is worse than saying nothing. Numbers and strings
409
+ * are what these fields actually are; anything else is reported as unknown
410
+ * rather than stringified into noise.
411
+ */
412
+ function describe(value) {
413
+ return typeof value === 'number' || typeof value === 'string' ? String(value) : '?';
414
+ }
415
+ /**
416
+ * The poll interval a response asks for, in seconds.
417
+ *
418
+ * Nine parts in ten of the lifetime, which is the rule `checkBackSeconds()`
419
+ * applies in the claim operation. Taken from the response where it is
420
+ * offered; derived by the same rule where it is not; and never less than one
421
+ * second, because a zero interval would be a busy loop rather than a wait.
422
+ */
423
+ function checkBackFrom(value) {
424
+ const offered = value['checkBackSeconds'];
425
+ if (typeof offered === 'number' && offered > 0) {
426
+ return offered;
427
+ }
428
+ const lifetime = value['queueSeconds'] ?? value['ttlSeconds'];
429
+ if (typeof lifetime === 'number' && lifetime > 0) {
430
+ return Math.max(1, Math.floor(lifetime * 0.9));
431
+ }
432
+ return 1;
433
+ }
434
+ /**
435
+ * Run one operation command: resolve the input, make **one** service call,
436
+ * shape the outcome for a terminal.
437
+ *
438
+ * The three steps are the whole of an adapter's job (`CLAUDE.md`), and they
439
+ * are in this order on purpose — nothing between the resolve and the call can
440
+ * decide anything, because a decision here would be a rule that holds on this
441
+ * route and nowhere else.
442
+ */
443
+ async function runOperation(operation, rest, context) {
444
+ const service = context.options.service ?? serviceUnavailable();
445
+ const waiting = operation === 'claim' && wantsWait(rest);
446
+ // **The flag is removed before the vector reaches the adapter.**
447
+ // `parseArguments` normalises every `--name` it sees into the arguments
448
+ // record, so leaving it in would send `wait: true` to the service as an
449
+ // argument of `claim` — and §3.2 has no such field. §5.3 is explicit that
450
+ // this is a behaviour of *this route*: "the one place this route does
451
+ // something the tool surface does not". A route that smuggled an extra
452
+ // argument into the operation would be inventing a rule the tool surface
453
+ // cannot see, which is exactly what the service seam exists to prevent.
454
+ const forwarded = waiting ? rest.filter((word) => word !== '--wait') : rest;
455
+ let outcome = await cliAdapter.invoke(service, operation, [...forwarded]);
456
+ // Runs only after the claim has been made and only when it came back
457
+ // queued — so the flag changes nothing about the request, which is what
458
+ // lets §5.3's "it calls the same operation on every poll and adds none of
459
+ // its own" stay true.
460
+ if (waiting && outcome.outcome === 'accepted') {
461
+ outcome = await waitForGrant(service, outcome, context);
462
+ }
463
+ if (outcome.outcome === 'accepted') {
464
+ // §5.6: a machine-readable mode produces one document per call and puts
465
+ // all human text on the error stream, "so a caller that did not ask for
466
+ // prose gets none".
467
+ //
468
+ // ── The one command that keeps its key, and why ───────────────────────
469
+ //
470
+ // §5.6's rule is that the lease key is never printed, and it is
471
+ // load-bearing: "absent rather than masked" is the specification, because
472
+ // a masked field advertises that a secret exists and is one format change
473
+ // from being the real one. That rule is kept everywhere here except the
474
+ // grant, which is the single named hole — spelled exactly as the tool
475
+ // surface spells its own in `tool/session.ts`, so the two surfaces state
476
+ // one rule rather than two.
477
+ //
478
+ // Without the hole, `broker claim` was a command that **succeeded and
479
+ // could not be used**. It takes real capacity — §2.3 makes grants and
480
+ // tabs the same integer — mints a lease, and then withheld the only thing
481
+ // that can address it. §2.2 returns a key once and makes it unrecoverable
482
+ // by construction, so there was no second way to learn it: the lease sat
483
+ // holding a tab until its lifetime elapsed, and every one of the nine
484
+ // keyed commands on this surface was unreachable for it. A command that
485
+ // silently spends bounded capacity on an unusable lease is worse than one
486
+ // that refuses.
487
+ //
488
+ // Removing `claim` from this surface was the alternative and is the wrong
489
+ // one: `commands.ts` exists so that "every §3 operation has a command, so
490
+ // parity is real rather than claimed", and dropping one would make that
491
+ // sentence false to buy a secrecy the tool surface does not keep either.
492
+ //
493
+ // The exception is as narrow as it can be. It is keyed on the operation
494
+ // being `claim`, so it cannot widen to a command added later; every other
495
+ // command, and every refusal on every command including this one, still
496
+ // goes through `withoutSecrets`.
497
+ const value = operation === 'claim' ? outcome.value : withoutSecrets(outcome.value);
498
+ if (context.json) {
499
+ context.streams.out(JSON.stringify({ outcome: 'accepted', value }));
500
+ }
501
+ else {
502
+ context.streams.out(renderForAPerson(value));
503
+ }
504
+ return EXIT.accepted;
505
+ }
506
+ const details = outcome.details === undefined ? undefined : withoutSecrets(outcome.details);
507
+ if (context.json) {
508
+ context.streams.out(JSON.stringify({
509
+ outcome: 'refused',
510
+ code: outcome.code,
511
+ rule: outcome.rule,
512
+ ...(details === undefined ? {} : { details }),
513
+ }));
514
+ // The sentence is for a person, so in the machine-readable mode it goes
515
+ // to the error stream rather than into the document (§5.6).
516
+ context.streams.err(outcome.message);
517
+ }
518
+ else {
519
+ context.streams.err(`refused (${outcome.rule}): ${outcome.message}`);
520
+ }
521
+ return EXIT.refused;
522
+ }
523
+ /**
524
+ * The sentence a person gets when an operation was accepted but no browser
525
+ * was reached.
526
+ *
527
+ * ── Why the boolean is not enough on this surface ───────────────────────
528
+ *
529
+ * `pageDriven` is what a *caller* branches on, and the machine-readable mode
530
+ * prints it as-is because that mode is for a program. The default mode is for
531
+ * a person, and §5.6 puts the prose there for exactly that reason. A line
532
+ * reading `pageDriven: false` among four identifiers is true, but it asks the
533
+ * reader to already know what the field means — and the whole defect being
534
+ * fixed here is a truth that was only legible to someone who already knew
535
+ * where to look. So the person-facing surface says it in words.
536
+ *
537
+ * It is derived from the same field rather than from a second source, so
538
+ * there is no way for the sentence and the boolean to disagree.
539
+ *
540
+ * ── What it must not say, and why the wording is careful ────────────────
541
+ *
542
+ * It cannot name a cause, because `pageDriven: false` has several. A browser
543
+ * is reached whenever one can be, so the field means one could not be
544
+ * **started or reached for this call** — which covers a machine with none
545
+ * installed, a launch that failed, a race this caller lost, and a browser that
546
+ * died partway through. Naming any one of those would send a person to
547
+ * investigate the wrong thing on three occasions out of four.
548
+ *
549
+ * So it reports the consequence, which is the same in every case and is the
550
+ * part that matters: the decision is real and the page did not move.
551
+ */
552
+ const NO_BROWSER_NOTE = 'note: no browser was reached for this call, so the page was not driven. ' +
553
+ 'The lease, its tab and this decision are real and recorded; nothing was ' +
554
+ 'navigated, read or captured. A browser that is not installed, one that ' +
555
+ 'failed to start, and one that stopped answering all read this way.';
556
+ /** Human-readable by default (§5.6): one `key: value` line per field. */
557
+ function renderForAPerson(value) {
558
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
559
+ return String(value);
560
+ }
561
+ const entries = Object.entries(value);
562
+ if (entries.length === 0) {
563
+ return 'done';
564
+ }
565
+ const lines = entries.map(([key, entry]) => `${key}: ${typeof entry === 'object' && entry !== null ? JSON.stringify(entry) : String(entry)}`);
566
+ if (value.pageDriven === false) {
567
+ lines.push(NO_BROWSER_NOTE);
568
+ }
569
+ return lines.join('\n');
570
+ }
571
+ /**
572
+ * Run one of the three operations commands.
573
+ *
574
+ * ── Why `doctor` opens the store differently from the other two ─────────
575
+ *
576
+ * `snapshot` and `events` read the store, so they need one. **`doctor` must
577
+ * answer without one** — a store that does not exist yet is a legitimate
578
+ * state to ask about, and arguably the state where the answer is most useful,
579
+ * since it is the one somebody has just installed into.
580
+ *
581
+ * It opens the store **only if one is already there, and never steps it**, by
582
+ * the one export that permits that — `openStoreForDiagnosis`. Both halves are
583
+ * load-bearing and the first was learned the hard way: opening creates the
584
+ * directory and the file, so a doctor that opened unconditionally would
585
+ * *create* an empty store at version zero and then truthfully report it as
586
+ * being at the wrong version — a failure the command had itself caused, on an
587
+ * installation that was fine a moment earlier. Reporting a fault you just
588
+ * produced is worse than reporting nothing. There is no open-but-do-not-create
589
+ * mode to ask for, so the only way not to create one is not to open one.
590
+ *
591
+ * **And it must not settle the budget agreement either**, which is the second
592
+ * reason it cannot use the spawn path: a store whose recorded budget disagrees
593
+ * with this environment is precisely one of the states `doctor` exists to
594
+ * report, and `prepareStore` refuses to return from it. Diagnosing that
595
+ * disagreement through a path that throws on it would hand the operator a
596
+ * refusal where the report naming both numbers is the whole point of asking.
597
+ *
598
+ * The other commands take the spawn path, because `SCHEMA.md` §1.2d puts
599
+ * stepping on every spawn and §1.10 puts the budget agreement there too.
600
+ */
601
+ /**
602
+ * `broker init` — run the setup handshake explicitly and show what it did.
603
+ *
604
+ * ── What the handshake is, and why a command runs it on purpose ─────────
605
+ *
606
+ * §1.2d describes this as what every spawn does: step the schema, confirm the
607
+ * two browser rows are present, and establish a profile directory for each
608
+ * browser — **creating one that is absent and using one that is present.**
609
+ *
610
+ * That last distinction is the whole point of the command existing separately
611
+ * from the bare spawn. A signed-in profile holds a login **a person
612
+ * established by hand**, and there is no recovering it if it is thrown away:
613
+ * recreating the directory would sign them out, silently, at the moment they
614
+ * were least expecting it. So the handshake never recreates and never clears,
615
+ * and `broker init` is how somebody confirms that for themselves before
616
+ * trusting the browsers to a run — the report names each profile as `created`
617
+ * or `found`, which is exactly the question being asked.
618
+ *
619
+ * The store is opened and stepped first, because the handshake reads the
620
+ * schema version and the browser rows out of it and refuses a store that has
621
+ * not been stepped.
622
+ */
623
+ async function runInitCommand(context) {
624
+ const { streams, json } = context;
625
+ let opened;
626
+ try {
627
+ const environment = context.options.environment ?? readEnvironment({ env: context.options.env });
628
+ // The store the spawn already prepared, which is where the tab budget was
629
+ // recorded. `broker init` is the command whose whole purpose is to make an
630
+ // installation ready, so opening a second store here would be the last
631
+ // place to acquire a second startup path.
632
+ opened = await storeForRun(context.options, environment);
633
+ const store = opened.store;
634
+ const report = await runSetupHandshake(store, environment.profileRoot, {
635
+ browsers: [...environment.regularBrowsers, ...environment.privateBrowsers],
636
+ });
637
+ if (json) {
638
+ streams.out(JSON.stringify(report, null, 2));
639
+ }
640
+ else {
641
+ streams.out(`schema: version ${String(report.schemaVersion)}`);
642
+ streams.out(`browsers: ${report.browserRows.join(', ')}`);
643
+ for (const line of describeSetupReport(report)) {
644
+ streams.out(line);
645
+ }
646
+ }
647
+ return EXIT.accepted;
648
+ }
649
+ catch (error) {
650
+ if (error instanceof BrokerError) {
651
+ streams.err(`refused (${error.rule}): ${error.message}`);
652
+ return EXIT.refused;
653
+ }
654
+ throw error;
655
+ }
656
+ finally {
657
+ // Closed only if this run opened it. A store the runtime owns outlives
658
+ // this command, and closing it would pull the file out from under the rest
659
+ // of the spawn.
660
+ if (opened?.owned === true) {
661
+ opened.store.close();
662
+ }
663
+ }
664
+ }
665
+ /**
666
+ * `broker login` — hand the signed-in browser to a person (§5.5.1).
667
+ *
668
+ * ── Why this needs the typed service and says so when it lacks one ──────
669
+ *
670
+ * Every other command here either takes the flat ten-operation seam or takes
671
+ * no service at all. This one takes neither: signing in is a service
672
+ * operation (the live-lease refusal is a fact about leases, derived inside
673
+ * the arbitration transaction) but it is **not** one of the ten, because a
674
+ * person at a keyboard is not a caller and takes no tab budget.
675
+ *
676
+ * So when the typed service is absent it refuses, in the same shape
677
+ * `serviceUnavailable` refuses, rather than opening a browser anyway. A
678
+ * command that handed somebody a window without having claimed the browser
679
+ * would be handing them one a caller might be using — which is the single
680
+ * thing §5.5.1's first step exists to prevent.
681
+ */
682
+ async function runLogin(rest, context) {
683
+ const { streams, json, options } = context;
684
+ const { broker, store, environment } = options;
685
+ if (broker === undefined || store === undefined || environment === undefined) {
686
+ streams.err('refused (service.not_built): signing in claims the browser through the service, and no service was supplied to this run. Without it the command could hand somebody a window that a caller is already using.');
687
+ return EXIT.unexpected;
688
+ }
689
+ // The browser is a positional word rather than a flag, per §5.5's own
690
+ // spelling of the command: `broker login <browser>`.
691
+ const named = rest.find((word) => !word.startsWith('-'));
692
+ try {
693
+ return await runLoginCommand({
694
+ broker,
695
+ store,
696
+ environment,
697
+ streams,
698
+ json,
699
+ ...(named === undefined ? {} : { browser: named }),
700
+ });
701
+ }
702
+ catch (error) {
703
+ if (error instanceof BrokerError) {
704
+ // The launch refusals get the extra sentence about what to do, which a
705
+ // message about endpoints cannot supply on its own.
706
+ streams.err(`refused (${error.rule}): ${explainLoginFailure(error)}`);
707
+ return EXIT.refused;
708
+ }
709
+ throw error;
710
+ }
711
+ }
712
+ async function runOperationsCommand(command, rest, context) {
713
+ const { streams, json } = context;
714
+ let opened;
715
+ try {
716
+ const environment = context.options.environment ?? readEnvironment({ env: context.options.env });
717
+ if (command === 'doctor') {
718
+ let opened;
719
+ if (fs.existsSync(environment.databasePath)) {
720
+ try {
721
+ opened = openStoreForDiagnosis(environment);
722
+ }
723
+ catch (error) {
724
+ if (!(error instanceof BrokerError)) {
725
+ throw error;
726
+ }
727
+ // The store is there and could not be opened. The checks read the
728
+ // environment and the filesystem directly, so the report is still
729
+ // worth producing — and the location check names the same refusal.
730
+ }
731
+ }
732
+ try {
733
+ return runDoctorCommand({
734
+ db: opened?.db,
735
+ environment,
736
+ streams,
737
+ json,
738
+ automationProbe: context.options.automationProbe,
739
+ });
740
+ }
741
+ finally {
742
+ opened?.close();
743
+ }
744
+ }
745
+ // The store the spawn prepared, rather than a second one of this
746
+ // command's own — see `storeForRun`.
747
+ opened = await storeForRun(context.options, environment);
748
+ const store = opened.store;
749
+ if (command === 'snapshot') {
750
+ return await runSnapshotCommand(rest, {
751
+ db: store.db,
752
+ streams,
753
+ json,
754
+ version: await readVersion(),
755
+ });
756
+ }
757
+ if (command === 'diffs') {
758
+ // Reading the comparison history back. It takes the same stepped store
759
+ // the other reads do; what it does not take is a lease, because it
760
+ // decides nothing.
761
+ return runDiffs(rest, { db: store.db, streams });
762
+ }
763
+ if (command === 'captures') {
764
+ // The capture telemetry rollups (#37). Same stepped store, same absence
765
+ // of a lease, and for the same reason: adding up what was recorded
766
+ // decides nothing.
767
+ return runCaptures(rest, { db: store.db, streams });
768
+ }
769
+ if (command === 'image') {
770
+ // **Serving the bytes of one recorded image** (§1.9). Unlike `diffs` it
771
+ // does take a lease key, because an artifact belongs to the lease that
772
+ // took it — but it still decides nothing and drives no browser, which is
773
+ // why it is a read beside the others rather than an operation.
774
+ //
775
+ // The artifact store is built here, from the same environment the
776
+ // service builds its own from, because turning a stored path into a
777
+ // location is the one thing this command cannot do for itself: the
778
+ // resolver that refuses a path escaping the root lives on that store,
779
+ // and a second one built anywhere else is the copy that would miss a
780
+ // case.
781
+ return await runImage(rest, {
782
+ db: store.db,
783
+ artifacts: new ArtifactStore(environment.artifactsRoot),
784
+ streams,
785
+ });
786
+ }
787
+ if (command === 'reconcile') {
788
+ // **The one command on this route that asks a browser anything.** It is
789
+ // here rather than beside `login` because it takes the same stepped
790
+ // store every other operations command takes, and its refusal path is
791
+ // the same one — see `reconcile-command.ts` for why it is an
792
+ // administrative command at all rather than an agent operation.
793
+ //
794
+ // The session provider is the runtime's own, passed through rather than
795
+ // rebuilt: `browser-session.ts` decides adoption once per browser per
796
+ // process, so a second provider here would be a second launch path.
797
+ return await runReconcileCommand(rest, {
798
+ db: store.db,
799
+ ...(context.options.session === undefined ? {} : { session: context.options.session }),
800
+ streams,
801
+ json,
802
+ browsers: [...environment.regularBrowsers, ...environment.privateBrowsers],
803
+ });
804
+ }
805
+ return runEventsCommand(rest, { db: store.db, streams, json });
806
+ }
807
+ catch (error) {
808
+ if (error instanceof BrokerError) {
809
+ streams.err(`refused (${error.rule}): ${error.message}`);
810
+ return EXIT.refused;
811
+ }
812
+ throw error;
813
+ }
814
+ finally {
815
+ // Closed only if this run opened it — see `storeForRun`.
816
+ if (opened?.owned === true) {
817
+ opened.store.close();
818
+ }
819
+ }
820
+ }
821
+ /**
822
+ * Whether this invocation is the reading half.
823
+ *
824
+ * **Reading is the default and writing is the flagged case**, which is the
825
+ * way round §5.3 describes: "with no arguments it reads the rows back". A
826
+ * submission is recognised by carrying `--rating`, which is required on every
827
+ * write — so a caller that meant to write and mistyped the flag gets a
828
+ * listing rather than a row it did not intend, and a caller that meant to
829
+ * read never accidentally writes.
830
+ */
831
+ export function isReadingFeedback(rest) {
832
+ return !rest.some((word) => word === '--rating' || word.startsWith('--rating='));
833
+ }
834
+ /**
835
+ * `broker feedback` — read the rows back, most recent first (#68).
836
+ *
837
+ * It opens the store and reads one table. **That is not a route reaching past
838
+ * the service layer**, and the distinction is worth stating rather than
839
+ * assuming: the reader rule (§2.4, §5.2) exists because a command that
840
+ * printed `state` from a table would report leases that do not exist, since
841
+ * liveness is derived rather than stored. **Feedback has no derived state.**
842
+ * A row is written once and never changes, no sweep touches it, and nothing
843
+ * expires — so there is nothing a service call would derive that this read
844
+ * would miss. Every other command goes through the service because for every
845
+ * other command that is false.
846
+ */
847
+ async function readFeedbackCommand(rest, context) {
848
+ const parsed = parseArguments(rest);
849
+ const asInteger = (value) => typeof value === 'string' && /^-?\d+$/u.test(value) ? Number(value) : value;
850
+ const requested = {
851
+ ...(parsed['rating'] === undefined ? {} : { rating: asInteger(parsed['rating']) }),
852
+ ...(parsed['category'] === undefined ? {} : { category: parsed['category'] }),
853
+ ...(parsed['limit'] === undefined ? {} : { limit: asInteger(parsed['limit']) }),
854
+ };
855
+ const refusal = refuseFilters(requested);
856
+ if (refusal !== undefined) {
857
+ context.streams.err(`refused (${refusal.code}): ${refusal.message}`);
858
+ return EXIT.malformed;
859
+ }
860
+ const filters = {
861
+ ...(typeof requested.rating === 'number' ? { rating: requested.rating } : {}),
862
+ ...(typeof requested.category === 'string' && isFeedbackCategory(requested.category)
863
+ ? { category: requested.category }
864
+ : {}),
865
+ limit: typeof requested.limit === 'number' ? requested.limit : DEFAULT_LIMIT,
866
+ };
867
+ let opened;
868
+ try {
869
+ const environment = context.options.environment ?? readEnvironment({ env: context.options.env });
870
+ opened = await storeForRun(context.options, environment);
871
+ const store = opened.store;
872
+ const rows = readFeedback(store.db, filters);
873
+ const narrowed = filters.rating !== undefined || filters.category !== undefined;
874
+ if (context.json) {
875
+ context.streams.out(JSON.stringify({ outcome: 'accepted', value: { feedback: rows } }));
876
+ }
877
+ else {
878
+ context.streams.out(renderFeedback(rows, narrowed));
879
+ }
880
+ return EXIT.accepted;
881
+ }
882
+ catch (error) {
883
+ if (error instanceof BrokerError) {
884
+ context.streams.err(`refused (${error.rule}): ${error.message}`);
885
+ return EXIT.notConfigured;
886
+ }
887
+ throw error;
888
+ }
889
+ finally {
890
+ // Closed only if this run opened it — see `storeForRun`.
891
+ if (opened?.owned === true) {
892
+ opened.store.close();
893
+ }
894
+ }
895
+ }