@mcoda/mswarm 0.1.0 → 0.1.46

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/dist/runtime.js CHANGED
@@ -1,16 +1,24 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
- import { homedir } from "node:os";
3
+ import { hostname, homedir, platform, userInfo } from "node:os";
4
4
  import { spawn } from "node:child_process";
5
+ import { createHash, randomUUID } from "node:crypto";
5
6
  const DEFAULT_GATEWAY_BASE_URL = "http://127.0.0.1:8080";
7
+ const DEFAULT_SETUP_GATEWAY_BASE_URL = "https://api.mswarm.org";
6
8
  const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
7
9
  const DEFAULT_LISTEN_HOST = "127.0.0.1";
8
10
  const DEFAULT_LISTEN_PORT = 18083;
9
11
  const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30;
10
- const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
12
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
13
+ const DEFAULT_SERVICE_COMMAND_TIMEOUT_MS = 60000;
11
14
  const DEFAULT_MCODA_BIN = "mcoda";
12
15
  const DEFAULT_MCODA_LIST_ARGS = ["agent", "list", "--json", "--refresh-health"];
13
16
  const DEFAULT_COMMAND_MAX_BUFFER = 16 * 1024 * 1024;
17
+ const DEFAULT_JOB_POLL_WAIT_MS = 25000;
18
+ const SERVICE_LABEL = "com.mcoda.mswarm.self-hosted-node";
19
+ const SYSTEMD_SERVICE_NAME = "mswarm-self-hosted-node.service";
20
+ const WINDOWS_TASK_NAME = "MswarmSelfHostedNode";
21
+ const WINDOWS_WRAPPER_SCRIPT_NAME = "mswarm-self-hosted-node.ps1";
14
22
  function optionalText(value) {
15
23
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
16
24
  }
@@ -53,6 +61,48 @@ function parseDiscoveryMode(value) {
53
61
  const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
54
62
  return normalized === "ollama" ? "ollama" : "mcoda";
55
63
  }
64
+ function parseRelayMode(value) {
65
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
66
+ return normalized === "direct" ? "direct" : "outbound";
67
+ }
68
+ function normalizeLocalName(value) {
69
+ return (value
70
+ .trim()
71
+ .toLowerCase()
72
+ .replace(/[^a-z0-9]+/g, "-")
73
+ .replace(/^-+|-+$/g, "")
74
+ .slice(0, 80) || "local-node");
75
+ }
76
+ export function resolveDefaultServerName() {
77
+ return normalizeLocalName(hostname() || "local-node");
78
+ }
79
+ function defaultMachineIdPath() {
80
+ return join(homedir(), ".mswarm", "self-hosted-node", "machine.id");
81
+ }
82
+ function parseCliOptions(argv) {
83
+ const options = {};
84
+ for (let index = 0; index < argv.length; index += 1) {
85
+ const token = argv[index];
86
+ if (!token.startsWith("--")) {
87
+ continue;
88
+ }
89
+ const equalsIndex = token.indexOf("=");
90
+ if (equalsIndex > 0) {
91
+ options[token.slice(2, equalsIndex)] = token.slice(equalsIndex + 1);
92
+ continue;
93
+ }
94
+ const key = token.slice(2);
95
+ const next = argv[index + 1];
96
+ if (next && !next.startsWith("--")) {
97
+ options[key] = next;
98
+ index += 1;
99
+ }
100
+ else {
101
+ options[key] = true;
102
+ }
103
+ }
104
+ return options;
105
+ }
56
106
  function trimTrailingSlash(value) {
57
107
  return value.replace(/\/+$/g, "");
58
108
  }
@@ -62,6 +112,26 @@ function defaultStatePath() {
62
112
  function defaultRuntimeTokenPath() {
63
113
  return join(homedir(), ".mswarm", "self-hosted-node", "node.key");
64
114
  }
115
+ export async function readOrCreateSelfHostedMachineId(machineIdPath = defaultMachineIdPath()) {
116
+ try {
117
+ const existing = (await readFile(machineIdPath, "utf8")).trim();
118
+ if (existing) {
119
+ return existing;
120
+ }
121
+ }
122
+ catch (error) {
123
+ if (error.code !== "ENOENT") {
124
+ throw error;
125
+ }
126
+ }
127
+ const machineId = randomUUID();
128
+ await mkdir(dirname(machineIdPath), { recursive: true });
129
+ await writeFile(machineIdPath, `${machineId}\n`, { encoding: "utf8", mode: 0o600 });
130
+ return machineId;
131
+ }
132
+ export function machineFingerprintFromId(machineId) {
133
+ return `sha256:${createHash("sha256").update(machineId.trim()).digest("hex")}`;
134
+ }
65
135
  function isVisionModel(modelName, family) {
66
136
  const normalized = `${modelName} ${family || ""}`.toLowerCase();
67
137
  return normalized.includes("llava") || normalized.includes("vision") || normalized.includes("bakllava");
@@ -198,6 +268,389 @@ export async function writeSelfHostedRuntimeToken(tokenPath, runtimeToken) {
198
268
  await mkdir(dirname(tokenPath), { recursive: true });
199
269
  await writeFile(tokenPath, `${runtimeToken.trim()}\n`, { encoding: "utf8", mode: 0o600 });
200
270
  }
271
+ function escapeXml(value) {
272
+ return value
273
+ .replace(/&/g, "&amp;")
274
+ .replace(/</g, "&lt;")
275
+ .replace(/>/g, "&gt;")
276
+ .replace(/"/g, "&quot;")
277
+ .replace(/'/g, "&apos;");
278
+ }
279
+ function quoteSystemdValue(value) {
280
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
281
+ }
282
+ function quoteEnvAssignment(key, value) {
283
+ return `${key}=${value}`;
284
+ }
285
+ function serviceLogDir(homeDir) {
286
+ return join(homeDir, ".mswarm", "self-hosted-node");
287
+ }
288
+ function serviceEnvironment(config, env, homeDir) {
289
+ const values = {
290
+ HOME: env.HOME || homeDir,
291
+ PATH: env.PATH || env.Path || env.path || "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin",
292
+ MSWARM_GATEWAY_BASE_URL: config.gatewayBaseUrl,
293
+ MSWARM_SELF_HOSTED_NODE_STATE_PATH: config.statePath,
294
+ MSWARM_SELF_HOSTED_NODE_KEY_PATH: config.runtimeTokenPath,
295
+ MSWARM_SELF_HOSTED_RELAY_MODE: config.relayMode || "outbound",
296
+ MSWARM_SELF_HOSTED_DIRECT_BASE_URL: config.directBaseUrl || null,
297
+ MSWARM_SELF_HOSTED_DISCOVERY_MODE: config.discoveryMode,
298
+ MSWARM_SELF_HOSTED_MCODA_BIN: config.mcodaBin,
299
+ MSWARM_SELF_HOSTED_MCODA_LIST_ARGS: config.mcodaListArgs.join(","),
300
+ MSWARM_SELF_HOSTED_OLLAMA_BASE_URL: config.ollamaBaseUrl,
301
+ MSWARM_SELF_HOSTED_NODE_VERSION: config.nodeVersion,
302
+ MSWARM_SELF_HOSTED_EXPOSE_ALL_MODELS: config.exposeAllModels ? "true" : "false",
303
+ MSWARM_SELF_HOSTED_MODEL_ALLOWLIST: config.modelAllowlist.join(","),
304
+ MSWARM_SELF_HOSTED_MODEL_BLOCKLIST: config.modelBlocklist.join(","),
305
+ MSWARM_SELF_HOSTED_HEARTBEAT_INTERVAL_SECONDS: String(config.heartbeatIntervalSeconds),
306
+ MSWARM_SELF_HOSTED_REQUEST_TIMEOUT_MS: String(config.requestTimeoutMs)
307
+ };
308
+ return Object.fromEntries(Object.entries(values).filter((entry) => typeof entry[1] === "string" && entry[1] !== ""));
309
+ }
310
+ function buildLaunchdPlist(input) {
311
+ const args = [
312
+ "/usr/bin/env",
313
+ "-i",
314
+ ...Object.entries(input.env).map(([key, value]) => quoteEnvAssignment(key, value)),
315
+ input.nodePath,
316
+ input.commandPath,
317
+ "start"
318
+ ]
319
+ .map((value) => ` <string>${escapeXml(value)}</string>`)
320
+ .join("\n");
321
+ return `<?xml version="1.0" encoding="UTF-8"?>
322
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
323
+ <plist version="1.0">
324
+ <dict>
325
+ <key>Label</key>
326
+ <string>${escapeXml(input.label)}</string>
327
+ <key>ProgramArguments</key>
328
+ <array>
329
+ ${args}
330
+ </array>
331
+ <key>RunAtLoad</key>
332
+ <true/>
333
+ <key>KeepAlive</key>
334
+ <true/>
335
+ <key>StandardOutPath</key>
336
+ <string>${escapeXml(input.logPath)}</string>
337
+ <key>StandardErrorPath</key>
338
+ <string>${escapeXml(input.errorLogPath)}</string>
339
+ </dict>
340
+ </plist>
341
+ `;
342
+ }
343
+ function buildSystemdUserService(input) {
344
+ const envArgs = Object.entries(input.env)
345
+ .map(([key, value]) => quoteSystemdValue(quoteEnvAssignment(key, value)))
346
+ .join(" ");
347
+ return `[Unit]
348
+ Description=mswarm self-hosted node
349
+ After=network-online.target
350
+ Wants=network-online.target
351
+
352
+ [Service]
353
+ Type=simple
354
+ ExecStart=/usr/bin/env -i ${envArgs} ${quoteSystemdValue(input.nodePath)} ${quoteSystemdValue(input.commandPath)} start
355
+ Restart=always
356
+ RestartSec=5
357
+ StandardOutput=append:${input.logPath}
358
+ StandardError=append:${input.errorLogPath}
359
+
360
+ [Install]
361
+ WantedBy=default.target
362
+ `;
363
+ }
364
+ function quotePowerShellValue(value) {
365
+ return `'${value.replace(/'/g, "''")}'`;
366
+ }
367
+ function quoteWindowsCommandArg(value) {
368
+ return `"${value.replace(/"/g, '\\"')}"`;
369
+ }
370
+ function buildWindowsTaskWrapperScript(input) {
371
+ const env = Object.entries(input.env)
372
+ .map(([key, value]) => `$env:${key} = ${quotePowerShellValue(value)}`)
373
+ .join("\n");
374
+ return `$ErrorActionPreference = 'Continue'
375
+
376
+ $logPath = ${quotePowerShellValue(input.logPath)}
377
+ $errorLogPath = ${quotePowerShellValue(input.errorLogPath)}
378
+ $nodePath = ${quotePowerShellValue(input.nodePath)}
379
+ $commandArguments = @(${quotePowerShellValue(input.commandPath)}, 'start')
380
+ $allowedInheritedEnvironment = @('SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT', 'PSModulePath', 'ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'TEMP', 'TMP')
381
+
382
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $logPath) | Out-Null
383
+
384
+ Get-ChildItem Env: | Where-Object { $allowedInheritedEnvironment -notcontains $_.Name } | ForEach-Object {
385
+ Remove-Item -Path ("Env:" + $_.Name) -ErrorAction SilentlyContinue
386
+ }
387
+
388
+ ${env}
389
+
390
+ while ($true) {
391
+ $startedAt = Get-Date -Format o
392
+ Add-Content -Path $logPath -Value "[$startedAt] starting mswarm self-hosted node"
393
+ try {
394
+ & $nodePath @commandArguments >> $logPath 2>> $errorLogPath
395
+ $exitCode = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }
396
+ $endedAt = Get-Date -Format o
397
+ Add-Content -Path $errorLogPath -Value "[$endedAt] mswarm self-hosted node exited with code $exitCode; restarting in 5 seconds"
398
+ } catch {
399
+ $failedAt = Get-Date -Format o
400
+ Add-Content -Path $errorLogPath -Value "[$failedAt] mswarm self-hosted node wrapper error: $_"
401
+ }
402
+ Start-Sleep -Seconds 5
403
+ }
404
+ `;
405
+ }
406
+ function buildWindowsTaskRegistrationCommand(wrapperPath) {
407
+ const actionArguments = `-NoProfile -ExecutionPolicy Bypass -File ${quoteWindowsCommandArg(wrapperPath)}`;
408
+ const settings = "New-ScheduledTaskSettingsSet " +
409
+ "-AllowStartIfOnBatteries " +
410
+ "-DontStopIfGoingOnBatteries " +
411
+ "-StartWhenAvailable " +
412
+ "-MultipleInstances IgnoreNew " +
413
+ "-RestartCount 999 " +
414
+ "-RestartInterval (New-TimeSpan -Minutes 1) " +
415
+ "-ExecutionTimeLimit (New-TimeSpan -Seconds 0)";
416
+ return [
417
+ `Stop-ScheduledTask -TaskName ${quotePowerShellValue(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue`,
418
+ `$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ${quotePowerShellValue(actionArguments)}`,
419
+ "$trigger = New-ScheduledTaskTrigger -AtLogOn",
420
+ `$settings = ${settings}`,
421
+ `Register-ScheduledTask -TaskName ${quotePowerShellValue(WINDOWS_TASK_NAME)} -Action $action -Trigger $trigger -Settings $settings -Description 'mswarm self-hosted node daemon' -Force | Out-Null`
422
+ ].join("; ");
423
+ }
424
+ function windowsTaskCommand(command) {
425
+ return `${command} -TaskName ${quotePowerShellValue(WINDOWS_TASK_NAME)}`;
426
+ }
427
+ function launchdDomain() {
428
+ const uid = typeof process.getuid === "function" ? process.getuid() : userInfo().uid;
429
+ return `gui/${uid}`;
430
+ }
431
+ export function resolveSelfHostedNodeServiceLayout(input = {}) {
432
+ const targetPlatform = input.platform || platform();
433
+ const homeDir = input.homeDir || homedir();
434
+ const logDir = serviceLogDir(homeDir);
435
+ const logPath = join(logDir, "daemon.log");
436
+ const errorLogPath = join(logDir, "daemon.err.log");
437
+ if (targetPlatform === "darwin") {
438
+ return {
439
+ platform: targetPlatform,
440
+ manager: "launchd",
441
+ serviceName: SERVICE_LABEL,
442
+ servicePath: join(homeDir, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`),
443
+ logPath,
444
+ errorLogPath
445
+ };
446
+ }
447
+ if (targetPlatform === "linux") {
448
+ return {
449
+ platform: targetPlatform,
450
+ manager: "systemd",
451
+ serviceName: SYSTEMD_SERVICE_NAME,
452
+ servicePath: join(homeDir, ".config", "systemd", "user", SYSTEMD_SERVICE_NAME),
453
+ logPath,
454
+ errorLogPath
455
+ };
456
+ }
457
+ if (targetPlatform === "win32") {
458
+ return {
459
+ platform: targetPlatform,
460
+ manager: "windows-task-scheduler",
461
+ serviceName: WINDOWS_TASK_NAME,
462
+ servicePath: join(logDir, WINDOWS_WRAPPER_SCRIPT_NAME),
463
+ logPath,
464
+ errorLogPath
465
+ };
466
+ }
467
+ throw new Error(`Persistent service control is not supported on ${targetPlatform}`);
468
+ }
469
+ function serviceControlResult(layout, action, result, ok = true, message) {
470
+ return {
471
+ manager: layout.manager,
472
+ serviceName: layout.serviceName,
473
+ servicePath: layout.servicePath,
474
+ logPath: layout.logPath,
475
+ errorLogPath: layout.errorLogPath,
476
+ action,
477
+ ok,
478
+ stdout: redactServiceManagerOutput(result.stdout),
479
+ stderr: redactServiceManagerOutput(result.stderr),
480
+ ...(message ? { message } : {})
481
+ };
482
+ }
483
+ function serviceCommandTimeoutMs(timeoutMs) {
484
+ return Math.max(Number.isFinite(timeoutMs) && typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : 0, DEFAULT_SERVICE_COMMAND_TIMEOUT_MS);
485
+ }
486
+ function redactServiceManagerOutput(value) {
487
+ return value.replace(/^(\s*[\w.-]*(?:API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[\w.-]*\s*(?:=>|=|:)\s*).*$/gim, "$1[redacted]");
488
+ }
489
+ async function runServiceCommand(runner, command, args, timeoutMs) {
490
+ return runner(command, args, { timeoutMs, maxBuffer: DEFAULT_COMMAND_MAX_BUFFER });
491
+ }
492
+ export async function installSelfHostedNodeService(config, options) {
493
+ const homeDir = options.homeDir || homedir();
494
+ const layout = resolveSelfHostedNodeServiceLayout({ platform: options.platform, homeDir });
495
+ const logDir = serviceLogDir(homeDir);
496
+ const env = serviceEnvironment(config, options.env || process.env, homeDir);
497
+ const nodePath = options.nodePath || process.execPath;
498
+ const runner = options.runner || defaultCommandRunner;
499
+ const serviceTimeoutMs = serviceCommandTimeoutMs(config.requestTimeoutMs);
500
+ await mkdir(logDir, { recursive: true });
501
+ if (layout.platform === "darwin") {
502
+ await mkdir(dirname(layout.servicePath), { recursive: true });
503
+ await writeFile(layout.servicePath, buildLaunchdPlist({
504
+ label: SERVICE_LABEL,
505
+ nodePath,
506
+ commandPath: options.commandPath,
507
+ logPath: layout.logPath,
508
+ errorLogPath: layout.errorLogPath,
509
+ env
510
+ }), "utf8");
511
+ const domain = launchdDomain();
512
+ await runner("launchctl", ["bootout", `${domain}/${SERVICE_LABEL}`], {
513
+ timeoutMs: serviceTimeoutMs,
514
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
515
+ }).catch(() => undefined);
516
+ await runner("launchctl", ["bootstrap", domain, layout.servicePath], {
517
+ timeoutMs: serviceTimeoutMs,
518
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
519
+ });
520
+ await runner("launchctl", ["enable", `${domain}/${SERVICE_LABEL}`], {
521
+ timeoutMs: serviceTimeoutMs,
522
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
523
+ }).catch(() => undefined);
524
+ await runner("launchctl", ["kickstart", "-k", `${domain}/${SERVICE_LABEL}`], {
525
+ timeoutMs: serviceTimeoutMs,
526
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
527
+ });
528
+ return { ...layout, started: true };
529
+ }
530
+ if (layout.platform === "linux") {
531
+ await mkdir(dirname(layout.servicePath), { recursive: true });
532
+ await writeFile(layout.servicePath, buildSystemdUserService({
533
+ nodePath,
534
+ commandPath: options.commandPath,
535
+ logPath: layout.logPath,
536
+ errorLogPath: layout.errorLogPath,
537
+ env
538
+ }), "utf8");
539
+ await runner("systemctl", ["--user", "daemon-reload"], {
540
+ timeoutMs: serviceTimeoutMs,
541
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
542
+ });
543
+ await runner("systemctl", ["--user", "enable", SYSTEMD_SERVICE_NAME], {
544
+ timeoutMs: serviceTimeoutMs,
545
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
546
+ });
547
+ await runner("loginctl", ["enable-linger", options.env?.USER || options.env?.USERNAME || userInfo().username], {
548
+ timeoutMs: serviceTimeoutMs,
549
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
550
+ }).catch(() => undefined);
551
+ await runner("systemctl", ["--user", "restart", SYSTEMD_SERVICE_NAME], {
552
+ timeoutMs: serviceTimeoutMs,
553
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
554
+ });
555
+ return { ...layout, started: true };
556
+ }
557
+ if (layout.platform === "win32") {
558
+ await writeFile(layout.servicePath, buildWindowsTaskWrapperScript({
559
+ nodePath,
560
+ commandPath: options.commandPath,
561
+ logPath: layout.logPath,
562
+ errorLogPath: layout.errorLogPath,
563
+ env
564
+ }), "utf8");
565
+ await runner("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", buildWindowsTaskRegistrationCommand(layout.servicePath)], {
566
+ timeoutMs: serviceTimeoutMs,
567
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
568
+ });
569
+ await runner("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", windowsTaskCommand("Start-ScheduledTask")], {
570
+ timeoutMs: serviceTimeoutMs,
571
+ maxBuffer: DEFAULT_COMMAND_MAX_BUFFER
572
+ });
573
+ return { ...layout, started: true };
574
+ }
575
+ throw new Error(`Persistent service install is not supported on ${layout.platform}`);
576
+ }
577
+ export async function controlSelfHostedNodeService(action, options = {}) {
578
+ const layout = resolveSelfHostedNodeServiceLayout(options);
579
+ const runner = options.runner || defaultCommandRunner;
580
+ const timeoutMs = serviceCommandTimeoutMs(options.requestTimeoutMs);
581
+ try {
582
+ if (layout.manager === "launchd") {
583
+ const domain = launchdDomain();
584
+ const serviceTarget = `${domain}/${SERVICE_LABEL}`;
585
+ if (action === "stop") {
586
+ const result = await runServiceCommand(runner, "launchctl", ["bootout", serviceTarget], timeoutMs);
587
+ return serviceControlResult(layout, action, result);
588
+ }
589
+ if (action === "status") {
590
+ const result = await runServiceCommand(runner, "launchctl", ["print", serviceTarget], timeoutMs);
591
+ return serviceControlResult(layout, action, result);
592
+ }
593
+ if (action === "restart") {
594
+ await runServiceCommand(runner, "launchctl", ["bootout", serviceTarget], timeoutMs).catch(() => undefined);
595
+ }
596
+ await runServiceCommand(runner, "launchctl", ["bootstrap", domain, layout.servicePath], timeoutMs).catch(() => undefined);
597
+ await runServiceCommand(runner, "launchctl", ["enable", serviceTarget], timeoutMs).catch(() => undefined);
598
+ const result = await runServiceCommand(runner, "launchctl", ["kickstart", "-k", serviceTarget], timeoutMs);
599
+ return serviceControlResult(layout, action, result);
600
+ }
601
+ if (layout.manager === "systemd") {
602
+ const systemdAction = action === "status" ? "status" : action;
603
+ const args = action === "status"
604
+ ? ["--user", "status", "--no-pager", SYSTEMD_SERVICE_NAME]
605
+ : ["--user", systemdAction, SYSTEMD_SERVICE_NAME];
606
+ const result = await runServiceCommand(runner, "systemctl", args, timeoutMs);
607
+ return serviceControlResult(layout, action, result);
608
+ }
609
+ if (layout.manager === "windows-task-scheduler") {
610
+ const command = action === "status"
611
+ ? [
612
+ `$task = Get-ScheduledTask -TaskName ${quotePowerShellValue(WINDOWS_TASK_NAME)} -ErrorAction Stop`,
613
+ `$info = Get-ScheduledTaskInfo -TaskName ${quotePowerShellValue(WINDOWS_TASK_NAME)} -ErrorAction Stop`,
614
+ "[pscustomobject]@{TaskName=$task.TaskName;State=$task.State;LastRunTime=$info.LastRunTime;LastTaskResult=$info.LastTaskResult} | ConvertTo-Json -Compress"
615
+ ].join("; ")
616
+ : action === "restart"
617
+ ? `${windowsTaskCommand("Stop-ScheduledTask")} -ErrorAction SilentlyContinue; ${windowsTaskCommand("Start-ScheduledTask")}`
618
+ : windowsTaskCommand(action === "start" ? "Start-ScheduledTask" : "Stop-ScheduledTask");
619
+ const result = await runServiceCommand(runner, "powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], timeoutMs);
620
+ return serviceControlResult(layout, action, result);
621
+ }
622
+ }
623
+ catch (error) {
624
+ if (action === "status") {
625
+ return serviceControlResult(layout, action, { stdout: "", stderr: "" }, false, error instanceof Error ? error.message : String(error));
626
+ }
627
+ throw error;
628
+ }
629
+ throw new Error(`Persistent service control is not supported on ${layout.platform}`);
630
+ }
631
+ export async function uninstallSelfHostedNodeService(options = {}) {
632
+ const layout = resolveSelfHostedNodeServiceLayout(options);
633
+ const runner = options.runner || defaultCommandRunner;
634
+ const timeoutMs = serviceCommandTimeoutMs(options.requestTimeoutMs);
635
+ if (layout.manager === "launchd") {
636
+ await runServiceCommand(runner, "launchctl", ["bootout", `${launchdDomain()}/${SERVICE_LABEL}`], timeoutMs).catch(() => undefined);
637
+ await rm(layout.servicePath, { force: true });
638
+ return serviceControlResult(layout, "uninstall", { stdout: "", stderr: "" });
639
+ }
640
+ if (layout.manager === "systemd") {
641
+ await runServiceCommand(runner, "systemctl", ["--user", "disable", "--now", SYSTEMD_SERVICE_NAME], timeoutMs).catch(() => undefined);
642
+ await rm(layout.servicePath, { force: true });
643
+ await runServiceCommand(runner, "systemctl", ["--user", "daemon-reload"], timeoutMs).catch(() => undefined);
644
+ return serviceControlResult(layout, "uninstall", { stdout: "", stderr: "" });
645
+ }
646
+ if (layout.manager === "windows-task-scheduler") {
647
+ const command = `${windowsTaskCommand("Stop-ScheduledTask")} -ErrorAction SilentlyContinue; Unregister-ScheduledTask -TaskName ${quotePowerShellValue(WINDOWS_TASK_NAME)} -Confirm:$false -ErrorAction SilentlyContinue`;
648
+ await runServiceCommand(runner, "powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], timeoutMs).catch(() => undefined);
649
+ await rm(layout.servicePath, { force: true });
650
+ return serviceControlResult(layout, "uninstall", { stdout: "", stderr: "" });
651
+ }
652
+ throw new Error(`Persistent service uninstall is not supported on ${layout.platform}`);
653
+ }
201
654
  export async function readSelfHostedNodeConfig(env = process.env) {
202
655
  const statePath = optionalText(env.MSWARM_SELF_HOSTED_NODE_STATE_PATH) || defaultStatePath();
203
656
  const runtimeTokenPath = optionalText(env.MSWARM_SELF_HOSTED_NODE_KEY_PATH) || defaultRuntimeTokenPath();
@@ -215,11 +668,15 @@ export async function readSelfHostedNodeConfig(env = process.env) {
215
668
  return {
216
669
  gatewayBaseUrl: trimTrailingSlash(gatewayBaseUrl),
217
670
  nodeId,
671
+ serverName: optionalText(env.MSWARM_SELF_HOSTED_SERVER_NAME) || state.server_name || null,
672
+ relayMode: parseRelayMode(env.MSWARM_SELF_HOSTED_RELAY_MODE || state.relay_mode),
673
+ machineFingerprint: state.machine_fingerprint || null,
674
+ directBaseUrl: optionalText(env.MSWARM_SELF_HOSTED_DIRECT_BASE_URL) || state.direct_base_url || null,
218
675
  enrollmentToken: optionalText(env.MSWARM_SELF_HOSTED_ENROLLMENT_TOKEN),
219
676
  runtimeToken: optionalText(env.MSWARM_SELF_HOSTED_RUNTIME_TOKEN) || persistedRuntimeToken || state.runtime_token || null,
220
- discoveryMode: parseDiscoveryMode(env.MSWARM_SELF_HOSTED_DISCOVERY_MODE),
221
- mcodaBin: optionalText(env.MSWARM_SELF_HOSTED_MCODA_BIN) || DEFAULT_MCODA_BIN,
222
- mcodaListArgs: parseArgs(env.MSWARM_SELF_HOSTED_MCODA_LIST_ARGS, DEFAULT_MCODA_LIST_ARGS),
677
+ discoveryMode: parseDiscoveryMode(env.MSWARM_SELF_HOSTED_DISCOVERY_MODE || state.discovery_mode),
678
+ mcodaBin: optionalText(env.MSWARM_SELF_HOSTED_MCODA_BIN) || state.mcoda_bin || DEFAULT_MCODA_BIN,
679
+ mcodaListArgs: parseArgs(env.MSWARM_SELF_HOSTED_MCODA_LIST_ARGS || state.mcoda_list_args, DEFAULT_MCODA_LIST_ARGS),
223
680
  ollamaBaseUrl: trimTrailingSlash(ollamaBaseUrl),
224
681
  statePath,
225
682
  runtimeTokenPath,
@@ -227,12 +684,60 @@ export async function readSelfHostedNodeConfig(env = process.env) {
227
684
  optionalText(env.MSWARM_SELF_HOSTED_RELAY_SIGNING_SECRET),
228
685
  listenHost: optionalText(env.MSWARM_SELF_HOSTED_LISTEN_HOST) || DEFAULT_LISTEN_HOST,
229
686
  listenPort: parsePositiveInteger(env.MSWARM_SELF_HOSTED_LISTEN_PORT, DEFAULT_LISTEN_PORT),
230
- nodeVersion: optionalText(env.MSWARM_SELF_HOSTED_NODE_VERSION) || "0.1.0",
687
+ nodeVersion: optionalText(env.MSWARM_SELF_HOSTED_NODE_VERSION) || state.node_version || "0.1.1",
231
688
  heartbeatIntervalSeconds: parsePositiveInteger(env.MSWARM_SELF_HOSTED_HEARTBEAT_INTERVAL_SECONDS, state.heartbeat_interval_seconds || DEFAULT_HEARTBEAT_INTERVAL_SECONDS),
689
+ requestTimeoutMs: parsePositiveInteger(env.MSWARM_SELF_HOSTED_REQUEST_TIMEOUT_MS, state.request_timeout_ms || DEFAULT_REQUEST_TIMEOUT_MS),
690
+ exposeAllModels: parseBoolean(env.MSWARM_SELF_HOSTED_EXPOSE_ALL_MODELS, typeof state.expose_all_models === "boolean" ? state.expose_all_models : false),
691
+ modelAllowlist: parseList(env.MSWARM_SELF_HOSTED_MODEL_ALLOWLIST || state.model_allowlist),
692
+ modelBlocklist: parseList(env.MSWARM_SELF_HOSTED_MODEL_BLOCKLIST || state.model_blocklist)
693
+ };
694
+ }
695
+ export async function readOwnerSetupConfig(argv = process.argv.slice(3), env = process.env) {
696
+ const options = parseCliOptions(argv);
697
+ const statePath = optionalText(env.MSWARM_SELF_HOSTED_NODE_STATE_PATH) || defaultStatePath();
698
+ const runtimeTokenPath = optionalText(env.MSWARM_SELF_HOSTED_NODE_KEY_PATH) || defaultRuntimeTokenPath();
699
+ const gatewayBaseUrl = optionalText(options.gateway) ||
700
+ optionalText(env.MSWARM_GATEWAY_BASE_URL) ||
701
+ DEFAULT_SETUP_GATEWAY_BASE_URL;
702
+ const apiKey = optionalText(options["api-key"]) || optionalText(env.MSWARM_API_KEY) || "";
703
+ if (!apiKey) {
704
+ throw new Error("--api-key or MSWARM_API_KEY is required");
705
+ }
706
+ const relayMode = parseRelayMode(options.mode || env.MSWARM_SELF_HOSTED_RELAY_MODE);
707
+ const directBaseUrl = optionalText(options["direct-url"]) || optionalText(env.MSWARM_SELF_HOSTED_DIRECT_BASE_URL);
708
+ if (relayMode === "direct" && !directBaseUrl) {
709
+ throw new Error("--direct-url is required when --mode direct is used");
710
+ }
711
+ if (relayMode === "outbound" && directBaseUrl) {
712
+ throw new Error("--direct-url can only be used with --mode direct");
713
+ }
714
+ const ollamaBaseUrl = optionalText(env.MSWARM_SELF_HOSTED_OLLAMA_BASE_URL) ||
715
+ optionalText(env.OLLAMA_HOST) ||
716
+ DEFAULT_OLLAMA_BASE_URL;
717
+ const allowlist = parseList(options.allow || env.MSWARM_SELF_HOSTED_MODEL_ALLOWLIST);
718
+ const blocklist = parseList(options.block || env.MSWARM_SELF_HOSTED_MODEL_BLOCKLIST);
719
+ return {
720
+ apiKey,
721
+ gatewayBaseUrl: trimTrailingSlash(gatewayBaseUrl),
722
+ serverName: normalizeLocalName(optionalText(options["server-name"]) ||
723
+ optionalText(env.MSWARM_SELF_HOSTED_SERVER_NAME) ||
724
+ resolveDefaultServerName()),
725
+ relayMode,
726
+ directBaseUrl,
727
+ discoveryMode: parseDiscoveryMode(env.MSWARM_SELF_HOSTED_DISCOVERY_MODE),
728
+ statePath,
729
+ runtimeTokenPath,
730
+ machineIdPath: optionalText(env.MSWARM_SELF_HOSTED_MACHINE_ID_PATH) || defaultMachineIdPath(),
731
+ mcodaBin: optionalText(env.MSWARM_SELF_HOSTED_MCODA_BIN) || DEFAULT_MCODA_BIN,
732
+ mcodaListArgs: parseArgs(env.MSWARM_SELF_HOSTED_MCODA_LIST_ARGS, DEFAULT_MCODA_LIST_ARGS),
733
+ ollamaBaseUrl: trimTrailingSlash(ollamaBaseUrl),
734
+ nodeVersion: optionalText(env.MSWARM_SELF_HOSTED_NODE_VERSION) || "0.1.1",
735
+ heartbeatIntervalSeconds: parsePositiveInteger(env.MSWARM_SELF_HOSTED_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS),
232
736
  requestTimeoutMs: parsePositiveInteger(env.MSWARM_SELF_HOSTED_REQUEST_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS),
233
- exposeAllModels: parseBoolean(env.MSWARM_SELF_HOSTED_EXPOSE_ALL_MODELS, false),
234
- modelAllowlist: parseList(env.MSWARM_SELF_HOSTED_MODEL_ALLOWLIST),
235
- modelBlocklist: parseList(env.MSWARM_SELF_HOSTED_MODEL_BLOCKLIST)
737
+ exposeAllModels: options["expose-all"] === true || parseBoolean(env.MSWARM_SELF_HOSTED_EXPOSE_ALL_MODELS, false),
738
+ modelAllowlist: allowlist,
739
+ modelBlocklist: blocklist,
740
+ start: options.start === true
236
741
  };
237
742
  }
238
743
  export function mapOllamaModelToSelfHostedModel(model, config) {
@@ -380,10 +885,6 @@ async function defaultCommandRunner(command, args, options) {
380
885
  });
381
886
  }
382
887
  export class McodaAgentInventoryClient {
383
- command;
384
- args;
385
- timeoutMs;
386
- runner;
387
888
  constructor(input) {
388
889
  this.command = input.command || DEFAULT_MCODA_BIN;
389
890
  this.args = input.args?.length ? input.args : DEFAULT_MCODA_LIST_ARGS;
@@ -413,9 +914,6 @@ export class McodaAgentInventoryClient {
413
914
  }
414
915
  }
415
916
  export class OllamaClient {
416
- baseUrl;
417
- fetchImpl;
418
- timeoutMs;
419
917
  constructor(input) {
420
918
  this.baseUrl = trimTrailingSlash(input.baseUrl);
421
919
  this.fetchImpl = input.fetchImpl || fetch;
@@ -502,9 +1000,6 @@ function buildOpenAIChatCompletion(input) {
502
1000
  };
503
1001
  }
504
1002
  export class McodaLocalAgentExecutor {
505
- command;
506
- timeoutMs;
507
- runner;
508
1003
  constructor(input) {
509
1004
  this.command = input.command || DEFAULT_MCODA_BIN;
510
1005
  this.timeoutMs = input.timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
@@ -534,9 +1029,6 @@ export class McodaLocalAgentExecutor {
534
1029
  }
535
1030
  }
536
1031
  export class MswarmSelfHostedNodeClient {
537
- gatewayBaseUrl;
538
- fetchImpl;
539
- timeoutMs;
540
1032
  constructor(input) {
541
1033
  this.gatewayBaseUrl = trimTrailingSlash(input.gatewayBaseUrl);
542
1034
  this.fetchImpl = input.fetchImpl || fetch;
@@ -549,6 +1041,19 @@ export class MswarmSelfHostedNodeClient {
549
1041
  body: JSON.stringify({ node_id: nodeId, enrollment_token: enrollmentToken })
550
1042
  }, this.timeoutMs);
551
1043
  }
1044
+ async bootstrap(apiKey, payload) {
1045
+ return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/bootstrap`, {
1046
+ method: "POST",
1047
+ headers: {
1048
+ "content-type": "application/json",
1049
+ "x-api-key": apiKey
1050
+ },
1051
+ body: JSON.stringify(payload)
1052
+ }, this.timeoutMs);
1053
+ }
1054
+ async health() {
1055
+ return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/healthz`, { method: "GET" }, this.timeoutMs);
1056
+ }
552
1057
  async heartbeat(runtimeToken, payload) {
553
1058
  return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/heartbeat`, {
554
1059
  method: "POST",
@@ -569,13 +1074,28 @@ export class MswarmSelfHostedNodeClient {
569
1074
  body: JSON.stringify(payload)
570
1075
  }, this.timeoutMs);
571
1076
  }
1077
+ async pollJob(runtimeToken, payload) {
1078
+ return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/jobs/poll`, {
1079
+ method: "POST",
1080
+ headers: {
1081
+ "content-type": "application/json",
1082
+ authorization: `Bearer ${runtimeToken}`
1083
+ },
1084
+ body: JSON.stringify(payload)
1085
+ }, Math.max(this.timeoutMs, (payload.wait_ms || 0) + 5000));
1086
+ }
1087
+ async postJobResult(runtimeToken, jobId, payload) {
1088
+ return fetchJson(this.fetchImpl, `${this.gatewayBaseUrl}/v1/swarm/self-hosted/node/jobs/${encodeURIComponent(jobId)}/result`, {
1089
+ method: "POST",
1090
+ headers: {
1091
+ "content-type": "application/json",
1092
+ authorization: `Bearer ${runtimeToken}`
1093
+ },
1094
+ body: JSON.stringify(payload)
1095
+ }, this.timeoutMs);
1096
+ }
572
1097
  }
573
1098
  export class SelfHostedNodeRuntime {
574
- config;
575
- gateway;
576
- mcoda;
577
- mcodaExecutor;
578
- ollama;
579
1099
  constructor(config, deps) {
580
1100
  this.config = config;
581
1101
  this.gateway =
@@ -606,6 +1126,96 @@ export class SelfHostedNodeRuntime {
606
1126
  timeoutMs: config.requestTimeoutMs
607
1127
  });
608
1128
  }
1129
+ static async setup(setupConfig, deps) {
1130
+ const gateway = deps?.gateway ||
1131
+ new MswarmSelfHostedNodeClient({
1132
+ gatewayBaseUrl: setupConfig.gatewayBaseUrl,
1133
+ fetchImpl: deps?.fetchImpl,
1134
+ timeoutMs: setupConfig.requestTimeoutMs
1135
+ });
1136
+ const machineId = await readOrCreateSelfHostedMachineId(setupConfig.machineIdPath);
1137
+ const machineFingerprint = machineFingerprintFromId(machineId);
1138
+ const bootstrap = await gateway.bootstrap(setupConfig.apiKey, {
1139
+ machine_fingerprint: machineFingerprint,
1140
+ server_name: setupConfig.serverName,
1141
+ label: setupConfig.serverName,
1142
+ relay_mode: setupConfig.relayMode,
1143
+ direct_base_url: setupConfig.directBaseUrl || null,
1144
+ node_version: setupConfig.nodeVersion,
1145
+ discovery_mode: setupConfig.discoveryMode,
1146
+ expose_all_models: setupConfig.exposeAllModels,
1147
+ model_allowlist: setupConfig.modelAllowlist,
1148
+ model_blocklist: setupConfig.modelBlocklist,
1149
+ heartbeat_interval_seconds: setupConfig.heartbeatIntervalSeconds
1150
+ });
1151
+ const nodeId = optionalText(bootstrap.node?.node_id);
1152
+ const runtimeToken = optionalText(bootstrap.runtime_token);
1153
+ if (!nodeId || !runtimeToken) {
1154
+ throw new Error("Bootstrap response did not include node_id and runtime_token");
1155
+ }
1156
+ const heartbeatInterval = bootstrap.heartbeat_interval_seconds || setupConfig.heartbeatIntervalSeconds;
1157
+ const state = {
1158
+ node_id: nodeId,
1159
+ server_name: optionalText(bootstrap.node?.server_name) || setupConfig.serverName,
1160
+ relay_mode: bootstrap.node?.relay_mode || setupConfig.relayMode,
1161
+ machine_fingerprint: machineFingerprint,
1162
+ direct_base_url: setupConfig.directBaseUrl || null,
1163
+ runtime_token: undefined,
1164
+ config_version: bootstrap.config_version,
1165
+ heartbeat_interval_seconds: heartbeatInterval,
1166
+ heartbeat_timeout_seconds: bootstrap.heartbeat_timeout_seconds,
1167
+ enrolled_at: new Date().toISOString(),
1168
+ updated_at: new Date().toISOString(),
1169
+ gateway_base_url: setupConfig.gatewayBaseUrl,
1170
+ ollama_base_url: setupConfig.ollamaBaseUrl,
1171
+ discovery_mode: setupConfig.discoveryMode,
1172
+ mcoda_bin: setupConfig.mcodaBin,
1173
+ mcoda_list_args: setupConfig.mcodaListArgs,
1174
+ node_version: setupConfig.nodeVersion,
1175
+ request_timeout_ms: setupConfig.requestTimeoutMs,
1176
+ expose_all_models: setupConfig.exposeAllModels,
1177
+ model_allowlist: setupConfig.modelAllowlist,
1178
+ model_blocklist: setupConfig.modelBlocklist
1179
+ };
1180
+ await writeSelfHostedNodeState(setupConfig.statePath, state);
1181
+ await writeSelfHostedRuntimeToken(setupConfig.runtimeTokenPath, runtimeToken);
1182
+ const runtime = new SelfHostedNodeRuntime({
1183
+ gatewayBaseUrl: setupConfig.gatewayBaseUrl,
1184
+ nodeId,
1185
+ serverName: state.server_name,
1186
+ relayMode: state.relay_mode || setupConfig.relayMode,
1187
+ machineFingerprint,
1188
+ directBaseUrl: setupConfig.directBaseUrl || null,
1189
+ enrollmentToken: null,
1190
+ runtimeToken,
1191
+ discoveryMode: setupConfig.discoveryMode,
1192
+ mcodaBin: setupConfig.mcodaBin,
1193
+ mcodaListArgs: setupConfig.mcodaListArgs,
1194
+ ollamaBaseUrl: setupConfig.ollamaBaseUrl,
1195
+ statePath: setupConfig.statePath,
1196
+ runtimeTokenPath: setupConfig.runtimeTokenPath,
1197
+ invocationSigningSecret: null,
1198
+ listenHost: DEFAULT_LISTEN_HOST,
1199
+ listenPort: DEFAULT_LISTEN_PORT,
1200
+ nodeVersion: setupConfig.nodeVersion,
1201
+ heartbeatIntervalSeconds: heartbeatInterval,
1202
+ requestTimeoutMs: setupConfig.requestTimeoutMs,
1203
+ exposeAllModels: setupConfig.exposeAllModels,
1204
+ modelAllowlist: setupConfig.modelAllowlist,
1205
+ modelBlocklist: setupConfig.modelBlocklist
1206
+ }, { ...deps, gateway });
1207
+ const once = await runtime.runOnce();
1208
+ return {
1209
+ created: bootstrap.created === true,
1210
+ nodeId,
1211
+ serverName: state.server_name || setupConfig.serverName,
1212
+ modelCount: once.model_count,
1213
+ status: once.status,
1214
+ statePath: setupConfig.statePath,
1215
+ runtimeTokenPath: setupConfig.runtimeTokenPath,
1216
+ start: setupConfig.start
1217
+ };
1218
+ }
609
1219
  async discoverModels() {
610
1220
  if (this.config.discoveryMode === "ollama") {
611
1221
  const [version, models] = await Promise.all([
@@ -642,7 +1252,15 @@ export class SelfHostedNodeRuntime {
642
1252
  enrolled_at: currentState.enrolled_at || new Date().toISOString(),
643
1253
  updated_at: new Date().toISOString(),
644
1254
  gateway_base_url: this.config.gatewayBaseUrl,
645
- ollama_base_url: this.config.ollamaBaseUrl
1255
+ ollama_base_url: this.config.ollamaBaseUrl,
1256
+ discovery_mode: this.config.discoveryMode,
1257
+ mcoda_bin: this.config.mcodaBin,
1258
+ mcoda_list_args: this.config.mcodaListArgs,
1259
+ node_version: this.config.nodeVersion,
1260
+ request_timeout_ms: this.config.requestTimeoutMs,
1261
+ expose_all_models: this.config.exposeAllModels,
1262
+ model_allowlist: this.config.modelAllowlist,
1263
+ model_blocklist: this.config.modelBlocklist
646
1264
  };
647
1265
  await writeSelfHostedNodeState(this.config.statePath, nextState);
648
1266
  await writeSelfHostedRuntimeToken(this.config.runtimeTokenPath, runtimeToken);
@@ -814,9 +1432,75 @@ export class SelfHostedNodeRuntime {
814
1432
  });
815
1433
  return { count: models.length, response };
816
1434
  }
1435
+ async pollAndExecuteJob(waitMs = DEFAULT_JOB_POLL_WAIT_MS) {
1436
+ const enrollment = await this.ensureEnrolled();
1437
+ const response = await this.gateway.pollJob(enrollment.runtimeToken, {
1438
+ node_id: this.config.nodeId,
1439
+ capacity: { active_jobs: 0, max_jobs: 1 },
1440
+ wait_ms: waitMs
1441
+ });
1442
+ const job = response.job || null;
1443
+ if (!job) {
1444
+ return { executed: false };
1445
+ }
1446
+ const result = await this.executeJob(job);
1447
+ await this.gateway.postJobResult(enrollment.runtimeToken, job.job_id, {
1448
+ ...result,
1449
+ node_id: this.config.nodeId
1450
+ });
1451
+ return { executed: true, job_id: job.job_id, status: result.status };
1452
+ }
1453
+ async doctor() {
1454
+ const checks = [];
1455
+ checks.push({ name: "config", ok: Boolean(this.config.nodeId), message: this.config.nodeId || "missing node id" });
1456
+ const runtimeToken = this.config.runtimeToken || (await readSelfHostedRuntimeToken(this.config.runtimeTokenPath));
1457
+ checks.push({ name: "runtime_token", ok: Boolean(runtimeToken), message: runtimeToken ? "present" : "missing" });
1458
+ try {
1459
+ await this.gateway.health();
1460
+ checks.push({ name: "gateway_health", ok: true });
1461
+ }
1462
+ catch (error) {
1463
+ checks.push({
1464
+ name: "gateway_health",
1465
+ ok: false,
1466
+ message: error instanceof Error ? error.message : String(error)
1467
+ });
1468
+ }
1469
+ try {
1470
+ const once = await this.runOnce();
1471
+ checks.push({ name: "heartbeat", ok: once.status === "online", message: once.status });
1472
+ checks.push({
1473
+ name: "local_agents",
1474
+ ok: once.model_count > 0,
1475
+ message: `${once.model_count} exposed local agent(s)`
1476
+ });
1477
+ }
1478
+ catch (error) {
1479
+ checks.push({
1480
+ name: "heartbeat",
1481
+ ok: false,
1482
+ message: error instanceof Error ? error.message : String(error)
1483
+ });
1484
+ }
1485
+ return { ok: checks.every((check) => check.ok), checks };
1486
+ }
817
1487
  startDaemon() {
818
1488
  let stopped = false;
819
1489
  let timer = null;
1490
+ let polling = false;
1491
+ const poll = () => {
1492
+ if (stopped || polling || this.config.relayMode === "direct")
1493
+ return;
1494
+ polling = true;
1495
+ void this.pollAndExecuteJob()
1496
+ .catch(() => undefined)
1497
+ .finally(() => {
1498
+ polling = false;
1499
+ if (!stopped) {
1500
+ setTimeout(poll, 0);
1501
+ }
1502
+ });
1503
+ };
820
1504
  const schedule = () => {
821
1505
  if (stopped)
822
1506
  return;
@@ -828,7 +1512,10 @@ export class SelfHostedNodeRuntime {
828
1512
  };
829
1513
  void this.runOnce()
830
1514
  .catch(() => undefined)
831
- .finally(schedule);
1515
+ .finally(() => {
1516
+ schedule();
1517
+ poll();
1518
+ });
832
1519
  return {
833
1520
  stop: () => {
834
1521
  stopped = true;