@decocms/sandbox-controller 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -3717,6 +3717,13 @@ interface FreestyleSandboxProviderOptions {
3717
3717
  orgFsSidecarImage?: string;
3718
3718
  /** Pause a VM after this long without network activity. Default 5 min. */
3719
3719
  idleTimeoutSeconds?: number;
3720
+ /**
3721
+ * Also pause a running VM once its daemon has gone this long unused. The
3722
+ * idle timeout counts any traffic, so a dev server holding an outbound
3723
+ * connection open keeps the VM awake without it. Checked every minute;
3724
+ * 0 turns it off. Default 10 min.
3725
+ */
3726
+ idlePauseMs?: number;
3720
3727
  /**
3721
3728
  * Delete a VM after this long without running. Default 1 day. A plan
3722
3729
  * that caps it lower gets its cap.
@@ -3736,6 +3743,7 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3736
3743
  private readonly image;
3737
3744
  private readonly sidecarImage;
3738
3745
  private readonly idleTimeoutSeconds;
3746
+ private readonly idlePauseMs;
3739
3747
  private readonly autoDeleteSeconds;
3740
3748
  private readonly domainSuffix;
3741
3749
  private readonly timeDaemonRequest;
@@ -3745,6 +3753,8 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3745
3753
  private readonly knownSnapshots;
3746
3754
  private readonly lookups;
3747
3755
  private readonly releaseTimers;
3756
+ private readonly idleSweep;
3757
+ private sweeping;
3748
3758
  constructor(opts: FreestyleSandboxProviderOptions);
3749
3759
  /** Whether this handle is a Freestyle sandbox. Cached; see `MISS_TTL_MS`. */
3750
3760
  owns(handle: string): Promise<boolean>;
@@ -3809,6 +3819,12 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3809
3819
  releaseAfter(handle: string, graceMs: number): Promise<void>;
3810
3820
  /** The VM's idle timeout already counts every request as activity. */
3811
3821
  renewTtl(handle: string): Promise<void>;
3822
+ /**
3823
+ * Pause every running VM of ours whose daemon reports `idlePauseMs` without
3824
+ * use. The daemon's clock counts requests and streaming runs, not the dev
3825
+ * server's own traffic. Returns the handles paused; never throws.
3826
+ */
3827
+ pauseIdle(): Promise<string[]>;
3812
3828
  private cancelRelease;
3813
3829
  close(): void;
3814
3830
  }
package/dist/index.js CHANGED
@@ -6759,7 +6759,7 @@ var Freestyle = class {
6759
6759
  // ../sandbox/package.json
6760
6760
  var package_default = {
6761
6761
  name: "@decocms/sandbox",
6762
- version: "1.85.1",
6762
+ version: "1.85.2",
6763
6763
  private: true,
6764
6764
  type: "module",
6765
6765
  description: "Sandbox runtime for isolated Studio tool execution",
@@ -6861,6 +6861,8 @@ var MISS_TTL_MS = 1e4;
6861
6861
  var LOOKUP_MAX = 5e3;
6862
6862
  var WATCH_TIMEOUT_MS = 5 * 6e4;
6863
6863
  var LOOKUP_TIMEOUT_MS = 3e3;
6864
+ var IDLE_SWEEP_MS = 6e4;
6865
+ var IDLE_SWEEP_PAGE = 100;
6864
6866
  var FreestyleSandboxProvider = class {
6865
6867
  apiKey;
6866
6868
  freestyle;
@@ -6868,6 +6870,7 @@ var FreestyleSandboxProvider = class {
6868
6870
  image;
6869
6871
  sidecarImage;
6870
6872
  idleTimeoutSeconds;
6873
+ idlePauseMs;
6871
6874
  autoDeleteSeconds;
6872
6875
  domainSuffix;
6873
6876
  timeDaemonRequest;
@@ -6877,6 +6880,8 @@ var FreestyleSandboxProvider = class {
6877
6880
  knownSnapshots = /* @__PURE__ */ new Set();
6878
6881
  lookups = /* @__PURE__ */ new Map();
6879
6882
  releaseTimers = /* @__PURE__ */ new Map();
6883
+ idleSweep = null;
6884
+ sweeping = false;
6880
6885
  constructor(opts) {
6881
6886
  this.apiKey = opts.apiKey;
6882
6887
  this.freestyle = opts.client ?? new Freestyle({ apiKey: opts.apiKey });
@@ -6896,9 +6901,14 @@ var FreestyleSandboxProvider = class {
6896
6901
  this.image = opts.image ?? `ghcr.io/decocms/studio/studio-sandbox-go:${package_default.version}`;
6897
6902
  this.sidecarImage = opts.orgFsSidecarImage ?? DEFAULT_SIDECAR_IMAGE;
6898
6903
  this.idleTimeoutSeconds = opts.idleTimeoutSeconds ?? 5 * 60;
6904
+ this.idlePauseMs = opts.idlePauseMs ?? 10 * 6e4;
6899
6905
  this.autoDeleteSeconds = opts.autoDeleteSeconds ?? 24 * 60 * 60;
6900
6906
  this.domainSuffix = opts.domainSuffix ?? "style.dev";
6901
6907
  this.timeDaemonRequest = daemonProxyTimer(opts.meter);
6908
+ if (this.idlePauseMs > 0) {
6909
+ this.idleSweep = setInterval(() => void this.pauseIdle(), IDLE_SWEEP_MS);
6910
+ this.idleSweep.unref?.();
6911
+ }
6902
6912
  }
6903
6913
  /** Whether this handle is a Freestyle sandbox. Cached; see `MISS_TTL_MS`. */
6904
6914
  async owns(handle) {
@@ -7329,12 +7339,64 @@ running`).digest("hex").slice(0, 16)}`;
7329
7339
  async renewTtl(handle) {
7330
7340
  this.cancelRelease(handle);
7331
7341
  }
7342
+ /**
7343
+ * Pause every running VM of ours whose daemon reports `idlePauseMs` without
7344
+ * use. The daemon's clock counts requests and streaming runs, not the dev
7345
+ * server's own traffic. Returns the handles paused; never throws.
7346
+ */
7347
+ async pauseIdle() {
7348
+ if (this.sweeping) return [];
7349
+ this.sweeping = true;
7350
+ try {
7351
+ const running = [];
7352
+ for (let offset = 0; ; offset += IDLE_SWEEP_PAGE) {
7353
+ const { vms } = await this.freestyle.vms.list({
7354
+ state: "running",
7355
+ limit: IDLE_SWEEP_PAGE,
7356
+ offset
7357
+ });
7358
+ running.push(...vms);
7359
+ if (vms.length < IDLE_SWEEP_PAGE) break;
7360
+ }
7361
+ const paused = await Promise.all(
7362
+ running.map(async (data) => {
7363
+ const handle = data.slug;
7364
+ if (!handle || !data.metadata[TOKEN_KEY]) return null;
7365
+ if (Date.now() - Date.parse(data.createdAt) < this.idlePauseMs) {
7366
+ return null;
7367
+ }
7368
+ const idleMs = await daemonIdleMs(this.daemonUrl(handle));
7369
+ if (idleMs === null || idleMs < this.idlePauseMs) return null;
7370
+ try {
7371
+ await this.freestyle.vms.ref(data.id).pause();
7372
+ } catch (err) {
7373
+ console.warn(
7374
+ `[${LOG_LABEL2}] pause ${handle} failed: ${errMsg2(err)}`
7375
+ );
7376
+ return null;
7377
+ }
7378
+ this.cancelRelease(handle);
7379
+ console.log(
7380
+ `[${LOG_LABEL2}] paused ${handle}, daemon idle ${Math.round(idleMs / 6e4)}m`
7381
+ );
7382
+ return handle;
7383
+ })
7384
+ );
7385
+ return paused.filter((h) => h !== null);
7386
+ } catch (err) {
7387
+ console.warn(`[${LOG_LABEL2}] idle sweep failed: ${errMsg2(err)}`);
7388
+ return [];
7389
+ } finally {
7390
+ this.sweeping = false;
7391
+ }
7392
+ }
7332
7393
  cancelRelease(handle) {
7333
7394
  const timer = this.releaseTimers.get(handle);
7334
7395
  if (timer) clearTimeout(timer);
7335
7396
  this.releaseTimers.delete(handle);
7336
7397
  }
7337
7398
  close() {
7399
+ if (this.idleSweep) clearInterval(this.idleSweep);
7338
7400
  for (const timer of this.releaseTimers.values()) clearTimeout(timer);
7339
7401
  this.releaseTimers.clear();
7340
7402
  this.lookups.clear();
@@ -7351,6 +7413,21 @@ async function daemonAnswers(url) {
7351
7413
  return false;
7352
7414
  }
7353
7415
  }
7416
+ async function daemonIdleMs(url) {
7417
+ try {
7418
+ const res = await fetch(`${url}/_sandbox/idle`, {
7419
+ signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS2)
7420
+ });
7421
+ if (!res.ok) {
7422
+ await res.body?.cancel();
7423
+ return null;
7424
+ }
7425
+ const body = await res.json();
7426
+ return typeof body === "object" && body !== null && "idleMs" in body && typeof body.idleMs === "number" ? body.idleMs : null;
7427
+ } catch {
7428
+ return null;
7429
+ }
7430
+ }
7354
7431
  function shellQuote(s) {
7355
7432
  return `'${s.replaceAll("'", `'\\''`)}'`;
7356
7433
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/sandbox-controller",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Runs Studio agent sandboxes on Kubernetes, and serves them to a remote Studio.",
5
5
  "repository": {
6
6
  "type": "git",