@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.
@@ -0,0 +1,376 @@
1
+ // The connection, and everything hanging off it.
2
+
3
+ import {
4
+ ProtocolMismatch, Unavailable, VersionMismatch, asMismatch,
5
+ } from "./errors.mjs";
6
+ import { PROTOCOL, RELEASE, pairedRelease, pairingNote } from "./pairing.mjs";
7
+ import { Connection, DEFAULT_CALL_TIMEOUT_MS, defaultAddress } from "./socket.mjs";
8
+ import { launch, stop } from "./launch.mjs";
9
+ import { JOB_WAIT_MS, waitFor } from "./wait.mjs";
10
+ import { Provenance } from "./values.mjs";
11
+ import { Assertions, Schedule } from "./checks.mjs";
12
+ import { Boundary } from "./boundary.mjs";
13
+ import { Live } from "./live.mjs";
14
+ import { Nodes, Node } from "./nodes.mjs";
15
+ import { Console, Events, Job, Project } from "./parts.mjs";
16
+ import { Firmware } from "./firmware.mjs";
17
+ import { Sim } from "./sim.mjs";
18
+ import { Subscription } from "./subscribe.mjs";
19
+
20
+ /** A running session.
21
+ *
22
+ * `launch` and `headless` own the process they started and stop it on the way
23
+ * out; `attach` never does - a script must not be able to close the workbench
24
+ * somebody is looking at by falling off the end of a function. */
25
+ export class Workbench {
26
+ constructor(conn, child = null) {
27
+ this._conn = conn;
28
+ this._child = child;
29
+ // What this connection is talking to, read at connect. Kept private and
30
+ // re-asked by hello(), where the Go and Python clients hold it as a field:
31
+ // a method is what JavaScript has for something worth reading again, and
32
+ // hello() was already this client's public way of re-checking.
33
+ this._hello = {};
34
+ /** What became of the release check at connect: empty when the two ends
35
+ * compared equal, and a sentence naming what was skipped and why when one
36
+ * of them was not a release build. */
37
+ this.versionCheck = "";
38
+ }
39
+
40
+ // ---- connecting ------------------------------------------------------
41
+
42
+ /** Connect to a workbench that is already running, and do the handshake
43
+ * before handing it back.
44
+ *
45
+ * Takes an address, or a row from `sessions()`. A row is the way to reach a
46
+ * second TCP session: its token sits beside its address in its own file,
47
+ * where the per-user rendezvous file two of them share has only one.
48
+ *
49
+ * There is deliberately no "attach to whatever is running". Where several
50
+ * are up and none was named, guessing is how a script ends up driving the
51
+ * session somebody else was watching. */
52
+ static async attach(opts = {}) {
53
+ const from = typeof opts === "string" ? { socket: opts } : opts;
54
+ const session = from.session;
55
+ const conn = await Connection.open({
56
+ address: session ? session.address : (from.socket || defaultAddress()),
57
+ token: session ? session.token : "",
58
+ rendezvous: from.rendezvous || "",
59
+ connectTimeoutMs: from.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS,
60
+ callTimeoutMs: from.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS,
61
+ });
62
+ return Workbench._greet(new Workbench(conn));
63
+ }
64
+
65
+ /** Start a session with no window, and own it.
66
+ *
67
+ * The one to use from a test or from CI: no display, no GPU, no toolkit. */
68
+ static headless(opts = {}) {
69
+ return Workbench._spawn("headless", opts);
70
+ }
71
+
72
+ /** Open the desktop workbench and own it. Needs a display. */
73
+ static launch(opts = {}) {
74
+ return Workbench._spawn("workbench", opts);
75
+ }
76
+
77
+ /** Use the session that is running, or start one with no window.
78
+ *
79
+ * For a script somebody runs repeatedly by hand: the second run carries on
80
+ * from the first rather than clearing everything down. Note which half you
81
+ * got - `ownsProcess` says - because attaching leaves the session running at
82
+ * the end and starting one does not. */
83
+ static attachOrHeadless(opts = {}) {
84
+ return Workbench._attachOr(Workbench.headless, opts);
85
+ }
86
+
87
+ /** The windowed half of the pair, so a re-run can put something back on
88
+ * screen. Needs a display. */
89
+ static attachOrLaunch(opts = {}) {
90
+ return Workbench._attachOr(Workbench.launch, opts);
91
+ }
92
+
93
+ static async _attachOr(start, opts) {
94
+ // The session that is started is started at the address that was just
95
+ // tried, which is the whole point of the pair. launch() and headless()
96
+ // called directly invent a private address so two of them do not fight over
97
+ // the per-user default; inheriting that here made attachOr useless - every
98
+ // run failed to attach, started a session somewhere nobody would look
99
+ // again, and the next run did the same.
100
+ const named = { ...opts, socket: opts.socket || defaultAddress() };
101
+ try {
102
+ return await Workbench.attach(named);
103
+ } catch (e) {
104
+ if (e instanceof ProtocolMismatch || e instanceof VersionMismatch) throw e;
105
+ return start(named);
106
+ }
107
+ }
108
+
109
+ static async _spawn(command, opts) {
110
+ const { conn, child } = await launch(command, opts);
111
+ let wb;
112
+ try {
113
+ wb = await Workbench._greet(new Workbench(conn, child));
114
+ } catch (e) {
115
+ conn.close();
116
+ await stop(child);
117
+ throw e;
118
+ }
119
+ if (opts.fixture) {
120
+ // The socket answers before the fixture is open. The windowed build loads
121
+ // it on a worker so the window appears first, so a client can connect,
122
+ // ask what is going on, and be told nothing - an empty job list, no
123
+ // nodes, and a waitIdle that returns instantly having waited for work
124
+ // that had not been queued yet.
125
+ try {
126
+ await wb.waitForNodes(opts.startTimeoutMs);
127
+ } catch (e) {
128
+ await wb.close();
129
+ throw e;
130
+ }
131
+ }
132
+ return wb;
133
+ }
134
+
135
+ static async _greet(wb) {
136
+ try {
137
+ await wb.hello();
138
+ } catch (e) {
139
+ wb._conn.close();
140
+ throw e;
141
+ }
142
+ return wb;
143
+ }
144
+
145
+ /** Ask the workbench what it is, and refuse a build this client may not
146
+ * drive: a protocol it does not speak, or a release it was not shipped with.
147
+ * Connecting calls this itself, so calling it again is only useful to
148
+ * re-check.
149
+ *
150
+ * Refused at both ends. The workbench has already turned away a version it
151
+ * will not serve, on the frame this client declared it on, so the
152
+ * comparisons here look redundant. They are not: a workbench old enough to
153
+ * predate the declaration ignores it and serves the connection anyway, and
154
+ * this end is then the only one left that can notice. */
155
+ async hello() {
156
+ let h;
157
+ try {
158
+ h = await this.call("session.hello");
159
+ } catch (e) {
160
+ throw asMismatch(e);
161
+ }
162
+ this._hello = h || {};
163
+ if (this._hello.protocol !== undefined && this._hello.protocol !== PROTOCOL) {
164
+ throw new ProtocolMismatch(
165
+ `this client speaks control protocol ${PROTOCOL} and the workbench at ` +
166
+ `${this._conn.address} speaks ${this._hello.protocol}. Upgrade whichever is older`,
167
+ { workbench: this._hello.protocol });
168
+ }
169
+ const theirs = this._hello.release || "";
170
+ if (!pairedRelease(RELEASE, theirs)) {
171
+ throw new VersionMismatch(
172
+ `this client is from MeshBench ${RELEASE} and this workbench is ` +
173
+ `MeshBench ${theirs}. A client and the workbench it drives must be the ` +
174
+ `same release: install the ${theirs} client, or run the ${RELEASE} workbench`,
175
+ { workbench: theirs });
176
+ }
177
+ this.versionCheck = pairingNote(RELEASE, theirs);
178
+ return this._hello;
179
+ }
180
+
181
+ // ---- lifetime --------------------------------------------------------
182
+
183
+ /** Where this connection was made, as the caller wrote it. */
184
+ get address() { return this._conn.address; }
185
+
186
+ /** Whether closing this will stop the workbench, or only hang up on it. */
187
+ get ownsProcess() { return this._child !== null; }
188
+
189
+ /** Whether this session has no interface, so a caller can check once rather
190
+ * than learn it from a dozen refusals. */
191
+ get isHeadless() { return this._hello.mode === "headless"; }
192
+
193
+ /** Hang up, and stop the process if this client started it. */
194
+ async close() {
195
+ this._conn.close();
196
+ if (this._child) await stop(this._child);
197
+ }
198
+
199
+ // ---- the wire --------------------------------------------------------
200
+
201
+ /** Run one verb and return its result.
202
+ *
203
+ * Public and documented, not an escape hatch to be ashamed of: the shaped
204
+ * API will never cover every verb the socket answers, and a verb added
205
+ * tomorrow should be usable today. Ask `verbs()` for the list this build
206
+ * actually offers.
207
+ *
208
+ * Pass `null` for `timeoutMs` to wait indefinitely on a call known to take a
209
+ * while. */
210
+ call(verb, params, timeoutMs) {
211
+ return this._conn.call(verb, params, timeoutMs);
212
+ }
213
+
214
+ /** Stream server-pushed notifications for the given topics, rather than
215
+ * polling. Opens a second connection to this same workbench, so closing the
216
+ * returned Subscription hangs up only that stream.
217
+ *
218
+ * Topics today: "status" (a new console line) and "snapshot" (a compact
219
+ * summary after each publish, coalesced by the server so a busy run cannot
220
+ * flood a slow reader). */
221
+ subscribe(...topics) {
222
+ return Subscription.open(this._conn.address, topics);
223
+ }
224
+
225
+ /** The whole session as the socket summarises it. */
226
+ async snapshot() { return (await this.call("session.snapshot")) || {}; }
227
+
228
+ /** The cheap summary: nodes, seed, time, whether it is playing. */
229
+ async describe() { return (await this.call("session.describe")) || {}; }
230
+
231
+ /** Every command this workbench has been driven with, newest last, and when
232
+ * the process started - so a session picked up cold can be told how the
233
+ * world got here, and whether it has been restarted. */
234
+ async journal() { return (await this.call("session.journal")) || {}; }
235
+
236
+ /** Every method this build answers. */
237
+ async verbs() { return ((await this.call("session.verbs")) || {}).verbs || []; }
238
+
239
+ /** Leave a line in the session's log, for whoever is watching. */
240
+ async say(text) { await this.call("ui.said", text); }
241
+
242
+ /** Freeze the whole session under a name - the network, how it is being run,
243
+ * and where the clock had got to - so it can be taken back here. */
244
+ async checkpoint(name) {
245
+ return (await this.call("session.checkpoint", { name })) || {};
246
+ }
247
+
248
+ /** Rebuild a checkpoint and replay to the moment it was taken. Returns as
249
+ * soon as the replay is under way; the sim reaching `target_ms` is when it
250
+ * has actually arrived. Deterministic, so it comes back to exactly where it
251
+ * was, at the cost of the replay taking the run's own time. */
252
+ async restore(name) {
253
+ return (await this.call("session.restore", { name })) || {};
254
+ }
255
+
256
+ /** What can be restored, by name. */
257
+ async checkpoints() {
258
+ return ((await this.call("session.checkpoints")) || {}).checkpoints || [];
259
+ }
260
+
261
+ /** What else is running on this machine, this session included.
262
+ *
263
+ * The same list `sessions()` reads from disk, asked of the workbench
264
+ * instead. Two differences: the row for this session has `self` set and
265
+ * describes itself from the inside, and no row carries a token, because a
266
+ * token belongs in the 0600 file it came from and not in a reply. So these
267
+ * rows are for choosing by; pass one from `sessions()` to `attach`. */
268
+ async sessions() {
269
+ return ((await this.call("session.list")) || {}).sessions || [];
270
+ }
271
+
272
+ /** Whether a panel opened in its own window stays above the main one. Reads
273
+ * the preference when called with nothing, sets it when given a value, and
274
+ * returns what it now is.
275
+ *
276
+ * The preference exists for Linux under Wayland, where no client may ask a
277
+ * normal window to stay above others. What can be asked for is a layer-shell
278
+ * surface, and that is a different kind of window: no title bar, no taskbar
279
+ * entry and no minimise, so the window draws its own bar. On macOS and
280
+ * Windows always-on-top costs nothing and the preference does not apply. */
281
+ async keepAbove(on) {
282
+ const got = await this.call("ui.keep_above", on === undefined ? {} : { on });
283
+ return (got || {}).on ?? true;
284
+ }
285
+
286
+ /** Open a node's own window, on a named tab, and return the tab it opened on.
287
+ *
288
+ * Windowed sessions only, and it says so here rather than appearing to work:
289
+ * a headless run has nothing to open, and a script that "opened the Hardware
290
+ * tab" in CI and saw no error will be written to assume it did. */
291
+ async window(node, tab = "") {
292
+ if (this.isHeadless) {
293
+ throw new Unavailable("node.window",
294
+ "this session has no interface attached, so there is nothing to show",
295
+ "unavailable");
296
+ }
297
+ const got = await this.call("node.window", { node: String(node), tab });
298
+ return (got || {}).tab || "";
299
+ }
300
+
301
+ // ---- the shape -------------------------------------------------------
302
+
303
+ get nodes() { return new Nodes(this); }
304
+ get sim() { return new Sim(this); }
305
+ get project() { return new Project(this); }
306
+ get firmware() { return new Firmware(this); }
307
+ get events() { return new Events(this); }
308
+ get schedule() { return new Schedule(this); }
309
+ get assertions() { return new Assertions(this); }
310
+
311
+ /** The study area: which nodes are in the question being asked. Set it before
312
+ * importing, because the import filters at fetch time. */
313
+ get boundary() { return new Boundary(this); }
314
+
315
+ /** A live deployment feed - CoreScope and the rest - and the import chain
316
+ * that brings one in. */
317
+ get live() { return new Live(this); }
318
+
319
+ /** A handle, without checking it exists - so one can be named before it is
320
+ * placed. Every method on it will say so if it does not. */
321
+ node(name) { return new Node(this, name); }
322
+
323
+ console(node) { return new Console(this, String(node)); }
324
+
325
+ job(id) { return new Job(this, id); }
326
+
327
+ /** Everything long-running that is in flight. */
328
+ async jobs() { return (await this.snapshot()).jobs || []; }
329
+
330
+ /** Sample every node and return what it found - the rows, not a count of
331
+ * them.
332
+ *
333
+ * A sample, not a read: it costs a /proc read per node, which is why the
334
+ * window only does it while somebody is looking at the panel. */
335
+ async nodeStats() {
336
+ return ((await this.call("nodes.stats")) || {}).stats || [];
337
+ }
338
+
339
+ /** What this session's measurements are being made under.
340
+ *
341
+ * Read from the session rather than carried on each result, for now: the
342
+ * verbs do not return it yet, and inventing it here would be a claim this
343
+ * client is not entitled to make. */
344
+ async provenance() { return new Provenance(await this.snapshot()); }
345
+
346
+ // ---- waiting ---------------------------------------------------------
347
+
348
+ /** Wait until the session has a network in it.
349
+ *
350
+ * For a fixture opened at startup, which happens on a worker: the socket
351
+ * answers first, so everything asked before the open lands describes an
352
+ * empty session and is believed. */
353
+ waitForNodes(timeoutMs = JOB_WAIT_MS) {
354
+ return waitFor(async () => {
355
+ const n = (await this.describe()).nodes || 0;
356
+ return n ? [true, ""] : [false, "no nodes yet"];
357
+ }, timeoutMs, "the fixture to open");
358
+ }
359
+
360
+ /** Wait for every job to finish - the honest way to wait out a warm, which is
361
+ * what most of them are.
362
+ *
363
+ * Finished jobs are ignored rather than waited for: some are removed when
364
+ * they end and some are only marked - infer.run's is marked - so waiting for
365
+ * the list to empty waits for ever on half of them. That is a difference
366
+ * between the verbs, and not a caller's to know about. */
367
+ waitIdle(timeoutMs = JOB_WAIT_MS) {
368
+ return waitFor(async () => {
369
+ const running = (await this.jobs()).filter((j) => !j.finished);
370
+ if (running.length === 0) return [true, ""];
371
+ const first = running[0];
372
+ return [false, `${running.length} still running, first is ` +
373
+ `"${first.what}" (${first.done} of ${first.total})`];
374
+ }, timeoutMs, "the workbench to go idle");
375
+ }
376
+ }
package/meshbench.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // Drive a MeshBench workbench from Node.
2
+ //
3
+ // import { Workbench } from "@meshbench/client";
4
+ //
5
+ // const wb = await Workbench.headless({ fixture: "fife-strict", seed: 9001 });
6
+ // try {
7
+ // await wb.sim.start();
8
+ // await wb.sim.run(5 * 60_000);
9
+ // console.log(String(await wb.provenance()));
10
+ // console.log(await wb.events.total(), "events");
11
+ // } finally {
12
+ // await wb.close();
13
+ // }
14
+ //
15
+ // Speaks the same control socket as pkg/client-go and pkg/client-python, on the
16
+ // same machine, and carries the same shape: `wb.nodes`, `wb.sim`,
17
+ // `wb.firmware`, `wb.events`, `wb.project`, `wb.live`, `wb.boundary` and a
18
+ // node's own console. Where the three differ it is because a language differs -
19
+ // a Python property that reads the session is a method here, because a method
20
+ // is what JavaScript has for something that awaits.
21
+ //
22
+ // Two layers, and both are public. `wb.call(verb, params)` is the whole
23
+ // protocol, so a verb this package has not shaped is one line away rather than
24
+ // a blocker; the shape above it is what a script should reach for first,
25
+ // because every helper on it exists to stop a mistake somebody has already
26
+ // made.
27
+ //
28
+ // Every wait is a method - `node.waitRunning()`, `sim.run()`,
29
+ // `firmware.waitStarted()` - never a sleep in a script. They poll today and
30
+ // will subscribe later, and no script changes when they do.
31
+ //
32
+ // A wait measured in simulated time is not a wait measured in yours:
33
+ // `sim.run(5 * 60_000)` is five minutes of the mesh's own clock, and on 155
34
+ // emulated nodes that is a great deal longer than five of yours. Nothing that
35
+ // means the mesh's clock is called a timeout.
36
+ //
37
+ // Zero dependencies and no build step: it is ES modules on Node's own `net`,
38
+ // because a client that needed a framework to speak to a local socket would be
39
+ // a client nobody could debug.
40
+
41
+ export { Workbench } from "./lib/workbench.mjs";
42
+
43
+ export {
44
+ BadParams, Closing, Conflict, MeshbenchError, NotFound, ProtocolMismatch,
45
+ Timeout, Unavailable, UnknownVerb, VersionMismatch, WorkbenchError,
46
+ } from "./lib/errors.mjs";
47
+
48
+ export { PROTOCOL, RELEASE, pairedRelease, pairingNote } from "./lib/pairing.mjs";
49
+
50
+ export {
51
+ BINARY_ENV, DEFAULT_CALL_TIMEOUT_MS, MAX_UNIX_PATH, RENDEZVOUS_ENV,
52
+ SESSIONS_ENV, SOCKET_ENV, defaultAddress,
53
+ } from "./lib/socket.mjs";
54
+
55
+ export { START_TIMEOUT_MS } from "./lib/launch.mjs";
56
+
57
+ export { DETAIL_WAIT_MS, sessions, sessionsDir } from "./lib/sessions.mjs";
58
+
59
+ export {
60
+ EVENT_WAIT_MS, FIRMWARE_WAIT_MS, JOB_WAIT_MS, RUN_WAIT_MS,
61
+ } from "./lib/wait.mjs";
62
+
63
+ export { ImportPreview, Provenance, describeBuild } from "./lib/values.mjs";
64
+
65
+ export { ASSERTION_KINDS, Assertions, Check, Report, Schedule } from "./lib/checks.mjs";
66
+
67
+ export { Boundary } from "./lib/boundary.mjs";
68
+ export { DEFAULT_WINDOW_HOURS, Live } from "./lib/live.mjs";
69
+ export { FIND_LEAST, Node, Nodes } from "./lib/nodes.mjs";
70
+ export { Console, Events, Job, Project } from "./lib/parts.mjs";
71
+ export { Firmware } from "./lib/firmware.mjs";
72
+ export { Sim } from "./lib/sim.mjs";
73
+ export { SCREEN_WAIT_MS, Device } from "./lib/device.mjs";
74
+ export { SUBSCRIBE, Subscription } from "./lib/subscribe.mjs";
75
+
76
+ // The closed sets, generated by tools/clientgen from internal/world/scenario.
77
+ // Never spell one as a free string: a board name nothing matches produces a
78
+ // different node, silently.
79
+ export {
80
+ Board, Boards, Class, Classes, DEFAULT_PRESET, Kind, Preset, Presets,
81
+ Role, Roles, Strategy, Tab, Tabs, Transport,
82
+ } from "./lib/sets.mjs";
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@meshbench/client",
3
+ "version": "0.0.1",
4
+ "description": "Drive a running MeshBench workbench from Node over its control socket.",
5
+ "license": "GPL-3.0-or-later",
6
+ "type": "module",
7
+ "main": "meshbench.mjs",
8
+ "exports": "./meshbench.mjs",
9
+ "files": [
10
+ "meshbench.mjs",
11
+ "lib/",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/MeshBench/meshbench.git",
23
+ "directory": "pkg/client-js"
24
+ },
25
+ "keywords": [
26
+ "meshbench",
27
+ "meshcore",
28
+ "lora",
29
+ "simulation"
30
+ ]
31
+ }