@actana/sdk 0.2.2
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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/core-client.d.ts +616 -0
- package/dist/core-client.d.ts.map +1 -0
- package/dist/core-client.js +1036 -0
- package/dist/core-client.js.map +1 -0
- package/dist/core-link-cursor-storage.d.ts +32 -0
- package/dist/core-link-cursor-storage.d.ts.map +1 -0
- package/dist/core-link-cursor-storage.js +66 -0
- package/dist/core-link-cursor-storage.js.map +1 -0
- package/dist/core-link-frames.d.ts +1123 -0
- package/dist/core-link-frames.d.ts.map +1 -0
- package/dist/core-link-frames.js +349 -0
- package/dist/core-link-frames.js.map +1 -0
- package/dist/core-link-socket.d.ts +54 -0
- package/dist/core-link-socket.d.ts.map +1 -0
- package/dist/core-link-socket.js +74 -0
- package/dist/core-link-socket.js.map +1 -0
- package/dist/core-link-transport.d.ts +177 -0
- package/dist/core-link-transport.d.ts.map +1 -0
- package/dist/core-link-transport.js +432 -0
- package/dist/core-link-transport.js.map +1 -0
- package/dist/core-registration-blob.d.ts +52 -0
- package/dist/core-registration-blob.d.ts.map +1 -0
- package/dist/core-registration-blob.js +61 -0
- package/dist/core-registration-blob.js.map +1 -0
- package/dist/core-session.d.ts +321 -0
- package/dist/core-session.d.ts.map +1 -0
- package/dist/core-session.js +660 -0
- package/dist/core-session.js.map +1 -0
- package/dist/durable-core-client.d.ts +172 -0
- package/dist/durable-core-client.d.ts.map +1 -0
- package/dist/durable-core-client.js +264 -0
- package/dist/durable-core-client.js.map +1 -0
- package/dist/terminal-screen.d.ts +139 -0
- package/dist/terminal-screen.d.ts.map +1 -0
- package/dist/terminal-screen.js +807 -0
- package/dist/terminal-screen.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
// `CoreClient` — the default entry point: connect, authenticate, ask, close.
|
|
2
|
+
//
|
|
3
|
+
// One socket, one Core, mTLS + bearer from a registration blob (#129 D1, D5).
|
|
4
|
+
// **There is no `Fleet` type here and there must not be**: a Core client speaks
|
|
5
|
+
// to the machine it dialed, and holding several is the Panel's job, not a shape
|
|
6
|
+
// this package grows. That is D5, and the Panel remains the fleet.
|
|
7
|
+
//
|
|
8
|
+
// This is what the CLI and a script use (#129 D6, "one-shot is the default"):
|
|
9
|
+
// connect, ask a question, act on the answer, exit. It does not reconnect, does
|
|
10
|
+
// not persist an event cursor, and does not ping — every one of those belongs to
|
|
11
|
+
// a program that stays up, and that program is
|
|
12
|
+
// {@link DurableCoreClient} in `durable-core-client.ts`, which is this class
|
|
13
|
+
// plus a supervisor. Both sit on one {@link CoreLinkTransport}, so the wire is
|
|
14
|
+
// written once.
|
|
15
|
+
//
|
|
16
|
+
// Extracted from the Panel's `PtyCoreLinkClient` (issue 153), which no longer
|
|
17
|
+
// exists: #156 deleted it and pointed the Panel here. The typed method set below
|
|
18
|
+
// is deliberately that class's, frame for frame, which is what made that
|
|
19
|
+
// migration a swap rather than a rewrite of its call sites; the parts
|
|
20
|
+
// that class did *not* have — an explicit `connect()` you can await, a
|
|
21
|
+
// connection that does not resurrect itself — are what a one-shot client needs
|
|
22
|
+
// and a Panel never asked for.
|
|
23
|
+
import { coreLinkProtocolCompatible, readMultiConnectionCapability, } from "./core-link-frames.js";
|
|
24
|
+
import { CoreLinkTransport, } from "./core-link-transport.js";
|
|
25
|
+
import { createNodeCoreLinkSocket, } from "./core-link-socket.js";
|
|
26
|
+
import { coreConnectionFromBlob } from "./core-registration-blob.js";
|
|
27
|
+
/**
|
|
28
|
+
* Request frames that only mean anything against a Core announcing
|
|
29
|
+
* `multiConnection` on `ready` (ADR 0024 D11) — the one list
|
|
30
|
+
* {@link CoreClient.canSendMultiConnectionFrames} guards.
|
|
31
|
+
*
|
|
32
|
+
* `ptySubscribe` / `ptyUnsubscribe` (ADR 0024 D2) are the first entries: a Core
|
|
33
|
+
* that fans a PTY out per subscription is exactly a multi-connection Core, and
|
|
34
|
+
* one that never announced the capability has no subscription list to put a PTY
|
|
35
|
+
* on. The Session lock's `claim` / `release` / `forceTakeover` (D3–D7) joined
|
|
36
|
+
* them for the same reason in its own key: a single-connection Core has no lock
|
|
37
|
+
* table to address, because it evicts every client but one and therefore has
|
|
38
|
+
* nothing for a lock to arbitrate between. `reclaim` (D9) is the sharpest of the
|
|
39
|
+
* three: a single-connection Core evicts its predecessor on connect *already*,
|
|
40
|
+
* so the frame asks for what that Core has just done, out of a lock table it
|
|
41
|
+
* does not have.
|
|
42
|
+
*
|
|
43
|
+
* Being in this set is not the whole story for a frame whose caller has
|
|
44
|
+
* something better to do than fail. A caller that can degrade consults
|
|
45
|
+
* {@link CoreClient.canSendMultiConnectionFrames} and takes the
|
|
46
|
+
* single-connection route itself — see {@link CoreClient.ptySubscribe} and
|
|
47
|
+
* {@link CoreClient.claim}, which both do exactly that. The rejection in
|
|
48
|
+
* {@link CoreClient.request} is the backstop for callers that ask without
|
|
49
|
+
* checking, not the designed path for the frames listed here.
|
|
50
|
+
*/
|
|
51
|
+
export const MULTI_CONNECTION_ONLY_FRAME_TYPES = new Set([
|
|
52
|
+
"ptySubscribe",
|
|
53
|
+
"ptyUnsubscribe",
|
|
54
|
+
"claim",
|
|
55
|
+
"release",
|
|
56
|
+
"forceTakeover",
|
|
57
|
+
"reclaim",
|
|
58
|
+
]);
|
|
59
|
+
/**
|
|
60
|
+
* The rejection a caller gets when the Core answers a request with an `error`
|
|
61
|
+
* frame — carrying that frame's machine-readable {@link CoreLinkErrorCode}
|
|
62
|
+
* alongside its prose (issue 144).
|
|
63
|
+
*
|
|
64
|
+
* `code` exists precisely so a caller does not have to match on `message`, and a
|
|
65
|
+
* client that threw a bare `Error` would put the parsing back that the field was
|
|
66
|
+
* added to remove. It is optional on the wire and optional here: absent on every
|
|
67
|
+
* error that shipped before the field, so a reader takes the code when it is
|
|
68
|
+
* there and falls back to the message when it is not — never the reverse.
|
|
69
|
+
*
|
|
70
|
+
* `session-locked` is its first value and the reason this class exists: a
|
|
71
|
+
* mutation refused because another Core client holds that Session throws,
|
|
72
|
+
* whereas a mutation aimed at a Session this Core no longer has resolves
|
|
73
|
+
* (`ok: false` / `task: null`). One of the two is worth retrying after a claim
|
|
74
|
+
* and the other never is, and telling them apart is the caller's whole job.
|
|
75
|
+
*/
|
|
76
|
+
export class CoreLinkRequestError extends Error {
|
|
77
|
+
/** The `code` off the `error` frame, when it carried one. */
|
|
78
|
+
code;
|
|
79
|
+
constructor(message, code) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = "CoreLinkRequestError";
|
|
82
|
+
this.code = code;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Thrown when a bearer is refused: `expired`, `bad-signature`, `malformed`. */
|
|
86
|
+
export class CoreLinkAuthError extends Error {
|
|
87
|
+
reason;
|
|
88
|
+
constructor(reason) {
|
|
89
|
+
super(`core-link authentication failed: ${reason}`);
|
|
90
|
+
this.name = "CoreLinkAuthError";
|
|
91
|
+
this.reason = reason;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Default deadline for a dial: `ready` plus, with a bearer, `authOk`. */
|
|
95
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
|
|
96
|
+
/** Default deadline for one request/response round trip. */
|
|
97
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
98
|
+
/**
|
|
99
|
+
* Mint this client's Core client id (ADR 0024 D9).
|
|
100
|
+
*
|
|
101
|
+
* Random rather than derived from anything: two clients must not collide, and
|
|
102
|
+
* every input that would make an id stable across processes — the endpoint, the
|
|
103
|
+
* coreId, the bearer — is shared by every client dialing that machine, which is
|
|
104
|
+
* the one shape D9 forbids. Unguessability buys nothing security-wise and is not
|
|
105
|
+
* claimed to; nothing verifies this string, and a Core hands out no more to a
|
|
106
|
+
* client presenting it than to one that asks for a `forceTakeover` outright.
|
|
107
|
+
*
|
|
108
|
+
* The `sdk-` prefix is for the Core's log, where a reclaim line is otherwise an
|
|
109
|
+
* opaque token with no clue which of a machine's clients reconnected.
|
|
110
|
+
*/
|
|
111
|
+
function mintCoreClientId() {
|
|
112
|
+
const random = globalThis.crypto?.randomUUID?.();
|
|
113
|
+
if (random)
|
|
114
|
+
return `sdk-${random}`;
|
|
115
|
+
// Every runtime this supports has `crypto.randomUUID`. The fallback keeps an
|
|
116
|
+
// id — which only has to differ from its neighbours — from being the thing
|
|
117
|
+
// that throws in an exotic one.
|
|
118
|
+
return `sdk-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A Core client over one core link.
|
|
122
|
+
*
|
|
123
|
+
* Lifecycle: construct, `await connect()`, ask, `close()`. Construction opens
|
|
124
|
+
* nothing — a client you have not connected has sent no bytes, which is what
|
|
125
|
+
* makes `connect()`'s rejection the one place a dial failure is reported.
|
|
126
|
+
*/
|
|
127
|
+
export class CoreClient {
|
|
128
|
+
/** The URL this client dials. Public because a log line without it is a riddle. */
|
|
129
|
+
url;
|
|
130
|
+
/**
|
|
131
|
+
* This client's Core client id — the same string on every connection it opens,
|
|
132
|
+
* which is the whole of what makes a reconnect reclaimable (ADR 0024 D9).
|
|
133
|
+
*
|
|
134
|
+
* Readonly: an id that moved between connections would reclaim nothing and
|
|
135
|
+
* leave the predecessor holding its Sessions for the full heartbeat timeout,
|
|
136
|
+
* which is the exact failure the frame exists to remove.
|
|
137
|
+
*/
|
|
138
|
+
clientId;
|
|
139
|
+
createSocket;
|
|
140
|
+
bearer;
|
|
141
|
+
heartbeat;
|
|
142
|
+
connectTimeoutMs;
|
|
143
|
+
requestTimeoutMs;
|
|
144
|
+
multiConnectionOnlyFrameTypes;
|
|
145
|
+
transport = null;
|
|
146
|
+
closed = false;
|
|
147
|
+
/**
|
|
148
|
+
* True once a `subscribe` has gone out for this client — see
|
|
149
|
+
* {@link subscribeEvents}. Per client rather than per connection: it records
|
|
150
|
+
* that this client *wants* the event stream, which is what a reconnecting
|
|
151
|
+
* subclass re-asks for, and what stops a second caller asking twice.
|
|
152
|
+
*/
|
|
153
|
+
eventsSubscribed = false;
|
|
154
|
+
/** True once this connection has been established — reset per connection. */
|
|
155
|
+
established = false;
|
|
156
|
+
/** Requests written to no socket yet. They survive a reconnect; a timeout is what ends them. */
|
|
157
|
+
queue = [];
|
|
158
|
+
/** Requests on the wire right now, so a dying socket can fail them at once. */
|
|
159
|
+
inFlight = new Set();
|
|
160
|
+
connectPromise = null;
|
|
161
|
+
connectSettle = null;
|
|
162
|
+
/** This connection's `ready` frame; null before it lands and after a drop. */
|
|
163
|
+
ready = null;
|
|
164
|
+
/**
|
|
165
|
+
* This Core's `multiConnection` capability as announced on the *current*
|
|
166
|
+
* connection; null when absent and before `ready` lands.
|
|
167
|
+
*
|
|
168
|
+
* Re-read on every `ready` rather than remembered across connections: a Core
|
|
169
|
+
* can be downgraded, and a stale `true` here would send frames the Core no
|
|
170
|
+
* longer understands. Null until the frame arrives, so the gate is closed
|
|
171
|
+
* during the window where the answer is genuinely unknown.
|
|
172
|
+
*/
|
|
173
|
+
multiConnection = null;
|
|
174
|
+
authOkFrame = null;
|
|
175
|
+
readyListeners = new Set();
|
|
176
|
+
dataListeners = new Set();
|
|
177
|
+
exitListeners = new Set();
|
|
178
|
+
eventListeners = new Set();
|
|
179
|
+
eventsReplayedListeners = new Set();
|
|
180
|
+
authOkListeners = new Set();
|
|
181
|
+
authErrorListeners = new Set();
|
|
182
|
+
disconnectedListeners = new Set();
|
|
183
|
+
reclaimedListeners = new Set();
|
|
184
|
+
constructor(opts) {
|
|
185
|
+
const connection = opts.blob ? coreConnectionFromBlob(opts.blob) : null;
|
|
186
|
+
const url = connection?.url ?? opts.url;
|
|
187
|
+
if (!url) {
|
|
188
|
+
throw new Error("CoreClient needs a url or a registration blob to dial");
|
|
189
|
+
}
|
|
190
|
+
this.url = url;
|
|
191
|
+
const tls = connection ? connection.tls : (opts.tls ?? null);
|
|
192
|
+
this.bearer = connection ? connection.bearer : (opts.bearer ?? null);
|
|
193
|
+
this.createSocket =
|
|
194
|
+
opts.createSocket ?? ((dialUrl) => createNodeCoreLinkSocket(dialUrl, tls ?? undefined));
|
|
195
|
+
this.connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
196
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
197
|
+
this.heartbeat = opts.heartbeat ?? false;
|
|
198
|
+
this.clientId = opts.clientId ?? mintCoreClientId();
|
|
199
|
+
this.multiConnectionOnlyFrameTypes =
|
|
200
|
+
opts.multiConnectionOnlyFrameTypes ?? MULTI_CONNECTION_ONLY_FRAME_TYPES;
|
|
201
|
+
}
|
|
202
|
+
/** Build a client straight off a registration blob (#129 D1). */
|
|
203
|
+
static fromRegistrationBlob(blob, opts = {}) {
|
|
204
|
+
return new CoreClient({ ...opts, blob });
|
|
205
|
+
}
|
|
206
|
+
// ─── Connection lifecycle ──────────────────────────────────────────────────
|
|
207
|
+
/**
|
|
208
|
+
* Open the link and settle when the Core has said who it is: resolves after
|
|
209
|
+
* `ready` and, when a bearer is configured, after `authOk`.
|
|
210
|
+
*
|
|
211
|
+
* Idempotent **while it is in flight** — a second call returns the first
|
|
212
|
+
* call's promise, so two callers racing to connect share one socket rather
|
|
213
|
+
* than opening two. Once it settles the memo is dropped, and what a later
|
|
214
|
+
* call gets is the state of the link *now*: the current connection's info if
|
|
215
|
+
* one is up, and a fresh attempt if none is.
|
|
216
|
+
*
|
|
217
|
+
* That distinction is the whole point. A memo kept past its rejection is a
|
|
218
|
+
* client that reports itself permanently unconnectable — a durable client
|
|
219
|
+
* whose connect deadline expired once would hand every later caller that same
|
|
220
|
+
* rejection long after its supervisor had reconnected, because nothing here
|
|
221
|
+
* ever cleared it (issue 153 review, note 3; #156 builds on this).
|
|
222
|
+
*
|
|
223
|
+
* Rejects on a refused bearer, on a socket that closed before it was
|
|
224
|
+
* established, and on the connect deadline. A {@link DurableCoreClient} keeps
|
|
225
|
+
* retrying underneath regardless; what rejects there is this promise, not the
|
|
226
|
+
* client.
|
|
227
|
+
*/
|
|
228
|
+
connect() {
|
|
229
|
+
if (this.closed)
|
|
230
|
+
return Promise.reject(new Error("core-link client closed"));
|
|
231
|
+
if (this.connectPromise)
|
|
232
|
+
return this.connectPromise;
|
|
233
|
+
// A link that is already up answers straight away rather than dialing a
|
|
234
|
+
// second one over a working first.
|
|
235
|
+
if (this.established)
|
|
236
|
+
return Promise.resolve(this.connectionInfo());
|
|
237
|
+
this.connectPromise = new Promise((resolve, reject) => {
|
|
238
|
+
const timer = setTimeout(() => {
|
|
239
|
+
this.failConnect(new Error(`core-link connect to ${this.url} timed out`));
|
|
240
|
+
}, this.connectTimeoutMs);
|
|
241
|
+
this.connectSettle = { resolve, reject, timer };
|
|
242
|
+
});
|
|
243
|
+
// Dial only when nothing is already dialing on this client's behalf. A
|
|
244
|
+
// transport mid-handshake, or a durable client's backoff timer about to make
|
|
245
|
+
// one, will settle the promise above through the ordinary handlers — opening
|
|
246
|
+
// another socket here would orphan whichever of the two lost the race.
|
|
247
|
+
if (!this.transport && !this.reconnectScheduled())
|
|
248
|
+
this.openTransport();
|
|
249
|
+
return this.connectPromise;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Is a reconnect already pending, so `connect()` must not dial itself? Never
|
|
253
|
+
* for a one-shot client, which has no supervisor; {@link DurableCoreClient}
|
|
254
|
+
* overrides it with its backoff timer.
|
|
255
|
+
*/
|
|
256
|
+
reconnectScheduled() {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Open one connection and wire it to this client. The transport is per socket;
|
|
261
|
+
* every listener above is per client, which is what lets a durable subclass
|
|
262
|
+
* replace the former without anything above noticing.
|
|
263
|
+
*/
|
|
264
|
+
openTransport() {
|
|
265
|
+
if (this.closed)
|
|
266
|
+
return;
|
|
267
|
+
this.ready = null;
|
|
268
|
+
this.multiConnection = null;
|
|
269
|
+
this.authOkFrame = null;
|
|
270
|
+
this.established = false;
|
|
271
|
+
this.transport = new CoreLinkTransport({
|
|
272
|
+
url: this.url,
|
|
273
|
+
createSocket: this.createSocket,
|
|
274
|
+
bearer: this.bearer,
|
|
275
|
+
heartbeat: this.heartbeat,
|
|
276
|
+
handlers: {
|
|
277
|
+
onReady: (frame) => {
|
|
278
|
+
this.ready = frame;
|
|
279
|
+
this.multiConnection = readMultiConnectionCapability(frame.multiConnection);
|
|
280
|
+
const info = this.connectionInfo();
|
|
281
|
+
for (const cb of this.readyListeners)
|
|
282
|
+
cb(info);
|
|
283
|
+
this.maybeEstablish();
|
|
284
|
+
},
|
|
285
|
+
onWritable: () => {
|
|
286
|
+
// Writability alone does not drain the queue. `maybeEstablish` flushes
|
|
287
|
+
// it once the connection is established, *behind* what that connection
|
|
288
|
+
// owed the Core — and on the no-bearer path writability lands before
|
|
289
|
+
// `ready`, so flushing here regardless would put a caller's request on
|
|
290
|
+
// the wire ahead of `reclaim` and `subscribe`, which is the ordering
|
|
291
|
+
// the comment on `onConnectionEstablished` promises callers. The guard
|
|
292
|
+
// covers the case where writability returns on a connection that is
|
|
293
|
+
// already established.
|
|
294
|
+
this.maybeEstablish();
|
|
295
|
+
if (this.established)
|
|
296
|
+
this.flushQueue();
|
|
297
|
+
},
|
|
298
|
+
onAuthOk: (frame) => {
|
|
299
|
+
this.authOkFrame = frame;
|
|
300
|
+
for (const cb of this.authOkListeners)
|
|
301
|
+
cb({ coreId: frame.coreId, exp: frame.exp });
|
|
302
|
+
},
|
|
303
|
+
onAuthError: (reason) => {
|
|
304
|
+
for (const cb of this.authErrorListeners)
|
|
305
|
+
cb({ reason });
|
|
306
|
+
this.failConnect(new CoreLinkAuthError(reason));
|
|
307
|
+
},
|
|
308
|
+
onData: (frame) => {
|
|
309
|
+
for (const cb of this.dataListeners)
|
|
310
|
+
cb(frame);
|
|
311
|
+
},
|
|
312
|
+
onExit: (frame) => {
|
|
313
|
+
for (const cb of this.exitListeners)
|
|
314
|
+
cb(frame);
|
|
315
|
+
},
|
|
316
|
+
onEvent: (event) => this.deliverEvent(event),
|
|
317
|
+
onEventsReplayed: (lastEventId) => this.deliverEventsReplayed(lastEventId),
|
|
318
|
+
onClose: (reason) => {
|
|
319
|
+
this.ready = null;
|
|
320
|
+
this.multiConnection = null;
|
|
321
|
+
this.authOkFrame = null;
|
|
322
|
+
// Cleared here and not only on the next dial: between a socket dying
|
|
323
|
+
// and a durable client's backoff opening the next one, this client is
|
|
324
|
+
// not established, and a `connect()` arriving in that gap must wait
|
|
325
|
+
// for the connection that is coming rather than be told about the one
|
|
326
|
+
// that just died.
|
|
327
|
+
this.established = false;
|
|
328
|
+
this.transport = null;
|
|
329
|
+
this.failInFlight();
|
|
330
|
+
// Before the listeners, not after. A disconnect listener may call
|
|
331
|
+
// `connect()` synchronously — the Panel registers such handlers
|
|
332
|
+
// (#156) — and with no transport and no backoff armed yet, that call
|
|
333
|
+
// would dial a socket of its own, which the backoff's own
|
|
334
|
+
// `openTransport()` then overwrote a moment later: one live link, one
|
|
335
|
+
// orphan nobody holds a reference to, and no `close()` coming for it.
|
|
336
|
+
// Arming first makes `reconnectScheduled()` true for that call, so it
|
|
337
|
+
// waits for the connection already on its way rather than opening a
|
|
338
|
+
// second one. Nothing observable moves: what this runs before the
|
|
339
|
+
// listeners either arms a timer or rejects a promise, and a promise
|
|
340
|
+
// rejection is a microtask that lands after the whole loop either way.
|
|
341
|
+
this.onConnectionClosed(reason);
|
|
342
|
+
for (const cb of this.disconnectedListeners)
|
|
343
|
+
cb({ error: reason });
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* The connection is up, authenticated if it had to be, and the capability off
|
|
350
|
+
* `ready` is known. What a fresh connection owes the Core goes here.
|
|
351
|
+
*
|
|
352
|
+
* `reclaim` is the whole of it for a one-shot client, and it is fire-and-forget
|
|
353
|
+
* by design (ADR 0024 D9): nothing waits on the answer, because the locks have
|
|
354
|
+
* already moved by the time it is written, and a reclaim that never lands costs
|
|
355
|
+
* the 45-second heartbeat timeout it was shortening — a slower path to the same
|
|
356
|
+
* state, never a wrong one.
|
|
357
|
+
*
|
|
358
|
+
* Overridden by {@link DurableCoreClient}, which owes the Core rather more.
|
|
359
|
+
*/
|
|
360
|
+
onConnectionEstablished() {
|
|
361
|
+
this.sendReclaim();
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Establish the connection once both halves of "established" are true: the
|
|
365
|
+
* Core has said who it is (`ready`), and the link can be written to (open, and
|
|
366
|
+
* authenticated when a bearer was configured).
|
|
367
|
+
*
|
|
368
|
+
* **Whichever lands last is what triggers it**, and that is not a fixed order.
|
|
369
|
+
* With a bearer it is `authOk`, well after `ready`. Without one, writability
|
|
370
|
+
* rides the socket opening and `ready` normally follows — but a Core that got
|
|
371
|
+
* its frame in first would otherwise have the connection's opening frames sent
|
|
372
|
+
* against a socket that is not writable yet, and a `subscribe` dropped there is
|
|
373
|
+
* a client that never receives an event again. Waiting for both removes the
|
|
374
|
+
* ordering assumption instead of relying on it.
|
|
375
|
+
*/
|
|
376
|
+
maybeEstablish() {
|
|
377
|
+
if (this.established || this.closed)
|
|
378
|
+
return;
|
|
379
|
+
if (!this.ready || !this.transport?.writable)
|
|
380
|
+
return;
|
|
381
|
+
this.established = true;
|
|
382
|
+
this.onConnectionEstablished();
|
|
383
|
+
// The queue goes out behind what the connection owed the Core, so a replay
|
|
384
|
+
// tail is delivered ahead of the answers to requests that were waiting.
|
|
385
|
+
this.flushQueue();
|
|
386
|
+
this.settleConnect();
|
|
387
|
+
}
|
|
388
|
+
/** The socket is gone. A one-shot client is done; a durable one reconnects. */
|
|
389
|
+
onConnectionClosed(reason) {
|
|
390
|
+
if (this.closed)
|
|
391
|
+
return;
|
|
392
|
+
this.failConnect(new Error(reason ? `core-link closed: ${reason}` : "core-link closed"));
|
|
393
|
+
}
|
|
394
|
+
settleConnect() {
|
|
395
|
+
const settle = this.connectSettle;
|
|
396
|
+
if (!settle)
|
|
397
|
+
return;
|
|
398
|
+
this.connectSettle = null;
|
|
399
|
+
this.connectPromise = null;
|
|
400
|
+
clearTimeout(settle.timer);
|
|
401
|
+
settle.resolve(this.connectionInfo());
|
|
402
|
+
}
|
|
403
|
+
failConnect(err) {
|
|
404
|
+
const settle = this.connectSettle;
|
|
405
|
+
if (!settle)
|
|
406
|
+
return;
|
|
407
|
+
this.connectSettle = null;
|
|
408
|
+
// Dropped on failure as well as on success: a rejected promise held in the
|
|
409
|
+
// memo would be handed to every later caller forever. See `connect()`.
|
|
410
|
+
this.connectPromise = null;
|
|
411
|
+
clearTimeout(settle.timer);
|
|
412
|
+
settle.reject(err);
|
|
413
|
+
}
|
|
414
|
+
/** What the current connection said about itself. */
|
|
415
|
+
connectionInfo() {
|
|
416
|
+
return {
|
|
417
|
+
protocolVersion: this.ready?.version ?? null,
|
|
418
|
+
compatible: coreLinkProtocolCompatible(this.ready?.version ?? null),
|
|
419
|
+
multiConnection: this.multiConnection,
|
|
420
|
+
coreId: this.authOkFrame?.coreId ?? null,
|
|
421
|
+
bearerExpiresAt: this.authOkFrame?.exp ?? null,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/** True once the Core has accepted this connection's bearer. */
|
|
425
|
+
isAuthenticated() {
|
|
426
|
+
return this.transport?.isAuthenticated ?? false;
|
|
427
|
+
}
|
|
428
|
+
/** True while a link is up and able to carry a frame. */
|
|
429
|
+
isConnected() {
|
|
430
|
+
return this.transport?.writable ?? false;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* May this Core be sent frames only a multi-connection Core understands — the
|
|
434
|
+
* Session lock's `claim`, the per-connection PTY subscription, and whatever
|
|
435
|
+
* else lands under ADR 0024?
|
|
436
|
+
*
|
|
437
|
+
* **The one predicate to consult before sending any such frame.** False means
|
|
438
|
+
* do not send it: not send-and-handle-the-error, not send-and-hope. The Core on
|
|
439
|
+
* the other end is a single-connection build that would answer an unknown frame
|
|
440
|
+
* with an error frame at best, and the caller is expected to have a
|
|
441
|
+
* single-connection path already — that path is what shipped before this
|
|
442
|
+
* capability existed.
|
|
443
|
+
*
|
|
444
|
+
* False before `ready` lands and after a drop, for as long as the answer is
|
|
445
|
+
* unknown. A caller that needs the capability at startup awaits
|
|
446
|
+
* {@link connect}, whose answer carries it, rather than reading this on the
|
|
447
|
+
* first tick.
|
|
448
|
+
*/
|
|
449
|
+
canSendMultiConnectionFrames() {
|
|
450
|
+
return this.multiConnection !== null;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* This connection's `multiConnection` capability, or null. For callers that
|
|
454
|
+
* need the version rather than the yes/no — the gate is
|
|
455
|
+
* {@link canSendMultiConnectionFrames}.
|
|
456
|
+
*/
|
|
457
|
+
multiConnectionCapability() {
|
|
458
|
+
return this.multiConnection;
|
|
459
|
+
}
|
|
460
|
+
/** Hang up. Rejects every outstanding request; fires no `onDisconnected`. */
|
|
461
|
+
close() {
|
|
462
|
+
this.closed = true;
|
|
463
|
+
const err = new Error("core-link client closed");
|
|
464
|
+
for (const entry of [...this.queue, ...this.inFlight])
|
|
465
|
+
this.settle(entry, () => entry.reject(err));
|
|
466
|
+
this.queue.length = 0;
|
|
467
|
+
this.inFlight.clear();
|
|
468
|
+
this.failConnect(err);
|
|
469
|
+
this.transport?.close();
|
|
470
|
+
this.transport = null;
|
|
471
|
+
}
|
|
472
|
+
// ─── Requests ──────────────────────────────────────────────────────────────
|
|
473
|
+
/**
|
|
474
|
+
* Send any core-link request frame and resolve with the Core's raw response
|
|
475
|
+
* frame — errors included, as frames rather than rejections.
|
|
476
|
+
*
|
|
477
|
+
* This is the seam a router forwards through. A router that had to go through
|
|
478
|
+
* the typed methods below would unwrap each response only to re-wrap it, and
|
|
479
|
+
* would have to invent a shape for the failures those methods turn into
|
|
480
|
+
* rejections. Handing it the frame keeps it a router rather than a translator.
|
|
481
|
+
*
|
|
482
|
+
* The `reqId` on the frame passed in is ignored — the transport owns
|
|
483
|
+
* correlation on its own socket, and the returned frame carries the id it
|
|
484
|
+
* assigned. A caller matching answers to its own callers (the Panel does,
|
|
485
|
+
* across many browsers) keys on its own id and rewrites it on the way back.
|
|
486
|
+
*
|
|
487
|
+
* A frame issued while no link is up is **queued, not refused**: it goes out
|
|
488
|
+
* when one comes up, or its deadline ends it. That is what makes a call made
|
|
489
|
+
* during a reconnect work rather than needing every call site to retry.
|
|
490
|
+
*/
|
|
491
|
+
request(frame, timeoutMs = this.requestTimeoutMs) {
|
|
492
|
+
if (this.closed)
|
|
493
|
+
return Promise.reject(new Error("core-link client closed"));
|
|
494
|
+
// The gate (ADR 0024 D11). A multi-connection-only frame aimed at a Core
|
|
495
|
+
// that never announced the capability is refused here, before anything
|
|
496
|
+
// reaches the socket — the Core never sees it, so there is no error frame to
|
|
497
|
+
// tolerate. The rejection is for the caller that asked without checking; the
|
|
498
|
+
// supported path is to consult `canSendMultiConnectionFrames()` and take the
|
|
499
|
+
// single-connection route.
|
|
500
|
+
if (this.multiConnectionOnlyFrameTypes.has(frame.type) && !this.canSendMultiConnectionFrames()) {
|
|
501
|
+
return Promise.reject(new Error(`core-link frame ${frame.type} needs the multiConnection capability, which this Core does not announce`));
|
|
502
|
+
}
|
|
503
|
+
return new Promise((resolve, reject) => {
|
|
504
|
+
const entry = { frame, resolve, reject, timer: null, settled: false };
|
|
505
|
+
entry.timer = setTimeout(() => {
|
|
506
|
+
this.forget(entry);
|
|
507
|
+
this.settle(entry, () => reject(new Error(`core-link rpc ${frame.type} timed out`)));
|
|
508
|
+
}, timeoutMs);
|
|
509
|
+
this.dispatch(entry);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Send now if there is an established link, otherwise hold it for the next
|
|
514
|
+
* one.
|
|
515
|
+
*
|
|
516
|
+
* **Established, not merely writable.** On the no-bearer path writability
|
|
517
|
+
* rides the socket opening and can land before `ready`, so a request *issued*
|
|
518
|
+
* in that window would go out ahead of the `reclaim` and `subscribe` this
|
|
519
|
+
* connection owes the Core — the ordering `onConnectionEstablished` documents
|
|
520
|
+
* as load-bearing, and the same property `onWritable`'s guard keeps for a
|
|
521
|
+
* request that was already queued. Both doors, one rule: nothing reaches the
|
|
522
|
+
* wire before the connection has been established (issue 153 review, #156).
|
|
523
|
+
*/
|
|
524
|
+
dispatch(entry) {
|
|
525
|
+
const transport = this.transport;
|
|
526
|
+
if (!this.established || !transport?.writable) {
|
|
527
|
+
this.queue.push(entry);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
this.inFlight.add(entry);
|
|
531
|
+
transport.request(entry.frame).then((response) => {
|
|
532
|
+
this.inFlight.delete(entry);
|
|
533
|
+
this.settle(entry, () => entry.resolve(response));
|
|
534
|
+
}, (err) => {
|
|
535
|
+
this.inFlight.delete(entry);
|
|
536
|
+
this.settle(entry, () => entry.reject(err));
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
/** Drain what was waiting for a link, in the order it was asked for. */
|
|
540
|
+
flushQueue() {
|
|
541
|
+
if (this.queue.length === 0)
|
|
542
|
+
return;
|
|
543
|
+
const queued = this.queue.splice(0);
|
|
544
|
+
for (const entry of queued) {
|
|
545
|
+
if (entry.settled)
|
|
546
|
+
continue;
|
|
547
|
+
this.dispatch(entry);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Fail every request that was on the wire when the socket died. Their replies
|
|
552
|
+
* can never arrive, so leaving them pending only makes a caller wait out its
|
|
553
|
+
* whole deadline before it can retry on the next connection.
|
|
554
|
+
*/
|
|
555
|
+
failInFlight() {
|
|
556
|
+
if (this.inFlight.size === 0)
|
|
557
|
+
return;
|
|
558
|
+
const err = new Error("core-link connection lost");
|
|
559
|
+
const entries = [...this.inFlight];
|
|
560
|
+
this.inFlight.clear();
|
|
561
|
+
for (const entry of entries)
|
|
562
|
+
this.settle(entry, () => entry.reject(err));
|
|
563
|
+
}
|
|
564
|
+
forget(entry) {
|
|
565
|
+
this.inFlight.delete(entry);
|
|
566
|
+
const at = this.queue.indexOf(entry);
|
|
567
|
+
if (at >= 0)
|
|
568
|
+
this.queue.splice(at, 1);
|
|
569
|
+
}
|
|
570
|
+
settle(entry, finish) {
|
|
571
|
+
if (entry.settled)
|
|
572
|
+
return;
|
|
573
|
+
entry.settled = true;
|
|
574
|
+
if (entry.timer)
|
|
575
|
+
clearTimeout(entry.timer);
|
|
576
|
+
entry.timer = null;
|
|
577
|
+
finish();
|
|
578
|
+
}
|
|
579
|
+
/** Send a request and unwrap the Core's answer into the value it carries. */
|
|
580
|
+
rpc(frame, timeoutMs) {
|
|
581
|
+
return this.request(frame, timeoutMs).then((response) => unwrapResponse(response));
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Send a fire-and-forget frame on the current connection. False when there is
|
|
585
|
+
* no writable connection to put it on — for the frames whose answer is a
|
|
586
|
+
* stream and whose retry is the next connection re-sending them anyway.
|
|
587
|
+
*/
|
|
588
|
+
sendNow(frame, reqIdPrefix) {
|
|
589
|
+
const transport = this.transport;
|
|
590
|
+
if (!transport)
|
|
591
|
+
return false;
|
|
592
|
+
return transport.send(frame, reqIdPrefix) !== null;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Present this client's Core client id, so the Core closes the socket this
|
|
596
|
+
* connection replaces and moves its Session locks here (ADR 0024 D9).
|
|
597
|
+
*
|
|
598
|
+
* The answer is not discarded, though (issue 147). `taskIds` names the Sessions
|
|
599
|
+
* whose locks came across, and that is a fact no layer above can derive: this
|
|
600
|
+
* connection did not claim them, the Core logged no event for the transfer —
|
|
601
|
+
* the locks moved in place, which is exactly the atomicity D9 needs — so until
|
|
602
|
+
* something asks for a fresh snapshot nothing else on the link says this client
|
|
603
|
+
* is holding them again. Hence {@link onReclaimed}.
|
|
604
|
+
*
|
|
605
|
+
* **Against a Core that does not announce `multiConnection`, this sends
|
|
606
|
+
* nothing** — the same consult-the-gate-and-degrade shape
|
|
607
|
+
* {@link ptySubscribe} and {@link claim} take, and here the degraded behaviour
|
|
608
|
+
* is not merely acceptable but identical: such a Core evicts the previous
|
|
609
|
+
* connection when this one opens, which is precisely what the frame asks for.
|
|
610
|
+
*/
|
|
611
|
+
sendReclaim() {
|
|
612
|
+
if (!this.canSendMultiConnectionFrames())
|
|
613
|
+
return;
|
|
614
|
+
void this.rpc({ type: "reclaim", reqId: "", clientId: this.clientId })
|
|
615
|
+
.then((result) => {
|
|
616
|
+
const answer = result;
|
|
617
|
+
if (!answer)
|
|
618
|
+
return;
|
|
619
|
+
const msg = {
|
|
620
|
+
replaced: answer.replaced === true,
|
|
621
|
+
taskIds: Array.isArray(answer.taskIds) ? answer.taskIds : [],
|
|
622
|
+
};
|
|
623
|
+
for (const cb of this.reclaimedListeners) {
|
|
624
|
+
try {
|
|
625
|
+
cb(msg);
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
/* a listener's failure is not this connection's failure */
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
})
|
|
632
|
+
.catch(() => {
|
|
633
|
+
/* the socket died again; the next connection presents the same id */
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
// ─── The three unsolicited streams, plus the event log ─────────────────────
|
|
637
|
+
/**
|
|
638
|
+
* One PTY's bytes, as the transport parsed them. Nothing above re-parses a raw
|
|
639
|
+
* message: this is the frame, typed against the SDK's own schema.
|
|
640
|
+
*
|
|
641
|
+
* A Core sends a PTY's `data` only to the connections that asked for it, so
|
|
642
|
+
* {@link ptySubscribe} is what makes this fire at all against a
|
|
643
|
+
* multi-connection Core.
|
|
644
|
+
*/
|
|
645
|
+
onData(cb) {
|
|
646
|
+
this.dataListeners.add(cb);
|
|
647
|
+
return () => this.dataListeners.delete(cb);
|
|
648
|
+
}
|
|
649
|
+
/** One PTY's exit, on the same terms as {@link onData}. */
|
|
650
|
+
onExit(cb) {
|
|
651
|
+
this.exitListeners.add(cb);
|
|
652
|
+
return () => this.exitListeners.delete(cb);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Notified on every connection's `ready` frame with what the Core says about
|
|
656
|
+
* itself — the protocol version, whether this build speaks it, and the
|
|
657
|
+
* `multiConnection` capability. Fires on every (re)connect, so a Core that
|
|
658
|
+
* comes back after an update reports itself compatible with no extra plumbing.
|
|
659
|
+
*/
|
|
660
|
+
onReady(cb) {
|
|
661
|
+
this.readyListeners.add(cb);
|
|
662
|
+
return () => this.readyListeners.delete(cb);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Domain events off the Core's monotonic log — task status, hooks, session
|
|
666
|
+
* finish, PTY lifecycle. A one-shot client receives only what arrives while it
|
|
667
|
+
* is connected; the cursor, the replay and the dedupe that make a *gap*
|
|
668
|
+
* recoverable are {@link DurableCoreClient}'s.
|
|
669
|
+
*/
|
|
670
|
+
onEvent(cb) {
|
|
671
|
+
this.eventListeners.add(cb);
|
|
672
|
+
return () => this.eventListeners.delete(cb);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Ask this Core for its event log: the tail past `lastEventId`, then live
|
|
676
|
+
* push for as long as this connection lasts.
|
|
677
|
+
*
|
|
678
|
+
* Fire-and-forget, because the answer is a *stream* — the replay tail as
|
|
679
|
+
* `event` frames and the {@link onEventsReplayed} marker behind them — rather
|
|
680
|
+
* than a reqId-correlated response. Returns false when there was no writable
|
|
681
|
+
* connection to put the frame on.
|
|
682
|
+
*
|
|
683
|
+
* A one-shot client sends this only if it is asked to. That is the difference
|
|
684
|
+
* between the two entry points here: {@link DurableCoreClient} owes the Core a
|
|
685
|
+
* subscribe on every connection and sends one through this method, whereas a
|
|
686
|
+
* program that connects to ask one question wants no event stream at all.
|
|
687
|
+
* What made it public is the session layer (issue 155): a script that starts a
|
|
688
|
+
* Session and waits for the harness to finish is reading the Core's own
|
|
689
|
+
* report of that, and the report is an event.
|
|
690
|
+
*
|
|
691
|
+
* `lastEventId` defaults to 0, which asks for the whole tail the Core will
|
|
692
|
+
* serve. That is the honest default for a caller with no cursor — the
|
|
693
|
+
* alternative is inventing a high-water mark, and a cursor that runs ahead of
|
|
694
|
+
* the log stops live push entirely. A caller that keeps a cursor passes it.
|
|
695
|
+
*/
|
|
696
|
+
subscribeEvents(lastEventId = 0) {
|
|
697
|
+
if (this.closed)
|
|
698
|
+
return false;
|
|
699
|
+
this.eventsSubscribed = true;
|
|
700
|
+
return this.sendNow({ type: "subscribe", reqId: "", lastEventId }, "sub");
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Has a `subscribe` gone out on this client's behalf?
|
|
704
|
+
*
|
|
705
|
+
* For a caller that needs the event stream but does not know whether the
|
|
706
|
+
* client it was handed is already receiving one — a durable client subscribes
|
|
707
|
+
* itself, a one-shot client does not, and a second subscribe would replay the
|
|
708
|
+
* tail a second time.
|
|
709
|
+
*/
|
|
710
|
+
isSubscribedToEvents() {
|
|
711
|
+
return this.eventsSubscribed;
|
|
712
|
+
}
|
|
713
|
+
/** Notified when a `subscribe` replay tail has been fully streamed. */
|
|
714
|
+
onEventsReplayed(cb) {
|
|
715
|
+
this.eventsReplayedListeners.add(cb);
|
|
716
|
+
return () => this.eventsReplayedListeners.delete(cb);
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Notified once per connection when the Core accepts the bearer. `exp` is the
|
|
720
|
+
* session expiry — a caller can hint "session expires at".
|
|
721
|
+
*/
|
|
722
|
+
onAuthOk(cb) {
|
|
723
|
+
this.authOkListeners.add(cb);
|
|
724
|
+
return () => this.authOkListeners.delete(cb);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Notified when the Core refuses the bearer. The Core closes the socket right
|
|
728
|
+
* after; an expired bearer keeps failing until a reissued blob is pasted (ADR
|
|
729
|
+
* 0003).
|
|
730
|
+
*/
|
|
731
|
+
onAuthError(cb) {
|
|
732
|
+
this.authErrorListeners.add(cb);
|
|
733
|
+
return () => this.authErrorListeners.delete(cb);
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Notified whenever the socket goes down — a dropped connection, a Core
|
|
737
|
+
* restart, a dial that never completed. `close()` does not fire it: that is
|
|
738
|
+
* this client hanging up, not the Core going away.
|
|
739
|
+
*/
|
|
740
|
+
onDisconnected(cb) {
|
|
741
|
+
this.disconnectedListeners.add(cb);
|
|
742
|
+
return () => this.disconnectedListeners.delete(cb);
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* What this client's `reclaim` reaped, once per connection that sent one (ADR
|
|
746
|
+
* 0024 D9, issue 147 is its consumer).
|
|
747
|
+
*
|
|
748
|
+
* Fires only on a Core that announces `multiConnection`, because only such a
|
|
749
|
+
* Core is sent the frame at all. `taskIds` is often empty and an empty list
|
|
750
|
+
* never means the id was unknown — it means this client held nothing when its
|
|
751
|
+
* previous socket went quiet, which is the ordinary case.
|
|
752
|
+
*/
|
|
753
|
+
onReclaimed(cb) {
|
|
754
|
+
this.reclaimedListeners.add(cb);
|
|
755
|
+
return () => this.reclaimedListeners.delete(cb);
|
|
756
|
+
}
|
|
757
|
+
/** Hand one event to the listeners. Overridden where a cursor is kept. */
|
|
758
|
+
deliverEvent(event) {
|
|
759
|
+
for (const cb of this.eventListeners)
|
|
760
|
+
cb({ event });
|
|
761
|
+
}
|
|
762
|
+
/** Hand the end-of-replay marker on. Overridden where a cursor is kept. */
|
|
763
|
+
deliverEventsReplayed(lastEventId) {
|
|
764
|
+
for (const cb of this.eventsReplayedListeners)
|
|
765
|
+
cb({ lastEventId });
|
|
766
|
+
}
|
|
767
|
+
// ─── Typed frame methods ───────────────────────────────────────────────────
|
|
768
|
+
spawn(opts) {
|
|
769
|
+
return this.rpc({ type: "spawn", reqId: "", opts });
|
|
770
|
+
}
|
|
771
|
+
write(ptyId, data) {
|
|
772
|
+
return this.rpc({ type: "write", reqId: "", ptyId, data });
|
|
773
|
+
}
|
|
774
|
+
resize(ptyId, cols, rows) {
|
|
775
|
+
return this.rpc({ type: "resize", reqId: "", ptyId, cols, rows });
|
|
776
|
+
}
|
|
777
|
+
kill(ptyId) {
|
|
778
|
+
return this.rpc({ type: "kill", reqId: "", ptyId });
|
|
779
|
+
}
|
|
780
|
+
killLaunchProcesses(opts) {
|
|
781
|
+
return this.rpc({
|
|
782
|
+
type: "killLaunchProcesses",
|
|
783
|
+
reqId: "",
|
|
784
|
+
cwd: opts.cwd,
|
|
785
|
+
commands: opts.commands,
|
|
786
|
+
ports: opts.ports,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
findByTask(taskId) {
|
|
790
|
+
return this.rpc({ type: "findByTask", reqId: "", taskId });
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* See {@link CoreLinkRequestFrame} `replay` — `sinceSeq` asks for the tail past
|
|
794
|
+
* a cursor (a reattach); omitting it asks for the whole scrollback.
|
|
795
|
+
*/
|
|
796
|
+
replay(ptyId, sinceSeq) {
|
|
797
|
+
return this.rpc({ type: "replay", reqId: "", ptyId, sinceSeq });
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Ask this Core for one PTY's byte stream (ADR 0024 D2). Until a client
|
|
801
|
+
* subscribes, {@link onData}/{@link onExit} never fire for that `ptyId` — a
|
|
802
|
+
* Core fans output out to the connections that asked and to no others.
|
|
803
|
+
*
|
|
804
|
+
* `catchUp` says a `replay` for this PTY follows and the Core must hold the
|
|
805
|
+
* live stream until it has been served, so the caller never paints live bytes
|
|
806
|
+
* in front of its own scrollback. A caller that sets it owes that replay;
|
|
807
|
+
* nothing else releases the hold.
|
|
808
|
+
*
|
|
809
|
+
* **Against a Core that does not announce `multiConnection`, this sends nothing
|
|
810
|
+
* and resolves** (ADR 0024 D11). That is the deliberate single-connection
|
|
811
|
+
* fallback, not a swallowed failure: such a Core fans every PTY out to every
|
|
812
|
+
* connection, so the caller is already receiving the bytes it just asked for,
|
|
813
|
+
* and the subscription it would send is a frame that Core has no vocabulary
|
|
814
|
+
* for. Resolving says what is true — this client renders that PTY — which is
|
|
815
|
+
* the only thing the caller acts on.
|
|
816
|
+
*/
|
|
817
|
+
ptySubscribe(ptyId, opts = {}) {
|
|
818
|
+
if (!this.canSendMultiConnectionFrames())
|
|
819
|
+
return Promise.resolve();
|
|
820
|
+
return this.rpc({
|
|
821
|
+
type: "ptySubscribe",
|
|
822
|
+
reqId: "",
|
|
823
|
+
ptyId,
|
|
824
|
+
catchUp: opts.catchUp === true,
|
|
825
|
+
}).then(() => undefined);
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Stop receiving one PTY's stream. Same fallback as {@link ptySubscribe}, for
|
|
829
|
+
* the same reason: against a capability-less Core there is no subscription to
|
|
830
|
+
* drop, because its fan-out is unconditional and cannot be narrowed by a frame
|
|
831
|
+
* it does not know.
|
|
832
|
+
*/
|
|
833
|
+
ptyUnsubscribe(ptyId) {
|
|
834
|
+
if (!this.canSendMultiConnectionFrames())
|
|
835
|
+
return Promise.resolve();
|
|
836
|
+
return this.rpc({ type: "ptyUnsubscribe", reqId: "", ptyId }).then(() => undefined);
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Take this Session's write lock (ADR 0024 D6).
|
|
840
|
+
*
|
|
841
|
+
* `granted: false` means another Core client holds it — an answer, not a
|
|
842
|
+
* failure, and the only way past it is {@link forceTakeover}. Idempotent for a
|
|
843
|
+
* client that already holds the Session.
|
|
844
|
+
*
|
|
845
|
+
* **Against a Core that does not announce `multiConnection`, this sends nothing
|
|
846
|
+
* and answers `{ supported: false, granted: false }`.** Such a Core evicts
|
|
847
|
+
* every client but one, so there is nothing for a lock to arbitrate between and
|
|
848
|
+
* no table to put a claim in.
|
|
849
|
+
*
|
|
850
|
+
* `supported: false` must never be rendered as read-only. It says this Core has
|
|
851
|
+
* no Session lock at all, so every mutation this client makes will be served —
|
|
852
|
+
* the opposite of what `granted: false` says. A caller that treats the two the
|
|
853
|
+
* same makes a single-connection Core look permanently locked to an operator
|
|
854
|
+
* who is in fact its only client.
|
|
855
|
+
*/
|
|
856
|
+
claim(taskId) {
|
|
857
|
+
if (!this.canSendMultiConnectionFrames()) {
|
|
858
|
+
return Promise.resolve({ supported: false, granted: false });
|
|
859
|
+
}
|
|
860
|
+
return this.rpc({ type: "claim", reqId: "", taskId }).then((granted) => ({
|
|
861
|
+
supported: true,
|
|
862
|
+
granted: granted === true,
|
|
863
|
+
}));
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Give this Session's write lock back (ADR 0024 D7).
|
|
867
|
+
*
|
|
868
|
+
* `released: false` means this client did not hold it — idempotent, not an
|
|
869
|
+
* error, so a caller releasing on teardown does not have to know whether it had
|
|
870
|
+
* already lost the lock to a takeover. Same capability fallback as
|
|
871
|
+
* {@link claim}: nothing goes on the wire to a Core with no lock table, and
|
|
872
|
+
* `supported: false` says there was never a lock to give back.
|
|
873
|
+
*/
|
|
874
|
+
release(taskId) {
|
|
875
|
+
if (!this.canSendMultiConnectionFrames()) {
|
|
876
|
+
return Promise.resolve({ supported: false, released: false });
|
|
877
|
+
}
|
|
878
|
+
return this.rpc({ type: "release", reqId: "", taskId }).then((released) => ({
|
|
879
|
+
supported: true,
|
|
880
|
+
released: released === true,
|
|
881
|
+
}));
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Take this Session's write lock whoever holds it (ADR 0024 D7).
|
|
885
|
+
*
|
|
886
|
+
* The answer to a hung client, and the reason the lock needs no idle timeout.
|
|
887
|
+
* Unrecoverable by design: the previous holder's in-flight keystrokes are gone
|
|
888
|
+
* and its next mutation is refused. `takenFrom` names who lost it so a caller
|
|
889
|
+
* can say so rather than infer it — taking an unheld Session is an ordinary
|
|
890
|
+
* claim by another name.
|
|
891
|
+
*
|
|
892
|
+
* Same capability fallback as {@link claim}, answering `takenFrom: "nobody"`:
|
|
893
|
+
* there was nobody to take it from, because there is no lock on such a Core to
|
|
894
|
+
* take.
|
|
895
|
+
*/
|
|
896
|
+
forceTakeover(taskId) {
|
|
897
|
+
if (!this.canSendMultiConnectionFrames()) {
|
|
898
|
+
return Promise.resolve({ supported: false, takenFrom: "nobody" });
|
|
899
|
+
}
|
|
900
|
+
return this.rpc({ type: "forceTakeover", reqId: "", taskId }).then((takenFrom) => ({
|
|
901
|
+
supported: true,
|
|
902
|
+
takenFrom: (takenFrom ?? "nobody"),
|
|
903
|
+
}));
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* List every project on this Core as a live snapshot. The Core is the source of
|
|
907
|
+
* truth; a client holds none. The returned `path` is a machine path on the
|
|
908
|
+
* Core — only the Core can validate it.
|
|
909
|
+
*/
|
|
910
|
+
projectsList() {
|
|
911
|
+
return this.rpc({ type: "projectsList", reqId: "" });
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* List every active (non-archived) task on this Core, optionally filtered to
|
|
915
|
+
* one project.
|
|
916
|
+
*
|
|
917
|
+
* `archivedCount` is how many archived rows the same scope holds — a scalar,
|
|
918
|
+
* never the rows (ADR 0019). Use {@link archivedTasksList} for those.
|
|
919
|
+
*/
|
|
920
|
+
tasksList(projectId) {
|
|
921
|
+
return this.rpc({ type: "tasksList", reqId: "", projectId });
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* List every archived task on this Core, optionally filtered to one project
|
|
925
|
+
* (ADR 0019) — a separate frame from {@link tasksList}, so an active answer
|
|
926
|
+
* stays free of archived rows by construction rather than by what a caller
|
|
927
|
+
* remembers to pass.
|
|
928
|
+
*/
|
|
929
|
+
archivedTasksList(projectId) {
|
|
930
|
+
return this.rpc({ type: "archivedTasksList", reqId: "", projectId });
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Create / rename / archive a project on this Core. The Core validates the
|
|
934
|
+
* machine path server-side; an invalid path comes back as an `error` frame that
|
|
935
|
+
* rejects this promise. Returns `null` when a `rename`/`archive` targets a
|
|
936
|
+
* missing row.
|
|
937
|
+
*/
|
|
938
|
+
projectsMutate(mutation) {
|
|
939
|
+
return this.rpc({ type: "projectsMutate", reqId: "", mutation });
|
|
940
|
+
}
|
|
941
|
+
/** Create / update / delete a task. Returns `null` when it targets a missing row. */
|
|
942
|
+
tasksMutate(mutation) {
|
|
943
|
+
return this.rpc({ type: "tasksMutate", reqId: "", mutation });
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* List every active session on this Core (optionally filtered to one project).
|
|
947
|
+
* A session's `ptyId` is set when the Core has a live PTY for that task — which
|
|
948
|
+
* is how a client knows what it can reattach to.
|
|
949
|
+
*/
|
|
950
|
+
sessionsList(projectId) {
|
|
951
|
+
return this.rpc({ type: "sessionsList", reqId: "", projectId });
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Snapshot of this Core's CLI availability. Live updates arrive as
|
|
955
|
+
* `agents:availabilityChanged` events on {@link onEvent}; this is how a client
|
|
956
|
+
* hydrates without waiting for the next probe tick.
|
|
957
|
+
*/
|
|
958
|
+
agentsAvailabilityList() {
|
|
959
|
+
return this.rpc({ type: "agentsAvailabilityList", reqId: "" });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Turn a response frame into the value the typed methods promise, or throw the
|
|
964
|
+
* failure it carries. The two failure frames (`spawnError`, `error`) become
|
|
965
|
+
* rejections here so a caller of `spawn()` sees an exception rather than a frame
|
|
966
|
+
* it has to inspect; {@link CoreClient.request} bypasses this and hands the frame
|
|
967
|
+
* over untouched.
|
|
968
|
+
*/
|
|
969
|
+
export function unwrapResponse(msg) {
|
|
970
|
+
switch (msg.type) {
|
|
971
|
+
case "spawned":
|
|
972
|
+
return { ptyId: msg.ptyId };
|
|
973
|
+
case "spawnError":
|
|
974
|
+
throw new Error(msg.message);
|
|
975
|
+
case "writeResult":
|
|
976
|
+
case "resizeResult":
|
|
977
|
+
case "killResult":
|
|
978
|
+
return msg.ok;
|
|
979
|
+
case "killLaunchProcessesResult":
|
|
980
|
+
return msg.result;
|
|
981
|
+
case "findByTaskResult":
|
|
982
|
+
return { ptyId: msg.ptyId };
|
|
983
|
+
case "replayResult":
|
|
984
|
+
return { data: msg.data, nextSeq: msg.nextSeq, from: msg.from };
|
|
985
|
+
// The active list answers with rows *and* the archived count (ADR 0019), so
|
|
986
|
+
// unwrapping to `msg.tasks` alone would drop a required field of the frame.
|
|
987
|
+
// The archived list carries rows only.
|
|
988
|
+
case "tasksListResult":
|
|
989
|
+
return { tasks: msg.tasks, archivedCount: msg.archivedCount };
|
|
990
|
+
case "archivedTasksListResult":
|
|
991
|
+
return msg.tasks;
|
|
992
|
+
case "projectsListResult":
|
|
993
|
+
return msg.projects;
|
|
994
|
+
case "tasksMutateResult":
|
|
995
|
+
return msg.task;
|
|
996
|
+
case "projectsMutateResult":
|
|
997
|
+
return msg.project;
|
|
998
|
+
case "sessionsListResult":
|
|
999
|
+
return msg.sessions;
|
|
1000
|
+
case "agentsAvailabilityListResult":
|
|
1001
|
+
return msg.availability;
|
|
1002
|
+
case "ptySubscribeAck":
|
|
1003
|
+
case "ptyUnsubscribeAck":
|
|
1004
|
+
return { ptyId: msg.ptyId, subscribed: msg.subscribed };
|
|
1005
|
+
// The Session lock's three answers unwrap to the one field each carries
|
|
1006
|
+
// beyond the taskId the caller already passed in (issue 144). A denied claim
|
|
1007
|
+
// comes back here as `false` rather than as a rejection — it is an answer,
|
|
1008
|
+
// not a failure, and only a *mutation* refused for the lock throws (that is
|
|
1009
|
+
// an `error` frame, below, carrying `session-locked`).
|
|
1010
|
+
case "claimResult":
|
|
1011
|
+
return msg.granted;
|
|
1012
|
+
case "releaseResult":
|
|
1013
|
+
return msg.released;
|
|
1014
|
+
case "forceTakeoverResult":
|
|
1015
|
+
return msg.takenFrom;
|
|
1016
|
+
// Both fields, because both are reporting a caller cannot derive (issue 146).
|
|
1017
|
+
// `taskIds` in particular is the set of Sessions whose locks came across with
|
|
1018
|
+
// this connection, and a lock register is its consumer: after a reconnect
|
|
1019
|
+
// those Sessions are held-by-you again, and nothing else on the link would
|
|
1020
|
+
// say so until the next snapshot.
|
|
1021
|
+
case "reclaimResult":
|
|
1022
|
+
return { replaced: msg.replaced, taskIds: msg.taskIds };
|
|
1023
|
+
// The code rides the rejection rather than being dropped at the boundary
|
|
1024
|
+
// (issue 144): a caller can already tell "locked" (throws) from "gone"
|
|
1025
|
+
// (resolves), and this is what lets it tell "locked" from any other error
|
|
1026
|
+
// without reading the prose the field exists to spare it.
|
|
1027
|
+
case "error":
|
|
1028
|
+
throw new CoreLinkRequestError(msg.message, msg.code);
|
|
1029
|
+
default:
|
|
1030
|
+
// `ready`, `subscribeAck`, the auth frames — not request/response answers
|
|
1031
|
+
// any typed method awaits. Resolving undefined matches the Panel's
|
|
1032
|
+
// behaviour of leaving such a frame unhandled rather than rejecting on it.
|
|
1033
|
+
return undefined;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
//# sourceMappingURL=core-client.js.map
|