@wenathlan/saddle 2.0.154 → 2.0.159

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 (314) hide show
  1. package/Dockerfile +167 -1334
  2. package/README.md +59 -60
  3. package/biome.jsonc +3 -3
  4. package/browser.ts +482 -0
  5. package/cli.ts +274 -0
  6. package/distribution.ts +731 -0
  7. package/docker.config +29 -8
  8. package/docs/{architecture.1.8.19.mmd → architecture1819.md} +6 -0
  9. package/docs/artifactavailability.md +2 -2
  10. package/docs/browser.md +18 -1
  11. package/docs/{containerplatforms.2.1.0.md → containerplatforms210.md} +1 -1
  12. package/docs/{documentmap.1.8.12.md → documentmap1812.md} +1 -1
  13. package/docs/extension.md +13 -2
  14. package/docs/features.md +5 -0
  15. package/docs/libraryapi.md +1 -1
  16. package/docs/{ninety.modalities.md → ninetymodalities.md} +87 -4
  17. package/docs/{planning.ninety.md → planningninety.md} +6 -6
  18. package/docs/{releasenotes.1.8.13.md → releasenotes1813.md} +2 -2
  19. package/docs/{releasenotes.1.8.14.md → releasenotes1814.md} +1 -1
  20. package/docs/{repo.synthesis.1.8.16.md → reposynthesis1816.md} +1 -1
  21. package/docs/todo.md +2 -2
  22. package/docs/{todo.1.8.15.md → todo1815.md} +1 -1
  23. package/docs/{todo.1.8.16.md → todo1816.md} +5 -5
  24. package/execution.ts +677 -0
  25. package/foundation.ts +314 -0
  26. package/index.ts +36 -34
  27. package/isolation.ts +84 -0
  28. package/modalityregistry.ts +189 -145
  29. package/modes.ts +170 -0
  30. package/mttg.config +1 -1
  31. package/orchestrator.ts +49 -15
  32. package/package.json +105 -89
  33. package/qemu.config +2 -2
  34. package/sandbox.ts +3279 -0
  35. package/scheduler.ts +1 -1
  36. package/server.ts +52 -0
  37. package/tsconfig.json +12 -13
  38. package/virtual.ts +7528 -0
  39. package/workloadlaw.ts +293 -0
  40. package/dist/App.d.ts +0 -5
  41. package/dist/App.js +0 -52
  42. package/dist/Sol/Sol.d.ts +0 -3
  43. package/dist/Sol/Sol.js +0 -46
  44. package/dist/Sol/agentbrowser/agentbrowser.d.ts +0 -9
  45. package/dist/Sol/agentbrowser/agentbrowser.js +0 -121
  46. package/dist/Sol/architecture/architecture.d.ts +0 -9
  47. package/dist/Sol/architecture/architecture.js +0 -93
  48. package/dist/Sol/compute/compute.d.ts +0 -9
  49. package/dist/Sol/compute/compute.js +0 -22
  50. package/dist/Sol/console/console.d.ts +0 -15
  51. package/dist/Sol/console/console.js +0 -292
  52. package/dist/Sol/dashboard/dashboard.d.ts +0 -10
  53. package/dist/Sol/dashboard/dashboard.js +0 -181
  54. package/dist/Sol/docs/docs.d.ts +0 -9
  55. package/dist/Sol/docs/docs.js +0 -28
  56. package/dist/Sol/home/home.d.ts +0 -35
  57. package/dist/Sol/home/home.js +0 -239
  58. package/dist/Sol/integrations/integrations.d.ts +0 -9
  59. package/dist/Sol/integrations/integrations.js +0 -83
  60. package/dist/Sol/intro/intro.d.ts +0 -7
  61. package/dist/Sol/intro/intro.js +0 -102
  62. package/dist/Sol/login/login.d.ts +0 -16
  63. package/dist/Sol/login/login.js +0 -49
  64. package/dist/Sol/notfound/notfound.d.ts +0 -9
  65. package/dist/Sol/notfound/notfound.js +0 -15
  66. package/dist/Sol/onboarding/onboarding.d.ts +0 -25
  67. package/dist/Sol/onboarding/onboarding.js +0 -60
  68. package/dist/Sol/playground/playground.d.ts +0 -9
  69. package/dist/Sol/playground/playground.js +0 -127
  70. package/dist/Sol/register/register.d.ts +0 -16
  71. package/dist/Sol/register/register.js +0 -193
  72. package/dist/Sol/shell/Shell.d.ts +0 -124
  73. package/dist/Sol/shell/Shell.js +0 -335
  74. package/dist/Sol/wingets/ComputeBootladder.d.ts +0 -6
  75. package/dist/Sol/wingets/ComputeBootladder.js +0 -71
  76. package/dist/Sol/wingets/ComputeModalitycatalog.d.ts +0 -7
  77. package/dist/Sol/wingets/ComputeModalitycatalog.js +0 -71
  78. package/dist/Sol/wingets/ConsoleApibadge.d.ts +0 -7
  79. package/dist/Sol/wingets/ConsoleApibadge.js +0 -5
  80. package/dist/Sol/wingets/ConsoleEdgelaw.d.ts +0 -6
  81. package/dist/Sol/wingets/ConsoleEdgelaw.js +0 -36
  82. package/dist/Sol/wingets/ConsoleEventstimeline.d.ts +0 -19
  83. package/dist/Sol/wingets/ConsoleEventstimeline.js +0 -12
  84. package/dist/Sol/wingets/ConsoleSnapshotcontrols.d.ts +0 -8
  85. package/dist/Sol/wingets/ConsoleSnapshotcontrols.js +0 -8
  86. package/dist/Sol/wingets/ConsoleSpecpanel.d.ts +0 -21
  87. package/dist/Sol/wingets/ConsoleSpecpanel.js +0 -75
  88. package/dist/Sol/wingets/ConsoleTerminal.d.ts +0 -22
  89. package/dist/Sol/wingets/ConsoleTerminal.js +0 -77
  90. package/dist/Sol/wingets/DashboardAccountpanel.d.ts +0 -18
  91. package/dist/Sol/wingets/DashboardAccountpanel.js +0 -65
  92. package/dist/Sol/wingets/DashboardAdminpanel.d.ts +0 -6
  93. package/dist/Sol/wingets/DashboardAdminpanel.js +0 -90
  94. package/dist/Sol/wingets/DashboardAuditlog.d.ts +0 -8
  95. package/dist/Sol/wingets/DashboardAuditlog.js +0 -50
  96. package/dist/Sol/wingets/DashboardCreatesandboxform.d.ts +0 -10
  97. package/dist/Sol/wingets/DashboardCreatesandboxform.js +0 -71
  98. package/dist/Sol/wingets/DashboardEventstimeline.d.ts +0 -8
  99. package/dist/Sol/wingets/DashboardEventstimeline.js +0 -83
  100. package/dist/Sol/wingets/DashboardGlobalsandboxestable.d.ts +0 -8
  101. package/dist/Sol/wingets/DashboardGlobalsandboxestable.js +0 -57
  102. package/dist/Sol/wingets/DashboardMeshnodestable.d.ts +0 -8
  103. package/dist/Sol/wingets/DashboardMeshnodestable.js +0 -103
  104. package/dist/Sol/wingets/DashboardSandboxlist.d.ts +0 -10
  105. package/dist/Sol/wingets/DashboardSandboxlist.js +0 -163
  106. package/dist/Sol/wingets/DashboardUserstable.d.ts +0 -8
  107. package/dist/Sol/wingets/DashboardUserstable.js +0 -78
  108. package/dist/Sol/wingets/HomeEntrygate.d.ts +0 -23
  109. package/dist/Sol/wingets/HomeEntrygate.js +0 -73
  110. package/dist/Sol/wingets/HomeMetricstrip.d.ts +0 -9
  111. package/dist/Sol/wingets/HomeMetricstrip.js +0 -4
  112. package/dist/Sol/wingets/HomeRuntimediagram.d.ts +0 -1
  113. package/dist/Sol/wingets/HomeRuntimediagram.js +0 -22
  114. package/dist/Sol/wingets/LoginForm.d.ts +0 -19
  115. package/dist/Sol/wingets/LoginForm.js +0 -141
  116. package/dist/Sol/wingets/RegisterPasswordconfirm.d.ts +0 -13
  117. package/dist/Sol/wingets/RegisterPasswordconfirm.js +0 -16
  118. package/dist/Sol/wingets/RegisterPasswordmeter.d.ts +0 -19
  119. package/dist/Sol/wingets/RegisterPasswordmeter.js +0 -65
  120. package/dist/Sol/wingets/RegisterUsernamerules.d.ts +0 -11
  121. package/dist/Sol/wingets/RegisterUsernamerules.js +0 -16
  122. package/dist/Sol/wingets/index.d.ts +0 -39
  123. package/dist/Sol/wingets/index.js +0 -39
  124. package/dist/alternatives.d.ts +0 -508
  125. package/dist/alternatives.js +0 -950
  126. package/dist/api.d.ts +0 -154
  127. package/dist/api.js +0 -174
  128. package/dist/auth.d.ts +0 -268
  129. package/dist/auth.js +0 -476
  130. package/dist/automation.d.ts +0 -425
  131. package/dist/automation.js +0 -566
  132. package/dist/browser.d.ts +0 -561
  133. package/dist/browser.js +0 -1058
  134. package/dist/capacitor.config.d.ts +0 -45
  135. package/dist/capacitor.config.js +0 -48
  136. package/dist/catalog.d.ts +0 -43
  137. package/dist/catalog.js +0 -44
  138. package/dist/cli.d.ts +0 -16
  139. package/dist/cli.js +0 -168
  140. package/dist/communication.d.ts +0 -181
  141. package/dist/communication.js +0 -376
  142. package/dist/compute.d.ts +0 -1160
  143. package/dist/compute.js +0 -2272
  144. package/dist/dashboardlib.d.ts +0 -58
  145. package/dist/dashboardlib.js +0 -161
  146. package/dist/db.d.ts +0 -569
  147. package/dist/db.js +0 -1080
  148. package/dist/distribution.d.ts +0 -528
  149. package/dist/distribution.js +0 -810
  150. package/dist/drizzle.config.d.ts +0 -20
  151. package/dist/drizzle.config.js +0 -18
  152. package/dist/edgelandes.d.ts +0 -70
  153. package/dist/edgelandes.host.d.ts +0 -26
  154. package/dist/edgelandes.host.js +0 -1089
  155. package/dist/edgelandes.inproc.d.ts +0 -27
  156. package/dist/edgelandes.inproc.js +0 -952
  157. package/dist/edgelandes.js +0 -1133
  158. package/dist/edgelandes.ninety.d.ts +0 -31
  159. package/dist/edgelandes.ninety.js +0 -174
  160. package/dist/edgelandes.virtual.d.ts +0 -25
  161. package/dist/edgelandes.virtual.js +0 -629
  162. package/dist/edgeplane.d.ts +0 -121
  163. package/dist/edgeplane.js +0 -341
  164. package/dist/examplelocaljob.d.ts +0 -1
  165. package/dist/examplelocaljob.js +0 -14
  166. package/dist/execution.d.ts +0 -529
  167. package/dist/execution.js +0 -760
  168. package/dist/family.d.ts +0 -16
  169. package/dist/family.js +0 -42
  170. package/dist/familyurl.d.ts +0 -21
  171. package/dist/familyurl.js +0 -24
  172. package/dist/format.d.ts +0 -18
  173. package/dist/format.js +0 -59
  174. package/dist/foundation.d.ts +0 -172
  175. package/dist/foundation.js +0 -312
  176. package/dist/index.d.ts +0 -735
  177. package/dist/index.js +0 -1020
  178. package/dist/integration.d.ts +0 -235
  179. package/dist/integration.js +0 -228
  180. package/dist/intelligence.d.ts +0 -93
  181. package/dist/intelligence.js +0 -144
  182. package/dist/isolation.d.ts +0 -128
  183. package/dist/isolation.js +0 -95
  184. package/dist/labels.d.ts +0 -665
  185. package/dist/labels.js +0 -707
  186. package/dist/localauth.d.ts +0 -127
  187. package/dist/localauth.js +0 -413
  188. package/dist/media.d.ts +0 -343
  189. package/dist/media.js +0 -985
  190. package/dist/mesh.d.ts +0 -177
  191. package/dist/mesh.js +0 -468
  192. package/dist/modalityregistry.d.ts +0 -109
  193. package/dist/modalityregistry.js +0 -1386
  194. package/dist/modes.d.ts +0 -358
  195. package/dist/modes.js +0 -133
  196. package/dist/onboardingstate.d.ts +0 -79
  197. package/dist/onboardingstate.js +0 -282
  198. package/dist/orchestrator.d.ts +0 -1338
  199. package/dist/orchestrator.js +0 -4310
  200. package/dist/packager/targetcli.js +0 -4
  201. package/dist/paths.d.ts +0 -2
  202. package/dist/paths.js +0 -14
  203. package/dist/performance.d.ts +0 -415
  204. package/dist/performance.js +0 -826
  205. package/dist/quantum.d.ts +0 -809
  206. package/dist/quantum.js +0 -2072
  207. package/dist/render.d.ts +0 -380
  208. package/dist/render.js +0 -1072
  209. package/dist/runall.d.ts +0 -2
  210. package/dist/runall.js +0 -39
  211. package/dist/runtimeprobe.d.ts +0 -4
  212. package/dist/runtimeprobe.js +0 -8
  213. package/dist/sandbox.d.ts +0 -355
  214. package/dist/sandbox.js +0 -1721
  215. package/dist/sandboxenvprofile.d.ts +0 -29
  216. package/dist/sandboxenvprofile.js +0 -89
  217. package/dist/sandboxprofile.d.ts +0 -137
  218. package/dist/sandboxprofile.js +0 -241
  219. package/dist/sandboxqueue.d.ts +0 -108
  220. package/dist/sandboxqueue.js +0 -201
  221. package/dist/sandboxquota.d.ts +0 -121
  222. package/dist/sandboxquota.js +0 -236
  223. package/dist/scheduler.d.ts +0 -595
  224. package/dist/scheduler.js +0 -1258
  225. package/dist/scriptlanes.d.ts +0 -182
  226. package/dist/scriptlanes.js +0 -660
  227. package/dist/scripts.buildengine.d.ts +0 -1
  228. package/dist/scripts.buildengine.js +0 -39
  229. package/dist/scripts.securitygate.d.ts +0 -1
  230. package/dist/scripts.securitygate.js +0 -68
  231. package/dist/scripts.validateflatnative.d.ts +0 -1
  232. package/dist/scripts.validateflatnative.js +0 -72
  233. package/dist/security.d.ts +0 -500
  234. package/dist/security.js +0 -1169
  235. package/dist/seedcatalog.d.ts +0 -13
  236. package/dist/seedcatalog.js +0 -46
  237. package/dist/server.d.ts +0 -20
  238. package/dist/server.js +0 -53
  239. package/dist/speccatalog.d.ts +0 -31
  240. package/dist/speccatalog.js +0 -163
  241. package/dist/tiers.d.ts +0 -1001
  242. package/dist/tiers.js +0 -2169
  243. package/dist/utils.d.ts +0 -2
  244. package/dist/utils.js +0 -5
  245. package/dist/vaulthandoff.d.ts +0 -141
  246. package/dist/vaulthandoff.js +0 -444
  247. package/dist/virtual.d.ts +0 -640
  248. package/dist/virtual.js +0 -1284
  249. package/dist/virtualcpu.d.ts +0 -315
  250. package/dist/virtualcpu.js +0 -1180
  251. package/dist/virtualgpu.d.ts +0 -344
  252. package/dist/virtualgpu.js +0 -1000
  253. package/dist/virtualization.d.ts +0 -606
  254. package/dist/virtualization.js +0 -1186
  255. package/dist/virtualmemory.d.ts +0 -817
  256. package/dist/virtualmemory.js +0 -1795
  257. package/dist/vite.config.d.ts +0 -3
  258. package/dist/vite.config.js +0 -48
  259. package/dist/vitest.config.d.ts +0 -2
  260. package/dist/vitest.config.js +0 -25
  261. package/dist/webserver.d.ts +0 -166
  262. package/dist/webserver.js +0 -2226
  263. package/dist/workloadlaw.d.ts +0 -133
  264. package/dist/workloadlaw.js +0 -193
  265. package/docs/.gitkeep +0 -0
  266. package/docs/architecture.1.8.19.md +0 -76
  267. package/docs/logs/examplesession.json +0 -54
  268. package/docs/repo.analysis.1.8.21.json +0 -2617
  269. package/docs/repo.candidates.1.8.21.jsonl +0 -666
  270. package/docs/repo.selected.1.8.21.json +0 -786
  271. package/manifest.json +0 -20
  272. package/media.ts +0 -1337
  273. package/virtualcpu.ts +0 -1274
  274. package/virtualgpu.ts +0 -1182
  275. package/virtualization.ts +0 -1628
  276. package/virtualmemory.ts +0 -2303
  277. /package/docs/{artifactresearch.1.8.9.md → artifactresearch189.md} +0 -0
  278. /package/docs/{batches.1816.to.1820.md → batches1816to1820.md} +0 -0
  279. /package/docs/{brancharchive.2026.08.18.md → brancharchive20260818.md} +0 -0
  280. /package/docs/{containerplatforms.1.8.17.md → containerplatforms1817.md} +0 -0
  281. /package/docs/{e2ugh.engine.md → e2ughengine.md} +0 -0
  282. /package/docs/{flatbuildresearch.1.8.11.md → flatbuildresearch1811.md} +0 -0
  283. /package/docs/{licenseaudit.1.8.12.md → licenseaudit1812.md} +0 -0
  284. /package/docs/{mobileconversionresearch.1.8.10.md → mobileconversionresearch1810.md} +0 -0
  285. /package/docs/{nativeidentityresearch.1.8.11.md → nativeidentityresearch1811.md} +0 -0
  286. /package/docs/{native.wrappers.md → nativewrappers.md} +0 -0
  287. /package/docs/{planning.1.8.18.md → planning1818.md} +0 -0
  288. /package/docs/{planning.1.8.19.md → planning1819.md} +0 -0
  289. /package/docs/{planning.engine.md → planningengine.md} +0 -0
  290. /package/docs/{releasenotes.1.8.12.md → releasenotes1812.md} +0 -0
  291. /package/docs/{releasenotes.1.8.15.md → releasenotes1815.md} +0 -0
  292. /package/docs/{releasenotes.1.8.16.md → releasenotes1816.md} +0 -0
  293. /package/docs/{releasenotes.1.8.17.md → releasenotes1817.md} +0 -0
  294. /package/docs/{releasenotes.1.8.18.md → releasenotes1818.md} +0 -0
  295. /package/docs/{releasenotes.1.8.19.md → releasenotes1819.md} +0 -0
  296. /package/docs/{reorganization.1.8.8.md → reorganization188.md} +0 -0
  297. /package/docs/{reorganization.1.8.9.md → reorganization189.md} +0 -0
  298. /package/docs/{repo.research.1.8.16.md → reporesearch1816.md} +0 -0
  299. /package/docs/{repo.research.1.8.21.md → reporesearch1821.md} +0 -0
  300. /package/docs/{repo.synthesis.1.8.21.md → reposynthesis1821.md} +0 -0
  301. /package/docs/{research.1.8.18.isolation.md → research1818isolation.md} +0 -0
  302. /package/docs/{research.1.8.19.virtual.browser.md → research1819virtualbrowser.md} +0 -0
  303. /package/docs/{scanningresearch.1.8.11.md → scanningresearch1811.md} +0 -0
  304. /package/docs/{securityaudit.1.8.7.md → securityaudit187.md} +0 -0
  305. /package/docs/{securityreferences.1.8.11.md → securityreferences1811.md} +0 -0
  306. /package/docs/{securityresearch.1.8.11.md → securityresearch1811.md} +0 -0
  307. /package/docs/{signingresearch.1.8.12.md → signingresearch1812.md} +0 -0
  308. /package/docs/{tauri.glib.risk.1.8.11.md → tauriglibrisk1811.md} +0 -0
  309. /package/docs/{todo.1.8.18.md → todo1818.md} +0 -0
  310. /package/docs/{todo.1.8.19.md → todo1819.md} +0 -0
  311. /package/docs/{toolchainresearch.1.8.9.md → toolchainresearch189.md} +0 -0
  312. /package/docs/{web.duplicatecleanup.md → webduplicatecleanup.md} +0 -0
  313. /package/docs/{web.ideas.md → webideas.md} +0 -0
  314. /package/docs/{workflowimprovements.2026.08.19.md → workflowimprovements20260819.md} +0 -0
package/sandbox.ts ADDED
@@ -0,0 +1,3279 @@
1
+ /**
2
+ * sandbox — consolidated sandbox engine logics of the saddle family.
3
+ * @module sandbox
4
+ * @family wave/logicfold2
5
+ * One keyed file per family: the former variant arms (sandboxenvprofile, sandboxprofile, sandboxqueue, sandboxquota)
6
+ * retired into this seat with every export preserved under the same name.
7
+ */
8
+
9
+ /* ===== block: sandbox ===== */
10
+ /**
11
+ * sandbox.ts — browser-pure port of the saddle virtual hardware engine.
12
+ *
13
+ * this module is a lightweight rewrite of the engine generators
14
+ * (virtualcpu.ts, virtualmemory.ts, virtualgpu.ts and render.ts) that runs
15
+ * unchanged in the browser and in node: it imports nothing, touches no
16
+ * node:* builtin and no dom api at module scope, and produces the exact
17
+ * same procfs payloads, the 89-character nvidia-smi summary table, the
18
+ * mesa software-stack summaries (rusticl opencl 3.1, lavapipe vulkan 1.4,
19
+ * llvmpipe opengl 4.6) and the boot dmesg sequence the native engine emits.
20
+ *
21
+ * contexts (22): cpudata, gpudata, migprofiles, vendortables, topology,
22
+ * cpuinfo, lscpu, pagealign, meminfo, freeh, smiformat, smitable, smilist,
23
+ * clinfo, vulkaninfo, glxinfo, mesaenv, bootsequence, sandboxstate,
24
+ * virtualfs, dispatcher, commands.
25
+ *
26
+ * rules: lowercase identifiers, english jsdoc in third person, no emoji,
27
+ * zero dependencies, and deterministic fallbacks when crypto randomness
28
+ * is unavailable.
29
+ */
30
+
31
+ /* ------------------------------------------------------------------ */
32
+ /* types: the exported surface */
33
+ /* ------------------------------------------------------------------ */
34
+
35
+ /** reviewed processor catalog entry (mirrors the vendor pages). */
36
+ export type CpuSpec = {
37
+ model: string;
38
+ displayname: string;
39
+ vendor: string;
40
+ arch: string;
41
+ cores: number;
42
+ threads: number;
43
+ baseclockmhz: number;
44
+ boostclockmhz: number;
45
+ allcoreclockmhz: number;
46
+ l3mb: number;
47
+ l2mb: number;
48
+ socket: string;
49
+ tdpwatts: number;
50
+ pcie: string;
51
+ maxmemorygb: number;
52
+ memorytype: string;
53
+ memorychannels: number;
54
+ microarch: string;
55
+ cpufamily: number;
56
+ cpumodel: number;
57
+ stepping: number;
58
+ physicalline: string;
59
+ virtualline: string;
60
+ launch: string;
61
+ };
62
+
63
+ /** reviewed virtual gpu catalog entry. */
64
+ export type GpuSpec = {
65
+ id: string;
66
+ name: string;
67
+ vendor: string;
68
+ pcivendor: string;
69
+ pcidevice: string;
70
+ vrammib: number;
71
+ smireportedmib: number;
72
+ memtype: string;
73
+ busbits: number;
74
+ bandwidthgbs: number;
75
+ tdpwatts: number;
76
+ arch: string;
77
+ smarch: string;
78
+ smcount: number;
79
+ mig: boolean;
80
+ driver: string;
81
+ cuda: string | null;
82
+ };
83
+
84
+ /** one mig slicing profile of the 96 gb-class device. */
85
+ export type MigProfile = {
86
+ id: string;
87
+ slicegb: number;
88
+ maxinstances: number;
89
+ };
90
+
91
+ /** sandbox specification accepted by createSandboxState. */
92
+ export type SandboxSpec = {
93
+ model: string;
94
+ vcpus?: number;
95
+ ramgb?: number;
96
+ gpu: string;
97
+ mig?: string;
98
+ id?: string;
99
+ };
100
+
101
+ /** resolved smp topology for a virtual cpu spec. */
102
+ export type CpuTopology = {
103
+ threadspercore: number;
104
+ coresonline: number;
105
+ vcpus: number;
106
+ siblings: number;
107
+ sockets: number;
108
+ fullypopulated: boolean;
109
+ };
110
+
111
+ /** one persistent workspace file entry. */
112
+ export type SandboxFile = {
113
+ content: string;
114
+ size: number;
115
+ updatedat: string;
116
+ };
117
+
118
+ /** the filesystem contract shared by the api host and the memoryfs. */
119
+ export type SandboxFs = {
120
+ write: (path: string, content: string) => { path: string; size: number; updatedat: string };
121
+ read: (path: string) => (SandboxFile & { path: string }) | null;
122
+ list: () => Array<{ path: string; size: number; updatedat: string }>;
123
+ del: (path: string) => boolean;
124
+ };
125
+
126
+ /** state object shared by the browser terminal and the api exec endpoint. */
127
+ export type SandboxState = {
128
+ model: string;
129
+ cpuspec: CpuSpec;
130
+ gpuspec: GpuSpec;
131
+ vcpus: number;
132
+ ramgb: number;
133
+ gpu: string;
134
+ mig: string;
135
+ id: string;
136
+ hostname: string;
137
+ boottime: number;
138
+ kernel: string;
139
+ memsnapshot: string;
140
+ history: string[];
141
+ files: Map<string, SandboxFile>;
142
+ };
143
+
144
+ /** command result matching the api exec contract; clear is set by the
145
+ * clear command so the terminal can wipe the screen. */
146
+ export type DispatchResult = {
147
+ output: string;
148
+ exitCode: number;
149
+ clear?: boolean;
150
+ };
151
+
152
+ /** optional exec context provided by the api host. */
153
+ export type DispatchContext = {
154
+ fs?: SandboxFs;
155
+ quota?: number;
156
+ };
157
+
158
+ /* ------------------------------------------------------------------ */
159
+ /* context: shared helpers */
160
+ /* ------------------------------------------------------------------ */
161
+
162
+ /** inclusive pseudo-random integer between min and max; deterministic
163
+ * fallback when crypto randomness is unavailable. */
164
+ function rnd(min: number, max: number): number {
165
+ try {
166
+ const span = max - min + 1;
167
+ if (typeof globalThis.crypto?.getRandomValues === 'function') {
168
+ const buf = new Uint32Array(1);
169
+ globalThis.crypto.getRandomValues(buf);
170
+ return min + (buf[0] % span);
171
+ }
172
+ } catch {
173
+ /* falls through to math.random */
174
+ }
175
+ return min + Math.floor(Math.random() * (max - min + 1));
176
+ }
177
+
178
+ /** right-pad helper that never exceeds the target width. */
179
+ function pad(text: string, width: number): string {
180
+ return text.length >= width ? text.slice(0, width) : text + ' '.repeat(width - text.length);
181
+ }
182
+
183
+ /** left-pad helper for right-aligned columns. */
184
+ function padstart(text: string, width: number): string {
185
+ return text.length >= width
186
+ ? text.slice(text.length - width)
187
+ : ' '.repeat(width - text.length) + text;
188
+ }
189
+
190
+ /**
191
+ * formats one byte count with binary units (512 B, 4 KiB, 16 MiB, 1.5 GiB);
192
+ * whole numbers drop the decimal so the default quota reads "16 MiB".
193
+ *
194
+ * @param {number} bytes the byte count.
195
+ * @returns {string} the human readable size.
196
+ */
197
+ function humanbytes(bytes: number): string {
198
+ const value = Math.max(0, Math.round(bytes));
199
+ const unit = (size: number, suffix: string): string => {
200
+ const scaled = value / size;
201
+ const rounded = Math.round(scaled * 10) / 10;
202
+ return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)} ${suffix}`;
203
+ };
204
+ if (value >= 1024 * 1024 * 1024) {
205
+ return unit(1024 * 1024 * 1024, 'GiB');
206
+ }
207
+ if (value >= 1024 * 1024) {
208
+ return unit(1024 * 1024, 'MiB');
209
+ }
210
+ if (value >= 1024) {
211
+ return unit(1024, 'KiB');
212
+ }
213
+ return `${value} B`;
214
+ }
215
+
216
+ /** lowercases a model string for case-insensitive lookups. */
217
+ function normalizemodel(model: string): string {
218
+ return String(model ?? '').trim().toLowerCase();
219
+ }
220
+
221
+ /** generates a random uuid-shaped hex token with a prefix. */
222
+ function token(prefix: string): string {
223
+ const raw =
224
+ typeof globalThis.crypto?.randomUUID === 'function'
225
+ ? globalThis.crypto.randomUUID().replace(/-/g, '')
226
+ : Array.from({ length: 32 }, () => '0123456789abcdef'[rnd(0, 15)]).join('');
227
+ return `${prefix}${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20, 32)}`;
228
+ }
229
+
230
+ /* ------------------------------------------------------------------ */
231
+ /* context: cpudata — the processor bank (specs verified 2026-08-22) */
232
+ /* ------------------------------------------------------------------ */
233
+
234
+ /**
235
+ * cpudata carries the six engine-bank processors (amd epyc 9965, ryzen 9
236
+ * 9950x3d, threadripper pro 9995wx, threadripper 7980x, core ultra 9
237
+ * 285k, epyc 9955) plus the two web showcase additions intel xeon 6980p
238
+ * granite rapids and apple m3 ultra. every field mirrors the vendor pages
239
+ * and the techpowerup database as pinned by best_virtual_processors.
240
+ */
241
+ export const cpudata: CpuSpec[] = [
242
+ {
243
+ model: 'AMD EPYC 9965',
244
+ displayname: 'AMD EPYC 9965 192-Core Processor',
245
+ vendor: 'amd',
246
+ arch: 'x86_64',
247
+ cores: 192,
248
+ threads: 384,
249
+ baseclockmhz: 2250,
250
+ boostclockmhz: 3700,
251
+ allcoreclockmhz: 3350,
252
+ l3mb: 384,
253
+ l2mb: 192,
254
+ socket: 'SP5 (LGA6096)',
255
+ tdpwatts: 500,
256
+ pcie: 'PCIe 5.0 x128',
257
+ maxmemorygb: 6144,
258
+ memorytype: 'DDR5-6400 ECC RDIMM',
259
+ memorychannels: 12,
260
+ microarch: 'Zen 5c (Turin)',
261
+ cpufamily: 25,
262
+ cpumodel: 17,
263
+ stepping: 2,
264
+ physicalline: '52 bits physical',
265
+ virtualline: '57 bits virtual',
266
+ launch: '2024-10-10',
267
+ },
268
+ {
269
+ model: 'AMD Ryzen 9 9950X3D',
270
+ displayname: 'AMD Ryzen 9 9950X3D 16-Core Processor',
271
+ vendor: 'amd',
272
+ arch: 'x86_64',
273
+ cores: 16,
274
+ threads: 32,
275
+ baseclockmhz: 4300,
276
+ boostclockmhz: 5700,
277
+ allcoreclockmhz: 5400,
278
+ l3mb: 128,
279
+ l2mb: 16,
280
+ socket: 'AM5',
281
+ tdpwatts: 170,
282
+ pcie: 'PCIe 5.0 x24',
283
+ maxmemorygb: 192,
284
+ memorytype: 'DDR5-5600',
285
+ memorychannels: 2,
286
+ microarch: 'Zen 5 X3D (3D V-Cache on one CCD)',
287
+ cpufamily: 25,
288
+ cpumodel: 17,
289
+ stepping: 2,
290
+ physicalline: '48 bits physical',
291
+ virtualline: '57 bits virtual',
292
+ launch: '2025-03-12',
293
+ },
294
+ {
295
+ model: 'AMD Threadripper PRO 9995WX',
296
+ displayname: 'AMD Ryzen Threadripper PRO 9995WX 96-Core Processor',
297
+ vendor: 'amd',
298
+ arch: 'x86_64',
299
+ cores: 96,
300
+ threads: 192,
301
+ baseclockmhz: 2500,
302
+ boostclockmhz: 5400,
303
+ allcoreclockmhz: 4100,
304
+ l3mb: 384,
305
+ l2mb: 96,
306
+ socket: 'sTR5 (LGA4844)',
307
+ tdpwatts: 350,
308
+ pcie: 'PCIe 5.0 x128',
309
+ maxmemorygb: 2048,
310
+ memorytype: 'DDR5-6400 ECC RDIMM',
311
+ memorychannels: 8,
312
+ microarch: 'Zen 5 (Shimada Peak, WRX90)',
313
+ cpufamily: 25,
314
+ cpumodel: 17,
315
+ stepping: 2,
316
+ physicalline: '52 bits physical',
317
+ virtualline: '57 bits virtual',
318
+ launch: '2025-07',
319
+ },
320
+ {
321
+ model: 'AMD Threadripper 7980X',
322
+ displayname: 'AMD Ryzen Threadripper 7980X 64-Core Processor',
323
+ vendor: 'amd',
324
+ arch: 'x86_64',
325
+ cores: 64,
326
+ threads: 128,
327
+ baseclockmhz: 3200,
328
+ boostclockmhz: 5100,
329
+ allcoreclockmhz: 4400,
330
+ l3mb: 256,
331
+ l2mb: 64,
332
+ socket: 'sTR5',
333
+ tdpwatts: 350,
334
+ pcie: 'PCIe 5.0 x48',
335
+ maxmemorygb: 1024,
336
+ memorytype: 'DDR5-6400',
337
+ memorychannels: 4,
338
+ microarch: 'Zen 5 (TRX50)',
339
+ cpufamily: 25,
340
+ cpumodel: 17,
341
+ stepping: 2,
342
+ physicalline: '52 bits physical',
343
+ virtualline: '57 bits virtual',
344
+ launch: '2023-10',
345
+ },
346
+ {
347
+ model: 'Intel Core Ultra 9 285K',
348
+ displayname: 'Intel(R) Core(TM) Ultra 9 285K',
349
+ vendor: 'intel',
350
+ arch: 'x86_64',
351
+ cores: 24,
352
+ threads: 24,
353
+ baseclockmhz: 3700,
354
+ boostclockmhz: 5700,
355
+ allcoreclockmhz: 5400,
356
+ l3mb: 36,
357
+ l2mb: 36,
358
+ socket: 'LGA1851',
359
+ tdpwatts: 125,
360
+ pcie: 'PCIe 5.0 x20 + PCIe 4.0 x4',
361
+ maxmemorygb: 192,
362
+ memorytype: 'DDR5-6400',
363
+ memorychannels: 2,
364
+ microarch: 'Arrow Lake (8P + 16E, no hyper-threading)',
365
+ cpufamily: 6,
366
+ cpumodel: 191,
367
+ stepping: 2,
368
+ physicalline: '46 bits physical',
369
+ virtualline: '57 bits virtual',
370
+ launch: '2024-10-24',
371
+ },
372
+ {
373
+ model: 'AMD EPYC 9955',
374
+ displayname: 'AMD EPYC 9955 128-Core Processor',
375
+ vendor: 'amd',
376
+ arch: 'x86_64',
377
+ cores: 128,
378
+ threads: 256,
379
+ baseclockmhz: 2600,
380
+ boostclockmhz: 3750,
381
+ allcoreclockmhz: 3450,
382
+ l3mb: 384,
383
+ l2mb: 128,
384
+ socket: 'SP5',
385
+ tdpwatts: 400,
386
+ pcie: 'PCIe 5.0 x128',
387
+ maxmemorygb: 6144,
388
+ memorytype: 'DDR5-6400 ECC RDIMM',
389
+ memorychannels: 12,
390
+ microarch: 'Zen 5c (Turin dense)',
391
+ cpufamily: 25,
392
+ cpumodel: 17,
393
+ stepping: 2,
394
+ physicalline: '52 bits physical',
395
+ virtualline: '57 bits virtual',
396
+ launch: '2024-10-10',
397
+ },
398
+ {
399
+ model: 'Intel Xeon 6980P',
400
+ displayname: 'Intel(R) Xeon(R) Platinum 6980P',
401
+ vendor: 'intel',
402
+ arch: 'x86_64',
403
+ cores: 128,
404
+ threads: 256,
405
+ baseclockmhz: 2200,
406
+ boostclockmhz: 3900,
407
+ allcoreclockmhz: 3300,
408
+ l3mb: 504,
409
+ l2mb: 128,
410
+ socket: 'LGA4710',
411
+ tdpwatts: 350,
412
+ pcie: 'PCIe 5.0 x128 + CXL 2.0 x16',
413
+ maxmemorygb: 6144,
414
+ memorytype: 'DDR5-6400 / MRDIMM-8800',
415
+ memorychannels: 12,
416
+ microarch: 'Granite Rapids (Intel 3)',
417
+ cpufamily: 6,
418
+ cpumodel: 173,
419
+ stepping: 2,
420
+ physicalline: '46 bits physical',
421
+ virtualline: '57 bits virtual',
422
+ launch: '2025-02',
423
+ },
424
+ {
425
+ model: 'Apple M3 Ultra',
426
+ displayname: 'Apple M3 Ultra (24P + 8E, 32-core CPU)',
427
+ vendor: 'apple',
428
+ arch: 'arm64',
429
+ cores: 32,
430
+ threads: 32,
431
+ baseclockmhz: 1000,
432
+ boostclockmhz: 4050,
433
+ allcoreclockmhz: 3400,
434
+ l3mb: 96,
435
+ l2mb: 96,
436
+ socket: 'soldered (UltraFusion interconnect)',
437
+ tdpwatts: 140,
438
+ pcie: 'PCIe 4.0 x8 + Thunderbolt 5 x2',
439
+ maxmemorygb: 512,
440
+ memorytype: 'LPDDR5 unified 819 GB/s',
441
+ memorychannels: 16,
442
+ microarch: 'Everest (3nm, two M3 Max dies fused)',
443
+ cpufamily: 0,
444
+ cpumodel: 0,
445
+ stepping: 0,
446
+ physicalline: '44 bits physical',
447
+ virtualline: '48 bits virtual',
448
+ launch: '2025-03',
449
+ },
450
+ ];
451
+
452
+ /** resolves a processor spec by model name, case-insensitive; returns
453
+ * undefined for unknown models so callers can shape friendly errors. */
454
+ export function getcpu(model: string): CpuSpec | undefined {
455
+ const needle = normalizemodel(model);
456
+ return cpudata.find((spec) => normalizemodel(spec.model) === needle);
457
+ }
458
+
459
+ /**
460
+ * resolves a catalog entry by its slug id (epyc-9965) as served by the
461
+ * /api/v1/specs/cpus endpoint; returns undefined for unknown ids.
462
+ * @param {string} id the catalog id.
463
+ * @returns {object | undefined} the matching cpu entry.
464
+ */
465
+ export function getcpubyid(id: string): CpuSpec | undefined {
466
+ const key = String(id).toLowerCase().replace(/\s+/g, '-');
467
+ const strip = (name: string): string =>
468
+ name
469
+ .toLowerCase()
470
+ .replace(/\s+/g, '-')
471
+ .replace(/^(amd|intel|apple|nvidia)-/, '');
472
+ return cpudata.find((entry) => {
473
+ const slug = entry.model.toLowerCase().replace(/\s+/g, '-');
474
+ return slug === key || slug.endsWith(key) || strip(entry.model) === strip(key);
475
+ });
476
+ }
477
+
478
+ /* ------------------------------------------------------------------ */
479
+ /* context: vendortables — flag sets and identity strings */
480
+ /* ------------------------------------------------------------------ */
481
+
482
+ /** zen 5 flag set exactly as generatevirtualcpuinfo renders it (avx-512
483
+ * family, sha_ni, gfni and friends). */
484
+ const zen5flags = [
485
+ 'fpu', 'vme', 'de', 'pse', 'tsc', 'msr', 'pae', 'mce', 'cx8', 'apic',
486
+ 'sep', 'mtrr', 'pge', 'mca', 'cmov', 'pat', 'pse36', 'clflush', 'mmx',
487
+ 'fxsr', 'sse', 'sse2', 'ht', 'syscall', 'nx', 'mmxext', 'fxsr_opt',
488
+ 'pdpe1gb', 'rdtscp', 'lm', 'constant_tsc', 'rep_good', 'amd_lbr_v2',
489
+ 'nopl', 'nonstop_tsc', 'cpuid', 'extd_apicid', 'aperfmperf', 'rapl',
490
+ 'pclmulqdq', 'monitor', 'ssse3', 'fma', 'cx16', 'sse4_1', 'sse4_2',
491
+ 'x2apic', 'movbe', 'popcnt', 'aes', 'xsave', 'avx', 'f16c', 'rdrand',
492
+ 'lahf_lm', 'cmp_legacy', 'svm', 'extapic', 'cr8_legacy', 'abm', 'sse4a',
493
+ 'misalignsse', '3dnowprefetch', 'osvw', 'ibs', 'skinit', 'wdt', 'tce',
494
+ 'topoext', 'perfctr_core', 'perfctr_nb', 'bpext', 'perfctr_llc',
495
+ 'mwaitx', 'cpb', 'cat_l3', 'cdp_l3', 'hw_pstate', 'ssbd', 'mba',
496
+ 'perfmon_v2', 'ibrs', 'ibpb', 'stibp', 'vmmcall', 'fsgsbase', 'bmi1',
497
+ 'avx2', 'smep', 'bmi2', 'erms', 'invpcid', 'cqm', 'rdt_a', 'avx512f',
498
+ 'avx512dq', 'rdseed', 'adx', 'smap', 'clflushopt', 'clwb', 'avx512vl',
499
+ 'avx512bw', 'avx512cd', 'avx512_bf16', 'avx512vbmi', 'avx512vbmi2',
500
+ 'avx512ifma', 'avx512vp2intersect', 'sha_ni', 'gfni', 'vaes',
501
+ 'vpclmulqdq', 'avx512vpopcntdq', 'avx512vnni', 'avx512bitalg', 'rdpid',
502
+ 'movdiri', 'movdir64b', 'fsrm', 'cldemote', 'serialize', 'flush_l1d',
503
+ 'arch_capabilities',
504
+ ];
505
+
506
+ /** arrow lake / granite rapids flag set: avx2 with avx_vnni and no avx-512
507
+ * execution units; the ht flag is appended per model because arrow lake
508
+ * ships without hyper-threading while xeon 6 keeps it. */
509
+ const intelflags = [
510
+ 'fpu', 'vme', 'de', 'pse', 'tsc', 'msr', 'pae', 'mce', 'cx8', 'apic',
511
+ 'sep', 'mtrr', 'pge', 'mca', 'cmov', 'pat', 'pse36', 'clflush', 'mmx',
512
+ 'fxsr', 'sse', 'sse2', 'syscall', 'nx', 'pdpe1gb', 'rdtscp', 'lm',
513
+ 'constant_tsc', 'arch_perfmon', 'rep_good', 'nopl', 'xtopology',
514
+ 'nonstop_tsc', 'cpuid', 'aperfmperf', 'tsc_known_freq', 'pclmulqdq',
515
+ 'monitor', 'ssse3', 'fma', 'cx16', 'sse4_1', 'sse4_2', 'x2apic', 'movbe',
516
+ 'popcnt', 'aes', 'xsave', 'avx', 'f16c', 'rdrand', 'lahf_lm', 'abm',
517
+ '3dnowprefetch', 'cpuid_fault', 'epb', 'invpcid_single', 'ssbd', 'ibrs',
518
+ 'ibpb', 'stibp', 'ibrs_enhanced', 'tpr_shadow', 'vnmi', 'flexpriority',
519
+ 'ept', 'vpid', 'ept_ad', 'fsgsbase', 'tsc_adjust', 'bmi1', 'avx2',
520
+ 'smep', 'bmi2', 'erms', 'invpcid', 'rdseed', 'adx', 'smap',
521
+ 'clflushopt', 'clwb', 'sha_ni', 'xsaveopt', 'xsavec', 'xgetbv1',
522
+ 'xsaves', 'split_lock_detect', 'user_shstk', 'avx_vnni', 'waitpkg',
523
+ 'gfni', 'vaes', 'vpclmulqdq', 'rdpid', 'movdiri', 'movdir64b', 'fsrm',
524
+ 'md_clear', 'serialize', 'tsxldtrk', 'la57', 'cldemote',
525
+ 'arch_capabilities',
526
+ ];
527
+
528
+ /** arm64 feature line rendered for apple silicon cpuinfo blocks. */
529
+ const armfeatures = [
530
+ 'fp', 'asimd', 'aes', 'pmull', 'sha1', 'sha2', 'sha3', 'sha512', 'crc32',
531
+ 'atomics', 'fphp', 'asimdhp', 'cpuid', 'asimdrdm', 'jscvt', 'fcma',
532
+ 'lrcpc', 'dcpop', 'sm3', 'sm4', 'asimddp', 'uscat', 'ilrcpc', 'flagm',
533
+ 'ssbs', 'sb', 'paca', 'pacg', 'dcpodp', 'flagm2', 'frint', 'ecs', 'wfxt',
534
+ 'bti',
535
+ ];
536
+
537
+ /** per-vendor identity block consumed by cpuinfo and lscpu. */
538
+ type VendorTable = {
539
+ vendorid: string;
540
+ flags: string[];
541
+ bugs: string;
542
+ tlbsize: string | null;
543
+ cpuidlevel: number;
544
+ powermanagement: string;
545
+ };
546
+
547
+ /** vendor identity table consumed by cpuinfo and lscpu. */
548
+ const vendortables: Record<string, VendorTable> = {
549
+ amd: {
550
+ vendorid: 'AuthenticAMD',
551
+ flags: zen5flags,
552
+ bugs: 'sysret_ss_attrs spectre_v1 spectre_v2 spec_store_bypass srso',
553
+ tlbsize: '3584 2M/4M pages',
554
+ cpuidlevel: 16,
555
+ powermanagement: 'ts ttp tm hwpstate cpb eff_freq_ro [13] [14]',
556
+ },
557
+ intel: {
558
+ vendorid: 'GenuineIntel',
559
+ flags: intelflags,
560
+ bugs: 'spectre_v1 spectre_v2 spec_store_bypass swapgs eibrs_pbrsb',
561
+ tlbsize: null,
562
+ cpuidlevel: 32,
563
+ powermanagement:
564
+ 'ts ttp tm hwpstate cpb eff_freq_ro [8] [9] [10] [11] [12] [13] [14]',
565
+ },
566
+ apple: {
567
+ vendorid: 'Apple',
568
+ flags: armfeatures,
569
+ bugs: '',
570
+ tlbsize: null,
571
+ cpuidlevel: 0,
572
+ powermanagement: '',
573
+ },
574
+ };
575
+
576
+ /* ------------------------------------------------------------------ */
577
+ /* context: topology */
578
+ /* ------------------------------------------------------------------ */
579
+
580
+ /**
581
+ * solves the package topology for a vcpu count against a spec; mirrors
582
+ * solvetopology from virtualcpu.ts (smt parts round core counts up,
583
+ * heterogeneous parts report one thread per core and the count is clamped
584
+ * to the model thread ceiling).
585
+ */
586
+ export function solvetopology(spec: CpuSpec, vcpus: number): CpuTopology {
587
+ const threadspercore = Math.max(1, Math.round(spec.threads / spec.cores));
588
+ const count = Math.max(1, Math.min(Math.round(vcpus) || 1, spec.threads));
589
+ return {
590
+ vcpus: count,
591
+ threadspercore,
592
+ coresonline: Math.ceil(count / threadspercore),
593
+ siblings: count,
594
+ sockets: 1,
595
+ fullypopulated: count === spec.threads,
596
+ };
597
+ }
598
+
599
+ /* ------------------------------------------------------------------ */
600
+ /* context: cpuinfo — /proc/cpuinfo generation */
601
+ /* ------------------------------------------------------------------ */
602
+
603
+ /** per-key tab alignment the kernel uses on x86 cpuinfo lines. */
604
+ const keytabs: Record<string, string> = {
605
+ processor: '\t', vendor_id: '\t', 'cpu family': '\t', model: '\t\t',
606
+ 'model name': '\t', stepping: '\t', microcode: '\t', 'cpu MHz': '\t\t',
607
+ 'cache size': '\t', 'physical id': '\t', siblings: '\t', 'core id': '\t\t',
608
+ 'cpu cores': '\t', apicid: '\t\t', 'initial apicid': '\t', fpu: '\t\t',
609
+ fpu_exception: '\t', 'cpuid level': '\t', wp: '\t\t', flags: '\t\t',
610
+ bugs: '\t\t', bogomips: '\t', 'TLB size': '\t', 'clflush size': '\t',
611
+ cache_alignment: '', 'address sizes': '\t', 'power management': '',
612
+ };
613
+
614
+ /** renders one cpuinfo line exactly as procfs does: key, tabs, colon, value. */
615
+ function line(key: string, value: number | string): string {
616
+ const tabs = keytabs[key] ?? '\t';
617
+ return `${key}${tabs}: ${value}`;
618
+ }
619
+
620
+ /**
621
+ * generates a complete /proc/cpuinfo text for a bank model with an
622
+ * arbitrary vcpu count; x86_64 models render the full zen 5 / intel flag
623
+ * set per logical processor and the apple model renders the arm64 cpuinfo
624
+ * block shape instead.
625
+ *
626
+ * @param {string} model model name present in cpudata.
627
+ * @param {number} vcpus number of logical processors to render.
628
+ * @returns {string} the full procfs payload.
629
+ */
630
+
631
+
632
+ export function cpuinfo(model: string, vcpus: number): string {
633
+ const spec = getcpu(model);
634
+ if (spec === undefined) {
635
+ throw new Error(
636
+ `unknown processor model "${model}"; known models: ${cpudata.map((entry) => entry.model).join(', ')}`,
637
+ );
638
+ }
639
+ const topology = solvetopology(spec, vcpus);
640
+ const table = vendortables[spec.vendor];
641
+ const blocks = [];
642
+ for (let processor = 0; processor < topology.vcpus; processor += 1) {
643
+ if (spec.vendor === 'apple') {
644
+ blocks.push(
645
+ [
646
+ `processor\t: ${processor}`,
647
+ 'BogoMIPS\t: 48.00',
648
+ `Features\t: ${table.flags.join(' ')}`,
649
+ 'CPU implementer\t: 0x41',
650
+ 'CPU architecture: 8',
651
+ 'CPU variant\t: 0x2',
652
+ 'CPU part\t: 0x612',
653
+ `CPU revision\t: ${processor % 2 === 0 ? 0 : 3}`,
654
+ '',
655
+ `MIDR_EL1\t: 0x0000000041026${processor % 2 === 0 ? '00' : '03'}`,
656
+ 'Revidr_EL1\t: 0x00000000',
657
+ `CPU affinity\t: ${processor}`,
658
+ ].join('\n'),
659
+ );
660
+ continue;
661
+ }
662
+ const currentmhz = rnd(
663
+ Math.round(spec.baseclockmhz),
664
+ Math.round(spec.boostclockmhz) + 1,
665
+ );
666
+ const bogomips = (spec.baseclockmhz * 2 + rnd(-40, 41) / 100).toFixed(2);
667
+ const coreid = Math.floor(processor / topology.threadspercore);
668
+ const flags =
669
+ spec.vendor === 'intel' && topology.threadspercore === 2
670
+ ? `${table.flags.join(' ')} ht`
671
+ : table.flags.join(' ');
672
+ const rows = [
673
+ line('processor', processor),
674
+ line('vendor_id', table.vendorid),
675
+ line('cpu family', spec.cpufamily),
676
+ line('model', spec.cpumodel),
677
+ line('model name', spec.displayname),
678
+ line('stepping', spec.stepping),
679
+ line('microcode', '0xffffffff'),
680
+ line('cpu MHz', currentmhz.toFixed(3)),
681
+ line('cache size', `${spec.l3mb} MB`),
682
+ line('physical id', 0),
683
+ line('siblings', topology.siblings),
684
+ line('core id', coreid),
685
+ line('cpu cores', topology.coresonline),
686
+ line('apicid', processor),
687
+ line('initial apicid', processor),
688
+ line('fpu', 'yes'),
689
+ line('fpu_exception', 'yes'),
690
+ line('cpuid level', table.cpuidlevel),
691
+ line('wp', 'yes'),
692
+ line('flags', flags),
693
+ line('bugs', table.bugs),
694
+ line('bogomips', bogomips),
695
+ ];
696
+ if (table.tlbsize !== null) {
697
+ rows.push(line('TLB size', table.tlbsize));
698
+ }
699
+ rows.push(
700
+ line('clflush size', 64),
701
+ line('cache_alignment', 64),
702
+ line('address sizes', `${spec.physicalline}, ${spec.virtualline}`),
703
+ line('power management', table.powermanagement),
704
+ );
705
+ blocks.push(rows.join('\n'));
706
+ }
707
+ return blocks.join('\n\n');
708
+ }
709
+
710
+ /**
711
+ * generates the util-linux lscpu summary for a bank model with numa node
712
+ * ranges and vulnerability lines, mirroring generatevirtuallscpu.
713
+ *
714
+ * @param {string} model model name present in cpudata.
715
+ * @param {number} vcpus logical processor count.
716
+ * @param {number} [numanodes=1] number of numa nodes to spread over.
717
+ * @returns {string} the lscpu text block.
718
+ */
719
+ export function lscpu(model: string, vcpus: number, numanodes: number = 1): string {
720
+ const spec = getcpu(model);
721
+ if (spec === undefined) {
722
+ throw new Error(`unknown processor model "${model}"`);
723
+ }
724
+ const topology = solvetopology(spec, vcpus);
725
+ const corespersocket = Math.ceil(topology.coresonline / numanodes) * numanodes;
726
+ const rows = [
727
+ ['Architecture:', spec.arch === 'arm64' ? 'aarch64' : 'x86_64'],
728
+ ['CPU op-mode(s):', '32-bit, 64-bit'],
729
+ ['Byte Order:', 'Little Endian'],
730
+ ['Address sizes:', `${spec.physicalline}, ${spec.virtualline}`],
731
+ ['CPU(s):', String(topology.vcpus)],
732
+ ['On-line CPU(s) list:', topology.vcpus === 1 ? '0' : `0-${topology.vcpus - 1}`],
733
+ ];
734
+ if (spec.vendor === 'apple') {
735
+ rows.push(
736
+ ['Vendor ID:', 'Apple'],
737
+ ['Model name:', spec.displayname],
738
+ ['Thread(s) per core:', '1'],
739
+ ['Core(s) per socket:', String(topology.coresonline)],
740
+ ['Socket(s):', '1'],
741
+ ['Cluster(s)-wide:', '24 performance + 8 efficiency cores'],
742
+ ['Frequency boost:', 'enabled'],
743
+ ['CPU max MHz:', `${spec.boostclockmhz}.0000`],
744
+ ['CPU min MHz:', '600.0000'],
745
+ );
746
+ } else {
747
+ rows.push(
748
+ ['Vendor ID:', vendortables[spec.vendor].vendorid],
749
+ ['Model name:', spec.displayname],
750
+ ['CPU family:', String(spec.cpufamily)],
751
+ ['Model:', String(spec.cpumodel)],
752
+ ['Thread(s) per core:', String(topology.threadspercore)],
753
+ ['Core(s) per socket:', String(corespersocket)],
754
+ ['Socket(s):', '1'],
755
+ ['Stepping:', String(spec.stepping)],
756
+ ['Frequency boost:', 'enabled'],
757
+ ['CPU max MHz:', `${spec.boostclockmhz}.0000`],
758
+ ['CPU min MHz:', `${(spec.baseclockmhz * 0.62).toFixed(4)}`],
759
+ ['BogoMIPS:', `${(spec.baseclockmhz * 2).toFixed(2)}`],
760
+ [
761
+ 'L1d cache:',
762
+ `${Math.round((spec.l2mb / spec.cores) * 1024)} KiB (${spec.cores} instances)`,
763
+ ],
764
+ ['L2 cache:', `${spec.l2mb} MiB (${spec.cores} instances)`],
765
+ ['L3 cache:', `${spec.l3mb} MiB (${Math.max(1, Math.round(spec.cores / 8))} instances)`],
766
+ );
767
+ }
768
+ rows.push(['NUMA node(s):', String(numanodes)]);
769
+ const pernode = Math.ceil(topology.vcpus / numanodes);
770
+ for (let node = 0; node < numanodes; node += 1) {
771
+ const start = node * pernode;
772
+ const end = Math.min(start + pernode, topology.vcpus) - 1;
773
+ rows.push([`NUMA node${node} CPU(s):`, start === end ? String(start) : `${start}-${end}`]);
774
+ }
775
+ if (spec.vendor !== 'apple') {
776
+ rows.push(
777
+ ['Vulnerability Itlb multihit:', 'Not affected'],
778
+ ['Vulnerability L1tf:', 'Not affected'],
779
+ ['Vulnerability Mds:', 'Not affected'],
780
+ ['Vulnerability Meltdown:', 'Not affected'],
781
+ [
782
+ 'Vulnerability Spectre v1:',
783
+ 'Mitigation; usercopy/swapgs barriers and __user pointer sanitization',
784
+ ],
785
+ [
786
+ 'Vulnerability Spectre v2:',
787
+ spec.vendor === 'amd' ? 'Mitigation; Retpolines, IBPB' : 'Mitigation; Enhanced IBRS',
788
+ ],
789
+ );
790
+ }
791
+ return rows.map(([key, value]) => `${key.padEnd(28)}${value}`).join('\n');
792
+ }
793
+
794
+ /* ------------------------------------------------------------------ */
795
+ /* context: gpudata and migprofiles */
796
+ /* ------------------------------------------------------------------ */
797
+
798
+ /**
799
+ * gpudata mirrors the verified gpu_bank: seven real identities with
800
+ * vrammib (physical capacity) and smireportedmib (the value after the
801
+ * firmware carveout) cross-checked on 2026-08-22.
802
+ */
803
+ export const gpudata: GpuSpec[] = [
804
+ {
805
+ id: 'rtx5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia',
806
+ pcivendor: '10DE', pcidevice: '2B85', vrammib: 32768,
807
+ smireportedmib: 32607, memtype: 'GDDR7', busbits: 512,
808
+ bandwidthgbs: 1792, tdpwatts: 575, arch: 'GB202 Blackwell',
809
+ smarch: 'sm_120', smcount: 170, mig: false, driver: '575.57.08',
810
+ cuda: '12.9',
811
+ },
812
+ {
813
+ id: 'rtxpro6000', name: 'NVIDIA RTX PRO 6000 Blackwell', vendor: 'nvidia',
814
+ pcivendor: '10DE', pcidevice: '26B5', vrammib: 98304,
815
+ smireportedmib: 98304, memtype: 'GDDR7 ECC', busbits: 512,
816
+ bandwidthgbs: 1792, tdpwatts: 600, arch: 'GB202 Blackwell workstation',
817
+ smarch: 'sm_120', smcount: 188, mig: true, driver: '575.57.08',
818
+ cuda: '12.9',
819
+ },
820
+ {
821
+ id: 'b200', name: 'NVIDIA B200', vendor: 'nvidia',
822
+ pcivendor: '10DE', pcidevice: '2665', vrammib: 196608,
823
+ smireportedmib: 184320, memtype: 'HBM3e', busbits: 8192,
824
+ bandwidthgbs: 8000, tdpwatts: 1000, arch: 'GB100 Blackwell dual-die',
825
+ smarch: 'sm_100', smcount: 208, mig: true, driver: '575.57.08',
826
+ cuda: '12.9',
827
+ },
828
+ {
829
+ id: 'h100', name: 'NVIDIA H100 80GB HBM3', vendor: 'nvidia',
830
+ pcivendor: '10DE', pcidevice: '2330', vrammib: 81920,
831
+ smireportedmib: 81559, memtype: 'HBM3', busbits: 6144,
832
+ bandwidthgbs: 3350, tdpwatts: 700, arch: 'GH100 Hopper SXM5',
833
+ smarch: 'sm_90', smcount: 132, mig: true, driver: '575.57.08',
834
+ cuda: '12.9',
835
+ },
836
+ {
837
+ id: 'a100', name: 'NVIDIA A100-SXM4-40GB', vendor: 'nvidia',
838
+ pcivendor: '10DE', pcidevice: '20B0', vrammib: 40960,
839
+ smireportedmib: 40536, memtype: 'HBM2e', busbits: 5120,
840
+ bandwidthgbs: 1555, tdpwatts: 400, arch: 'GA100 Ampere SXM4',
841
+ smarch: 'sm_80', smcount: 108, mig: true, driver: '575.57.08',
842
+ cuda: '12.9',
843
+ },
844
+ {
845
+ id: 'rx9070xt', name: 'AMD Radeon RX 9070 XT', vendor: 'amd',
846
+ pcivendor: '1002', pcidevice: '748E', vrammib: 16384,
847
+ smireportedmib: 16384, memtype: 'GDDR6', busbits: 256,
848
+ bandwidthgbs: 640, tdpwatts: 304, arch: 'Navi 48 RDNA 4',
849
+ smarch: 'gfx1201', smcount: 64, mig: false,
850
+ driver: 'mesa 26.2.1 / rusticl', cuda: null,
851
+ },
852
+ {
853
+ id: 'mi350x', name: 'AMD Instinct MI350X', vendor: 'amd',
854
+ pcivendor: '1002', pcidevice: '75A0', vrammib: 294912,
855
+ smireportedmib: 294912, memtype: 'HBM3e', busbits: 8192,
856
+ bandwidthgbs: 8000, tdpwatts: 1000, arch: 'CDNA 4 Aqua Vanjaram',
857
+ smarch: 'gfx950', smcount: 304, mig: false, driver: 'rocm 7.x',
858
+ cuda: null,
859
+ },
860
+ ];
861
+
862
+ /** resolves a gpu spec by id or name, case-insensitive. */
863
+ export function getgpu(gpu: string): GpuSpec | undefined {
864
+ const needle = normalizemodel(gpu);
865
+ return gpudata.find(
866
+ (spec) =>
867
+ normalizemodel(spec.id) === needle || normalizemodel(spec.name) === needle,
868
+ );
869
+ }
870
+
871
+ /**
872
+ * migprofiles mirrors the mig catalog of the 96 gb-class virtual device:
873
+ * 1g.24gb exposes 24 gb slices (4 instances), 2g.48gb 48 gb slices (2
874
+ * instances) and 4g.96gb dedicates the full device to one instance.
875
+ */
876
+ export const migprofiles: MigProfile[] = [
877
+ { id: '1g.24gb', slicegb: 24, maxinstances: 4 },
878
+ { id: '2g.48gb', slicegb: 48, maxinstances: 2 },
879
+ { id: '4g.96gb', slicegb: 96, maxinstances: 1 },
880
+ ];
881
+
882
+ /** resolves a mig profile by id; null for 'off' and unknown names. */
883
+ export function getmig(id: string | null | undefined): MigProfile | null {
884
+ if (id === 'off' || id === undefined || id === null || id === '') {
885
+ return null;
886
+ }
887
+ return migprofiles.find((profile) => profile.id === id) ?? null;
888
+ }
889
+
890
+ /* ------------------------------------------------------------------ */
891
+ /* context: meminfo and free — /proc/meminfo generation */
892
+ /* ------------------------------------------------------------------ */
893
+
894
+ /** rounds a kb value down to the nearest 4 kb page boundary. */
895
+ export function pagealign(kb: number): number {
896
+ return Math.floor(kb / 4) * 4;
897
+ }
898
+
899
+ /**
900
+ * generates a complete /proc/meminfo text for the requested total ram in
901
+ * gb, mirroring generatevirtualmeminfo: memtotal, memfree, memavailable,
902
+ * buffers, cached, the anon/file split, swap, slab, commit accounting
903
+ * under vm.overcommit_memory=2 semantics and the direct map breakdown.
904
+ *
905
+ * @param {number} ramgb total guest ram in gb (positive number).
906
+ * @param {{swapgb?: number, vcpus?: number, freefraction?: number,
907
+ * availablefraction?: number}} [options] optional overrides.
908
+ * @returns {string} the full meminfo payload.
909
+ */
910
+ export function meminfo(
911
+ ramgb: number,
912
+ options: {
913
+ swapgb?: number;
914
+ vcpus?: number;
915
+ freefraction?: number;
916
+ availablefraction?: number;
917
+ } = {},
918
+ ): string {
919
+ if (!Number.isFinite(ramgb) || ramgb <= 0) {
920
+ throw new Error(`ramgb must be a positive number, received ${ramgb}`);
921
+ }
922
+ const vcpus = options.vcpus ?? 8;
923
+ const totalkb = pagealign(ramgb * 1024 * 1024 - 131072);
924
+ const freefraction = options.freefraction ?? rnd(38, 68) / 1000;
925
+ const availablefraction = options.availablefraction ?? rnd(860, 930) / 1000;
926
+ const memfree = pagealign(totalkb * freefraction);
927
+ const memavailable = pagealign(totalkb * availablefraction);
928
+ const buffers = pagealign(totalkb * (rnd(50, 80) / 10000));
929
+ const cached = pagealign(totalkb * (rnd(300, 420) / 1000));
930
+ const activeanon = pagealign(totalkb * (rnd(240, 320) / 1000));
931
+ const inactiveanon = pagealign(totalkb * (rnd(60, 120) / 1000));
932
+ const active = pagealign(activeanon + cached * 0.55);
933
+ const inactive = pagealign(inactiveanon + cached * 0.4);
934
+ const swapgb = options.swapgb ?? Math.min(Math.max(Math.round(ramgb / 8), 2), 128);
935
+ const swaptotal = swapgb * 1024 * 1024;
936
+ const swapfree = pagealign(swaptotal * (rnd(850, 980) / 1000));
937
+ const commitlimit = pagealign(swaptotal + (totalkb * 50) / 100);
938
+ const committedas = pagealign(totalkb * (rnd(95, 160) / 100));
939
+ const slab = pagealign(totalkb * (rnd(190, 240) / 10000));
940
+ const sreclaimable = pagealign(slab * 0.64);
941
+ const anonhuge = pagealign(activeanon * (rnd(20, 45) / 100));
942
+ const directmap1g = ramgb >= 64 ? pagealign(totalkb * 0.8) : 0;
943
+ const directmap2m =
944
+ directmap1g > 0 ? pagealign(totalkb * 0.15) : pagealign(totalkb * 0.9);
945
+ const rows: Array<[string, number]> = [
946
+ ['MemTotal', totalkb], ['MemFree', memfree],
947
+ ['MemAvailable', memavailable], ['Buffers', buffers], ['Cached', cached],
948
+ ['SwapCached', 0], ['Active', active], ['Inactive', inactive],
949
+ ['Active(anon)', activeanon], ['Inactive(anon)', inactiveanon],
950
+ ['Active(file)', pagealign(active - activeanon)],
951
+ ['Inactive(file)', pagealign(inactive - inactiveanon)],
952
+ ['Unevictable', pagealign(rnd(16384, 65536))],
953
+ ['Mlocked', pagealign(rnd(4096, 16384))],
954
+ ['SwapTotal', swaptotal], ['SwapFree', swapfree],
955
+ ['Dirty', pagealign(rnd(20480, 262144))], ['Writeback', 0],
956
+ ['AnonPages', pagealign(activeanon * 1.08)],
957
+ ['Mapped', pagealign(totalkb * (rnd(20, 42) / 1000))],
958
+ ['Shmem', pagealign(totalkb * (rnd(5, 12) / 1000))],
959
+ ['KReclaimable', sreclaimable], ['Slab', slab],
960
+ ['SReclaimable', sreclaimable],
961
+ ['SUnreclaim', pagealign(slab - sreclaimable)],
962
+ ['KernelStack', pagealign(vcpus * 320)],
963
+ ['PageTables', pagealign(totalkb * (rnd(30, 60) / 10000))],
964
+ ['SecPageTables', pagealign(totalkb * (rnd(8, 16) / 10000))],
965
+ ['NFS_Unstable', 0], ['Bounce', 0], ['WritebackTmp', 0],
966
+ ['CommitLimit', commitlimit], ['Committed_AS', committedas],
967
+ ['VmallocTotal', 34359738367],
968
+ ['VmallocUsed', pagealign(rnd(49152, 131072))], ['VmallocChunk', 0],
969
+ ['Percpu', pagealign(vcpus * rnd(20480, 40960))],
970
+ ['HardwareCorrupted', 0], ['AnonHugePages', anonhuge],
971
+ ['ShmemHugePages', 0], ['ShmemPmdMapped', 0], ['FileHugePages', 0],
972
+ ['FilePmdMapped', 0], ['Unaccepted', 0], ['HugePages_Total', 0],
973
+ ['HugePages_Free', 0], ['HugePages_Rsvd', 0], ['HugePages_Surp', 0],
974
+ ['Hugepagesize', 2048], ['Hugetlb', 0],
975
+ ['DirectMap4k', pagealign(rnd(131072, 262144))],
976
+ ['DirectMap2M', directmap2m], ['DirectMap1G', directmap1g],
977
+ ];
978
+ return rows
979
+ .map(([key, value]) => `${key}:${String(value).padStart(24 - key.length - 1)} kB`)
980
+ .join('\n');
981
+ }
982
+
983
+ /** converts a kb value to the largest human unit for free -h. */
984
+ function human(kb: number): string {
985
+ if (kb >= 1024 * 1024) {
986
+ const gb = kb / (1024 * 1024);
987
+ return `${gb >= 100 ? Math.round(gb) : gb.toFixed(1)}Gi`;
988
+ }
989
+ if (kb >= 1024) {
990
+ return `${(kb / 1024).toFixed(1)}Mi`;
991
+ }
992
+ return `${Math.round(kb)}Ki`;
993
+ }
994
+
995
+ /**
996
+ * renders the free -h table derived from a memoized meminfo snapshot so
997
+ * cat /proc/meminfo and free -h always agree inside one sandbox.
998
+ *
999
+ * @param {string} meminfotext a payload previously returned by meminfo().
1000
+ * @returns {string} the free -h table.
1001
+ */
1002
+ export function freeh(meminfotext: string): string {
1003
+ const map: Record<string, number> = {};
1004
+ for (const row of meminfotext.split('\n')) {
1005
+ const match = /^([A-Za-z_()]+):\s*(\d+)\skB$/.exec(row);
1006
+ if (match !== null) {
1007
+ map[match[1]] = Number(match[2]);
1008
+ }
1009
+ }
1010
+ const total = map.MemTotal ?? 0;
1011
+ const freekb = map.MemFree ?? 0;
1012
+ const buffers = map.Buffers ?? 0;
1013
+ const cached = (map.Cached ?? 0) + (map.SReclaimable ?? 0);
1014
+ const available = map.MemAvailable ?? 0;
1015
+ const used = Math.max(0, total - freekb - buffers - cached);
1016
+ const swap = map.SwapTotal ?? 0;
1017
+ const swapfreekb = map.SwapFree ?? 0;
1018
+ const swapused = swap - swapfreekb;
1019
+ const w = (text: string): string => padstart(String(text), 13);
1020
+ return [
1021
+ `${w('total')}${w('used')}${w('free')}${w('shared')}${w('buff/cache')}${w('available')}`,
1022
+ ['Mem:', w(human(total)), w(human(used)), w(human(freekb)),
1023
+ w(human(map.Shmem ?? 0)), w(human(buffers + cached)), w(human(available))].join(''),
1024
+ ['Swap:', w(human(swap)), w(human(swapused)), w(human(swapfreekb))].join(''),
1025
+ ].join('\n');
1026
+ }
1027
+
1028
+ /* ------------------------------------------------------------------ */
1029
+ /* context: smiformat — the exact 89-char summary table */
1030
+ /* ------------------------------------------------------------------ */
1031
+
1032
+ const smiwidth = 89;
1033
+ const smicolumns = [40, 24, 21];
1034
+ const procwidth = 75;
1035
+ const smidriver = '575.57.08';
1036
+ const cudaversion = '12.9';
1037
+ const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1038
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1039
+
1040
+ /** formats the nvidia-smi timestamp "weekday month day hh:mm:ss year". */
1041
+ function formatstamp(date: Date): string {
1042
+ const two = (value: number): string => String(value).padStart(2, '0');
1043
+ return `${weekdays[date.getDay()]} ${months[date.getMonth()]} ${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())} ${date.getFullYear()}`;
1044
+ }
1045
+
1046
+ /** full-width border line: "+" plus 87 fill characters plus "+". */
1047
+ function smiborder(fill: string): string {
1048
+ return `+${fill.repeat(smiwidth - 2)}+`;
1049
+ }
1050
+
1051
+ /** full-width content line padded to the 89-char table width. */
1052
+ function smifull(content: string): string {
1053
+ return `|${pad(content, smiwidth - 2)}|`;
1054
+ }
1055
+
1056
+ /** three-column separator: pipes at fixed offsets 1, 42 and 67. */
1057
+ function smisep(fill: string): string {
1058
+ return `|${fill.repeat(smicolumns[0])}+${fill.repeat(smicolumns[1])}+${fill.repeat(smicolumns[2])}|`;
1059
+ }
1060
+
1061
+ /** three-column data row within the 89-char width. */
1062
+ function smirow(left: string, mid: string, right: string): string {
1063
+ return `|${pad(left, smicolumns[0])}|${pad(mid, smicolumns[1])}|${pad(right, smicolumns[2])}|`;
1064
+ }
1065
+
1066
+ /** process table content line within the 75-char width. */
1067
+ function procline(content: string): string {
1068
+ return `|${pad(content, procwidth - 2)}|`;
1069
+ }
1070
+
1071
+ /**
1072
+ * renders the complete virtual nvidia-smi adapter report; the summary
1073
+ * table is exactly 89 characters wide and the header pins driver
1074
+ * 575.57.08 and cuda 12.9 exactly as the native smiadapterbuilder does.
1075
+ * when a mig profile is active the memory column reflects the slice size
1076
+ * and a mig line joins the table.
1077
+ *
1078
+ * @param {string} gpu gpu id or name present in gpudata.
1079
+ * @param {string} [migprofile='off'] one of 1g.24gb, 2g.48gb, 4g.96gb or off.
1080
+ * @returns {string} the full report text.
1081
+ */
1082
+ export function nvidiaSmiTable(gpu: string, migprofile: string = 'off'): string {
1083
+ const spec = getgpu(gpu);
1084
+ if (spec === undefined) {
1085
+ throw new Error(
1086
+ `unknown gpu "${gpu}"; known gpus: ${gpudata.map((entry) => entry.id).join(', ')}`,
1087
+ );
1088
+ }
1089
+ const mig = getmig(migprofile);
1090
+ const vrammib = mig !== null ? mig.slicegb * 1024 : spec.smireportedmib;
1091
+ const memusedmib = rnd(0, 24);
1092
+ const util = rnd(0, 3);
1093
+ const temperature = rnd(38, 47);
1094
+ const fanspeed = rnd(24, 38);
1095
+ const powerw = Math.max(10, Math.round(spec.tdpwatts * 0.04));
1096
+ const head = ` NVIDIA-SMI ${smidriver} ${' '.repeat(13)}Driver Version: ${smidriver}${' '.repeat(6)}CUDA Version: ${cudaversion}`;
1097
+ const lines = [
1098
+ formatstamp(new Date()),
1099
+ smiborder('-'),
1100
+ smifull(head),
1101
+ smisep('-'),
1102
+ smirow(
1103
+ `${pad(' GPU Name', 27)}${pad('Persistence-M', 13)}`,
1104
+ pad(' Bus-Id Disp.A', 24),
1105
+ ` ${padstart('Volatile Uncorr. ECC', 20)}`,
1106
+ ),
1107
+ smirow(
1108
+ pad(' Fan Temp Perf Pwr:Usage/Cap', 40),
1109
+ pad(' Memory-Usage', 24),
1110
+ ` ${padstart('GPU-Util Compute M.', 20)}`,
1111
+ ),
1112
+ smisep('='),
1113
+ smirow(
1114
+ `${pad(` 0 ${spec.name}`, 36)}${pad('Off', 4)}`,
1115
+ pad(' 00000000:01:00.0 Off', 24),
1116
+ ` ${padstart('N/A', 20)}`,
1117
+ ),
1118
+ smirow(
1119
+ pad(` ${fanspeed}% ${temperature}C P0 ${powerw}W / ${spec.tdpwatts}W`, 40),
1120
+ padstart(`${memusedmib}MiB / ${vrammib}MiB`, 24),
1121
+ `${padstart(`${util}%`, 10)}${pad(' Default', 11)}`,
1122
+ ),
1123
+ ];
1124
+ if (mig !== null) {
1125
+ lines.push(
1126
+ smifull(
1127
+ ` MIG: enabled, profile ${mig.id}, ${mig.maxinstances} instance(s) of ${mig.slicegb} GB`,
1128
+ ),
1129
+ );
1130
+ }
1131
+ lines.push(
1132
+ smirow('', '', ''),
1133
+ smiborder('-'),
1134
+ '',
1135
+ 'Processes:',
1136
+ procline(
1137
+ `${pad(' GPU GI CI', 20)}${pad('PID', 7)}${pad('Type', 8)}${pad('Process name', 27)}${pad('GPU Memory', 11)}`,
1138
+ ),
1139
+ procline('='.repeat(procwidth - 2)),
1140
+ );
1141
+ if (mig !== null) {
1142
+ for (let instance = 0; instance < mig.maxinstances; instance += 1) {
1143
+ lines.push(
1144
+ procline(
1145
+ `${pad(` 0 ${instance} 0`, 20)}${pad(String(1200 + instance), 7)}${pad('MIG', 8)}${pad('mig-worker', 27)}${pad(`${rnd(0, 512)}MiB`, 11)}`,
1146
+ ),
1147
+ );
1148
+ }
1149
+ } else {
1150
+ lines.push(procline(' No running processes found'));
1151
+ }
1152
+ lines.push(`+${'-'.repeat(procwidth - 2)}+`);
1153
+ return lines.join('\n');
1154
+ }
1155
+
1156
+ /**
1157
+ * renders the nvidia-smi -l device listing; with an active mig profile
1158
+ * the mig device lines follow the parent gpu line.
1159
+ *
1160
+ * @param {string} gpu gpu id or name present in gpudata.
1161
+ * @param {string} [migprofile='off'] mig profile id or off.
1162
+ * @returns {string} the -l listing.
1163
+ */
1164
+ export function nvidiaSmiList(gpu: string, migprofile: string = 'off'): string {
1165
+ const spec = getgpu(gpu);
1166
+ if (spec === undefined) {
1167
+ throw new Error(`unknown gpu "${gpu}"`);
1168
+ }
1169
+ const mig = getmig(migprofile);
1170
+ const lines = [`GPU 0: ${spec.name} (UUID: ${token('GPU-')})`];
1171
+ if (mig !== null) {
1172
+ for (let instance = 0; instance < mig.maxinstances; instance += 1) {
1173
+ lines.push(` MIG ${mig.id} Device ${instance}: (UUID: ${token('MIG-')})`);
1174
+ }
1175
+ }
1176
+ return lines.join('\n');
1177
+ }
1178
+
1179
+ /* ------------------------------------------------------------------ */
1180
+ /* context: mesa stack summaries */
1181
+ /* ------------------------------------------------------------------ */
1182
+
1183
+ /** the software rendering stack pinned from primary sources. */
1184
+ const mesastack = {
1185
+ mesa: '26.2.1', llvm: '22.1.8', gl: '4.6', gles: '3.2', vulkan: '1.4',
1186
+ opencl: '3.1',
1187
+ };
1188
+
1189
+ /**
1190
+ * renders the clinfo summary for the rusticl platform on llvmpipe: opencl
1191
+ * 3.1 (same-day spec support in mesa 26.2), fp16 default since 25.2 and
1192
+ * fp64 through rusticl_features.
1193
+ *
1194
+ * @param {number} [vcpus=8] vcpu count reported as compute units.
1195
+ * @returns {string} the clinfo summary block.
1196
+ */
1197
+ export function clinfoSummary(vcpus: number = 8): string {
1198
+ const units = Math.max(1, Math.min(vcpus, 32));
1199
+ return [
1200
+ 'Number of platforms 1',
1201
+ ' Platform Name rusticl',
1202
+ ' Platform Vendor Mesa/X.org',
1203
+ ' Platform Version OpenCL 3.1',
1204
+ ' Platform Profile FULL_PROFILE',
1205
+ ' Platform Extensions cl_khr_fp64 cl_khr_fp16 cl_khr_icd cl_khr_command_buffer',
1206
+ 'Number of devices 1',
1207
+ ' Device Name llvmpipe (CPU)',
1208
+ ' Device Vendor Mesa/X.org',
1209
+ ' Device Vendor ID 0x10005 (Mesa)',
1210
+ ' Device Version OpenCL 3.1',
1211
+ ' Device Type GPU',
1212
+ ` Max compute units ${units}`,
1213
+ ' Max work item dimensions 3',
1214
+ ' Max work group size 1024',
1215
+ ' Preferred vector width char/int/float 16/16/16',
1216
+ ' Max clock frequency 3700MHz',
1217
+ ' Address bits 64',
1218
+ ' Half precision support cl_khr_fp16 (default since mesa 25.2)',
1219
+ ' Double precision support cl_khr_fp64 (rusticl_features=fp64)',
1220
+ ' Device available Yes',
1221
+ ' Compiler available Yes',
1222
+ ' SPIR versions 1.2',
1223
+ ].join('\n');
1224
+ }
1225
+
1226
+ /**
1227
+ * renders the vulkaninfo --summary block for lavapipe: vulkan 1.4 surface
1228
+ * since mesa 25.1, khronos-conformant on the vulkan 1.3 cts, selected
1229
+ * through vk_driver_files (vk_icd_filenames is deprecated).
1230
+ *
1231
+ * @returns {string} the vulkan summary block.
1232
+ */
1233
+ export function vulkanSummary(): string {
1234
+ return [
1235
+ '==========',
1236
+ 'VULKANINFO',
1237
+ '==========',
1238
+ '',
1239
+ 'Instance Extensions: count=10',
1240
+ 'VK_KHR_surface : extension revision 25',
1241
+ 'VK_KHR_get_physical_device_properties2 : extension revision 2',
1242
+ 'VK_EXT_headless_surface : extension revision 1',
1243
+ '',
1244
+ 'API Version: 1.4.313',
1245
+ '',
1246
+ 'GPU0:',
1247
+ ' apiVersion = 1.4.313',
1248
+ ' driverVersion = 26.2.1',
1249
+ ' vendorID = 0x10005',
1250
+ ' deviceID = 0x0000',
1251
+ ' deviceType = CPU',
1252
+ ' deviceName = llvmpipe (CPU (x86_64))',
1253
+ ' driverName = mesa_llvmpipe',
1254
+ ` driverInfo = Mesa ${mesastack.mesa} (LLVM ${mesastack.llvm})`,
1255
+ ' conformantVersion = 1.3.0.0',
1256
+ '',
1257
+ 'Device Extensions: count=204',
1258
+ 'VK_KHR_8bit_storage VK_KHR_16bit_storage VK_KHR_shader_float16_int8',
1259
+ 'VK_KHR_spirv_1_4 VK_KHR_storage_buffer_storage_class',
1260
+ ].join('\n');
1261
+ }
1262
+
1263
+ /**
1264
+ * renders the glxinfo header block for llvmpipe: opengl 4.6 core profile
1265
+ * 100% (161/161 extensions tracked by mesamatrix) and the llvmpipe
1266
+ * renderer string with the llvm version.
1267
+ *
1268
+ * @returns {string} the glxinfo block.
1269
+ */
1270
+ export function glxinfoSummary(): string {
1271
+ return [
1272
+ 'name of display: :0',
1273
+ 'display: :0 screen: 0',
1274
+ 'direct rendering: Yes',
1275
+ 'server glx vendor string: SGI',
1276
+ 'client glx vendor string: Mesa Project and SGI',
1277
+ 'OpenGL vendor string: Mesa',
1278
+ `OpenGL renderer string: llvmpipe (LLVM ${mesastack.llvm}, 256 bits)`,
1279
+ `OpenGL core profile version string: ${mesastack.gl} (Core Profile) Mesa ${mesastack.mesa}`,
1280
+ 'OpenGL core profile shading language version string: 4.60',
1281
+ '',
1282
+ 'OpenGL core profile context flags: (none)',
1283
+ 'OpenGL core profile profile mask: core profile',
1284
+ '',
1285
+ 'OpenGL core profile extensions: 161',
1286
+ `OpenGL version string: ${mesastack.gl} (Compatibility Profile) Mesa ${mesastack.mesa}`,
1287
+ 'OpenGL shading language version string: 4.60',
1288
+ 'OpenGL context flags: (none)',
1289
+ '',
1290
+ 'Extended renderer info (GLX_MESA_query_renderer):',
1291
+ ' Vendor: Mesa/X.org (0x1002)',
1292
+ ` Device: llvmpipe (LLVM ${mesastack.llvm}, 256 bits) (0xffffffff)`,
1293
+ ` Version: ${mesastack.mesa}`,
1294
+ ' Accelerated: no',
1295
+ ' Video memory: 32768MB',
1296
+ ' Unified memory: yes',
1297
+ ' Texture from pixmap: yes',
1298
+ ].join('\n');
1299
+ }
1300
+
1301
+ /**
1302
+ * assembles the validated mesa environment exactly as mesaenvbuilder +
1303
+ * renderenv('opengl') do: llvmpipe forced, gl 4.6, glsl 460, vector width
1304
+ * 512 to exercise the avx-512 paths, lavapipe through vk_driver_files and
1305
+ * rusticl opencl 3.1 with the fp64 feature.
1306
+ *
1307
+ * @param {number} [vcpus=8] vcpu count used for lp_num_threads (0 uses
1308
+ * every core up to the lp_max_threads=32 ceiling).
1309
+ * @returns {Record<string, string>} the environment record.
1310
+ */
1311
+ export function mesaenv(vcpus: number = 8): Record<string, string> {
1312
+ const threads = vcpus <= 0 ? 0 : Math.min(vcpus, 32);
1313
+ return {
1314
+ LIBGL_ALWAYS_SOFTWARE: 'true',
1315
+ GALLIUM_DRIVER: 'llvmpipe',
1316
+ MESA_GL_VERSION_OVERRIDE: mesastack.gl,
1317
+ MESA_GLSL_VERSION_OVERRIDE: '460',
1318
+ MESA_GLES_VERSION_OVERRIDE: mesastack.gles,
1319
+ MESA_VK_VERSION_OVERRIDE: mesastack.vulkan,
1320
+ LP_NUM_THREADS: String(threads),
1321
+ LP_NATIVE_VECTOR_WIDTH: '512',
1322
+ MESA_NO_ERROR: '1',
1323
+ VK_DRIVER_FILES: '/usr/share/vulkan/icd.d/lvp_icd.x86_64.json',
1324
+ MESA_VK_WSI_HEADLESS_SWAPCHAIN: '1',
1325
+ RUSTICL_ENABLE: 'llvmpipe',
1326
+ RUSTICL_DEVICE_TYPE: 'gpu',
1327
+ RUSTICL_CL_VERSION: mesastack.opencl,
1328
+ RUSTICL_FEATURES: 'fp64',
1329
+ };
1330
+ }
1331
+
1332
+ /* ------------------------------------------------------------------ */
1333
+ /* context: bootsequence — dmesg-style boot lines */
1334
+ /* ------------------------------------------------------------------ */
1335
+
1336
+ /**
1337
+ * renders the firecracker-style boot log for a sandbox: kernel banner,
1338
+ * 125 ms microvm bring-up, memory sizing, vcpu online lines, the mesa
1339
+ * software stack bring-up and the virtual gpu identity binding. every
1340
+ * line carries the kernel [ 0.000000] timestamp style.
1341
+ *
1342
+ * @param {string} model cpu model name present in cpudata.
1343
+ * @param {number} vcpus logical processor count.
1344
+ * @param {number} [ramgb=32] guest ram in gb.
1345
+ * @param {string} [gpu='rtx5090'] gpu id present in gpudata.
1346
+ * @param {number} [quota=16777216] persistent workspace quota in bytes.
1347
+ * @returns {string[]} ordered dmesg lines.
1348
+ */
1349
+ export function bootSequence(
1350
+ model: string,
1351
+ vcpus: number,
1352
+ ramgb: number = 32,
1353
+ gpu: string = 'rtx5090',
1354
+ quota: number = 16 * 1024 * 1024,
1355
+ ): string[] {
1356
+ const spec = getcpu(model) ?? cpudata[0];
1357
+ const gpuspec = getgpu(gpu) ?? gpudata[0];
1358
+ const topology = solvetopology(spec, vcpus);
1359
+ const arch = spec.arch === 'arm64' ? 'aarch64' : 'x86_64';
1360
+ const stamp = (seconds: number): string => `[${seconds.toFixed(6).padStart(12)}]`;
1361
+ const onlinetime = 0.039 + 0.00042 * topology.vcpus;
1362
+ return [
1363
+ `${stamp(0)} Linux version 6.12.0-saddle (root@saddle) (gcc 15.1.0, ld 2.44) #1 SMP PREEMPT_DYNAMIC ${arch}`,
1364
+ `${stamp(0.000049)} Command line: root=/dev/vda rw console=ttyS0 reboot=k panic=1 pci=off i8042.noaux i8042.nomux i8042.nopnp i8042.dumbkbd`,
1365
+ `${stamp(0.004201)} BIOS-provided physical RAM map (virtio-mem + memfd hugetlb backend)`,
1366
+ `${stamp(0.008913)} Memory: ${pagealign(ramgb * 1024 * 1024 - 131072)}K available (${ramgb} GB guest plan, 4 kB pages)`,
1367
+ `${stamp(0.012451)} firecracker: microvm vm_state=Running boot_time=125ms vmm=fc v1.16.1`,
1368
+ `${stamp(0.02133)} virtio_blk virtio1: [vda] 8589934592 512-byte logical blocks (8.6 GB)`,
1369
+ `${stamp(0.038774)} smpboot: ${topology.coresonline} cores, ${topology.vcpus} threads online (${spec.model})`,
1370
+ `${stamp(0.039001)} cpu cpu0: ${spec.microarch}, boost ${spec.boostclockmhz} MHz, l3 ${spec.l3mb} MB`,
1371
+ `${stamp(onlinetime)} smp: brought up ${topology.vcpus} cpus, cpu mask 0-${topology.vcpus - 1}`,
1372
+ `${stamp(onlinetime + 0.0148)} NUMA: ${Math.max(1, Math.round(topology.vcpus / 64))} node(s), interleave enabled`,
1373
+ `${stamp(onlinetime + 0.0283)} hugepage: 2048 kB pages reserved, thp=always`,
1374
+ `${stamp(onlinetime + 0.0422)} docker: engine 29.7.2 ready, containerd 2.3.1, memswap=-1 shm=2g`,
1375
+ `${stamp(onlinetime + 0.0551)} qemu-system-${arch}: bridge 11.1.0 attached (qmp unix:/run/saddle/qemu-0.sock)`,
1376
+ `${stamp(onlinetime + 0.0784)} mesa ${mesastack.mesa}: llvmpipe rasterizer, lp_num_threads=${Math.min(topology.vcpus, 32)}, lp_max_threads=32`,
1377
+ `${stamp(onlinetime + 0.0789)} llvmpipe: avx-512 vector width 512 selected by gallivm (llvm ${mesastack.llvm})`,
1378
+ `${stamp(onlinetime + 0.0812)} lavapipe: vulkan ${mesastack.vulkan}, icd lvp_icd.${arch}.json, wsi headless`,
1379
+ `${stamp(onlinetime + 0.0837)} rusticl: opencl ${mesastack.opencl} on llvmpipe, features fp64 (fp16 default since 25.2)`,
1380
+ `${stamp(onlinetime + 0.091)} virtualgpu: identity ${gpuspec.name} [${gpuspec.pcivendor}:${gpuspec.pcidevice}] ${gpuspec.memtype} ${gpuspec.vrammib / 1024} GB`,
1381
+ `${stamp(onlinetime + 0.0922)} nvml shim: FAKE_MODEL="${gpuspec.name}" FAKE_VRAM=${gpuspec.smireportedmib} (fake-nvidia-smi adapter ready)`,
1382
+ `${stamp(onlinetime + 0.1045)} saddle: virtual hardware engine v2.0.127, sandbox created -> running`,
1383
+ `${stamp(onlinetime + 0.1047)} saddle: persistent workspace: ${humanbytes(quota)} quota, data stays with the sandbox id`,
1384
+ `${stamp(onlinetime + 0.1046)} Freeing unused kernel image memory`,
1385
+ ];
1386
+ }
1387
+
1388
+ /* ------------------------------------------------------------------ */
1389
+ /* context: sandboxstate and dispatcher */
1390
+ /* ------------------------------------------------------------------ */
1391
+
1392
+ /**
1393
+ * creates the state object shared by the browser terminal and the api
1394
+ * exec endpoint: resolved specs, a stable hostname, the boot timestamp and
1395
+ * a memoized meminfo snapshot so memory reports stay consistent within
1396
+ * one sandbox session.
1397
+ *
1398
+ * @param {{model: string, vcpus: number, ramgb: number, gpu: string,
1399
+ * mig?: string, id?: string}} spec the sandbox specification.
1400
+ * @returns {object} the sandbox state consumed by dispatch.
1401
+ */
1402
+ export function createSandboxState(spec: SandboxSpec): SandboxState {
1403
+ // the reviewed catalog is the only source of processor identity; ram
1404
+ // accepts any user chosen plan up to the 18 tb virtual ceiling.
1405
+ const cpu = getcpu(spec.model) ?? cpudata[0];
1406
+ const gpu = getgpu(spec.gpu) ?? gpudata[0];
1407
+ const topology = solvetopology(cpu, spec.vcpus ?? 8);
1408
+ const ramgb = Math.max(1, Math.min(Math.round(spec.ramgb ?? 32), 18432));
1409
+ const id = spec.id ?? token('sb-');
1410
+ return {
1411
+ model: cpu.model,
1412
+ cpuspec: cpu,
1413
+ gpuspec: gpu,
1414
+ vcpus: topology.vcpus,
1415
+ ramgb,
1416
+ gpu: gpu.id,
1417
+ mig: getmig(spec.mig) === null ? 'off' : (spec.mig as string),
1418
+ id,
1419
+ hostname: `saddle-${id.replace(/^sb-/, '').slice(0, 8)}`,
1420
+ boottime: Date.now(),
1421
+ kernel: cpu.arch === 'arm64' ? '6.12.0-saddle-aarch64' : '6.12.0-saddle',
1422
+ memsnapshot: meminfo(ramgb, { vcpus: topology.vcpus }),
1423
+ history: [],
1424
+ files: new Map(),
1425
+ };
1426
+ }
1427
+
1428
+ /** the supported command list, reused by help and the terminal. */
1429
+ export const commands: string[] = [
1430
+ 'help', 'lscpu', 'cat /proc/cpuinfo', 'cat /proc/meminfo', 'free -h',
1431
+ 'nvidia-smi', 'nvidia-smi -L', 'clinfo', 'vulkaninfo --summary',
1432
+ 'glxinfo', 'uname -a', 'ls /etc/virtual', 'env', 'docker --version',
1433
+ 'qemu-system-x86_64 --version', 'uptime', 'whoami', 'neofetch', 'clear',
1434
+ 'echo', 'echo <text> > <file>', 'echo <text> >> <file>', 'touch <file>',
1435
+ 'cat <file>', 'ls', 'rm <file>', 'df -h', 'stat <file>', 'pwd',
1436
+ 'history', 'streaming', 'man <command>',
1437
+ ];
1438
+
1439
+ /** renders the help text with every supported command. */
1440
+ function helpText(): string {
1441
+ const rows = [
1442
+ ['help', 'this command list'],
1443
+ ['lscpu', 'virtual processor topology summary'],
1444
+ ['cat /proc/cpuinfo', 'full procfs cpuinfo payload'],
1445
+ ['cat /proc/meminfo', 'full procfs meminfo payload'],
1446
+ ['free -h', 'human readable memory table'],
1447
+ ['nvidia-smi', 'virtual gpu adapter report (89-char table)'],
1448
+ ['nvidia-smi -L', 'virtual gpu device listing'],
1449
+ ['clinfo', 'rusticl opencl 3.1 summary on llvmpipe'],
1450
+ ['vulkaninfo --summary', 'lavapipe vulkan 1.4 summary'],
1451
+ ['glxinfo', 'llvmpipe opengl 4.6 summary'],
1452
+ ['uname -a', 'kernel and architecture line'],
1453
+ ['ls /etc/virtual', 'virtual hardware definition files'],
1454
+ ['env', 'mesa llvmpipe environment variables'],
1455
+ ['docker --version', 'docker engine version'],
1456
+ ['qemu-system-x86_64 --version', 'qemu bridge version'],
1457
+ ['uptime', 'sandbox uptime and load average'],
1458
+ ['whoami', 'current user'],
1459
+ ['neofetch', 'saddle ascii logo and sandbox specs'],
1460
+ ['clear', 'clear the terminal'],
1461
+ ['echo <text>', 'print text'],
1462
+ ['echo <text> > <file>', 'write text to a workspace file'],
1463
+ ['echo <text> >> <file>', 'append text to a workspace file'],
1464
+ ['touch <file>', 'create an empty workspace file'],
1465
+ ['cat <file>', 'print a persistent workspace file'],
1466
+ ['ls', 'list the workspace root with sizes and dates'],
1467
+ ['rm <file>', 'delete one workspace file'],
1468
+ ['df -h', 'workspace quota usage as a filesystem table'],
1469
+ ['stat <file>', 'size and timestamps of one workspace file'],
1470
+ ['pwd', 'print the working directory (always /)'],
1471
+ ['history', 'streaming', 'the last 50 commands of this session'],
1472
+ ['man <command>', 'short manual for one command'],
1473
+ ];
1474
+ return rows.map(([name, about]) => ` ${pad(name, 32)}${about}`).join('\n');
1475
+ }
1476
+
1477
+ /** renders the neofetch block: the saddle ascii logo beside sandbox specs. */
1478
+ function neofetch(state: SandboxState): string {
1479
+ const cpu = state.cpuspec;
1480
+ const gpu = state.gpuspec;
1481
+ const logo = [
1482
+ ' ``-://////:-`` ',
1483
+ ' ./+++++++++++++/-. ',
1484
+ ' -+++++++++++++++++++-` ',
1485
+ ' ./+++ saddle sandbox ++/. ',
1486
+ ' -+++++++++++++++++++++-` ',
1487
+ ' `-:/+++++++++++++/-. ',
1488
+ ' .-://////:-. ',
1489
+ ' ',
1490
+ ];
1491
+ const uptime = Math.max(1, Math.round((Date.now() - state.boottime) / 1000));
1492
+ const mins = Math.floor(uptime / 60);
1493
+ const hours = Math.floor(mins / 60);
1494
+ const uptimeText = hours > 0 ? `${hours} hour(s), ${mins % 60} min` : `${mins} min`;
1495
+ const total = pagealign(state.ramgb * 1024 * 1024 - 131072);
1496
+ const mig = getmig(state.mig);
1497
+ const info = [
1498
+ `root@${state.hostname}`,
1499
+ '-----------------',
1500
+ 'OS: saddle linux (virtual hardware engine v2.0.127)',
1501
+ 'Host: firecracker microvm (125 ms boot)',
1502
+ `Kernel: ${state.kernel}`,
1503
+ `Uptime: ${uptimeText}`,
1504
+ 'Packages: 412 (dpkg), 8 (snap)',
1505
+ 'Shell: bash 5.2.37',
1506
+ `CPU: ${cpu.displayname} (${state.vcpus}) @ ${cpu.boostclockmhz}.00MHz`,
1507
+ `GPU: ${gpu.name} [${gpu.pcivendor}:${gpu.pcidevice}]`,
1508
+ `Memory: ${human(Math.round(total * 0.31))} / ${human(total)}`,
1509
+ `vRAM: ${gpu.memtype} ${gpu.vrammib / 1024} GB${mig !== null ? ` (MIG ${mig.id})` : ''}`,
1510
+ 'Renderer: llvmpipe (LLVM 22.1.8) / lavapipe 1.4 / rusticl 3.1',
1511
+ ];
1512
+ const rows = Math.max(logo.length, info.length);
1513
+ const out = [];
1514
+ for (let index = 0; index < rows; index += 1) {
1515
+ out.push(`${(logo[index] ?? '').padEnd(30)}${info[index] ?? ''}`);
1516
+ }
1517
+ return out.join('\n');
1518
+ }
1519
+
1520
+ /** renders the uptime line with a load average derived from the vcpus. */
1521
+ function uptimeLine(state: SandboxState): string {
1522
+ const totalSeconds = Math.max(0, Math.floor((Date.now() - state.boottime) / 1000));
1523
+ const now = new Date();
1524
+ const mins = Math.max(1, Math.round(totalSeconds / 60));
1525
+ const load = (decay: number): string =>
1526
+ (Math.random() * state.vcpus * 0.18 * Math.exp(-decay / 30)).toFixed(2);
1527
+ return ` ${now.toTimeString().slice(0, 8)} up ${mins} min, 1 user, load average: ${load(1)}, ${load(5)}, ${load(15)}`;
1528
+ }
1529
+
1530
+ /** renders the env listing: the mesa stack plus the sandbox variables. */
1531
+ function envText(state: SandboxState): string {
1532
+ const mig = getmig(state.mig);
1533
+ const entries: Record<string, string> = {
1534
+ ...mesaenv(state.vcpus),
1535
+ FAKE_MODEL: state.gpuspec.name,
1536
+ FAKE_VRAM: String(mig !== null ? mig.slicegb * 1024 : state.gpuspec.smireportedmib),
1537
+ SADDLE_VERSION: '2.0.127',
1538
+ SADDLE_SANDBOX: state.id,
1539
+ SADDLE_STATE: 'running',
1540
+ SADDLE_CPU: state.model,
1541
+ SADDLE_GPU: state.gpu,
1542
+ SADDLE_MIG: state.mig,
1543
+ HOSTNAME: state.hostname,
1544
+ TERM: 'xterm-256color',
1545
+ SHELL: '/bin/bash',
1546
+ USER: 'root',
1547
+ HOME: '/root',
1548
+ PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
1549
+ LANG: 'C.UTF-8',
1550
+ };
1551
+ return Object.keys(entries)
1552
+ .sort()
1553
+ .map((key) => `${key}=${entries[key]}`)
1554
+ .join('\n');
1555
+ }
1556
+
1557
+ /* ------------------------------------------------------------------ */
1558
+ /* context: virtualfs — the persistent workspace filesystem */
1559
+ /* ------------------------------------------------------------------ */
1560
+
1561
+ /** the default workspace quota mirrored from the api contract. */
1562
+ const defaultquota = 16 * 1024 * 1024;
1563
+
1564
+ /** the per-session history cap shown by the history command. */
1565
+ const historycap = 50;
1566
+
1567
+ /**
1568
+ * normalizes one workspace path: the working directory is always the
1569
+ * sandbox root, so relative names gain the leading slash, duplicated
1570
+ * slashes collapse, trailing slashes drop and any traversal attempt
1571
+ * ("..") or oversized path is rejected.
1572
+ *
1573
+ * @param {string} input the raw path typed by the user.
1574
+ * @returns {{ok: true, path: string} | {ok: false, error: string}} the verdict.
1575
+ */
1576
+ function normalizepath(input: string): { ok: true; path: string } | { ok: false; error: string } {
1577
+ const raw = String(input ?? '').trim();
1578
+ if (raw.length === 0) {
1579
+ return { ok: false, error: 'empty path' };
1580
+ }
1581
+ if (raw.includes('..')) {
1582
+ return { ok: false, error: 'path traversal is not allowed' };
1583
+ }
1584
+ let path = raw.replace(/\/+/, '/');
1585
+ if (!path.startsWith('/')) {
1586
+ path = `/${path}`;
1587
+ }
1588
+ if (path.length > 1 && path.endsWith('/')) {
1589
+ path = path.slice(0, -1);
1590
+ }
1591
+ if (path.length > 128) {
1592
+ return { ok: false, error: 'path is too long (max 128 characters)' };
1593
+ }
1594
+ return { ok: true, path };
1595
+ }
1596
+
1597
+ /**
1598
+ * builds the fallback filesystem for contexts without callbacks (the
1599
+ * browser terminal): a per-state map that lives exactly as long as the
1600
+ * sandbox session and mirrors the {write, read, list, del} contract.
1601
+ *
1602
+ * @param {object} state a state built by createsandboxstate.
1603
+ * @returns {{write: Function, read: Function, list: Function, del: Function}}
1604
+ * the in-memory filesystem adapter.
1605
+ */
1606
+ function memoryfs(state: SandboxState): SandboxFs {
1607
+ return {
1608
+ write(path, content) {
1609
+ const text = content === null || content === undefined ? '' : String(content);
1610
+ const size = new TextEncoder().encode(text).length;
1611
+ const updatedat = new Date().toISOString();
1612
+ state.files.set(path, { content: text, size, updatedat });
1613
+ return { path, size, updatedat };
1614
+ },
1615
+ read(path) {
1616
+ const entry = state.files.get(path);
1617
+ return entry === undefined ? null : { path, ...entry };
1618
+ },
1619
+ list() {
1620
+ return [...state.files.entries()]
1621
+ .map(([path, entry]) => ({ path, size: entry.size, updatedat: entry.updatedat }))
1622
+ .sort((a, b) => (a.path < b.path ? -1 : 1));
1623
+ },
1624
+ del(path) {
1625
+ return state.files.delete(path);
1626
+ },
1627
+ };
1628
+ }
1629
+
1630
+ /**
1631
+ * renders one ls row in the long listing style with size and date.
1632
+ *
1633
+ * @param {{path: string, size: number, updatedat: string}} file the file row.
1634
+ * @returns {string} the formatted line.
1635
+ */
1636
+ function lsrow(file: { path: string; size: number; updatedat: string }): string {
1637
+ const when = String(file.updatedat ?? '').slice(0, 16).replace('T', ' ');
1638
+ return `-rw-r--r-- 1 root root ${padstart(String(file.size), 9)} ${when} ${file.path}`;
1639
+ }
1640
+
1641
+ /** the short manual pages served by the man command. */
1642
+ const manualpages: Record<string, string | undefined> = {
1643
+ streaming: 'streaming: shows the streaming memory plan - any workload size runs inside a small hot window (mmap layers, evict after last consumer)',
1644
+ quantum: 'quantum: quantum layer summary - simulator, bb84, grover, dna vault, optical planner',
1645
+ tiers: 'tiers: shows the layered virtual memory - l1 ram to l4 buckets, the free pool and the npm-as-disk farm',
1646
+ touch: 'touch <file> - create an empty file in the persistent workspace (or refresh its timestamp)',
1647
+ echo: 'echo <text> - print text; echo <text> > <file> writes and echo <text> >> <file> appends (stored verbatim, no implicit newline)',
1648
+ cat: 'cat <file> - print one persistent workspace file (also /proc/cpuinfo and /proc/meminfo)',
1649
+ ls: 'ls [path] - list the persistent workspace root with sizes and dates; ls /etc/virtual keeps the hardware table',
1650
+ rm: 'rm <file> - delete one persistent workspace file',
1651
+ df: 'df [-h] - workspace quota usage as a filesystem table; the quota is fixed per sandbox id',
1652
+ stat: 'stat <file> - size and timestamps of one persistent workspace file',
1653
+ pwd: 'pwd - print the working directory; the sandbox is always rooted at /',
1654
+ history: 'history - the last 50 commands typed in this session',
1655
+ man: 'man <command> - this short manual',
1656
+ };
1657
+
1658
+ /**
1659
+ * dispatches one shell command against a sandbox state; the return shape
1660
+ * matches the api exec contract {output, exitCode}. the optional context
1661
+ * carries the persistent filesystem callbacks {write, read, list, del}
1662
+ * bound to the sandbox id by the api host; without a context the same
1663
+ * commands run on the per-session in-memory workspace so the browser
1664
+ * terminal keeps working unchanged.
1665
+ *
1666
+ * @param {string} command the raw command line typed by the user.
1667
+ * @param {object} state a state built by createsandboxstate.
1668
+ * @param {{fs?: {write: Function, read: Function, list: Function,
1669
+ * del: Function}, quota?: number}} [context] the optional filesystem
1670
+ * context provided by the api host.
1671
+ * @returns {{output: string, exitCode: number, clear?: boolean}} the result.
1672
+ */
1673
+ export function dispatch(
1674
+ command: string,
1675
+ state: SandboxState,
1676
+ context?: DispatchContext,
1677
+ ): DispatchResult {
1678
+ const raw = String(command ?? '').trim();
1679
+ if (raw.length === 0) {
1680
+ return { output: '', exitCode: 0 };
1681
+ }
1682
+ try {
1683
+ if (!Array.isArray(state.history)) {
1684
+ state.history = [];
1685
+ }
1686
+ state.history.push(raw);
1687
+ if (state.history.length > historycap) {
1688
+ state.history.splice(0, state.history.length - historycap);
1689
+ }
1690
+ } catch {
1691
+ /* history is best effort; a frozen state must not break exec */
1692
+ }
1693
+ const fs = context?.fs ?? memoryfs(state);
1694
+ const argv = raw.split(/\s+/);
1695
+ const head = argv[0];
1696
+ const rest = argv.slice(1).join(' ');
1697
+ try {
1698
+ /* echo is the one command with output redirection: the text is
1699
+ * stored verbatim ("echo a > f" then "echo b >> f" yields "ab").
1700
+ * The split is a linear scan off the last '>' — the lazy regex it
1701
+ * replaces walked every boundary of a space run and the redos gate
1702
+ * refused the polynomial detour: the operator is the last '>' doubled
1703
+ * or single, the filename the single whitespace-free token after it
1704
+ * and the command everything before the operator. */
1705
+ const gt = raw.lastIndexOf('>');
1706
+ let redirect: { command: string; operator: string; file: string } | null = null;
1707
+ if (gt >= 0) {
1708
+ const file = raw.slice(gt + 1).trim();
1709
+ if (file.length > 0 && !/\s/.test(file)) {
1710
+ const doubled = gt > 0 && raw[gt - 1] === '>';
1711
+ redirect = {
1712
+ command: raw.slice(0, doubled ? gt - 1 : gt).trim(),
1713
+ operator: doubled ? '>>' : '>',
1714
+ file,
1715
+ };
1716
+ }
1717
+ }
1718
+ if (redirect !== null) {
1719
+ const commandpart = redirect.command;
1720
+ const redirhead = commandpart.split(/\s+/)[0];
1721
+ if (redirhead !== 'echo') {
1722
+ return {
1723
+ output: `bash: output redirection is only supported for echo (got ${redirhead})`,
1724
+ exitCode: 1,
1725
+ };
1726
+ }
1727
+ const text = commandpart.slice(redirhead.length).trim();
1728
+ const norm = normalizepath(redirect.file);
1729
+ if (!norm.ok) {
1730
+ return { output: `bash: echo: ${redirect.file}: ${norm.error}`, exitCode: 1 };
1731
+ }
1732
+ try {
1733
+ if (redirect.operator === '>>') {
1734
+ const existing = fs.read(norm.path);
1735
+ fs.write(norm.path, `${existing === null ? '' : existing.content}${text}`);
1736
+ } else {
1737
+ fs.write(norm.path, text);
1738
+ }
1739
+ return { output: '', exitCode: 0 };
1740
+ } catch (writeerror) {
1741
+ return {
1742
+ output: `bash: echo: ${writeerror instanceof Error ? writeerror.message : String(writeerror)}`,
1743
+ exitCode: 1,
1744
+ };
1745
+ }
1746
+ }
1747
+ switch (head) {
1748
+ case 'help':
1749
+ return { output: `saddle sandbox commands:\n${helpText()}`, exitCode: 0 };
1750
+ case 'lscpu':
1751
+ return { output: lscpu(state.model, state.vcpus, 1), exitCode: 0 };
1752
+ case 'cat': {
1753
+ if (argv[1] === '/proc/cpuinfo') {
1754
+ return { output: cpuinfo(state.model, state.vcpus), exitCode: 0 };
1755
+ }
1756
+ if (argv[1] === '/proc/meminfo') {
1757
+ return { output: state.memsnapshot, exitCode: 0 };
1758
+ }
1759
+ if (argv[1] === undefined) {
1760
+ return { output: 'cat: missing file operand', exitCode: 1 };
1761
+ }
1762
+ const catnorm = normalizepath(argv[1]);
1763
+ if (!catnorm.ok) {
1764
+ return { output: `cat: ${argv[1]}: ${catnorm.error}`, exitCode: 1 };
1765
+ }
1766
+ const catfile = fs.read(catnorm.path);
1767
+ if (catfile === null) {
1768
+ return {
1769
+ output: `cat: ${argv[1]}: No such file or directory`,
1770
+ exitCode: 1,
1771
+ };
1772
+ }
1773
+ return { output: catfile.content, exitCode: 0 };
1774
+ }
1775
+ case 'free':
1776
+ return { output: freeh(state.memsnapshot), exitCode: 0 };
1777
+ case 'nvidia-smi':
1778
+ if (argv[1] === '-L') {
1779
+ return { output: nvidiaSmiList(state.gpu, state.mig), exitCode: 0 };
1780
+ }
1781
+ return { output: nvidiaSmiTable(state.gpu, state.mig), exitCode: 0 };
1782
+ case 'clinfo':
1783
+ return { output: clinfoSummary(state.vcpus), exitCode: 0 };
1784
+ case 'vulkaninfo':
1785
+ return { output: vulkanSummary(), exitCode: 0 };
1786
+ case 'glxinfo':
1787
+ return { output: glxinfoSummary(), exitCode: 0 };
1788
+ case 'uname':
1789
+ return {
1790
+ output: `Linux ${state.hostname} ${state.kernel} #1 SMP PREEMPT_DYNAMIC ${state.cpuspec.arch === 'arm64' ? 'aarch64' : 'x86_64'} GNU/Linux`,
1791
+ exitCode: 0,
1792
+ };
1793
+ case 'ls': {
1794
+ const target = argv[1] ?? '/';
1795
+ if (target === '/etc/virtual') {
1796
+ return {
1797
+ output: [
1798
+ 'boards.json cores.json gpus.json',
1799
+ 'processors.json virtualhardware.json vm.config.json',
1800
+ 'cpuinfo meminfo lscpu nvidia-smi',
1801
+ ].join('\n'),
1802
+ exitCode: 0,
1803
+ };
1804
+ }
1805
+ const lsnorm = normalizepath(target);
1806
+ if (!lsnorm.ok) {
1807
+ return { output: `ls: ${target}: ${lsnorm.error}`, exitCode: 2 };
1808
+ }
1809
+ const rows = fs.list().filter(
1810
+ (entry) => lsnorm.path === '/' || entry.path === lsnorm.path,
1811
+ );
1812
+ if (rows.length === 0) {
1813
+ if (lsnorm.path !== '/') {
1814
+ return {
1815
+ output: `ls: cannot access '${target}': No such file or directory`,
1816
+ exitCode: 2,
1817
+ };
1818
+ }
1819
+ return { output: '', exitCode: 0 };
1820
+ }
1821
+ const blocks = Math.max(1, Math.ceil(rows.reduce((sum, row) => sum + row.size, 0) / 1024));
1822
+ return {
1823
+ output: [`total ${blocks}`, ...rows.map(lsrow)].join('\n'),
1824
+ exitCode: 0,
1825
+ };
1826
+ }
1827
+ case 'env':
1828
+ return { output: envText(state), exitCode: 0 };
1829
+ case 'docker':
1830
+ return { output: 'Docker version 29.7.2, build 5ea4c1f', exitCode: 0 };
1831
+ case 'qemu-system-x86_64':
1832
+ return {
1833
+ output: [
1834
+ 'QEMU emulator version 11.1.0 (saddle bridge)',
1835
+ 'Copyright (c) 2003-2026 Fabrice Bellard and the QEMU Project developers',
1836
+ ].join('\n'),
1837
+ exitCode: 0,
1838
+ };
1839
+ case 'uptime':
1840
+ return { output: uptimeLine(state), exitCode: 0 };
1841
+ case 'whoami':
1842
+ return { output: 'root', exitCode: 0 };
1843
+ case 'neofetch':
1844
+ return { output: neofetch(state), exitCode: 0 };
1845
+ case 'echo':
1846
+ return { output: rest, exitCode: 0 };
1847
+ case 'touch': {
1848
+ if (argv[1] === undefined) {
1849
+ return { output: 'touch: missing file operand', exitCode: 1 };
1850
+ }
1851
+ const touchnorm = normalizepath(argv[1]);
1852
+ if (!touchnorm.ok) {
1853
+ return { output: `touch: ${argv[1]}: ${touchnorm.error}`, exitCode: 1 };
1854
+ }
1855
+ try {
1856
+ const existing = fs.read(touchnorm.path);
1857
+ fs.write(touchnorm.path, existing === null ? '' : existing.content);
1858
+ return { output: '', exitCode: 0 };
1859
+ } catch (toucherror) {
1860
+ return {
1861
+ output: `touch: ${toucherror instanceof Error ? toucherror.message : String(toucherror)}`,
1862
+ exitCode: 1,
1863
+ };
1864
+ }
1865
+ }
1866
+ case 'rm': {
1867
+ if (argv[1] === undefined) {
1868
+ return { output: 'rm: missing operand', exitCode: 1 };
1869
+ }
1870
+ const rmnorm = normalizepath(argv[1]);
1871
+ if (!rmnorm.ok) {
1872
+ return { output: `rm: ${argv[1]}: ${rmnorm.error}`, exitCode: 1 };
1873
+ }
1874
+ try {
1875
+ const removed = fs.del(rmnorm.path);
1876
+ if (!removed) {
1877
+ return {
1878
+ output: `rm: cannot remove '${argv[1]}': No such file or directory`,
1879
+ exitCode: 1,
1880
+ };
1881
+ }
1882
+ return { output: '', exitCode: 0 };
1883
+ } catch (rmerror) {
1884
+ return {
1885
+ output: `rm: ${rmerror instanceof Error ? rmerror.message : String(rmerror)}`,
1886
+ exitCode: 1,
1887
+ };
1888
+ }
1889
+ }
1890
+ case 'quantum':
1891
+ return { output: quantumdemo(), exitCode: 0 };
1892
+ case 'tiers':
1893
+ return { output: tiersdemo(), exitCode: 0 };
1894
+ case 'streaming':
1895
+ return { output: streamingdemo(), exitCode: 0 };
1896
+ case 'df': {
1897
+ const rows = fs.list();
1898
+ const used = rows.reduce((sum, row) => sum + row.size, 0);
1899
+ const quota = Number(context?.quota ?? defaultquota);
1900
+ const avail = Math.max(0, quota - used);
1901
+ const percent = quota > 0 ? Math.min(100, Math.floor((used / quota) * 100)) : 0;
1902
+ const col = (text: string, width: number): string => padstart(String(text), width);
1903
+ return {
1904
+ output: [
1905
+ `Filesystem ${col('Size', 10)}${col('Used', 10)}${col('Avail', 10)}${col('Use%', 6)} Mounted on`,
1906
+ `${pad(state.id, 11)}${col(humanbytes(quota), 10)}${col(humanbytes(used), 10)}${col(humanbytes(avail), 10)}${col(`${percent}%`, 6)} /`,
1907
+ ].join('\n'),
1908
+ exitCode: 0,
1909
+ };
1910
+ }
1911
+ case 'stat': {
1912
+ if (argv[1] === undefined) {
1913
+ return { output: 'stat: missing operand', exitCode: 1 };
1914
+ }
1915
+ const statnorm = normalizepath(argv[1]);
1916
+ if (!statnorm.ok) {
1917
+ return { output: `stat: ${argv[1]}: ${statnorm.error}`, exitCode: 1 };
1918
+ }
1919
+ const file = fs.read(statnorm.path);
1920
+ if (file === null) {
1921
+ return {
1922
+ output: `stat: cannot stat '${argv[1]}': No such file or directory`,
1923
+ exitCode: 1,
1924
+ };
1925
+ }
1926
+ return {
1927
+ output: [
1928
+ ` File: ${file.path ?? statnorm.path}`,
1929
+ ` Size: ${padstart(String(file.size), 10)}\tBlocks: ${padstart(String(Math.ceil(file.size / 512)), 6)} IO Block: 4096 regular file`,
1930
+ `Device: saddle/virtual\tInode: ${padstart(String(state.files instanceof Map ? state.files.size + 1 : 1), 12)} Links: 1`,
1931
+ `Modify: ${file.updatedat}`,
1932
+ ].join('\n'),
1933
+ exitCode: 0,
1934
+ };
1935
+ }
1936
+ case 'pwd':
1937
+ return { output: '/', exitCode: 0 };
1938
+ case 'history': {
1939
+ const lines = (Array.isArray(state.history) ? state.history : [])
1940
+ .map((entry, index) => `${padstart(String(index + 1), 5)} ${entry}`);
1941
+ return { output: lines.join('\n'), exitCode: 0 };
1942
+ }
1943
+ case 'man': {
1944
+ const topic = argv[1];
1945
+ if (topic === undefined) {
1946
+ return { output: 'What manual page do you want?', exitCode: 1 };
1947
+ }
1948
+ const page = manualpages[topic];
1949
+ if (page === undefined) {
1950
+ return { output: `man: no manual for ${topic} (try: ${Object.keys(manualpages).join(', ')})`, exitCode: 1 };
1951
+ }
1952
+ return { output: `${topic.toUpperCase()}(1)\n\n ${page}`, exitCode: 0 };
1953
+ }
1954
+ case 'clear':
1955
+ return { output: '', exitCode: 0, clear: true };
1956
+ default:
1957
+ return {
1958
+ output: `bash: ${head}: command not found (try "help")`,
1959
+ exitCode: 127,
1960
+ };
1961
+ }
1962
+ } catch (error) {
1963
+ return {
1964
+ output: `bash: ${head}: engine error: ${error instanceof Error ? error.message : String(error)}`,
1965
+ exitCode: 1,
1966
+ };
1967
+ }
1968
+ }
1969
+
1970
+ /**
1971
+ * renders the streaming plan for a workload larger than any host: the
1972
+ * demo the terminal shows for "how does a 1.5 tb model run here" - layers
1973
+ * stream through a fixed window, the workload size is unbounded.
1974
+ * @param {number} totalbytes workload size in bytes.
1975
+ * @param {number} windowbytes hot-set budget in bytes.
1976
+ * @returns {string} the plan summary lines.
1977
+ */
1978
+
1979
+ /**
1980
+ * renders the layered virtual memory architecture: everything is vram and
1981
+ * the only physical layer is the free pool (github, npm cdn and friends -
1982
+ * the saddle storage doctrine applied to this engine).
1983
+ * @returns {string} the tier summary lines.
1984
+ */
1985
+
1986
+ /**
1987
+ * renders the quantum layer summary: a classical statevector simulator
1988
+ * (1-20 qubits), the bb84/e91 key exchange, grover search, dna and 5d
1989
+ * optical storage planners - every "quantum" capability is software.
1990
+ * @returns {string} the quantum summary lines.
1991
+ */
1992
+ export function quantumdemo(): string {
1993
+ return [
1994
+ 'saddle quantum layer (100 percent classical simulation)',
1995
+ ' simulator statevector, float64 interleaved re/im, 1-20 qubits',
1996
+ ' gates h x y z s t rx ry rz cnot cz toffoli swap',
1997
+ ' algorithms bellstate, ghz, grover2 (provable), grover3, deutsch, teleport',
1998
+ ' protocols bb84 (1984) with intercept-resend detection, e91 (1991) + chsh',
1999
+ ' randomness quantum-inspired (csprng) with honest anu qrng fallback note',
2000
+ ' storage dna vault (goldman 2013, 215 pb/g, 4 strands, roundtrip proven)',
2001
+ ' 5d quartz planner (360 tb/disc, 13.8 billion years at room)',
2002
+ ' qram bucket-brigade model (giovannetti prl 2008)',
2003
+ ' engine: import { quantumsim, runbb84, dnavaultencode } from ./quantum',
2004
+ ].join('\n');
2005
+ }
2006
+
2007
+ export function tiersdemo(): string {
2008
+ const ladder = ['ram ~100ns', 'zram ~500ns', 'tmpfs ~1us', 'mmap ~5us', 'sqlite ~10us', 'r2 ~50us'];
2009
+ return [
2010
+ 'saddle tiered virtual memory (everything is vram)',
2011
+ ' l1 ram working set, ephemeral ~100 ns',
2012
+ ' l2 vram compute-bound identity layer spoofed',
2013
+ ' l3 storage repos as ram (sqlite kv, lru) ~10 us',
2014
+ ' l4 buckets hf 10tb + kaggle 20tb + terabox ~50 us',
2015
+ ' physical github artifacts + ghcr + npm cdn (the only disks)',
2016
+ ` ladder ${ladder.join(' | ')}`,
2017
+ ' autoscale <64mb memfs / <1gb mmap / larger sqlite+r2',
2018
+ ' pool >33 tb free across accounts (see tiers.ts FREEPOOL)',
2019
+ ' npm-as-disk 200 mb chunks as .bin.js packages on the cdn farm',
2020
+ ' vdr 64-bit bigint addressing up to 9.22 eb',
2021
+ ' engine: import { creatiersengine } from the package (./tiers)',
2022
+ ].join('\n');
2023
+ }
2024
+
2025
+ export function streamingdemo(
2026
+ totalbytes: number = 1649267441664,
2027
+ windowbytes: number = 4 * 1024 * 1024 * 1024,
2028
+ ): string {
2029
+ const layerbytes = 512 * 1024 * 1024;
2030
+ const count = Math.ceil(totalbytes / layerbytes);
2031
+ const batches = Math.ceil(count / Math.max(1, Math.floor(windowbytes / layerbytes)));
2032
+ const fmt = (b: number): string =>
2033
+ (b >= 1024 ** 4 ? (b / 1024 ** 4).toFixed(2) + ' TB' : (b / 1024 ** 3).toFixed(1) + ' GB');
2034
+ return [
2035
+ 'saddle streaming memory plan',
2036
+ ` workload: ${fmt(totalbytes)} decomposed into ${count} layers of ${layerbytes / 1024 ** 2} MiB`,
2037
+ ` window: ${fmt(windowbytes)} hot-set budget (peak resident)`,
2038
+ ` batches: ${batches} load/run/evict passes over the weights`,
2039
+ ' technique: mmap + page cache streams each layer on demand; eviction',
2040
+ ' after the last consumer keeps the resident set inside the',
2041
+ ' window - the workload size is unbounded, only the window',
2042
+ ' is fixed. this is how engines run models larger than ram.',
2043
+ ].join('\n');
2044
+ }
2045
+
2046
+ /* ------------------------------------------------------------------ */
2047
+ /* lifecycle: the cold/warm/hot sandbox state machine (wave 3) */
2048
+ /* ------------------------------------------------------------------ */
2049
+ /**
2050
+ * the sandbox lifecycle state machine of the family, distilled from the
2051
+ * foundry herd (wave 3, the engine ownership law: the foundry page model
2052
+ * re-implemented the machine as a local twin; the machine answers from
2053
+ * the saddle owner from this day on and the apps import it through the
2054
+ * published library specifier). the ladder mirrors the boot classes the
2055
+ * modality registry answers (hot, warm, cold) and the warm pool the
2056
+ * orchestrator keeps: cold is built and not booted, warm is booted and
2057
+ * keeping its working set, hot is executing work. zero logic change to
2058
+ * the engine lanes above - the section is additive and imports nothing,
2059
+ * so the browser-pure contract of this module stands.
2060
+ */
2061
+
2062
+ /** the three states of the sandbox lifecycle machine. */
2063
+ export type SandboxLifecycleState = 'cold' | 'warm' | 'hot';
2064
+
2065
+ /** the state ladder in machine order. */
2066
+ export const SANDBOX_LIFECYCLE_STATES: readonly SandboxLifecycleState[] = ['cold', 'warm', 'hot'];
2067
+
2068
+ /** the allowed transitions of the machine: cold boots to warm, warm
2069
+ * executes to hot, and both ride the cooling paths back. a cold sandbox
2070
+ * never executes; a hot one is already executing. */
2071
+ const LIFECYCLE_TRANSITIONS: Readonly<Record<SandboxLifecycleState, readonly SandboxLifecycleState[]>> = {
2072
+ cold: ['warm'],
2073
+ warm: ['hot', 'cold'],
2074
+ hot: ['warm', 'cold'],
2075
+ };
2076
+
2077
+ /** answers whether the machine allows one transition. */
2078
+ export function canTransition(from: SandboxLifecycleState, to: SandboxLifecycleState): boolean {
2079
+ return LIFECYCLE_TRANSITIONS[from].includes(to);
2080
+ }
2081
+
2082
+ /** the refusal the machine answers when a transition is not allowed -
2083
+ * honest strings, never a silent fallback. */
2084
+ export function refusalFor(from: SandboxLifecycleState, to: SandboxLifecycleState): string {
2085
+ if (from === to) return `the sandbox is already ${from}`;
2086
+ if (from === 'cold' && to === 'hot') return 'a cold sandbox cannot execute — warm it first';
2087
+ if (from === 'hot' && to === 'hot') return 'the sandbox is already hot';
2088
+ return `the machine never goes ${from} → ${to} directly`;
2089
+ }
2090
+
2091
+ /** the action names of the machine, one per transition. */
2092
+ export type SandboxLifecycleAction = 'warm' | 'heat' | 'cool' | 'freeze';
2093
+
2094
+ /** the guard target each action drives - the machine answers the
2095
+ * transition against this target (the landing state stays the caller's
2096
+ * projection: cool guards the cooling path yet may keep its working set
2097
+ * warm). */
2098
+ export const SANDBOX_LIFECYCLE_ACTION_TARGETS: Readonly<Record<SandboxLifecycleAction, SandboxLifecycleState>> = {
2099
+ warm: 'warm',
2100
+ heat: 'hot',
2101
+ cool: 'cold',
2102
+ freeze: 'cold',
2103
+ };
2104
+
2105
+ /* ===== block: sandboxenvprofile ===== */
2106
+ /**
2107
+ * sandboxenvprofile.ts — the configuration adapter of the sandbox spec
2108
+ * plane.
2109
+ *
2110
+ * the declarative spec (sandboxprofile.ts) never holds capacities: the
2111
+ * limits table is a parameter. this module is the adapter that turns
2112
+ * configuration into that parameter: it reads the SADDLE_SPEC_LIMITS
2113
+ * json environment variable ({ "bases": { ... } }), merges it over a
2114
+ * documented development bootstrap table (so a partial override stays
2115
+ * safe) and returns the effective limits table. production deployments
2116
+ * can equally hand the database sourced table straight to
2117
+ * resolvesandboxspec and skip this adapter entirely.
2118
+ *
2119
+ * the module is browser-pure: zero dom, zero node builtins; it imports
2120
+ * only the vocabulary and types of sandboxprofile.ts. the bootstrap table
2121
+ * is a documented convenience, never a product rule — every field it
2122
+ * carries is overridable.
2123
+ */
2124
+
2125
+
2126
+
2127
+
2128
+ /** documented development bootstrap table; production overrides it with
2129
+ * the SADDLE_SPEC_LIMITS json (or the database table). fields: maxvcpus/
2130
+ * maxramgb/maxvgpus/maxdiskgb/maxtimeoutseconds/defaultvcpus/
2131
+ * defaultramgb. */
2132
+ const bootstraplimits: sandboxspeclimits = {
2133
+ bases: {
2134
+ lite: {
2135
+ maxvcpus: 4,
2136
+ maxramgb: 8,
2137
+ maxvgpus: 0,
2138
+ maxdiskgb: 32,
2139
+ maxtimeoutseconds: 900,
2140
+ defaultvcpus: 2,
2141
+ defaultramgb: 4,
2142
+ },
2143
+ balanced: {
2144
+ maxvcpus: 16,
2145
+ maxramgb: 32,
2146
+ maxvgpus: 1,
2147
+ maxdiskgb: 128,
2148
+ maxtimeoutseconds: 3600,
2149
+ defaultvcpus: 8,
2150
+ defaultramgb: 16,
2151
+ },
2152
+ max: {
2153
+ maxvcpus: 64,
2154
+ maxramgb: 256,
2155
+ maxvgpus: 2,
2156
+ maxdiskgb: 512,
2157
+ maxtimeoutseconds: 14400,
2158
+ defaultvcpus: 32,
2159
+ defaultramgb: 64,
2160
+ },
2161
+ },
2162
+ defaulttimeoutseconds: 900,
2163
+ };
2164
+
2165
+ /**
2166
+ * reads the limits table from the SADDLE_SPEC_LIMITS environment json;
2167
+ * the bootstrap table fills every base the json does not define, so a
2168
+ * partial override stays safe. broken json falls back to the bootstrap
2169
+ * table (the spec plane never becomes unavailable over config drift).
2170
+ *
2171
+ * @param env the environment record (process.env shaped).
2172
+ * @returns the effective limits table.
2173
+ */
2174
+ export function specenvlimits(env: Record<string, string | undefined>): sandboxspeclimits {
2175
+ const raw = env.SADDLE_SPEC_LIMITS;
2176
+ if (raw === undefined || raw.trim() === '') {
2177
+ return bootstraplimits;
2178
+ }
2179
+ try {
2180
+ const parsed = JSON.parse(raw) as { bases?: Record<string, Partial<sandboxbaselimit>> };
2181
+ const bases = { ...bootstraplimits.bases };
2182
+ if (typeof parsed?.bases === 'object' && parsed.bases !== null) {
2183
+ for (const key of SANDBOXBASES) {
2184
+ const override = parsed.bases[key as sandboxbaseid];
2185
+ if (typeof override === 'object' && override !== null) {
2186
+ bases[key as sandboxbaseid] = {
2187
+ ...bootstraplimits.bases[key as sandboxbaseid],
2188
+ ...override,
2189
+ };
2190
+ }
2191
+ }
2192
+ }
2193
+ return { ...bootstraplimits, bases };
2194
+ } catch {
2195
+ return bootstraplimits;
2196
+ }
2197
+ }
2198
+
2199
+ /* ===== block: sandboxprofile ===== */
2200
+ /**
2201
+ * sandboxprofile.ts — the declarative sandbox specification of saddle.
2202
+ *
2203
+ * a saddle sandbox is declared, never assembled: one request names the
2204
+ * base (max, balanced or lite), the guest operating system (android,
2205
+ * grapheneos, linux, omarchy, windows), the cpu architecture (arm64 or
2206
+ * amd64), the runtime form (runtime-node, full-docker, published) and
2207
+ * the optional resource wishes (vcpus, ramgb, vgpu, diskgb, timeout,
2208
+ * networking). this module owns that vocabulary: it validates one
2209
+ * declared spec against a caller supplied limits table and resolves it
2210
+ * into concrete values the engine surface consumes.
2211
+ *
2212
+ * nothing about capacities lives in code: the limits table is a plain
2213
+ * parameter (config, environment or database). the environment adapter
2214
+ * lives in sandboxenvprofile.ts (SADDLE_SPEC_LIMITS json over a
2215
+ * documented bootstrap table).
2216
+ *
2217
+ * the module is browser-pure like sandbox.ts: zero imports, zero dom,
2218
+ * zero node builtins, deterministic verdicts. errors are typed and
2219
+ * traceable — every sandboxspecerror carries a machine readable code,
2220
+ * the offending field, the received value and the expectation missed.
2221
+ *
2222
+ * competitor sources shaping the contract: opensandbox-group/
2223
+ * opensandbox (declared timeoutSeconds bounded by the server side
2224
+ * max_sandbox_timeout_seconds; declared resources on the create
2225
+ * request) and daytonaio/daytona (per-sandbox cpu/memory/disk quota
2226
+ * fields with min=1 validation; gpu admission before create).
2227
+ *
2228
+ * wire points (kept honest): webserver.ts createsandbox validates the
2229
+ * optional base/os/arch/form fields here before building the engine
2230
+ * spec; sandbox.ts createSandboxState stays the hardware identity
2231
+ * layer and receives the resolved vcpus/ramgb untouched.
2232
+ */
2233
+
2234
+ /* ------------------------------------------------------------------ */
2235
+ /* types: the declared vocabulary */
2236
+ /* ------------------------------------------------------------------ */
2237
+
2238
+ /** the three runtime bases of the saddle fleet. */
2239
+ export type sandboxbaseid = 'max' | 'balanced' | 'lite';
2240
+
2241
+ /** the guest operating systems a sandbox can boot. */
2242
+ export type sandboxosid = 'android' | 'grapheneos' | 'linux' | 'omarchy' | 'windows';
2243
+
2244
+ /** the guest cpu architectures (amd64 == the x86_64 of the engine bank). */
2245
+ export type sandboxarchid = 'arm64' | 'amd64';
2246
+
2247
+ /** the runtime forms of a published sandbox. */
2248
+ export type sandboxformid = 'runtime-node' | 'full-docker' | 'published';
2249
+
2250
+ /** the networking modes of a sandbox. */
2251
+ export type sandboxnetworkid = 'nat' | 'bridged' | 'isolated';
2252
+
2253
+ /** the os family a guest belongs to (android builds on the linux kernel). */
2254
+ export type sandboxosfamily = 'android' | 'linux' | 'windows';
2255
+
2256
+ /** fixed product vocabulary; only the capacities are configuration. */
2257
+ export const SANDBOXBASES: readonly sandboxbaseid[] = ['max', 'balanced', 'lite'];
2258
+ export const SANDBOXOSES: readonly sandboxosid[] = [
2259
+ 'android',
2260
+ 'grapheneos',
2261
+ 'linux',
2262
+ 'omarchy',
2263
+ 'windows',
2264
+ ];
2265
+ export const SANDBOXARCHES: readonly sandboxarchid[] = ['arm64', 'amd64'];
2266
+ export const SANDBOXFORMS: readonly sandboxformid[] = ['runtime-node', 'full-docker', 'published'];
2267
+ export const SANDBOXNETWORKS: readonly sandboxnetworkid[] = ['nat', 'bridged', 'isolated'];
2268
+
2269
+ /* ------------------------------------------------------------------ */
2270
+ /* types: the limits table (pure configuration, never hardcoded) */
2271
+ /* ------------------------------------------------------------------ */
2272
+
2273
+ /** one base row of the limits table: ceilings and defaults per base. */
2274
+ export type sandboxbaselimit = {
2275
+ readonly maxvcpus: number;
2276
+ readonly maxramgb: number;
2277
+ readonly maxvgpus: number;
2278
+ readonly maxdiskgb: number;
2279
+ readonly maxtimeoutseconds: number;
2280
+ readonly defaultvcpus: number;
2281
+ readonly defaultramgb: number;
2282
+ };
2283
+
2284
+ /** the full limits table handed to the resolver by the caller. */
2285
+ export type sandboxspeclimits = {
2286
+ readonly bases: Readonly<Record<sandboxbaseid, sandboxbaselimit>>;
2287
+ readonly defaulttimeoutseconds?: number;
2288
+ };
2289
+
2290
+ /** the declared request shape (every field is optional or unknown). */
2291
+ export type sandboxspecinput = Record<string, unknown>;
2292
+
2293
+ /** the resolved spec: concrete values validated against the limits. */
2294
+ export type resolvedsandboxspec = {
2295
+ readonly base: sandboxbaseid;
2296
+ readonly os: sandboxosid;
2297
+ readonly arch: sandboxarchid;
2298
+ readonly form: sandboxformid;
2299
+ readonly networking: sandboxnetworkid;
2300
+ readonly family: sandboxosfamily;
2301
+ readonly vcpus: number;
2302
+ readonly ramgb: number;
2303
+ readonly vgpu: boolean;
2304
+ readonly diskgb: number | null;
2305
+ readonly timeoutseconds: number;
2306
+ };
2307
+
2308
+ /* ------------------------------------------------------------------ */
2309
+ /* errors: typed and traceable */
2310
+ /* ------------------------------------------------------------------ */
2311
+
2312
+ /** every failure code the resolver can produce. */
2313
+ export type sandboxspecerrorcode =
2314
+ | 'invalid-spec'
2315
+ | 'missing-base'
2316
+ | 'invalid-base'
2317
+ | 'invalid-os'
2318
+ | 'invalid-arch'
2319
+ | 'invalid-form'
2320
+ | 'invalid-networking'
2321
+ | 'invalid-vcpus'
2322
+ | 'invalid-ramgb'
2323
+ | 'invalid-vgpu'
2324
+ | 'invalid-diskgb'
2325
+ | 'invalid-timeout'
2326
+ | 'timeout-above-limit'
2327
+ | 'vgpu-not-available'
2328
+ | 'unknown-base-limits';
2329
+
2330
+ /** the typed spec failure: code, field, received value and expectation. */
2331
+ export class sandboxspecerror extends Error {
2332
+ /** machine readable failure code. */
2333
+ readonly code: sandboxspecerrorcode;
2334
+ /** the declared field that failed (or 'spec' for envelope failures). */
2335
+ readonly field: string;
2336
+ /** the received value, json-safe. */
2337
+ readonly value: unknown;
2338
+ /** the expectation the value missed. */
2339
+ readonly expected: string;
2340
+
2341
+ constructor(
2342
+ code: sandboxspecerrorcode,
2343
+ field: string,
2344
+ value: unknown,
2345
+ expected: string,
2346
+ message?: string,
2347
+ ) {
2348
+ super(message ?? `sandbox spec field "${field}" ${expected} (received ${show(value)})`);
2349
+ this.name = 'sandboxspecerror';
2350
+ this.code = code;
2351
+ this.field = field;
2352
+ this.value = value;
2353
+ this.expected = expected;
2354
+ }
2355
+ }
2356
+
2357
+ /** renders a json-safe preview of any received value for error messages. */
2358
+ function show(value: unknown): string {
2359
+ if (typeof value === 'string') {
2360
+ return JSON.stringify(value);
2361
+ }
2362
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
2363
+ return String(value);
2364
+ }
2365
+ if (value === undefined) {
2366
+ return 'undefined';
2367
+ }
2368
+ return Array.isArray(value) ? 'an array' : typeof value;
2369
+ }
2370
+
2371
+ /** picks the matching member of a fixed vocabulary; undefined otherwise. */
2372
+ function memberof<t extends string>(vocabulary: readonly t[], value: unknown): t | undefined {
2373
+ return typeof value === 'string'
2374
+ ? (vocabulary.find((item) => item === value) as t | undefined)
2375
+ : undefined;
2376
+ }
2377
+
2378
+ /** validates a positive integer wish; null when the shape is wrong. */
2379
+ function positiveinteger(value: unknown): number | null {
2380
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : null;
2381
+ }
2382
+
2383
+ /* ------------------------------------------------------------------ */
2384
+ /* the resolver */
2385
+ /* ------------------------------------------------------------------ */
2386
+
2387
+ /**
2388
+ * validates one declared spec against the limits table and resolves it
2389
+ * into concrete values. the verdict is a discriminated result so
2390
+ * callers surface the typed error without try/catch.
2391
+ *
2392
+ * @param input the declared spec (unknown json is tolerated).
2393
+ * @param limits the limits table (config, env or database sourced).
2394
+ * @returns the resolved spec or the typed, traceable error.
2395
+ */
2396
+ export function resolvesandboxspec(
2397
+ input: unknown,
2398
+ limits: sandboxspeclimits,
2399
+ ): { ok: true; value: resolvedsandboxspec } | { ok: false; error: sandboxspecerror } {
2400
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
2401
+ return {
2402
+ ok: false,
2403
+ error: new sandboxspecerror('invalid-spec', 'spec', input, 'a plain object'),
2404
+ };
2405
+ }
2406
+ const declared = input as sandboxspecinput;
2407
+ const base = memberof(SANDBOXBASES, declared.base);
2408
+ if (base === undefined) {
2409
+ return {
2410
+ ok: false,
2411
+ error:
2412
+ declared.base === undefined
2413
+ ? new sandboxspecerror(
2414
+ 'missing-base',
2415
+ 'base',
2416
+ undefined,
2417
+ `one of ${SANDBOXBASES.join(', ')}`,
2418
+ )
2419
+ : new sandboxspecerror(
2420
+ 'invalid-base',
2421
+ 'base',
2422
+ declared.base,
2423
+ `one of ${SANDBOXBASES.join(', ')}`,
2424
+ ),
2425
+ };
2426
+ }
2427
+ const row: sandboxbaselimit | undefined = limits.bases[base];
2428
+ if (row === undefined) {
2429
+ return {
2430
+ ok: false,
2431
+ error: new sandboxspecerror(
2432
+ 'unknown-base-limits',
2433
+ 'base',
2434
+ base,
2435
+ 'a base present in the limits table',
2436
+ ),
2437
+ };
2438
+ }
2439
+ // fixed vocabularies: a present but unknown member is a typed error.
2440
+ const words: Array<readonly [string, readonly string[], sandboxspecerrorcode]> = [
2441
+ ['os', SANDBOXOSES, 'invalid-os'],
2442
+ ['arch', SANDBOXARCHES, 'invalid-arch'],
2443
+ ['form', SANDBOXFORMS, 'invalid-form'],
2444
+ ['networking', SANDBOXNETWORKS, 'invalid-networking'],
2445
+ ];
2446
+ for (const [field, vocabulary, code] of words) {
2447
+ if (declared[field] !== undefined && !memberof(vocabulary, declared[field])) {
2448
+ return {
2449
+ ok: false,
2450
+ error: new sandboxspecerror(
2451
+ code,
2452
+ field,
2453
+ declared[field],
2454
+ `one of ${vocabulary.join(', ')}`,
2455
+ ),
2456
+ };
2457
+ }
2458
+ }
2459
+ // resource wishes: positive integers bounded by the base ceiling.
2460
+ const ceilings: Array<readonly [string, sandboxspecerrorcode, number]> = [
2461
+ ['vcpus', 'invalid-vcpus', row.maxvcpus],
2462
+ ['ramgb', 'invalid-ramgb', row.maxramgb],
2463
+ ['diskgb', 'invalid-diskgb', row.maxdiskgb],
2464
+ ];
2465
+ for (const [field, code, ceiling] of ceilings) {
2466
+ if (declared[field] === undefined) {
2467
+ continue;
2468
+ }
2469
+ const wish = positiveinteger(declared[field]);
2470
+ if (wish === null) {
2471
+ return {
2472
+ ok: false,
2473
+ error: new sandboxspecerror(code, field, declared[field], 'a positive integer'),
2474
+ };
2475
+ }
2476
+ if (wish > ceiling) {
2477
+ return {
2478
+ ok: false,
2479
+ error: new sandboxspecerror(
2480
+ code,
2481
+ field,
2482
+ declared[field],
2483
+ `an integer between 1 and ${ceiling} on the ${base} base`,
2484
+ ),
2485
+ };
2486
+ }
2487
+ }
2488
+ let vgpu = false;
2489
+ if (declared.vgpu !== undefined && typeof declared.vgpu !== 'boolean') {
2490
+ return {
2491
+ ok: false,
2492
+ error: new sandboxspecerror('invalid-vgpu', 'vgpu', declared.vgpu, 'a boolean'),
2493
+ };
2494
+ }
2495
+ vgpu = declared.vgpu === true;
2496
+ if (vgpu && row.maxvgpus < 1) {
2497
+ return {
2498
+ ok: false,
2499
+ error: new sandboxspecerror(
2500
+ 'vgpu-not-available',
2501
+ 'vgpu',
2502
+ true,
2503
+ `a base with vgpu slices (the ${base} base exposes none)`,
2504
+ ),
2505
+ };
2506
+ }
2507
+ const defaulttimeout = limits.defaulttimeoutseconds ?? 900;
2508
+ let timeoutseconds = defaulttimeout;
2509
+ if (declared.timeoutseconds !== undefined) {
2510
+ const wish = positiveinteger(declared.timeoutseconds);
2511
+ if (wish === null) {
2512
+ return {
2513
+ ok: false,
2514
+ error: new sandboxspecerror(
2515
+ 'invalid-timeout',
2516
+ 'timeoutseconds',
2517
+ declared.timeoutseconds,
2518
+ 'a positive integer of seconds',
2519
+ ),
2520
+ };
2521
+ }
2522
+ if (wish > row.maxtimeoutseconds) {
2523
+ return {
2524
+ ok: false,
2525
+ error: new sandboxspecerror(
2526
+ 'timeout-above-limit',
2527
+ 'timeoutseconds',
2528
+ declared.timeoutseconds,
2529
+ `at most ${row.maxtimeoutseconds} seconds on the ${base} base`,
2530
+ ),
2531
+ };
2532
+ }
2533
+ timeoutseconds = wish;
2534
+ }
2535
+ const os = (memberof(SANDBOXOSES, declared.os) ?? 'linux') as sandboxosid;
2536
+ return {
2537
+ ok: true,
2538
+ value: {
2539
+ base,
2540
+ os,
2541
+ arch: (memberof(SANDBOXARCHES, declared.arch) ?? 'amd64') as sandboxarchid,
2542
+ form: (memberof(SANDBOXFORMS, declared.form) ?? 'runtime-node') as sandboxformid,
2543
+ networking: (memberof(SANDBOXNETWORKS, declared.networking) ?? 'nat') as sandboxnetworkid,
2544
+ family: osfamily(os),
2545
+ vcpus: (declared.vcpus as number | undefined) ?? row.defaultvcpus,
2546
+ ramgb: (declared.ramgb as number | undefined) ?? row.defaultramgb,
2547
+ vgpu,
2548
+ diskgb: (declared.diskgb as number | undefined) ?? null,
2549
+ timeoutseconds,
2550
+ },
2551
+ };
2552
+ }
2553
+
2554
+ /* ------------------------------------------------------------------ */
2555
+ /* helpers and bridges */
2556
+ /* ------------------------------------------------------------------ */
2557
+
2558
+ /** maps a guest os onto its kernel family (grapheneos builds on aosp). */
2559
+ export function osfamily(os: sandboxosid): sandboxosfamily {
2560
+ if (os === 'android' || os === 'grapheneos') {
2561
+ return 'android';
2562
+ }
2563
+ return os === 'windows' ? 'windows' : 'linux';
2564
+ }
2565
+
2566
+ /** the structural shape consumed by sandbox.ts createSandboxState. */
2567
+ export type sandboxenginespec = {
2568
+ readonly model: string;
2569
+ readonly vcpus: number;
2570
+ readonly ramgb: number;
2571
+ readonly gpu: string;
2572
+ readonly mig: string;
2573
+ };
2574
+
2575
+ /**
2576
+ * bridges a resolved spec onto the engine surface: vcpus and ramgb come
2577
+ * from the resolved spec, the cpu model and gpu identity stay caller
2578
+ * choices (the reviewed catalog of sandbox.ts remains the only source
2579
+ * of processor and gpu identity).
2580
+ *
2581
+ * @param resolved the resolved spec produced by resolvesandboxspec.
2582
+ * @param options the caller picked engine identities (model, gpu, mig).
2583
+ * @returns the engine spec object.
2584
+ */
2585
+ export function enginespecfor(
2586
+ resolved: resolvedsandboxspec,
2587
+ options?: { model?: string; gpu?: string; mig?: string },
2588
+ ): sandboxenginespec {
2589
+ return {
2590
+ model: options?.model ?? '',
2591
+ vcpus: resolved.vcpus,
2592
+ ramgb: resolved.ramgb,
2593
+ gpu: resolved.vgpu ? (options?.gpu ?? '') : '',
2594
+ mig: options?.mig ?? 'off',
2595
+ };
2596
+ }
2597
+
2598
+ /* ===== block: sandboxqueue ===== */
2599
+ /**
2600
+ * sandboxqueue.ts — the sandbox lifecycle queue of saddle.
2601
+ *
2602
+ * one sandbox walks a single linear machine: creating -> starting ->
2603
+ * running -> stopping -> stopped, with the supervision states cancelled,
2604
+ * failed and timedout closing the paths out. every intermediate state
2605
+ * carries a per-state time budget; the health-check tick reaps entries
2606
+ * whose budget expired (the ttl reaper pattern of webserver.ts and the
2607
+ * pool reaper of opensandbox). transitions are validated by a pure
2608
+ * transition table so both callers and tests exercise the same guard.
2609
+ *
2610
+ * the module is deliberately pure: no timers of its own, no dom, no node
2611
+ * builtins, an injectable clock. hosts drive it — webserver.ts ramps
2612
+ * the api lifecycle with a timeout, the ttl reaper sweeps every minute,
2613
+ * and orchestrator.ts owns the fifteen state vm plane machine; this
2614
+ * module is the queue-level complement those surfaces can delegate to
2615
+ * (wire point documented in the webserver statemachine context: the
2616
+ * api states map as created->creating, expired->timedout,
2617
+ * destroyed->stopped).
2618
+ *
2619
+ * studied competitors shaping the contract: daytonaio/daytona (sandbox
2620
+ * states creating/starting/started/stopping/stopped/error with the
2621
+ * -ing intermediates) and opensandbox-group/opensandbox (Creating ->
2622
+ * Running -> Deleting -> Deleted with 409 on state conflicting
2623
+ * operations and a pool acquisition that fails fast with Retry-After).
2624
+ */
2625
+
2626
+ /* ------------------------------------------------------------------ */
2627
+ /* types: phases, transitions, entries */
2628
+ /* ------------------------------------------------------------------ */
2629
+
2630
+ /** every phase of the sandbox lifecycle queue. */
2631
+ export type sandboxphase =
2632
+ | 'creating'
2633
+ | 'starting'
2634
+ | 'running'
2635
+ | 'stopping'
2636
+ | 'stopped'
2637
+ | 'cancelled'
2638
+ | 'failed'
2639
+ | 'timedout';
2640
+
2641
+ /** phases that end the queue entry (no further transition is legal). */
2642
+ export const SANDBOXTERMINALPHASES: readonly sandboxphase[] = [
2643
+ 'stopped',
2644
+ 'cancelled',
2645
+ 'failed',
2646
+ 'timedout',
2647
+ ];
2648
+
2649
+ /** the legal transitions of the single lifecycle machine. */
2650
+ const sandboxtransitions: Record<sandboxphase, readonly sandboxphase[]> = {
2651
+ creating: ['starting', 'cancelled', 'failed', 'timedout'],
2652
+ starting: ['running', 'cancelled', 'failed', 'timedout'],
2653
+ running: ['stopping', 'cancelled', 'failed', 'timedout'],
2654
+ stopping: ['stopped', 'failed', 'timedout'],
2655
+ stopped: [],
2656
+ cancelled: [],
2657
+ failed: [],
2658
+ timedout: [],
2659
+ };
2660
+
2661
+ /** per-state time budgets in milliseconds; a state without a budget
2662
+ * never times out (running typically relies on the host ttl instead). */
2663
+ export type sandboxphasebudgets = {
2664
+ readonly creating?: number;
2665
+ readonly starting?: number;
2666
+ readonly running?: number;
2667
+ readonly stopping?: number;
2668
+ };
2669
+
2670
+ /** one queue entry: identity, phase, bookkeeping. */
2671
+ export type sandboxqueueentry = {
2672
+ readonly id: string;
2673
+ state: sandboxphase;
2674
+ /** epoch ms of the last phase change. */
2675
+ enteredat: number;
2676
+ /** epoch ms the current phase budget expires; null when unbounded. */
2677
+ deadline: number | null;
2678
+ };
2679
+
2680
+ /** one recorded lifecycle event (bounded ring, newest last). */
2681
+ export type sandboxqueueevent = {
2682
+ readonly id: string;
2683
+ readonly from: sandboxphase;
2684
+ readonly to: sandboxphase;
2685
+ readonly at: number;
2686
+ readonly reason: 'requested' | 'timeout' | 'cancel';
2687
+ };
2688
+
2689
+ /** failure codes carried by sandboxqueueerror. */
2690
+ export type sandboxqueueerrorcode =
2691
+ | 'unknown-sandbox'
2692
+ | 'duplicate-sandbox'
2693
+ | 'invalid-transition'
2694
+ | 'terminal-phase';
2695
+
2696
+ /** the typed queue failure, traceable to the entry and both phases. */
2697
+ export class sandboxqueueerror extends Error {
2698
+ /** machine readable failure code. */
2699
+ readonly code: sandboxqueueerrorcode;
2700
+ /** the entry the failure belongs to. */
2701
+ readonly id: string;
2702
+ /** the phase the entry was in (or from), when known. */
2703
+ readonly from: sandboxphase | null;
2704
+ /** the requested target phase, when known. */
2705
+ readonly to: sandboxphase | null;
2706
+
2707
+ constructor(
2708
+ code: sandboxqueueerrorcode,
2709
+ id: string,
2710
+ from: sandboxphase | null,
2711
+ to: sandboxphase | null,
2712
+ message?: string,
2713
+ ) {
2714
+ super(
2715
+ message ??
2716
+ `sandbox queue ${code} for ${id}${from === null ? '' : ` (from ${from})`}${to === null ? '' : ` to ${to}`}`,
2717
+ );
2718
+ this.name = 'sandboxqueueerror';
2719
+ this.code = code;
2720
+ this.id = id;
2721
+ this.from = from;
2722
+ this.to = to;
2723
+ }
2724
+ }
2725
+
2726
+ /* ------------------------------------------------------------------ */
2727
+ /* pure guards */
2728
+ /* ------------------------------------------------------------------ */
2729
+
2730
+ /** checks whether a phase change is legal on the single machine. */
2731
+ export function cansandboxstep(from: sandboxphase, to: sandboxphase): boolean {
2732
+ return sandboxtransitions[from].includes(to);
2733
+ }
2734
+
2735
+ /** reports whether a phase is terminal. */
2736
+ export function issandboxterminal(phase: sandboxphase): boolean {
2737
+ return SANDBOXTERMINALPHASES.includes(phase);
2738
+ }
2739
+
2740
+ /* ------------------------------------------------------------------ */
2741
+ /* the queue */
2742
+ /* ------------------------------------------------------------------ */
2743
+
2744
+ /** options of createsandboxqueue (all optional, sane defaults). */
2745
+ export type sandboxqueueoptions = {
2746
+ /** per-state budgets; the defaults mirror the 125 ms api ramp. */
2747
+ readonly budgets?: sandboxphasebudgets;
2748
+ /** event ring capacity (default 100). */
2749
+ readonly eventcap?: number;
2750
+ };
2751
+
2752
+ /** a read-only status view answered by status(). */
2753
+ export type sandboxqueuestatus = {
2754
+ readonly id: string;
2755
+ readonly state: sandboxphase;
2756
+ readonly enteredat: number;
2757
+ readonly deadline: number | null;
2758
+ readonly age: number;
2759
+ readonly terminal: boolean;
2760
+ };
2761
+
2762
+ /**
2763
+ * creates the lifecycle queue. the queue validates every transition,
2764
+ * stamps deadlines from the budgets and reaps expired entries on the
2765
+ * health-check tick; the host decides when to tick.
2766
+ *
2767
+ * @param options the budgets and event ring capacity.
2768
+ * @returns the queue facade (add, step, cancel, healthcheck, status,
2769
+ * list, events, size).
2770
+ */
2771
+ export function createsandboxqueue(options?: sandboxqueueoptions): {
2772
+ add: (id: string, now?: number) => sandboxqueueentry;
2773
+ step: (id: string, to: sandboxphase, now?: number) => sandboxqueueentry;
2774
+ cancel: (id: string, now?: number) => sandboxqueueentry;
2775
+ healthcheck: (now?: number) => string[];
2776
+ status: (id: string, now?: number) => sandboxqueuestatus;
2777
+ list: (now?: number) => sandboxqueuestatus[];
2778
+ events: () => sandboxqueueevent[];
2779
+ size: () => number;
2780
+ } {
2781
+ const budgets: sandboxphasebudgets = options?.budgets ?? {
2782
+ creating: 125,
2783
+ starting: 125,
2784
+ stopping: 30000,
2785
+ };
2786
+ const eventcap = options?.eventcap ?? 100;
2787
+ const entries = new Map<string, sandboxqueueentry>();
2788
+ const ring: sandboxqueueevent[] = [];
2789
+
2790
+ /** stamps one event into the bounded ring. */
2791
+ function record(
2792
+ id: string,
2793
+ from: sandboxphase,
2794
+ to: sandboxphase,
2795
+ at: number,
2796
+ reason: sandboxqueueevent['reason'],
2797
+ ): void {
2798
+ ring.push({ id, from, to, at, reason });
2799
+ if (ring.length > eventcap) {
2800
+ ring.splice(0, ring.length - eventcap);
2801
+ }
2802
+ }
2803
+
2804
+ /** resolves the deadline of a phase from the budget table. */
2805
+ function deadlinefor(state: sandboxphase, now: number): number | null {
2806
+ const budget = budgets[state as keyof sandboxphasebudgets];
2807
+ return typeof budget === 'number' && budget > 0 ? now + budget : null;
2808
+ }
2809
+
2810
+ function statusOf(entry: sandboxqueueentry, now: number): sandboxqueuestatus {
2811
+ return {
2812
+ id: entry.id,
2813
+ state: entry.state,
2814
+ enteredat: entry.enteredat,
2815
+ deadline: entry.deadline,
2816
+ age: Math.max(0, now - entry.enteredat),
2817
+ terminal: issandboxterminal(entry.state),
2818
+ };
2819
+ }
2820
+
2821
+ return {
2822
+ add(id, now = Date.now()) {
2823
+ if (entries.has(id)) {
2824
+ throw new sandboxqueueerror('duplicate-sandbox', id, null, null);
2825
+ }
2826
+ const entry: sandboxqueueentry = {
2827
+ id,
2828
+ state: 'creating',
2829
+ enteredat: now,
2830
+ deadline: deadlinefor('creating', now),
2831
+ };
2832
+ entries.set(id, entry);
2833
+ record(id, 'creating', 'creating', now, 'requested');
2834
+ return entry;
2835
+ },
2836
+
2837
+ step(id, to, now = Date.now()) {
2838
+ const entry = entries.get(id);
2839
+ if (entry === undefined) {
2840
+ throw new sandboxqueueerror('unknown-sandbox', id, null, to);
2841
+ }
2842
+ if (!cansandboxstep(entry.state, to)) {
2843
+ if (issandboxterminal(entry.state)) {
2844
+ throw new sandboxqueueerror(
2845
+ 'terminal-phase',
2846
+ id,
2847
+ entry.state,
2848
+ to,
2849
+ `sandbox ${id} is terminal (${entry.state}); ${to} is unreachable`,
2850
+ );
2851
+ }
2852
+ throw new sandboxqueueerror(
2853
+ 'invalid-transition',
2854
+ id,
2855
+ entry.state,
2856
+ to,
2857
+ `sandbox ${id} cannot transition ${entry.state} -> ${to}`,
2858
+ );
2859
+ }
2860
+ const from = entry.state;
2861
+ entry.state = to;
2862
+ entry.enteredat = now;
2863
+ entry.deadline = deadlinefor(to, now);
2864
+ record(id, from, to, now, 'requested');
2865
+ return entry;
2866
+ },
2867
+
2868
+ cancel(id, now = Date.now()) {
2869
+ const entry = entries.get(id);
2870
+ if (entry === undefined) {
2871
+ throw new sandboxqueueerror('unknown-sandbox', id, null, 'cancelled');
2872
+ }
2873
+ const from = entry.state;
2874
+ if (!cansandboxstep(from, 'cancelled')) {
2875
+ if (issandboxterminal(from)) {
2876
+ throw new sandboxqueueerror(
2877
+ 'terminal-phase',
2878
+ id,
2879
+ from,
2880
+ 'cancelled',
2881
+ `sandbox ${id} is terminal (${from}); cancellation is unreachable`,
2882
+ );
2883
+ }
2884
+ throw new sandboxqueueerror(
2885
+ 'invalid-transition',
2886
+ id,
2887
+ from,
2888
+ 'cancelled',
2889
+ `sandbox ${id} cannot be cancelled from ${from}`,
2890
+ );
2891
+ }
2892
+ entry.state = 'cancelled';
2893
+ entry.enteredat = now;
2894
+ entry.deadline = null;
2895
+ record(id, from, 'cancelled', now, 'cancel');
2896
+ return entry;
2897
+ },
2898
+
2899
+ healthcheck(now = Date.now()) {
2900
+ const reaped: string[] = [];
2901
+ for (const entry of entries.values()) {
2902
+ if (entry.deadline === null || entry.deadline > now || issandboxterminal(entry.state)) {
2903
+ continue;
2904
+ }
2905
+ const from = entry.state;
2906
+ entry.state = 'timedout';
2907
+ entry.enteredat = now;
2908
+ entry.deadline = null;
2909
+ record(entry.id, from, 'timedout', now, 'timeout');
2910
+ reaped.push(entry.id);
2911
+ }
2912
+ return reaped;
2913
+ },
2914
+
2915
+ status(id, now = Date.now()) {
2916
+ const entry = entries.get(id);
2917
+ if (entry === undefined) {
2918
+ throw new sandboxqueueerror('unknown-sandbox', id, null, null);
2919
+ }
2920
+ return statusOf(entry, now);
2921
+ },
2922
+
2923
+ list(now = Date.now()) {
2924
+ return [...entries.values()]
2925
+ .map((entry) => statusOf(entry, now))
2926
+ .sort((a, b) => (a.id < b.id ? -1 : 1));
2927
+ },
2928
+
2929
+ events() {
2930
+ return [...ring];
2931
+ },
2932
+
2933
+ size() {
2934
+ return entries.size;
2935
+ },
2936
+ };
2937
+ }
2938
+
2939
+ /* ===== block: sandboxquota ===== */
2940
+ /**
2941
+ * sandboxquota.ts — the per-account resource quota ledger of saddle.
2942
+ *
2943
+ * every account holds counters of the resources its live sandboxes
2944
+ * allocate (sandbox count, vcpus, ramgb, vgpus, diskgb) against a
2945
+ * limits table. the limits are a plain parameter — the operator's plan
2946
+ * table comes from config or the database and never lives in code; an
2947
+ * absent limit means unlimited, mirroring the SADDLE_MAX_SANDBOXES
2948
+ * convention of webserver.ts (0 or unset means unlimited).
2949
+ *
2950
+ * the ledger keeps reservations and releases in balance with a simple
2951
+ * reconciliation: the host projects the authoritative usage (for
2952
+ * example from the sqlite sandboxes rows) and reconcile() overwrites
2953
+ * the in-memory counters with it, so drift from admin deletes, restarts
2954
+ * or crashes self-heals on the next create.
2955
+ *
2956
+ * the module is pure: zero imports, zero dom, zero node builtins, a
2957
+ * Map per account, typed traceable errors (quotaexceedederror carries
2958
+ * the resource, the request, the limit and the remaining headroom).
2959
+ *
2960
+ * competitor sources shaping the contract: daytonaio/daytona (tenant
2961
+ * resource quota admission with per-sandbox cpu/memory/gpu quota
2962
+ * fields and the gpu allocator acquire/release pair around create) and
2963
+ * opensandbox-group/opensandbox (namespace ResourceQuota admission —
2964
+ * the sandbox is not admitted when the quota is exhausted — and the
2965
+ * pool acquisition that fails fast when capacity stays unavailable).
2966
+ *
2967
+ * wire point: webserver.ts createsandbox reconciles the owning
2968
+ * account from store.listsandboxesbyuser rows and reserves the new
2969
+ * sandbox; destroysandbox releases it. limits come from
2970
+ * accountlimitsfromenv(process.env) and stay unlimited when unset.
2971
+ */
2972
+
2973
+ /* ------------------------------------------------------------------ */
2974
+ /* types: usage, limits, errors */
2975
+ /* ------------------------------------------------------------------ */
2976
+
2977
+ /** the resource counters one account allocates through live sandboxes. */
2978
+ export type accountusage = {
2979
+ sandboxes: number;
2980
+ vcpus: number;
2981
+ ramgb: number;
2982
+ vgpus: number;
2983
+ diskgb: number;
2984
+ };
2985
+
2986
+ /** the per-account limits; an absent or non-positive field is unlimited. */
2987
+ export type accountlimits = {
2988
+ readonly sandboxes?: number;
2989
+ readonly vcpus?: number;
2990
+ readonly ramgb?: number;
2991
+ readonly vgpus?: number;
2992
+ readonly diskgb?: number;
2993
+ };
2994
+
2995
+ /** one resource wish (a partial usage delta). */
2996
+ export type resourcewish = Partial<accountusage>;
2997
+
2998
+ /** the resource names tracked by the ledger. */
2999
+ const RESOURCES: readonly (keyof accountusage)[] = [
3000
+ 'sandboxes',
3001
+ 'vcpus',
3002
+ 'ramgb',
3003
+ 'vgpus',
3004
+ 'diskgb',
3005
+ ];
3006
+
3007
+ /** failure codes carried by quotaerror. */
3008
+ export type quotaerrorcode = 'invalid-wish' | 'quota-exceeded';
3009
+
3010
+ /** the typed quota failure, traceable to the account and the resource. */
3011
+ export class quotaerror extends Error {
3012
+ /** machine readable failure code. */
3013
+ readonly code: quotaerrorcode;
3014
+ /** the account the failure belongs to. */
3015
+ readonly accountid: string;
3016
+ /** the resource that failed (or 'wish' for malformed deltas). */
3017
+ readonly resource: string;
3018
+ /** the requested amount (null when the wish itself was malformed). */
3019
+ readonly requested: number | null;
3020
+ /** the active limit (null when unlimited). */
3021
+ readonly limit: number | null;
3022
+ /** the headroom the account had before the request. */
3023
+ readonly available: number | null;
3024
+
3025
+ constructor(
3026
+ code: quotaerrorcode,
3027
+ accountid: string,
3028
+ resource: string,
3029
+ requested: number | null,
3030
+ limit: number | null,
3031
+ available: number | null,
3032
+ message?: string,
3033
+ ) {
3034
+ super(
3035
+ message ??
3036
+ `account ${accountid} ${code} on ${resource}: requested ${requested ?? '?'} of ${limit ?? 'unlimited'} (${available ?? '?'} available)`,
3037
+ );
3038
+ this.name = 'quotaerror';
3039
+ this.code = code;
3040
+ this.accountid = accountid;
3041
+ this.resource = resource;
3042
+ this.requested = requested;
3043
+ this.limit = limit;
3044
+ this.available = available;
3045
+ }
3046
+ }
3047
+
3048
+ /* ------------------------------------------------------------------ */
3049
+ /* helpers */
3050
+ /* ------------------------------------------------------------------ */
3051
+
3052
+ /** validates one resource wish: every present field is a positive number. */
3053
+ function sanitize(wish: resourcewish): Partial<accountusage> {
3054
+ const clean: Partial<accountusage> = {};
3055
+ for (const resource of RESOURCES) {
3056
+ const value = wish[resource];
3057
+ if (value === undefined) {
3058
+ continue;
3059
+ }
3060
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
3061
+ throw new quotaerror(
3062
+ 'invalid-wish',
3063
+ '',
3064
+ resource,
3065
+ typeof value === 'number' ? value : null,
3066
+ null,
3067
+ null,
3068
+ `resource ${resource} must be a positive finite number (received ${String(value)})`,
3069
+ );
3070
+ }
3071
+ clean[resource] = value;
3072
+ }
3073
+ return clean;
3074
+ }
3075
+
3076
+ /** headroom of one resource under its limit (null limit means Infinity). */
3077
+ function headroom(limit: number | undefined, used: number): number | null {
3078
+ return typeof limit === 'number' && limit > 0 ? Math.max(0, limit - used) : null;
3079
+ }
3080
+
3081
+ /* ------------------------------------------------------------------ */
3082
+ /* the ledger */
3083
+ /* ------------------------------------------------------------------ */
3084
+
3085
+ /** a read-only snapshot answered by usage(). */
3086
+ export type accountquotasnapshot = {
3087
+ readonly accountid: string;
3088
+ readonly used: Readonly<accountusage>;
3089
+ readonly remaining: Readonly<Record<keyof accountusage, number | null>>;
3090
+ };
3091
+
3092
+ /**
3093
+ * creates the per-account ledger with one limits table for every
3094
+ * account (per-plan tables ride on the caller passing the plan row of
3095
+ * the account).
3096
+ *
3097
+ * @param limits the limits table (absent fields are unlimited).
3098
+ * @returns the ledger facade (admits, reserve, release, usage,
3099
+ * reconcile, reset).
3100
+ */
3101
+ export function createaccountledger(limits: accountlimits): {
3102
+ admits: (accountid: string, wish: resourcewish) => boolean;
3103
+ reserve: (accountid: string, wish: resourcewish) => accountquotasnapshot;
3104
+ release: (accountid: string, wish: resourcewish) => accountquotasnapshot;
3105
+ usage: (accountid: string) => accountquotasnapshot;
3106
+ reconcile: (
3107
+ accountid: string,
3108
+ observed: accountusage,
3109
+ ) => { before: accountquotasnapshot; after: accountquotasnapshot };
3110
+ reset: (accountid?: string) => void;
3111
+ } {
3112
+ const books = new Map<string, accountusage>();
3113
+
3114
+ /** zeroes the counters of an unseen account. */
3115
+ function blank(accountid: string): accountusage {
3116
+ const existing = books.get(accountid);
3117
+ if (existing !== undefined) {
3118
+ return existing;
3119
+ }
3120
+ const fresh: accountusage = { sandboxes: 0, vcpus: 0, ramgb: 0, vgpus: 0, diskgb: 0 };
3121
+ books.set(accountid, fresh);
3122
+ return fresh;
3123
+ }
3124
+
3125
+ function snapshotOf(accountid: string, used: accountusage): accountquotasnapshot {
3126
+ const remaining = {} as Record<keyof accountusage, number | null>;
3127
+ for (const resource of RESOURCES) {
3128
+ remaining[resource] = headroom(limits[resource], used[resource]);
3129
+ }
3130
+ return { accountid, used: { ...used }, remaining };
3131
+ }
3132
+
3133
+ /** the shared admit-or-throw walk used by admits and reserve. */
3134
+ function check(accountid: string, wish: resourcewish): Partial<accountusage> {
3135
+ const clean = sanitize(wish);
3136
+ const used = blank(accountid);
3137
+ for (const resource of RESOURCES) {
3138
+ const request = clean[resource];
3139
+ if (request === undefined) {
3140
+ continue;
3141
+ }
3142
+ const room = headroom(limits[resource], used[resource]);
3143
+ if (room !== null && request > room) {
3144
+ throw new quotaerror(
3145
+ 'quota-exceeded',
3146
+ accountid,
3147
+ resource,
3148
+ request,
3149
+ limits[resource] ?? null,
3150
+ room,
3151
+ );
3152
+ }
3153
+ }
3154
+ return clean;
3155
+ }
3156
+
3157
+ return {
3158
+ admits(accountid, wish) {
3159
+ try {
3160
+ check(accountid, wish);
3161
+ return true;
3162
+ } catch (error) {
3163
+ if (error instanceof quotaerror) {
3164
+ return false;
3165
+ }
3166
+ throw error;
3167
+ }
3168
+ },
3169
+
3170
+ reserve(accountid, wish) {
3171
+ const clean = check(accountid, wish);
3172
+ const used = blank(accountid);
3173
+ for (const resource of RESOURCES) {
3174
+ const request = clean[resource];
3175
+ if (request !== undefined) {
3176
+ used[resource] += request;
3177
+ }
3178
+ }
3179
+ return snapshotOf(accountid, used);
3180
+ },
3181
+
3182
+ release(accountid, wish) {
3183
+ const clean = sanitize(wish);
3184
+ const used = blank(accountid);
3185
+ for (const resource of RESOURCES) {
3186
+ const request = clean[resource];
3187
+ if (request !== undefined) {
3188
+ used[resource] = Math.max(0, used[resource] - request);
3189
+ }
3190
+ }
3191
+ return snapshotOf(accountid, used);
3192
+ },
3193
+
3194
+ usage(accountid) {
3195
+ return snapshotOf(accountid, blank(accountid));
3196
+ },
3197
+
3198
+ reconcile(accountid, observed) {
3199
+ const used = blank(accountid);
3200
+ const before = snapshotOf(accountid, used);
3201
+ for (const resource of RESOURCES) {
3202
+ const value = observed[resource];
3203
+ used[resource] =
3204
+ typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
3205
+ }
3206
+ const after = snapshotOf(accountid, used);
3207
+ return { before, after };
3208
+ },
3209
+
3210
+ reset(accountid) {
3211
+ if (accountid === undefined) {
3212
+ books.clear();
3213
+ return;
3214
+ }
3215
+ books.delete(accountid);
3216
+ },
3217
+ };
3218
+ }
3219
+
3220
+ /* ------------------------------------------------------------------ */
3221
+ /* projection: rows to usage */
3222
+ /* ------------------------------------------------------------------ */
3223
+
3224
+ /** the structural row shape the projection reads (SandboxRow of db.ts
3225
+ * satisfies it; kept structural so the module stays import-free). */
3226
+ export type sandboxrowlike = {
3227
+ readonly vcpus: number | null;
3228
+ readonly ramgb: number | null;
3229
+ readonly state: string | null;
3230
+ };
3231
+
3232
+ /**
3233
+ * projects sandbox rows onto one account usage: every row that is not
3234
+ * destroyed counts as a live allocation (the same semantics the
3235
+ * SADDLE_MAX_SANDBOXES cap check of webserver.ts uses).
3236
+ *
3237
+ * @param rows the authoritative rows of one account.
3238
+ * @returns the usage counters the rows allocate.
3239
+ */
3240
+ export function usagefromrows(rows: readonly sandboxrowlike[]): accountusage {
3241
+ const usage: accountusage = { sandboxes: 0, vcpus: 0, ramgb: 0, vgpus: 0, diskgb: 0 };
3242
+ for (const row of rows) {
3243
+ if (row.state === 'destroyed') {
3244
+ continue;
3245
+ }
3246
+ usage.sandboxes += 1;
3247
+ usage.vcpus += typeof row.vcpus === 'number' ? row.vcpus : 0;
3248
+ usage.ramgb += typeof row.ramgb === 'number' ? row.ramgb : 0;
3249
+ }
3250
+ return usage;
3251
+ }
3252
+
3253
+ /* ------------------------------------------------------------------ */
3254
+ /* adapter: limits from the environment */
3255
+ /* ------------------------------------------------------------------ */
3256
+
3257
+ /**
3258
+ * reads the per-account limits from the environment: SADDLE_MAX_SANDBOXES
3259
+ * (the existing count cap of webserver.ts), SADDLE_ACCOUNT_MAX_VCPUS,
3260
+ * SADDLE_ACCOUNT_MAX_RAM_GB, SADDLE_ACCOUNT_MAX_VGPUS and
3261
+ * SADDLE_ACCOUNT_MAX_DISK_GB. unset, zero and negative values mean
3262
+ * unlimited, so the default deployment enforces nothing.
3263
+ *
3264
+ * @param env the environment record (process.env shaped).
3265
+ * @returns the limits table.
3266
+ */
3267
+ export function accountlimitsfromenv(env: Record<string, string | undefined>): accountlimits {
3268
+ const positive = (name: string): number | undefined => {
3269
+ const parsed = Number(env[name]);
3270
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined;
3271
+ };
3272
+ return {
3273
+ sandboxes: positive('SADDLE_MAX_SANDBOXES'),
3274
+ vcpus: positive('SADDLE_ACCOUNT_MAX_VCPUS'),
3275
+ ramgb: positive('SADDLE_ACCOUNT_MAX_RAM_GB'),
3276
+ vgpus: positive('SADDLE_ACCOUNT_MAX_VGPUS'),
3277
+ diskgb: positive('SADDLE_ACCOUNT_MAX_DISK_GB'),
3278
+ };
3279
+ }