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.
- package/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- 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
|
+
}
|