browser-broker 0.1.0 → 0.3.1
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 +0 -16
- package/README.md +55 -23
- package/dist/package.json +3 -3
- package/dist/src/adapter/conformance/cases.js +138 -1
- package/dist/src/adapter/conformance/run.js +135 -0
- package/dist/src/adapter/conformance/service-subject.js +5 -1
- package/dist/src/browser/fake.js +69 -3
- package/dist/src/browser/real.js +23 -33
- package/dist/src/capture/tiers.js +53 -0
- package/dist/src/cli/adapter.js +37 -3
- package/dist/src/cli/commands.js +42 -1
- package/dist/src/cli/index.js +12 -1
- package/dist/src/cli/operations-commands.js +82 -4
- package/dist/src/cli/reconcile-command.js +90 -7
- package/dist/src/config/environment.js +0 -44
- package/dist/src/doctor/checks.js +64 -0
- package/dist/src/doctor/report.js +10 -1
- package/dist/src/service/arbitration.js +61 -0
- package/dist/src/service/bridge.js +148 -2
- package/dist/src/service/broker.js +52 -1
- package/dist/src/service/browser-session.js +147 -8
- package/dist/src/service/comparison.js +23 -5
- package/dist/src/service/operations/claim.js +63 -0
- package/dist/src/service/operations/pages.js +150 -5
- package/dist/src/service/operations/status.js +8 -0
- package/dist/src/service/pages.js +81 -0
- package/dist/src/service/reconcile.js +75 -3
- package/dist/src/service/runtime.js +26 -1
- package/dist/src/service/tabs.js +70 -0
- package/dist/src/tool/session.js +17 -4
- package/dist/src/tool/tools.js +162 -6
- package/package.json +3 -3
- package/RELEASES.md +0 -97
package/dist/src/browser/real.js
CHANGED
|
@@ -493,9 +493,17 @@ class RealBrowserSession {
|
|
|
493
493
|
* The address after redirects rather than the one asked for, because those
|
|
494
494
|
* differ constantly and the caller needs the one it got.
|
|
495
495
|
*/
|
|
496
|
-
async navigate(tab, url) {
|
|
496
|
+
async navigate(tab, url, waitMs) {
|
|
497
497
|
const page = await this.#page(tab);
|
|
498
|
-
|
|
498
|
+
// Passed as the navigation's own timeout rather than as a pause taken
|
|
499
|
+
// afterwards, so a page that arrives early returns early and the argument
|
|
500
|
+
// only ever costs what the page costs.
|
|
501
|
+
//
|
|
502
|
+
// Absent means the library's configured default applies. Spread rather
|
|
503
|
+
// than passed as `{ timeout: undefined }`, because an explicit undefined
|
|
504
|
+
// and an omitted key are the same to this library only by convention, and
|
|
505
|
+
// relying on that convention would make the default this service's to own.
|
|
506
|
+
const response = await page.goto(url, ...(waitMs === undefined ? [] : [{ timeout: waitMs }]));
|
|
499
507
|
return {
|
|
500
508
|
url: page.url(),
|
|
501
509
|
title: await page.title(),
|
|
@@ -1246,6 +1254,18 @@ class RealBrowserSession {
|
|
|
1246
1254
|
}
|
|
1247
1255
|
}
|
|
1248
1256
|
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Whether this process's connection is still usable.
|
|
1259
|
+
*
|
|
1260
|
+
* Delegates to the flag the driving package already maintains — its
|
|
1261
|
+
* `isConnected()` is declared `(): boolean` and its implementation is a
|
|
1262
|
+
* bare field read (`return this._isConnected`), so this performs no
|
|
1263
|
+
* input/output and cannot throw. That is what {@link BrowserSession.isConnected}
|
|
1264
|
+
* requires of it, because it is consulted before every page verb.
|
|
1265
|
+
*/
|
|
1266
|
+
isConnected() {
|
|
1267
|
+
return this.#connection.isConnected();
|
|
1268
|
+
}
|
|
1249
1269
|
/**
|
|
1250
1270
|
* End this process's connection. **The browser is unaffected.**
|
|
1251
1271
|
*
|
|
@@ -1295,34 +1315,6 @@ async function connect(options) {
|
|
|
1295
1315
|
await session.ensureKeeperTab();
|
|
1296
1316
|
return session;
|
|
1297
1317
|
}
|
|
1298
|
-
/**
|
|
1299
|
-
* Where a configured engine's binary lives, if this process was told.
|
|
1300
|
-
*
|
|
1301
|
-
* ── What is built here, and what is deliberately not ────────────────
|
|
1302
|
-
*
|
|
1303
|
-
* All three engines are Chromium over the same remote-debugging protocol, so
|
|
1304
|
-
* choosing between them is choosing a binary — which is what makes the hook
|
|
1305
|
-
* cheap, and it is the hook `DECISIONS.md` §13i asks for. **The expensive
|
|
1306
|
-
* half is explicitly out of scope there**: per-engine executable discovery,
|
|
1307
|
-
* per-engine discovery-record locations, per-engine health checks.
|
|
1308
|
-
*
|
|
1309
|
-
* **So resolution is a lookup of what a caller supplied, never a search.**
|
|
1310
|
-
* The engine selects among paths this process was given; it does not go
|
|
1311
|
-
* looking for an installation, and it never carries a path of its own.
|
|
1312
|
-
* Writing a per-engine install location into this file would name one machine
|
|
1313
|
-
* — §1.0's rule forbids that outright, and `check-external-refs` fails on the
|
|
1314
|
-
* shape.
|
|
1315
|
-
*
|
|
1316
|
-
* **What happens when nothing supplied a path for the configured engine** is
|
|
1317
|
-
* the case worth being exact about: the launch falls back to the automation
|
|
1318
|
-
* library's own Chromium, which is what an unconfigured build launches. That
|
|
1319
|
-
* is a real limit and it is named in §13i rather than hidden here — the
|
|
1320
|
-
* variables are validated and carried, and the row that resolves an engine to
|
|
1321
|
-
* an installed binary is separable work.
|
|
1322
|
-
*/
|
|
1323
|
-
export function executablePathForEngine(engine, supplied) {
|
|
1324
|
-
return supplied?.[engine];
|
|
1325
|
-
}
|
|
1326
1318
|
/**
|
|
1327
1319
|
* The real driver.
|
|
1328
1320
|
*
|
|
@@ -1342,9 +1334,7 @@ export class RealBrowserDriver {
|
|
|
1342
1334
|
if (this.#options.executablePath !== undefined) {
|
|
1343
1335
|
return this.#options.executablePath;
|
|
1344
1336
|
}
|
|
1345
|
-
|
|
1346
|
-
const resolved = engine === undefined ? undefined : executablePathForEngine(engine, this.#options.enginePaths);
|
|
1347
|
-
return resolved ?? chromium.executablePath();
|
|
1337
|
+
return chromium.executablePath();
|
|
1348
1338
|
}
|
|
1349
1339
|
/**
|
|
1350
1340
|
* Attach to a browser that is already running, **having checked the record
|
|
@@ -164,3 +164,56 @@ const TOKENS_PER_PIXEL_DIVISOR = 750;
|
|
|
164
164
|
export function estimateTokens(width, height) {
|
|
165
165
|
return Math.ceil((width * height) / TOKENS_PER_PIXEL_DIVISOR);
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Describe the shrink, or return nothing when there was not one.
|
|
169
|
+
*
|
|
170
|
+
* **Absent rather than `scale: 1` when nothing was reduced**, and the
|
|
171
|
+
* difference is the one this whole helper is for: a field that is always
|
|
172
|
+
* present is a field a caller stops reading. Its presence is the signal.
|
|
173
|
+
*/
|
|
174
|
+
export function describeReduction(source, written, tier) {
|
|
175
|
+
if (written.width >= source.width && written.height >= source.height) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
// Taken on the long edge, which is the edge the cap is applied to — so this
|
|
179
|
+
// is the factor that was actually used rather than one recovered from
|
|
180
|
+
// whichever dimension happens to round more kindly.
|
|
181
|
+
const scale = Math.max(source.width, source.height) === 0
|
|
182
|
+
? 1
|
|
183
|
+
: Math.max(written.width, written.height) / Math.max(source.width, source.height);
|
|
184
|
+
const percent = Math.round(scale * 100);
|
|
185
|
+
// Named only when there is one, so the sentence never tells a caller already
|
|
186
|
+
// on the top rung to escalate to it.
|
|
187
|
+
const higher = HIGHER_TIERS[tier];
|
|
188
|
+
const remedy = higher === undefined
|
|
189
|
+
? `This is the highest rung, so a larger image of the whole page is not available; capture a selector, or read the page as text instead.`
|
|
190
|
+
: `For more detail pass tier="${higher}"${higher === TIER_REQUIRING_REASON ? ' together with reason' : ''}.`;
|
|
191
|
+
// The width is called out separately because it is the number that decides
|
|
192
|
+
// legibility on a tall page, and it is the one a caller reading "scale" on
|
|
193
|
+
// its own would not think to compare against the viewport.
|
|
194
|
+
return {
|
|
195
|
+
sourceWidth: source.width,
|
|
196
|
+
sourceHeight: source.height,
|
|
197
|
+
scale: Math.round(scale * 1000) / 1000,
|
|
198
|
+
note: `This image was REDUCED to about ${String(percent)}% of the page: ` +
|
|
199
|
+
`${String(source.width)}x${String(source.height)} was written as ` +
|
|
200
|
+
`${String(written.width)}x${String(written.height)}. ` +
|
|
201
|
+
`A capture is shrunk so its LONGEST edge fits ${String(TIER_LONGEST_EDGE[tier])}px, so on a page ` +
|
|
202
|
+
`taller than it is wide the height sets the factor and the width shrinks with it — ` +
|
|
203
|
+
`${String(written.width)}px of width here. Text may not be legible. ` +
|
|
204
|
+
remedy,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The next rung up from each, and `undefined` at the top.
|
|
209
|
+
*
|
|
210
|
+
* A table rather than an ordering computed from {@link TIER_LONGEST_EDGE},
|
|
211
|
+
* because "which rung does a caller ask for next" is a fact about the
|
|
212
|
+
* surface's vocabulary — `default` is not requestable by name — and not about
|
|
213
|
+
* which number is larger.
|
|
214
|
+
*/
|
|
215
|
+
const HIGHER_TIERS = {
|
|
216
|
+
default: 'detail',
|
|
217
|
+
detail: 'max',
|
|
218
|
+
max: undefined,
|
|
219
|
+
};
|
package/dist/src/cli/adapter.js
CHANGED
|
@@ -123,19 +123,53 @@ export function parseArguments(rest) {
|
|
|
123
123
|
const body = word.slice(2);
|
|
124
124
|
const equals = body.indexOf('=');
|
|
125
125
|
if (equals !== -1) {
|
|
126
|
-
parsed
|
|
126
|
+
record(parsed, normaliseKey(body.slice(0, equals)), body.slice(equals + 1));
|
|
127
127
|
continue;
|
|
128
128
|
}
|
|
129
129
|
const next = rest[index + 1];
|
|
130
130
|
if (next === undefined || looksLikeFlag(next)) {
|
|
131
|
-
parsed
|
|
131
|
+
record(parsed, normaliseKey(body), true);
|
|
132
132
|
continue;
|
|
133
133
|
}
|
|
134
|
-
parsed
|
|
134
|
+
record(parsed, normaliseKey(body), next);
|
|
135
135
|
index += 1;
|
|
136
136
|
}
|
|
137
137
|
return parsed;
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Write an option, accumulating the ones that may legitimately repeat.
|
|
141
|
+
*
|
|
142
|
+
* Assignment was unconditional, so a repeated option kept only its **last**
|
|
143
|
+
* occurrence. For every option that names one thing that is the right
|
|
144
|
+
* behaviour and the last word plainly wins. For an option that names *one of
|
|
145
|
+
* many* it is silent data loss: `--field a=1 --field b=2` filled one field
|
|
146
|
+
* and reported success, which is worse than the refusal it replaced, because
|
|
147
|
+
* a refusal is visible.
|
|
148
|
+
*
|
|
149
|
+
* Only the options in {@link REPEATABLE} accumulate. Making every option an
|
|
150
|
+
* array on its second appearance would change the type a reader gets for
|
|
151
|
+
* `--value` typed twice by accident, and turn a typo into a shape no
|
|
152
|
+
* operation expects.
|
|
153
|
+
*/
|
|
154
|
+
function record(parsed, key, value) {
|
|
155
|
+
if (!REPEATABLE.has(key)) {
|
|
156
|
+
parsed[key] = value;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const existing = parsed[key];
|
|
160
|
+
if (existing === undefined) {
|
|
161
|
+
parsed[key] = [value];
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
existing.push(value);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The options that name one of many rather than one thing.
|
|
168
|
+
*
|
|
169
|
+
* Keyed by their normalised names. Deliberately a short list: an option
|
|
170
|
+
* belongs here only when the operation behind it takes a collection.
|
|
171
|
+
*/
|
|
172
|
+
const REPEATABLE = new Set(['field']);
|
|
139
173
|
function normaliseKey(key) {
|
|
140
174
|
return key.replaceAll('-', '_');
|
|
141
175
|
}
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -35,6 +35,26 @@ export const OPERATION_COMMANDS = [
|
|
|
35
35
|
operation: 'claim',
|
|
36
36
|
summary: 'Ask for a lease. Get one tab, or a place in the queue.',
|
|
37
37
|
options: [
|
|
38
|
+
// **The two a claim is refused for omitting, listed first because they
|
|
39
|
+
// are the two it is refused for omitting.** Both were absent here while
|
|
40
|
+
// `claim.session_bounded` and `claim.purpose_bounded` asked for them by
|
|
41
|
+
// name, which is the worst arrangement available: the refusals are
|
|
42
|
+
// models of the form — each names the missing thing and says why it
|
|
43
|
+
// exists — and a caller who did as they asked, under a plausible
|
|
44
|
+
// spelling, was refused a second time in identical words. A help text
|
|
45
|
+
// that lists three optional flags and neither required one teaches a
|
|
46
|
+
// reader how to call this command unsuccessfully.
|
|
47
|
+
{
|
|
48
|
+
flag: '--session-id <id>',
|
|
49
|
+
summary: 'Required. Who is asking. It attributes this lease and every refusal on it in the ' +
|
|
50
|
+
'ledger, and it is how the service can tell you when you are queued behind capacity ' +
|
|
51
|
+
'you already hold.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
flag: '--purpose <text>',
|
|
55
|
+
summary: 'Required, 3 to 200 characters. What the lease is for, in one line, read by whoever ' +
|
|
56
|
+
'finds the tab still open.',
|
|
57
|
+
},
|
|
38
58
|
{
|
|
39
59
|
flag: '--wait',
|
|
40
60
|
summary: 'Poll a queued place until it is granted, lost or refused, rather than returning the place.',
|
|
@@ -56,7 +76,7 @@ export const OPERATION_COMMANDS = [
|
|
|
56
76
|
{
|
|
57
77
|
words: ['act'],
|
|
58
78
|
operation: 'act',
|
|
59
|
-
summary: 'Click, type, fill, press, select, hover, check, scroll, resize, emulate, dialog.',
|
|
79
|
+
summary: 'Click, type, fill, press, select, hover, check, scroll, resize, emulate, dialog, fill_form.',
|
|
60
80
|
// **Undocumented options are unusable options**, and this command had
|
|
61
81
|
// none listed at all — so `broker act --help` printed `--json` and
|
|
62
82
|
// `--help` and nothing else, for the verb with the most arguments on the
|
|
@@ -93,6 +113,16 @@ export const OPERATION_COMMANDS = [
|
|
|
93
113
|
flag: '--forced-colours <active|none>',
|
|
94
114
|
summary: 'For emulate.',
|
|
95
115
|
},
|
|
116
|
+
{
|
|
117
|
+
flag: '--accept | --dismiss',
|
|
118
|
+
summary: 'For dialog, which answer to give. `--prompt-text <text>` is what to type before ' +
|
|
119
|
+
'accepting, and cannot accompany a dismissal.',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
flag: '--field <ref>=<value>',
|
|
123
|
+
summary: 'For fill_form, one field to fill. Repeat it once per field; only the first = ' +
|
|
124
|
+
'separates, so a value may contain one.',
|
|
125
|
+
},
|
|
96
126
|
],
|
|
97
127
|
},
|
|
98
128
|
{
|
|
@@ -142,6 +172,11 @@ export const STANDALONE_COMMANDS = [
|
|
|
142
172
|
words: ['snapshot'],
|
|
143
173
|
summary: 'Write the operations document to a path and exit.',
|
|
144
174
|
owedBy: 'the row that builds the operations document',
|
|
175
|
+
options: [
|
|
176
|
+
{ flag: '--out <path>', summary: 'The file to write the document to. Required.' },
|
|
177
|
+
{ flag: '--events <n>', summary: 'How many ledger entries to include.' },
|
|
178
|
+
{ flag: '--feedback <n>', summary: 'How many feedback rows to include.' },
|
|
179
|
+
],
|
|
145
180
|
},
|
|
146
181
|
{
|
|
147
182
|
words: ['doctor'],
|
|
@@ -222,6 +257,12 @@ export const STANDALONE_COMMANDS = [
|
|
|
222
257
|
flag: '--browser <regular|private>',
|
|
223
258
|
summary: 'Which browser to reconcile. May also be given as the first word.',
|
|
224
259
|
},
|
|
260
|
+
{
|
|
261
|
+
flag: '--session-id <id>',
|
|
262
|
+
summary: 'Optional. Who is asking, so a tab still being opened can be named as your own lease ' +
|
|
263
|
+
'rather than as somebody’s. Omitting it is fine and costs only that distinction: ' +
|
|
264
|
+
'the report degrades to the general caution, which is the honest answer when nobody said.',
|
|
265
|
+
},
|
|
225
266
|
],
|
|
226
267
|
},
|
|
227
268
|
{
|
package/dist/src/cli/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { ArtifactStore } from "../artifacts/store.js";
|
|
|
11
11
|
import { runDiffs } from "./diffs.js";
|
|
12
12
|
import { runCaptures } from "./telemetry.js";
|
|
13
13
|
import { runImage } from "./image.js";
|
|
14
|
-
import { runDoctorCommand, runEventsCommand, runSnapshotCommand } from "./operations-commands.js";
|
|
14
|
+
import { runDoctorCommand, runEventsCommand, runSnapshotCommand, UnknownFlagError, } from "./operations-commands.js";
|
|
15
15
|
import { explainLoginFailure, runLoginCommand } from "./login-command.js";
|
|
16
16
|
import { runReconcileCommand } from "./reconcile-command.js";
|
|
17
17
|
const defaultStreams = {
|
|
@@ -805,6 +805,17 @@ async function runOperationsCommand(command, rest, context) {
|
|
|
805
805
|
return runEventsCommand(rest, { db: store.db, streams, json });
|
|
806
806
|
}
|
|
807
807
|
catch (error) {
|
|
808
|
+
// **A mistyped flag is malformed input, not a refused decision.** It is
|
|
809
|
+
// answered here rather than inside each command because all three parse
|
|
810
|
+
// their flags the same way and would otherwise each need the same catch —
|
|
811
|
+
// and because the exit code is the thing a caller branches on: `malformed`
|
|
812
|
+
// says the vector was wrong, which is what a typo is, while `refused`
|
|
813
|
+
// would say the service considered the request and declined it. Nothing
|
|
814
|
+
// was considered; the command never ran.
|
|
815
|
+
if (error instanceof UnknownFlagError) {
|
|
816
|
+
streams.err(error.message);
|
|
817
|
+
return EXIT.malformed;
|
|
818
|
+
}
|
|
808
819
|
if (error instanceof BrokerError) {
|
|
809
820
|
streams.err(`refused (${error.rule}): ${error.message}`);
|
|
810
821
|
return EXIT.refused;
|
|
@@ -14,14 +14,80 @@ import { writeSnapshot } from "../report/snapshot.js";
|
|
|
14
14
|
* constant is how they stay in step with the route that does.
|
|
15
15
|
*/
|
|
16
16
|
export const COMMAND_EXIT = EXIT;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Flags every command on this route takes, rendered by the help writer rather
|
|
19
|
+
* than declared per command — so they are always accepted and never appear in
|
|
20
|
+
* a command's own list of known flags.
|
|
21
|
+
*/
|
|
22
|
+
const UNIVERSAL_FLAGS = ['json', 'help'];
|
|
23
|
+
/**
|
|
24
|
+
* An unknown flag, named, with the flags that command does accept.
|
|
25
|
+
*
|
|
26
|
+
* Thrown rather than returned because {@link parseFlags} answers with a record
|
|
27
|
+
* and has no room in it for a refusal, and every caller of it is a command
|
|
28
|
+
* that must stop rather than proceed on a misread vector.
|
|
29
|
+
*/
|
|
30
|
+
export class UnknownFlagError extends Error {
|
|
31
|
+
// Declared and assigned rather than written as constructor parameter
|
|
32
|
+
// properties: this build strips types rather than compiling them, and a
|
|
33
|
+
// parameter property is syntax that needs a compiler to exist at runtime.
|
|
34
|
+
flag;
|
|
35
|
+
known;
|
|
36
|
+
constructor(flag, known) {
|
|
37
|
+
// Named the way `claim.browser_known` names the browsers: the thing that
|
|
38
|
+
// was wrong, then the set it should have come from. A caller that mistypes
|
|
39
|
+
// a flag is one edit from being right, and the edit is only obvious if the
|
|
40
|
+
// alternatives are on the screen.
|
|
41
|
+
super(`There is no option named --${flag}. This command accepts ${known
|
|
42
|
+
.map((name) => `--${name}`)
|
|
43
|
+
.join(', ')}.`);
|
|
44
|
+
this.name = 'UnknownFlagError';
|
|
45
|
+
this.flag = flag;
|
|
46
|
+
this.known = known;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `--name value` and `--name=value`, plus bare `--flag`.
|
|
51
|
+
*
|
|
52
|
+
* ── Why an unknown flag is refused rather than dropped ──────────────────
|
|
53
|
+
*
|
|
54
|
+
* Because dropping it produced the one failure a good refusal cannot rescue.
|
|
55
|
+
* A caller typing `--session` instead of `--session-id` had the flag discarded
|
|
56
|
+
* without comment, so the command ran as though nothing had been passed and
|
|
57
|
+
* truthfully reported that nothing was there — and any argument riding behind
|
|
58
|
+
* the bad flag was consumed as its value and lost with it. The message that
|
|
59
|
+
* came back was correct, and it pointed at the value rather than at the flag
|
|
60
|
+
* name, which is the one place the error actually was. A refusal that repeats
|
|
61
|
+
* identically after a caller has complied with it moves their suspicion onto
|
|
62
|
+
* the wrong thing.
|
|
63
|
+
*
|
|
64
|
+
* This is what makes an undocumented flag unrecoverable rather than merely
|
|
65
|
+
* inconvenient: with no entry in `--help` and no signal from the parser, a
|
|
66
|
+
* caller has nothing to correct against and no reason to suspect a typo.
|
|
67
|
+
*
|
|
68
|
+
* **The known set is passed in by the command**, because only the command
|
|
69
|
+
* knows it. A parser that guessed would either refuse a flag that works or
|
|
70
|
+
* accept one that does not, and both reintroduce the silence.
|
|
71
|
+
*
|
|
72
|
+
* Omitting `known` accepts everything, which is what an in-process caller
|
|
73
|
+
* testing the parsing shape itself wants; every shipped command passes its
|
|
74
|
+
* list.
|
|
75
|
+
*/
|
|
76
|
+
export function parseFlags(rest, known) {
|
|
19
77
|
const parsed = {};
|
|
78
|
+
const accepted = known === undefined ? undefined : new Set([...known, ...UNIVERSAL_FLAGS]);
|
|
20
79
|
for (let index = 0; index < rest.length; index += 1) {
|
|
21
80
|
const word = rest[index];
|
|
22
81
|
if (word === undefined || !word.startsWith('--')) {
|
|
23
82
|
continue;
|
|
24
83
|
}
|
|
84
|
+
const name = word.slice(2).split('=')[0] ?? '';
|
|
85
|
+
if (accepted !== undefined && !accepted.has(name)) {
|
|
86
|
+
// The command's own flags, without the universal two: those are
|
|
87
|
+
// rendered by the help writer on every command, so listing them here
|
|
88
|
+
// would pad the sentence with the two the caller did not get wrong.
|
|
89
|
+
throw new UnknownFlagError(name, known ?? []);
|
|
90
|
+
}
|
|
25
91
|
const body = word.slice(2);
|
|
26
92
|
const equals = body.indexOf('=');
|
|
27
93
|
if (equals !== -1) {
|
|
@@ -62,7 +128,10 @@ function asNumber(value) {
|
|
|
62
128
|
* leaving a blank.
|
|
63
129
|
*/
|
|
64
130
|
export async function runSnapshotCommand(rest, options) {
|
|
65
|
-
|
|
131
|
+
// `output` and `path` are long-standing spellings of `--out` that this
|
|
132
|
+
// command has always read; they are accepted here for that reason, and left
|
|
133
|
+
// out of the help table because one name is what a table should teach.
|
|
134
|
+
const flags = parseFlags(rest, ['out', 'output', 'path', 'events', 'feedback']);
|
|
66
135
|
const outputPath = asString(flags.out) ?? asString(flags.output) ?? asString(flags.path);
|
|
67
136
|
if (outputPath === undefined) {
|
|
68
137
|
options.streams.err('broker snapshot needs somewhere to write: --out <path>. It writes one self-contained HTML file and exits.');
|
|
@@ -151,7 +220,16 @@ export function runDoctorCommand(options) {
|
|
|
151
220
|
* caller types reaches the SQL text.
|
|
152
221
|
*/
|
|
153
222
|
export function runEventsCommand(rest, options) {
|
|
154
|
-
const flags = parseFlags(rest
|
|
223
|
+
const flags = parseFlags(rest, [
|
|
224
|
+
'kind',
|
|
225
|
+
'outcome',
|
|
226
|
+
'guard',
|
|
227
|
+
'session-id',
|
|
228
|
+
'claim-id',
|
|
229
|
+
'since',
|
|
230
|
+
'before',
|
|
231
|
+
'limit',
|
|
232
|
+
]);
|
|
155
233
|
const query = {
|
|
156
234
|
kinds: asString(flags.kind)?.split(',') ?? undefined,
|
|
157
235
|
outcome: asString(flags.outcome),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_BROWSER_IDS } from "../browser/driver.js";
|
|
2
2
|
import { append } from "../service/events.js";
|
|
3
|
-
import { applyReconciliation, decideReconciliation, readRecordedTabs, } from "../service/reconcile.js";
|
|
3
|
+
import { applyReconciliation, decideReconciliation, readRecordedTabs, settleStrandedTabs, } from "../service/reconcile.js";
|
|
4
4
|
import { COMMAND_EXIT, parseFlags } from "./operations-commands.js";
|
|
5
5
|
/** Timestamps are spelled one way in this store. */
|
|
6
6
|
function now() {
|
|
@@ -18,9 +18,13 @@ function isBrowserId(value, browsers) {
|
|
|
18
18
|
* person who wants both runs it twice and reads two reports.
|
|
19
19
|
*/
|
|
20
20
|
export async function runReconcileCommand(rest, options) {
|
|
21
|
-
const flags = parseFlags(rest);
|
|
21
|
+
const flags = parseFlags(rest, ['browser', 'session-id']);
|
|
22
22
|
const named = rest.find((word) => !word.startsWith('--'));
|
|
23
23
|
const browser = typeof flags.browser === 'string' ? flags.browser : named;
|
|
24
|
+
// Optional, and the report degrades honestly without it: a caller that does
|
|
25
|
+
// not say who it is gets the ordinary message rather than a claim about
|
|
26
|
+
// ownership nobody established.
|
|
27
|
+
const callerSession = typeof flags['session-id'] === 'string' ? flags['session-id'] : undefined;
|
|
24
28
|
const browsers = options.browsers ?? DEFAULT_BROWSER_IDS;
|
|
25
29
|
if (browser === undefined) {
|
|
26
30
|
options.streams.err(`broker reconcile needs to be told which browser: ${browsers.join(' or ')}. It asks that browser what it has open, closes pages no live lease owns, and settles rows whose pages are gone.`);
|
|
@@ -51,6 +55,13 @@ export async function runReconcileCommand(rest, options) {
|
|
|
51
55
|
// ── 4. Writing. A database handle and no session.
|
|
52
56
|
const at = now();
|
|
53
57
|
applyReconciliation(options.db, plan.vanishedTabs, at);
|
|
58
|
+
// Rows left `closing` by a lease that has already ended, whose page this
|
|
59
|
+
// browser does not have. The vanished-tab path cannot see them — it reads
|
|
60
|
+
// only tabs of *active* leases — so without this they are unreachable by
|
|
61
|
+
// anything, forever, while still holding their slot in the partial unique
|
|
62
|
+
// index. The browser has just said what it has open; that is the answer
|
|
63
|
+
// those rows were waiting for.
|
|
64
|
+
const strandedSettled = settleStrandedTabs(options.db, browser, pages.map((page) => page.driverTabId), at);
|
|
54
65
|
for (const tab of plan.vanishedTabs) {
|
|
55
66
|
// §1.6: one row per decision, and this is a decision — a lease was ended
|
|
56
67
|
// by something that was neither the caller nor the clock. `cli` rather
|
|
@@ -85,10 +96,14 @@ export async function runReconcileCommand(rest, options) {
|
|
|
85
96
|
}
|
|
86
97
|
const report = {
|
|
87
98
|
pagesSeen: pages.length,
|
|
99
|
+
strandedSettled,
|
|
88
100
|
settled: plan.vanishedTabs.map((tab) => tab.tabId),
|
|
89
101
|
closed,
|
|
90
102
|
closeFailures,
|
|
91
103
|
skippedOpening: plan.skippedOpening.length,
|
|
104
|
+
skippedOpeningOwnedByCaller: callerSession === undefined
|
|
105
|
+
? 0
|
|
106
|
+
: plan.skippedOpening.filter((tab) => tab.sessionId === callerSession).length,
|
|
92
107
|
};
|
|
93
108
|
if (options.json) {
|
|
94
109
|
options.streams.out(JSON.stringify({
|
|
@@ -106,16 +121,55 @@ export async function runReconcileCommand(rest, options) {
|
|
|
106
121
|
}
|
|
107
122
|
return COMMAND_EXIT.accepted;
|
|
108
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Did this run decline to do the thing it was called to do?
|
|
126
|
+
*
|
|
127
|
+
* **Two facts together, and neither alone is enough.** Tabs still being
|
|
128
|
+
* opened block the sweep (`decideReconciliation` leaves them alone, because
|
|
129
|
+
* closing a page a mid-open lease is about to be handed would be worse than
|
|
130
|
+
* declining) — but a run that closed pages *and* skipped one did work, and
|
|
131
|
+
* calling that "nothing was closed" would be false. A run that closed
|
|
132
|
+
* nothing because there was nothing to close is not declining either; it is
|
|
133
|
+
* simply a clean run, and telling that caller to try again would send it
|
|
134
|
+
* back for an answer it already has.
|
|
135
|
+
*
|
|
136
|
+
* So the conclusion is drawn only where both hold: something was in the way,
|
|
137
|
+
* and nothing was swept past it.
|
|
138
|
+
*/
|
|
139
|
+
function nothingClosedPendingRetry(report) {
|
|
140
|
+
return report.skippedOpening > 0 && report.closed === 0;
|
|
141
|
+
}
|
|
109
142
|
/**
|
|
110
143
|
* The report a person reads.
|
|
111
144
|
*
|
|
112
145
|
* **Every line is a count or an opaque identifier**, which is §1.4's rule
|
|
113
146
|
* made true by there being nothing else available to print: the report type
|
|
114
147
|
* carries no driver name, so this function could not print one if it tried.
|
|
148
|
+
*
|
|
149
|
+
* ── Why the outcome is the first line and not the last ──────────────────
|
|
150
|
+
*
|
|
151
|
+
* A caller reads the first line and acts on it. When this run declined —
|
|
152
|
+
* {@link nothingClosedPendingRetry} — the sentence that predicts that
|
|
153
|
+
* caller's next failure is the one that has to arrive first, because a
|
|
154
|
+
* headline of `reconciled: <browser>` above four counters reads as
|
|
155
|
+
* completion, and a reader who takes it at face value stops there and runs
|
|
156
|
+
* straight back into the state they invoked this to clear. The counters are
|
|
157
|
+
* still printed, unchanged and in the same order; what moves is the
|
|
158
|
+
* conclusion, which stops being something the reader has to derive from the
|
|
159
|
+
* bottom of a list.
|
|
160
|
+
*
|
|
161
|
+
* **The headline stops claiming completion on such a run** for the same
|
|
162
|
+
* reason. `reconciled:` is a claim about what happened, and on a run that
|
|
163
|
+
* closed nothing and needs invoking again it is not a true one — this is the
|
|
164
|
+
* defect class this repository keeps finding in itself, a call that succeeds
|
|
165
|
+
* while delivering less than it announced. The word is kept for the runs
|
|
166
|
+
* that earned it.
|
|
115
167
|
*/
|
|
116
168
|
export function formatReconciliation(browser, report) {
|
|
169
|
+
const declined = nothingClosedPendingRetry(report);
|
|
117
170
|
const lines = [
|
|
118
|
-
`reconciled: ${browser}`,
|
|
171
|
+
declined ? `did not reconcile: ${browser}` : `reconciled: ${browser}`,
|
|
172
|
+
...(declined ? [conclusionLine(report)] : []),
|
|
119
173
|
`pages open, not counting the keeper: ${String(report.pagesSeen)}`,
|
|
120
174
|
`pages closed because no live lease owned them: ${String(report.closed)}`,
|
|
121
175
|
`leases ended because their page was gone: ${String(report.settled.length)}`,
|
|
@@ -123,15 +177,44 @@ export function formatReconciliation(browser, report) {
|
|
|
123
177
|
for (const tabId of report.settled) {
|
|
124
178
|
lines.push(` tab ${tabId}`);
|
|
125
179
|
}
|
|
180
|
+
// Only when it happened. A line that is present and zero on every healthy
|
|
181
|
+
// run is noise, and this one describes a state that should be rare.
|
|
182
|
+
if (report.strandedSettled > 0) {
|
|
183
|
+
lines.push(`records settled that were waiting on a close nobody was coming to answer: ${String(report.strandedSettled)}`);
|
|
184
|
+
}
|
|
126
185
|
if (report.closeFailures > 0) {
|
|
127
186
|
// §2.4b: a leaked page, not a leaked lease. Said in those terms so the
|
|
128
187
|
// reader knows what it costs — memory, and not budget.
|
|
129
188
|
lines.push(`${String(report.closeFailures)} page(s) would not close. That is a leaked page and not a leaked lease: the budget is unaffected, and \`broker doctor\` reports them.`);
|
|
130
189
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
190
|
+
// Said on the run it happened on, because the alternative is a person
|
|
191
|
+
// reading "0 closed" and concluding there was nothing to close.
|
|
192
|
+
//
|
|
193
|
+
// **Printed here only when it was not already printed at the top.** The
|
|
194
|
+
// conclusion belongs above the counters on a run that declined, and below
|
|
195
|
+
// them on a run that closed pages anyway — where it is a caveat on real
|
|
196
|
+
// work rather than the outcome. Either way it is written once, by one
|
|
197
|
+
// function, so the two positions cannot drift into two wordings.
|
|
198
|
+
if (report.skippedOpening > 0 && !declined) {
|
|
199
|
+
lines.push(conclusionLine(report));
|
|
135
200
|
}
|
|
136
201
|
return lines;
|
|
137
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* The sentence that tells a caller what to do next.
|
|
205
|
+
*
|
|
206
|
+
* **The caution itself does not change when the caller owns the blocking
|
|
207
|
+
* row.** Closing a page belonging to an in-flight claim would be worse than
|
|
208
|
+
* declining, and that is true whoever the claim belongs to. What changes is
|
|
209
|
+
* that the caller is told the remedy is in its own hands: an operator can
|
|
210
|
+
* otherwise run this repeatedly against a row that is its own lease, held
|
|
211
|
+
* open for as long as the command keeps being run, with nothing in the
|
|
212
|
+
* message able to say so.
|
|
213
|
+
*/
|
|
214
|
+
function conclusionLine(report) {
|
|
215
|
+
const owned = report.skippedOpeningOwnedByCaller;
|
|
216
|
+
return (`${String(report.skippedOpening)} tab(s) are still being opened, so nothing was closed on this run — a page seen now may belong to one of them.` +
|
|
217
|
+
(owned > 0
|
|
218
|
+
? ` ${String(owned)} of them ${owned === 1 ? 'belongs' : 'belong'} to your own lease — release ${owned === 1 ? 'it' : 'them'}, or run this from another session.`
|
|
219
|
+
: ' Run again once they have settled.'));
|
|
220
|
+
}
|
|
@@ -146,39 +146,6 @@ const DECLARATIONS = [
|
|
|
146
146
|
maximum: 3,
|
|
147
147
|
browserKind: 'clean-room',
|
|
148
148
|
},
|
|
149
|
-
{
|
|
150
|
-
/**
|
|
151
|
-
* Which browser binary the signed-in browsers launch (§6.2).
|
|
152
|
-
*
|
|
153
|
-
* **One engine per kind, never per browser**, for the same reason there is
|
|
154
|
-
* no per-entry private flag: an engine per entry reintroduces the
|
|
155
|
-
* per-entry attribute this configuration exists without.
|
|
156
|
-
*
|
|
157
|
-
* The three accepted words are all Chromium over the same remote-debugging
|
|
158
|
-
* protocol, which is what makes the choice a binary path rather than a
|
|
159
|
-
* second driver.
|
|
160
|
-
*/
|
|
161
|
-
key: 'BROKER_REGULAR_BROWSER_ENGINE',
|
|
162
|
-
kind: 'enum',
|
|
163
|
-
fallback: 'msedge',
|
|
164
|
-
allowed: ['chrome', 'brave', 'msedge'],
|
|
165
|
-
unit: 'a browser engine',
|
|
166
|
-
},
|
|
167
|
-
{
|
|
168
|
-
/**
|
|
169
|
-
* Which browser binary the clean-room browsers launch (§6.2).
|
|
170
|
-
*
|
|
171
|
-
* **May differ from the signed-in engine**, and separate variables are
|
|
172
|
-
* what make that expressible: a person signs into the signed-in browser by
|
|
173
|
-
* hand, so which binary that is can be a matter of what they already use,
|
|
174
|
-
* while nobody signs into a clean-room browser at all.
|
|
175
|
-
*/
|
|
176
|
-
key: 'BROKER_PRIVATE_BROWSER_ENGINE',
|
|
177
|
-
kind: 'enum',
|
|
178
|
-
fallback: 'msedge',
|
|
179
|
-
allowed: ['chrome', 'brave', 'msedge'],
|
|
180
|
-
unit: 'a browser engine',
|
|
181
|
-
},
|
|
182
149
|
];
|
|
183
150
|
/** Every variable this build declares. Row #9's walk test reads this. */
|
|
184
151
|
export const DECLARED_VARIABLES = DECLARATIONS.map((d) => d.key);
|
|
@@ -407,15 +374,6 @@ export function readEnvironment(options = {}) {
|
|
|
407
374
|
}
|
|
408
375
|
return value;
|
|
409
376
|
};
|
|
410
|
-
// The reader already refused anything outside the declared set, so this
|
|
411
|
-
// narrows a checked value rather than trusting one.
|
|
412
|
-
const getEngine = (key) => {
|
|
413
|
-
const value = resolved.get(key);
|
|
414
|
-
if (value !== 'chrome' && value !== 'brave' && value !== 'msedge') {
|
|
415
|
-
throw new Error(`${key} was declared as an engine but not resolved as one`);
|
|
416
|
-
}
|
|
417
|
-
return value;
|
|
418
|
-
};
|
|
419
377
|
const regularBrowsers = getList('BROKER_REGULAR_BROWSERS');
|
|
420
378
|
const privateBrowsers = getList('BROKER_PRIVATE_BROWSERS');
|
|
421
379
|
// **A name in both lists is refused rather than resolved**, because the
|
|
@@ -440,7 +398,5 @@ export function readEnvironment(options = {}) {
|
|
|
440
398
|
launchReadinessTimeoutSeconds: getNumber('BROKER_LAUNCH_READINESS_TIMEOUT_SECONDS'),
|
|
441
399
|
regularBrowsers,
|
|
442
400
|
privateBrowsers,
|
|
443
|
-
regularBrowserEngine: getEngine('BROKER_REGULAR_BROWSER_ENGINE'),
|
|
444
|
-
privateBrowserEngine: getEngine('BROKER_PRIVATE_BROWSER_ENGINE'),
|
|
445
401
|
};
|
|
446
402
|
}
|