jishushell 0.4.30 → 0.5.22

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.
Files changed (226) hide show
  1. package/Dockerfile.hermes-slim +2 -5
  2. package/apps/anythingllm-container.yaml +287 -0
  3. package/apps/browserless-chromium-container.yaml +18 -6
  4. package/apps/filebrowser-container.yaml +164 -0
  5. package/apps/ollama-binary.yaml +44 -0
  6. package/apps/ollama-with-hollama-binary.yaml +45 -1
  7. package/apps/openclaw-binary.yaml +8 -0
  8. package/apps/openclaw-container.yaml +9 -1
  9. package/apps/openclaw-with-searxng-container.yaml +4 -0
  10. package/apps/searxng-container.yaml +5 -4
  11. package/apps/weknora-container.yaml +471 -0
  12. package/dist/cli/doctor.js +144 -16
  13. package/dist/cli/doctor.js.map +1 -1
  14. package/dist/cli/panel.js.map +1 -1
  15. package/dist/config.d.ts +19 -0
  16. package/dist/config.js +99 -1
  17. package/dist/config.js.map +1 -1
  18. package/dist/install.js +4 -4
  19. package/dist/install.js.map +1 -1
  20. package/dist/routes/auth.js +2 -2
  21. package/dist/routes/auth.js.map +1 -1
  22. package/dist/routes/backup.js +64 -11
  23. package/dist/routes/backup.js.map +1 -1
  24. package/dist/routes/external-mounts.d.ts +17 -0
  25. package/dist/routes/external-mounts.js +73 -0
  26. package/dist/routes/external-mounts.js.map +1 -0
  27. package/dist/routes/file-mounts.d.ts +13 -0
  28. package/dist/routes/file-mounts.js +90 -0
  29. package/dist/routes/file-mounts.js.map +1 -0
  30. package/dist/routes/files-organize.d.ts +28 -0
  31. package/dist/routes/files-organize.js +167 -0
  32. package/dist/routes/files-organize.js.map +1 -0
  33. package/dist/routes/files.d.ts +31 -0
  34. package/dist/routes/files.js +321 -0
  35. package/dist/routes/files.js.map +1 -0
  36. package/dist/routes/instances.js +87 -12
  37. package/dist/routes/instances.js.map +1 -1
  38. package/dist/routes/internal.d.ts +2 -0
  39. package/dist/routes/internal.js +59 -0
  40. package/dist/routes/internal.js.map +1 -0
  41. package/dist/routes/llm.js +29 -0
  42. package/dist/routes/llm.js.map +1 -1
  43. package/dist/routes/setup.js +9 -9
  44. package/dist/routes/setup.js.map +1 -1
  45. package/dist/routes/system.js +1 -1
  46. package/dist/routes/system.js.map +1 -1
  47. package/dist/routes/webdav.d.ts +17 -0
  48. package/dist/routes/webdav.js +114 -0
  49. package/dist/routes/webdav.js.map +1 -0
  50. package/dist/server.js +358 -6
  51. package/dist/server.js.map +1 -1
  52. package/dist/services/agent-apps/catalog.d.ts +3 -0
  53. package/dist/services/agent-apps/catalog.js +40 -13
  54. package/dist/services/agent-apps/catalog.js.map +1 -1
  55. package/dist/services/agent-apps/installers/shell-script.d.ts +1 -1
  56. package/dist/services/agent-apps/installers/shell-script.js +19 -2
  57. package/dist/services/agent-apps/installers/shell-script.js.map +1 -1
  58. package/dist/services/agent-apps/types.d.ts +3 -0
  59. package/dist/services/app/app-compiler.d.ts +1 -1
  60. package/dist/services/app/app-compiler.js +5 -5
  61. package/dist/services/app/app-compiler.js.map +1 -1
  62. package/dist/services/app/app-manager.d.ts +9 -0
  63. package/dist/services/app/app-manager.js +248 -43
  64. package/dist/services/app/app-manager.js.map +1 -1
  65. package/dist/services/app/custom-manager.js.map +1 -1
  66. package/dist/services/app/hermes-agent-manager.js +1 -0
  67. package/dist/services/app/hermes-agent-manager.js.map +1 -1
  68. package/dist/services/app/ollama-manager.js +1 -1
  69. package/dist/services/app/ollama-manager.js.map +1 -1
  70. package/dist/services/app/openclaw-manager.js +37 -5
  71. package/dist/services/app/openclaw-manager.js.map +1 -1
  72. package/dist/services/app/platform-transform.d.ts +32 -0
  73. package/dist/services/app/platform-transform.js +65 -0
  74. package/dist/services/app/platform-transform.js.map +1 -0
  75. package/dist/services/app-passwords.d.ts +61 -0
  76. package/dist/services/app-passwords.js +173 -0
  77. package/dist/services/app-passwords.js.map +1 -0
  78. package/dist/services/backup-manager.d.ts +11 -0
  79. package/dist/services/backup-manager.js +220 -8
  80. package/dist/services/backup-manager.js.map +1 -1
  81. package/dist/services/capability-endpoint-validator.js +26 -7
  82. package/dist/services/capability-endpoint-validator.js.map +1 -1
  83. package/dist/services/connection-apply.d.ts +2 -0
  84. package/dist/services/connection-apply.js +55 -1
  85. package/dist/services/connection-apply.js.map +1 -1
  86. package/dist/services/connection-resolver.js +1 -1
  87. package/dist/services/connection-resolver.js.map +1 -1
  88. package/dist/services/connection-transactor.d.ts +2 -0
  89. package/dist/services/connection-transactor.js +12 -2
  90. package/dist/services/connection-transactor.js.map +1 -1
  91. package/dist/services/external-mounts.d.ts +40 -0
  92. package/dist/services/external-mounts.js +187 -0
  93. package/dist/services/external-mounts.js.map +1 -0
  94. package/dist/services/files-manager.d.ts +252 -0
  95. package/dist/services/files-manager.js +1075 -0
  96. package/dist/services/files-manager.js.map +1 -0
  97. package/dist/services/files-mounts.d.ts +42 -0
  98. package/dist/services/files-mounts.js +207 -0
  99. package/dist/services/files-mounts.js.map +1 -0
  100. package/dist/services/instance-manager.js +90 -32
  101. package/dist/services/instance-manager.js.map +1 -1
  102. package/dist/services/llm-proxy/index.d.ts +28 -0
  103. package/dist/services/llm-proxy/index.js +76 -3
  104. package/dist/services/llm-proxy/index.js.map +1 -1
  105. package/dist/services/llm-proxy/ssrf.js +6 -2
  106. package/dist/services/llm-proxy/ssrf.js.map +1 -1
  107. package/dist/services/llm-proxy/validate-key.d.ts +41 -0
  108. package/dist/services/llm-proxy/validate-key.js +672 -0
  109. package/dist/services/llm-proxy/validate-key.js.map +1 -0
  110. package/dist/services/macos-launchd.d.ts +89 -0
  111. package/dist/services/macos-launchd.js +273 -0
  112. package/dist/services/macos-launchd.js.map +1 -0
  113. package/dist/services/nomad-manager.d.ts +11 -0
  114. package/dist/services/nomad-manager.js +343 -98
  115. package/dist/services/nomad-manager.js.map +1 -1
  116. package/dist/services/organize/applier.d.ts +46 -0
  117. package/dist/services/organize/applier.js +218 -0
  118. package/dist/services/organize/applier.js.map +1 -0
  119. package/dist/services/organize/rules.d.ts +57 -0
  120. package/dist/services/organize/rules.js +286 -0
  121. package/dist/services/organize/rules.js.map +1 -0
  122. package/dist/services/organize/scanner.d.ts +50 -0
  123. package/dist/services/organize/scanner.js +366 -0
  124. package/dist/services/organize/scanner.js.map +1 -0
  125. package/dist/services/organize/store.d.ts +14 -0
  126. package/dist/services/organize/store.js +82 -0
  127. package/dist/services/organize/store.js.map +1 -0
  128. package/dist/services/panel-manager.js +40 -11
  129. package/dist/services/panel-manager.js.map +1 -1
  130. package/dist/services/process-manager.js +3 -2
  131. package/dist/services/process-manager.js.map +1 -1
  132. package/dist/services/runtime/adapters/custom.js +56 -0
  133. package/dist/services/runtime/adapters/custom.js.map +1 -1
  134. package/dist/services/runtime/adapters/hermes.d.ts +4 -3
  135. package/dist/services/runtime/adapters/hermes.js +166 -64
  136. package/dist/services/runtime/adapters/hermes.js.map +1 -1
  137. package/dist/services/runtime/adapters/openclaw-routes.d.ts +8 -2
  138. package/dist/services/runtime/adapters/openclaw-routes.js +68 -0
  139. package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -1
  140. package/dist/services/runtime/adapters/openclaw.d.ts +118 -0
  141. package/dist/services/runtime/adapters/openclaw.js +1459 -49
  142. package/dist/services/runtime/adapters/openclaw.js.map +1 -1
  143. package/dist/services/runtime/instance.d.ts +1 -1
  144. package/dist/services/runtime/instance.js +1 -1
  145. package/dist/services/runtime/instance.js.map +1 -1
  146. package/dist/services/runtime/mcp-shims/anythingllm-shim.d.ts +46 -0
  147. package/dist/services/runtime/mcp-shims/anythingllm-shim.js +281 -0
  148. package/dist/services/runtime/mcp-shims/anythingllm-shim.js.map +1 -0
  149. package/dist/services/runtime/mcp-shims/drive-shim.d.ts +54 -0
  150. package/dist/services/runtime/mcp-shims/drive-shim.js +489 -0
  151. package/dist/services/runtime/mcp-shims/drive-shim.js.map +1 -0
  152. package/dist/services/runtime/types.d.ts +31 -0
  153. package/dist/services/setup-manager.js +190 -68
  154. package/dist/services/setup-manager.js.map +1 -1
  155. package/dist/services/suggestions.js.map +1 -1
  156. package/dist/services/update-manager.js +32 -14
  157. package/dist/services/update-manager.js.map +1 -1
  158. package/dist/services/webdav/server.d.ts +24 -0
  159. package/dist/services/webdav/server.js +420 -0
  160. package/dist/services/webdav/server.js.map +1 -0
  161. package/dist/services/webdav/xml-builder.d.ts +73 -0
  162. package/dist/services/webdav/xml-builder.js +156 -0
  163. package/dist/services/webdav/xml-builder.js.map +1 -0
  164. package/dist/services/workspace-builder.d.ts +29 -0
  165. package/dist/services/workspace-builder.js +188 -0
  166. package/dist/services/workspace-builder.js.map +1 -0
  167. package/dist/types.d.ts +61 -0
  168. package/dist/utils/path-locks.d.ts +30 -0
  169. package/dist/utils/path-locks.js +63 -0
  170. package/dist/utils/path-locks.js.map +1 -0
  171. package/dist/utils/path-safety.d.ts +41 -0
  172. package/dist/utils/path-safety.js +119 -0
  173. package/dist/utils/path-safety.js.map +1 -0
  174. package/dist/utils/safe-write.d.ts +24 -0
  175. package/dist/utils/safe-write.js +82 -0
  176. package/dist/utils/safe-write.js.map +1 -0
  177. package/install/jishu-install.sh +247 -35
  178. package/install/jishu-uninstall.sh +45 -5
  179. package/package.json +20 -2
  180. package/public/assets/ApiKeyField-CvyAOcJS.js +1 -0
  181. package/public/assets/Dashboard-AuJESBlJ.js +1 -0
  182. package/public/assets/{HermesChatPanel-_GHoklgo.js → HermesChatPanel-CByPREwb.js} +1 -1
  183. package/public/assets/HermesConfigForm-DRda8FKX.js +4 -0
  184. package/public/assets/InitPassword-ka4wNpM5.js +1 -0
  185. package/public/assets/InstanceDetail-Cg1nS8HX.js +92 -0
  186. package/public/assets/Login-aPajuQzf.js +1 -0
  187. package/public/assets/NewInstance-Dd1ebNIx.js +1 -0
  188. package/public/assets/ProviderRecommendations-DFmADQ7V.js +1 -0
  189. package/public/assets/Settings-BYQnbLYL.js +1 -0
  190. package/public/assets/Setup-D05lwDOV.js +1 -0
  191. package/public/assets/WeixinLoginPanel-D89kdhP4.js +9 -0
  192. package/public/assets/index-HSXCsceK.css +1 -0
  193. package/public/assets/index-bnBu0nlQ.js +19 -0
  194. package/public/assets/registry-C_qeFTkZ.js +2 -0
  195. package/public/assets/usePolling-Bn93fe7M.js +1 -0
  196. package/public/assets/{vendor-i18n-ucpM0OR0.js → vendor-i18n-flxcMVeP.js} +2 -2
  197. package/public/assets/{vendor-react-Bk1hRGiY.js → vendor-react-ZC5T_huj.js} +7 -7
  198. package/public/index.html +4 -4
  199. package/scripts/check-app-spec.mjs +18 -4
  200. package/scripts/check-colima-launchd.mjs +230 -0
  201. package/scripts/check-new-file-tests.mjs +230 -0
  202. package/scripts/check-quarantine-expiry.mjs +105 -0
  203. package/scripts/perf/README.md +49 -0
  204. package/scripts/perf/auth.js +99 -0
  205. package/scripts/perf/config.js +63 -0
  206. package/scripts/perf/instances.js +143 -0
  207. package/scripts/perf/proxy.js +96 -0
  208. package/scripts/smoke/files-w1.sh +142 -0
  209. package/scripts/smoke-backend.mjs +122 -0
  210. package/scripts/smoke-post-publish.mjs +346 -0
  211. package/public/assets/Dashboard-rkWp-CXd.js +0 -1
  212. package/public/assets/HermesConfigForm-anDnwUp_.js +0 -4
  213. package/public/assets/InitPassword-ZU9_-hDr.js +0 -1
  214. package/public/assets/InstanceDetail-CN0FH1aw.js +0 -92
  215. package/public/assets/Login-BItXqYAJ.js +0 -1
  216. package/public/assets/NewInstance-BousE6kY.js +0 -1
  217. package/public/assets/ProviderRecommendations-DFYj7Fb6.js +0 -1
  218. package/public/assets/Settings-Bttc6QmM.js +0 -1
  219. package/public/assets/Setup-Bsxx1zgj.js +0 -1
  220. package/public/assets/WeixinLoginPanel-DPZpAKgO.js +0 -9
  221. package/public/assets/index-8xZy1z5k.css +0 -1
  222. package/public/assets/index-Dw3HhUYE.js +0 -19
  223. package/public/assets/input-paste-CrNVAyOy.js +0 -1
  224. package/public/assets/providers-DtNXh9JD.js +0 -1
  225. package/public/assets/registry-5s2UB6is.js +0 -2
  226. package/public/assets/usePolling-Do5Erqm_.js +0 -1
@@ -20,7 +20,7 @@ import { StringDecoder } from "string_decoder";
20
20
  import { promisify } from "util";
21
21
  import { parse } from "yaml";
22
22
  import * as config from "../config.js";
23
- import { getGatewayPort, getInstance, getInstanceRuntime, instanceMetaPath, getRuntimeEnv, isPortInUse, reallocateGatewayPort, } from "./instance-manager.js";
23
+ import { extractGatewayPort, getGatewayPort, getInstance, getInstanceRuntime, instanceMetaPath, getRuntimeEnv, isPortInUse, reallocateGatewayPort, } from "./instance-manager.js";
24
24
  import { getAdapter, resolveAgentType } from "./runtime/index.js";
25
25
  function getConfigValue(name) {
26
26
  return name in config ? config[name] : undefined;
@@ -30,7 +30,7 @@ function resolveConfigPath(value, fallback) {
30
30
  }
31
31
  const JISHUSHELL_HOME = resolveConfigPath(getConfigValue("JISHUSHELL_HOME"), join(process.env.HOME ?? homedir(), ".jishushell"));
32
32
  const APPS_DIR = resolveConfigPath(getConfigValue("APPS_DIR"), join(JISHUSHELL_HOME, "apps"));
33
- const INSTANCES_DIR = resolveConfigPath(getConfigValue("INSTANCES_DIR"), join(JISHUSHELL_HOME, "instances"));
33
+ const _INSTANCES_DIR = resolveConfigPath(getConfigValue("INSTANCES_DIR"), join(JISHUSHELL_HOME, "instances"));
34
34
  const getNomadAddrValue = getConfigValue("getNomadAddr");
35
35
  const getNomadDriverValue = getConfigValue("getNomadDriver");
36
36
  const getNomadTokenValue = getConfigValue("getNomadToken");
@@ -441,13 +441,22 @@ function wrapNomadJob(jid, groupName, task) {
441
441
  Count: 1,
442
442
  ...(groupNetworks ? { Networks: groupNetworks } : {}),
443
443
  RestartPolicy: {
444
- Attempts: 3,
445
- Interval: 300000000000,
444
+ // 10 attempts × 15s delay = ~2.5min recovery window. Multi-task
445
+ // groups (e.g. weknora-app racing paradedb cold-init) commonly
446
+ // need 3-5 restarts before the dependency's external port-publish
447
+ // settles. Previous 3-attempt cap caused alloc-fail cascades on
448
+ // first-boot of stacks with DB sidecars.
449
+ Attempts: 10,
450
+ Interval: 600000000000,
446
451
  Delay: 15000000000,
447
452
  Mode: "fail",
448
453
  },
449
454
  Reschedule: {
450
- Attempts: 0,
455
+ // Allow Nomad to reschedule the whole alloc up to 3 times if all
456
+ // restarts fail (e.g. transient image pull or host reboot).
457
+ // Unlimited stays false to keep alloc churn bounded.
458
+ Attempts: 3,
459
+ Interval: 3600000000000,
451
460
  Unlimited: false,
452
461
  },
453
462
  Update: {
@@ -531,24 +540,35 @@ async function injectConnectionsRuntimeEnv(instanceId, task) {
531
540
  }
532
541
  }
533
542
  async function getRunningAlloc(instanceId) {
543
+ const allocs = await getAllocs(instanceId);
544
+ if (!allocs)
545
+ return null;
546
+ for (const status of ["running", "pending"]) {
547
+ for (const alloc of allocs) {
548
+ if (alloc.ClientStatus === status)
549
+ return alloc;
550
+ }
551
+ }
552
+ return null;
553
+ }
554
+ async function getAllocs(instanceId) {
534
555
  const jid = jobId(instanceId);
535
556
  try {
536
557
  const resp = await nomadGet(`/v1/job/${jid}/allocations`);
537
558
  if (resp.status === 404)
538
- return null;
559
+ return [];
539
560
  const allocs = await resp.json();
540
- for (const status of ["running", "pending"]) {
541
- for (const alloc of allocs) {
542
- if (alloc.ClientStatus === status)
543
- return alloc;
544
- }
545
- }
546
- return null;
561
+ return Array.isArray(allocs) ? allocs : [];
547
562
  }
548
563
  catch {
549
564
  return null;
550
565
  }
551
566
  }
567
+ function latestAlloc(allocs) {
568
+ if (!allocs.length)
569
+ return null;
570
+ return [...allocs].sort((a, b) => ((b.ModifyIndex ?? b.CreateIndex ?? 0) - (a.ModifyIndex ?? a.CreateIndex ?? 0)))[0] ?? null;
571
+ }
552
572
  // Returns true if the Nomad job exists and was NOT explicitly stopped by the user (Stop=false).
553
573
  // Used on jishushell startup to auto-restart instances that were running before a reboot.
554
574
  export async function shouldAutoStart(instanceId) {
@@ -595,7 +615,12 @@ export async function getStatus(instanceId) {
595
615
  catch {
596
616
  return { ...stopped, status: "unknown", error: "Nomad unreachable" };
597
617
  }
598
- const alloc = await getRunningAlloc(instanceId);
618
+ const allocs = await getAllocs(instanceId);
619
+ if (allocs == null || allocs.length === 0)
620
+ return { ...stopped, status: "pending" };
621
+ const alloc = allocs.find((entry) => entry.ClientStatus === "running")
622
+ ?? allocs.find((entry) => entry.ClientStatus === "pending")
623
+ ?? latestAlloc(allocs);
599
624
  if (!alloc)
600
625
  return { ...stopped, status: "pending" };
601
626
  const allocId = alloc.ID;
@@ -633,32 +658,19 @@ export async function getStatus(instanceId) {
633
658
  }
634
659
  }
635
660
  catch { /* ignore */ }
636
- // Fallback: Nomad cgroup stats are often zero on cgroup v2 (e.g. Raspberry Pi).
637
- // Use `docker stats` directly when Nomad reports 0.
638
- if (!result.memory_mb && allocId) {
639
- try {
640
- // Validate allocId to prevent shell injection (Nomad UUIDs are hex + hyphens)
641
- if (!/^[a-f0-9-]+$/i.test(allocId))
642
- throw new Error("invalid allocId");
643
- const containerName = `${resolveTaskName(instanceId)}-${allocId}`;
644
- const { execFile } = await import("child_process");
645
- const { promisify } = await import("util");
646
- const execFileAsync = promisify(execFile);
647
- const { stdout } = await execFileAsync("docker", ["stats", "--no-stream", "--format", "{{.MemUsage}}", containerName], { timeout: 5000 });
648
- const raw = stdout.trim();
649
- // Format: "499.6MiB / 3GiB" or "123.4MB / 2GB"
650
- const match = raw.match(/^([\d.]+)\s*(MiB|GiB|MB|GB|KiB|KB)/i);
651
- if (match) {
652
- let mb = parseFloat(match[1]);
653
- const unit = match[2].toLowerCase();
654
- if (unit === "gib" || unit === "gb")
655
- mb *= 1024;
656
- else if (unit === "kib" || unit === "kb")
657
- mb /= 1024;
658
- result.memory_mb = Math.round(mb * 10) / 10;
659
- }
661
+ // Fallback: Nomad cgroup stats are often zero on cgroup v2 (e.g. Raspberry
662
+ // Pi / CIX). Read from the shared, cached, single-flight `docker stats`
663
+ // snapshot instead of forking one `docker stats` per instance — see
664
+ // getDockerMemSnapshot for why per-instance forking was the cold-path cost.
665
+ if (!result.memory_mb && allocId && /^[a-f0-9-]+$/i.test(allocId)) {
666
+ const containerName = `${resolveTaskName(instanceId)}-${allocId}`;
667
+ const stat = (await getDockerMemSnapshot()).get(containerName);
668
+ if (stat) {
669
+ if (stat.memory_mb)
670
+ result.memory_mb = stat.memory_mb;
671
+ if (!result.cpu_percent && stat.cpu_percent)
672
+ result.cpu_percent = stat.cpu_percent;
660
673
  }
661
- catch { /* ignore */ }
662
674
  }
663
675
  return result;
664
676
  }
@@ -670,6 +682,20 @@ async function phaseRunningCheck(instanceId) {
670
682
  }
671
683
  return { ok: true };
672
684
  }
685
+ async function phaseResetTerminalJobBeforeStart(instanceId) {
686
+ const status = await getStatus(instanceId);
687
+ if (!["failed", "dead", "complete"].includes(String(status.status)))
688
+ return;
689
+ try {
690
+ const resp = await nomadDelete(`/v1/job/${jobId(instanceId)}?purge=false`);
691
+ if (!resp.ok && resp.status !== 404) {
692
+ console.warn(`[nomad] ${instanceId}: failed to stop terminal job before start (HTTP ${resp.status}): ${await resp.text()}`);
693
+ }
694
+ }
695
+ catch (e) {
696
+ console.warn(`[nomad] ${instanceId}: failed to stop terminal job before start: ${e?.message ?? e}`);
697
+ }
698
+ }
673
699
  /**
674
700
  * Phase 2: home-conflict check — dispatched through the adapter so
675
701
  * framework code carries no agentType-specific knowledge. Adapters that
@@ -677,7 +703,7 @@ async function phaseRunningCheck(instanceId) {
677
703
  * each instance owns its own bind-mount) leave the hook unset and this
678
704
  * phase is a no-op.
679
705
  */
680
- async function phaseHomeConflict(instanceId, sharedHomeIds) {
706
+ async function phaseHomeConflict(_instanceId, sharedHomeIds) {
681
707
  const homeConflicts = [];
682
708
  for (const otherId of sharedHomeIds) {
683
709
  const otherStatus = await getStatus(otherId);
@@ -881,6 +907,7 @@ export async function startInstance(instanceId) {
881
907
  extra.code = running.code;
882
908
  return failed("running_check", extra);
883
909
  }
910
+ await phaseResetTerminalJobBeforeStart(instanceId);
884
911
  const legacyManager = await getLegacyAppManager(instanceId);
885
912
  if (legacyManager) {
886
913
  const prep = await legacyManager.prepareStart(instanceId);
@@ -1118,6 +1145,82 @@ export async function getLogs(instanceId, lines = 200, logType = "stderr") {
1118
1145
  return [];
1119
1146
  }
1120
1147
  const execFileAsync = promisify(execFileCb);
1148
+ const DOCKER_STATS_TTL_MS = 30_000;
1149
+ /** Field separator for the batched `docker stats --format` line. Exported so
1150
+ * tests can construct mock output without hardcoding the literal. */
1151
+ export const DOCKER_STATS_FIELD_SEP = "__JS__";
1152
+ let _dockerStatsEntry = null;
1153
+ let _dockerStatsInFlight = null;
1154
+ /** Test-only: reset the shared docker-stats snapshot so each test starts from
1155
+ * a cold cache (the 30s TTL + single-flight would otherwise leak one test's
1156
+ * mocked snapshot into the next). Not used by production code paths. */
1157
+ export function __resetDockerStatsCacheForTests() {
1158
+ _dockerStatsEntry = null;
1159
+ _dockerStatsInFlight = null;
1160
+ }
1161
+ function parseDockerMemUsageMb(memUsage) {
1162
+ // Format: "499.6MiB / 3GiB" — the used side is everything before "/".
1163
+ const used = (memUsage.split("/")[0] ?? "").trim();
1164
+ const match = used.match(/^([\d.]+)\s*(MiB|GiB|MB|GB|KiB|KB|B)?/i);
1165
+ if (!match)
1166
+ return 0;
1167
+ let mb = parseFloat(match[1]);
1168
+ if (!Number.isFinite(mb))
1169
+ return 0;
1170
+ const unit = (match[2] ?? "MiB").toLowerCase();
1171
+ if (unit === "gib" || unit === "gb")
1172
+ mb *= 1024;
1173
+ else if (unit === "kib" || unit === "kb")
1174
+ mb /= 1024;
1175
+ else if (unit === "b")
1176
+ mb /= 1024 * 1024;
1177
+ return Math.round(mb * 10) / 10;
1178
+ }
1179
+ async function loadDockerStatsSnapshot() {
1180
+ const snapshot = new Map();
1181
+ try {
1182
+ const fmt = `{{.Name}}${DOCKER_STATS_FIELD_SEP}{{.MemUsage}}${DOCKER_STATS_FIELD_SEP}{{.CPUPerc}}`;
1183
+ const { stdout } = await execFileAsync("docker", ["stats", "--no-stream", "--format", fmt], { timeout: 8_000 });
1184
+ for (const line of stdout.split("\n")) {
1185
+ const trimmed = line.trim();
1186
+ if (!trimmed)
1187
+ continue;
1188
+ const [name, memUsage, cpuPerc] = trimmed.split(DOCKER_STATS_FIELD_SEP);
1189
+ if (!name)
1190
+ continue;
1191
+ snapshot.set(name, {
1192
+ memory_mb: parseDockerMemUsageMb(memUsage ?? ""),
1193
+ cpu_percent: Math.round((parseFloat(cpuPerc ?? "") || 0) * 10) / 10,
1194
+ });
1195
+ }
1196
+ }
1197
+ catch {
1198
+ /* docker missing / timeout / daemon down → empty map, caller degrades */
1199
+ }
1200
+ return snapshot;
1201
+ }
1202
+ /**
1203
+ * Returns a per-container stats map, refreshing at most once per
1204
+ * DOCKER_STATS_TTL_MS. Concurrent callers (the `Promise.all` over every
1205
+ * instance in `GET /api/instances`) share a single in-flight docker call.
1206
+ */
1207
+ async function getDockerMemSnapshot() {
1208
+ const now = Date.now();
1209
+ if (_dockerStatsEntry && now - _dockerStatsEntry.ts < DOCKER_STATS_TTL_MS) {
1210
+ return _dockerStatsEntry.data;
1211
+ }
1212
+ if (_dockerStatsInFlight)
1213
+ return _dockerStatsInFlight;
1214
+ _dockerStatsInFlight = loadDockerStatsSnapshot()
1215
+ .then((data) => {
1216
+ _dockerStatsEntry = { data, ts: Date.now() };
1217
+ return data;
1218
+ })
1219
+ .finally(() => {
1220
+ _dockerStatsInFlight = null;
1221
+ });
1222
+ return _dockerStatsInFlight;
1223
+ }
1121
1224
  export async function exec(instanceId, command, timeoutMs = 120_000) {
1122
1225
  const alloc = await getRunningAlloc(instanceId);
1123
1226
  if (!alloc || alloc.ClientStatus !== "running") {
@@ -1214,7 +1317,7 @@ var UnifiedNomadJobs;
1214
1317
  const MAX_MEMORY_MAX_MB = 4096; // 4 GB hard limit
1215
1318
  const DEFAULT_PIDS_LIMIT = 512;
1216
1319
  const NOMAD_CONFIG_PATH = join(JISHUSHELL_HOME, "nomad", "nomad.hcl");
1217
- const DEFAULT_CWD = homedir();
1320
+ const _DEFAULT_CWD = homedir();
1218
1321
  function appDirForId(appId) {
1219
1322
  return join(APPS_DIR, appId);
1220
1323
  }
@@ -1408,12 +1511,12 @@ var UnifiedNomadJobs;
1408
1511
  if (!s)
1409
1512
  return defaultNs;
1410
1513
  if (s.endsWith("ms"))
1411
- return parseInt(s) * 1_000_000;
1514
+ return parseInt(s, 10) * 1_000_000;
1412
1515
  if (s.endsWith("s"))
1413
- return parseInt(s) * 1_000_000_000;
1516
+ return parseInt(s, 10) * 1_000_000_000;
1414
1517
  if (s.endsWith("m"))
1415
- return parseInt(s) * 60_000_000_000;
1416
- return parseInt(s) * 1_000_000_000;
1518
+ return parseInt(s, 10) * 60_000_000_000;
1519
+ return parseInt(s, 10) * 1_000_000_000;
1417
1520
  }
1418
1521
  function portLabel(taskName, portName) {
1419
1522
  const sanitize = (value) => value.replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -1434,6 +1537,14 @@ var UnifiedNomadJobs;
1434
1537
  function hostNetworkForPort(port) {
1435
1538
  if ((port.visibility ?? "external") === "internal")
1436
1539
  return undefined;
1540
+ // Honor explicit host_network from spec (e.g. weknora's ports declare
1541
+ // `host_network: docker_bridge` so peer tasks reach the published port
1542
+ // via host.docker.internal). Verify the named network is actually
1543
+ // declared in nomad.hcl — otherwise Nomad rejects job submission with
1544
+ // "unknown host network". Falls back to "external" when unset.
1545
+ const requested = (port.host_network ?? "").trim();
1546
+ if (requested && nomadConfigDeclaresHostNetwork(requested))
1547
+ return requested;
1437
1548
  return nomadConfigDeclaresHostNetwork("external") ? "external" : undefined;
1438
1549
  }
1439
1550
  function specRequiresExternalHostNetwork(spec) {
@@ -1492,6 +1603,96 @@ var UnifiedNomadJobs;
1492
1603
  : {}),
1493
1604
  }));
1494
1605
  }
1606
+ function isExternalAppTaskPort(port) {
1607
+ return (port.visibility ?? "external") !== "internal";
1608
+ }
1609
+ function readDeclaredHostPort(port) {
1610
+ const candidate = port.host_port ?? port.port;
1611
+ return Number.isInteger(candidate) && candidate > 0 ? candidate : null;
1612
+ }
1613
+ function applyPersistedAppSpecPortOverrides(appId, spec) {
1614
+ const meta = getInstance(appId);
1615
+ if (!meta)
1616
+ return spec;
1617
+ const runtime = getInstanceRuntime(appId);
1618
+ const runtimePorts = Array.isArray(runtime.ports) ? runtime.ports : [];
1619
+ const persistedGatewayPort = extractGatewayPort(runtime, resolveAgentType(meta));
1620
+ const totalExternalPorts = spec.tasks.reduce((count, task) => count + (task.ports ?? []).filter((port) => isExternalAppTaskPort(port)).length, 0);
1621
+ let changed = false;
1622
+ const tasks = spec.tasks.map((task) => {
1623
+ if (!Array.isArray(task.ports) || task.ports.length === 0)
1624
+ return task;
1625
+ let taskChanged = false;
1626
+ const ports = task.ports.map((port) => {
1627
+ if (!isExternalAppTaskPort(port))
1628
+ return port;
1629
+ const currentHostPort = readDeclaredHostPort(port);
1630
+ if (!currentHostPort)
1631
+ return port;
1632
+ let nextHostPort = null;
1633
+ const namedRuntimePort = typeof port.name === "string" && port.name
1634
+ ? runtimePorts.find((candidate) => candidate?.name === port.name
1635
+ && Number.isInteger(candidate?.hostPort)
1636
+ && candidate.hostPort > 0)
1637
+ : null;
1638
+ if (namedRuntimePort) {
1639
+ nextHostPort = namedRuntimePort.hostPort;
1640
+ }
1641
+ else if (runtimePorts.length === 1
1642
+ && totalExternalPorts === 1
1643
+ && Number.isInteger(runtimePorts[0]?.hostPort)
1644
+ && runtimePorts[0].hostPort > 0) {
1645
+ nextHostPort = runtimePorts[0].hostPort;
1646
+ }
1647
+ else if (totalExternalPorts === 1
1648
+ && persistedGatewayPort != null
1649
+ && persistedGatewayPort > 0) {
1650
+ nextHostPort = persistedGatewayPort;
1651
+ }
1652
+ if (!nextHostPort || nextHostPort === currentHostPort)
1653
+ return port;
1654
+ changed = true;
1655
+ taskChanged = true;
1656
+ return { ...port, host_port: nextHostPort };
1657
+ });
1658
+ return taskChanged ? { ...task, ports } : task;
1659
+ });
1660
+ return changed ? { ...spec, tasks } : spec;
1661
+ }
1662
+ async function maybeReallocateAppSpecHostPort(appId, spec, reason) {
1663
+ if (!getInstance(appId))
1664
+ return { spec, changed: false };
1665
+ const effectiveSpec = applyPersistedAppSpecPortOverrides(appId, spec);
1666
+ const currentGatewayPort = getGatewayPort(appId);
1667
+ if (!Number.isInteger(currentGatewayPort) || currentGatewayPort <= 0) {
1668
+ return { spec: effectiveSpec, changed: false };
1669
+ }
1670
+ const declaredPorts = effectiveSpec.tasks.flatMap((task) => (task.ports ?? [])
1671
+ .filter((port) => isExternalAppTaskPort(port))
1672
+ .map((port) => readDeclaredHostPort(port))
1673
+ .filter((port) => port != null));
1674
+ if (!declaredPorts.includes(currentGatewayPort)) {
1675
+ return { spec: effectiveSpec, changed: false };
1676
+ }
1677
+ if (!(await isPortInUse(currentGatewayPort))) {
1678
+ return { spec: effectiveSpec, changed: false };
1679
+ }
1680
+ try {
1681
+ const reallocation = await reallocateGatewayPort(appId);
1682
+ console.log(`[nomad] ${appId}: reallocated AppSpec host port ${reallocation.from} -> ${reallocation.to} (${reason})`);
1683
+ return {
1684
+ spec: applyPersistedAppSpecPortOverrides(appId, spec),
1685
+ changed: true,
1686
+ };
1687
+ }
1688
+ catch (e) {
1689
+ return {
1690
+ spec: effectiveSpec,
1691
+ changed: false,
1692
+ error: `AppSpec host port ${currentGatewayPort} is held by another process and reallocation failed: ${e?.message ?? e}`,
1693
+ };
1694
+ }
1695
+ }
1495
1696
  // ── Health check → Nomad service check builder ────────────────────────────
1496
1697
  function buildServiceCheck(task, appId) {
1497
1698
  const health = task.health;
@@ -1710,12 +1911,6 @@ var UnifiedNomadJobs;
1710
1911
  return null;
1711
1912
  return command.replace(/^~(?=\/|$)/, homedir());
1712
1913
  }
1713
- function taskCommandLine(task) {
1714
- const command = expandTaskCommand(task.command);
1715
- if (!command)
1716
- return null;
1717
- return [command, ...(task.args ?? []).map(String)].join(" ").trim();
1718
- }
1719
1914
  function commandLineMatchesTask(commandLine, task) {
1720
1915
  const normalized = commandLine.trim();
1721
1916
  const command = expandTaskCommand(task.command);
@@ -2264,6 +2459,21 @@ var UnifiedNomadJobs;
2264
2459
  }
2265
2460
  return declared;
2266
2461
  })();
2462
+ // Validate cap_add against a tight allowlist. The full Linux cap surface
2463
+ // is large; we only honor capabilities image entrypoints actually need
2464
+ // for the canonical "start as root + gosu to user" pattern. Anything
2465
+ // outside this list is silently dropped — failing closed protects
2466
+ // against typo'd / hostile specs widening the container's capability
2467
+ // bounds beyond what the panel author signed off on.
2468
+ const ALLOWED_CAPS = new Set([
2469
+ "CHOWN", "DAC_OVERRIDE", "FOWNER",
2470
+ "SETUID", "SETGID", "SETPCAP", "NET_BIND_SERVICE",
2471
+ ]);
2472
+ const capAdd = Array.isArray(task.cap_add)
2473
+ ? task.cap_add
2474
+ .map((c) => typeof c === "string" ? c.trim().toUpperCase().replace(/^CAP_/, "") : "")
2475
+ .filter((c) => ALLOWED_CAPS.has(c))
2476
+ : [];
2267
2477
  const taskDef = {
2268
2478
  Name: task.name,
2269
2479
  Driver: "docker",
@@ -2285,6 +2495,7 @@ var UnifiedNomadJobs;
2285
2495
  ...(publishedPorts.length > 0 ? { ports: publishedPorts } : {}),
2286
2496
  extra_hosts: ["host.docker.internal:host-gateway"],
2287
2497
  cap_drop: ["ALL"],
2498
+ ...(capAdd.length > 0 ? { cap_add: capAdd } : {}),
2288
2499
  security_opt: ["no-new-privileges"],
2289
2500
  pids_limit: DEFAULT_PIDS_LIMIT,
2290
2501
  readonly_rootfs: false,
@@ -2354,13 +2565,17 @@ var UnifiedNomadJobs;
2354
2565
  ? { Networks: [{ ReservedPorts: groupReservedPorts }] }
2355
2566
  : {}),
2356
2567
  RestartPolicy: {
2357
- Attempts: 3,
2358
- Interval: 300_000_000_000,
2568
+ // 10 attempts × 15s delay = ~2.5min recovery window. Multi-task
2569
+ // app groups commonly need 3-5 restarts before sidecar
2570
+ // dependencies' external port-publish settles.
2571
+ Attempts: 10,
2572
+ Interval: 600_000_000_000,
2359
2573
  Delay: 15_000_000_000,
2360
2574
  Mode: "fail",
2361
2575
  },
2362
2576
  Reschedule: {
2363
- Attempts: 0,
2577
+ Attempts: 3,
2578
+ Interval: 3_600_000_000_000,
2364
2579
  Unlimited: false,
2365
2580
  },
2366
2581
  Update: {
@@ -2485,6 +2700,7 @@ var UnifiedNomadJobs;
2485
2700
  }
2486
2701
  return statuses[0];
2487
2702
  }
2703
+ UnifiedNomadJobs.aggregateHealthStatus = aggregateHealthStatus;
2488
2704
  async function getRunningAlloc(appId) {
2489
2705
  return pickLiveAlloc(await getAllocs(appId));
2490
2706
  }
@@ -2616,28 +2832,17 @@ var UnifiedNomadJobs;
2616
2832
  }
2617
2833
  }
2618
2834
  catch { /* ignore */ }
2619
- // Fallback: use `docker stats` when Nomad cgroup stats are zero (cgroup v2 / Pi).
2620
- // Only applicable for docker-driver tasks.
2621
- if (!result.memory_mb && allocId && ptName) {
2622
- try {
2623
- if (!/^[a-f0-9-]+$/i.test(allocId))
2624
- throw new Error("invalid allocId");
2625
- const containerName = `${ptName}-${allocId}`;
2626
- const execFileAsync = promisify(execFileCb);
2627
- const { stdout } = await execFileAsync("docker", ["stats", "--no-stream", "--format", "{{.MemUsage}}", containerName], { timeout: 5_000 });
2628
- const raw = stdout.trim();
2629
- const match = raw.match(/^([\d.]+)\s*(MiB|GiB|MB|GB|KiB|KB)/i);
2630
- if (match) {
2631
- let mb = parseFloat(match[1]);
2632
- const unit = match[2].toLowerCase();
2633
- if (unit === "gib" || unit === "gb")
2634
- mb *= 1024;
2635
- else if (unit === "kib" || unit === "kb")
2636
- mb /= 1024;
2637
- result.memory_mb = Math.round(mb * 10) / 10;
2638
- }
2835
+ // Fallback: cgroup v2 (Pi / CIX) → Nomad alloc-stats are zero. Use the
2836
+ // shared cached `docker stats` snapshot rather than forking per-instance.
2837
+ if (!result.memory_mb && allocId && ptName && /^[a-f0-9-]+$/i.test(allocId)) {
2838
+ const containerName = `${ptName}-${allocId}`;
2839
+ const stat = (await getDockerMemSnapshot()).get(containerName);
2840
+ if (stat) {
2841
+ if (stat.memory_mb)
2842
+ result.memory_mb = stat.memory_mb;
2843
+ if (!result.cpu_percent && stat.cpu_percent)
2844
+ result.cpu_percent = stat.cpu_percent;
2639
2845
  }
2640
- catch { /* ignore */ }
2641
2846
  }
2642
2847
  return result;
2643
2848
  }
@@ -2725,7 +2930,7 @@ var UnifiedNomadJobs;
2725
2930
  if (adoptedExternal.conflicts.length > 0) {
2726
2931
  return { ok: false, error: adoptedExternal.conflicts.join("; ") };
2727
2932
  }
2728
- const effectiveSpec = adoptedExternal.spec;
2933
+ let effectiveSpec = applyPersistedAppSpecPortOverrides(appId, adoptedExternal.spec);
2729
2934
  // Validate all images before submitting
2730
2935
  for (const task of effectiveSpec.tasks) {
2731
2936
  if (task.runtime === "container") {
@@ -2752,31 +2957,54 @@ var UnifiedNomadJobs;
2752
2957
  if (hostNetworkError) {
2753
2958
  return { ok: false, error: hostNetworkError };
2754
2959
  }
2755
- let jobDef;
2756
- try {
2757
- jobDef = buildAppJob(effectiveSpec, appId, driver, extraEnv);
2758
- }
2759
- catch (e) {
2760
- return { ok: false, error: `Job build failed: ${e.message}` };
2960
+ if (driver === "docker") {
2961
+ const preflight = await maybeReallocateAppSpecHostPort(appId, effectiveSpec, "host_port_busy");
2962
+ if (preflight.error)
2963
+ return { ok: false, error: preflight.error };
2964
+ effectiveSpec = preflight.spec;
2761
2965
  }
2762
- try {
2763
- const resp = await nomadPost("/v1/jobs", jobDef);
2764
- if (resp.ok) {
2765
- const data = await resp.json();
2766
- return { ok: true, eval_id: data.EvalID };
2966
+ for (let attempt = 0; attempt < 2; attempt++) {
2967
+ let jobDef;
2968
+ try {
2969
+ jobDef = buildAppJob(effectiveSpec, appId, driver, extraEnv);
2767
2970
  }
2768
- const text = await resp.text();
2769
- return { ok: false, error: text };
2770
- }
2771
- catch (e) {
2772
- const isNetErr = e?.message === "fetch failed" || e?.cause?.code === "ECONNREFUSED";
2773
- return {
2774
- ok: false,
2775
- error: isNetErr
2776
- ? `Nomad 服务不可达 (${getNomadAddr()}),请先启动 Nomad`
2777
- : e.message,
2778
- };
2971
+ catch (e) {
2972
+ return { ok: false, error: `Job build failed: ${e.message}` };
2973
+ }
2974
+ let submitError = null;
2975
+ let netErr = false;
2976
+ try {
2977
+ const resp = await nomadPost("/v1/jobs", jobDef);
2978
+ if (resp.ok) {
2979
+ const data = await resp.json();
2980
+ // When the app was previously failed, verify it actually transitions
2981
+ // away from the failed state rather than reporting false success.
2982
+ if (status.status === "failed") {
2983
+ const recovered = await waitForRecovery(appId, 15_000, 2_000);
2984
+ if (!recovered) {
2985
+ return { ok: false, error: "App start submitted but instance remains in failed state. Check app logs for details.", eval_id: data.EvalID };
2986
+ }
2987
+ }
2988
+ return { ok: true, eval_id: data.EvalID };
2989
+ }
2990
+ submitError = await resp.text();
2991
+ }
2992
+ catch (e) {
2993
+ netErr = e?.message === "fetch failed" || e?.cause?.code === "ECONNREFUSED";
2994
+ submitError = netErr ? `Nomad 服务不可达 (${getNomadAddr()}),请先启动 Nomad` : e.message;
2995
+ }
2996
+ if (attempt === 0 && driver === "docker" && !netErr) {
2997
+ const retry = await maybeReallocateAppSpecHostPort(appId, effectiveSpec, "docker_race");
2998
+ if (retry.error)
2999
+ return { ok: false, error: retry.error };
3000
+ if (retry.changed) {
3001
+ effectiveSpec = retry.spec;
3002
+ continue;
3003
+ }
3004
+ }
3005
+ return { ok: false, error: submitError ?? "unknown error" };
2779
3006
  }
3007
+ return { ok: false, error: "start retry exhausted" };
2780
3008
  }
2781
3009
  UnifiedNomadJobs.startAppJob = startAppJob;
2782
3010
  /**
@@ -2796,6 +3024,21 @@ var UnifiedNomadJobs;
2796
3024
  return false;
2797
3025
  }
2798
3026
  UnifiedNomadJobs.waitForRunning = waitForRunning;
3027
+ /**
3028
+ * Poll until the app job leaves the "failed" state or times out.
3029
+ * Used after start submission to verify actual recovery before reporting success.
3030
+ * Returns true if the app transitions away from "failed" (to pending/running/etc).
3031
+ */
3032
+ async function waitForRecovery(appId, timeoutMs = 15_000, pollIntervalMs = 2_000) {
3033
+ const deadline = Date.now() + timeoutMs;
3034
+ while (Date.now() < deadline) {
3035
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
3036
+ const status = await getAppStatus(appId);
3037
+ if (status.status !== "failed")
3038
+ return true;
3039
+ }
3040
+ return false;
3041
+ }
2799
3042
  async function checkDependencies(spec) {
2800
3043
  if (!spec.depends_on || Object.keys(spec.depends_on).length === 0) {
2801
3044
  return { ok: true, errors: [] };
@@ -3705,4 +3948,6 @@ export const shouldAutoStartNomadJob = UnifiedNomadJobs.shouldAutoStart;
3705
3948
  export const startNomadJobInstance = UnifiedNomadJobs.startInstance;
3706
3949
  export const stopNomadJobInstance = UnifiedNomadJobs.stopInstance;
3707
3950
  export const restartNomadJobInstance = UnifiedNomadJobs.restartInstance;
3951
+ // @internal — exposed for Phase 10.4 unit testing only.
3952
+ export const __aggregateHealthStatusForTests = UnifiedNomadJobs.aggregateHealthStatus;
3708
3953
  //# sourceMappingURL=nomad-manager.js.map