@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.
- package/Dockerfile +167 -1334
- package/README.md +59 -60
- package/biome.jsonc +3 -3
- package/browser.ts +482 -0
- package/cli.ts +274 -0
- package/distribution.ts +731 -0
- package/docker.config +29 -8
- package/docs/{architecture.1.8.19.mmd → architecture1819.md} +6 -0
- package/docs/artifactavailability.md +2 -2
- package/docs/browser.md +18 -1
- package/docs/{containerplatforms.2.1.0.md → containerplatforms210.md} +1 -1
- package/docs/{documentmap.1.8.12.md → documentmap1812.md} +1 -1
- package/docs/extension.md +13 -2
- package/docs/features.md +5 -0
- package/docs/libraryapi.md +1 -1
- package/docs/{ninety.modalities.md → ninetymodalities.md} +87 -4
- package/docs/{planning.ninety.md → planningninety.md} +6 -6
- package/docs/{releasenotes.1.8.13.md → releasenotes1813.md} +2 -2
- package/docs/{releasenotes.1.8.14.md → releasenotes1814.md} +1 -1
- package/docs/{repo.synthesis.1.8.16.md → reposynthesis1816.md} +1 -1
- package/docs/todo.md +2 -2
- package/docs/{todo.1.8.15.md → todo1815.md} +1 -1
- package/docs/{todo.1.8.16.md → todo1816.md} +5 -5
- package/execution.ts +677 -0
- package/foundation.ts +314 -0
- package/index.ts +36 -34
- package/isolation.ts +84 -0
- package/modalityregistry.ts +189 -145
- package/modes.ts +170 -0
- package/mttg.config +1 -1
- package/orchestrator.ts +49 -15
- package/package.json +105 -89
- package/qemu.config +2 -2
- package/sandbox.ts +3279 -0
- package/scheduler.ts +1 -1
- package/server.ts +52 -0
- package/tsconfig.json +12 -13
- package/virtual.ts +7528 -0
- package/workloadlaw.ts +293 -0
- package/dist/App.d.ts +0 -5
- package/dist/App.js +0 -52
- package/dist/Sol/Sol.d.ts +0 -3
- package/dist/Sol/Sol.js +0 -46
- package/dist/Sol/agentbrowser/agentbrowser.d.ts +0 -9
- package/dist/Sol/agentbrowser/agentbrowser.js +0 -121
- package/dist/Sol/architecture/architecture.d.ts +0 -9
- package/dist/Sol/architecture/architecture.js +0 -93
- package/dist/Sol/compute/compute.d.ts +0 -9
- package/dist/Sol/compute/compute.js +0 -22
- package/dist/Sol/console/console.d.ts +0 -15
- package/dist/Sol/console/console.js +0 -292
- package/dist/Sol/dashboard/dashboard.d.ts +0 -10
- package/dist/Sol/dashboard/dashboard.js +0 -181
- package/dist/Sol/docs/docs.d.ts +0 -9
- package/dist/Sol/docs/docs.js +0 -28
- package/dist/Sol/home/home.d.ts +0 -35
- package/dist/Sol/home/home.js +0 -239
- package/dist/Sol/integrations/integrations.d.ts +0 -9
- package/dist/Sol/integrations/integrations.js +0 -83
- package/dist/Sol/intro/intro.d.ts +0 -7
- package/dist/Sol/intro/intro.js +0 -102
- package/dist/Sol/login/login.d.ts +0 -16
- package/dist/Sol/login/login.js +0 -49
- package/dist/Sol/notfound/notfound.d.ts +0 -9
- package/dist/Sol/notfound/notfound.js +0 -15
- package/dist/Sol/onboarding/onboarding.d.ts +0 -25
- package/dist/Sol/onboarding/onboarding.js +0 -60
- package/dist/Sol/playground/playground.d.ts +0 -9
- package/dist/Sol/playground/playground.js +0 -127
- package/dist/Sol/register/register.d.ts +0 -16
- package/dist/Sol/register/register.js +0 -193
- package/dist/Sol/shell/Shell.d.ts +0 -124
- package/dist/Sol/shell/Shell.js +0 -335
- package/dist/Sol/wingets/ComputeBootladder.d.ts +0 -6
- package/dist/Sol/wingets/ComputeBootladder.js +0 -71
- package/dist/Sol/wingets/ComputeModalitycatalog.d.ts +0 -7
- package/dist/Sol/wingets/ComputeModalitycatalog.js +0 -71
- package/dist/Sol/wingets/ConsoleApibadge.d.ts +0 -7
- package/dist/Sol/wingets/ConsoleApibadge.js +0 -5
- package/dist/Sol/wingets/ConsoleEdgelaw.d.ts +0 -6
- package/dist/Sol/wingets/ConsoleEdgelaw.js +0 -36
- package/dist/Sol/wingets/ConsoleEventstimeline.d.ts +0 -19
- package/dist/Sol/wingets/ConsoleEventstimeline.js +0 -12
- package/dist/Sol/wingets/ConsoleSnapshotcontrols.d.ts +0 -8
- package/dist/Sol/wingets/ConsoleSnapshotcontrols.js +0 -8
- package/dist/Sol/wingets/ConsoleSpecpanel.d.ts +0 -21
- package/dist/Sol/wingets/ConsoleSpecpanel.js +0 -75
- package/dist/Sol/wingets/ConsoleTerminal.d.ts +0 -22
- package/dist/Sol/wingets/ConsoleTerminal.js +0 -77
- package/dist/Sol/wingets/DashboardAccountpanel.d.ts +0 -18
- package/dist/Sol/wingets/DashboardAccountpanel.js +0 -65
- package/dist/Sol/wingets/DashboardAdminpanel.d.ts +0 -6
- package/dist/Sol/wingets/DashboardAdminpanel.js +0 -90
- package/dist/Sol/wingets/DashboardAuditlog.d.ts +0 -8
- package/dist/Sol/wingets/DashboardAuditlog.js +0 -50
- package/dist/Sol/wingets/DashboardCreatesandboxform.d.ts +0 -10
- package/dist/Sol/wingets/DashboardCreatesandboxform.js +0 -71
- package/dist/Sol/wingets/DashboardEventstimeline.d.ts +0 -8
- package/dist/Sol/wingets/DashboardEventstimeline.js +0 -83
- package/dist/Sol/wingets/DashboardGlobalsandboxestable.d.ts +0 -8
- package/dist/Sol/wingets/DashboardGlobalsandboxestable.js +0 -57
- package/dist/Sol/wingets/DashboardMeshnodestable.d.ts +0 -8
- package/dist/Sol/wingets/DashboardMeshnodestable.js +0 -103
- package/dist/Sol/wingets/DashboardSandboxlist.d.ts +0 -10
- package/dist/Sol/wingets/DashboardSandboxlist.js +0 -163
- package/dist/Sol/wingets/DashboardUserstable.d.ts +0 -8
- package/dist/Sol/wingets/DashboardUserstable.js +0 -78
- package/dist/Sol/wingets/HomeEntrygate.d.ts +0 -23
- package/dist/Sol/wingets/HomeEntrygate.js +0 -73
- package/dist/Sol/wingets/HomeMetricstrip.d.ts +0 -9
- package/dist/Sol/wingets/HomeMetricstrip.js +0 -4
- package/dist/Sol/wingets/HomeRuntimediagram.d.ts +0 -1
- package/dist/Sol/wingets/HomeRuntimediagram.js +0 -22
- package/dist/Sol/wingets/LoginForm.d.ts +0 -19
- package/dist/Sol/wingets/LoginForm.js +0 -141
- package/dist/Sol/wingets/RegisterPasswordconfirm.d.ts +0 -13
- package/dist/Sol/wingets/RegisterPasswordconfirm.js +0 -16
- package/dist/Sol/wingets/RegisterPasswordmeter.d.ts +0 -19
- package/dist/Sol/wingets/RegisterPasswordmeter.js +0 -65
- package/dist/Sol/wingets/RegisterUsernamerules.d.ts +0 -11
- package/dist/Sol/wingets/RegisterUsernamerules.js +0 -16
- package/dist/Sol/wingets/index.d.ts +0 -39
- package/dist/Sol/wingets/index.js +0 -39
- package/dist/alternatives.d.ts +0 -508
- package/dist/alternatives.js +0 -950
- package/dist/api.d.ts +0 -154
- package/dist/api.js +0 -174
- package/dist/auth.d.ts +0 -268
- package/dist/auth.js +0 -476
- package/dist/automation.d.ts +0 -425
- package/dist/automation.js +0 -566
- package/dist/browser.d.ts +0 -561
- package/dist/browser.js +0 -1058
- package/dist/capacitor.config.d.ts +0 -45
- package/dist/capacitor.config.js +0 -48
- package/dist/catalog.d.ts +0 -43
- package/dist/catalog.js +0 -44
- package/dist/cli.d.ts +0 -16
- package/dist/cli.js +0 -168
- package/dist/communication.d.ts +0 -181
- package/dist/communication.js +0 -376
- package/dist/compute.d.ts +0 -1160
- package/dist/compute.js +0 -2272
- package/dist/dashboardlib.d.ts +0 -58
- package/dist/dashboardlib.js +0 -161
- package/dist/db.d.ts +0 -569
- package/dist/db.js +0 -1080
- package/dist/distribution.d.ts +0 -528
- package/dist/distribution.js +0 -810
- package/dist/drizzle.config.d.ts +0 -20
- package/dist/drizzle.config.js +0 -18
- package/dist/edgelandes.d.ts +0 -70
- package/dist/edgelandes.host.d.ts +0 -26
- package/dist/edgelandes.host.js +0 -1089
- package/dist/edgelandes.inproc.d.ts +0 -27
- package/dist/edgelandes.inproc.js +0 -952
- package/dist/edgelandes.js +0 -1133
- package/dist/edgelandes.ninety.d.ts +0 -31
- package/dist/edgelandes.ninety.js +0 -174
- package/dist/edgelandes.virtual.d.ts +0 -25
- package/dist/edgelandes.virtual.js +0 -629
- package/dist/edgeplane.d.ts +0 -121
- package/dist/edgeplane.js +0 -341
- package/dist/examplelocaljob.d.ts +0 -1
- package/dist/examplelocaljob.js +0 -14
- package/dist/execution.d.ts +0 -529
- package/dist/execution.js +0 -760
- package/dist/family.d.ts +0 -16
- package/dist/family.js +0 -42
- package/dist/familyurl.d.ts +0 -21
- package/dist/familyurl.js +0 -24
- package/dist/format.d.ts +0 -18
- package/dist/format.js +0 -59
- package/dist/foundation.d.ts +0 -172
- package/dist/foundation.js +0 -312
- package/dist/index.d.ts +0 -735
- package/dist/index.js +0 -1020
- package/dist/integration.d.ts +0 -235
- package/dist/integration.js +0 -228
- package/dist/intelligence.d.ts +0 -93
- package/dist/intelligence.js +0 -144
- package/dist/isolation.d.ts +0 -128
- package/dist/isolation.js +0 -95
- package/dist/labels.d.ts +0 -665
- package/dist/labels.js +0 -707
- package/dist/localauth.d.ts +0 -127
- package/dist/localauth.js +0 -413
- package/dist/media.d.ts +0 -343
- package/dist/media.js +0 -985
- package/dist/mesh.d.ts +0 -177
- package/dist/mesh.js +0 -468
- package/dist/modalityregistry.d.ts +0 -109
- package/dist/modalityregistry.js +0 -1386
- package/dist/modes.d.ts +0 -358
- package/dist/modes.js +0 -133
- package/dist/onboardingstate.d.ts +0 -79
- package/dist/onboardingstate.js +0 -282
- package/dist/orchestrator.d.ts +0 -1338
- package/dist/orchestrator.js +0 -4310
- package/dist/packager/targetcli.js +0 -4
- package/dist/paths.d.ts +0 -2
- package/dist/paths.js +0 -14
- package/dist/performance.d.ts +0 -415
- package/dist/performance.js +0 -826
- package/dist/quantum.d.ts +0 -809
- package/dist/quantum.js +0 -2072
- package/dist/render.d.ts +0 -380
- package/dist/render.js +0 -1072
- package/dist/runall.d.ts +0 -2
- package/dist/runall.js +0 -39
- package/dist/runtimeprobe.d.ts +0 -4
- package/dist/runtimeprobe.js +0 -8
- package/dist/sandbox.d.ts +0 -355
- package/dist/sandbox.js +0 -1721
- package/dist/sandboxenvprofile.d.ts +0 -29
- package/dist/sandboxenvprofile.js +0 -89
- package/dist/sandboxprofile.d.ts +0 -137
- package/dist/sandboxprofile.js +0 -241
- package/dist/sandboxqueue.d.ts +0 -108
- package/dist/sandboxqueue.js +0 -201
- package/dist/sandboxquota.d.ts +0 -121
- package/dist/sandboxquota.js +0 -236
- package/dist/scheduler.d.ts +0 -595
- package/dist/scheduler.js +0 -1258
- package/dist/scriptlanes.d.ts +0 -182
- package/dist/scriptlanes.js +0 -660
- package/dist/scripts.buildengine.d.ts +0 -1
- package/dist/scripts.buildengine.js +0 -39
- package/dist/scripts.securitygate.d.ts +0 -1
- package/dist/scripts.securitygate.js +0 -68
- package/dist/scripts.validateflatnative.d.ts +0 -1
- package/dist/scripts.validateflatnative.js +0 -72
- package/dist/security.d.ts +0 -500
- package/dist/security.js +0 -1169
- package/dist/seedcatalog.d.ts +0 -13
- package/dist/seedcatalog.js +0 -46
- package/dist/server.d.ts +0 -20
- package/dist/server.js +0 -53
- package/dist/speccatalog.d.ts +0 -31
- package/dist/speccatalog.js +0 -163
- package/dist/tiers.d.ts +0 -1001
- package/dist/tiers.js +0 -2169
- package/dist/utils.d.ts +0 -2
- package/dist/utils.js +0 -5
- package/dist/vaulthandoff.d.ts +0 -141
- package/dist/vaulthandoff.js +0 -444
- package/dist/virtual.d.ts +0 -640
- package/dist/virtual.js +0 -1284
- package/dist/virtualcpu.d.ts +0 -315
- package/dist/virtualcpu.js +0 -1180
- package/dist/virtualgpu.d.ts +0 -344
- package/dist/virtualgpu.js +0 -1000
- package/dist/virtualization.d.ts +0 -606
- package/dist/virtualization.js +0 -1186
- package/dist/virtualmemory.d.ts +0 -817
- package/dist/virtualmemory.js +0 -1795
- package/dist/vite.config.d.ts +0 -3
- package/dist/vite.config.js +0 -48
- package/dist/vitest.config.d.ts +0 -2
- package/dist/vitest.config.js +0 -25
- package/dist/webserver.d.ts +0 -166
- package/dist/webserver.js +0 -2226
- package/dist/workloadlaw.d.ts +0 -133
- package/dist/workloadlaw.js +0 -193
- package/docs/.gitkeep +0 -0
- package/docs/architecture.1.8.19.md +0 -76
- package/docs/logs/examplesession.json +0 -54
- package/docs/repo.analysis.1.8.21.json +0 -2617
- package/docs/repo.candidates.1.8.21.jsonl +0 -666
- package/docs/repo.selected.1.8.21.json +0 -786
- package/manifest.json +0 -20
- package/media.ts +0 -1337
- package/virtualcpu.ts +0 -1274
- package/virtualgpu.ts +0 -1182
- package/virtualization.ts +0 -1628
- package/virtualmemory.ts +0 -2303
- /package/docs/{artifactresearch.1.8.9.md → artifactresearch189.md} +0 -0
- /package/docs/{batches.1816.to.1820.md → batches1816to1820.md} +0 -0
- /package/docs/{brancharchive.2026.08.18.md → brancharchive20260818.md} +0 -0
- /package/docs/{containerplatforms.1.8.17.md → containerplatforms1817.md} +0 -0
- /package/docs/{e2ugh.engine.md → e2ughengine.md} +0 -0
- /package/docs/{flatbuildresearch.1.8.11.md → flatbuildresearch1811.md} +0 -0
- /package/docs/{licenseaudit.1.8.12.md → licenseaudit1812.md} +0 -0
- /package/docs/{mobileconversionresearch.1.8.10.md → mobileconversionresearch1810.md} +0 -0
- /package/docs/{nativeidentityresearch.1.8.11.md → nativeidentityresearch1811.md} +0 -0
- /package/docs/{native.wrappers.md → nativewrappers.md} +0 -0
- /package/docs/{planning.1.8.18.md → planning1818.md} +0 -0
- /package/docs/{planning.1.8.19.md → planning1819.md} +0 -0
- /package/docs/{planning.engine.md → planningengine.md} +0 -0
- /package/docs/{releasenotes.1.8.12.md → releasenotes1812.md} +0 -0
- /package/docs/{releasenotes.1.8.15.md → releasenotes1815.md} +0 -0
- /package/docs/{releasenotes.1.8.16.md → releasenotes1816.md} +0 -0
- /package/docs/{releasenotes.1.8.17.md → releasenotes1817.md} +0 -0
- /package/docs/{releasenotes.1.8.18.md → releasenotes1818.md} +0 -0
- /package/docs/{releasenotes.1.8.19.md → releasenotes1819.md} +0 -0
- /package/docs/{reorganization.1.8.8.md → reorganization188.md} +0 -0
- /package/docs/{reorganization.1.8.9.md → reorganization189.md} +0 -0
- /package/docs/{repo.research.1.8.16.md → reporesearch1816.md} +0 -0
- /package/docs/{repo.research.1.8.21.md → reporesearch1821.md} +0 -0
- /package/docs/{repo.synthesis.1.8.21.md → reposynthesis1821.md} +0 -0
- /package/docs/{research.1.8.18.isolation.md → research1818isolation.md} +0 -0
- /package/docs/{research.1.8.19.virtual.browser.md → research1819virtualbrowser.md} +0 -0
- /package/docs/{scanningresearch.1.8.11.md → scanningresearch1811.md} +0 -0
- /package/docs/{securityaudit.1.8.7.md → securityaudit187.md} +0 -0
- /package/docs/{securityreferences.1.8.11.md → securityreferences1811.md} +0 -0
- /package/docs/{securityresearch.1.8.11.md → securityresearch1811.md} +0 -0
- /package/docs/{signingresearch.1.8.12.md → signingresearch1812.md} +0 -0
- /package/docs/{tauri.glib.risk.1.8.11.md → tauriglibrisk1811.md} +0 -0
- /package/docs/{todo.1.8.18.md → todo1818.md} +0 -0
- /package/docs/{todo.1.8.19.md → todo1819.md} +0 -0
- /package/docs/{toolchainresearch.1.8.9.md → toolchainresearch189.md} +0 -0
- /package/docs/{web.duplicatecleanup.md → webduplicatecleanup.md} +0 -0
- /package/docs/{web.ideas.md → webideas.md} +0 -0
- /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
|
+
}
|