@wenathlan/saddle 2.0.155 → 2.0.160

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/sandbox.ts CHANGED
@@ -1,3 +1,12 @@
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 ===== */
1
10
  /**
2
11
  * sandbox.ts — browser-pure port of the saddle virtual hardware engine.
3
12
  *
@@ -2092,3 +2101,1179 @@ export const SANDBOX_LIFECYCLE_ACTION_TARGETS: Readonly<Record<SandboxLifecycleA
2092
2101
  cool: 'cold',
2093
2102
  freeze: 'cold',
2094
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
+ }