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,152 @@
|
|
|
1
|
+
import { SIGNABLE_BROWSER } from "../service/operations/sign-in.js";
|
|
2
|
+
import { readTabBudget } from "../operations/status.js";
|
|
3
|
+
import { classifySignIn } from "../service/signin-recovery.js";
|
|
4
|
+
import { readStoreVersion } from "../store/schema/step.js";
|
|
5
|
+
import { inspectProfileSession } from "./session.js";
|
|
6
|
+
import { checkAbandonedSignIn, checkAutomation, checkCaptureSurface, checkDiscoveryRecord, checkKeeperTab, checkRootWritable, checkSchemaVersion, checkSignInSession, checkStoreLocation, checkStorePresent, checkTabBudget, exitCodeFor, } from "./checks.js";
|
|
7
|
+
/**
|
|
8
|
+
* Run the preconditions.
|
|
9
|
+
*
|
|
10
|
+
* `db` is optional because a store that does not exist yet is a legitimate
|
|
11
|
+
* state to ask about — arguably the state where the answer is most useful,
|
|
12
|
+
* since it is the one somebody has just installed into. Every store-derived
|
|
13
|
+
* check reports `unknown` rather than failing when there is nothing to read.
|
|
14
|
+
*
|
|
15
|
+
* **Nothing here writes to the store.** The only write anywhere in this
|
|
16
|
+
* command is the write probe on each root, which removes what it wrote and is
|
|
17
|
+
* named in `checks.ts`'s header.
|
|
18
|
+
*/
|
|
19
|
+
export function runDoctor(environment, db, probes = {}) {
|
|
20
|
+
const version = db === undefined ? null : readStoreVersion(db);
|
|
21
|
+
const storedBudget = db === undefined ? null : readTabBudget(db);
|
|
22
|
+
const checks = [
|
|
23
|
+
checkStoreLocation(environment, probes.networkChecks),
|
|
24
|
+
checkStorePresent(environment),
|
|
25
|
+
checkSchemaVersion(version),
|
|
26
|
+
checkAutomation(probes.automation ?? { present: undefined }),
|
|
27
|
+
checkRootWritable('roots.artifacts_writable', 'The artifact root is writable', environment.artifactsRoot),
|
|
28
|
+
checkRootWritable('roots.profiles_writable', 'The profile root is writable', environment.profileRoot),
|
|
29
|
+
];
|
|
30
|
+
// **Every configured browser, not the default pair.** `DEFAULT_BROWSER_IDS`
|
|
31
|
+
// is "the default set, never the permitted set" (see its own comment); an
|
|
32
|
+
// installation that names a third browser has three to report on, and a
|
|
33
|
+
// doctor that walked the constant would silently answer about two of them.
|
|
34
|
+
// A health report that is quietly partial is worse than one that is absent,
|
|
35
|
+
// because nothing in its output says which browsers it did not look at.
|
|
36
|
+
//
|
|
37
|
+
// Order is regular-then-private, each in configured order, which is the
|
|
38
|
+
// order `environment.ts` records and the order a person reading `.env`
|
|
39
|
+
// wrote them in.
|
|
40
|
+
const configuredBrowsers = [
|
|
41
|
+
...environment.regularBrowsers,
|
|
42
|
+
...environment.privateBrowsers,
|
|
43
|
+
];
|
|
44
|
+
for (const browser of configuredBrowsers) {
|
|
45
|
+
checks.push(checkDiscoveryRecord(browser, probes.discovery?.[browser] ?? { recorded: false }));
|
|
46
|
+
}
|
|
47
|
+
checks.push(checkCaptureSurface(probes.captureSurface));
|
|
48
|
+
for (const browser of configuredBrowsers) {
|
|
49
|
+
checks.push(checkKeeperTab(browser, probes.keeperTabs?.[browser]));
|
|
50
|
+
}
|
|
51
|
+
// The sign-in check, for the one browser that has a profile to sign into.
|
|
52
|
+
// Not run for the private browser: its profile is ephemeral, so the
|
|
53
|
+
// question does not apply and an entry saying `unknown` about a browser
|
|
54
|
+
// that can never be signed in would read as a gap rather than as a
|
|
55
|
+
// non-question.
|
|
56
|
+
//
|
|
57
|
+
// **Whether a browser is running changes what a zero count means**, so the
|
|
58
|
+
// discovery probe's answer is passed through rather than re-derived. See
|
|
59
|
+
// `session.ts`: a live browser has not necessarily flushed its cookies.
|
|
60
|
+
const signInBrowser = SIGNABLE_BROWSER;
|
|
61
|
+
const discoveryProbe = probes.discovery?.[signInBrowser];
|
|
62
|
+
const browserRunning = discoveryProbe?.recorded === true && discoveryProbe.answered === true;
|
|
63
|
+
checks.push(checkSignInSession(signInBrowser, inspectProfileSession(environment.profileRoot, signInBrowser, {
|
|
64
|
+
...(probes.cookieReader === undefined ? {} : { reader: probes.cookieReader }),
|
|
65
|
+
browserRunning,
|
|
66
|
+
})));
|
|
67
|
+
// **Whether a sign-in has been abandoned**, which is the one thing on this
|
|
68
|
+
// report that can be actively refusing every caller right now. Read from the
|
|
69
|
+
// store rather than probed, because the two facts it needs — the state and
|
|
70
|
+
// the owning process — are both rows.
|
|
71
|
+
//
|
|
72
|
+
// A store that is absent, or one written by a build older than the owner
|
|
73
|
+
// column, yields no row to classify; both come back as *not signing in*,
|
|
74
|
+
// which is the honest answer when there is nothing recorded to say
|
|
75
|
+
// otherwise.
|
|
76
|
+
checks.push(checkAbandonedSignIn(signInBrowser, classifySignIn(db === undefined ? undefined : readSignInOwner(db, signInBrowser), probes.processIsRunning)));
|
|
77
|
+
checks.push(checkTabBudget(storedBudget, probes.configuredTabBudget ?? null));
|
|
78
|
+
return {
|
|
79
|
+
checks,
|
|
80
|
+
exitCode: exitCodeFor(checks),
|
|
81
|
+
storeLocation: environment.databasePath,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Read each browser's discovery record out of the store, so the caller has
|
|
86
|
+
* something to probe.
|
|
87
|
+
*
|
|
88
|
+
* **This reads the record and does not check it.** §1.2c: the record is a
|
|
89
|
+
* claim, not a proof — it survives the browser it names. Turning these into
|
|
90
|
+
* probe results means reaching the endpoint, which needs a driver, which is
|
|
91
|
+
* the row that will supply {@link DoctorProbes.discovery}.
|
|
92
|
+
*/
|
|
93
|
+
export function readDiscoveryRecords(db) {
|
|
94
|
+
const rows = db.prepare('SELECT id, endpoint, browser_uuid FROM browsers').all();
|
|
95
|
+
const records = {};
|
|
96
|
+
for (const row of rows) {
|
|
97
|
+
records[row.id] = { endpoint: row.endpoint, browserUuid: row.browser_uuid };
|
|
98
|
+
}
|
|
99
|
+
return records;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Read the sign-in state and its owning process for one browser.
|
|
103
|
+
*
|
|
104
|
+
* **Tolerant of a store that predates the owner column**, because `doctor` is
|
|
105
|
+
* the command most likely to be pointed at an old installation — that is
|
|
106
|
+
* largely what it is for. A store without the column answers as though nothing
|
|
107
|
+
* is signing in, which is the honest reading: there is no record to conclude
|
|
108
|
+
* anything from.
|
|
109
|
+
*/
|
|
110
|
+
export function readSignInOwner(db, browser) {
|
|
111
|
+
try {
|
|
112
|
+
return db.prepare('SELECT state, signin_owner_pid FROM browsers WHERE id = ?').get(browser);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const SYMBOL = { ok: 'ok ', failed: 'FAIL', unknown: '-- ' };
|
|
119
|
+
/**
|
|
120
|
+
* The report as lines for a terminal.
|
|
121
|
+
*
|
|
122
|
+
* One line per precondition with its own status, then the failures repeated
|
|
123
|
+
* with what to do about them. **No summary verdict** — §4.4 is explicit that
|
|
124
|
+
* collapsing preconditions into one word is the thing this declines to do,
|
|
125
|
+
* and a "3 of 12 healthy" line at the bottom is that word with arithmetic.
|
|
126
|
+
* What the bottom carries instead is the exit code, which is the machine's
|
|
127
|
+
* answer and names which group failed.
|
|
128
|
+
*/
|
|
129
|
+
export function formatReport(report) {
|
|
130
|
+
const lines = [`store: ${report.storeLocation}`, ''];
|
|
131
|
+
for (const check of report.checks) {
|
|
132
|
+
lines.push(`[${SYMBOL[check.status] ?? '? '}] ${check.title}`);
|
|
133
|
+
lines.push(` ${check.detail}`);
|
|
134
|
+
}
|
|
135
|
+
const failures = report.checks.filter((check) => check.status === 'failed');
|
|
136
|
+
if (failures.length > 0) {
|
|
137
|
+
lines.push('', 'What to do:');
|
|
138
|
+
for (const failure of failures) {
|
|
139
|
+
lines.push(` ${failure.id}: ${failure.remedy ?? 'No remedy recorded for this check.'}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const unknown = report.checks.filter((check) => check.status === 'unknown');
|
|
143
|
+
if (unknown.length > 0) {
|
|
144
|
+
lines.push('',
|
|
145
|
+
// Said rather than left to be inferred: an unknown is not a failure and
|
|
146
|
+
// does not affect the exit code, and a reader who assumed otherwise
|
|
147
|
+
// would treat a fresh install as broken.
|
|
148
|
+
`${String(unknown.length)} precondition(s) could not be evaluated. That is not a failure — a check with nothing to examine has not found a fault — and none of them affects the exit code.`);
|
|
149
|
+
}
|
|
150
|
+
lines.push('', `exit code: ${String(report.exitCode)}`);
|
|
151
|
+
return lines;
|
|
152
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
4
|
+
import { profileDirectory } from "../browser/discovery.js";
|
|
5
|
+
/**
|
|
6
|
+
* Whether the signed-in browser's profile **looks** signed in.
|
|
7
|
+
*
|
|
8
|
+
* ── The question, and why it is worth answering without a browser ───────
|
|
9
|
+
*
|
|
10
|
+
* A person signs in by hand exactly once (`SCHEMA.md` §5.5.1) and then has no
|
|
11
|
+
* way to confirm it took. Opening a browser to look is the obvious answer and
|
|
12
|
+
* is a bad one: `broker doctor` **reports and changes nothing**, and a check
|
|
13
|
+
* that launched a browser against the profile would be a check that could
|
|
14
|
+
* itself take the profile's lock — reporting a fault it had just caused, on
|
|
15
|
+
* an installation that was fine a moment earlier.
|
|
16
|
+
*
|
|
17
|
+
* So this reads files, and the whole design problem is that **the obvious
|
|
18
|
+
* observable does not work**.
|
|
19
|
+
*
|
|
20
|
+
* ── What was measured, because the obvious check always passes ──────────
|
|
21
|
+
*
|
|
22
|
+
* Two profiles were built with a real browser: one where nothing was ever
|
|
23
|
+
* visited, and one where a session cookie was set and the browser was closed
|
|
24
|
+
* cleanly. Then every candidate observable was compared.
|
|
25
|
+
*
|
|
26
|
+
* | Observable | Fresh profile | Signed-in profile | Distinguishes? |
|
|
27
|
+
* |---|---|---|---|
|
|
28
|
+
* | `Default/Network/Cookies` exists | **yes** | yes | **no** |
|
|
29
|
+
* | its size on disk | **20480 bytes** | 20480 bytes | **no** |
|
|
30
|
+
* | rows in its `cookies` table | **0** | **1** | **yes** |
|
|
31
|
+
*
|
|
32
|
+
* **The file is created on first run whether or not anything is stored in
|
|
33
|
+
* it.** So `existsSync` — the check anyone reaches for first, and the one this
|
|
34
|
+
* module would have shipped without the measurement — is a check that
|
|
35
|
+
* **cannot fail**. It would report every fresh install as signed in, which is
|
|
36
|
+
* the exact failure the house standard names: a check that claims what it
|
|
37
|
+
* cannot see. The size is no better, because SQLite allocates its pages up
|
|
38
|
+
* front.
|
|
39
|
+
*
|
|
40
|
+
* The row count is the observable that carries the fact, so it is the one
|
|
41
|
+
* this reads.
|
|
42
|
+
*
|
|
43
|
+
* ── The two things this deliberately does not claim ─────────────────────
|
|
44
|
+
*
|
|
45
|
+
* Both are reported as `unknown` with the reason rather than being guessed
|
|
46
|
+
* at, because an `unknown` that says why beats a confident wrong answer.
|
|
47
|
+
*
|
|
48
|
+
* 1. **A running browser has not necessarily written its cookies down.**
|
|
49
|
+
* Measured: with the browser ended abruptly, the signed-in profile read
|
|
50
|
+
* **zero rows** — identical to the fresh one. The store is flushed on a
|
|
51
|
+
* clean shutdown, so a count taken while a browser is live is a count of
|
|
52
|
+
* what has been flushed so far and not of what the session holds. Reading
|
|
53
|
+
* zero in that state means *cannot tell yet*, never *not signed in*.
|
|
54
|
+
* 2. **Cookies are one carrier of a session, not the only one.** A site that
|
|
55
|
+
* keeps its session in local storage or a token in IndexedDB leaves this
|
|
56
|
+
* table empty while being perfectly signed in. So a positive result is
|
|
57
|
+
* strong and a zero result is weak, and they are reported asymmetrically
|
|
58
|
+
* for that reason.
|
|
59
|
+
*
|
|
60
|
+
* **This is positive-evidence-only, in exactly the way `profileLockLooksHeld`
|
|
61
|
+
* is**, and for the same reason: finding rows means a session was stored;
|
|
62
|
+
* finding none means no evidence was found, which is not the same as evidence
|
|
63
|
+
* of absence.
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* Where a Chromium profile keeps its cookies.
|
|
67
|
+
*
|
|
68
|
+
* **Measured rather than assumed, and the obvious guess is wrong.** It is not
|
|
69
|
+
* `Default/Cookies` — that path does not exist in a profile this service
|
|
70
|
+
* creates. The store sits under the network directory, and a check pointed at
|
|
71
|
+
* the wrong path would report every profile as unreadable.
|
|
72
|
+
*/
|
|
73
|
+
export const COOKIE_STORE_RELATIVE = ['Default', 'Network', 'Cookies'];
|
|
74
|
+
/**
|
|
75
|
+
* The real reader: opens the store **read-only** and counts.
|
|
76
|
+
*
|
|
77
|
+
* Read-only is not a precaution, it is the contract — `checks.ts` promises
|
|
78
|
+
* the doctor changes nothing, and a cookie store opened for writing can be
|
|
79
|
+
* migrated by the opening process. Opening a browser's own store read-write
|
|
80
|
+
* to ask a question would be able to damage the one thing this whole command
|
|
81
|
+
* exists to protect.
|
|
82
|
+
*/
|
|
83
|
+
export function realCookieStoreReader() {
|
|
84
|
+
return {
|
|
85
|
+
countCookies: (file) => {
|
|
86
|
+
try {
|
|
87
|
+
const db = new Database(file, { readonly: true, fileMustExist: true });
|
|
88
|
+
try {
|
|
89
|
+
const row = db.prepare('SELECT COUNT(*) AS n FROM cookies').get();
|
|
90
|
+
return { count: row?.n ?? 0 };
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
db.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Inspect one profile and say what the evidence supports.
|
|
104
|
+
*
|
|
105
|
+
* Every branch that cannot answer says so and says why. There is no branch
|
|
106
|
+
* that returns `no-session-found` on evidence that would also be produced by
|
|
107
|
+
* a signed-in profile.
|
|
108
|
+
*/
|
|
109
|
+
export function inspectProfileSession(profileRoot, browser, options = {}) {
|
|
110
|
+
const directory = profileDirectory(profileRoot, browser);
|
|
111
|
+
let profilePresent;
|
|
112
|
+
try {
|
|
113
|
+
profilePresent = fs.statSync(directory).isDirectory();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
profilePresent = false;
|
|
117
|
+
}
|
|
118
|
+
if (!profilePresent) {
|
|
119
|
+
return {
|
|
120
|
+
evidence: 'no-profile',
|
|
121
|
+
reason: 'No profile directory. It is created by the setup handshake on the next spawn, and nobody has signed in against it.',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const store = path.join(directory, ...COOKIE_STORE_RELATIVE);
|
|
125
|
+
if (!fs.existsSync(store)) {
|
|
126
|
+
return {
|
|
127
|
+
evidence: 'no-session-found',
|
|
128
|
+
cookieCount: 0,
|
|
129
|
+
reason: 'The profile exists but the browser has never written a cookie store into it, so no browser has run against it yet.',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const reader = options.reader ?? realCookieStoreReader();
|
|
133
|
+
const outcome = reader.countCookies(store);
|
|
134
|
+
if ('error' in outcome) {
|
|
135
|
+
return {
|
|
136
|
+
evidence: 'undetermined',
|
|
137
|
+
reason: `The cookie store could not be read (${outcome.error}). A store held open by a running browser is the ordinary cause; nothing is concluded from a read that did not happen.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (outcome.count > 0) {
|
|
141
|
+
return {
|
|
142
|
+
evidence: 'session-present',
|
|
143
|
+
cookieCount: outcome.count,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Zero. The one branch where the honest answer depends on something else,
|
|
147
|
+
// because a live browser has not necessarily flushed yet — measured, and
|
|
148
|
+
// the reason this parameter exists.
|
|
149
|
+
if (options.browserRunning === true) {
|
|
150
|
+
return {
|
|
151
|
+
evidence: 'undetermined',
|
|
152
|
+
cookieCount: 0,
|
|
153
|
+
reason: 'The stored cookie count is zero and a browser is running against this profile. A browser writes its cookies down when it shuts down cleanly, so a zero read while one is live means the store has nothing flushed yet rather than that nobody is signed in. Close the browser and ask again.',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
evidence: 'no-session-found',
|
|
158
|
+
cookieCount: 0,
|
|
159
|
+
reason: 'The profile has a cookie store and it holds no cookies. That is what a profile nobody has signed into looks like — though a site that keeps its session only in local storage would look the same, so this is the absence of evidence rather than evidence of absence.',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The base every refusal in this service is thrown as.
|
|
3
|
+
*
|
|
4
|
+
* `SCHEMA.md` §7 is organised around refusals — "a rule that never refuses
|
|
5
|
+
* anything protects nothing, so the refusals are the specification" — and a
|
|
6
|
+
* refusal has to be distinguishable from a programming mistake at the point
|
|
7
|
+
* something decides what to print and what exit code to use. A thrown
|
|
8
|
+
* `TypeError` is a bug in this service; a thrown `BrokerError` is this
|
|
9
|
+
* service declining to run, and the difference is the whole of the entry
|
|
10
|
+
* point's error handling.
|
|
11
|
+
*
|
|
12
|
+
* Row #10 grows this into the rejection taxonomy every guard draws from.
|
|
13
|
+
* Row #3 ships only the base and the two refusals it actually has, because
|
|
14
|
+
* a taxonomy with no cases is a shape nobody has tested.
|
|
15
|
+
*/
|
|
16
|
+
export class BrokerError extends Error {
|
|
17
|
+
/**
|
|
18
|
+
* The rule that refused, spelled as `SCHEMA.md` §7 spells it — for
|
|
19
|
+
* example `store.not_on_network_filesystem`. Carried as data rather than
|
|
20
|
+
* baked into the message so a caller can branch on the rule without
|
|
21
|
+
* matching on English.
|
|
22
|
+
*/
|
|
23
|
+
rule;
|
|
24
|
+
constructor(rule, message, options) {
|
|
25
|
+
super(message, options);
|
|
26
|
+
this.name = 'BrokerError';
|
|
27
|
+
this.rule = rule;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** A refusal from §7.2 — checked on every spawn, and the service does not run. */
|
|
31
|
+
export class StartupRefusal extends BrokerError {
|
|
32
|
+
constructor(rule, message, options) {
|
|
33
|
+
super(rule, message, options);
|
|
34
|
+
this.name = 'StartupRefusal';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { FEEDBACK_CATEGORY_NAMES, isFeedbackCategory, RATING_MAXIMUM, RATING_MINIMUM, } from "./record.js";
|
|
2
|
+
export const DEFAULT_LIMIT = 50;
|
|
3
|
+
/**
|
|
4
|
+
* Check the filters a person typed.
|
|
5
|
+
*
|
|
6
|
+
* A filter naming a category that does not exist would silently return no
|
|
7
|
+
* rows, and **no rows is the reading this whole mechanism's exit condition
|
|
8
|
+
* turns on** — silence means "nothing to report". A typo that produced
|
|
9
|
+
* silence would be read as the success condition, so it is refused with the
|
|
10
|
+
* list instead.
|
|
11
|
+
*/
|
|
12
|
+
export function refuseFilters(filters) {
|
|
13
|
+
if (filters.rating !== undefined) {
|
|
14
|
+
const { rating } = filters;
|
|
15
|
+
if (typeof rating !== 'number' ||
|
|
16
|
+
!Number.isInteger(rating) ||
|
|
17
|
+
rating < RATING_MINIMUM ||
|
|
18
|
+
rating > RATING_MAXIMUM) {
|
|
19
|
+
return {
|
|
20
|
+
code: 'rating_out_of_range',
|
|
21
|
+
message: `--rating takes a whole number from ${String(RATING_MINIMUM)} to ${String(RATING_MAXIMUM)}.`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (filters.category !== undefined) {
|
|
26
|
+
const { category } = filters;
|
|
27
|
+
if (typeof category !== 'string' || !isFeedbackCategory(category)) {
|
|
28
|
+
return {
|
|
29
|
+
code: 'unknown_category',
|
|
30
|
+
message: `--category takes one of these five: ${FEEDBACK_CATEGORY_NAMES.join(', ')}. An unrecognised one is refused rather than matching nothing, because no rows is also what "nothing to report" looks like.`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (filters.limit !== undefined) {
|
|
35
|
+
const { limit } = filters;
|
|
36
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) {
|
|
37
|
+
return { code: 'bad_limit', message: '--limit takes a whole number of at least 1.' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read the rows back, **most recent first**.
|
|
44
|
+
*
|
|
45
|
+
* Ordered by the counter rather than by the timestamp: two rows written in
|
|
46
|
+
* the same second are ordered by the counter and are not ordered by the
|
|
47
|
+
* clock, so "most recent first" is a claim about order that only the counter
|
|
48
|
+
* can actually make.
|
|
49
|
+
*/
|
|
50
|
+
export function readFeedback(db, filters = {}) {
|
|
51
|
+
const where = [];
|
|
52
|
+
const parameters = [];
|
|
53
|
+
if (filters.rating !== undefined) {
|
|
54
|
+
where.push('rating = ?');
|
|
55
|
+
parameters.push(filters.rating);
|
|
56
|
+
}
|
|
57
|
+
if (filters.category !== undefined) {
|
|
58
|
+
where.push('category = ?');
|
|
59
|
+
parameters.push(filters.category);
|
|
60
|
+
}
|
|
61
|
+
const clause = where.length === 0 ? '' : ` WHERE ${where.join(' AND ')}`;
|
|
62
|
+
parameters.push(filters.limit ?? DEFAULT_LIMIT);
|
|
63
|
+
const rows = db
|
|
64
|
+
.prepare(`SELECT id, at, session_id, claim_id, last_event_id, last_guard, rating, category, note
|
|
65
|
+
FROM feedback${clause}
|
|
66
|
+
ORDER BY id DESC
|
|
67
|
+
LIMIT ?`)
|
|
68
|
+
.all(...parameters);
|
|
69
|
+
return rows.map((row) => ({
|
|
70
|
+
id: row.id,
|
|
71
|
+
at: row.at,
|
|
72
|
+
sessionId: row.session_id,
|
|
73
|
+
claimId: row.claim_id,
|
|
74
|
+
lastEventId: row.last_event_id,
|
|
75
|
+
lastGuard: row.last_guard,
|
|
76
|
+
rating: row.rating,
|
|
77
|
+
category: row.category,
|
|
78
|
+
note: row.note,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Render the rows for a terminal.
|
|
83
|
+
*
|
|
84
|
+
* **Silence is reported as silence, in words.** An empty reading is the exit
|
|
85
|
+
* condition this mechanism is built around, so it says so rather than
|
|
86
|
+
* printing nothing — a blank screen reads as a broken command, and the one
|
|
87
|
+
* reading that must not be mistaken for a malfunction is the one that means
|
|
88
|
+
* the tool has done its job.
|
|
89
|
+
*/
|
|
90
|
+
export function renderFeedback(rows, filtered) {
|
|
91
|
+
if (rows.length === 0) {
|
|
92
|
+
return filtered
|
|
93
|
+
? 'No feedback matches those filters.'
|
|
94
|
+
: [
|
|
95
|
+
'No feedback has been recorded.',
|
|
96
|
+
'',
|
|
97
|
+
'This tool is v0 scaffolding and silence is its success condition: a long',
|
|
98
|
+
'stretch with nothing logged is the signal to remove it. Removing it is a',
|
|
99
|
+
'deletion — the tool, the command, this reader, the writer and the table.',
|
|
100
|
+
].join('\n');
|
|
101
|
+
}
|
|
102
|
+
return rows
|
|
103
|
+
.map((row) => {
|
|
104
|
+
const context = [
|
|
105
|
+
row.sessionId === null ? undefined : `session ${row.sessionId}`,
|
|
106
|
+
row.claimId === null ? undefined : `lease ${row.claimId}`,
|
|
107
|
+
row.lastEventId === null ? undefined : `last operation #${String(row.lastEventId)}`,
|
|
108
|
+
row.lastGuard === null ? undefined : `refused by ${row.lastGuard}`,
|
|
109
|
+
].filter((part) => part !== undefined);
|
|
110
|
+
return [
|
|
111
|
+
`#${String(row.id)} ${row.at} ${String(row.rating)}/5 ${row.category}`,
|
|
112
|
+
context.length === 0
|
|
113
|
+
? ' (no context was captured — no lease and no prior event)'
|
|
114
|
+
: ` ${context.join(' · ')}`,
|
|
115
|
+
` ${row.note}`,
|
|
116
|
+
].join('\n');
|
|
117
|
+
})
|
|
118
|
+
.join('\n\n');
|
|
119
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { immediate } from "../store/transaction.js";
|
|
2
|
+
/**
|
|
3
|
+
* `browser_feedback` — the tenth tool's write, and the one built to be
|
|
4
|
+
* removed.
|
|
5
|
+
*
|
|
6
|
+
* ── ITS EXIT CONDITION, WRITTEN DOWN, BECAUSE THAT IS PART OF THE ROW ────
|
|
7
|
+
*
|
|
8
|
+
* **This is v0 scaffolding. The signal to remove it is a long stretch in
|
|
9
|
+
* which nothing is logged** (`SCHEMA.md` §3.16). Silence is the success
|
|
10
|
+
* condition, not a failure to collect: once callers have stopped finding
|
|
11
|
+
* things to report, a channel for reporting them has nothing left to carry
|
|
12
|
+
* and it goes.
|
|
13
|
+
*
|
|
14
|
+
* Three consequences the implementation has to honour, and does:
|
|
15
|
+
*
|
|
16
|
+
* 1. **No migration story is owed.** Nothing later is expected to read these
|
|
17
|
+
* rows, so this table does not have to be one anybody would want to keep.
|
|
18
|
+
* 2. **It does not have to be beautiful.** Trivially callable, trivially
|
|
19
|
+
* readable, and nothing else. That is why this file is short and why there
|
|
20
|
+
* is no abstraction over it.
|
|
21
|
+
* 3. **Removing it stays a DELETION, not an extraction.** Nothing else reads
|
|
22
|
+
* this table, no operation depends on a row existing, and no refusal
|
|
23
|
+
* changes shape when it goes. **Whoever removes this deletes this file,
|
|
24
|
+
* the reader beside it, the tool, the command and the table — and nothing
|
|
25
|
+
* else moves.** That property is worth protecting deliberately as the rest
|
|
26
|
+
* of the service grows, so: do not join anything to this table, and do not
|
|
27
|
+
* make any operation's behaviour depend on a row in it.
|
|
28
|
+
*
|
|
29
|
+
* It is its own table rather than an event kind precisely to keep that true
|
|
30
|
+
* (§3.16): the ledger records what the service *did*, this records what a
|
|
31
|
+
* caller *thought*, and folding it in would make every count over `events`
|
|
32
|
+
* start by excluding a kind that is not an event.
|
|
33
|
+
*
|
|
34
|
+
* ── It is local, and it has no route out ────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* The store is the installation's own file. **There is no outbound path in
|
|
37
|
+
* this module and there must never be one.** Nothing else in this design
|
|
38
|
+
* opens an outbound connection and nothing listens, so a feedback channel
|
|
39
|
+
* that transmitted would be the only component with a reason to reach the
|
|
40
|
+
* network — carrying prose written by a caller about a page it was looking
|
|
41
|
+
* at, next to a lease identifier and a session identity. The consequence is
|
|
42
|
+
* accepted: feedback is per-installation and read by somebody with access to
|
|
43
|
+
* the machine.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* The five categories, and the reason the set is exactly this.
|
|
47
|
+
*
|
|
48
|
+
* **Small, fixed, disjoint, and one of them is positive.** A category list
|
|
49
|
+
* with no positive value collects only complaints, and a channel that can
|
|
50
|
+
* only ever report problems produces a picture in which nothing works. The
|
|
51
|
+
* exit condition above depends on being able to tell *nothing to report*
|
|
52
|
+
* apart from *nobody bothered*, and `worked-well` is the entry that makes
|
|
53
|
+
* that distinction possible.
|
|
54
|
+
*
|
|
55
|
+
* Two categories that overlap get chosen by coin-flip and their counts mean
|
|
56
|
+
* nothing, which is why these are disjoint rather than merely different.
|
|
57
|
+
*/
|
|
58
|
+
export const FEEDBACK_CATEGORIES = {
|
|
59
|
+
'refusal-unclear': 'The service refused, and the message did not say what to do next',
|
|
60
|
+
'no-path': 'The task could not be accomplished at all with the tools available',
|
|
61
|
+
'worked-around': 'The caller got there, but by an awkward or indirect route',
|
|
62
|
+
'surprised-me': 'It worked — but not the way the caller expected',
|
|
63
|
+
'worked-well': 'Positive. Something helped, and it is worth knowing which thing',
|
|
64
|
+
};
|
|
65
|
+
/** Every category, in a stable order. */
|
|
66
|
+
export const FEEDBACK_CATEGORY_NAMES = Object.keys(FEEDBACK_CATEGORIES);
|
|
67
|
+
export function isFeedbackCategory(value) {
|
|
68
|
+
return Object.prototype.hasOwnProperty.call(FEEDBACK_CATEGORIES, value);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The rating scale, with both ends and the middle written out.
|
|
72
|
+
*
|
|
73
|
+
* **An unanchored scale is used differently by every caller and produces
|
|
74
|
+
* numbers that cannot be compared** — which defeats the only reason to have a
|
|
75
|
+
* number at all. So the anchors live here, in code, and are what the tool
|
|
76
|
+
* description and the reader both quote.
|
|
77
|
+
*
|
|
78
|
+
* **The axis is not satisfaction.** It is not whether the caller liked the
|
|
79
|
+
* service. It is whether the service moved the caller's actual work forward
|
|
80
|
+
* or got in its way.
|
|
81
|
+
*/
|
|
82
|
+
export const RATING_ANCHORS = {
|
|
83
|
+
1: 'It stalled the work — could not do what it came to do, or spent more effort working around this service than on the task',
|
|
84
|
+
2: 'Substantial friction — the work got done, but the detour was a significant part of the effort',
|
|
85
|
+
3: 'Neutral — neither sped the work up nor got in its way',
|
|
86
|
+
4: 'It helped — the work went faster, with a rough edge worth mentioning',
|
|
87
|
+
5: 'It made the work faster — got where it was going quicker than by any other available route',
|
|
88
|
+
};
|
|
89
|
+
export const RATING_MINIMUM = 1;
|
|
90
|
+
export const RATING_MAXIMUM = 5;
|
|
91
|
+
/**
|
|
92
|
+
* The note's bounds. **The floor is deliberate** — twenty characters is
|
|
93
|
+
* roughly the shortest useful sentence, and it stops a reflexive one-word row.
|
|
94
|
+
*/
|
|
95
|
+
export const NOTE_MINIMUM = 20;
|
|
96
|
+
export const NOTE_MAXIMUM = 2000;
|
|
97
|
+
/**
|
|
98
|
+
* Read the three auto-captured columns from what the store already knows.
|
|
99
|
+
*
|
|
100
|
+
* **The caller's last operation is read from the ledger**, so it names what
|
|
101
|
+
* was actually attempted rather than what the caller remembers attempting —
|
|
102
|
+
* and `last_guard` comes off that same row rather than being asked for, so
|
|
103
|
+
* the refusal recorded is the refusal that happened.
|
|
104
|
+
*
|
|
105
|
+
* Resolution order, and it matters: a lease key identifies one claim exactly,
|
|
106
|
+
* so it is preferred. Failing that, a session identity finds that session's
|
|
107
|
+
* most recent event. Failing both, the row carries prose and a rating and
|
|
108
|
+
* nothing else, which is still a row worth having — a caller whose very first
|
|
109
|
+
* call was refused before any lease existed is exactly the population §3.16
|
|
110
|
+
* exists to hear from.
|
|
111
|
+
*/
|
|
112
|
+
export function captureContext(db, submission) {
|
|
113
|
+
let claimId = null;
|
|
114
|
+
if (submission.leaseKeyHash !== undefined) {
|
|
115
|
+
// Looked up by hash, because the key itself is never stored (§1.4).
|
|
116
|
+
const claim = db
|
|
117
|
+
.prepare('SELECT id FROM claims WHERE key_hash = ? ORDER BY created_at DESC LIMIT 1')
|
|
118
|
+
.get(submission.leaseKeyHash);
|
|
119
|
+
claimId = claim?.id ?? null;
|
|
120
|
+
}
|
|
121
|
+
// The most recent event this caller produced. Ordered by the ledger's own
|
|
122
|
+
// counter rather than by its timestamp: two rows written in the same second
|
|
123
|
+
// are ordered by the counter and are not ordered by the clock, and "the
|
|
124
|
+
// caller's LAST operation" is a claim about order.
|
|
125
|
+
const lastEvent = (claimId !== null
|
|
126
|
+
? db
|
|
127
|
+
.prepare('SELECT id, guard FROM events WHERE claim_id = ? ORDER BY id DESC LIMIT 1')
|
|
128
|
+
.get(claimId)
|
|
129
|
+
: submission.sessionId !== undefined
|
|
130
|
+
? db
|
|
131
|
+
.prepare('SELECT id, guard FROM events WHERE session_id = ? ORDER BY id DESC LIMIT 1')
|
|
132
|
+
.get(submission.sessionId)
|
|
133
|
+
: undefined);
|
|
134
|
+
return {
|
|
135
|
+
claimId,
|
|
136
|
+
lastEventId: lastEvent?.id ?? null,
|
|
137
|
+
// Null when the last event was an allow: `guard` is null on an allow by
|
|
138
|
+
// the table's own constraint, so this carries "the refusal it hit" and
|
|
139
|
+
// not "the last rule that happened to be mentioned".
|
|
140
|
+
lastGuard: lastEvent?.guard ?? null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Check a submission, returning the refusal or nothing.
|
|
145
|
+
*
|
|
146
|
+
* Every refusal names the way forward (§3.14) — which matters more here than
|
|
147
|
+
* anywhere, because this is the tool whose whole purpose is learning that a
|
|
148
|
+
* refusal failed to guide. A refusal from *this* tool that did not guide
|
|
149
|
+
* would be the one nobody could report.
|
|
150
|
+
*/
|
|
151
|
+
export function refuseSubmission(submission) {
|
|
152
|
+
const { rating } = submission;
|
|
153
|
+
if (typeof rating !== 'number' ||
|
|
154
|
+
!Number.isInteger(rating) ||
|
|
155
|
+
rating < RATING_MINIMUM ||
|
|
156
|
+
rating > RATING_MAXIMUM) {
|
|
157
|
+
return {
|
|
158
|
+
code: 'rating_out_of_range',
|
|
159
|
+
rule: 'feedback.rating_in_scale',
|
|
160
|
+
message: `The rating is a whole number from ${String(RATING_MINIMUM)} to ${String(RATING_MAXIMUM)}, on a help-versus-hinder axis rather than a satisfaction one. ${RATING_MINIMUM}: ${RATING_ANCHORS[RATING_MINIMUM] ?? ''}. 3: ${RATING_ANCHORS[3] ?? ''}. ${RATING_MAXIMUM}: ${RATING_ANCHORS[RATING_MAXIMUM] ?? ''}.`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const { category } = submission;
|
|
164
|
+
if (typeof category !== 'string' || !isFeedbackCategory(category)) {
|
|
165
|
+
// Refused with the full list (§3.16), because a caller that guessed wrong
|
|
166
|
+
// cannot guess better without seeing the set.
|
|
167
|
+
return {
|
|
168
|
+
code: 'unknown_category',
|
|
169
|
+
rule: 'feedback.category_known',
|
|
170
|
+
message: `The category is one of these five: ${FEEDBACK_CATEGORY_NAMES.map((name) => `${name} (${FEEDBACK_CATEGORIES[name]})`).join('; ')}.`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const { note } = submission;
|
|
174
|
+
if (typeof note !== 'string' || note.length < NOTE_MINIMUM || note.length > NOTE_MAXIMUM) {
|
|
175
|
+
return {
|
|
176
|
+
code: 'note_out_of_bounds',
|
|
177
|
+
rule: 'feedback.note_bounded',
|
|
178
|
+
message: `The note is ${String(NOTE_MINIMUM)} to ${String(NOTE_MAXIMUM)} characters. Say what you were trying to achieve, what you expected to happen, and what you did instead. Do not supply the lease id, the operation or the rule that refused — all three are captured for you.`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Write one feedback row.
|
|
185
|
+
*
|
|
186
|
+
* **Nothing about it is rate-limited or gated** (§3.16). It writes a row and
|
|
187
|
+
* returns. The worst case is a noisy caller writing many rows into a table
|
|
188
|
+
* nothing depends on, in a store that gets deleted when the tool does.
|
|
189
|
+
*/
|
|
190
|
+
export async function recordFeedback(db, submission) {
|
|
191
|
+
return immediate(db, (scope) => {
|
|
192
|
+
const captured = captureContext(scope.db, submission);
|
|
193
|
+
const inserted = scope.db
|
|
194
|
+
.prepare(`INSERT INTO feedback (session_id, claim_id, last_event_id, last_guard, rating, category, note)
|
|
195
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
196
|
+
.run(submission.sessionId ?? null, captured.claimId, captured.lastEventId, captured.lastGuard, submission.rating, submission.category, submission.note);
|
|
197
|
+
return { value: { id: Number(inserted.lastInsertRowid), captured } };
|
|
198
|
+
});
|
|
199
|
+
}
|