@love-moon/conductor-cli 0.2.19 → 0.2.21
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/bin/conductor-channel.js +130 -0
- package/bin/conductor-config.js +1 -1
- package/bin/conductor-daemon.js +51 -0
- package/bin/conductor-diagnose.js +25 -0
- package/bin/conductor-fire.js +230 -1
- package/bin/conductor-update.js +15 -110
- package/bin/conductor.js +77 -52
- package/package.json +11 -4
- package/src/cli-update-notifier.js +241 -0
- package/src/daemon.js +1006 -32
- package/src/version-check.js +240 -0
package/src/daemon.js
CHANGED
|
@@ -11,15 +11,32 @@ import yaml from "js-yaml";
|
|
|
11
11
|
import { ConductorWebSocketClient, ConductorConfig, loadConfig, ConfigFileNotFound } from "@love-moon/conductor-sdk";
|
|
12
12
|
import { DaemonLogCollector } from "./log-collector.js";
|
|
13
13
|
import { filterRuntimeSupportedAllowCliList, normalizeRuntimeBackendName } from "./runtime-backends.js";
|
|
14
|
+
import {
|
|
15
|
+
PACKAGE_NAME,
|
|
16
|
+
fetchLatestVersion,
|
|
17
|
+
isNewerVersion,
|
|
18
|
+
detectPackageManager,
|
|
19
|
+
parseUpdateWindow,
|
|
20
|
+
isInUpdateWindow,
|
|
21
|
+
isManagedInstallPath,
|
|
22
|
+
} from "./version-check.js";
|
|
14
23
|
|
|
15
24
|
dotenv.config();
|
|
16
25
|
|
|
17
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
27
|
const __dirname = path.dirname(__filename);
|
|
28
|
+
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
19
29
|
const moduleRequire = createRequire(import.meta.url);
|
|
20
|
-
const CLI_PATH = path.resolve(
|
|
30
|
+
const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-fire.js");
|
|
21
31
|
const DAEMON_LOG_DIR = path.join(os.homedir(), ".conductor", "logs");
|
|
22
32
|
const DAEMON_LOG_PATH = path.join(DAEMON_LOG_DIR, "conductor-daemon.log");
|
|
33
|
+
const CLI_VERSION = (() => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
|
|
36
|
+
} catch {
|
|
37
|
+
return "unknown";
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
23
40
|
const PLAN_LIMIT_MESSAGES = {
|
|
24
41
|
manual_fire_active_task: "Free plan limit reached: only 1 active fire task is allowed.",
|
|
25
42
|
app_active_task: "Free plan limit reached: only 1 active app task is allowed.",
|
|
@@ -28,6 +45,7 @@ const PLAN_LIMIT_MESSAGES = {
|
|
|
28
45
|
const DEFAULT_TERMINAL_COLS = 120;
|
|
29
46
|
const DEFAULT_TERMINAL_ROWS = 40;
|
|
30
47
|
const DEFAULT_TERMINAL_RING_BUFFER_MAX_BYTES = 2 * 1024 * 1024;
|
|
48
|
+
const DEFAULT_RTC_MODULE_CANDIDATES = ["@roamhq/wrtc", "wrtc"];
|
|
31
49
|
let nodePtySpawnPromise = null;
|
|
32
50
|
|
|
33
51
|
function appendDaemonLog(line) {
|
|
@@ -199,6 +217,16 @@ function normalizeOptionalString(value) {
|
|
|
199
217
|
return normalized || null;
|
|
200
218
|
}
|
|
201
219
|
|
|
220
|
+
function normalizeStringArray(value) {
|
|
221
|
+
if (!Array.isArray(value)) {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
return value
|
|
225
|
+
.filter((entry) => typeof entry === "string")
|
|
226
|
+
.map((entry) => entry.trim())
|
|
227
|
+
.filter(Boolean);
|
|
228
|
+
}
|
|
229
|
+
|
|
202
230
|
function normalizePositiveInt(value, fallback) {
|
|
203
231
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
204
232
|
if (Number.isFinite(parsed) && parsed > 0) {
|
|
@@ -207,6 +235,25 @@ function normalizePositiveInt(value, fallback) {
|
|
|
207
235
|
return fallback;
|
|
208
236
|
}
|
|
209
237
|
|
|
238
|
+
function normalizeNonNegativeInt(value, fallback = null) {
|
|
239
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
240
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
241
|
+
return parsed;
|
|
242
|
+
}
|
|
243
|
+
return fallback;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeIsoTimestamp(value) {
|
|
247
|
+
if (typeof value !== "string") {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const normalized = value.trim();
|
|
251
|
+
if (!normalized) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
return Number.isNaN(Date.parse(normalized)) ? null : normalized;
|
|
255
|
+
}
|
|
256
|
+
|
|
210
257
|
function normalizeLaunchConfig(value) {
|
|
211
258
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
212
259
|
return {};
|
|
@@ -309,6 +356,43 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
309
356
|
// Get allow_cli_list from config
|
|
310
357
|
const ALLOW_CLI_LIST = getAllowCliList(userConfig);
|
|
311
358
|
const SUPPORTED_BACKENDS = Object.keys(ALLOW_CLI_LIST);
|
|
359
|
+
const fetchLatestVersionFn = deps.fetchLatestVersion || fetchLatestVersion;
|
|
360
|
+
const isNewerVersionFn = deps.isNewerVersion || isNewerVersion;
|
|
361
|
+
const detectPackageManagerFn = deps.detectPackageManager || detectPackageManager;
|
|
362
|
+
const parseUpdateWindowFn = deps.parseUpdateWindow || parseUpdateWindow;
|
|
363
|
+
const isInUpdateWindowFn = deps.isInUpdateWindow || isInUpdateWindow;
|
|
364
|
+
const isManagedInstallPathFn = deps.isManagedInstallPath || isManagedInstallPath;
|
|
365
|
+
const installedPackageRoot = deps.packageRoot || PACKAGE_ROOT;
|
|
366
|
+
const cliVersion = deps.cliVersion || CLI_VERSION;
|
|
367
|
+
const isBackgroundProcess = deps.isBackgroundProcess ?? !process.stdout.isTTY;
|
|
368
|
+
const autoUpdateForceLocal = parseBooleanEnv(process.env.CONDUCTOR_AUTO_UPDATE_FORCE_LOCAL);
|
|
369
|
+
const autoUpdateSupportedInstall =
|
|
370
|
+
autoUpdateForceLocal || isManagedInstallPathFn(installedPackageRoot);
|
|
371
|
+
const lockHandoffToken =
|
|
372
|
+
normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
|
|
373
|
+
normalizeOptionalString(process.env.CONDUCTOR_LOCK_HANDOFF_TOKEN);
|
|
374
|
+
const lockHandoffFromPid = normalizePositiveInt(
|
|
375
|
+
config.LOCK_HANDOFF_FROM_PID || process.env.CONDUCTOR_LOCK_HANDOFF_FROM_PID,
|
|
376
|
+
null,
|
|
377
|
+
);
|
|
378
|
+
const restartLauncherScript = normalizeOptionalString(config.RESTART_LAUNCHER_SCRIPT);
|
|
379
|
+
const restartLauncherArgs = normalizeStringArray(config.RESTART_LAUNCHER_ARGS);
|
|
380
|
+
const normalizedVersionCheckArgs = normalizeStringArray(config.VERSION_CHECK_ARGS);
|
|
381
|
+
const versionCheckScript =
|
|
382
|
+
normalizeOptionalString(config.VERSION_CHECK_SCRIPT) || restartLauncherScript;
|
|
383
|
+
const versionCheckArgs =
|
|
384
|
+
normalizedVersionCheckArgs.length > 0
|
|
385
|
+
? normalizedVersionCheckArgs
|
|
386
|
+
: ["--version"];
|
|
387
|
+
|
|
388
|
+
// Auto-update configuration
|
|
389
|
+
const AUTO_UPDATE_ENABLED =
|
|
390
|
+
autoUpdateSupportedInstall &&
|
|
391
|
+
(process.env.CONDUCTOR_AUTO_UPDATE !== "false") &&
|
|
392
|
+
(userConfig.auto_update !== false);
|
|
393
|
+
const UPDATE_WINDOW = parseUpdateWindowFn(
|
|
394
|
+
process.env.CONDUCTOR_UPDATE_WINDOW || userConfig.update_window || "02:00-04:00"
|
|
395
|
+
);
|
|
312
396
|
|
|
313
397
|
const spawnFn = deps.spawn || spawn;
|
|
314
398
|
const mkdirSyncFn = deps.mkdirSync || fs.mkdirSync;
|
|
@@ -319,10 +403,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
319
403
|
const renameSyncFn = deps.renameSync || fs.renameSync;
|
|
320
404
|
const createWriteStreamFn = deps.createWriteStream || fs.createWriteStream;
|
|
321
405
|
const fetchFn = deps.fetch || fetch;
|
|
406
|
+
const createRtcPeerConnection = deps.createRtcPeerConnection || null;
|
|
407
|
+
const importOptionalModule = deps.importOptionalModule || ((moduleName) => import(moduleName));
|
|
322
408
|
const createWebSocketClient =
|
|
323
409
|
deps.createWebSocketClient ||
|
|
324
410
|
((clientConfig, options) => new ConductorWebSocketClient(clientConfig, options));
|
|
325
411
|
const createLogCollector = deps.createLogCollector || ((backendUrl) => new DaemonLogCollector(backendUrl));
|
|
412
|
+
const RTC_MODULE_CANDIDATES = resolveRtcModuleCandidates(process.env.CONDUCTOR_PTY_RTC_MODULES);
|
|
413
|
+
const RTC_DIRECT_DISABLED = parseBooleanEnv(process.env.CONDUCTOR_DISABLE_PTY_DIRECT_RTC);
|
|
326
414
|
const PROJECT_PATH_LOOKUP_TIMEOUT_MS = parsePositiveInt(
|
|
327
415
|
process.env.CONDUCTOR_PROJECT_PATH_LOOKUP_TIMEOUT_MS,
|
|
328
416
|
1500,
|
|
@@ -368,6 +456,53 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
368
456
|
DEFAULT_TERMINAL_RING_BUFFER_MAX_BYTES,
|
|
369
457
|
);
|
|
370
458
|
|
|
459
|
+
const readLockState = () => {
|
|
460
|
+
const raw = String(readFileSyncFn(LOCK_FILE, "utf-8") || "").trim();
|
|
461
|
+
if (!raw) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const pid = Number.parseInt(raw, 10);
|
|
466
|
+
if (!Number.isNaN(pid) && pid > 0) {
|
|
467
|
+
return {
|
|
468
|
+
pid,
|
|
469
|
+
handoffFromPid: null,
|
|
470
|
+
handoffToken: null,
|
|
471
|
+
handoffExpiresAt: null,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const parsed = JSON.parse(raw);
|
|
477
|
+
const parsedPid = normalizePositiveInt(parsed?.pid, null);
|
|
478
|
+
const parsedHandoffFromPid = normalizePositiveInt(parsed?.handoff_from_pid, null);
|
|
479
|
+
return {
|
|
480
|
+
pid: parsedPid ?? parsedHandoffFromPid,
|
|
481
|
+
handoffFromPid: parsedHandoffFromPid,
|
|
482
|
+
handoffToken: normalizeOptionalString(parsed?.handoff_token),
|
|
483
|
+
handoffExpiresAt: normalizePositiveInt(parsed?.handoff_expires_at, null),
|
|
484
|
+
};
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const hasMatchingLockHandoff = (lockState) => {
|
|
491
|
+
if (!lockState || !lockHandoffToken || !lockHandoffFromPid) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
if (lockState.handoffToken !== lockHandoffToken) {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
if (lockState.handoffFromPid !== lockHandoffFromPid) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
if (lockState.handoffExpiresAt && lockState.handoffExpiresAt < Date.now()) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
return true;
|
|
504
|
+
};
|
|
505
|
+
|
|
371
506
|
try {
|
|
372
507
|
mkdirSyncFn(WORKSPACE_ROOT, { recursive: true });
|
|
373
508
|
} catch (err) {
|
|
@@ -378,44 +513,57 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
378
513
|
const LOCK_FILE = path.join(WORKSPACE_ROOT, "daemon.pid");
|
|
379
514
|
try {
|
|
380
515
|
if (existsSyncFn(LOCK_FILE)) {
|
|
381
|
-
const
|
|
382
|
-
|
|
516
|
+
const lockState = readLockState();
|
|
517
|
+
const pid = lockState?.pid;
|
|
518
|
+
if (pid) {
|
|
519
|
+
const handoffMatched = hasMatchingLockHandoff(lockState);
|
|
383
520
|
try {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
521
|
+
if (handoffMatched) {
|
|
522
|
+
log(`Taking over daemon lock from PID ${pid} via handoff`);
|
|
523
|
+
} else {
|
|
524
|
+
const alive = isProcessAlive(pid);
|
|
525
|
+
if (alive) {
|
|
526
|
+
if (config.FORCE) {
|
|
527
|
+
log(`Force enabled: stopping existing daemon PID ${pid}`);
|
|
528
|
+
try {
|
|
529
|
+
killFn(pid, "SIGTERM");
|
|
530
|
+
} catch (killErr) {
|
|
531
|
+
if (!killErr || killErr.code !== "ESRCH") {
|
|
532
|
+
logError(`Failed to stop existing daemon PID ${pid}: ${killErr.message}`);
|
|
533
|
+
return exitAndReturn(1);
|
|
534
|
+
}
|
|
394
535
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
536
|
+
try {
|
|
537
|
+
if (isProcessAlive(pid)) {
|
|
538
|
+
logError(`Existing daemon PID ${pid} is still running; please stop it manually.`);
|
|
539
|
+
return exitAndReturn(1);
|
|
540
|
+
}
|
|
541
|
+
} catch (checkErr) {
|
|
542
|
+
logError(`Failed to verify daemon PID ${pid}: ${checkErr.message}`);
|
|
399
543
|
return exitAndReturn(1);
|
|
400
544
|
}
|
|
401
|
-
|
|
402
|
-
|
|
545
|
+
log("Removing lock file after force stop");
|
|
546
|
+
unlinkSyncFn(LOCK_FILE);
|
|
547
|
+
} else {
|
|
548
|
+
logError(`Daemon already running with PID ${pid}`);
|
|
403
549
|
return exitAndReturn(1);
|
|
404
550
|
}
|
|
405
|
-
log("Removing lock file after force stop");
|
|
406
|
-
unlinkSyncFn(LOCK_FILE);
|
|
407
551
|
} else {
|
|
408
|
-
|
|
409
|
-
|
|
552
|
+
log("Removing stale lock file");
|
|
553
|
+
unlinkSyncFn(LOCK_FILE);
|
|
410
554
|
}
|
|
411
|
-
} else {
|
|
412
|
-
log("Removing stale lock file");
|
|
413
|
-
unlinkSyncFn(LOCK_FILE);
|
|
414
555
|
}
|
|
415
556
|
} catch (e) {
|
|
416
|
-
|
|
417
|
-
|
|
557
|
+
if (handoffMatched) {
|
|
558
|
+
log(`Taking over daemon lock from PID ${pid} via handoff`);
|
|
559
|
+
} else {
|
|
560
|
+
logError(`Daemon already running with PID ${pid} (access denied)`);
|
|
561
|
+
return exitAndReturn(1);
|
|
562
|
+
}
|
|
418
563
|
}
|
|
564
|
+
} else {
|
|
565
|
+
log("Removing malformed lock file");
|
|
566
|
+
unlinkSyncFn(LOCK_FILE);
|
|
419
567
|
}
|
|
420
568
|
}
|
|
421
569
|
writeFileSyncFn(LOCK_FILE, process.pid.toString());
|
|
@@ -427,8 +575,8 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
427
575
|
const cleanupLock = () => {
|
|
428
576
|
try {
|
|
429
577
|
if (existsSyncFn(LOCK_FILE)) {
|
|
430
|
-
const
|
|
431
|
-
if (pid === process.pid) {
|
|
578
|
+
const lockState = readLockState();
|
|
579
|
+
if (lockState?.pid === process.pid) {
|
|
432
580
|
unlinkSyncFn(LOCK_FILE);
|
|
433
581
|
}
|
|
434
582
|
}
|
|
@@ -437,6 +585,18 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
437
585
|
}
|
|
438
586
|
};
|
|
439
587
|
|
|
588
|
+
const writeLockHandoff = ({ handoffToken, handoffFromPid, handoffExpiresAt }) => {
|
|
589
|
+
writeFileSyncFn(
|
|
590
|
+
LOCK_FILE,
|
|
591
|
+
JSON.stringify({
|
|
592
|
+
pid: handoffFromPid,
|
|
593
|
+
handoff_from_pid: handoffFromPid,
|
|
594
|
+
handoff_token: handoffToken,
|
|
595
|
+
handoff_expires_at: handoffExpiresAt,
|
|
596
|
+
}),
|
|
597
|
+
);
|
|
598
|
+
};
|
|
599
|
+
|
|
440
600
|
process.on("exit", cleanupLock);
|
|
441
601
|
const signalExitCode = (signal) => (signal === "SIGINT" ? 130 : 143);
|
|
442
602
|
const handleSignal = (signal) => {
|
|
@@ -503,6 +663,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
503
663
|
let daemonShuttingDown = false;
|
|
504
664
|
const activeTaskProcesses = new Map();
|
|
505
665
|
const activePtySessions = new Map();
|
|
666
|
+
const activePtyRtcTransports = new Map();
|
|
506
667
|
const suppressedExitStatusReports = new Set();
|
|
507
668
|
const seenCommandRequestIds = new Set();
|
|
508
669
|
let lastConnectedAt = null;
|
|
@@ -519,6 +680,16 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
519
680
|
let watchdogLastPresenceMismatchAt = 0;
|
|
520
681
|
let watchdogAwaitingHealthySignalAt = null;
|
|
521
682
|
let watchdogTimer = null;
|
|
683
|
+
|
|
684
|
+
// --- Auto-update state ---
|
|
685
|
+
const VERSION_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
686
|
+
let lastVersionCheckAt = 0;
|
|
687
|
+
let latestKnownVersion = null;
|
|
688
|
+
let updateAvailable = false;
|
|
689
|
+
let autoUpdateInProgress = false;
|
|
690
|
+
|
|
691
|
+
let rtcImplementationPromise = null;
|
|
692
|
+
let rtcAvailabilityLogKey = null;
|
|
522
693
|
const logCollector = createLogCollector(BACKEND_HTTP);
|
|
523
694
|
const createPtyFn = deps.createPty || defaultCreatePty;
|
|
524
695
|
const client = createWebSocketClient(sdkConfig, {
|
|
@@ -526,6 +697,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
526
697
|
"x-conductor-host": AGENT_NAME,
|
|
527
698
|
"x-conductor-backends": SUPPORTED_BACKENDS.join(","),
|
|
528
699
|
"x-conductor-capabilities": "pty_task",
|
|
700
|
+
"x-conductor-version": cliVersion,
|
|
529
701
|
},
|
|
530
702
|
onConnected: ({ isReconnect, connectedAt } = { isReconnect: false, connectedAt: Date.now() }) => {
|
|
531
703
|
wsConnected = true;
|
|
@@ -583,8 +755,15 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
583
755
|
logError(`Failed to connect: ${err}`);
|
|
584
756
|
});
|
|
585
757
|
|
|
758
|
+
if (!AUTO_UPDATE_ENABLED && autoUpdateSupportedInstall === false) {
|
|
759
|
+
log("[auto-update] Disabled for local/dev install; set CONDUCTOR_AUTO_UPDATE_FORCE_LOCAL=true to override");
|
|
760
|
+
}
|
|
761
|
+
|
|
586
762
|
watchdogTimer = setInterval(() => {
|
|
587
763
|
void runDaemonWatchdog();
|
|
764
|
+
// Auto-update checks (internally throttled)
|
|
765
|
+
void checkForUpdate().catch(() => {});
|
|
766
|
+
void tryAutoUpdate().catch(() => {});
|
|
588
767
|
}, DAEMON_WATCHDOG_INTERVAL_MS);
|
|
589
768
|
if (typeof watchdogTimer?.unref === "function") {
|
|
590
769
|
watchdogTimer.unref();
|
|
@@ -742,6 +921,260 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
742
921
|
}
|
|
743
922
|
}
|
|
744
923
|
|
|
924
|
+
// ---------------------------------------------------------------------------
|
|
925
|
+
// Auto-update: periodic version check (P1) + safe-window update (P2)
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
|
|
929
|
+
|
|
930
|
+
async function checkForUpdate() {
|
|
931
|
+
if (!AUTO_UPDATE_ENABLED || cliVersion === "unknown") return;
|
|
932
|
+
const now = Date.now();
|
|
933
|
+
if (now - lastVersionCheckAt < VERSION_CHECK_INTERVAL_MS) return;
|
|
934
|
+
lastVersionCheckAt = now;
|
|
935
|
+
try {
|
|
936
|
+
const latest = await fetchLatestVersionFn();
|
|
937
|
+
if (!latest || !SEMVER_RE.test(latest)) return; // reject malformed versions
|
|
938
|
+
if (isNewerVersionFn(latest, cliVersion)) {
|
|
939
|
+
if (latestKnownVersion !== latest) {
|
|
940
|
+
log(`[auto-update] New version available: ${cliVersion} -> ${latest}`);
|
|
941
|
+
}
|
|
942
|
+
latestKnownVersion = latest;
|
|
943
|
+
updateAvailable = true;
|
|
944
|
+
} else {
|
|
945
|
+
updateAvailable = false;
|
|
946
|
+
latestKnownVersion = latest;
|
|
947
|
+
}
|
|
948
|
+
} catch {
|
|
949
|
+
/* silent — non-critical */
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const AUTO_UPDATE_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour cooldown after failure
|
|
954
|
+
let lastAutoUpdateFailAt = 0;
|
|
955
|
+
|
|
956
|
+
function hasActiveTasks() {
|
|
957
|
+
return activeTaskProcesses.size > 0 || activePtySessions.size > 0;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function tryAutoUpdate() {
|
|
961
|
+
if (!AUTO_UPDATE_ENABLED || !updateAvailable || !latestKnownVersion) return;
|
|
962
|
+
if (autoUpdateInProgress || daemonShuttingDown) return;
|
|
963
|
+
if (hasActiveTasks()) return;
|
|
964
|
+
if (!isInUpdateWindowFn(UPDATE_WINDOW)) return;
|
|
965
|
+
if (Date.now() - lastAutoUpdateFailAt < AUTO_UPDATE_COOLDOWN_MS) return;
|
|
966
|
+
|
|
967
|
+
autoUpdateInProgress = true;
|
|
968
|
+
log(`[auto-update] Starting: ${cliVersion} -> ${latestKnownVersion}`);
|
|
969
|
+
try {
|
|
970
|
+
await performAutoUpdate(latestKnownVersion);
|
|
971
|
+
} catch (err) {
|
|
972
|
+
logError(`[auto-update] Failed: ${err?.message || err}`);
|
|
973
|
+
lastAutoUpdateFailAt = Date.now();
|
|
974
|
+
} finally {
|
|
975
|
+
autoUpdateInProgress = false;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function runInstallCommand(pm, pkgSpec) {
|
|
980
|
+
return new Promise((resolve) => {
|
|
981
|
+
let cmd, args;
|
|
982
|
+
switch (pm) {
|
|
983
|
+
case "pnpm":
|
|
984
|
+
cmd = "pnpm";
|
|
985
|
+
args = ["add", "-g", pkgSpec];
|
|
986
|
+
break;
|
|
987
|
+
case "yarn":
|
|
988
|
+
cmd = "yarn";
|
|
989
|
+
args = ["global", "add", pkgSpec];
|
|
990
|
+
break;
|
|
991
|
+
default:
|
|
992
|
+
cmd = "npm";
|
|
993
|
+
args = ["install", "-g", pkgSpec];
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
let stdout = "";
|
|
997
|
+
let stderr = "";
|
|
998
|
+
const child = spawnFn(cmd, args, {
|
|
999
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1000
|
+
});
|
|
1001
|
+
const timer = setTimeout(() => {
|
|
1002
|
+
try {
|
|
1003
|
+
child.kill("SIGTERM");
|
|
1004
|
+
} catch {
|
|
1005
|
+
/* ignore */
|
|
1006
|
+
}
|
|
1007
|
+
}, 120_000);
|
|
1008
|
+
child.stdout?.on("data", (d) => {
|
|
1009
|
+
if (stdout.length < 4000) stdout += d.toString().slice(0, 2000);
|
|
1010
|
+
});
|
|
1011
|
+
child.stderr?.on("data", (d) => {
|
|
1012
|
+
if (stderr.length < 4000) stderr += d.toString().slice(0, 2000);
|
|
1013
|
+
});
|
|
1014
|
+
child.on("close", (code) => {
|
|
1015
|
+
clearTimeout(timer);
|
|
1016
|
+
resolve({ success: code === 0, code, stdout, stderr });
|
|
1017
|
+
});
|
|
1018
|
+
child.on("error", (err) => {
|
|
1019
|
+
clearTimeout(timer);
|
|
1020
|
+
resolve({ success: false, code: -1, stdout, stderr: err.message });
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function runCommand(command, args, timeoutMs = 120_000) {
|
|
1026
|
+
return new Promise((resolve) => {
|
|
1027
|
+
let stdout = "";
|
|
1028
|
+
let stderr = "";
|
|
1029
|
+
const child = spawnFn(command, args, {
|
|
1030
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1031
|
+
env: { ...process.env },
|
|
1032
|
+
});
|
|
1033
|
+
const timer = setTimeout(() => {
|
|
1034
|
+
try {
|
|
1035
|
+
child.kill("SIGTERM");
|
|
1036
|
+
} catch {
|
|
1037
|
+
/* ignore */
|
|
1038
|
+
}
|
|
1039
|
+
}, timeoutMs);
|
|
1040
|
+
child.stdout?.on("data", (chunk) => {
|
|
1041
|
+
if (stdout.length < 4000) stdout += chunk.toString().slice(0, 2000);
|
|
1042
|
+
});
|
|
1043
|
+
child.stderr?.on("data", (chunk) => {
|
|
1044
|
+
if (stderr.length < 4000) stderr += chunk.toString().slice(0, 2000);
|
|
1045
|
+
});
|
|
1046
|
+
child.on("close", (code) => {
|
|
1047
|
+
clearTimeout(timer);
|
|
1048
|
+
resolve({ success: code === 0, code, stdout, stderr });
|
|
1049
|
+
});
|
|
1050
|
+
child.on("error", (err) => {
|
|
1051
|
+
clearTimeout(timer);
|
|
1052
|
+
resolve({ success: false, code: -1, stdout, stderr: err.message || String(err) });
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
async function readInstalledCliVersion() {
|
|
1058
|
+
const commandAttempts = versionCheckScript
|
|
1059
|
+
? [{
|
|
1060
|
+
command: process.execPath,
|
|
1061
|
+
args: [versionCheckScript, ...versionCheckArgs],
|
|
1062
|
+
}]
|
|
1063
|
+
: [{
|
|
1064
|
+
command: "conductor",
|
|
1065
|
+
args: ["--version"],
|
|
1066
|
+
}];
|
|
1067
|
+
|
|
1068
|
+
for (const attempt of commandAttempts) {
|
|
1069
|
+
const commandResult = await runCommand(attempt.command, attempt.args, 15_000);
|
|
1070
|
+
const combinedOutput = `${commandResult.stdout}\n${commandResult.stderr}`.trim();
|
|
1071
|
+
const match = combinedOutput.match(/conductor version ([^\s]+)/);
|
|
1072
|
+
if (match?.[1]) {
|
|
1073
|
+
return match[1];
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
let pkgPath;
|
|
1078
|
+
try {
|
|
1079
|
+
pkgPath = moduleRequire.resolve(`${PACKAGE_NAME}/package.json`);
|
|
1080
|
+
} catch {
|
|
1081
|
+
pkgPath = path.join(installedPackageRoot, "package.json");
|
|
1082
|
+
}
|
|
1083
|
+
const newPkg = JSON.parse(readFileSyncFn(pkgPath, "utf-8"));
|
|
1084
|
+
return newPkg.version || null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async function performAutoUpdate(targetVersion) {
|
|
1088
|
+
const pm = detectPackageManagerFn({
|
|
1089
|
+
launcherPath: restartLauncherScript || versionCheckScript,
|
|
1090
|
+
packageRoot: installedPackageRoot,
|
|
1091
|
+
});
|
|
1092
|
+
const pkgSpec = `${PACKAGE_NAME}@${targetVersion}`;
|
|
1093
|
+
log(`[auto-update] Installing ${pkgSpec} via ${pm}...`);
|
|
1094
|
+
|
|
1095
|
+
// Step 1: install
|
|
1096
|
+
const result = await runInstallCommand(pm, pkgSpec);
|
|
1097
|
+
if (!result.success) {
|
|
1098
|
+
throw new Error(
|
|
1099
|
+
`Install failed (exit ${result.code}): ${(result.stderr || result.stdout).slice(0, 200)}`
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Step 2: re-check active tasks — a task may have arrived during the install
|
|
1104
|
+
if (hasActiveTasks()) {
|
|
1105
|
+
log("[auto-update] Active tasks appeared during install; aborting restart (will retry later)");
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Step 3: verify installed version using the globally resolved CLI entry point.
|
|
1110
|
+
try {
|
|
1111
|
+
const installedVersion = await readInstalledCliVersion();
|
|
1112
|
+
if (installedVersion !== targetVersion) {
|
|
1113
|
+
throw new Error(
|
|
1114
|
+
`Version mismatch after install: expected ${targetVersion}, got ${installedVersion}`
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
} catch (verifyErr) {
|
|
1118
|
+
throw new Error(`Version verification failed: ${verifyErr?.message || verifyErr}`);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
log(`[auto-update] Verified ${targetVersion}. Restarting daemon...`);
|
|
1122
|
+
|
|
1123
|
+
let logFd = null;
|
|
1124
|
+
if (isBackgroundProcess) {
|
|
1125
|
+
if (!restartLauncherScript) {
|
|
1126
|
+
throw new Error("Missing daemon restart launcher script");
|
|
1127
|
+
}
|
|
1128
|
+
try {
|
|
1129
|
+
mkdirSyncFn(DAEMON_LOG_DIR, { recursive: true });
|
|
1130
|
+
} catch {
|
|
1131
|
+
/* ignore */
|
|
1132
|
+
}
|
|
1133
|
+
logFd = fs.openSync(DAEMON_LOG_PATH, "a");
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Step 4: graceful shutdown
|
|
1137
|
+
await shutdownDaemon("auto-update");
|
|
1138
|
+
|
|
1139
|
+
// Step 5: re-spawn (only in background/nohup mode)
|
|
1140
|
+
if (isBackgroundProcess) {
|
|
1141
|
+
const handoffToken = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1142
|
+
const handoffExpiresAt = Date.now() + 15_000;
|
|
1143
|
+
try {
|
|
1144
|
+
writeLockHandoff({
|
|
1145
|
+
handoffToken,
|
|
1146
|
+
handoffFromPid: process.pid,
|
|
1147
|
+
handoffExpiresAt,
|
|
1148
|
+
});
|
|
1149
|
+
const child = spawnFn(process.execPath, [restartLauncherScript, ...restartLauncherArgs], {
|
|
1150
|
+
detached: true,
|
|
1151
|
+
stdio: ["ignore", logFd, logFd],
|
|
1152
|
+
env: {
|
|
1153
|
+
...process.env,
|
|
1154
|
+
CONDUCTOR_LOCK_HANDOFF_TOKEN: handoffToken,
|
|
1155
|
+
CONDUCTOR_LOCK_HANDOFF_FROM_PID: String(process.pid),
|
|
1156
|
+
CONDUCTOR_LOCK_HANDOFF_EXPIRES_AT: String(handoffExpiresAt),
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
child.unref();
|
|
1160
|
+
log(`[auto-update] New daemon spawned (PID ${child.pid})`);
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
cleanupLock();
|
|
1163
|
+
exitFn(1);
|
|
1164
|
+
throw new Error(`Restart failed after shutdown: ${error?.message || error}`);
|
|
1165
|
+
} finally {
|
|
1166
|
+
if (typeof logFd === "number") {
|
|
1167
|
+
fs.closeSync(logFd);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
} else {
|
|
1171
|
+
log(
|
|
1172
|
+
`[auto-update] Updated to ${targetVersion}. Foreground mode — please restart the daemon.`
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
exitFn(0);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
745
1178
|
const getActiveTaskIds = () => [
|
|
746
1179
|
...new Set([...activeTaskProcesses.keys(), ...activePtySessions.keys()]),
|
|
747
1180
|
];
|
|
@@ -908,6 +1341,285 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
908
1341
|
});
|
|
909
1342
|
}
|
|
910
1343
|
|
|
1344
|
+
function sendPtyTransportStatus(payload) {
|
|
1345
|
+
return client.sendJson({
|
|
1346
|
+
type: "pty_transport_status",
|
|
1347
|
+
payload,
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function rejectCreateTaskDuringShutdown(payload, { sendAck = true } = {}) {
|
|
1352
|
+
const taskId = payload?.task_id ? String(payload.task_id) : "";
|
|
1353
|
+
const projectId = payload?.project_id ? String(payload.project_id) : "";
|
|
1354
|
+
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
1355
|
+
log(`Rejecting create_task for ${taskId || "unknown"}: daemon is shutting down`);
|
|
1356
|
+
if (sendAck) {
|
|
1357
|
+
sendAgentCommandAck({
|
|
1358
|
+
requestId,
|
|
1359
|
+
taskId,
|
|
1360
|
+
eventType: "create_task",
|
|
1361
|
+
accepted: false,
|
|
1362
|
+
}).catch(() => {});
|
|
1363
|
+
}
|
|
1364
|
+
if (!taskId || !projectId) {
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
client
|
|
1368
|
+
.sendJson({
|
|
1369
|
+
type: "task_status_update",
|
|
1370
|
+
payload: {
|
|
1371
|
+
task_id: taskId,
|
|
1372
|
+
project_id: projectId,
|
|
1373
|
+
status: "KILLED",
|
|
1374
|
+
summary: "daemon shutting down",
|
|
1375
|
+
},
|
|
1376
|
+
})
|
|
1377
|
+
.catch((err) => {
|
|
1378
|
+
logError(`Failed to report shutdown rejection for ${taskId}: ${err?.message || err}`);
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function rejectCreatePtyTaskDuringShutdown(payload, { sendAck = true } = {}) {
|
|
1383
|
+
const taskId = payload?.task_id ? String(payload.task_id) : "";
|
|
1384
|
+
const projectId = payload?.project_id ? String(payload.project_id) : "";
|
|
1385
|
+
const ptySessionId = payload?.pty_session_id ? String(payload.pty_session_id) : null;
|
|
1386
|
+
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
1387
|
+
log(`Rejecting create_pty_task for ${taskId || "unknown"}: daemon is shutting down`);
|
|
1388
|
+
if (sendAck) {
|
|
1389
|
+
sendAgentCommandAck({
|
|
1390
|
+
requestId,
|
|
1391
|
+
taskId,
|
|
1392
|
+
eventType: "create_pty_task",
|
|
1393
|
+
accepted: false,
|
|
1394
|
+
}).catch(() => {});
|
|
1395
|
+
}
|
|
1396
|
+
sendTerminalEvent("terminal_error", {
|
|
1397
|
+
task_id: taskId || undefined,
|
|
1398
|
+
project_id: projectId || undefined,
|
|
1399
|
+
pty_session_id: ptySessionId,
|
|
1400
|
+
message: "daemon shutting down",
|
|
1401
|
+
}).catch((err) => {
|
|
1402
|
+
logError(`Failed to report PTY shutdown rejection for ${taskId || "unknown"}: ${err?.message || err}`);
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function sendPtyTransportSignal(payload) {
|
|
1407
|
+
return client.sendJson({
|
|
1408
|
+
type: "pty_transport_signal",
|
|
1409
|
+
payload,
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function logRtcAvailabilityOnce(key, message) {
|
|
1414
|
+
if (rtcAvailabilityLogKey === key) {
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
rtcAvailabilityLogKey = key;
|
|
1418
|
+
log(message);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
async function resolveRtcImplementation() {
|
|
1422
|
+
if (RTC_DIRECT_DISABLED) {
|
|
1423
|
+
logRtcAvailabilityOnce(
|
|
1424
|
+
"disabled",
|
|
1425
|
+
"PTY direct RTC runtime disabled by CONDUCTOR_DISABLE_PTY_DIRECT_RTC=1; relay fallback only",
|
|
1426
|
+
);
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
if (createRtcPeerConnection) {
|
|
1431
|
+
logRtcAvailabilityOnce("ready:deps", "PTY direct RTC runtime ready via injected peer connection");
|
|
1432
|
+
return {
|
|
1433
|
+
source: "deps.createRtcPeerConnection",
|
|
1434
|
+
createPeerConnection: (...args) => createRtcPeerConnection(...args),
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (typeof globalThis.RTCPeerConnection === "function") {
|
|
1439
|
+
logRtcAvailabilityOnce("ready:global", "PTY direct RTC runtime ready via globalThis.RTCPeerConnection");
|
|
1440
|
+
return {
|
|
1441
|
+
source: "globalThis.RTCPeerConnection",
|
|
1442
|
+
createPeerConnection: (...args) => new globalThis.RTCPeerConnection(...args),
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if (!rtcImplementationPromise) {
|
|
1447
|
+
rtcImplementationPromise = (async () => {
|
|
1448
|
+
for (const moduleName of RTC_MODULE_CANDIDATES) {
|
|
1449
|
+
try {
|
|
1450
|
+
const mod = await importOptionalModule(moduleName);
|
|
1451
|
+
const PeerConnectionCtor =
|
|
1452
|
+
mod?.RTCPeerConnection ||
|
|
1453
|
+
mod?.default?.RTCPeerConnection ||
|
|
1454
|
+
mod?.default;
|
|
1455
|
+
if (typeof PeerConnectionCtor === "function") {
|
|
1456
|
+
return {
|
|
1457
|
+
source: moduleName,
|
|
1458
|
+
createPeerConnection: (...args) => new PeerConnectionCtor(...args),
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
} catch {
|
|
1462
|
+
// Try next implementation.
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
return null;
|
|
1466
|
+
})();
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const rtc = await rtcImplementationPromise;
|
|
1470
|
+
if (rtc) {
|
|
1471
|
+
logRtcAvailabilityOnce(`ready:${rtc.source}`, `PTY direct RTC runtime ready via ${rtc.source}`);
|
|
1472
|
+
return rtc;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
logRtcAvailabilityOnce(
|
|
1476
|
+
"unavailable",
|
|
1477
|
+
`PTY direct RTC runtime unavailable; install optional dependency ${DEFAULT_RTC_MODULE_CANDIDATES[0]} or keep relay fallback`,
|
|
1478
|
+
);
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function cleanupPtyRtcTransport(taskId, expectedSessionId = null) {
|
|
1483
|
+
const current = activePtyRtcTransports.get(taskId);
|
|
1484
|
+
if (!current) {
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (expectedSessionId && current.sessionId !== expectedSessionId) {
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
try {
|
|
1491
|
+
current.channel?.close?.();
|
|
1492
|
+
} catch {}
|
|
1493
|
+
try {
|
|
1494
|
+
current.peer?.close?.();
|
|
1495
|
+
} catch {}
|
|
1496
|
+
activePtyRtcTransports.delete(taskId);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
async function startPtyRtcNegotiation(taskId, sessionId, connectionId, offerDescription) {
|
|
1500
|
+
const record = activePtySessions.get(taskId);
|
|
1501
|
+
if (!record) {
|
|
1502
|
+
return { ok: false, reason: "terminal_session_not_found" };
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
const rtc = await resolveRtcImplementation();
|
|
1506
|
+
if (!rtc) {
|
|
1507
|
+
return { ok: false, reason: "direct_transport_not_supported" };
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
cleanupPtyRtcTransport(taskId);
|
|
1511
|
+
|
|
1512
|
+
try {
|
|
1513
|
+
const peer = rtc.createPeerConnection();
|
|
1514
|
+
const transport = {
|
|
1515
|
+
taskId,
|
|
1516
|
+
sessionId,
|
|
1517
|
+
connectionId,
|
|
1518
|
+
peer,
|
|
1519
|
+
channel: null,
|
|
1520
|
+
};
|
|
1521
|
+
activePtyRtcTransports.set(taskId, transport);
|
|
1522
|
+
|
|
1523
|
+
peer.ondatachannel = (event) => {
|
|
1524
|
+
transport.channel = event?.channel || null;
|
|
1525
|
+
if (transport.channel) {
|
|
1526
|
+
transport.channel.onmessage = (messageEvent) => {
|
|
1527
|
+
try {
|
|
1528
|
+
const raw =
|
|
1529
|
+
typeof messageEvent?.data === "string"
|
|
1530
|
+
? messageEvent.data
|
|
1531
|
+
: Buffer.isBuffer(messageEvent?.data)
|
|
1532
|
+
? messageEvent.data.toString("utf8")
|
|
1533
|
+
: String(messageEvent?.data ?? "");
|
|
1534
|
+
const parsed = JSON.parse(raw);
|
|
1535
|
+
handleDirectTransportPayload(taskId, sessionId, connectionId, parsed);
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
logError(`Failed to handle PTY direct channel message for ${taskId}: ${error?.message || error}`);
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
transport.channel.onopen = () => {
|
|
1541
|
+
sendPtyTransportStatus({
|
|
1542
|
+
task_id: taskId,
|
|
1543
|
+
session_id: sessionId,
|
|
1544
|
+
connection_id: connectionId,
|
|
1545
|
+
transport_state: "direct",
|
|
1546
|
+
transport_policy: "direct_preferred",
|
|
1547
|
+
writer_connection_id: connectionId,
|
|
1548
|
+
direct_candidate: true,
|
|
1549
|
+
}).catch((err) => {
|
|
1550
|
+
logError(`Failed to report direct PTY transport status for ${taskId}: ${err?.message || err}`);
|
|
1551
|
+
});
|
|
1552
|
+
};
|
|
1553
|
+
transport.channel.onclose = () => {
|
|
1554
|
+
sendPtyTransportStatus({
|
|
1555
|
+
task_id: taskId,
|
|
1556
|
+
session_id: sessionId,
|
|
1557
|
+
connection_id: connectionId,
|
|
1558
|
+
transport_state: "fallback_relay",
|
|
1559
|
+
transport_policy: "direct_preferred",
|
|
1560
|
+
writer_connection_id: connectionId,
|
|
1561
|
+
direct_candidate: false,
|
|
1562
|
+
reason: "direct_channel_closed",
|
|
1563
|
+
}).catch((err) => {
|
|
1564
|
+
logError(`Failed to report PTY transport fallback for ${taskId}: ${err?.message || err}`);
|
|
1565
|
+
});
|
|
1566
|
+
cleanupPtyRtcTransport(taskId, sessionId);
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
};
|
|
1570
|
+
|
|
1571
|
+
peer.onicecandidate = (event) => {
|
|
1572
|
+
if (!event?.candidate) {
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
sendPtyTransportSignal({
|
|
1576
|
+
task_id: taskId,
|
|
1577
|
+
session_id: sessionId,
|
|
1578
|
+
connection_id: connectionId,
|
|
1579
|
+
signal_type: "ice_candidate",
|
|
1580
|
+
candidate: typeof event.candidate.toJSON === "function" ? event.candidate.toJSON() : event.candidate,
|
|
1581
|
+
}).catch((err) => {
|
|
1582
|
+
logError(`Failed to report PTY ICE candidate for ${taskId}: ${err?.message || err}`);
|
|
1583
|
+
});
|
|
1584
|
+
};
|
|
1585
|
+
|
|
1586
|
+
await peer.setRemoteDescription({
|
|
1587
|
+
type: "offer",
|
|
1588
|
+
sdp: offerDescription.sdp,
|
|
1589
|
+
});
|
|
1590
|
+
const answer = await peer.createAnswer();
|
|
1591
|
+
await peer.setLocalDescription(answer);
|
|
1592
|
+
|
|
1593
|
+
await sendPtyTransportSignal({
|
|
1594
|
+
task_id: taskId,
|
|
1595
|
+
session_id: sessionId,
|
|
1596
|
+
connection_id: connectionId,
|
|
1597
|
+
signal_type: "answer",
|
|
1598
|
+
description: {
|
|
1599
|
+
type: answer.type,
|
|
1600
|
+
sdp: answer.sdp,
|
|
1601
|
+
},
|
|
1602
|
+
});
|
|
1603
|
+
await sendPtyTransportStatus({
|
|
1604
|
+
task_id: taskId,
|
|
1605
|
+
session_id: sessionId,
|
|
1606
|
+
connection_id: connectionId,
|
|
1607
|
+
transport_state: "negotiating",
|
|
1608
|
+
transport_policy: "direct_preferred",
|
|
1609
|
+
writer_connection_id: connectionId,
|
|
1610
|
+
direct_candidate: true,
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
return { ok: true };
|
|
1614
|
+
} catch (error) {
|
|
1615
|
+
cleanupPtyRtcTransport(taskId, sessionId);
|
|
1616
|
+
return {
|
|
1617
|
+
ok: false,
|
|
1618
|
+
reason: error?.message || "rtc_negotiation_failed",
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
911
1623
|
function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
|
|
912
1624
|
const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
|
|
913
1625
|
const entrypointType =
|
|
@@ -1025,6 +1737,54 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1025
1737
|
return record.outputSeq;
|
|
1026
1738
|
}
|
|
1027
1739
|
|
|
1740
|
+
function sendDirectPtyPayload(taskId, payload) {
|
|
1741
|
+
const transport = activePtyRtcTransports.get(taskId);
|
|
1742
|
+
const channel = transport?.channel;
|
|
1743
|
+
if (!channel || channel.readyState !== "open" || typeof channel.send !== "function") {
|
|
1744
|
+
return false;
|
|
1745
|
+
}
|
|
1746
|
+
try {
|
|
1747
|
+
channel.send(JSON.stringify(payload));
|
|
1748
|
+
return true;
|
|
1749
|
+
} catch (error) {
|
|
1750
|
+
logError(`Failed to send PTY direct payload for ${taskId}: ${error?.message || error}`);
|
|
1751
|
+
if (transport) {
|
|
1752
|
+
sendPtyTransportStatus({
|
|
1753
|
+
task_id: taskId,
|
|
1754
|
+
session_id: transport.sessionId,
|
|
1755
|
+
connection_id: transport.connectionId,
|
|
1756
|
+
transport_state: "fallback_relay",
|
|
1757
|
+
transport_policy: "direct_preferred",
|
|
1758
|
+
writer_connection_id: transport.connectionId,
|
|
1759
|
+
direct_candidate: false,
|
|
1760
|
+
reason: "direct_channel_send_failed",
|
|
1761
|
+
}).catch((err) => {
|
|
1762
|
+
logError(`Failed to report PTY direct send fallback for ${taskId}: ${err?.message || err}`);
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
cleanupPtyRtcTransport(taskId);
|
|
1766
|
+
return false;
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
function handleDirectTransportPayload(taskId, sessionId, connectionId, payload) {
|
|
1771
|
+
const transport = activePtyRtcTransports.get(taskId);
|
|
1772
|
+
if (
|
|
1773
|
+
!transport ||
|
|
1774
|
+
transport.sessionId !== sessionId ||
|
|
1775
|
+
transport.connectionId !== connectionId
|
|
1776
|
+
) {
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
if (payload?.type === "terminal_input" && payload.payload) {
|
|
1780
|
+
handleTerminalInput(payload.payload);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
if (payload?.type === "terminal_resize" && payload.payload) {
|
|
1784
|
+
handleTerminalResize(payload.payload);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1028
1788
|
function attachPtyStreamHandlers(taskId, record) {
|
|
1029
1789
|
const writeLogChunk = (chunk) => {
|
|
1030
1790
|
if (record.logStream) {
|
|
@@ -1035,13 +1795,30 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1035
1795
|
record.pty.onData((data) => {
|
|
1036
1796
|
writeLogChunk(data);
|
|
1037
1797
|
const seq = bufferTerminalOutput(record, data);
|
|
1038
|
-
|
|
1798
|
+
const latencySample = record.pendingLatencySample
|
|
1799
|
+
? {
|
|
1800
|
+
client_input_seq: record.pendingLatencySample.clientInputSeq ?? undefined,
|
|
1801
|
+
client_sent_at: record.pendingLatencySample.clientSentAt ?? undefined,
|
|
1802
|
+
server_received_at: record.pendingLatencySample.serverReceivedAt ?? undefined,
|
|
1803
|
+
daemon_received_at: record.pendingLatencySample.daemonReceivedAt,
|
|
1804
|
+
first_output_at: new Date().toISOString(),
|
|
1805
|
+
daemon_input_to_first_output_ms: Math.max(0, Date.now() - record.pendingLatencySample.daemonReceivedAtMs),
|
|
1806
|
+
}
|
|
1807
|
+
: undefined;
|
|
1808
|
+
record.pendingLatencySample = null;
|
|
1809
|
+
const outputPayload = {
|
|
1039
1810
|
task_id: taskId,
|
|
1040
1811
|
project_id: record.projectId,
|
|
1041
1812
|
pty_session_id: record.ptySessionId,
|
|
1042
1813
|
seq,
|
|
1043
1814
|
data,
|
|
1044
|
-
|
|
1815
|
+
...(latencySample ? { latency_sample: latencySample } : {}),
|
|
1816
|
+
};
|
|
1817
|
+
sendDirectPtyPayload(taskId, {
|
|
1818
|
+
type: "terminal_output",
|
|
1819
|
+
payload: outputPayload,
|
|
1820
|
+
});
|
|
1821
|
+
sendTerminalEvent("terminal_output", outputPayload).catch((err) => {
|
|
1045
1822
|
logError(`Failed to report terminal_output for ${taskId}: ${err?.message || err}`);
|
|
1046
1823
|
});
|
|
1047
1824
|
});
|
|
@@ -1050,6 +1827,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1050
1827
|
if (record.stopForceKillTimer) {
|
|
1051
1828
|
clearTimeout(record.stopForceKillTimer);
|
|
1052
1829
|
}
|
|
1830
|
+
cleanupPtyRtcTransport(taskId);
|
|
1053
1831
|
activePtySessions.delete(taskId);
|
|
1054
1832
|
if (record.logStream) {
|
|
1055
1833
|
const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
|
|
@@ -1113,6 +1891,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1113
1891
|
return;
|
|
1114
1892
|
}
|
|
1115
1893
|
|
|
1894
|
+
if (daemonShuttingDown) {
|
|
1895
|
+
rejectCreatePtyTaskDuringShutdown(payload);
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1116
1899
|
if (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) {
|
|
1117
1900
|
log(`Duplicate create_pty_task ignored for ${taskId}: task already active`);
|
|
1118
1901
|
sendAgentCommandAck({
|
|
@@ -1125,6 +1908,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1125
1908
|
}
|
|
1126
1909
|
|
|
1127
1910
|
let boundPath = await getProjectLocalPath(projectId);
|
|
1911
|
+
if (daemonShuttingDown) {
|
|
1912
|
+
rejectCreatePtyTaskDuringShutdown(payload);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1128
1915
|
let taskDir = normalizeOptionalString(launchConfig.cwd) || boundPath;
|
|
1129
1916
|
if (!taskDir) {
|
|
1130
1917
|
const now = new Date();
|
|
@@ -1217,6 +2004,20 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1217
2004
|
cwd: launchSpec.cwd,
|
|
1218
2005
|
env,
|
|
1219
2006
|
});
|
|
2007
|
+
if (daemonShuttingDown) {
|
|
2008
|
+
try {
|
|
2009
|
+
if (typeof pty?.kill === "function") {
|
|
2010
|
+
pty.kill("SIGTERM");
|
|
2011
|
+
}
|
|
2012
|
+
} catch (killError) {
|
|
2013
|
+
logError(`Failed to stop PTY task ${taskId} during shutdown: ${killError?.message || killError}`);
|
|
2014
|
+
}
|
|
2015
|
+
if (logStream) {
|
|
2016
|
+
logStream.end();
|
|
2017
|
+
}
|
|
2018
|
+
rejectCreatePtyTaskDuringShutdown(payload, { sendAck: false });
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
1220
2021
|
const resolvedLogPath = path.join(taskDir, "conductor-terminal.log");
|
|
1221
2022
|
|
|
1222
2023
|
const startedAt = new Date().toISOString();
|
|
@@ -1235,6 +2036,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1235
2036
|
outputSeq: 0,
|
|
1236
2037
|
ringBuffer: [],
|
|
1237
2038
|
ringBufferByteLength: 0,
|
|
2039
|
+
pendingLatencySample: null,
|
|
1238
2040
|
stopForceKillTimer: null,
|
|
1239
2041
|
};
|
|
1240
2042
|
activePtySessions.set(taskId, record);
|
|
@@ -1322,6 +2124,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1322
2124
|
if (!record || typeof record.pty.write !== "function") {
|
|
1323
2125
|
return;
|
|
1324
2126
|
}
|
|
2127
|
+
record.pendingLatencySample = {
|
|
2128
|
+
clientInputSeq: normalizeNonNegativeInt(payload?.client_input_seq ?? payload?.clientInputSeq, null),
|
|
2129
|
+
clientSentAt: normalizeIsoTimestamp(payload?.client_sent_at ?? payload?.clientSentAt),
|
|
2130
|
+
serverReceivedAt: normalizeIsoTimestamp(payload?.server_received_at ?? payload?.serverReceivedAt),
|
|
2131
|
+
daemonReceivedAt: new Date().toISOString(),
|
|
2132
|
+
daemonReceivedAtMs: Date.now(),
|
|
2133
|
+
};
|
|
1325
2134
|
record.pty.write(data);
|
|
1326
2135
|
}
|
|
1327
2136
|
|
|
@@ -1337,6 +2146,124 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1337
2146
|
// PTY sessions stay alive without viewers. Detach is currently a no-op.
|
|
1338
2147
|
}
|
|
1339
2148
|
|
|
2149
|
+
async function handlePtyTransportSignal(payload) {
|
|
2150
|
+
const taskId = payload?.task_id ? String(payload.task_id) : "";
|
|
2151
|
+
const sessionId = payload?.session_id ? String(payload.session_id) : "";
|
|
2152
|
+
const connectionId = payload?.connection_id ? String(payload.connection_id) : "";
|
|
2153
|
+
const signalType = payload?.signal_type ? String(payload.signal_type) : "";
|
|
2154
|
+
if (!taskId || !connectionId || !signalType) {
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
const record = activePtySessions.get(taskId);
|
|
2159
|
+
const description =
|
|
2160
|
+
payload?.description && typeof payload.description === "object" && !Array.isArray(payload.description)
|
|
2161
|
+
? payload.description
|
|
2162
|
+
: null;
|
|
2163
|
+
const candidate =
|
|
2164
|
+
payload?.candidate && typeof payload.candidate === "object" && !Array.isArray(payload.candidate)
|
|
2165
|
+
? payload.candidate
|
|
2166
|
+
: null;
|
|
2167
|
+
|
|
2168
|
+
if (signalType === "ice_candidate") {
|
|
2169
|
+
if (!sessionId) {
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
const transport = activePtyRtcTransports.get(taskId);
|
|
2173
|
+
if (
|
|
2174
|
+
transport &&
|
|
2175
|
+
transport.sessionId === sessionId &&
|
|
2176
|
+
transport.connectionId === connectionId &&
|
|
2177
|
+
typeof transport.peer?.addIceCandidate === "function" &&
|
|
2178
|
+
candidate
|
|
2179
|
+
) {
|
|
2180
|
+
try {
|
|
2181
|
+
await transport.peer.addIceCandidate(candidate);
|
|
2182
|
+
} catch (err) {
|
|
2183
|
+
logError(`Failed to apply PTY ICE candidate for ${taskId}: ${err?.message || err}`);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
if (signalType === "revoke") {
|
|
2190
|
+
const transport = activePtyRtcTransports.get(taskId);
|
|
2191
|
+
if (transport && transport.connectionId === connectionId) {
|
|
2192
|
+
cleanupPtyRtcTransport(taskId);
|
|
2193
|
+
}
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
if (signalType === "offer" && description?.type === "offer" && typeof description.sdp === "string") {
|
|
2198
|
+
if (!sessionId) {
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
const negotiation = await startPtyRtcNegotiation(taskId, sessionId, connectionId, description);
|
|
2202
|
+
if (negotiation.ok) {
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
const reason = negotiation.reason || (record ? "direct_transport_not_supported" : "terminal_session_not_found");
|
|
2206
|
+
sendPtyTransportSignal({
|
|
2207
|
+
task_id: taskId,
|
|
2208
|
+
session_id: sessionId,
|
|
2209
|
+
connection_id: connectionId,
|
|
2210
|
+
signal_type: "answer_placeholder",
|
|
2211
|
+
description: {
|
|
2212
|
+
type: "answer",
|
|
2213
|
+
mode: "placeholder",
|
|
2214
|
+
reason,
|
|
2215
|
+
},
|
|
2216
|
+
}).catch((err) => {
|
|
2217
|
+
logError(`Failed to report pty_transport_signal for ${taskId}: ${err?.message || err}`);
|
|
2218
|
+
});
|
|
2219
|
+
sendPtyTransportStatus({
|
|
2220
|
+
task_id: taskId,
|
|
2221
|
+
session_id: sessionId,
|
|
2222
|
+
connection_id: connectionId,
|
|
2223
|
+
transport_state: "fallback_relay",
|
|
2224
|
+
transport_policy: "relay_only",
|
|
2225
|
+
writer_connection_id: connectionId,
|
|
2226
|
+
direct_candidate: false,
|
|
2227
|
+
reason,
|
|
2228
|
+
}).catch((err) => {
|
|
2229
|
+
logError(`Failed to report pty_transport_status for ${taskId}: ${err?.message || err}`);
|
|
2230
|
+
});
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
const reason = record ? "direct_transport_not_supported" : "terminal_session_not_found";
|
|
2235
|
+
if (signalType === "direct_request") {
|
|
2236
|
+
if (!sessionId) {
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
sendPtyTransportSignal({
|
|
2240
|
+
task_id: taskId,
|
|
2241
|
+
session_id: sessionId,
|
|
2242
|
+
connection_id: connectionId,
|
|
2243
|
+
signal_type: "answer_placeholder",
|
|
2244
|
+
description: {
|
|
2245
|
+
type: "answer",
|
|
2246
|
+
mode: "placeholder",
|
|
2247
|
+
reason,
|
|
2248
|
+
},
|
|
2249
|
+
}).catch((err) => {
|
|
2250
|
+
logError(`Failed to report pty_transport_signal for ${taskId}: ${err?.message || err}`);
|
|
2251
|
+
});
|
|
2252
|
+
sendPtyTransportStatus({
|
|
2253
|
+
task_id: taskId,
|
|
2254
|
+
session_id: sessionId,
|
|
2255
|
+
connection_id: connectionId,
|
|
2256
|
+
transport_state: "fallback_relay",
|
|
2257
|
+
transport_policy: "relay_only",
|
|
2258
|
+
writer_connection_id: connectionId,
|
|
2259
|
+
direct_candidate: false,
|
|
2260
|
+
reason,
|
|
2261
|
+
}).catch((err) => {
|
|
2262
|
+
logError(`Failed to report pty_transport_status for ${taskId}: ${err?.message || err}`);
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
|
|
1340
2267
|
function handleEvent(event) {
|
|
1341
2268
|
const receivedAt = Date.now();
|
|
1342
2269
|
lastInboundAt = receivedAt;
|
|
@@ -1357,6 +2284,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1357
2284
|
return;
|
|
1358
2285
|
}
|
|
1359
2286
|
|
|
2287
|
+
if (daemonShuttingDown) {
|
|
2288
|
+
if (event.type === "create_task") {
|
|
2289
|
+
rejectCreateTaskDuringShutdown(event.payload);
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
if (event.type === "create_pty_task") {
|
|
2293
|
+
rejectCreatePtyTaskDuringShutdown(event.payload);
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
|
|
1360
2298
|
if (event.type === "create_task") {
|
|
1361
2299
|
handleCreateTask(event.payload);
|
|
1362
2300
|
return;
|
|
@@ -1385,6 +2323,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1385
2323
|
handleTerminalDetach(event.payload);
|
|
1386
2324
|
return;
|
|
1387
2325
|
}
|
|
2326
|
+
if (event.type === "pty_transport_signal") {
|
|
2327
|
+
void handlePtyTransportSignal(event.payload);
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
1388
2330
|
if (event.type === "collect_logs") {
|
|
1389
2331
|
void handleCollectLogs(event.payload);
|
|
1390
2332
|
}
|
|
@@ -1516,6 +2458,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1516
2458
|
clearTimeout(activeRecord.stopForceKillTimer);
|
|
1517
2459
|
activeRecord.stopForceKillTimer = null;
|
|
1518
2460
|
}
|
|
2461
|
+
if (ptyRecord) {
|
|
2462
|
+
cleanupPtyRtcTransport(taskId);
|
|
2463
|
+
}
|
|
1519
2464
|
|
|
1520
2465
|
if (processRecord?.child) {
|
|
1521
2466
|
try {
|
|
@@ -1651,6 +2596,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1651
2596
|
return;
|
|
1652
2597
|
}
|
|
1653
2598
|
|
|
2599
|
+
if (daemonShuttingDown) {
|
|
2600
|
+
rejectCreateTaskDuringShutdown(payload);
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
1654
2604
|
const existingTaskRecord = activeTaskProcesses.get(taskId);
|
|
1655
2605
|
if (existingTaskRecord?.child) {
|
|
1656
2606
|
log(
|
|
@@ -1718,6 +2668,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1718
2668
|
|
|
1719
2669
|
// Check if project has a bound local path for this daemon
|
|
1720
2670
|
const boundPath = await getProjectLocalPath(projectId);
|
|
2671
|
+
if (daemonShuttingDown) {
|
|
2672
|
+
rejectCreateTaskDuringShutdown(payload, { sendAck: false });
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
1721
2675
|
let taskDir;
|
|
1722
2676
|
let logPath;
|
|
1723
2677
|
let runTimestampPart = null;
|
|
@@ -1961,6 +2915,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1961
2915
|
if (record?.stopForceKillTimer) {
|
|
1962
2916
|
clearTimeout(record.stopForceKillTimer);
|
|
1963
2917
|
}
|
|
2918
|
+
cleanupPtyRtcTransport(taskId);
|
|
1964
2919
|
try {
|
|
1965
2920
|
if (typeof record.pty?.kill === "function") {
|
|
1966
2921
|
record.pty.kill("SIGTERM");
|
|
@@ -2044,6 +2999,25 @@ function parsePositiveInt(value, fallback) {
|
|
|
2044
2999
|
return fallback;
|
|
2045
3000
|
}
|
|
2046
3001
|
|
|
3002
|
+
function parseBooleanEnv(value) {
|
|
3003
|
+
if (typeof value !== "string") {
|
|
3004
|
+
return false;
|
|
3005
|
+
}
|
|
3006
|
+
const normalized = value.trim().toLowerCase();
|
|
3007
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
function resolveRtcModuleCandidates(value) {
|
|
3011
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
3012
|
+
return [...DEFAULT_RTC_MODULE_CANDIDATES];
|
|
3013
|
+
}
|
|
3014
|
+
const candidates = value
|
|
3015
|
+
.split(",")
|
|
3016
|
+
.map((entry) => entry.trim())
|
|
3017
|
+
.filter(Boolean);
|
|
3018
|
+
return candidates.length > 0 ? [...new Set(candidates)] : [...DEFAULT_RTC_MODULE_CANDIDATES];
|
|
3019
|
+
}
|
|
3020
|
+
|
|
2047
3021
|
function formatDisconnectDiagnostics(event) {
|
|
2048
3022
|
const parts = [];
|
|
2049
3023
|
const reason = typeof event?.reason === "string" && event.reason.trim()
|