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/LICENSE +21 -0
- package/PROTOCOL.md +694 -0
- package/README.md +176 -2
- package/agent/README.md +383 -0
- package/agent/dsh-computers-agent.mjs +2808 -0
- package/client.js +3986 -0
- package/cordis.patch.yml +25 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +241 -0
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +587 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +1084 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +190 -0
- package/lib/tools.js +993 -0
- package/lib/version.js +62 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
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
|
+
}
|