dsh-computers 1.1.0 → 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/PROTOCOL.md +135 -0
- package/README.md +45 -16
- package/agent/README.md +112 -5
- package/agent/dsh-computers-agent.mjs +835 -20
- package/client.js +777 -10
- package/cordis.patch.yml +2 -0
- package/index.js +32 -2
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/hub.js +346 -140
- package/lib/routes.js +425 -38
- package/lib/state.js +11 -2
- package/lib/tools.js +684 -85
- package/lib/version.js +62 -0
- package/package.json +1 -1
package/lib/hub.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
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, §
|
|
3
|
+
* dispatcher that rides it (PROTOCOL §3.2, §3.3, §11.2, §11.6).
|
|
4
4
|
*
|
|
5
5
|
* Invariants this file keeps:
|
|
6
6
|
* - at most one stream per computer; opening a new one ends the old one and
|
|
7
7
|
* fails the old agent's in-flight executions;
|
|
8
8
|
* - `online` is the live stream and nothing else; `lastSeenAt` is display data
|
|
9
9
|
* refreshed by stream open / heartbeat / result POST;
|
|
10
|
-
* - every exec has a unique `req_…` id
|
|
11
|
-
*
|
|
12
|
-
* plugin disposing;
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
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).
|
|
15
20
|
*
|
|
16
21
|
* Node builtins only.
|
|
17
22
|
*/
|
|
@@ -21,7 +26,7 @@ import { newRequestId } from './keys.js'
|
|
|
21
26
|
/** Failure carrying a machine-readable code the tool layer maps to a message. */
|
|
22
27
|
export class HubError extends Error {
|
|
23
28
|
/**
|
|
24
|
-
* @param {string} code - `offline` | `timeout` | `aborted` | `disposed`.
|
|
29
|
+
* @param {string} code - `offline` | `timeout` | `aborted` | `invalid` | `disposed`.
|
|
25
30
|
* @param {string} message - model-facing detail.
|
|
26
31
|
*/
|
|
27
32
|
constructor(code, message) {
|
|
@@ -39,6 +44,12 @@ const SSE_HEADERS = {
|
|
|
39
44
|
'x-accel-buffering': 'no',
|
|
40
45
|
}
|
|
41
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
|
+
|
|
42
53
|
/**
|
|
43
54
|
* Trim UTF-8 text to a byte budget without cutting a character in half.
|
|
44
55
|
* @param {string} text - input text.
|
|
@@ -50,17 +61,25 @@ function capText(text, maxBytes) {
|
|
|
50
61
|
const bytes = Buffer.byteLength(text, 'utf8')
|
|
51
62
|
if (bytes <= maxBytes) return { text, truncated: false }
|
|
52
63
|
let out = Buffer.from(text, 'utf8').subarray(0, maxBytes).toString('utf8')
|
|
53
|
-
// A multi-byte character split at the boundary decodes to U+FFFD; drop only
|
|
54
|
-
// the trailing replacement characters this slice may have produced.
|
|
55
64
|
while (out.endsWith('\uFFFD')) out = out.slice(0, -1)
|
|
56
65
|
return { text: `${out}\n[output truncated]`, truncated: true }
|
|
57
66
|
}
|
|
58
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
|
+
|
|
59
78
|
/**
|
|
60
79
|
* Create the hub for one plugin mount.
|
|
61
80
|
* @param {object} options - hub options.
|
|
62
81
|
* @param {object} options.store - registry store (presence timestamps).
|
|
63
|
-
* @param {number} options.heartbeatMs -
|
|
82
|
+
* @param {number} options.heartbeatMs - ping cadence and presence window base.
|
|
64
83
|
* @param {number} options.maxOutputBytes - per-stream result cap.
|
|
65
84
|
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
66
85
|
* @returns {object} the hub.
|
|
@@ -85,11 +104,15 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
|
|
|
85
104
|
}
|
|
86
105
|
}
|
|
87
106
|
|
|
88
|
-
/** Detach one pending execution's
|
|
107
|
+
/** Detach one pending execution's timers and abort listener (idempotent). */
|
|
89
108
|
const detach = (entry) => {
|
|
90
|
-
if (entry.
|
|
91
|
-
clearTimeout(entry.
|
|
92
|
-
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
|
|
93
116
|
}
|
|
94
117
|
if (entry.signal !== undefined && entry.onAbort !== null) {
|
|
95
118
|
entry.signal.removeEventListener('abort', entry.onAbort)
|
|
@@ -97,13 +120,91 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
|
|
|
97
120
|
}
|
|
98
121
|
}
|
|
99
122
|
|
|
100
|
-
/**
|
|
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). */
|
|
101
190
|
const failEntry = (entry, error) => {
|
|
102
191
|
if (entry.settled) return
|
|
103
192
|
entry.settled = true
|
|
104
193
|
detach(entry)
|
|
105
194
|
pending.delete(entry.requestId)
|
|
106
|
-
entry.
|
|
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)
|
|
107
208
|
}
|
|
108
209
|
|
|
109
210
|
/** Reject every execution belonging to one computer (stream gone/offline). */
|
|
@@ -129,22 +230,139 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
|
|
|
129
230
|
}
|
|
130
231
|
}
|
|
131
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
|
+
|
|
132
361
|
const hub = {
|
|
133
362
|
/** Whether a computer currently holds an open SSE stream. */
|
|
134
363
|
isLive: (computerId) => streams.has(computerId),
|
|
135
364
|
|
|
136
|
-
/**
|
|
137
|
-
* Presence is the live SSE stream and nothing else.
|
|
138
|
-
*
|
|
139
|
-
* `lastSeenAt` is still refreshed by stream open / heartbeat / result POST
|
|
140
|
-
* and is shown in the UI, but a heartbeat without a stream must NOT read
|
|
141
|
-
* as online: the settings page polls every 5 s, so a machine that died
|
|
142
|
-
* while its heartbeat timestamp is still warm would show "在线" for up to
|
|
143
|
-
* 2x heartbeatMs. (PROTOCOL.md §2 still describes that older fallback; the
|
|
144
|
-
* live behaviour is stream-only and the protocol is being updated.)
|
|
145
|
-
* @param {object} computer - registry record.
|
|
146
|
-
* @returns {boolean} whether the computer holds an open stream.
|
|
147
|
-
*/
|
|
365
|
+
/** Presence used everywhere the browser/tools need "online". */
|
|
148
366
|
isOnline(computer) {
|
|
149
367
|
if (computer === null || typeof computer !== 'object') return false
|
|
150
368
|
return streams.has(computer.id)
|
|
@@ -155,14 +373,14 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
|
|
|
155
373
|
|
|
156
374
|
/**
|
|
157
375
|
* Accept a new SSE stream for a computer, replacing any previous one.
|
|
158
|
-
* The caller owns authentication and `lastSeenAt`; the hub owns framing.
|
|
159
376
|
* @param {string} computerId - authenticated computer.
|
|
160
377
|
* @param {string} name - display name for the `hello` event.
|
|
161
378
|
* @param {import('node:http').IncomingMessage} req - request (for close hooks).
|
|
162
379
|
* @param {import('node:http').ServerResponse} res - response to hold open.
|
|
380
|
+
* @param {{ capabilities?: string[] }} [handshake] - metadata to echo in `hello`.
|
|
163
381
|
* @returns {{ replaced: boolean, stream: object }} the opened stream.
|
|
164
382
|
*/
|
|
165
|
-
openStream(computerId, name, req, res) {
|
|
383
|
+
openStream(computerId, name, req, res, handshake = {}) {
|
|
166
384
|
if (disposed) throw new HubError('disposed', 'the dsh-computers host plugin is shutting down')
|
|
167
385
|
const previous = streams.get(computerId)
|
|
168
386
|
let replaced = false
|
|
@@ -174,8 +392,6 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
|
|
|
174
392
|
}
|
|
175
393
|
|
|
176
394
|
res.writeHead(200, SSE_HEADERS)
|
|
177
|
-
// Flush headers immediately: an agent must see the stream accepted
|
|
178
|
-
// before it decides the connection failed.
|
|
179
395
|
if (typeof res.flushHeaders === 'function') res.flushHeaders()
|
|
180
396
|
res.write('retry: 2000\n\n')
|
|
181
397
|
if (typeof res.socket?.setNoDelay === 'function') {
|
|
@@ -188,10 +404,17 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
|
|
|
188
404
|
|
|
189
405
|
const stream = { computerId, res, timer: null, openedAt: Date.now() }
|
|
190
406
|
streams.set(computerId, stream)
|
|
191
|
-
|
|
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
|
+
})
|
|
192
416
|
|
|
193
417
|
const onClose = () => {
|
|
194
|
-
// Only the CURRENT stream owns the computer's liveness.
|
|
195
418
|
if (streams.get(computerId) !== stream) return
|
|
196
419
|
retire(computerId, stream, { end: false })
|
|
197
420
|
failComputerExecs(computerId, new HubError('offline', 'the connection to the computer was lost'))
|
|
@@ -199,15 +422,12 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
|
|
|
199
422
|
res.on('close', onClose)
|
|
200
423
|
res.on('error', onClose)
|
|
201
424
|
req.on('close', () => {
|
|
202
|
-
// The agent may close the request side without the response side
|
|
203
|
-
// reporting it promptly on every platform.
|
|
204
425
|
if (res.writableEnded || res.destroyed) onClose()
|
|
205
426
|
})
|
|
206
427
|
|
|
207
428
|
stream.timer = setInterval(() => {
|
|
208
429
|
if (!writeFrame(stream, 'ping', { t: Date.now() })) onClose()
|
|
209
430
|
}, heartbeatMs)
|
|
210
|
-
// Pings must not keep a shutting-down process alive.
|
|
211
431
|
if (typeof stream.timer.unref === 'function') stream.timer.unref()
|
|
212
432
|
|
|
213
433
|
return { replaced, stream }
|
|
@@ -215,9 +435,6 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
|
|
|
215
435
|
|
|
216
436
|
/**
|
|
217
437
|
* Push one event to a computer's live stream.
|
|
218
|
-
* @param {string} computerId - target computer.
|
|
219
|
-
* @param {string} event - SSE event name.
|
|
220
|
-
* @param {unknown} data - JSON payload.
|
|
221
438
|
* @returns {boolean} false when the computer has no live stream.
|
|
222
439
|
*/
|
|
223
440
|
send(computerId, event, data) {
|
|
@@ -226,15 +443,10 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
|
|
|
226
443
|
return writeFrame(stream, event, data)
|
|
227
444
|
},
|
|
228
445
|
|
|
229
|
-
/**
|
|
230
|
-
* End a computer's live stream and fail its in-flight executions.
|
|
231
|
-
* @param {string} computerId - target computer.
|
|
232
|
-
* @returns {boolean} whether a stream was open.
|
|
233
|
-
*/
|
|
446
|
+
/** End a computer's live stream and fail its in-flight executions. */
|
|
234
447
|
closeStream(computerId) {
|
|
235
448
|
const stream = streams.get(computerId)
|
|
236
449
|
if (stream === undefined) {
|
|
237
|
-
// Still fail executions: the HTTP side may already be gone.
|
|
238
450
|
failComputerExecs(computerId, new HubError('offline', 'the computer was disconnected'))
|
|
239
451
|
return false
|
|
240
452
|
}
|
|
@@ -244,86 +456,103 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
|
|
|
244
456
|
},
|
|
245
457
|
|
|
246
458
|
/**
|
|
247
|
-
* Dispatch one command through the
|
|
459
|
+
* Dispatch one foreground command (v1 shape) through the live stream.
|
|
248
460
|
* @param {object} request - exec request.
|
|
249
|
-
* @
|
|
250
|
-
* @param {string} request.name - display name used in error messages.
|
|
251
|
-
* @param {string} request.command - shell command.
|
|
252
|
-
* @param {string | null} [request.cwd] - working directory or null.
|
|
253
|
-
* @param {number} request.timeoutMs - settlement deadline.
|
|
254
|
-
* @param {string} [request.shell] - shell hint for the agent.
|
|
255
|
-
* @param {AbortSignal | undefined} [request.signal] - caller cancellation.
|
|
256
|
-
* @returns {Promise<object>} normalized result when the agent answers.
|
|
461
|
+
* @returns {Promise<object>} normalized result, or `{ promoted:true, jobId }`.
|
|
257
462
|
*/
|
|
258
|
-
async dispatch({ computerId, name, command, cwd = null, timeoutMs, shell = 'sh', signal }) {
|
|
259
|
-
const label = typeof name === 'string' && name !== '' ? name : computerId
|
|
260
|
-
if (disposed) throw new HubError('disposed', 'the dsh-computers host plugin is shutting down')
|
|
261
|
-
if (!streams.has(computerId)) {
|
|
262
|
-
throw new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`)
|
|
263
|
-
}
|
|
264
|
-
if (typeof command !== 'string' || command.trim() === '') {
|
|
265
|
-
throw new HubError('invalid', 'command must be a non-empty string')
|
|
266
|
-
}
|
|
267
|
-
const stream = streams.get(computerId)
|
|
268
|
-
if (stream === undefined) {
|
|
269
|
-
throw new HubError('offline', `computer '${label}' is offline; run computer_list to see what is connected`)
|
|
270
|
-
}
|
|
271
|
-
const requestId = newRequestId()
|
|
463
|
+
async dispatch({ computerId, name, command, cwd = null, timeoutMs, agentTimeoutMs = null, shell = 'sh', signal, stream = true, onTimeout = null }) {
|
|
272
464
|
return await new Promise((resolve, reject) => {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
settled: false,
|
|
282
|
-
resolve,
|
|
283
|
-
reject,
|
|
284
|
-
}
|
|
285
|
-
pending.set(requestId, entry)
|
|
286
|
-
|
|
287
|
-
// NOT unref'd: an in-flight execution must settle even if it is the
|
|
288
|
-
// only work left in the process.
|
|
289
|
-
entry.timer = setTimeout(() => {
|
|
290
|
-
// Tell the agent to kill the process group before settling, so a
|
|
291
|
-
// timed-out command does not keep eating the user's computer.
|
|
292
|
-
hub.send(computerId, 'cancel', { requestId })
|
|
293
|
-
failEntry(entry, new HubError('timeout',
|
|
294
|
-
`computer '${label}' did not answer within ${timeoutMs}ms (the command was cancelled)`))
|
|
295
|
-
}, timeoutMs)
|
|
296
|
-
|
|
297
|
-
if (signal !== undefined) {
|
|
298
|
-
const abort = () => {
|
|
299
|
-
hub.send(computerId, 'cancel', { requestId })
|
|
300
|
-
failEntry(entry, new HubError('aborted', `computer_exec was aborted while running on '${label}'`))
|
|
301
|
-
}
|
|
302
|
-
if (signal.aborted) {
|
|
303
|
-
abort()
|
|
304
|
-
return
|
|
305
|
-
}
|
|
306
|
-
entry.onAbort = abort
|
|
307
|
-
signal.addEventListener('abort', abort, { once: true })
|
|
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)
|
|
308
473
|
}
|
|
474
|
+
})
|
|
475
|
+
},
|
|
309
476
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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) : '',
|
|
316
537
|
})
|
|
317
|
-
|
|
318
|
-
|
|
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)
|
|
319
548
|
}
|
|
320
|
-
|
|
549
|
+
entry.expectedSeq += 1
|
|
550
|
+
}
|
|
551
|
+
return true
|
|
321
552
|
},
|
|
322
553
|
|
|
323
554
|
/**
|
|
324
|
-
* Settle one pending execution from the agent's result POST.
|
|
325
|
-
* @param {string} computerId - authenticated computer.
|
|
326
|
-
* @param {unknown} payload - result body from the agent.
|
|
555
|
+
* Settle one pending execution from the agent's final result POST.
|
|
327
556
|
* @returns {boolean} false when the id is unknown/settled or owned elsewhere.
|
|
328
557
|
*/
|
|
329
558
|
settle(computerId, payload) {
|
|
@@ -331,30 +560,7 @@ export function createHub({ store, heartbeatMs, maxOutputBytes, onWarn = () => {
|
|
|
331
560
|
if (typeof requestId !== 'string') return false
|
|
332
561
|
const entry = pending.get(requestId)
|
|
333
562
|
if (entry === undefined || entry.computerId !== computerId) return false
|
|
334
|
-
entry
|
|
335
|
-
detach(entry)
|
|
336
|
-
pending.delete(requestId)
|
|
337
|
-
|
|
338
|
-
const stdout = capText(typeof payload.stdout === 'string' ? payload.stdout : '', maxOutputBytes)
|
|
339
|
-
const stderr = capText(typeof payload.stderr === 'string' ? payload.stderr : '', maxOutputBytes)
|
|
340
|
-
const exitCode = typeof payload.exitCode === 'number' && Number.isFinite(payload.exitCode)
|
|
341
|
-
? Math.trunc(payload.exitCode)
|
|
342
|
-
: null
|
|
343
|
-
const duration = typeof payload.durationMs === 'number' && Number.isFinite(payload.durationMs) && payload.durationMs >= 0
|
|
344
|
-
? Math.trunc(payload.durationMs)
|
|
345
|
-
: Date.now() - entry.startedAt
|
|
346
|
-
entry.resolve({
|
|
347
|
-
requestId,
|
|
348
|
-
ok: payload.ok === true,
|
|
349
|
-
exitCode,
|
|
350
|
-
signal: typeof payload.signal === 'string' ? payload.signal : null,
|
|
351
|
-
stdout: stdout.text,
|
|
352
|
-
stderr: stderr.text,
|
|
353
|
-
durationMs: duration,
|
|
354
|
-
truncated: payload.truncated === true || stdout.truncated || stderr.truncated,
|
|
355
|
-
timedOut: payload.timedOut === true,
|
|
356
|
-
error: typeof payload.error === 'string' ? payload.error : null,
|
|
357
|
-
})
|
|
563
|
+
finish(entry, normalizeResult(entry, payload))
|
|
358
564
|
return true
|
|
359
565
|
},
|
|
360
566
|
|