@meshbench/client 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/errors.mjs ADDED
@@ -0,0 +1,150 @@
1
+ // What went wrong, in a shape a script can catch.
2
+ //
3
+ // The workbench answers with a sentence and a code. The sentence is the good
4
+ // part - "no node is running firmware, so there is nothing to send to" - and it
5
+ // survives untouched; the code decides which class it becomes, because
6
+ // `instanceof` is what JavaScript has where Go has errors.Is and Python has an
7
+ // exception hierarchy. A caller that matched on prose would break the day the
8
+ // workbench reworded a refusal.
9
+
10
+ import { PROTOCOL, RELEASE } from "./pairing.mjs";
11
+
12
+ /** Anything this client throws. */
13
+ export class MeshbenchError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "MeshbenchError";
17
+ }
18
+ }
19
+
20
+ /** A verb the workbench declined, with its own words kept.
21
+ *
22
+ * Named for the workbench rather than for the refusal because that is what a
23
+ * Node script has caught since the first release, and the name is the API. */
24
+ export class WorkbenchError extends MeshbenchError {
25
+ /** Carries the code beside the message rather than folding it into the
26
+ * prose, because a caller that has to match on prose breaks the day the
27
+ * workbench rewords a refusal. */
28
+ constructor(verb, message, code) {
29
+ super(verb ? `${verb}: ${message}` : message);
30
+ this.name = "WorkbenchError";
31
+ /** Which verb was refused, so a log line says what was asked as well as
32
+ * what came back. Empty for a refusal that answered no verb - the
33
+ * connection itself being turned away. */
34
+ this.verb = verb || "";
35
+ /** The workbench's own words, unaltered and without the verb in front. */
36
+ this.detail = message;
37
+ /** How the refusal was classified, so a caller can branch on it instead of
38
+ * on prose: the workbench's own code (`not_found`, `conflict`, `closing`
39
+ * and the rest the control socket defines) when a verb was refused, and
40
+ * `protocol_mismatch` or `version_mismatch` when this client was the end
41
+ * that refused, at the handshake - the same two the workbench uses, so a
42
+ * script branching on the code need not care which end noticed. Empty when
43
+ * a refusal arrived without one, so test it rather than assume it is set. */
44
+ this.code = code || "";
45
+ }
46
+ }
47
+
48
+ /** Not a method this build has.
49
+ *
50
+ * Nearly always a client older or newer than the workbench, which connecting
51
+ * is supposed to have caught first, so seeing this is worth looking into. */
52
+ export class UnknownVerb extends WorkbenchError {}
53
+
54
+ /** The verb refused what it was given. */
55
+ export class BadParams extends WorkbenchError {}
56
+
57
+ /** No node, build, area or job of that name. */
58
+ export class NotFound extends WorkbenchError {}
59
+
60
+ /** The right request in the wrong state: nothing loaded, nothing running to
61
+ * send to, no import preview to commit. */
62
+ export class Conflict extends WorkbenchError {}
63
+
64
+ /** A request this session cannot serve at all - a window verb with no window,
65
+ * or hardware that is not here. */
66
+ export class Unavailable extends WorkbenchError {}
67
+
68
+ /** The workbench is shutting down. Retry against a new session rather than
69
+ * report a bug. */
70
+ export class Closing extends WorkbenchError {}
71
+
72
+ const BY_CODE = {
73
+ unknown_verb: UnknownVerb,
74
+ bad_params: BadParams,
75
+ not_found: NotFound,
76
+ conflict: Conflict,
77
+ unavailable: Unavailable,
78
+ closing: Closing,
79
+ };
80
+
81
+ /** The right class for a code.
82
+ *
83
+ * An unrecognised code becomes a plain WorkbenchError rather than an error
84
+ * about the error: a workbench newer than this client may classify something
85
+ * in a way this version has never heard of, and swallowing that would be worse
86
+ * than passing it on. */
87
+ export function refusal(verb, message, code) {
88
+ const Cls = BY_CODE[code] || WorkbenchError;
89
+ const e = new Cls(verb, message, code);
90
+ e.name = Cls.name;
91
+ return e;
92
+ }
93
+
94
+ /** A client and a workbench that cannot speak to each other's frames.
95
+ *
96
+ * Its own class rather than a WorkbenchError carrying a code, because a
97
+ * script has to be able to tell "these two cannot talk" from "this build
98
+ * declined what I asked" with `instanceof`, and the two remedies have nothing
99
+ * in common. */
100
+ export class ProtocolMismatch extends WorkbenchError {
101
+ constructor(message, { client = PROTOCOL, workbench = 0 } = {}) {
102
+ super("", message, "protocol_mismatch");
103
+ this.name = "ProtocolMismatch";
104
+ /** The wire version each end speaks. `workbench` is 0 when the workbench
105
+ * refused the connection before it would say what it was. */
106
+ this.client = client;
107
+ this.workbench = workbench;
108
+ }
109
+ }
110
+
111
+ /** A released client driving a workbench from a different release.
112
+ *
113
+ * Separate from ProtocolMismatch: two ends can understand each other's frames
114
+ * perfectly and still be a pair nobody ever built or tested together. */
115
+ export class VersionMismatch extends WorkbenchError {
116
+ constructor(message, { client = RELEASE, workbench = "" } = {}) {
117
+ super("", message, "version_mismatch");
118
+ this.name = "VersionMismatch";
119
+ /** The release each end belongs to. `workbench` is empty when the
120
+ * workbench refused before it would say what it was. */
121
+ this.client = client;
122
+ this.workbench = workbench;
123
+ }
124
+ }
125
+
126
+ /** A wait that ran out, saying what it wanted and what it last saw.
127
+ *
128
+ * Not a bare deadline: "timeout" in a CI log tells whoever reads it nothing,
129
+ * and the state at the moment it gave up is the only thing that does. */
130
+ export class Timeout extends MeshbenchError {
131
+ constructor(what, afterMs, last = "") {
132
+ super(`waited ${Math.round(afterMs / 1000)}s for ${what}` +
133
+ (last ? `; last saw: ${last}` : ""));
134
+ this.name = "Timeout";
135
+ this.what = what;
136
+ this.afterMs = afterMs;
137
+ this.last = last;
138
+ }
139
+ }
140
+
141
+ /** The workbench's refusal of what this client declared, as the mismatch it is
142
+ * rather than as whichever call happened to be in flight failing - which is
143
+ * the confusion the declaration exists to end. Everything else is left alone. */
144
+ export function asMismatch(e) {
145
+ if (e instanceof ProtocolMismatch || e instanceof VersionMismatch) return e;
146
+ if (!(e instanceof WorkbenchError)) return e;
147
+ if (e.code === "protocol_mismatch") return new ProtocolMismatch(e.detail);
148
+ if (e.code === "version_mismatch") return new VersionMismatch(e.detail);
149
+ return e;
150
+ }
@@ -0,0 +1,249 @@
1
+ // What this machine can run, and what it is running.
2
+
3
+ import { NotFound, refusal } from "./errors.mjs";
4
+ import { FIRMWARE_WAIT_MS, JOB_WAIT_MS, waitFor } from "./wait.mjs";
5
+ import { describeBuild } from "./values.mjs";
6
+
7
+ /** Which build a call means, from either a library row or a bare label.
8
+ *
9
+ * A row carries all three names and they are sent together, so the call cannot
10
+ * land on a different build that happens to share a label. A bare label sends
11
+ * only what was given, and the workbench refuses it when it is ambiguous
12
+ * rather than guessing - acting on the wrong build is a rename of somebody
13
+ * else's image. */
14
+ function buildID(build, { board = "", role = "" } = {}) {
15
+ if (build && typeof build === "object") {
16
+ return { version: build.version, role: build.role, board: build.board };
17
+ }
18
+ const p = { version: build };
19
+ if (role) p.role = role;
20
+ if (board) p.board = board;
21
+ return p;
22
+ }
23
+
24
+ /** What this machine can run, and what it is running. Live. */
25
+ export class Firmware {
26
+ constructor(wb) { this._wb = wb; }
27
+
28
+ /** Every build, published or on disk, with what runs it - the rows, not a
29
+ * count of them. */
30
+ async library() {
31
+ return ((await this._wb.call("firmware.library")) || {}).builds || [];
32
+ }
33
+
34
+ /** Only the ones this machine holds, which is the only thing that decides
35
+ * what a node can run. A build that failed to download and one in daily use
36
+ * look identical from anywhere else. */
37
+ async onDisk() {
38
+ return (await this.library()).filter((b) => b.on_disk);
39
+ }
40
+
41
+ /** One build by version, and by board where the version alone is ambiguous -
42
+ * which it is for every board image, because "wadamesh" is not a build until
43
+ * it is wadamesh for a particular piece of hardware. */
44
+ async find(version, board = "") {
45
+ for (const b of await this.library()) {
46
+ if (b.version === version && (!board || b.board === board)) return b;
47
+ }
48
+ throw new NotFound("firmware.library",
49
+ `no build "${version}" for board "${board}"`, "not_found");
50
+ }
51
+
52
+ /** Everything known about one build: where it is, what it is, and what has
53
+ * been decided about it. */
54
+ async details(build, opts) {
55
+ return (await this._wb.call("firmware.details", buildID(build, opts))) || {};
56
+ }
57
+
58
+ /** Rename a build, move it to another board or role, or change how it is run,
59
+ * and report it as it now stands.
60
+ *
61
+ * Renaming moves the file, because the name is the identity: a board image
62
+ * is stored as `<board>/<role>@<label>.bin` and nothing else records what it
63
+ * is. Nodes pinned to the old name are repointed, or they would fail at
64
+ * their next start with "no image in the cache" about a build sitting in the
65
+ * library under its new name.
66
+ *
67
+ * Every setting left out is left alone, which is why they are undefined
68
+ * rather than "" or false: "leave this" and "turn it off" are different
69
+ * answers. */
70
+ async update(build, {
71
+ board = "", role = "", label, newRole, newBoard,
72
+ coprocAtReset, cardRequired, notes,
73
+ } = {}) {
74
+ const p = buildID(build, { board, role });
75
+ if (label !== undefined) p.label = label;
76
+ if (newRole !== undefined) p.new_role = newRole;
77
+ if (newBoard !== undefined) p.new_board = newBoard;
78
+ if (coprocAtReset !== undefined) p.coproc_at_reset = coprocAtReset;
79
+ if (cardRequired !== undefined) p.card_required = cardRequired;
80
+ if (notes !== undefined) p.notes = notes;
81
+ const moved = (await this._wb.call("firmware.update", p)) || {};
82
+ // Read back under whatever it is called now, which is not what it was
83
+ // called if this was a rename.
84
+ return this.details(moved.version || "",
85
+ { board: moved.board || "", role: moved.role || "" });
86
+ }
87
+
88
+ /** Open the build's own window - what a click on a library row does. Refused
89
+ * by a workbench with no interface. */
90
+ async window(build, opts) {
91
+ await this._wb.call("firmware.window", buildID(build, opts));
92
+ }
93
+
94
+ /** Ask the catalogue what is published, which is how a build nobody has
95
+ * downloaded becomes offerable. */
96
+ async scan() { await this._wb.call("firmware.rescan"); }
97
+
98
+ /** Fetch a published build. It returns once the download has been asked for,
99
+ * not once it has landed: wait on the job.
100
+ *
101
+ * `role` is a plain string here and a Role everywhere else, deliberately:
102
+ * this one names a published release asset, and the catalogue's own
103
+ * spellings are not always the application names the verbs are keyed on. */
104
+ async download(role, version, board = "") {
105
+ const p = { role, version };
106
+ if (board) p.board = board;
107
+ await this._wb.call("firmware.download", p);
108
+ }
109
+
110
+ /** Take a build from a path - the one way a locally built image gets into the
111
+ * library.
112
+ *
113
+ * `label` is what the library will know it by and what a node pins. Left out
114
+ * it is a timestamp, so importing twice gives two builds rather than one
115
+ * that quietly replaced the other - which matters the moment you want to put
116
+ * the new one on a node and delete the old. */
117
+ async import(path, role, { board = "", label = "" } = {}) {
118
+ const p = { path, role };
119
+ if (board) p.board = board;
120
+ if (label) p.label = label;
121
+ return (await this._wb.call("firmware.import", p)) || {};
122
+ }
123
+
124
+ /** Remove a build from the cache, and say what was removed.
125
+ *
126
+ * By path, and the workbench refuses any path outside the firmware cache. A
127
+ * build nodes are still pinned to will go: they keep the pin, which then
128
+ * cannot be honoured and fails at start - so move them onto the replacement
129
+ * first. */
130
+ async delete(build) {
131
+ if (!build || !build.path) {
132
+ throw refusal("firmware.delete",
133
+ `${describeBuild(build)} has no path on this machine to delete`,
134
+ "bad_params");
135
+ }
136
+ const got = await this._wb.call("firmware.delete", { path: build.path });
137
+ return (got || {}).deleted || "";
138
+ }
139
+
140
+ /** Compile a MeshCore checkout and put the results in the library.
141
+ *
142
+ * Both roles unless one is named, deliberately. A locally built repeater
143
+ * compiled against a stale shim once answered console output with 0x06 where
144
+ * the host expects 0x07: it connected, misbehaved and exited. Two arms of a
145
+ * comparison built at different moments from different trees measure the
146
+ * build process rather than the firmware, so the easy thing here is the
147
+ * thing that builds them together.
148
+ *
149
+ * Blocks until it is done - a MeshCore build is a minute or two per role -
150
+ * and returns what the library now holds that was built locally. */
151
+ async build(checkout, { role = "", label = "", waitMs = JOB_WAIT_MS } = {}) {
152
+ const p = { source: checkout };
153
+ if (role) p.role = role;
154
+ if (label) p.label = label;
155
+ const got = (await this._wb.call("firmware.build", p)) || {};
156
+ await this._wb.job(got.job || "firmware-build").wait(waitMs);
157
+ return (await this.library()).filter((b) => b.version.startsWith("local-"));
158
+ }
159
+
160
+ /** Pin every role that needs one to the newest build on this machine, and
161
+ * report what it chose.
162
+ *
163
+ * What a script wants almost every time: this mesh, whatever this machine
164
+ * holds, rather than a version typed into the script that goes stale. A run
165
+ * refuses to start until every role is answered, so the alternative is the
166
+ * same loop written out in every caller.
167
+ *
168
+ * It refuses by name when a role has nothing, because "no companion build"
169
+ * is a thing to go and fix rather than a reason to start a mesh with a
170
+ * silent hole in it. */
171
+ async useWhatIsHere() {
172
+ const have = (await this.onDisk()).filter((b) => !b.board);
173
+ const chosen = {};
174
+ for (const want of await this.needed()) {
175
+ const pick = have.filter((b) => b.role === want.role).pop();
176
+ if (!pick) {
177
+ throw new NotFound("firmware.needed",
178
+ `no ${want.role} build on this machine: ` +
179
+ `meshbench firmware download ${want.role}`, "not_found");
180
+ }
181
+ await this.useForRole(want.role, pick);
182
+ chosen[want.role] = pick;
183
+ }
184
+ return chosen;
185
+ }
186
+
187
+ /** Pin every node of a role to one build. */
188
+ async useForRole(role, build) {
189
+ const version = typeof build === "string" ? build : build.version;
190
+ await this._wb.call("firmware.set", { role, version });
191
+ }
192
+
193
+ /** Bring up firmware on every node.
194
+ *
195
+ * Asynchronous, and always has been: it answers with what it has begun, not
196
+ * with what is up. It was synchronous once, and on 155 nodes that froze the
197
+ * window and the socket together for as long as it was left - which read as
198
+ * a crash and was reported as one. Wait with waitStarted. */
199
+ async start() { await this._wb.call("firmware.start"); }
200
+
201
+ /** How far a start has got. */
202
+ async state() { return (await this._wb.call("firmware.state")) || {}; }
203
+
204
+ /** The roles this scenario has nodes for and no build pinned to, with what
205
+ * could be pinned. A run refuses to start until every one is answered. */
206
+ async needed() {
207
+ return ((await this._wb.call("firmware.needed")) || {}).roles || [];
208
+ }
209
+
210
+ /** Wait for every node's firmware to be up. `timeoutMs` is wall clock.
211
+ *
212
+ * `nodes` here is the nodes that run firmware, which is not every node: an
213
+ * SDR observer and an emitter never boot one. It used to be every node, so a
214
+ * fixture holding either reported "56 of 58" until the timeout with no way
215
+ * to see which two.
216
+ *
217
+ * Which is why this names the stragglers rather than counting them. Ten
218
+ * minutes of "56 of 58" tells you nothing; two node names tell you whether a
219
+ * build is missing or a board is wedged. */
220
+ waitStarted(timeoutMs = FIRMWARE_WAIT_MS) {
221
+ let named = 0;
222
+ let last = "";
223
+ return waitFor(async () => {
224
+ const st = await this.state();
225
+ const running = st.running || 0;
226
+ const nodes = st.nodes || 0;
227
+ if (!st.starting && nodes > 0 && running >= nodes) return [true, ""];
228
+ // The names cost a /proc read per node and this polls while firmware is
229
+ // starting, which is the busiest moment there is - every fiftieth of a
230
+ // second is how a diagnostic becomes the fault it was meant to explain,
231
+ // and it timed the socket out. Once every ten seconds is often enough for
232
+ // something a person only reads when the wait fails.
233
+ if (Date.now() - named >= 10_000) {
234
+ named = Date.now();
235
+ last = await this._stragglers();
236
+ }
237
+ return [false, `${running} of ${nodes} running${last}`];
238
+ }, timeoutMs, "firmware to come up");
239
+ }
240
+
241
+ async _stragglers() {
242
+ const waiting = (await this._wb.nodeStats())
243
+ .filter((s) => !s.running).map((s) => s.name);
244
+ if (waiting.length === 0) return "";
245
+ let shown = waiting.slice(0, 4).join(", ");
246
+ if (waiting.length > 4) shown += ` and ${waiting.length - 4} more`;
247
+ return `; waiting on ${shown}`;
248
+ }
249
+ }
package/lib/launch.mjs ADDED
@@ -0,0 +1,112 @@
1
+ // Starting a workbench of one's own, and waiting for it to answer.
2
+ //
3
+ // Separate from the Workbench itself because it is process work rather than
4
+ // protocol work: which binary, which address, and how long to wait before
5
+ // deciding it is not coming.
6
+
7
+ import { spawn } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ import { MeshbenchError } from "./errors.mjs";
13
+ import {
14
+ BINARY_ENV, Connection, MAX_UNIX_PATH, RENDEZVOUS_ENV,
15
+ } from "./socket.mjs";
16
+
17
+ /** How long to give a workbench to answer before deciding it is not coming.
18
+ * A national fixture takes a while to open and a small one does not. */
19
+ export const START_TIMEOUT_MS = 90_000;
20
+
21
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
+
23
+ /** Start `meshbench <command>` and connect to it.
24
+ *
25
+ * Resolves to the connection, the child, and the rendezvous file the child was
26
+ * told to write - which the connection had to be pointed at, because the
27
+ * per-user one names whatever else on this machine last left an address. */
28
+ export async function launch(command, {
29
+ fixture = "", seed = 0, socket = "", binary = "", args = [],
30
+ startTimeoutMs = START_TIMEOUT_MS, callTimeoutMs, stderr = "inherit",
31
+ } = {}) {
32
+ const chosen = address(socket);
33
+ const exe = binary || process.env[BINARY_ENV] || "meshbench";
34
+ const argv = [command, "-control-socket", chosen.address];
35
+ if (fixture) argv.push("-fixture", fixture);
36
+ if (seed) argv.push("-seed", String(seed));
37
+ argv.push(...args);
38
+
39
+ const env = { ...process.env };
40
+ if (chosen.rendezvous) env[RENDEZVOUS_ENV] = chosen.rendezvous;
41
+ let child;
42
+ try {
43
+ child = spawn(exe, argv, { env, stdio: ["ignore", "inherit", stderr] });
44
+ } catch (e) {
45
+ throw new MeshbenchError(`could not start ${exe}: ${e.message}`);
46
+ }
47
+ let exited = null;
48
+ child.on("error", (e) => { exited = e.message; });
49
+ child.on("exit", (code) => { exited ??= `exited with ${code}`; });
50
+
51
+ // Wait for the socket rather than for a fixed moment: a sleep long enough for
52
+ // a national fixture is wasted on every run of a small one.
53
+ const deadline = Date.now() + startTimeoutMs;
54
+ for (;;) {
55
+ if (exited) {
56
+ throw new MeshbenchError(
57
+ `${exe} ${command} ${exited} before answering at ${chosen.address}`);
58
+ }
59
+ try {
60
+ const conn = await Connection.open({
61
+ address: chosen.address, rendezvous: chosen.rendezvous,
62
+ connectTimeoutMs: 1000, callTimeoutMs,
63
+ });
64
+ return { conn, child, rendezvous: chosen.rendezvous };
65
+ } catch (e) {
66
+ if (Date.now() > deadline) {
67
+ child.kill("SIGKILL");
68
+ throw new MeshbenchError(
69
+ `${exe} ${command} did not answer at ${chosen.address} within ` +
70
+ `${Math.round(startTimeoutMs / 1000)}s: ${e.message}`);
71
+ }
72
+ await sleep(50);
73
+ }
74
+ }
75
+ }
76
+
77
+ /** An address of its own unless one was named, so launching two of these does
78
+ * not have them fight over the per-user default.
79
+ *
80
+ * Two reasons that path may not do. Windows has no unix socket a Node client
81
+ * can reach, and a temporary directory on macOS is long enough on its own to
82
+ * exceed sun_path. Either way, loopback - with a rendezvous file of its own
83
+ * too, or two sessions would overwrite each other's port and token in the
84
+ * per-user one. */
85
+ function address(named) {
86
+ if (named) return { address: named, rendezvous: "" };
87
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meshbench"));
88
+ const sock = path.join(dir, "control.sock");
89
+ if (process.platform === "win32" || sock.length > MAX_UNIX_PATH) {
90
+ return { address: "tcp", rendezvous: path.join(dir, "control.json") };
91
+ }
92
+ return { address: sock, rendezvous: "" };
93
+ }
94
+
95
+ /** Stop a workbench this client started.
96
+ *
97
+ * An interrupt asks the run to stop its firmware on the way out, which on
98
+ * fifty-eight emulated nodes is not instant. Windows has no SIGINT to send a
99
+ * process that is not sharing a console, so it is killed there and whatever
100
+ * the run was holding is the operating system's problem. */
101
+ export async function stop(child, graceMs = 20_000) {
102
+ if (child.exitCode !== null || child.signalCode !== null) return;
103
+ child.kill(process.platform === "win32" ? "SIGKILL" : "SIGINT");
104
+ const deadline = Date.now() + graceMs;
105
+ while (child.exitCode === null && child.signalCode === null) {
106
+ if (Date.now() > deadline) {
107
+ child.kill("SIGKILL");
108
+ return;
109
+ }
110
+ await sleep(50);
111
+ }
112
+ }
package/lib/live.mjs ADDED
@@ -0,0 +1,103 @@
1
+ // Bringing a real deployment in from a live feed.
2
+ //
3
+ // Four steps in a fixed order, and every one of them has been skipped by
4
+ // somebody at least once. The two that get missed are the last two, and missing
5
+ // them does not fail: the mesh comes up with regions inferred but never
6
+ // applied, which transmits everything, relays nothing, and reports no error at
7
+ // all. It reads as bad RF.
8
+ //
9
+ // So the steps are here individually, because sometimes you want to look at a
10
+ // preview before committing - and `pull` runs all four, because the ordinary
11
+ // case is wanting the whole deployment and the ordinary mistake is stopping
12
+ // early.
13
+
14
+ import { MeshbenchError } from "./errors.mjs";
15
+ import { Strategy } from "./sets.mjs";
16
+ import { ImportPreview } from "./values.mjs";
17
+ import { JOB_WAIT_MS } from "./wait.mjs";
18
+
19
+ /** How far back to read traffic when working out what each node holds.
20
+ *
21
+ * A week, because that is what it takes for the quiet regions to say anything
22
+ * at all: on ScotMesh a small region is about sixty packets in seven days, and
23
+ * a shorter window drops it entirely rather than reporting it as thin. */
24
+ export const DEFAULT_WINDOW_HOURS = 7 * 24;
25
+
26
+ /** A live feed, and the deployment it describes. Live in both senses. */
27
+ export class Live {
28
+ constructor(wb) { this._wb = wb; }
29
+
30
+ /** Fetch, commit, read the traffic, and apply what it implies.
31
+ *
32
+ * The whole chain, in the order that works. `windowHours` is how far back
33
+ * into the feed's history to read - the mesh's own past, not your patience;
34
+ * `waitMs` is yours.
35
+ *
36
+ * Returns what the fetch found. Link measurement is still running when this
37
+ * returns on anything but a small mesh, so follow it with `wb.waitIdle()`
38
+ * before starting a run. */
39
+ async pull(url, { strategy = Strategy.REPLACE,
40
+ windowHours = DEFAULT_WINDOW_HOURS, waitMs = JOB_WAIT_MS } = {}) {
41
+ const preview = await this.fetch(url);
42
+ if (preview.nodes === 0) {
43
+ throw new MeshbenchError(
44
+ `${url} described ${preview.records} nodes, none usable`);
45
+ }
46
+ await this.commit(strategy);
47
+ await this.infer({ windowHours, waitMs });
48
+ await this.applyRegions();
49
+ return preview;
50
+ }
51
+
52
+ /** Point at a feed without reading it, and say how the URL was tidied.
53
+ *
54
+ * A method rather than a property, because a property implies something to
55
+ * read back and the session offers no way to ask what its source currently
56
+ * is. One that answered from a value this object happened to remember would
57
+ * be right until anything else set it. */
58
+ async setSource(url) {
59
+ return ((await this._wb.call("import.set_source", { url })) || {}).url || url;
60
+ }
61
+
62
+ /** Read the deployment and say what would change, changing nothing. */
63
+ async fetch(url = "") {
64
+ if (url) await this.setSource(url);
65
+ return new ImportPreview((await this._wb.call("import.fetch")) || {});
66
+ }
67
+
68
+ /** Apply the fetched nodes to the scenario, and say how many it now holds.
69
+ *
70
+ * "replace-all" is what the shipped fixtures were built with; "add" keeps
71
+ * what is already here and skips names that clash.
72
+ *
73
+ * Measuring the links afterwards is a job rather than part of this call - 676
74
+ * nodes is 228,000 terrain paths over real ground - so this returns while
75
+ * that is still running. */
76
+ async commit(strategy = Strategy.REPLACE) {
77
+ return ((await this._wb.call("import.commit", { strategy })) || {}).nodes || 0;
78
+ }
79
+
80
+ /** Read the feed's recent traffic to work out what each node holds.
81
+ *
82
+ * This is the step that decides whether anything relays. A node whose regions
83
+ * are unknown forwards nothing, and nothing says so.
84
+ *
85
+ * `windowHours` is the feed's own past; `waitMs` is how long you will sit
86
+ * here for it. A week of ScotMesh is around 150,000 packets and several
87
+ * minutes of paging. */
88
+ async infer({ windowHours = DEFAULT_WINDOW_HOURS, waitMs = JOB_WAIT_MS } = {}) {
89
+ if (!(windowHours > 0)) {
90
+ throw new MeshbenchError("infer() needs a window, in hours of the feed's past");
91
+ }
92
+ await this._wb.call("infer.run", { hours: windowHours });
93
+ await this._wb.job("infer").wait(waitMs);
94
+ }
95
+
96
+ /** Put the inferred regions onto the nodes, and say how many took one.
97
+ *
98
+ * The forgotten step. Everything above can succeed and the mesh still be
99
+ * silent until this runs. */
100
+ async applyRegions() {
101
+ return ((await this._wb.call("infer.apply")) || {}).applied || 0;
102
+ }
103
+ }