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.
@@ -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
- const response = await page.goto(url);
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
- const engine = this.#options.engine;
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
+ };
@@ -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[normaliseKey(body.slice(0, equals))] = body.slice(equals + 1);
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[normaliseKey(body)] = true;
131
+ record(parsed, normaliseKey(body), true);
132
132
  continue;
133
133
  }
134
- parsed[normaliseKey(body)] = next;
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
  }
@@ -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
  {
@@ -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
- /** `--name value` and `--name=value`, plus bare `--flag`. */
18
- export function parseFlags(rest) {
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
- const flags = parseFlags(rest);
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
- if (report.skippedOpening > 0) {
132
- // Said on the run it happened on, because the alternative is a person
133
- // reading "0 closed" and concluding there was nothing to close.
134
- lines.push(`${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. Run again once they have settled.`);
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
  }