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,137 @@
|
|
|
1
|
+
import { DEFAULT_BROWSER_IDS } from "../browser/driver.js";
|
|
2
|
+
import { append } from "../service/events.js";
|
|
3
|
+
import { applyReconciliation, decideReconciliation, readRecordedTabs, } from "../service/reconcile.js";
|
|
4
|
+
import { COMMAND_EXIT, parseFlags } from "./operations-commands.js";
|
|
5
|
+
/** Timestamps are spelled one way in this store. */
|
|
6
|
+
function now() {
|
|
7
|
+
return new Date().toISOString();
|
|
8
|
+
}
|
|
9
|
+
function isBrowserId(value, browsers) {
|
|
10
|
+
return browsers.includes(value);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Run one reconciliation against one browser.
|
|
14
|
+
*
|
|
15
|
+
* **One browser per invocation, named explicitly.** There is no `--all`, and
|
|
16
|
+
* the absence is deliberate in the direction §3.13 keeps pointing: the more
|
|
17
|
+
* of the installation one command touches, the worse a mistake in it is. A
|
|
18
|
+
* person who wants both runs it twice and reads two reports.
|
|
19
|
+
*/
|
|
20
|
+
export async function runReconcileCommand(rest, options) {
|
|
21
|
+
const flags = parseFlags(rest);
|
|
22
|
+
const named = rest.find((word) => !word.startsWith('--'));
|
|
23
|
+
const browser = typeof flags.browser === 'string' ? flags.browser : named;
|
|
24
|
+
const browsers = options.browsers ?? DEFAULT_BROWSER_IDS;
|
|
25
|
+
if (browser === undefined) {
|
|
26
|
+
options.streams.err(`broker reconcile needs to be told which browser: ${browsers.join(' or ')}. It asks that browser what it has open, closes pages no live lease owns, and settles rows whose pages are gone.`);
|
|
27
|
+
return COMMAND_EXIT.malformed;
|
|
28
|
+
}
|
|
29
|
+
if (!isBrowserId(browser, browsers)) {
|
|
30
|
+
options.streams.err(`There is no browser named ${JSON.stringify(browser)}. This service manages ${browsers.join(' and ')}.`);
|
|
31
|
+
return COMMAND_EXIT.malformed;
|
|
32
|
+
}
|
|
33
|
+
if (options.session === undefined) {
|
|
34
|
+
// Refused rather than reported as a clean run. A reconciliation with no
|
|
35
|
+
// browser to ask would find every recorded page absent and settle every
|
|
36
|
+
// live lease on that browser — the most destructive possible outcome,
|
|
37
|
+
// arrived at by asking nothing.
|
|
38
|
+
options.streams.err('refused (browser.unreachable): reconciliation has to ask a live browser what it has open, and no browser connection was available to this command.');
|
|
39
|
+
return COMMAND_EXIT.refused;
|
|
40
|
+
}
|
|
41
|
+
// ── 1. What the store holds ───────────────────────────────────────────
|
|
42
|
+
const recorded = readRecordedTabs(options.db, browser);
|
|
43
|
+
// ── 2. What the browser says. The asking, in the open, outside every
|
|
44
|
+
// transaction (§2.4b). The keeper tab is not in this list, because
|
|
45
|
+
// `listTabs` excludes it (§3.15) — which is what stops the next line
|
|
46
|
+
// deciding the browser's own life-support page is unowned.
|
|
47
|
+
const session = await options.session(browser);
|
|
48
|
+
const pages = await session.listTabs();
|
|
49
|
+
// ── 3. Deciding. Pure: two lists in, a plan out, no handle held.
|
|
50
|
+
const plan = decideReconciliation(pages, recorded);
|
|
51
|
+
// ── 4. Writing. A database handle and no session.
|
|
52
|
+
const at = now();
|
|
53
|
+
applyReconciliation(options.db, plan.vanishedTabs, at);
|
|
54
|
+
for (const tab of plan.vanishedTabs) {
|
|
55
|
+
// §1.6: one row per decision, and this is a decision — a lease was ended
|
|
56
|
+
// by something that was neither the caller nor the clock. `cli` rather
|
|
57
|
+
// than `internal` because a person ran this (§4.3).
|
|
58
|
+
append(options.db, {
|
|
59
|
+
kind: 'claim_revoked',
|
|
60
|
+
outcome: 'allow',
|
|
61
|
+
adapter: 'cli',
|
|
62
|
+
claimId: tab.claimId,
|
|
63
|
+
tabId: tab.tabId,
|
|
64
|
+
browserId: browser,
|
|
65
|
+
// No driver name in the detail. §1.4 keeps it out of anything a caller
|
|
66
|
+
// reads, and the ledger is read back by `broker events`.
|
|
67
|
+
detail: { reason: 'tab_not_open_in_browser', reconciledAt: at },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// ── 5. Closing. Browser work, last, and best effort (§2.4b): a page that
|
|
71
|
+
// will not close is a leaked page, and the run still succeeded at
|
|
72
|
+
// everything else it did.
|
|
73
|
+
let closed = 0;
|
|
74
|
+
let closeFailures = 0;
|
|
75
|
+
for (const page of plan.unownedPages) {
|
|
76
|
+
try {
|
|
77
|
+
await session.closeTab({ browser, driverTabId: page.driverTabId });
|
|
78
|
+
closed += 1;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Swallowed, and counted. The count is what makes this visible without
|
|
82
|
+
// naming the page — a driver name is never printed (§1.4).
|
|
83
|
+
closeFailures += 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const report = {
|
|
87
|
+
pagesSeen: pages.length,
|
|
88
|
+
settled: plan.vanishedTabs.map((tab) => tab.tabId),
|
|
89
|
+
closed,
|
|
90
|
+
closeFailures,
|
|
91
|
+
skippedOpening: plan.skippedOpening.length,
|
|
92
|
+
};
|
|
93
|
+
if (options.json) {
|
|
94
|
+
options.streams.out(JSON.stringify({
|
|
95
|
+
browser,
|
|
96
|
+
pages_seen: report.pagesSeen,
|
|
97
|
+
settled: report.settled,
|
|
98
|
+
closed: report.closed,
|
|
99
|
+
close_failures: report.closeFailures,
|
|
100
|
+
skipped_opening: report.skippedOpening,
|
|
101
|
+
}));
|
|
102
|
+
return COMMAND_EXIT.accepted;
|
|
103
|
+
}
|
|
104
|
+
for (const line of formatReconciliation(browser, report)) {
|
|
105
|
+
options.streams.out(line);
|
|
106
|
+
}
|
|
107
|
+
return COMMAND_EXIT.accepted;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The report a person reads.
|
|
111
|
+
*
|
|
112
|
+
* **Every line is a count or an opaque identifier**, which is §1.4's rule
|
|
113
|
+
* made true by there being nothing else available to print: the report type
|
|
114
|
+
* carries no driver name, so this function could not print one if it tried.
|
|
115
|
+
*/
|
|
116
|
+
export function formatReconciliation(browser, report) {
|
|
117
|
+
const lines = [
|
|
118
|
+
`reconciled: ${browser}`,
|
|
119
|
+
`pages open, not counting the keeper: ${String(report.pagesSeen)}`,
|
|
120
|
+
`pages closed because no live lease owned them: ${String(report.closed)}`,
|
|
121
|
+
`leases ended because their page was gone: ${String(report.settled.length)}`,
|
|
122
|
+
];
|
|
123
|
+
for (const tabId of report.settled) {
|
|
124
|
+
lines.push(` tab ${tabId}`);
|
|
125
|
+
}
|
|
126
|
+
if (report.closeFailures > 0) {
|
|
127
|
+
// §2.4b: a leaked page, not a leaked lease. Said in those terms so the
|
|
128
|
+
// reader knows what it costs — memory, and not budget.
|
|
129
|
+
lines.push(`${String(report.closeFailures)} page(s) would not close. That is a leaked page and not a leaked lease: the budget is unaffected, and \`broker doctor\` reports them.`);
|
|
130
|
+
}
|
|
131
|
+
if (report.skippedOpening > 0) {
|
|
132
|
+
// Said on the run it happened on, because the alternative is a person
|
|
133
|
+
// reading "0 closed" and concluding there was nothing to close.
|
|
134
|
+
lines.push(`${String(report.skippedOpening)} tab(s) are still being opened, so nothing was closed on this run — a page seen now may belong to one of them. Run again once they have settled.`);
|
|
135
|
+
}
|
|
136
|
+
return lines;
|
|
137
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { profileDirectory } from "../browser/discovery.js";
|
|
3
|
+
import { SIGNABLE_BROWSER } from "../service/operations/sign-in.js";
|
|
4
|
+
/**
|
|
5
|
+
* `broker login` — the words a person reads while they are signing in.
|
|
6
|
+
*
|
|
7
|
+
* ── Why the prose is a module and not a template literal in the handler ──
|
|
8
|
+
*
|
|
9
|
+
* This is the **one command whose output is the product**. Every other
|
|
10
|
+
* command reports what happened; this one asks a person to do something, and
|
|
11
|
+
* if they do the wrong thing there is no error to read — a sign-in performed
|
|
12
|
+
* against the wrong profile succeeds, looks identical, and is discarded. So
|
|
13
|
+
* the text is here, in one place, where it can be asserted by a test rather
|
|
14
|
+
* than reviewed by eye.
|
|
15
|
+
*
|
|
16
|
+
* What each line has to carry, and why each is load-bearing:
|
|
17
|
+
*
|
|
18
|
+
* - **Which window is theirs.** A person with several browser windows open
|
|
19
|
+
* needs to know which one this opened, and the answer is "the one that just
|
|
20
|
+
* appeared" only for as long as they are looking at the screen.
|
|
21
|
+
* - **That closing it ends the step.** Otherwise they leave it open, and the
|
|
22
|
+
* command that would confirm the sign-in refuses because a browser is still
|
|
23
|
+
* running against the profile.
|
|
24
|
+
* - **That it is a real sign-in against a real site.** Nothing here is a
|
|
25
|
+
* sandbox: what they type goes to whoever they are signing in to, exactly as
|
|
26
|
+
* it would in their own browser.
|
|
27
|
+
* - **That nothing here records what they type.** Said plainly because the
|
|
28
|
+
* opposite is a reasonable assumption about a tool that opened the window,
|
|
29
|
+
* and a person who assumes it might reasonably decline to use it.
|
|
30
|
+
*/
|
|
31
|
+
/** The heading, so the window and the terminal are unambiguously paired. */
|
|
32
|
+
export const SIGN_IN_HEADING = 'A browser window is open for you to sign in.';
|
|
33
|
+
/**
|
|
34
|
+
* What a person does, in order.
|
|
35
|
+
*
|
|
36
|
+
* Numbered rather than prose because it is a procedure someone follows while
|
|
37
|
+
* looking at something else.
|
|
38
|
+
*/
|
|
39
|
+
export function signInInstructions(browser, profileRelativePath) {
|
|
40
|
+
return [
|
|
41
|
+
SIGN_IN_HEADING,
|
|
42
|
+
'',
|
|
43
|
+
` 1. Switch to the browser window that just opened. It is the ${browser} browser,`,
|
|
44
|
+
` running against the profile at ${profileRelativePath} under the configured`,
|
|
45
|
+
' profile root — which is the profile every caller will share.',
|
|
46
|
+
' 2. Go to whichever site you want this service to be signed in to, and sign in',
|
|
47
|
+
' normally. This is a real browser and a real sign-in: what you type goes to',
|
|
48
|
+
' that site exactly as it would in your own browser.',
|
|
49
|
+
' 3. Close the window when you are done. Closing it is what ends this step.',
|
|
50
|
+
'',
|
|
51
|
+
'While this is happening the browser is not serving callers. Anything that asks',
|
|
52
|
+
'for it is told a person is signing in and to try again shortly; anything already',
|
|
53
|
+
'waiting in the queue keeps its place and its timer.',
|
|
54
|
+
'',
|
|
55
|
+
'Nothing here records what you type. The sign-in is written into the browser’s',
|
|
56
|
+
'own profile directory by the browser itself — this service never sees a',
|
|
57
|
+
'credential, and stores nothing about one anywhere.',
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
/** What a person is told once the window has closed and the browser is back. */
|
|
61
|
+
export function signInCompletion(browser, queueDepth) {
|
|
62
|
+
const lines = [
|
|
63
|
+
`The window has closed and the ${browser} browser is serving again.`,
|
|
64
|
+
'',
|
|
65
|
+
'Confirm the sign-in took with:',
|
|
66
|
+
'',
|
|
67
|
+
' broker doctor',
|
|
68
|
+
'',
|
|
69
|
+
'It reports whether the profile carries a session, without opening a browser.',
|
|
70
|
+
];
|
|
71
|
+
if (queueDepth > 0) {
|
|
72
|
+
lines.push('', `${String(queueDepth)} caller(s) were queued while you signed in and kept their places.`);
|
|
73
|
+
}
|
|
74
|
+
return lines;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The absolute profile directory the browser is launched against.
|
|
78
|
+
*
|
|
79
|
+
* ── Never the default profile, and never a temporary one ────────────────
|
|
80
|
+
*
|
|
81
|
+
* `launch.explicit_profile_dir` (§7.2) is a launch-time refusal, but by the
|
|
82
|
+
* time it fires the caller has already decided which directory it meant. This
|
|
83
|
+
* is where that decision is made, and it is made **from the configured
|
|
84
|
+
* profile root by the same function the setup handshake uses**
|
|
85
|
+
* (`profileDirectory`) rather than by joining a path here.
|
|
86
|
+
*
|
|
87
|
+
* That matters more than it looks: a sign-in against a directory that is one
|
|
88
|
+
* character different from the one callers use is the failure this command
|
|
89
|
+
* exists to prevent, and it is undetectable afterwards — the person signs in
|
|
90
|
+
* successfully, the browser writes a real session to a real directory, and
|
|
91
|
+
* every caller attaches to a different one and sees a signed-out browser. So
|
|
92
|
+
* there is deliberately no second spelling of where a profile lives.
|
|
93
|
+
*/
|
|
94
|
+
export function signInProfileDirectory(profileRoot, browser) {
|
|
95
|
+
return profileDirectory(profileRoot, browser);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* What to say when no browser is running to hand over.
|
|
99
|
+
*
|
|
100
|
+
* ── The honest seam, stated rather than papered over ────────────────────
|
|
101
|
+
*
|
|
102
|
+
* §5.5.1 says *"nothing is stopped and nothing is relaunched"*, and that is
|
|
103
|
+
* written for the arrangement where the signed-in browser is already up:
|
|
104
|
+
* *"the window is already there"*. This command therefore claims the browser
|
|
105
|
+
* and hands over the window that exists.
|
|
106
|
+
*
|
|
107
|
+
* When none exists, it starts one — and that is the whole of what this
|
|
108
|
+
* command launches. It is a cold start of the browser a person is about to
|
|
109
|
+
* sign into, which is the case §5.5.1's own step 3 describes as *"hands the
|
|
110
|
+
* person the window"*: there has to be a window. What it does **not** do is
|
|
111
|
+
* take a lease, open a tab, or drive anything, because none of those is a
|
|
112
|
+
* person signing in.
|
|
113
|
+
*/
|
|
114
|
+
export const NO_BROWSER_NOTE = 'No browser was running against this profile, so one was started for you to sign into.';
|
|
115
|
+
/**
|
|
116
|
+
* A hint for the collision case, which is the one that fails least legibly.
|
|
117
|
+
*
|
|
118
|
+
* Measured, and the reason `coldStartDetached` asserts rather than infers: a
|
|
119
|
+
* second browser started against a profile already in use **hands its address
|
|
120
|
+
* to the first**, opening no endpoint of its own. The launch
|
|
121
|
+
* refuses on that, and this is what the refusal is worth saying alongside it —
|
|
122
|
+
* because the thing a person should do about it is not obvious from a message
|
|
123
|
+
* about endpoints.
|
|
124
|
+
*/
|
|
125
|
+
export const COLLISION_HINT = 'A browser is already running against this profile. That may be a window you or ' +
|
|
126
|
+
'something else opened earlier — find it and use it to sign in, or close it and ' +
|
|
127
|
+
'run this again. Two browsers cannot share one profile directory: the second ' +
|
|
128
|
+
'silently hands its address to the first and opens nothing.';
|
|
129
|
+
/** Where the profile is, relative to the root, for a message (§1.7a). */
|
|
130
|
+
export function relativeProfilePath(profileRoot, browser) {
|
|
131
|
+
return path.relative(profileRoot, profileDirectory(profileRoot, browser)) || browser;
|
|
132
|
+
}
|
|
133
|
+
/** The browser this command signs into when none is named. */
|
|
134
|
+
export const DEFAULT_SIGN_IN_BROWSER = SIGNABLE_BROWSER;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { readCaptureDiffActivity, readCaptureRollup, readMostDiffedTargets, } from "../operations/telemetry.js";
|
|
2
|
+
export const CAPTURES_USAGE = [
|
|
3
|
+
'broker captures — what pictures cost, and what diffs did.',
|
|
4
|
+
'',
|
|
5
|
+
'Usage:',
|
|
6
|
+
' broker captures [--since <t>] [--until <t>] [--lease <id>] [--json]',
|
|
7
|
+
' broker captures --capture <id> [--limit <n>] [--json]',
|
|
8
|
+
' broker captures --targets [--limit <n>] [--json]',
|
|
9
|
+
'',
|
|
10
|
+
'The window is half-open: --since includes its instant, --until excludes it,',
|
|
11
|
+
'so two adjacent windows partition the captures rather than sharing one.',
|
|
12
|
+
' --since <t> captures taken at or after this timestamp',
|
|
13
|
+
' --until <t> captures taken strictly before this timestamp',
|
|
14
|
+
' --lease <id> only captures taken under this lease',
|
|
15
|
+
' --capture <id> instead: what diffs ran from and against one capture',
|
|
16
|
+
' --targets instead: which captures are most diffed against',
|
|
17
|
+
' --limit <n> at most this many rows, where a listing is returned',
|
|
18
|
+
' --json one JSON document, for something reading rather than someone',
|
|
19
|
+
].join('\n');
|
|
20
|
+
const WINDOW_FLAGS = {
|
|
21
|
+
'--since': 'since',
|
|
22
|
+
'--until': 'until',
|
|
23
|
+
'--lease': 'claimId',
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Parse the arguments after `captures`.
|
|
27
|
+
*
|
|
28
|
+
* **An unrecognised flag refuses rather than being ignored**, and a
|
|
29
|
+
* value-taking flag with nothing after it refuses too. Ignoring either would
|
|
30
|
+
* run a query nobody asked for and print a result that looks like an answer.
|
|
31
|
+
*
|
|
32
|
+
* **The three modes are mutually exclusive and saying so is the point.**
|
|
33
|
+
* `--capture` asks about one picture's diffs and `--targets` asks which
|
|
34
|
+
* pictures get diffed against; neither takes a window. Silently ignoring a
|
|
35
|
+
* window passed beside one of them would print a number labelled with a
|
|
36
|
+
* restriction that was never applied, which is the specific way a telemetry
|
|
37
|
+
* read gets believed when it should not be.
|
|
38
|
+
*/
|
|
39
|
+
export function parseCapturesArguments(argv) {
|
|
40
|
+
const window = {};
|
|
41
|
+
let captureId;
|
|
42
|
+
let targets = false;
|
|
43
|
+
let limit;
|
|
44
|
+
let json = false;
|
|
45
|
+
for (let at = 0; at < argv.length; at += 1) {
|
|
46
|
+
const argument = argv[at] ?? '';
|
|
47
|
+
if (argument === '--json') {
|
|
48
|
+
json = true;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (argument === '--targets') {
|
|
52
|
+
targets = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (argument === '--limit') {
|
|
56
|
+
const raw = argv[at + 1];
|
|
57
|
+
if (raw === undefined) {
|
|
58
|
+
return { ok: false, message: '--limit needs a number.' };
|
|
59
|
+
}
|
|
60
|
+
const parsed = Number(raw);
|
|
61
|
+
if (!Number.isFinite(parsed)) {
|
|
62
|
+
return { ok: false, message: `--limit needs a number, not ${raw}.` };
|
|
63
|
+
}
|
|
64
|
+
limit = parsed;
|
|
65
|
+
at += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (argument === '--capture') {
|
|
69
|
+
const raw = argv[at + 1];
|
|
70
|
+
if (raw === undefined) {
|
|
71
|
+
return { ok: false, message: '--capture needs a capture identifier.' };
|
|
72
|
+
}
|
|
73
|
+
captureId = raw;
|
|
74
|
+
at += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const field = WINDOW_FLAGS[argument];
|
|
78
|
+
if (field !== undefined) {
|
|
79
|
+
const raw = argv[at + 1];
|
|
80
|
+
if (raw === undefined) {
|
|
81
|
+
return { ok: false, message: `${argument} needs a value.` };
|
|
82
|
+
}
|
|
83
|
+
window[field] = raw;
|
|
84
|
+
at += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
return { ok: false, message: `Unrecognised option: ${argument}` };
|
|
88
|
+
}
|
|
89
|
+
const hasWindow = window.since !== undefined || window.until !== undefined || window.claimId !== undefined;
|
|
90
|
+
if (captureId !== undefined && targets) {
|
|
91
|
+
return { ok: false, message: '--capture and --targets ask different questions; pick one.' };
|
|
92
|
+
}
|
|
93
|
+
if (captureId !== undefined && hasWindow) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
message: '--capture asks about one capture, so it takes no window.',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (targets && hasWindow) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
message: '--targets asks across every comparison, so it takes no window.',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (captureId !== undefined) {
|
|
106
|
+
return { ok: true, mode: 'capture', captureId, limit, json };
|
|
107
|
+
}
|
|
108
|
+
if (targets) {
|
|
109
|
+
return { ok: true, mode: 'targets', limit, json };
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, mode: 'rollup', window, json };
|
|
112
|
+
}
|
|
113
|
+
/** A count and its share of a total, where a bare count invites the wrong reading. */
|
|
114
|
+
function share(part, whole) {
|
|
115
|
+
if (whole === 0) {
|
|
116
|
+
return '0';
|
|
117
|
+
}
|
|
118
|
+
return `${String(part)} (${String(Math.round((part / whole) * 100))}%)`;
|
|
119
|
+
}
|
|
120
|
+
function renderGroups(label, groups) {
|
|
121
|
+
if (groups.length === 0) {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
return [
|
|
125
|
+
`${label}:`,
|
|
126
|
+
...groups.map((group) => ` ${group.group.padEnd(10)} ${String(group.captures).padStart(5)} captures ` +
|
|
127
|
+
`${String(group.bytes).padStart(10)} bytes ` +
|
|
128
|
+
`${String(group.estimatedTokens).padStart(9)} est. tokens`),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
function renderOutcomes(label, outcomes) {
|
|
132
|
+
if (outcomes.comparisons === 0) {
|
|
133
|
+
return [`${label}: none.`];
|
|
134
|
+
}
|
|
135
|
+
return [
|
|
136
|
+
`${label}: ${String(outcomes.comparisons)} comparisons, ` +
|
|
137
|
+
`${share(outcomes.changed, outcomes.comparisons)} found a change, ` +
|
|
138
|
+
`${String(outcomes.truncated)} truncated.`,
|
|
139
|
+
...outcomes.settings.map((use) => ` tolerance ${String(use.colourTolerance)}, ` +
|
|
140
|
+
`min area ${String(use.minimumRegionArea)}, ` +
|
|
141
|
+
`max regions ${String(use.maximumRegions)}: ` +
|
|
142
|
+
`${String(use.comparisons)} comparisons, ${String(use.changed)} changed`),
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Run `broker captures`.
|
|
147
|
+
*
|
|
148
|
+
* Returns an exit code rather than exiting, so the dispatcher owns the process
|
|
149
|
+
* and a test can drive this in the same way the parity suite drives the rest of
|
|
150
|
+
* the command surface.
|
|
151
|
+
*/
|
|
152
|
+
export function runCaptures(rest, options) {
|
|
153
|
+
if (rest.includes('--help')) {
|
|
154
|
+
options.streams.out(CAPTURES_USAGE);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
const parsed = parseCapturesArguments(rest);
|
|
158
|
+
if (!parsed.ok) {
|
|
159
|
+
options.streams.err(parsed.message);
|
|
160
|
+
options.streams.err(CAPTURES_USAGE);
|
|
161
|
+
return 2;
|
|
162
|
+
}
|
|
163
|
+
if (parsed.mode === 'capture') {
|
|
164
|
+
const activity = readCaptureDiffActivity(options.db, parsed.captureId, parsed.limit);
|
|
165
|
+
if (parsed.json) {
|
|
166
|
+
options.streams.out(JSON.stringify(activity));
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
options.streams.out(`Capture ${activity.captureId}`);
|
|
170
|
+
for (const line of renderOutcomes(' Diffs run FROM it', activity.asSource)) {
|
|
171
|
+
options.streams.out(line);
|
|
172
|
+
}
|
|
173
|
+
for (const line of renderOutcomes(' Diffs run AGAINST it', activity.asTarget)) {
|
|
174
|
+
options.streams.out(line);
|
|
175
|
+
}
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
if (parsed.mode === 'targets') {
|
|
179
|
+
const rows = readMostDiffedTargets(options.db, parsed.limit);
|
|
180
|
+
if (parsed.json) {
|
|
181
|
+
options.streams.out(JSON.stringify({ targets: rows }));
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
if (rows.length === 0) {
|
|
185
|
+
options.streams.out('No comparisons recorded.');
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
options.streams.out('Most diffed against — a baseline in behaviour, not in the schema:');
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
options.streams.out(` ${row.captureId} ${String(row.comparisons)} comparisons, ` +
|
|
191
|
+
`${String(row.changed)} changed ${row.url ?? '(capture row gone)'}`);
|
|
192
|
+
}
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
const rollup = readCaptureRollup(options.db, parsed.window);
|
|
196
|
+
if (parsed.json) {
|
|
197
|
+
options.streams.out(JSON.stringify(rollup));
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
if (rollup.total.captures === 0) {
|
|
201
|
+
options.streams.out('No captures in this window.');
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
const total = rollup.total;
|
|
205
|
+
options.streams.out(`${String(total.captures)} captures, ${String(total.bytes)} bytes, ` +
|
|
206
|
+
`${String(total.estimatedTokens)} estimated tokens.`);
|
|
207
|
+
options.streams.out(`${share(total.downscaled, total.captures)} were downscaled; ` +
|
|
208
|
+
`${share(total.warned, total.captures)} carried the accounting warning.`);
|
|
209
|
+
for (const line of renderGroups('By tier', rollup.byTier)) {
|
|
210
|
+
options.streams.out(line);
|
|
211
|
+
}
|
|
212
|
+
for (const line of renderGroups('By kind', rollup.byKind)) {
|
|
213
|
+
options.streams.out(line);
|
|
214
|
+
}
|
|
215
|
+
if (rollup.escalationReasons.length > 0) {
|
|
216
|
+
options.streams.out('Why callers escalated, as written:');
|
|
217
|
+
for (const reason of rollup.escalationReasons) {
|
|
218
|
+
options.streams.out(` ${reason.takenAt} ${reason.reason}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return 0;
|
|
222
|
+
}
|