@decocms/sandbox-controller 0.8.1 → 0.8.3

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>;
@@ -3807,8 +3817,21 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3807
3817
  * VM anyway, only later.
3808
3818
  */
3809
3819
  releaseAfter(handle: string, graceMs: number): Promise<void>;
3820
+ /**
3821
+ * `renewTtl` cancels only this process's timer, so a turn that started
3822
+ * through another replica still has to be seen: the daemon's clock counts
3823
+ * its requests and streaming runs. A daemon that won't say is left to the
3824
+ * VM's idle timeout.
3825
+ */
3826
+ private pauseIfIdle;
3810
3827
  /** The VM's idle timeout already counts every request as activity. */
3811
3828
  renewTtl(handle: string): Promise<void>;
3829
+ /**
3830
+ * Pause every running VM of ours whose daemon reports `idlePauseMs` without
3831
+ * use. The daemon's clock counts requests and streaming runs, not the dev
3832
+ * server's own traffic. Returns the handles paused; never throws.
3833
+ */
3834
+ pauseIdle(): Promise<string[]>;
3812
3835
  private cancelRelease;
3813
3836
  close(): void;
3814
3837
  }
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.89.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) {
@@ -7318,23 +7328,88 @@ running`).digest("hex").slice(0, 16)}`;
7318
7328
  this.cancelRelease(handle);
7319
7329
  const timer = setTimeout(() => {
7320
7330
  this.releaseTimers.delete(handle);
7321
- void this.find(handle).then((f) => f?.vm.pause()).catch(
7331
+ void this.pauseIfIdle(handle, graceMs).catch(
7322
7332
  (err) => console.warn(`[${LOG_LABEL2}] pause ${handle} failed: ${errMsg2(err)}`)
7323
7333
  );
7324
7334
  }, graceMs);
7325
7335
  timer.unref?.();
7326
7336
  this.releaseTimers.set(handle, timer);
7327
7337
  }
7338
+ /**
7339
+ * `renewTtl` cancels only this process's timer, so a turn that started
7340
+ * through another replica still has to be seen: the daemon's clock counts
7341
+ * its requests and streaming runs. A daemon that won't say is left to the
7342
+ * VM's idle timeout.
7343
+ */
7344
+ async pauseIfIdle(handle, graceMs) {
7345
+ const found = await this.find(handle);
7346
+ if (!found) return;
7347
+ const idleMs = await daemonIdleMs(found.daemon.url);
7348
+ if (idleMs === null || idleMs < graceMs) return;
7349
+ await found.vm.pause();
7350
+ }
7328
7351
  /** The VM's idle timeout already counts every request as activity. */
7329
7352
  async renewTtl(handle) {
7330
7353
  this.cancelRelease(handle);
7331
7354
  }
7355
+ /**
7356
+ * Pause every running VM of ours whose daemon reports `idlePauseMs` without
7357
+ * use. The daemon's clock counts requests and streaming runs, not the dev
7358
+ * server's own traffic. Returns the handles paused; never throws.
7359
+ */
7360
+ async pauseIdle() {
7361
+ if (this.sweeping) return [];
7362
+ this.sweeping = true;
7363
+ try {
7364
+ const running = [];
7365
+ for (let offset = 0; ; offset += IDLE_SWEEP_PAGE) {
7366
+ const { vms } = await this.freestyle.vms.list({
7367
+ state: "running",
7368
+ limit: IDLE_SWEEP_PAGE,
7369
+ offset
7370
+ });
7371
+ running.push(...vms);
7372
+ if (vms.length < IDLE_SWEEP_PAGE) break;
7373
+ }
7374
+ const paused = await Promise.all(
7375
+ running.map(async (data) => {
7376
+ const handle = data.slug;
7377
+ if (!handle || !data.metadata[TOKEN_KEY]) return null;
7378
+ if (Date.now() - Date.parse(data.createdAt) < this.idlePauseMs) {
7379
+ return null;
7380
+ }
7381
+ const idleMs = await daemonIdleMs(this.daemonUrl(handle));
7382
+ if (idleMs === null || idleMs < this.idlePauseMs) return null;
7383
+ try {
7384
+ await this.freestyle.vms.ref(data.id).pause();
7385
+ } catch (err) {
7386
+ console.warn(
7387
+ `[${LOG_LABEL2}] pause ${handle} failed: ${errMsg2(err)}`
7388
+ );
7389
+ return null;
7390
+ }
7391
+ this.cancelRelease(handle);
7392
+ console.log(
7393
+ `[${LOG_LABEL2}] paused ${handle}, daemon idle ${Math.round(idleMs / 6e4)}m`
7394
+ );
7395
+ return handle;
7396
+ })
7397
+ );
7398
+ return paused.filter((h) => h !== null);
7399
+ } catch (err) {
7400
+ console.warn(`[${LOG_LABEL2}] idle sweep failed: ${errMsg2(err)}`);
7401
+ return [];
7402
+ } finally {
7403
+ this.sweeping = false;
7404
+ }
7405
+ }
7332
7406
  cancelRelease(handle) {
7333
7407
  const timer = this.releaseTimers.get(handle);
7334
7408
  if (timer) clearTimeout(timer);
7335
7409
  this.releaseTimers.delete(handle);
7336
7410
  }
7337
7411
  close() {
7412
+ if (this.idleSweep) clearInterval(this.idleSweep);
7338
7413
  for (const timer of this.releaseTimers.values()) clearTimeout(timer);
7339
7414
  this.releaseTimers.clear();
7340
7415
  this.lookups.clear();
@@ -7351,6 +7426,21 @@ async function daemonAnswers(url) {
7351
7426
  return false;
7352
7427
  }
7353
7428
  }
7429
+ async function daemonIdleMs(url) {
7430
+ try {
7431
+ const res = await fetch(`${url}/_sandbox/idle`, {
7432
+ signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS2)
7433
+ });
7434
+ if (!res.ok) {
7435
+ await res.body?.cancel();
7436
+ return null;
7437
+ }
7438
+ const body = await res.json();
7439
+ return typeof body === "object" && body !== null && "idleMs" in body && typeof body.idleMs === "number" ? body.idleMs : null;
7440
+ } catch {
7441
+ return null;
7442
+ }
7443
+ }
7354
7444
  function shellQuote(s) {
7355
7445
  return `'${s.replaceAll("'", `'\\''`)}'`;
7356
7446
  }
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.3",
4
4
  "description": "Runs Studio agent sandboxes on Kubernetes, and serves them to a remote Studio.",
5
5
  "repository": {
6
6
  "type": "git",