dsh-computers 0.0.0-stage → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/hub.js ADDED
@@ -0,0 +1,587 @@
1
+ /**
2
+ * The live side of dsh-computers: one SSE stream per computer and the exec
3
+ * dispatcher that rides it (PROTOCOL §3.2, §3.3, §11.2, §11.6).
4
+ *
5
+ * Invariants this file keeps:
6
+ * - at most one stream per computer; opening a new one ends the old one and
7
+ * fails the old agent's in-flight executions;
8
+ * - `online` is the live stream and nothing else; `lastSeenAt` is display data
9
+ * refreshed by stream open / heartbeat / result POST;
10
+ * - every exec has a unique `req_…` id and settles exactly once — a result
11
+ * POST, the silence timeout, the abort signal, a job cancel, the stream
12
+ * closing or the plugin disposing;
13
+ * - streaming frames are deduped/reassembled by `seq` and a `heartbeat` frame
14
+ * resets the execution's silence timer (it carries no output);
15
+ * - a foreground timeout can be *adopted* by a job instead of killing the
16
+ * command (`promoteOnTimeout`), after which chunks keep flowing into the job
17
+ * ring and the model controls it with job_output/job_kill;
18
+ * - a late result for an already settled id is not accepted (the route
19
+ * answers 404).
20
+ *
21
+ * Node builtins only.
22
+ */
23
+
24
+ import { newRequestId } from './keys.js'
25
+
26
+ /** Failure carrying a machine-readable code the tool layer maps to a message. */
27
+ export class HubError extends Error {
28
+ /**
29
+ * @param {string} code - `offline` | `timeout` | `aborted` | `invalid` | `disposed`.
30
+ * @param {string} message - model-facing detail.
31
+ */
32
+ constructor(code, message) {
33
+ super(message)
34
+ this.name = 'HubError'
35
+ this.code = code
36
+ }
37
+ }
38
+
39
+ /** SSE response headers required by the protocol. */
40
+ const SSE_HEADERS = {
41
+ 'content-type': 'text/event-stream; charset=utf-8',
42
+ 'cache-control': 'no-store',
43
+ connection: 'keep-alive',
44
+ 'x-accel-buffering': 'no',
45
+ }
46
+
47
+ /** Host features advertised in `hello` (PROTOCOL §11.1/§11.5). */
48
+ export const HOST_FEATURES = Object.freeze(['stream', 'fs'])
49
+
50
+ /** How long a killed execution may take to report its final result before the host settles it locally. */
51
+ const KILL_SETTLE_MS = 5_000
52
+
53
+ /**
54
+ * Trim UTF-8 text to a byte budget without cutting a character in half.
55
+ * @param {string} text - input text.
56
+ * @param {number} maxBytes - inclusive byte budget.
57
+ * @returns {{ text: string, truncated: boolean }} trimmed text and whether it was cut.
58
+ */
59
+ function capText(text, maxBytes) {
60
+ if (typeof text !== 'string') return { text: '', truncated: false }
61
+ const bytes = Buffer.byteLength(text, 'utf8')
62
+ if (bytes <= maxBytes) return { text, truncated: false }
63
+ let out = Buffer.from(text, 'utf8').subarray(0, maxBytes).toString('utf8')
64
+ while (out.endsWith('\uFFFD')) out = out.slice(0, -1)
65
+ return { text: `${out}\n[output truncated]`, truncated: true }
66
+ }
67
+
68
+ /** Decode one base64 frame payload; empty/absent decodes to ''. */
69
+ function decodeFrame(data) {
70
+ if (typeof data !== 'string' || data === '') return ''
71
+ try {
72
+ return Buffer.from(data, 'base64').toString('utf8')
73
+ } catch {
74
+ return ''
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Create the hub for one plugin mount.
80
+ * @param {object} options - hub options.
81
+ * @param {object} options.store - registry store (presence timestamps).
82
+ * @param {number} options.heartbeatMs - ping cadence and presence window base.
83
+ * @param {number} options.maxOutputBytes - per-stream result cap.
84
+ * @param {(message: string) => void} [options.onWarn] - warning sink.
85
+ * @returns {object} the hub.
86
+ */
87
+ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {} }) {
88
+ /** @type {Map<string, { res: import('node:http').ServerResponse, timer: ReturnType<typeof setInterval>, openedAt: number }>} */
89
+ const streams = new Map()
90
+ /** @type {Map<string, object>} */
91
+ const pending = new Map()
92
+ let disposed = false
93
+
94
+ /** Write one SSE frame; false when the stream is gone. */
95
+ const writeFrame = (stream, event, data) => {
96
+ const res = stream.res
97
+ if (res.writableEnded || res.destroyed) return false
98
+ try {
99
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
100
+ return true
101
+ } catch (error) {
102
+ onWarn(`dsh-computers: SSE write failed: ${error && error.message ? error.message : error}`)
103
+ return false
104
+ }
105
+ }
106
+
107
+ /** Detach one pending execution's timers and abort listener (idempotent). */
108
+ const detach = (entry) => {
109
+ if (entry.silenceTimer !== null) {
110
+ clearTimeout(entry.silenceTimer)
111
+ entry.silenceTimer = null
112
+ }
113
+ if (entry.graceTimer !== null) {
114
+ clearTimeout(entry.graceTimer)
115
+ entry.graceTimer = null
116
+ }
117
+ if (entry.signal !== undefined && entry.onAbort !== null) {
118
+ entry.signal.removeEventListener('abort', entry.onAbort)
119
+ entry.onAbort = null
120
+ }
121
+ }
122
+
123
+ /** Normalize one final result payload into the tool-facing shape. */
124
+ const normalizeResult = (entry, payload) => {
125
+ const stdout = capText(typeof payload.stdout === 'string' ? payload.stdout : entry.buffered.stdout, maxOutputBytes)
126
+ const stderr = capText(typeof payload.stderr === 'string' ? payload.stderr : entry.buffered.stderr, maxOutputBytes)
127
+ const exitCode = typeof payload.exitCode === 'number' && Number.isFinite(payload.exitCode)
128
+ ? Math.trunc(payload.exitCode)
129
+ : null
130
+ const duration = typeof payload.durationMs === 'number' && Number.isFinite(payload.durationMs) && payload.durationMs >= 0
131
+ ? Math.trunc(payload.durationMs)
132
+ : Date.now() - entry.startedAt
133
+ return {
134
+ requestId: entry.requestId,
135
+ ok: payload.ok === true,
136
+ exitCode,
137
+ signal: typeof payload.signal === 'string' ? payload.signal : null,
138
+ stdout: stdout.text,
139
+ stderr: stderr.text,
140
+ durationMs: duration,
141
+ truncated: payload.truncated === true || stdout.truncated || stderr.truncated,
142
+ timedOut: payload.timedOut === true,
143
+ error: typeof payload.error === 'string' ? payload.error : null,
144
+ killed: entry.killed === true,
145
+ }
146
+ }
147
+
148
+ /** Locally settle a killed execution that never reported a final result. */
149
+ const settleKilled = (entry) => {
150
+ if (entry.settled) return
151
+ const result = {
152
+ requestId: entry.requestId,
153
+ ok: false,
154
+ exitCode: null,
155
+ signal: 'SIGKILL',
156
+ stdout: entry.buffered.stdout,
157
+ stderr: entry.buffered.stderr,
158
+ durationMs: Date.now() - entry.startedAt,
159
+ truncated: false,
160
+ timedOut: false,
161
+ error: 'cancelled',
162
+ killed: true,
163
+ }
164
+ finish(entry, result)
165
+ }
166
+
167
+ /** Resolve/reject the entry exactly once, feeding the job ring when present. */
168
+ const finish = (entry, result) => {
169
+ if (entry.settled) return
170
+ entry.settled = true
171
+ detach(entry)
172
+ pending.delete(entry.requestId)
173
+ if (entry.job !== null) {
174
+ try {
175
+ entry.job.settle(result)
176
+ } catch (error) {
177
+ onWarn(`dsh-computers: job settle failed: ${error && error.message ? error.message : error}`)
178
+ }
179
+ }
180
+ if (entry.background === true) {
181
+ entry.resultResolve(result)
182
+ } else if (entry.promoted === true) {
183
+ // The foreground tool call already returned {jobId, promoted:true}.
184
+ } else {
185
+ entry.resolve(result)
186
+ }
187
+ }
188
+
189
+ /** Reject one entry exactly once (v1 failure paths). */
190
+ const failEntry = (entry, error) => {
191
+ if (entry.settled) return
192
+ entry.settled = true
193
+ detach(entry)
194
+ pending.delete(entry.requestId)
195
+ if (entry.job !== null) {
196
+ try {
197
+ entry.job.failed(error)
198
+ } catch {
199
+ // Already settled by the job registry.
200
+ }
201
+ }
202
+ if (entry.background === true) entry.resultResolve({
203
+ requestId: entry.requestId, ok: false, exitCode: null, signal: null, stdout: entry.buffered.stdout,
204
+ stderr: entry.buffered.stderr, durationMs: Date.now() - entry.startedAt, truncated: false,
205
+ timedOut: false, error: error.message, killed: error.code === 'aborted',
206
+ })
207
+ else if (entry.promoted !== true) entry.reject(error)
208
+ }
209
+
210
+ /** Reject every execution belonging to one computer (stream gone/offline). */
211
+ const failComputerExecs = (computerId, error) => {
212
+ for (const entry of [...pending.values()]) {
213
+ if (entry.computerId === computerId) failEntry(entry, error)
214
+ }
215
+ }
216
+
217
+ /** Stop a stream without letting its close event touch a replacement. */
218
+ const retire = (computerId, stream, { end }) => {
219
+ if (streams.get(computerId) === stream) streams.delete(computerId)
220
+ if (stream.timer !== null) {
221
+ clearInterval(stream.timer)
222
+ stream.timer = null
223
+ }
224
+ if (end) {
225
+ try {
226
+ stream.res.end()
227
+ } catch {
228
+ // Already closed by the peer.
229
+ }
230
+ }
231
+ }
232
+
233
+ /** (Re)arm the silence timeout. Streaming heartbeats/chunks push it forward. */
234
+ const armSilenceTimer = (entry) => {
235
+ if (entry.timeoutMs <= 0) return
236
+ if (entry.silenceTimer !== null) clearTimeout(entry.silenceTimer)
237
+ entry.silenceTimer = setTimeout(() => {
238
+ entry.silenceTimer = null
239
+ if (entry.settled) return
240
+ // §11.6.3: adopt the running execution into a background job instead of
241
+ // killing it, when the tool layer offered a promotion adapter.
242
+ const adopt = typeof entry.onTimeout === 'function' ? entry.onTimeout(entry) : null
243
+ if (adopt !== null && adopt !== undefined) {
244
+ entry.job = adopt
245
+ entry.promoted = true
246
+ entry.resolve({ promoted: true, jobId: adopt.jobId, requestId: entry.requestId, computer: entry.computer, command: entry.command })
247
+ return
248
+ }
249
+ hub.send(entry.computerId, 'cancel', { requestId: entry.requestId })
250
+ failEntry(entry, new HubError('timeout',
251
+ `computer '${entry.label}' did not answer within ${entry.timeoutMs}ms (the command was cancelled)`))
252
+ }, entry.timeoutMs)
253
+ if (typeof entry.silenceTimer.unref === 'function') entry.silenceTimer.unref()
254
+ }
255
+
256
+ /** Append decoded incremental output to the execution tail and the job ring. */
257
+ const appendChunk = (entry, channel, text) => {
258
+ if (text === '') return
259
+ const budget = maxOutputBytes
260
+ const key = channel === 'stderr' ? 'stderr' : 'stdout'
261
+ if (entry.bufferedBytes[key] < budget) {
262
+ const room = budget - entry.bufferedBytes[key]
263
+ const piece = capText(text, room)
264
+ entry.buffered[key] += piece.text
265
+ entry.bufferedBytes[key] += Buffer.byteLength(piece.text, 'utf8')
266
+ }
267
+ if (entry.job !== null) {
268
+ try {
269
+ entry.job.append(key, text)
270
+ } catch {
271
+ // The ring rejects writes after settlement; nothing else to do.
272
+ }
273
+ }
274
+ }
275
+
276
+ /** Create one pending execution and send its `exec` event. */
277
+ const beginExecution = ({
278
+ computerId, name, command, cwd = null, timeoutMs, agentTimeoutMs = null, shell = 'sh', signal,
279
+ background = false, stream = true, job = null, onTimeout = null,
280
+ resolve = () => {}, reject = () => {},
281
+ }) => {
282
+ const label = typeof name === 'string' && name !== '' ? name : computerId
283
+ if (disposed) throw new HubError('disposed', 'the dsh-computers host plugin is shutting down')
284
+ if (!streams.has(computerId)) {
285
+ throw new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`)
286
+ }
287
+ if (typeof command !== 'string' || command.trim() === '') {
288
+ throw new HubError('invalid', 'command must be a non-empty string')
289
+ }
290
+ const streamRef = streams.get(computerId)
291
+ if (streamRef === undefined) {
292
+ throw new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`)
293
+ }
294
+ const requestId = newRequestId()
295
+ let resultResolve = () => {}
296
+ const entry = {
297
+ requestId,
298
+ computerId,
299
+ label,
300
+ command,
301
+ computer: null,
302
+ startedAt: Date.now(),
303
+ signal,
304
+ onAbort: null,
305
+ silenceTimer: null,
306
+ graceTimer: null,
307
+ settled: false,
308
+ killed: false,
309
+ promoted: false,
310
+ background,
311
+ stream,
312
+ job,
313
+ onTimeout,
314
+ timeoutMs,
315
+ expectedSeq: 0,
316
+ chunkMap: new Map(),
317
+ buffered: { stdout: '', stderr: '' },
318
+ bufferedBytes: { stdout: 0, stderr: 0 },
319
+ resultPromise: null,
320
+ resultResolve: () => {},
321
+ resolve,
322
+ reject,
323
+ }
324
+ entry.resultPromise = new Promise((resolve) => {
325
+ resultResolve = resolve
326
+ entry.resultResolve = resolve
327
+ })
328
+ entry.resultResolve = resultResolve
329
+ pending.set(requestId, entry)
330
+ armSilenceTimer(entry)
331
+
332
+ if (signal !== undefined) {
333
+ const abort = () => {
334
+ hub.send(computerId, 'cancel', { requestId })
335
+ failEntry(entry, new HubError('aborted', `computer_exec was aborted while running on '${label}'`))
336
+ }
337
+ if (signal.aborted) {
338
+ abort()
339
+ return { requestId, result: entry.resultPromise, entry }
340
+ }
341
+ entry.onAbort = abort
342
+ signal.addEventListener('abort', abort, { once: true })
343
+ }
344
+
345
+ const written = writeFrame(streamRef, 'exec', {
346
+ requestId,
347
+ command,
348
+ cwd: cwd ?? null,
349
+ // The HOST owns the foreground timeout (§11.6.3 promotes instead of
350
+ // killing), so the agent receives its own generous ceiling.
351
+ timeoutMs: agentTimeoutMs ?? timeoutMs,
352
+ shell,
353
+ stream,
354
+ })
355
+ if (!written) {
356
+ failEntry(entry, new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`))
357
+ }
358
+ return { requestId, result: entry.resultPromise, entry }
359
+ }
360
+
361
+ const hub = {
362
+ /** Whether a computer currently holds an open SSE stream. */
363
+ isLive: (computerId) => streams.has(computerId),
364
+
365
+ /** Presence used everywhere the browser/tools need "online". */
366
+ isOnline(computer) {
367
+ if (computer === null || typeof computer !== 'object') return false
368
+ return streams.has(computer.id)
369
+ },
370
+
371
+ /** How many computers are online right now. */
372
+ onlineCount: () => store.read().computers.filter((computer) => hub.isOnline(computer)).length,
373
+
374
+ /**
375
+ * Accept a new SSE stream for a computer, replacing any previous one.
376
+ * @param {string} computerId - authenticated computer.
377
+ * @param {string} name - display name for the `hello` event.
378
+ * @param {import('node:http').IncomingMessage} req - request (for close hooks).
379
+ * @param {import('node:http').ServerResponse} res - response to hold open.
380
+ * @param {{ capabilities?: string[] }} [handshake] - metadata to echo in `hello`.
381
+ * @returns {{ replaced: boolean, stream: object }} the opened stream.
382
+ */
383
+ openStream(computerId, name, req, res, handshake = {}) {
384
+ if (disposed) throw new HubError('disposed', 'the dsh-computers host plugin is shutting down')
385
+ const previous = streams.get(computerId)
386
+ let replaced = false
387
+ if (previous !== undefined) {
388
+ replaced = true
389
+ retire(computerId, previous, { end: true })
390
+ failComputerExecs(computerId, new HubError('offline',
391
+ 'the computer reconnected and replaced its previous session'))
392
+ }
393
+
394
+ res.writeHead(200, SSE_HEADERS)
395
+ if (typeof res.flushHeaders === 'function') res.flushHeaders()
396
+ res.write('retry: 2000\n\n')
397
+ if (typeof res.socket?.setNoDelay === 'function') {
398
+ try {
399
+ res.socket.setNoDelay(true)
400
+ } catch {
401
+ // Socket already gone; the close handler will clean up.
402
+ }
403
+ }
404
+
405
+ const stream = { computerId, res, timer: null, openedAt: Date.now() }
406
+ streams.set(computerId, stream)
407
+ const capabilities = Array.isArray(handshake.capabilities) ? handshake.capabilities : ['exec']
408
+ writeFrame(stream, 'hello', {
409
+ computerId,
410
+ name,
411
+ heartbeatMs,
412
+ capabilities,
413
+ // §11.1/§11.5: the agent only uses v2 frames when the host advertises them.
414
+ features: [...HOST_FEATURES],
415
+ })
416
+
417
+ const onClose = () => {
418
+ if (streams.get(computerId) !== stream) return
419
+ retire(computerId, stream, { end: false })
420
+ failComputerExecs(computerId, new HubError('offline', 'the connection to the computer was lost'))
421
+ }
422
+ res.on('close', onClose)
423
+ res.on('error', onClose)
424
+ req.on('close', () => {
425
+ if (res.writableEnded || res.destroyed) onClose()
426
+ })
427
+
428
+ stream.timer = setInterval(() => {
429
+ if (!writeFrame(stream, 'ping', { t: Date.now() })) onClose()
430
+ }, heartbeatMs)
431
+ if (typeof stream.timer.unref === 'function') stream.timer.unref()
432
+
433
+ return { replaced, stream }
434
+ },
435
+
436
+ /**
437
+ * Push one event to a computer's live stream.
438
+ * @returns {boolean} false when the computer has no live stream.
439
+ */
440
+ send(computerId, event, data) {
441
+ const stream = streams.get(computerId)
442
+ if (stream === undefined) return false
443
+ return writeFrame(stream, event, data)
444
+ },
445
+
446
+ /** End a computer's live stream and fail its in-flight executions. */
447
+ closeStream(computerId) {
448
+ const stream = streams.get(computerId)
449
+ if (stream === undefined) {
450
+ failComputerExecs(computerId, new HubError('offline', 'the computer was disconnected'))
451
+ return false
452
+ }
453
+ retire(computerId, stream, { end: true })
454
+ failComputerExecs(computerId, new HubError('offline', 'the computer was disconnected'))
455
+ return true
456
+ },
457
+
458
+ /**
459
+ * Dispatch one foreground command (v1 shape) through the live stream.
460
+ * @param {object} request - exec request.
461
+ * @returns {Promise<object>} normalized result, or `{ promoted:true, jobId }`.
462
+ */
463
+ async dispatch({ computerId, name, command, cwd = null, timeoutMs, agentTimeoutMs = null, shell = 'sh', signal, stream = true, onTimeout = null }) {
464
+ return await new Promise((resolve, reject) => {
465
+ // Promise executor runs synchronously; beginExecution throws are captured.
466
+ try {
467
+ beginExecution({
468
+ computerId, name, command, cwd, timeoutMs, agentTimeoutMs, shell, signal,
469
+ background: false, stream, onTimeout, resolve, reject,
470
+ })
471
+ } catch (error) {
472
+ reject(error)
473
+ }
474
+ })
475
+ },
476
+
477
+ /**
478
+ * Start one background execution immediately (job path, §11.2).
479
+ * @returns {{ requestId: string, result: Promise<object> }} the execution.
480
+ */
481
+ startExecution({ computerId, name, command, cwd = null, timeoutMs = 0, agentTimeoutMs = null, shell = 'sh', job = null }) {
482
+ const handle = beginExecution({
483
+ computerId, name, command, cwd, timeoutMs, agentTimeoutMs, shell,
484
+ background: true, stream: true, job, onTimeout: null,
485
+ })
486
+ return { requestId: handle.requestId, result: handle.result }
487
+ },
488
+
489
+ /**
490
+ * Request cancellation of a running execution and settle it locally when
491
+ * the agent does not report a final result in time.
492
+ */
493
+ cancelExecution(computerId, requestId, { settleAfterMs = KILL_SETTLE_MS } = {}) {
494
+ const entry = pending.get(requestId)
495
+ if (entry === undefined || entry.computerId !== computerId) return false
496
+ entry.killed = true
497
+ hub.send(computerId, 'cancel', { requestId })
498
+ if (entry.graceTimer !== null) clearTimeout(entry.graceTimer)
499
+ entry.graceTimer = setTimeout(() => {
500
+ entry.graceTimer = null
501
+ if (!entry.settled) settleKilled(entry)
502
+ }, settleAfterMs)
503
+ if (typeof entry.graceTimer.unref === 'function') entry.graceTimer.unref()
504
+ return true
505
+ },
506
+
507
+ /**
508
+ * Apply one streaming frame (§11.2/§11.6.2):
509
+ * `{type:'stdout'|'stderr'|'heartbeat', data(base64), seq}` or the v1.3
510
+ * `{stdout, stderr}` pair. Out-of-order frames are reassembled by seq;
511
+ * duplicates are dropped; heartbeats only reset the silence timer.
512
+ * @returns {boolean} false when the id is unknown/settled or owned elsewhere.
513
+ */
514
+ settleChunk(computerId, payload) {
515
+ const requestId = payload !== null && typeof payload === 'object' ? payload.requestId : undefined
516
+ if (typeof requestId !== 'string') return false
517
+ const entry = pending.get(requestId)
518
+ if (entry === undefined || entry.computerId !== computerId) return false
519
+ const frameType = typeof payload.type === 'string' ? payload.type : ''
520
+ if (frameType === 'heartbeat') {
521
+ armSilenceTimer(entry)
522
+ return true
523
+ }
524
+ const seq = Number.isSafeInteger(payload.seq) && payload.seq >= 0 ? payload.seq : null
525
+ if (seq === null) return false
526
+ if (seq < entry.expectedSeq) return true // duplicate / already assembled
527
+ // A streaming frame revives the execution: heartbeats reset it too, and
528
+ // any real output proves the agent is alive.
529
+ armSilenceTimer(entry)
530
+ if (frameType === 'stdout' || frameType === 'stderr') {
531
+ entry.chunkMap.set(seq, { channel: frameType, text: decodeFrame(payload.data) })
532
+ } else if (payload.stdout !== undefined || payload.stderr !== undefined) {
533
+ // v1.3 chunk shape: one seq carrying both streams.
534
+ entry.chunkMap.set(seq, {
535
+ stdout: typeof payload.stdout === 'string' ? decodeFrame(payload.stdout) : '',
536
+ stderr: typeof payload.stderr === 'string' ? decodeFrame(payload.stderr) : '',
537
+ })
538
+ } else {
539
+ return true
540
+ }
541
+ while (entry.chunkMap.has(entry.expectedSeq)) {
542
+ const frame = entry.chunkMap.get(entry.expectedSeq)
543
+ entry.chunkMap.delete(entry.expectedSeq)
544
+ if (frame.channel !== undefined) appendChunk(entry, frame.channel, frame.text)
545
+ else {
546
+ appendChunk(entry, 'stdout', frame.stdout)
547
+ appendChunk(entry, 'stderr', frame.stderr)
548
+ }
549
+ entry.expectedSeq += 1
550
+ }
551
+ return true
552
+ },
553
+
554
+ /**
555
+ * Settle one pending execution from the agent's final result POST.
556
+ * @returns {boolean} false when the id is unknown/settled or owned elsewhere.
557
+ */
558
+ settle(computerId, payload) {
559
+ const requestId = payload !== null && typeof payload === 'object' ? payload.requestId : undefined
560
+ if (typeof requestId !== 'string') return false
561
+ const entry = pending.get(requestId)
562
+ if (entry === undefined || entry.computerId !== computerId) return false
563
+ finish(entry, normalizeResult(entry, payload))
564
+ return true
565
+ },
566
+
567
+ /** Pending execution count (diagnostics + smoke assertions). */
568
+ pendingCount: (computerId) => [...pending.values()].filter((entry) => computerId === undefined || entry.computerId === computerId).length,
569
+
570
+ /** Fail one computer's executions (delete/disable paths). */
571
+ failComputerExecs,
572
+
573
+ /** End every stream and reject every pending execution (plugin disposal). */
574
+ dispose() {
575
+ disposed = true
576
+ for (const computerId of [...streams.keys()]) {
577
+ const stream = streams.get(computerId)
578
+ if (stream !== undefined) retire(computerId, stream, { end: true })
579
+ }
580
+ for (const entry of [...pending.values()]) {
581
+ failEntry(entry, new HubError('disposed', 'the dsh-computers host plugin shut down'))
582
+ }
583
+ },
584
+ }
585
+
586
+ return hub
587
+ }