@synapse-sdk/client 0.1.0 → 0.3.3

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,93 @@
1
+ import { type Age, type ClockInfo, type ErrorBody, type LocalTime, type ProjectSnapshot, type ServiceContract, type TimeOffsets, type TopicContract } from "@synapse-sdk/core";
2
+ import { Node } from "./node.ts";
3
+ import { StoreHandleImpl } from "./store.ts";
4
+ import { type Warn } from "./topics.ts";
5
+ import type { CallEvent, CallOptions, Catalog, ConnectOptions, ConnectTarget, ConnectionState, Envelope, Info, Metrics, Notice, Publisher, Registration, Run, ServeHandler, ServeOptions, SceneHandle, StoreHandle, StoreOptions, SubscribeOptions, Subscription, Tasks, View, ViewOptions } from "./types.ts";
6
+ /** How long the first `connect()` keeps trying a retryable failure before `failed` (SDK §2: the first connect is bounded). */
7
+ export declare const FIRST_CONNECT_GIVE_UP_MS = 30000;
8
+ export declare class Synapse implements AsyncDisposable {
9
+ #private;
10
+ warn: Warn;
11
+ constructor(opts: ConnectOptions);
12
+ /** `await Synapse.connect(opts)`: a node with a session, or the reason there is none. */
13
+ static connect(opts: ConnectOptions): Promise<Synapse>;
14
+ /** Connects (once; later calls return the same promise). Resolves once a session is established. */
15
+ connect(): Promise<this>;
16
+ get node(): Node;
17
+ get status(): ConnectionState;
18
+ get connected(): boolean;
19
+ /** Called on every change of the connection state; returns the unsubscribe. */
20
+ watchStatus(cb: (s: ConnectionState) => void): () => void;
21
+ /** Resolves on the next connected session (at once if connected); rejects once the node ended. */
22
+ whenConnected(): Promise<void>;
23
+ /** Declares a new target (broker, identity); resolves once connected to it (or rejects with why not). `null` disconnects for good. */
24
+ setTarget(target: ConnectTarget | null): Promise<void>;
25
+ /** Tells the runtime we are leaving, ends every handle, and resolves once the node has ended. */
26
+ /** During the first `connect()` it ends that attempt: the node ends `closed` and `connect()` rejects `not_connected`. */
27
+ close(): Promise<void>;
28
+ [Symbol.asyncDispose](): Promise<void>;
29
+ /** Resolves once the node has ended (closed, failed, or the policy gave up). */
30
+ get ended(): Promise<void>;
31
+ info(): Info;
32
+ metrics(): Metrics;
33
+ /** Broker notices addressed to this node (an error body on `res` without a call). */
34
+ onNotice(cb: (n: ErrorBody) => void): () => void;
35
+ /** `promoted`/`demoted` notices (WP §3.5). */
36
+ onProjectNotice(cb: (n: Notice) => void): () => void;
37
+ nowUs(): number;
38
+ clock(): ClockInfo;
39
+ timeOffsets(): TimeOffsets;
40
+ /** A publisher stamp in our clock (WP §7.4), when the offsets allow it. */
41
+ toLocalUs(tUs: number, dom: string): LocalTime | undefined;
42
+ /** How old a message is now, in our clock. */
43
+ ageUs(env: {
44
+ tUs: number;
45
+ dom: string;
46
+ }): Age | undefined;
47
+ /** A publisher of `topic`; resolves once the runtime admitted it on the current session (a refusal is asked again, paced, warned). */
48
+ publisher<TIn, TOut>(topic: TopicContract<TIn, TOut>): Promise<Publisher<TIn>>;
49
+ publisher<TIn = unknown>(topic: string): Promise<Publisher<TIn>>;
50
+ /** A subscription to `topic` (every publisher the catalog lists but ourselves); its follow task starts now. */
51
+ subscribe<TIn, TOut>(topic: TopicContract<TIn, TOut>, opts?: SubscribeOptions): Subscription<TOut>;
52
+ subscribe<TOut = unknown>(topic: string, opts?: SubscribeOptions): Subscription<TOut>;
53
+ /** The topic's latest value as a cell (SDK §4.3); subscribed while someone listens. */
54
+ view<TIn, TOut, S = TOut>(topic: TopicContract<TIn, TOut>, opts?: ViewOptions<TOut, S>): View<S>;
55
+ view<TOut = unknown, S = TOut>(topic: string, opts?: ViewOptions<TOut, S>): View<S>;
56
+ /** Starts a call on the current session (SDK §5.2): `await` it for the result, or read `updates()` first. `not_connected` between sessions. */
57
+ call<TArgs, TInput, U, R>(svc: ServiceContract<TArgs, TInput, U, R>, args: TArgs, opts?: CallOptions<U>): Run<U, R>;
58
+ call<U = unknown, R = unknown>(svc: string, args?: unknown, opts?: CallOptions<U>): Run<U, R>;
59
+ /** The call's updates and result as one ordered stream; `break` cancels. */
60
+ callStream<TArgs, TInput, U, R>(svc: ServiceContract<TArgs, TInput, U, R>, args: TArgs, opts?: CallOptions<U>): AsyncGenerator<CallEvent<U, R>, void, undefined>;
61
+ callStream<U = unknown, R = unknown>(svc: string, args?: unknown, opts?: CallOptions<U>): AsyncGenerator<CallEvent<U, R>, void, undefined>;
62
+ /** Registers `handler` as the provider of `svc`; resolves once the runtime lists us on the current session. One provider per service path per node. */
63
+ serve<TArgs, TInput, U, R>(svc: ServiceContract<TArgs, TInput, U, R>, handler: ServeHandler<TInput, U, R>, opts?: ServeOptions): Promise<Registration>;
64
+ serve<I = unknown, U = unknown, R = unknown>(svc: string, handler: ServeHandler<I, U, R>, opts?: ServeOptions): Promise<Registration>;
65
+ /** Opens namespace `name` (SDK §9); resolves once the mirror is in (or the namespace is known absent). */
66
+ store(name: string, opts?: StoreOptions): Promise<StoreHandle>;
67
+ /** The runtime's `config` namespace (operators write it). */
68
+ config(opts?: StoreOptions): Promise<StoreHandle>;
69
+ /** The active project's scene (PLAN v0.24 §5); resolves attached, or at once `detached` when no project is active. */
70
+ scene(): Promise<SceneHandle>;
71
+ /** The runtime's catalogs as the current session last read them; empty between sessions. */
72
+ get catalog(): Catalog;
73
+ /** Called when either catalog changes on the current session. */
74
+ watchCatalog(cb: (c: Catalog) => void): () => void;
75
+ /** The project state the current session last read (WP §3.5); `undefined` between sessions or before the first snapshot. */
76
+ project(): ProjectSnapshot | undefined;
77
+ watchProject(cb: (p: ProjectSnapshot | undefined) => void): () => void;
78
+ /** `synapse/project/activate {id}`: activates `id` (or joins it when active); promoted to operator. */
79
+ activateProject(id: string): Promise<ProjectSnapshot>;
80
+ /** `synapse/project/deactivate {}`: every operator is demoted. */
81
+ deactivateProject(): Promise<ProjectSnapshot>;
82
+ /** `synapse/project/join {as: "operator"}`: promoted on the active project, or `project_inactive`. */
83
+ joinProject(as?: "operator"): Promise<ProjectSnapshot>;
84
+ /** `synapse/project/leave {}`: drops our standing (every session of our `user_id`). */
85
+ leaveProject(): Promise<ProjectSnapshot>;
86
+ getProject(): Promise<ProjectSnapshot>;
87
+ /**
88
+ * The robot's task declarations, what runs (`status`), what the runtime can run (`catalog`), and the verbs; the same
89
+ * object for the node's life. Each handle opens on its first touch and is the node's own (`node.close()` ends it).
90
+ */
91
+ get tasks(): Tasks;
92
+ }
93
+ export type { Envelope, StoreHandleImpl };
@@ -0,0 +1,268 @@
1
+ // `Synapse` (SDK §2–§9): the public face of one node. It owns nothing the modules do not: the node's
2
+ // loop (`node.ts`) drives sessions; publishers, registrations, subscriptions and namespaces are their
3
+ // own tasks, told about sessions by the loop; every fact about the world is read from the current
4
+ // `Session` (the runtime's statement) at the moment it is asked for, never copied here.
5
+ import { ClockSync, SynapseError, ageOf, defaultDomId, fecSymbolSizeFor, isServiceContract, nowUs, resolveTopic, serviceName, toLocalUs, } from "@synapse-sdk/core";
6
+ import { callStream, startOn } from "./calls.js";
7
+ import { Node, resolveTarget, startNode } from "./node.js";
8
+ import { ServiceRegistration } from "./serve.js";
9
+ import { openScene } from "./scene.js";
10
+ import { Namespace, StoreHandleImpl, attachStore, openStore } from "./store.js";
11
+ import { TasksFacet } from "./tasks.js";
12
+ import { PublisherHandle, TopicPublisher, TopicSubscription, wasmFor } from "./topics.js";
13
+ import { TopicView } from "./view.js";
14
+ /** How long the first `connect()` keeps trying a retryable failure before `failed` (SDK §2: the first connect is bounded). */
15
+ export const FIRST_CONNECT_GIVE_UP_MS = 30_000;
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). */
17
+ function wantedDesc(contract, n) {
18
+ const d = contract.desc(n.dom);
19
+ if (d.delivery === "datagram" && d.fec && d.fec.symbol_size === 0)
20
+ d.fec = { ...d.fec, symbol_size: fecSymbolSizeFor(n.knobs.maxDatagram) };
21
+ return d;
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). */
24
+ function descKey(d) {
25
+ return JSON.stringify({ delivery: d.delivery, content_type: d.content_type, schema_hash: d.schema_hash ?? null, fec: d.fec ?? null });
26
+ }
27
+ export class Synapse {
28
+ #node;
29
+ #connecting;
30
+ #starting;
31
+ #publishers = new Map();
32
+ #stores = new Map();
33
+ warn;
34
+ // Private: the options hold the token and the credential, which nothing prints.
35
+ #opts;
36
+ constructor(opts) {
37
+ this.#opts = opts;
38
+ this.warn = msg => console.warn(`[synapse] ${msg}`);
39
+ }
40
+ /** `await Synapse.connect(opts)`: a node with a session, or the reason there is none. */
41
+ static connect(opts) { return new Synapse(opts).connect(); }
42
+ /** Connects (once; later calls return the same promise). Resolves once a session is established. */
43
+ connect() {
44
+ this.#connecting ??= (async () => {
45
+ const started = startNode(this.#opts, FIRST_CONNECT_GIVE_UP_MS);
46
+ // The node as soon as its loop runs (or none: the options were refused), for a `close()` during the first dial.
47
+ this.#starting = started.then(s => s.node, () => undefined);
48
+ const { node, connected } = await started;
49
+ await connected;
50
+ this.#node = node;
51
+ return this;
52
+ })();
53
+ return this.#connecting;
54
+ }
55
+ get node() {
56
+ if (!this.#node)
57
+ throw new SynapseError("not_connected", "connect() has not completed");
58
+ return this.#node;
59
+ }
60
+ #session() { return this.node.currentSession(); }
61
+ // ------------------------------------------------------------------------------ lifecycle
62
+ get status() { return this.#node?.state() ?? { status: "connecting", attempt: 1 }; }
63
+ get connected() { return this.#node?.isConnected ?? false; }
64
+ /** Called on every change of the connection state; returns the unsubscribe. */
65
+ watchStatus(cb) { return this.node.link.subscribe(() => cb(this.node.state())); }
66
+ /** Resolves on the next connected session (at once if connected); rejects once the node ended. */
67
+ whenConnected() {
68
+ const n = this.node;
69
+ if (n.isConnected)
70
+ return Promise.resolve();
71
+ if (n.isEnded)
72
+ return Promise.reject(new SynapseError("not_connected", "node closed"));
73
+ return new Promise((resolve, reject) => {
74
+ const un = n.link.subscribe(l => { if (l.status === "connected") {
75
+ un();
76
+ resolve();
77
+ } });
78
+ // The node's end settles it (a resolved promise ignores the later reject).
79
+ void n.ended.promise.then(() => { un(); reject(new SynapseError("not_connected", "node closed")); });
80
+ });
81
+ }
82
+ /** Declares a new target (broker, identity); resolves once connected to it (or rejects with why not). `null` disconnects for good. */
83
+ setTarget(target) { return this.node.setTarget(target ? resolveTarget(this.node, target) : undefined); }
84
+ /** Tells the runtime we are leaving, ends every handle, and resolves once the node has ended. */
85
+ /** During the first `connect()` it ends that attempt: the node ends `closed` and `connect()` rejects `not_connected`. */
86
+ async close() {
87
+ const n = this.#node ?? (await this.#starting);
88
+ if (n)
89
+ await n.close();
90
+ }
91
+ async [Symbol.asyncDispose]() { await this.close(); }
92
+ /** Resolves once the node has ended (closed, failed, or the policy gave up). */
93
+ get ended() { return this.node.ended.promise; }
94
+ info() {
95
+ const n = this.node;
96
+ const id = n.target.get().identity;
97
+ const s = n.currentSession();
98
+ 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 ?? "" };
99
+ }
100
+ metrics() { const n = this.node; return { sessions: n.epochs, reconnects: n.reconnects, callsSent: n.callsSent, callsAnswered: n.callsAnswered }; }
101
+ /** Broker notices addressed to this node (an error body on `res` without a call). */
102
+ onNotice(cb) { this.node.notices.add(cb); return () => { this.node.notices.delete(cb); }; }
103
+ /** `promoted`/`demoted` notices (WP §3.5). */
104
+ onProjectNotice(cb) { this.node.projectNotices.add(cb); return () => { this.node.projectNotices.delete(cb); }; }
105
+ // ---------------------------------------------------------------------------------- clock
106
+ nowUs() { return nowUs(); }
107
+ clock() { const s = this.#session(); return s ? s.clock.get() : new ClockSync(this.node.dom.id, "", 0).info(); }
108
+ timeOffsets() { return this.#session()?.timeOffsets.get() ?? {}; }
109
+ /** A publisher stamp in our clock (WP §7.4), when the offsets allow it. */
110
+ toLocalUs(tUs, dom) { const s = this.#session(); return s ? toLocalUs(s.clock.get(), s.timeOffsets.get(), tUs, dom) : undefined; }
111
+ /** How old a message is now, in our clock. */
112
+ ageUs(env) { const s = this.#session(); return s ? ageOf(s.clock.get(), s.timeOffsets.get(), env.tUs, env.dom, nowUs()) : undefined; }
113
+ async publisher(topic) {
114
+ const contract = resolveTopic(topic);
115
+ const n = this.node;
116
+ const name = contract.name;
117
+ let p;
118
+ let held;
119
+ for (;;) {
120
+ const entry = this.#publishers.get(name);
121
+ if (!entry) {
122
+ // Looked up and inserted in one step (no await between): a concurrent `publisher()` for the
123
+ // same name finds this entry and waits for it instead of making a second one.
124
+ const made = wasmFor(contract).then(wasm => new TopicPublisher(n, contract, n.knobs.maxDatagram, wasm, this.warn));
125
+ this.#publishers.set(name, made);
126
+ try {
127
+ p = (await made);
128
+ }
129
+ catch (e) {
130
+ if (this.#publishers.get(name) === made)
131
+ this.#publishers.delete(name);
132
+ throw e;
133
+ }
134
+ held = made;
135
+ break;
136
+ }
137
+ const live = await entry.catch(() => undefined);
138
+ // Gone meanwhile (its making failed, or its last handle closed): look again.
139
+ if (!live || this.#publishers.get(name) !== entry)
140
+ continue;
141
+ // A second one with another descriptor is not this one, and its intent never reached the runtime.
142
+ const have = descKey(live.desc), want = descKey(wantedDesc(contract, n));
143
+ if (have !== want)
144
+ throw new SynapseError("invalid_input", `topic ${name}: a publisher with a different descriptor exists on this node (live: ${have}; asked: ${want})`);
145
+ p = live;
146
+ held = entry;
147
+ break;
148
+ }
149
+ const h = new PublisherHandle(p, () => { if (this.#publishers.get(name) === held)
150
+ this.#publishers.delete(name); });
151
+ try {
152
+ await h.ready();
153
+ }
154
+ catch (e) {
155
+ h.close();
156
+ throw e;
157
+ }
158
+ return h;
159
+ }
160
+ subscribe(topic, opts = {}) {
161
+ const contract = resolveTopic(topic);
162
+ return new TopicSubscription(this.node, contract, opts, this.warn);
163
+ }
164
+ view(topic, opts = {}) {
165
+ const contract = resolveTopic(topic);
166
+ const { staleTime: _s, select: _sel, ...subOpts } = opts;
167
+ return new TopicView(this.node, () => new TopicSubscription(this.node, contract, subOpts, this.warn), opts, () => { });
168
+ }
169
+ call(svc, args = {}, opts = {}) {
170
+ const s = this.#session();
171
+ if (!s)
172
+ throw new SynapseError("not_connected", "no session: the call was not sent");
173
+ const contract = isServiceContract(svc) ? svc : undefined;
174
+ return startOn(this.node, s, serviceName(svc), args, opts, contract);
175
+ }
176
+ callStream(svc, args = {}, opts = {}) {
177
+ return callStream(this.call(svc, args, opts));
178
+ }
179
+ async serve(svc, handler, opts = {}) {
180
+ const n = this.node;
181
+ const name = serviceName(svc);
182
+ const contract = isServiceContract(svc) ? svc : undefined;
183
+ const card = contract ? contract.card() : { name, streaming: true, cancelable: true, min_role: opts.minRole ?? "agent", ...(opts.description ? { description: opts.description } : {}) };
184
+ if (opts.minRole)
185
+ card.min_role = opts.minRole;
186
+ if (opts.description)
187
+ card.description = opts.description;
188
+ if (this.#registrations.has(name))
189
+ throw new SynapseError("already_registered", `${name}: this node already provides it (one provider per service path)`);
190
+ const reg = new ServiceRegistration(n, name, card, contract, handler, this.warn);
191
+ this.#registrations.set(name, reg);
192
+ void reg.handle.exited.promise.then(() => { if (this.#registrations.get(name) === reg)
193
+ this.#registrations.delete(name); });
194
+ try {
195
+ await reg.ready();
196
+ }
197
+ catch (e) {
198
+ reg.unregister();
199
+ throw e;
200
+ }
201
+ return reg;
202
+ }
203
+ #registrations = new Map();
204
+ // ---------------------------------------------------------------------------------- store
205
+ /** Opens namespace `name` (SDK §9); resolves once the mirror is in (or the namespace is known absent). */
206
+ store(name, opts = {}) { return openStore(this.node, this.#stores, name, opts, this.warn); }
207
+ /** The runtime's `config` namespace (operators write it). */
208
+ config(opts = {}) { return this.store("config", opts); }
209
+ // ---------------------------------------------------------------------------------- scene
210
+ #scenes = { current: undefined };
211
+ /** The active project's scene (PLAN v0.24 §5); resolves attached, or at once `detached` when no project is active. */
212
+ scene() { return openScene(this.node, this.#scenes, this.warn); }
213
+ // -------------------------------------------------------------------------------- catalog
214
+ /** The runtime's catalogs as the current session last read them; empty between sessions. */
215
+ get catalog() {
216
+ const s = this.#session();
217
+ const topics = s?.topics.get().topics ?? {};
218
+ const services = s?.services.get().services ?? {};
219
+ return { topics, services, describe: name => services[name]?.card };
220
+ }
221
+ /** Called when either catalog changes on the current session. */
222
+ watchCatalog(cb) {
223
+ let un = [];
224
+ const bind = () => {
225
+ for (const u of un)
226
+ u();
227
+ un = [];
228
+ const s = this.#session();
229
+ if (!s)
230
+ return;
231
+ un.push(s.topics.subscribe(() => cb(this.catalog)), s.services.subscribe(() => cb(this.catalog)));
232
+ };
233
+ bind();
234
+ const unLink = this.node.link.subscribe(() => { bind(); cb(this.catalog); });
235
+ return () => { unLink(); for (const u of un)
236
+ u(); };
237
+ }
238
+ // -------------------------------------------------------------------------------- project
239
+ /** The project state the current session last read (WP §3.5); `undefined` between sessions or before the first snapshot. */
240
+ project() { return this.#session()?.project.get(); }
241
+ watchProject(cb) {
242
+ let un;
243
+ const bind = () => { un?.(); un = this.#session()?.project.subscribe(p => cb(p)); };
244
+ bind();
245
+ const unLink = this.node.link.subscribe(() => { bind(); cb(this.project()); });
246
+ return () => { unLink(); un?.(); };
247
+ }
248
+ #projectCall(op, args) { return this.call(`synapse/project/${op}`, args).result; }
249
+ /** `synapse/project/activate {id}`: activates `id` (or joins it when active); promoted to operator. */
250
+ activateProject(id) { return this.#projectCall("activate", { id }); }
251
+ /** `synapse/project/deactivate {}`: every operator is demoted. */
252
+ deactivateProject() { return this.#projectCall("deactivate", {}); }
253
+ /** `synapse/project/join {as: "operator"}`: promoted on the active project, or `project_inactive`. */
254
+ joinProject(as = "operator") { return this.#projectCall("join", { as }); }
255
+ /** `synapse/project/leave {}`: drops our standing (every session of our `user_id`). */
256
+ leaveProject() { return this.#projectCall("leave", {}); }
257
+ getProject() { return this.#projectCall("get", {}); }
258
+ // -------------------------------------------------------------------------------- tasks
259
+ #tasks;
260
+ /**
261
+ * The robot's task declarations, what runs (`status`), what the runtime can run (`catalog`), and the verbs; the same
262
+ * object for the node's life. Each handle opens on its first touch and is the node's own (`node.close()` ends it).
263
+ */
264
+ get tasks() {
265
+ const n = this.node;
266
+ return (this.#tasks ??= new TasksFacet(name => attachStore(n, this.#stores, name, {}, m => this.warn(m)), svc => this.call(svc, {}).result, m => this.warn(m)));
267
+ }
268
+ }
@@ -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.value` 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,132 @@
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
+ /** The namespaces the facet reads and writes (the runtime's names). */
11
+ export const TASKS_NAMESPACES = { declarations: "tasks", status: "task_status", catalog: "task_catalog" };
12
+ /** What `status.value` is before the runtime said anything: neither scope's state known, no tasks. */
13
+ export const EMPTY_TASKS_STATUS = { robot: { state: undefined, tasks: {} }, project: { state: undefined, tasks: {} } };
14
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
15
+ /** A run row (`robot` / `project`: `{state, since, reason?}`) as the scope's run fields. */
16
+ function runOf(row) {
17
+ if (!isObject(row))
18
+ return { state: undefined };
19
+ const out = { state: row.state };
20
+ if (row.since !== undefined)
21
+ out.since = row.since;
22
+ if (row.reason !== undefined)
23
+ out.reason = row.reason;
24
+ return out;
25
+ }
26
+ /**
27
+ * `task_status`'s rows as a `TasksStatus`. A run row is the one keyed `robot` or `project`; an instance
28
+ * row goes where its own `scope`, `entity` and `instance` say (its key is never parsed: an instance
29
+ * name may hold `:`). A row that cannot be placed is left out and passed to `unplaced` (the runtime
30
+ * never writes one). The instance rows are the mirror's own objects.
31
+ */
32
+ export function tasksStatusOf(rows, unplaced = () => { }) {
33
+ const robot = {};
34
+ const project = {};
35
+ for (const [key, row] of Object.entries(rows)) {
36
+ if (key === "robot" || key === "project")
37
+ continue;
38
+ if (!isObject(row) || typeof row.instance !== "string") {
39
+ unplaced(key);
40
+ continue;
41
+ }
42
+ if (row.scope === "robot")
43
+ robot[row.instance] = row;
44
+ else if (row.scope === "project" && typeof row.entity === "string")
45
+ (project[row.entity] ??= {})[row.instance] = row;
46
+ else
47
+ unplaced(key);
48
+ }
49
+ return { robot: { ...runOf(rows.robot), tasks: robot }, project: { ...runOf(rows.project), tasks: project } };
50
+ }
51
+ /**
52
+ * A read-only handle over one namespace of the node: `value` is `derive` of the mirror, recomputed only
53
+ * when the mirror changed and shared structurally with the previous value. The namespace is opened on
54
+ * the first touch of any member.
55
+ */
56
+ class DerivedHandle {
57
+ open;
58
+ derive;
59
+ #h;
60
+ #from;
61
+ #value;
62
+ constructor(open, derive, empty) {
63
+ this.open = open;
64
+ this.derive = derive;
65
+ this.#value = empty;
66
+ }
67
+ #handle() { return (this.#h ??= this.open()); }
68
+ get value() {
69
+ const rows = this.#handle().value;
70
+ if (rows !== this.#from) {
71
+ this.#from = rows;
72
+ this.#value = replaceEqualDeep(this.#value, this.derive(rows));
73
+ }
74
+ return this.#value;
75
+ }
76
+ get synced() { return this.#handle().synced; }
77
+ ready() { return this.#handle().ready(); }
78
+ async *watch() {
79
+ for await (const _ of this.#handle().watch())
80
+ yield this.value;
81
+ }
82
+ subscribe(listener) { return this.#handle().subscribe(listener); }
83
+ }
84
+ /** The robot's declarations: the node's handle on `tasks`; every write waits for the value to be current first. */
85
+ class Declarations {
86
+ open;
87
+ #h;
88
+ constructor(open) {
89
+ this.open = open;
90
+ }
91
+ #handle() { return (this.#h ??= this.open()); }
92
+ get value() { return this.#handle().value; }
93
+ get(instance) { return this.#handle().get(instance); }
94
+ get synced() { return this.#handle().synced; }
95
+ get status() { return this.#handle().status; }
96
+ ready() { return this.#handle().ready(); }
97
+ async set(a, b) {
98
+ const h = this.#handle();
99
+ await h.ready();
100
+ await (typeof a === "string" ? h.set(a, b) : h.set(a));
101
+ }
102
+ async delete(instance) { const h = this.#handle(); await h.ready(); await h.delete(instance); }
103
+ async clear() { const h = this.#handle(); await h.ready(); await h.clear(); }
104
+ watch(instance) { return this.#handle().watch(instance); }
105
+ subscribe(listener) { return this.#handle().subscribe(listener); }
106
+ }
107
+ /** `node.tasks`. `attach(name)` hands out the node's handle on a namespace; `call` starts a call on the current session. */
108
+ export class TasksFacet {
109
+ call;
110
+ declarations;
111
+ status;
112
+ catalog;
113
+ constructor(attach, call, warn) {
114
+ this.call = call;
115
+ const warned = new Set();
116
+ const unplaced = (key) => { if (!warned.has(key)) {
117
+ warned.add(key);
118
+ warn(`tasks: status row ${JSON.stringify(key)} names no scope and instance; left out`);
119
+ } };
120
+ this.declarations = new Declarations(() => attach(TASKS_NAMESPACES.declarations));
121
+ this.status = new DerivedHandle(() => attach(TASKS_NAMESPACES.status), rows => tasksStatusOf(rows, unplaced), EMPTY_TASKS_STATUS);
122
+ this.catalog = new DerivedHandle(() => attach(TASKS_NAMESPACES.catalog), rows => rows, {});
123
+ }
124
+ async runRobotTasks() { return (await this.call("synapse/tasks/run_robot")); }
125
+ async stopRobotTasks() { await this.call("synapse/tasks/stop_robot"); }
126
+ async runProjectTasks() { return (await this.call("synapse/tasks/run_project")); }
127
+ async stopProjectTasks() { await this.call("synapse/tasks/stop_project"); }
128
+ }
129
+ /** `true` for a refused run, `preflight_failed` or `launch_failed`, whose `data.report` names each declaration at fault and why. */
130
+ export function isTasksRefusal(e) {
131
+ return e instanceof SynapseError && (e.code === "preflight_failed" || e.code === "launch_failed") && isObject(e.data) && Array.isArray(e.data.report);
132
+ }