browser-broker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- package/package.json +58 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { captureFileName } from "../artifacts/names.js";
|
|
3
|
+
import { BrokerError } from "../errors.js";
|
|
4
|
+
import { captureWarning } from "./accounting.js";
|
|
5
|
+
import { decodePng, downscale, encodePng } from "./image.js";
|
|
6
|
+
import { DEFAULT_TIER, REASON_MAXIMUM_LENGTH, REASON_MINIMUM_LENGTH, TIER_LONGEST_EDGE, TIER_REQUIRING_REASON, estimateTokens, } from "./tiers.js";
|
|
7
|
+
/**
|
|
8
|
+
* The escalation guidance, built from the tier names themselves.
|
|
9
|
+
*
|
|
10
|
+
* `SCHEMA.md` §3.11 is specific about what this must say, and the specificity
|
|
11
|
+
* is the point: *"Not merely that higher tiers exist — **which fields to
|
|
12
|
+
* pass**, naming `tier` and its two values, **and that the top tier requires a
|
|
13
|
+
* written reason.**"* A caller that cannot read the fine print out of a
|
|
14
|
+
* specification it does not have open is a caller that either never escalates
|
|
15
|
+
* or escalates by trial and error, and both waste a call.
|
|
16
|
+
*
|
|
17
|
+
* Composed from {@link TIER_LONGEST_EDGE} and {@link TIER_REQUIRING_REASON}
|
|
18
|
+
* rather than written out, so this text always quotes the rungs the pipeline
|
|
19
|
+
* actually shrinks to, whatever the study (#34) sets them to.
|
|
20
|
+
*/
|
|
21
|
+
function escalationGuidance() {
|
|
22
|
+
return (`This is the default resolution (about ${String(TIER_LONGEST_EDGE.default)}px on the long ` +
|
|
23
|
+
`edge), which is what you get for passing no tier. For more detail pass ` +
|
|
24
|
+
`tier="detail" (about ${String(TIER_LONGEST_EDGE.detail)}px). For the most, pass ` +
|
|
25
|
+
`tier="max" (about ${String(TIER_LONGEST_EDGE.max)}px) — and note that "max" additionally ` +
|
|
26
|
+
`requires reason, a written explanation of ${String(REASON_MINIMUM_LENGTH)}–` +
|
|
27
|
+
`${String(REASON_MAXIMUM_LENGTH)} characters in your own words. The reason is recorded for ` +
|
|
28
|
+
`review rather than used to decide anything: escalating is allowed, and saying why is how ` +
|
|
29
|
+
`the default gets tuned.`);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The three argument mistakes §3.11 names, and **the only refusals here.**
|
|
33
|
+
*
|
|
34
|
+
* *"The only refusals are argument mistakes: an unknown or closed tab, the top
|
|
35
|
+
* tier without a reason, and a selector combined with a full page."* The first
|
|
36
|
+
* belongs to the layer that resolves a tab identifier; the other two are this
|
|
37
|
+
* module's.
|
|
38
|
+
*
|
|
39
|
+
* **Nothing in this function considers cost, budget, count or resolution**,
|
|
40
|
+
* and that absence is what `capture.never_refused_for_cost` (§7.3) asserts.
|
|
41
|
+
* Every refusal it can make is about the arguments disagreeing with each
|
|
42
|
+
* other, never about how many pictures anybody has taken.
|
|
43
|
+
*/
|
|
44
|
+
function refuseArgumentMistakes(options) {
|
|
45
|
+
if (options.fullPage === true && options.selector !== undefined) {
|
|
46
|
+
throw new BrokerError('capture.arguments_consistent', 'A selector cannot be combined with a full page: one asks for a single element and the other for the whole document. Pass one or the other.');
|
|
47
|
+
}
|
|
48
|
+
const tier = options.tier ?? DEFAULT_TIER;
|
|
49
|
+
if (tier !== TIER_REQUIRING_REASON) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const reason = options.reason?.trim() ?? '';
|
|
53
|
+
if (reason.length < REASON_MINIMUM_LENGTH || reason.length > REASON_MAXIMUM_LENGTH) {
|
|
54
|
+
throw new BrokerError('capture.reason_required', `tier="${TIER_REQUIRING_REASON}" requires reason: a written explanation of ${String(REASON_MINIMUM_LENGTH)}–${String(REASON_MAXIMUM_LENGTH)} characters, in your own words rather than chosen from a list. It is recorded for review, not used to decide whether to serve the capture — nothing here is refused on cost grounds.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Take one capture.
|
|
59
|
+
*
|
|
60
|
+
* @param capturesTakenBefore how many captures this lease had already taken.
|
|
61
|
+
* Supplied by the layer that counts them, because counting is a query
|
|
62
|
+
* against the store and this module reaches no store.
|
|
63
|
+
*
|
|
64
|
+
* The order below is the specification and is not incidental:
|
|
65
|
+
*
|
|
66
|
+
* 1. **Refuse the argument mistakes first**, before anything physical happens.
|
|
67
|
+
* `DECISIONS.md` §5: *a guard that returns "denied" after the tab has
|
|
68
|
+
* already opened is worse than no guard.* The refusals above run before the
|
|
69
|
+
* driver is touched at all, and the test for each asserts the driver's call
|
|
70
|
+
* log is empty rather than merely that an error was thrown.
|
|
71
|
+
* 2. **Settle the page, then take the picture** (§3.11). Two calls in that
|
|
72
|
+
* order, on the seam, so the ordering is assertable from outside.
|
|
73
|
+
* 3. **Downscale to the tier**, never up.
|
|
74
|
+
* 4. **Write it**, through the artifact store, which is the only thing that
|
|
75
|
+
* decides where a file may go.
|
|
76
|
+
*/
|
|
77
|
+
export async function takeCapture(dependencies, claimId, tab, options, capturesTakenBefore) {
|
|
78
|
+
refuseArgumentMistakes(options);
|
|
79
|
+
const now = dependencies.now ?? (() => new Date());
|
|
80
|
+
const newId = dependencies.newId ?? randomUUID;
|
|
81
|
+
const tier = options.tier ?? DEFAULT_TIER;
|
|
82
|
+
const fullPage = options.fullPage ?? false;
|
|
83
|
+
// Settling comes before the shutter and is a separate call, so that "every
|
|
84
|
+
// capture settles the page first" is one assertion on the driver's call log
|
|
85
|
+
// rather than a claim about a driver's internals (§3.11).
|
|
86
|
+
await dependencies.tabs.settlePage(tab);
|
|
87
|
+
const raw = await dependencies.tabs.capture(tab, {
|
|
88
|
+
fullPage,
|
|
89
|
+
selector: options.selector,
|
|
90
|
+
// Passed straight through to the driver, because masking *before* the
|
|
91
|
+
// pixels exist beats filtering afterwards: a region that was never
|
|
92
|
+
// captured cannot be reported as changed (§3.11).
|
|
93
|
+
mask: options.mask,
|
|
94
|
+
});
|
|
95
|
+
const decoded = decodePng(raw.image);
|
|
96
|
+
const shrunk = downscale(decoded, TIER_LONGEST_EDGE[tier]);
|
|
97
|
+
// Re-encoded only when the pixels actually changed. A picture already inside
|
|
98
|
+
// the rung is written as the browser produced it, which keeps
|
|
99
|
+
// `width === source_width` meaning "nothing was shrunk" without a re-encode
|
|
100
|
+
// silently changing the bytes underneath that claim.
|
|
101
|
+
const encoded = shrunk === decoded ? raw.image : encodePng(shrunk);
|
|
102
|
+
const takenAt = now();
|
|
103
|
+
const id = newId();
|
|
104
|
+
const stored = dependencies.artifacts.write(claimId, 'images', captureFileName({
|
|
105
|
+
// Derived from the address, with the query string stripped first — see
|
|
106
|
+
// `names.ts` for why a file name is held to a stricter rule than a
|
|
107
|
+
// column is.
|
|
108
|
+
url: raw.url,
|
|
109
|
+
label: options.label,
|
|
110
|
+
viewportWidth: raw.viewportWidth,
|
|
111
|
+
takenAt,
|
|
112
|
+
id,
|
|
113
|
+
}), encoded);
|
|
114
|
+
const warning = captureWarning(capturesTakenBefore);
|
|
115
|
+
const kind = options.selector !== undefined ? 'element' : fullPage ? 'full_page' : 'viewport';
|
|
116
|
+
const telemetry = {
|
|
117
|
+
id,
|
|
118
|
+
kind,
|
|
119
|
+
tier,
|
|
120
|
+
// Only ever recorded on the tier that requires it: a reason attached to a
|
|
121
|
+
// capture nobody had to justify would put noise into the one column the
|
|
122
|
+
// resolution study reads (§1.7).
|
|
123
|
+
reason: tier === TIER_REQUIRING_REASON ? options.reason?.trim() : undefined,
|
|
124
|
+
sourceWidth: raw.width,
|
|
125
|
+
sourceHeight: raw.height,
|
|
126
|
+
width: shrunk.width,
|
|
127
|
+
height: shrunk.height,
|
|
128
|
+
bytes: stored.bytes,
|
|
129
|
+
path: stored.relativePath,
|
|
130
|
+
selector: options.selector,
|
|
131
|
+
viewportWidth: raw.viewportWidth,
|
|
132
|
+
url: raw.url,
|
|
133
|
+
warned: warning !== undefined,
|
|
134
|
+
takenAt,
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
captureId: id,
|
|
138
|
+
path: stored.relativePath,
|
|
139
|
+
width: shrunk.width,
|
|
140
|
+
height: shrunk.height,
|
|
141
|
+
bytes: stored.bytes,
|
|
142
|
+
sourceWidth: raw.width,
|
|
143
|
+
sourceHeight: raw.height,
|
|
144
|
+
tier,
|
|
145
|
+
estimatedTokens: estimateTokens(shrunk.width, shrunk.height),
|
|
146
|
+
capturesThisLease: capturesTakenBefore + 1,
|
|
147
|
+
// Present exactly when the caller landed on the default rung, because that
|
|
148
|
+
// is exactly the caller who has not been told what the alternatives are.
|
|
149
|
+
...(tier === DEFAULT_TIER ? { escalation: escalationGuidance() } : {}),
|
|
150
|
+
...(warning !== undefined ? { warning } : {}),
|
|
151
|
+
telemetry,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three resolution rungs, the default, and the estimate of what a picture
|
|
3
|
+
* costs to look at.
|
|
4
|
+
*
|
|
5
|
+
* ── The default is the lever, and this file is where it lives ───────────
|
|
6
|
+
*
|
|
7
|
+
* `DECISIONS.md` §13d reverses the research recommendation, which was a hard
|
|
8
|
+
* service-enforced ceiling with a refusal past a per-lease budget. What ships
|
|
9
|
+
* is a **low default, an explicit opt-in to go higher, and a warning that
|
|
10
|
+
* never becomes a wall** — and the reason is that *most callers never pass an
|
|
11
|
+
* optional parameter*, so a low default does nearly all the work of a ceiling
|
|
12
|
+
* **without blocking anyone**. `MILESTONES.md` #31 puts it plainly: this row
|
|
13
|
+
* carries the lever, not #33.
|
|
14
|
+
*
|
|
15
|
+
* The property underneath, stated as a property because a property survives
|
|
16
|
+
* somebody raising the default and a coincidence does not: **text legibility
|
|
17
|
+
* breaks at a higher resolution than layout critique does.** So a low default
|
|
18
|
+
* naturally pushes a caller that needs to *read* something toward the snapshot
|
|
19
|
+
* or the evaluation, which return text and cost almost nothing. The policy does
|
|
20
|
+
* not have to argue anyone into the cheaper tool.
|
|
21
|
+
*
|
|
22
|
+
* ── These numbers have been MEASURED, and they survived it ──────────────
|
|
23
|
+
*
|
|
24
|
+
* `MILESTONES.md` #34's resolution-ladder study has been run — the harness is
|
|
25
|
+
* `ladder.ts`, the instruments are `legibility.ts`, and the measurements are
|
|
26
|
+
* `tests/capture/ladder.test.ts` (everywhere) and
|
|
27
|
+
* `tests/capture/ladder-rendered.test.ts` (where a browser exists).
|
|
28
|
+
* **The measurement kept all three rungs below**, and the evidence for each is
|
|
29
|
+
* published beside it. A change to any of them needs a measurement rather than
|
|
30
|
+
* an argument.
|
|
31
|
+
*
|
|
32
|
+
* **The property the default rests on held.** *Text legibility breaks at a
|
|
33
|
+
* higher resolution than layout critique does* is now measured rather than
|
|
34
|
+
* asserted, and the mechanism is nameable: a downscale destroys a feature when
|
|
35
|
+
* the feature's period falls below roughly two and a half **destination**
|
|
36
|
+
* pixels, so what a rung costs a picture depends on the size of the feature
|
|
37
|
+
* rather than on the rung. A block-scale feature — the scale a layout
|
|
38
|
+
* judgement is made at — survives every rung on this ladder, including well
|
|
39
|
+
* below the cheapest one. Fine text detail does not.
|
|
40
|
+
*
|
|
41
|
+
* Stem retention on rendered prose, at a viewport wide enough that all three
|
|
42
|
+
* rungs genuinely shrink:
|
|
43
|
+
*
|
|
44
|
+
* | Font | `default` | `detail` | `max` |
|
|
45
|
+
* |---|---|---|---|
|
|
46
|
+
* | 11px | 35% | 41% | 100% |
|
|
47
|
+
* | 12px | 24% | 50% | 100% |
|
|
48
|
+
* | 14px | 7% | 83% | 100% |
|
|
49
|
+
* | 16px | 63% | 93% | 100% |
|
|
50
|
+
* | 20px | 87% | 100% | 100% |
|
|
51
|
+
* | 32px | 98% | 100% | 100% |
|
|
52
|
+
*
|
|
53
|
+
* Each rung does the job its name claims: **`max` returns everything**, which
|
|
54
|
+
* is why it is the one that costs a written reason; **`detail` recovers
|
|
55
|
+
* ordinary body copy**; and **`default` keeps headings and layout intact while
|
|
56
|
+
* damaging small body copy** — which is not a defect but the lever working,
|
|
57
|
+
* since a caller that needs to *read* something is pushed toward the snapshot
|
|
58
|
+
* or the evaluation, which return text and cost almost nothing.
|
|
59
|
+
*
|
|
60
|
+
* ── ⚠️ What the study did NOT settle ────────────────────────────────────
|
|
61
|
+
*
|
|
62
|
+
* **The absolute legibility floor is still open**, and it is worth being exact
|
|
63
|
+
* about why. The instruments measure what the *pipeline* destroys — stroke
|
|
64
|
+
* contrast, and whether the gaps between strokes survive — which **bounds**
|
|
65
|
+
* what any reader could recover but does not predict what one will. What an
|
|
66
|
+
* agent looking at a picture can actually read is a property of that model,
|
|
67
|
+
* not of these pixels, and no test here can establish it. So the rungs are
|
|
68
|
+
* settled as *"these deliver the structure they claim to"*, not as *"this is
|
|
69
|
+
* the smallest picture a reader can use"*. §9.3 keeps that second question
|
|
70
|
+
* open.
|
|
71
|
+
*
|
|
72
|
+
* They remain named constants in one module: **nothing hard-codes a rung
|
|
73
|
+
* anywhere a later study cannot change it**, and a capture records which rung
|
|
74
|
+
* it was taken at (`captures.tier`) rather than having its rung inferred from
|
|
75
|
+
* its dimensions, so a rung moving invalidates nothing already stored (§6.2).
|
|
76
|
+
*
|
|
77
|
+
* ── The token estimate is fixed by the version, deliberately ────────────
|
|
78
|
+
*
|
|
79
|
+
* `SCHEMA.md` §6.4: the formula that estimates an image's token cost is fixed
|
|
80
|
+
* by the version and **is not configurable**. *"An estimate is only comparable
|
|
81
|
+
* across time if it was computed the same way, and letting an operator change
|
|
82
|
+
* the formula would silently make old and new numbers incomparable — which
|
|
83
|
+
* would break the one study they exist for."* So there is no environment
|
|
84
|
+
* variable here and no parameter: {@link estimateTokens} takes dimensions and
|
|
85
|
+
* nothing else.
|
|
86
|
+
*/
|
|
87
|
+
/**
|
|
88
|
+
* What a caller gets for asking for nothing.
|
|
89
|
+
*
|
|
90
|
+
* The single most consequential value in this milestone — `MILESTONES.md`
|
|
91
|
+
* #31: *"getting 'cheapest tier when nothing is asked for' right matters more
|
|
92
|
+
* than any threshold downstream of it."*
|
|
93
|
+
*/
|
|
94
|
+
export const DEFAULT_TIER = 'default';
|
|
95
|
+
/**
|
|
96
|
+
* The tier that costs a written reason.
|
|
97
|
+
*
|
|
98
|
+
* Named rather than spelled `'max'` at each site that checks it, so that the
|
|
99
|
+
* rule *"the top tier requires a reason"* has one definition and the message
|
|
100
|
+
* telling a caller so cannot drift from the check that enforces it.
|
|
101
|
+
*/
|
|
102
|
+
export const TIER_REQUIRING_REASON = 'max';
|
|
103
|
+
/**
|
|
104
|
+
* The long edge each rung shrinks to, in pixels (`SCHEMA.md` §6.2,
|
|
105
|
+
* `DECISIONS.md` §13d). **Measured by #34 and kept** — see the evidence table
|
|
106
|
+
* in this file's header.
|
|
107
|
+
*
|
|
108
|
+
* | Tier | Long edge | How a caller gets it | What the study measured it delivering |
|
|
109
|
+
* |---|---|---|---|
|
|
110
|
+
* | `default` | 1024 | passes nothing | layout and headings intact; small body copy damaged |
|
|
111
|
+
* | `detail` | 1568 | asks for it — the ceiling of the cheap vision tier | ordinary body copy recovered |
|
|
112
|
+
* | `max` | 2576 | asks for it **and gives a written reason**, which is recorded | everything, at every font size tested |
|
|
113
|
+
*/
|
|
114
|
+
export const TIER_LONGEST_EDGE = {
|
|
115
|
+
default: 1024,
|
|
116
|
+
detail: 1568,
|
|
117
|
+
max: 2576,
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* The bounds on a written reason (`SCHEMA.md` §3.11: 8–200 characters).
|
|
121
|
+
*
|
|
122
|
+
* **The minimum is not a deterrent and must not be tuned as one.** §3.11 is
|
|
123
|
+
* explicit: *a caller asked to justify itself will always produce a
|
|
124
|
+
* justification*, so the friction is not the mechanism, and making the field
|
|
125
|
+
* longer or the wording sterner pursues an effect it was never going to have.
|
|
126
|
+
* **The value is the record** — every escalation leaves a reviewable row with
|
|
127
|
+
* a reason attached. The minimum exists only to make the empty answer
|
|
128
|
+
* slightly harder to give than a real one.
|
|
129
|
+
*/
|
|
130
|
+
export const REASON_MINIMUM_LENGTH = 8;
|
|
131
|
+
export const REASON_MAXIMUM_LENGTH = 200;
|
|
132
|
+
/**
|
|
133
|
+
* How many captures a lease may take before every subsequent one carries a
|
|
134
|
+
* warning (`SCHEMA.md` §6.2: **12**).
|
|
135
|
+
*
|
|
136
|
+
* *"Roughly a five-view sweep at two breakpoints plus slack."* **Never a
|
|
137
|
+
* refusal** — see `accounting.ts`, which is where that promise is kept and
|
|
138
|
+
* where the reason it can never become a wall is written down.
|
|
139
|
+
*/
|
|
140
|
+
export const CAPTURES_BEFORE_WARNING = 12;
|
|
141
|
+
/**
|
|
142
|
+
* The divisor in the token estimate. **Fixed by the version** (§6.4).
|
|
143
|
+
*
|
|
144
|
+
* `SCHEMA.md` §1.7 declines to store the result: *"It is width times height
|
|
145
|
+
* divided by a fixed constant — a calculation over two columns on the same
|
|
146
|
+
* row … computed when asked for rather than frozen into a column that could
|
|
147
|
+
* disagree with the dimensions beside it."*
|
|
148
|
+
*
|
|
149
|
+
* The constant is consistent with the measured figures `DECISIONS.md` records
|
|
150
|
+
* — roughly 1,600 tokens at the 1568-pixel rung and roughly 4,800 at the
|
|
151
|
+
* 2576-pixel one — and it is an **estimate**, which is the word used
|
|
152
|
+
* everywhere on purpose. `captures.bytes` is what it is sanity-checked
|
|
153
|
+
* against (§1.7): a file whose size is wildly out of step with its dimensions
|
|
154
|
+
* is the signal that a picture was not what the numbers said.
|
|
155
|
+
*/
|
|
156
|
+
const TOKENS_PER_PIXEL_DIVISOR = 750;
|
|
157
|
+
/**
|
|
158
|
+
* What looking at a picture of these dimensions is estimated to cost.
|
|
159
|
+
*
|
|
160
|
+
* Takes dimensions and nothing else — no tier, no configuration, no options
|
|
161
|
+
* object with a divisor in it. That signature is the enforcement of §6.4:
|
|
162
|
+
* there is no position in which an operator's value could arrive.
|
|
163
|
+
*/
|
|
164
|
+
export function estimateTokens(width, height) {
|
|
165
|
+
return Math.ceil((width * height) / TOKENS_PER_PIXEL_DIVISOR);
|
|
166
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { OPERATION_COMMANDS } from "./commands.js";
|
|
2
|
+
/**
|
|
3
|
+
* The command line as an adapter: a thin shell over one service call.
|
|
4
|
+
*
|
|
5
|
+
* `SCHEMA.md` §5.1: "The command line is a full route in, and it is worth
|
|
6
|
+
* building even if no agent ever calls it. It is the cheapest available proof
|
|
7
|
+
* that the rules live in one place rather than inside a tool handler — a rule
|
|
8
|
+
* inside a handler is a rule that holds on one route and nowhere else."
|
|
9
|
+
*
|
|
10
|
+
* ── What this file may and may not do ───────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* It resolves an argument vector into arguments, calls **one** operation, and
|
|
13
|
+
* shapes the outcome for a terminal. It reaches no database and no guard: it
|
|
14
|
+
* is handed a {@link BrokerService} rather than finding one, so there is no
|
|
15
|
+
* store handle in scope to be tempted by. Every rule that decides whether an
|
|
16
|
+
* operation is allowed lives behind that seam, or it holds on this route and
|
|
17
|
+
* not the others (`CLAUDE.md`).
|
|
18
|
+
*
|
|
19
|
+
* §5.2 is the consequence worth stating because it surprises people once: in
|
|
20
|
+
* process, **any command that goes through arbitration performs the lazy
|
|
21
|
+
* sweep** — so a listing command can close somebody else's lapsed tabs. That
|
|
22
|
+
* is correct, and it is why no command reads the tables directly.
|
|
23
|
+
*/
|
|
24
|
+
/** Exit codes, chosen so opposite responses are distinguishable (§5.6). */
|
|
25
|
+
export const EXIT = {
|
|
26
|
+
/**
|
|
27
|
+
* Accepted — **including queued**. §5.6 is explicit: "queuing is an
|
|
28
|
+
* outcome, not a failure". A caller that treated a queue place as an error
|
|
29
|
+
* would abandon exactly the wait the queue exists to make orderly.
|
|
30
|
+
*/
|
|
31
|
+
accepted: 0,
|
|
32
|
+
/** Something this service did not anticipate. */
|
|
33
|
+
unexpected: 1,
|
|
34
|
+
/** The command itself was malformed. */
|
|
35
|
+
malformed: 2,
|
|
36
|
+
/**
|
|
37
|
+
* Refused by a rule — distinct, "because a refusal is the service
|
|
38
|
+
* working". A caller can retry a refusal intelligently; it cannot retry a
|
|
39
|
+
* typo.
|
|
40
|
+
*/
|
|
41
|
+
refused: 3,
|
|
42
|
+
/** Not configured. */
|
|
43
|
+
notConfigured: 4,
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Fields never printed by any command, on any stream, in any mode.
|
|
47
|
+
*
|
|
48
|
+
* §5.6: "**The lease key is never printed by any command**, including in
|
|
49
|
+
* error output and in the machine-readable mode, where the field is absent
|
|
50
|
+
* rather than masked." Absent rather than masked is the specification — a
|
|
51
|
+
* masked field tells a reader a key exists and is being withheld, which is an
|
|
52
|
+
* invitation, and a masked value is one careless format change away from
|
|
53
|
+
* being the real one.
|
|
54
|
+
*/
|
|
55
|
+
export const NEVER_PRINTED = ['lease_key', 'leaseKey', 'key'];
|
|
56
|
+
/**
|
|
57
|
+
* Strip anything that must never be printed, at every depth.
|
|
58
|
+
*
|
|
59
|
+
* Applied on the way out rather than trusted not to be included, because the
|
|
60
|
+
* shape of an operation's result belongs to the operation and this route
|
|
61
|
+
* cannot know what a later row will put in one. A rule enforced at the exit
|
|
62
|
+
* holds for results that did not exist when it was written.
|
|
63
|
+
*/
|
|
64
|
+
export function withoutSecrets(value) {
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
return value.map((entry) => withoutSecrets(entry));
|
|
67
|
+
}
|
|
68
|
+
if (value !== null && typeof value === 'object') {
|
|
69
|
+
const output = {};
|
|
70
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
71
|
+
if (NEVER_PRINTED.includes(key)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
output[key] = withoutSecrets(entry);
|
|
75
|
+
}
|
|
76
|
+
return output;
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Turn the words after a command into arguments.
|
|
82
|
+
*
|
|
83
|
+
* `--name value` and `--name=value`, plus bare `--flag` for a boolean. No
|
|
84
|
+
* argument-parsing library: the manifest carries exactly one runtime
|
|
85
|
+
* dependency and a command line of this shape does not need a second
|
|
86
|
+
* (`MILESTONES.md`'s binding, and a framework here would be a design
|
|
87
|
+
* regression rather than a convenience).
|
|
88
|
+
*
|
|
89
|
+
* Keys are normalised from the terminal's spelling to the service's:
|
|
90
|
+
* `--session-id` becomes `session_id`, so a caller types what a terminal
|
|
91
|
+
* reads and the service receives what §3 names.
|
|
92
|
+
*
|
|
93
|
+
* ── Why a value is not rejected merely for starting with `--` ───────────
|
|
94
|
+
*
|
|
95
|
+
* A lease key is 32 random bytes rendered as base64url, and **the base64url
|
|
96
|
+
* alphabet contains `-`**, so about one key in 6,250 begins with `--`. Read
|
|
97
|
+
* as "the next word looks like a flag, so this one is a boolean", such a key
|
|
98
|
+
* is swallowed: `lease_key` arrives as `true`, the key never reaches the
|
|
99
|
+
* service, and the caller is told its key is missing — for a key it holds and
|
|
100
|
+
* typed correctly. It is a real defect on the shipped route rather than a
|
|
101
|
+
* curiosity, because there is no way for that caller to succeed and nothing
|
|
102
|
+
* in the message points at the cause.
|
|
103
|
+
*
|
|
104
|
+
* So "looks like a flag" is decided by **shape rather than by the leading
|
|
105
|
+
* dashes**: a flag's name is lower-case letters, digits and hyphens, which is
|
|
106
|
+
* every option this command line actually has. A base64url key contains
|
|
107
|
+
* upper-case letters or underscores, or is far longer than any option name,
|
|
108
|
+
* so it fails that test and is correctly read as a value. A genuine adjacent
|
|
109
|
+
* flag — `broker read --lease-key K --json` — still parses as two arguments,
|
|
110
|
+
* because `json` matches the shape.
|
|
111
|
+
*
|
|
112
|
+
* This is deliberately a **narrow** rule and not an argument-parsing library:
|
|
113
|
+
* it separates the two cases that actually occur without adding a dependency
|
|
114
|
+
* the manifest's one-runtime-dependency binding forbids.
|
|
115
|
+
*/
|
|
116
|
+
export function parseArguments(rest) {
|
|
117
|
+
const parsed = {};
|
|
118
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
119
|
+
const word = rest[index];
|
|
120
|
+
if (word === undefined || !word.startsWith('--')) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const body = word.slice(2);
|
|
124
|
+
const equals = body.indexOf('=');
|
|
125
|
+
if (equals !== -1) {
|
|
126
|
+
parsed[normaliseKey(body.slice(0, equals))] = body.slice(equals + 1);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const next = rest[index + 1];
|
|
130
|
+
if (next === undefined || looksLikeFlag(next)) {
|
|
131
|
+
parsed[normaliseKey(body)] = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
parsed[normaliseKey(body)] = next;
|
|
135
|
+
index += 1;
|
|
136
|
+
}
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
function normaliseKey(key) {
|
|
140
|
+
return key.replaceAll('-', '_');
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Whether a word is an option rather than a value that begins with dashes.
|
|
144
|
+
*
|
|
145
|
+
* See {@link parseArguments} for why the leading `--` cannot decide this on
|
|
146
|
+
* its own. The name after the dashes must look like an option name: lower-case
|
|
147
|
+
* letters and digits, hyphen-separated, and non-empty. `--json` and
|
|
148
|
+
* `--full-page` match; a base64url lease key such as
|
|
149
|
+
* `--SgvBJ5qVwX2T8B9XEhfQHsD2iZ2maYPC0sflBlFjg` does not, because of its
|
|
150
|
+
* upper-case letters.
|
|
151
|
+
*/
|
|
152
|
+
function looksLikeFlag(word) {
|
|
153
|
+
if (!word.startsWith('--')) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
const body = word.slice(2);
|
|
157
|
+
// `--name=value` is unambiguously an option however its value is spelled.
|
|
158
|
+
const name = body.indexOf('=') === -1 ? body : body.slice(0, body.indexOf('='));
|
|
159
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The waivers this route carries (§5.5).
|
|
163
|
+
*
|
|
164
|
+
* Every one of these is a **command that exists** with no service operation
|
|
165
|
+
* behind it, which is why it is a written waiver rather than an absence: the
|
|
166
|
+
* command line genuinely offers `snapshot`, `doctor`, `login` and `init`, and
|
|
167
|
+
* none of them is a §3 operation to be at parity about.
|
|
168
|
+
*
|
|
169
|
+
* They do not name §3 operations, so nothing here is exempting this route
|
|
170
|
+
* from an operation it should offer — the waiver rule in the runner refuses
|
|
171
|
+
* that, and this list is deliberately empty of operation names for that
|
|
172
|
+
* reason. It is kept as prose the reviewer can read rather than being
|
|
173
|
+
* discarded, because "quietly absent from the matrix" is the failure
|
|
174
|
+
* `MILESTONES.md` names.
|
|
175
|
+
*/
|
|
176
|
+
export const CLI_COMMAND_WAIVERS = [
|
|
177
|
+
{
|
|
178
|
+
command: 'snapshot',
|
|
179
|
+
reason: 'Writes the operations document and exits. It performs no service operation and refuses nothing, so it has nothing to be at parity with.',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
command: 'doctor',
|
|
183
|
+
reason: 'Reports preconditions and exits with a distinct code. It is a readiness check rather than an operation, and refuses no caller.',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
command: 'login',
|
|
187
|
+
reason: 'Hands a browser to a person to sign in to. A person drives it, no lease is granted, and the tool surface deliberately has no equivalent.',
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
command: 'init',
|
|
191
|
+
reason: 'Shows the setup handshake report. Every spawn performs the handshake anyway, so the command causes no effect of its own.',
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
/**
|
|
195
|
+
* Operations this route does not offer, with the reason each is absent.
|
|
196
|
+
*
|
|
197
|
+
* **Empty, and that is the claim.** §5.3: every §3 operation has a command,
|
|
198
|
+
* so parity is real rather than claimed. The array exists so that a later row
|
|
199
|
+
* removing a command has somewhere to write down why — and so the runner's
|
|
200
|
+
* waiver rule has something to check rather than an absence to interpret.
|
|
201
|
+
*/
|
|
202
|
+
export const CLI_OPERATION_WAIVERS = [];
|
|
203
|
+
/** Every operation this route offers — all ten, from the command table. */
|
|
204
|
+
export const CLI_OPERATIONS = OPERATION_COMMANDS.map((command) => command.operation);
|
|
205
|
+
/**
|
|
206
|
+
* The command-line adapter.
|
|
207
|
+
*
|
|
208
|
+
* `readOnly` is false because this route performs writes, and that
|
|
209
|
+
* declaration is what makes the waiver rule bite: a route exposing a write
|
|
210
|
+
* operation may not waive an operation any rule can refuse
|
|
211
|
+
* (`MILESTONES.md`). Declaring it read-only to buy waivers would be the
|
|
212
|
+
* loophole, so the declaration is made honestly and the waiver list is empty.
|
|
213
|
+
*/
|
|
214
|
+
export const cliAdapter = {
|
|
215
|
+
id: 'cli',
|
|
216
|
+
description: 'The command line. In process, because there is nothing else for a command to talk to.',
|
|
217
|
+
readOnly: false,
|
|
218
|
+
operations: CLI_OPERATIONS,
|
|
219
|
+
waivers: CLI_OPERATION_WAIVERS,
|
|
220
|
+
invoke: async (service, operation, input) => {
|
|
221
|
+
// The route's own vocabulary is an argument vector. Anything else is a
|
|
222
|
+
// caller reaching past the transport, so it is refused rather than
|
|
223
|
+
// coerced — a shell only ever produces an array of strings.
|
|
224
|
+
if (!Array.isArray(input) || input.some((word) => typeof word !== 'string')) {
|
|
225
|
+
throw new TypeError('the command-line adapter takes an argument vector');
|
|
226
|
+
}
|
|
227
|
+
return service.perform({
|
|
228
|
+
operation,
|
|
229
|
+
adapter: 'cli',
|
|
230
|
+
arguments: parseArguments(input),
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
};
|