browser-broker 0.1.0 → 0.2.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.
@@ -175,6 +175,19 @@ export function serviceFor(options) {
175
175
  key,
176
176
  tabId: tabForKey(db, key),
177
177
  url: argument(args, 'url'),
178
+ // **Coercion only, never validation** — the same split
179
+ // `viewportFrom` documents at length, and here for the same
180
+ // reason. A command line produces flat strings and nothing else,
181
+ // so a wait that was never coerced would be a number no
182
+ // command-line caller could type: it would reach the integer guard
183
+ // as text and be refused however correctly it was written.
184
+ //
185
+ // Whether the number is whole, positive and within the lease is
186
+ // the operation's decision, on the ledger. Unparseable text
187
+ // becomes `NaN` and is handed on deliberately, so it fails that
188
+ // guard and produces the refusal naming the range rather than a
189
+ // different one invented here.
190
+ waitMs: asInteger(argument(args, 'wait_ms', 'waitMs')),
178
191
  })),
179
192
  };
180
193
  }
@@ -222,6 +235,22 @@ export function serviceFor(options) {
222
235
  // told the diff is available, passes it, and gets a capture with no
223
236
  // comparison and nothing saying why.
224
237
  const compareTo = argument(args, 'compare_to', 'compareTo');
238
+ // The resolution rung and the written justification the top rung
239
+ // requires. **Coercion only, never validation**, the same split the
240
+ // wait above keeps: `validateCaptureTier` names the accepted words and
241
+ // the pipeline decides whether a reason is owed, so an unrecognised
242
+ // value is handed on deliberately and reaches the rule that can
243
+ // explain it.
244
+ //
245
+ // Read here because the ladder they drive was built end to end and
246
+ // connected to nothing: the pipeline refuses the top rung without a
247
+ // reason, the `captures` table carries the column, and the telemetry
248
+ // rollups query it — while no surface could populate either field. An
249
+ // escalation rollup with no way to escalate does not report an empty
250
+ // result, it reports "nobody escalates", which is a fact that is not
251
+ // one.
252
+ const tier = argument(args, 'tier');
253
+ const reason = argument(args, 'reason');
225
254
  return {
226
255
  ...(await broker.capture({
227
256
  key,
@@ -229,6 +258,8 @@ export function serviceFor(options) {
229
258
  ...(fullPage === undefined ? {} : { fullPage: asBoolean(fullPage) }),
230
259
  ...(typeof selector === 'string' ? { selector } : {}),
231
260
  ...(typeof compareTo === 'string' && compareTo.length > 0 ? { compareTo } : {}),
261
+ ...(tier === undefined ? {} : { tier }),
262
+ ...(reason === undefined ? {} : { reason }),
232
263
  })),
233
264
  };
234
265
  }
@@ -358,8 +389,8 @@ function actionFrom(args) {
358
389
  const targetRef = argument(args, 'target_ref', 'targetRef');
359
390
  const viewport = viewportFrom(args);
360
391
  const preferences = preferencesFrom(args);
361
- const response = argument(args, 'response');
362
- const fields = argument(args, 'fields');
392
+ const response = responseFrom(args);
393
+ const fields = fieldsFrom(args);
363
394
  return {
364
395
  action,
365
396
  ...(ref === undefined ? {} : { ref }),
@@ -429,6 +460,121 @@ function viewportFrom(args) {
429
460
  }
430
461
  return undefined;
431
462
  }
463
+ /**
464
+ * The answer a `dialog` gives, assembled from flat flags.
465
+ *
466
+ * The same unreachability {@link viewportFrom} exists for, and it survived the
467
+ * pull request that fixed that one — recorded there as "known and deliberately
468
+ * not fixed here", which is a reasonable thing to write once and a poor thing
469
+ * to leave true.
470
+ *
471
+ * `validateAction` wants `response: { accept, promptText? }`, an object whose
472
+ * first member is a **boolean**. A command line produces flat strings, so
473
+ * `--response accept`, `--value accept` and `--accept true` all arrived as
474
+ * something that is not an object and drew the identical refusal:
475
+ *
476
+ * > Answering a dialog says whether to accept it or dismiss it.
477
+ *
478
+ * Which is what the caller was trying to say. As with a resize, the message
479
+ * describes the semantics and never the syntax, and no syntax existed.
480
+ *
481
+ * `--accept` and `--dismiss` are the two things a caller means, written as
482
+ * the two words the refusal itself uses. `--prompt-text` carries what to type
483
+ * before accepting. **Coercion only** — that text may not accompany a
484
+ * dismissal, and that stays the operation's decision, on the ledger, rather
485
+ * than being re-decided here.
486
+ */
487
+ function responseFrom(args) {
488
+ const given = argument(args, 'response');
489
+ if (given !== undefined && typeof given === 'object') {
490
+ return given;
491
+ }
492
+ const promptText = argument(args, 'prompt_text', 'promptText');
493
+ const accept = acceptFrom(args, given);
494
+ if (accept === undefined) {
495
+ return given;
496
+ }
497
+ return {
498
+ accept,
499
+ ...(typeof promptText === 'string' ? { promptText } : {}),
500
+ };
501
+ }
502
+ /**
503
+ * Whether the caller said accept or dismiss, across the spellings a person
504
+ * reaches for. Anything else is left alone so the operation refuses it.
505
+ */
506
+ function acceptFrom(args, given) {
507
+ const acceptFlag = argument(args, 'accept');
508
+ const dismissFlag = argument(args, 'dismiss');
509
+ // Both flags names opposite intentions, so neither is the answer. Resolving
510
+ // them by which was read first hands the caller a decision it never made,
511
+ // and about the one thing a dialog answer decides — the same reasoning that
512
+ // refuses prompt text alongside a dismissal rather than picking one.
513
+ //
514
+ // **Nothing is refused here, deliberately.** This function coerces; the
515
+ // operation decides. Yielding `undefined` for an unanswerable pair leaves
516
+ // the assembled response without an `accept`, which the dialog operation
517
+ // already refuses by name — so the rule stays spelled in one place and
518
+ // arrives identically whichever route a caller came in on. Inventing a
519
+ // second rule at this layer would make the same mistake refuse differently
520
+ // depending on the transport.
521
+ if (acceptFlag !== undefined && dismissFlag !== undefined) {
522
+ return undefined;
523
+ }
524
+ if (acceptFlag !== undefined) {
525
+ return acceptFlag === '' || acceptFlag === true || acceptFlag === 'true' ? true : undefined;
526
+ }
527
+ if (dismissFlag !== undefined) {
528
+ return dismissFlag === '' || dismissFlag === true || dismissFlag === 'true' ? false : undefined;
529
+ }
530
+ const word = typeof given === 'string' ? given : argument(args, 'value');
531
+ if (word === 'accept')
532
+ return true;
533
+ if (word === 'dismiss')
534
+ return false;
535
+ return undefined;
536
+ }
537
+ /**
538
+ * The fields a `fill_form` fills, assembled from repeated flat pairs.
539
+ *
540
+ * Unreachable for the same reason and fixed the same way: the operation wants
541
+ * an **array of objects**, and a command line has only strings.
542
+ *
543
+ * `--field ref=value` is the form, repeated once per field, because that is
544
+ * what the operation is — a list of pairs — and because a caller already types
545
+ * `--target` and `--value` for the single-field verbs. Only the first `=`
546
+ * separates, so a value may contain one.
547
+ *
548
+ * **Coercion only.** The bound on how many fields a batch carries, and whether
549
+ * a reference is a reference, stay inside the operation.
550
+ */
551
+ function fieldsFrom(args) {
552
+ const given = argument(args, 'fields');
553
+ if (given !== undefined && Array.isArray(given)) {
554
+ return given;
555
+ }
556
+ const pairs = argument(args, 'field');
557
+ const list = Array.isArray(pairs)
558
+ ? pairs
559
+ : pairs === undefined
560
+ ? []
561
+ : [pairs];
562
+ if (list.length === 0) {
563
+ return given;
564
+ }
565
+ return list.map((pair) => {
566
+ if (typeof pair !== 'string') {
567
+ return pair;
568
+ }
569
+ const split = pair.indexOf('=');
570
+ if (split < 0) {
571
+ // No separator is not a pair. Handed on so the operation refuses it
572
+ // and names which field, rather than being silently dropped here.
573
+ return { ref: pair };
574
+ }
575
+ return { ref: pair.slice(0, split), value: pair.slice(split + 1) };
576
+ });
577
+ }
432
578
  /**
433
579
  * The media preferences an `emulate` sets, assembled from flat flags.
434
580
  *
@@ -95,7 +95,58 @@ export function createBroker(options) {
95
95
  }
96
96
  return result;
97
97
  },
98
- status: (input) => run('status', input),
98
+ /**
99
+ * **After the call returns, so the probe is outside the transaction.**
100
+ * The same shape `claim` above uses for its seed, and for a stricter
101
+ * reason: this one talks to a browser, and
102
+ * `arbitration.no_browser_io` (§2.4b) is what keeps that out of the
103
+ * arbitration transaction.
104
+ *
105
+ * A queued lease is not probed. It holds no tab and is waiting for
106
+ * capacity rather than using it, so a browser that is down is the
107
+ * ordinary reason the queue exists — reporting a waiting caller as
108
+ * expired would end a lease that has nothing wrong with it.
109
+ */
110
+ status: async (input) => {
111
+ const result = await run('status', input);
112
+ if (result.state !== 'active' || options.checkBrowser === undefined) {
113
+ return result;
114
+ }
115
+ const liveness = await options.checkBrowser(result.browserId);
116
+ if (liveness !== 'gone') {
117
+ return { ...result, browser: liveness };
118
+ }
119
+ // **The lease is reported `expired`, and this writes nothing.**
120
+ //
121
+ // Two things are true at once and the split matters: the browser is
122
+ // gone, so this lease cannot do anything a lease is for; but the
123
+ // reclamation that ends leases is the sweep's, inside the arbitration
124
+ // transaction, and this code is deliberately outside it. Writing here
125
+ // would be a second writer of lease state racing the one that owns it.
126
+ //
127
+ // So this reports the truth without asserting authorship of it, which
128
+ // is exactly what §2.4's standing rule licenses: **stored state is
129
+ // provisional, derived state is the truth.** A row saying `active`
130
+ // against a dead browser is provisional in the same way a lapsed row
131
+ // is, and a reader that derives correctly renders the same picture
132
+ // whether or not the row has been swept.
133
+ //
134
+ // `tabId` is dropped rather than carried: it names a page in a browser
135
+ // that does not exist, and handing it back invites a caller to address
136
+ // it. Nothing owns it to close — the browser took it.
137
+ const { tabId, ...withoutTab } = result;
138
+ // Read so that dropping it is a decision the compiler can see rather
139
+ // than an unused binding a later cleanup would "tidy" back into the
140
+ // result. The tab is real in the store; what is gone is the browser
141
+ // holding it, which is why it is omitted here and not closed here.
142
+ void tabId;
143
+ return {
144
+ ...withoutTab,
145
+ state: 'expired',
146
+ browser: 'gone',
147
+ checkBack: `The ${result.browserId} browser this lease was held against is not running: its endpoint did not answer, or answered as a different browser. The lease cannot be used, so it is reported expired rather than active however much time is left on it. Release this lease and claim again — a claim is what starts a browser, and the next one will start a fresh one.`,
148
+ };
149
+ },
99
150
  release: (input) => run('release', {
100
151
  ...input,
101
152
  settings,
@@ -111,8 +111,74 @@ export function browserSessionProvider(options) {
111
111
  inFlight.set(browser, acquiring);
112
112
  return acquiring;
113
113
  };
114
+ const liveness = async (browser) => {
115
+ const isRunning = options.isRunning ?? browserIsRunning;
116
+ const profileDir = profileDirectory(options.environment.profileRoot, browser);
117
+ try {
118
+ const record = await isRunning(profileDir);
119
+ // The same two-condition reading `acquire` applies to its own
120
+ // observation: a record with no identifier failed the identity half,
121
+ // and a record that failed either half is stale (§1.2c). Stale means
122
+ // the browser is treated as not running.
123
+ if (record !== undefined && record.browserUuid !== undefined) {
124
+ return 'live';
125
+ }
126
+ // ── The recovery path, and it is one line for a reason ────────────
127
+ //
128
+ // **Forgetting the dead session is what makes reclaiming work.** The
129
+ // memoised entry is a connection to a browser that is gone, and it is
130
+ // handed to every page verb for the life of this process — so without
131
+ // this, a caller that does the obvious correct thing (release, claim
132
+ // again, drive the page) gets the same dead attachment each time and
133
+ // there is no way back from the tool surface at all.
134
+ //
135
+ // Dropping it sends the next caller through `acquire`, which makes the
136
+ // observation, loses or wins the launch race in the store like any
137
+ // other caller, and starts a browser. Nothing here launches anything
138
+ // itself: a second process launching against one profile directory is
139
+ // the measured silent-collision failure the race exists to prevent.
140
+ //
141
+ // **Nothing is detached and no browser is ended.** There is nothing to
142
+ // detach from — the browser is the thing that went away — and this
143
+ // service never ends a browser (`browser_scoped.never`, §7.3).
144
+ settled.delete(browser);
145
+ inFlight.delete(browser);
146
+ // ── A browser that was never started is NOT a browser that died ────
147
+ //
148
+ // The distinction is the whole reason this reads the row, and getting
149
+ // it wrong breaks the ordinary path rather than an edge case: **a lease
150
+ // is granted before any browser exists.** Acquisition is lazy, so the
151
+ // normal life of a lease is claim, then status, then a page verb that
152
+ // finally causes the launch — and on a machine with no browser
153
+ // installed at all, that launch never comes and the lease is still
154
+ // perfectly valid for everything that does not need a page.
155
+ //
156
+ // Both states look identical from the profile directory: no verified
157
+ // record either way. What separates them is what the store was told.
158
+ // `recordLaunched` moves the row to `running`, so a row that says
159
+ // `running` while nothing answers is a browser that **has died**;
160
+ // `stopped` is one that was never started, and a lease against it is
161
+ // waiting for a launch rather than holding a corpse.
162
+ const row = options.store.db
163
+ .prepare('SELECT state FROM browsers WHERE id = ?')
164
+ .get(browser);
165
+ return row?.state === 'running' ? 'gone' : 'unknown';
166
+ }
167
+ catch {
168
+ // **`unknown`, never `gone`.** A probe that could not be carried out —
169
+ // an unreadable profile directory, a fetch that threw rather than
170
+ // answering — has observed nothing, and reporting a browser dead on the
171
+ // strength of a failed observation would end working leases on a
172
+ // machine having an unrelated bad moment. Not knowing is a state this
173
+ // result can express precisely so it does not have to be guessed at.
174
+ return 'unknown';
175
+ }
176
+ };
177
+ const holds = (browser) => settled.has(browser) || inFlight.has(browser);
114
178
  return {
115
179
  session,
180
+ liveness,
181
+ holds,
116
182
  close: async () => {
117
183
  for (const open of settled.values()) {
118
184
  try {
@@ -5,6 +5,7 @@ import { append } from "../events.js";
5
5
  import { hashKey, mintKey } from "../keys.js";
6
6
  import { nudgeIfOwnObstacle } from "../nudge.js";
7
7
  import { queuePosition, waitEstimateSeconds } from "../queue.js";
8
+ import { countStrandedTabsFor } from "../tabs.js";
8
9
  import { CallRefusal } from "../refusals.js";
9
10
  import { StorageSeedRefusal, seedRecord, validateStorageSeed, } from "../storage-seed.js";
10
11
  /**
@@ -612,6 +613,30 @@ function grant(branch) {
612
613
  // last unit and the rest of its work is now queued behind other callers.
613
614
  // §2.3a scopes the nudge to a refusal or a queue placement, so nothing is
614
615
  // attached here; the ledger already records the grant.
616
+ // **The count is taken on the handle this transaction is already writing
617
+ // through**, over the table the INSERT above has just touched, so it costs
618
+ // no round trip and cannot read a browser's state from a different instant
619
+ // than the grant did. The threshold is `doctor`'s own definition, imported
620
+ // rather than restated: two notions of "stranded" in one product is the
621
+ // defect this note exists to report.
622
+ const strandedBacklog = strandedBacklogNote(scope.db, browserId, settings.leaseSeconds, scope.adapter);
623
+ if (strandedBacklog !== undefined) {
624
+ // The ledger row is the part that is not optional. Each occurrence is
625
+ // invisible once the caller acts on it, which is exactly why it is
626
+ // recorded: without a record there is no way to learn that granting into
627
+ // a browser with a backlog has become common, and *common* is the signal
628
+ // that something upstream is killing callers mid-lease.
629
+ append(scope.db, {
630
+ kind: 'claim_granted',
631
+ outcome: 'allow',
632
+ adapter: scope.adapter,
633
+ claimId,
634
+ tabId,
635
+ sessionId: input.sessionId,
636
+ browserId,
637
+ detail: { note: 'stranded_backlog', stranded: strandedBacklog.stranded },
638
+ });
639
+ }
615
640
  return {
616
641
  value: {
617
642
  outcome: 'granted',
@@ -622,9 +647,47 @@ function grant(branch) {
622
647
  expiresAt,
623
648
  leaseSeconds: settings.leaseSeconds,
624
649
  storageSeed,
650
+ ...(strandedBacklog === undefined ? {} : { strandedBacklog }),
625
651
  },
626
652
  };
627
653
  }
654
+ /**
655
+ * The note, when this browser is carrying a backlog worth telling a caller
656
+ * about.
657
+ *
658
+ * ── Why the threshold is one stranded tab ───────────────────────────────
659
+ *
660
+ * Not a round number chosen for feel. `doctor` already fails its check at
661
+ * one, on the reasoning that a report which is clean while any tab waits on a
662
+ * close nobody will answer is not reporting — and a second, higher threshold
663
+ * here would mean the claim path and the readiness check disagreeing about
664
+ * whether the same browser is healthy. **That disagreement is the defect this
665
+ * note exists to close**, so the two thresholds are the same threshold, read
666
+ * from the same function.
667
+ *
668
+ * The cost of saying it at one is a single extra line on a response, carrying
669
+ * a true fact, only when a fact is true. The cost of not saying it until some
670
+ * larger number is a caller working blind through exactly the band where the
671
+ * backlog is easiest to clear.
672
+ */
673
+ function strandedBacklogNote(db, browserId, leaseSeconds, adapter) {
674
+ const stranded = countStrandedTabsFor(db, browserId, leaseSeconds);
675
+ if (stranded === 0) {
676
+ return undefined;
677
+ }
678
+ // The remedy names the browser rather than saying "the browser", so it can
679
+ // be run as typed. `doctor` sets that standard and it is why it diagnosed
680
+ // this in one call.
681
+ const remedy = adapter === 'cli'
682
+ ? `broker reconcile ${browserId}`
683
+ : `broker reconcile ${browserId}, from a shell`;
684
+ return {
685
+ stranded,
686
+ note: `note: ${String(stranded)} tab(s) on ${browserId} are stranded mid-close, which can leave ` +
687
+ 'this browser unable to serve the lease just granted — a page call may report the browser ' +
688
+ `closed. Run \`${remedy}\` to clear them, and \`broker doctor\` to confirm.`,
689
+ };
690
+ }
628
691
  /**
629
692
  * The queue placement: a lease and a key, and no tab.
630
693
  *
@@ -2,7 +2,7 @@ import { updateSweptTabs } from "../arbitration.js";
2
2
  import { append } from "../events.js";
3
3
  import { extendLease, resolveLease } from "../leases.js";
4
4
  import { resolveOwnedTabOrRefuse } from "../ownership.js";
5
- import { disposeEvaluationResult, MAX_INLINE_RESULT_BYTES, validateCaptureMode, resolveReadArtifacts, validateAction, validateExpression, validateNavigationTarget, } from "../pages.js";
5
+ import { disposeEvaluationResult, MAX_INLINE_RESULT_BYTES, validateCaptureMode, validateCaptureTier, resolveReadArtifacts, validateAction, validateExpression, validateNavigationTarget, validateNavigationWait, } from "../pages.js";
6
6
  import { recordTabOpened, reserveTab } from "../tabs.js";
7
7
  import { seedRecord } from "../storage-seed.js";
8
8
  import { BrokerError } from "../../errors.js";
@@ -277,10 +277,22 @@ function withPageDriven(value, work) {
277
277
  *
278
278
  * The address is checked against the scheme allowlist before anything is
279
279
  * written, so a refused scheme leaves no trace but the refusal row.
280
+ *
281
+ * The wait is checked after the lease has been resolved rather than before,
282
+ * because what bounds it is that lease's own promised lifetime. It leaves the
283
+ * same absence behind: a refusal rolls the transaction back, taking the
284
+ * renewal with it, so the tab is never asked to go anywhere.
280
285
  */
281
286
  export function decideNavigate(scope, input) {
282
287
  const url = validateNavigationTarget(input.url);
283
288
  const { lease, tab, expiresAt } = admit(scope, input, 'navigate');
289
+ // Bounded by **this lease's own promised lifetime**, read off the row
290
+ // `admit` just renewed rather than from a settings snapshot. That is the
291
+ // same source every duration these handlers report comes from, and for the
292
+ // same reason: a renewal extends by the duration the caller was already
293
+ // told about, so a ceiling taken from the environment could differ from the
294
+ // lease the caller is actually holding.
295
+ const waitMs = validateNavigationWait(input.waitMs, lease.ttlSeconds);
284
296
  append(scope.db, {
285
297
  kind: 'navigate',
286
298
  outcome: 'allow',
@@ -289,14 +301,36 @@ export function decideNavigate(scope, input) {
289
301
  tabId: tab.tabId,
290
302
  sessionId: lease.sessionId,
291
303
  browserId: tab.browserId,
292
- detail: { url },
304
+ // The wait is on the row when the caller asked for one, because the ledger
305
+ // is what answers *"what was this call actually given"* long after the
306
+ // call — and an argument that is invisible in the record is one nobody can
307
+ // check was honoured.
308
+ detail: { url, ...(waitMs === undefined ? {} : { waitMs }) },
293
309
  });
294
- const work = afterCommitWork(scope, input, tab, (session, page) => session.navigate(page, url), lease.claimId);
310
+ const work = afterCommitWork(scope, input, tab, (session, page) => session.navigate(page, url, waitMs), lease.claimId);
295
311
  return {
296
312
  value: withPageDriven({ claimId: lease.claimId, tabId: tab.tabId, expiresAt, url }, work),
297
313
  afterCommit: work.afterCommit,
298
314
  };
299
315
  }
316
+ /**
317
+ * What an `emulate` result says about how long its effect lasts.
318
+ *
319
+ * A constant rather than an inline literal so there is one place to reword it.
320
+ *
321
+ * **What the tests hold it to is the meaning, not the wording.** They match the
322
+ * parts that have to survive a rewrite — that the effect is scoped to the
323
+ * connection, and that a path which works is named — rather than the sentence
324
+ * itself. Equality against the whole string would break on every harmless
325
+ * rewording while proving less: a note can keep every word and still stop
326
+ * telling a caller what to do. So a rewrite that keeps the meaning is free, and
327
+ * one that drops the working path fails.
328
+ */
329
+ export const EMULATION_SCOPE_NOTE = 'This preference lasts as long as the connection that set it, not as long ' +
330
+ 'as the tab. A later call from a separate invocation will not see it: the ' +
331
+ 'tab survives and the emulation binding does not. To act on it, emulate ' +
332
+ 'and capture within one invocation, or use the tool surface, where one ' +
333
+ 'connection spans the calls.';
300
334
  /**
301
335
  * `act` (§3.6) — one interaction against an owned tab.
302
336
  *
@@ -319,7 +353,17 @@ export function decideAct(scope, input) {
319
353
  });
320
354
  const work = afterCommitWork(scope, input, tab, (session, page) => session.act(page, request), lease.claimId);
321
355
  return {
322
- value: withPageDriven({ claimId: lease.claimId, tabId: tab.tabId, expiresAt, action: request.action }, work),
356
+ value: withPageDriven({
357
+ claimId: lease.claimId,
358
+ tabId: tab.tabId,
359
+ expiresAt,
360
+ action: request.action,
361
+ // Derived from the action that was validated, in the one place that
362
+ // knows which action ran — the same property `pageDriven` is built
363
+ // for. Spread away on the other twelve so the field's presence is
364
+ // itself the signal, with no "" to mistake for a scope nobody stated.
365
+ ...(request.action === 'emulate' ? { emulationScope: EMULATION_SCOPE_NOTE } : {}),
366
+ }, work),
323
367
  afterCommit: work.afterCommit,
324
368
  };
325
369
  }
@@ -478,9 +522,26 @@ export function decideCapture(scope, input) {
478
522
  // before ownership is checked and before a single row is written, so the
479
523
  // refusal leaves nothing behind but its own ledger entry.
480
524
  validateCaptureMode({ fullPage, selector: input.selector });
525
+ // Checked here for the same reason and in the same place. The pipeline's own
526
+ // `refuseArgumentMistakes` still decides whether the top rung carries its
527
+ // written reason — that rule is not duplicated here, only the one the type
528
+ // system cannot make on text arriving from a surface.
529
+ const tier = validateCaptureTier(input.tier);
481
530
  const request = {
482
531
  fullPage,
483
532
  ...(input.selector === undefined ? {} : { selector: input.selector }),
533
+ ...(tier === undefined ? {} : { tier }),
534
+ // Carried whenever it was given, rather than only alongside a tier.
535
+ //
536
+ // **A reason passed without a tier is still discarded**, and that is the
537
+ // pipeline's existing rule rather than something introduced here: it
538
+ // records a reason "only ever on the tier that requires it", because a
539
+ // reason attached to a rung nobody had to justify is not evidence of an
540
+ // escalation. Passing it on regardless keeps that decision in the one
541
+ // place that makes it, instead of adding a second, quieter version of it
542
+ // here — the value reaches the rule either way, and this layer does not
543
+ // get to have an opinion about which reasons are worth carrying.
544
+ ...(typeof input.reason === 'string' ? { reason: input.reason } : {}),
484
545
  };
485
546
  const { lease, tab, expiresAt } = admit(scope, input, 'capture');
486
547
  append(scope.db, {
@@ -539,6 +600,7 @@ export function decideCapture(scope, input) {
539
600
  width: taken.width,
540
601
  height: taken.height,
541
602
  bytes: taken.bytes,
603
+ compareHint: `to diff a later capture against this one, pass compare_to: ${taken.captureId}`,
542
604
  };
543
605
  // ── The diff, when one was asked for (§3.11, §1.9) ──────────────────
544
606
  //
@@ -57,6 +57,14 @@ export function decideStatus(scope, input) {
57
57
  checkBackSeconds: checkBack,
58
58
  checkBack: advice,
59
59
  ...(tab === undefined ? {} : { tabId: tab.tabId }),
60
+ // **`unknown` rather than `live`, and the difference is the whole
61
+ // defect.** Everything above is derived from rows and a clock, which is
62
+ // what a lease is; whether the browser still exists is a fact about the
63
+ // operating system and cannot be read here without breaking
64
+ // `arbitration.no_browser_io`. Claiming `live` from this seat would be
65
+ // asserting the very thing that was measured false — a store believing
66
+ // in a browser that had been dead for minutes.
67
+ browser: 'unknown',
60
68
  },
61
69
  };
62
70
  }
@@ -98,6 +98,59 @@ export function validateNavigationTarget(url) {
98
98
  }
99
99
  return candidate;
100
100
  }
101
+ /**
102
+ * Check how long a navigation may take, before anything navigates.
103
+ *
104
+ * Returns the wait in milliseconds, or `undefined` when the caller expressed
105
+ * no opinion and the browser library's own default should apply.
106
+ *
107
+ * ── Why the lease is the ceiling, rather than a number somebody picked ──
108
+ *
109
+ * `ttlSeconds` is the lifetime this particular lease was promised, converted
110
+ * to milliseconds. It is the number the bound is *about*: a lease is a tab
111
+ * (§2.3) and capacity is a fixed total across the browsers (§6.2), so a caller
112
+ * permitted to wait longer than its own lease lives would sit inside a single
113
+ * call while the tab it is holding becomes reclaimable — which is the one
114
+ * thing an expiry exists to make impossible, and it would be reached without
115
+ * the caller doing anything wrong.
116
+ *
117
+ * Any other ceiling would be a literal, and a literal is wrong in both
118
+ * directions at once: too low and it refuses a slow page an installation with
119
+ * long leases can perfectly well afford, too high and it reintroduces the
120
+ * overrun. Derived from the lease, it moves with the configuration that sets
121
+ * lease lifetimes and needs no separate setting of its own.
122
+ *
123
+ * **The lease's row rather than the environment**, which is the discipline
124
+ * every duration these operations report already keeps: the call carrying the
125
+ * wait renews the lease by the duration that lease was granted for, so a
126
+ * ceiling read from the environment could name a lifetime the caller is not
127
+ * actually holding.
128
+ *
129
+ * A wait exactly equal to the lease is allowed rather than refused. The call
130
+ * carrying it renews the lease first, so both are measured from that instant
131
+ * and equality is the exact edge rather than an overrun.
132
+ */
133
+ export function validateNavigationWait(waitMs, ttlSeconds) {
134
+ if (waitMs === undefined || waitMs === null)
135
+ return undefined;
136
+ const maximumMs = ttlSeconds * 1000;
137
+ // The syntax as well as the semantics, which is the lesson the viewport and
138
+ // emulate refusals below were both rewritten for: a caller that cannot see
139
+ // the accepted range from the message has no way to converge except by
140
+ // guessing, and a refusal that leaves it guessing costs more than the
141
+ // argument saves.
142
+ if (typeof waitMs !== 'number' || !Number.isInteger(waitMs) || waitMs <= 0) {
143
+ throw new PageRefusal('navigate.wait_bounded', `How long to wait for a page is a whole number of milliseconds from 1 to ${String(maximumMs)}, for example \`--wait-ms 5000\`.`, { waitMs, minimumMs: 1, maximumMs });
144
+ }
145
+ if (waitMs > maximumMs) {
146
+ // Named separately from the shape refusal above because the caller's
147
+ // mistake is a different one: the value is well formed and simply asks for
148
+ // more than a lease lasts, so the sentence says what the ceiling is and
149
+ // where it comes from rather than how to write a number.
150
+ throw new PageRefusal('navigate.wait_bounded', `A wait of ${String(waitMs)}ms is longer than a lease lives (${String(maximumMs)}ms), so the tab would be reclaimable before the navigation returned. Ask for at most ${String(maximumMs)}ms.`, { waitMs, minimumMs: 1, maximumMs });
151
+ }
152
+ return waitMs;
153
+ }
101
154
  /* ───────────────────────── act (#22, #61–#64) ───────────────────────── */
102
155
  /**
103
156
  * The refusal that **lists every action by name**.
@@ -581,6 +634,34 @@ export function validateCaptureMode(options) {
581
634
  throw new PageRefusal('capture.exclusive_mode', 'A capture takes a selector or the whole page, and this call asked for both. They are different pictures rather than different amounts of one, so nothing here can pick for you: ask for the element, or ask for the page.', { fullPage: true, selector: options.selector });
582
635
  }
583
636
  }
637
+ /**
638
+ * The resolution rung a capture was asked for, checked before the pipeline
639
+ * indexes anything by it.
640
+ *
641
+ * ── Why this guard is here and not left to the pipeline ─────────────────
642
+ *
643
+ * The pipeline types the field {@link RequestableTier}, so within the service
644
+ * an unknown rung is a compile error and no check is needed. It stops being a
645
+ * compile-time question at the surface: a tool call and a command line both
646
+ * arrive as free text, and an unrecognised word typed by a caller would reach
647
+ * `TIER_LONGEST_EDGE[tier]`, resolve to `undefined`, and be handed to the
648
+ * downscaler as a target edge. That is a bad answer arriving quietly, which is
649
+ * the same family as the inert argument this pair was wired for.
650
+ *
651
+ * **`default` is refused as a value even though it is a real tier**, because
652
+ * it is the rung you get by passing nothing. `RequestableTier` excludes it on
653
+ * the seam deliberately — "there is deliberately no way to ask for the default
654
+ * explicitly" is a compile error rather than a line in a document — and this
655
+ * refusal keeps that true for callers who reach the service through text.
656
+ */
657
+ export function validateCaptureTier(tier) {
658
+ if (tier === undefined || tier === null)
659
+ return undefined;
660
+ if (tier !== 'detail' && tier !== 'max') {
661
+ throw new PageRefusal('capture.tier_known', 'A capture tier is "detail" or "max". Omit it for the default resolution — there is no way to ask for the default by name, because passing nothing is how you get it. "max" additionally requires reason, a written explanation in your own words.', { tier, accepted: ['detail', 'max'] });
662
+ }
663
+ return tier;
664
+ }
584
665
  /**
585
666
  * Decide whether a result comes back inline or goes to a file.
586
667
  *