@hope_phenom/dsh-plugin-remote-use 0.0.0-stage → 0.2.24
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 +207 -2
- package/README.zh.md +195 -0
- package/assets/icon.svg +47 -0
- package/cordis.patch.yml +41 -0
- package/lib/bridge/acp-bridge.js +905 -0
- package/lib/bridge/peer-connection.js +730 -0
- package/lib/client.js +2842 -0
- package/lib/core/acp-model-overlay.js +121 -0
- package/lib/core/approval-queue.js +216 -0
- package/lib/core/audit-log.js +322 -0
- package/lib/core/desktop-notify.js +173 -0
- package/lib/core/environment-prompt.js +76 -0
- package/lib/core/relay-supervisor.js +278 -0
- package/lib/core/review-engine.js +679 -0
- package/lib/core/sandbox-diagnosis.js +258 -0
- package/lib/core/sandbox-probe.js +310 -0
- package/lib/core/self-acp-spec.js +117 -0
- package/lib/core/selfcheck-runner.js +150 -0
- package/lib/core/settings-store.js +226 -0
- package/lib/core/shot-gc.js +334 -0
- package/lib/core/task-cwd.js +50 -0
- package/lib/core/task-memory.js +163 -0
- package/lib/core/task-registry.js +238 -0
- package/lib/core/toolcall-cache.js +206 -0
- package/lib/core/transfer-queue.js +58 -0
- package/lib/core/transfer.js +1144 -0
- package/lib/core/worker-arming.js +334 -0
- package/lib/index.js +2207 -0
- package/lib/provider/out-of-process-helpers.js +76 -0
- package/lib/provider/remote-provider.js +261 -0
- package/lib/tools/mediator-shim.mjs +180 -0
- package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
- package/lib/tools/remote-tools.js +680 -0
- package/lib/tools/selfcheck-fix-prompt.md +206 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/slave/acp-profile-approval.patch.yml +66 -0
- package/slave/file-transfer.mjs +552 -0
- package/slave/integrity-probe.ps1 +99 -0
- package/slave/make-launch-spec.mjs +184 -0
- package/slave/net-address.mjs +138 -0
- package/slave/node_modules/.package-lock.json +26 -0
- package/slave/node_modules/ws/LICENSE +20 -0
- package/slave/node_modules/ws/README.md +548 -0
- package/slave/node_modules/ws/browser.js +8 -0
- package/slave/node_modules/ws/index.js +22 -0
- package/slave/node_modules/ws/lib/buffer-util.js +131 -0
- package/slave/node_modules/ws/lib/constants.js +19 -0
- package/slave/node_modules/ws/lib/event-target.js +292 -0
- package/slave/node_modules/ws/lib/extension.js +203 -0
- package/slave/node_modules/ws/lib/limiter.js +55 -0
- package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/slave/node_modules/ws/lib/receiver.js +743 -0
- package/slave/node_modules/ws/lib/sender.js +607 -0
- package/slave/node_modules/ws/lib/stream.js +161 -0
- package/slave/node_modules/ws/lib/subprotocol.js +62 -0
- package/slave/node_modules/ws/lib/validation.js +152 -0
- package/slave/node_modules/ws/lib/websocket-server.js +562 -0
- package/slave/node_modules/ws/lib/websocket.js +1421 -0
- package/slave/node_modules/ws/package.json +74 -0
- package/slave/node_modules/ws/wrapper.mjs +21 -0
- package/slave/pairing.mjs +175 -0
- package/slave/permission-pairing.mjs +240 -0
- package/slave/prune-shots.mjs +170 -0
- package/slave/relay.mjs +1351 -0
- package/slave/repair-workspace-integrity.ps1 +75 -0
- package/slave/screenshot.ps1 +114 -0
- package/slave/selfcheck.ps1 +350 -0
- package/slave/stage-app.ps1 +116 -0
- package/slave/workspace-integrity.mjs +157 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection manager for one worker.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: `AcpBridge` is a passive client -- it does nothing until `connect()`
|
|
5
|
+
* is called, and `startTask()` / `callPlugin()` throw "not connected" otherwise. The
|
|
6
|
+
* first shipped version of this plugin built a bridge and used it directly, so EVERY path
|
|
7
|
+
* (self-check and dispatch alike) failed with `AcpBridge.callPlugin: not connected`. The
|
|
8
|
+
* unit and integration tests missed it because they injected bridges that were already
|
|
9
|
+
* connected; only a real profile load exposed it.
|
|
10
|
+
*
|
|
11
|
+
* This manager OWNS the bridge lifecycle instead of wrapping a long-lived instance, which
|
|
12
|
+
* is what makes recovery real:
|
|
13
|
+
*
|
|
14
|
+
* - lazy: the worker may be off or still booting when the host starts, so connecting at
|
|
15
|
+
* load time would be both pointless and fatal (one failure would strand the plugin);
|
|
16
|
+
* - single-flight: two concurrent delegations must not race into two connects;
|
|
17
|
+
* - self-healing: a transport failure DISPOSES the bridge rather than merely flagging it,
|
|
18
|
+
* because `AcpBridge` has no way to re-arm itself -- `connect()` on a live instance
|
|
19
|
+
* throws "already connected". Dropping it lets the next call build a fresh one.
|
|
20
|
+
*
|
|
21
|
+
* The surface matches what the provider already expects (`startTask` / `cancel` /
|
|
22
|
+
* `selfcheck` / `dispose` / `connected` / `stats`), so the provider cannot tell the
|
|
23
|
+
* difference between a manager and a raw bridge.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-plugin-remote-use/peer-connection
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
29
|
+
import { DEFAULT_KEEP_SHOTS, DEFAULT_SHOT_CACHE_BYTES, clearShots, pruneShots, scanShotUsage } from '../core/shot-gc.js'
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_TRANSFER_MAX_BYTES,
|
|
32
|
+
TRANSFER_CHUNK_BYTES,
|
|
33
|
+
abortTransfer,
|
|
34
|
+
defaultReceiveDir,
|
|
35
|
+
downloadFile,
|
|
36
|
+
listFiles,
|
|
37
|
+
statFile,
|
|
38
|
+
uploadFile,
|
|
39
|
+
} from '../core/transfer.js'
|
|
40
|
+
import { join } from 'node:path'
|
|
41
|
+
import { tmpdir } from 'node:os'
|
|
42
|
+
|
|
43
|
+
/** Where downloaded worker screenshots land. The host's image reader must be able to open it. */
|
|
44
|
+
export function defaultShotDir() {
|
|
45
|
+
return join(tmpdir(), 'dsh-remote-screenshots')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Liveness probe interval.
|
|
50
|
+
*
|
|
51
|
+
* WHY A HEARTBEAT: `AcpBridge` does not watch its socket, and the relay closes it on
|
|
52
|
+
* restart, so `connected` keeps reporting true over a dead link. Without a probe the first
|
|
53
|
+
* call after any worker restart fails -- measured, and the failure surfaced as the
|
|
54
|
+
* misleading "nobody is logged in" before that was fixed. A cheap periodic ping turns a
|
|
55
|
+
* silent death into a prompt reconnect.
|
|
56
|
+
*/
|
|
57
|
+
const DEFAULT_HEARTBEAT_MS = 15_000
|
|
58
|
+
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 5_000
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Turn a WebSocket URL into its HTTP counterpart on the same relay:
|
|
62
|
+
* `ws://h:p` -> `http://h:p`, `wss://h:p` -> `https://h:p`.
|
|
63
|
+
* @param {string} wsUrl
|
|
64
|
+
* @returns {string} '' when the URL is unusable
|
|
65
|
+
*/
|
|
66
|
+
export function httpBaseOf(wsUrl) {
|
|
67
|
+
if (typeof wsUrl !== 'string' || wsUrl === '') return ''
|
|
68
|
+
try {
|
|
69
|
+
const u = new URL(wsUrl)
|
|
70
|
+
u.protocol = u.protocol === 'wss:' ? 'https:' : 'http:'
|
|
71
|
+
return `${u.protocol}//${u.host}`
|
|
72
|
+
} catch {
|
|
73
|
+
return ''
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class PeerConnection {
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} opts
|
|
80
|
+
* @param {string} opts.peerId
|
|
81
|
+
* @param {() => object} opts.createBridge builds a fresh, unconnected bridge
|
|
82
|
+
* @param {(level: 'info'|'warn'|'error', message: string) => void} [opts.log]
|
|
83
|
+
* @param {() => number} [opts.now]
|
|
84
|
+
* @param {string} [opts.httpBase] relay HTTP origin, for binary endpoints
|
|
85
|
+
* @param {string} [opts.token] bearer for those endpoints
|
|
86
|
+
* @param {string} [opts.shotDir] where downloaded screenshots are written
|
|
87
|
+
* @param {number} [opts.shotCacheBytes] byte ceiling for those downloads; <= 0 disables it
|
|
88
|
+
* @param {string} [opts.receiveDir] where received FILES are written (never automatically pruned)
|
|
89
|
+
* @param {number} [opts.transferMaxBytes] host-side refusal ceiling for ONE transfer
|
|
90
|
+
* @param {typeof fetch} [opts.fetchImpl]
|
|
91
|
+
* @param {number} [opts.heartbeatMs] liveness probe interval; 0 disables it
|
|
92
|
+
* @param {number} [opts.heartbeatTimeoutMs]
|
|
93
|
+
*/
|
|
94
|
+
constructor(opts) {
|
|
95
|
+
this.peerId = opts.peerId
|
|
96
|
+
this.createBridge = opts.createBridge
|
|
97
|
+
this.log = opts.log ?? (() => {})
|
|
98
|
+
this.now = opts.now ?? (() => Date.now())
|
|
99
|
+
this.httpBase = opts.httpBase ?? ''
|
|
100
|
+
this.token = opts.token ?? ''
|
|
101
|
+
this.shotDir = opts.shotDir ?? defaultShotDir()
|
|
102
|
+
/** How many of this worker's screenshots to keep on disk. See core/shot-gc.js. */
|
|
103
|
+
this.keepShots = Number.isFinite(opts.keepShots) ? Number(opts.keepShots) : DEFAULT_KEEP_SHOTS
|
|
104
|
+
/**
|
|
105
|
+
* Byte ceiling for this worker's HOST-side downloads. <= 0 (the default) means the count
|
|
106
|
+
* budget is the only bound, which is what an install upgrading into this feature expects: a
|
|
107
|
+
* byte budget that appeared on its own would start deleting captures nobody agreed to lose.
|
|
108
|
+
*/
|
|
109
|
+
this.shotCacheBytes = Number.isFinite(opts.shotCacheBytes)
|
|
110
|
+
? Number(opts.shotCacheBytes)
|
|
111
|
+
: DEFAULT_SHOT_CACHE_BYTES
|
|
112
|
+
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch
|
|
113
|
+
/**
|
|
114
|
+
* Where files pulled from this worker land.
|
|
115
|
+
*
|
|
116
|
+
* WHY NOT `shotDir`: that one is in the system temp directory and IS pruned by `pruneShots`. A
|
|
117
|
+
* received file is output the operator explicitly asked for, so it lives under the plugin's own
|
|
118
|
+
* state directory and is never deleted automatically -- silently removing requested output is a
|
|
119
|
+
* different act from reclaiming a cache, and the receive directory therefore has no GC.
|
|
120
|
+
*/
|
|
121
|
+
this.receiveDir = opts.receiveDir ?? defaultReceiveDir()
|
|
122
|
+
/**
|
|
123
|
+
* Host-side ceiling for ONE transfer, checked BEFORE anything is sent (see core/transfer.js).
|
|
124
|
+
*
|
|
125
|
+
* Captured at construction, like `keepShots` and for the same reason: the refusal has to be
|
|
126
|
+
* answerable before a single byte is read, and a budget that changed mid-transfer would leave
|
|
127
|
+
* "was this allowed?" unanswerable after the fact.
|
|
128
|
+
*/
|
|
129
|
+
this.transferMaxBytes = Number.isFinite(Number(opts.transferMaxBytes)) && opts.transferMaxBytes !== null && opts.transferMaxBytes !== undefined
|
|
130
|
+
? Number(opts.transferMaxBytes)
|
|
131
|
+
: DEFAULT_TRANSFER_MAX_BYTES
|
|
132
|
+
/**
|
|
133
|
+
* How much of a file travels per PUT/GET, i.e. the resume granularity.
|
|
134
|
+
*
|
|
135
|
+
* It has to be forwarded explicitly: the core reads `opts.chunkBytes` (see its
|
|
136
|
+
* `chunkBytes` default), but an option a caller passes and the connection drops is a setting
|
|
137
|
+
* that silently does nothing -- the master would honour `transferChunkBytes` everywhere except
|
|
138
|
+
* on the wire.
|
|
139
|
+
*/
|
|
140
|
+
this.transferChunkBytes = Number.isFinite(Number(opts.transferChunkBytes)) && Number(opts.transferChunkBytes) > 0
|
|
141
|
+
? Math.floor(Number(opts.transferChunkBytes))
|
|
142
|
+
: TRANSFER_CHUNK_BYTES
|
|
143
|
+
this.heartbeatMs = Number.isFinite(opts.heartbeatMs) ? Math.max(0, Number(opts.heartbeatMs)) : DEFAULT_HEARTBEAT_MS
|
|
144
|
+
this.heartbeatTimeoutMs = Number.isFinite(opts.heartbeatTimeoutMs)
|
|
145
|
+
? Math.max(1, Number(opts.heartbeatTimeoutMs))
|
|
146
|
+
: DEFAULT_HEARTBEAT_TIMEOUT_MS
|
|
147
|
+
|
|
148
|
+
/** @type {object | undefined} */
|
|
149
|
+
this.bridge = undefined
|
|
150
|
+
/** @type {Promise<void> | undefined} in-flight connect, shared by concurrent callers */
|
|
151
|
+
this.connecting = undefined
|
|
152
|
+
this.connects = 0
|
|
153
|
+
this.connectFailures = 0
|
|
154
|
+
this.lastError = ''
|
|
155
|
+
this.lastConnectedAt = 0
|
|
156
|
+
this.disposed = false
|
|
157
|
+
|
|
158
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
159
|
+
this.heartbeatTimer = undefined
|
|
160
|
+
this.heartbeatBeating = false
|
|
161
|
+
this.heartbeats = 0
|
|
162
|
+
this.heartbeatFailures = 0
|
|
163
|
+
this.lastHeartbeatAt = 0
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** True when a bridge exists and reports an established session. */
|
|
167
|
+
get connected() {
|
|
168
|
+
return this.bridge !== undefined && this.bridge.connected === true
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Observable counters for the settings route and the audit trail. */
|
|
172
|
+
get stats() {
|
|
173
|
+
return {
|
|
174
|
+
peerId: this.peerId,
|
|
175
|
+
connected: this.connected,
|
|
176
|
+
connects: this.connects,
|
|
177
|
+
connectFailures: this.connectFailures,
|
|
178
|
+
lastError: this.lastError,
|
|
179
|
+
lastConnectedAt: this.lastConnectedAt,
|
|
180
|
+
heartbeatMs: this.heartbeatMs,
|
|
181
|
+
heartbeats: this.heartbeats,
|
|
182
|
+
heartbeatFailures: this.heartbeatFailures,
|
|
183
|
+
lastHeartbeatAt: this.lastHeartbeatAt,
|
|
184
|
+
...(this.bridge?.stats !== undefined && this.bridge.stats !== null ? { bridge: this.bridge.stats } : {}),
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Ensure an established connection.
|
|
190
|
+
*
|
|
191
|
+
* A failure clears the shared promise so the NEXT call retries; without that, one
|
|
192
|
+
* transient error (worker still booting) would pin the rejection for every later call.
|
|
193
|
+
*
|
|
194
|
+
* @returns {Promise<void>}
|
|
195
|
+
*/
|
|
196
|
+
async ensure() {
|
|
197
|
+
if (this.disposed) throw new Error(`PeerConnection(${this.peerId}): disposed`)
|
|
198
|
+
if (this.connected) return
|
|
199
|
+
|
|
200
|
+
if (this.connecting === undefined) {
|
|
201
|
+
this.connecting = (async () => {
|
|
202
|
+
// Build a bridge here rather than in the constructor: a previous attempt may have
|
|
203
|
+
// been discarded, and `connect()` cannot be retried on a used instance.
|
|
204
|
+
if (this.bridge === undefined) this.bridge = this.createBridge()
|
|
205
|
+
this.connects += 1
|
|
206
|
+
this.log('info', `connecting to worker "${this.peerId}" (attempt ${this.connects})`)
|
|
207
|
+
await this.bridge.connect()
|
|
208
|
+
this.lastConnectedAt = this.now()
|
|
209
|
+
this.lastError = ''
|
|
210
|
+
this.log('info', `connected to worker "${this.peerId}"`)
|
|
211
|
+
this.#startHeartbeat()
|
|
212
|
+
})().catch((error) => {
|
|
213
|
+
this.connectFailures += 1
|
|
214
|
+
this.lastError = error instanceof Error ? error.message : String(error)
|
|
215
|
+
this.log('warn', `worker "${this.peerId}" connect failed: ${this.lastError}`)
|
|
216
|
+
throw error
|
|
217
|
+
}).finally(() => {
|
|
218
|
+
// Always clear: on success the `connected` flag takes over, on failure the next
|
|
219
|
+
// caller must be free to start a fresh attempt.
|
|
220
|
+
this.connecting = undefined
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
return await this.connecting
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Ask the worker for a readiness report, connecting first.
|
|
228
|
+
* @param {{ maxAgeMs?: number, timeoutMs?: number }} [opts]
|
|
229
|
+
*/
|
|
230
|
+
async selfcheck(opts = {}) {
|
|
231
|
+
await this.ensure()
|
|
232
|
+
if (typeof this.bridge?.selfcheck !== 'function') {
|
|
233
|
+
throw new Error(`PeerConnection(${this.peerId}): bridge does not implement selfcheck`)
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return await this.bridge.selfcheck(opts)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
await this.#invalidateIfTransportError(error)
|
|
239
|
+
throw error
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Run one task on the worker, connecting first. Per-call callbacks pass through
|
|
245
|
+
* unchanged; the bridge resolves them per task.
|
|
246
|
+
* @param {object} req
|
|
247
|
+
*/
|
|
248
|
+
async startTask(req) {
|
|
249
|
+
await this.ensure()
|
|
250
|
+
try {
|
|
251
|
+
return await this.bridge.startTask(req)
|
|
252
|
+
} catch (error) {
|
|
253
|
+
await this.#invalidateIfTransportError(error)
|
|
254
|
+
throw error
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Cancel the worker's current run. Never throws for a missing connection. */
|
|
259
|
+
async cancel() { if (!this.connected) {
|
|
260
|
+
// Nothing to cancel on a link that was never established: do not connect purely to
|
|
261
|
+
// deliver a cancel.
|
|
262
|
+
this.log('warn', `cancel for "${this.peerId}" ignored: not connected`)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
await this.bridge.cancel()
|
|
267
|
+
} catch (error) {
|
|
268
|
+
await this.#invalidateIfTransportError(error)
|
|
269
|
+
this.log('warn', `cancel on "${this.peerId}" failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Capture the worker's desktop and save the PNG locally.
|
|
275
|
+
*
|
|
276
|
+
* WHY HTTP AND NOT THE RPC: the relay exposes both, but the bytes must not travel as
|
|
277
|
+
* base64 inside a JSON-RPC result -- that inflates a multi-megabyte PNG by a third and
|
|
278
|
+
* would have to pass through the ACP message pump, which exists for small control
|
|
279
|
+
* frames. The RPC returns metadata; `GET /screenshot` returns the image. One download
|
|
280
|
+
* gives both (geometry is in the response headers), so no RPC round trip is needed.
|
|
281
|
+
*
|
|
282
|
+
* The file is written where the HOST's image reader can open it, which is what lets the
|
|
283
|
+
* model actually look at the worker's screen rather than only read about it.
|
|
284
|
+
*
|
|
285
|
+
* @param {{ fresh?: boolean, timeoutMs?: number }} [opts]
|
|
286
|
+
* @returns {Promise<{ localPath: string, width: number, height: number, bytes: number,
|
|
287
|
+
* displays: number, capturedAt: string, elapsedMs: number }>}
|
|
288
|
+
*/
|
|
289
|
+
async screenshot(opts = {}) {
|
|
290
|
+
if (this.httpBase === '') {
|
|
291
|
+
throw new Error(`PeerConnection(${this.peerId}): no HTTP base derived from the worker URL`)
|
|
292
|
+
}
|
|
293
|
+
if (typeof this.fetchImpl !== 'function') {
|
|
294
|
+
throw new Error('PeerConnection: no fetch implementation available')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const url = `${this.httpBase}/screenshot${opts.fresh === false ? '' : '?fresh=1'}`
|
|
298
|
+
const controller = new AbortController()
|
|
299
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) ? Number(opts.timeoutMs) : 60_000
|
|
300
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
301
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
302
|
+
|
|
303
|
+
let res
|
|
304
|
+
try {
|
|
305
|
+
res = await this.fetchImpl(url, {
|
|
306
|
+
headers: this.token === '' ? {} : { authorization: `Bearer ${this.token}` },
|
|
307
|
+
signal: controller.signal,
|
|
308
|
+
})
|
|
309
|
+
} catch (error) {
|
|
310
|
+
throw new Error(`worker "${this.peerId}" screenshot request failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
311
|
+
} finally {
|
|
312
|
+
clearTimeout(timer)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (res.status !== 200) {
|
|
316
|
+
let detail = ''
|
|
317
|
+
try { detail = String(await res.text()).slice(0, 200) } catch { /* body may be empty */ }
|
|
318
|
+
throw new Error(`worker "${this.peerId}" screenshot returned HTTP ${res.status}${detail === '' ? '' : `: ${detail}`}`)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
322
|
+
if (buf.length === 0) throw new Error(`worker "${this.peerId}" screenshot was empty`)
|
|
323
|
+
|
|
324
|
+
mkdirSync(this.shotDir, { recursive: true })
|
|
325
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
326
|
+
const localPath = join(this.shotDir, `${this.peerId}-${stamp}.png`)
|
|
327
|
+
writeFileSync(localPath, buf)
|
|
328
|
+
|
|
329
|
+
// Housekeeping. A worker screenshot is a full-resolution PNG (~9.5 MB measured), so an
|
|
330
|
+
// afternoon of GUI verification fills a disk. This runs AFTER the file is safely written
|
|
331
|
+
// and never throws: failing to delete an older capture must not fail this one. The outcome
|
|
332
|
+
// is reported so the page can show that space was reclaimed.
|
|
333
|
+
const pruned = pruneShots(this.shotDir, this.peerId, this.keepShots, { maxBytes: this.shotCacheBytes })
|
|
334
|
+
|
|
335
|
+
const num = (name) => {
|
|
336
|
+
const v = Number(res.headers.get(name))
|
|
337
|
+
return Number.isFinite(v) ? v : 0
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
localPath,
|
|
341
|
+
width: num('x-shot-width'),
|
|
342
|
+
height: num('x-shot-height'),
|
|
343
|
+
bytes: buf.length,
|
|
344
|
+
displays: num('x-shot-displays'),
|
|
345
|
+
capturedAt: res.headers.get('x-shot-captured-at') ?? '',
|
|
346
|
+
elapsedMs: num('x-shot-ms'),
|
|
347
|
+
// Reported rather than silent: an operator who wonders where the disk went should be
|
|
348
|
+
// able to see that the plugin is reclaiming its own files, and how many it kept.
|
|
349
|
+
pruned,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The HTTP face of this worker's relay, wired for one transfer.
|
|
355
|
+
*
|
|
356
|
+
* WHY ONE PLACE: every file endpoint needs the same three things -- the derived origin, the bearer
|
|
357
|
+
* header and the fetch that is actually in use -- and the three must agree. Two copies of this
|
|
358
|
+
* (one per direction) is how a `wss://` worker ends up being uploaded to over plain `http://`, or
|
|
359
|
+
* how a test that injected `fetchImpl` silently falls back to the global one.
|
|
360
|
+
*/
|
|
361
|
+
#httpChannel() {
|
|
362
|
+
if (this.httpBase === '') {
|
|
363
|
+
throw new Error(`PeerConnection(${this.peerId}): no HTTP base derived from the worker URL`)
|
|
364
|
+
}
|
|
365
|
+
if (typeof this.fetchImpl !== 'function') {
|
|
366
|
+
throw new Error('PeerConnection: no fetch implementation available')
|
|
367
|
+
}
|
|
368
|
+
const fetchImpl = this.fetchImpl
|
|
369
|
+
return {
|
|
370
|
+
base: this.httpBase,
|
|
371
|
+
authHeaders: () => (this.token === '' ? {} : { authorization: `Bearer ${this.token}` }),
|
|
372
|
+
head: (url, init) => fetchImpl(url, { ...init, method: 'HEAD' }),
|
|
373
|
+
put: (url, init) => fetchImpl(url, init),
|
|
374
|
+
post: (url, init) => fetchImpl(url, init),
|
|
375
|
+
get: (url, init) => fetchImpl(url, init),
|
|
376
|
+
del: (url, init) => fetchImpl(url, init),
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Send one local file to the worker.
|
|
382
|
+
*
|
|
383
|
+
* WHY IT DOES NOT GO THROUGH THE RPC: the payload would have to be base64 inside a JSON-RPC result
|
|
384
|
+
* (a third larger) and would cross the ACP message pump, which exists for small control frames --
|
|
385
|
+
* the same reasoning that put screenshots on HTTP. Bytes travel on the relay's HTTP side.
|
|
386
|
+
*
|
|
387
|
+
* WHY IT SKIPS `ensure()`: an upload must work while the agent is busy, and a worker whose link is
|
|
388
|
+
* down can still accept one, so requiring a session would refuse a transfer the relay would have
|
|
389
|
+
* taken. That is the same choice `screenshot()` makes.
|
|
390
|
+
*
|
|
391
|
+
* @param {{ localPath: string, remotePath: string, overwrite?: boolean, timeoutMs?: number,
|
|
392
|
+
* signal?: AbortSignal, onProgress?: Function }} req
|
|
393
|
+
* @returns {Promise<object>} the core's upload report, plus `peerId` and `httpBase`
|
|
394
|
+
*/
|
|
395
|
+
async sendFile(req) {
|
|
396
|
+
const result = await uploadFile({
|
|
397
|
+
peerId: this.peerId,
|
|
398
|
+
localPath: req.localPath,
|
|
399
|
+
remotePath: req.remotePath,
|
|
400
|
+
channel: this.#httpChannel(),
|
|
401
|
+
overwrite: req.overwrite === true,
|
|
402
|
+
maxBytes: Number.isFinite(req.maxBytes) ? Number(req.maxBytes) : this.transferMaxBytes,
|
|
403
|
+
chunkBytes: Number.isFinite(req.chunkBytes) ? Number(req.chunkBytes) : this.transferChunkBytes,
|
|
404
|
+
timeoutMs: req.timeoutMs,
|
|
405
|
+
signal: req.signal,
|
|
406
|
+
onProgress: req.onProgress,
|
|
407
|
+
})
|
|
408
|
+
return { ...result, peerId: this.peerId, httpBase: this.httpBase }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Pull one remote file into `receiveDir` (or `destPath`), atomically.
|
|
413
|
+
*
|
|
414
|
+
* The default destination is derived from the REMOTE file's own name so a download the operator
|
|
415
|
+
* did not name still lands under the name the worker knows -- and gets a timestamp prefix, because
|
|
416
|
+
* two pulls of the same path must not silently overwrite each other. The `.part` staging file the
|
|
417
|
+
* core uses is what makes an interrupted pull resumable and the destination path trustworthy.
|
|
418
|
+
*
|
|
419
|
+
* @param {{ remotePath: string, destPath?: string, overwrite?: boolean, timeoutMs?: number,
|
|
420
|
+
* signal?: AbortSignal, onProgress?: Function }} req
|
|
421
|
+
* @returns {Promise<object>} the core's download report, plus `peerId` and `receiveDir`
|
|
422
|
+
*/
|
|
423
|
+
async fetchFile(req) {
|
|
424
|
+
const remotePath = String(req.remotePath ?? '')
|
|
425
|
+
const destPath = typeof req.destPath === 'string' && req.destPath !== ''
|
|
426
|
+
? req.destPath
|
|
427
|
+
: join(this.receiveDir, this.#receivedName(remotePath))
|
|
428
|
+
const result = await downloadFile({
|
|
429
|
+
peerId: this.peerId,
|
|
430
|
+
remotePath,
|
|
431
|
+
destPath,
|
|
432
|
+
channel: this.#httpChannel(),
|
|
433
|
+
overwrite: req.overwrite === true,
|
|
434
|
+
maxBytes: Number.isFinite(req.maxBytes) ? Number(req.maxBytes) : this.transferMaxBytes,
|
|
435
|
+
chunkBytes: Number.isFinite(req.chunkBytes) ? Number(req.chunkBytes) : this.transferChunkBytes,
|
|
436
|
+
timeoutMs: req.timeoutMs,
|
|
437
|
+
signal: req.signal,
|
|
438
|
+
onProgress: req.onProgress,
|
|
439
|
+
})
|
|
440
|
+
return { ...result, peerId: this.peerId, httpBase: this.httpBase, receiveDir: this.receiveDir }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* A collision-free local name for an unnamed download.
|
|
445
|
+
*
|
|
446
|
+
* The worker's own basename is kept -- it is the name the operator will recognise -- but prefixed
|
|
447
|
+
* with the peer id and a stamp, so two workers sending `report.pdf`, or the same worker sending it
|
|
448
|
+
* twice, cannot land on one path. A trailing separator is dropped so a remote directory path cannot
|
|
449
|
+
* produce a name that is only a directory reference.
|
|
450
|
+
*/
|
|
451
|
+
#receivedName(remotePath) {
|
|
452
|
+
const last = String(remotePath).replace(/[\\/]+$/u, '').split(/[\\/]/u).pop() ?? ''
|
|
453
|
+
// The remote basename reaches the FILESYSTEM here, so the shapes that are not writable names are
|
|
454
|
+
// replaced rather than passed through: an empty result or a traversal token would make a path
|
|
455
|
+
// that means something other than "a file in the receive directory", and on Windows the reserved
|
|
456
|
+
// device names (`NUL`, `CON`, `COM1`...) address hardware, not files -- a download that named
|
|
457
|
+
// itself `NUL` would fail with an error that looks nothing like a naming problem.
|
|
458
|
+
const safe = last === '' || last === '.' || last === '..' || /^(?:nul|con|prn|aux|com[1-9]|lpt[1-9])(?:\..*)?$/iu.test(last)
|
|
459
|
+
? 'file'
|
|
460
|
+
: last
|
|
461
|
+
const stamp = new Date(this.now()).toISOString().replace(/[:.]/g, '-')
|
|
462
|
+
return `${this.peerId}-${stamp}-${safe}`
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Ask the worker what it knows about one path.
|
|
467
|
+
*
|
|
468
|
+
* A small JSON question over the RPC channel the plugin already owns: existence and size are a
|
|
469
|
+
* handful of bytes, and reusing the channel means no new endpoint and no second thing to
|
|
470
|
+
* authenticate. `HEAD /file` is deliberately NOT used here -- it is kept for the upload pre-flight,
|
|
471
|
+
* where the response headers are the point.
|
|
472
|
+
*
|
|
473
|
+
* @param {string} path
|
|
474
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
475
|
+
*/
|
|
476
|
+
async statFile(path, opts = {}) {
|
|
477
|
+
await this.ensure()
|
|
478
|
+
if (typeof this.bridge?.callPlugin !== 'function') {
|
|
479
|
+
throw new Error(`PeerConnection(${this.peerId}): bridge does not implement callPlugin`)
|
|
480
|
+
}
|
|
481
|
+
return await this.#withRpc((client) => statFile(client, path, opts), opts)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* List one directory on the worker.
|
|
486
|
+
* @param {string} path
|
|
487
|
+
* @param {{ limit?: number, timeoutMs?: number }} [opts]
|
|
488
|
+
*/
|
|
489
|
+
async listFiles(path, opts = {}) {
|
|
490
|
+
await this.ensure()
|
|
491
|
+
if (typeof this.bridge?.callPlugin !== 'function') {
|
|
492
|
+
throw new Error(`PeerConnection(${this.peerId}): bridge does not implement callPlugin`)
|
|
493
|
+
}
|
|
494
|
+
return await this.#withRpc((client) => listFiles(client, path, opts), opts)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Run one transfer RPC, discarding the link only when the failure was the link's fault.
|
|
499
|
+
*
|
|
500
|
+
* A worker that does not know the method yet (an older relay) is a HEALTHY worker with a missing
|
|
501
|
+
* capability: tearing the session down over it would turn "this relay is older than the feature"
|
|
502
|
+
* into a reconnect loop, which is precisely the reading `workerShotCache` already makes.
|
|
503
|
+
*
|
|
504
|
+
* The call's own options (`limit`) stay the CALLER's business -- only the timeout is pulled out for
|
|
505
|
+
* the transport. Collapsing the two once dropped `listFiles`'s limit on the floor, which produces
|
|
506
|
+
* an unfiltered directory listing that looks like it honoured a limit it never sent.
|
|
507
|
+
*/
|
|
508
|
+
async #withRpc(run, opts = {}) {
|
|
509
|
+
const client = { rpc: (method, params, rpcOpts) => this.bridge.callPlugin(method, params, rpcOpts) }
|
|
510
|
+
const rpcOpts = Number.isFinite(opts.timeoutMs) ? { timeoutMs: Number(opts.timeoutMs) } : {}
|
|
511
|
+
try {
|
|
512
|
+
return await run(client, rpcOpts)
|
|
513
|
+
} catch (error) {
|
|
514
|
+
await this.#invalidateIfTransportError(error)
|
|
515
|
+
throw error
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Give up on a transfer by deleting the WORKER's staging file.
|
|
521
|
+
*
|
|
522
|
+
* The contract makes this safe to offer: `DELETE /file` removes `<path>.part` and never the target,
|
|
523
|
+
* so the worst outcome of cancelling is losing resume progress. A partial upload therefore cannot be
|
|
524
|
+
* mistaken for a finished one later, and the operator cannot accidentally delete real data by
|
|
525
|
+
* cancelling.
|
|
526
|
+
*
|
|
527
|
+
* @param {{ remotePath: string, timeoutMs?: number, signal?: AbortSignal }} req
|
|
528
|
+
*/
|
|
529
|
+
async abortTransfer(req) {
|
|
530
|
+
const result = await abortTransfer({
|
|
531
|
+
peerId: this.peerId,
|
|
532
|
+
remotePath: req.remotePath,
|
|
533
|
+
channel: this.#httpChannel(),
|
|
534
|
+
timeoutMs: req.timeoutMs,
|
|
535
|
+
signal: req.signal,
|
|
536
|
+
})
|
|
537
|
+
return { ...result, peerId: this.peerId }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* What this worker's screenshots occupy on the HOST, plus what the budget says it should.
|
|
542
|
+
*
|
|
543
|
+
* Kept separate from the relay call below because the two answer different questions: this one
|
|
544
|
+
* describes the copies the page and the model actually read, and the relay's describes the
|
|
545
|
+
* copies sitting on the worker. Both are worth showing -- an operator clearing 218 MB of host
|
|
546
|
+
* cache and finding the worker still full would rightly call the button a lie.
|
|
547
|
+
*
|
|
548
|
+
* @param {{ maxBytes?: number }} [opts]
|
|
549
|
+
*/
|
|
550
|
+
hostShotCache(opts = {}) {
|
|
551
|
+
const usage = scanShotUsage(this.shotDir, this.peerId)
|
|
552
|
+
return {
|
|
553
|
+
...usage,
|
|
554
|
+
dir: this.shotDir,
|
|
555
|
+
keep: this.keepShots,
|
|
556
|
+
maxBytes: Number.isFinite(opts.maxBytes) ? Number(opts.maxBytes) : this.shotCacheBytes,
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Apply the budgets now and report what was reclaimed. Never throws. */
|
|
561
|
+
pruneHostShotCache(opts = {}) {
|
|
562
|
+
const maxBytes = Number.isFinite(opts.maxBytes) ? Number(opts.maxBytes) : this.shotCacheBytes
|
|
563
|
+
const pruned = pruneShots(this.shotDir, this.peerId, this.keepShots, { maxBytes })
|
|
564
|
+
return { ...pruned, dir: this.shotDir, maxBytes }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Delete this worker's host-side downloads. Never throws. */
|
|
568
|
+
clearHostShotCache() {
|
|
569
|
+
const cleared = clearShots(this.shotDir, this.peerId)
|
|
570
|
+
return { ...cleared, dir: this.shotDir }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Whether the worker's relay can answer the cache RPC at all.
|
|
575
|
+
*
|
|
576
|
+
* WHY A GETTER RATHER THAN LETTING THE CALLER TEST THE METHOD: the same fact -- "this worker
|
|
577
|
+
* cannot report its cache" -- has to be read the same way by the page's snapshot and by the
|
|
578
|
+
* page's Clear button. When the two decided differently (the snapshot treated it as an optional
|
|
579
|
+
* capability and reported `null`, while the action path called the method blindly and turned the
|
|
580
|
+
* TypeError into a 502) a worker whose relay predates this feature looked healthy on load and
|
|
581
|
+
* broken when the operator clicked. One question, one answer.
|
|
582
|
+
*
|
|
583
|
+
* A relay that is older than this feature is a HEALTHY worker with a missing report, not a
|
|
584
|
+
* failure: the link works, the captures work, only the enquiry is unavailable.
|
|
585
|
+
*/
|
|
586
|
+
get supportsWorkerShotCache() {
|
|
587
|
+
return this.connected && typeof this.bridge?.callPlugin === 'function'
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Ask the worker what ITS screenshot directory occupies, and optionally make it clean up.
|
|
592
|
+
*
|
|
593
|
+
* WHY IT GOES OVER THE RELAY RATHER THAN HTTP: the numbers are a handful of integers and they
|
|
594
|
+
* ride the JSON-RPC channel the plugin already owns, so this needs no new endpoint, no new
|
|
595
|
+
* authorization path, and no second thing to keep authenticated.
|
|
596
|
+
*
|
|
597
|
+
* A worker running a relay older than this feature answers "unknown method" -- which is NOT an
|
|
598
|
+
* error worth showing as a failure, because the link is fine and only the report is missing.
|
|
599
|
+
* The two cases are distinguished by `supported`, so the page can say "the worker does not
|
|
600
|
+
* report its cache" instead of reddening a healthy card. `ensure()` is deliberately skipped for
|
|
601
|
+
* the report path by the caller so a page load never waits on a connect it did not need.
|
|
602
|
+
*
|
|
603
|
+
* @param {{ maxBytes?: number, prune?: boolean, clear?: boolean }} [opts]
|
|
604
|
+
* @returns {Promise<{ supported: boolean, error: string, files: number, bytes: number,
|
|
605
|
+
* newestAt: number, oldestAt: number, keep: number, maxBytes: number,
|
|
606
|
+
* dir: string, pruned: object | null }>}
|
|
607
|
+
*/
|
|
608
|
+
async workerShotCache(opts = {}) {
|
|
609
|
+
const unsupported = {
|
|
610
|
+
supported: false, error: '', files: 0, bytes: 0, newestAt: 0, oldestAt: 0,
|
|
611
|
+
keep: 0, maxBytes: 0, dir: '', pruned: null,
|
|
612
|
+
}
|
|
613
|
+
if (!this.connected || typeof this.bridge?.callPlugin !== 'function') {
|
|
614
|
+
return { ...unsupported, error: 'not connected' }
|
|
615
|
+
}
|
|
616
|
+
const params = {}
|
|
617
|
+
if (Number.isFinite(opts.maxBytes)) params.maxBytes = Number(opts.maxBytes)
|
|
618
|
+
if (opts.prune === true) params.prune = true
|
|
619
|
+
if (opts.clear === true) params.clear = true
|
|
620
|
+
try {
|
|
621
|
+
const result = await this.bridge.callPlugin('dsh-remote/shotcache', params)
|
|
622
|
+
return {
|
|
623
|
+
supported: true,
|
|
624
|
+
error: '',
|
|
625
|
+
files: Number(result?.files ?? 0),
|
|
626
|
+
bytes: Number(result?.bytes ?? 0),
|
|
627
|
+
newestAt: Number(result?.newestAt ?? 0),
|
|
628
|
+
oldestAt: Number(result?.oldestAt ?? 0),
|
|
629
|
+
keep: Number(result?.keepShots ?? 0),
|
|
630
|
+
maxBytes: Number(result?.maxBytes ?? 0),
|
|
631
|
+
dir: String(result?.dir ?? ''),
|
|
632
|
+
pruned: result?.pruned ?? null,
|
|
633
|
+
}
|
|
634
|
+
} catch (error) {
|
|
635
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
636
|
+
// -32601 is JSON-RPC "method not found": an older worker, not a broken one.
|
|
637
|
+
const unknownMethod = /-32601|unknown plugin method|method not found/i.test(message)
|
|
638
|
+
// An unknown method is a property of the worker, not of the link, so the link is kept.
|
|
639
|
+
if (!unknownMethod) await this.#invalidateIfTransportError(error)
|
|
640
|
+
return { ...unsupported, error: unknownMethod ? '' : message }
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Tear down the connection. Idempotent. */
|
|
645
|
+
async dispose() {
|
|
646
|
+
if (this.disposed) return
|
|
647
|
+
this.disposed = true
|
|
648
|
+
this.#stopHeartbeat()
|
|
649
|
+
const bridge = this.bridge
|
|
650
|
+
this.bridge = undefined
|
|
651
|
+
if (bridge === undefined) return
|
|
652
|
+
try {
|
|
653
|
+
await bridge.dispose()
|
|
654
|
+
} catch (error) {
|
|
655
|
+
this.log('warn', `dispose of "${this.peerId}" failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// -------------------------------------------------------------------------
|
|
660
|
+
// liveness
|
|
661
|
+
// -------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Probe the link periodically so a socket that died silently is noticed BETWEEN calls
|
|
665
|
+
* rather than by a user-facing failure.
|
|
666
|
+
*
|
|
667
|
+
* The relay answers `dsh-remote/ping` from its own event loop, so this costs the worker's
|
|
668
|
+
* agent nothing and gives a definitive alive/dead answer.
|
|
669
|
+
*/
|
|
670
|
+
#startHeartbeat() {
|
|
671
|
+
this.#stopHeartbeat()
|
|
672
|
+
if (this.heartbeatMs <= 0 || this.disposed) return
|
|
673
|
+
this.heartbeatTimer = setInterval(() => { void this.#beat() }, this.heartbeatMs)
|
|
674
|
+
// Never hold the process open just for a liveness probe.
|
|
675
|
+
if (typeof this.heartbeatTimer?.unref === 'function') this.heartbeatTimer.unref()
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
#stopHeartbeat() {
|
|
679
|
+
if (this.heartbeatTimer !== undefined) {
|
|
680
|
+
clearInterval(this.heartbeatTimer)
|
|
681
|
+
this.heartbeatTimer = undefined
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async #beat() {
|
|
686
|
+
if (this.disposed || !this.connected || this.heartbeatBeating) return
|
|
687
|
+
if (typeof this.bridge?.callPlugin !== 'function') return
|
|
688
|
+
this.heartbeatBeating = true
|
|
689
|
+
try {
|
|
690
|
+
await this.bridge.callPlugin('dsh-remote/ping', {}, { timeoutMs: this.heartbeatTimeoutMs })
|
|
691
|
+
this.heartbeats += 1
|
|
692
|
+
this.lastHeartbeatAt = this.now()
|
|
693
|
+
} catch (error) {
|
|
694
|
+
this.heartbeatFailures += 1
|
|
695
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
696
|
+
this.log('warn', `worker "${this.peerId}" heartbeat failed (${message}); treating the link as dead`)
|
|
697
|
+
// A failed liveness probe IS evidence the link is gone, so this deliberately does not
|
|
698
|
+
// go through the message-matching in #invalidateIfTransportError.
|
|
699
|
+
await this.#discardLink()
|
|
700
|
+
} finally {
|
|
701
|
+
this.heartbeatBeating = false
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Throw away the current bridge so the next `ensure()` builds a fresh one. */
|
|
706
|
+
async #discardLink() {
|
|
707
|
+
this.#stopHeartbeat()
|
|
708
|
+
const dead = this.bridge
|
|
709
|
+
this.bridge = undefined
|
|
710
|
+
if (dead === undefined) return
|
|
711
|
+
try { await dead.dispose() } catch { /* the socket is already gone */ }
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Drop a connection that is no longer usable, so the next `ensure()` rebuilds it.
|
|
716
|
+
*
|
|
717
|
+
* Only transport-level failures count. A JSON-RPC error from a healthy session (a
|
|
718
|
+
* failing tool, a refused approval) must NOT tear the link down, or one bad task would
|
|
719
|
+
* force a reconnect on every subsequent call.
|
|
720
|
+
*/
|
|
721
|
+
async #invalidateIfTransportError(error) {
|
|
722
|
+
if (!this.connected) return
|
|
723
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
724
|
+
const transportish = /not connected|connection closed|EPIPE|ECONNRESET|ECONNREFUSED|ECONNABORTED|socket|websocket|network|hang ?up/i.test(message)
|
|
725
|
+
if (!transportish) return
|
|
726
|
+
|
|
727
|
+
this.log('warn', `worker "${this.peerId}" link is no longer usable (${message}); discarding it so the next call reconnects`)
|
|
728
|
+
await this.#discardLink()
|
|
729
|
+
}
|
|
730
|
+
}
|