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
|
@@ -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,12 +2,13 @@ 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";
|
|
9
9
|
import { sanitiseLabel, stampFromInstant } from "../../artifacts/names.js";
|
|
10
10
|
import { takeCapture } from "../../capture/pipeline.js";
|
|
11
|
+
import { describeReduction } from "../../capture/tiers.js";
|
|
11
12
|
import { capturesTakenBy, recordCapture } from "../capture-store.js";
|
|
12
13
|
import { captureSource } from "../capture-seam.js";
|
|
13
14
|
import { insertComparison } from "../comparison-store.js";
|
|
@@ -277,10 +278,22 @@ function withPageDriven(value, work) {
|
|
|
277
278
|
*
|
|
278
279
|
* The address is checked against the scheme allowlist before anything is
|
|
279
280
|
* written, so a refused scheme leaves no trace but the refusal row.
|
|
281
|
+
*
|
|
282
|
+
* The wait is checked after the lease has been resolved rather than before,
|
|
283
|
+
* because what bounds it is that lease's own promised lifetime. It leaves the
|
|
284
|
+
* same absence behind: a refusal rolls the transaction back, taking the
|
|
285
|
+
* renewal with it, so the tab is never asked to go anywhere.
|
|
280
286
|
*/
|
|
281
287
|
export function decideNavigate(scope, input) {
|
|
282
288
|
const url = validateNavigationTarget(input.url);
|
|
283
289
|
const { lease, tab, expiresAt } = admit(scope, input, 'navigate');
|
|
290
|
+
// Bounded by **this lease's own promised lifetime**, read off the row
|
|
291
|
+
// `admit` just renewed rather than from a settings snapshot. That is the
|
|
292
|
+
// same source every duration these handlers report comes from, and for the
|
|
293
|
+
// same reason: a renewal extends by the duration the caller was already
|
|
294
|
+
// told about, so a ceiling taken from the environment could differ from the
|
|
295
|
+
// lease the caller is actually holding.
|
|
296
|
+
const waitMs = validateNavigationWait(input.waitMs, lease.ttlSeconds);
|
|
284
297
|
append(scope.db, {
|
|
285
298
|
kind: 'navigate',
|
|
286
299
|
outcome: 'allow',
|
|
@@ -289,14 +302,64 @@ export function decideNavigate(scope, input) {
|
|
|
289
302
|
tabId: tab.tabId,
|
|
290
303
|
sessionId: lease.sessionId,
|
|
291
304
|
browserId: tab.browserId,
|
|
292
|
-
|
|
305
|
+
// The wait is on the row when the caller asked for one, because the ledger
|
|
306
|
+
// is what answers *"what was this call actually given"* long after the
|
|
307
|
+
// call — and an argument that is invisible in the record is one nobody can
|
|
308
|
+
// check was honoured.
|
|
309
|
+
detail: { url, ...(waitMs === undefined ? {} : { waitMs }) },
|
|
293
310
|
});
|
|
294
|
-
|
|
311
|
+
// Where the page actually ended up, filled by the after-commit closure
|
|
312
|
+
// below. Undefined until the browser has answered — and permanently so on a
|
|
313
|
+
// build with no browser, which is what the getters' fallbacks are for.
|
|
314
|
+
let arrived;
|
|
315
|
+
const work = afterCommitWork(scope, input, tab, async (session, page) => {
|
|
316
|
+
// **The driver's answer is kept, not discarded.** It reports
|
|
317
|
+
// `page.url()` read after the load settles, plus the title and status,
|
|
318
|
+
// and this assignment is the whole of the redirect fix: the value was
|
|
319
|
+
// always available here and was being thrown away.
|
|
320
|
+
arrived = await session.navigate(page, url, waitMs);
|
|
321
|
+
}, lease.claimId);
|
|
295
322
|
return {
|
|
296
|
-
value: withPageDriven({
|
|
323
|
+
value: withPageDriven({
|
|
324
|
+
claimId: lease.claimId,
|
|
325
|
+
tabId: tab.tabId,
|
|
326
|
+
expiresAt,
|
|
327
|
+
// Getters, for the reason `pageDriven` is one — see
|
|
328
|
+
// {@link withPageDriven}. Read eagerly they would always report the
|
|
329
|
+
// request and no title, because nothing has run yet. That eager read
|
|
330
|
+
// is precisely the defect this fix removes, so spelling these as
|
|
331
|
+
// plain properties would restore it while looking correct.
|
|
332
|
+
get url() {
|
|
333
|
+
return arrived?.url ?? url;
|
|
334
|
+
},
|
|
335
|
+
get title() {
|
|
336
|
+
return arrived?.title;
|
|
337
|
+
},
|
|
338
|
+
get status() {
|
|
339
|
+
return arrived?.status;
|
|
340
|
+
},
|
|
341
|
+
}, work),
|
|
297
342
|
afterCommit: work.afterCommit,
|
|
298
343
|
};
|
|
299
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* What an `emulate` result says about how long its effect lasts.
|
|
347
|
+
*
|
|
348
|
+
* A constant rather than an inline literal so there is one place to reword it.
|
|
349
|
+
*
|
|
350
|
+
* **What the tests hold it to is the meaning, not the wording.** They match the
|
|
351
|
+
* parts that have to survive a rewrite — that the effect is scoped to the
|
|
352
|
+
* connection, and that a path which works is named — rather than the sentence
|
|
353
|
+
* itself. Equality against the whole string would break on every harmless
|
|
354
|
+
* rewording while proving less: a note can keep every word and still stop
|
|
355
|
+
* telling a caller what to do. So a rewrite that keeps the meaning is free, and
|
|
356
|
+
* one that drops the working path fails.
|
|
357
|
+
*/
|
|
358
|
+
export const EMULATION_SCOPE_NOTE = 'This preference lasts as long as the connection that set it, not as long ' +
|
|
359
|
+
'as the tab. A later call from a separate invocation will not see it: the ' +
|
|
360
|
+
'tab survives and the emulation binding does not. To act on it, emulate ' +
|
|
361
|
+
'and capture within one invocation, or use the tool surface, where one ' +
|
|
362
|
+
'connection spans the calls.';
|
|
300
363
|
/**
|
|
301
364
|
* `act` (§3.6) — one interaction against an owned tab.
|
|
302
365
|
*
|
|
@@ -319,7 +382,17 @@ export function decideAct(scope, input) {
|
|
|
319
382
|
});
|
|
320
383
|
const work = afterCommitWork(scope, input, tab, (session, page) => session.act(page, request), lease.claimId);
|
|
321
384
|
return {
|
|
322
|
-
value: withPageDriven({
|
|
385
|
+
value: withPageDriven({
|
|
386
|
+
claimId: lease.claimId,
|
|
387
|
+
tabId: tab.tabId,
|
|
388
|
+
expiresAt,
|
|
389
|
+
action: request.action,
|
|
390
|
+
// Derived from the action that was validated, in the one place that
|
|
391
|
+
// knows which action ran — the same property `pageDriven` is built
|
|
392
|
+
// for. Spread away on the other twelve so the field's presence is
|
|
393
|
+
// itself the signal, with no "" to mistake for a scope nobody stated.
|
|
394
|
+
...(request.action === 'emulate' ? { emulationScope: EMULATION_SCOPE_NOTE } : {}),
|
|
395
|
+
}, work),
|
|
323
396
|
afterCommit: work.afterCommit,
|
|
324
397
|
};
|
|
325
398
|
}
|
|
@@ -478,9 +551,40 @@ export function decideCapture(scope, input) {
|
|
|
478
551
|
// before ownership is checked and before a single row is written, so the
|
|
479
552
|
// refusal leaves nothing behind but its own ledger entry.
|
|
480
553
|
validateCaptureMode({ fullPage, selector: input.selector });
|
|
554
|
+
// Checked here for the same reason and in the same place. The pipeline's own
|
|
555
|
+
// `refuseArgumentMistakes` still decides whether the top rung carries its
|
|
556
|
+
// written reason — that rule is not duplicated here, only the one the type
|
|
557
|
+
// system cannot make on text arriving from a surface.
|
|
558
|
+
const tier = validateCaptureTier(input.tier);
|
|
559
|
+
// **Typed as the pipeline's options, not the driver's `CaptureRequest`.**
|
|
560
|
+
//
|
|
561
|
+
// This annotation is the defect's whole mechanism, so it is worth naming.
|
|
562
|
+
// `CaptureRequest` is the *driver* seam — what the browser is told — and it
|
|
563
|
+
// has no `tier` and no `reason`, correctly: a rung is a decision about the
|
|
564
|
+
// picture after the shutter, not something a browser is asked for. The
|
|
565
|
+
// literal below nevertheless packed both in, and the excess-property check
|
|
566
|
+
// that would ordinarily catch that **does not apply to conditionally spread
|
|
567
|
+
// properties**, so it compiled silently. The value then had nowhere to go,
|
|
568
|
+
// and the one call site downstream quietly took only the two fields the
|
|
569
|
+
// driver type admits.
|
|
570
|
+
//
|
|
571
|
+
// Annotating with the type that actually consumes these fields is what makes
|
|
572
|
+
// the same mistake a compile error next time rather than an inert argument.
|
|
481
573
|
const request = {
|
|
482
574
|
fullPage,
|
|
483
575
|
...(input.selector === undefined ? {} : { selector: input.selector }),
|
|
576
|
+
...(tier === undefined ? {} : { tier }),
|
|
577
|
+
// Carried whenever it was given, rather than only alongside a tier.
|
|
578
|
+
//
|
|
579
|
+
// **A reason passed without a tier is still discarded**, and that is the
|
|
580
|
+
// pipeline's existing rule rather than something introduced here: it
|
|
581
|
+
// records a reason "only ever on the tier that requires it", because a
|
|
582
|
+
// reason attached to a rung nobody had to justify is not evidence of an
|
|
583
|
+
// escalation. Passing it on regardless keeps that decision in the one
|
|
584
|
+
// place that makes it, instead of adding a second, quieter version of it
|
|
585
|
+
// here — the value reaches the rule either way, and this layer does not
|
|
586
|
+
// get to have an opinion about which reasons are worth carrying.
|
|
587
|
+
...(typeof input.reason === 'string' ? { reason: input.reason } : {}),
|
|
484
588
|
};
|
|
485
589
|
const { lease, tab, expiresAt } = admit(scope, input, 'capture');
|
|
486
590
|
append(scope.db, {
|
|
@@ -529,16 +633,57 @@ export function decideCapture(scope, input) {
|
|
|
529
633
|
const taken = await takeCapture({ tabs: session, artifacts }, lease.claimId, page, {
|
|
530
634
|
fullPage,
|
|
531
635
|
...(request.selector === undefined ? {} : { selector: request.selector }),
|
|
636
|
+
// **The rung and its justification, which used to stop here.**
|
|
637
|
+
//
|
|
638
|
+
// `request` was built with both a dozen lines above — `tier`
|
|
639
|
+
// validated by `validateCaptureTier`, `reason` carried whenever it
|
|
640
|
+
// was given — and then this call site spread only `fullPage` and
|
|
641
|
+
// `selector`, so both died one line before the pipeline that
|
|
642
|
+
// honours them. Every capture was consequently taken at the default
|
|
643
|
+
// rung no matter what the caller asked for.
|
|
644
|
+
//
|
|
645
|
+
// That is the inert-argument defect `check:argument-reachability`
|
|
646
|
+
// exists to prevent, one layer below where that check looks: its
|
|
647
|
+
// rule is that a declared argument is read *at the bridge*, and
|
|
648
|
+
// `tier` is read there, so the check passed while the value went
|
|
649
|
+
// nowhere. It is worse than the `wait_ms` case that motivated the
|
|
650
|
+
// check, because `tier="max"` charges the caller a written
|
|
651
|
+
// justification first — the caller pays for the escalation, is told
|
|
652
|
+
// it was accepted, and receives the unescalated picture.
|
|
653
|
+
...(request.tier === undefined ? {} : { tier: request.tier }),
|
|
654
|
+
...(request.reason === undefined ? {} : { reason: request.reason }),
|
|
532
655
|
}, takenBefore);
|
|
533
656
|
// The row last, describing a file that is already on disk. See
|
|
534
657
|
// `capture-store.ts` for why that order is the rule and not a preference.
|
|
535
658
|
recordCapture(scope.db, lease.claimId, tab.tabId, taken.telemetry);
|
|
659
|
+
// **What the browser produced, and by how much it was shrunk to fit the
|
|
660
|
+
// rung** — present exactly when the two differ.
|
|
661
|
+
//
|
|
662
|
+
// The pipeline has computed `sourceWidth`/`sourceHeight` all along and
|
|
663
|
+
// `captures` has stored them all along; this object simply never passed
|
|
664
|
+
// them on, so the one layer that talks to the caller was the one layer
|
|
665
|
+
// that could not tell a reduced picture from an unreduced one. A
|
|
666
|
+
// `full_page` capture of a long article consequently came back at about
|
|
667
|
+
// sixteen per cent, complete and undistorted and entirely illegible,
|
|
668
|
+
// with nothing in the response saying so.
|
|
669
|
+
const reduction = describeReduction({ width: taken.sourceWidth, height: taken.sourceHeight }, { width: taken.width, height: taken.height }, taken.tier);
|
|
536
670
|
written = {
|
|
537
671
|
captureId: taken.captureId,
|
|
538
672
|
path: taken.path,
|
|
539
673
|
width: taken.width,
|
|
540
674
|
height: taken.height,
|
|
541
675
|
bytes: taken.bytes,
|
|
676
|
+
// Echoed on every capture, reduced or not, because "what did the page
|
|
677
|
+
// actually measure" is a fact a caller may want either way — and
|
|
678
|
+
// because a field that appears only on the bad case is a field nobody
|
|
679
|
+
// learns to read.
|
|
680
|
+
sourceWidth: taken.sourceWidth,
|
|
681
|
+
sourceHeight: taken.sourceHeight,
|
|
682
|
+
tier: taken.tier,
|
|
683
|
+
// Absent when nothing was shrunk. Its **presence** is the signal, which
|
|
684
|
+
// is why it is not a `scale: 1` that a caller would learn to skip.
|
|
685
|
+
...(reduction === undefined ? {} : { reduced: reduction }),
|
|
686
|
+
compareHint: `to diff a later capture against this one, pass compare_to: ${taken.captureId}`,
|
|
542
687
|
};
|
|
543
688
|
// ── The diff, when one was asked for (§3.11, §1.9) ──────────────────
|
|
544
689
|
//
|
|
@@ -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
|
*
|
|
@@ -82,7 +82,9 @@
|
|
|
82
82
|
* on the assumption the seam holds up its end.
|
|
83
83
|
*/
|
|
84
84
|
export function decideReconciliation(pages, recorded) {
|
|
85
|
-
const skippedOpening = recorded
|
|
85
|
+
const skippedOpening = recorded
|
|
86
|
+
.filter((tab) => tab.driverTabId === null)
|
|
87
|
+
.map((tab) => ({ tabId: tab.tabId, sessionId: tab.sessionId }));
|
|
86
88
|
// Every driver name a live lease claims. Built from the rows that have one,
|
|
87
89
|
// which by §1.4's check is exactly the rows that are not `opening`.
|
|
88
90
|
const owned = new Set(recorded
|
|
@@ -123,7 +125,8 @@ export function readRecordedTabs(db, browserId) {
|
|
|
123
125
|
return db
|
|
124
126
|
.prepare(`SELECT tabs.id AS tabId,
|
|
125
127
|
tabs.driver_tab_id AS driverTabId,
|
|
126
|
-
tabs.claim_id AS claimId
|
|
128
|
+
tabs.claim_id AS claimId,
|
|
129
|
+
claims.session_id AS sessionId
|
|
127
130
|
FROM tabs
|
|
128
131
|
JOIN claims ON claims.id = tabs.claim_id
|
|
129
132
|
WHERE tabs.browser_id = ?
|
|
@@ -132,6 +135,57 @@ export function readRecordedTabs(db, browserId) {
|
|
|
132
135
|
ORDER BY tabs.id`)
|
|
133
136
|
.all(browserId);
|
|
134
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Settle rows stranded at `closing` whose page the browser does not have.
|
|
140
|
+
*
|
|
141
|
+
* ── Why the vanished-tab path cannot reach these ────────────────────────
|
|
142
|
+
*
|
|
143
|
+
* {@link readRecordedTabs} requires `claims.state = 'active'`, and rightly:
|
|
144
|
+
* a lapsed lease's rows are the sweep's business, not reconciliation's. But
|
|
145
|
+
* that is exactly the population that strands. A lease ends, its tab is moved
|
|
146
|
+
* to `closing`, and if the answer is never written back the row is left in a
|
|
147
|
+
* state meaning "waiting for the tool" — attached to a lease that is no
|
|
148
|
+
* longer active, and therefore invisible to every later reconciliation.
|
|
149
|
+
*
|
|
150
|
+
* A store was found holding 22 such rows, the oldest two days old, every one
|
|
151
|
+
* with `close_attempts = 0`. The pages had been closed by hand; the rows
|
|
152
|
+
* could not be reached by anything.
|
|
153
|
+
*
|
|
154
|
+
* ── Why this is safe to settle without asking again ─────────────────────
|
|
155
|
+
*
|
|
156
|
+
* The caller has just asked the browser what it has open, and passes the
|
|
157
|
+
* driver names it answered with. A row whose name is not in that list
|
|
158
|
+
* describes a page this browser does not have, so there is no round trip
|
|
159
|
+
* outstanding and nothing to wait for — the same reasoning
|
|
160
|
+
* {@link applyReconciliation} uses for a vanished page, and the sweep uses
|
|
161
|
+
* for a tab that never opened.
|
|
162
|
+
*
|
|
163
|
+
* **A row whose name IS in the list is left alone.** Its page exists, the
|
|
164
|
+
* close may genuinely still be in flight, and settling it would claim an
|
|
165
|
+
* answer nobody has given.
|
|
166
|
+
*/
|
|
167
|
+
export function settleStrandedTabs(db, browserId, openDriverTabIds, at) {
|
|
168
|
+
const stranded = db
|
|
169
|
+
.prepare(`SELECT tabs.id AS tabId, tabs.driver_tab_id AS driverTabId
|
|
170
|
+
FROM tabs
|
|
171
|
+
JOIN claims ON claims.id = tabs.claim_id
|
|
172
|
+
WHERE tabs.browser_id = ?
|
|
173
|
+
AND tabs.state = 'closing'
|
|
174
|
+
AND claims.state <> 'active'
|
|
175
|
+
ORDER BY tabs.id`)
|
|
176
|
+
.all(browserId);
|
|
177
|
+
const open = new Set(openDriverTabIds);
|
|
178
|
+
const gone = stranded.filter((tab) => tab.driverTabId === null || !open.has(tab.driverTabId));
|
|
179
|
+
if (gone.length === 0) {
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
const placeholders = gone.map(() => '?').join(', ');
|
|
183
|
+
db.prepare(`UPDATE tabs
|
|
184
|
+
SET state = 'closed', closed_at = ?, updated_at = ?
|
|
185
|
+
WHERE id IN (${placeholders})
|
|
186
|
+
AND state = 'closing'`).run(at, at, ...gone.map((tab) => tab.tabId));
|
|
187
|
+
return gone.length;
|
|
188
|
+
}
|
|
135
189
|
/**
|
|
136
190
|
* Settle the rows whose pages are gone, and end the leases that held them.
|
|
137
191
|
*
|
|
@@ -155,6 +209,24 @@ export function readRecordedTabs(db, browserId) {
|
|
|
155
209
|
* | `updateSweptTabs` | The lease ended; is there a page to close? | `closing` — the tool is about to be asked |
|
|
156
210
|
* | this | The page is already gone | `closed` — there is nothing to ask |
|
|
157
211
|
*
|
|
212
|
+
* **`closing` appears in the predicate, and no row reaches it in that
|
|
213
|
+
* state.** The rows here come from {@link readRecordedTabs} by way of
|
|
214
|
+
* {@link decideReconciliation}, and that read requires `tabs.state IN
|
|
215
|
+
* ('opening', 'open')` — so the third value in the predicate below matches
|
|
216
|
+
* nothing this caller can supply. It is kept as a bound on what the write is
|
|
217
|
+
* permitted to touch rather than as a population it serves: the statement
|
|
218
|
+
* says which states this function may move a row out of, and a future caller
|
|
219
|
+
* that widens its own read cannot silently reopen a `closed` row through it.
|
|
220
|
+
*
|
|
221
|
+
* **The stranded-`closing` population is {@link settleStrandedTabs}'s**, not
|
|
222
|
+
* this function's, and the distinction is load-bearing. Those rows belong to
|
|
223
|
+
* leases that have already ended, which is precisely why `readRecordedTabs`
|
|
224
|
+
* (`claims.state = 'active'`) cannot see them and why they need their own
|
|
225
|
+
* pass. A store was found holding 22 of them, each still occupying its slot
|
|
226
|
+
* in the partial unique index on `(browser_id, driver_tab_id)`. Reading that
|
|
227
|
+
* story as this function's would leave the impression the gap is covered
|
|
228
|
+
* here, and it is not.
|
|
229
|
+
*
|
|
158
230
|
* A vanished page has no round trip outstanding, so `closing` would assert
|
|
159
231
|
* one that is not, and the row would wait forever for an answer nobody is
|
|
160
232
|
* coming to give — the exact reasoning the sweep uses for a tab that never
|
|
@@ -193,7 +265,7 @@ export function applyReconciliation(db, vanished, at) {
|
|
|
193
265
|
db.prepare(`UPDATE tabs
|
|
194
266
|
SET state = 'closed', closed_at = ?, updated_at = ?
|
|
195
267
|
WHERE id IN (${tabPlaceholders})
|
|
196
|
-
AND state IN ('opening', 'open')`).run(at, at, ...tabIds);
|
|
268
|
+
AND state IN ('opening', 'open', 'closing')`).run(at, at, ...tabIds);
|
|
197
269
|
// The lease goes with the tab, because a lease *is* a tab (§2.3): a lease
|
|
198
270
|
// whose tab is gone owns nothing while still counting against the budget,
|
|
199
271
|
// which §3.13 names as a state that should not exist.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordTabCloseFailed, recordTabClosed } from "./arbitration.js";
|
|
1
2
|
import { readEnvironment } from "../config/environment.js";
|
|
2
3
|
import { prepareStore } from "../store/open.js";
|
|
3
4
|
import { ArtifactStore } from "../artifacts/store.js";
|
|
@@ -70,6 +71,14 @@ export async function createRuntime(options) {
|
|
|
70
71
|
environment,
|
|
71
72
|
adapter: options.adapter,
|
|
72
73
|
session: browsers.session,
|
|
74
|
+
// **What makes `status` able to tell the truth about a dead browser.**
|
|
75
|
+
// Supplied here rather than defaulted inside the broker for the reason
|
|
76
|
+
// the option's own comment gives: a build that cannot look must report
|
|
77
|
+
// `unknown` rather than claim the browser is fine. This build can look,
|
|
78
|
+
// so it does. Note this is the provider's `liveness`, which asks the
|
|
79
|
+
// operating system — not its memoised session, which is the very thing
|
|
80
|
+
// that keeps presenting a dead browser as a working connection.
|
|
81
|
+
checkBrowser: browsers.liveness,
|
|
73
82
|
artifacts,
|
|
74
83
|
// The same provider closes the tabs the sweep orphaned. Without one,
|
|
75
84
|
// `SCHEMA.md` §2.4b's "a leaked tab is not a leaked lease" describes a
|
|
@@ -79,8 +88,24 @@ export async function createRuntime(options) {
|
|
|
79
88
|
closeTab: async (tab) => {
|
|
80
89
|
const session = await browsers.session(tab.browserId);
|
|
81
90
|
const opened = await resolveDriverTab(store.db, tab.tabId);
|
|
82
|
-
if (opened
|
|
91
|
+
if (opened === undefined) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// **The answer is written down either way.** `closing` means "the tool
|
|
95
|
+
// was asked and has not answered", so a close that returns and is never
|
|
96
|
+
// recorded leaves a row saying that forever — which is what happened,
|
|
97
|
+
// 22 rows deep, until a person noticed his browser had filled with
|
|
98
|
+
// pages no lease owned.
|
|
99
|
+
//
|
|
100
|
+
// A failure is recorded rather than thrown: §2.4b's "a leaked tab is
|
|
101
|
+
// not a leaked lease" means the capacity is already back, and failing
|
|
102
|
+
// the release over the page would fail a call that did its job.
|
|
103
|
+
try {
|
|
83
104
|
await session.closeTab({ browser: tab.browserId, driverTabId: opened });
|
|
105
|
+
recordTabClosed(store.db, tab.tabId, new Date().toISOString());
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
recordTabCloseFailed(store.db, tab.tabId, new Date().toISOString());
|
|
84
109
|
}
|
|
85
110
|
},
|
|
86
111
|
});
|
package/dist/src/service/tabs.js
CHANGED
|
@@ -121,3 +121,73 @@ export function recordTabOpened(db, tabId, driverTabId) {
|
|
|
121
121
|
throw new Error(`Tab ${tabId} was not awaiting an open, so the driver name could not be recorded against it.`);
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* How many tabs on one browser are waiting on a close that will not come.
|
|
126
|
+
*
|
|
127
|
+
* ── Why this is one function and not two ────────────────────────────────
|
|
128
|
+
*
|
|
129
|
+
* `broker doctor` and the claim grant both have to answer *"is this browser
|
|
130
|
+
* carrying a backlog of stranded tabs"*, and a product holding two notions of
|
|
131
|
+
* "stranded" would be the same defect the backlog itself caused: a caller
|
|
132
|
+
* told one number by one surface and a different number by another has no way
|
|
133
|
+
* to tell which is the real one. So the predicate lives here once, and both
|
|
134
|
+
* read it.
|
|
135
|
+
*
|
|
136
|
+
* ── Why the threshold is a lease's own lifetime ─────────────────────────
|
|
137
|
+
*
|
|
138
|
+
* Taken from `doctor`'s existing definition rather than invented alongside
|
|
139
|
+
* it. `closing` means the tool was asked and has not answered — a transient
|
|
140
|
+
* state measured in a round trip. The honest boundary between "a close is in
|
|
141
|
+
* flight" and "a close is never happening" is the one the system already uses
|
|
142
|
+
* to decide a caller is gone: if a lease may be declared lapsed after this
|
|
143
|
+
* long without contact, a round trip outstanding for longer is not pending.
|
|
144
|
+
*
|
|
145
|
+
* The comparison is on `updated_at`, which is when the row was moved to
|
|
146
|
+
* `closing`. A round trip still inside the window is deliberately not
|
|
147
|
+
* counted, because reporting one would make a healthy release look like a
|
|
148
|
+
* fault.
|
|
149
|
+
*/
|
|
150
|
+
export function countStrandedTabsFor(db, browserId, leaseSeconds, at = new Date()) {
|
|
151
|
+
const cutoff = new Date(at.getTime() - leaseSeconds * 1000).toISOString();
|
|
152
|
+
const row = db
|
|
153
|
+
.prepare(`SELECT COUNT(*) AS n
|
|
154
|
+
FROM tabs
|
|
155
|
+
WHERE browser_id = ?
|
|
156
|
+
AND state = 'closing'
|
|
157
|
+
AND updated_at < ?`)
|
|
158
|
+
.get(browserId, cutoff);
|
|
159
|
+
return row?.n ?? 0;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The stranded backlog broken down per browser, heaviest first.
|
|
163
|
+
*
|
|
164
|
+
* ── Why a breakdown rather than a total ─────────────────────────────────
|
|
165
|
+
*
|
|
166
|
+
* A total is not actionable when the remedy is per-browser. `doctor`'s
|
|
167
|
+
* remedy line says to run `broker reconcile` against each browser, but a
|
|
168
|
+
* single total cannot say which ones still need it: an operator who
|
|
169
|
+
* reconciled `regular` and saw the count fall from 29 to 13 reasonably
|
|
170
|
+
* concluded reconcile had not worked, when in fact the remaining 13 were all
|
|
171
|
+
* on `private` and the run had done exactly what it said.
|
|
172
|
+
*
|
|
173
|
+
* **Grouped rather than asked per browser from a configured list**, because
|
|
174
|
+
* the browsers are a configured list per kind and not a fixed pair — a
|
|
175
|
+
* breakdown assembled from the list this build happens to know about would
|
|
176
|
+
* silently omit a backlog on a browser that had been reconfigured away, which
|
|
177
|
+
* is the population most likely to be stranded.
|
|
178
|
+
*
|
|
179
|
+
* Only browsers carrying a backlog appear. A row reading zero is not a
|
|
180
|
+
* finding, and listing every configured browser on every healthy run would
|
|
181
|
+
* bury the one line that matters.
|
|
182
|
+
*/
|
|
183
|
+
export function strandedTabsByBrowser(db, leaseSeconds, at = new Date()) {
|
|
184
|
+
const cutoff = new Date(at.getTime() - leaseSeconds * 1000).toISOString();
|
|
185
|
+
return db
|
|
186
|
+
.prepare(`SELECT browser_id AS browserId, COUNT(*) AS stranded
|
|
187
|
+
FROM tabs
|
|
188
|
+
WHERE state = 'closing'
|
|
189
|
+
AND updated_at < ?
|
|
190
|
+
GROUP BY browser_id
|
|
191
|
+
ORDER BY COUNT(*) DESC, browser_id`)
|
|
192
|
+
.all(cutoff);
|
|
193
|
+
}
|