livedesk 0.1.457 → 0.1.459

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.
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import {
8
+ REQUIRED_PHASES,
9
+ buildTransportPhysicalReport,
10
+ genericTransportPhysicalError,
11
+ isTransportPhysicalPhaseEvidence
12
+ } from './livedesk-transport-physical-core.mjs';
13
+
14
+ const DEFAULT_HUB_URL = 'http://127.0.0.1:5179';
15
+ const DEFAULT_PHASE_TIMEOUT_MS = 15_000;
16
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
17
+ const HUB_COMMAND_TIMEOUT_MS = 8_000;
18
+ const CONTROL_REQUEST_TIMEOUT_MS = 10_000;
19
+ const START_REQUEST_TIMEOUT_MS = 15_000;
20
+ const CLEANUP_CONFIRM_TIMEOUT_MS = 20_000;
21
+ const CLEANUP_RETRY_DELAY_MS = 250;
22
+ const DEFAULT_TTL_MS = 150_000;
23
+ const MIN_TTL_MARGIN_MS = 20_000;
24
+
25
+ export function normalizeTransportPhysicalTiming(options = {}) {
26
+ const ttlMs = Math.max(15_000, Math.min(180_000, Number(options.ttlMs) || DEFAULT_TTL_MS));
27
+ const phaseTimeoutMs = Math.max(
28
+ 3_000,
29
+ Math.min(15_000, Number(options.phaseTimeoutMs) || DEFAULT_PHASE_TIMEOUT_MS));
30
+ const requestTimeoutMs = Math.max(
31
+ 500,
32
+ Math.min(10_000, Number(options.requestTimeoutMs) || DEFAULT_REQUEST_TIMEOUT_MS));
33
+ const acquireCommandBudgetMs = HUB_COMMAND_TIMEOUT_MS;
34
+ const transitionCommandBudgetMs = (REQUIRED_PHASES.length - 1) * HUB_COMMAND_TIMEOUT_MS;
35
+ const releaseCommandBudgetMs = HUB_COMMAND_TIMEOUT_MS;
36
+ const commandBudgetMs = acquireCommandBudgetMs
37
+ + transitionCommandBudgetMs
38
+ + releaseCommandBudgetMs;
39
+ const frameEvidenceBudgetMs = REQUIRED_PHASES.length * phaseTimeoutMs;
40
+ const requiredTtlMs = frameEvidenceBudgetMs + commandBudgetMs + MIN_TTL_MARGIN_MS;
41
+ if (requiredTtlMs > ttlMs) {
42
+ throw new Error(
43
+ `transport-physical timing budget invalid: ${REQUIRED_PHASES.length} frame evidence budgets, `
44
+ + `acquire/transition/release command budgets, and ${MIN_TTL_MARGIN_MS}ms margin `
45
+ + `require at least ${requiredTtlMs}ms TTL.`);
46
+ }
47
+ return {
48
+ ttlMs,
49
+ phaseTimeoutMs,
50
+ requestTimeoutMs,
51
+ requiredTtlMs,
52
+ ttlMarginMs: MIN_TTL_MARGIN_MS,
53
+ frameEvidenceBudgetMs,
54
+ commandBudgetMs,
55
+ acquireCommandBudgetMs,
56
+ transitionCommandBudgetMs,
57
+ releaseCommandBudgetMs
58
+ };
59
+ }
60
+
61
+ export function parseTransportPhysicalArgs(argv) {
62
+ const args = {
63
+ hubUrl: DEFAULT_HUB_URL,
64
+ deviceId: '',
65
+ reportPath: '',
66
+ ttlMs: DEFAULT_TTL_MS,
67
+ phaseTimeoutMs: DEFAULT_PHASE_TIMEOUT_MS,
68
+ requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS
69
+ };
70
+ const values = [...argv];
71
+ if (values[0] === 'diagnose' && values[1] === 'transport-physical') values.splice(0, 2);
72
+ for (let index = 0; index < values.length; index += 1) {
73
+ const flag = values[index];
74
+ const next = values[index + 1];
75
+ if (flag === '--hub' && next) {
76
+ args.hubUrl = next;
77
+ index += 1;
78
+ } else if (flag === '--device' && next) {
79
+ args.deviceId = next;
80
+ index += 1;
81
+ } else if (flag === '--report' && next) {
82
+ args.reportPath = path.resolve(next);
83
+ index += 1;
84
+ } else if (flag === '--ttl' && next) {
85
+ args.ttlMs = Number(next);
86
+ index += 1;
87
+ } else if (flag === '--phase-timeout' && next) {
88
+ args.phaseTimeoutMs = Number(next);
89
+ index += 1;
90
+ } else if (flag === '--request-timeout' && next) {
91
+ args.requestTimeoutMs = Number(next);
92
+ index += 1;
93
+ } else if (flag === '--help' || flag === '-h') {
94
+ args.help = true;
95
+ } else {
96
+ throw new Error(`Unknown transport-physical option: ${flag}`);
97
+ }
98
+ }
99
+ return {
100
+ ...args,
101
+ ...normalizeTransportPhysicalTiming(args)
102
+ };
103
+ }
104
+
105
+ export function assertLoopbackHubUrl(value) {
106
+ let parsed;
107
+ try {
108
+ parsed = new URL(value);
109
+ } catch {
110
+ throw new Error('transport-physical requires a valid localhost HTTP Hub URL');
111
+ }
112
+ if (parsed.protocol !== 'http:'
113
+ || parsed.username
114
+ || parsed.password
115
+ || !['127.0.0.1', 'localhost', '[::1]', '::1'].includes(parsed.hostname)) {
116
+ throw new Error('transport-physical requires a localhost HTTP Hub URL');
117
+ }
118
+ return parsed.origin;
119
+ }
120
+
121
+ export function combineAbortSignals(signals = []) {
122
+ const controller = new AbortController();
123
+ const listeners = [];
124
+ let disposed = false;
125
+ const dispose = () => {
126
+ if (disposed) return;
127
+ disposed = true;
128
+ for (const [signal, listener] of listeners.splice(0)) {
129
+ signal.removeEventListener?.('abort', listener);
130
+ }
131
+ };
132
+ const abortFrom = () => {
133
+ if (!controller.signal.aborted) {
134
+ controller.abort(new Error('transport diagnostic aborted'));
135
+ }
136
+ dispose();
137
+ };
138
+ for (const signal of signals.filter(Boolean)) {
139
+ if (signal.aborted) {
140
+ abortFrom();
141
+ break;
142
+ }
143
+ const listener = () => abortFrom();
144
+ signal.addEventListener?.('abort', listener, { once: true });
145
+ listeners.push([signal, listener]);
146
+ }
147
+ return { signal: controller.signal, dispose };
148
+ }
149
+
150
+ export function abortableDelay(delayMs, signal) {
151
+ return new Promise((resolve, reject) => {
152
+ let settled = false;
153
+ let timer;
154
+ const finish = (callback, value) => {
155
+ if (settled) return;
156
+ settled = true;
157
+ clearTimeout(timer);
158
+ signal?.removeEventListener?.('abort', onAbort);
159
+ callback(value);
160
+ };
161
+ const onAbort = () => finish(
162
+ reject,
163
+ signal?.reason instanceof Error
164
+ ? signal.reason
165
+ : new Error('transport diagnostic aborted'));
166
+ if (signal?.aborted) {
167
+ onAbort();
168
+ return;
169
+ }
170
+ signal?.addEventListener?.('abort', onAbort, { once: true });
171
+ timer = setTimeout(() => finish(resolve), Math.max(0, Number(delayMs) || 0));
172
+ });
173
+ }
174
+
175
+ export async function requestJson(
176
+ url,
177
+ {
178
+ method = 'GET',
179
+ token = '',
180
+ body,
181
+ signal,
182
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS
183
+ } = {}
184
+ ) {
185
+ const timeoutController = new AbortController();
186
+ const timeout = setTimeout(
187
+ () => timeoutController.abort(new Error('Hub request timed out.')),
188
+ Math.max(1, Number(requestTimeoutMs) || DEFAULT_REQUEST_TIMEOUT_MS));
189
+ const combined = combineAbortSignals([signal, timeoutController.signal]);
190
+ try {
191
+ const response = await fetch(url, {
192
+ method,
193
+ signal: combined.signal,
194
+ headers: {
195
+ accept: 'application/json',
196
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
197
+ ...(token ? { 'x-livedesk-diagnostic-token': token } : {})
198
+ },
199
+ body: body === undefined ? undefined : JSON.stringify(body)
200
+ });
201
+ const payload = await response.json().catch(() => ({}));
202
+ if (!response.ok || payload?.ok === false) {
203
+ throw new Error(genericTransportPhysicalError(
204
+ payload?.error,
205
+ `Hub request failed with status ${response.status}.`));
206
+ }
207
+ return payload;
208
+ } catch (error) {
209
+ if (timeoutController.signal.aborted && !signal?.aborted) {
210
+ throw new Error('Hub request timed out.');
211
+ }
212
+ if (signal?.aborted || combined.signal.aborted) {
213
+ throw new Error('transport diagnostic aborted');
214
+ }
215
+ throw new Error(genericTransportPhysicalError(error, 'Hub request failed.'));
216
+ } finally {
217
+ clearTimeout(timeout);
218
+ combined.dispose();
219
+ }
220
+ }
221
+
222
+ export function hasConfirmedTransportPhysicalRelease(snapshot) {
223
+ return snapshot?.state === 'completed'
224
+ && Array.isArray(snapshot?.cleanup)
225
+ && snapshot.cleanup.some(entry => entry?.state === 'agent-released');
226
+ }
227
+
228
+ export async function cleanupTransportPhysicalLease({
229
+ hubUrl,
230
+ leaseId,
231
+ token,
232
+ reason = 'runner-failed',
233
+ initialSnapshot = null,
234
+ cleanupTimeoutMs = CLEANUP_CONFIRM_TIMEOUT_MS,
235
+ requestTimeoutMs = CONTROL_REQUEST_TIMEOUT_MS,
236
+ retryDelayMs = CLEANUP_RETRY_DELAY_MS
237
+ }) {
238
+ const deadline = Date.now() + Math.max(1, Number(cleanupTimeoutMs) || CLEANUP_CONFIRM_TIMEOUT_MS);
239
+ let latest = initialSnapshot;
240
+ let lastError = null;
241
+ let attempts = 0;
242
+ while (Date.now() < deadline) {
243
+ attempts += 1;
244
+ const remainingMs = Math.max(1, deadline - Date.now());
245
+ try {
246
+ latest = await requestJson(
247
+ `${hubUrl}/api/remote/diagnostics/transport/${encodeURIComponent(leaseId)}`,
248
+ {
249
+ method: 'DELETE',
250
+ token,
251
+ body: { reason },
252
+ requestTimeoutMs: Math.min(
253
+ Math.max(1, Number(requestTimeoutMs) || CONTROL_REQUEST_TIMEOUT_MS),
254
+ remainingMs)
255
+ });
256
+ lastError = null;
257
+ if (hasConfirmedTransportPhysicalRelease(latest)) {
258
+ return { confirmed: true, snapshot: latest, attempts, error: '' };
259
+ }
260
+ } catch (error) {
261
+ lastError = error;
262
+ }
263
+ const retryRemainingMs = deadline - Date.now();
264
+ if (retryRemainingMs <= 0) break;
265
+ await abortableDelay(Math.min(
266
+ Math.max(1, Number(retryDelayMs) || CLEANUP_RETRY_DELAY_MS),
267
+ retryRemainingMs));
268
+ }
269
+ return {
270
+ confirmed: false,
271
+ snapshot: latest,
272
+ attempts,
273
+ error: genericTransportPhysicalError(
274
+ latest ? latest.error : lastError,
275
+ 'Hub did not confirm Agent diagnostic release.')
276
+ };
277
+ }
278
+
279
+ async function chooseDevice(hubUrl, requestedDeviceId, signal, requestTimeoutMs) {
280
+ if (requestedDeviceId) return requestedDeviceId;
281
+ const response = await requestJson(`${hubUrl}/api/remote/devices`, {
282
+ signal,
283
+ requestTimeoutMs
284
+ });
285
+ const candidates = Array.isArray(response?.devices) ? response.devices : [];
286
+ const candidate = candidates.find(device =>
287
+ device?.connected === true
288
+ && device?.latestLiveFrame?.currentGenerationVerified === true
289
+ && String(device?.latestLiveFrame?.mimeType || '').toLowerCase() === 'video/h264'
290
+ && String(device?.latestLiveFrame?.frameTransportActive || '').toLowerCase() === 'direct');
291
+ if (!candidate?.deviceId) {
292
+ throw new Error('No connected Client has a current Direct H.264 Wall/Control stream.');
293
+ }
294
+ return candidate.deviceId;
295
+ }
296
+
297
+ async function waitForPhase(
298
+ hubUrl,
299
+ leaseId,
300
+ token,
301
+ phase,
302
+ deadline,
303
+ signal,
304
+ requestTimeoutMs
305
+ ) {
306
+ let latest = null;
307
+ while (Date.now() < deadline) {
308
+ signal?.throwIfAborted?.();
309
+ const remainingMs = Math.max(1, deadline - Date.now());
310
+ latest = await requestJson(
311
+ `${hubUrl}/api/remote/diagnostics/transport/${encodeURIComponent(leaseId)}`,
312
+ {
313
+ token,
314
+ signal,
315
+ requestTimeoutMs: Math.min(requestTimeoutMs, remainingMs)
316
+ });
317
+ if (latest.frames?.some(frame => isTransportPhysicalPhaseEvidence(frame, phase))) {
318
+ return latest;
319
+ }
320
+ if (latest.state !== 'active') {
321
+ throw new Error(genericTransportPhysicalError(
322
+ latest.error,
323
+ `transport diagnostic stopped during ${phase}`));
324
+ }
325
+ await abortableDelay(Math.min(200, Math.max(1, deadline - Date.now())), signal);
326
+ }
327
+ throw new Error(`Timed out waiting for a received ${phase} H.264 SPS+PPS+IDR.`);
328
+ }
329
+
330
+ async function writeStableJson(reportPath, report) {
331
+ await fs.mkdir(path.dirname(reportPath), { recursive: true });
332
+ const temporaryPath = `${reportPath}.${process.pid}.tmp`;
333
+ await fs.writeFile(temporaryPath, `${JSON.stringify(report, null, 2)}\n`, {
334
+ encoding: 'utf8',
335
+ mode: 0o600
336
+ });
337
+ await fs.rename(temporaryPath, reportPath);
338
+ }
339
+
340
+ function defaultReportPath() {
341
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
342
+ return path.join(os.homedir(), '.livedesk', 'diagnostics', `transport-physical-${stamp}.json`);
343
+ }
344
+
345
+ export async function runTransportPhysicalDiagnostic(options = {}) {
346
+ const hubUrl = assertLoopbackHubUrl(options.hubUrl || DEFAULT_HUB_URL);
347
+ const timing = normalizeTransportPhysicalTiming(options);
348
+ const lifecycleController = new AbortController();
349
+ const combined = combineAbortSignals([lifecycleController.signal, options.signal]);
350
+ const signal = combined.signal;
351
+ const reportPath = options.reportPath || defaultReportPath();
352
+ let deviceId = '';
353
+ let leaseId = '';
354
+ let token = '';
355
+ let latest = null;
356
+ let completed = false;
357
+ let runError = null;
358
+
359
+ const stopForSignal = () => {
360
+ if (!lifecycleController.signal.aborted) {
361
+ lifecycleController.abort(new Error('transport diagnostic interrupted'));
362
+ }
363
+ };
364
+ process.once('SIGINT', stopForSignal);
365
+ process.once('SIGTERM', stopForSignal);
366
+ try {
367
+ deviceId = await chooseDevice(
368
+ hubUrl,
369
+ options.deviceId || '',
370
+ signal,
371
+ timing.requestTimeoutMs);
372
+ const started = await requestJson(`${hubUrl}/api/remote/diagnostics/transport/start`, {
373
+ method: 'POST',
374
+ body: { deviceId, ttlMs: timing.ttlMs },
375
+ signal,
376
+ requestTimeoutMs: Math.max(timing.requestTimeoutMs, START_REQUEST_TIMEOUT_MS)
377
+ });
378
+ leaseId = started.leaseId;
379
+ token = started.token;
380
+ if (!leaseId || !token) throw new Error('Hub did not issue a diagnostic lease.');
381
+ const actualLeaseMs = Date.parse(started.expiresAt || '') - Date.parse(started.startedAt || '');
382
+ if (!Number.isFinite(actualLeaseMs) || actualLeaseMs < timing.requiredTtlMs) {
383
+ throw new Error('Hub diagnostic lease is shorter than the four-phase timing contract.');
384
+ }
385
+
386
+ let frameEvidenceDeadline = Date.now() + timing.phaseTimeoutMs;
387
+ latest = await waitForPhase(
388
+ hubUrl,
389
+ leaseId,
390
+ token,
391
+ REQUIRED_PHASES[0],
392
+ frameEvidenceDeadline,
393
+ signal,
394
+ timing.requestTimeoutMs);
395
+ for (const phase of REQUIRED_PHASES.slice(1)) {
396
+ await requestJson(
397
+ `${hubUrl}/api/remote/diagnostics/transport/${encodeURIComponent(leaseId)}/phase`,
398
+ {
399
+ method: 'POST',
400
+ token,
401
+ body: { phase },
402
+ signal,
403
+ requestTimeoutMs: Math.max(timing.requestTimeoutMs, CONTROL_REQUEST_TIMEOUT_MS)
404
+ });
405
+ frameEvidenceDeadline = Date.now() + timing.phaseTimeoutMs;
406
+ latest = await waitForPhase(
407
+ hubUrl,
408
+ leaseId,
409
+ token,
410
+ phase,
411
+ frameEvidenceDeadline,
412
+ signal,
413
+ timing.requestTimeoutMs);
414
+ }
415
+ } catch (error) {
416
+ runError = error;
417
+ } finally {
418
+ if (leaseId && token) {
419
+ const cleanup = await cleanupTransportPhysicalLease({
420
+ hubUrl,
421
+ leaseId,
422
+ token,
423
+ reason: runError
424
+ ? (signal.aborted ? 'runner-aborted' : 'runner-failed')
425
+ : 'runner-complete',
426
+ initialSnapshot: latest,
427
+ requestTimeoutMs: Math.max(timing.requestTimeoutMs, CONTROL_REQUEST_TIMEOUT_MS)
428
+ });
429
+ latest = cleanup.snapshot || latest;
430
+ completed = cleanup.confirmed;
431
+ if (!completed && !runError) {
432
+ runError = new Error(cleanup.error || 'Hub did not confirm diagnostic lease cleanup.');
433
+ }
434
+ }
435
+ process.removeListener('SIGINT', stopForSignal);
436
+ process.removeListener('SIGTERM', stopForSignal);
437
+ combined.dispose();
438
+ }
439
+
440
+ if (runError) {
441
+ latest = {
442
+ ...(latest || {}),
443
+ ok: false,
444
+ state: latest?.state === 'completed' ? latest.state : 'failed',
445
+ error: genericTransportPhysicalError(runError)
446
+ };
447
+ }
448
+ const report = buildTransportPhysicalReport(latest || {}, { hubUrl, deviceId });
449
+ await writeStableJson(reportPath, report);
450
+ if (runError) {
451
+ const failure = new Error(
452
+ `${genericTransportPhysicalError(runError)} (redacted report: ${reportPath})`);
453
+ failure.cause = runError;
454
+ throw failure;
455
+ }
456
+ return { report, reportPath };
457
+ }
458
+
459
+ function printHelp() {
460
+ console.log([
461
+ 'Usage: livedesk diagnose transport-physical [options]',
462
+ '',
463
+ ' --device <id> Connected Client device ID (auto-selects a Direct H.264 stream)',
464
+ ' --hub <localhost-url> Hub UI origin (default http://127.0.0.1:5179)',
465
+ ' --ttl <ms> Fail-closed diagnostic lease TTL (default 150000)',
466
+ ' --phase-timeout <ms> Per-path SPS+PPS+IDR timeout (3000-15000)',
467
+ ' --request-timeout <ms> Poll timeout (500-10000; control/start use safe minimums)',
468
+ ' --report <path> Redacted JSON report path',
469
+ '',
470
+ `Contract: ${REQUIRED_PHASES.length} frame budgets + acquire + `
471
+ + `${REQUIRED_PHASES.length - 1} transitions + release command budgets `
472
+ + `+ ${MIN_TTL_MARGIN_MS}ms margin <= TTL.`
473
+ ].join('\n'));
474
+ }
475
+
476
+ export async function runTransportPhysicalDiagnosticCli(argv = []) {
477
+ const args = parseTransportPhysicalArgs(argv);
478
+ if (args.help) {
479
+ printHelp();
480
+ return { status: 'help' };
481
+ }
482
+ const { report, reportPath } = await runTransportPhysicalDiagnostic(args);
483
+ console.log(`[LiveDesk] transport-physical ${report.result.status}: ${report.result.summary}`);
484
+ console.log(`[LiveDesk] redacted report: ${reportPath}`);
485
+ if (report.result.status !== 'passed') process.exitCode = 2;
486
+ return { status: report.result.status, report, reportPath };
487
+ }
488
+
489
+ const isMain = process.argv[1]
490
+ && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
491
+ if (isMain) {
492
+ runTransportPhysicalDiagnosticCli(process.argv.slice(2))
493
+ .catch(error => {
494
+ console.error(
495
+ `[LiveDesk] transport-physical failed: ${genericTransportPhysicalError(error)}`);
496
+ process.exitCode = 1;
497
+ });
498
+ }
499
+
500
+ export {
501
+ DEFAULT_PHASE_TIMEOUT_MS,
502
+ DEFAULT_REQUEST_TIMEOUT_MS,
503
+ DEFAULT_TTL_MS,
504
+ CLEANUP_CONFIRM_TIMEOUT_MS,
505
+ CONTROL_REQUEST_TIMEOUT_MS,
506
+ HUB_COMMAND_TIMEOUT_MS,
507
+ MIN_TTL_MARGIN_MS
508
+ };
package/hub/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",