@livedesk/hub 0.1.36 → 0.1.38
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/package.json +32 -32
- package/src/agents/agent-audit-store.js +6 -16
- package/src/agents/agent-permissions.js +27 -33
- package/src/agents/agent-tool-registry.js +322 -340
- package/src/captures/capture-store.js +252 -299
- package/src/filesystem/shared-folders.js +181 -189
- package/src/filesystem/transfer-jobs.js +314 -345
- package/src/live-desk-update.js +615 -683
- package/src/remote-hub.js +85 -654
- package/src/server.js +154 -965
- package/src/settings/settings-schema.js +40 -32
- package/src/settings/settings-store.js +2 -7
- package/src/transport/relay-hub-control.js +1376 -1703
- package/src/transport/udp-hub-transport.js +520 -543
- package/src/transport/udp-rendezvous.js +408 -574
- package/src/security/device-credential-authority.js +0 -406
- package/src/security/security-audit-store.js +0 -260
- package/src/transport/secure-direct-acceptor.js +0 -543
|
@@ -1,345 +1,314 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
4
|
-
|
|
5
|
-
const CHUNK_BYTES = 512 * 1024;
|
|
6
|
-
const JOB_RETENTION_MS = 10 * 60 * 1000;
|
|
7
|
-
const COMMAND_RESULT_TIMEOUT_MS = 30 * 1000;
|
|
8
|
-
|
|
9
|
-
function snapshot(job) {
|
|
10
|
-
return {
|
|
11
|
-
jobId: job.jobId,
|
|
12
|
-
state: job.state,
|
|
13
|
-
totalFiles: job.totalFiles,
|
|
14
|
-
completedFiles: job.completedFiles,
|
|
15
|
-
totalBytes: job.totalBytes,
|
|
16
|
-
sentBytes: job.sentBytes,
|
|
17
|
-
currentPath: job.currentPath || '',
|
|
18
|
-
targetCount: job.deviceIds.length,
|
|
19
|
-
failedTargets: [...job.failedTargets].map(deviceId => ({ deviceId, error: job.targetErrors.get(deviceId) || 'transfer-failed' })),
|
|
20
|
-
error: job.error || null,
|
|
21
|
-
createdAt: job.createdAt,
|
|
22
|
-
updatedAt: job.updatedAt,
|
|
23
|
-
completedAt: job.completedAt || null
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function markUpdated(job) {
|
|
28
|
-
job.updatedAt = new Date().toISOString();
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export class HubTransferJobs {
|
|
32
|
-
constructor({ filesystem, remoteHub, maxConcurrent = 2, commandResultTimeoutMs = COMMAND_RESULT_TIMEOUT_MS
|
|
33
|
-
this.filesystem = filesystem;
|
|
34
|
-
this.remoteHub = remoteHub;
|
|
35
|
-
this.maxConcurrent = Math.max(1, Number(maxConcurrent) || 2);
|
|
36
|
-
this.commandResultTimeoutMs = Math.max(1_000, Number(commandResultTimeoutMs) || COMMAND_RESULT_TIMEOUT_MS);
|
|
37
|
-
this.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
job.
|
|
96
|
-
job.
|
|
97
|
-
job
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
this.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
job.
|
|
144
|
-
job.
|
|
145
|
-
job.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
await
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
error: 'device-disconnected'
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
async finishCallback(job, completed) {
|
|
321
|
-
if (typeof job.onComplete !== 'function') return;
|
|
322
|
-
try {
|
|
323
|
-
await job.onComplete({ completed, job: snapshot(job) });
|
|
324
|
-
} catch (error) {
|
|
325
|
-
// A completion callback can be the durable security-audit gate. Never
|
|
326
|
-
// leave a job looking successful when that final record was not stored.
|
|
327
|
-
job.state = 'failed';
|
|
328
|
-
job.error = `completion-callback-failed:${error instanceof Error ? error.message : String(error)}`;
|
|
329
|
-
job.completedAt ||= new Date().toISOString();
|
|
330
|
-
markUpdated(job);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
prune() {
|
|
335
|
-
const threshold = Date.now() - JOB_RETENTION_MS;
|
|
336
|
-
for (const [id, job] of this.jobs) {
|
|
337
|
-
const createdAt = Date.parse(job.createdAt);
|
|
338
|
-
if (job.completedAt && Number.isFinite(createdAt) && createdAt < threshold) this.jobs.delete(id);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
export function createHubTransferJobs(options) {
|
|
344
|
-
return new HubTransferJobs(options);
|
|
345
|
-
}
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
4
|
+
|
|
5
|
+
const CHUNK_BYTES = 512 * 1024;
|
|
6
|
+
const JOB_RETENTION_MS = 10 * 60 * 1000;
|
|
7
|
+
const COMMAND_RESULT_TIMEOUT_MS = 30 * 1000;
|
|
8
|
+
|
|
9
|
+
function snapshot(job) {
|
|
10
|
+
return {
|
|
11
|
+
jobId: job.jobId,
|
|
12
|
+
state: job.state,
|
|
13
|
+
totalFiles: job.totalFiles,
|
|
14
|
+
completedFiles: job.completedFiles,
|
|
15
|
+
totalBytes: job.totalBytes,
|
|
16
|
+
sentBytes: job.sentBytes,
|
|
17
|
+
currentPath: job.currentPath || '',
|
|
18
|
+
targetCount: job.deviceIds.length,
|
|
19
|
+
failedTargets: [...job.failedTargets].map(deviceId => ({ deviceId, error: job.targetErrors.get(deviceId) || 'transfer-failed' })),
|
|
20
|
+
error: job.error || null,
|
|
21
|
+
createdAt: job.createdAt,
|
|
22
|
+
updatedAt: job.updatedAt,
|
|
23
|
+
completedAt: job.completedAt || null
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function markUpdated(job) {
|
|
28
|
+
job.updatedAt = new Date().toISOString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class HubTransferJobs {
|
|
32
|
+
constructor({ filesystem, remoteHub, maxConcurrent = 2, commandResultTimeoutMs = COMMAND_RESULT_TIMEOUT_MS } = {}) {
|
|
33
|
+
this.filesystem = filesystem;
|
|
34
|
+
this.remoteHub = remoteHub;
|
|
35
|
+
this.maxConcurrent = Math.max(1, Number(maxConcurrent) || 2);
|
|
36
|
+
this.commandResultTimeoutMs = Math.max(1_000, Number(commandResultTimeoutMs) || COMMAND_RESULT_TIMEOUT_MS);
|
|
37
|
+
this.jobs = new Map();
|
|
38
|
+
this.queue = [];
|
|
39
|
+
this.running = 0;
|
|
40
|
+
this.pendingCommands = new Map();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
create({ itemIds = [], deviceIds = [], remoteDirectory = '', files = null, onComplete = null } = {}) {
|
|
44
|
+
const targets = [...new Set((Array.isArray(deviceIds) ? deviceIds : []).map(value => String(value || '').trim()).filter(Boolean))].slice(0, 240);
|
|
45
|
+
if (targets.length === 0) throw new Error('no-target-devices');
|
|
46
|
+
const job = {
|
|
47
|
+
jobId: `job_${crypto.randomBytes(14).toString('base64url')}`,
|
|
48
|
+
state: 'queued',
|
|
49
|
+
itemIds: [...new Set((Array.isArray(itemIds) ? itemIds : []).map(value => String(value || '').trim()).filter(Boolean))],
|
|
50
|
+
files,
|
|
51
|
+
deviceIds: targets,
|
|
52
|
+
remoteDirectory: String(remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600),
|
|
53
|
+
totalFiles: 0,
|
|
54
|
+
completedFiles: 0,
|
|
55
|
+
totalBytes: 0,
|
|
56
|
+
sentBytes: 0,
|
|
57
|
+
currentPath: '',
|
|
58
|
+
failedTargets: new Set(),
|
|
59
|
+
targetErrors: new Map(),
|
|
60
|
+
error: '',
|
|
61
|
+
createdAt: new Date().toISOString(),
|
|
62
|
+
updatedAt: new Date().toISOString(),
|
|
63
|
+
completedAt: '',
|
|
64
|
+
cancelled: false,
|
|
65
|
+
currentStream: null,
|
|
66
|
+
onComplete
|
|
67
|
+
};
|
|
68
|
+
this.jobs.set(job.jobId, job);
|
|
69
|
+
this.queue.push(job);
|
|
70
|
+
this.pump();
|
|
71
|
+
return snapshot(job);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get(jobId) {
|
|
75
|
+
this.prune();
|
|
76
|
+
const job = this.jobs.get(String(jobId || ''));
|
|
77
|
+
return job ? snapshot(job) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
list({ activeOnly = false } = {}) {
|
|
81
|
+
this.prune();
|
|
82
|
+
return [...this.jobs.values()]
|
|
83
|
+
.filter(job => !activeOnly || ['scanning', 'queued', 'sending'].includes(job.state))
|
|
84
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
|
85
|
+
.map(snapshot);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cancel(jobId) {
|
|
89
|
+
const job = this.jobs.get(String(jobId || ''));
|
|
90
|
+
if (!job) return null;
|
|
91
|
+
if (['completed', 'partially-failed', 'failed', 'cancelled'].includes(job.state)) return snapshot(job);
|
|
92
|
+
job.cancelled = true;
|
|
93
|
+
job.state = 'cancelled';
|
|
94
|
+
job.error = 'transfer-cancelled';
|
|
95
|
+
if (job.currentStream) job.currentStream.destroy();
|
|
96
|
+
this.finishPendingCommands(job.jobId, 'transfer-cancelled');
|
|
97
|
+
markUpdated(job);
|
|
98
|
+
return snapshot(job);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
pump() {
|
|
102
|
+
while (this.running < this.maxConcurrent && this.queue.length > 0) {
|
|
103
|
+
const job = this.queue.shift();
|
|
104
|
+
if (!job || job.cancelled) continue;
|
|
105
|
+
this.running += 1;
|
|
106
|
+
void this.run(job).finally(() => {
|
|
107
|
+
this.running -= 1;
|
|
108
|
+
this.pump();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async run(job) {
|
|
114
|
+
try {
|
|
115
|
+
job.state = 'scanning';
|
|
116
|
+
markUpdated(job);
|
|
117
|
+
const scan = job.files
|
|
118
|
+
? { files: job.files, totalBytes: job.files.reduce((sum, file) => sum + Number(file.size || 0), 0) }
|
|
119
|
+
: await this.filesystem.scan(job.itemIds);
|
|
120
|
+
if (job.cancelled) return;
|
|
121
|
+
job.totalFiles = scan.files.length;
|
|
122
|
+
job.totalBytes = scan.totalBytes;
|
|
123
|
+
job.state = 'sending';
|
|
124
|
+
markUpdated(job);
|
|
125
|
+
if (scan.files.length === 0) {
|
|
126
|
+
job.state = 'completed';
|
|
127
|
+
job.completedAt = new Date().toISOString();
|
|
128
|
+
markUpdated(job);
|
|
129
|
+
await this.finishCallback(job, true);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const file of scan.files) {
|
|
134
|
+
if (job.cancelled) return;
|
|
135
|
+
job.currentPath = file.relativePath;
|
|
136
|
+
markUpdated(job);
|
|
137
|
+
await this.sendFile(job, file);
|
|
138
|
+
job.completedFiles += 1;
|
|
139
|
+
markUpdated(job);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (job.cancelled) return;
|
|
143
|
+
job.state = job.failedTargets.size === 0 ? 'completed' : 'partially-failed';
|
|
144
|
+
job.completedAt = new Date().toISOString();
|
|
145
|
+
job.currentPath = '';
|
|
146
|
+
if (job.failedTargets.size > 0) job.error = 'one-or-more-targets-failed';
|
|
147
|
+
markUpdated(job);
|
|
148
|
+
await this.finishCallback(job, job.state === 'completed');
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (job.cancelled) return;
|
|
151
|
+
job.state = job.failedTargets.size > 0 ? 'partially-failed' : 'failed';
|
|
152
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
153
|
+
job.completedAt = new Date().toISOString();
|
|
154
|
+
job.currentPath = '';
|
|
155
|
+
markUpdated(job);
|
|
156
|
+
await this.finishCallback(job, false);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async sendFile(job, file) {
|
|
161
|
+
const activeTargets = () => job.deviceIds.filter(deviceId => !job.failedTargets.has(deviceId));
|
|
162
|
+
if (Number(file.size || 0) === 0) {
|
|
163
|
+
await this.sendChunk(job, file, 0, Buffer.alloc(0), true, activeTargets());
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const stream = fs.createReadStream(file.absolutePath, { highWaterMark: CHUNK_BYTES });
|
|
167
|
+
job.currentStream = stream;
|
|
168
|
+
let offset = 0;
|
|
169
|
+
try {
|
|
170
|
+
for await (const chunk of stream) {
|
|
171
|
+
if (job.cancelled) return;
|
|
172
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
173
|
+
const final = offset + buffer.length >= Number(file.size || 0);
|
|
174
|
+
const targets = activeTargets();
|
|
175
|
+
if (targets.length === 0) throw new Error('all-targets-failed');
|
|
176
|
+
await this.sendChunk(job, file, offset, buffer, final, targets);
|
|
177
|
+
offset += buffer.length;
|
|
178
|
+
job.sentBytes += buffer.length;
|
|
179
|
+
markUpdated(job);
|
|
180
|
+
await yieldToEventLoop();
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
job.currentStream = null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async sendChunk(job, file, offset, buffer, final, targets) {
|
|
188
|
+
const outcomes = await Promise.all(targets.map(async deviceId => {
|
|
189
|
+
if (job.cancelled) return { deviceId, ok: false, error: 'transfer-cancelled' };
|
|
190
|
+
const commandId = crypto.randomUUID();
|
|
191
|
+
const pending = this.waitForCommandResult(job.jobId, deviceId, commandId);
|
|
192
|
+
const result = this.remoteHub.sendCommand(deviceId, {
|
|
193
|
+
command: 'file.transfer.chunk',
|
|
194
|
+
commandId,
|
|
195
|
+
payload: {
|
|
196
|
+
transferId: job.jobId,
|
|
197
|
+
remoteDirectory: job.remoteDirectory,
|
|
198
|
+
name: file.name,
|
|
199
|
+
relativePath: file.relativePath,
|
|
200
|
+
offset,
|
|
201
|
+
totalBytes: Number(file.size || 0),
|
|
202
|
+
final,
|
|
203
|
+
dataBase64: buffer.toString('base64'),
|
|
204
|
+
lastModified: Number(file.modifiedMs || 0) || 0,
|
|
205
|
+
requestedAt: new Date().toISOString()
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
if (!result?.ok) {
|
|
209
|
+
this.settlePendingCommand(commandId, {
|
|
210
|
+
ok: false,
|
|
211
|
+
error: String(result?.error || 'device-not-connected')
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return { deviceId, ...await pending };
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
for (const outcome of outcomes) {
|
|
218
|
+
if (outcome.ok || outcome.error === 'transfer-cancelled') continue;
|
|
219
|
+
job.failedTargets.add(outcome.deviceId);
|
|
220
|
+
job.targetErrors.set(outcome.deviceId, outcome.error || 'transfer-failed');
|
|
221
|
+
}
|
|
222
|
+
if (!job.cancelled && outcomes.length > 0 && outcomes.every(outcome => !outcome.ok)) {
|
|
223
|
+
throw new Error('all-targets-failed');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
waitForCommandResult(jobId, deviceId, commandId) {
|
|
228
|
+
return new Promise(resolve => {
|
|
229
|
+
const timer = setTimeout(() => {
|
|
230
|
+
this.settlePendingCommand(commandId, {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: 'file-transfer-ack-timeout'
|
|
233
|
+
});
|
|
234
|
+
}, this.commandResultTimeoutMs);
|
|
235
|
+
timer.unref?.();
|
|
236
|
+
this.pendingCommands.set(commandId, {
|
|
237
|
+
jobId,
|
|
238
|
+
deviceId,
|
|
239
|
+
timer,
|
|
240
|
+
resolve
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
settlePendingCommand(commandId, outcome) {
|
|
246
|
+
const pending = this.pendingCommands.get(String(commandId || ''));
|
|
247
|
+
if (!pending) return false;
|
|
248
|
+
this.pendingCommands.delete(commandId);
|
|
249
|
+
clearTimeout(pending.timer);
|
|
250
|
+
pending.resolve(outcome);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
finishPendingCommands(jobId, error) {
|
|
255
|
+
for (const [commandId, pending] of this.pendingCommands) {
|
|
256
|
+
if (pending.jobId !== jobId) continue;
|
|
257
|
+
this.settlePendingCommand(commandId, { ok: false, error });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
handleRemoteEvent(type, event) {
|
|
262
|
+
if (type === 'RemoteCommandResult') {
|
|
263
|
+
const commandId = String(event?.commandId || '');
|
|
264
|
+
const pending = this.pendingCommands.get(commandId);
|
|
265
|
+
if (!pending) return;
|
|
266
|
+
const eventDeviceId = String(event?.deviceId || event?.device?.deviceId || '');
|
|
267
|
+
if (eventDeviceId && eventDeviceId !== pending.deviceId) return;
|
|
268
|
+
const result = event?.result;
|
|
269
|
+
const error = String(
|
|
270
|
+
event?.error
|
|
271
|
+
|| (result?.ok === false || result?.status === 'failed' || result?.status === 'rejected'
|
|
272
|
+
? result?.error || 'file-transfer-rejected'
|
|
273
|
+
: '')
|
|
274
|
+
);
|
|
275
|
+
this.settlePendingCommand(commandId, error
|
|
276
|
+
? { ok: false, error }
|
|
277
|
+
: { ok: true, result });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (type !== 'RemoteDeviceDisconnected') return;
|
|
282
|
+
const deviceId = String(event?.deviceId || event?.device?.deviceId || '');
|
|
283
|
+
if (!deviceId) return;
|
|
284
|
+
for (const [commandId, pending] of this.pendingCommands) {
|
|
285
|
+
if (pending.deviceId !== deviceId) continue;
|
|
286
|
+
this.settlePendingCommand(commandId, {
|
|
287
|
+
ok: false,
|
|
288
|
+
error: 'device-disconnected'
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async finishCallback(job, completed) {
|
|
294
|
+
if (typeof job.onComplete !== 'function') return;
|
|
295
|
+
try {
|
|
296
|
+
await job.onComplete({ completed, job: snapshot(job) });
|
|
297
|
+
} catch (error) {
|
|
298
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
299
|
+
markUpdated(job);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
prune() {
|
|
304
|
+
const threshold = Date.now() - JOB_RETENTION_MS;
|
|
305
|
+
for (const [id, job] of this.jobs) {
|
|
306
|
+
const createdAt = Date.parse(job.createdAt);
|
|
307
|
+
if (job.completedAt && Number.isFinite(createdAt) && createdAt < threshold) this.jobs.delete(id);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function createHubTransferJobs(options) {
|
|
313
|
+
return new HubTransferJobs(options);
|
|
314
|
+
}
|