@synapse-sdk/client 0.3.0 → 0.3.4
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/dist/calls.d.ts +7 -1
- package/dist/calls.js +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node.d.ts +62 -24
- package/dist/node.js +208 -68
- package/dist/scene.d.ts +150 -56
- package/dist/scene.js +995 -163
- package/dist/scene_frames.d.ts +2 -2
- package/dist/scene_frames.js +1 -1
- package/dist/session.d.ts +74 -11
- package/dist/session.js +279 -59
- package/dist/store.d.ts +31 -10
- package/dist/store.js +140 -28
- package/dist/synapse.d.ts +77 -46
- package/dist/synapse.js +193 -114
- package/dist/tasks.d.ts +37 -0
- package/dist/tasks.js +146 -0
- package/dist/topics.d.ts +23 -6
- package/dist/topics.js +59 -22
- package/dist/types.d.ts +405 -93
- package/dist/util.d.ts +43 -0
- package/dist/util.js +97 -1
- package/dist/view.d.ts +5 -3
- package/dist/view.js +21 -10
- package/package.json +2 -2
package/dist/synapse.js
CHANGED
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
// loop (`node.ts`) drives sessions; publishers, registrations, subscriptions and namespaces are their
|
|
3
3
|
// own tasks, told about sessions by the loop; every fact about the world is read from the current
|
|
4
4
|
// `Session` (the runtime's statement) at the moment it is asked for, never copied here.
|
|
5
|
-
import {
|
|
6
|
-
import { callStream, startOn } from "./calls.js";
|
|
7
|
-
import { Node,
|
|
5
|
+
import { SynapseError, ageOf, fecSymbolSizeFor, isServiceContract, nowUs, resolveTopic, serviceName, } from "@synapse-sdk/core";
|
|
6
|
+
import { callStream, failedRun, startOn } from "./calls.js";
|
|
7
|
+
import { Node, createNode, endpointDial, resolveTarget } from "./node.js";
|
|
8
8
|
import { ServiceRegistration } from "./serve.js";
|
|
9
9
|
import { openScene } from "./scene.js";
|
|
10
|
-
import { Namespace, StoreHandleImpl, openStore } from "./store.js";
|
|
10
|
+
import { Namespace, StoreHandleImpl, attachStore, openStore } from "./store.js";
|
|
11
|
+
import { TasksFacet } from "./tasks.js";
|
|
11
12
|
import { PublisherHandle, TopicPublisher, TopicSubscription, wasmFor } from "./topics.js";
|
|
12
13
|
import { TopicView } from "./view.js";
|
|
13
|
-
|
|
14
|
-
export
|
|
14
|
+
import { watchState } from "./util.js";
|
|
15
|
+
export { FIRST_CONNECT_GIVE_UP_MS } from "./node.js";
|
|
15
16
|
/** The descriptor a publisher of `contract` on node `n` declares (as `TopicPublisher` builds it: a `fec` symbol size of 0 is the node's). */
|
|
16
17
|
function wantedDesc(contract, n) {
|
|
17
18
|
const d = contract.desc(n.dom);
|
|
@@ -21,96 +22,160 @@ function wantedDesc(contract, n) {
|
|
|
21
22
|
}
|
|
22
23
|
/** The parts of a descriptor two publishers of one topic on one node must agree on, as text (also for the refusal's message). */
|
|
23
24
|
function descKey(d) {
|
|
24
|
-
return JSON.stringify({ delivery: d.delivery, content_type: d.content_type, schema_hash: d.schema_hash ?? null, fec: d.fec ?? null
|
|
25
|
+
return JSON.stringify({ delivery: d.delivery, content_type: d.content_type, schema_hash: d.schema_hash ?? null, fec: d.fec ?? null });
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* A fact of the node as a state value (`node.status`, `node.catalog`, `node.project`): `read` is the fact now, the same
|
|
29
|
+
* object while it is unchanged. It changes with the node's link and, while connected, with the current session's values
|
|
30
|
+
* that `watched` names; a callback is rebound to the new session's values on every link change.
|
|
31
|
+
*/
|
|
32
|
+
class NodeCell {
|
|
33
|
+
node;
|
|
34
|
+
read;
|
|
35
|
+
watched;
|
|
36
|
+
constructor(node, read, watched) {
|
|
37
|
+
this.node = node;
|
|
38
|
+
this.read = read;
|
|
39
|
+
this.watched = watched;
|
|
40
|
+
}
|
|
41
|
+
get() { return this.read(); }
|
|
42
|
+
/** `cb` with the value on each change of what it is read from; returns the remover. */
|
|
43
|
+
onChange(cb) {
|
|
44
|
+
const listener = () => cb(this.read());
|
|
45
|
+
let un = [];
|
|
46
|
+
const bind = () => {
|
|
47
|
+
for (const u of un)
|
|
48
|
+
u();
|
|
49
|
+
const s = this.node.currentSession();
|
|
50
|
+
un = s ? this.watched(s).map(c => c.subscribe(listener)) : [];
|
|
51
|
+
};
|
|
52
|
+
bind();
|
|
53
|
+
const unLink = this.node.link.subscribe(() => { bind(); listener(); });
|
|
54
|
+
return () => { unLink(); for (const u of un)
|
|
55
|
+
u(); };
|
|
56
|
+
}
|
|
57
|
+
/** The value after its next change; once the node ends, the value then, whether or not it changed (nothing will change any more). */
|
|
58
|
+
changed() {
|
|
59
|
+
const from = this.read();
|
|
60
|
+
if (this.node.isEnded)
|
|
61
|
+
return Promise.resolve(from);
|
|
62
|
+
return new Promise(resolve => {
|
|
63
|
+
let done = false;
|
|
64
|
+
const finish = (v) => { if (done)
|
|
65
|
+
return; done = true; un(); resolve(v); };
|
|
66
|
+
const un = this.onChange(v => { if (v !== from)
|
|
67
|
+
finish(v); });
|
|
68
|
+
void this.node.ended.promise.then(() => finish(this.read()));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** The value after each change; ends when the node ends. */
|
|
72
|
+
watch() { return watchState(cb => this.onChange(cb), this.read, () => this.node.isEnded); }
|
|
73
|
+
}
|
|
74
|
+
const NO_TOPICS = Object.freeze({});
|
|
75
|
+
const NO_SERVICES = Object.freeze({});
|
|
26
76
|
export class Synapse {
|
|
27
|
-
opts;
|
|
28
77
|
#node;
|
|
29
|
-
#connecting;
|
|
30
|
-
#starting;
|
|
31
78
|
#publishers = new Map();
|
|
32
79
|
#stores = new Map();
|
|
33
80
|
warn;
|
|
81
|
+
/**
|
|
82
|
+
* The node, usable at once and `idle` (no target) until `connect()`: constructing opens no connection. Where to connect
|
|
83
|
+
* (the url, its token, the certificate pin) is the target's, given to `connect()`. Throws `invalid_input` for options
|
|
84
|
+
* that can never work (a bad `name`).
|
|
85
|
+
*/
|
|
34
86
|
constructor(opts) {
|
|
35
|
-
this.opts = opts;
|
|
36
87
|
this.warn = msg => console.warn(`[synapse] ${msg}`);
|
|
88
|
+
const n = this.#node = createNode(opts);
|
|
89
|
+
let lastLink = n.link.get(), lastState = n.state();
|
|
90
|
+
this.status = new NodeCell(n, () => {
|
|
91
|
+
const l = n.link.get();
|
|
92
|
+
if (l !== lastLink) {
|
|
93
|
+
lastLink = l;
|
|
94
|
+
lastState = n.state();
|
|
95
|
+
}
|
|
96
|
+
return lastState;
|
|
97
|
+
}, () => []);
|
|
98
|
+
let lastCatalog;
|
|
99
|
+
this.catalog = new NodeCell(n, () => {
|
|
100
|
+
const s = n.currentSession();
|
|
101
|
+
const topics = s?.topics.get().topics ?? NO_TOPICS, services = s?.services.get().services ?? NO_SERVICES;
|
|
102
|
+
if (lastCatalog?.topics !== topics || lastCatalog.services !== services)
|
|
103
|
+
lastCatalog = { topics, services, describe: name => services[name]?.card };
|
|
104
|
+
return lastCatalog;
|
|
105
|
+
}, s => [s.topics, s.services]);
|
|
106
|
+
this.project = new NodeCell(n, () => n.currentSession()?.project.get(), s => [s.project]);
|
|
37
107
|
}
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
get connected() { return this.#node?.isConnected ?? false; }
|
|
62
|
-
/** Called on every change of the connection state; returns the unsubscribe. */
|
|
63
|
-
watchStatus(cb) { return this.node.link.subscribe(() => cb(this.node.state())); }
|
|
64
|
-
/** Resolves on the next connected session (at once if connected); rejects once the node ended. */
|
|
65
|
-
whenConnected() {
|
|
66
|
-
const n = this.node;
|
|
67
|
-
if (n.isConnected)
|
|
68
|
-
return Promise.resolve();
|
|
108
|
+
/**
|
|
109
|
+
* Connects the node to `target` (a string is the url), the one connect verb: the first connect, and how an HMI moves
|
|
110
|
+
* from one robot's runtime to another. The current session, if any, is torn down before `target` is dialed; every
|
|
111
|
+
* handle re-binds to the new session as across a reconnect. Identity fields `target` leaves out keep the node's.
|
|
112
|
+
*
|
|
113
|
+
* Operator intent is never lost under quick clicking: the newest call wins. The node works toward its latest target; a
|
|
114
|
+
* call superseded by a newer `connect()` or `disconnect()` resolves quietly. The target already declared declares
|
|
115
|
+
* nothing and settles with it, except from its own `failed`, where it is dialed again.
|
|
116
|
+
*
|
|
117
|
+
* Bounded (SDK §2): a retryable failure is tried again, paced by the reconnect policy, until `FIRST_CONNECT_GIVE_UP_MS`
|
|
118
|
+
* after the first attempt, a hard bound (an attempt still outstanding then is cut); `unauthorized` gives up at once.
|
|
119
|
+
* Resolves once a session to *this* target is connected (or a newer call superseded it). Rejects with this target's
|
|
120
|
+
* error when the node rests `failed` (still usable: `connect()` dials again), `invalid_input` at once for a malformed
|
|
121
|
+
* target, `not_connected` once the node has ended (`close()`).
|
|
122
|
+
*
|
|
123
|
+
* With no target: the declared target, if there is one (settles with it, as above), else `SYNAPSE_ENDPOINT` (Bun/Node;
|
|
124
|
+
* the runtime sets it for every task it starts); neither (or a browser) is `invalid_input` and the node stays `idle`.
|
|
125
|
+
*
|
|
126
|
+
* The outcome is handled: `node.connect(url);` without a handler is safe (`useConnection()` shows `failed`); `await` still rejects.
|
|
127
|
+
*/
|
|
128
|
+
connect(target) {
|
|
129
|
+
const n = this.#node;
|
|
130
|
+
let p;
|
|
69
131
|
if (n.isEnded)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
/** During the first `connect()` it ends that attempt: the node ends `closed` and `connect()` rejects `not_connected`. */
|
|
84
|
-
async close() {
|
|
85
|
-
const n = this.#node ?? (await this.#starting);
|
|
86
|
-
if (n)
|
|
87
|
-
await n.close();
|
|
132
|
+
p = Promise.reject(new SynapseError("not_connected", "node closed"));
|
|
133
|
+
else {
|
|
134
|
+
let dial;
|
|
135
|
+
try {
|
|
136
|
+
dial = target === undefined ? (n.target.get().dial ?? endpointDial(n)) : resolveTarget(n, typeof target === "string" ? { url: target } : target);
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
return Promise.reject(e);
|
|
140
|
+
}
|
|
141
|
+
p = n.declare(dial).then(() => this);
|
|
142
|
+
}
|
|
143
|
+
p.catch(() => { });
|
|
144
|
+
return p;
|
|
88
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Disconnects: the session, if any, is torn down and the node rests `idle` (no target), not ended. Every handle is kept,
|
|
148
|
+
* exactly as between two sessions, and a later `connect()` dials again. A `connect()` still pursuing is superseded (it
|
|
149
|
+
* resolves quietly). Resolves once the session is gone; already `idle`: at once. `not_connected` once the node has ended.
|
|
150
|
+
*/
|
|
151
|
+
disconnect() { return this.#node.declare(undefined); }
|
|
152
|
+
/** The node's engine (the SDK's own modules and tests use it). */
|
|
153
|
+
get node() { return this.#node; }
|
|
154
|
+
#session() { return this.#node.currentSession(); }
|
|
155
|
+
// ------------------------------------------------------------------------------ lifecycle
|
|
156
|
+
/** Where the node stands (`ConnectionState`), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. */
|
|
157
|
+
status;
|
|
158
|
+
/** Tells the runtime we are leaving, ends every handle, and resolves once the node has ended (`closed`, for good). A `connect()` in flight rejects `not_connected`. */
|
|
159
|
+
async close() { await this.#node.close(); }
|
|
89
160
|
async [Symbol.asyncDispose]() { await this.close(); }
|
|
90
|
-
/** Resolves once the node has ended (closed, failed, or the policy gave up). */
|
|
91
|
-
get ended() { return this.node.ended.promise; }
|
|
92
161
|
info() {
|
|
93
162
|
const n = this.node;
|
|
94
163
|
const id = n.target.get().identity;
|
|
95
164
|
const s = n.currentSession();
|
|
96
|
-
return { name: id.name ?? "", type: id.type, userId: id.user_id, prefix: s?.prefix ?? "", role: s?.role ?? "observer", dom: n.dom, brokerDom: s?.hello.dom ?? "", brokerVersion: s?.hello.broker_version ?? "", robot: s?.hello.robot ?? "",
|
|
165
|
+
return { name: id.name ?? "", type: id.type, userId: id.user_id, prefix: s?.prefix ?? "", role: s?.role ?? "observer", dom: n.dom, brokerDom: s?.hello.dom ?? "", brokerVersion: s?.hello.broker_version ?? "", robot: s?.hello.robot ?? "", runtimeName: s?.hello.runtime_name ?? "", runtimeId: s?.hello.runtime_id ?? "", url: n.target.get().dial?.url ?? "" };
|
|
97
166
|
}
|
|
98
167
|
metrics() { const n = this.node; return { sessions: n.epochs, reconnects: n.reconnects, callsSent: n.callsSent, callsAnswered: n.callsAnswered }; }
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
/** `promoted`/`demoted` notices (WP §3.5). */
|
|
102
|
-
onProjectNotice(cb) { this.node.projectNotices.add(cb); return () => { this.node.projectNotices.delete(cb); }; }
|
|
168
|
+
/** Notices addressed to this node: `cb` per notice (an error body on `res` without a call, and a refused live override claim of this node's scene); returns the unsubscribe. */
|
|
169
|
+
notices(cb) { this.node.notices.add(cb); return () => { this.node.notices.delete(cb); }; }
|
|
103
170
|
// ---------------------------------------------------------------------------------- clock
|
|
104
171
|
nowUs() { return nowUs(); }
|
|
105
|
-
clock() { const s = this.#session(); return s ? s.clock.get() : new ClockSync(this.node.dom.id, "", 0).info(); }
|
|
106
|
-
timeOffsets() { return this.#session()?.timeOffsets.get() ?? {}; }
|
|
107
|
-
/** A publisher stamp in our clock (WP §7.4), when the offsets allow it. */
|
|
108
|
-
toLocalUs(tUs, dom) { const s = this.#session(); return s ? toLocalUs(s.clock.get(), s.timeOffsets.get(), tUs, dom) : undefined; }
|
|
109
172
|
/** How old a message is now, in our clock. */
|
|
110
173
|
ageUs(env) { const s = this.#session(); return s ? ageOf(s.clock.get(), s.timeOffsets.get(), env.tUs, env.dom, nowUs()) : undefined; }
|
|
111
174
|
async publisher(topic) {
|
|
112
175
|
const contract = resolveTopic(topic);
|
|
113
176
|
const n = this.node;
|
|
177
|
+
// Its descriptor names the node's time domain: final before any publisher is made.
|
|
178
|
+
await n.prepared;
|
|
114
179
|
const name = contract.name;
|
|
115
180
|
let p;
|
|
116
181
|
let held;
|
|
@@ -155,9 +220,13 @@ export class Synapse {
|
|
|
155
220
|
}
|
|
156
221
|
return h;
|
|
157
222
|
}
|
|
158
|
-
subscribe(topic, opts = {}) {
|
|
223
|
+
subscribe(topic, cbOrOpts, opts = {}) {
|
|
159
224
|
const contract = resolveTopic(topic);
|
|
160
|
-
|
|
225
|
+
if (typeof cbOrOpts !== "function")
|
|
226
|
+
return new TopicSubscription(this.node, contract, cbOrOpts ?? {}, this.warn);
|
|
227
|
+
const sub = new TopicSubscription(this.node, contract, opts, this.warn);
|
|
228
|
+
sub.deliverTo(cbOrOpts);
|
|
229
|
+
return sub;
|
|
161
230
|
}
|
|
162
231
|
view(topic, opts = {}) {
|
|
163
232
|
const contract = resolveTopic(topic);
|
|
@@ -167,7 +236,7 @@ export class Synapse {
|
|
|
167
236
|
call(svc, args = {}, opts = {}) {
|
|
168
237
|
const s = this.#session();
|
|
169
238
|
if (!s)
|
|
170
|
-
|
|
239
|
+
return failedRun(new SynapseError("not_connected", "no session: the call was not sent"));
|
|
171
240
|
const contract = isServiceContract(svc) ? svc : undefined;
|
|
172
241
|
return startOn(this.node, s, serviceName(svc), args, opts, contract);
|
|
173
242
|
}
|
|
@@ -202,55 +271,65 @@ export class Synapse {
|
|
|
202
271
|
// ---------------------------------------------------------------------------------- store
|
|
203
272
|
/** Opens namespace `name` (SDK §9); resolves once the mirror is in (or the namespace is known absent). */
|
|
204
273
|
store(name, opts = {}) { return openStore(this.node, this.#stores, name, opts, this.warn); }
|
|
205
|
-
/** The runtime's `config` namespace (operators write it). */
|
|
206
|
-
config(opts = {}) { return this.store("config", opts); }
|
|
207
274
|
// ---------------------------------------------------------------------------------- scene
|
|
208
275
|
#scenes = { current: undefined };
|
|
209
276
|
/** The active project's scene (PLAN v0.24 §5); resolves attached, or at once `detached` when no project is active. */
|
|
210
277
|
scene() { return openScene(this.node, this.#scenes, this.warn); }
|
|
211
278
|
// -------------------------------------------------------------------------------- catalog
|
|
212
|
-
/** The runtime's catalogs as the current session last read them
|
|
213
|
-
|
|
214
|
-
const s = this.#session();
|
|
215
|
-
const topics = s?.topics.get().topics ?? {};
|
|
216
|
-
const services = s?.services.get().services ?? {};
|
|
217
|
-
return { topics, services, describe: name => services[name]?.card };
|
|
218
|
-
}
|
|
219
|
-
/** Called when either catalog changes on the current session. */
|
|
220
|
-
watchCatalog(cb) {
|
|
221
|
-
let un = [];
|
|
222
|
-
const bind = () => {
|
|
223
|
-
for (const u of un)
|
|
224
|
-
u();
|
|
225
|
-
un = [];
|
|
226
|
-
const s = this.#session();
|
|
227
|
-
if (!s)
|
|
228
|
-
return;
|
|
229
|
-
un.push(s.topics.subscribe(() => cb(this.catalog)), s.services.subscribe(() => cb(this.catalog)));
|
|
230
|
-
};
|
|
231
|
-
bind();
|
|
232
|
-
const unLink = this.node.link.subscribe(() => { bind(); cb(this.catalog); });
|
|
233
|
-
return () => { unLink(); for (const u of un)
|
|
234
|
-
u(); };
|
|
235
|
-
}
|
|
279
|
+
/** The runtime's catalogs as the current session last read them (empty between sessions), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. */
|
|
280
|
+
catalog;
|
|
236
281
|
// -------------------------------------------------------------------------------- project
|
|
237
|
-
/** The project state the current session last read (WP §3.5); `undefined` between sessions or before the first snapshot. */
|
|
238
|
-
project
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
282
|
+
/** The project state the current session last read (WP §3.5), a state value; `get()` is `undefined` between sessions or before the first snapshot. */
|
|
283
|
+
project;
|
|
284
|
+
/**
|
|
285
|
+
* A project call on the current session. The runtime tells a web session whose role it changed before it answers on
|
|
286
|
+
* it (the notice, then the answer, on the same `res` track, read in order) and closes it `role_changed` after: a role
|
|
287
|
+
* that differs from the session's hello grant means the switch is coming, and the call resolves once a session other
|
|
288
|
+
* than the one it went out on is connected (`not_connected` if the node ends or is disconnected meanwhile, the error
|
|
289
|
+
* if it rests `failed`). An hmi's role is `hmi` from admission and never changes, so its calls resolve on the answer.
|
|
290
|
+
*/
|
|
291
|
+
async #projectCall(op, args) {
|
|
292
|
+
const n = this.node;
|
|
293
|
+
const asked = n.currentSession();
|
|
294
|
+
if (!asked)
|
|
295
|
+
throw new SynapseError("not_connected", "no session: the call was not sent");
|
|
296
|
+
const snapshot = await startOn(n, asked, `synapse/project/${op}`, args, {}).result;
|
|
297
|
+
if (asked.role !== asked.hello.role) {
|
|
298
|
+
// The runtime replaces this session (the role changed): its close is expected, as one we make ourselves, so
|
|
299
|
+
// @moq/net's track and discovery lines about it are routine (moq_log.ts).
|
|
300
|
+
asked.log?.closing();
|
|
301
|
+
await n.switchedFrom(asked);
|
|
302
|
+
}
|
|
303
|
+
return snapshot;
|
|
245
304
|
}
|
|
246
|
-
|
|
247
|
-
|
|
305
|
+
/**
|
|
306
|
+
* `synapse/project/activate {id}`: activates `id` when no project is active, or joins it when it is the active one;
|
|
307
|
+
* `project_busy` while another project is active (no takeover: deactivate it first). A web node is promoted to
|
|
308
|
+
* operator and resolves once the session with the new role is connected; an hmi stays `hmi` and resolves on the answer.
|
|
309
|
+
*/
|
|
248
310
|
activateProject(id) { return this.#projectCall("activate", { id }); }
|
|
249
|
-
/**
|
|
311
|
+
/**
|
|
312
|
+
* `synapse/project/deactivate {}`: any operator or hmi may deactivate, except a project an hmi activated
|
|
313
|
+
* (`active.activated_by_hmi`), which only an hmi may (`not_operator`). Every web operator is demoted; when that
|
|
314
|
+
* demotes this node, resolves once the demoted session is connected. An hmi is never demoted.
|
|
315
|
+
*/
|
|
250
316
|
deactivateProject() { return this.#projectCall("deactivate", {}); }
|
|
251
|
-
/** `synapse/project/join {as: "operator"}`:
|
|
317
|
+
/** `synapse/project/join {as: "operator"}`: an operator of the active project, or `project_inactive`. A web node resolves once the promoted session is connected; an hmi on the answer. */
|
|
252
318
|
joinProject(as = "operator") { return this.#projectCall("join", { as }); }
|
|
253
|
-
/**
|
|
319
|
+
/**
|
|
320
|
+
* `synapse/project/leave {}`: drops our standing. For an hmi membership is per session: this session only (it stays
|
|
321
|
+
* `hmi`, never closed). For a web node it removes our `user_id`'s web sessions only; when that demotes this node,
|
|
322
|
+
* resolves once the demoted session is connected.
|
|
323
|
+
*/
|
|
254
324
|
leaveProject() { return this.#projectCall("leave", {}); }
|
|
255
|
-
|
|
325
|
+
// -------------------------------------------------------------------------------- tasks
|
|
326
|
+
#tasks;
|
|
327
|
+
/**
|
|
328
|
+
* The robot's task declarations, what runs (`status`), what the runtime can run (`catalog`), and the verbs; the same
|
|
329
|
+
* object for the node's life. Each handle opens on its first touch and is the node's own (`node.close()` ends it).
|
|
330
|
+
*/
|
|
331
|
+
get tasks() {
|
|
332
|
+
const n = this.node;
|
|
333
|
+
return (this.#tasks ??= new TasksFacet(name => attachStore(n, this.#stores, name, {}, m => this.warn(m)), svc => this.call(svc, {}).result, m => this.warn(m)));
|
|
334
|
+
}
|
|
256
335
|
}
|
package/dist/tasks.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { SynapseError, type TasksReportItem, type TasksRun } from "@synapse-sdk/core";
|
|
2
|
+
import type { StoreHandleImpl } from "./store.ts";
|
|
3
|
+
import type { ReadonlyStoreHandle, TaskCatalogEntry, TaskDeclarations, Tasks, TasksStatus } from "./types.ts";
|
|
4
|
+
import type { Warn } from "./topics.ts";
|
|
5
|
+
/** The namespaces the facet reads and writes (the runtime's names). */
|
|
6
|
+
export declare const TASKS_NAMESPACES: {
|
|
7
|
+
readonly declarations: "tasks";
|
|
8
|
+
readonly status: "task_status";
|
|
9
|
+
readonly catalog: "task_catalog";
|
|
10
|
+
};
|
|
11
|
+
/** What `status.get()` is before the runtime said anything: neither scope's state known, no tasks. */
|
|
12
|
+
export declare const EMPTY_TASKS_STATUS: TasksStatus;
|
|
13
|
+
/**
|
|
14
|
+
* `task_status`'s rows as a `TasksStatus`. A run row is the one keyed `robot` or `project`; an instance
|
|
15
|
+
* row goes where its own `scope`, `entity` and `instance` say (its key is never parsed: an instance
|
|
16
|
+
* name may hold `:`). A row that cannot be placed is left out and passed to `unplaced` (the runtime
|
|
17
|
+
* never writes one). The instance rows are the mirror's own objects.
|
|
18
|
+
*/
|
|
19
|
+
export declare function tasksStatusOf(rows: Readonly<Record<string, unknown>>, unplaced?: (key: string) => void): TasksStatus;
|
|
20
|
+
/** `node.tasks`. `attach(name)` hands out the node's handle on a namespace; `call` starts a call on the current session. */
|
|
21
|
+
export declare class TasksFacet implements Tasks {
|
|
22
|
+
private readonly call;
|
|
23
|
+
readonly declarations: TaskDeclarations;
|
|
24
|
+
readonly status: ReadonlyStoreHandle<TasksStatus>;
|
|
25
|
+
readonly catalog: ReadonlyStoreHandle<Readonly<Record<string, TaskCatalogEntry>>>;
|
|
26
|
+
constructor(attach: (name: string) => StoreHandleImpl, call: (svc: string) => Promise<unknown>, warn: Warn);
|
|
27
|
+
runRobotTasks(): Promise<TasksRun>;
|
|
28
|
+
stopRobotTasks(): Promise<void>;
|
|
29
|
+
runProjectTasks(): Promise<TasksRun>;
|
|
30
|
+
stopProjectTasks(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/** `true` for a refused run, `preflight_failed` or `launch_failed`, whose `data.report` names each declaration at fault and why. */
|
|
33
|
+
export declare function isTasksRefusal(e: unknown): e is SynapseError & {
|
|
34
|
+
readonly data: {
|
|
35
|
+
report: TasksReportItem[];
|
|
36
|
+
};
|
|
37
|
+
};
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// `node.tasks` (PLAN v0.29 SDK pass 2): the robot's task declarations (namespace `tasks`), what runs
|
|
2
|
+
// (`task_status`, read-only, typed by scope), what the runtime can run (`task_catalog`, read-only), and
|
|
3
|
+
// the four verbs. Each handle is a handle on the node's own namespace task (`store.ts`), opened on its
|
|
4
|
+
// first touch and held for the node's life: `node.store("task_status")` and `node.tasks.status` are one
|
|
5
|
+
// subscription. Nothing here is decided: the mirror is the runtime's, the verbs' replies are its
|
|
6
|
+
// answers, and the typed values are computed from the mirror (no copy), with structural sharing so an
|
|
7
|
+
// unchanged part keeps its object.
|
|
8
|
+
import { SynapseError } from "@synapse-sdk/core";
|
|
9
|
+
import { replaceEqualDeep } from "./util.js";
|
|
10
|
+
import { ulid } from "./scene.js";
|
|
11
|
+
/** The namespaces the facet reads and writes (the runtime's names). */
|
|
12
|
+
export const TASKS_NAMESPACES = { declarations: "tasks", status: "task_status", catalog: "task_catalog" };
|
|
13
|
+
/** What `status.get()` is before the runtime said anything: neither scope's state known, no tasks. */
|
|
14
|
+
export const EMPTY_TASKS_STATUS = { robot: { state: undefined, tasks: {} }, project: { state: undefined, tasks: {} } };
|
|
15
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
16
|
+
/** A run row (`robot` / `project`: `{state, since, reason?}`) as the scope's run fields. */
|
|
17
|
+
function runOf(row) {
|
|
18
|
+
if (!isObject(row))
|
|
19
|
+
return { state: undefined };
|
|
20
|
+
const out = { state: row.state };
|
|
21
|
+
if (row.since !== undefined)
|
|
22
|
+
out.since = row.since;
|
|
23
|
+
if (row.reason !== undefined)
|
|
24
|
+
out.reason = row.reason;
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `task_status`'s rows as a `TasksStatus`. A run row is the one keyed `robot` or `project`; an instance
|
|
29
|
+
* row goes where its own `scope`, `entity` and `instance` say (its key is never parsed: an instance
|
|
30
|
+
* name may hold `:`). A row that cannot be placed is left out and passed to `unplaced` (the runtime
|
|
31
|
+
* never writes one). The instance rows are the mirror's own objects.
|
|
32
|
+
*/
|
|
33
|
+
export function tasksStatusOf(rows, unplaced = () => { }) {
|
|
34
|
+
const robot = {};
|
|
35
|
+
const project = {};
|
|
36
|
+
for (const [key, row] of Object.entries(rows)) {
|
|
37
|
+
if (key === "robot" || key === "project")
|
|
38
|
+
continue;
|
|
39
|
+
if (!isObject(row) || typeof row.instance !== "string") {
|
|
40
|
+
unplaced(key);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (row.scope === "robot")
|
|
44
|
+
robot[row.instance] = row;
|
|
45
|
+
else if (row.scope === "project" && typeof row.entity === "string")
|
|
46
|
+
(project[row.entity] ??= {})[row.instance] = row;
|
|
47
|
+
else
|
|
48
|
+
unplaced(key);
|
|
49
|
+
}
|
|
50
|
+
return { robot: { ...runOf(rows.robot), tasks: robot }, project: { ...runOf(rows.project), tasks: project } };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A read-only handle over one namespace of the node: `get()` is `derive` of the mirror, recomputed only
|
|
54
|
+
* when the mirror changed and shared structurally with the previous value. The namespace is opened on
|
|
55
|
+
* the first touch of any member.
|
|
56
|
+
*/
|
|
57
|
+
class DerivedHandle {
|
|
58
|
+
open;
|
|
59
|
+
derive;
|
|
60
|
+
#h;
|
|
61
|
+
#from;
|
|
62
|
+
#value;
|
|
63
|
+
constructor(open, derive, empty) {
|
|
64
|
+
this.open = open;
|
|
65
|
+
this.derive = derive;
|
|
66
|
+
this.#value = empty;
|
|
67
|
+
}
|
|
68
|
+
#handle() { return (this.#h ??= this.open()); }
|
|
69
|
+
get() {
|
|
70
|
+
const rows = this.#handle().get();
|
|
71
|
+
if (rows !== this.#from) {
|
|
72
|
+
this.#from = rows;
|
|
73
|
+
this.#value = replaceEqualDeep(this.#value, this.derive(rows));
|
|
74
|
+
}
|
|
75
|
+
return this.#value;
|
|
76
|
+
}
|
|
77
|
+
get synced() { return this.#handle().synced; }
|
|
78
|
+
ready() { return this.#handle().ready(); }
|
|
79
|
+
async *watch() {
|
|
80
|
+
for await (const _ of this.#handle().watch())
|
|
81
|
+
yield this.get();
|
|
82
|
+
}
|
|
83
|
+
onChange(cb) { return this.#handle().onChange(() => cb(this.get())); }
|
|
84
|
+
}
|
|
85
|
+
/** The robot's declarations: the node's handle on `tasks`; every write waits for the value to be current first. */
|
|
86
|
+
class Declarations {
|
|
87
|
+
open;
|
|
88
|
+
#h;
|
|
89
|
+
constructor(open) {
|
|
90
|
+
this.open = open;
|
|
91
|
+
}
|
|
92
|
+
#handle() { return (this.#h ??= this.open()); }
|
|
93
|
+
get(instance) {
|
|
94
|
+
const h = this.#handle();
|
|
95
|
+
return (instance === undefined ? h.get() : h.get(instance));
|
|
96
|
+
}
|
|
97
|
+
get synced() { return this.#handle().synced; }
|
|
98
|
+
get status() { return this.#handle().status; }
|
|
99
|
+
changed() { return this.#handle().changed(); }
|
|
100
|
+
ready() { return this.#handle().ready(); }
|
|
101
|
+
async set(a, b) {
|
|
102
|
+
const h = this.#handle();
|
|
103
|
+
await h.ready();
|
|
104
|
+
if (typeof a === "string") {
|
|
105
|
+
await h.set(a, b);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// One object: a declaration (its `ref` is a string) gets a minted instance; else it is a map of declarations.
|
|
109
|
+
if (typeof a.ref === "string") {
|
|
110
|
+
const instance = ulid();
|
|
111
|
+
await h.set(instance, a);
|
|
112
|
+
return instance;
|
|
113
|
+
}
|
|
114
|
+
await h.set(a);
|
|
115
|
+
}
|
|
116
|
+
async delete(instance) { const h = this.#handle(); await h.ready(); await h.delete(instance); }
|
|
117
|
+
async clear() { const h = this.#handle(); await h.ready(); await h.clear(); }
|
|
118
|
+
watch(instance) { return this.#handle().watch(instance); }
|
|
119
|
+
onChange(cb) { return this.#handle().onChange(cb); }
|
|
120
|
+
}
|
|
121
|
+
/** `node.tasks`. `attach(name)` hands out the node's handle on a namespace; `call` starts a call on the current session. */
|
|
122
|
+
export class TasksFacet {
|
|
123
|
+
call;
|
|
124
|
+
declarations;
|
|
125
|
+
status;
|
|
126
|
+
catalog;
|
|
127
|
+
constructor(attach, call, warn) {
|
|
128
|
+
this.call = call;
|
|
129
|
+
const warned = new Set();
|
|
130
|
+
const unplaced = (key) => { if (!warned.has(key)) {
|
|
131
|
+
warned.add(key);
|
|
132
|
+
warn(`tasks: status row ${JSON.stringify(key)} names no scope and instance; left out`);
|
|
133
|
+
} };
|
|
134
|
+
this.declarations = new Declarations(() => attach(TASKS_NAMESPACES.declarations));
|
|
135
|
+
this.status = new DerivedHandle(() => attach(TASKS_NAMESPACES.status), rows => tasksStatusOf(rows, unplaced), EMPTY_TASKS_STATUS);
|
|
136
|
+
this.catalog = new DerivedHandle(() => attach(TASKS_NAMESPACES.catalog), rows => rows, {});
|
|
137
|
+
}
|
|
138
|
+
async runRobotTasks() { return (await this.call("synapse/tasks/run_robot")); }
|
|
139
|
+
async stopRobotTasks() { await this.call("synapse/tasks/stop_robot"); }
|
|
140
|
+
async runProjectTasks() { return (await this.call("synapse/tasks/run_project")); }
|
|
141
|
+
async stopProjectTasks() { await this.call("synapse/tasks/stop_project"); }
|
|
142
|
+
}
|
|
143
|
+
/** `true` for a refused run, `preflight_failed` or `launch_failed`, whose `data.report` names each declaration at fault and why. */
|
|
144
|
+
export function isTasksRefusal(e) {
|
|
145
|
+
return e instanceof SynapseError && (e.code === "preflight_failed" || e.code === "launch_failed") && isObject(e.data) && Array.isArray(e.data.report);
|
|
146
|
+
}
|
package/dist/topics.d.ts
CHANGED
|
@@ -39,10 +39,22 @@ export declare class TopicPublisher<TIn, TOut> implements Intent {
|
|
|
39
39
|
* frame the codec refuses is refused (`invalid_input`) without taking a `seq`.
|
|
40
40
|
*/
|
|
41
41
|
publishFrame(type: number, flags: number, tUs: number, tlvs: Tlv[], body: Uint8Array): void;
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* The typed form: validates against the contract's schema and encodes msgpack; a raw topic's bytes
|
|
44
|
+
* go as given, and on a `video/*` topic as a `MEDIA` frame whose `KEY`/`CONFIG`/`RECOVERY` flags
|
|
45
|
+
* are read off the bitstream (Rust `Publisher<RawBody>::publish`).
|
|
46
|
+
*/
|
|
43
47
|
publish(value: TIn, opts?: PublishOptions): void;
|
|
48
|
+
/** An encoder's frame with the flags it already carries and its `STAGES` stamps (no re-parse); `tUs` 0 = now. The video helper's path (Rust `publish_media_frame`), not a user verb. */
|
|
49
|
+
publishMediaFrame(f: MediaFrame): void;
|
|
44
50
|
stats(): PublisherStats;
|
|
45
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The key of a publisher handle's encoder-frame path, the JS spelling of Rust's `pub(crate)
|
|
54
|
+
* publish_media_frame`: `@synapse-sdk/media`'s `videoPublisher` reaches it by the same
|
|
55
|
+
* `Symbol.for` key (the two packages share no module), so it is on no public type.
|
|
56
|
+
*/
|
|
57
|
+
export declare const PUBLISH_MEDIA_FRAME: unique symbol;
|
|
46
58
|
/** The application's handle: the last one closed ends the publisher's task (`unregister`). */
|
|
47
59
|
export declare class PublisherHandle<TIn, TOut> implements Publisher<TIn> {
|
|
48
60
|
#private;
|
|
@@ -53,10 +65,8 @@ export declare class PublisherHandle<TIn, TOut> implements Publisher<TIn> {
|
|
|
53
65
|
get verdict(): Verdict;
|
|
54
66
|
ready(): Promise<void>;
|
|
55
67
|
publish(value: TIn, opts?: PublishOptions): void;
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
/** `tUs` 0 = capture now (as the Rust client stamps it). */
|
|
59
|
-
publishMedia(f: MediaFrame): void;
|
|
68
|
+
/** `@synapse-sdk/media`'s video publisher: an encoder's frame with its flags and `STAGES` (see `PUBLISH_MEDIA_FRAME`). */
|
|
69
|
+
[PUBLISH_MEDIA_FRAME](f: MediaFrame): void;
|
|
60
70
|
stats(): PublisherStats;
|
|
61
71
|
close(): void;
|
|
62
72
|
[Symbol.asyncDispose](): Promise<void>;
|
|
@@ -119,7 +129,12 @@ export declare class TopicSubscription<T> implements Subscription<T> {
|
|
|
119
129
|
dropFecOf(publisher: string): void;
|
|
120
130
|
/** A new session (or none): the `fec` frames and a batch held by `throttleMs` are the previous session's and go. */
|
|
121
131
|
clearFec(): void;
|
|
122
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* The established descriptor's `content_type` (the catalog's, the runtime's statement), set by the
|
|
134
|
+
* follow task from each snapshot: bodies decode by it, not by what this subscription declared.
|
|
135
|
+
*/
|
|
136
|
+
contentType: string | undefined;
|
|
137
|
+
/** The decoding step: header facts plus the body by the established `content_type` (msgpack → a value, validated when the contract has a schema; anything else → bytes), or `mismatch` and dropped. */
|
|
123
138
|
envelope(payload: Uint8Array, publisher: string, dom: string): Envelope<T> | undefined;
|
|
124
139
|
/**
|
|
125
140
|
* The next batch, never empty; `undefined` once the subscription or the node ended. Drains what
|
|
@@ -132,5 +147,7 @@ export declare class TopicSubscription<T> implements Subscription<T> {
|
|
|
132
147
|
/** Ends the subscription: the follow task stops, every track is closed, a pending `next()` returns `undefined`. */
|
|
133
148
|
unsubscribe(): void;
|
|
134
149
|
[Symbol.asyncDispose](): Promise<void>;
|
|
150
|
+
/** The callback form's read loop (`node.subscribe(topic, cb)`): `cb` with each batch `next()` returns, until the subscription ends; a throwing `cb` is rethrown later and the loop goes on. */
|
|
151
|
+
deliverTo(cb: (batch: Envelope<T>[]) => void): void;
|
|
135
152
|
}
|
|
136
153
|
export {};
|